Compare commits

..

11 Commits

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,132 @@
"""
DjangoBlog 站点地图配置模块
功能为搜索引擎提供网站结构地图支持文章分类标签等内容的自动索引
"""
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
"""
静态页面站点地图
用于生成固定页面的站点地图如首页等
"""
# 优先级0.5中等优先级首页等重要页面可以设为1.0
priority = 0.5
# 更新频率:每天检查
changefreq = 'daily'
def items(self):
"""
返回包含在站点地图中的静态页面名称
这些名称需要与 urls.py 中的 URL 名称对应
"""
return ['blog:index', ] # 博客首页
def location(self, item):
"""
根据页面名称生成完整的 URL 地址
"""
return reverse(item)
class ArticleSiteMap(Sitemap):
"""
文章站点地图
自动生成所有已发布文章的站点地图
"""
# 更新频率:每月检查(文章内容相对稳定)
changefreq = "monthly"
# 优先级0.6(文章是核心内容,优先级较高)
priority = "0.6"
def items(self):
"""
返回所有已发布的文章对象
status='p' 表示已发布状态
"""
return Article.objects.filter(status='p')
def lastmod(self, obj):
"""
返回文章的最后修改时间
帮助搜索引擎了解内容更新情况
"""
return obj.last_modify_time
class CategorySiteMap(Sitemap):
"""
分类站点地图
生成文章分类页面的站点地图
"""
# 更新频率:每周检查(分类结构相对稳定)
changefreq = "Weekly"
# 优先级0.6(分类页面重要程度较高)
priority = "0.6"
def items(self):
"""
返回所有分类对象
"""
return Category.objects.all()
def lastmod(self, obj):
"""
返回分类的最后修改时间
当分类下的文章更新时分类页面也需要更新
"""
return obj.last_modify_time
class TagSiteMap(Sitemap):
"""
标签站点地图
生成标签页面的站点地图
"""
# 更新频率:每周检查
changefreq = "Weekly"
# 优先级0.3(标签页面重要性相对较低)
priority = "0.3"
def items(self):
"""
返回所有标签对象
"""
return Tag.objects.all()
def lastmod(self, obj):
"""
返回标签的最后修改时间
当标签关联的文章更新时标签页面也需要更新
"""
return obj.last_modify_time
class UserSiteMap(Sitemap):
"""
用户站点地图
生成用户主页的站点地图
"""
# 更新频率:每周检查
changefreq = "Weekly"
# 优先级0.3(用户页面重要性相对较低)
priority = "0.3"
def items(self):
"""
返回所有发表过文章的用户作者
使用 set 去重确保每个用户只出现一次
"""
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
"""
返回用户的注册时间
这里使用用户注册时间作为最后修改时间
实际可以根据用户最后活动时间优化
"""
return obj.date_joined

@ -1,82 +1,107 @@
from django import forms
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.admin import UserAdmin # Django 自带的用户管理后台基类
from django.contrib.auth.forms import UserChangeForm # Django 默认的用户信息修改表单
from django.contrib.auth.forms import UsernameField # Django 用于用户名字段的专用表单字段
from django.utils.translation import gettext_lazy as _ # 用于支持多语言翻译的辅助函数
# Register your models here.
# 从当前 app 的 models 导入自定义的用户模型 BlogUser
from .models import BlogUser
# ======================
# 自定义用户创建表单(用于后台添加用户时使用)
# ======================
class BlogUserCreationForm(forms.ModelForm):
"""
自定义用户创建表单用于在Admin后台添加新用户
继承自ModelForm提供密码验证和哈希处理功能
"""
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
# 添加两个密码字段,用于用户注册时输入和确认密码
password1 = forms.CharField(
label=_('password'), # 字段显示名称可翻译这里是“password”
widget=forms.PasswordInput # 使用密码输入框,输入内容会被隐藏
)
password2 = forms.CharField(
label=_('Enter password again'), # 确认密码的标签
widget=forms.PasswordInput # 同样是密码输入框
)
class Meta:
model = BlogUser # 指定使用的模型
fields = ('email',) # 表单中显示的字段,这里只显示邮箱
model = BlogUser # 指定该表单关联的模型是 BlogUser
fields = ('email',) # 在创建用户时,只显示 email 字段(可以从后台选择的字段)
def clean_password2(self):
"""
验证两次输入的密码是否一致
如果密码不匹配抛出验证错误
校验两次输入的密码是否一致
"""
# 从 cleaned_data 中获取用户输入的两个密码
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
# 如果两个密码都有值,但它们不相等,则抛出验证错误
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
raise forms.ValidationError(_("passwords do not match")) # 提示“密码不匹配”
# 验证通过,返回 password2通常返回确认密码字段的值
return password2
def save(self, commit=True):
"""
保存用户信息对密码进行哈希处理
commit参数控制是否立即保存到数据库
保存用户对象并对密码进行哈希处理
"""
user = super().save(commit=False) # 创建用户对象但不保存到数据库
user.set_password(self.cleaned_data["password1"]) # 对密码进行哈希处理
# 调用父类的 save 方法但不立即提交到数据库commit=False
user = super().save(commit=False)
# 对用户输入的密码password1进行哈希处理并设置到 user 对象上
user.set_password(self.cleaned_data["password1"])
if commit:
user.source = 'adminsite' # 标记用户来源为管理员后台
user.save() # 保存到数据库
# 如果 commit=True默认则保存到数据库
# 同时,给用户添加一个来源标识 source = 'adminsite',表示是通过后台添加的
user.source = 'adminsite'
user.save()
# 返回保存后的用户对象
return user
# ======================
# 自定义用户修改表单(用于后台编辑用户信息时使用)
# ======================
class BlogUserChangeForm(UserChangeForm):
"""
自定义用户信息修改表单
继承自Django内置的UserChangeForm用于在Admin后台编辑用户信息
"""
class Meta:
model = BlogUser
fields = '__all__' # 包含所有字段
field_classes = {'username': UsernameField} # 指定用户名字段的类型
model = BlogUser # 指定关联的模型是 BlogUser
fields = '__all__' # 表单中包含模型的所有字段
# 指定 username 字段使用 Django 提供的 UsernameField它对用户名有特殊处理如唯一性等
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
"""初始化方法,可以在这里添加自定义的表单逻辑"""
super().__init__(*args, **kwargs)
def __init__(self, *args, **kwargs):
"""
初始化方法这里暂时没有额外逻辑只是调用了父类的初始化
"""
super().__init__(*args, **kwargs)
# ======================
# 自定义用户管理后台类(用于在 Django Admin 中管理 BlogUser 模型)
# ======================
class BlogUserAdmin(UserAdmin):
"""
自定义用户管理类配置Admin后台的用户管理界面
继承自Django内置的UserAdmin类
"""
form = BlogUserChangeForm # 设置用户编辑表单
add_form = BlogUserCreationForm # 设置用户添加表单
# 指定用户修改时使用的表单类(编辑用户信息时)
form = BlogUserChangeForm
# 列表页面显示的字段
# 指定用户创建时使用的表单类(添加新用户时)
add_form = BlogUserCreationForm
# 定义在用户列表页显示哪些字段
list_display = (
'id',
'nickname', # 昵称
'id', # 用户 ID
'nickname', # 昵称(假设你的 BlogUser 模型中有这个字段)
'username', # 用户名
'email', # 邮箱
'last_login', # 最后登录时间
'last_login', # 上次登录时间
'date_joined', # 注册时间
'source' # 用户来源
'source' # 用户来源(比如 adminsite 表示后台添加)
)
list_display_links = ('id', 'username') # 可点击跳转到编辑页面的字段
ordering = ('-id',) # 按ID倒序排列最新的用户显示在最前面
# 定义哪些字段可以作为链接,点击后可以进入编辑页面
# 这里 id 和 username 都可以作为链接
list_display_links = ('id', 'username')
# 定义默认排序方式,这里是按照 id 降序(最新的用户在前面)
ordering = ('-id',)

@ -2,27 +2,4 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""
账户应用的配置类
Django应用配置类用于配置accounts应用的元数据和行为
继承自Django的AppConfig基类
"""
# 应用的Python路径Django使用这个属性来识别应用
# 这应该与应用的目录名一致
name = 'accounts'
# 其他常用但未在此定义的配置选项包括:
# - verbose_name: 应用的易读名称(用于管理后台显示)
# - default_auto_field: 默认的主键字段类型
# - label: 应用的简短标签用于替代name
# - path: 应用的文件系统路径
# 示例如果需要配置verbose_name可以这样添加
# verbose_name = '用户账户管理'
# 示例如果需要自定义ready方法可以这样添加
# def ready(self):
# # 应用启动时执行的代码
# # 通常用于信号注册等初始化操作
# import accounts.signals

@ -9,116 +9,90 @@ from .models import BlogUser
class LoginForm(AuthenticationForm):
"""自定义登录表单继承自Django的AuthenticationForm"""
def __init__(self, *args, **kwargs):
"""初始化方法设置表单字段的widget属性"""
super(LoginForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入框属性
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 设置密码字段的输入框属性
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
"""自定义用户注册表单继承自Django的UserCreationForm"""
def __init__(self, *args, **kwargs):
"""初始化方法设置所有表单字段的widget属性"""
super(RegisterForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入框属性
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 设置邮箱字段的输入框属性
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# 设置密码字段的输入框属性
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 设置确认密码字段的输入框属性
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
"""邮箱字段验证方法"""
email = self.cleaned_data['email']
# 检查邮箱是否已存在
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
"""表单的元数据配置"""
model = get_user_model() # 使用当前激活的用户模型
fields = ("username", "email") # 表单包含的字段
model = get_user_model()
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
"""忘记密码重置表单"""
# 新密码字段
new_password1 = forms.CharField(
label=_("New password"), # 字段标签
widget=forms.PasswordInput( # 密码输入框
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control", # CSS类
'placeholder': _("New password") # 占位符文本
"class": "form-control",
'placeholder': _("New password")
}
),
)
# 确认新密码字段
new_password2 = forms.CharField(
label="确认密码", # 字段标签
widget=forms.PasswordInput( # 密码输入框
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control", # CSS类
'placeholder': _("Confirm password") # 占位符文本
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱', # 字段标签
widget=forms.TextInput( # 文本输入框
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control', # CSS类
'placeholder': _("Email") # 占位符文本
'class': 'form-control',
'placeholder': _("Email")
}
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'), # 字段标签
widget=forms.TextInput( # 文本输入框
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control', # CSS类
'placeholder': _("Code") # 占位符文本
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
"""确认密码字段验证方法"""
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
# 检查两次输入的密码是否一致
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
# 使用Django的密码验证器验证密码强度
password_validation.validate_password(password2)
return password2
def clean_email(self):
"""邮箱字段验证方法"""
user_email = self.cleaned_data.get("email")
# 检查邮箱对应的用户是否存在
if not BlogUser.objects.filter(
email=user_email
).exists():
@ -127,9 +101,7 @@ class ForgetPasswordForm(forms.Form):
return user_email
def clean_code(self):
"""验证码字段验证方法"""
code = self.cleaned_data.get("code")
# 使用utils模块验证邮箱和验证码是否匹配
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
@ -140,8 +112,6 @@ class ForgetPasswordForm(forms.Form):
class ForgetPasswordCodeForm(forms.Form):
"""忘记密码验证码请求表单(仅包含邮箱字段)"""
email = forms.EmailField(
label=_('Email'), # 邮箱字段标签
label=_('Email'),
)

@ -7,88 +7,42 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
Django数据库迁移文件
用于创建BlogUser模型的数据库表结构
这是一个初始迁移文件initial migration
"""
# 标记为初始迁移Django使用这个标志来识别应用的第一个迁移
initial = True
# 依赖关系此迁移依赖于auth应用的特定迁移
# 确保在创建用户表之前,权限相关的表已经存在
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
# 迁移操作列表:定义要执行的具体数据库操作
operations = [
# 创建BlogUser模型的数据库表
migrations.CreateModel(
name='BlogUser', # 模型名称
name='BlogUser',
fields=[
# 主键字段使用BigAutoField作为自增主键
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段存储加密后的密码最大长度128字符
('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间:记录用户最后一次登录的时间
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 超级用户标志:标记用户是否拥有所有权限
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
# 用户名字段:唯一标识用户,有严格的字符限制和验证
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
# 名字字段:用户的名,可选
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 姓氏字段:用户的姓,可选
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 邮箱字段:用户的邮箱地址,可选
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 员工状态:标记用户是否可以登录管理后台
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
# 活跃状态:标记用户账户是否激活
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
# 加入日期:用户注册的时间,默认为当前时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 昵称字段:自定义字段,用户显示名称,可选
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 创建时间:自定义字段,记录用户创建时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间:自定义字段,记录用户信息最后修改时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 来源字段:自定义字段,记录用户创建来源(如注册渠道)
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 组关联:多对多关系,用户所属的权限组
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
# 用户权限:多对多关系,用户特有的权限
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
# 模型元选项
options={
'verbose_name': '用户', # 单数名称(用于管理后台)
'verbose_name_plural': '用户', # 复数名称(用于管理后台)
'ordering': ['-id'], # 默认排序按ID降序最新的在前
'get_latest_by': 'id', # 指定获取最新记录时使用的字段
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
# 模型管理器
managers=[
# 使用Django默认的UserManager来管理用户对象
('objects', django.contrib.auth.models.UserManager()),
],
),

@ -5,82 +5,42 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
Django数据库迁移文件
用于修改BlogUser模型的结构和字段定义
这是一个数据模型重构迁移主要更新字段命名和国际化
"""
# 依赖关系此迁移依赖于accounts应用的初始迁移
# 确保在修改表结构之前,初始表已经创建
dependencies = [
('accounts', '0001_initial'), # 依赖于accounts应用的第一个迁移文件
('accounts', '0001_initial'),
]
# 迁移操作列表:定义要执行的具体数据库结构修改
operations = [
# 修改模型的元选项(主要是国际化显示名称)
migrations.AlterModelOptions(
name='bloguser', # 目标模型名称
options={
'get_latest_by': 'id', # 保持按id获取最新记录
'ordering': ['-id'], # 保持按id降序排列
'verbose_name': 'user', # 更新单数名称为英文(国际化准备)
'verbose_name_plural': 'user' # 更新复数名称为英文(国际化准备)
},
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
# 删除旧的创建时间字段(为后续添加新字段做准备)
migrations.RemoveField(
model_name='bloguser', # 目标模型
name='created_time', # 要删除的字段名
model_name='bloguser',
name='created_time',
),
# 删除旧的最后修改时间字段
migrations.RemoveField(
model_name='bloguser', # 目标模型
name='last_mod_time', # 要删除的字段名
model_name='bloguser',
name='last_mod_time',
),
# 添加新的创建时间字段(使用国际化的字段名)
migrations.AddField(
model_name='bloguser', # 目标模型
name='creation_time', # 新字段名
field=models.DateTimeField(
default=django.utils.timezone.now, # 默认值为当前时间
verbose_name='creation time' # 英文显示名称(国际化)
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
),
# 添加新的最后修改时间字段(使用国际化的字段名)
migrations.AddField(
model_name='bloguser', # 目标模型
name='last_modify_time', # 新字段名
field=models.DateTimeField(
default=django.utils.timezone.now, # 默认值为当前时间
verbose_name='last modify time' # 英文显示名称(国际化)
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
),
# 修改昵称字段的显示名称(国际化)
migrations.AlterField(
model_name='bloguser', # 目标模型
name='nickname', # 要修改的字段
field=models.CharField(
blank=True, # 保持允许为空
max_length=100, # 保持最大长度100
verbose_name='nick name' # 更新为英文显示名称
),
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
# 修改来源字段的显示名称(国际化)
migrations.AlterField(
model_name='bloguser', # 目标模型
name='source', # 要修改的字段
field=models.CharField(
blank=True, # 保持允许为空
max_length=100, # 保持最大长度100
verbose_name='create source' # 更新为英文显示名称
),
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -9,61 +9,27 @@ from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
"""
自定义用户模型继承自Django的AbstractUser基类
扩展了博客系统的用户功能
"""
# 昵称字段,允许为空
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
# 用户创建时间,默认为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间,默认为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 用户创建来源(如:网站注册、第三方登录等),允许为空
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
"""
获取用户的绝对URL用于Django的通用视图和模板中
返回用户详情页的URL
"""
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
"""
定义模型的字符串表示形式
在管理后台和其他显示对象的地方使用
这里使用邮箱作为标识
"""
return self.email
def get_full_url(self):
"""
获取用户的完整URL包含域名
用于生成完整的用户主页链接
"""
site = get_current_site().domain # 获取当前站点域名
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
"""模型的元数据配置"""
# 默认按ID降序排列最新的用户排在前面
ordering = ['-id']
# 在管理后台中显示的单数名称
verbose_name = _('user')
# 在管理后台中显示的复数名称
verbose_name_plural = verbose_name
# 指定获取最新记录时使用的字段
get_latest_by = 'id'

@ -13,215 +13,172 @@ from . import utils
class AccountTest(TestCase):
def setUp(self):
"""测试用例初始化方法,每个测试方法执行前都会运行"""
self.client = Client() # 创建测试客户端用于模拟HTTP请求
self.factory = RequestFactory() # 创建请求工厂,用于构建请求对象
# 创建测试用户
self.client = Client()
self.factory = RequestFactory()
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--=" # 测试用的新密码
self.new_test = "xxx123--="
def test_validate_account(self):
"""测试账户验证功能,包括登录、管理员权限和文章管理"""
site = get_current_site().domain # 获取当前站点域名
# 创建超级用户
site = get_current_site().domain
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
# 测试登录功能
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True) # 断言登录成功
# 测试管理员页面访问
self.assertEqual(loginresult, True)
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200) # 断言可以访问管理员页面
self.assertEqual(response.status_code, 200)
# 创建测试分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.type = 'a'
article.status = 'p'
article.save()
# 测试文章管理页面访问
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200) # 断言可以访问文章管理页面
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
"""测试用户注册流程,包括注册、邮箱验证、登录和权限管理"""
# 验证注册前用户不存在
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 发送注册请求
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
# 验证注册后用户已创建
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 生成邮箱验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
# 测试验证页面访问
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# 测试登录功能
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 提升用户权限为管理员
user.is_superuser = True
user.is_staff = True
user.save()
delete_sidebar_cache() # 清理侧边栏缓存
# 创建测试分类
delete_sidebar_cache()
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
# 测试文章管理页面访问
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 测试登出功能
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200]) # 登出通常会有重定向
self.assertIn(response.status_code, [301, 302, 200])
# 登出后测试文章管理页面访问(应该被拒绝)
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200]) # 应该重定向到登录页
self.assertIn(response.status_code, [301, 302, 200])
# 重新登录测试(使用错误密码)
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123' # 错误的密码
'password': 'password123'
})
self.assertIn(response.status_code, [301, 302, 200])
# 登录后再次测试文章管理页面访问
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
"""测试邮箱验证码功能"""
to_email = "admin@admin.com"
code = generate_code() # 生成验证码
# 设置验证码到缓存
code = generate_code()
utils.set_code(to_email, code)
# 发送验证邮件
utils.send_verify_email(to_email, code)
# 测试正确的验证码验证
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None) # 应该没有错误
self.assertEqual(err, None)
# 测试错误的邮箱验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str) # 应该返回错误信息字符串
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
"""测试成功发送忘记密码验证码"""
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok") # 断言返回成功消息
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
"""测试忘记密码验证码发送失败的情况"""
# 测试空邮箱参数
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试无效邮箱格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com") # 无效的邮箱格式
data=dict(email="admin@com")
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
"""测试成功重置密码"""
code = generate_code()
utils.set_code(self.blog_user.email, code) # 设置验证码到缓存
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
)
# 提交密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302) # 重置成功应该重定向
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None) # 用户应该存在
self.assertEqual(blog_user.check_password(data["new_password1"]), True) # 密码应该匹配
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
"""测试重置密码时用户不存在的情况"""
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com", # 不存在的邮箱
email="123@123.com",
code="123456",
)
resp = self.client.post(
@ -229,21 +186,22 @@ class AccountTest(TestCase):
data=data
)
self.assertEqual(resp.status_code, 200) # 应该返回错误页面而不是重定向
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
"""测试重置密码时验证码错误的情况"""
code = generate_code()
utils.set_code(self.blog_user.email, code) # 设置正确的验证码
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111", # 错误的验证码
code="111111",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200) # 应该返回错误页面而不是重定向
self.assertEqual(resp.status_code, 200)

@ -4,46 +4,25 @@ from django.urls import re_path
from . import views
from .forms import LoginForm
# 定义应用的命名空间用于URL反向解析
# 在模板中使用如:{% url 'accounts:login' %}
app_name = "accounts"
# URL配置列表定义所有用户账户相关的路由
urlpatterns = [
# 登录路由 - 使用正则表达式匹配以login/结尾的URL
re_path(r'^login/$',
# 使用基于类的视图,登录成功后重定向到首页
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login', # URL名称用于反向解析
kwargs={'authentication_form': LoginForm}), # 传递自定义登录表单类
# 注册路由 - 使用正则表达式匹配以register/结尾的URL
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
# 注册视图,注册成功后重定向到首页
views.RegisterView.as_view(success_url="/"),
name='register'), # URL名称
# 登出路由 - 使用正则表达式匹配以logout/结尾的URL
name='register'),
re_path(r'^logout/$',
# 登出视图,处理用户退出登录
views.LogoutView.as_view(),
name='logout'), # URL名称
# 账户操作结果页面 - 使用path匹配精确路径
name='logout'),
path(r'account/result.html',
# 使用函数视图显示账户操作结果(如注册成功、密码重置成功等)
views.account_result,
name='result'), # URL名称
# 忘记密码页面 - 使用正则表达式匹配以forget_password/结尾的URL
name='result'),
re_path(r'^forget_password/$',
# 忘记密码视图,显示密码重置页面
views.ForgetPasswordView.as_view(),
name='forget_password'), # URL名称
# 忘记密码验证码接口 - 使用正则表达式匹配以forget_password_code/结尾的URL
name='forget_password'),
re_path(r'^forget_password_code/$',
# 处理忘记密码的邮箱验证码发送和验证
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'), # URL名称
]
name='forget_password_code'),
]

@ -4,58 +4,23 @@ from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
自定义认证后端允许用户使用用户名或邮箱登录
Extends ModelBackend to allow authentication using either username or email.
允许使用用户名或邮箱登录
"""
def authenticate(self, request, username=None, password=None, **kwargs):
"""
用户认证方法
Authenticate a user based on username/email and password.
Args:
request: HTTP请求对象
username: 用户输入的用户名或邮箱
password: 用户输入的密码
**kwargs: 其他参数
Returns:
User: 认证成功的用户对象
None: 认证失败
"""
# 判断输入的是邮箱还是用户名
if '@' in username:
# 如果包含@符号,按邮箱处理
kwargs = {'email': username}
else:
# 否则按用户名处理
kwargs = {'username': username}
try:
# 根据用户名或邮箱查找用户
user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password):
return user
except get_user_model().DoesNotExist:
# 用户不存在时返回None
return None
def get_user(self, user_id):
"""
根据用户ID获取用户对象
Get a user by their primary key.
Args:
user_id: 用户ID
Returns:
User: 用户对象
None: 用户不存在
"""
def get_user(self, username):
try:
# 通过主键查找用户
return get_user_model().objects.get(pk=user_id)
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
# 用户不存在时返回None
return None

@ -7,58 +7,43 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email
# 验证码的生存时间Time To Live设置为5分钟
_code_ttl = timedelta(minutes=5)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送验证邮件
"""发送重设密码验证码
Args:
to_mail: 收邮箱地址
subject: 邮件主题默认为"Verify Email"
code: 需要发送的验证码
to_mail: 受邮箱
subject: 邮件主题
code: 验证码
"""
# 生成邮件HTML内容包含验证码信息并支持国际化
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
# 调用邮件发送工具函数发送邮件
send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]:
"""验证邮箱和验证码是否匹配
"""验证code是否有效
Args:
email: 需要验证的邮箱地址
code: 用户输入的验证码
email: 请求邮箱
code: 验证码
Return:
如果验证失败返回错误信息字符串验证成功返回None
Note:
当前错误处理方式不够合理建议改为抛出异常的方式
这样调用方可以通过try-except来处理错误而不是检查返回值
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email)
# 比较缓存中的验证码和用户输入的验证码
if cache_code != code:
return gettext("Verification code error")
# 验证成功返回None
def set_code(email: str, code: str):
"""将验证码存储到缓存中
Args:
email: 作为缓存键的邮箱地址
code: 需要存储的验证码
"""
"""设置code"""
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""从缓存中获取验证码
Args:
email: 作为缓存键的邮箱地址
Return:
返回缓存中的验证码如果不存在或已过期则返回None
"""
"""获取code"""
return cache.get(email)

@ -29,47 +29,31 @@ from .models import BlogUser
logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView):
"""
用户注册视图
处理用户注册流程包括表单验证用户创建和发送验证邮件
"""
form_class = RegisterForm
template_name = 'account/registration_form.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""确保视图受到CSRF保护"""
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
"""
处理有效的注册表单
创建非活跃用户发送邮箱验证邮件
"""
if form.is_valid():
# 创建用户但不立即保存到数据库
user = form.save(False)
user.is_active = False # 邮箱验证前用户不可用
user.source = 'Register' # 标记用户来源
user.save(True) # 保存用户到数据库
# 获取当前站点信息
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
# 生成验证签名,用于验证链接的安全性
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 调试模式下使用本地地址
if settings.DEBUG:
site = '127.0.0.1:8000'
# 构建验证URL
path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
# 构建邮件内容
content = """
<p>请点击下面链接验证您的邮箱</p>
@ -80,8 +64,6 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
user.email,
@ -89,195 +71,134 @@ class RegisterView(FormView):
title='验证您的电子邮箱',
content=content)
# 重定向到结果页面
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
"""
用户登出视图
处理用户登出操作并清理相关缓存
"""
url = '/login/' # 登出后重定向的URL
url = '/login/'
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""确保登出页面不被缓存"""
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
"""处理GET请求的登出操作"""
logout(request) # 执行登出操作
delete_sidebar_cache() # 清理侧边栏缓存
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
"""
用户登录视图
处理用户认证和登录会话管理
"""
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/' # 登录成功后默认重定向的URL
redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名
login_ttl = 2626560 # 一个月的时间(秒),用于"记住我"功能
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password')) # 保护密码参数
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 禁止缓存
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""应用装饰器到视图分发方法"""
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""向模板上下文添加重定向URL"""
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/' # 默认重定向到首页
redirect_to = '/'
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""处理有效的登录表单"""
# 使用Django的AuthenticationForm进行认证
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
# 认证成功,清理缓存并记录日志
delete_sidebar_cache()
logger.info(self.redirect_field_name)
# 登录用户
auth.login(self.request, form.get_user())
# 处理"记住我"功能
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else:
# 认证失败,重新显示表单
return self.render_to_response({
'form': form
})
def get_success_url(self):
"""获取登录成功后重定向的URL"""
redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证重定向URL的安全性
redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url # 不安全的URL使用默认URL
redirect_to = self.success_url
return redirect_to
def account_result(request):
"""
账户操作结果页面
处理注册结果和邮箱验证
"""
type = request.GET.get('type') # 操作类型register或validation
id = request.GET.get('id') # 用户ID
# 获取用户对象如果不存在返回404
type = request.GET.get('type')
id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
# 如果用户已激活,直接重定向到首页
if user.is_active:
return HttpResponseRedirect('/')
# 处理注册和验证操作
if type and type in ['register', 'validation']:
if type == 'register':
# 注册成功页面
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
# 邮箱验证处理
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
# 验证签名安全性
if sign != c_sign:
return HttpResponseForbidden() # 签名不匹配,禁止访问
# 激活用户账户
return HttpResponseForbidden()
user.is_active = True
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
# 无效的操作类型,重定向到首页
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
"""
忘记密码视图
处理密码重置请求
"""
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
def form_valid(self, form):
"""处理有效的密码重置表单"""
if form.is_valid():
# 根据邮箱查找用户
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 使用Django的密码哈希器设置新密码
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save() # 保存新密码
# 重定向到登录页面
blog_user.save()
return HttpResponseRedirect('/login/')
else:
# 表单无效,重新显示表单
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
"""
发送忘记密码验证码视图
处理密码重置验证码的发送
"""
def post(self, request: HttpRequest):
"""处理POST请求发送密码重置验证码"""
form = ForgetPasswordCodeForm(request.POST)
# 验证表单数据
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
# 生成并发送验证码
code = generate_code()
utils.send_verify_email(to_email, code) # 发送验证邮件
utils.set_code(to_email, code) # 存储验证码(通常在缓存中)
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("ok") # 返回成功响应
return HttpResponse("ok")

@ -1,31 +1,50 @@
#!/usr/bin/env bash
# 指定脚本解释器为bash
# 定义应用名称为djangoblog
NAME="djangoblog"
# 定义Django项目根目录路径
DJANGODIR=/code/djangoblog
# 定义运行应用的用户
USER=root
# 定义运行应用的用户组
GROUP=root
# 定义Gunicorn工作进程数量
NUM_WORKERS=1
# 定义Django的WSGI模块路径
DJANGO_WSGI_MODULE=djangoblog.wsgi
# 输出启动信息,显示当前启动的应用名称和执行用户
echo "Starting $NAME as `whoami`"
# 进入Django项目根目录
cd $DJANGODIR
# 将项目目录添加到Python路径中确保Python能正确导入项目模块
export PYTHONPATH=$DJANGODIR:$PYTHONPATH
# 执行Django项目初始化命令序列若任何一步失败则退出脚本
# 1. 生成数据库迁移文件
python manage.py makemigrations && \
# 2. 应用数据库迁移
python manage.py migrate && \
# 3. 收集静态文件(无交互模式)
python manage.py collectstatic --noinput && \
# 4. 强制压缩静态文件通常用于CSS/JS压缩
python manage.py compress --force && \
# 5. 构建搜索索引(如果项目使用了全文搜索功能)
python manage.py build_index && \
# 6. 编译翻译文件(用于国际化支持)
python manage.py compilemessages || exit 1
# 启动Gunicorn作为WSGI服务器替换当前进程exec命令特性
exec gunicorn ${DJANGO_WSGI_MODULE}:application \
--name $NAME \
--workers $NUM_WORKERS \
--user=$USER --group=$GROUP \
--bind 0.0.0.0:8000 \
--log-level=debug \
--log-file=- \
--worker-class gevent \
--threads 4
--name $NAME \ # 指定应用名称
--workers $NUM_WORKERS \ # 指定工作进程数量
--user=$USER --group=$GROUP \ # 指定运行的用户和用户组
--bind 0.0.0.0:8000 \ # 绑定监听地址和端口0.0.0.0表示允许所有网络访问)
--log-level=debug \ # 设置日志级别为debug
--log-file=- \ # 日志输出到标准输出(-表示stdout
--worker-class gevent \ # 使用gevent工作类支持异步IO提高并发性能
--threads 4 # 每个工作进程的线程数量

@ -1,27 +1,7 @@
# 导入 Django 内置的 Admin 核心模块
# django.contrib.admin 提供了完整的后台管理界面生成、数据CRUD、权限控制等功能
from django.contrib import admin
# Register your models here.
# 说明:该注释为 Django 自动生成,提示开发者在此处注册需要通过后台管理的模型
# 注册方式:使用 admin.site.register(模型类, 自定义Admin类) 关联模型与管理配置
class OwnTrackLogsAdmin(admin.ModelAdmin):
"""
自定义 Admin 配置类继承自 Django 内置的 ModelAdmin
作用配置 OwnTrackLog 模型在后台管理界面的展示形式操作权限数据筛选等功能
若需扩展后台功能可在此类中添加属性/方法如列表显示字段搜索框过滤条件等
"""
# pass 关键字:表示当前类暂未定义额外配置,完全使用 ModelAdmin 的默认行为
# 默认效果:
# 1. 列表页显示模型的所有字段id、tid、lat、lon、creation_time
# 2. 支持点击主键id进入详情页编辑数据
# 3. 支持批量删除、简单搜索(默认搜索主键字段)
# 4. 按模型 Meta 中定义的 ordering 排序(即 creation_time 升序)
pass
# 【注】当前代码缺少模型注册语句,需补充以下代码才能在后台看到该模型(否则配置不生效)
# 需先导入 OwnTrackLog 模型(从对应的 models.py 中),再注册关联
# 完整注册代码示例:
# from .models import OwnTrackLog # 从当前应用的 models.py 导入模型类
# admin.site.register(OwnTrackLog, OwnTrackLogsAdmin) # 关联模型与自定义Admin配置

@ -1,26 +1,5 @@
# 导入 Django 应用配置核心类 AppConfig
# django.apps.AppConfig 是 Django 管理应用元数据的基础类,用于定义应用的名称、初始化逻辑、信号绑定等
from django.apps import AppConfig
class OwntracksConfig(AppConfig):
"""
自定义应用配置类继承自 Django 内置的 AppConfig
作用管理 'owntracks' 应用的核心配置包括应用名称初始化行为模型注册信号监听等
每个 Django 应用建议创建独立的 AppConfig 便于后续扩展应用功能如添加启动时初始化逻辑
"""
# 应用名称Django 识别应用的唯一标识,必须与应用目录名一致(此处为 'owntracks'
# 作用:
# 1. 作为应用的核心标识,用于迁移命令(如 python manage.py migrate owntracks、权限控制等
# 2. 关联 models、views、admin 等模块,确保 Django 能正确识别应用内的组件
# 3. 若需跨应用引用模型,需通过该名称定位(如 from owntracks.models import OwnTrackLog
name = 'owntracks'
# 【可选扩展配置】若需添加更多应用级配置,可在此处补充(示例):
# 1. 应用verbose名称后台管理界面显示的应用名称支持中文
# verbose_name = '用户轨迹管理'
# 2. 定义应用初始化逻辑(如启动时加载数据、绑定信号)
# def ready(self):
# # 导入信号处理模块(避免循环导入,需在 ready 方法内导入)
# import owntracks.signals

@ -1,64 +1,31 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 说明:该文件为 Django 自动生成的数据迁移文件,用于创建数据库表结构
# 生成条件:执行 makemigrations 命令时Django 检测到 models.py 中新增 OwnTrackLog 模型后自动生成
# 兼容版本Django 4.1.7(迁移文件与 Django 版本强相关,修改需注意兼容性)
# 导入 Django 迁移核心模块和模型字段类
from django.db import migrations, models
# 导入 Django 时区工具(用于处理时间字段的时区一致性)
import django.utils.timezone
class Migration(migrations.Migration):
"""
数据迁移类Django 迁移系统的核心载体用于定义数据库结构变更逻辑
每个迁移类对应一次数据库操作如建表改字段删索引等
"""
# 标记是否为初始迁移(首次创建模型时为 True后续修改为 False
initial = True
# 依赖迁移列表:当前迁移依赖的其他迁移文件(为空表示无依赖)
# 若需依赖其他 app 的迁移,格式为 ['其他app名称.迁移文件名前缀']
dependencies = [
]
# 迁移操作列表:定义具体的数据库变更操作
operations = [
# 创建数据库表操作:对应 models.py 中的 OwnTrackLog 模型
migrations.CreateModel(
# 模型名称(必须与 models.py 中定义的类名一致)
name='OwnTrackLog',
# 字段配置:与模型类中的 field 定义一一对应,决定表的列结构
fields=[
# 主键字段BigAutoField 为自增bigint类型Django 默认主键类型
('id', models.BigAutoField(
auto_created=True, # 自动创建,无需手动赋值
primary_key=True, # 标记为主键
serialize=False, # 不序列化(主键默认不参与序列化)
verbose_name='ID' # 后台管理界面显示的字段名称
)),
# 用户标识字段CharField 对应数据库 varchar 类型
('tid', models.CharField(
max_length=100, # 最大长度100必填参数
verbose_name='用户' # 后台显示名称,支持中文
)),
# 纬度字段FloatField 对应数据库 float 类型,存储地理纬度(如 39.9042
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tid', models.CharField(max_length=100, verbose_name='用户')),
('lat', models.FloatField(verbose_name='纬度')),
# 经度字段FloatField 对应数据库 float 类型,存储地理经度(如 116.4074
('lon', models.FloatField(verbose_name='经度')),
# 创建时间字段DateTimeField 对应数据库 datetime 类型
('created_time', models.DateTimeField(
default=django.utils.timezone.now, # 默认值:当前时区的当前时间
verbose_name='创建时间' # 后台显示名称
)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
# 模型元数据配置:对应模型类中的 Meta 内部类,影响表的整体属性
options={
'verbose_name': 'OwnTrackLogs', # 单数形式的表名称(后台显示)
'verbose_name_plural': 'OwnTrackLogs', # 复数形式的表名称(后台列表页显示)
'ordering': ['created_time'], # 默认排序:按创建时间升序排列(-created_time 表示降序)
'get_latest_by': 'created_time', # 支持使用 Model.objects.latest() 方法,默认按创建时间取最新记录
'verbose_name': 'OwnTrackLogs',
'verbose_name_plural': 'OwnTrackLogs',
'ordering': ['created_time'],
'get_latest_by': 'created_time',
},
),
]

@ -1,44 +1,22 @@
# Generated by Django 4.2.5 on 2023-09-06 13:19
# 说明:该文件为 Django 自动生成的**增量迁移文件**,用于更新数据库表结构
# 生成条件:修改 models.py 中 OwnTrackLog 模型的 Meta 配置排序字段、最新记录字段和字段名created_time → creation_time执行 makemigrations 命令生成
# 兼容版本Django 4.2.5(与初始迁移文件 0001_initial 版本需匹配,避免迁移冲突)
# 核心作用1. 重命名字段 created_time 为 creation_time2. 更新模型元数据的排序和最新记录查询字段
# 导入 Django 迁移核心模块(仅需 migrations无需额外字段类因无新增字段
from django.db import migrations
class Migration(migrations.Migration):
"""
数据迁移类定义数据库结构的增量变更操作
本次迁移依赖初始迁移文件仅修改字段名称和模型元数据不改变表结构核心逻辑
"""
# 依赖迁移列表:当前迁移必须在 'owntracks' 应用的 0001_initial 迁移执行后才能运行
# 格式:['应用名称.迁移文件前缀'],确保迁移顺序正确,避免字段不存在导致的报错
dependencies = [
('owntracks', '0001_initial'), # 依赖初始迁移(创建 OwnTrackLog 表的迁移)
('owntracks', '0001_initial'),
]
# 迁移操作列表:包含两个核心变更操作,按顺序执行
operations = [
# 操作1修改模型的元数据配置对应 models.py 中 OwnTrackLog 类的 Meta 内部类)
migrations.AlterModelOptions(
name='owntracklog', # 目标模型名称(必须与 models.py 中类名一致)
options={
'get_latest_by': 'creation_time', # 更新「查询最新记录」的字段:从 created_time 改为新字段 creation_time
# 影响 Model.objects.latest() 方法的默认查询逻辑
'ordering': ['creation_time'], # 更新默认排序字段:从 created_time 改为 creation_time升序
'verbose_name': 'OwnTrackLogs', # 单数显示名称(未修改,与初始迁移一致)
'verbose_name_plural': 'OwnTrackLogs', # 复数显示名称(未修改)
},
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
),
# 操作2重命名模型的字段数据库表中对应列名也会同步修改
migrations.RenameField(
model_name='owntracklog', # 目标模型名称
old_name='created_time', # 旧字段名(原模型中定义的字段名)
new_name='creation_time', # 新字段名(修改后的字段名)
# 说明该操作会同步更新数据库表中对应的列名created_time → creation_time且保留原有字段数据
# 无需手动处理数据迁移Django 会自动完成字段名映射和数据保留
model_name='owntracklog',
old_name='created_time',
new_name='creation_time',
),
]

@ -1,54 +1,20 @@
# 导入 Django ORM 核心模块models 用于定义数据模型,对应数据库表结构
from django.db import models
# 导入 Django 时区工具now() 用于获取当前时区的时间(避免时区不一致问题)
from django.utils.timezone import now
# Create your models here.
# 说明:该注释为 Django 自动生成,提示开发者在此处定义数据模型类
# 模型类与数据库表的映射关系:每个模型类对应一张数据库表,类属性对应表字段
class OwnTrackLog(models.Model):
"""
轨迹数据模型类继承自 Django 内置的 models.Model所有数据模型的基类
核心作用存储用户的地理轨迹信息用户标识经纬度创建时间
映射数据库表名默认生成规则为应用名_模型名小写 owntracks_owntracklog
"""
# 1. 用户标识字段存储用户唯一标识如设备ID、用户名等
tid = models.CharField(
max_length=100, # 字段最大长度CharField 必填参数),适配多数用户标识场景
null=False, # 数据库层面不允许为空(必填字段),确保数据完整性
verbose_name='用户' # Django 后台管理界面显示的字段名称(支持中文)
)
# 2. 纬度字段:存储地理纬度值(如 39.9042,支持正负值,适配全球地理坐标)
lat = models.FloatField(verbose_name='纬度') # FloatField 对应数据库 float 类型,满足精度需求
# 3. 经度字段:存储地理经度值(如 116.4074,与纬度配合定位地理坐标)
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
lat = models.FloatField(verbose_name='纬度')
lon = models.FloatField(verbose_name='经度')
# 4. 创建时间字段:记录轨迹数据的生成时间
creation_time = models.DateTimeField(
'创建时间', # verbose_name 的简写形式(第一个参数直接指定后台显示名称)
default=now # 默认值当前时区的当前时间now 是可调用对象,每次创建记录时动态获取)
# 注:区别于 datetime.datetime.now()django.utils.timezone.now() 包含时区信息,符合 Django 时区配置
)
creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
"""
模型实例的字符串表示方法
作用 Django 后台终端打印实例时显示直观的标识而非默认的 <OwnTrackLog object>
返回值以用户标识tid作为实例的字符串描述便于区分不同用户的轨迹数据
"""
return self.tid
class Meta:
"""
模型元数据类用于配置模型的整体属性不对应表字段影响表的行为和显示
所有配置仅作用于当前模型不影响其他模型
"""
ordering = ['creation_time'] # 默认排序规则:按创建时间升序排列(-creation_time 表示降序)
verbose_name = "OwnTrackLogs" # 后台管理界面显示的「单数模型名称」
verbose_name_plural = verbose_name # 后台管理界面显示的「复数模型名称」(此处与单数一致,避免英文复数歧义)
get_latest_by = 'creation_time' # 支持 Model.objects.latest() 方法,默认按创建时间获取最新一条记录
ordering = ['creation_time']
verbose_name = "OwnTrackLogs"
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'

@ -1,124 +1,64 @@
# 导入 JSON 模块:用于将 Python 字典序列化为 JSON 字符串(适配接口的 JSON 数据格式)
import json
# 导入 Django 测试核心工具:
# - Client模拟客户端发起 HTTP 请求GET/POST 等),用于测试视图接口
# - RequestFactory生成原始请求对象适用于单独测试视图函数/类,本用例未直接使用)
# - TestCaseDjango 单元测试基类,提供断言方法、测试环境初始化/清理等功能
from django.test import Client, RequestFactory, TestCase
# 导入跨应用模型BlogUser用户模型用于测试登录权限相关接口
from accounts.models import BlogUser
# 导入当前应用的测试目标模型OwnTrackLog轨迹数据模型用于验证数据读写
from .models import OwnTrackLog
# Create your tests here.
# 说明:该注释为 Django 自动生成,提示开发者在此处定义测试类/测试方法
class OwnTrackLogTest(TestCase):
"""
轨迹数据相关接口与模型单元测试类
继承自 TestCase专注测试 OwnTrackLog 模型的数据读写及相关视图接口/owntracks/ 下的接口
测试覆盖场景数据提交合法/非法接口权限控制响应状态码验证
"""
def setUp(self):
"""
测试前置初始化方法
在每个测试方法执行前自动调用用于创建测试所需的公共资源
作用避免重复代码确保每个测试方法的环境一致性
"""
# 初始化客户端对象:模拟浏览器发起 HTTP 请求,后续所有接口测试均通过该对象执行
self.client = Client()
# 初始化请求工厂对象:用于生成自定义请求(本用例未直接使用,预留扩展)
self.factory = RequestFactory()
def test_own_track_log(self):
"""
核心测试方法命名以 test_ 开头Django 测试框架自动识别执行
测试内容
1. 合法轨迹数据提交完整字段 验证数据是否成功写入数据库
2. 非法轨迹数据提交缺少必填字段 验证数据是否被拒绝数据库无新增
3. 未登录状态访问需权限接口 验证是否重定向302
4. 管理员登录后访问接口 验证是否正常响应200
5. 管理员登录后操作模型 验证数据写入及接口查询功能
"""
# --------------- 场景1提交完整合法的轨迹数据tid、lat、lon 字段齐全)---------------
# 构造合法的请求数据字典:包含模型所需的所有必填字段
o = {
'tid': 12, # 用户标识(整数类型,模型中 CharField 会自动转换为字符串存储)
'lat': 123.123, # 纬度(合法浮点数)
'lon': 134.341 # 经度(合法浮点数)
'tid': 12,
'lat': 123.123,
'lon': 134.341
}
# 模拟 POST 请求:向轨迹提交接口发送 JSON 格式数据
self.client.post(
'/owntracks/logtracks', # 请求接口路径(需与 urls.py 中配置一致)
json.dumps(o), # 请求体:将字典序列化为 JSON 字符串
content_type='application/json' # 指定请求头:声明数据格式为 JSON
)
# 验证:数据库中是否新增 1 条轨迹记录(断言实际数量与预期一致)
length = len(OwnTrackLog.objects.all()) # 查询所有轨迹记录的数量
self.assertEqual(length, 1) # 断言数量为 1 → 验证合法数据提交成功
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# --------------- 场景2提交非法轨迹数据缺少必填字段 lon---------------
# 构造非法请求数据缺少经度lon字段模型中 lon 为必填字段,无 null=True 配置)
o = {
'tid': 12, # 用户标识
'lat': 123.123 # 纬度(仅含该字段,缺少 lon
'tid': 12,
'lat': 123.123
}
# 再次向同一接口发送非法数据
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
# 验证:数据库记录数量是否仍为 1非法数据未被写入
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1) # 断言数量不变 → 验证非法数据被拒绝
self.assertEqual(length, 1)
# --------------- 场景3未登录状态访问需权限的地图展示接口 ---------------
# 模拟 GET 请求:未登录状态下访问 /owntracks/show_maps 接口
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302)
# 验证:响应状态码是否为 302重定向通常跳转到登录页
self.assertEqual(rsp.status_code, 302) # 断言重定向 → 验证接口权限控制生效
# --------------- 场景4创建管理员用户并登录 ---------------
# 创建超级用户(管理员):用于测试登录后访问权限接口
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com", # 邮箱(超级用户必填字段)
username="liangliangyy1", # 用户名(登录账号)
password="liangliangyy1") # 密码(登录密码)
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 模拟管理员登录:使用上述创建的账号密码登录系统
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 手动创建一条轨迹记录(用于后续接口查询测试)
s = OwnTrackLog()
s.tid = 12 # 设置用户标识
s.lon = 123.234 # 设置经度
s.lat = 34.234 # 设置纬度
# creation_time 字段使用默认值(当前时间),无需手动赋值
s.save() # 保存到数据库
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
# --------------- 场景5登录后访问各类接口验证响应状态 ---------------
# 1. 访问日期列表接口 → 预期 200正常响应
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200)
# 2. 再次访问地图展示接口 → 预期 200已登录权限通过
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
# 3. 访问轨迹数据查询接口(无日期参数)→ 预期 200
rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200)
# 4. 访问轨迹数据查询接口(带日期参数)→ 预期 200
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200)
# 注:此处仅验证接口是否正常响应(状态码 200未验证返回数据的正确性可根据需求补充数据断言

@ -1,40 +1,12 @@
# 导入 Django 路由核心函数path 用于定义 URL 路径与视图函数的映射关系
from django.urls import path
# 导入当前应用的视图模块views 中包含所有路由对应的业务处理函数
from . import views
# 应用路由命名空间:用于区分不同应用的同名路由(避免反向解析时冲突)
# 作用在模板或视图中通过「app_name:route_name」反向生成 URL如 reverse('owntracks:logtracks')
app_name = "owntracks"
# 路由配置列表:每个 path 对应一条 URL 规则,按定义顺序匹配(优先匹配靠前的规则)
urlpatterns = [
# 1. 轨迹数据提交接口:接收客户端发送的轨迹数据(经纬度、用户标识)并存储
path(
'owntracks/logtracks', # URL 路径:客户端访问的接口地址(需完整匹配)
views.manage_owntrack_log, # 对应的视图函数:处理该 URL 的业务逻辑(如数据验证、写入数据库)
name='logtracks' # 路由别名:用于反向解析 URL替代硬编码路径便于维护
),
# 2. 地图展示接口:渲染包含用户轨迹的地图页面(需登录权限)
path(
'owntracks/show_maps', # URL 路径:地图展示页面地址
views.show_maps, # 视图函数:查询轨迹数据并传递给模板渲染地图
name='show_maps' # 路由别名:如模板中使用 {% url 'owntracks:show_maps' %} 生成 URL
),
# 3. 轨迹数据查询接口:返回指定条件的轨迹数据(如按日期筛选),通常用于前端异步请求
path(
'owntracks/get_datas', # URL 路径:数据查询接口地址(支持带查询参数,如 ?date=2023-09-06
views.get_datas, # 视图函数:处理查询条件,从数据库筛选数据并返回(如 JSON 格式)
name='get_datas' # 路由别名:前端 AJAX 请求时可通过反向解析获取接口地址
),
# 4. 轨迹日期列表接口:返回所有轨迹数据的日期列表(用于前端筛选日期选择)
path(
'owntracks/show_dates', # URL 路径:日期列表展示/查询地址
views.show_log_dates, # 视图函数:查询数据库中轨迹数据的所有日期并返回(去重处理)
name='show_dates' # 路由别名:用于反向生成日期筛选接口的 URL
)
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
path('owntracks/show_maps', views.show_maps, name='show_maps'),
path('owntracks/get_datas', views.get_datas, name='get_datas'),
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]

@ -1,237 +1,127 @@
# Create your views here.
# 说明:该文件为 Django 视图层核心文件,包含所有 /owntracks/ 路由对应的业务处理逻辑
# 视图函数职责:接收请求、处理数据(数据库读写/第三方接口调用)、返回响应(页面/JSON/状态码)
# 导入标准库模块
import datetime # 处理日期时间相关操作(如日期计算、格式化)
import itertools # 提供迭代器工具(如切片、分组,用于批量处理经纬度数据)
import json # 处理 JSON 数据序列化/反序列化(适配接口请求/响应)
import logging # 日志模块:记录业务日志(信息/错误),便于问题排查
from datetime import timezone # 处理时区相关(确保时间计算一致性)
from itertools import groupby # 分组工具按用户标识tid分组轨迹数据
# 导入第三方库/框架模块
import django # Django 核心模块(用于时区时间处理)
import requests # HTTP 请求库:调用高德地图坐标转换接口
from django.contrib.auth.decorators import login_required # 登录验证装饰器:限制未登录用户访问
from django.http import HttpResponse # 基础响应类:返回文本/状态码响应
from django.http import JsonResponse # JSON 响应类:返回 JSON 格式数据(适配前端异步请求)
from django.shortcuts import render # 页面渲染函数:加载模板并返回 HTML 页面
from django.views.decorators.csrf import csrf_exempt # CSRF 豁免装饰器:关闭跨站请求伪造保护(适配第三方客户端提交数据)
# 导入当前应用模块
from .models import OwnTrackLog # 轨迹数据模型:用于数据库读写操作
# 初始化日志对象:按当前模块名创建日志实例,日志输出会携带模块标识
import datetime
import itertools
import json
import logging
from datetime import timezone
from itertools import groupby
import django
import requests
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from .models import OwnTrackLog
logger = logging.getLogger(__name__)
@csrf_exempt # 豁免 CSRF 验证:因客户端(如设备/第三方系统)可能无法提供 CSRF Token故关闭保护
@csrf_exempt
def manage_owntrack_log(request):
"""
轨迹数据提交接口视图
功能接收客户端 POST 提交的 JSON 格式轨迹数据tid/经纬度验证后写入数据库
请求方式POST仅支持 POST其他方式会因缺少请求体报错
请求体格式{"tid": "用户标识", "lat": 纬度值, "lon": 经度值}
响应
- 成功写入返回 "ok"HTTP 200
- 数据不完整返回 "data error"HTTP 200
- 异常报错返回 "error"HTTP 200并记录错误日志
"""
try:
# 读取请求体:将 JSON 字符串解码为 Python 字典utf-8 编码适配中文/特殊字符)
s = json.loads(request.read().decode('utf-8'))
# 提取请求数据中的核心字段(用户标识、纬度、经度)
tid = s['tid']
lat = s['lat']
lon = s['lon']
# 记录信息日志:打印提交的轨迹数据(便于追踪数据流转)
logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon)
)
# 数据合法性校验:确保核心字段非空(避免写入无效数据)
tid=tid, lat=lat, lon=lon))
if tid and lat and lon:
# 创建模型实例并赋值
m = OwnTrackLog()
m.tid = tid # 用户标识
m.lat = lat # 纬度
m.lon = lon # 经度
# creation_time 字段使用默认值(当前时间),无需手动赋值
m.save() # 保存到数据库
return HttpResponse('ok') # 响应成功标识
m.tid = tid
m.lat = lat
m.lon = lon
m.save()
return HttpResponse('ok')
else:
# 数据不完整:返回错误提示
return HttpResponse('data error')
except Exception as e:
# 捕获所有异常(如 JSON 解析失败、字段缺失、数据库报错等)
logger.error(e) # 记录错误日志(包含异常堆栈信息,便于排查)
return HttpResponse('error') # 响应错误标识
logger.error(e)
return HttpResponse('error')
@login_required # 登录验证:仅登录用户可访问,未登录自动重定向到登录页
@login_required
def show_maps(request):
"""
地图展示页面视图
功能渲染包含用户轨迹的地图页面需管理员权限
请求方式GET
请求参数?date=YYYY-MM-DD可选默认当前日期
响应
- 管理员登录返回地图 HTML 页面携带日期参数
- 非管理员登录返回 403 禁止访问
"""
# 权限二次校验仅超级管理员is_superuser=True可访问普通登录用户无权限
if request.user.is_superuser:
# 计算默认日期:当前 UTC 时间的日期格式YYYY-MM-DD
defaultdate = str(datetime.datetime.now(timezone.utc).date())
# 获取请求参数中的日期(若未传则使用默认日期)
date = request.GET.get('date', defaultdate)
# 构造模板上下文:传递日期参数给前端模板(用于筛选该日期的轨迹数据)
context = {
'date': date
}
# 渲染模板:加载 show_maps.html 模板并传入上下文,返回 HTML 响应
return render(request, 'owntracks/show_maps.html', context)
else:
# 非管理员:导入并返回 403 禁止访问响应
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
@login_required # 登录验证:仅登录用户可访问
@login_required
def show_log_dates(request):
"""
轨迹日期列表页面视图
功能查询数据库中所有轨迹数据的日期去重渲染日期列表页面用于前端筛选
请求方式GET
响应返回日期列表 HTML 页面包含去重后的所有轨迹日期
"""
# 查询所有轨迹记录的创建时间(仅取 creation_time 字段flat=True 返回一维列表)
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
# 日期处理:
# 1. map 转换:将 datetime 对象格式化为 'YYYY-MM-DD' 字符串
# 2. set 去重:去除重复日期
# 3. sorted 排序:按日期升序排列
# 4. list 转换:转为列表用于模板渲染
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
# 构造上下文:传递日期列表给模板
context = {
'results': results
}
# 渲染日期列表模板
return render(request, 'owntracks/show_log_dates.html', context)
def convert_to_amap(locations):
"""
高德地图坐标转换工具函数
功能 GPS 坐标系WGS84的经纬度转换为高德坐标系GCJ02
原因GPS 原始坐标在高德地图上会有偏移转换后可精准定位
参数locations - OwnTrackLog 模型实例列表包含 lon/lat 字段
返回值转换后的经纬度字符串格式"lon1,lat1;lon2,lat2;..."
限制高德接口单次最多支持 30 个坐标故分批次转换
"""
convert_result = [] # 存储所有批次的转换结果
it = iter(locations) # 将列表转为迭代器,便于分批次切片
# 循环分批次处理:每次取 30 个坐标(适配高德接口限制)
convert_result = []
it = iter(locations)
item = list(itertools.islice(it, 30))
while item:
# 构造坐标字符串:将每个实例的 lon/lat 拼接为 "lon,lat",再用 ";" 连接多个坐标
# set 去重:避免重复坐标提交(减少接口调用量)
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item))
)
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
# 高德地图坐标转换接口配置
key = '8440a376dfc9743d8924bf0ad141f28e' # 高德开发者密钥(需替换为有效密钥)
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' # 转换接口地址
key = '8440a376dfc9743d8924bf0ad141f28e'
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
query = {
'key': key, # 开发者密钥(必填)
'locations': datas, # 待转换的坐标字符串
'coordsys': 'gps' # 源坐标系gpsWGS84
'key': key,
'locations': datas,
'coordsys': 'gps'
}
# 调用高德接口GET 请求)
rsp = requests.get(url=api, params=query)
# 解析接口响应JSON 转字典)
result = json.loads(rsp.text)
# 若响应包含 "locations" 字段(转换成功),添加到结果列表
if "locations" in result:
convert_result.append(result['locations'])
# 处理下一批次坐标
item = list(itertools.islice(it, 30))
# 拼接所有批次结果,返回统一格式的坐标字符串
return ";".join(convert_result)
@login_required # 登录验证:仅登录用户可访问
@login_required
def get_datas(request):
"""
轨迹数据查询接口视图
功能按日期筛选轨迹数据按用户标识tid分组返回 JSON 格式的轨迹路径经纬度列表
请求方式GET
请求参数?date=YYYY-MM-DD可选默认当前日期
响应JSON 数组格式[{"name": "tid1", "path": [[lon1,lat1], [lon2,lat2], ...]}, ...]
"""
# 获取当前 UTC 时间(带时区信息,确保与数据库时间字段时区一致)
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
# 构造默认查询日期:当前日期的 00:00:00UTC 时间)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0
)
# 若请求携带 date 参数,解析为指定日期的 00:00:00
now.year, now.month, now.day, 0, 0, 0)
if request.GET.get('date', None):
# 拆分日期字符串YYYY-MM-DD → [年, 月, 日])并转为整数
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0
)
# 构造查询结束日期:查询日期的次日 00:00:00即筛选 [querydate, nextdate) 区间的数据)
date[0], date[1], date[2], 0, 0, 0)
nextdate = querydate + datetime.timedelta(days=1)
# 数据库查询:筛选指定日期区间内的所有轨迹记录
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate)
)
result = list() # 存储最终返回的 JSON 数据
# 若查询到数据,按 tid 分组并构造轨迹路径
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
# 1. sorted按 tid 排序(确保相同 tid 的记录连续,为 groupby 分组做准备)
# 2. groupby按 tid 分组key 为分组依据tid
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
# 构造单个用户的轨迹数据字典
d = dict()
d["name"] = tid # 用户标识(用于前端区分不同用户的轨迹)
paths = list() # 存储该用户的经纬度路径列表
# 【可选】使用高德转换后的经纬度(当前注释未启用,默认使用 GPS 原始坐标)
d["name"] = tid
paths = list()
# 使用高德转换后的经纬度
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time) # 按创建时间排序,确保轨迹顺序正确
# )
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(',')) # 拆分坐标为 [lon, lat] 列表
# 使用 GPS 原始经纬度(默认启用)
# 按创建时间排序:确保轨迹点按时间顺序排列(避免路径错乱)
# paths.append(i.split(','))
# 使用GPS原始经纬度
for location in sorted(item, key=lambda x: x.creation_time):
# 转为字符串格式(避免 JSON 序列化时的精度问题),添加到路径列表
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths # 关联路径列表到用户字典
result.append(d) # 添加到最终结果列表
# 返回 JSON 响应safe=False 允许返回非字典类型(此处为列表)
d["path"] = paths
result.append(d)
return JsonResponse(result, safe=False)

@ -5,83 +5,28 @@ from djangoblog.utils import cache
class MemcacheStorage(SessionStorage):
"""
基于Memcache的会话存储实现类
该类继承自SessionStorage使用memcache作为后端存储来管理会话数据
Args:
prefix (str): 存储键名的前缀默认为'ws_'
"""
def __init__(self, prefix='ws_'):
self.prefix = prefix
self.cache = cache
@property
def is_available(self):
"""
检查存储是否可用
通过设置并获取一个测试值来验证存储服务的可用性
Returns:
bool: 存储服务可用返回True否则返回False
"""
value = "1"
self.set('checkavaliable', value=value)
return value == self.get('checkavaliable')
def key_name(self, s):
"""
生成带前缀的完整键名
Args:
s (str): 原始键名
Returns:
str: 添加前缀后的完整键名
"""
return '{prefix}{s}'.format(prefix=self.prefix, s=s)
def get(self, id):
"""
根据ID获取会话数据
Args:
id (str): 会话ID
Returns:
dict: 解析后的会话数据字典
"""
# 构造完整的缓存键名
id = self.key_name(id)
# 从缓存中获取会话数据如果不存在则返回空JSON对象
session_json = self.cache.get(id) or '{}'
# 将JSON字符串解析为Python对象并返回
return json_loads(session_json)
def set(self, id, value):
"""
设置会话数据
Args:
id (str): 会话ID
value (any): 要存储的会话数据
"""
# 构造完整的缓存键名
id = self.key_name(id)
# 将数据序列化为JSON字符串并存储到缓存中
self.cache.set(id, json_dumps(value))
def delete(self, id):
"""
删除指定ID的会话数据
Args:
id (str): 要删除的会话ID
"""
# 构造完整的缓存键名
id = self.key_name(id)
# 从缓存中删除对应的会话数据
self.cache.delete(id)

@ -3,25 +3,10 @@ from django.contrib import admin
class CommandsAdmin(admin.ModelAdmin):
"""
命令管理后台类
用于在Django管理后台中展示和管理命令信息配置了列表页面显示的字段
"""
list_display = ('title', 'command', 'describe')
class EmailSendLogAdmin(admin.ModelAdmin):
"""
邮件发送日志管理后台类
用于在Django管理后台中展示和管理邮件发送日志信息配置了列表页面显示的字段
和只读字段并重写了权限控制方法
Attributes:
list_display: 列表页面显示的字段元组
readonly_fields: 只读字段元组
"""
list_display = ('title', 'emailto', 'send_result', 'creation_time')
readonly_fields = (
'title',
@ -31,17 +16,4 @@ class EmailSendLogAdmin(admin.ModelAdmin):
'content')
def has_add_permission(self, request):
"""
控制是否具有添加新记录的权限
重写父类方法禁止用户在管理后台手动添加邮件发送日志记录
Args:
request: HTTP请求对象
Returns:
bool: 总是返回False表示没有添加权限
"""
return False

@ -1,72 +1,27 @@
from haystack.query import SearchQuerySet
#hz代码注释
from haystack.query import SearchQuerySet
from blog.models import Article, Category
class BlogApi:
"""
博客API类提供文章搜索分类获取等相关功能
Attributes:
searchqueryset (SearchQuerySet): 搜索查询集对象
__max_takecount__ (int): 最大返回记录数默认为8
"""
def __init__(self):
"""
初始化BlogApi实例
"""
self.searchqueryset = SearchQuerySet()
self.searchqueryset.auto_query('')
self.__max_takecount__ = 8
def search_articles(self, query):
"""
根据查询关键字搜索文章
Args:
query (str): 搜索关键字
Returns:
list: 匹配的文章列表最多返回__max_takecount__条记录
"""
sqs = self.searchqueryset.auto_query(query)
sqs = sqs.load_all()
return sqs[:self.__max_takecount__]
def get_category_lists(self):
"""
获取所有文章分类列表
Returns:
QuerySet: 所有分类对象的查询集
"""
return Category.objects.all()
def get_category_articles(self, categoryname):
"""
根据分类名称获取该分类下的文章列表
Args:
categoryname (str): 分类名称
Returns:
QuerySet or None: 指定分类下的文章查询集最多返回__max_takecount__条记录
如果没有找到相关文章则返回None
"""
articles = Article.objects.filter(category__name=categoryname)
if articles:
return articles[:self.__max_takecount__]
return None
def get_recent_articles(self):
"""
获取最近发布的文章列表
Returns:
QuerySet: 最近发布的文章查询集最多返回__max_takecount__条记录
"""
return Article.objects.all()[:self.__max_takecount__]

@ -2,14 +2,4 @@ from django.apps import AppConfig
class ServermanagerConfig(AppConfig):
"""
Django应用配置类
该类用于配置servermanager应用的基本信息继承自Django的AppConfig基类
通过设置name属性来指定应用的名称Django框架会使用这个配置来识别和管理应用
属性:
name (str): 应用的名称用于Django框架识别该应用模块
"""
name = 'servermanager'

@ -1,16 +1,9 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
#hz代码注释
from django.db import migrations, models
class Migration(migrations.Migration):
"""
Django数据库迁移类用于创建初始数据表结构
该迁移文件包含两个模型的创建操作
1. commands模型 - 用于存储命令信息
2. EmailSendLog模型 - 用于记录邮件发送日志
"""
initial = True
@ -18,7 +11,6 @@ class Migration(migrations.Migration):
]
operations = [
# 创建commands数据表用于存储命令相关信息
migrations.CreateModel(
name='commands',
fields=[
@ -34,7 +26,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': '命令',
},
),
# 创建EmailSendLog数据表用于记录邮件发送日志信息
migrations.CreateModel(
name='EmailSendLog',
fields=[
@ -52,4 +43,3 @@ class Migration(migrations.Migration):
},
),
]

@ -1,38 +1,29 @@
# Generated by Django 4.2.5 on 2023-09-06 13:19
#hx代码注释
from django.db import migrations
class Migration(migrations.Migration):
"""
Django数据库迁移类用于执行模型字段重命名和模型选项修改操作
该迁移依赖于servermanager应用的0001_initial迁移文件
"""
dependencies = [
('servermanager', '0001_initial'),
]
operations = [
# 修改EmailSendLog模型的元数据选项设置排序规则和显示名称
migrations.AlterModelOptions(
name='emailsendlog',
options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
),
# 重命名Commands模型的created_time字段为creation_time
migrations.RenameField(
model_name='commands',
old_name='created_time',
new_name='creation_time',
),
# 重命名Commands模型的last_mod_time字段为last_modify_time
migrations.RenameField(
model_name='commands',
old_name='last_mod_time',
new_name='last_modify_time',
),
# 重命名EmailSendLog模型的created_time字段为creation_time
migrations.RenameField(
model_name='emailsendlog',
old_name='created_time',

@ -3,18 +3,6 @@ from django.db import models
# Create your models here.
class commands(models.Model):
"""
命令模型类
用于存储命令相关信息的数据库模型
Attributes:
title (CharField): 命令标题最大长度300字符
command (CharField): 命令内容最大长度2000字符
describe (CharField): 命令描述最大长度300字符
creation_time (DateTimeField): 创建时间自动设置为记录创建时的时间
last_modify_time (DateTimeField): 修改时间自动更新为记录每次修改的时间
"""
title = models.CharField('命令标题', max_length=300)
command = models.CharField('命令', max_length=2000)
describe = models.CharField('命令描述', max_length=300)
@ -22,37 +10,14 @@ class commands(models.Model):
last_modify_time = models.DateTimeField('修改时间', auto_now=True)
def __str__(self):
"""
返回命令对象的字符串表示
Returns:
str: 命令的标题
"""
return self.title
class Meta:
"""
模型元数据配置
配置模型在Django管理界面中的显示名称
"""
verbose_name = '命令'
verbose_name_plural = verbose_name
class EmailSendLog(models.Model):
"""
邮件发送日志模型类
用于记录邮件发送历史和结果的数据库模型
Attributes:
emailto (CharField): 收件人邮箱地址最大长度300字符
title (CharField): 邮件标题最大长度2000字符
content (TextField): 邮件正文内容
send_result (BooleanField): 邮件发送结果True表示成功False表示失败
creation_time (DateTimeField): 创建时间自动设置为记录创建时的时间
"""
emailto = models.CharField('收件人', max_length=300)
title = models.CharField('邮件标题', max_length=2000)
content = models.TextField('邮件内容')
@ -60,20 +25,9 @@ class EmailSendLog(models.Model):
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
def __str__(self):
"""
返回邮件发送日志对象的字符串表示
Returns:
str: 邮件的标题
"""
return self.title
class Meta:
"""
模型元数据配置
配置模型在Django管理界面中的显示名称和排序规则
"""
verbose_name = '邮件发送log'
verbose_name_plural = verbose_name
ordering = ['-creation_time']

@ -13,46 +13,29 @@ from servermanager.api.blogapi import BlogApi
from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
# 初始化微信机器人实例配置token和启用session功能
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
or 'lylinux', enable_session=True)
# 创建Memcache存储实例用于session存储
memstorage = MemcacheStorage()
# 根据存储可用性配置机器人的session存储方式
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
# 如果文件存储存在则删除旧文件使用文件存储作为session存储
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
# 初始化博客API和命令处理器实例
blogapi = BlogApi()
cmd_handler = CommandHandler()
logger = logging.getLogger(__name__)
def convert_to_article_reply(articles, message):
"""
将文章列表转换为微信文章回复格式
Args:
articles: 文章对象列表
message: 微信消息对象
Returns:
ArticlesReply: 微信文章回复对象
"""
reply = ArticlesReply(message=message)
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
# 提取文章中的图片URL
imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body)
imgurl = ''
if imgs:
imgurl = imgs[0]
# 创建单篇文章对象
article = Article(
title=post.title,
description=truncatechars_content(post.body),
@ -65,16 +48,6 @@ def convert_to_article_reply(articles, message):
@robot.filter(re.compile(r"^\?.*"))
def search(message, session):
"""
处理文章搜索请求根据关键词搜索文章并返回结果
Args:
message: 微信消息对象包含搜索关键词
session: 用户会话对象
Returns:
ArticlesReply或str: 搜索结果或提示信息
"""
s = message.content
searchstr = str(s).replace('?', '')
result = blogapi.search_articles(searchstr)
@ -88,16 +61,6 @@ def search(message, session):
@robot.filter(re.compile(r'^category\s*$', re.I))
def category(message, session):
"""
获取所有文章分类目录信息
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
str: 包含所有分类名称的字符串
"""
categorys = blogapi.get_category_lists()
content = ','.join(map(lambda x: x.name, categorys))
return '所有文章分类目录:' + content
@ -105,16 +68,6 @@ def category(message, session):
@robot.filter(re.compile(r'^recent\s*$', re.I))
def recents(message, session):
"""
获取最新发布的文章列表
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
ArticlesReply或str: 最新文章列表或提示信息
"""
articles = blogapi.get_recent_articles()
if articles:
reply = convert_to_article_reply(articles, message)
@ -125,16 +78,6 @@ def recents(message, session):
@robot.filter(re.compile('^help$', re.I))
def help(message, session):
"""
返回系统帮助信息包含所有可用命令说明
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
str: 帮助信息文本
"""
return '''欢迎关注!
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
@ -157,61 +100,22 @@ def help(message, session):
@robot.filter(re.compile(r'^weather\:.*$', re.I))
def weather(message, session):
"""
处理天气查询请求待实现
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
str: 建设中提示信息
"""
return "建设中..."
@robot.filter(re.compile(r'^idcard\:.*$', re.I))
def idcard(message, session):
"""
处理身份证信息查询请求待实现
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
str: 建设中提示信息
"""
return "建设中..."
@robot.handler
def echo(message, session):
"""
主消息处理函数创建消息处理器并处理用户消息
Args:
message: 微信消息对象
session: 用户会话对象
Returns:
str或其他类型: 处理结果
"""
handler = MessageHandler(message, session)
return handler.handler()
class MessageHandler:
"""微信消息处理器类,负责处理各种用户消息和命令"""
def __init__(self, message, session):
"""
初始化消息处理器
Args:
message: 微信消息对象
session: 用户会话对象
"""
userid = message.source
self.message = message
self.session = session
@ -225,51 +129,27 @@ class MessageHandler:
@property
def is_admin(self):
"""
判断当前用户是否为管理员
Returns:
bool: 是否为管理员
"""
return self.userinfo.isAdmin
@property
def is_password_set(self):
"""
判断管理员密码是否已设置
Returns:
bool: 密码是否已设置
"""
return self.userinfo.isPasswordSet
def save_session(self):
"""
保存用户会话信息到session中
"""
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
def handler(self):
"""
主要的消息处理逻辑根据用户状态和输入内容进行相应处理
Returns:
str: 处理结果响应文本
"""
info = self.message.content
# 处理管理员退出命令
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
self.save_session()
return "退出成功"
# 处理管理员登录命令
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
self.save_session()
return "输入管理员密码"
# 处理管理员密码验证
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
if settings.TESTING:
@ -279,7 +159,6 @@ class MessageHandler:
self.save_session()
return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助"
else:
# 处理密码错误次数限制
if self.userinfo.Count >= 3:
self.userinfo = WxUserInfo()
self.save_session()
@ -287,7 +166,6 @@ class MessageHandler:
self.userinfo.Count += 1
self.save_session()
return "验证失败,请重新输入管理员密码:"
# 处理管理员命令执行
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
if self.userinfo.Command != '' and info.upper() == 'Y':
return cmd_handler.run(self.userinfo.Command)
@ -298,19 +176,12 @@ class MessageHandler:
self.save_session()
return "确认执行: " + info + " 命令?"
# 默认使用ChatGPT处理普通消息
return ChatGPT.chat(info)
class WxUserInfo():
"""微信用户信息类,存储用户的状态信息"""
def __init__(self):
"""
初始化用户信息默认为非管理员状态
"""
self.isAdmin = False
self.isPasswordSet = False
self.Count = 0
self.Command = ''

@ -12,32 +12,15 @@ from .robot import search, category, recents
# Create your tests here.
class ServerManagerTest(TestCase):
"""
服务器管理模块的测试类用于测试聊天机器人命令处理文章搜索等功能
"""
def setUp(self):
"""
测试初始化方法在每个测试方法执行前运行
创建用于模拟HTTP请求的Client和RequestFactory实例
"""
self.client = Client()
self.factory = RequestFactory()
def test_chat_gpt(self):
"""
测试ChatGPT聊天功能
验证调用ChatGPT.chat方法能否返回非空内容
"""
content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
def test_validate_comment(self):
"""
测试评论验证及相关功能包括用户登录文章创建命令处理和消息处理等
验证搜索分类最近文章命令执行和消息处理等功能是否正常运行
"""
# 创建超级用户并登录
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
@ -45,12 +28,10 @@ class ServerManagerTest(TestCase):
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建分类
c = Category()
c.name = "categoryccc"
c.save()
# 创建文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
@ -59,33 +40,23 @@ class ServerManagerTest(TestCase):
article.type = 'a'
article.status = 'p'
article.save()
# 测试搜索功能
s = TextMessage([])
s.content = "nice"
rsp = search(s, None)
# 测试分类功能
rsp = category(None, None)
self.assertIsNotNone(rsp)
# 测试最近文章功能
rsp = recents(None, None)
self.assertTrue(rsp != '暂时还没有文章')
# 创建并保存命令
cmd = commands()
cmd.title = "test"
cmd.command = "ls"
cmd.describe = "test"
cmd.save()
# 测试命令处理器
cmdhandler = CommandHandler()
rsp = cmdhandler.run('test')
self.assertIsNotNone(rsp)
# 测试消息处理器的各种场景
s.source = 'u'
s.content = 'test'
msghandler = MessageHandler(s, {})
@ -106,5 +77,3 @@ class ServerManagerTest(TestCase):
s.content = 'exit'
msghandler.handler()

@ -5,7 +5,6 @@ from .robot import robot
app_name = "servermanager"
urlpatterns = [
# 将微信机器人接口映射到/robot路径
path(r'robot', make_view(robot)),
]

@ -1,34 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<!-- 加载static静态文件标签用于后面引用静态资源 -->
{% load static %}
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
<!-- 以上3个meta标签必须放在head的最前面 -->
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="../../favicon.ico">
<!-- 禁止搜索引擎索引此页面 -->
<meta name="robots" content="noindex">
<!-- 动态设置页面标题使用Django模板变量 -->
<title>{{ SITE_NAME }} | {{ SITE_DESCRIPTION }}</title>
<!-- 加载账户相关的CSS文件 -->
<link href="{% static 'account/css/account.css' %}" rel="stylesheet">
<!-- 使用Django压缩工具压缩CSS文件 -->
{% load compress %}
{% compress css %}
<!-- Bootstrap core CSS -->
<!-- Bootstrap核心CSS文件 -->
<link href="{% static 'assets/css/bootstrap.min.css' %}" rel="stylesheet">
<!-- OAuth认证样式 -->
<link href="{% static 'blog/css/oauth_style.css' %}" rel="stylesheet">
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<!-- IE10视口bug修复 -->
<link href="{% static 'assets/css/ie10-viewport-bug-workaround.css' %}" rel="stylesheet">
<!-- TODC Bootstrap core CSS -->
<!-- TODC Bootstrap样式 -->
<link href="{% static 'assets/css/todc-bootstrap.min.css' %}" rel="stylesheet">
<!-- Custom styles for this template -->
<!-- 登录页面自定义样式 -->
<link href="{% static 'assets/css/signin.css' %}" rel="stylesheet">
{% endcompress %}
<!-- 压缩JavaScript文件 -->
{% compress js %}
<!-- IE10视口bug修复脚本 -->
<script src="{% static 'assets/js/ie10-viewport-bug-workaround.js' %}"></script>
<!-- IE浏览器仿真模式警告 -->
<script src="{% static 'assets/js/ie-emulation-modes-warning.js' %}"></script>
{% endcompress %}
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!-- HTML5 shim和Respond.js用于IE8支持HTML5元素和媒体查询 -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
@ -36,12 +49,15 @@
</head>
<body>
{% block content %}
{% endblock %}
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<!-- 定义内容块,子模板可以在此处插入具体内容 -->
{% block content %}
{% endblock %}
<!-- IE10视口hack用于Surface/桌面Windows 8 bug -->
</body>
<!-- 引入jQuery库 -->
<script type="text/javascript" src="{% static 'blog/js/jquery-3.6.0.min.js' %}"></script>
<!-- 引入账户相关的JavaScript文件 -->
<script src="{% static 'account/js/account.js' %}" type="text/javascript"></script>
</html>

@ -1,10 +0,0 @@
[run]
source = .
include = *.py
omit =
*migrations*
*tests*
*.html
*whoosh_cn_backend*
*settings.py*
*venv*

@ -1,11 +0,0 @@
bin/data/
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
uploads/
settings_production.py
*.md
docs/
logs/
static/

@ -1,6 +0,0 @@
blog/static/* linguist-vendored
*.js linguist-vendored
*.css linguist-vendored
* text=auto
*.sh text eol=lf
*.conf text eol=lf

@ -1,18 +0,0 @@
<!--
如果你不认真勾选下面的内容,我可能会直接关闭你的 Issue。
提问之前,建议先阅读 https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way
-->
**我确定我已经查看了** (标注`[ ]`为`[x]`)
- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
----
**我要申请** (标注`[ ]`为`[x]`)
- [ ] BUG 反馈
- [ ] 添加新的特性或者功能
- [ ] 请求技术支持

@ -1,47 +0,0 @@
name: "CodeQL"
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
schedule:
- cron: '30 1 * * 0'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

@ -1,136 +0,0 @@
name: Django CI
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
jobs:
build-normal:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
run: |
python manage.py makemigrations
python manage.py migrate
python manage.py test
build-with-es:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- name: Configure sysctl limits
run: |
sudo swapoff -a
sudo sysctl -w vm.swappiness=1
sudo sysctl -w fs.file-max=262144
sudo sysctl -w vm.max_map_count=262144
- uses: miyataka/elasticsearch-github-actions@1
with:
stack-version: '7.12.1'
plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip'
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: 127.0.0.1:9200
run: |
python manage.py makemigrations
python manage.py migrate
coverage run manage.py test
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: false
tags: djangoblog/djangoblog:dev

@ -1,43 +0,0 @@
name: docker
on:
push:
paths-ignore:
- '**/*.md'
- '**/*.yml'
branches:
- 'master'
- 'dev'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set env to docker dev tag
if: endsWith(github.ref, '/dev')
run: |
echo "DOCKER_TAG=test" >> $GITHUB_ENV
- name: Set env to docker latest tag
if: endsWith(github.ref, '/master')
run: |
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}

@ -1,39 +0,0 @@
name: publish release
on:
release:
types: [ published ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: name/app
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: |
linux/amd64
linux/arm64
linux/arm/v7
linux/arm/v6
linux/386
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}

@ -1,82 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.pot
# Django stuff:
*.log
logs/
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# PyCharm
# http://www.jetbrains.com/pycharm/webhelp/project.html
.idea
.iml
static/
# virtualenv
venv/
.venv/
.ruff_cache/
collectedstatic/
djangoblog/whoosh_index/
google93fd32dbd906620a.html
baidu_verify_FlHL7cUyC9.html
BingSiteAuth.xml
cb9339dbe2ff86a5aa169d28dba5f615.txt
werobot_session.*
django.jpg
uploads/
settings_production.py
werobot_session.db
bin/datas/

@ -1,376 +0,0 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -1,372 +0,0 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
cache.clear()
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))

@ -1,393 +0,0 @@
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from haystack.query import SearchQuerySet
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
return self.request.get['pages']
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
else:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def get_queryset(self):
queryset = super().get_queryset()
query = self.get_query()
# 如果查询只有一个字符,使用更宽松的匹配策略
if query and len(query.strip()) == 1:
# 使用 OR 条件匹配标题或内容中的单个字符
sqs = SearchQuerySet().models(Article)
# 对于单字符搜索,放宽匹配条件
results = sqs.filter_or(title__contains=query).filter_or(body__contains=query)
return results
return queryset
def get_context(self):
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
from PIL import Image
with Image.open(savepath) as image:
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')

@ -1,47 +0,0 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -1,50 +0,0 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
'<a href="{}">{}</a>',
link,
obj.author.nickname if obj.author.nickname else obj.author.email)
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
'<a href="{}">{}</a>',
link,
obj.article.title)
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -1,376 +0,0 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -1,372 +0,0 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
cache.clear()
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))

@ -1,379 +0,0 @@
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
return self.request.get['pages']
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
else:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')

@ -1,379 +0,0 @@
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
return self.request.get['pages']
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
else:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
from PIL import Image
with Image.open(savepath) as image:
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')

@ -1,47 +0,0 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -1,50 +0,0 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
'<a href="{}">{}</a>',
link,
obj.author.nickname if obj.author.nickname else obj.author.email)
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
'<a href="{}">{}</a>',
link,
obj.article.title)
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -1,15 +0,0 @@
FROM python:3.11
ENV PYTHONUNBUFFERED 1
WORKDIR /code/djangoblog/
RUN apt-get update && \
apt-get install default-libmysqlclient-dev gettext -y && \
rm -rf /var/lib/apt/lists/*
ADD requirements.txt requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir gunicorn[gevent] && \
pip cache purge
ADD . .
RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2025 车亮亮
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -1,158 +0,0 @@
# DjangoBlog
<p align="center">
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg" alt="Django CI"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL"></a>
<a href="https://codecov.io/gh/liangliangyy/DjangoBlog"><img src="https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg" alt="codecov"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/blob/master/LICENSE"><img src="https://img.shields.io/github/license/liangliangyy/djangoblog.svg" alt="license"></a>
</p>
<p align="center">
<b>一款功能强大、设计优雅的现代化博客系统</b>
<br>
<a href="/docs/README-en.md">English</a><b>简体中文</b>
</p>
---
DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能还通过一个灵活的插件系统让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
## ✨ 特性亮点
- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能代码解耦易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
## 🛠️ 技术栈
- **后端**: Python 3.10, Django 4.0
- **数据库**: MySQL, SQLite (可配置)
- **缓存**: Redis
- **前端**: HTML5, CSS3, JavaScript
- **搜索**: Whoosh, Elasticsearch (可配置)
- **编辑器**: Markdown (mdeditor)
## 🚀 快速开始
### 1. 环境准备
确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
### 2. 克隆与安装
```bash
# 克隆项目到本地
git clone https://github.com/liangliangyy/DjangoBlog.git
cd DjangoBlog
# 安装依赖
pip install -r requirements.txt
```
### 3. 项目配置
- **数据库**:
打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
```
在 MySQL 中创建数据库:
```sql
CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
- **更多配置**:
关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [s](/docs/config.md)。
### 4. 初始化数据库
```bash
python manage.py makemigrations
python manage.py migrate
# 创建一个超级管理员账户
python manage.py createsuperuser
```
### 5. 运行项目
```bash
# (可选) 生成一些测试数据
python manage.py create_testdata
# (可选) 收集和压缩静态文件
python manage.py collectstatic --noinput
python manage.py compress --force
# 启动开发服务器
python manage.py runserver
```
现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
## 部署
- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
## 🧩 插件系统
插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
- **现有插件**: `view_count`(浏览计数), `seo_optimizer`SEO优化等都是通过插件系统实现的。
- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
## 🤝 贡献指南
我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug请随时提交 Issue 或 Pull Request。
## 📄 许可证
本项目基于 [MIT License](LICENSE) 开源。
---
## ❤️ 支持与赞助
如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
<p align="center">
<img src="/docs/imgs/alipay.jpg" width="150" alt="支付宝赞助">
<img src="/docs/imgs/wechat.jpg" width="150" alt="微信赞助">
</p>
<p align="center">
<i>(左) 支付宝 / (右) 微信</i>
</p>
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">
<img src="/docs/imgs/pycharm_logo.png" width="150" alt="JetBrains Logo">
</a>
</p>
---
> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。

@ -1,60 +0,0 @@
from django import forms
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
fields = ('email',)
def clean_password2(self):
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
def save(self, commit=True):
# Save the provided password in hashed format
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.source = 'adminsite'
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
class Meta:
model = BlogUser
fields = '__all__'
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
list_display_links = ('id', 'username')
ordering = ('-id',)
# 新增显式配置字段集避免使用默认的usable_password字段

@ -1,5 +0,0 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'

@ -1,139 +0,0 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
class LoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
model = get_user_model()
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
}
),
)
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
}
),
)
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2
def clean_email(self):
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
email = forms.EmailField(
label=_('Email'),
)
class AvatarUploadForm(forms.ModelForm):
max_upload_size = 2 * 1024 * 1024 # 2MB
class Meta:
model = BlogUser
fields = ('avatar',)
widgets = {
'avatar': widgets.ClearableFileInput(
attrs={
'class': 'form-control-file',
'accept': 'image/*',
}
)
}
def clean_avatar(self):
avatar = self.cleaned_data.get('avatar')
if avatar and avatar.size > self.max_upload_size:
raise ValidationError(_("Avatar size must be smaller than 2MB."))
return avatar

@ -1,49 +0,0 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='BlogUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -1,46 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_alter_bloguser_options_remove_bloguser_created_time_and_more'),
]
operations = [
migrations.AddField(
model_name='bloguser',
name='avatar',
field=models.ImageField(blank=True, null=True, upload_to='avatars/%Y/%m/%d', verbose_name='avatar'),
),
]

@ -1,47 +0,0 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.templatetags.static import static
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
source = models.CharField(_('create source'), max_length=100, blank=True)
avatar = models.ImageField(
_('avatar'),
upload_to='avatars/%Y/%m/%d',
blank=True,
null=True,
)
def get_absolute_url(self):
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
return self.email
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
def get_avatar_url(self):
if self.avatar:
return self.avatar.url
return static('blog/img/avatar.png')
class Meta:
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'

@ -1,230 +0,0 @@
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.conf import settings # 修复问题8: 添加缺失的导入
from accounts.models import BlogUser
from blog.models import Article, Category
from djangoblog.utils import get_current_site, get_sha256, delete_sidebar_cache, generate_code # 修复问题10: 具体导入所需函数
from . import utils
# Create your tests here.
class AccountTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
def test_validate_account(self):
# 修复问题6: 移除未使用的局部变量 "site"
# site = get_current_site().domain # 这行被删除
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
# 修复问题1: 移除未使用的局部变量 "testuser"
# testuser = BlogUser.objects.get(username='liangliangyy1') # 这行被删除
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True)
# 修复问题2: 移除未使用的response赋值
# 直接使用返回值进行断言,不赋值给变量
self.assertEqual(self.client.get('/admin/').status_code, 200)
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
# 修复问题2: 移除未使用的response赋值
# 直接使用返回值进行断言,不赋值给变量
self.assertEqual(self.client.get(article.get_admin_url()).status_code, 200)
def test_validate_register(self):
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 修复问题1-5: 使用字典字面量替代构造函数调用
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
# 这里保留了response变量因为它被用于断言
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
user.is_staff = True
user.save()
delete_sidebar_cache()
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
# 这里保留了response变量因为它被用于断言
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 这里保留了response变量因为它被用于断言
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
# 这里保留了response变量因为它被用于断言
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 这里保留了response变量因为它被用于断言
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
self.assertIn(response.status_code, [301, 302, 200])
# 这里保留了response变量因为它被用于断言
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
# 修复问题9: 使用字典字面量替代构造函数调用
resp = self.client.post(
path=reverse("account:forget_password_code"),
data={"email": "admin@admin.com"} # 使用字典字面量
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
data={} # 使用字典字面量
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
resp = self.client.post(
path=reverse("account:forget_password_code"),
data={"email": "admin@com"} # 使用字典字面量
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
# 修复问题2-5: 使用字典字面量替代构造函数调用
data = {
"new_password1": self.new_test,
"new_password2": self.new_test,
"email": self.blog_user.email,
"code": code,
}
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
# 修复问题1: 使用字典字面量替代构造函数调用
data = {
"new_password1": self.new_test,
"new_password2": self.new_test,
"email": "123@123.com",
"code": "123456",
}
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
# 修复问题3: 使用字典字面量替代构造函数调用
data = {
"new_password1": self.new_test,
"new_password2": self.new_test,
"email": self.blog_user.email,
"code": "111111",
}
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)

@ -1,29 +0,0 @@
from django.urls import path
from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
path('profile/avatar/', views.AvatarUpdateView.as_view(), name='avatar'),
]

@ -1,26 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
def authenticate(self, request, username=None, password=None, **kwargs):
if '@' in username:
kwargs = {'email': username}
else:
kwargs = {'username': username}
try:
user = get_user_model().objects.get(**kwargs)
if user.check_password(password):
return user
except get_user_model().DoesNotExist:
return None
def get_user(self, username):
try:
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
return None

@ -1,49 +0,0 @@
import typing
from datetime import timedelta
from django.core.cache import cache
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email
_code_ttl = timedelta(minutes=5)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效
Args:
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
cache_code = get_code(email)
if cache_code != code:
return gettext("Verification code error")
def set_code(email: str, code: str):
"""设置code"""
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
return cache.get(email)

@ -1,221 +0,0 @@
import logging
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib import auth, messages
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import make_password
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm, AvatarUploadForm
from .models import BlogUser
logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView):
form_class = RegisterForm
template_name = 'account/registration_form.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
if form.is_valid():
user = form.save(False)
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
content = """
<p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
再次感谢您
<br />
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
send_email(
emailto=[
user.email,
],
title='验证您的电子邮箱',
content=content)
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
url = '/login/'
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
# 修复问题4: 引入新变量避免重新赋值'form'
auth_form = AuthenticationForm(data=self.request.POST, request=self.request)
if auth_form.is_valid():
delete_sidebar_cache()
logger.info(self.redirect_field_name)
auth.login(self.request, auth_form.get_user())
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(auth_form)
else:
return self.render_to_response({
'form': auth_form
})
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
return redirect_to
def account_result(request):
# 修复问题1和2: 重命名变量避免与内置函数冲突
request_type = request.GET.get('type')
user_id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=user_id)
logger.info(request_type)
if user.is_active:
return HttpResponseRedirect('/')
if request_type and request_type in ['register', 'validation']:
if request_type == 'register':
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
if sign != c_sign:
return HttpResponseForbidden()
user.is_active = True
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
def form_valid(self, form):
if form.is_valid():
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save()
return HttpResponseRedirect('/login/')
else:
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("ok")
class AvatarUpdateView(LoginRequiredMixin, FormView):
form_class = AvatarUploadForm
template_name = 'account/avatar_form.html'
success_url = reverse_lazy('account:avatar')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = self.request.user
return kwargs
def form_valid(self, form):
form.save()
delete_sidebar_cache()
messages.success(self.request, _('Avatar updated successfully.'))
return super().form_valid(form)

@ -1,112 +0,0 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import Article
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
'id',
'title',
'author',
'link_to_category',
'creation_time',
'views',
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
filter_horizontal = ('tags',)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('category')
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
if obj:
url = obj.get_full_url()
return url
else:
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
pass

@ -1,5 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

@ -1,43 +0,0 @@
import logging
from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
logger = logging.getLogger(__name__)
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
if value:
return value
else:
logger.info('set processor cache.')
setting = get_blog_setting()
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'nav_pages': Article.objects.filter(
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
}
cache.set(key, value, 60 * 60 * 10)
return value

@ -1,213 +0,0 @@
import time
import elasticsearch.client
from django.conf import settings
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
from elasticsearch_dsl.connections import connections
from blog.models import Article
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
class UserAgentOS(UserAgentBrowser):
pass
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
useragent = Object(UserAgent, required=False)
class Index:
name = 'performance'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'ElapsedTime'
class ElaspedTimeDocumentManager:
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
if not res:
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
ElaspedTimeDocumentManager.build_index()
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
doc.save(pipeline="geoip")
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date()
status = Text()
comment_status = Text()
type = Text()
views = Integer()
article_order = Integer()
class Index:
name = 'blog'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'Article'
class ArticleDocumentManager():
def __init__(self):
self.create_index()
def create_index(self):
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
return [
ArticleDocument(
meta={
'id': article.id},
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
'id': article.category.id},
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
for doc in docs:
doc.save()
def update_docs(self, docs):
for doc in docs:
doc.save()

@ -1,19 +0,0 @@
import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True)
def search(self):
datas = super(BlogSearchForm, self).search()
if not self.is_valid():
return self.no_query_found()
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas

@ -1,18 +0,0 @@
from django.core.management.base import BaseCommand
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
# TODO 参数化
class Command(BaseCommand):
help = 'build search index'
def handle(self, *args, **options):
if ELASTICSEARCH_ENABLED:
ElaspedTimeDocumentManager.build_index()
manager = ElapsedTimeDocument()
manager.init()
manager = ArticleDocumentManager()
manager.delete_index()
manager.rebuild()

@ -1,13 +0,0 @@
from django.core.management.base import BaseCommand
from blog.models import Tag, Category
# TODO 参数化
class Command(BaseCommand):
help = 'build search words'
def handle(self, *args, **options):
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
print('\n'.join(datas))

@ -1,11 +0,0 @@
from django.core.management.base import BaseCommand
from djangoblog.utils import cache
class Command(BaseCommand):
help = 'clear the whole cache'
def handle(self, *args, **options):
cache.clear()
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))

@ -1,40 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand
from blog.models import Article, Tag, Category
class Command(BaseCommand):
help = 'create test datas'
def handle(self, *args, **options):
user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
category.save()
basetag = Tag()
basetag.name = "标签"
basetag.save()
for i in range(1, 20):
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
article.tags.add(tag)
article.tags.add(basetag)
article.save()
from djangoblog.utils import cache
cache.clear()
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -1,50 +0,0 @@
from django.core.management.base import BaseCommand
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site
from blog.models import Article, Tag, Category
site = get_current_site().domain
class Command(BaseCommand):
help = 'notify baidu url'
def add_arguments(self, parser):
parser.add_argument(
'data_type',
type=str,
choices=[
'all',
'article',
'tag',
'category'],
help='article : all article,tag : all tag,category: all category,all: All of these')
def get_full_url(self, path):
url = "https://{site}{path}".format(site=site, path=path)
return url
def handle(self, *args, **options):
type = options['data_type']
self.stdout.write('start get %s' % type)
urls = []
if type == 'article' or type == 'all':
for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url())
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
if type == 'category' or type == 'all':
for category in Category.objects.all():
url = category.get_absolute_url()
urls.append(self.get_full_url(url))
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -1,47 +0,0 @@
import requests
from django.core.management.base import BaseCommand
from django.templatetags.static import static
from djangoblog.utils import save_user_avatar
from oauth.models import OAuthUser
from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
help = 'sync user avatar'
def test_picture(self, url):
try:
if requests.get(url, timeout=2).status_code == 200:
return True
except:
pass
def handle(self, *args, **options):
static_url = static("../")
users = OAuthUser.objects.all()
self.stdout.write(f'开始同步{len(users)}个用户头像')
for u in users:
self.stdout.write(f'开始同步:{u.nickname}')
url = u.picture
if url:
if url.startswith(static_url):
if self.test_picture(url):
continue
else:
if u.metadata:
manage = get_manager_by_type(u.type)
url = manage.get_picture(u.metadata)
url = save_user_avatar(url)
else:
url = static('blog/img/avatar.png')
else:
url = save_user_avatar(url)
else:
url = static('blog/img/avatar.png')
if url:
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
u.picture = url
u.save()
self.stdout.write('结束同步')

@ -1,42 +0,0 @@
import logging
import time
from ipware import get_client_ip
from user_agents import parse
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__)
class OnlineMiddleware(object):
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def __call__(self, request):
''' page render time '''
start_time = time.time()
response = self.get_response(request)
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent)
if not response.streaming:
try:
cast_time = time.time() - start_time
if ELASTICSEARCH_ENABLED:
time_taken = round((cast_time) * 1000, 2)
url = request.path
from django.utils import timezone
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
logger.error("Error OnlineMiddleware: %s" % e)
return response

@ -1,137 +0,0 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BlogSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
'verbose_name': '网站配置',
'verbose_name_plural': '网站配置',
},
),
migrations.CreateModel(
name='Links',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
('link', models.URLField(verbose_name='链接地址')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': '友情链接',
'verbose_name_plural': '友情链接',
'ordering': ['sequence'],
},
),
migrations.CreateModel(
name='SideBar',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='标题')),
('content', models.TextField(verbose_name='内容')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': '侧边栏',
'verbose_name_plural': '侧边栏',
'ordering': ['sequence'],
},
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
'verbose_name': '标签',
'verbose_name_plural': '标签',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
'verbose_name': '分类',
'verbose_name_plural': '分类',
'ordering': ['-index'],
},
),
migrations.CreateModel(
name='Article',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
options={
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'],
'get_latest_by': 'id',
},
),
]

@ -1,23 +0,0 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]

@ -1,17 +0,0 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]

@ -1,27 +0,0 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
]
operations = [
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
),
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
),
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
),
]

@ -1,364 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
# 定义常量以避免重复字符串字面量
CREATION_TIME_VERBOSE = 'creation time'
MODIFY_TIME_VERBOSE = 'modify time'
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='article',
options={
'get_latest_by': 'id',
'ordering': ['-article_order', '-pub_time'],
'verbose_name': 'article',
'verbose_name_plural': 'article'
},
),
migrations.AlterModelOptions(
name='category',
options={
'ordering': ['-index'],
'verbose_name': 'category',
'verbose_name_plural': 'category'
},
),
migrations.AlterModelOptions(
name='links',
options={
'ordering': ['sequence'],
'verbose_name': 'link',
'verbose_name_plural': 'link'
},
),
migrations.AlterModelOptions(
name='sidebar',
options={
'ordering': ['sequence'],
'verbose_name': 'sidebar',
'verbose_name_plural': 'sidebar'
},
),
migrations.AlterModelOptions(
name='tag',
options={
'ordering': ['name'],
'verbose_name': 'tag',
'verbose_name_plural': 'tag'
},
),
migrations.RemoveField(
model_name='article',
name='created_time',
),
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
migrations.RemoveField(
model_name='category',
name='created_time',
),
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
migrations.RemoveField(
model_name='links',
name='created_time',
),
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=CREATION_TIME_VERBOSE),
),
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=MODIFY_TIME_VERBOSE),
),
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=CREATION_TIME_VERBOSE),
),
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=MODIFY_TIME_VERBOSE),
),
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=CREATION_TIME_VERBOSE),
),
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=CREATION_TIME_VERBOSE),
),
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=CREATION_TIME_VERBOSE),
),
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=MODIFY_TIME_VERBOSE),
),
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name='author'
),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='blog.category',
verbose_name='category'
),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(
choices=[('o', 'Open'), ('c', 'Close')],
default='o',
max_length=1,
verbose_name='comment status'
),
),
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(
choices=[('d', 'Draft'), ('p', 'Published')],
default='p',
max_length=1,
verbose_name='status'
),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(
choices=[('a', 'Article'), ('p', 'Page')],
default='a',
max_length=1,
verbose_name='type'
),
),
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(
blank=True,
default='',
max_length=2000,
null=True,
verbose_name='adsense code'
),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to='blog.category',
verbose_name='parent category'
),
),
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=MODIFY_TIME_VERBOSE),
),
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(
choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')],
default='i',
max_length=1,
verbose_name='show type'
),
),
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name=MODIFY_TIME_VERBOSE),
),
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]

@ -1,17 +0,0 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]

@ -1,372 +0,0 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
cache.clear()
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save