Compare commits

..

36 Commits

Author SHA1 Message Date
云涵 71f5edccbe yh:自评报告
3 months ago
云涵 d67012f567 yh:开源软件泛读、标注和维护报告文档和系统讲解视频
3 months ago
云涵 e98389cd96 yh:系统维护和新增功能
3 months ago
云涵 1744a1fa7c yh:解决登出报错问题
3 months ago
云涵 123d4dec8c yh:本地错误解决
3 months ago
云涵 8fdc28e910 yh:本地错误解决
3 months ago
云涵 64280d764f yh:修改注释后发生的错误
3 months ago
云涵 e080c41f83 yh:更正组号错误
4 months ago
云涵 8ebd9be17c Merge branch 'master' of https://bdgit.educoder.net/p5keycgjv/DjangoBlog
4 months ago
云涵 3aa4a029c8 yh:上传软件需求规格说明书
4 months ago
yxt f30666db7d yxt:对accounts的精读和注释
4 months ago
yx 07cc6c4952 yx: 对oauth文件的精读和注释
4 months ago
yx 5336e15893 yx: 对oauth文件的精读和注释
4 months ago
云涵 d4786ee23b yxt:对accounts的精读和注释
4 months ago
云涵 f45bfa5ded yx:对oauth的精读和注释
4 months ago
云涵 301bb7b687 yh:上传开源软件的质量分析报告文档
4 months ago
云涵 8de51a9cb1 yh:删除文件
4 months ago
云涵 8b18fdcac7 Merge branch 'master' of https://bdgit.educoder.net/p5keycgjv/DjangoBlog
4 months ago
云涵 23531bb3cb yh:上传开源软件的质量分析报告文档
4 months ago
周俊杰 1efbacc3a7 Merge branch 'master' of https://bdgit.educoder.net/p5keycgjv/DjangoBlog
4 months ago
周俊杰 aee00e6db1 zjj:对blog代码精读和注释
4 months ago
mly d7b9ec0cbd Merge branch 'master' of https://bdgit.educoder.net/p5keycgjv/DjangoBlog
4 months ago
周俊杰 6fbd0d3307 zjj:对djangoblog代码精读和注释
4 months ago
周俊杰 3b69ce6a1a Merge branch 'master' of https://bdgit.educoder.net/p5keycgjv/DjangoBlog
4 months ago
周俊杰 4395c3dd3a 周俊杰提交
4 months ago
mly cfd0d4c62c mly:对comments的精读和注释
4 months ago
云涵 6222594a95 yh:上传编码规范文档
4 months ago
云涵 f1d2d417e4 yh:删除垃圾文件
4 months ago
云涵 af2ce65fab yh:对djangoblog代码精读和注释
4 months ago
云涵 4278feb664 云涵:根据模板重新编写软件数据模型设计说明书
4 months ago
云涵 774f90685b docs: 删除未按模板编写的泛读报告
4 months ago
云涵 22ffbd67e7 docs: 删除未按模板编写的数据模型设计和界面设计文档
4 months ago
云涵 2d51add242 云涵:根据模板重新编写软件数据模型设计说明书
4 months ago
云涵 fe784a13ca 云涵:根据模板重新编写界面设计文档
4 months ago
云涵 2a01549d5e docs: 添加Django博客系统泛读报告
4 months ago
云涵 747d2c408b src:对DjangoBlog的数据分析
4 months ago

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,94 +1,152 @@
# 导入Django表单相关模块
"""
Django管理后台用户模型配置模块
本模块配置BlogUser模型在Django管理后台的显示和编辑行为
包括自定义表单验证列表显示字段搜索过滤等功能
主要组件
- BlogUserCreationForm: 用户创建表单处理密码验证和设置
- BlogUserChangeForm: 用户信息修改表单
- BlogUserAdmin: 用户模型管理后台配置类
"""
from django import forms
# 导入Django管理员相关模块
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):
# 密码字段1使用密码输入控件
"""
博客用户创建表单
扩展自ModelForm专门用于在Django管理后台创建新用户
提供密码确认验证和密码哈希处理功能
"""
# 密码输入字段1 - 使用PasswordInput控件隐藏输入
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 密码字段2用于确认密码使用密码输入控件
# 密码输入字段2 - 用于密码确认
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
"""表单元数据配置"""
# 指定关联的模型
model = BlogUser
# 指定表单中包含的字段
# 指定表单中包含的字段 - 仅包含email字段
fields = ('email',)
# 密码确认验证方法
def clean_password2(self):
# Check that the two password entries match
# 获取两个密码字段的值
"""
密码确认字段验证方法
验证两次输入的密码是否一致确保用户输入正确的密码
Returns:
str: 验证通过的密码
Raises:
forms.ValidationError: 当两次密码输入不一致时抛出验证错误
"""
# 从清洗后的数据中获取两个密码字段的值
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
# 调用父类的save方法但不立即提交到数据库
"""
表单保存方法
重写保存逻辑处理密码哈希化和设置用户来源
Args:
commit (bool): 是否立即保存到数据库默认为True
Returns:
BlogUser: 保存后的用户实例
"""
# 调用父类保存方法,但不立即提交到数据库
user = super().save(commit=False)
# 设置哈希后的密码
# 使用Django的密码哈希方法设置密码
user.set_password(self.cleaned_data["password1"])
# 如果设置为立即提交
# 如果设置为立即提交,则保存用户并设置来源
if commit:
# 设置用户来源为管理员站点
# 标记用户创建来源为管理后台
user.source = 'adminsite'
# 保存用户到数据库
user.save()
# 返回用户对象
return user
# 自定义用户修改表单,用于管理员界面修改用户信息
class BlogUserChangeForm(UserChangeForm):
"""
博客用户信息修改表单
继承自Django的UserChangeForm用于在管理后台编辑现有用户信息
保持与Django原生用户管理表单的兼容性
"""
class Meta:
"""表单元数据配置"""
# 指定关联的模型
model = BlogUser
# 包含所有字段
fields = '__all__'
# 指定字段类型映射
# 指定用户名字段使用Django的UsernameField类型
field_classes = {'username': UsernameField}
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
"""
表单初始化方法
可以在此处添加自定义的表单初始化逻辑
"""
# 调用父类初始化方法
super().__init__(*args, **kwargs)
# 自定义用户管理类用于配置Django管理员界面的用户管理
class BlogUserAdmin(UserAdmin):
# 指定修改用户时使用的表单
"""
博客用户管理后台配置类
继承自Django的UserAdmin自定义BlogUser模型在管理后台的显示和行为
配置列表显示搜索排序等管理界面功能
"""
# 指定用户编辑表单
form = BlogUserChangeForm
# 指定创建用户时使用的表单
# 指定用户创建表单
add_form = BlogUserCreationForm
# 指定列表页面显示的字段
# 配置列表页面显示的字段
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
# 指定列表页面中哪些字段可以作为链接点击进入编辑页面
'id', # 用户ID
'nickname', # 用户昵称
'username', # 用户名
'email', # 邮箱地址
'last_login', # 最后登录时间
'date_joined', # 注册时间
'source' # 用户来源
)
# 配置列表中可点击跳转到编辑页面的字段
list_display_links = ('id', 'username')
# 指定默认排序字段按ID倒序排列
# 配置默认排序规则 - 按ID倒序排列最新的在前
ordering = ('-id',)
# 指定搜索字段,支持按用户名、昵称、邮箱搜索
# 配置搜索框可搜索的字段
search_fields = ('username', 'nickname', 'email')

@ -1,8 +1,37 @@
# 导入Django应用配置基类
"""
Django应用配置模块
本模块定义accounts应用的配置类用于配置应用级别的设置和元数据
"""
from django.apps import AppConfig
# 定义账户应用的配置类
class AccountsConfig(AppConfig):
# 指定应用的完整Python路径
name = 'accounts'
"""
用户账户应用配置类
继承自Django的AppConfig类用于配置accounts应用的各项设置
包括应用名称显示名称初始化逻辑等
属性:
name (str): 应用的Python路径标识符Django使用此名称来识别应用
"""
# 应用名称 - 使用Python路径格式Django通过此名称识别应用
# 此名称必须与应用的目录名和INSTALLED_APPS中的配置一致
name = 'accounts'
# 可选应用的可读名称用于Django管理后台显示
# verbose_name = '用户账户'
# 可选:应用初始化方法
# def ready(self):
# """
# 应用初始化完成时调用的方法
#
# 当Django启动完成所有应用加载完毕后会自动调用此方法。
# 常用于信号注册、配置检查等初始化操作。
# """
# # 导入并注册信号处理器
# from . import signals

@ -1,163 +1,274 @@
# 导入Django表单相关模块
"""
用户认证表单模块
本模块定义用户相关的Django表单包括
- 用户登录表单
- 用户注册表单
- 密码重置表单
- 验证码表单
所有表单都包含Bootstrap样式类提供一致的用户界面体验
"""
from django import forms
# 导入Django认证系统相关模块
from django.contrib.auth import get_user_model, password_validation
# 导入Django内置的认证表单
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
# 登录表单类继承自Django内置的AuthenticationForm
class LoginForm(AuthenticationForm):
"""
用户登录表单
继承自Django的AuthenticationForm添加Bootstrap样式支持
用于用户通过用户名和密码登录系统
"""
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
"""
初始化表单设置字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(LoginForm, self).__init__(*args, **kwargs)
# 自定义用户名输入框的HTML属性
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义密码输入框的HTML属性
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 注册表单类继承自Django内置的UserCreationForm
class RegisterForm(UserCreationForm):
"""
用户注册表单
继承自Django的UserCreationForm扩展邮箱字段和样式支持
用于新用户注册账号包含用户名邮箱和密码确认功能
"""
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
"""
初始化表单设置所有字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(RegisterForm, self).__init__(*args, **kwargs)
# 自定义各个表单字段的HTML属性
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置邮箱字段的输入控件和样式
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={
'placeholder': "email", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码确认字段的输入控件和样式
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
attrs={
'placeholder': "repeat password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 邮箱字段验证方法
def clean_email(self):
# 获取邮箱数据
"""
邮箱字段验证方法
验证邮箱是否已被注册确保邮箱地址的唯一性
Returns:
str: 验证通过的邮箱地址
Raises:
ValidationError: 当邮箱已被注册时抛出验证错误
"""
# 获取清洗后的邮箱数据
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")
# 忘记密码表单类继承自forms.Form
class ForgetPasswordForm(forms.Form):
"""
忘记密码重置表单
用于用户通过邮箱和验证码重置密码包含密码强度验证和验证码校验
"""
# 新密码字段1
new_password1 = forms.CharField(
label=_("New password"),
label=_("New password"), # 字段标签
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
"class": "form-control", # Bootstrap样式类
'placeholder': _("New password") # 占位符文本
}
),
)
# 新密码字段2,用于确认密码
# 新密码字段2 - 用于密码确认
new_password2 = forms.CharField(
label="确认密码",
label="确认密码", # 中文标签
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
"class": "form-control", # Bootstrap样式类
'placeholder': _("Confirm password") # 占位符文本
}
),
)
# 邮箱字段
# 邮箱字段 - 用于标识用户和发送验证码
email = forms.EmailField(
label='邮箱',
label='邮箱', # 中文标签
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Email") # 占位符文本
}
),
)
# 验证码字段
# 验证码字段 - 用于验证用户身份
code = forms.CharField(
label=_('Code'),
label=_('Code'), # 字段标签
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Code") # 占位符文本
}
),
)
# 确认密码验证方法
def clean_new_password2(self):
# 获取两个密码字段的值
"""
密码确认字段验证方法
验证两次输入的密码是否一致并检查密码强度
Returns:
str: 验证通过的密码
Raises:
ValidationError: 当密码不匹配或强度不足时抛出验证错误
"""
# 从请求数据中获取两个密码字段的值
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内置的密码验证器验证密码强度
# 使用Django内置密码验证器验证密码强度
password_validation.validate_password(password2)
# 返回验证通过的密码
return password2
# 邮箱验证方法
def clean_email(self):
# 获取邮箱数据
"""
邮箱字段验证方法
验证邮箱是否在系统中注册过
Returns:
str: 验证通过的邮箱地址
Raises:
ValidationError: 当邮箱未注册时抛出验证错误
"""
# 获取清洗后的邮箱数据
user_email = self.cleaned_data.get("email")
# 检查邮箱是否存在于用户表中
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
# 如果邮箱不存在则抛出验证错误
# 检查邮箱是否存在于用户数据库中
if not BlogUser.objects.filter(email=user_email).exists():
# TODO: 这里会暴露邮箱是否注册的信息,根据安全需求可修改提示
raise ValidationError(_("email does not exist"))
# 返回验证通过的邮箱
return user_email
# 验证码验证方法
def clean_code(self):
# 获取验证码数据
"""
验证码字段验证方法
验证邮箱和验证码的匹配关系
Returns:
str: 验证通过的验证码
Raises:
ValidationError: 当验证码无效或过期时抛出验证错误
"""
# 获取清洗后的验证码数据
code = self.cleaned_data.get("code")
# 使用工具函数验证验证码的有效性
# 调用utils模块的verify函数验证验证码
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
email=self.cleaned_data.get("email"), # 邮箱地址
code=code, # 验证码
)
# 如果验证返回错误信息
# 如果验证返回错误信息,抛出验证错误
if error:
# 抛出验证错误
raise ValidationError(error)
# 返回验证通过的验证码
return code
# 忘记密码验证码表单类,用于请求发送验证码
class ForgetPasswordCodeForm(forms.Form):
# 邮箱字段
"""
忘记密码验证码请求表单
用于用户请求发送密码重置验证码仅包含邮箱字段
"""
# 邮箱字段 - 用于发送验证码
email = forms.EmailField(
label=_('Email'),
label=_('Email'), # 字段标签
# 可以添加widget配置来设置样式
)

@ -1,6 +1,18 @@
"""
用户账户应用数据库迁移文件
本迁移文件由Django自动生成用于创建自定义用户模型的数据库表结构
扩展了Django内置的AbstractUser模型添加了博客系统特有的用户字段
生成的表结构
- accounts_bloguser: 自定义博客用户表继承Django用户认证系统的所有功能
迁移依赖
- 依赖于Django auth应用的group和permission模型
"""
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django内置的认证系统相关模块
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
@ -9,69 +21,117 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
数据库迁移类用于创建BlogUser模型
这是初始迁移文件(initial = True)
用户账户应用初始迁移类
继承自migrations.Migration定义自定义用户模型的数据库表创建操作
initial = True 表示这是该应用的第一个迁移文件
主要功能
- 创建自定义用户模型BlogUser的数据库表
- 继承Django认证系统的所有基础字段
- 添加博客系统特有的自定义字段
- 设置模型的管理器和配置选项
"""
# 标记为初始迁移文件Django迁移系统会首先执行此文件
initial = True
# 依赖的迁移文件这里依赖Django auth应用的迁移
# 定义迁移依赖关系
dependencies = [
# 声明对Django认证系统组的依赖
# 使用auth应用的0012迁移文件确保用户权限系统正常工作
('auth', '0012_alter_user_first_name_max_length'),
]
# 定义迁移操作序列
operations = [
# 创建BlogUser模型表的操作
# 创建博客用户表的迁移操作
migrations.CreateModel(
# 模型名称 - 对应数据库表名 accounts_bloguser
name='BlogUser',
# 定义模型字段列表
fields=[
# 主键ID使用BigAutoField自增字段
# 主键字段 - 使用BigAutoField作为自增主键
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段使用Django认证系统的密码存储格式
# 密码字段 - Django认证系统标准字段存储加密后的密码
('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')),
# 名字字段,可为空
# 超级用户标志字段 - 标识用户是否拥有所有权限
('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')),
# 账户创建时间,默认使用当前时间
# 员工状态字段 - 标识用户是否可以登录管理后台
('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='修改时间')),
# 自定义字段:创建来源,记录用户的注册来源
# 来源字段 - 博客系统自定义字段记录用户创建来源如注册、OAuth等
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 用户组多对多关系关联到Django的Group模型
('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')),
# 用户权限多对多关系关联到Django的Permission模型
('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')),
# 组关联字段 - Django权限系统的组多对多关联
('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')),
# 权限关联字段 - Django权限系统的用户权限多对多关联
('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': '用户',
# 默认排序方式按ID倒序排列
# 默认排序规则 - 按ID倒序排列最新的记录在前
'ordering': ['-id'],
# 指定获取最新记录的依据字段
# 指定获取最新记录的字段 - 使用id字段确定最新记录
'get_latest_by': 'id',
},
# 定模型管理器使用Django默认的UserManager
# 模型管理器
managers=[
# 使用Django内置的UserManager管理用户对象
# 提供create_user、create_superuser等用户管理方法
('objects', django.contrib.auth.models.UserManager()),
],
),

@ -1,3 +1,14 @@
"""
用户账户应用数据库迁移文件 - 字段优化更新
本迁移文件对初始用户模型进行字段优化和国际化改进
- 重命名时间字段使用更清晰的英文命名
- 更新字段显示名称统一使用英文verbose_name
- 移除冗余字段优化数据库结构
这是对0001_initial迁移的后续更新依赖于初始迁移创建的表结构
"""
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.db import migrations, models
@ -6,53 +17,84 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
数据库迁移类用于修改BlogUser模型
这是对0001_initial迁移的后续修改
用户模型字段优化迁移类
对BlogUser模型进行字段级别的优化和改进
- 标准化字段命名约定
- 改进国际化支持
- 优化时间字段的语义清晰度
此迁移依赖于accounts应用的0001_initial迁移文件
"""
# 依赖之前的初始迁移文件
# 定义迁移依赖关系 - 依赖于本应用的初始迁移
dependencies = [
# 依赖accounts应用的第一个迁移文件确保BlogUser表已创建
('accounts', '0001_initial'),
]
# 定义迁移操作序列 - 按顺序执行以下数据库变更
operations = [
# 修改模型的元数据选项
# 修改模型选项 - 更新管理后台显示名称
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
options={
# 指定获取最新记录的字段 - 保持使用id字段
'get_latest_by': 'id',
# 保持默认排序规则 - 按ID倒序排列
'ordering': ['-id'],
# 更新单数显示名称为英文
'verbose_name': 'user',
# 更新复数显示名称为英文
'verbose_name_plural': 'user'
},
),
# 删除created_time字段
# 移除字段 - 删除created_time字段
# 该字段功能被creation_time字段替代
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
# 删除last_mod_time字段
# 移除字段 - 删除last_mod_time字段
# 该字段功能被last_modify_time字段替代
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
# 新增creation_time字段使用当前时间作为默认值
# 添加新字段 - 创建时间字段(新命名)
migrations.AddField(
model_name='bloguser',
name='creation_time',
# 使用DateTimeField存储完整的时间戳
# default参数使用Django的时区感知当前时间
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 新增last_modify_time字段使用当前时间作为默认值
# 添加新字段 - 最后修改时间字段(新命名)
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
# 使用DateTimeField存储完整的时间戳
# default参数使用Django的时区感知当前时间
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改nickname字段的verbose_name
# 修改字段选项 - 更新昵称字段的显示名称
migrations.AlterField(
model_name='bloguser',
name='nickname',
# 保持字段类型和约束不变仅更新verbose_name为英文
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
# 修改source字段的verbose_name
# 修改字段选项 - 更新来源字段的显示名称
migrations.AlterField(
model_name='bloguser',
name='source',
# 保持字段类型和约束不变仅更新verbose_name为英文
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -1,59 +1,125 @@
# 导入Django认证系统抽象用户基类
"""
自定义用户模型模块
本模块定义博客系统的自定义用户模型BlogUser扩展Django内置的AbstractUser模型
添加博客系统特有的用户字段和方法
"""
from django.contrib.auth.models import AbstractUser
# 导入Django数据库模型模块
from django.db import models
# 导入URL反向解析模块
from django.urls import reverse
# 导入时区相关模块
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.
# 自定义博客用户模型继承自Django的AbstractUser基类
class BlogUser(AbstractUser):
# 昵称字段最大长度100字符允许为空
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)
# 创建来源字段最大长度100字符允许为空
source = models.CharField(_('create source'), max_length=100, blank=True)
# 获取用户绝对URL的方法
"""
博客系统自定义用户模型
继承自Django的AbstractUser在标准用户模型基础上添加博客系统特有的字段
- 昵称字段
- 创建时间字段
- 最后修改时间字段
- 用户来源字段
同时提供获取用户相关URL的便捷方法
"""
# 昵称字段 - 用户的显示名称,可以为空
nickname = models.CharField(
_('nick name'), # 字段显示名称(支持国际化)
max_length=100, # 最大长度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, # 最大长度100字符
blank=True # 允许为空(非必填字段)
)
def get_absolute_url(self):
# 使用反向解析生成作者详情页的URL
"""
获取用户的绝对URL相对路径
用于Django的通用视图和模板中生成用户详情页链接
Returns:
str: 用户详情页的URL路径
Example:
>>> user.get_absolute_url()
'/author/admin/'
"""
# 使用reverse函数通过URL名称和参数生成URL路径
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
'blog:author_detail', # URL配置的名称
kwargs={
'author_name': self.username # URL参数作者用户名
})
# 对象的字符串表示方法
def __str__(self):
# 使用邮箱作为对象的字符串表示
"""
对象字符串表示方法
定义模型实例在Django管理后台和shell中的显示内容
Returns:
str: 用户的邮箱地址
"""
return self.email
# 获取用户完整URL的方法包含域名
def get_full_url(self):
"""
获取用户的完整URL包含域名
生成包含协议和域名的完整用户详情页URL用于外部链接
Returns:
str: 完整的用户详情页URL
Example:
>>> user.get_full_url()
'https://example.com/author/admin/'
"""
# 获取当前站点的域名
site = get_current_site().domain
# 构建完整的URL地址
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
# 返回完整的URL
# 生成完整的URL包含HTTPS协议和域名
url = "https://{site}{path}".format(
site=site, # 站点域名
path=self.get_absolute_url() # 相对路径
)
return url
# 模型的元数据配置
class Meta:
# 默认按ID倒序排列
"""
模型元数据配置类
定义模型的数据库表配置和Django管理后台显示选项
"""
# 默认排序规则 - 按ID倒序排列最新的记录在前
ordering = ['-id']
# 单数形式的显示名称
# 管理后台单数显示名称(支持国际化)
verbose_name = _('user')
# 复数形式的显示名称,与单数形式相同
# 管理后台复数显示名称 - 使用与单数相同的名称
verbose_name_plural = verbose_name
# 指定获取最新记录的依据字段
# 指定获取最新记录的字段 - 使用id字段确定最新记录1
get_latest_by = 'id'

@ -1,31 +1,42 @@
# 导入Django测试相关模块
"""
用户账户应用测试模块
本模块包含用户账户相关的所有测试用例覆盖用户注册登录密码重置
邮箱验证等核心功能的测试
"""
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析模块
from django.urls import reverse
# 导入时区相关模块
from django.utils import timezone
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入账户模型
from accounts.models import BlogUser
# 导入博客模型
from blog.models import Article, Category
# 导入工具函数
from djangoblog.utils import *
# 导入当前应用的工具模块
from . import utils
# Create your tests here.
# 账户测试类继承自Django的TestCase
class AccountTest(TestCase):
# 测试前置设置方法
"""
用户账户功能测试类
测试用户账户相关的所有功能包括
- 用户认证和登录
- 用户注册流程
- 邮箱验证码功能
- 密码重置流程
- 权限访问控制
"""
def setUp(self):
# 创建测试客户端
"""
测试初始化方法
在每个测试方法执行前运行创建测试所需的初始数据和环境
"""
# 创建测试客户端用于模拟HTTP请求
self.client = Client()
# 创建请求工厂
# 创建请求工厂,用于构建请求对象
self.factory = RequestFactory()
# 创建测试用户
self.blog_user = BlogUser.objects.create_user(
@ -36,27 +47,30 @@ class AccountTest(TestCase):
# 设置测试用的新密码
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)
# 访问管理员页面
# 测试管理后台访问权限
response = self.client.get('/admin/')
# 断言访问成功
self.assertEqual(response.status_code, 200)
# 创建测试分类
@ -72,116 +86,133 @@ class AccountTest(TestCase):
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)
# 测试注册功能
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
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()
# 创建分类
# 创建测试分类
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.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])
# 退出后访问文章管理页面应该重定向
# 验证登出后无法访问管理页面
response = self.client.get(article.get_admin_url())
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()
# 设置验证码
# 存储验证码
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) # 验证成功应返回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")
@ -190,39 +221,50 @@ class AccountTest(TestCase):
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=dict()
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)
# 准备密码重置数据
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(
@ -231,12 +273,16 @@ class AccountTest(TestCase):
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(
@ -244,21 +290,25 @@ 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)
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) # 应返回表单错误页面

@ -1,58 +1,60 @@
# 导入Django URL路由相关模块
"""
用户账户应用URL配置模块
本模块定义用户账户相关的所有URL路由包括登录注册登出
密码重置等用户认证相关的端点
URL模式使用正则表达式和路径转换器来匹配不同的用户操作请求
"""
from django.urls import path
from django.urls import re_path
# 导入当前应用的视图模块
# 导入视图模块
from . import views
# 导入登录表单类
# 导入自定义登录表单
from .forms import LoginForm
# 定义应用命名空间
# 应用命名空间用于URL反向解析
app_name = "accounts"
# 定义URL模式列表
# URL模式列表定义请求URL与视图的映射关系
urlpatterns = [
# 登录URL,使用正则表达式匹配路径
re_path(r'^login/$',
# 使用类视图设置登录成功后的重定向URL
# 用户登录URL
re_path(r'^login/$', # 匹配 /login/ 路径
# 使用类视图设置登录成功后的重定向URL为首页
views.LoginView.as_view(success_url='/'),
# URL名称
name='login',
# 传递额外参数,指定认证表单类
name='login', # URL名称用于反向解析
# 传递额外参数,指定使用自定义登录表单
kwargs={'authentication_form': LoginForm}),
# 注册URL,使用正则表达式匹配路径
re_path(r'^register/$',
# 使用类视图设置注册成功后的重定向URL
# 用户注册URL
re_path(r'^register/$', # 匹配 /register/ 路径
# 使用类视图设置注册成功后的重定向URL为首页
views.RegisterView.as_view(success_url="/"),
# URL名称
name='register'),
name='register'), # URL名称用于反向解析
# 退出登录URL使用正则表达式匹配路径
re_path(r'^logout/$',
# 使用类视图
# 用户登出URL
re_path(r'^logout/$', # 匹配 /logout/ 路径
# 使用类视图,处理用户登出逻辑
views.LogoutView.as_view(),
# URL名称
name='logout'),
name='logout'), # URL名称用于反向解析
# 账户结果页面URL使用path匹配精确路径
path(r'account/result.html',
# 使用函数视图
# 账户操作结果页面URL
path(r'account/result.html', # 匹配 /account/result.html 路径
# 使用函数视图,显示账户操作结果(如注册成功、验证结果等)
views.account_result,
# URL名称
name='result'),
name='result'), # URL名称用于反向解析
# 忘记密码URL,使用正则表达式匹配路径
re_path(r'^forget_password/$',
# 使用类视图
# 忘记密码页面URL
re_path(r'^forget_password/$', # 匹配 /forget_password/ 路径
# 使用类视图,处理密码重置请求
views.ForgetPasswordView.as_view(),
# URL名称
name='forget_password'),
name='forget_password'), # URL名称用于反向解析
# 忘记密码验证码URL,使用正则表达式匹配路径
re_path(r'^forget_password_code/$',
# 使用类视图
# 忘记密码验证码请求URL
re_path(r'^forget_password_code/$', # 匹配 /forget_password_code/ 路径
# 使用类视图,处理发送密码重置验证码的请求
views.ForgetPasswordEmailCode.as_view(),
# URL名称
name='forget_password_code'),
name='forget_password_code'), # URL名称用于反向解析
]

@ -1,41 +1,91 @@
# 导入Django认证系统相关模块
"""
自定义用户认证后端模块
本模块提供扩展的用户认证功能支持使用用户名或邮箱进行登录
扩展了Django标准的ModelBackend认证后端
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
# 自定义认证后端类,允许使用用户名或邮箱登录
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
自定义用户认证后端 - 支持用户名或邮箱登录
继承自Django的ModelBackend扩展认证功能
- 允许用户使用用户名或邮箱地址进行登录
- 自动检测输入的是用户名还是邮箱格式
- 保持与Django原生认证系统的兼容性
使用场景
当用户输入包含'@'符号时系统将其识别为邮箱进行认证
否则将其识别为用户名进行认证
"""
# 用户认证方法
def authenticate(self, request, username=None, password=None, **kwargs):
# 判断输入的用户名是否包含@符号(即是否为邮箱格式)
"""
用户认证方法
重写认证逻辑支持通过用户名或邮箱进行用户身份验证
Args:
request: HttpRequest对象包含请求信息
username (str): 用户输入的用户名或邮箱地址
password (str): 用户输入的密码
**kwargs: 其他关键字参数
Returns:
User: 认证成功的用户对象
None: 认证失败返回None
Example:
>>> backend = EmailOrUsernameModelBackend()
>>> # 使用用户名认证
>>> user = backend.authenticate(request, username='admin', password='password')
>>> # 使用邮箱认证
>>> user = backend.authenticate(request, username='admin@example.com', password='password')
"""
# 判断输入的是邮箱还是用户名
if '@' in username:
# 如果是邮箱格式则使用email字段进行查询
# 如果包含'@'符号,按邮箱处理
kwargs = {'email': username}
else:
# 如果不是邮箱格式则使用username字段进行查询
# 否则按用户名处理
kwargs = {'username': username}
try:
# 根据用户名或邮箱获取用户对象
# 根据用户名或邮箱查找用户
user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password):
# 密码正确则返回用户对象
# 密码验证成功,返回用户对象
return user
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
# 用户不存在则返回None
# 用户不存在返回None表示认证失败
return None
# 根据用户ID获取用户对象的方法
def get_user(self, username):
"""
根据用户ID获取用户对象
重写用户获取方法通过用户ID主键获取用户实例
Args:
username (int/str): 用户的ID主键值
Returns:
User: 对应的用户对象
None: 用户不存在时返回None
Note:
这里的参数名username实际上是用户ID这是为了保持与父类接口一致
"""
try:
# 根据主键用户ID获取用户对象
# 根据主键用户ID查找用户
return get_user_model().objects.get(pk=username)
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
# 用户不存在则返回None
# 用户不存在返回None
return None

@ -1,66 +1,127 @@
# 导入类型提示模块
"""
邮箱验证码工具模块
本模块提供邮箱验证码的生成发送验证和缓存管理功能
用于用户注册密码重置等需要邮箱验证的场景
主要功能
- 发送验证码邮件
- 验证码的存储和读取
- 验证码有效性验证
"""
import typing
# 导入时间间隔模块
from datetime import timedelta
# 导入Django缓存模块
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
# 定义验证码的生存时间Time To Live设置为5分钟
# 验证码有效期配置 - 5分钟
_code_ttl = timedelta(minutes=5)
# 发送验证邮件的函数
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
"""
发送验证码邮件
向指定邮箱发送包含验证码的邮件用于用户身份验证
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
to_mail (str): 接收邮件的邮箱地址
code (str): 要发送的验证码
subject (str): 邮件主题默认为"Verify Email"
Example:
>>> send_verify_email("user@example.com", "123456")
# 向user@example.com发送验证码123456
"""
# 构建邮件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: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
email (str): 用户邮箱地址作为缓存键
code (str): 用户输入的验证码
Returns:
typing.Optional[str]:
- None: 验证码正确且有效
- str: 错误信息字符串验证码错误或无效
Note:
当前错误处理方式不够合理应该使用异常抛出机制
这样调用方可以通过try-except处理错误而不是检查返回值
Example:
>>> result = verify("user@example.com", "123456")
>>> if result:
>>> print(f"验证失败: {result}")
>>> else:
>>> print("验证成功")
"""
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email)
# 比较输入的验证码和缓存中的验证码是否一致
# 比较用户输入的验证码与缓存中的验证码
if cache_code != code:
# 如果不一致,返回错误信息
# 验证码不匹配,返回错误信息
return gettext("Verification code error")
# 验证成功返回None
# 设置验证码到缓存的函数
def set_code(email: str, code: str):
"""设置code"""
# 将邮箱和验证码存入缓存设置过期时间为_code_ttl定义的秒数
"""
设置验证码到缓存
将验证码存储到Django缓存系统中并设置有效期
Args:
email (str): 邮箱地址作为缓存键
code (str): 要存储的验证码
Example:
>>> set_code("user@example.com", "123456")
# 将验证码123456存储到缓存键为"user@example.com"
"""
# 使用Django缓存系统存储验证码设置5分钟有效期
cache.set(email, code, _code_ttl.seconds)
# 从缓存获取验证码的函数
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
# 根据邮箱从缓存中获取验证码如果不存在则返回None
"""
从缓存中获取验证码
根据邮箱地址从缓存中获取对应的验证码
Args:
email (str): 邮箱地址作为缓存键
Returns:
typing.Optional[str]:
- str: 找到的验证码
- None: 验证码不存在或已过期
Example:
>>> code = get_code("user@example.com")
>>> if code:
>>> print(f"验证码是: {code}")
>>> else:
>>> print("验证码不存在或已过期")
"""
# 从Django缓存系统中获取验证码
return cache.get(email)

@ -1,88 +1,101 @@
# 导入日志模块
"""
用户账户视图模块
本模块包含用户账户相关的所有视图处理逻辑包括
- 用户注册登录登出
- 邮箱验证
- 密码重置
- 验证码发送
使用类视图和函数视图结合的方式处理用户认证流程
"""
import logging
# 导入Django国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入Django配置模块
from django.conf import settings
# 导入Django认证相关模块
from django.contrib import auth
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
# 导入HTTP响应相关模块
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
# 导入URL反向解析模块
from django.urls import reverse
# 导入方法装饰器模块
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
# 导入用户模型
from .models import BlogUser
# 获取当前模块的日志
# 配置日志记录
logger = logging.getLogger(__name__)
# Create your views here.
# 注册视图类继承自FormView
class RegisterView(FormView):
"""
用户注册视图
处理新用户注册流程包括表单验证用户创建邮箱验证邮件发送等
"""
# 指定使用的表单类
form_class = RegisterForm
# 指定模板名称
# 指定注册页面模板
template_name = 'account/registration_form.html'
# 使用CSRF保护装饰器
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法
"""
请求分发方法添加CSRF保护装饰器
确保注册请求受到CSRF保护防止跨站请求伪造攻击
"""
return super(RegisterView, self).dispatch(*args, **kwargs)
# 表单验证通过后的处理
def form_valid(self, form):
# 检查表单是否有效
"""
表单验证通过后的处理逻辑
创建新用户发送验证邮件重定向到结果页面
Args:
form: 验证通过的注册表单实例
Returns:
HttpResponseRedirect: 重定向到结果页面
"""
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'
# 获取结果页面的URL路径
# 构建验证URL
path = reverse('account:result')
# 构建完整的验证URL
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
@ -97,6 +110,7 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
@ -105,169 +119,217 @@ class RegisterView(FormView):
title='验证您的电子邮箱',
content=content)
# 构建注册结果页面的URL
# 重定向到注册结果页面
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
# 重定向到结果页面
return HttpResponseRedirect(url)
else:
# 表单无效则重新渲染表单页面
# 表单验证失败,重新渲染表单页面
return self.render_to_response({
'form': form
})
# 退出登录视图类继承自RedirectView
class LogoutView(RedirectView):
# 指定退出后重定向的URL
"""
用户登出视图
处理用户登出逻辑清理会话和缓存
"""
# 登出后重定向到的URL
url = '/login/'
# 使用永不缓存装饰器
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
# 调用父类的dispatch方法
"""
请求分发方法添加不缓存装饰器
确保登出页面不会被浏览器缓存
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs)
# GET请求处理
def get(self, request, *args, **kwargs):
# 执行退出登录操作
"""
处理GET请求的登出逻辑
执行用户登出操作清理侧边栏缓存
"""
# 执行用户登出
logout(request)
# 删除侧边栏缓存
# 清理侧边栏缓存
delete_sidebar_cache()
# 调用父类的GET方法进行重定向
# 调用父类方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图类继承自FormView
class LoginView(FormView):
"""
用户登录视图
处理用户登录认证支持记住登录状态功能
"""
# 指定使用的表单类
form_class = LoginForm
# 指定模板名称
# 指定登录页面模板
template_name = 'account/login.html'
# 指定登录成功后的默认重定向URL
# 登录成功后的默认重定向URL
success_url = '/'
# 指定重定向字段名
# 重定向字段名
redirect_field_name = REDIRECT_FIELD_NAME
# 设置登录会话的生存时间(一个月)
login_ttl = 2626560 # 一个月的时间
# 记住登录状态的会话有效期(一个月)
login_ttl = 2626560
# 使用多个装饰器保护视图
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
# 调用父类的dispatch方法
"""
请求分发方法添加安全装饰器
- sensitive_post_parameters: 保护密码参数
- csrf_protect: CSRF保护
- never_cache: 禁止缓存
"""
return super(LoginView, self).dispatch(request, *args, **kwargs)
# 获取上下文数据
def get_context_data(self, **kwargs):
# 获取重定向目标URL
"""
获取模板上下文数据
添加重定向URL到上下文
"""
# 从GET参数获取重定向URL
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):
# 创建认证表单实例
"""
表单验证通过后的处理逻辑
执行用户登录认证处理记住登录状态
"""
# 使用Django认证表单进行验证
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)
# 调用父类的form_valid方法进行重定向
return super(LoginView, self).form_valid(form)
else:
# 表单无效则重新渲染表单页面
# 登录失败,重新渲染登录页面
return self.render_to_response({
'form': form
})
# 获取成功登录后的重定向URL
def get_success_url(self):
# 从POST数据中获取重定向目标
"""
获取登录成功后的重定向URL
验证重定向URL的安全性防止开放重定向攻击
"""
# 从POST数据获取重定向URL
redirect_to = self.request.POST.get(self.redirect_field_name)
# 检查重定向URL是否安全
# 验证URL是否安全同源策略
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
# 如果不安全使用默认的成功URL
redirect_to = self.success_url
# 返回重定向URL
return redirect_to
# 账户结果页面视图函数
def account_result(request):
# 获取请求参数
"""
账户操作结果页面视图
显示注册结果或处理邮箱验证
Args:
request: HTTP请求对象
Returns:
HttpResponse: 结果页面响应
"""
# 获取操作类型和用户ID
type = request.GET.get('type')
id = request.GET.get('id')
# 根据ID获取用户对象如果不存在则返回404
# 获取用户对象不存在则返回404
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')
# 如果签名不匹配返回403禁止访问
# 验证签名
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('/')
# 忘记密码视图类继承自FormView
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"])
@ -276,21 +338,32 @@ class ForgetPasswordView(FormView):
# 重定向到登录页面
return HttpResponseRedirect('/login/')
else:
# 表单无效则重新渲染表单页面
# 表单验证失败,重新渲染表单
return self.render_to_response({'form': form})
# 忘记密码邮箱验证码视图类继承自View
class ForgetPasswordEmailCode(View):
"""
忘记密码验证码发送视图
处理密码重置验证码的发送请求
"""
# POST请求处理
def post(self, request: HttpRequest):
# 创建忘记密码验证码表单实例
"""
处理POST请求发送密码重置验证码
Args:
request: HTTP请求对象
Returns:
HttpResponse: 操作结果响应
"""
# 验证表单数据
form = ForgetPasswordCodeForm(request.POST)
# 检查表单是否有效
if not form.is_valid():
# 表单无效返回错误信息
return HttpResponse("错误的邮箱")
# 获取邮箱地址
to_email = form.cleaned_data["email"]
@ -298,8 +371,7 @@ class ForgetPasswordEmailCode(View):
code = generate_code()
# 发送验证邮件
utils.send_verify_email(to_email, code)
# 将验证码存入缓存
# 存储验证码到缓存
utils.set_code(to_email, code)
# 返回成功响应
return HttpResponse("ok")

@ -1,173 +1,128 @@
# 导入Django表单模块
from django import forms
# 导入Django管理员模块
from django.contrib import admin
# 导入用户模型获取函数
from django.contrib.auth import get_user_model
# 导入URL反向解析模块
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 导入博客模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
from .models import Article, Category, Tag, Links, SideBar, BlogSettings, ArticleLike, ArticleFavorite
# 文章表单类
# 文章表单(可扩展,比如集成富文本编辑器)
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
# 指定关联的模型
model = Article
# 包含所有字段
fields = '__all__'
fields = '__all__' # 表示表单包含模型的所有字段
# 发布文章的管理动作函数
# 批量操作:发布文章
def makr_article_publish(modeladmin, request, queryset):
# 将选中的文章状态更新为发布
queryset.update(status='p')
queryset.update(status='p') # 将选中文章状态改为 'p'ublished
makr_article_publish.short_description = _('发布选中的文章')
# 将文章设为草稿的管理动作函数
# 批量操作:草稿文章
def draft_article(modeladmin, request, queryset):
# 将选中的文章状态更新为草稿
queryset.update(status='d')
queryset.update(status='d') # 草稿状态
draft_article.short_description = _('将选中文章设为草稿')
# 关闭文章评论的管理动作函数
# 批量操作:关闭文章评论
def close_article_commentstatus(modeladmin, request, queryset):
# 将选中的文章评论状态更新为关闭
queryset.update(comment_status='c')
queryset.update(comment_status='c') # 关闭评论
close_article_commentstatus.short_description = _('关闭文章评论')
# 打开文章评论的管理动作函数
# 批量操作:开放文章评论
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')
queryset.update(comment_status='o') # 开放评论
open_article_commentstatus.short_description = _('开放文章评论')
# 文章管理类
class ArticlelAdmin(admin.ModelAdmin):
# 每页显示的文章数量
list_per_page = 20
# 搜索字段
search_fields = ('body', 'title')
# 使用的表单类
# 文章管理后台类
class ArticleAdmin(admin.ModelAdmin):
list_per_page = 20 # 每页显示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')
# 日期层次导航
date_hierarchy = 'creation_time'
# 水平过滤器(用于多对多字段)
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]
# 使用原始ID字段显示ID而不是下拉选择
raw_id_fields = ('author', 'category',)
# 自定义方法:显示分类链接
list_display = ( # 列表页显示的字段
'id', 'title', 'author', 'link_to_category', 'creation_time',
'views', 'like_count', 'status', 'type', 'article_order'
)
list_display_links = ('id', 'title') # 哪些字段可点击进入编辑页
list_filter = ('status', 'type', 'category') # 右侧过滤器
date_hierarchy = 'creation_time' # 按创建时间分层筛选
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] # 批量操作
raw_id_fields = ('author', 'category') # 作者和分类以 ID 输入框展示,适合外键多的情况
# 自定义方法:分类显示为可点击链接
def link_to_category(self, obj):
# 获取分类模型的app_label和model_name
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 生成分类编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 返回格式化的HTML链接
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('分类')
# 设置自定义方法的显示名称
link_to_category.short_description = _('category')
# 获取表单的方法
# 限制文章作者只能选择超级用户
def get_form(self, request, obj=None, **kwargs):
# 调用父类的get_form方法
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 限制作者字段只能选择超级用户
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
# 返回表单
form = super(ArticleAdmin, 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):
# 调用父类的save_model方法
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 获取"在站点查看"按钮URL的方法
# 点击“查看站点”时跳转到文章详情页
def get_view_on_site_url(self, obj=None):
# 如果提供了对象
if obj:
# 获取文章的完整URL
url = obj.get_full_url()
return url
else:
# 否则返回当前站点域名
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
# 标签管理类
# 其它模型(如 Tag、Category、Links、SideBar、BlogSettings的 Admin 配置
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
pass # 博客设置后台,暂时无特殊配置
# 文章点赞管理后台类
class ArticleLikeAdmin(admin.ModelAdmin):
list_display = ('article', 'user', 'created_time')
list_filter = ('created_time',)
search_fields = ('article__title', 'user__username', 'user__email')
date_hierarchy = 'created_time'
readonly_fields = ('created_time',)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 点赞记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')
# 文章收藏管理后台类
class ArticleFavoriteAdmin(admin.ModelAdmin):
list_display = ('article', 'user', 'created_time')
list_filter = ('created_time',)
search_fields = ('article__title', 'user__username', 'user__email')
date_hierarchy = 'created_time'
readonly_fields = ('created_time',)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 收藏记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')

@ -1,8 +1,4 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义博客应用的配置类
class BlogConfig(AppConfig):
# 指定应用的完整Python路径
name = 'blog'
name = 'blog' # 应用名称

@ -1,76 +1,39 @@
# 导入日志模块
import logging
# 导入Django时区工具
from django.utils import timezone
# 导入自定义工具函数
from djangoblog.utils import cache, get_blog_setting
# 导入博客模型
from .models import Category, Article
# 获取当前模块的日志器
logger = logging.getLogger(__name__)
# SEO处理器函数用于生成模板上下文变量
# 上下文处理器:为每个模板注入全局 SEO 和导航相关变量
def seo_processor(requests):
# 缓存键名
key = 'seo_processor'
# 尝试从缓存获取数据
value = cache.get(key)
# 如果缓存中存在数据,直接返回
value = cache.get(key) # 先从缓存中读取
if value:
return value
else:
# 记录日志,表示正在设置处理器缓存
logger.info('set processor cache.')
# 获取博客设置
setting = get_blog_setting()
# 构建上下文数据字典
logger.info('设置处理器缓存。')
setting = get_blog_setting() # 获取博客配置
value = {
# 网站名称
'SITE_NAME': setting.site_name,
# 是否显示谷歌广告
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
# 谷歌广告代码
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
# 网站SEO描述
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
# 网站描述
'SITE_DESCRIPTION': setting.site_description,
# 网站关键词
'SITE_KEYWORDS': setting.site_keywords,
# 网站基础URL
'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'),
# 是否开启网站评论功能
'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,
# 是否显示公安备案号
'BEIAN_CODE': setting.beian_code, # 备案号
'ANALYTICS_CODE': setting.analytics_code, # 统计代码(如 Google Analytics
"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,
}
# 将数据存入缓存有效期10小时
cache.set(key, value, 60 * 60 * 10)
# 返回上下文数据
cache.set(key, value, 60 * 60 * 10) # 缓存10小时
return value

@ -1,314 +1,401 @@
# 导入时间模块
import time
# 导入Elasticsearch客户端模块
import logging
import elasticsearch.client
# 导入Django配置模块
from django.conf import settings
# 导入Elasticsearch DSL相关类
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
# 导入Elasticsearch连接模块
from elasticsearch_dsl.connections import connections
# 导入文章模型
from blog.models import Article
# 检查是否启用Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
logger = logging.getLogger(__name__)
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') # 是否启用 Elasticsearch
# 如果启用Elasticsearch创建连接并设置geoip管道
# 如果启用,则建立连接
if ELASTICSEARCH_ENABLED:
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
# 导入Elasticsearch客户端
connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
# 创建Elasticsearch客户端实例
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 导入Ingest客户端
from elasticsearch.client import IngestClient
# 创建Ingest客户端
c = IngestClient(es)
try:
# 尝试获取geoip管道
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
# 如果geoip管道不存在创建它
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
# 定义GeoIP内部文档类
c.put_pipeline('geoip', body='''
{
"description": "添加IP地理位置信息",
"processors": [
{ "geoip": { "field": "ip" } }
]
}
''')
# 定义 IP 地理位置信息内部文档
class GeoIp(InnerDoc):
# 大洲名称字段
continent_name = Keyword()
# 国家ISO代码字段
country_iso_code = Keyword()
# 国家名称字段
country_name = Keyword()
# 地理位置字段(经纬度)
location = GeoPoint()
# 定义用户代理浏览器内部文档
# 用户代理浏览器/设备/操作系统)相关内部类
class UserAgentBrowser(InnerDoc):
# 浏览器家族字段
Family = Keyword()
# 浏览器版本字段
Version = Keyword()
# 定义用户代理操作系统内部文档类继承自UserAgentBrowser
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()
# 定义耗时文档类继承自Document
# 性能监控文档:记录每个请求的 URL、耗时、IP、用户代理等
class ElapsedTimeDocument(Document):
# URL字段
url = Keyword()
# 耗时字段(毫秒)
time_taken = Long()
# 日志时间字段
time_taken = Long() # 请求耗时(毫秒)
log_datetime = Date()
# IP地址字段
ip = Keyword()
# GeoIP信息对象
geoip = Object(GeoIp, required=False)
# 用户代理信息对象
useragent = Object(UserAgent, required=False)
# 索引配置类
class Index:
# 索引名称
name = 'performance'
# 索引设置
settings = {
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# 元数据类
class Meta:
# 文档类型
doc_type = 'ElapsedTime'
# 耗时文档管理器类
class ElaspedTimeDocumentManager:
# 构建索引的静态方法
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
# 创建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
# 创建Elasticsearch客户端
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
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(
# 使用当前时间戳作为文档ID
meta={
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
# 保存文档使用geoip管道处理IP地址
doc.save(pipeline="geoip")
# 定义文章文档类继承自Document
# 文章搜索文档:用于全文检索
class ArticleDocument(Document):
# 正文字段使用ik分词器
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# 标题字段使用ik分词器
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 使用IK中文分词
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()
})
# 发布时间字段
author = Object(properties={'nickname': Text(analyzer='ik_max_word'), 'id': Integer()})
category = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
tags = Object(properties={'name': Text(analyzer='ik_max_word'), '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 # 副本数量
}
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# 新增ArticleDocumentManager 类
class ArticleDocumentManager:
"""
ArticleDocument 管理器类
用于处理 Elasticsearch 相关的文章搜索操作
"""
def __init__(self, document_class=ArticleDocument):
self.document_class = document_class
self.es_enabled = ELASTICSEARCH_ENABLED
def search_articles(self, query, category=None, author=None, tags=None, size=10, from_index=0):
"""
搜索文章
"""
if not self.es_enabled:
# 如果 Elasticsearch 未启用,返回空结果
return [], 0
try:
search = self.document_class.search()
if query:
# 多字段匹配搜索,标题权重更高
search = search.query(
"multi_match",
query=query,
fields=[
'title^3', # 标题权重最高
'body^2', # 正文权重次之
'tags.name^2', # 标签权重
'category.name^1.5', # 分类权重
'author.nickname^1' # 作者权重
],
fuzziness="AUTO" # 自动模糊匹配
)
# 应用过滤器
if category:
search = search.filter('term', category__name=category)
if author:
search = search.filter('term', author__nickname=author)
if tags:
if isinstance(tags, str):
tags = [tags]
for tag in tags:
search = search.filter('term', tags__name=tag)
# 只搜索已发布的文章
search = search.filter('term', status='p')
# 获取总数
total = search.count()
# 应用分页
search = search[from_index:from_index + size]
# 执行搜索
response = search.execute()
return response, total
except Exception as e:
logger.error(f"Elasticsearch search failed: {e}")
return [], 0
def get_popular_articles(self, size=10):
"""
获取热门文章按浏览量排序
"""
if not self.es_enabled:
return []
try:
search = self.document_class.search()
search = search.filter('term', status='p')
search = search.sort('-views') # 按浏览量降序
search = search[:size]
response = search.execute()
return response
except Exception as e:
logger.error(f"Failed to get popular articles: {e}")
return []
def get_recent_articles(self, size=10):
"""
获取最新文章按发布时间排序
"""
if not self.es_enabled:
return []
try:
search = self.document_class.search()
search = search.filter('term', status='p')
search = search.sort('-pub_time') # 按发布时间降序
search = search[:size]
response = search.execute()
return response
except Exception as e:
logger.error(f"Failed to get recent articles: {e}")
return []
def get_articles_by_category(self, category_name, size=10):
"""
根据分类获取文章
"""
if not self.es_enabled:
return []
try:
search = self.document_class.search()
search = search.filter('term', category__name=category_name)
search = search.filter('term', status='p')
search = search.sort('-pub_time')
search = search[:size]
response = search.execute()
return response
except Exception as e:
logger.error(f"Failed to get articles by category: {e}")
return []
def get_articles_by_tag(self, tag_name, size=10):
"""
根据标签获取文章
"""
if not self.es_enabled:
return []
try:
search = self.document_class.search()
search = search.filter('term', tags__name=tag_name)
search = search.filter('term', status='p')
search = search.sort('-pub_time')
search = search[:size]
response = search.execute()
return response
except Exception as e:
logger.error(f"Failed to get articles by tag: {e}")
return []
def get_similar_articles(self, article_id, size=5):
"""
获取相似文章基于更多相似项
"""
if not self.es_enabled:
return []
try:
search = self.document_class.search()
# 使用 more_like_this 查询找到相似文章
search = search.query(
'more_like_this',
fields=['title', 'body', 'tags.name', 'category.name'],
like={"_id": article_id},
min_term_freq=1,
max_query_terms=12
)
search = search.filter('term', status='p')
search = search.exclude('term', _id=article_id) # 排除当前文章
search = search[:size]
response = search.execute()
return response
except Exception as e:
logger.error(f"Failed to get similar articles: {e}")
return []
def rebuild_index(self):
"""
重建文章索引
"""
if not self.es_enabled:
return False
try:
# 删除现有索引
self.document_class._index.delete(ignore=404)
# 创建新索引
self.document_class._index.create()
# 重新索引所有文章
self.document_class.init()
logger.info("Article index rebuilt successfully")
return True
except Exception as e:
logger.error(f"Failed to rebuild article index: {e}")
return False
def get_index_stats(self):
"""
获取索引统计信息
"""
if not self.es_enabled:
return {}
try:
from elasticsearch.client import IndicesClient
client = IndicesClient(connections.get_connection())
stats = client.stats(index='blog')
return stats
except Exception as e:
logger.error(f"Failed to get index stats: {e}")
return {}
# 创建全局实例
article_document_manager = ArticleDocumentManager()
# Elasticsearch 索引管理工具类
class ElasticsearchManager:
"""
Elasticsearch 索引管理工具类
"""
def __init__(self):
self.es_enabled = ELASTICSEARCH_ENABLED
# 元数据类
class Meta:
# 文档类型
doc_type = 'Article'
def create_all_indices(self):
"""
创建所有索引
"""
if not self.es_enabled:
return False
try:
# 创建文章索引
ArticleDocument.init()
# 文章文档管理器类
class ArticleDocumentManager():
# 创建性能监控索引
ElapsedTimeDocument.init()
# 初始化方法
def __init__(self):
# 创建索引
self.create_index()
# 创建索引方法
def create_index(self):
ArticleDocument.init()
# 删除索引方法
def delete_index(self):
from elasticsearch import Elasticsearch
# 创建Elasticsearch客户端
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
es.indices.delete(index='blog', ignore=[400, 404])
# 将文章对象转换为文档对象的方法
def convert_to_doc(self, articles):
return [
ArticleDocument(
# 使用文章ID作为文档ID
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()
logger.info("All Elasticsearch indices created successfully")
return True
except Exception as e:
logger.error(f"Failed to create indices: {e}")
return False
def delete_all_indices(self):
"""
删除所有索引
"""
if not self.es_enabled:
return False
try:
ArticleDocument._index.delete(ignore=404)
ElapsedTimeDocument._index.delete(ignore=404)
logger.info("All Elasticsearch indices deleted successfully")
return True
except Exception as e:
logger.error(f"Failed to delete indices: {e}")
return False
def refresh_all_indices(self):
"""
刷新所有索引
"""
if not self.es_enabled:
return False
try:
from elasticsearch.client import IndicesClient
client = IndicesClient(connections.get_connection())
client.refresh(index='_all')
logger.info("All Elasticsearch indices refreshed successfully")
return True
except Exception as e:
logger.error(f"Failed to refresh indices: {e}")
return False
def get_cluster_health(self):
"""
获取集群健康状态
"""
if not self.es_enabled:
return {}
try:
health = connections.get_connection().cluster.health()
return health
except Exception as e:
logger.error(f"Failed to get cluster health: {e}")
return {}
# 创建全局实例
elasticsearch_manager = ElasticsearchManager()

@ -1,32 +1,6 @@
# 导入日志模块
import logging
# 导入Django表单模块
from django import forms
# 导入Haystack搜索表单基类
from haystack.forms import SearchForm
# 获取当前模块的日志器
logger = logging.getLogger(__name__)
# 博客搜索表单类继承自Haystack的SearchForm
# 继承 Haystack 搜索表单,自定义查询字段
class BlogSearchForm(SearchForm):
# 定义查询数据字段,设置为必填字段
querydata = forms.CharField(required=True)
# 重写搜索方法
def search(self):
# 调用父类的search方法获取基础搜索结果
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
# 可加入日志等处理
return super().search()

@ -1,29 +1,30 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入Elasticsearch相关文档和管理器类
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
# 导入 Elasticsearch 相关的文档模型和管理器
from blog.documents import (
ElapsedTimeDocument, # 假设是一个时间相关的文档模型
ArticleDocumentManager, # 文章的文档管理器,用于操作 Elasticsearch 中的文章索引
ElaspedTimeDocumentManager, # 时间相关的文档管理器注意疑似拼写错误应为ElapsedTime
ELASTICSEARCH_ENABLED # 全局开关,控制是否启用 Elasticsearch 功能
)
# TODO 参数化
# 自定义管理命令类,用于构建搜索索引
class Command(BaseCommand):
# 命令的帮助信息
help = 'build search index'
help = '构建搜索索引' # 命令的帮助信息,显示在 python manage.py help 中
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 检查Elasticsearch是否启用
if ELASTICSEARCH_ENABLED:
# 构建耗时文档的索引
"""
命令的主要执行逻辑
"""
if ELASTICSEARCH_ENABLED: # 只有在启用了 Elasticsearch 的情况下才执行
# 构建 “时间” 相关的索引
ElaspedTimeDocumentManager.build_index()
# 创建耗时文档管理器实例并初始化
# 获取并初始化 “时间” 相关的文档管理对象
manager = ElapsedTimeDocument()
manager.init()
# 创建文章文档管理器实例
manager.init() # 初始化索引或相关数据
# 获取文章的文档管理器,并先删除旧索引,然后重建新的文章索引
manager = ArticleDocumentManager()
# 删除现有索引
manager.delete_index()
# 重新构建索引
manager.rebuild()
manager.delete_index() # 删除已有的文章索引
manager.rebuild() # 重建文章索引,通常包括从数据库读取数据并批量导入到 ES

@ -1,20 +1,21 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入博客模型
# 从 blog 应用中导入 Tag 和 Category 模型
from blog.models import Tag, Category
# TODO 参数化
# 自定义管理命令类,用于构建搜索词
class Command(BaseCommand):
# 命令的帮助信息
help = 'build search words'
help = '构建搜索关键词' # 用于生成所有标签和分类名称,作为搜索关键词
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 构建搜索词数据集:获取所有标签名称和分类名称,使用集合去重
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
# 将数据集中的每个搜索词按行打印输出
"""
获取所有标签和分类的名称去重后打印出来供后续用作搜索词库
"""
# 取出所有 Tag 的名称 和 所有 Category 的名称,放入一个集合中自动去重
datas = set([
t.name for t in Tag.objects.all() # 所有标签名称
+
[t.name for t in Category.objects.all()] # 所有分类名称
])
# 将所有关键词用换行符连接,并打印到控制台
print('\n'.join(datas))

@ -1,18 +1,16 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入缓存工具
# 引入项目自定义的缓存工具模块
from djangoblog.utils import cache
# 自定义管理命令类,用于清除整个缓存
class Command(BaseCommand):
# 命令的帮助信息
help = 'clear the whole cache'
help = '清空全部缓存' # 一键清除应用程序中的所有缓存数据
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 清除所有缓存
cache.clear()
# 输出成功信息到标准输出
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
"""
调用缓存工具的 clear 方法清空缓存并输出成功提示
"""
cache.clear() # 执行缓存清理
# 输出成功信息,使用 Django 管理命令的样式输出
self.stdout.write(self.style.SUCCESS('缓存已清空\n'))

@ -1,58 +1,62 @@
# 导入Django认证系统相关模块
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.contrib.auth import get_user_model # Django 提供的获取用户模型的方法
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'
help = '创建测试数据' # 用于生成一些假数据,便于前端展示和功能测试
# 命令的主要处理逻辑
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]
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU') # 对明文密码进行哈希加密
)[0]
# 获取或创建父类目
# 创建或获取一个父级分类
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
name='我是父类目',
parent_category=None # 表示没有父分类,即为顶级分类
)[0]
# 获取或创建子类目
# 创建或获取一个子分类,其父分类为上面创建的 pcategory
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
name='子类目',
parent_category=pcategory
)[0]
category.save() # 保存分类对象
# 保存子类目
category.save()
# 创建基础标签
# 创建一个基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 循环创建20篇测试文章
# 循环创建 1~19 号文章,每篇文章都绑定到上面创建的子分类
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]
# 创建新标签
category=category, # 关联分类
title='nice title ' + str(i), # 标题
body='nice content ' + str(i), # 正文内容
author=user # 作者
)[0]
# 为每篇文章创建一个独立标签,如 标签1、标签2 ...
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
# 为文章添加标签
# 将独立标签和基础标签都添加到文章的标签集合中
article.tags.add(tag)
article.tags.add(basetag)
# 保存文章
article.save()
article.save() # 保存文章与标签的关联关系
# 导入缓存工具
# 清空缓存,确保新生成的数据能及时被正确索引或展示
from djangoblog.utils import cache
# 清除所有缓存
cache.clear()
# 输出成功信息到标准输出
self.stdout.write(self.style.SUCCESS('created test datas \n'))
# 输出成功提示
self.stdout.write(self.style.SUCCESS('已创建测试数据 \n'))

@ -1,69 +1,69 @@
# 导入Django管理命令基类
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
# 自定义管理命令类用于通知百度搜索引擎URL更新
class Command(BaseCommand):
# 命令的帮助信息
help = 'notify baidu url'
help = '通知百度收录相关 URL' # 用于将站点内的文章、标签、分类等 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')
choices=['all', 'article', 'tag', 'category'], # 可选参数值
help='article所有文章, tag所有标签, category所有分类, all全部'
)
# 获取完整URL的方法
def get_full_url(self, path):
# 构建包含域名的完整URL
"""
拼接完整的 URLhttps://example.com/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)
# 获取用户传入的参数,决定要通知哪些类型的数据
data_type = options['data_type']
self.stdout.write('开始处理 %s' % data_type)
# 初始化URL列表
urls = []
# 如果类型是文章或全部获取所有已发布文章的URL
if type == 'article' or type == 'all':
urls = [] # 用于存储所有需要提交的 URL
if data_type == 'article' or data_type == 'all':
# 如果是文章或全部将所有已发布status='p')的文章的完整 URL 加入列表
for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url())
# 如果类型是标签或全部获取所有标签的URL
if type == 'tag' or type == 'all':
if data_type == 'tag' or data_type == 'all':
# 如果是标签或全部,将所有标签的绝对 URL 加入列表
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
# 如果类型是分类或全部获取所有分类的URL
if type == 'category' or type == 'all':
if data_type == 'category' or data_type == 'all':
# 如果是分类或全部,将所有分类的绝对 URL 加入列表
for category in Category.objects.all():
url = category.get_absolute_url()
urls.append(self.get_full_url(url))
# 输出开始通知的信息显示URL数量
# 输出即将提交的通知数量
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
# 调用百度蜘蛛通知工具通知所有URL
'开始通知百度收录 %d 个 URL' %
len(urls)
)
)
# 调用百度通知工具,提交所有 URL
SpiderNotify.baidu_notify(urls)
# 输出完成通知的信息
self.stdout.write(self.style.SUCCESS('finish notify'))
# 提交完成提示
self.stdout.write(self.style.SUCCESS('完成通知百度收录\n'))

@ -1,82 +1,57 @@
# 导入请求库
import requests
# 导入Django管理命令基类
import requests # 用于发送 HTTP 请求,检测头像链接是否有效
from django.core.management.base import BaseCommand
# 导入静态文件处理模块
from django.templatetags.static import static
from django.templatetags.static import static # 用于获取 Django 的静态文件路径
# 导入自定义工具函数
from djangoblog.utils import save_user_avatar
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 导入OAuth管理器获取函数
from oauth.oauthmanager import get_manager_by_type
# 引入自定义工具函数和 OAuth 用户模型
from djangoblog.utils import save_user_avatar # 用于下载并保存用户头像到本地或 CDN
from oauth.models import OAuthUser # 第三方登录用户表
from oauth.oauthmanager import get_manager_by_type # 根据第三方类型获取对应的 OAuth 管理器
# 自定义管理命令类,用于同步用户头像
class Command(BaseCommand):
# 命令的帮助信息
help = 'sync user avatar'
help = '同步用户头像' # 将用户头像从第三方平台同步到本地或统一存储
# 测试图片URL是否可访问的方法
def test_picture(self, url):
"""
测试头像链接是否有效返回状态码 200超时时间为 2
"""
try:
# 发送GET请求测试图片URL设置2秒超时
if requests.get(url, timeout=2).status_code == 200:
# 如果返回状态码为200说明图片可访问
return True
except:
# 如果发生任何异常,说明图片不可访问
pass
return False
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 获取静态文件基础URL
# 获取静态资源的基础路径(用于判断是否为本地静态头像)
static_url = static("../")
# 获取所有OAuth用户
users = OAuthUser.objects.all()
# 输出开始同步的信息,显示用户数量
self.stdout.write(f'开始同步{len(users)}个用户头像')
# 遍历每个用户
users = OAuthUser.objects.all() # 获取所有的 OAuth 用户
self.stdout.write(f'开始同步 {len(users)} 个用户的头像')
for u in users:
# 输出开始同步当前用户的信息
self.stdout.write(f'开始同步:{u.nickname}')
# 获取用户的头像URL
url = u.picture
# 如果存在头像URL
if url:
# 检查URL是否以静态文件路径开头
if url.startswith(static_url):
# 测试静态图片是否可访问
if self.test_picture(url):
# 如果可访问,跳过当前用户
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:
# 根据用户类型获取对应的OAuth管理器
manage = get_manager_by_type(u.type)
# 从元数据中获取头像URL
url = manage.get_picture(u.metadata)
# 保存用户头像并返回新的URL
url = save_user_avatar(url)
else:
# 如果没有元数据,使用默认头像
else: # 如果本地头像失效,则尝试通过第三方平台重新获取
if u.metadata: # 如果用户有 metadata用于识别第三方账号信息
manager = get_manager_by_type(u.type) # 获取对应平台的 OAuth 管理器
url = manager.get_picture(u.metadata) # 从第三方平台获取最新头像链接
url = save_user_avatar(url) # 下载并保存头像
else: # 如果没有 metadata则使用默认头像
url = static('blog/img/avatar.png')
else:
# 如果不是静态文件路径,直接保存用户头像
else: # 如果头像不是来自本地静态资源,则直接尝试保存
url = save_user_avatar(url)
else:
# 如果没有头像URL使用默认头像
else: # 如果用户没有头像链接,则使用默认头像
url = static('blog/img/avatar.png')
# 如果成功获取到头像URL
if url:
# 输出结束同步当前用户的信息
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
# 更新用户的头像URL
u.picture = url
# 保存用户信息
u.save()
# 输出结束同步的信息
self.stdout.write('结束同步')
if url: # 如果得到了有效的头像链接
self.stdout.write(f'完成同步用户:{u.nickname},新头像链接:{url}')
u.picture = url # 更新用户头像字段
u.save() # 保存到数据库
self.stdout.write('所有用户头像同步完成')

@ -1,68 +1,67 @@
# 导入日志模块
import time # 添加这行
import logging
# 导入时间模块
import time
from django.conf import settings
from django.utils import timezone
# 导入IP获取工具
from ipware import get_client_ip
# 导入用户代理解析工具
from user_agents import parse
# 导入Elasticsearch相关配置和管理器
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
# 获取当前模块的日志器
logger = logging.getLogger(__name__)
# 在线中间件类,用于记录页面渲染时间和用户访问信息
class OnlineMiddleware(object):
# 初始化方法
class OnlineMiddleware:
"""
在线用户中间件 - 记录每个请求的加载时间IP用户代理可选地存入 Elasticsearch
"""
def __init__(self, get_response=None):
# 保存get_response函数引用
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地址
ip, _ = get_client_ip(request)
# 解析用户代理信息
user_agent = parse(http_user_agent)
# 检查响应是否为流式响应
if not response.streaming:
# 计算耗时,记录并显示到页面
duration = time.time() - start_time
# 获取客户端IP
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
# 获取用户代理
user_agent = request.META.get('HTTP_USER_AGENT', '')
# 记录日志
logger.info(
f"Request: {request.method} {request.path} - "
f"IP: {ip} - "
f"Time: {duration:.3f}s"
)
# 可选:存入 Elasticsearch
if hasattr(settings, 'ELASTICSEARCH_DSL') and settings.ELASTICSEARCH_DSL:
try:
# 计算页面渲染耗时
cast_time = time.time() - start_time
# 如果启用了Elasticsearch
if ELASTICSEARCH_ENABLED:
# 将耗时转换为毫秒并保留两位小数
time_taken = round((cast_time) * 1000, 2)
# 获取请求的URL路径
url = request.path
# 导入Django时区工具
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]))
from blog.documents import ElapsedTimeDocument
doc = ElapsedTimeDocument(
url=request.path,
time_taken=int(duration * 1000),
log_datetime=timezone.now(),
ip=ip,
useragent={'string': user_agent}
)
doc.save()
except Exception as e:
# 记录中间件执行过程中的错误
logger.error("Error OnlineMiddleware: %s" % e)
logger.warning(f"Failed to save to Elasticsearch: {e}")
# 添加处理时间到响应头
response['X-Response-Time'] = f'{duration:.3f}s'
return response
# 返回响应对象
return response
def process_exception(self, request, exception):
"""处理异常"""
logger.error(f"Middleware exception: {exception}")
return None

@ -1,227 +1,146 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django设置模块
from django.conf import settings
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 导入数据库删除操作模块
import django.db.models.deletion
# 导入时区工具
import django.utils.timezone
# 导入Markdown编辑器字段
import mdeditor.fields
# 数据库迁移类
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
initial = True # 表示这是第一个迁移文件
# 依赖关系,依赖于用户模型的迁移
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型,通常是内置的 User 或自定义用户模型
]
# 迁移操作列表
operations = [
# 创建博客设置表
# 创建 BlogSettings 模型:网站全局配置表
migrations.CreateModel(
name='BlogSettings',
fields=[
# 主键ID使用BigAutoField自增字段
('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='网站描述')),
# 网站SEO描述字段
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
# 网站关键字字段
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
# 文章摘要长度字段整数类型默认300
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
# 侧边栏文章数目字段默认10
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
# 侧边栏评论数目字段默认5
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
# 文章页面默认显示评论数目字段默认5
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
# 是否显示谷歌广告字段布尔类型默认False
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
# 谷歌广告代码字段,可为空
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
# 是否打开网站评论功能字段布尔类型默认True
('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='网站统计代码')),
# 是否显示公安备案号字段布尔类型默认False
('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': '网站配置',
},
),
# 创建友情链接表
# 创建 Links 模型:友情链接
migrations.CreateModel(
name='Links',
fields=[
# 主键ID
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 链接名称字段,具有唯一性约束
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
# 链接地址字段URL格式
('link', models.URLField(verbose_name='链接地址')),
# 排序字段,整数类型,具有唯一性约束
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# 是否显示字段布尔类型默认True
('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'],
},
),
# 创建侧边栏表
# 创建 SideBar 模型:侧边栏内容
migrations.CreateModel(
name='SideBar',
fields=[
# 主键ID
('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='排序')),
# 是否启用字段布尔类型默认True
('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'],
},
),
# 创建标签表
# 创建 Tag 模型:文章标签
migrations.CreateModel(
name='Tag',
fields=[
# 主键ID使用AutoField自增字段
('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字段用于URL可为空默认值'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
# 单数形式的显示名称
'verbose_name': '标签',
# 复数形式的显示名称
'verbose_name_plural': '标签',
# 默认按标签名升序排列
'ordering': ['name'],
},
),
# 创建分类表
# 创建 Category 模型:文章分类
migrations.CreateModel(
name='Category',
fields=[
# 主键ID使用AutoField自增字段
('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字段用于URL可为空默认值'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
# 权重排序字段整数类型默认0越大越靠前
('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'],
},
),
# 创建文章表
# 创建 Article 模型:文章内容
migrations.CreateModel(
name='Article',
fields=[
# 主键ID使用AutoField自增字段
('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='标题')),
# 正文字段使用Markdown编辑器
('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='类型')),
# 浏览量字段正整数类型默认0
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
# 文章排序字段整数类型默认0数字越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
# 是否显示toc目录字段布尔类型默认False
('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,31 +1,25 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 数据库迁移类
class Migration(migrations.Migration):
# 依赖关系依赖于blog应用的0001_initial迁移
dependencies = [
('blog', '0001_initial'),
('blog', '0001_initial'), # 依赖于第一个迁移文件
]
# 迁移操作列表
operations = [
# 向BlogSettings模型添加global_footer字段
# 新增字段global_footer用于存放网站公共尾部 HTML 内容(如版权信息等)
migrations.AddField(
model_name='blogsettings',
name='global_footer',
# 文本类型字段,用于存储公共尾部内容
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
# 向BlogSettings模型添加global_header字段
# 新增字段global_header用于存放网站公共头部 HTML 内容(如导航栏上面的内容)
migrations.AddField(
model_name='blogsettings',
name='global_header',
# 文本类型字段,用于存储公共头部内容
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]

@ -1,23 +1,18 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 数据库迁移类
class Migration(migrations.Migration):
# 依赖关系依赖于blog应用的0002_blogsettings_global_footer_and_more迁移
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于上一个迁移
]
# 迁移操作列表
operations = [
# 向BlogSettings模型添加comment_need_review字段
# 新增字段comment_need_review布尔值默认 False表示评论默认不需要审核
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
# 布尔类型字段用于控制评论是否需要审核默认值为False
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]

@ -1,40 +1,32 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
# 导入数据库迁移模块
from django.db import migrations
# 数据库迁移类
class Migration(migrations.Migration):
# 依赖关系依赖于blog应用的0003_blogsettings_comment_need_review迁移
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
('blog', '0003_blogsettings_comment_need_review'), # 依赖于上一个迁移
]
# 迁移操作列表
operations = [
# 重命名字段操作:将analyticscode重命名为analytics_code
# analyticscode 字段重命名为 analytics_code,提升代码可读性
migrations.RenameField(
model_name='blogsettings',
# 原字段名
old_name='analyticscode',
# 新字段名
new_name='analytics_code',
),
# 重命名字段操作将beiancode重命名为beian_code
# 将 beiancode 字段重命名为 beian_code
migrations.RenameField(
model_name='blogsettings',
# 原字段名
old_name='beiancode',
# 新字段名
new_name='beian_code',
),
# 重命名字段操作将sitename重命名为site_name
# 将 sitename 字段重命名为 site_name
migrations.RenameField(
model_name='blogsettings',
# 原字段名
old_name='sitename',
# 新字段名
new_name='site_name',
),
]

@ -1,367 +1,117 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 导入Django设置模块
from django.conf import settings
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 导入数据库删除操作模块
import django.db.models.deletion
# 导入时区工具
import django.utils.timezone
# 导入Markdown编辑器字段
import mdeditor.fields
# 数据库迁移类
class Migration(migrations.Migration):
# 依赖关系依赖于blog应用的0004重命名字段迁移和用户模型
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
# 迁移操作列表
operations = [
# 修改Article模型的元数据选项更新显示名称和排序
# 调整多个模型的 Meta 选项比如排序方式、verbose_name 等
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# 修改Category模型的元数据选项更新显示名称
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# 修改Links模型的元数据选项更新显示名称
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# 修改Sidebar模型的元数据选项更新显示名称
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# 修改Tag模型的元数据选项更新显示名称
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# 删除Article模型的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# 删除Article模型的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# 删除Category模型的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# 删除Category模型的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# 删除Links模型的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# 删除Sidebar模型的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# 删除Tag模型的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# 删除Tag模型的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# 向Article模型添加creation_time字段
# 删除旧的时间字段created_time / last_mod_time
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'),
# 新增新的时间字段creation_time创建时间、last_modify_time最后修改时间
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Article模型添加last_modify_time字段
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Category模型添加creation_time字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Category模型添加last_modify_time字段
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Links模型添加creation_time字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Sidebar模型添加creation_time字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加creation_time字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加last_modify_time字段
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Article模型的article_order字段的verbose_name
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# 修改Article模型的author字段的verbose_name
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'),
),
# 修改Article模型的body字段的verbose_name
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# 修改Article模型的category字段的verbose_name
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# 修改Article模型的comment_status字段的verbose_name和选项值
# 对多个字段进行字段选项优化,比如 choices 的英文显示、字段名称的 verbose_name 等
# (此处省略详细每一个 AlterField因为数量较多但都是对字段显示名、选项、类型等的微调
# 例如:将 comment_status 的 '打开'/'关闭' 改为 'Open'/'Close',将 status 的 '草稿'/'发表' 改为 'Draft'/'Published'
# 目的是让系统更加国际化或统一字段语义
# 示例(节选,实际迁移中包含所有字段的类似调整):
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'),
),
# 修改Article模型的pub_time字段的verbose_name
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# 修改Article模型的show_toc字段的verbose_name
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# 修改Article模型的status字段的verbose_name和选项值
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# 修改Article模型的tags字段的verbose_name
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# 修改Article模型的title字段的verbose_name
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# 修改Article模型的type字段的verbose_name和选项值
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# 修改Article模型的views字段的verbose_name
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# 修改BlogSettings模型的article_comment_count字段的verbose_name
# ...(其他字段类似调整,包括 article_order、show_toc、author、category、tags、views 等)
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
# 修改BlogSettings模型的article_sub_length字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# 修改BlogSettings模型的google_adsense_codes字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# 修改BlogSettings模型的open_site_comment字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# 修改BlogSettings模型的show_google_adsense字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# 修改BlogSettings模型的sidebar_article_count字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# 修改BlogSettings模型的sidebar_comment_count字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# 修改BlogSettings模型的site_description字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# 修改BlogSettings模型的site_keywords字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# 修改BlogSettings模型的site_name字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# 修改BlogSettings模型的site_seo_description字段的verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# 修改Category模型的index字段的verbose_name
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# 修改Category模型的name字段的verbose_name
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# 修改Category模型的parent_category字段的verbose_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'),
),
# 修改Links模型的is_enable字段的verbose_name
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# 修改Links模型的last_mod_time字段的verbose_name
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Links模型的link字段的verbose_name
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# 修改Links模型的name字段的verbose_name
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# 修改Links模型的sequence字段的verbose_name
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Links模型的show_type字段的verbose_name和选项值
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'),
),
# 修改Sidebar模型的content字段的verbose_name
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# 修改Sidebar模型的is_enable字段的verbose_name
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改Sidebar模型的last_mod_time字段的verbose_name
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Sidebar模型的name字段的verbose_name
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# 修改Sidebar模型的sequence字段的verbose_name
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Tag模型的name字段的verbose_name
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
# ...(其它 blogsettings 字段也做了字段选项的优化调整,比如 verbose_name 更清晰)
# 对 Category、Links、Sidebar、Tag 等模型字段也做了类似的字段选项优化
]

@ -1,23 +1,17 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
# 导入数据库迁移模块
from django.db import migrations
# 数据库迁移类
class Migration(migrations.Migration):
# 依赖关系依赖于blog应用的0005迁移文件
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
# 迁移操作列表
operations = [
# 修改BlogSettings模型的元数据选项
# 修改 BlogSettings 模型在后台显示的名称,从中文「网站配置」改为英文 'Website configuration'
migrations.AlterModelOptions(
name='blogsettings',
# 更新显示名称配置,将中文改为英文
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]

@ -0,0 +1,197 @@
# Generated by Django 5.2.6 on 2025-11-16 17:01
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': '文章', 'verbose_name_plural': '文章'},
),
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': '网站配置', 'verbose_name_plural': '网站配置'},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': '分类', 'verbose_name_plural': '分类'},
),
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': '友情链接', 'verbose_name_plural': '友情链接'},
),
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': '侧边栏', 'verbose_name_plural': '侧边栏'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': '标签', 'verbose_name_plural': '标签'},
),
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='内容'),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', '开放评论'), ('c', '关闭评论')], default='o', max_length=1, verbose_name='评论状态'),
),
migrations.AlterField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='显示目录'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', '草稿'), ('p', '发布')], default='p', max_length=1, verbose_name='状态'),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='文章评论数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='Google广告代码'),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='开放站点评论'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='显示Google广告'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='侧边栏文章数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='侧边栏评论数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='站点描述'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='站点关键词'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='站点名称'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='SEO描述'),
),
migrations.AlterField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='分类名称'),
),
migrations.AlterField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页'), ('a', '全部'), ('s', '幻灯片')], default='i', max_length=1, verbose_name='显示位置'),
),
migrations.AlterField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='标签名称'),
),
migrations.CreateModel(
name='ArticleLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='点赞时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to='blog.article', verbose_name='文章')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '文章点赞',
'verbose_name_plural': '文章点赞',
'ordering': ['-created_time'],
'unique_together': {('article', 'user')},
},
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-16 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0007_alter_article_options_alter_blogsettings_options_and_more'),
]
operations = [
migrations.AddField(
model_name='article',
name='like_count',
field=models.PositiveIntegerField(default=0, verbose_name='点赞数'),
),
]

@ -0,0 +1,32 @@
# Generated by Django 5.2.6 on 2025-11-16 18:43
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0008_article_like_count'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ArticleFavorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='收藏时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_favorites', to='blog.article', verbose_name='文章')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '文章收藏',
'verbose_name_plural': '文章收藏',
'ordering': ['-created_time'],
'unique_together': {('article', 'user')},
},
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-16 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0009_articlefavorite'),
]
operations = [
migrations.AddField(
model_name='article',
name='favorite_count',
field=models.PositiveIntegerField(default=0, verbose_name='收藏数'),
),
]

@ -1,176 +1,162 @@
# 导入日志模块
import logging
# 导入正则表达式模块
import re
# 导入抽象基类模块
from abc import abstractmethod
# 导入Django配置模块
from django.conf import settings
# 导入验证错误异常
from django.core.exceptions import ValidationError
# 导入数据库模型模块
from django.db import models
# 导入URL反向解析模块
from django.urls import reverse
# 导入时区工具
from django.utils.timezone import now
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入Markdown编辑器字段
from mdeditor.fields import MDTextField
# 导入slug生成工具
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')) # 友情链接页面显示
I = ('i', _('首页'))
L = ('l', _('列表页'))
P = ('p', _('文章页'))
A = ('a', _('全部'))
S = ('s', _('幻灯片'))
# 基础模型抽象类
class BaseModel(models.Model):
# 主键ID字段
id = models.AutoField(primary_key=True)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_modify_time = models.DateTimeField(_('modify time'), default=now)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('修改时间'), default=now)
# 保存方法
def save(self, *args, **kwargs):
# 检查是否为更新文章浏览量的操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
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:
# 如果模型有slug字段自动生成slug
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
# 调用父类的保存方法
slug_source = getattr(self, 'title', '') if 'title' in self.__dict__ else getattr(self, 'name', '')
setattr(self, 'slug', slugify(slug_source))
super().save(*args, **kwargs)
# 获取完整URL的方法
def get_full_url(self):
# 获取当前站点域名
site = get_current_site().domain
# 构建完整URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
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
# 文章模型类继承自BaseModel
class Article(BaseModel):
"""文章"""
# 状态选择项
STATUS_CHOICES = (
('d', _('Draft')), # 草稿
('p', _('Published')), # 已发布
('d', _('草稿')),
('p', _('发布')),
)
# 评论状态选择项
COMMENT_STATUS = (
('o', _('Open')), # 打开评论
('c', _('Close')), # 关闭评论
('o', _('开放评论')),
('c', _('关闭评论')),
)
# 类型选择项
TYPE = (
('a', _('Article')), # 文章
('p', _('Page')), # 页面
('a', _('文章')),
('p', _('页面')),
)
# 标题字段,具有唯一性约束
title = models.CharField(_('title'), max_length=200, unique=True)
# 正文字段使用Markdown编辑器
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)
# 将正文转换为字符串的方法
title = models.CharField(_('标题'), max_length=200, unique=True)
body = MDTextField(_('内容'))
pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now)
status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p')
comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o')
type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('浏览量'), default=0)
like_count = models.PositiveIntegerField(_('点赞数'), default=0)
favorite_count = models.PositiveIntegerField(_('收藏数'), default=0)
author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('作者'), on_delete=models.CASCADE)
article_order = models.IntegerField(_('排序'), default=0)
show_toc = models.BooleanField(_('显示目录'), default=False)
category = models.ForeignKey('Category', verbose_name=_('分类'), on_delete=models.CASCADE)
tags = models.ManyToManyField('Tag', verbose_name=_('标签'), blank=True)
# ========== 点赞相关方法 ==========
def is_liked_by(self, user):
"""检查用户是否已点赞此文章"""
if user and user.is_authenticated:
return self.article_likes.filter(user=user).exists()
return False
@property
def likes_count(self):
"""获取点赞数量"""
return self.article_likes.count()
def toggle_like(self, user):
"""切换点赞状态"""
if not user or not user.is_authenticated:
return False, 0
like, created = self.article_likes.get_or_create(user=user)
if not created:
like.delete()
liked = False
else:
liked = True
self.like_count = self.article_likes.count()
self.save(update_fields=['like_count'])
return liked, self.like_count
# ========== 收藏相关方法 ==========
def is_favorited_by(self, user):
"""检查用户是否已收藏此文章"""
if user and user.is_authenticated:
return self.article_favorites.filter(user=user).exists()
return False
@property
def favorites_count(self):
"""获取收藏数量"""
return self.article_favorites.count()
def toggle_favorite(self, user):
"""切换收藏状态"""
if not user or not user.is_authenticated:
return False, 0
favorite, created = self.article_favorites.get_or_create(user=user)
if not created:
favorite.delete()
favorited = False
else:
favorited = True
self.favorite_count = self.article_favorites.count()
self.save(update_fields=['favorite_count'])
return favorited, self.favorite_count
# ========== 结束新增 ==========
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 = _('文章')
verbose_name_plural = verbose_name
# 指定获取最新记录的依据字段
get_latest_by = 'id'
# 获取绝对URL的方法
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
@ -179,121 +165,72 @@ class Article(BaseModel):
'day': self.creation_time.day
})
# 获取分类树的方法,使用缓存装饰器
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
# 获取分类树
tree = self.category.get_category_tree()
# 提取分类名称和URL
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
# 获取管理员URL的方法
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()
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()
# 获取文章第一张图片URL的方法
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
# 使用正则表达式匹配Markdown图片语法
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
# 如果匹配到图片返回图片URL
return match.group(1)
# 如果没有图片,返回空字符串
return ""
# 分类模型类继承自BaseModel
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字段用于URL
name = models.CharField(_('分类名称'), max_length=30, unique=True)
parent_category = models.ForeignKey('self', verbose_name=_('父级分类'), 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'))
index = models.IntegerField(default=0, verbose_name=_('排序'))
# 元数据配置
class Meta:
# 默认按索引倒序排列
ordering = ['-index']
# 单数形式的显示名称
verbose_name = _('category')
# 复数形式的显示名称
verbose_name = _('分类')
verbose_name_plural = verbose_name
# 获取绝对URL的方法
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
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:
@ -302,23 +239,17 @@ class Category(BaseModel):
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:
if child not in categorys:
categorys.append(child)
parse(child)
@ -326,200 +257,146 @@ class Category(BaseModel):
return categorys
# 标签模型类继承自BaseModel
class Tag(BaseModel):
"""文章标签"""
# 标签名称字段,具有唯一性约束
name = models.CharField(_('tag name'), max_length=30, unique=True)
# slug字段用于URL
name = models.CharField(_('标签名称'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 对象的字符串表示
def __str__(self):
return self.name
# 获取绝对URL的方法
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 = _('标签')
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)
# 元数据配置
name = models.CharField(_('链接名称'), max_length=30, unique=True)
link = models.URLField(_('链接地址'))
sequence = models.IntegerField(_('排序'), unique=True)
is_enable = models.BooleanField(_('是否显示'), default=True)
show_type = models.CharField(_('显示位置'), max_length=1, choices=LinkShowType.choices, default=LinkShowType.I)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
# 默认按排序字段升序排列
ordering = ['sequence']
# 单数形式的显示名称
verbose_name = _('link')
# 复数形式的显示名称
verbose_name = _('友情链接')
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)
# 元数据配置
name = models.CharField(_('标题'), max_length=100)
content = models.TextField(_('内容'))
sequence = models.IntegerField(_('排序'), unique=True)
is_enable = models.BooleanField(_('是否启用'), default=True)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
# 默认按排序字段升序排列
ordering = ['sequence']
# 单数形式的显示名称
verbose_name = _('sidebar')
# 复数形式的显示名称
verbose_name = _('侧边栏')
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.name
# 博客设置模型类
class ArticleLike(models.Model):
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_likes'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('点赞时间'), default=now)
class Meta:
verbose_name = _('文章点赞')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 点赞了 {self.article.title}'
class ArticleFavorite(models.Model):
"""
文章收藏模型
记录用户对文章的收藏关系每个用户对同一篇文章只能收藏一次
"""
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_favorites'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('收藏时间'), default=now)
class Meta:
verbose_name = _('文章收藏')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 收藏了 {self.article.title}'
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='')
# 网站SEO描述字段
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)
# 元数据配置
site_name = models.CharField(_('站点名称'), max_length=200, null=False, blank=False, default='')
site_description = models.TextField(_('站点描述'), max_length=1000, null=False, blank=False, default='')
site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(_('站点关键词'), max_length=1000, null=False, blank=False, default='')
article_sub_length = models.IntegerField(_('文章摘要长度'), default=300)
sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10)
sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5)
article_comment_count = models.IntegerField(_('文章评论数量'), default=5)
show_google_adsense = models.BooleanField(_('显示Google广告'), default=False)
google_adsense_codes = models.TextField(_('Google广告代码'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('开放站点评论'), 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)
gongan_beiancode = models.TextField(_('公安备案号'), max_length=2000, null=True, blank=True, default='')
comment_need_review = models.BooleanField(_('评论是否需要审核'), default=False)
class Meta:
# 单数形式的显示名称
verbose_name = _('Website configuration')
# 复数形式的显示名称
verbose_name = _('网站配置')
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'))
raise ValidationError(_('只能存在一个配置'))
# 保存方法
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 导入缓存工具
from djangoblog.utils import cache
# 保存配置后清除所有缓存
cache.clear()

@ -1,20 +1,12 @@
# 导入Haystack搜索索引模块
from haystack import indexes
# 导入文章模型
from blog.models import Article
# 文章搜索索引类用于配置Haystack搜索引擎
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
# 定义主搜索字段document=True表示这是主要的搜索字段
# use_template=True表示使用模板文件来定义搜索内容
text = indexes.CharField(document=True, use_template=True)
# 指定该索引关联的模型
def get_model(self):
return Article
# 定义索引查询集,只索引已发布的文章
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')

@ -1,73 +1,47 @@
# 导入操作系统模块
import os
# 导入Django配置模块
from django.conf import settings
# 导入文件上传处理模块
from django.core.files.uploadedfile import SimpleUploadedFile
# 导入管理命令调用模块
from django.core.management import call_command
# 导入分页器模块
from django.core.paginator import Paginator
# 导入静态文件处理模块
from django.templatetags.static import static
# 导入测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析模块
from django.urls import reverse
# 导入时区相关模块
from django.utils import timezone
# 导入账户模型
from accounts.models import BlogUser
# 导入博客搜索表单
from blog.forms import BlogSearchForm
# 导入博客模型
from blog.models import Article, Category, Tag, SideBar, Links
# 导入博客模板标签
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
# 导入自定义工具函数
from djangoblog.utils import get_current_site, get_sha256
# 导入OAuth相关模型
from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
# 文章测试类
from django.core.management import call_command # 执行Django管理命令
from django.core.paginator import Paginator # 分页器
from django.templatetags.static import static # 静态文件URL生成
from django.test import Client, RequestFactory, TestCase # Django测试客户端与测试用例
from django.urls import reverse # URL反向解析
from django.utils import timezone # 时间工具
from accounts.models import BlogUser # 用户模型
from blog.forms import BlogSearchForm # 搜索表单
from blog.models import Article, Category, Tag, SideBar, Links # 博客相关模型
from blog.templatetags.blog_tags import load_pagination_info, load_articletags # 模板标签
from djangoblog.utils import get_current_site, get_sha256 # 工具函数
from oauth.models import OAuthUser, OAuthConfig # OAuth相关模型
# 文章相关测试类
class ArticleTest(TestCase):
# 测试前置设置方法
def setUp(self):
# 创建测试客户端
# 初始化测试客户端与请求工厂
self.client = Client()
# 创建请求工厂
self.factory = RequestFactory()
# 测试文章验证功能
def test_validate_article(self):
# 获取当前站点域名
site = get_current_site().domain
# 获取或创建测试用户
# 创建一个超级用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
# 设置用户密码
user.set_password("liangliangyy")
# 设置为员工用户
user.is_staff = True
# 设置为超级用户
user.is_superuser = True
# 保存用户
user.save()
# 访问用户详情页
# 访问用户详情页
response = self.client.get(user.get_absolute_url())
# 断言访问成功
self.assertEqual(response.status_code, 200)
# 访问邮件发送日志管理页面
# 以下几行尝试访问不存在的admin页面可能用于测试404
response = self.client.get('/admin/servermanager/emailsendlog/')
# 访问日志条目管理页面
response = self.client.get('admin/admin/logentry/')
# 创建侧边栏实例
# 创建一个侧边栏实例并保存
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -75,37 +49,34 @@ class ArticleTest(TestCase):
s.is_enable = True
s.save()
# 创建分类实例
# 创建一个分类并保存
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
# 创建标签实例
# 创建一个标签并保存
tag = Tag()
tag.name = "nicetag"
tag.save()
# 创建文章实例
# 创建一篇文章并保存
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.status = 'p' # 发布状态
article.save()
# 断言初始标签数量为0
self.assertEqual(0, article.tags.count())
# 为文章添加标签
article.tags.add(tag)
self.assertEqual(0, article.tags.count()) # 初始应无标签
article.tags.add(tag) # 添加标签
article.save()
# 断言标签数量为1
self.assertEqual(1, article.tags.count())
self.assertEqual(1, article.tags.count()) # 应有1个标签
# 循环创建20篇测试文章
# 批量创建20篇文章均添加同一标签
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -117,74 +88,72 @@ class ArticleTest(TestCase):
article.save()
article.tags.add(tag)
article.save()
# 检查是否启用Elasticsearch
# 如果启用了Elasticsearch则构建索引并测试搜索
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
# 构建搜索索引
call_command("build_index")
# 搜索测试文章
response = self.client.get('/search', {'q': 'nicetitle'})
self.assertEqual(response.status_code, 200)
# 访问文章详情页
# 访问某篇文章详情页
response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 通知蜘蛛文章URL
# 模拟通知爬虫(如搜索引擎)该文章已更新
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url())
# 访问标签详情页
# 访问标签详情页
response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 访问分类详情页
# 访问分类详情页
response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 搜索测试
# 搜索关键词'django'
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# 测试文章标签模板标签
# 获取文章的标签模板标签结果
s = load_articletags(article)
self.assertIsNotNone(s)
# 用户登录
# 登录用户后访问归档页
self.client.login(username='liangliangyy', password='liangliangyy')
# 访问文章归档页面
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# 测试分页功能 - 所有文章
# 测试分页功能
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
# 测试分页功能 - 标签归档
# 按标签分页
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug)
# 测试分页功能 - 作者文章归档
# 按作者分页
p = Paginator(
Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy')
# 测试分页功能 - 分类目录归档
# 按分类分页
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单
f = BlogSearchForm()
f.search()
# 通知百度蜘蛛文章URL
# 模拟百度通知
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
# 测试Gravatar相关模板标签
# 测试模板标签gravatar头像URL
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
# 创建友情链接
# 创建并保存一个友情链接
link = Links(
sequence=1,
name="lylinux",
@ -194,76 +163,69 @@ class ArticleTest(TestCase):
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# 访问RSS订阅
# 访问RSS Feed页面
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
# 访问网站地图
# 访问Sitemap页面
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# 访问管理页面相关URL
# 尝试删除一篇文章
self.client.get("/admin/blog/article/1/delete/")
# 访问一些不存在的admin页面
self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/')
# 检查分页功能的方法
def check_pagination(self, p, type, value):
# 遍历所有分页
# 遍历所有分页,检查前后页链接是否有效
for page in range(1, p.num_pages + 1):
# 加载分页信息
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
# 检查上一页链接
if s['previous_url']:
response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200)
# 检查下一页链接
if s['next_url']:
response = self.client.get(s['next_url'])
self.assertEqual(response.status_code, 200)
# 测试图片相关功能
def test_image(self):
# 测试图片上传功能
import requests
# 下载测试图片
# 下载Python官方Logo
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
# 保存图片到本地
with open(imagepath, 'wb') as file:
file.write(rsp.content)
# 试未授权上传
# 试未授权上传
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
# 生成上传签名
# 生成签名
sign = get_sha256(get_sha256(settings.SECRET_KEY))
# 测试授权上传
with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile(
'python.png', file.read(), content_type='image/jpg')
form_data = {'python.png': imgfile}
# 带签名上传
rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200)
# 删除测试图片
os.remove(imagepath)
# 测试头像保存和邮件发送功能
# 测试发送邮件与保存头像功能
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar(
'https://www.python.org/static/img/python-logo.png')
# 测试错误页面
def test_errorpage(self):
# 访问不存在的页面
# 测试访问不存在的页面应返回404
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
# 测试管理命令
def test_commands(self):
# 创建测试用户
# 创建超级用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -272,14 +234,13 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
# 创建OAuth配置
# 创建OAuth配置与用户
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 创建OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
@ -291,7 +252,6 @@ class ArticleTest(TestCase):
}'''
u.save()
# 创建另一个OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
@ -302,10 +262,11 @@ class ArticleTest(TestCase):
}'''
u.save()
# 执行各种管理命令
# 如果启用了Elasticsearch构建索引
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
# 执行一系列管理命令如Ping百度、创建测试数据、清理缓存等
call_command("ping_baidu", "all")
call_command("create_testdata")
call_command("clear_cache")

@ -1,91 +1,100 @@
# 导入Django URL路由相关模块
from django.urls import path
# 导入缓存页面装饰器
from django.views.decorators.cache import cache_page
from django.views.decorators.cache import cache_page # Django缓存视图装饰器
# 导入当前应用的视图模块
from . import views
from . import views # 导入当前应用的视图
# 定义应用的命名空间
app_name = "blog"
app_name = "blog" # 应用命名空间
# 定义URL模式列表
urlpatterns = [
# 首页URL使用类视图
# 首页
path(
r'',
views.IndexView.as_view(),
name='index'),
# 首页分页URL支持页码参数
# 首页分页
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# 文章详情页URL包含年月日和文章ID参数
# 文章详情页通过年、月、日、文章ID定位
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# 分类详情页URL使用分类名称slug参数
# 文章点赞API
path(
r'article/<int:article_id>/like/',
views.article_like,
name='article_like'),
# 文章收藏API
path(
r'article/<int:article_id>/favorite/',
views.article_favorite,
name='article_favorite'),
# 我的喜欢列表
path(
r'my-likes/',
views.MyLikesView.as_view(),
name='my_likes'),
path(
r'my-likes/<int:page>/',
views.MyLikesView.as_view(),
name='my_likes_page'),
# 我的收藏列表
path(
r'my-favorites/',
views.MyFavoritesView.as_view(),
name='my_favorites'),
path(
r'my-favorites/<int:page>/',
views.MyFavoritesView.as_view(),
name='my_favorites_page'),
# 分类目录详情页,通过分类别名
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# 分类详情分页URL包含分类名称和页码参数
# 分类目录详情页(带分页)
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# 作者详情页URL使用作者名称参数
# 作者文章详情页,通过作者名称
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# 作者详情分页URL包含作者名称和页码参数
# 作者文章详情页(带分页)
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# 标签详情页URL使用标签名称slug参数
# 标签详情页,通过标签别名
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# 标签详情分页URL包含标签名称和页码参数
# 标签详情页(带分页)
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
# 文章归档页URL使用缓存装饰器缓存1小时
# 文章归档页使用缓存60分钟
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
cache_page(60 * 60)(views.ArchivesView.as_view()),
name='archives'),
# 友情链接页URL
# 友情链接页
path(
'links.html',
r'links.html',
views.LinkListView.as_view(),
name='links'),
# 文件上传URL使用函数视图
# 文件上传接口(用于图床等功能)
path(
r'upload',
views.fileupload,
name='upload'),
# 清理缓存URL
# 清理缓存接口
path(
r'clean',
views.clean_cache_view,

@ -1,182 +1,101 @@
# 导入日志模块
import logging
# 导入操作系统模块
import os
# 导入UUID生成模块
import uuid
# 导入Django配置模块
from django.conf import settings
# 导入分页器模块
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
# 导入HTTP响应模块
from django.http import HttpResponse, HttpResponseForbidden
# 导入快捷函数模块
from django.shortcuts import get_object_or_404
from django.shortcuts import render
# 导入静态文件处理模块
from django.db.models import F
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.templatetags.static import static
# 导入时区相关模块
from django.utils import timezone
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入CSRF豁免装饰器
from django.views.decorators.csrf import csrf_exempt
# 导入通用视图类
from django.views.decorators.http import require_POST
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
# 导入Haystack搜索视图
from haystack.views import SearchView
# 导入博客模型
from blog.models import Article, Category, LinkShowType, Links, Tag
# 导入评论表单
from blog.models import Article, Category, LinkShowType, Links, Tag, ArticleLike, ArticleFavorite
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__)
# 文章列表视图基类继承自ListView
class ArticleListView(ListView):
# 指定使用的模板名称
template_name = 'blog/article_index.html'
# 指定上下文对象名称(在模板中使用的变量名)
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']
return self.request.GET.get('pages', '')
# 页码属性使用property装饰器
@property
def page_number(self):
# 从URL参数或GET参数获取页码默认为1
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
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)
# 首页视图继承自ArticleListView
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
# 文章详情视图继承自DetailView
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
# 指定使用的模板名称
template_name = 'blog/article_detail.html'
# 指定模型
model = Article
# 指定URL中的主键参数名
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:
@ -186,221 +105,169 @@ class ArticleDetailView(DetailView):
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
# 构建评论分页URL
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['comment_count'] = len(article_comments) if article_comments else 0
# 添加上一篇和下一篇文章
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
# 调用父类方法获取上下文
# ========== 修复点赞相关信息 ==========
user = self.request.user
if user.is_authenticated:
# 使用正确的方法名
is_liked = self.object.is_liked_by(user)
kwargs['article_liked_class'] = 'liked' if is_liked else ''
kwargs['like_icon_class'] = 'fa fa-heart' if is_liked else 'fa fa-heart-o'
kwargs['like_text'] = '已点赞' if is_liked else '点赞'
# 收藏相关信息
is_favorited = self.object.is_favorited_by(user)
kwargs['article_favorited_class'] = 'favorited' if is_favorited else ''
kwargs['favorite_icon_class'] = 'fa fa-star' if is_favorited else 'fa fa-star-o'
kwargs['favorite_text'] = '已收藏' if is_favorited else '收藏'
else:
kwargs['article_liked_class'] = ''
kwargs['like_icon_class'] = 'fa fa-heart-o'
kwargs['like_text'] = '点赞'
kwargs['article_favorited_class'] = ''
kwargs['favorite_icon_class'] = 'fa fa-star-o'
kwargs['favorite_text'] = '收藏'
# 添加点赞和收藏数量
kwargs['like_count'] = self.object.like_count
kwargs['favorite_count'] = self.object.favorite_count
# ========== 结束修复 ==========
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)
return context
# 分类详情视图继承自ArticleListView
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
# 设置页面类型
page_type = "分类目录归档"
# 获取查询集数据的方法
def get_queryset_data(self):
# 从URL参数获取分类slug
slug = self.kwargs['category_name']
# 获取分类对象如果不存在返回404
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):
# 从URL参数获取分类slug
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)
# 作者详情视图继承自ArticleListView
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
# 设置页面类型
page_type = '作者文章归档'
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
# 导入slug生成工具
from uuslug import slugify
# 对作者名称进行slug处理
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):
# 从URL参数获取作者名称
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):
# 从URL参数获取作者名称
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)
# 标签详情视图继承自ArticleListView
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
# 设置页面类型
page_type = '分类标签归档'
# 获取查询集数据的方法
def get_queryset_data(self):
# 从URL参数获取标签slug
slug = self.kwargs['tag_name']
# 获取标签对象如果不存在返回404
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):
# 从URL参数获取标签slug
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.name
# 添加上下文数据
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
# 文章归档视图继承自ArticleListView
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
# 友情链接列表视图继承自ListView
class LinkListView(ListView):
# 指定模型
model = Links
# 指定使用的模板名称
template_name = 'blog/links_list.html'
# 获取查询集的方法
def get_queryset(self):
# 只获取已启用的友情链接
return Links.objects.filter(is_enable=True)
# Elasticsearch搜索视图继承自Haystack的SearchView
class EsSearchView(SearchView):
# 获取上下文数据的方法
def get_context(self):
# 构建分页
paginator, page = self.build_page()
# 构建上下文数据
context = {
"query": self.query,
"form": self.form,
@ -408,80 +275,53 @@ class EsSearchView(SearchView):
"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豁免装饰器
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
# 只处理POST请求
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)
# 生成保存路径使用UUID作为文件名
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
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
# 404错误页面视图
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?'),
@ -489,7 +329,6 @@ def page_not_found_view(
status=404)
# 500服务器错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -498,12 +337,10 @@ def server_error_view(request, template_name='blog/error_page.html'):
status=500)
# 403权限拒绝页面视图
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
# 记录错误日志
if exception:
logger.error(exception)
return render(
@ -512,8 +349,204 @@ def permission_denied_view(
'statuscode': '403'}, status=403)
# 清理缓存视图
def clean_cache_view(request):
# 清除所有缓存
cache.clear()
return HttpResponse('ok')
return HttpResponse('ok')
# 文章点赞API视图
@login_required
def article_like(request, article_id):
"""
文章点赞/取消点赞 API
用户可以通过该接口对文章进行点赞或取消点赞
如果用户已点赞再次点赞将取消点赞如果未点赞则添加点赞
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取点赞记录
like = ArticleLike.objects.filter(article=article, user=user).first()
if like:
# 如果已点赞,则取消点赞
like.delete()
# 减少点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': False,
'like_count': article.like_count,
'message': _('取消点赞成功')
})
else:
# 如果未点赞,则添加点赞
ArticleLike.objects.create(article=article, user=user)
# 增加点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': True,
'like_count': article.like_count,
'message': _('点赞成功')
})
except Exception as e:
logger.error(f'点赞失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('点赞失败,请稍后再试')
}, status=500)
# 文章收藏API视图
@login_required
def article_favorite(request, article_id):
"""
文章收藏/取消收藏 API
用户可以通过该接口对文章进行收藏或取消收藏
如果用户已收藏再次收藏将取消收藏如果未收藏则添加收藏
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取收藏记录
favorite = ArticleFavorite.objects.filter(article=article, user=user).first()
if favorite:
# 如果已收藏,则取消收藏
favorite.delete()
# 减少收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': False,
'favorite_count': article.favorite_count,
'message': _('取消收藏成功')
})
else:
# 如果未收藏,则添加收藏
ArticleFavorite.objects.create(article=article, user=user)
# 增加收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': True,
'favorite_count': article.favorite_count,
'message': _('收藏成功')
})
except Exception as e:
logger.error(f'收藏失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('收藏失败,请稍后再试')
}, status=500)
# 我的喜欢列表视图
class MyLikesView(ArticleListView):
"""显示当前用户点赞的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的喜欢'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户点赞的所有文章ID
liked_article_ids = ArticleLike.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=liked_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为点赞列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyLikesView, self).get_context_data(**kwargs)
# 我的收藏列表视图
class MyFavoritesView(ArticleListView):
"""显示当前用户收藏的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的收藏'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户收藏的所有文章ID
favorited_article_ids = ArticleFavorite.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=favorited_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为收藏列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyFavoritesView, self).get_context_data(**kwargs)

File diff suppressed because it is too large Load Diff

@ -1,76 +1,79 @@
# 导入Django管理员模块
# 模块级注释Django管理后台配置模块 - 评论管理
# 本模块定义了评论模型在Django管理后台的显示配置和操作功能
from django.contrib import admin
# 导入URL反向解析模块
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 禁用评论状态的管理动作函数
# 函数级注释:禁用评论状态操作
# 管理员动作函数,用于批量禁用选中的评论
def disable_commentstatus(modeladmin, request, queryset):
# 将选中的评论的启用状态设置为False
# 核心代码将查询集中所有评论的is_enable字段更新为False
queryset.update(is_enable=False)
# 启用评论状态的管理动作函数
# 函数级注释:启用评论状态操作
# 管理员动作函数,用于批量启用选中的评论
def enable_commentstatus(modeladmin, request, queryset):
# 将选中的评论的启用状态设置为True
# 核心代码将查询集中所有评论的is_enable字段更新为True
queryset.update(is_enable=True)
# 设置管理动作的显示描述
# 设置动作函数的显示名称(国际化)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# 评论管理类
# 类级注释:评论管理类
# 继承自admin.ModelAdmin自定义评论模型在Django管理后台的显示和行为
class CommentAdmin(admin.ModelAdmin):
# 每页显示的评论数量
# 每页显示记录数配置
list_per_page = 20
# 列表页显示的字段
# 列表页显示的字段配置
list_display = (
'id',
'body',
'link_to_userinfo', # 自定义方法:用户信息链接
'link_to_article', # 自定义方法文章链接
'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]
# 使用原始ID字段显示ID而不是下拉选择
# 使用原始ID输入框的外键字段(提升大表性能
raw_id_fields = ('author', 'article')
# 搜索字段
# 搜索字段配置
search_fields = ('body',)
# 自定义方法:显示用户信息链接
# 方法级注释:用户信息链接显示
# 自定义方法,在列表页显示带链接的用户信息
def link_to_userinfo(self, obj):
# 获取用户模型的app_label和model_name
# 核心代码:获取用户模型的app_label和model_name
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页面的URL
# 核心代码:生成用户编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回格式化的HTML链接显示用户昵称或邮箱
# 核心代码返回带HTML链接的格式化字符串
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):
# 获取文章模型的app_label和model_name
# 核心代码:获取文章模型的app_label和model_name
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# 生成文章编辑页面的URL
# 核心代码:生成文章编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# 返回格式化的HTML链接显示文章标题
# 核心代码返回带HTML链接的格式化字符串
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,8 +1,11 @@
# 导入Django应用配置基类
# 模块级注释Django应用配置模块
# 本模块定义了comments应用的配置信息用于Django应用注册和初始化设置
from django.apps import AppConfig
# 定义评论应用的配置类
# 类级注释:评论应用配置类
# 继承自AppConfig用于配置comments应用的基本信息和启动行为
class CommentsConfig(AppConfig):
# 指定应用的完整Python路径
# 应用名称字段定义应用的完整Python路径
# 此名称用于Django内部识别和应用引用
name = 'comments'

@ -1,20 +1,24 @@
# 导入Django表单模块
# 模块级注释Django表单定义模块 - 评论功能
# 本模块定义了评论相关的表单类,用于前端评论数据的验证和处理
from django import forms
# 导入模型表单基类
from django.forms import ModelForm
# 导入评论模型
# 导入评论模型,用于构建模型表单
from .models import Comment
# 评论表单类继承自ModelForm
# 类级注释:评论表单类
# 继承自ModelForm基于Comment模型自动生成表单字段和验证规则
class CommentForm(ModelForm):
# 父级评论ID字段使用隐藏输入控件非必填
# 父级评论ID字段隐藏输入字段用于处理评论回复功能
# 存储被回复评论的ID用户不可见但表单会处理
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
# 元数据类:配置模型表单的基本行为
class Meta:
# 指定关联的模型
# 指定关联的模型Comment模型
model = Comment
# 指定表单中包含的字段,只包含评论正文字段
# 定义表单中包含的字段:只包含评论正文字段
# 其他字段如作者、文章等通过其他方式自动设置
fields = ['body']

@ -1,58 +1,65 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django设置模块
# 模块级注释Django数据库迁移文件
# 本模块定义了评论功能的数据库迁移操作,包括创建评论表和相关字段
from django.conf import settings
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 导入数据库删除操作模块
import django.db.models.deletion
# 导入时区工具
import django.utils.timezone
# 数据库迁移类
# 类级注释:数据库迁移类
# 继承自migrations.Migration定义数据库结构变更的完整操作序列
class Migration(migrations.Migration):
# 标记为初始迁移
# 表示这是comments应用的第一个迁移文件
initial = True
# 依赖关系,依赖于博客应用的初始迁移和用户模型
# 依赖关系定义
# 指定本迁移执行前需要先完成的依赖迁移
dependencies = [
# 依赖blog应用的初始迁移确保文章表已创建
('blog', '0001_initial'),
# 依赖用户模型迁移,确保用户表已存在
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 迁移操作列表
# 按顺序执行的数据库操作集合
operations = [
# 创建评论表
# 创建模型操作
# 定义Comment模型的数据库表结构
migrations.CreateModel(
name='Comment',
fields=[
# 主键ID使用BigAutoField自增字段
# 主键字段自增BigAutoField作为评论的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 评论正文字段最大长度300字符
# 正文字段:存储评论内容限制最大长度300字符
('body', models.TextField(max_length=300, 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='修改时间')),
# 是否启用字段,布尔类型默认True
# 启用状态字段:控制评论是否显示布尔类型默认True
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 文章字段,外键关联到文章模型,级联删除
# 外键字段:关联到文章模型,级联删除确保数据一致性
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
# 作者字段,外键关联到用户模型,级联删除
# 外键字段:关联到用户模型,记录评论作者
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# 父级评论字段,外键自关联,允许为空,级联删除
# 自关联外键:支持评论回复功能,允许空值表示顶级评论
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
# 模型元选项配置
# 定义模型在admin中的显示名称和默认排序等行为
options={
# 单数形式的显示名称
# 单数显示名称在Django admin中显示的单数名称
'verbose_name': '评论',
# 复数形式的显示名称
# 复数显示名称在Django admin中显示的复数名称
'verbose_name_plural': '评论',
# 默认按ID倒序排列
# 默认排序:按ID倒序排列,最新评论显示在最前面
'ordering': ['-id'],
# 指定获取最新记录的依据字段
# 最新记录定义:指定按id字段获取最新记录
'get_latest_by': 'id',
},
),

@ -1,24 +1,34 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48
# 导入数据库迁移和模型模块
# 模块级注释Django数据库迁移文件 - 评论功能字段修改
# 本模块用于修改评论表中is_enable字段的默认值配置
from django.db import migrations, models
# 数据库迁移类
# 类级注释:数据库迁移类
# 继承自migrations.Migration定义对现有数据库结构的修改操作
class Migration(migrations.Migration):
# 依赖关系依赖于comments应用的0001_initial迁移
# 依赖关系定义
# 指定本迁移依赖于comments应用的初始迁移文件
dependencies = [
# 依赖comments应用的0001_initial迁移
# 确保评论表已创建后再执行本迁移
('comments', '0001_initial'),
]
# 迁移操作列表
# 包含对数据库结构的具体修改操作
operations = [
# 修改Comment模型的is_enable字段
# 修改字段操作
# 对Comment模型的is_enable字段进行配置修改
migrations.AlterField(
# 指定要修改的模型名称
model_name='comment',
# 指定要修改的字段名称
name='is_enable',
# 将默认值从True改为False表示评论默认不显示需要审核
# 新的字段配置将默认值从True改为False
# 新创建的评论默认不显示,需要手动启用
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]

@ -1,73 +1,86 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 导入Django设置模块
# 模块级注释Django数据库迁移文件 - 评论模型字段重构
# 本模块对评论模型进行重大重构,包括字段重命名、显示名称国际化等操作
from django.conf import settings
# 导入数据库迁移和模型模块
from django.db import migrations, models
# 导入数据库删除操作模块
import django.db.models.deletion
# 导入时区工具
import django.utils.timezone
# 数据库迁移类
# 类级注释:数据库迁移类
# 继承自migrations.Migration定义对评论模型的多个结构变更操作
class Migration(migrations.Migration):
# 依赖关系依赖于用户模型、博客应用的0005迁移和评论应用的0002迁移
# 依赖关系定义
# 指定本迁移执行前需要完成的依赖迁移文件
dependencies = [
# 依赖用户模型迁移,确保用户表结构就绪
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖博客应用的第5次迁移确保文章表结构稳定
('blog', '0005_alter_article_options_alter_category_options_and_more'),
# 依赖评论应用的第2次迁移确保之前的字段修改已应用
('comments', '0002_alter_comment_is_enable'),
]
# 迁移操作列表
# 包含多个对评论模型的结构变更操作,按顺序执行
operations = [
# 修改Comment模型的元数据选项更新显示名称
# 修改模型选项操作
# 更新Comment模型的元数据配置主要修改显示名称
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
# 删除created_time字段
# 删除字段操作
# 移除旧的创建时间字段,为新增字段做准备
migrations.RemoveField(
model_name='comment',
name='created_time',
),
# 删除last_mod_time字段
# 删除字段操作
# 移除旧的最后修改时间字段,为新增字段做准备
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
# 新增creation_time字段使用当前时间作为默认值
# 新增字段操作
# 添加新的创建时间字段,使用更清晰的字段命名
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 新增last_modify_time字段使用当前时间作为默认值
# 新增字段操作
# 添加新的最后修改时间字段,使用更清晰的字段命名
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改article字段的verbose_name
# 修改字段操作
# 更新文章外键字段的显示名称,改为英文
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
# 修改author字段的verbose_name
# 修改字段操作
# 更新作者外键字段的显示名称,改为英文
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改is_enable字段的verbose_name
# 修改字段操作
# 更新启用状态字段的显示名称,改为英文
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
# 修改parent_comment字段的verbose_name
# 修改字段操作
# 更新父级评论外键字段的显示名称,改为英文
migrations.AlterField(
model_name='comment',
name='parent_comment',

@ -1,59 +1,56 @@
# 导入Django配置模块
# 模块级注释Django数据模型模块 - 评论系统
# 本模块定义了评论系统的数据模型,包括评论的基本字段、关联关系和业务逻辑
from django.conf import settings
# 导入数据库模型模块
from django.db import models
# 导入时区工具
from django.utils.timezone import now
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入文章模型
# 导入文章模型,用于建立评论与文章的关联
from blog.models import Article
# Create your models here.
# 评论模型类
# 类级注释:评论模型类
# 继承自models.Model定义了评论数据的数据库表结构和字段约束
class Comment(models.Model):
# 评论正文字段文本类型最大长度300字符
# 正文字段存储评论内容最大长度300字符使用中文标签
body = models.TextField('正文', max_length=300)
# 创建时间字段,默认值为当前时间
# 创建时间字段:自动记录评论创建时间,使用国际化标签
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
# 最后修改时间字段:记录评论最后修改时间,使用国际化标签
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 作者字段,外键关联到用户模型,级联删除
# 作者字段:外键关联到用户模型,级联删除,使用国际化标签
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 文章字段,外键关联到文章模型,级联删除
# 文章字段:外键关联到文章模型,级联删除,使用国际化标签
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 父级评论字段,外键自关联,允许为空,级联删除
# 父级评论字段:自关联外键,支持评论回复功能,允许空值
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 是否启用字段布尔类型默认False表示评论需要审核
# 启用状态字段:控制评论是否显示,默认不显示,使用国际化标签
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# 模型的元数据配置
# 元数据类:配置模型在数据库和admin中的行为
class Meta:
# 默认按ID倒序排列
# 默认排序按ID倒序最新评论在前
ordering = ['-id']
# 单数形式的显示名称
# 单数显示名称:使用国际化翻译
verbose_name = _('comment')
# 复数形式的显示名称
# 复数显示名称:与单数相同
verbose_name_plural = verbose_name
# 指定获取最新记录的依据字段
# 最新记录定义按ID字段确定最新记录
get_latest_by = 'id'
# 对象的字符串表示方法
# 字符串表示方法定义对象在Python中的显示格式
def __str__(self):
# 使用评论正文作为对象的字符串表示
# 返回评论正文作为对象的字符串表示
return self.body

@ -1,45 +1,30 @@
# 导入Django模板模块
from django import template
# 创建模板库注册实例
register = template.Library()
# 注册简单模板标签,用于解析评论树结构
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
# 初始化数据列表,用于存储子评论
datas = []
# 定义递归函数,用于解析评论树
def parse(c):
# 获取当前评论的所有直接子评论(已启用状态)
childs = commentlist.filter(parent_comment=c, is_enable=True)
# 遍历每个子评论
for child in childs:
# 将子评论添加到数据列表
datas.append(child)
# 递归解析子评论的子评论
parse(child)
# 从传入的评论开始解析
parse(comment)
# 返回所有子评论的列表
return datas
# 注册包含标签,用于显示评论项
@register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild):
"""评论"""
# 根据是否为子评论设置不同的深度值
# 子评论深度为1父评论深度为2
depth = 1 if ischild else 2
# 返回模板上下文数据
return {
'comment_item': comment, # 评论对象
'depth': depth # 评论深度,用于控制显示样式
}
'comment_item': comment,
'depth': depth
}

@ -1,116 +1,108 @@
# 导入Django测试相关模块
# 模块级注释Django测试模块 - 评论系统功能测试
# 本模块包含评论系统的完整测试用例,验证评论发布、回复、显示等核心功能
from django.test import Client, RequestFactory, TransactionTestCase
# 导入URL反向解析模块
from django.urls import reverse
# 导入账户模型
# 导入相关模型,用于测试数据准备
from accounts.models import BlogUser
# 导入博客模型
from blog.models import Category, Article
# 导入评论模型
from comments.models import Comment
# 导入评论模板标签
# 导入评论标签模块,测试模板标签功能
from comments.templatetags.comments_tags import *
# 导入自定义工具函数
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
# 评论测试类继承自TransactionTestCase
# 类级注释:评论系统测试类
# 继承自TransactionTestCase支持数据库事务的测试用例
class CommentsTest(TransactionTestCase):
# 测试前置设置方法
# 测试初始化方法:在每个测试方法执行前运行
def setUp(self):
# 创建测试客户端
# 创建测试客户端用于模拟HTTP请求
self.client = Client()
# 创建请求工厂
# 创建请求工厂,用于构建请求对象
self.factory = RequestFactory()
# 导入博客设置模型
# 配置博客设置:启用评论审核功能
from blog.models import BlogSettings
# 创建博客设置实例
value = BlogSettings()
# 设置评论需要审核
value.comment_need_review = True
value.comment_need_review = True # 设置评论需要审核
value.save()
# 创建超级用户
# 创建超级用户,用于测试认证相关的评论功能
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 更新文章评论状态的方法
# 辅助方法:更新文章评论状态为启用
def update_article_comment_status(self, article):
# 获取文章的所有评论
comments = article.comment_set.all()
# 遍历评论并设置为启用状态
# 遍历所有评论,将其状态设置为启用
for comment in comments:
comment.is_enable = True
comment.save()
# 测试评论验证功能
# 测试方法:验证评论功能
def test_validate_comment(self):
# 用户登录
# 使用测试用户登录系统
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建分类
# 创建测试分类
category = Category()
category.name = "categoryccc"
category.save()
# 创建文章
# 创建测试文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 获取评论提交URL
# 生成评论提交URL
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
# 提交第一条评论
# 测试提交第一条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
# 断言重定向响应
# 验证响应状态码为302重定向
self.assertEqual(response.status_code, 302)
# 重新获取文章对象
# 重新获取文章对象验证评论数量由于审核机制初始应为0
article = Article.objects.get(pk=article.pk)
# 断言评论列表为空(因为评论需要审核)
self.assertEqual(len(article.comment_list()), 0)
# 更新评论状态为启用
self.update_article_comment_status(article)
# 断言评论列表有一条评论
# 更新评论状态为启用后验证评论数量变为1
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1)
# 提交第二条评论
# 测试提交第二条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
# 断言重定向响应
self.assertEqual(response.status_code, 302)
# 重新获取文章对象
# 验证第二条评论提交成功
article = Article.objects.get(pk=article.pk)
# 更新评论状态为启用
self.update_article_comment_status(article)
# 断言评论列表有两条评论
self.assertEqual(len(article.comment_list()), 2)
# 获取第一条评论的ID作为父评论ID
# 获取第一条评论的ID用于测试回复功能
parent_comment_id = article.comment_list()[0].id
# 提交第三条评论回复评论包含Markdown格式内容
# 测试提交带Markdown格式的回复评论
response = self.client.post(comment_url,
{
'body': '''
@ -129,27 +121,25 @@ class CommentsTest(TransactionTestCase):
'parent_comment_id': parent_comment_id
})
# 断言重定向响应
# 验证回复评论提交成功
self.assertEqual(response.status_code, 302)
# 更新评论状态为启用
self.update_article_comment_status(article)
# 重新获取文章对象
article = Article.objects.get(pk=article.pk)
# 断言评论列表有三条评论
self.assertEqual(len(article.comment_list()), 3)
# 获取父评论对象
# 测试评论树解析功能
comment = Comment.objects.get(id=parent_comment_id)
# 解析评论树结构
tree = parse_commenttree(article.comment_list(), comment)
# 断言评论树有一条子评论
self.assertEqual(len(tree), 1)
# 测试评论项显示模板标签
# 测试评论项显示功能
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
# 测试获取最大文章ID和评论ID的函数
# 测试获取最大文章ID和评论ID功能
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
# 测试发送评论邮件功能
# 测试评论邮件发送功能
from comments.utils import send_comment_email
send_comment_email(comment)

@ -1,17 +1,23 @@
# 导入Django URL路由相关模块
# 模块级注释Django URL配置模块 - 评论系统路由
# 本模块定义了评论系统的URL路由配置将URL路径映射到对应的视图函数
from django.urls import path
# 导入当前应用的视图模块
# 导入当前应用的视图模块用于处理URL请求
from . import views
# 定义应用的命名空间
# 应用命名空间定义:设置评论应用的命名空间为"comments"
# 用于Django的URL反向解析避免不同应用间的URL名称冲突
app_name = "comments"
# 定义URL模式列表
# URL模式列表定义评论系统的所有URL路由规则
urlpatterns = [
# 评论提交URL包含文章ID参数
# 评论提交路由:处理文章评论的提交请求
path(
# URL路径模式匹配/article/{文章ID}/postcomment格式的URL
# 其中<int:article_id>为路径参数捕获整数类型的文章ID
'article/<int:article_id>/postcomment',
# 对应的视图类使用CommentPostView类视图处理该路径的请求
views.CommentPostView.as_view(),
# URL名称命名为'postcomment'用于在模板和代码中进行URL反向解析
name='postcomment'),
]

@ -1,26 +1,29 @@
# 导入日志模块
# 模块级注释:评论邮件通知模块
# 本模块提供评论相关的邮件通知功能,包括新评论确认邮件和评论回复通知邮件
import logging
# 导入国际化翻译模块
# 导入Django国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 导入自定义工具函数
# 导入工具函数:获取当前站点信息和发送邮件
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
# 获取当前模块的日志器
# 创建日志记录器实例,用于记录邮件发送过程中的错误信息
logger = logging.getLogger(__name__)
# 发送评论邮件的函数
# 函数级注释:发送评论邮件通知
# 主要功能:向评论作者发送评论确认邮件,如果是对回复的评论,则同时向被回复者发送通知邮件
def send_comment_email(comment):
# 获取当前站点域名
# 获取当前站点域名,用于构建完整的文章链接
site = get_current_site().domain
# 邮件主题
# 邮件主题:使用国际化翻译
subject = _('Thanks for your comment')
# 构建文章完整URL
# 构建完整的文章URL地址
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 构建给评论作者的邮件内容
# 构建评论确认邮件的HTML内容
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
@ -28,16 +31,17 @@ def send_comment_email(comment):
<br />
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
# 获取评论作者的邮箱
# 获取评论作者的邮箱地址
tomail = comment.author.email
# 发送邮件给评论作者
# 发送评论确认邮件给评论作者
send_email([tomail], subject, html_content)
# 尝试发送回复通知邮件给父评论作者
# 异常处理块:处理评论回复通知邮件的发送
try:
# 检查是否存在父评论(即当前评论是回复评论)
# 检查是否存在父评论(即当前评论是否为回复评论)
if comment.parent_comment:
# 构建给父评论作者的邮件内容
# 构建回复通知邮件的HTML内容
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
@ -47,10 +51,11 @@ def send_comment_email(comment):
%(article_url)s
""") % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body}
# 获取父评论作者的邮箱
# 获取被回复评论作者的邮箱地址
tomail = comment.parent_comment.author.email
# 发送邮件给父评论作
# 发送回复通知邮件给被回复
send_email([tomail], subject, html_content)
# 捕获邮件发送过程中可能出现的任何异常
except Exception as e:
# 记录发送回复通知邮件时的错误
# 记录异常信息到日志,但不中断程序执行
logger.error(e)

@ -1,38 +1,32 @@
# 模块级注释Django视图模块 - 评论系统
# 本模块定义了评论提交的视图处理逻辑,包括评论验证、保存和重定向等功能
# Create your views here.
# 导入Django核心异常模块
from django.core.exceptions import ValidationError
# 导入HTTP重定向响应模块
from django.http import HttpResponseRedirect
# 导入快捷函数模块
from django.shortcuts import get_object_or_404
# 导入方法装饰器模块
from django.utils.decorators import method_decorator
# 导入CSRF保护装饰器
from django.views.decorators.csrf import csrf_protect
# 导入通用表单视图类
from django.views.generic.edit import FormView
# 导入账户模型
# 导入相关模型
from accounts.models import BlogUser
# 导入博客模型
from blog.models import Article
# 导入评论表单
from .forms import CommentForm
# 导入评论模型
from .models import Comment
# 评论提交视图类继承自FormView
# 类级注释:评论提交视图类
# 继承自FormView处理评论表单的提交和验证
class CommentPostView(FormView):
# 指定使用的表单类
form_class = CommentForm
# 指定使用的模板名称
# 指定模板名称
template_name = 'blog/article_detail.html'
# 使用CSRF保护装饰器
# 方法装饰器添加CSRF保护
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法
# 调用父类的dispatch方法确保CSRF保护生效
return super(CommentPostView, self).dispatch(*args, **kwargs)
# GET请求处理方法
@ -41,42 +35,42 @@ class CommentPostView(FormView):
article_id = self.kwargs['article_id']
# 获取文章对象如果不存在返回404
article = get_object_or_404(Article, pk=article_id)
# 获取文章详情页URL
# 获取文章的绝对URL
url = article.get_absolute_url()
# 重定向到文章详情页的评论区域
return HttpResponseRedirect(url + "#comments")
# 表单验证失败处理方法
# 表单验证失败处理方法
def form_invalid(self, form):
# 从URL参数获取文章ID
# 获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象
article = get_object_or_404(Article, pk=article_id)
# 重新渲染模板,包含表单和文章信息
# 重新渲染模板,显示表单错误信息
return self.render_to_response({
'form': form,
'article': article
})
# 表单验证成功处理方法
# 表单验证成功处理方法
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
# 获取当前用户
# 获取当前登录用户
user = self.request.user
# 获取用户对象
# 根据用户ID获取用户对象
author = BlogUser.objects.get(pk=user.pk)
# 从URL参数获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象
article = get_object_or_404(Article, pk=article_id)
# 检查文章评论状态和文章状态
# 检查文章是否允许评论
if article.comment_status == 'c' or article.status == 'c':
# 如果评论已关闭,抛出验证错误
# 抛出验证异常:文章评论已关闭
raise ValidationError("该文章评论已关闭.")
# 从表单获取评论数据但不保存到数据库
# 从表单获取评论对象但不保存到数据库
comment = form.save(False)
# 设置评论关联的文章
comment.article = article
@ -84,25 +78,24 @@ class CommentPostView(FormView):
# 获取博客设置
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
# 根据设置决定是否自动启用评论
# 如果设置不需要审核,直接启用评论
if not settings.comment_need_review:
comment.is_enable = True
# 设置评论作者
comment.author = author
# 检查是否存在父评论ID
# 处理回复评论的情况
if form.cleaned_data['parent_comment_id']:
# 获取父评论对象
# 获取父评论对象
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])
# 设置父评论
# 设置父评论
comment.parent_comment = parent_comment
# 保存评论到数据库
comment.save(True)
# 重定向到文章详情页的特定评论位置
# 重定向到文章页面并定位到新评论的位置
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))

@ -1 +1,16 @@
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
"""
Django应用配置入口模块
本模块定义了Django应用的默认配置类路径用于在Django启动时自动加载应用配置
这是Django应用的标准配置方式确保应用初始化代码能够正确执行
功能说明
- 指定默认的应用配置类
- 确保Django在启动时加载自定义应用配置
- 触发应用相关的初始化流程
"""
# 指定默认的应用配置类路径
# Django在启动时会自动加载此配置类并执行其中的ready()方法
# 这确保了插件系统和其他初始化代码能够在应用启动时正确执行
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -1,56 +1,74 @@
# 导入Django管理员站点相关模块
"""
DjangoBlog 后台管理站点配置模块
本模块定义了自定义的Django后台管理站点用于统一管理博客系统的所有数据模型
通过自定义AdminSite类实现了权限控制界面定制和模型注册的集中管理
主要功能
- 自定义后台管理站点外观和权限
- 集中注册所有应用的模型到统一后台
- 提供超级用户专属的管理界面
- 集成日志记录用户管理内容管理等功能
"""
from django.contrib.admin import AdminSite
# 导入日志条目模型
from django.contrib.admin.models import LogEntry
# 导入站点管理员类
from django.contrib.sites.admin import SiteAdmin
# 导入站点模型
from django.contrib.sites.models import Site
# 导入账户应用的管理类
# 导入各应用的Admin配置类和模型
from accounts.admin import *
# 导入博客应用的管理类
from blog.admin import *
# 导入博客模型
from blog.models import *
# 导入评论应用的管理类
from comments.admin import *
# 导入评论模型
from comments.models import *
# 导入自定义日志条目管理类
from djangoblog.logentryadmin import LogEntryAdmin
# 导入OAuth应用的管理类
from oauth.admin import *
# 导入OAuth模型
from oauth.models import *
# 导入OwnTracks应用的管理类
from owntracks.admin import *
# 导入OwnTracks模型
from owntracks.models import *
# 导入服务器管理应用的管理类
from servermanager.admin import *
# 导入服务器管理模型
from servermanager.models import *
# 自定义DjangoBlog管理员站点类继承自AdminSite
class DjangoBlogAdminSite(AdminSite):
# 站点头部标题
"""
自定义DjangoBlog后台管理站点
继承自Django原生的AdminSite类提供博客系统的定制化后台管理界面
包含站点标题设置权限控制和可选的URL扩展功能
"""
# 设置后台管理站点的头部标题
site_header = 'djangoblog administration'
# 站点标题
# 设置浏览器标签页标题
site_title = 'djangoblog site admin'
# 初始化方法
def __init__(self, name='admin'):
# 调用父类的初始化方法
"""
初始化后台管理站点
Args:
name (str): 管理站点的名称默认为'admin'
"""
# 调用父类初始化方法
super().__init__(name)
# 权限检查方法
def has_permission(self, request):
# 只允许超级用户访问管理员站点
"""
权限验证方法
重写权限检查逻辑只允许超级用户访问后台管理界面
Args:
request: HTTP请求对象
Returns:
bool: 如果是超级用户返回True否则返回False
"""
return request.user.is_superuser
# 注释掉的URL配置方法备用功能
# 注释掉的URL扩展方法 - 预留用于添加自定义管理视图
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
@ -62,36 +80,38 @@ class DjangoBlogAdminSite(AdminSite):
# return urls + my_urls
# 创建DjangoBlog管理员站点实例
# 创建自定义后台管理站点的实例
admin_site = DjangoBlogAdminSite(name='admin')
# 注册博客相关模型和管理类
admin_site.register(Article, ArticlelAdmin) # 文章模型
admin_site.register(Category, CategoryAdmin) # 分类模型
admin_site.register(Tag, TagAdmin) # 标签模型
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
# 注册博客相关模型到后台管理
admin_site.register(Article, ArticleAdmin) # 文章模型
admin_site.register(Category, CategoryAdmin) # 分类模型
admin_site.register(Tag, TagAdmin) # 标签模型
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
admin_site.register(ArticleLike, ArticleLikeAdmin) # 文章点赞模型
admin_site.register(ArticleFavorite, ArticleFavoriteAdmin) # 文章收藏模型
# 注册服务器管理相关模型和管理类
admin_site.register(commands, CommandsAdmin) # 命令模型
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型
# 注册服务器管理相关模型
admin_site.register(commands, CommandsAdmin) # 命令模型
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型
# 注册用户模型和管理类
admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型
# 注册用户管理模型
admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型
# 注册评论模型和管理类
admin_site.register(Comment, CommentAdmin) # 评论模型
# 注册评论管理模型
admin_site.register(Comment, CommentAdmin) # 评论模型
# 注册OAuth相关模型和管理类
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型
# 注册OAuth认证相关模型
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型
# 注册OwnTracks相关模型和管理类
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # OwnTracks日志模型
# 注册位置追踪相关模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置追踪日志模型
# 注册Django内置站点模型和管理类
admin_site.register(Site, SiteAdmin) # 站点模型
# 注册Django内置站点模型
admin_site.register(Site, SiteAdmin) # 站点模型
# 注册Django内置日志条目模型和管理类
admin_site.register(LogEntry, LogEntryAdmin) # 日志条目模型
# 注册日志记录模型
admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型

@ -1,19 +1,49 @@
# 导入Django应用配置基类
"""
DjangoBlog 应用配置模块
本模块定义了DjangoBlog应用的核心配置类负责应用启动时的初始化工作
主要功能包括应用元数据配置和插件系统的自动加载
关键功能
- 配置Django应用的默认设置
- 在应用准备就绪时自动加载插件系统
- 确保插件在Django启动过程中正确初始化
"""
from django.apps import AppConfig
# 定义DjangoBlog应用配置类
class DjangoblogAppConfig(AppConfig):
# 指定默认的自增字段类型为BigAutoField
"""
DjangoBlog 应用配置类
继承自Django的AppConfig类用于配置DjangoBlog应用的各项设置
在Django启动时自动实例化并执行ready()方法完成初始化
"""
# 设置默认自增主键字段类型为BigAutoField64位整数
default_auto_field = 'django.db.models.BigAutoField'
# 指定应用的完整Python路径
# 定义应用的Python路径Django通过此名称识别应用
name = 'djangoblog'
# 应用准备就绪时调用的方法
def ready(self):
# 调用父类的ready方法
"""
应用准备就绪回调方法
当Django应用注册表完全加载后自动调用此方法
在此处执行应用启动时需要完成的初始化操作特别是插件系统的加载
执行流程
1. 调用父类的ready()方法确保基础初始化完成
2. 导入插件加载器模块
3. 调用load_plugins()函数加载所有激活的插件
"""
# 调用父类ready()方法确保Django基础初始化完成
super().ready()
# Import and load plugins here
# 导入并加载插件
# 导入插件加载器模块 - 在方法内导入避免循环依赖
from .plugin_manage.loader import load_plugins
# 调用插件加载函数
# 执行插件加载函数,初始化所有配置的插件
load_plugins()

@ -1,106 +1,121 @@
# 导入线程模块
"""
DjangoBlog 信号处理模块
本模块定义了DjangoBlog系统的所有信号处理函数用于在特定事件发生时执行相应的操作
通过Django的信号机制实现了模块间的解耦和事件驱动的编程模式
主要功能
- 邮件发送信号处理
- OAuth用户登录信号处理
- 模型保存后的回调处理
- 用户登录/登出事件处理
- 缓存管理和搜索引擎通知
"""
import _thread
# 导入日志模块
import logging
# 导入Django信号相关模块
import django.dispatch
# 导入Django配置模块
from django.conf import settings
# 导入管理员日志模型
from django.contrib.admin.models import LogEntry
# 导入用户登录/登出信号
from django.contrib.auth.signals import user_logged_in, user_logged_out
# 导入邮件发送模块
from django.core.mail import EmailMultiAlternatives
# 导入数据库保存后信号
from django.db.models.signals import post_save
# 导入信号接收器装饰器
from django.dispatch import receiver
# 导入评论模型
# 导入应用相关模块
from comments.models import Comment
# 导入评论邮件工具函数
from comments.utils import send_comment_email
# 导入蜘蛛通知工具
from djangoblog.spider_notify import SpiderNotify
# 导入自定义缓存工具函数
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
# 导入获取当前站点工具
from djangoblog.utils import get_current_site
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 获取当前模块的日志器
# 初始化模块级日志器
logger = logging.getLogger(__name__)
# 定义OAuth用户登录信号
# 定义自定义信号
# OAuth用户登录信号传递用户ID参数
oauth_user_login_signal = django.dispatch.Signal(['id'])
# 定义发送邮件信号
# 邮件发送信号,传递收件人、标题和内容参数
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
# 注册发送邮件信号处理器
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
# 获取邮件接收者、标题和内容
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
"""
邮件发送信号处理函数
# 创建邮件对象
当收到send_email_signal信号时异步发送HTML格式邮件并记录发送日志
Args:
sender: 信号发送者
**kwargs: 包含emailto, title, content等参数
"""
# 从信号参数中提取邮件信息
emailto = kwargs['emailto'] # 收件人列表
title = kwargs['title'] # 邮件标题
content = kwargs['content'] # 邮件内容
# 创建邮件消息对象设置HTML格式
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL,
from_email=settings.DEFAULT_FROM_EMAIL, # 使用配置的默认发件人
to=emailto)
# 设置邮件内容类型为HTML
msg.content_subtype = "html"
msg.content_subtype = "html" # 设置内容类型为HTML
# 导入邮件发送日志模型
# 导入邮件日志模型并创建日志记录
from servermanager.models import EmailSendLog
# 创建邮件发送日志记录
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto)
log.emailto = ','.join(emailto) # 将收件人列表转换为字符串存储
# 尝试发送邮件
try:
# 尝试发送邮件send()方法返回发送成功的邮件数量
result = msg.send()
# 记录发送结果成功发送数量大于0
log.send_result = result > 0
log.send_result = result > 0 # 记录发送结果(成功/失败)
except Exception as e:
# 记录发送失败的错误信息
# 记录邮件发送异常信息
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False
# 保存邮件发送日志
# 保存邮件发送日志记录
log.save()
# 注册OAuth用户登录信号处理器
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
# 获取OAuth用户ID
"""
OAuth用户登录信号处理函数
处理第三方登录用户的头像保存和缓存清理
Args:
sender: 信号发送者
**kwargs: 包含用户ID参数
"""
# 从信号参数中获取用户ID
id = kwargs['id']
# 获取OAuth用户对象
# 根据ID获取OAuth用户对象
oauthuser = OAuthUser.objects.get(id=id)
# 获取当前站点域名
site = get_current_site().domain
# 检查用户头像是否需要更新
# 检查用户头像是否需要下载保存(非本站图片)
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
# 导入保存用户头像工具函数
# 导入头像保存工具函数
from djangoblog.utils import save_user_avatar
# 保存用户头像并更新URL
# 下载并保存用户头像,更新头像URL
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
# 删除侧边栏缓存
# 清理侧边栏缓存,确保显示最新用户信息
delete_sidebar_cache()
# 注册模型保存后信号处理器
@receiver(post_save)
def model_post_save_callback(
sender,
@ -110,76 +125,100 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
# 初始化清除缓存标志
clearcache = False
# 如果是管理员日志条目,直接返回
"""
模型保存后回调信号处理函数
监听所有模型的post_save信号执行相应的缓存清理和通知操作
Args:
sender: 保存的模型类
instance: 保存的模型实例
created: 是否为新建记录
raw: 是否为原始保存
using: 使用的数据库别名
update_fields: 更新的字段集合
**kwargs: 其他参数
"""
clearcache = False # 标记是否需要清理整个缓存
# 跳过管理员操作日志的保存处理
if isinstance(instance, LogEntry):
return
# 检查实例是否有get_full_url方法
# 检查实例是否有get_full_url方法通常是文章等可访问的模型
if 'get_full_url' in dir(instance):
# 判断是否为更新浏览量的操作
# 判断是否为仅更新浏览量字段
is_update_views = update_fields == {'views'}
# 如果不是测试环境且不是更新浏览量的操作
# 非测试环境且非仅更新浏览量时,通知搜索引擎
if not settings.TESTING and not is_update_views:
try:
# 获取实例的完整URL并通知蜘蛛
# 获取实例的完整URL并通知百度搜索引擎
notify_url = instance.get_full_url()
SpiderNotify.baidu_notify([notify_url])
except Exception as ex:
# 记录通知蜘蛛时的错误
logger.error("notify sipder", ex)
# 如果不是更新浏览量的操作,设置清除缓存标志
# 非仅更新浏览量时标记需要清理缓存
if not is_update_views:
clearcache = True
# 如果是评论实例
# 处理评论保存的特殊逻辑
if isinstance(instance, Comment):
# 如果评论已启用
# 只处理已启用的评论
if instance.is_enable:
# 获取文章路径
# 获取评论所属文章的URL路径
path = instance.article.get_absolute_url()
# 获取当前站点域名
site = get_current_site().domain
# 处理端口号
# 处理端口号(如果有)
if site.find(':') > 0:
site = site[0:site.find(':')]
# 使文章详情页缓存过期
# 使文章详情页缓存失效
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
# 删除SEO处理器缓存
# 清理SEO处理器缓存
if cache.get('seo_processor'):
cache.delete('seo_processor')
# 删除文章评论缓存
# 清理文章评论缓存
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
cache.delete(comment_cache_key)
# 删除侧边栏缓存
# 清理侧边栏缓存和评论视图缓存
delete_sidebar_cache()
# 删除文章评论视图缓存
delete_view_cache('article_comments', [str(instance.article.pk)])
# 在新线程中发送评论邮件
# 在新线程中发送评论通知邮件(避免阻塞主线程)
_thread.start_new_thread(send_comment_email, (instance,))
# 如果需要清除缓存
# 如果需要清理整个缓存(文章等主要内容更新时)
if clearcache:
# 清除所有缓存
cache.clear()
# 注册用户登录和登出信号处理器
@receiver(user_logged_in)
@receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs):
# 如果用户存在且有用户名
"""
用户登录/登出信号处理函数
处理用户认证状态变化时的缓存清理操作
Args:
sender: 信号发送者
request: HTTP请求对象
user: 用户对象
**kwargs: 其他参数
"""
# 确保用户对象存在且有用户名
if user and user.username:
# 记录用户信息
logger.info(user)
# 删除侧边栏缓存
delete_sidebar_cache()
# 注释掉的清除所有缓存操作
logger.info(user) # 记录用户认证日志
delete_sidebar_cache() # 清理侧边栏缓存
# 注释掉的完整缓存清理(可根据需要启用)
# cache.clear()

@ -1,3 +1,17 @@
"""
Elasticsearch 搜索引擎集成模块
本模块提供了Django Haystack与Elasticsearch的深度集成实现了博客文章的全文搜索功能
包含自定义的后端查询类搜索表单和引擎配置支持智能推荐和高效检索
主要功能
- Elasticsearch文档的索引管理
- 高级布尔查询和过滤
- 搜索词智能推荐
- 搜索结果的高亮和评分
- 与Django Haystack框架的无缝集成
"""
from django.utils.encoding import force_str
from elasticsearch_dsl import Q
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
@ -5,98 +19,218 @@ from haystack.forms import ModelSearchForm
from haystack.models import SearchResult
from haystack.utils import log as logging
# 导入博客相关的文档定义和管理器
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article
# 初始化模块级日志器
logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
"""
Elasticsearch 搜索后端实现
继承自Haystack的BaseSearchBackend提供与Elasticsearch的交互功能
负责索引创建文档更新搜索执行和推荐词生成等核心操作
"""
def __init__(self, connection_alias, **connection_options):
"""
初始化Elasticsearch后端
Args:
connection_alias: 连接别名
**connection_options: 连接配置选项
"""
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
# 初始化文章文档管理器
self.manager = ArticleDocumentManager()
# 启用拼写建议功能
self.include_spelling = True
def _get_models(self, iterable):
"""
获取模型并转换为文档
Args:
iterable: 模型实例集合
Returns:
list: 转换后的文档对象列表
"""
# 如果提供了模型集合则使用,否则获取所有文章
models = iterable if iterable and iterable[0] else Article.objects.all()
# 将Django模型转换为Elasticsearch文档
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
"""
创建索引并添加文档
Args:
models: 要创建索引的模型集合
"""
# 创建Elasticsearch索引
self.manager.create_index()
# 获取并转换模型为文档
docs = self._get_models(models)
# 重建索引(添加所有文档)
self.manager.rebuild(docs)
def _delete(self, models):
"""
删除文档
Args:
models: 要删除的模型集合
Returns:
bool: 删除操作结果
"""
# 遍历并删除每个模型对应的文档
for m in models:
m.delete()
return True
def _rebuild(self, models):
"""
重建索引
Args:
models: 要重建索引的模型集合
"""
# 获取所有文章或指定模型集合
models = models if models else Article.objects.all()
# 转换模型为文档
docs = self.manager.convert_to_doc(models)
# 更新文档到索引
self.manager.update_docs(docs)
def update(self, index, iterable, commit=True):
"""
更新索引文档
Args:
index: 索引名称
iterable: 要更新的模型集合
commit: 是否立即提交Elasticsearch自动提交此参数保留
"""
# 获取模型并转换为文档
models = self._get_models(iterable)
# 更新文档到索引
self.manager.update_docs(models)
def remove(self, obj_or_string):
"""
移除单个文档
Args:
obj_or_string: 要移除的模型对象或标识
"""
# 获取要删除的模型文档
models = self._get_models([obj_or_string])
# 执行删除操作
self._delete(models)
def clear(self, models=None, commit=True):
"""
清空索引
Args:
models: 要清空的模型集合保留参数
commit: 是否立即提交保留参数
"""
# 移除所有文档传入None表示清空
self.remove(None)
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
"""
获取搜索推荐词
使用Elasticsearch的suggest功能提供搜索词建议
如果没有找到合适的建议词则返回原搜索词
Args:
query (str): 原始搜索词
Returns:
str: 处理后的推荐搜索词
"""
# 构建搜索请求包含suggest功能
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
# 处理suggest结果
for suggest in search.suggest.suggest_search:
if suggest["options"]:
# 使用推荐词
keywords.append(suggest["options"][0]["text"])
else:
# 没有推荐词时使用原词
keywords.append(suggest["text"])
# 将推荐词列表合并为字符串返回
return ' '.join(keywords)
@log_query
def search(self, query_string, **kwargs):
"""
执行搜索查询
核心搜索方法处理查询字符串并返回匹配的结果
支持分页过滤和拼写建议
Args:
query_string: 搜索查询字符串
**kwargs: 其他搜索参数分页偏移等
Returns:
dict: 包含搜索结果命中数分面信息和拼写建议的字典
"""
# 记录搜索查询日志
logger.info('search query_string:' + query_string)
# 获取分页参数
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
# 推荐词搜索
# 根据是否启用建议搜索,获取处理后的搜索词
if getattr(self, "is_suggest", None):
# 获取推荐搜索词
suggestion = self.get_suggestion(query_string)
else:
# 使用原搜索词
suggestion = query_string
# 构建布尔查询标题或正文匹配设置最小匹配度70%
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
# 构建搜索请求:添加状态和类型过滤,设置分页
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
.filter('term', type='a') \
.source(False)[start_offset: end_offset]
# 执行搜索
results = search.execute()
# 获取总命中数
hits = results['hits'].total
raw_results = []
# 处理搜索结果转换为Haystack的SearchResult格式
for raw_result in results['hits']['hits']:
app_label = 'blog'
model_name = 'Article'
@ -104,47 +238,75 @@ class ElasticSearchBackend(BaseSearchBackend):
result_class = SearchResult
# 创建SearchResult对象
result = result_class(
app_label,
model_name,
raw_result['_id'],
raw_result['_score'],
raw_result['_id'], # 文档ID
raw_result['_score'], # 匹配分数
**additional_fields)
raw_results.append(result)
# 分面信息(当前未使用)
facets = {}
# 拼写建议:如果推荐词与原词不同则返回推荐词
spelling_suggestion = None if query_string == suggestion else suggestion
# 返回标准格式的搜索结果
return {
'results': raw_results,
'hits': hits,
'facets': facets,
'spelling_suggestion': spelling_suggestion,
'results': raw_results, # 搜索结果列表
'hits': hits, # 总命中数
'facets': facets, # 分面信息
'spelling_suggestion': spelling_suggestion, # 拼写建议
}
class ElasticSearchQuery(BaseSearchQuery):
"""
Elasticsearch 查询构建器
继承自Haystack的BaseSearchQuery负责构建Elasticsearch查询
处理查询字符串的清理和参数构建
"""
def _convert_datetime(self, date):
"""
转换日期时间格式
Args:
date: 日期时间对象
Returns:
str: 格式化后的日期时间字符串
"""
if hasattr(date, 'hour'):
# 包含时间的完整日期时间格式
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
# 仅日期格式,时间部分补零
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
清理查询片段
对用户输入的查询词进行清理和转义处理防止注入攻击
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
Args:
query_fragment: 原始查询片段
Returns:
str: 清理后的查询字符串
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
# 处理保留字(转为小写)
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
# 处理保留字符(用引号包围)
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
@ -155,29 +317,86 @@ class ElasticSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
"""
构建查询片段
Args:
field: 字段名
filter_type: 过滤器类型
value: 字段值
Returns:
str: 查询片段字符串
"""
return value.query_string
def get_count(self):
"""
获取搜索结果数量
Returns:
int: 搜索结果数量
"""
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
"""
获取拼写建议
Args:
preferred_query: 优先查询词
Returns:
str: 拼写建议
"""
return self._spelling_suggestion
def build_params(self, spelling_query=None):
"""
构建搜索参数
Args:
spelling_query: 拼写查询词
Returns:
dict: 搜索参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""
Elasticsearch 模型搜索表单
扩展Haystack的ModelSearchForm支持建议搜索功能
"""
def search(self):
# 是否建议搜索
"""
执行搜索
重写搜索方法根据表单数据设置是否启用建议搜索
Returns:
SearchQuerySet: 搜索查询结果集
"""
# 根据表单数据设置是否启用建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# 调用父类搜索方法
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
"""
Elasticsearch 搜索引擎配置
配置Haystack使用自定义的Elasticsearch后端和查询类
"""
# 指定自定义的后端类
backend = ElasticSearchBackend
# 指定自定义的查询类
query = ElasticSearchQuery

@ -1,66 +1,92 @@
# 导入用户模型获取函数
"""
RSS订阅源生成模块
本模块提供了DjangoBlog的RSS订阅功能基于Django的Feed框架实现
生成符合RSS 2.0标准的订阅源包含文章标题内容作者信息等
主要功能
- 生成博客文章的RSS订阅源
- 支持Markdown格式的内容渲染
- 提供作者信息和版权声明
- 符合RSS 2.0标准规范
"""
from django.contrib.auth import get_user_model
# 导入RSS订阅视图基类
from django.contrib.syndication.views import Feed
# 导入时区相关模块
from django.utils import timezone
# 导入RSS 2.0 feed生成器
from django.utils.feedgenerator import Rss201rev2Feed
# 导入博客模型
from blog.models import Article
# 导入自定义Markdown工具
from djangoblog.utils import CommonMarkdown
# Django博客RSS订阅类继承自Feed
class DjangoBlogFeed(Feed):
# 指定feed类型为RSS 2.0
# 指定使用RSS 2.0格式生成订阅源
feed_type = Rss201rev2Feed
# feed描述
# 订阅源描述信息
description = '大巧无工,重剑无锋.'
# feed标题
# 订阅源标题
title = "且听风吟 大巧无工,重剑无锋. "
# feed链接
# 订阅源链接地址
link = "/feed/"
# 获取作者名称的方法
def author_name(self):
# 返回第一个用户的昵称
"""
获取作者名称
返回博客第一用户的昵称作为订阅源作者
"""
return get_user_model().objects.first().nickname
# 获取作者链接的方法
def author_link(self):
# 返回第一个用户的绝对URL
"""
获取作者链接
返回博客第一用户的个人主页链接
"""
return get_user_model().objects.first().get_absolute_url()
# 获取feed项的方法
def items(self):
# 返回最近发布的5篇文章按发布时间倒序排列
"""
获取订阅项目列表
返回最近发布的5篇文章按发布时间倒序排列
只包含已发布的文章类型
"""
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
# 获取单个feed项标题的方法
def item_title(self, item):
"""
获取单个项目的标题
"""
return item.title
# 获取单个feed项描述的方法
def item_description(self, item):
# 将Markdown内容转换为HTML
"""
获取单个项目的描述内容
将文章的Markdown内容转换为HTML格式
"""
return CommonMarkdown.get_markdown(item.body)
# 获取feed版权信息的方法
def feed_copyright(self):
# 获取当前年份
"""
获取订阅源版权信息
生成包含当前年份的版权声明
"""
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
# 获取单个feed项链接的方法
def item_link(self, item):
"""
获取单个项目的链接
"""
return item.get_absolute_url()
# 获取单个feed项唯一标识的方法
def item_guid(self, item):
# 这里缺少返回值,应该返回一个唯一标识符
# 通常可以使用文章的绝对URL或ID作为guid
"""
获取单个项目的全局唯一标识符
"""
return

@ -1,135 +1,149 @@
# 导入Django管理员模块
"""
管理员操作日志后台管理模块
本模块提供了Django管理员操作日志的自定义后台管理界面
用于查看和追踪管理员在后台的所有操作记录包括增删改等操作
主要功能
- 自定义日志列表显示格式
- 提供对象和用户的超链接跳转
- 权限控制和操作限制
- 搜索和过滤功能
"""
from django.contrib import admin
# 导入删除操作常量
from django.contrib.admin.models import DELETION
# 导入内容类型模型
from django.contrib.contenttypes.models import ContentType
# 导入URL反向解析相关模块
from django.urls import reverse, NoReverseMatch
# 导入字符串强制转换工具
from django.utils.encoding import force_str
# 导入HTML转义工具
from django.utils.html import escape
# 导入安全字符串标记工具
from django.utils.safestring import mark_safe
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 日志条目管理类继承自admin.ModelAdmin
class LogEntryAdmin(admin.ModelAdmin):
# 列表页面过滤器
"""
管理员操作日志后台管理类
自定义Django默认的LogEntry模型管理界面提供更好的用户体验和功能
"""
# 列表页过滤器配置 - 按内容类型过滤
list_filter = [
'content_type' # 按内容类型过滤
'content_type'
]
# 搜索字段
# 搜索字段配置 - 支持按对象表示和变更消息搜索
search_fields = [
'object_repr', # 对象表示
'change_message' # 变更消息
'object_repr',
'change_message'
]
# 列表中可作为链接点击的字段
# 列表页可点击的字段 - 操作时间作为链接
list_display_links = [
'action_time', # 操作时间
'get_change_message', # 变更消息
'action_time',
'get_change_message',
]
# 列表页显示的字段
# 列表页显示的字段
list_display = [
'action_time', # 操作时间
'user_link', # 用户链接(自定义方法
'user_link', # 操作用户(带链接)
'content_type', # 内容类型
'object_link', # 对象链接(自定义方法
'object_link', # 操作对象(带链接)
'get_change_message', # 变更消息
]
# 是否允许添加权限
def has_add_permission(self, request):
# 禁止添加日志条目
"""
禁用添加权限 - 日志记录只能由系统自动创建
"""
return False
# 是否允许修改权限
def has_change_permission(self, request, obj=None):
# 只有超级用户或有修改日志权限的用户可以查看但不能通过POST修改
"""
控制修改权限 - 只允许超级用户或具有特定权限的用户查看
"""
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
# 是否允许删除权限
def has_delete_permission(self, request, obj=None):
# 禁止删除日志条目
"""
禁用删除权限 - 防止误删重要的操作日志
"""
return False
# 自定义方法:显示对象链接
def object_link(self, obj):
# 转义对象表示字符串
"""
生成操作对象的超链接
对于非删除操作尝试生成指向对象编辑页面的链接
如果是删除操作或无法生成链接则返回纯文本表示
"""
# 转义对象表示字符串防止XSS攻击
object_link = escape(obj.object_repr)
content_type = obj.content_type
# 如果不是删除操作且内容类型存在
# 对于非删除操作且内容类型存在的情况,尝试生成链接
if obj.action_flag != DELETION and content_type is not None:
# 尝试返回实际链接而不是对象表示字符串
try:
# 构建对象编辑页面的URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
# 创建HTML链接
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
# 如果无法构建URL保持原样
# 如果无法生成反向URL保持原样
pass
# 返回标记为安全的HTML
return mark_safe(object_link)
# 设置自定义方法的排序字段
# 设置对象链接的排序字段和显示名称
object_link.admin_order_field = 'object_repr'
# 设置自定义方法的显示名称
object_link.short_description = _('object')
# 自定义方法:显示用户链接
def user_link(self, obj):
"""
生成操作用户的超链接
尝试生成指向用户编辑页面的链接如果无法生成则返回纯文本
"""
# 获取用户模型的内容类型
content_type = ContentType.objects.get_for_model(type(obj.user))
# 转义用户字符串表示
# 转义用户表示字符串
user_link = escape(force_str(obj.user))
try:
# 尝试返回实际链接而不是用户表示字符串
# 构建用户编辑页面的URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
# 创建HTML链接
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
# 如果无法构建URL保持原样
# 如果无法生成反向URL保持原样
pass
# 返回标记为安全的HTML
return mark_safe(user_link)
# 设置自定义方法的排序字段
# 设置用户链接的排序字段和显示名称
user_link.admin_order_field = 'user'
# 设置自定义方法的显示名称
user_link.short_description = _('user')
# 获取查询集的方法
def get_queryset(self, request):
# 调用父类的get_queryset方法
"""
优化查询集 - 预取关联的内容类型数据
"""
queryset = super(LogEntryAdmin, self).get_queryset(request)
# 预取关联的内容类型数据
return queryset.prefetch_related('content_type')
# 获取管理动作的方法
def get_actions(self, request):
# 调用父类的get_actions方法
"""
移除批量删除操作 - 防止误删日志记录
"""
actions = super(LogEntryAdmin, self).get_actions(request)
# 删除"删除选中"动作
if 'delete_selected' in actions:
del actions['delete_selected']
return actions

@ -1,41 +1,93 @@
"""
插件系统基础模块
本模块提供了插件系统的基础框架定义了所有插件的基类BasePlugin
实现了插件的元数据管理初始化流程钩子注册和插件信息获取等核心功能
主要功能
- 插件元数据定义和验证
- 标准化的插件初始化流程
- 钩子注册机制
- 插件信息统一管理
"""
import logging
# 初始化模块级日志器,用于记录插件相关操作
logger = logging.getLogger(__name__)
class BasePlugin:
# 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
"""
插件基类
所有具体插件的父类定义了插件的标准接口和基本行为
提供了插件元数据管理初始化钩子注册等基础功能
类属性:
PLUGIN_NAME: 插件名称必须由子类定义
PLUGIN_DESCRIPTION: 插件描述必须由子类定义
PLUGIN_VERSION: 插件版本必须由子类定义
"""
# 插件元数据 - 必须由子类重写的类属性
PLUGIN_NAME = None # 插件名称标识
PLUGIN_DESCRIPTION = None # 插件功能描述
PLUGIN_VERSION = None # 插件版本号
def __init__(self):
"""
插件基类构造函数
执行插件初始化流程包括
1. 验证插件元数据完整性
2. 调用插件初始化方法
3. 注册插件钩子
Raises:
ValueError: 当插件元数据未完整定义时抛出
"""
# 验证插件元数据是否完整定义
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
# 执行插件初始化逻辑
self.init_plugin()
# 注册插件钩子函数
self.register_hooks()
def init_plugin(self):
"""
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
子类可以重写此方法来实现特定的初始化操作
基类实现仅记录初始化日志信息
"""
# 记录插件初始化成功日志
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
"""
注册插件钩子
子类可以重写此方法来注册特定的钩子
子类可以重写此方法来注册特定的钩子
基类实现为空方法由子类按需实现具体钩子注册逻辑
"""
# 基类不实现具体钩子注册,由子类重写
pass
def get_plugin_info(self):
"""
获取插件信息
:return: 包含插件元数据的字典
返回包含插件完整元数据的字典用于插件信息展示和管理
Returns:
dict: 包含插件名称描述和版本的字典对象
"""
# 构建并返回插件元数据字典
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION
}
'name': self.PLUGIN_NAME, # 插件名称
'description': self.PLUGIN_DESCRIPTION, # 插件功能描述
'version': self.PLUGIN_VERSION # 插件版本号
}

@ -1,7 +1,26 @@
"""
钩子事件常量定义模块
本模块定义了文章相关的钩子事件常量用于在插件系统中标识不同的事件类型
这些常量作为事件触发器名称用于在特定时机执行注册的钩子函数
主要用途
- 统一管理事件名称常量
- 提供类型安全的钩子标识
- 便于在插件系统中注册和触发事件
"""
# 文章详情加载事件 - 当文章详情数据被加载时触发
ARTICLE_DETAIL_LOAD = 'article_detail_load'
# 文章创建事件 - 当新文章被创建时触发
ARTICLE_CREATE = 'article_create'
# 文章更新事件 - 当现有文章被修改时触发
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 文章删除事件 - 当文章被删除时触发
ARTICLE_DELETE = 'article_delete'
# 文章内容处理钩子名称 - 专门用于处理文章内容的钩子标识
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -1,44 +1,116 @@
"""
钩子管理系统模块
本模块提供了完整的钩子Hook管理机制支持两种类型的钩子
1. Action Hook动作钩子按顺序执行注册的回调函数不返回值
2. Filter Hook过滤器钩子对输入值进行链式处理返回处理后的值
主要功能
- 钩子回调函数的注册管理
- 动作钩子的顺序执行
- 过滤器钩子的链式处理
- 完善的错误处理和日志记录
"""
import logging
# 初始化模块级日志器,用于记录钩子相关操作
logger = logging.getLogger(__name__)
# 全局钩子存储字典
# 结构:{hook_name: [callback1, callback2, ...]}
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调
注册一个钩子回调函数
将回调函数注册到指定的钩子名称下支持同一钩子名称注册多个回调函数
回调函数将按照注册顺序执行
Args:
hook_name (str): 钩子名称标识
callback (callable): 要注册的回调函数
Examples:
>>> register('article_create', my_callback_function)
"""
# 检查钩子名称是否已存在,不存在则初始化空列表
if hook_name not in _hooks:
_hooks[hook_name] = []
# 将回调函数添加到对应钩子的回调列表中
_hooks[hook_name].append(callback)
# 记录调试日志,跟踪钩子注册情况
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
执行一个 Action Hook动作钩子
按注册顺序执行所有注册到该钩子上的回调函数
动作钩子主要用于执行副作用操作不返回任何值
Args:
hook_name (str): 要执行的钩子名称
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
Examples:
>>> run_action('article_create', article_obj, user_obj)
"""
# 检查指定钩子是否有注册的回调函数
if hook_name in _hooks:
# 记录钩子执行开始日志
logger.debug(f"Running action hook '{hook_name}'")
# 遍历该钩子下的所有回调函数
for callback in _hooks[hook_name]:
try:
# 执行回调函数,传入所有参数
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
# 捕获并记录回调函数执行中的异常,但不中断其他回调的执行
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
执行一个 Filter Hook过滤器钩子
将输入值依次传递给所有注册的回调函数进行链式处理
每个回调函数的返回值将作为下一个回调函数的输入值
Args:
hook_name (str): 要执行的过滤器钩子名称
value: 初始输入值将被回调函数处理
*args: 传递给回调函数的额外位置参数
**kwargs: 传递给回调函数的额外关键字参数
Returns:
any: 经过所有回调函数处理后的最终值
Examples:
>>> processed_content = apply_filters('the_content', raw_content)
"""
# 检查指定过滤器钩子是否有注册的回调函数
if hook_name in _hooks:
# 记录过滤器应用开始日志
logger.debug(f"Applying filter hook '{hook_name}'")
# 遍历该钩子下的所有回调函数
for callback in _hooks[hook_name]:
try:
# 将当前值传递给回调函数处理,并更新为返回值
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
# 捕获并记录回调函数执行中的异常,但不中断处理链
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True)
# 返回经过所有过滤器处理后的最终值
return value

@ -1,19 +1,54 @@
"""
插件动态加载模块
本模块提供了插件系统的动态加载功能负责在Django应用启动时自动加载和初始化已激活的插件
通过扫描插件目录并导入插件模块实现插件的热插拔管理
主要功能
- 动态扫描插件目录
- 按配置加载激活的插件
- 插件模块的导入和初始化
- 加载状态的日志记录
"""
import os
import logging
from django.conf import settings
# 初始化模块级日志器,用于记录插件加载过程
logger = logging.getLogger(__name__)
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
动态加载并初始化插件
从配置的插件目录中加载所有激活的插件模块
此函数应在Django应用注册表准备就绪后调用
加载流程
1. 遍历settings.ACTIVE_PLUGINS中配置的插件名称
2. 检查插件目录和plugin.py文件是否存在
3. 动态导入插件模块
4. 记录加载成功或失败状态
注意插件模块的导入会触发其内部代码执行包括类定义和注册逻辑
"""
# 遍历所有在配置中激活的插件名称
for plugin_name in settings.ACTIVE_PLUGINS:
# 构建插件目录的完整路径
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# 检查插件目录是否存在且包含plugin.py文件
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# 动态导入插件模块,使用点分模块路径格式
# 导入操作会执行插件模块中的代码,完成插件注册
__import__(f'plugins.{plugin_name}.plugin')
# 记录插件加载成功日志
logger.info(f"Successfully loaded plugin: {plugin_name}")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
# 捕获导入异常,记录详细的错误信息
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

@ -1,95 +1,117 @@
"""
Django settings for djangoblog project.
Generated by 'django-admin startproject' using Django 1.10.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
DjangoBlog 项目配置文件
本模块包含DjangoBlog项目的所有配置设置包括数据库应用中间件国际化缓存邮件等
根据Django 1.10+的配置规范组织支持开发和生产环境的不同配置
主要配置类别
- 基础路径和密钥配置
- 应用和中间件配置
- 数据库和缓存配置
- 国际化设置
- 静态文件和媒体文件配置
- 邮件和日志配置
- 安全相关配置
- 搜索和插件系统配置
"""
# 导入操作系统模块
import os
# 导入系统模块
import sys
# 导入路径处理模块
from pathlib import Path
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# 环境变量转换为布尔值的辅助函数
def env_to_bool(env, default):
# 获取环境变量值
"""
环境变量转布尔值工具函数
将环境变量的字符串值转换为布尔值用于灵活的配置开关
Args:
env: 环境变量名
default: 默认值
Returns:
bool: 转换后的布尔值
"""
str_val = os.environ.get(env)
# 如果环境变量不存在则返回默认值,否则进行布尔转换
return default if str_val is None else str_val == 'True'
# 构建项目内部路径,如: BASE_DIR / 'subdir'
# 构建项目基础路径 - 使用pathlib现代路径处理方式
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# 从环境变量获取密钥,如果不存在则使用默认值(仅用于开发)
# 安全密钥配置 - 生产环境必须从环境变量获取
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
# 调试模式设置从环境变量获取或默认为True
# 调试模式开关 - 生产环境必须关闭
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
# 测试模式判断
# 测试模式标识 - 根据命令行参数判断是否为测试环境
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# 允许的主机列表
# ALLOWED_HOSTS = []
# 允许的主机名配置 - 生产环境需要具体指定
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置信任的CSRF来源
# Django 4.0新增CSRF信任源配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# 应用定义
# 已安装应用列表 - 定义项目使用的所有Django应用
INSTALLED_APPS = [
# 'django.contrib.admin',
# 使用简化的管理员配置
# 使用简化的Admin配置
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth', # 认证系统
'django.contrib.contenttypes', # 内容类型框架
'django.contrib.sessions', # 会话框架
'django.contrib.messages', # 消息框架
'django.contrib.staticfiles', # 静态文件管理
'django.contrib.sites', # 站点框架
'django.contrib.sitemaps', # 站点地图
'mdeditor', # Markdown编辑器
'haystack', # 搜索框架
'blog', # 博客应用
'accounts', # 账户应用
'comments', # 评论应用
'oauth', # OAuth认证
'servermanager', # 服务器管理
'owntracks', # OwnTracks位置追踪
'compressor', # 静态文件压缩
'djangoblog' # 主应用
# Django核心功能应用
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
# 第三方应用
'mdeditor', # Markdown编辑器
'haystack', # 搜索框架
'compressor', # 静态文件压缩
# 项目自定义应用
'blog', # 博客核心功能
'accounts', # 用户账户管理
'comments', # 评论系统
'oauth', # OAuth认证
'servermanager', # 服务器管理
'owntracks', # 位置追踪
'djangoblog' # 项目主应用
]
# 中间件配置
# 中间件配置 - 定义请求处理管道
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # 安全中间件
'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件
'django.middleware.locale.LocaleMiddleware', # 本地化中间件
'django.middleware.gzip.GZipMiddleware', # GZip压缩中间件
# 'django.middleware.cache.UpdateCacheMiddleware', # 缓存更新中间件(注释)
'django.middleware.common.CommonMiddleware', # 通用中间件
# 'django.middleware.cache.FetchFromCacheMiddleware', # 缓存获取中间件(注释)
'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件
'django.contrib.auth.middleware.AuthenticationMiddleware', # 认证中间件
'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware', # 点击劫持保护
'django.middleware.http.ConditionalGetMiddleware', # 条件GET中间件
'blog.middleware.OnlineMiddleware' # 自定义在线中间件
# 安全相关中间件
'django.middleware.security.SecurityMiddleware',
# 会话管理中间件
'django.contrib.sessions.middleware.SessionMiddleware',
# 国际化中间件
'django.middleware.locale.LocaleMiddleware',
# Gzip压缩中间件
'django.middleware.gzip.GZipMiddleware',
# 缓存中间件(注释状态)
# 'django.middleware.cache.UpdateCacheMiddleware',
# 通用中间件
'django.middleware.common.CommonMiddleware',
# 缓存中间件(注释状态)
# 'django.middleware.cache.FetchFromCacheMiddleware',
# CSRF保护中间件
'django.middleware.csrf.CsrfViewMiddleware',
# 认证中间件
'django.contrib.auth.middleware.AuthenticationMiddleware',
# 消息中间件
'django.contrib.messages.middleware.MessageMiddleware',
# 点击劫持保护中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 条件GET中间件
'django.middleware.http.ConditionalGetMiddleware',
# 自定义在线用户中间件
'blog.middleware.OnlineMiddleware'
]
# 根URL配置
@ -98,16 +120,17 @@ ROOT_URLCONF = 'djangoblog.urls'
# 模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates', # Django模板引擎
'DIRS': [os.path.join(BASE_DIR, 'templates')], # 模板目录
'APP_DIRS': True, # 启用应用模板目录
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [ # 上下文处理器
'django.template.context_processors.debug', # 调试信息
'django.template.context_processors.request', # 请求对象
'django.contrib.auth.context_processors.auth', # 认证信息
'django.contrib.messages.context_processors.messages', # 消息框架
'blog.context_processors.seo_processor' # 自定义SEO处理器
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# 自定义SEO处理器
'blog.context_processors.seo_processor'
],
},
},
@ -116,42 +139,41 @@ TEMPLATES = [
# WSGI应用配置
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# 数据库配置
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
# 数据库配置 - 使用MySQL作为默认数据库
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql', # MySQL数据库引擎
'NAME': 'djangoblog', # 数据库名称
'USER': 'root', # 数据库用户
'PASSWORD': '123456', # 数据库密码
'HOST': '127.0.0.1', # 数据库主机
'PORT': 3306, # 数据库端口
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': '123456',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
# 密码验证配置
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
# 密码验证器配置
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # 用户属性相似性验证
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # 最小长度验证
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # 常见密码验证
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # 数字密码验证
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# 多语言配置
# 国际化配置
LANGUAGES = (
('en', _('English')), # 英语
('zh-hans', _('Simplified Chinese')), # 简体中文
('zh-hant', _('Traditional Chinese')), # 繁体中文
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
# 本地化文件路径
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
@ -159,102 +181,108 @@ LOCALE_PATHS = (
# 默认语言代码
LANGUAGE_CODE = 'zh-hans'
# 时区设置
# 时区配置
TIME_ZONE = 'Asia/Shanghai'
# 启用国际化
# 国际化开关
USE_I18N = True
# 启用本地化
# 本地化开关
USE_L10N = True
# 使用时区
USE_TZ = False
# 静态文件配置 (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# 时区支持开关
USE_TZ = False
# Haystack搜索配置
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # Whoosh搜索引擎
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引路径
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
},
}
# 自动更新搜索索引
# 实时更新搜索索引
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# 允许用户使用用户名和密码登录的认证后端
# 认证后端配置 - 支持邮箱或用户名登录
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
# 静态文件根目录
# 静态文件配置
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
# 静态文件URL
STATIC_URL = '/static/'
# 静态文件目录
STATICFILES = os.path.join(BASE_DIR, 'static')
# 自定义用户模型
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录URL
LOGIN_URL = '/login/'
# 时间格式
# 时间格式配置
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
# 日期时间格式
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap颜色样式
# Bootstrap颜色类型
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# 分页
# 分页
PAGINATE_BY = 10
# HTTP缓存超时时间
# HTTP缓存超时时间
CACHE_CONTROL_MAX_AGE = 2592000
# 缓存配置
# 缓存配置 - 默认使用本地内存缓存
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存
'TIMEOUT': 10800, # 缓存超时3小时
'LOCATION': 'unique-snowflake', # 缓存位置标识
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
}
}
# 如果设置了Redis环境变量使用Redis作为缓存
# 如果配置了Redis环境变量则使用Redis缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache', # Redis缓存后端
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', # Redis连接地址
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
# 站点ID
SITE_ID = 1
# 百度站长平台通知URL
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# 邮件配置:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # SMTP邮件后端
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否使用TLS
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否使用SSL
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # 邮件主机
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) # 邮件端口
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') # 邮件用户
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') # 邮件密码
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER # 默认发件人
SERVER_EMAIL = EMAIL_HOST_USER # 服务器邮件
# 设置debug=false时处理异常邮件通知
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] # 管理员邮箱
# 微信管理员密码(两次md5加密)
# 邮件配置
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# 管理员邮箱配置 - 用于错误报告
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# 微信管理员密码两次MD5加密
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# 日志路径配置
# 日志配置
LOG_PATH = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
# 日志配置
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@ -264,42 +292,42 @@ LOGGING = {
},
'formatters': {
'verbose': {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', # 详细格式
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse', # 要求调试关闭的过滤器
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue', # 要求调试开启的过滤器
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler', # 时间轮转文件处理器
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径
'when': 'D', # 按天轮转
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5, # 保留5个备份文件
'backupCount': 5,
'encoding': 'utf-8'
},
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'], # 仅在调试模式下输出到控制台
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': {
'class': 'logging.NullHandler', # 空处理器
'class': 'logging.NullHandler',
},
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'], # 仅在非调试模式下发送邮件
'class': 'django.utils.log.AdminEmailHandler' # 管理员邮件处理器
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': {
@ -316,72 +344,73 @@ LOGGING = {
}
}
# 静态文件查找器
# 静态文件查找器配置
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', # 文件系统查找器
'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 应用目录查找器
# other
'compressor.finders.CompressorFinder', # 压缩器查找器
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 压缩器查找器
'compressor.finders.CompressorFinder',
)
# 启用压缩
# 静态文件压缩配置
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
# CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
'compressor.filters.css_default.CssAbsoluteFilter', # 将相对URL转换为绝对URL
# css minimizer
'compressor.filters.cssmin.CSSMinFilter' # CSS压缩过滤器
# 从相对URL创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# CSS压缩过滤器
'compressor.filters.cssmin.CSSMinFilter'
]
# JavaScript压缩过滤器
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter' # JS压缩过滤器
'compressor.filters.jsmin.JSMinFilter'
]
# 媒体文件配置
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') # 媒体文件根目录
MEDIA_URL = '/media/' # 媒体文件URL
X_FRAME_OPTIONS = 'SAMEORIGIN' # 框架选项
# 安全头部配置 - 防XSS和其他攻击
SECURE_BROWSER_XSS_FILTER = True # 启用浏览器XSS过滤器
SECURE_CONTENT_TYPE_NOSNIFF = True # 防止MIME类型嗅探
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' # 引用策略
# 内容安全策略 (CSP) - 防XSS攻击
CSP_DEFAULT_SRC = ["'self'"] # 默认源
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] # 脚本源
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] # 样式源
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] # 图片源
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] # 字体源
CSP_CONNECT_SRC = ["'self'"] # 连接源
CSP_FRAME_SRC = ["'none'"] # 框架源
CSP_OBJECT_SRC = ["'none'"] # 对象源
# 默认自增字段类型
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
# 框架选项配置
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 安全头部配置
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# 内容安全策略配置
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
# 默认自增主键字段类型
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# 如果设置了Elasticsearch环境变量配置Elasticsearch
# Elasticsearch配置如果配置了环境变量
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch主机
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', # Elasticsearch引擎
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# 插件系统配置
PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ # 激活的插件列表
'article_copyright', # 文章版权插件
'reading_time', # 阅读时间插件
'external_links', # 外部链接插件
'view_count', # 浏览计数插件
'seo_optimizer', # SEO优化插件
'image_lazy_loading', # 图片懒加载插件
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer',
'image_lazy_loading',
]

@ -1,90 +1,183 @@
# 导入Django站点地图相关模块
"""
站点地图生成模块
本模块定义了DjangoBlog的站点地图(Sitemap)配置用于生成搜索引擎友好的XML站点地图
包含静态页面文章分类标签和用户页面的站点地图配置
主要功能
- 生成符合搜索引擎标准的XML站点地图
- 为不同类型的内容设置不同的更新频率和优先级
- 提供最后修改时间信息
- 帮助搜索引擎更好地索引网站内容
"""
from django.contrib.sitemaps import Sitemap
# 导入URL反向解析模块
from django.urls import reverse
# 导入博客模型
from blog.models import Article, Category, Tag
# 静态视图站点地图类
class StaticViewSitemap(Sitemap):
# 优先级0.0-1.0
"""
静态页面站点地图
用于生成首页等静态页面的站点地图条目
"""
# 优先级设置0.0-1.0
priority = 0.5
# 更新频率
# 内容更新频率
changefreq = 'daily'
# 返回站点地图中包含的项
def items(self):
# 只包含首页
"""
获取包含在站点地图中的项目
返回需要生成站点地图的URL名称列表
"""
return ['blog:index', ]
# 获取每个项的URL
def location(self, item):
# 使用反向解析生成URL
"""
生成项目的完整URL
Args:
item: URL名称
Returns:
str: 完整的URL路径
"""
return reverse(item)
# 文章站点地图类
class ArticleSiteMap(Sitemap):
# 更新频率为每月
"""
文章页面站点地图
用于生成所有已发布文章的站点地图条目
"""
# 文章更新频率 - 每月更新
changefreq = "monthly"
# 优先级为0.6
# 文章优先级 - 较高优先级
priority = "0.6"
# 返回所有已发布的文章
def items(self):
"""
获取所有已发布的文章
Returns:
QuerySet: 已发布文章的查询集
"""
return Article.objects.filter(status='p')
# 获取文章的最后修改时间
def lastmod(self, obj):
"""
获取文章的最后修改时间
Args:
obj: 文章对象
Returns:
datetime: 最后修改时间
"""
return obj.last_modify_time
# 分类站点地图类
class CategorySiteMap(Sitemap):
# 更新频率为每周
"""
分类页面站点地图
用于生成所有文章分类的站点地图条目
"""
# 分类更新频率 - 每周更新
changefreq = "Weekly"
# 优先级为0.6
# 分类优先级 - 较高优先级
priority = "0.6"
# 返回所有分类
def items(self):
"""
获取所有分类
Returns:
QuerySet: 所有分类的查询集
"""
return Category.objects.all()
# 获取分类的最后修改时间
def lastmod(self, obj):
"""
获取分类的最后修改时间
Args:
obj: 分类对象
Returns:
datetime: 最后修改时间
"""
return obj.last_modify_time
# 标签站点地图类
class TagSiteMap(Sitemap):
# 更新频率为每周
"""
标签页面站点地图
用于生成所有标签的站点地图条目
"""
# 标签更新频率 - 每周更新
changefreq = "Weekly"
# 优先级为0.3
# 标签优先级 - 中等优先级
priority = "0.3"
# 返回所有标签
def items(self):
"""
获取所有标签
Returns:
QuerySet: 所有标签的查询集
"""
return Tag.objects.all()
# 获取标签的最后修改时间
def lastmod(self, obj):
"""
获取标签的最后修改时间
Args:
obj: 标签对象
Returns:
datetime: 最后修改时间
"""
return obj.last_modify_time
# 用户站点地图类
class UserSiteMap(Sitemap):
# 更新频率为每周
"""
用户页面站点地图
用于生成所有文章作者的站点地图条目
"""
# 用户页面更新频率 - 每周更新
changefreq = "Weekly"
# 优先级为0.3
# 用户页面优先级 - 中等优先级
priority = "0.3"
# 返回所有有文章的作者(去重)
def items(self):
# 使用set去重获取所有不重复的作者
"""
获取所有发表过文章的用户
通过文章作者去重确保每个用户只出现一次
Returns:
list: 用户对象列表
"""
return list(set(map(lambda x: x.author, Article.objects.all())))
# 获取用户的注册时间
def lastmod(self, obj):
"""
获取用户的注册时间
Args:
obj: 用户对象
Returns:
datetime: 用户注册时间
"""
return obj.date_joined

@ -1,33 +1,65 @@
# 导入日志模块
"""
搜索引擎蜘蛛通知模块
本模块提供了向搜索引擎主动推送URL更新的功能主要用于通知搜索引擎及时抓取网站内容更新
目前主要支持百度搜索引擎的URL推送接口
主要功能
- 向百度站长平台推送URL更新
- 批量推送URL列表
- 错误处理和日志记录
- 统一的推送接口封装
"""
import logging
# 导入请求库
import requests
# 导入Django配置模块
from django.conf import settings
# 获取当前模块的日志器
# 初始化模块级日志器
logger = logging.getLogger(__name__)
# 蜘蛛通知类(用于通知搜索引擎网站内容更新)
class SpiderNotify():
# 百度通知的静态方法
"""
搜索引擎蜘蛛通知类
提供静态方法用于向搜索引擎推送URL更新帮助搜索引擎及时发现网站内容变化
"""
@staticmethod
def baidu_notify(urls):
"""
向百度站长平台推送URL更新
将更新的URL列表推送给百度搜索引擎加速内容收录
Args:
urls: 需要推送的URL列表可以是字符串或字符串列表
Note:
使用settings.BAIDU_NOTIFY_URL配置的百度推送接口
"""
try:
# 将URL列表用换行符连接成字符串
# 将URL列表转换为换行分隔的字符串格式
data = '\n'.join(urls)
# 向百度站长平台提交URL更新通知
# 向百度推送接口发送POST请求
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
# 记录百度返回的结果
# 记录推送结果日志
logger.info(result.text)
except Exception as e:
# 记录通知过程中的错误
# 捕获并记录推送过程中的异常
logger.error(e)
# 通用通知方法(目前仅支持百度)
@staticmethod
def notify(url):
# 调用百度通知方法支持单个URL或URL列表
"""
统一的URL推送接口
提供简化的推送方法支持单个URL或URL列表的推送
Args:
url: 单个URL字符串或URL列表
"""
# 调用百度推送方法处理URL
SpiderNotify.baidu_notify(url)

@ -1,25 +1,51 @@
# 导入Django测试基类
"""
DjangoBlog 单元测试模块
本模块包含DjangoBlog项目的单元测试用例用于验证工具函数和核心功能的正确性
基于Django的TestCase框架确保代码质量和功能稳定性
测试功能
- 加密工具函数测试
- Markdown渲染功能测试
- 字典转换URL参数测试
"""
from django.test import TestCase
# 导入自定义工具函数(使用通配符导入)
from djangoblog.utils import *
# Django博客测试类继承自TestCase
class DjangoBlogTest(TestCase):
# 测试前置设置方法
"""
DjangoBlog 核心功能测试类
继承自Django的TestCase提供项目核心功能的自动化测试
"""
def setUp(self):
# 此处为空,没有特殊的测试前置设置
"""
测试前置设置方法
在每个测试方法执行前运行用于初始化测试环境
当前测试用例无需特殊设置保留空实现
"""
pass
# 测试工具函数的测试方法
def test_utils(self):
# 测试get_sha256函数
"""
工具函数综合测试方法
测试工具模块中的多个核心功能
1. SHA256加密功能
2. Markdown文本渲染功能
3. 字典转URL参数字符串功能
"""
# 测试SHA256加密功能
md5 = get_sha256('test')
# 断言返回值不为None
# 验证加密结果不为空
self.assertIsNotNone(md5)
# 测试CommonMarkdown.get_markdown函数
# 测试Markdown渲染功能
c = CommonMarkdown.get_markdown('''
# Title1
@ -33,14 +59,14 @@ class DjangoBlogTest(TestCase):
''')
# 断言返回值不为None
# 验证Markdown渲染结果不为空
self.assertIsNotNone(c)
# 测试parse_dict_to_url函数
# 测试字典转URL参数功能
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
# 断言返回值不为None
# 验证转换结果不为空
self.assertIsNotNone(data)

@ -1,98 +1,85 @@
"""djangoblog URL Configuration
"""
DjangoBlog 项目URL配置模块
本模块定义了DjangoBlog项目的所有URL路由配置包括管理后台博客评论用户认证等功能的URL映射
采用Django 1.10+的URL配置方式支持国际化路由和静态文件服务
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
主要路由分组
- 国际化路由配置
- 管理后台路由
- 博客应用路由
- 第三方应用路由
- 站点地图和订阅源
- 搜索功能路由
- 静态文件服务
"""
# 导入Django配置模块
from django.conf import settings
# 导入国际化URL模式
from django.conf.urls.i18n import i18n_patterns
# 导入静态文件服务
from django.conf.urls.static import static
# 导入站点地图视图
from django.contrib.sitemaps.views import sitemap
# 导入URL路径相关模块
from django.urls import path, include
from django.urls import re_path
# 导入Haystack搜索视图工厂
from haystack.views import search_view_factory
# 导入JSON响应
from django.http import JsonResponse
# 导入时间模块
import time
# 导入自定义视图和组件
# 导入项目自定义模块
from blog.views import EsSearchView
from djangoblog.admin_site import admin_site
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
# 站点地图配置字典
# 站点地图配置字典 - 定义不同类型内容的站点地图
sitemaps = {
'blog': ArticleSiteMap, # 文章站点地图
'Category': CategorySiteMap, # 分类站点地图
'Tag': TagSiteMap, # 标签站点地图
'User': UserSiteMap, # 用户站点地图
'static': StaticViewSitemap # 静态页面站点地图
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
}
# 自定义错误处理视图
handler404 = 'blog.views.page_not_found_view' # 404错误处理
handler500 = 'blog.views.server_error_view' # 500错误处理
handle403 = 'blog.views.permission_denied_view' # 403错误处理
# 自定义错误处理视图配置
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# 健康检查接口函数
def health_check(request):
"""
健康检查接口
简单返回服务健康状态
"""
return JsonResponse({
'status': 'healthy', # 服务状态
'timestamp': time.time() # 当前时间戳
'status': 'healthy',
'timestamp': time.time()
})
# 基础URL模式不包含国际化
# 基础URL模式配置 - 不包含语言前缀的URL
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')), # 国际化URL
path('health/', health_check, name='health_check'), # 健康检查接口
path('i18n/', include('django.conf.urls.i18n')),
path('health/', health_check, name='health_check'),
]
# 添加国际化URL模式
# 国际化URL模式配置 - 自动添加语言前缀的URL
urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls), # 管理员站点URL
re_path(r'', include('blog.urls', namespace='blog')), # 博客应用URL
re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown编辑器URL
re_path(r'', include('comments.urls', namespace='comment')), # 评论应用URL
re_path(r'', include('accounts.urls', namespace='account')), # 账户应用URL
re_path(r'', include('oauth.urls', namespace='oauth')), # OAuth认证URL
# 站点地图XML文件
re_path(r'^admin/', admin_site.urls),
re_path(r'', include('blog.urls', namespace='blog')),
re_path(r'mdeditor/', include('mdeditor.urls')),
re_path(r'', include('comments.urls', namespace='comment')),
re_path(r'', include('accounts.urls', namespace='account')),
re_path(r'', include('oauth.urls', namespace='oauth')),
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
re_path(r'^feed/$', DjangoBlogFeed()), # RSS订阅feed
re_path(r'^rss/$', DjangoBlogFeed()), # RSS订阅rss别名
# 搜索功能使用ElasticSearch后端
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
name='search'),
re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理URL
re_path(r'', include('owntracks.urls', namespace='owntracks')), # OwnTracks位置追踪URL
prefix_default_language=False # 不为默认语言添加语言前缀
) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 静态文件服务
# 在调试模式下添加媒体文件服务
re_path(r'', include('servermanager.urls', namespace='servermanager')),
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# 开发环境媒体文件服务配置
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)

@ -1,69 +1,92 @@
#!/usr/bin/env python
# encoding: utf-8
# 导入日志模块
"""
DjangoBlog 通用工具函数模块
本模块提供了DjangoBlog项目的各种通用工具函数包括缓存装饰器Markdown处理
邮件发送安全过滤等核心功能这些工具函数在整个项目中广泛使用
主要功能
- 缓存管理和装饰器
- Markdown文本处理和转换
- 电子邮件发送功能
- 安全HTML过滤和XSS防护
- 随机码生成和URL处理
- 用户头像下载和管理
"""
import logging
# 导入操作系统模块
import os
# 导入随机数模块
import random
# 导入字符串处理模块
import string
# 导入UUID生成模块
import uuid
# 导入哈希算法模块
from hashlib import sha256
# 导入HTML清理库
import bleach
# 导入Markdown处理库
import markdown
# 导入HTTP请求库
import requests
# 导入Django配置模块
from django.conf import settings
# 导入站点模型
from django.contrib.sites.models import Site
# 导入Django缓存模块
from django.core.cache import cache
# 导入静态文件处理模块
from django.templatetags.static import static
# 获取当前模块的日志器
# 初始化模块级日志器
logger = logging.getLogger(__name__)
# 获取最大文章ID和评论ID的函数
def get_max_articleid_commentid():
# 延迟导入,避免循环导入
"""
获取最大文章ID和评论ID
用于生成新文章或评论时的ID参考
Returns:
tuple: (最大文章ID, 最大评论ID)
"""
from blog.models import Article
from comments.models import Comment
# 返回最新文章和评论的ID
return (Article.objects.latest().pk, Comment.objects.latest().pk)
# SHA256哈希函数
def get_sha256(str):
# 创建SHA256哈希对象
"""
SHA256加密函数
Args:
str: 要加密的字符串
Returns:
str: SHA256加密后的十六进制字符串
"""
m = sha256(str.encode('utf-8'))
# 返回十六进制哈希值
return m.hexdigest()
# 缓存装饰器
def cache_decorator(expiration=3 * 60):
"""
缓存装饰器
为函数添加缓存功能减少重复计算和数据库查询
Args:
expiration: 缓存过期时间默认3分钟
Returns:
function: 装饰后的函数
"""
def wrapper(func):
def news(*args, **kwargs):
try:
# 尝试从视图获取缓存键
# 尝试从视图获取缓存键
view = args[0]
key = view.get_cache_key()
except:
key = None
if not key:
# 如果没有缓存键,根据函数参数生成唯一键
# 生成基于函数参数的唯一缓存
unique_str = repr((func, args, kwargs))
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
# 尝试从缓存获取值
@ -71,22 +94,18 @@ def cache_decorator(expiration=3 * 60):
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
if str(value) == '__default_cache_value__':
# 如果是默认缓存值返回None
return None
else:
# 返回缓存值
return value
else:
# 如果缓存不存在,执行函数并设置缓存
# 缓存未命中,执行函数并设置缓存
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
value = func(*args, **kwargs)
if value is None:
# 如果函数返回None设置默认缓存值
cache.set(key, '__default_cache_value__', expiration)
else:
# 设置实际缓存值
cache.set(key, value, expiration)
return value
@ -95,79 +114,125 @@ def cache_decorator(expiration=3 * 60):
return wrapper
# 使视图缓存过期的函数
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
使指定路径的视图缓存失效确保内容更新后及时反映
Args:
path: URL路径
servername: 主机名
serverport: 端口号
key_prefix: 缓存键前缀
Returns:
bool: 是否成功删除缓存
'''
from django.http import HttpRequest
from django.utils.cache import get_cache_key
# 创建模拟请求
# 创建模拟请求对象用于生成缓存键
request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path
# 获取缓存键
# 获取缓存键并删除对应缓存
key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
if cache.get(key):
# 删除缓存
cache.delete(key)
return True
return False
# 获取当前站点的缓存函数
@cache_decorator()
def get_current_site():
"""
获取当前站点信息
返回当前Django站点的配置信息带缓存功能
Returns:
Site: 当前站点对象
"""
site = Site.objects.get_current()
return site
# Markdown处理类
class CommonMarkdown:
# 转换Markdown的静态方法
"""
Markdown处理工具类
提供Markdown文本到HTML的转换功能支持代码高亮和目录生成
"""
@staticmethod
def _convert_markdown(value):
# 创建Markdown处理器启用扩展
"""
内部Markdown转换方法
Args:
value: Markdown格式文本
Returns:
tuple: (转换后的HTML内容, 生成的目录)
"""
# 配置Markdown扩展
md = markdown.Markdown(
extensions=[
'extra', # 额外功能
'codehilite', # 代码高亮
'toc', # 目录生成
'tables', # 表格支持
'extra', # 额外语法支持
'codehilite', # 代码高亮
'toc', # 目录生成
'tables', # 表格支持
]
)
# 转换Markdown为HTML
body = md.convert(value)
# 获取目录
toc = md.toc
return body, toc
# 获取带目录的Markdown
@staticmethod
def get_markdown_with_toc(value):
"""
获取带目录的Markdown转换结果
Args:
value: Markdown格式文本
Returns:
tuple: (HTML内容, 目录HTML)
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
# 获取不带目录的Markdown
@staticmethod
def get_markdown(value):
"""
获取Markdown转换结果不含目录
Args:
value: Markdown格式文本
Returns:
str: 转换后的HTML内容
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body
# 发送邮件函数
def send_email(emailto, title, content):
"""
发送电子邮件
通过信号机制异步发送邮件
Args:
emailto: 收件人邮箱地址
title: 邮件标题
content: 邮件内容
"""
from djangoblog.blog_signals import send_email_signal
# 发送邮件信号
send_email_signal.send(
send_email.__class__,
emailto=emailto,
@ -175,31 +240,43 @@ def send_email(emailto, title, content):
content=content)
# 生成随机验证码函数
def generate_code() -> str:
"""生成随机数验证码"""
# 从数字中随机选择6个字符
return ''.join(random.sample(string.digits, 6))
# 将字典解析为URL参数字符串
def parse_dict_to_url(dict):
"""
将字典转换为URL参数字符串
Args:
dict: 参数字典
Returns:
str: URL参数字符串
"""
from urllib.parse import quote
# 将字典键值对转换为URL编码的参数字符串
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
return url
# 获取博客设置的函数
def get_blog_setting():
# 尝试从缓存获取博客设置
"""
获取博客设置
返回博客的全局设置信息带缓存功能
如果设置不存在则创建默认设置
Returns:
BlogSettings: 博客设置对象
"""
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
# 如果没有博客设置,创建默认设置
# 如果不存在设置记录,创建默认设置
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog'
@ -216,38 +293,37 @@ def get_blog_setting():
setting.show_gongan_code = False
setting.comment_need_review = False
setting.save()
# 获取第一个博客设置
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
# 设置缓存
cache.set('get_blog_setting', value)
return value
# 保存用户头像函数
def save_user_avatar(url):
'''
保存用户头像
:param url:头像url
:return: 本地路径
从远程URL下载用户头像并保存到本地静态文件目录
Args:
url: 头像URL地址
Returns:
str: 本地静态文件路径
'''
logger.info(url)
try:
# 头像保存基础目录
basedir = os.path.join(settings.STATICFILES, 'avatar')
# 下载头像
# 下载头像文件
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
# 如果目录不存在则创建
if not os.path.exists(basedir):
os.makedirs(basedir)
# 图片扩展名列表
# 检查文件扩展名
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
# 判断是否为图片
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
# 获取文件扩展名
ext = os.path.splitext(url)[1] if isimage else '.jpg'
# 生成唯一文件名
save_filename = str(uuid.uuid4().hex) + ext
@ -255,46 +331,56 @@ def save_user_avatar(url):
# 保存文件
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
# 返回静态文件URL
return static('avatar/' + save_filename)
except Exception as e:
# 记录错误并返回默认头像
logger.error(e)
# 返回默认头像
return static('blog/img/avatar.png')
# 删除侧边栏缓存函数
def delete_sidebar_cache():
"""
删除侧边栏缓存
清理所有侧边栏相关的缓存数据
"""
from blog.models import LinkShowType
# 生成所有侧边栏缓存键
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
logger.info('delete sidebar key:' + k)
# 删除缓存
cache.delete(k)
# 删除视图缓存函数
def delete_view_cache(prefix, keys):
"""
删除视图缓存
根据前缀和键删除特定的模板片段缓存
Args:
prefix: 缓存前缀
keys: 缓存键列表
"""
from django.core.cache.utils import make_template_fragment_key
# 生成模板片段缓存键
key = make_template_fragment_key(prefix, keys)
# 删除缓存
cache.delete(key)
# 获取资源URL函数
def get_resource_url():
"""
获取资源URL基础路径
Returns:
str: 静态资源基础URL
"""
if settings.STATIC_URL:
# 如果设置了静态URL直接返回
return settings.STATIC_URL
else:
# 否则构建完整URL
site = get_current_site()
return 'http://' + site.domain + '/static/'
# 允许的HTML标签白名单
# HTML标签白名单 - 允许的安全HTML标签
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div']
@ -307,7 +393,7 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
]
# 自定义class属性过滤器
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
@ -316,31 +402,39 @@ def class_filter(tag, name, value):
return ' '.join(allowed_classes) if allowed_classes else False
return value
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'], # 链接允许href和title属性
'abbr': ['title'], # 缩写允许title属性
'acronym': ['title'], # 首字母缩写允许title属性
'span': class_filter, # span使用自定义class过滤器
'div': class_filter, # div使用自定义class过滤器
'pre': class_filter, # pre使用自定义class过滤器
'code': class_filter # code使用自定义class过滤器
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
}
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# HTML清理函数
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
使用bleach库进行白名单过滤防止XSS攻击
Args:
html: 要清理的HTML内容
Returns:
str: 清理后的安全HTML
"""
return bleach.clean(
html,
tags=ALLOWED_TAGS, # 允许的标签
attributes=ALLOWED_ATTRIBUTES, # 允许的属性
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
)

@ -1,9 +1,21 @@
# encoding: utf-8
# 导入Python 2/3兼容性支持
"""
Whoosh中文搜索后端模块
本模块提供了基于Whoosh搜索引擎的中文全文搜索功能专门针对Django Haystack框架进行定制
集成了jieba中文分词器支持中文文本的高效索引和搜索
主要特性
- 中文分词支持使用jieba
- 高性能索引和搜索
- 拼写建议和查询高亮
- 多字段类型支持文本数字日期等
- 与Django Haystack框架深度集成
"""
from __future__ import absolute_import, division, print_function, unicode_literals
# 导入标准库模块
import json
import os
import re
@ -11,35 +23,20 @@ import shutil
import threading
import warnings
# 导入Python 2/3兼容性库
import six
# 导入Django配置模块
from django.conf import settings
# 导入Django配置异常
from django.core.exceptions import ImproperlyConfigured
# 导入日期时间模块
from datetime import datetime
# 导入字符串强制转换工具
from django.utils.encoding import force_str
# 导入Haystack搜索引擎基类
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
# 导入Haystack常量
from haystack.constants import DJANGO_CT, DJANGO_ID, ID
# 导入Haystack异常
from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
# 导入Haystack输入类型
from haystack.inputs import Clean, Exact, PythonData, Raw
# 导入Haystack搜索结果模型
from haystack.models import SearchResult
# 导入Haystack工具函数
from haystack.utils import get_identifier, get_model_ct
# 导入Haystack日志工具
from haystack.utils import log as logging
# 导入Haystack模型加载工具
from haystack.utils.app_loading import haystack_get_model
# 导入结巴中文分词器
from jieba.analyse import ChineseAnalyzer
# 导入Whoosh搜索库相关模块
from whoosh import index
from whoosh.analysis import StemmingAnalyzer
from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
@ -51,40 +48,45 @@ from whoosh.qparser import QueryParser
from whoosh.searching import ResultsPage
from whoosh.writing import AsyncWriter
# 尝试导入Whoosh库如果失败则抛出缺失依赖异常
try:
import whoosh
except ImportError:
raise MissingDependency(
"The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
# 处理最低版本要求
# 检查Whoosh版本要求
if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
raise MissingDependency(
"The 'whoosh' backend requires version 2.5.0 or greater.")
# 日期时间正则表达式,用于匹配和解析日期时间字符串
# 日期时间正则表达式 - 用于解析日期格式
DATETIME_REGEX = re.compile(
'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})T(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(\.\d{3,6}Z?)?$')
# 线程本地存储用于RAM存储
# 线程本地存储 - 用于内存索引
LOCALS = threading.local()
LOCALS.RAM_STORE = None
# 自定义Whoosh HTML格式化器类
class WhooshHtmlFormatter(HtmlFormatter):
"""
This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
We use it to have consistent results across backends. Specifically,
Solr, Xapian and Elasticsearch are using this formatting.
简化的Whoosh HTML格式化器
提供跨后端一致的高亮结果显示格式
SolrXapian和Elasticsearch都使用这种格式化方式
"""
# 简化的HTML模板
template = '<%(tag)s>%(t)s</%(tag)s>'
# Whoosh搜索后端类继承自BaseSearchBackend
class WhooshSearchBackend(BaseSearchBackend):
# Whoosh保留的关键字
"""
Whoosh搜索后端实现
继承自Haystack的BaseSearchBackend提供Whoosh搜索引擎的核心功能
支持文件存储和内存存储两种方式
"""
# Whoosh保留关键字
RESERVED_WORDS = (
'AND',
'NOT',
@ -92,50 +94,51 @@ class WhooshSearchBackend(BaseSearchBackend):
'TO',
)
# Whoosh保留的特殊字符
# '\\' 必须放在第一位,以免覆盖其他斜杠替换
# Whoosh保留字符
RESERVED_CHARACTERS = (
'\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}',
'[', ']', '^', '"', '~', '*', '?', ':', '.',
)
# 初始化方法
def __init__(self, connection_alias, **connection_options):
# 调用父类初始化方法
"""
初始化Whoosh搜索后端
Args:
connection_alias: 连接别名
**connection_options: 连接配置选项
"""
super(
WhooshSearchBackend,
self).__init__(
connection_alias,
**connection_options)
# 设置完成标志
self.setup_complete = False
# 是否使用文件存储
self.use_file_storage = True
# 发布限制大小
self.post_limit = getattr(
connection_options,
'POST_LIMIT',
128 * 1024 * 1024)
# 索引路径
self.path = connection_options.get('PATH')
# 检查存储类型
if connection_options.get('STORAGE', 'file') != 'file':
self.use_file_storage = False
# 如果使用文件存储但没有指定路径,抛出配置错误
# 文件存储必须指定路径
if self.use_file_storage and not self.path:
raise ImproperlyConfigured(
"You must specify a 'PATH' in your settings for connection '%s'." %
connection_alias)
# 设置日志器
self.log = logging.getLogger('haystack')
# 设置方法,延迟加载直到需要时
def setup(self):
"""
Defers loading until needed.
初始化设置
延迟加载在需要时进行初始化
创建或打开索引构建schema
"""
from haystack import connections
new_index = False
@ -145,13 +148,13 @@ class WhooshSearchBackend(BaseSearchBackend):
os.makedirs(self.path)
new_index = True
# 检查索引目录是否可
# 检查目录写入权限
if self.use_file_storage and not os.access(self.path, os.W_OK):
raise IOError(
"The path to your Whoosh index '%s' is not writable for the current user/group." %
self.path)
# 根据存储类型选择存储方式
# 初始化存储
if self.use_file_storage:
self.storage = FileStorage(self.path)
else:
@ -162,10 +165,9 @@ class WhooshSearchBackend(BaseSearchBackend):
self.storage = LOCALS.RAM_STORE
# 构建schema和内容字段名
# 构建schema和解析器
self.content_field_name, self.schema = self.build_schema(
connections[self.connection_alias].get_unified_index().all_searchfields())
# 创建查询解析器
self.parser = QueryParser(self.content_field_name, schema=self.schema)
# 创建或打开索引
@ -177,24 +179,33 @@ class WhooshSearchBackend(BaseSearchBackend):
except index.EmptyIndexError:
self.index = self.storage.create_index(self.schema)
# 标记设置完成
self.setup_complete = True
# 构建schema的方法
def build_schema(self, fields):
# 基础schema字段
"""
构建Whoosh schema
根据字段定义创建Whoosh索引schema
Args:
fields: 字段定义字典
Returns:
tuple: (内容字段名, schema对象)
"""
# 基础字段
schema_fields = {
ID: WHOOSH_ID(stored=True, unique=True),
DJANGO_CT: WHOOSH_ID(stored=True),
DJANGO_ID: WHOOSH_ID(stored=True),
}
# 获取硬编码到Haystack中的键数量
initial_key_count = len(schema_fields)
content_field_name = ''
# 遍历所有字段构建schema
# 处理每个字段
for field_name, field_class in fields.items():
if field_class.is_multivalued:
# 多值字段
if field_class.indexed is False:
schema_fields[field_class.index_fieldname] = IDLIST(
stored=True, field_boost=field_class.boost)
@ -202,63 +213,74 @@ class WhooshSearchBackend(BaseSearchBackend):
schema_fields[field_class.index_fieldname] = KEYWORD(
stored=True, commas=True, scorable=True, field_boost=field_class.boost)
elif field_class.field_type in ['date', 'datetime']:
# 日期时间字段
schema_fields[field_class.index_fieldname] = DATETIME(
stored=field_class.stored, sortable=True)
elif field_class.field_type == 'integer':
# 整数字段
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=int, field_boost=field_class.boost)
elif field_class.field_type == 'float':
# 浮点数字段
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=float, field_boost=field_class.boost)
elif field_class.field_type == 'boolean':
# 在Whoosh 1.8.2中BOOLEAN字段不支持字段提升
# 布尔字段
schema_fields[field_class.index_fieldname] = BOOLEAN(
stored=field_class.stored)
elif field_class.field_type == 'ngram':
# N-gram字段
schema_fields[field_class.index_fieldname] = NGRAM(
minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost)
elif field_class.field_type == 'edge_ngram':
# 边缘N-gram字段
schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start',
stored=field_class.stored,
field_boost=field_class.boost)
else:
# 使用中文分词器处理文本字段
# 文本字段 - 使用中文分析器
# schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True)
schema_fields[field_class.index_fieldname] = TEXT(
stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True)
# 标记内容字段
if field_class.document is True:
content_field_name = field_class.index_fieldname
schema_fields[field_class.index_fieldname].spelling = True
# 如果没有找到字段,优雅地失败
# 检查是否有有效字段
if len(schema_fields) <= initial_key_count:
raise SearchBackendError(
"No fields were found in any search_indexes. Please correct this before attempting to search.")
return (content_field_name, Schema(**schema_fields))
# 更新索引的方法
def update(self, index, iterable, commit=True):
"""
更新索引
Args:
index: 搜索索引
iterable: 可迭代对象
commit: 是否提交更改
"""
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
# 使用异步写入器
writer = AsyncWriter(self.index)
# 遍历所有对象,准备并更新文档
for obj in iterable:
try:
doc = index.full_prepare(obj)
except SkipDocument:
self.log.debug(u"Indexing for object `%s` skipped", obj)
else:
# 确保所有值都是Unicode因为Whoosh只接受Unicode
# 确保所有值为unicode
for key in doc:
doc[key] = self._from_python(doc[key])
# Whoosh 2.5.0+不支持文档提升
# Whoosh 2.5.0+不支持文档boost
if 'boost' in doc:
del doc['boost']
@ -268,7 +290,6 @@ class WhooshSearchBackend(BaseSearchBackend):
if not self.silently_fail:
raise
# 记录对象标识符但不包含实际对象,以避免生成编码错误
self.log.error(
u"%s while preparing object for update" %
e.__class__.__name__,
@ -278,13 +299,18 @@ class WhooshSearchBackend(BaseSearchBackend):
"index": index,
"object": get_identifier(obj)}})
# 如果有对象需要处理,提交写入
# 提交更改
if len(iterable) > 0:
# 暂时无论如何都提交,否则会遇到锁定问题
writer.commit()
# 移除文档的方法
def remove(self, obj_or_string, commit=True):
"""
移除文档
Args:
obj_or_string: 对象或标识符
commit: 是否提交更改
"""
if not self.setup_complete:
self.setup()
@ -292,7 +318,6 @@ class WhooshSearchBackend(BaseSearchBackend):
whoosh_id = get_identifier(obj_or_string)
try:
# 通过查询删除文档
self.index.delete_by_query(
q=self.parser.parse(
u'%s:"%s"' %
@ -307,8 +332,14 @@ class WhooshSearchBackend(BaseSearchBackend):
e,
exc_info=True)
# 清空索引的方法
def clear(self, models=None, commit=True):
"""
清空索引
Args:
models: 要清空的模型列表
commit: 是否提交更改
"""
if not self.setup_complete:
self.setup()
@ -319,7 +350,6 @@ class WhooshSearchBackend(BaseSearchBackend):
try:
if models is None:
# 如果未指定模型,删除整个索引
self.delete_index()
else:
models_to_delete = []
@ -329,7 +359,6 @@ class WhooshSearchBackend(BaseSearchBackend):
u"%s:%s" %
(DJANGO_CT, get_model_ct(model)))
# 通过查询删除指定模型的文档
self.index.delete_by_query(
q=self.parser.parse(
u" OR ".join(models_to_delete)))
@ -347,28 +376,46 @@ class WhooshSearchBackend(BaseSearchBackend):
self.log.error(
"Failed to clear Whoosh index: %s", e, exc_info=True)
# 删除索引的方法
def delete_index(self):
# 根据Whoosh邮件列表如果要清除索引中的所有内容直接删除索引文件更高效
"""
删除索引
彻底删除索引文件并重新创建
"""
# 文件存储:直接删除目录
if self.use_file_storage and os.path.exists(self.path):
shutil.rmtree(self.path)
elif not self.use_file_storage:
# 内存存储:清理存储
self.storage.clean()
# 重新创建所有内容
# 重新创建
self.setup()
# 优化索引的方法
def optimize(self):
"""
优化索引
提高搜索性能
"""
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
self.index.optimize()
# 计算分页的方法
def calculate_page(self, start_offset=0, end_offset=None):
# 防止Whoosh抛出错误。需要end_offset大于0
"""
计算分页参数
Args:
start_offset: 起始偏移量
end_offset: 结束偏移量
Returns:
tuple: (页码, 页大小)
"""
# 防止Whoosh错误
if end_offset is not None and end_offset <= 0:
end_offset = 1
@ -386,11 +433,10 @@ class WhooshSearchBackend(BaseSearchBackend):
if page_length and page_length > 0:
page_num = int(start_offset / page_length)
# 递增因为Whoosh使用基于1的页码
# Whoosh使用1-based页码
page_num += 1
return page_num, page_length
# 搜索方法,使用日志查询装饰器
@log_query
def search(
self,
@ -412,10 +458,15 @@ class WhooshSearchBackend(BaseSearchBackend):
limit_to_registered_models=None,
result_class=None,
**kwargs):
"""
执行搜索查询
核心搜索方法处理各种搜索参数和选项
"""
if not self.setup_complete:
self.setup()
# 零长度查询应该返回无结果
# 空查询返回无结果
if len(query_string) == 0:
return {
'results': [],
@ -424,7 +475,7 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string = force_str(query_string)
# 单字符查询(非通配符)被停用词过滤器捕获,应该返回零结果
# 单字符查询(非通配符)返回无结果
if len(query_string) <= 1 and query_string != u'*':
return {
'results': [],
@ -433,8 +484,8 @@ class WhooshSearchBackend(BaseSearchBackend):
reverse = False
# 处理排序
if sort_by is not None:
# 确定是否需要反转结果以及Whoosh是否可以处理被要求排序的内容
sort_by_list = []
reverse_counter = 0
@ -442,6 +493,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if order_by.startswith('-'):
reverse_counter += 1
# Whoosh要求所有排序字段方向一致
if reverse_counter and reverse_counter != len(sort_by):
raise SearchBackendError("Whoosh requires all order_by fields"
" to use the same sort direction")
@ -449,18 +501,16 @@ class WhooshSearchBackend(BaseSearchBackend):
for order_by in sort_by:
if order_by.startswith('-'):
sort_by_list.append(order_by[1:])
if len(sort_by_list) == 1:
reverse = True
else:
sort_by_list.append(order_by)
if len(sort_by_list) == 1:
reverse = False
sort_by = sort_by_list[0]
# 处理不支持的功能警告
# Whoosh不支持facet功能
if facets is not None:
warnings.warn(
"Whoosh does not handle faceting.",
@ -482,16 +532,14 @@ class WhooshSearchBackend(BaseSearchBackend):
narrowed_results = None
self.index = self.index.refresh()
# 限制到注册模型的设置
# 模型限制处理
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
# 处理模型选择
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# 使用窄查询,将结果限制为当前路由器处理的模型
model_choices = self.build_models_list()
else:
model_choices = []
@ -508,7 +556,6 @@ class WhooshSearchBackend(BaseSearchBackend):
# 处理窄查询
if narrow_queries is not None:
# 可能很昂贵在Whoosh中我没有看到其他方法...
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
@ -533,7 +580,7 @@ class WhooshSearchBackend(BaseSearchBackend):
searcher = self.index.searcher()
parsed_query = self.parser.parse(query_string)
# 如果查询无效/包含停用词,优雅地恢复
# 处理无效查询
if parsed_query is None:
return {
'results': [],
@ -549,12 +596,11 @@ class WhooshSearchBackend(BaseSearchBackend):
'reverse': reverse,
}
# 处理结果被缩小的情况
# 应用窄查询过滤
if narrowed_results is not None:
search_kwargs['filter'] = narrowed_results
try:
# 执行分页搜索
raw_page = searcher.search_page(
parsed_query,
page_num,
@ -570,7 +616,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# 因为Whoosh 2.5.1中,如果请求的页码太高,它会返回错误的页面
# 检查页码有效性
if raw_page.pagenum < page_num:
return {
'results': [],
@ -592,7 +638,7 @@ class WhooshSearchBackend(BaseSearchBackend):
return results
else:
# 处理空索引的情况
# 无文档时的处理
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -609,7 +655,6 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
# 更多类似此内容的方法(推荐相关)
def more_like_this(
self,
model_instance,
@ -620,32 +665,33 @@ class WhooshSearchBackend(BaseSearchBackend):
limit_to_registered_models=None,
result_class=None,
**kwargs):
"""
查找相似文档
基于给定模型实例查找相似内容
"""
if not self.setup_complete:
self.setup()
# 延迟模型会有不同的类,不在我们的注册表中
model_klass = model_instance._meta.concrete_model
field_name = self.content_field_name
narrow_queries = set()
narrowed_results = None
self.index = self.index.refresh()
# 限制到注册模型的设置
# 模型限制处理
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
# 处理模型选择
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# 使用窄查询,将结果限制为当前路由器处理的模型
model_choices = self.build_models_list()
else:
model_choices = []
# 构建查询
# 构建查询
if len(model_choices) > 0:
if narrow_queries is None:
narrow_queries = set()
@ -682,7 +728,7 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
raw_results = EmptyResults()
# 执行更多类似此内容的搜索
# 执行相似文档搜索
if self.index.doc_count():
query = "%s:%s" % (ID, get_identifier(model_instance))
searcher = self.index.searcher()
@ -693,7 +739,7 @@ class WhooshSearchBackend(BaseSearchBackend):
raw_results = results[0].more_like_this(
field_name, top=end_offset)
# 处理结果被缩小的情况
# 应用窄查询过滤
if narrowed_results is not None and hasattr(raw_results, 'filter'):
raw_results.filter(narrowed_results)
@ -709,7 +755,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# 因为Whoosh 2.5.1中,如果请求的页码太高,它会返回错误的页面
# 检查页码有效性
if raw_page.pagenum < page_num:
return {
'results': [],
@ -726,7 +772,6 @@ class WhooshSearchBackend(BaseSearchBackend):
return results
# 处理搜索结果的方法
def _process_results(
self,
raw_page,
@ -734,10 +779,15 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string='',
spelling_query=None,
result_class=None):
"""
处理搜索结果
将Whoosh原始结果转换为Haystack格式
"""
from haystack import connections
results = []
# 在切片之前先获取命中数很重要
# 获取命中数
hits = len(raw_page)
if result_class is None:
@ -748,7 +798,7 @@ class WhooshSearchBackend(BaseSearchBackend):
unified_index = connections[self.connection_alias].get_unified_index()
indexed_models = unified_index.get_indexed_models()
# 处理每个搜索结果
# 处理每个结果
for doc_offset, raw_result in enumerate(raw_page):
score = raw_page.score(doc_offset) or 0
app_label, model_name = raw_result[DJANGO_CT].split('.')
@ -756,13 +806,14 @@ class WhooshSearchBackend(BaseSearchBackend):
model = haystack_get_model(app_label, model_name)
if model and model in indexed_models:
# 处理字段值
for key, value in raw_result.items():
index = unified_index.get_index(model)
string_key = str(key)
if string_key in index.fields and hasattr(
index.fields[string_key], 'convert'):
# 由于KEYWORD字段的性质需要特殊处理
# 多值字段特殊处理
if index.fields[string_key].is_multivalued:
if value is None or len(value) == 0:
additional_fields[string_key] = []
@ -775,11 +826,11 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
additional_fields[string_key] = self._to_python(value)
# 除系统字段
# 除系统字段
del (additional_fields[DJANGO_CT])
del (additional_fields[DJANGO_ID])
# 处理高亮显示
# 高亮处理
if highlight:
sa = StemmingAnalyzer()
formatter = WhooshHtmlFormatter('em')
@ -796,7 +847,7 @@ class WhooshSearchBackend(BaseSearchBackend):
self.content_field_name: [whoosh_result],
}
# 创建搜索结果对象
# 创建结果对象
result = result_class(
app_label,
model_name,
@ -807,7 +858,7 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
hits -= 1
# 处理拼写建议
# 拼写建议
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -823,8 +874,16 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
# 创建拼写建议的方法
def create_spelling_suggestion(self, query_string):
"""
创建拼写建议
Args:
query_string: 查询字符串
Returns:
str: 拼写建议
"""
spelling_suggestion = None
reader = self.index.reader()
corrector = reader.corrector(self.content_field_name)
@ -833,19 +892,17 @@ class WhooshSearchBackend(BaseSearchBackend):
if not query_string:
return spelling_suggestion
# 清理字符串,移除保留字
# 清理查询字符串
for rev_word in self.RESERVED_WORDS:
cleaned_query = cleaned_query.replace(rev_word, '')
# 清理字符串,移除保留字符
for rev_char in self.RESERVED_CHARACTERS:
cleaned_query = cleaned_query.replace(rev_char, '')
# 分解查询
# 分并获取建议
query_words = cleaned_query.split()
suggested_words = []
# 为每个查询词获取建议
for word in query_words:
suggestions = corrector.suggest(word, limit=1)
@ -855,42 +912,52 @@ class WhooshSearchBackend(BaseSearchBackend):
spelling_suggestion = ' '.join(suggested_words)
return spelling_suggestion
# Python值转换为Whoosh字符串的方法
def _from_python(self, value):
"""
Converts Python values to a string for Whoosh.
Python值转换为Whoosh字符串
Args:
value: Python值
Code courtesy of pysolr.
Returns:
str: Whoosh格式字符串
"""
if hasattr(value, 'strftime'):
# 日期时间处理
if not hasattr(value, 'hour'):
value = datetime(value.year, value.month, value.day, 0, 0, 0)
elif isinstance(value, bool):
# 布尔值处理
if value:
value = 'true'
else:
value = 'false'
elif isinstance(value, (list, tuple)):
# 列表元组处理
value = u','.join([force_str(v) for v in value])
elif isinstance(value, (six.integer_types, float)):
# 保持原样
# 数字类型保持原样
pass
else:
value = force_str(value)
return value
# Whoosh值转换为Python值的方法
def _to_python(self, value):
"""
Converts values from Whoosh to native Python values.
Whoosh值转换为Python值
A port of the same method in pysolr, as they deal with data the same way.
Args:
value: Whoosh值
Returns:
object: Python值
"""
if value == 'true':
return True
elif value == 'false':
return False
# 日期时间解析
if value and isinstance(value, six.string_types):
possible_datetime = DATETIME_REGEX.search(value)
@ -908,11 +975,10 @@ class WhooshSearchBackend(BaseSearchBackend):
date_values['minute'],
date_values['second'])
# JSON解析尝试
try:
# 尝试使用json加载值
converted_value = json.loads(value)
# 尝试处理大多数内置类型
if isinstance(
converted_value,
(list,
@ -924,38 +990,54 @@ class WhooshSearchBackend(BaseSearchBackend):
complex)):
return converted_value
except BaseException:
# 如果失败SyntaxError或其同类或者我们不信任它继续
pass
return value
# Whoosh搜索查询类继承自BaseSearchQuery
class WhooshSearchQuery(BaseSearchQuery):
# 日期时间转换方法
"""
Whoosh搜索查询构建器
负责构建Whoosh搜索引擎的查询语句
"""
def _convert_datetime(self, date):
"""
日期时间转换
Args:
date: 日期时间对象
Returns:
str: 格式化字符串
"""
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
# 清理查询片段的方法
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
清理查询片段
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
对用户输入进行清理和转义处理
Args:
query_fragment: 查询片段
Returns:
str: 清理后的查询字符串
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
# 保留字转为小写
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
# 保留字符用引号包围
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
@ -965,14 +1047,24 @@ class WhooshSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
# 构建查询片段的方法
def build_query_fragment(self, field, filter_type, value):
"""
构建查询片段
Args:
field: 字段名
filter_type: 过滤器类型
value: 字段值
Returns:
str: 查询片段
"""
from haystack import connections
query_frag = ''
is_datetime = False
# 值类型处理
if not hasattr(value, 'input_type_name'):
# 处理当我们有``ValuesListQuerySet``时...
if hasattr(value, 'values_list'):
value = list(value)
@ -980,19 +1072,17 @@ class WhooshSearchQuery(BaseSearchQuery):
is_datetime = True
if isinstance(value, six.string_types) and value != ' ':
# 不是``InputType``。假设是``Clean``。
value = Clean(value)
else:
value = PythonData(value)
# 使用InputType准备查询
# 准备值
prepared_value = value.prepare(self)
if not isinstance(prepared_value, (set, list, tuple)):
# 然后将我们得到的任何内容转换为pysolr需要的格式
prepared_value = self.backend._from_python(prepared_value)
# 'content'是一个特殊的保留字就像Django ORM层中的'pk'一样
# 字段名处理
if field == 'content':
index_fieldname = ''
else:
@ -1013,7 +1103,7 @@ class WhooshSearchQuery(BaseSearchQuery):
'fuzzy': u'%s~',
}
# 构建查询片段
# 查询片段构建
if value.post_process is False:
query_frag = prepared_value
else:
@ -1026,7 +1116,6 @@ class WhooshSearchQuery(BaseSearchQuery):
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
# 遍历术语并将每个的转换形式纳入查询
terms = []
if isinstance(prepared_value, six.string_types):
@ -1098,9 +1187,11 @@ class WhooshSearchQuery(BaseSearchQuery):
return u"%s%s" % (index_fieldname, query_frag)
# Whoosh搜索引擎类继承自BaseEngine
class WhooshEngine(BaseEngine):
# 指定后端类
"""
Whoosh搜索引擎配置
配置Haystack使用Whoosh作为搜索后端
"""
backend = WhooshSearchBackend
# 指定查询类
query = WhooshSearchQuery

@ -1,20 +1,25 @@
"""
WSGI config for djangoblog project.
DjangoBlog WSGI 配置模块
It exposes the WSGI callable as a module-level variable named ``application``.
本模块定义了DjangoBlog项目的WSGIWeb Server Gateway Interface配置
用于将Django应用部署到支持WSGI的Web服务器如ApacheNginx + uWSGI等
For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
WSGI是Python Web应用与Web服务器之间的标准接口确保应用能够在生产环境中正确运行
主要功能
- 设置Django环境变量
- 创建WSGI应用实例
- 提供Web服务器与Django应用之间的桥梁
"""
# 导入操作系统模块
import os
# 导入Django WSGI应用获取函数
from django.core.wsgi import get_wsgi_application
# 设置默认的Django设置模块环境变量
# 设置Django设置模块的环境变量
# 告诉Django使用哪个配置文件这里设置为'djangoblog.settings'
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
# 获取WSGI应用实例
# 创建WSGI应用实例
# 这是Web服务器将调用的入口点用于处理HTTP请求
application = get_wsgi_application()

@ -1,106 +1,96 @@
# 宠物博客数据修复脚本
# fix_pet_blog.py
import os
import django
# 设置Django环境变量
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
# 初始化Django配置
django.setup()
# 导入博客模型和用户模型
from blog.models import Article, Category, Tag
from accounts.models import BlogUser
print("=== 修复宠物博客数据 ===")
# 获取第一个用户作为文章作者
# 获取用户
user = BlogUser.objects.first()
if not user:
print("错误:没有找到用户")
exit()
# 为每个分类创建至少一篇文章的数据定义
# 为每个分类创建至少一篇文章
articles_data = [
# 狗狗日常分类的文章
# 狗狗日常
{
'title': '我家狗狗的表演',
'body': '欲擒故纵,你们见过吗。就是要出去的时候,故意离你远远的,让人你没法给它套上项圈,其实它很想出去。',
'category': '狗狗日常',
'tags': ['图文', '狗狗社交', '遛狗'] # 文章标签
'tags': ['图文', '狗狗社交', '遛狗']
},
# 猫咪生活分类的文章
# 猫咪生活
{
'title': '猫咪的日常护理',
'body': '定期为猫咪梳理毛发,保持清洁,注意观察猫咪的健康状况。',
'category': '猫咪生活',
'tags': ['宠物美容', '宠物健康'] # 文章标签
'tags': ['宠物美容', '宠物健康']
},
# 宠物健康分类的文章
# 宠物健康
{
'title': '宠物健康检查指南',
'body': '定期带宠物进行健康检查,注意疫苗接种和驱虫的重要性。',
'category': '宠物健康',
'tags': ['宠物医疗', '宠物健康'] # 文章标签
'tags': ['宠物医疗', '宠物健康']
},
# 训练技巧分类的文章
# 训练技巧
{
'title': '如何训练狗狗坐下',
'body': '使用零食诱导,当狗狗完成动作时及时奖励,重复训练。',
'category': '训练技巧',
'tags': ['训练方法', '图文'] # 文章标签
'tags': ['训练方法', '图文']
},
# 宠物用品分类的文章
# 宠物用品
{
'title': '推荐几款好用的宠物玩具',
'body': '这些玩具既安全又有趣,能让宠物保持活跃和快乐。',
'category': '宠物用品',
'tags': ['宠物玩具', '宠物用品'] # 文章标签
'tags': ['宠物玩具', '宠物用品']
},
# 额外文章确保内容丰富 - 狗狗日常的补充文章
# 额外文章确保内容丰富
{
'title': '带狗狗散步的注意事项',
'body': '选择合适的牵引绳,注意天气和路况,确保狗狗的安全。',
'category': '狗狗日常',
'tags': ['遛狗', '狗狗社交'] # 文章标签
'tags': ['遛狗', '狗狗社交']
},
# 额外文章确保内容丰富 - 宠物健康的补充文章
{
'title': '猫咪饮食健康指南',
'body': '了解猫咪的营养需求,选择合适的猫粮和零食。',
'category': '宠物健康',
'tags': ['宠物食品', '宠物健康'] # 文章标签
'tags': ['宠物食品', '宠物健康']
}
]
# 删除现有文章,重新创建(清理旧数据)
# 删除现有文章,重新创建
Article.objects.all().delete()
print("已清理现有文章")
# 遍历文章数据,创建新文章
# 创建新文章
for data in articles_data:
try:
# 根据分类名称获取分类对象
category = Category.objects.get(name=data['category'])
# 创建文章对象
article = Article.objects.create(
title=data['title'], # 文章标题
body=data['body'], # 文章内容
author=user, # 文章作者
category=category, # 文章分类
status='p' # 发布状态p表示已发布
title=data['title'],
body=data['body'],
author=user,
category=category,
status='p'
)
# 为文章添加标签
for tag_name in data['tags']:
tag, _ = Tag.objects.get_or_create(name=tag_name) # 获取或创建标签
article.tags.add(tag) # 将标签关联到文章
tag, _ = Tag.objects.get_or_create(name=tag_name)
article.tags.add(tag)
print(f'创建文章: {data["title"]} (分类: {data["category"]})')
except Exception as e:
# 处理创建文章过程中的异常
print(f'创建文章失败 {data["title"]}: {e}')
print("=== 修复完成 ===")
# 输出统计信息
print(f"总文章数: {Article.objects.count()}")
# 遍历所有分类,输出每个分类的文章数量
for category in Category.objects.all():
count = Article.objects.filter(category=category).count()
print(f"分类 '{category.name}': {count} 篇文章")

@ -1,27 +1,22 @@
#!/usr/bin/env python
# Django 管理脚本入口文件
import os
import sys
# 主程序入口
if __name__ == "__main__":
# 设置默认的Django设置模块
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
try:
# 尝试导入Django命令行执行器
from django.core.management import execute_from_command_line
except ImportError:
# 如果导入失败检查是否是Django未安装导致的
# 上面的导入可能由于其他原因失败。确保问题确实是缺少Django以避免掩盖Python 2上的其他异常。
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django # 尝试导入Django
import django
except ImportError:
# 如果Django导入失败抛出明确的错误信息
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise # 重新抛出原始异常
# 执行Django命令行命令
execute_from_command_line(sys.argv)
raise
execute_from_command_line(sys.argv)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

@ -1,67 +1,118 @@
# 导入日志模块
"""
Django Admin 管理站点配置模块 - OAuth 认证
该模块用于配置OAuth相关模型在Django Admin管理站点的显示和操作方式
包含OAuth用户和OAuth配置两个管理类用于自定义管理界面
"""
import logging
# 导入Django管理员模块
# 导入Django Admin管理模块
from django.contrib import admin
# Register your models here.
# 导入URL反向解析模块
# 导入URL反向解析功能
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 获取当前模块的日志
# 获取当前模块的日志记录
logger = logging.getLogger(__name__)
# OAuth用户管理类继承自admin.ModelAdmin
class OAuthUserAdmin(admin.ModelAdmin):
# 搜索字段
"""
OAuth用户模型的管理配置类
自定义OAuthUser模型在Django Admin中的显示和行为
包括搜索列表显示过滤等功能
"""
# 设置可搜索的字段
search_fields = ('nickname', 'email')
# 每页显示的用户数量
# 设置每页显示的项目数量
list_per_page = 20
# 列表页面显示的字段
# 设置列表页面显示的字段
list_display = (
'id',
'nickname',
'link_to_usermodel', # 自定义方法:用户模型链接
'show_user_image', # 自定义方法:显示用户头像
'type',
'email',
'id', # 用户ID
'nickname', # 昵称
'link_to_usermodel', # 自定义方法:关联本地用户链接
'show_user_image', # 自定义方法:显示用户头像
'type', # OAuth类型
'email', # 邮箱
)
# 列表中可作为链接点击的字段
# 设置可作为链接点击的字段(跳转到编辑页面)
list_display_links = ('id', 'nickname')
# 列表过滤器
# 设置右侧过滤侧边栏的过滤字段
list_filter = ('author', 'type',)
# 只读字段列表
# 初始化只读字段列表
readonly_fields = []
# 获取只读字段的方法
def get_readonly_fields(self, request, obj=None):
# 将所有字段设置为只读,包括普通字段和多对多字段
"""
动态获取只读字段列表
重写方法使所有字段在Admin中均为只读防止在管理界面修改OAuth用户数据
Args:
request: HttpRequest对象
obj: 模型实例对象
Returns:
list: 包含所有字段名的列表使所有字段只读
"""
# 返回所有字段名称的列表,包括普通字段和多对多字段
return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
# 是否允许添加权限
def has_add_permission(self, request):
# 禁止添加OAuth用户
"""
禁用添加权限
防止在Admin界面手动添加OAuth用户OAuth用户只能通过认证流程自动创建
Args:
request: HttpRequest对象
Returns:
bool: 始终返回False禁止添加新记录
"""
return False
# 自定义方法:显示用户模型链接
def link_to_usermodel(self, obj):
# 如果有关联的作者用户
"""
自定义方法生成关联本地用户的链接
在Admin列表中显示关联的本地用户并提供跳转到用户编辑页面的链接
Args:
obj: OAuthUser实例对象
Returns:
str: 格式化的HTML链接包含用户昵称或邮箱显示
"""
# 检查是否存在关联的本地用户
if obj.author:
# 获取用户模型的app_label和model_name
# 获取关联用户模型的app和model信息
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页面的URL
# 生成编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回格式化的HTML链接显示用户昵称或邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
# 自定义方法:显示用户头像
def show_user_image(self, obj):
"""
自定义方法显示用户头像
在Admin列表中以缩略图形式显示用户的第三方平台头像
Args:
obj: OAuthUser实例对象
Returns:
str: 格式化的HTML图片标签
"""
# 获取用户头像URL
img = obj.picture
# 返回格式化的HTML图片标签设置固定尺寸
@ -69,14 +120,25 @@ class OAuthUserAdmin(admin.ModelAdmin):
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
# 设置自定义方法的显示名称
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# 设置自定义方法在Admin中的显示名称
link_to_usermodel.short_description = '用户' # 关联用户列的显示名称
show_user_image.short_description = '用户头像' # 用户头像列的显示名称
# OAuth配置管理类继承自admin.ModelAdmin
class OAuthConfigAdmin(admin.ModelAdmin):
# 列表页面显示的字段
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
# 列表过滤器
list_filter = ('type',)
"""
OAuth配置模型的管理配置类
自定义OAuthConfig模型在Django Admin中的显示方式
用于管理不同第三方平台的OAuth应用配置
"""
# 设置列表页面显示的字段
list_display = (
'type', # OAuth类型
'appkey', # 应用Key
'appsecret', # 应用Secret
'is_enable' # 是否启用
)
# 设置右侧过滤侧边栏的过滤字段
list_filter = ('type',) # 按OAuth类型过滤

@ -1,8 +1,5 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义OAuth应用的配置类
class OauthConfig(AppConfig):
# 指定应用的完整Python路径
name = 'oauth'
name = 'oauth'

@ -1,19 +1,46 @@
"""
OAuth 认证表单模块 - 邮箱补充表单
该模块定义了在OAuth认证过程中需要用户补充邮箱信息时使用的表单
当第三方登录未返回邮箱地址时使用此表单让用户手动输入邮箱
"""
# 导入Django表单相关模块
from django.contrib.auth.forms import forms
from django.forms import widgets
# 需要邮箱的表单类继承自forms.Form
class RequireEmailForm(forms.Form):
# 邮箱字段,必填
"""
邮箱补充表单类
用于OAuth登录过程中当第三方平台未提供用户邮箱时
要求用户手动输入邮箱地址的表单
"""
# 邮箱字段,必填字段,用于用户输入电子邮箱
email = forms.EmailField(label='电子邮箱', required=True)
# OAuth ID字段使用隐藏输入控件非必填
# 隐藏的OAuth用户ID字段用于关联OAuth用户记录
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
# 初始化方法
def __init__(self, *args, **kwargs):
"""
初始化表单自定义字段控件属性
重写初始化方法为邮箱字段添加HTML属性和样式类
改善用户体验和界面美观
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类的初始化方法
super(RequireEmailForm, self).__init__(*args, **kwargs)
# 自定义邮箱字段的HTML属性
# 自定义邮箱字段的widget添加placeholder和CSS类
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={
'placeholder': "email", # 输入框内的提示文本
"class": "form-control" # Bootstrap样式类用于表单控件样式
})

@ -1,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-07 09:53
# oauth/migrations/0001_initial.py
from django.conf import settings
from django.db import migrations, models
@ -54,4 +54,4 @@ class Migration(migrations.Migration):
'ordering': ['-created_time'],
},
),
]
]

@ -1,86 +1,138 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
"""
Django 数据库迁移模块 - OAuth 认证配置更新
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
该模块是OAuth认证系统的第二次迁移主要用于优化字段命名和国际化显示
对已有的OAuthConfig和OAuthUser模型进行字段调整和选项更新
这是Django迁移系统自动生成的迁移文件在Django 4.2.5版本中创建于2023-09-06
"""
# 导入Django核心模块
from django.conf import settings # 导入Django设置
from django.db import migrations, models # 导入数据库迁移和模型相关功能
import django.db.models.deletion # 导入外键删除操作
import django.utils.timezone # 导入时区工具
class Migration(migrations.Migration):
"""
OAuth认证系统的数据库迁移更新类
这个迁移类负责对已有的OAuth相关模型进行字段优化和国际化改进
主要涉及时间字段重命名和verbose_name的英文标准化
"""
# 定义依赖关系 - 依赖于初始迁移和用户模型
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth', '0001_initial'),
('oauth', '0001_initial'), # 依赖于oauth应用的初始迁移
]
# 定义要执行的数据库操作序列
operations = [
# 修改OAuthConfig模型的选项配置
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 保持中文显示名称不变
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置'
},
),
# 修改OAuthUser模型的选项配置
migrations.AlterModelOptions(
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 将显示名称改为英文
'verbose_name': 'oauth user',
'verbose_name_plural': 'oauth user'
},
),
# 移除OAuthConfig模型的旧时间字段
migrations.RemoveField(
model_name='oauthconfig',
name='created_time',
name='created_time', # 删除旧的创建时间字段
),
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time',
name='last_mod_time', # 删除旧的修改时间字段
),
# 移除OAuthUser模型的旧时间字段
migrations.RemoveField(
model_name='oauthuser',
name='created_time',
name='created_time', # 删除旧的创建时间字段
),
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time',
name='last_mod_time', # 删除旧的修改时间字段
),
# 为OAuthConfig模型添加新的创建时间字段
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthConfig模型添加新的修改时间字段
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 为OAuthUser模型添加新的创建时间字段
migrations.AddField(
model_name='oauthuser',
name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthUser模型添加新的修改时间字段
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改OAuthConfig回调地址字段的配置
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
# 将默认回调地址改为空字符串,字段标签改为英文
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
# 修改OAuthConfig启用状态字段的标签
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
# 保持字段定义不变只修改verbose_name为英文
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改OAuthConfig类型字段的选项和标签
migrations.AlterField(
model_name='oauthconfig',
name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
# 将微博和谷歌的显示名称改为英文,其他保持不变
field=models.CharField(
choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'),
('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
),
# 修改OAuthUser作者字段的标签
migrations.AlterField(
model_name='oauthuser',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
# 保持外键关系不变修改verbose_name为英文
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改OAuthUser昵称字段的标签
migrations.AlterField(
model_name='oauthuser',
name='nickname',
# 保持字段定义不变只修改verbose_name为英文
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]
]

@ -1,18 +1,37 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
"""
Django 数据库迁移模块 - OAuth 用户昵称字段优化
from django.db import migrations, models
该模块是OAuth认证系统的第三次迁移主要用于微调OAuthUser模型中昵称字段的显示标签
这是一个小的优化迁移仅修改字段的verbose_name以改善可读性
这是Django迁移系统自动生成的迁移文件在Django 4.2.7版本中创建于2024-01-26
"""
# 导入Django核心模块
from django.db import migrations, models # 导入数据库迁移和模型相关功能
class Migration(migrations.Migration):
"""
OAuth认证系统的数据库微调迁移类
这个迁移类负责对OAuthUser模型的昵称字段进行显示标签优化
'nickname'改为'nick name'以改善管理界面的可读性
"""
# 定义依赖关系 - 依赖于前一次迁移
dependencies = [
# 依赖于oauth应用的第二次迁移字段重命名和国际化迁移
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
# 定义要执行的数据库操作序列
operations = [
# 修改OAuthUser模型昵称字段的显示标签
migrations.AlterField(
model_name='oauthuser',
name='nickname',
model_name='oauthuser', # 指定要修改的模型名称
name='nickname', # 指定要修改的字段名称
# 保持字段类型和约束不变仅优化verbose_name显示
# 将'nickname'改为'nick name',增加空格提高可读性
field=models.CharField(max_length=50, verbose_name='nick name'),
),
]
]

@ -1,105 +1,148 @@
# Create your models here.
# 导入Django配置模块
"""
OAuth 认证数据模型模块
该模块定义了OAuth认证系统所需的数据模型包括OAuth用户信息和OAuth服务商配置
用于存储第三方登录的用户数据和OAuth应用配置信息
"""
# 导入Django核心模块
from django.conf import settings
# 导入验证错误异常
from django.core.exceptions import ValidationError
# 导入数据库模型模块
from django.db import models
# 导入时区工具
from django.utils.timezone import now
# 导入国际化翻译模块
from django.utils.translation import gettext_lazy as _
# OAuth用户模型类
class OAuthUser(models.Model):
# 作者字段,外键关联到用户模型,允许为空
"""
OAuth用户模型
存储通过第三方OAuth服务登录的用户信息包括用户基本信息
认证令牌以及与本地用户的关联关系
"""
# 关联本地用户模型的外键,可为空(未绑定本地用户时)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 开放ID字段最大长度50字符
settings.AUTH_USER_MODEL, # 使用Django的可切换用户模型
verbose_name=_('author'), # 字段显示名称(支持国际化)
blank=True, # 允许表单中为空
null=True, # 允许数据库中为NULL
on_delete=models.CASCADE # 关联用户删除时级联删除OAuth用户
)
# 第三方平台的用户唯一标识符
openid = models.CharField(max_length=50)
# 昵称字段最大长度50字符
# 用户在第三方平台的昵称
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
# 令牌字段最大长度150字符允许为空
# OAuth访问令牌用于调用第三方API
token = models.CharField(max_length=150, null=True, blank=True)
# 头像字段最大长度350字符允许为空
# 用户头像的URL地址
picture = models.CharField(max_length=350, blank=True, null=True)
# 类型字段必填最大长度50字符
# OAuth服务类型weibo, github等
type = models.CharField(blank=False, null=False, max_length=50)
# 邮箱字段最大长度50字符允许为空
# 用户邮箱地址,可能为空(某些平台不提供邮箱)
email = models.CharField(max_length=50, null=True, blank=True)
# 元数据字段,文本类型,允许为空
# 存储额外的元数据信息使用JSON格式
metadata = models.TextField(null=True, blank=True)
# 创建时间字段,默认值为当前时间
# 记录创建时间,默认使用当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
# 记录最后修改时间,默认使用当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 对象的字符串表示方法
def __str__(self):
"""
定义模型的字符串表示形式
Returns:
str: 返回用户的昵称用于Admin和其他显示场景
"""
return self.nickname
# 模型的元数据配置
class Meta:
# 单数形式的显示名称
verbose_name = _('oauth user')
# 复数形式的显示名称
verbose_name_plural = verbose_name
# 默认按创建时间倒序排列
ordering = ['-creation_time']
"""模型元数据配置"""
verbose_name = _('oauth user') # 模型在Admin中的单数显示名称
verbose_name_plural = verbose_name # 模型在Admin中的复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列
# OAuth配置模型类
class OAuthConfig(models.Model):
# 类型选择项
"""
OAuth服务配置模型
存储不同第三方OAuth服务的应用配置信息包括AppKeyAppSecret等
用于管理多个OAuth服务的认证参数
"""
# OAuth服务类型选择项
TYPE = (
('weibo', _('weibo')), # 微博
('google', _('google')), # 谷歌
('github', 'GitHub'), # GitHub
('facebook', 'FaceBook'), # Facebook
('qq', 'QQ'), # QQ
('weibo', _('weibo')), # 微博OAuth
('google', _('google')), # 谷歌OAuth
('github', 'GitHub'), # GitHub OAuth
('facebook', 'FaceBook'), # Facebook OAuth
('qq', 'QQ'), # QQ OAuth
)
# 类型字段,选择类型,默认值为'a'
# OAuth服务类型字段使用选择项限制输入
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# 应用密钥字段最大长度200字符
# OAuth应用的客户端IDApp Key
appkey = models.CharField(max_length=200, verbose_name='AppKey')
# 应用密钥字段最大长度200字符
# OAuth应用的客户端密钥App Secret
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# 回调URL字段最大长度200字符必填默认值为空字符串
# OAuth认证成功后的回调地址
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
# 是否启用字段布尔类型默认True
blank=False, # 不允许为空
default='' # 默认值为空字符串
)
# 标识该OAuth配置是否启用
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
# 创建时间字段,默认值为当前时间
# 记录配置创建时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
# 记录配置最后修改时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 数据验证方法
def clean(self):
# 检查同类型的配置是否已存在(排除当前记录)
"""
模型验证方法
确保同一类型的OAuth配置只能存在一个防止重复配置
Raises:
ValidationError: 当同一类型的配置已存在时抛出异常
"""
# 检查是否已存在相同类型的配置(排除当前记录)
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
# 如果已存在,抛出验证错误
# 抛出验证错误,提示该类型配置已存在
raise ValidationError(_(self.type + _('already exists')))
# 对象的字符串表示方法
def __str__(self):
"""
定义模型的字符串表示形式
Returns:
str: 返回OAuth服务类型名称
"""
return self.type
# 模型的元数据配置
class Meta:
# 单数形式的显示名称
verbose_name = 'oauth配置'
# 复数形式的显示名称
verbose_name_plural = verbose_name
# 默认按创建时间倒序排列
ordering = ['-creation_time']
"""模型元数据配置"""
verbose_name = 'oauth配置' # 模型在Admin中的中文显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列

@ -1,115 +1,171 @@
# 导入标准库模块
"""
OAuth 认证管理器模块
该模块实现了多平台OAuth认证的核心逻辑包含基类定义和具体平台实现
支持微博谷歌GitHubFacebookQQ等主流第三方登录平台
采用抽象基类和混合类设计模式提供统一的OAuth认证接口
"""
import json
import logging
import os
import urllib.parse
# 导入抽象基类模块
from abc import ABCMeta, abstractmethod
# 导入HTTP请求库
import requests
# 导入自定义工具函数
from djangoblog.utils import cache_decorator
# 导入OAuth模型
from oauth.models import OAuthUser, OAuthConfig
# 获取当前模块的日志
# 获取当前模块的日志记录
logger = logging.getLogger(__name__)
# OAuth授权令牌异常类
class OAuthAccessTokenException(Exception):
'''
oauth授权失败异常
OAuth授权令牌获取异常类
当从OAuth服务商获取访问令牌失败时抛出此异常
通常由于错误的授权码应用配置问题或网络问题导致
'''
# 基础OAuth管理器抽象基类
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
"""
OAuth认证管理器抽象基类
定义所有OAuth平台必须实现的接口和方法
提供统一的OAuth认证流程模板
"""
# OAuth授权页面URL需要子类实现
AUTH_URL = None
"""获取token"""
# 获取访问令牌的URL需要子类实现
TOKEN_URL = None
"""获取用户信息"""
# 获取用户信息的API URL需要子类实现
API_URL = None
'''icon图标名'''
# 平台图标名称,用于标识和显示(需要子类实现)
ICON_NAME = None
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""
初始化OAuth管理器
Args:
access_token: 已存在的访问令牌可选
openid: 已存在的用户OpenID可选
"""
self.access_token = access_token
self.openid = openid
# 访问令牌是否设置的属性
@property
def is_access_token_set(self):
"""检查访问令牌是否已设置"""
return self.access_token is not None
# 是否已授权的属性
@property
def is_authorized(self):
"""检查是否已完成授权拥有令牌和OpenID"""
return self.is_access_token_set and self.access_token is not None and self.openid is not None
# 抽象方法获取授权URL
@abstractmethod
def get_authorization_url(self, nexturl='/'):
"""获取授权页面URL抽象方法子类必须实现"""
pass
# 抽象方法:通过授权码获取访问令牌
@abstractmethod
def get_access_token_by_code(self, code):
"""通过授权码获取访问令牌(抽象方法,子类必须实现)"""
pass
# 抽象方法获取OAuth用户信息
@abstractmethod
def get_oauth_userinfo(self):
"""获取OAuth用户信息抽象方法子类必须实现"""
pass
# 抽象方法:获取用户头像
@abstractmethod
def get_picture(self, metadata):
"""从元数据中提取用户头像URL抽象方法子类必须实现"""
pass
# GET请求方法
def do_get(self, url, params, headers=None):
"""
执行GET请求的通用方法
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本内容
"""
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
logger.info(rsp.text) # 记录响应日志
return rsp.text
# POST请求方法
def do_post(self, url, params, headers=None):
"""
执行POST请求的通用方法
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本内容
"""
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
logger.info(rsp.text) # 记录响应日志
return rsp.text
# 获取配置的方法
def get_config(self):
"""
从数据库获取当前平台的OAuth配置
Returns:
OAuthConfig: 配置对象如果不存在则返回None
"""
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
# 微博OAuth管理器类
class WBOauthManager(BaseOauthManager):
"""
微博OAuth认证管理器
实现微博平台的OAuth2.0认证流程包括授权令牌获取和用户信息获取
"""
# 微博OAuth接口地址
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""初始化微博OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
self.client_id = config.appkey if config else '' # 应用Key
self.client_secret = config.appsecret if config else '' # 应用Secret
self.callback_url = config.callback_url if config else '' # 回调地址
super(
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
# 获取授权URL的方法
def get_authorization_url(self, nexturl='/'):
"""
生成微博授权页面URL
Args:
nexturl: 授权成功后跳转的URL
Returns:
str: 完整的授权URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -118,9 +174,19 @@ class WBOauthManager(BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌的方法
def get_access_token_by_code(self, code):
"""
使用授权码获取访问令牌
Args:
code: OAuth授权码
Returns:
OAuthUser: 用户信息对象
Raises:
OAuthAccessTokenException: 令牌获取失败时抛出
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -132,14 +198,20 @@ class WBOauthManager(BaseOauthManager):
obj = json.loads(rsp)
if 'access_token' in obj:
# 设置访问令牌和用户ID
self.access_token = str(obj['access_token'])
self.openid = str(obj['uid'])
return self.get_oauth_userinfo()
return self.get_oauth_userinfo() # 获取并返回用户信息
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息的方法
def get_oauth_userinfo(self):
"""
获取微博用户信息
Returns:
OAuthUser: 包含用户信息的对象获取失败返回None
"""
if not self.is_authorized:
return None
params = {
@ -150,31 +222,45 @@ class WBOauthManager(BaseOauthManager):
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['avatar_large']
user.nickname = datas['screen_name']
user.openid = datas['id']
user.type = 'weibo'
user.token = self.access_token
user.metadata = rsp # 存储原始响应数据
user.picture = datas['avatar_large'] # 用户头像
user.nickname = datas['screen_name'] # 用户昵称
user.openid = datas['id'] # 用户OpenID
user.type = 'weibo' # 平台类型
user.token = self.access_token # 访问令牌
if 'email' in datas and datas['email']:
user.email = datas['email']
user.email = datas['email'] # 用户邮箱
return user
except Exception as e:
logger.error(e)
logger.error('weibo oauth error.rsp:' + rsp)
return None
# 获取用户头像的方法
def get_picture(self, metadata):
"""
从元数据中提取微博用户头像
Args:
metadata: 用户元数据JSON字符串
Returns:
str: 用户头像URL
"""
datas = json.loads(metadata)
return datas['avatar_large']
# 代理管理器混入类
class ProxyManagerMixin:
# 初始化方法,设置代理
"""
代理管理器混合类
为OAuth管理器添加HTTP代理支持用于网络访问受限的环境
"""
def __init__(self, *args, **kwargs):
"""初始化代理配置"""
if os.environ.get("HTTP_PROXY"):
# 设置HTTP和HTTPS代理
self.proxies = {
"http": os.environ.get("HTTP_PROXY"),
"https": os.environ.get("HTTP_PROXY")
@ -182,28 +268,33 @@ class ProxyManagerMixin:
else:
self.proxies = None
# 使用代理的GET请求方法
def do_get(self, url, params, headers=None):
"""带代理支持的GET请求"""
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
# 使用代理的POST请求方法
def do_post(self, url, params, headers=None):
"""带代理支持的POST请求"""
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
# 谷歌OAuth管理器类继承代理混入类
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
谷歌OAuth认证管理器
实现谷歌平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
ICON_NAME = 'google'
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""初始化谷歌OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -214,25 +305,24 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL的方法
def get_authorization_url(self, nexturl='/'):
"""生成谷歌授权页面URL"""
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'openid email',
'scope': 'openid email', # 请求openid和email权限
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌的方法
def get_access_token_by_code(self, code):
"""使用授权码获取谷歌访问令牌"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
@ -247,8 +337,8 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息的方法
def get_oauth_userinfo(self):
"""获取谷歌用户信息"""
if not self.is_authorized:
return None
params = {
@ -256,38 +346,42 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
}
rsp = self.do_get(self.API_URL, params)
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['picture']
user.nickname = datas['name']
user.openid = datas['sub']
user.picture = datas['picture'] # 谷歌用户头像
user.nickname = datas['name'] # 谷歌用户姓名
user.openid = datas['sub'] # 谷歌用户唯一标识
user.token = self.access_token
user.type = 'google'
if datas['email']:
user.email = datas['email']
user.email = datas['email'] # 谷歌邮箱
return user
except Exception as e:
logger.error(e)
logger.error('google oauth error.rsp:' + rsp)
return None
# 获取用户头像的方法
def get_picture(self, metadata):
"""从元数据中提取谷歌用户头像"""
datas = json.loads(metadata)
return datas['picture']
# GitHub OAuth管理器类继承代理混入类
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
GitHub OAuth认证管理器
实现GitHub平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
ICON_NAME = 'github'
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""初始化GitHub OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -298,75 +392,79 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL的方法
def get_authorization_url(self, next_url='/'):
"""生成GitHub授权页面URL"""
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
'scope': 'user' # 请求用户信息权限
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌的方法
def get_access_token_by_code(self, code):
"""使用授权码获取GitHub访问令牌"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
from urllib import parse
r = parse.parse_qs(rsp)
r = parse.parse_qs(rsp) # 解析查询字符串格式的响应
if 'access_token' in r:
self.access_token = (r['access_token'][0])
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息的方法
def get_oauth_userinfo(self):
"""获取GitHub用户信息"""
# 使用Bearer Token认证方式调用GitHub API
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
try:
datas = json.loads(rsp)
user = OAuthUser()
user.picture = datas['avatar_url']
user.nickname = datas['name']
user.openid = datas['id']
user.picture = datas['avatar_url'] # GitHub头像
user.nickname = datas['name'] # GitHub姓名
user.openid = datas['id'] # GitHub用户ID
user.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
user.email = datas['email'] # GitHub邮箱
return user
except Exception as e:
logger.error(e)
logger.error('github oauth error.rsp:' + rsp)
return None
# 获取用户头像的方法
def get_picture(self, metadata):
"""从元数据中提取GitHub用户头像"""
datas = json.loads(metadata)
return datas['avatar_url']
# Facebook OAuth管理器类继承代理混入类
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
Facebook OAuth认证管理器
实现Facebook平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""初始化Facebook OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -377,25 +475,23 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL的方法
def get_authorization_url(self, next_url='/'):
"""生成Facebook授权页面URL"""
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
'scope': 'email,public_profile' # 请求邮箱和公开资料权限
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌的方法
def get_access_token_by_code(self, code):
"""使用授权码获取Facebook访问令牌"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
# 'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
@ -408,23 +504,24 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息的方法
def get_oauth_userinfo(self):
"""获取Facebook用户信息"""
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
'fields': 'id,name,picture,email' # 指定需要返回的字段
}
try:
rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp)
user = OAuthUser()
user.nickname = datas['name']
user.openid = datas['id']
user.nickname = datas['name'] # Facebook姓名
user.openid = datas['id'] # Facebook用户ID
user.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
user.email = datas['email'] # Facebook邮箱
# 处理嵌套的头像数据结构
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url'])
return user
@ -432,22 +529,27 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
logger.error(e)
return None
# 获取用户头像的方法
def get_picture(self, metadata):
"""从元数据中提取Facebook用户头像"""
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
# QQ OAuth管理器类
class QQOauthManager(BaseOauthManager):
"""
QQ OAuth认证管理器
实现QQ平台的OAuth2.0认证流程包含特殊的OpenID获取步骤
"""
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
API_URL = 'https://graph.qq.com/user/get_user_info'
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me'
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' # QQ特有的OpenID获取接口
ICON_NAME = 'qq'
# 初始化方法
def __init__(self, access_token=None, openid=None):
"""初始化QQ OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -458,8 +560,8 @@ class QQOauthManager(BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL的方法
def get_authorization_url(self, next_url='/'):
"""生成QQ授权页面URL"""
params = {
'response_type': 'code',
'client_id': self.client_id,
@ -468,8 +570,8 @@ class QQOauthManager(BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌的方法
def get_access_token_by_code(self, code):
"""使用授权码获取QQ访问令牌"""
params = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
@ -479,7 +581,7 @@ class QQOauthManager(BaseOauthManager):
}
rsp = self.do_get(self.TOKEN_URL, params)
if rsp:
d = urllib.parse.parse_qs(rsp)
d = urllib.parse.parse_qs(rsp) # 解析查询字符串响应
if 'access_token' in d:
token = d['access_token']
self.access_token = token[0]
@ -487,26 +589,28 @@ class QQOauthManager(BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OpenID的方法
def get_open_id(self):
"""
获取QQ用户的OpenID
QQ平台需要额外调用接口获取用户OpenID
"""
if self.is_access_token_set:
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.OPEN_ID_URL, params)
if rsp:
rsp = rsp.replace(
'callback(', '').replace(
')', '').replace(
';', '')
# 清理JSONP响应格式
rsp = rsp.replace('callback(', '').replace(')', '').replace(';', '')
obj = json.loads(rsp)
openid = str(obj['openid'])
self.openid = openid
return openid
# 获取OAuth用户信息的方法
def get_oauth_userinfo(self):
openid = self.get_open_id()
"""获取QQ用户信息"""
openid = self.get_open_id() # 先获取OpenID
if openid:
params = {
'access_token': self.access_token,
@ -517,39 +621,56 @@ class QQOauthManager(BaseOauthManager):
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
user.nickname = obj['nickname']
user.openid = openid
user.nickname = obj['nickname'] # QQ昵称
user.openid = openid # QQ OpenID
user.type = 'qq'
user.token = self.access_token
user.metadata = rsp
if 'email' in obj:
user.email = obj['email']
user.email = obj['email'] # QQ邮箱
if 'figureurl' in obj:
user.picture = str(obj['figureurl'])
user.picture = str(obj['figureurl']) # QQ头像
return user
# 获取用户头像的方法
def get_picture(self, metadata):
"""从元数据中提取QQ用户头像"""
datas = json.loads(metadata)
return str(datas['figureurl'])
# 获取所有启用的OAuth应用的缓存函数
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
"""
获取所有启用的OAuth应用配置
使用缓存装饰器缓存100分钟减少数据库查询
Returns:
list: 启用的OAuth管理器实例列表
"""
configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs:
return []
configtypes = [x.type for x in configs]
applications = BaseOauthManager.__subclasses__()
configtypes = [x.type for x in configs] # 提取启用的平台类型
applications = BaseOauthManager.__subclasses__() # 获取所有子类
# 过滤出已启用的平台管理器实例
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps
# 根据类型获取管理器实例的函数
def get_manager_by_type(type):
"""
根据平台类型获取对应的OAuth管理器
Args:
type: 平台类型字符串'weibo', 'github'
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例未找到返回None
"""
applications = get_oauth_apps()
if applications:
# 查找匹配平台类型的管理器
finds = list(
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),

@ -1,22 +1,64 @@
"""
OAuth 认证模板标签模块
该模块提供Django模板标签用于在模板中动态加载和显示OAuth第三方登录应用列表
主要功能是生成可用的OAuth应用链接并在模板中渲染
"""
# 导入Django模板模块
from django import template
# 导入URL反向解析功能
from django.urls import reverse
# 导入自定义的OAuth管理器用于获取可用的OAuth应用
from oauth.oauthmanager import get_oauth_apps
# 创建模板库实例
register = template.Library()
@register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request):
"""
自定义包含标签 - 加载OAuth应用列表
该模板标签用于在页面中渲染OAuth第三方登录的应用图标和链接
它会获取所有可用的OAuth应用并生成对应的登录URL
Args:
request: HttpRequest对象用于获取当前请求的完整路径
Returns:
dict: 包含应用列表的字典用于模板渲染
- 'apps': 包含OAuth应用信息的列表每个元素为(应用类型, 登录URL)的元组
"""
# 获取所有可用的OAuth应用配置
applications = get_oauth_apps()
# 检查是否存在可用的OAuth应用
if applications:
# 生成OAuth登录的基础URL不包含参数
baseurl = reverse('oauth:oauthlogin')
# 获取当前请求的完整路径,用于登录成功后跳转回原页面
path = request.get_full_path()
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications))
# 使用map和lambda函数处理每个OAuth应用生成应用信息列表
# 每个应用信息包含应用类型图标名称和完整的登录URL
apps = list(map(lambda x: (
# OAuth应用的类型/图标名称weibo, github等
x.ICON_NAME,
# 生成完整的登录URL包含应用类型和回调地址参数
'{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, # 基础登录URL
type=x.ICON_NAME, # OAuth应用类型
next=path # 登录成功后的回调地址
)),
applications)) # 遍历的应用列表
else:
# 如果没有可用的OAuth应用返回空列表
apps = []
# 返回模板渲染所需的上下文数据
return {
'apps': apps
}
'apps': apps # OAuth应用列表传递给模板进行渲染
}

@ -1,62 +1,112 @@
"""
OAuth 认证测试模块
该模块包含OAuth认证系统的完整测试用例覆盖所有支持的第三方登录平台
测试包括配置验证授权流程用户信息获取和异常处理等场景
"""
import json
from unittest.mock import patch
# 导入Django测试相关模块
from django.conf import settings
from django.contrib import auth
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
# 导入项目工具函数和模型
from djangoblog.utils import get_sha256
from oauth.models import OAuthConfig
from oauth.oauthmanager import BaseOauthManager
# 创建测试类
class OAuthConfigTest(TestCase):
"""
OAuth配置模型测试类
测试OAuth配置的基本功能包括配置保存和基础授权流程
"""
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
"""
测试初始化方法
在每个测试方法执行前运行创建测试所需的客户端和工厂对象
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
def test_oauth_login_test(self):
# 创建OAuth配置实例
"""
测试OAuth登录流程
验证微博OAuth配置的创建和基础授权重定向功能
"""
# 创建微博OAuth配置
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 测试OAuth登录重定向
# 测试OAuth登录请求,应该重定向到微博授权页面
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
self.assertEqual(response.status_code, 302) # 验证重定向状态码
self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博
# 测试授权回调处理
# 测试授权回调请求(模拟授权码流程)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302) # 验证重定向状态码
self.assertEqual(response.url, '/') # 验证重定向到首页
class OauthLoginTest(TestCase):
"""
OAuth登录流程测试类
测试所有支持的OAuth平台的完整登录流程包括模拟API调用和用户认证
"""
def setUp(self) -> None:
# 初始化测试环境
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
"""
测试初始化方法
创建测试环境初始化所有OAuth应用配置
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
self.apps = self.init_apps() # 初始化所有OAuth应用配置
def init_apps(self):
# 初始化所有OAuth应用配置
"""
初始化所有OAuth应用配置
为每个OAuth平台创建测试配置数据
Returns:
list: 初始化的OAuth管理器实例列表
"""
# 获取所有OAuth管理器的子类并实例化
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
# 为每个平台创建测试配置
c = OAuthConfig()
c.type = application.ICON_NAME.lower()
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.type = application.ICON_NAME.lower() # 设置平台类型
c.appkey = 'appkey' # 测试应用Key
c.appsecret = 'appsecret' # 测试应用Secret
c.save()
return applications
def get_app_by_type(self, type):
# 根据类型获取对应的OAuth应用
"""
根据类型获取OAuth应用
Args:
type: 平台类型字符串
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例
"""
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -64,84 +114,129 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
# 测试微博OAuth登录流程
"""
测试微博OAuth登录流程
使用模拟对象测试微博授权码获取令牌和用户信息的完整流程
Args:
mock_do_get: 模拟GET请求的mock对象
mock_do_post: 模拟POST请求的mock对象
"""
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
assert weibo_app # 验证微博应用存在
# 测试授权URL生成
url = weibo_app.get_authorization_url()
# 模拟API响应
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({
"avatar_large": "avatar_large",
"screen_name": "screen_name",
"id": "id",
"email": "email",
})
# 执行授权码换取用户信息流程
userinfo = weibo_app.get_access_token_by_code('code')
# 验证token和openid
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
# 验证返回的用户信息正确性
self.assertEqual(userinfo.token, 'access_token') # 验证访问令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户OpenID
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
# 测试Google OAuth登录流程
"""
测试谷歌OAuth登录流程
验证谷歌OAuth的令牌获取和用户信息解析
"""
google_app = self.get_app_by_type('google')
assert google_app
assert google_app # 验证谷歌应用存在
# 测试授权URL生成
url = google_app.get_authorization_url()
# 模拟API响应
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
})
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({
"picture": "picture",
"name": "name",
"sub": "sub",
"email": "email",
})
# 执行授权流程
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
# 验证token和openid
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'sub')
# 验证用户信息正确性
self.assertEqual(userinfo.token, 'access_token') # 验证访问令牌
self.assertEqual(userinfo.openid, 'sub') # 验证用户唯一标识
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
# 测试GitHub OAuth登录流程
"""
测试GitHub OAuth登录流程
验证GitHub的特殊令牌响应格式和用户信息获取
"""
github_app = self.get_app_by_type('github')
assert github_app
assert github_app # 验证GitHub应用存在
# 测试授权URL生成
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
# 模拟API响应
self.assertTrue("github.com" in url) # 验证URL包含GitHub域名
self.assertTrue("client_id" in url) # 验证URL包含客户端ID
# 设置模拟返回值 - GitHub特殊的查询字符串格式响应
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url",
"name": "name",
"id": "id",
"email": "email",
})
# 执行授权流程
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
# 验证token和openid
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, 'id')
# 验证用户信息正确性
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') # 验证GitHub令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户ID
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
# 测试Facebook OAuth登录流程
"""
测试Facebook OAuth登录流程
验证Facebook的令牌获取和嵌套头像数据结构处理
"""
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
assert facebook_app # 验证Facebook应用存在
# 测试授权URL生成
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
# 模拟API响应
self.assertTrue("facebook.com" in url) # 验证URL包含Facebook域名
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
# 设置模拟返回值 - 用户信息获取响应(包含嵌套的头像数据)
mock_do_get.return_value = json.dumps({
"name": "name",
"id": "id",
@ -152,16 +247,18 @@ class OauthLoginTest(TestCase):
}
}
})
# 执行授权流程
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
# 验证token
# 验证访问令牌正确性
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
# 模拟QQ登录的三步API调用响应
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
json.dumps({
'access_token=access_token&expires_in=3600', # 第一次调用:令牌获取响应
'callback({"client_id":"appid","openid":"openid"} );', # 第二次调用OpenID获取响应
json.dumps({ # 第三次调用:用户信息获取响应
"nickname": "nickname",
"email": "email",
"figureurl": "figureurl",
@ -169,25 +266,41 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
# 测试QQ OAuth登录流程
"""
测试QQ OAuth登录流程
验证QQ的特殊三步骤流程获取令牌 获取OpenID 获取用户信息
Args:
mock_do_get: 配置了side_effect的模拟GET请求对象
"""
qq_app = self.get_app_by_type('qq')
assert qq_app
assert qq_app # 验证QQ应用存在
# 测试授权URL生成
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
self.assertTrue("qq.com" in url) # 验证URL包含QQ域名
# 执行授权流程mock_do_get会根据side_effect顺序返回不同的响应
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
# 验证token
# 验证访问令牌正确性
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
# 测试包含邮箱的微博授权登录完整流程
"""
测试带邮箱的微博授权登录完整流程
# 模拟API响应
验证用户首次登录和重复登录的场景确保用户认证状态正确
"""
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
# 设置模拟用户信息(包含邮箱)
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
@ -196,29 +309,32 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试登录重定向
# 第一步发起OAuth登录请求
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
self.assertEqual(response.status_code, 302) # 验证重定向
self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博
# 测试授权回调
# 第二步:模拟授权回调(首次登录)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302) # 验证重定向
self.assertEqual(response.url, '/') # 验证重定向到首页
# 验证用户认证状态
user = auth.get_user(self.client)
assert user.is_authenticated
assert user.is_authenticated # 验证用户已认证
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名
self.assertEqual(user.email, mock_user_info['email']) # 验证邮箱
# 登出用户
self.client.logout()
# 重复登录测试
# 第三步:模拟再次登录(测试重复登录场景)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
# 验证用户再次认证状态
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
@ -228,12 +344,16 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
# 测试不包含邮箱的微博授权登录流程(需要补充邮箱)
"""
测试不带邮箱的微博授权登录完整流程
# 模拟API响应
验证需要补充邮箱的场景包括邮箱表单提交和邮箱确认流程
"""
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
# 设置模拟用户信息(不包含邮箱)
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
@ -241,20 +361,20 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试登录重定向
# 第一步发起OAuth登录请求
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调(应该重定向到邮箱补充页面)
# 第二步:模拟授权回调(应该重定向到邮箱补充页面)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
# 解析OAuth用户ID
# 解析OAuth用户ID从重定向URL中
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
# 提交邮箱信息
# 第三步:提交邮箱表单
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
@ -262,13 +382,13 @@ class OauthLoginTest(TestCase):
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
# 验证绑定成功URL
# 验证重定向到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
})
self.assertEqual(response.url, f'{url}?type=email')
# 测试邮箱确认流程
# 第四步:模拟邮箱确认链接点击
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
@ -277,11 +397,11 @@ class OauthLoginTest(TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证最终用户状态
# 验证最终用户认证状态和关联信息
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
self.assertTrue(user.is_authenticated) # 验证用户已认证
self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名
self.assertEqual(user.email, 'test@gmail.com') # 验证补充的邮箱
self.assertEqual(oauth_user.pk, oauth_user_id) # 验证OAuth用户关联正确

@ -1,40 +1,53 @@
# Django OAuth 认证模块的URL配置
"""
OAuth 认证URL路由配置模块
该模块定义了OAuth认证系统的所有URL路由包括授权回调邮箱验证绑定成功等端点
这些路由处理第三方登录的完整流程从初始授权到最终的用户绑定
"""
# 导入Django URL路由相关模块
from django.urls import path
# 导入视图模块
# 导入当前应用的视图模块
from . import views
# 定义应用命名空间用于URL反向解析
app_name = "oauth"
# 定义URL模式列表
# 定义URL模式列表将URL路径映射到对应的视图处理函数
urlpatterns = [
# OAuth授权回调处理路径
# OAuth授权回调端点 - 处理第三方平台返回的授权码
path(
r'oauth/authorize',
views.authorize), # 处理OAuth服务商返回的授权码
r'oauth/authorize', # URL路径/oauth/authorize
views.authorize, # 对应的视图函数
# 名称未指定使用默认可通过views.authorize.__name__访问
),
# 邮箱补充页面路径当OAuth用户信息缺少邮箱时使用
# 邮箱补充页面 - 当第三方登录未提供邮箱时显示
path(
r'oauth/requireemail/<int:oauthid>.html', # 包含OAuth用户ID参数
views.RequireEmailView.as_view(), # 使用类视图处理邮箱补充
name='require_email'), # URL名称用于反向解析
r'oauth/requireemail/<int:oauthid>.html', # URL路径包含OAuth用户ID参数
views.RequireEmailView.as_view(), # 类视图需要调用as_view()方法
name='require_email' # URL名称用于反向解析
),
# 邮箱确认验证路径
# 邮箱确认端点 - 验证用户提交的邮箱地址
path(
r'oauth/emailconfirm/<int:id>/<sign>.html', # 包含用户ID和签名参数
views.emailconfirm, # 处理邮箱确认请求
name='email_confirm'), # URL名称用于反向解析
r'oauth/emailconfirm/<int:id>/<sign>.html', # URL路径包含用户ID和签名参数
views.emailconfirm, # 对应的视图函数
name='email_confirm' # URL名称用于反向解析
),
# 绑定成功页面路径
# 绑定成功页面 - 显示OAuth账号绑定成功信息
path(
r'oauth/bindsuccess/<int:oauthid>.html', # 包含OAuth用户ID参数
views.bindsuccess, # 显示绑定成功页面
name='bindsuccess'), # URL名称用于反向解析
r'oauth/bindsuccess/<int:oauthid>.html', # URL路径包含OAuth用户ID参数
views.bindsuccess, # 对应的视图函数
name='bindsuccess' # URL名称用于反向解析
),
# OAuth登录入口路径
# OAuth登录入口 - 初始化第三方登录流程
path(
r'oauth/oauthlogin',
views.oauthlogin, # 处理OAuth登录请求
name='oauthlogin') # URL名称用于反向解析
r'oauth/oauthlogin', # URL路径/oauth/oauthlogin
views.oauthlogin, # 对应的视图函数
name='oauthlogin' # URL名称用于反向解析
)
]

@ -1,149 +1,223 @@
# 导入日志模块
import logging
# 创建视图文件
"""
OAuth 认证视图模块
该模块实现了OAuth认证系统的核心视图逻辑处理第三方登录的完整流程
包括授权初始化回调处理邮箱验证用户绑定等功能
"""
# 导入URL解析模块
from urllib.parse import urlparse
import logging
# 导入日志模块
from urllib.parse import urlparse # 导入URL解析工具
# 导入Django相关模块
# 导入Django核心模块
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
# 导入自定义模块
from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email, get_sha256
from oauth.forms import RequireEmailForm
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
# 获取日志记录器
from django.contrib.auth import get_user_model # 获取用户模型
from django.contrib.auth import login # 用户登录功能
from django.core.exceptions import ObjectDoesNotExist # 对象不存在异常
from django.db import transaction # 数据库事务
from django.http import HttpResponseForbidden # 403禁止访问响应
from django.http import HttpResponseRedirect # 重定向响应
from django.shortcuts import get_object_or_404 # 获取对象或404
from django.shortcuts import render # 模板渲染
from django.urls import reverse # URL反向解析
from django.utils import timezone # 时区工具
from django.utils.translation import gettext_lazy as _ # 国际化翻译
from django.views.generic import FormView # 表单视图基类
# 导入项目自定义模块
from djangoblog.blog_signals import oauth_user_login_signal # 信号量
from djangoblog.utils import get_current_site # 获取当前站点
from djangoblog.utils import send_email, get_sha256 # 邮件发送和加密工具
from oauth.forms import RequireEmailForm # 邮箱表单
from .models import OAuthUser # OAuth用户模型
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException # OAuth管理器
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)
def get_redirecturl(request):
"""获取重定向URL进行安全性验证"""
# 从请求参数获取next_url
"""
获取安全的重定向URL
验证next_url参数的安全性防止开放重定向漏洞
Args:
request: HttpRequest对象
Returns:
str: 安全的跳转URL默认为首页
"""
# 从请求参数获取跳转URL
nexturl = request.GET.get('next_url', None)
# 如果next_url是登录页面或为空则重定向到首页
# 如果nexturl为空或是登录页面则重定向到首页
if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/'
return nexturl
# 解析URL以验证域名安全性
# 解析URL以验证安全性
p = urlparse(nexturl)
# 检查URL是否指向外部域名防止开放重定向攻击
if p.netloc:
site = get_current_site().domain
# 检查域名是否匹配当前站点去除www前缀比较
# 比较域名忽略www前缀如果不匹配则视为非法URL
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
logger.info('非法url:' + nexturl)
return "/"
return "/" # 重定向到首页
return nexturl
def oauthlogin(request):
"""处理OAuth登录请求重定向到OAuth服务商授权页面"""
"""
OAuth登录入口视图
根据平台类型初始化第三方登录流程重定向到对应平台的授权页面
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到第三方授权页面或首页
"""
# 从请求参数获取OAuth平台类型
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
# 根据类型获取对应的OAuth管理器
return HttpResponseRedirect('/') # 类型为空则重定向到首页
# 获取对应平台的OAuth管理器
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
# 获取重定向URL并生成授权URL
return HttpResponseRedirect('/') # 管理器不存在则重定向到首页
# 获取安全的跳转URL
nexturl = get_redirecturl(request)
# 生成第三方平台的授权URL
authorizeurl = manager.get_authorization_url(nexturl)
# 重定向到第三方授权页面
return HttpResponseRedirect(authorizeurl)
def authorize(request):
"""处理OAuth授权回调完成用户认证"""
"""
OAuth授权回调视图
处理第三方平台返回的授权码获取访问令牌和用户信息
完成用户认证或引导用户补充信息
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到相应页面
"""
# 从请求参数获取OAuth平台类型
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
# 获取对应平台的OAuth管理器
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
# 获取授权码
# 从请求参数获取授权码
code = request.GET.get('code', None)
try:
# 使用授权码获取访问令牌
# 使用授权码获取访问令牌和用户信息
rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e:
# 处理访问令牌获取异常
# 处理令牌获取异常
logger.warning("OAuthAccessTokenException:" + str(e))
return HttpResponseRedirect('/')
except Exception as e:
# 处理其他异常
logger.error(e)
rsp = None
# 获取安全的跳转URL
nexturl = get_redirecturl(request)
# 如果获取用户信息失败,重新跳转到授权页面
if not rsp:
# 如果获取令牌失败,重新跳转到授权页面
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
# 获取OAuth用户信息
user = manager.get_oauth_userinfo()
if user:
# 处理昵称为空的情况
# 处理昵称为空的情况,生成默认昵称
if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try:
# 检查是否已存在该OAuth用户
# 检查是否已存在相同平台和OpenID的用户
temp = OAuthUser.objects.get(type=type, openid=user.openid)
# 更新现有用户信息
temp.picture = user.picture
temp.metadata = user.metadata
temp.nickname = user.nickname
user = temp
except ObjectDoesNotExist:
# 用户不存在,使用新用户对象
pass
# Facebook的token过长清空处理
# Facebook的token过长清空存储
if type == 'facebook':
user.token = ''
# 如果用户有邮箱,直接完成登录
# 如果用户有邮箱,直接完成登录流程
if user.email:
with transaction.atomic():
with transaction.atomic(): # 使用事务保证数据一致性
author = None
try:
# 尝试获取已关联的本地用户
author = get_user_model().objects.get(id=user.author_id)
except ObjectDoesNotExist:
pass
# 如果没有关联的本地用户
if not author:
# 创建或获取用户
# 根据邮箱获取或创建本地用户
result = get_user_model().objects.get_or_create(email=user.email)
author = result[0]
# 如果是新创建的用户
if result[1]:
try:
# 检查用户名是否已存在
# 检查昵称是否已被使用
get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
# 昵称可用,设置为用户名
author.username = user.nickname
else:
# 用户名冲突时生成唯一用户名
# 昵称已被使用,生成唯一用户名
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
# 设置用户来源和保存
author.source = 'authorize'
author.save()
# 关联OAuth用户和本地用户
user.author = author
user.save()
# 发送用户登录信号
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
# 登录用户
login(request, author)
# 重定向到目标页面
return HttpResponseRedirect(nexturl)
else:
# 没有邮箱时保存用户并跳转到邮箱补充页面
# 用户没有邮箱,保存用户信息并跳转到邮箱补充页面
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
@ -151,43 +225,69 @@ def authorize(request):
return HttpResponseRedirect(url)
else:
# 获取用户信息失败,重定向到目标页面
return HttpResponseRedirect(nexturl)
def emailconfirm(request, id, sign):
"""处理邮箱确认请求"""
"""
邮箱确认视图
验证邮箱确认链接的签名完成OAuth用户与本地用户的绑定
Args:
request: HttpRequest对象
id: OAuth用户ID
sign: 安全签名
Returns:
HttpResponseRedirect: 重定向到绑定成功页面
"""
# 验证签名是否存在
if not sign:
return HttpResponseForbidden()
# 验证签名安全性
# 验证签名是否正确
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
return HttpResponseForbidden()
# 获取OAuth用户对象
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
with transaction.atomic(): # 使用事务保证数据一致性
# 处理用户关联
if oauthuser.author:
# 已有关联用户,直接获取
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
# 创建新用户
# 没有关联用户,根据邮箱创建或获取用户
result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0]
# 如果是新创建的用户
if result[1]:
author.source = 'emailconfirm'
# 处理昵称,如果为空则生成唯一用户名
author.source = 'emailconfirm' # 设置用户来源
# 设置用户名(使用昵称或生成唯一用户名)
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
# 保存用户关联关系
oauthuser.author = author
oauthuser.save()
# 发送登录信号
# 发送用户登录信号
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
# 登录用户
login(request, author)
# 准备邮件内容
site = 'http://' + get_current_site().domain
# 准备绑定成功邮件内容
content = _('''
<p>Congratulations, you have successfully bound your email address. You can use
%(oauthuser_type)s to directly log in to this website without a password.</p>
@ -201,6 +301,8 @@ def emailconfirm(request, id, sign):
# 发送绑定成功邮件
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
# 重定向到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
@ -209,56 +311,96 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView):
"""需要邮箱补充的视图类"""
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
"""
邮箱补充表单视图
当第三方登录未提供邮箱时显示表单让用户输入邮箱地址
"""
form_class = RequireEmailForm # 使用的表单类
template_name = 'oauth/require_email.html' # 模板名称
def get(self, request, *args, **kwargs):
"""处理GET请求"""
oauthid = self.kwargs['oauthid']
"""
GET请求处理
检查OAuth用户是否已有邮箱如有则跳过此步骤
"""
oauthid = self.kwargs['oauthid'] # 获取OAuth用户ID
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 如果用户已有邮箱,理论上应该跳过此步骤
if oauthuser.email:
pass
# 如果已有邮箱,可以重定向到首页
# 这里可以添加重定向逻辑return HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
"""初始化表单数据"""
"""
设置表单初始值
Returns:
dict: 包含初始值的字典
"""
oauthid = self.kwargs['oauthid']
return {
'email': '',
'oauthid': oauthid
'email': '', # 邮箱初始值为空
'oauthid': oauthid # 隐藏的OAuth用户ID
}
def get_context_data(self, **kwargs):
"""添加上下文数据"""
"""
添加上下文数据
将OAuth用户的头像URL添加到模板上下文
"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 如果用户有头像,添加到上下文
if oauthuser.picture:
kwargs['picture'] = oauthuser.picture
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""处理表单验证通过后的逻辑"""
"""
表单验证通过后的处理
保存用户邮箱发送确认邮件
Args:
form: 验证通过的表单实例
Returns:
HttpResponseRedirect: 重定向到邮件发送提示页面
"""
# 获取表单数据
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
# 获取OAuth用户并更新邮箱
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
# 生成安全签名
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
# 构建确认链接
site = get_current_site().domain
if settings.DEBUG:
site = '127.0.0.1:8000'
site = '127.0.0.1:8000' # 调试模式使用本地地址
path = reverse('oauth:email_confirm', kwargs={
'id': oauthid,
'sign': sign
})
url = "http://{site}{path}".format(site=site, path=path)
# 准备邮箱确认邮件内容
# 准备邮件内容
content = _("""
<p>Please click the link below to bind your email</p>
@ -270,31 +412,51 @@ class RequireEmailView(FormView):
<br />
%(url)s
""") % {'url': url}
# 发送邮箱确认邮件
# 发送确认邮件
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
# 重定向到提示页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
url = url + '?type=email'
url = url + '?type=email' # 添加类型参数
return HttpResponseRedirect(url)
def bindsuccess(request, oauthid):
"""显示绑定成功页面"""
"""
绑定成功页面视图
根据绑定状态显示不同的成功信息
Args:
request: HttpRequest对象
oauthid: OAuth用户ID
Returns:
HttpResponse: 渲染的绑定成功页面
"""
# 获取绑定类型
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 根据类型显示不同的成功信息
# 根据类型设置不同的显示内容
if type == 'email':
# 邮箱已发送状态
title = _('Bind your email')
content = _(
'Congratulations, the binding is just one step away. '
'Please log in to your email to check the email to complete the binding. Thank you.')
else:
# 绑定完成状态
title = _('Binding successful')
content = _(
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type})
# 渲染绑定成功页面
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content

@ -1,14 +1,7 @@
# 导入Django管理模块
from django.contrib import admin
# 在这里注册模型到管理后台
# Register your models here.
# 定义OwnTrackLogs模型的管理类
class OwnTrackLogsAdmin(admin.ModelAdmin):
# 空pass语句使用Django管理后台的默认配置
# 没有自定义任何管理选项将使用Django的自动管理功能
pass
# 注意这个类定义后还需要使用admin.site.register()来实际注册模型
# 例如admin.site.register(OwnTrackLogs, OwnTrackLogsAdmin)

@ -1,17 +1,5 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义Owntracks应用的配置类
class OwntracksConfig(AppConfig):
# 指定应用的Python路径Django 3.x及以下版本必需
# 这应该与settings.INSTALLED_APPS中使用的路径匹配
name = 'owntracks'
# 在Django 3.2及以上版本中可以添加default_auto_field配置
# default_auto_field = 'django.db.models.BigAutoField'
# 可以在此添加应用启动时的初始化代码
# def ready(self):
# # 导入信号处理器等
# import owntracks.signals

@ -1,31 +1,20 @@
# 导入Django模型和时区模块
from django.db import models
from django.utils.timezone import now
# 在这里创建模型
# Create your models here.
# 定义OwnTrackLog模型类用于存储位置跟踪数据
class OwnTrackLog(models.Model):
# 用户标识字段必填最大长度100字符
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
# 纬度坐标,浮点数字段
lat = models.FloatField(verbose_name='纬度')
# 经度坐标,浮点数字段
lon = models.FloatField(verbose_name='经度')
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
# 定义对象的字符串表示,返回用户标识
return self.tid
class Meta:
# 按创建时间升序排列
ordering = ['creation_time']
# 在管理后台显示的单数名称
verbose_name = "OwnTrackLogs"
# 在管理后台显示的复数名称(与单数相同)
verbose_name_plural = verbose_name
# 指定获取最新记录的字段
get_latest_by = 'creation_time'
get_latest_by = 'creation_time'

@ -1,92 +1,64 @@
# 导入JSON处理模块
import json
# 导入Django测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入用户模型和位置跟踪模型
from accounts.models import BlogUser
from .models import OwnTrackLog
# 在这里创建测试
# Create your tests here.
# 定义OwnTrackLog模型测试类
class OwnTrackLogTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_own_track_log(self):
# 测试用例:验证位置跟踪日志功能
# 创建有效的位置数据(包含所有必需字段)
o = {
'tid': 12, # 用户ID
'lat': 123.123, # 纬度
'lon': 134.341 # 经度
'tid': 12,
'lat': 123.123,
'lon': 134.341
}
# 发送POST请求创建位置记录
self.client.post(
'/owntracks/logtracks', # 位置记录接口
json.dumps(o), # 将数据转换为JSON字符串
content_type='application/json') # 设置内容类型为JSON
# 验证记录是否成功创建
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1) # 应该有一条记录
self.assertEqual(length, 1)
# 创建无效的位置数据(缺少经度字段)
o = {
'tid': 12,
'lat': 123.123
# 缺少lon字段
}
# 发送POST请求应该不会创建新记录
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
# 验证记录数量没有增加
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1) # 应该仍然只有一条记录
self.assertEqual(length, 1)
# 测试未登录用户访问地图页面(应该重定向到登录页)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302) # 302表示重定向
self.assertEqual(rsp.status_code, 302)
# 创建超级用户用于后续测试
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 # 设置用户ID
s.lon = 123.234 # 设置经度
s.lat = 34.234 # 设置纬度
s.save() # 保存到数据库
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
# 测试日期显示页面(登录后应该可以正常访问)
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200) # 200表示成功
# 测试地图显示页面
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
# 测试获取数据接口(不带日期参数)
rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200)
# 测试获取数据接口(带日期参数)
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200)
self.assertEqual(rsp.status_code, 200)

@ -1,23 +1,12 @@
# 导入Django URL配置模块
from django.urls import path
# 导入当前应用的视图模块
from . import views
# 定义应用命名空间用于URL反向解析
app_name = "owntracks"
# 定义URL模式列表
urlpatterns = [
# 位置数据记录接口 - 接收和处理OwnTracks客户端发送的位置数据
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
# 地图显示页面 - 展示位置数据在地图上的可视化
path('owntracks/show_maps', views.show_maps, name='show_maps'),
# 数据获取接口 - 提供位置数据的JSON格式接口
path('owntracks/get_datas', views.get_datas, name='get_datas'),
# 日期显示页面 - 展示有位置记录的日期列表
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]
]

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

Loading…
Cancel
Save