Compare commits

..

3 Commits

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,152 +1,92 @@
"""
Django管理后台用户模型配置模块
本模块配置BlogUser模型在Django管理后台的显示和编辑行为
包括自定义表单验证列表显示字段搜索过滤等功能
主要组件
- BlogUserCreationForm: 用户创建表单处理密码验证和设置
- BlogUserChangeForm: 用户信息修改表单
- BlogUserAdmin: 用户模型管理后台配置类
"""
# 导入Django表单模块
from django import forms
# 导入Django用户管理模块
from django.contrib.auth.admin import UserAdmin
# 导入Django用户修改表单
from django.contrib.auth.forms import UserChangeForm
# 导入用户名字段
from django.contrib.auth.forms import UsernameField
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 在此注册模型
# 导入自定义用户模型
from .models import BlogUser
# 自定义用户创建表单
class BlogUserCreationForm(forms.ModelForm):
"""
博客用户创建表单
扩展自ModelForm专门用于在Django管理后台创建新用户
提供密码确认验证和密码哈希处理功能
"""
# 密码输入字段1 - 使用PasswordInput控件隐藏输入
# 密码字段1使用密码输入控件
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):
"""
密码确认字段验证方法
验证两次输入的密码是否一致确保用户输入正确的密码
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):
"""
表单保存方法
重写保存逻辑处理密码哈希化和设置用户来源
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
# 自定义用户修改表单继承自UserChangeForm
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)
# 自定义用户管理类继承自UserAdmin
class BlogUserAdmin(UserAdmin):
"""
博客用户管理后台配置类
继承自Django的UserAdmin自定义BlogUser模型在管理后台的显示和行为
配置列表显示搜索排序等管理界面功能
"""
# 指定用户编辑表单
# 指定修改表单
form = BlogUserChangeForm
# 指定用户创建表单
# 指定添加表单
add_form = BlogUserCreationForm
# 配置列表页面显示的字段
# 列表页面显示的字段
list_display = (
'id', # 用户ID
'nickname', # 用户昵称
'username', # 用户名
'email', # 邮箱地址
'last_login', # 最后登录时间
'date_joined', # 注册时间
'source' # 用户来源
)
# 配置列表中可点击跳转到编辑页面的字段
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
# 列表页面可点击的链接字段
list_display_links = ('id', 'username')
# 配置默认排序规则 - 按ID倒序排列最新的在前
# 默认排序字段
ordering = ('-id',)
# 配置搜索框可搜索的字段
# 搜索字段
search_fields = ('username', 'nickname', 'email')

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

@ -1,274 +1,162 @@
"""
用户认证表单模块
本模块定义用户相关的Django表单包括
- 用户登录表单
- 用户注册表单
- 密码重置表单
- 验证码表单
所有表单都包含Bootstrap样式类提供一致的用户界面体验
"""
# 导入Django表单模块
from django import forms
# 导入获取用户模型函数和密码验证工具
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)
# 设置用户名字段的输入控件和样式
# 设置用户名字段的小部件为文本输入框添加占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
attrs={'placeholder': "username", "class": "form-control"})
# 设置密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password'].widget = widgets.PasswordInput(
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
attrs={'placeholder': "password", "class": "form-control"})
# 注册表单类继承自Django内置的UserCreationForm
class RegisterForm(UserCreationForm):
"""
用户注册表单
继承自Django的UserCreationForm扩展邮箱字段和样式支持
用于新用户注册账号包含用户名邮箱和密码确认功能
"""
# 初始化方法
def __init__(self, *args, **kwargs):
"""
初始化表单设置所有字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(RegisterForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入控件和样式
# 设置用户名字段的小部件为文本输入框添加占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置邮箱字段的输入控件和样式
attrs={'placeholder': "username", "class": "form-control"})
# 设置邮箱字段的小部件为邮箱输入框添加占位符和CSS类
self.fields['email'].widget = widgets.EmailInput(
attrs={
'placeholder': "email", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
attrs={'placeholder': "email", "class": "form-control"})
# 设置密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password1'].widget = widgets.PasswordInput(
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码确认字段的输入控件和样式
attrs={'placeholder': "password", "class": "form-control"})
# 设置确认密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password2'].widget = widgets.PasswordInput(
attrs={
'placeholder': "repeat password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
attrs={'placeholder': "repeat password", "class": "form-control"})
# 邮箱字段的清理验证方法
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", # Bootstrap样式类
'placeholder': _("New password") # 占位符文本
"class": "form-control",
'placeholder': _("New password")
}
),
)
# 新密码字段2 - 用于密码确认
# 新密码字段2,用于确认密码
new_password2 = forms.CharField(
label="确认密码", # 中文标签
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control", # Bootstrap样式类
'placeholder': _("Confirm password") # 占位符文本
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
# 邮箱字段 - 用于标识用户和发送验证码
# 邮箱字段
email = forms.EmailField(
label='邮箱', # 中文标签
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Email") # 占位符文本
'class': 'form-control',
'placeholder': _("Email")
}
),
)
# 验证码字段 - 用于验证用户身份
# 验证码字段
code = forms.CharField(
label=_('Code'), # 字段标签
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Code") # 占位符文本
'class': 'form-control',
'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():
# 如果邮箱不存在,抛出验证错误
# 注释说明:这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
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'), # 字段标签
# 可以添加widget配置来设置样式
label=_('Email'),
)

@ -1,17 +1,4 @@
"""
用户账户应用数据库迁移文件
本迁移文件由Django自动生成用于创建自定义用户模型的数据库表结构
扩展了Django内置的AbstractUser模型添加了博客系统特有的用户字段
生成的表结构
- accounts_bloguser: 自定义博客用户表继承Django用户认证系统的所有功能
迁移依赖
- 依赖于Django auth应用的group和permission模型
"""
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由Django 4.1.7于2023-03-02 07:14自动生成
import django.contrib.auth.models
import django.contrib.auth.validators
@ -20,118 +7,64 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
用户账户应用初始迁移类
继承自migrations.Migration定义自定义用户模型的数据库表创建操作
initial = True 表示这是该应用的第一个迁移文件
主要功能
- 创建自定义用户模型BlogUser的数据库表
- 继承Django认证系统的所有基础字段
- 添加博客系统特有的自定义字段
- 设置模型的管理器和配置选项
"""
# 标记为初始迁移文件Django迁移系统会首先执行此文件
# 初始迁移类
initial = True
# 定义迁移依赖关系
# 依赖的迁移文件
dependencies = [
# 声明对Django认证系统组的依赖
# 使用auth应用的0012迁移文件确保用户权限系统正常工作
('auth', '0012_alter_user_first_name_max_length'),
]
# 定义迁移操作
# 迁移操作列表
operations = [
# 创建博客用户表的迁移操作
migrations.CreateModel(
# 模型名称 - 对应数据库表名 accounts_bloguser
name='BlogUser',
# 定义模型字段列表
name='BlogUser', # 自定义用户模型名称
fields=[
# 主键字段 - 使用BigAutoField作为自增主键
# 主键ID字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段 - 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权限系统的组多对多关联
('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')),
# 用户组多对多关系
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
# 用户权限多对多关系
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
# 模型元数据配置
# 模型元选项
options={
# 管理后台单数显示名称(中文)
'verbose_name': '用户',
# 管理后台复数显示名称(中文)
'verbose_name_plural': '用户',
# 默认排序规则 - 按ID倒序排列最新的记录在前
'ordering': ['-id'],
# 指定获取最新记录的字段 - 使用id字段确定最新记录
'get_latest_by': 'id',
'verbose_name': '用户', # 单数显示名称
'verbose_name_plural': '用户', # 复数显示名称
'ordering': ['-id'], # 默认按ID降序排列
'get_latest_by': 'id', # 指定获取最新记录的字段
},
# 定义模型管理器
# 模型管理器
managers=[
# 使用Django内置的UserManager管理用户对象
# 提供create_user、create_superuser等用户管理方法
# 使用Django默认的用户管理器
('objects', django.contrib.auth.models.UserManager()),
],
),

@ -1,100 +1,55 @@
"""
用户账户应用数据库迁移文件 - 字段优化更新
本迁移文件对初始用户模型进行字段优化和国际化改进
- 重命名时间字段使用更清晰的英文命名
- 更新字段显示名称统一使用英文verbose_name
- 移除冗余字段优化数据库结构
这是对0001_initial迁移的后续更新依赖于初始迁移创建的表结构
"""
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 由Django 4.2.5于2023-09-06 13:13自动生成
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
"""
用户模型字段优化迁移类
# 数据库迁移类
对BlogUser模型进行字段级别的优化和改进
- 标准化字段命名约定
- 改进国际化支持
- 优化时间字段的语义清晰度
此迁移依赖于accounts应用的0001_initial迁移文件
"""
# 定义迁移依赖关系 - 依赖于本应用的初始迁移
dependencies = [
# 依赖accounts应用的第一个迁移文件确保BlogUser表已创建
# 依赖accounts应用的0001_initial迁移文件
('accounts', '0001_initial'),
]
# 定义迁移操作序列 - 按顺序执行以下数据库变更
operations = [
# 修改模型选项 - 更新管理后台显示名称
# 修改BlogUser模型的元选项
migrations.AlterModelOptions(
name='bloguser',
options={
# 指定获取最新记录的字段 - 保持使用id字段
'get_latest_by': 'id',
# 保持默认排序规则 - 按ID倒序排列
'ordering': ['-id'],
# 更新单数显示名称为英文
'verbose_name': 'user',
# 更新复数显示名称为英文
'verbose_name_plural': 'user'
},
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
# 移除字段 - 删除created_time字段
# 该字段功能被creation_time字段替代
# 删除created_time字段
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
# 移除字段 - 删除last_mod_time字段
# 该字段功能被last_modify_time字段替代
# 删除last_mod_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,125 +1,58 @@
"""
自定义用户模型模块
本模块定义博客系统的自定义用户模型BlogUser扩展Django内置的AbstractUser模型
添加博客系统特有的用户字段和方法
"""
# 导入Django认证系统的抽象用户基类
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
class BlogUser(AbstractUser):
"""
博客系统自定义用户模型
继承自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 # 允许为空(非必填字段)
)
# 在此创建模型
# 自定义博客用户模型继承自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的方法
def get_absolute_url(self):
"""
获取用户的绝对URL相对路径
用于Django的通用视图和模板中生成用户详情页链接
Returns:
str: 用户详情页的URL路径
Example:
>>> user.get_absolute_url()
'/author/admin/'
"""
# 使用reverse函数通过URL名称和参数生成URL路径
# 使用反向解析生成作者详情页URL
return reverse(
'blog:author_detail', # URL配置的名称
kwargs={
'author_name': self.username # URL参数作者用户名
})
'blog:author_detail', kwargs={
'author_name': self.username})
# 对象的字符串表示方法
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包含HTTPS协议和域名
url = "https://{site}{path}".format(
site=site, # 站点域名
path=self.get_absolute_url() # 相对路径
)
# 构建完整的HTTPS URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 模型元数据配置
class Meta:
"""
模型元数据配置类
定义模型的数据库表配置和Django管理后台显示选项
"""
# 默认排序规则 - 按ID倒序排列最新的记录在前
# 默认按ID降序排列
ordering = ['-id']
# 管理后台单数显示名称(支持国际化)
# 单数显示名称
verbose_name = _('user')
# 管理后台复数显示名称 - 使用与单数相同的名称
# 复数显示名称(与单数相同)
verbose_name_plural = verbose_name
# 指定获取最新记录的字段 - 使用id字段确定最新记录1
# 指定获取最新记录的字段
get_latest_by = 'id'

@ -1,42 +1,31 @@
"""
用户账户应用测试模块
本模块包含用户账户相关的所有测试用例覆盖用户注册登录密码重置
邮箱验证等核心功能的测试
"""
# 导入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
class AccountTest(TestCase):
"""
用户账户功能测试类
测试用户账户相关的所有功能包括
- 用户认证和登录
- 用户注册流程
- 邮箱验证码功能
- 密码重置流程
- 权限访问控制
"""
# 在此创建测试
# 账户测试类继承自TestCase
class AccountTest(TestCase):
# 测试前置设置方法
def setUp(self):
"""
测试初始化方法
在每个测试方法执行前运行创建测试所需的初始数据和环境
"""
# 创建测试客户端用于模拟HTTP请求
# 创建测试客户端
self.client = Client()
# 创建请求工厂,用于构建请求对象
# 创建请求工厂
self.factory = RequestFactory()
# 创建测试用户
self.blog_user = BlogUser.objects.create_user(
@ -44,43 +33,40 @@ class AccountTest(TestCase):
email="admin@admin.com",
password="12345678"
)
# 设置测试用的新密码
# 设置测试密码
self.new_test = "xxx123--="
# 测试账户验证功能
def test_validate_account(self):
"""
测试账户验证和权限功能
验证超级用户的创建登录管理后台访问和文章管理权限
"""
# 创建超级用户
# 获取当前站点域名
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)
# 创建测试分类
# 创建分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章
# 创建文章
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
@ -90,129 +76,112 @@ class AccountTest(TestCase):
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.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) # 验证成功应返回None
self.assertEqual(err, None)
# 测试错误邮箱验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str) # 验证失败应返回错误信息字符串
self.assertEqual(type(err), str)
# 测试忘记密码邮箱验证码成功情况
def test_forget_password_email_code_success(self):
"""
测试忘记密码验证码请求成功场景
验证正确邮箱地址的验证码请求处理
"""
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
@ -221,68 +190,54 @@ 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(
email=self.blog_user.email,
).first() # type: BlogUser
).first() # 类型注解:BlogUser
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(
@ -290,14 +245,10 @@ 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(
@ -311,4 +262,4 @@ class AccountTest(TestCase):
data=data
)
self.assertEqual(resp.status_code, 200) # 应返回表单错误页面
self.assertEqual(resp.status_code, 200)

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

@ -1,91 +1,42 @@
"""
自定义用户认证后端模块
本模块提供扩展的用户认证功能支持使用用户名或邮箱进行登录
扩展了Django标准的ModelBackend认证后端
"""
# 导入获取用户模型函数
from django.contrib.auth import get_user_model
# 导入Django认证后端基类
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
return None

@ -1,127 +1,66 @@
"""
邮箱验证码工具模块
本模块提供邮箱验证码的生成发送验证和缓存管理功能
用于用户注册密码重置等需要邮箱验证的场景
主要功能
- 发送验证码邮件
- 验证码的存储和读取
- 验证码有效性验证
"""
# 导入类型提示模块
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
# 验证码有效期配置 - 5分钟
# 设置验证码过期时间为5分钟
_code_ttl = timedelta(minutes=5)
# 发送验证邮件函数
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""
发送验证码邮件
向指定邮箱发送包含验证码的邮件用于用户身份验证
"""发送重设密码验证码
Args:
to_mail (str): 接收邮件的邮箱地址
code (str): 要发送的验证码
subject (str): 邮件主题默认为"Verify Email"
Example:
>>> send_verify_email("user@example.com", "123456")
# 向user@example.com发送验证码123456
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
# 构建邮件HTML内容包含验证码信息
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
# 调用邮件发送工具发送邮件
# 调用发送邮件函数
send_email([to_mail], subject, html_content)
# 验证验证码函数
def verify(email: str, code: str) -> typing.Optional[str]:
"""
验证验证码是否有效
检查用户输入的验证码与缓存中存储的是否一致并验证有效性
"""验证code是否有效
Args:
email (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("验证成功")
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
# 从缓存中获取该邮箱对应的验证码
# 从缓存中获取对应邮箱的验证码
cache_code = get_code(email)
# 比较用户输入的验证码与缓存中的验证码
# 比较输入的验证码和缓存中的验证码
if cache_code != code:
# 验证码不匹配,返回错误信息
# 如果不匹配,返回错误信息
return gettext("Verification code error")
# 验证成功返回None
# 设置验证码到缓存函数
def set_code(email: str, code: str):
"""
设置验证码到缓存
将验证码存储到Django缓存系统中并设置有效期
Args:
email (str): 邮箱地址作为缓存键
code (str): 要存储的验证码
Example:
>>> set_code("user@example.com", "123456")
# 将验证码123456存储到缓存键为"user@example.com"
"""
# 使用Django缓存系统存储验证码设置5分钟有效期
"""设置code"""
# 将验证码存入缓存使用邮箱作为key设置过期时间
cache.set(email, code, _code_ttl.seconds)
# 从缓存获取验证码函数
def get_code(email: str) -> typing.Optional[str]:
"""
从缓存中获取验证码
根据邮箱地址从缓存中获取对应的验证码
Args:
email (str): 邮箱地址作为缓存键
Returns:
typing.Optional[str]:
- str: 找到的验证码
- None: 验证码不存在或已过期
Example:
>>> code = get_code("user@example.com")
>>> if code:
>>> print(f"验证码是: {code}")
>>> else:
>>> print("验证码不存在或已过期")
"""
# 从Django缓存系统中获取验证码
"""获取code"""
# 从缓存中获取对应邮箱的验证码
return cache.get(email)

@ -1,101 +1,85 @@
"""
用户账户视图模块
本模块包含用户账户相关的所有视图处理逻辑包括
- 用户注册登录登出
- 邮箱验证
- 密码重置
- 验证码发送
使用类视图和函数视图结合的方式处理用户认证流程
"""
# 导入日志模块
import logging
# 导入国际化翻译函数
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__)
class RegisterView(FormView):
"""
用户注册视图
处理新用户注册流程包括表单验证用户创建邮箱验证邮件发送等
"""
# 在此创建视图
# 注册视图类继承自FormView
class RegisterView(FormView):
# 指定使用的表单类
form_class = RegisterForm
# 指定注册页面模板
# 指定模板名称
template_name = 'account/registration_form.html'
# 使用CSRF保护装饰器
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""
请求分发方法添加CSRF保护装饰器
确保注册请求受到CSRF保护防止跨站请求伪造攻击
"""
# 调用父类的dispatch方法
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
# 获取结果页面路径
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)
@ -110,7 +94,6 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
@ -119,88 +102,60 @@ 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):
"""
请求分发方法添加不缓存装饰器
确保登出页面不会被浏览器缓存
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs)
# GET请求处理
def get(self, request, *args, **kwargs):
"""
处理GET请求的登出逻辑
执行用户登出操作清理侧边栏缓存
"""
# 执行用户登出
# 执行登出操作
logout(request)
# 清理侧边栏缓存
# 删除侧边栏缓存
delete_sidebar_cache()
# 调用父类方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图类继承自FormView
class LoginView(FormView):
"""
用户登录视图
处理用户登录认证支持记住登录状态功能
"""
# 指定使用的表单类
form_class = LoginForm
# 指定登录页面模板
# 指定模板名称
template_name = 'account/login.html'
# 登录成功后的默认重定向URL
success_url = '/'
# 重定向字段名称
redirect_field_name = REDIRECT_FIELD_NAME
# 记住登录状态的会话有效期(一个月)
# 登录会话有效期(一个月)
login_ttl = 2626560
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
# 使用多个装饰器保护登录视图
@method_decorator(sensitive_post_parameters('password')) # 敏感参数保护
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 不缓存
def dispatch(self, request, *args, **kwargs):
"""
请求分发方法添加安全装饰器
- sensitive_post_parameters: 保护密码参数
- csrf_protect: CSRF保护
- never_cache: 禁止缓存
"""
return super(LoginView, self).dispatch(request, *args, **kwargs)
# 获取上下文数据
def get_context_data(self, **kwargs):
"""
获取模板上下文数据
添加重定向URL到上下文
"""
# 从GET参数获取重定向URL
# 获取重定向URL
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
@ -208,44 +163,35 @@ class LoginView(FormView):
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)
return super(LoginView, self).form_valid(form)
else:
# 登录失败,重新渲染登录页面
# 表单无效,重新渲染表单
return self.render_to_response({
'form': form
})
# 获取成功后的重定向URL
def get_success_url(self):
"""
获取登录成功后的重定向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()]):
@ -253,117 +199,85 @@ class LoginView(FormView):
return redirect_to
# 账户结果页面视图函数
def account_result(request):
"""
账户操作结果页面视图
显示注册结果或处理邮箱验证
Args:
request: HTTP请求对象
Returns:
HttpResponse: 结果页面响应
"""
# 获取操作类型和用户ID
# 获取类型参数
type = request.GET.get('type')
# 获取用户ID参数
id = request.GET.get('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')
# 验证签名
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"])
# 保存用户信息
# 保存用户
blog_user.save()
# 重定向到登录页面
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"]
@ -371,7 +285,7 @@ class ForgetPasswordEmailCode(View):
code = generate_code()
# 发送验证邮件
utils.send_verify_email(to_email, code)
# 验证码到缓存
# 存验证码到缓存
utils.set_code(to_email, code)
return HttpResponse("ok")

@ -1,128 +1,173 @@
# 导入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 _
from .models import Article, Category, Tag, Links, SideBar, BlogSettings, ArticleLike, ArticleFavorite
# 在此注册模型
# 导入博客模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 文章表单(可扩展,比如集成富文本编辑器)
# 文章表单类
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') # 将选中文章状态改为 'p'ublished
makr_article_publish.short_description = _('发布选中的文章')
# 将选中的文章状态更新为发布
queryset.update(status='p')
# 批量操作:草稿文章
# 将文章设为草稿的管理动作函数
def draft_article(modeladmin, request, queryset):
queryset.update(status='d') # 草稿状态
draft_article.short_description = _('将选中文章设为草稿')
# 将选中的文章状态更新为草稿
queryset.update(status='d')
# 批量操作:关闭文章评论
# 关闭文章评论的管理动作函数
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c') # 关闭评论
close_article_commentstatus.short_description = _('关闭文章评论')
# 将选中的文章评论状态更新为关闭
queryset.update(comment_status='c')
# 批量操作:开放文章评论
# 打开文章评论的管理动作函数
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o') # 开放评论
open_article_commentstatus.short_description = _('开放文章评论')
# 将选中的文章评论状态更新为打开
queryset.update(comment_status='o')
# 设置管理动作的显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
# 文章管理后台类
class ArticleAdmin(admin.ModelAdmin):
list_per_page = 20 # 每页显示20条
search_fields = ('body', 'title') # 可以搜索正文和标题
# 文章管理类
class ArticlelAdmin(admin.ModelAdmin):
# 每页显示数量
list_per_page = 20
# 搜索字段
search_fields = ('body', 'title')
# 使用的表单类
form = ArticleForm
list_display = ( # 列表页显示的字段
'id', 'title', 'author', 'link_to_category', 'creation_time',
'views', '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 输入框展示,适合外键多的情况
# 自定义方法:分类显示为可点击链接
# 列表页面显示的字段
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字段用于优化大表查询
raw_id_fields = ('author', 'category',)
# 自定义分类链接字段方法
def link_to_category(self, obj):
# 获取分类模型的元信息
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):
form = super(ArticleAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True)
# 调用父类方法获取表单
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 限制作者字段只能选择超级用户
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
# 点击“查看站点”时跳转到文章详情页
# 保存模型的方法
def save_model(self, request, obj, form, change):
# 调用父类保存方法
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 获取站点查看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 # 博客设置后台,暂时无特殊配置
# 文章点赞管理后台类
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')
# 使用默认配置
pass

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

@ -1,39 +1,76 @@
# 导入日志模块
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('设置处理器缓存。')
setting = get_blog_setting() # 获取博客配置
# 缓存不存在,记录日志并生成新数据
logger.info('set processor cache.')
# 获取博客设置
setting = get_blog_setting()
# 构建上下文数据字典
value = {
# 网站名称
'SITE_NAME': setting.site_name,
# 是否显示谷歌广告
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
# 谷歌广告代码
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
# 网站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, # 统计代码(如 Google Analytics
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号
# 备案号
'BEIAN_CODE': setting.beian_code,
# 网站统计代码
'ANALYTICS_CODE': setting.analytics_code,
# 公安备案号
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
# 是否显示公安备案
"SHOW_GONGAN_CODE": setting.show_gongan_code,
# 当前年份
"CURRENT_YEAR": timezone.now().year,
# 全局头部内容
"GLOBAL_HEADER": setting.global_header,
# 全局尾部内容
"GLOBAL_FOOTER": setting.global_footer,
# 评论是否需要审核
"COMMENT_NEED_REVIEW": setting.comment_need_review,
}
cache.set(key, value, 60 * 60 * 10) # 缓存10小时
# 将数据存入缓存有效期10小时
cache.set(key, value, 60 * 60 * 10)
# 返回数据
return value

@ -1,401 +1,311 @@
# 导入时间模块
import time
import logging
# 导入Elasticsearch客户端
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
logger = logging.getLogger(__name__)
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') # 是否启用 Elasticsearch
# 检查是否启用Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
# 如果启用,则建立连接
# 如果启用Elasticsearch创建连接
if ELASTICSEARCH_ENABLED:
connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
# 导入Elasticsearch客户端
from elasticsearch import Elasticsearch
# 创建Elasticsearch客户端实例
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 导入Ingest客户端
from elasticsearch.client import IngestClient
# 创建Ingest客户端
c = IngestClient(es)
# 检查并创建geoip管道
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''
{
"description": "添加IP地理位置信息",
"processors": [
{ "geoip": { "field": "ip" } }
]
}
''')
# 定义 IP 地理位置信息内部文档
# 如果geoip管道不存在创建它
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
# 定义GeoIP内部文档类
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()
# 性能监控文档:记录每个请求的 URL、耗时、IP、用户代理等
# 定义耗时文档类继承自Document
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(
meta={
# 使用当前时间戳作为文档ID
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
# 保存文档使用geoip管道处理
doc.save(pipeline="geoip")
# 定义文章文档类继承自Document
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 使用IK中文分词
# 正文字段使用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'), 'id': Integer()})
category = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
tags = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
# 作者对象字段
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 分类对象字段
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 标签对象字段
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 发布时间字段
pub_time = Date()
# 状态字段
status = Text()
# 评论状态字段
comment_status = Text()
# 类型字段
type = Text()
# 浏览量字段
views = Integer()
# 文章排序字段
article_order = Integer()
# 索引配置
class Index:
# 索引名称
name = 'blog'
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# 新增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
# 索引设置
settings = {
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
def create_all_indices(self):
"""
创建所有索引
"""
if not self.es_enabled:
return False
# 元数据配置
class Meta:
# 文档类型
doc_type = 'Article'
try:
# 创建文章索引
ArticleDocument.init()
# 创建性能监控索引
ElapsedTimeDocument.init()
# 文章文档管理器类
class ArticleDocumentManager():
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()
# 初始化方法
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(
meta={
'id': article.id}, # 使用文章ID作为文档ID
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
'id': article.category.id},
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()], # 遍历所有标签
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
article_order=article.article_order) for article in articles]
# 重建索引方法
def rebuild(self, articles=None):
# 初始化索引
ArticleDocument.init()
# 如果没有提供文章,获取所有文章
articles = articles if articles else Article.objects.all()
# 将文章转换为文档
docs = self.convert_to_doc(articles)
# 保存所有文档
for doc in docs:
doc.save()
# 更新文档方法
def update_docs(self, docs):
# 遍历并保存所有文档
for doc in docs:
doc.save()

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

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

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

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

@ -1,62 +1,60 @@
from django.contrib.auth import get_user_model # Django 提供的获取用户模型的方法
from django.contrib.auth.hashers import make_password # 用于密码加密
# 导入获取用户模型函数
from django.contrib.auth import get_user_model
# 导入密码加密函数
from django.contrib.auth.hashers import make_password
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 引入文章、标签、分类模型
# 导入博客模型
from blog.models import Article, Tag, Category
# 创建测试数据的管理命令类
class Command(BaseCommand):
help = '创建测试数据' # 用于生成一些假数据,便于前端展示和功能测试
# 命令帮助信息
help = 'create test datas'
# 命令处理主方法
def handle(self, *args, **options):
# 创建或获取一个测试用户
# 获取或创建测试用户
user = get_user_model().objects.get_or_create(
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU') # 对明文密码进行哈希加密
)[0]
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]
category.save() # 保存分类对象
name='子类目', parent_category=pcategory)[0]
# 创建一个基础标签
# 保存子类目
category.save()
# 创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 循环创建 1~19 号文章,每篇文章都绑定到上面创建的子分类
# 循环创建20篇测试文章
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]
# 为每篇文章创建一个独立标签,如 标签1、标签2 ...
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
# 创建新标签
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
# 将独立标签和基础标签都添加到文章的标签集合中
# 为文章添加标签
article.tags.add(tag)
article.tags.add(basetag)
article.save() # 保存文章与标签的关联关系
# 保存文章
article.save()
# 清空缓存,确保新生成的数据能及时被正确索引或展示
# 导入缓存工具
from djangoblog.utils import cache
# 清理缓存
cache.clear()
# 输出成功提示
self.stdout.write(self.style.SUCCESS('已创建测试数据 \n'))
# 输出成功信息
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -1,69 +1,77 @@
# 导入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 = '通知百度收录相关 URL' # 用于将站点内的文章、标签、分类等 URL 提交给百度站长平台,加快收录
# 命令帮助信息
help = 'notify baidu url'
# 添加命令行参数
def add_arguments(self, parser):
"""
添加命令行参数允许用户选择要通知的类型文章 / 标签 / 分类 / 全部
"""
parser.add_argument(
'data_type',
type=str,
choices=['all', 'article', 'tag', 'category'], # 可选参数值
help='article所有文章, tag所有标签, category所有分类, all全部'
)
'data_type', # 参数名称
type=str, # 参数类型
choices=[
'all', # 所有类型
'article', # 仅文章
'tag', # 仅标签
'category'], # 仅分类
help='article : all article,tag : all tag,category: all category,all: All of these') # 帮助信息
# 获取完整URL的方法
def get_full_url(self, path):
"""
拼接完整的 URLhttps://example.com/path/
"""
# 构建完整的HTTPS URL
url = "https://{site}{path}".format(site=site, path=path)
return url
# 命令处理主方法
def handle(self, *args, **options):
# 获取用户传入的参数,决定要通知哪些类型的数据
data_type = options['data_type']
self.stdout.write('开始处理 %s' % data_type)
# 获取数据类型参数
type = options['data_type']
# 输出开始信息
self.stdout.write('start get %s' % type)
urls = [] # 用于存储所有需要提交的 URL
if data_type == 'article' or data_type == 'all':
# 如果是文章或全部将所有已发布status='p')的文章的完整 URL 加入列表
# 初始化URL列表
urls = []
# 处理文章URL
if type == 'article' or type == 'all':
# 获取所有已发布的文章
for article in Article.objects.filter(status='p'):
# 添加文章的完整URL
urls.append(article.get_full_url())
if data_type == 'tag' or data_type == 'all':
# 如果是标签或全部,将所有标签的绝对 URL 加入列表
# 处理标签URL
if type == 'tag' or type == 'all':
# 获取所有标签
for tag in Tag.objects.all():
# 获取标签的相对URL
url = tag.get_absolute_url()
# 添加标签的完整URL
urls.append(self.get_full_url(url))
if data_type == 'category' or data_type == 'all':
# 如果是分类或全部,将所有分类的绝对 URL 加入列表
# 处理分类URL
if type == 'category' or type == 'all':
# 获取所有分类
for category in Category.objects.all():
# 获取分类的相对URL
url = category.get_absolute_url()
# 添加分类的完整URL
urls.append(self.get_full_url(url))
# 输出即将提交的通知数量
# 输出开始通知信息
self.stdout.write(
self.style.SUCCESS(
'开始通知百度收录 %d 个 URL' %
len(urls)
)
)
# 调用百度通知工具,提交所有 URL
'start notify %d urls' %
len(urls)))
# 调用百度蜘蛛通知
SpiderNotify.baidu_notify(urls)
# 提交完成提示
self.stdout.write(self.style.SUCCESS('完成通知百度收录\n'))
# 输出完成信息
self.stdout.write(self.style.SUCCESS('finish notify'))

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

@ -1,67 +1,68 @@
import time # 添加这行
# 导入日志模块
import logging
from django.conf import settings
from django.utils import timezone
# 导入时间模块
import time
logger = logging.getLogger(__name__)
# 导入获取客户端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:
"""
在线用户中间件 - 记录每个请求的加载时间IP用户代理可选地存入 Elasticsearch
"""
# 在线中间件类,用于记录页面渲染时间和用户访问信息
class OnlineMiddleware(object):
# 初始化方法
def __init__(self, get_response=None):
# 保存get_response函数
self.get_response = get_response
# 调用父类初始化
super().__init__()
# 调用方法,处理请求和响应
def __call__(self, request):
# 记录请求开始时间
''' 页面渲染时间统计 '''
# 记录开始时间
start_time = time.time()
# 处理请求
# 调用后续中间件和视图,获取响应
response = self.get_response(request)
# 计算耗时,记录并显示到页面
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:
# 获取用户代理字符串
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:
try:
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()
# 计算渲染耗时
cast_time = time.time() - start_time
# 如果启用了Elasticsearch
if ELASTICSEARCH_ENABLED:
# 将耗时转换为毫秒并保留两位小数
time_taken = round((cast_time) * 1000, 2)
# 获取请求的URL路径
url = request.path
# 导入时区工具
from django.utils import timezone
# 创建耗时文档记录
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(), # 当前时间
useragent=user_agent, # 用户代理信息
ip=ip) # IP地址
# 在响应内容中替换加载时间占位符
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
logger.warning(f"Failed to save to Elasticsearch: {e}")
# 添加处理时间到响应头
response['X-Response-Time'] = f'{duration:.3f}s'
return response
# 记录错误日志
logger.error("Error OnlineMiddleware: %s" % e)
def process_exception(self, request, exception):
"""处理异常"""
logger.error(f"Middleware exception: {exception}")
return None
# 返回响应
return response

@ -1,4 +1,5 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由Django 4.1.7于2023-03-02 07:14自动生成
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
@ -7,141 +8,197 @@ import mdeditor.fields
class Migration(migrations.Migration):
# 初始迁移类
initial = True
initial = True # 表示这是第一个迁移文件
# 依赖关系
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型,通常是内置的 User 或自定义用户模型
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 迁移操作列表
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='网站关键字')),
# 文章摘要长度字段,整数类型
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
# 侧边栏文章数量字段
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
# 侧边栏评论数量字段
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
# 文章页面评论数量字段
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
# 是否显示谷歌广告字段,布尔类型
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
# 谷歌广告代码字段,可为空
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
# 是否开启网站评论功能字段
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
# 备案号字段,可为空
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
# 网站统计代码字段
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
# 是否显示公安备案字段
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
# 公安备案号字段,可为空
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
'verbose_name': '网站配置',
'verbose_name_plural': '网站配置',
'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='排序')),
# 是否启用字段
('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'],
'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='排序')),
# 是否启用字段
('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'],
'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='标签名')),
# 缩略名字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
'verbose_name': '标签',
'verbose_name_plural': '标签',
'ordering': ['name'],
'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='分类名')),
# 缩略名字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
# 权重排序字段,越大越靠前
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
# 父级分类字段,外键自关联,可为空
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
'verbose_name': '分类',
'verbose_name_plural': '分类',
'ordering': ['-index'],
'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='类型')),
# 浏览量字段,正整数
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
# 文章排序字段,数字越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
# 是否显示TOC目录字段
('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',
'verbose_name': '文章', # 单数显示名称
'verbose_name_plural': '文章', # 复数显示名称
'ordering': ['-article_order', '-pub_time'], # 按排序和发布时间降序排列
'get_latest_by': 'id', # 指定获取最新记录的字段
},
),
]

@ -1,22 +1,24 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
# 由Django 4.1.7于2023-03-29 06:08自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
('blog', '0001_initial'), # 依赖于第一个迁移文件
# 依赖blog应用的0001_initial迁移文件
('blog', '0001_initial'),
]
# 迁移操作列表
operations = [
# 新增字段global_footer用于存放网站公共尾部 HTML 内容(如版权信息等)
# 添加全局尾部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
# 新增字段global_header用于存放网站公共头部 HTML 内容(如导航栏上面的内容)
# 添加全局头部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_header',

@ -1,15 +1,18 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
# 由Django 4.2.1于2023-05-09 07:45自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于上一个迁移
# 依赖blog应用的0002_blogsettings_global_footer_and_more迁移文件
('blog', '0002_blogsettings_global_footer_and_more'),
]
# 迁移操作列表
operations = [
# 新增字段comment_need_review布尔值默认 False表示评论默认不需要审核
# 添加评论审核字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',

@ -1,29 +1,30 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
# 由Django 4.2.1于2023-05-09 07:51自动生成
from django.db import migrations
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
('blog', '0003_blogsettings_comment_need_review'), # 依赖于上一个迁移
# 依赖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',

@ -1,117 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
# 调整多个模型的 Meta 选项比如排序方式、verbose_name 等
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# 删除旧的时间字段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'),
),
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 对多个字段进行字段选项优化,比如 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'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# ...(其他字段类似调整,包括 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 字段也做了字段选项的优化调整,比如 verbose_name 更清晰)
# 对 Category、Links、Sidebar、Tag 等模型字段也做了类似的字段选项优化
]

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

@ -1,197 +0,0 @@
# 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')},
},
),
]

@ -1,18 +0,0 @@
# 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='点赞数'),
),
]

@ -1,32 +0,0 @@
# 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')},
},
),
]

@ -1,18 +0,0 @@
# 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,162 +1,180 @@
# 导入日志模块
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', _('首页'))
L = ('l', _('列表页'))
P = ('p', _('文章页'))
A = ('a', _('全部'))
S = ('s', _('幻灯片'))
# 首页显示
I = ('i', _('index'))
# 列表页显示
L = ('l', _('list'))
# 文章页面显示
P = ('p', _('post'))
# 全站显示
A = ('a', _('all'))
# 幻灯片显示
S = ('s', _('slide'))
# 基础模型抽象类
class BaseModel(models.Model):
# 主键ID字段自增
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('修改时间'), default=now)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_modify_time = models.DateTimeField(_('modify time'), default=now)
# 保存方法重写
def save(self, *args, **kwargs):
is_update_views = isinstance(self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == [
'views']
# 检查是否为文章视图更新
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_source = getattr(self, 'title', '') if 'title' in self.__dict__ else getattr(self, 'name', '')
setattr(self, 'slug', slugify(slug_source))
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
# 调用父类保存方法
super().save(*args, **kwargs)
# 获取完整URL的方法
def get_full_url(self):
# 获取当前站点域名
site = get_current_site().domain
url = "https://{site}{path}".format(site=site, path=self.get_absolute_url())
# 构建完整HTTPS URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 元数据配置 - 抽象类
class Meta:
abstract = True
# 抽象方法 - 获取绝对URL
@abstractmethod
def get_absolute_url(self):
pass
# 文章模型类继承自BaseModel
class Article(BaseModel):
"""文章模型"""
# 状态选择项
STATUS_CHOICES = (
('d', _('草稿')),
('p', _('发布')),
('d', _('Draft')), # 草稿
('p', _('Published')), # 已发布
)
# 评论状态选择项
COMMENT_STATUS = (
('o', _('开放评论')),
('c', _('关闭评论')),
('o', _('Open')), # 打开评论
('c', _('Close')), # 关闭评论
)
# 类型选择项
TYPE = (
('a', _('文章')),
('p', _('页面')),
('a', _('Article')), # 文章
('p', _('Page')), # 页面
)
# 标题字段,唯一
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 = _('文章')
# 单数显示名称
verbose_name = _('article')
# 复数显示名称
verbose_name_plural = verbose_name
# 指定获取最新记录的字段
get_latest_by = 'id'
# 获取绝对URL的方法
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
@ -165,72 +183,118 @@ 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):
"""
从文章正文中获取第一张图片URL
:return:
"""
# 使用正则表达式匹配Markdown图片语法
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# 分类模型类继承自BaseModel
class Category(BaseModel):
name = models.CharField(_('分类名称'), max_length=30, unique=True)
parent_category = models.ForeignKey('self', verbose_name=_('父级分类'), blank=True, null=True,
on_delete=models.CASCADE)
"""文章分类"""
# 分类名称字段,唯一
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
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('排序'))
# 排序索引字段
index = models.IntegerField(default=0, verbose_name=_('index'))
# 元数据配置
class Meta:
# 按索引降序排列
ordering = ['-index']
verbose_name = _('分类')
# 单数显示名称
verbose_name = _('category')
# 复数显示名称
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:
@ -239,17 +303,23 @@ 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 child not in categorys:
if category not in categorys:
categorys.append(child)
parse(child)
@ -257,146 +327,200 @@ class Category(BaseModel):
return categorys
# 标签模型类继承自BaseModel
class Tag(BaseModel):
name = models.CharField(_('标签名称'), max_length=30, unique=True)
"""文章标签"""
# 标签名称字段,唯一
name = models.CharField(_('tag name'), max_length=30, unique=True)
# slug字段用于URL
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 = _('标签')
# 单数显示名称
verbose_name = _('tag')
# 复数显示名称
verbose_name_plural = verbose_name
# 友情链接模型类
class Links(models.Model):
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)
"""友情链接"""
# 链接名称字段,唯一
name = models.CharField(_('link name'), max_length=30, unique=True)
# 链接地址字段
link = models.URLField(_('link'))
# 排序字段,唯一
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用字段
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
# 显示类型字段
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 元数据配置
class Meta:
# 按排序升序排列
ordering = ['sequence']
verbose_name = _('友情链接')
# 单数显示名称
verbose_name = _('link')
# 复数显示名称
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.name
# 侧边栏模型类
class SideBar(models.Model):
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)
"""侧边栏,可以展示一些html内容"""
# 标题字段
name = models.CharField(_('title'), max_length=100)
# 内容字段
content = models.TextField(_('content'))
# 排序字段,唯一
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用字段
is_enable = models.BooleanField(_('is enable'), default=True)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 元数据配置
class Meta:
# 按排序升序排列
ordering = ['sequence']
verbose_name = _('侧边栏')
# 单数显示名称
verbose_name = _('sidebar')
# 复数显示名称
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):
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)
"""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)
# 元数据配置
class Meta:
verbose_name = _('网站配置')
# 单数显示名称
verbose_name = _('Website configuration')
# 复数显示名称
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.site_name
# 清理验证方法
def clean(self):
# 确保只能有一个配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('只能存在一个配置'))
raise ValidationError(_('There can only be one configuration'))
# 保存方法
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 导入缓存工具
from djangoblog.utils import cache
# 保存后清理缓存
cache.clear()

@ -1,12 +1,22 @@
# 导入Haystack搜索索引相关模块
from haystack import indexes
# 导入文章模型
from blog.models import Article
# 文章搜索索引类继承自SearchIndex和Indexable
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):
# 只索引状态为已发布('p')的文章
return self.get_model().objects.filter(status='p')

@ -1,47 +1,69 @@
# 导入操作系统模块
import os
# 导入Django配置和文件上传相关模块
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
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相关模型
# 文章相关测试类
# 导入管理命令调用
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
# 在此创建测试
# 文章测试类
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'
@ -49,34 +71,36 @@ 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.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
self.assertEqual(0, article.tags.count()) # 初始应无标签
article.tags.add(tag) # 添加标签
# 验证初始标签数量为0
self.assertEqual(0, article.tags.count())
# 添加标签
article.tags.add(tag)
article.save()
self.assertEqual(1, article.tags.count()) # 应有1个标签
# 验证标签数量为1
self.assertEqual(1, article.tags.count())
# 批量创建20篇文章,均添加同一标签
# 批量创建20篇文章
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -89,54 +113,61 @@ class ArticleTest(TestCase):
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)
# 模拟通知爬虫(如搜索引擎)该文章已更新
# 测试蜘蛛通知功能
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)
@ -144,88 +175,97 @@ class ArticleTest(TestCase):
f = BlogSearchForm()
f.search()
# 模拟百度通知
# 测试百度蜘蛛通知
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
# 测试模板标签gravatar头像URL
# 测试Gravatar相关模板标签
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",
link='https://wwww.lylinux.net')
link.save()
# 访问友情链接页面
# 测试友情链接页面
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# 访问RSS Feed页面
# 测试RSS订阅
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
# 访问Sitemap页面
# 测试网站地图
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# 尝试删除一篇文章
# 测试管理后台页面
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)
# 尝试未授权上传
# 测试无签名上传应该返回403
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]
@ -234,13 +274,14 @@ 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'
@ -252,6 +293,7 @@ class ArticleTest(TestCase):
}'''
u.save()
# 创建另一个OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
@ -262,13 +304,12 @@ 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")
call_command("sync_user_avatar")
call_command("build_search_words")
call_command("build_index") # 构建搜索索引
call_command("ping_baidu", "all") # 百度推送
call_command("create_testdata") # 创建测试数据
call_command("clear_cache") # 清理缓存
call_command("sync_user_avatar") # 同步用户头像
call_command("build_search_words") # 构建搜索词

@ -1,100 +1,91 @@
# 导入Django URL路由相关模块
from django.urls import path
from django.views.decorators.cache import cache_page # Django缓存视图装饰器
# 导入缓存页面装饰器
from django.views.decorators.cache import cache_page
from . import views # 导入当前应用的视图
# 导入当前应用的视图模块
from . import views
app_name = "blog" # 应用命名空间
# 定义应用命名空间
app_name = "blog"
# 定义URL模式列表
urlpatterns = [
# 首页
# 首页URL使用IndexView类视图
path(
r'',
views.IndexView.as_view(),
name='index'),
# 首页分页
# 首页分页URL支持页码参数
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# 文章详情页通过年、月、日、文章ID定位
# 文章详情页URL包含年月日和文章ID参数
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# 文章点赞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'),
# 分类目录详情页,通过分类别名
# 分类详情页URL使用分类名称slug参数
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'),
# 文章归档页使用缓存60分钟
# 归档页面URL使用缓存装饰器缓存1小时
path(
'archives.html',
cache_page(60 * 60)(views.ArchivesView.as_view()),
cache_page(
60 * 60)( # 缓存1小时60分钟 * 60秒
views.ArchivesView.as_view()),
name='archives'),
# 友情链接页
# 友情链接页面URL
path(
r'links.html',
'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,100 +1,175 @@
# 导入日志模块
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
from django.db.models import F
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, render
# 导入HTTP响应类
from django.http import HttpResponse, HttpResponseForbidden
# 导入快捷函数
from django.shortcuts import get_object_or_404
from django.shortcuts import render
# 导入静态文件处理
from django.templatetags.static import static
# 导入时区工具
from django.utils import timezone
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入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, ArticleLike, ArticleFavorite
# 导入博客模型
from blog.models import Article, Category, LinkShowType, Links, Tag
# 导入评论表单
from comments.forms import CommentForm
# 导入插件管理钩子
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 导入工具函数
from djangoblog.utils import cache, get_blog_setting, get_sha256
# 获取日志器
logger = logging.getLogger(__name__)
# 文章列表视图基类继承自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.get('pages', '')
return self.request.get['pages']
# 页码属性
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
# 从URL参数或GET参数获取页码默认为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
@ -105,73 +180,64 @@ 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):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
@ -181,9 +247,11 @@ class CategoryDetailView(ArticleListView):
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
@ -192,22 +260,31 @@ class CategoryDetailView(ArticleListView):
return super(CategoryDetailView, self).get_context_data(**kwargs)
# 作者详情视图继承自ArticleListView
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
from uuslug import slugify
# 对作者名称进行slugify处理
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
# 获取查询集数据的方法
def get_queryset_data(self):
author_name = self.kwargs['author_name']
# 获取该作者的所有已发布文章
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
@ -215,18 +292,26 @@ class AuthorDetailView(ArticleListView):
return super(AuthorDetailView, self).get_context_data(**kwargs)
# 标签详情视图继承自ArticleListView
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
# 获取查询集数据的方法
def get_queryset_data(self):
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):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
@ -236,6 +321,7 @@ class TagDetailView(ArticleListView):
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
@ -243,30 +329,43 @@ class TagDetailView(ArticleListView):
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,
@ -275,46 +374,68 @@ 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:
"""
if request.method == 'POST':
# 获取签名参数
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
# 验证签名
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
# 遍历所有上传的文件
for filename in request.FILES:
# 生成时间目录
timestr = timezone.now().strftime('%Y/%m/%d')
# 图片扩展名列表
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
# 判断是否为图片
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
# 构建保存路径
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 生成唯一文件名
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
# 安全检查
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
# 生成静态文件URL
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
# 404错误页面视图
def page_not_found_view(
request,
exception,
@ -329,6 +450,7 @@ def page_not_found_view(
status=404)
# 500服务器错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -337,6 +459,7 @@ def server_error_view(request, template_name='blog/error_page.html'):
status=500)
# 403权限拒绝页面视图
def permission_denied_view(
request,
exception,
@ -349,204 +472,7 @@ def permission_denied_view(
'statuscode': '403'}, status=403)
# 清理缓存视图
def clean_cache_view(request):
cache.clear()
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)
return HttpResponse('ok')

File diff suppressed because it is too large Load Diff

@ -1,79 +1,76 @@
# 模块级注释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):
# 核心代码将查询集中所有评论的is_enable字段更新为False
# 将选中的评论设置为禁用状态
queryset.update(is_enable=False)
# 函数级注释:启用评论状态操作
# 管理员动作函数,用于批量启用选中的评论
# 启用评论状态的管理动作函数
def enable_commentstatus(modeladmin, request, queryset):
# 核心代码将查询集中所有评论的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字段用于优化大表查询
raw_id_fields = ('author', 'article')
# 搜索字段配置
# 搜索字段
search_fields = ('body',)
# 方法级注释:用户信息链接显示
# 自定义方法,在列表页显示带链接的用户信息
# 自定义用户信息链接字段方法
def link_to_userinfo(self, obj):
# 核心代码获取用户模型的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
# 获取文章模型的元信息
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,11 +1,8 @@
# 模块级注释Django应用配置模块
# 本模块定义了comments应用的配置信息用于Django应用注册和初始化设置
# 导入Django应用配置类
from django.apps import AppConfig
# 类级注释:评论应用配置类
# 继承自AppConfig用于配置comments应用的基本信息和启动行为
# 定义评论应用的配置类
class CommentsConfig(AppConfig):
# 应用名称字段定义应用的完整Python路径
# 此名称用于Django内部识别和应用引用
# 指定应用的名称
name = 'comments'

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

@ -1,66 +1,51 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由Django 4.1.7于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
# 类级注释:数据库迁移类
# 继承自migrations.Migration定义数据库结构变更的完整操作序列
class Migration(migrations.Migration):
# 标记为初始迁移
# 表示这是comments应用的第一个迁移文件
# 初始迁移类
initial = True
# 依赖关系定义
# 指定本迁移执行前需要先完成的依赖迁移
# 依赖关系
dependencies = [
# 依赖blog应用的初始迁移确保文章表已创建
# 依赖博客应用的0001_initial迁移文件
('blog', '0001_initial'),
# 依赖用户模型迁移,确保用户表已存在
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 迁移操作列表
# 按顺序执行的数据库操作集合
operations = [
# 创建模型操作
# 定义Comment模型的数据库表结构
# 创建评论模型
migrations.CreateModel(
name='Comment',
fields=[
# 主键字段自增BigAutoField作为评论的唯一标识
# 主键ID字段自增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
# 是否启用字段,控制评论是否显示
('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倒序排列最新评论显示在最前面
'ordering': ['-id'],
# 最新记录定义指定按id字段获取最新记录
'get_latest_by': 'id',
'verbose_name': '评论', # 单数显示名称
'verbose_name_plural': '评论', # 复数显示名称
'ordering': ['-id'], # 默认按ID降序排列
'get_latest_by': 'id', # 指定获取最新记录的字段
},
),
]

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

@ -1,86 +1,70 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 由Django 4.2.5于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
# 类级注释:数据库迁移类
# 继承自migrations.Migration定义对评论模型的多个结构变更操作
class Migration(migrations.Migration):
# 依赖关系定义
# 指定本迁移执行前需要完成的依赖迁移文件
# 迁移依赖关系
dependencies = [
# 依赖用户模型迁移,确保用户表结构就绪
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖博客应用的第5次迁移确保文章表结构稳定
# 依赖blog应用的0005迁移文件
('blog', '0005_alter_article_options_alter_category_options_and_more'),
# 依赖评论应用的第2次迁移确保之前的字段修改已应用
# 依赖comments应用的0002迁移文件
('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,56 +1,59 @@
# 模块级注释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
# 类级注释:评论模型类
# 继承自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)
# 启用状态字段:控制评论是否显示,默认不显示,使用国际化标签
# 是否启用字段,控制评论是否显示
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,60 +1,63 @@
# 模块级注释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
# 类级注释:评论系统测试类
# 继承自TransactionTestCase支持数据库事务的测试用例
class CommentsTest(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"
@ -64,45 +67,48 @@ class CommentsTest(TransactionTestCase):
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)
# 更新评论状态为启用后验证评论数量变为1
# 更新评论状态为启用
self.update_article_comment_status(article)
# 断言评论列表长度为1
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)
# 断言评论列表长度为2
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': '''
@ -118,28 +124,29 @@ class CommentsTest(TransactionTestCase):
''',
'parent_comment_id': parent_comment_id
'parent_comment_id': parent_comment_id # 设置父评论ID
})
# 验证回复评论提交成功
# 断言重定向响应
self.assertEqual(response.status_code, 302)
# 更新评论状态并重新获取文章
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
# 断言评论列表长度为3
self.assertEqual(len(article.comment_list()), 3)
# 测试评论树解析功能
# 获取父评论对象
comment = Comment.objects.get(id=parent_comment_id)
# 解析评论树
tree = parse_commenttree(article.comment_list(), comment)
# 断言评论树长度为1
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,23 +1,17 @@
# 模块级注释Django URL配置模块 - 评论系统路由
# 本模块定义了评论系统的URL路由配置将URL路径映射到对应的视图函数
# 导入Django 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'),
'article/<int:article_id>/postcomment', # URL路径模式
views.CommentPostView.as_view(), # 使用CommentPostView类视图处理
name='postcomment'), # URL名称
]

@ -1,29 +1,26 @@
# 模块级注释:评论邮件通知模块
# 本模块提供评论相关的邮件通知功能,包括新评论确认邮件和评论回复通知邮件
# 导入日志模块
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内容
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,
@ -31,17 +28,15 @@ 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内容
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/>
@ -51,11 +46,10 @@ 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,76 +1,80 @@
# 模块级注释Django视图模块 - 评论系统
# 本模块定义了评论提交的视图处理逻辑,包括评论验证、保存和重定向等功能
# Create your views here.
# 在此创建视图
# 导入验证错误异常
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方法确保CSRF保护生效
return super(CommentPostView, self).dispatch(*args, **kwargs)
# GET请求处理方法
def get(self, request, *args, **kwargs):
# 从URL参数获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象,如果不存在返回404
# 获取文章对象,不存在返回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):
# 获取文章ID
# 从URL参数获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象
# 获取文章对象不存在则返回404
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']
# 获取文章对象
# 获取文章对象不存在则返回404
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
@ -78,14 +82,14 @@ 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
# 处理回复评论的情况
# 处理父级评论(回复评论的情况
if form.cleaned_data['parent_comment_id']:
# 获取父级评论对象
parent_comment = Comment.objects.get(
@ -95,7 +99,7 @@ class CommentPostView(FormView):
# 保存评论到数据库
comment.save(True)
# 重定向到文章页面并定位到新评论的位置
# 重定向到文章详情页的特定评论位置
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))

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

@ -1,74 +1,55 @@
"""
DjangoBlog 后台管理站点配置模块
本模块定义了自定义的Django后台管理站点用于统一管理博客系统的所有数据模型
通过自定义AdminSite类实现了权限控制界面定制和模型注册的集中管理
主要功能
- 自定义后台管理站点外观和权限
- 集中注册所有应用的模型到统一后台
- 提供超级用户专属的管理界面
- 集成日志记录用户管理内容管理等功能
"""
# 导入Django管理站点基类
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管理站点类
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
@ -80,38 +61,36 @@ class DjangoBlogAdminSite(AdminSite):
# return urls + my_urls
# 创建自定义后台管理站点的实例
# 创建DjangoBlog管理站点实例
admin_site = DjangoBlogAdminSite(name='admin')
# 注册博客相关模型到后台管理
admin_site.register(Article, ArticleAdmin) # 文章模型
# 注册博客相关模型和管理类
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(ArticleLike, ArticleLikeAdmin) # 文章点赞模型
admin_site.register(ArticleFavorite, ArticleFavoriteAdmin) # 文章收藏模型
# 注册服务器管理相关模型
admin_site.register(commands, CommandsAdmin) # 命令模型
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型
# 注册用户管理模型
# 注册用户模型
admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型
# 注册评论管理模型
# 注册评论模型
admin_site.register(Comment, CommentAdmin) # 评论模型
# 注册OAuth认证相关模型
# 注册OAuth相关模型
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型
# 注册位置追踪相关模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置追踪日志模型
# 注册OwnTracks相关模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # OwnTracks日志模型
# 注册Django内置站点模型
admin_site.register(Site, SiteAdmin) # 站点模型
# 注册日志记录模型
admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型
# 注册日志条目模型
admin_site.register(LogEntry, LogEntryAdmin) # 日志条目模型

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

@ -1,20 +1,9 @@
"""
DjangoBlog 信号处理模块
本模块定义了DjangoBlog系统的所有信号处理函数用于在特定事件发生时执行相应的操作
通过Django的信号机制实现了模块间的解耦和事件驱动的编程模式
主要功能
- 邮件发送信号处理
- OAuth用户登录信号处理
- 模型保存后的回调处理
- 用户登录/登出事件处理
- 缓存管理和搜索引擎通知
"""
# 导入线程模块
import _thread
# 导入日志模块
import logging
# 导入Django信号相关模块
import django.dispatch
from django.conf import settings
from django.contrib.admin.models import LogEntry
@ -23,99 +12,82 @@ 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用户登录信号传递用户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)
msg.content_subtype = "html" # 设置内容类型为HTML
# 设置邮件内容类型为HTML
msg.content_subtype = "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()
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用户登录信号处理函数
处理第三方登录用户的头像保存和缓存清理
Args:
sender: 信号发送者
**kwargs: 包含用户ID参数
"""
# 从信号参数中获取用户ID
# 从信号参数获取用户ID
id = kwargs['id']
# 根据ID获取OAuth用户对象
# 获取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
# 保存用户头像并更新数据库
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
# 清理侧边栏缓存,确保显示最新用户信息
# 删除侧边栏缓存
delete_sidebar_cache()
# 模型保存后信号处理器
@receiver(post_save)
def model_post_save_callback(
sender,
@ -125,100 +97,71 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
"""
模型保存后回调信号处理函数
监听所有模型的post_save信号执行相应的缓存清理和通知操作
Args:
sender: 保存的模型类
instance: 保存的模型实例
created: 是否为新建记录
raw: 是否为原始保存
using: 使用的数据库别名
update_fields: 更新的字段集合
**kwargs: 其他参数
"""
clearcache = False # 标记是否需要清理整个缓存
# 跳过管理员操作日志的保存处理
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,17 +1,3 @@
"""
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
@ -19,218 +5,98 @@ 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'
@ -238,75 +104,47 @@ class ElasticSearchBackend(BaseSearchBackend):
result_class = SearchResult
# 创建SearchResult对象
result = result_class(
app_label,
model_name,
raw_result['_id'], # 文档ID
raw_result['_score'], # 匹配分数
raw_result['_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.
Args:
query_fragment: 原始查询片段
Returns:
str: 清理后的查询字符串
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.
"""
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
@ -317,86 +155,29 @@ 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
query = ElasticSearchQuery

@ -1,92 +1,64 @@
"""
RSS订阅源生成模块
本模块提供了DjangoBlog的RSS订阅功能基于Django的Feed框架实现
生成符合RSS 2.0标准的订阅源包含文章标题内容作者信息等
主要功能
- 生成博客文章的RSS订阅源
- 支持Markdown格式的内容渲染
- 提供作者信息和版权声明
- 符合RSS 2.0标准规范
"""
# 导入获取用户模型函数
from django.contrib.auth import get_user_model
# 导入Django聚合视图
from django.contrib.syndication.views import Feed
# 导入时区工具
from django.utils import timezone
# 导入RSS feed生成器
from django.utils.feedgenerator import Rss201rev2Feed
# 导入文章模型
from blog.models import Article
# 导入Markdown工具
from djangoblog.utils import CommonMarkdown
# Django博客Feed类继承自Feed
class DjangoBlogFeed(Feed):
# 指定使用RSS 2.0格式生成订阅源
# 指定feed类型为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]
# 获取每个项的标题
def item_title(self, item):
"""
获取单个项目的标题
"""
return item.title
# 获取每个项的描述将Markdown转换为HTML
def item_description(self, item):
"""
获取单个项目的描述内容
将文章的Markdown内容转换为HTML格式
"""
return CommonMarkdown.get_markdown(item.body)
# 获取Feed版权信息
def feed_copyright(self):
"""
获取订阅源版权信息
生成包含当前年份的版权声明
"""
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
# 获取每个项的链接
def item_link(self, item):
"""
获取单个项目的链接
"""
return item.get_absolute_url()
# 获取每个项的全局唯一标识符(需要实现)
def item_guid(self, item):
"""
获取单个项目的全局唯一标识符
"""
return
# 这里需要返回每个item的唯一标识符
# 通常可以使用文章的ID或绝对URL
pass

@ -1,149 +1,135 @@
"""
管理员操作日志后台管理模块
本模块提供了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 _
# 日志条目管理类
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):
"""
优化查询集 - 预取关联的内容类型数据
"""
# 调用父类方法获取查询集
queryset = super(LogEntryAdmin, self).get_queryset(request)
# 预取关联的内容类型数据
return queryset.prefetch_related('content_type')
# 获取管理动作的方法
def get_actions(self, request):
"""
移除批量删除操作 - 防止误删日志记录
"""
# 调用父类方法获取动作
actions = super(LogEntryAdmin, self).get_actions(request)
# 删除"删除选中"动作
if 'delete_selected' in actions:
del actions['delete_selected']
return actions

@ -1,93 +1,41 @@
"""
插件系统基础模块
本模块提供了插件系统的基础框架定义了所有插件的基类BasePlugin
实现了插件的元数据管理初始化流程钩子注册和插件信息获取等核心功能
主要功能
- 插件元数据定义和验证
- 标准化的插件初始化流程
- 钩子注册机制
- 插件信息统一管理
"""
import logging
# 初始化模块级日志器,用于记录插件相关操作
logger = logging.getLogger(__name__)
class BasePlugin:
"""
插件基类
所有具体插件的父类定义了插件的标准接口和基本行为
提供了插件元数据管理初始化钩子注册等基础功能
类属性:
PLUGIN_NAME: 插件名称必须由子类定义
PLUGIN_DESCRIPTION: 插件描述必须由子类定义
PLUGIN_VERSION: 插件版本必须由子类定义
"""
# 插件元数据 - 必须由子类重写的类属性
PLUGIN_NAME = None # 插件名称标识
PLUGIN_DESCRIPTION = None # 插件功能描述
PLUGIN_VERSION = None # 插件版本号
# 插件元数据
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):
"""
获取插件信息
返回包含插件完整元数据的字典用于插件信息展示和管理
Returns:
dict: 包含插件名称描述和版本的字典对象
:return: 包含插件元数据的字典
"""
# 构建并返回插件元数据字典
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,26 +1,7 @@
"""
钩子事件常量定义模块
本模块定义了文章相关的钩子事件常量用于在插件系统中标识不同的事件类型
这些常量作为事件触发器名称用于在特定时机执行注册的钩子函数
主要用途
- 统一管理事件名称常量
- 提供类型安全的钩子标识
- 便于在插件系统中注册和触发事件
"""
# 文章详情加载事件 - 当文章详情数据被加载时触发
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_CONTENT_HOOK_NAME = "the_content"

@ -1,116 +1,44 @@
"""
钩子管理系统模块
本模块提供了完整的钩子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动作钩子
按注册顺序执行所有注册到该钩子上的回调函数
动作钩子主要用于执行副作用操作不返回任何值
Args:
hook_name (str): 要执行的钩子名称
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
Examples:
>>> run_action('article_create', article_obj, user_obj)
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
"""
# 检查指定钩子是否有注册的回调函数
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过滤器钩子
将输入值依次传递给所有注册的回调函数进行链式处理
每个回调函数的返回值将作为下一个回调函数的输入值
Args:
hook_name (str): 要执行的过滤器钩子名称
value: 初始输入值将被回调函数处理
*args: 传递给回调函数的额外位置参数
**kwargs: 传递给回调函数的额外关键字参数
Returns:
any: 经过所有回调函数处理后的最终值
Examples:
>>> processed_content = apply_filters('the_content', raw_content)
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
"""
# 检查指定过滤器钩子是否有注册的回调函数
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,54 +1,19 @@
"""
插件动态加载模块
本模块提供了插件系统的动态加载功能负责在Django应用启动时自动加载和初始化已激活的插件
通过扫描插件目录并导入插件模块实现插件的热插拔管理
主要功能
- 动态扫描插件目录
- 按配置加载激活的插件
- 插件模块的导入和初始化
- 加载状态的日志记录
"""
import os
import logging
from django.conf import settings
# 初始化模块级日志器,用于记录插件加载过程
logger = logging.getLogger(__name__)
def load_plugins():
"""
动态加载并初始化插件
从配置的插件目录中加载所有激活的插件模块
此函数应在Django应用注册表准备就绪后调用
加载流程
1. 遍历settings.ACTIVE_PLUGINS中配置的插件名称
2. 检查插件目录和plugin.py文件是否存在
3. 动态导入插件模块
4. 记录加载成功或失败状态
注意插件模块的导入会触发其内部代码执行包括类定义和注册逻辑
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
"""
# 遍历所有在配置中激活的插件名称
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,117 +1,91 @@
"""
DjangoBlog 项目配置文件
本模块包含DjangoBlog项目的所有配置设置包括数据库应用中间件国际化缓存邮件等
根据Django 1.10+的配置规范组织支持开发和生产环境的不同配置
主要配置类别
- 基础路径和密钥配置
- 应用和中间件配置
- 数据库和缓存配置
- 国际化设置
- 静态文件和媒体文件配置
- 邮件和日志配置
- 安全相关配置
- 搜索和插件系统配置
"""
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/
"""
# 导入操作系统模块
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'
# 构建项目基础路径 - 使用pathlib现代路径处理方式
# 构建项目内的路径BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent
# 安全密钥配置 - 生产环境必须从环境变量获取
# 快速开发配置 - 不适用于生产环境
# 参见 https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# 安全警告:在生产环境中保持密钥机密!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# 调试模式开关 - 生产环境必须关闭
# 安全警告:在生产环境中不要开启调试模式!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# 测试模式标识 - 根据命令行参数判断是否为测试环境
# 测试模式判断
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# 允许的主机名配置 - 生产环境需要具体指定
# 允许的主机列表
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 = [
# 使用简的Admin配置
# 使用简的Admin配置
'django.contrib.admin.apps.SimpleAdminConfig',
# 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' # 项目主应用
'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', # 位置追踪
'compressor', # 静态文件压缩
'djangoblog' # 主应用
]
# 中间件配置 - 定义请求处理管道
# 中间件配置
MIDDLEWARE = [
# 安全相关中间件
'django.middleware.security.SecurityMiddleware',
# 会话管理中间件
'django.contrib.sessions.middleware.SessionMiddleware',
# 国际化中间件
'django.middleware.locale.LocaleMiddleware',
# Gzip压缩中间件
'django.middleware.gzip.GZipMiddleware',
'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.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'
'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' # 自定义在线中间件
]
# 根URL配置
@ -120,17 +94,16 @@ ROOT_URLCONF = 'djangoblog.urls'
# 模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'BACKEND': 'django.template.backends.django.DjangoTemplates', # Django模板引擎
'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',
# 自定义SEO处理器
'blog.context_processors.seo_processor'
'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处理器
],
},
},
@ -139,41 +112,42 @@ TEMPLATES = [
# WSGI应用配置
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# 数据库配置 - 使用MySQL作为默认数据库
# 数据库配置
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': '123456',
'HOST': '127.0.0.1',
'PORT': 3306,
'ENGINE': 'django.db.backends.mysql', # 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'),
@ -181,236 +155,232 @@ 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/
# Haystack搜索配置
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # Whoosh搜索引擎
'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缓存超时时间
CACHE_CONTROL_MAX_AGE = 2592000
# 缓存配置 - 默认使用本地内存缓存
# 缓存配置
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存
'TIMEOUT': 10800, # 缓存超时时间3小时
'LOCATION': 'unique-snowflake', # 缓存位置标识
}
}
# 如果配置了Redis环境变量则使用Redis缓存
# 如果设置了Redis环境变量使用Redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
'BACKEND': 'django.core.cache.backends.redis.RedisCache', # Redis缓存后端
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', # Redis连接地址
}
}
# 站点ID
SITE_ID = 1
# 百度站长平台通知URL
# 百度推送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'
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
# 管理员邮箱配置 - 用于错误报告
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加密
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,
'version': 1, # 日志配置版本
'disable_existing_loggers': False, # 不禁用现有日志记录器
'root': {
'level': 'INFO',
'handlers': ['console', 'log_file'],
'level': 'INFO', # 根日志级别
'handlers': ['console', 'log_file'], # 处理器
},
'formatters': {
'verbose': {
'verbose': { # 详细格式
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'require_debug_false': { # 要求调试关闭的过滤器
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'require_debug_true': { # 要求调试开启的过滤器
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'log_file': { # 文件日志处理器
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5,
'encoding': 'utf-8'
'class': 'logging.handlers.TimedRotatingFileHandler', # 时间轮转文件处理器
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径
'when': 'D', # 按天轮转
'formatter': 'verbose', # 使用详细格式
'interval': 1, # 轮转间隔
'delay': True, # 延迟创建
'backupCount': 5, # 备份文件数量
'encoding': 'utf-8' # 文件编码
},
'console': {
'console': { # 控制台处理器
'level': 'DEBUG',
'filters': ['require_debug_true'],
'filters': ['require_debug_true'], # 仅在调试模式下启用
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': {
'null': { # 空处理器
'class': 'logging.NullHandler',
},
'mail_admins': {
'mail_admins': { # 管理员邮件处理器
'level': 'ERROR',
'filters': ['require_debug_false'],
'filters': ['require_debug_false'], # 仅在非调试模式下发送邮件
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': {
'djangoblog': {
'djangoblog': { # 项目日志记录器
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
'propagate': True, # 向上传播
},
'django.request': {
'django.request': { # Django请求日志记录器
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': False,
'propagate': False, # 不向上传播
}
}
}
# 静态文件查找器配置
# 静态文件查找器
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 压缩器查找器
'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 = [
# 从相对URL创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# CSS压缩过滤
# CSS压缩
'compressor.filters.cssmin.CSSMinFilter'
]
# JavaScript压缩过滤器
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
# 媒体文件配置
# 媒体文件根目录
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
# 媒体文件URL
MEDIA_URL = '/media/'
# 框架选项配置
# X-Frame选项
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'"]
# 默认自增主键字段类型
# 安全头部配置 - 防XSS和其他攻击
SECURE_BROWSER_XSS_FILTER = True # 启用浏览器XSS过滤器
SECURE_CONTENT_TYPE_NOSNIFF = True # 防止MIME类型嗅探
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' # Referrer策略
# 内容安全策略 (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'"] # 对象来源(禁止)
# 默认自增字段类型
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Elasticsearch配置如果置了环境变量)
# Elasticsearch配置如果置了环境变量)
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch主机
},
}
# 使用Elasticsearch作为搜索后端
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', # Elasticsearch引擎
},
}
# 插件系统配置
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer',
'image_lazy_loading',
# 插件系统
PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ # 激活的插件列表
'article_copyright', # 文章版权
'reading_time', # 阅读时间
'external_links', # 外部链接
'view_count', # 浏览量统计
'seo_optimizer', # SEO优化
'image_lazy_loading', # 图片懒加载
]

@ -1,183 +1,90 @@
"""
站点地图生成模块
本模块定义了DjangoBlog的站点地图(Sitemap)配置用于生成搜索引擎友好的XML站点地图
包含静态页面文章分类标签和用户页面的站点地图配置
主要功能
- 生成符合搜索引擎标准的XML站点地图
- 为不同类型的内容设置不同的更新频率和优先级
- 提供最后修改时间信息
- 帮助搜索引擎更好地索引网站内容
"""
# 导入Django网站地图相关模块
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
Args:
item: URL名称
Returns:
str: 完整的URL路径
"""
# 使用反向解析生成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):
"""
获取所有发表过文章的用户
通过文章作者去重确保每个用户只出现一次
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,65 +1,90 @@
"""
搜索引擎蜘蛛通知模块
本模块提供了向搜索引擎主动推送URL更新的功能主要用于通知搜索引擎及时抓取网站内容更新
目前主要支持百度搜索引擎的URL推送接口
主要功能
- 向百度站长平台推送URL更新
- 批量推送URL列表
- 错误处理和日志记录
- 统一的推送接口封装
"""
import logging
import requests
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列表转换为换行分隔的字符串格式
data = '\n'.join(urls)
# 向百度推送接口发送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列表的推送
Args:
url: 单个URL字符串或URL列表
"""
# 调用百度推送方法处理URL
SpiderNotify.baidu_notify(url)
# 导入Django网站地图相关模块
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
priority = 0.5
# 更新频率
changefreq = 'daily'
# 返回要包含在网站地图中的项目
def items(self):
# 只包含首页
return ['blog:index', ]
# 获取每个项目的绝对URL
def location(self, item):
# 使用反向解析生成URL
return reverse(item)
# 文章网站地图类
class ArticleSiteMap(Sitemap):
# 更新频率为每月
changefreq = "monthly"
# 优先级为0.6
priority = "0.6"
# 返回所有已发布的文章
def items(self):
return Article.objects.filter(status='p')
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 分类网站地图类
class CategorySiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.6
priority = "0.6"
# 返回所有分类
def items(self):
return Category.objects.all()
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 标签网站地图类
class TagSiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
# 返回所有标签
def items(self):
return Tag.objects.all()
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 用户网站地图类
class UserSiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
# 返回所有有文章的作者(去重)
def items(self):
# 获取所有文章的作者并去重
return list(set(map(lambda x: x.author, Article.objects.all())))
# 获取用户加入时间作为最后修改时间
def lastmod(self, obj):
return obj.date_joined

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

@ -1,85 +1,96 @@
"""
DjangoBlog 项目URL配置模块
本模块定义了DjangoBlog项目的所有URL路由配置包括管理后台博客评论用户认证等功能的URL映射
采用Django 1.10+的URL配置方式支持国际化路由和静态文件服务
"""djangoblog URL 配置
主要路由分组
- 国际化路由配置
- 管理后台路由
- 博客应用路由
- 第三方应用路由
- 站点地图和订阅源
- 搜索功能路由
- 静态文件服务
`urlpatterns` 列表将 URL 路由到视图更多信息请参阅
https://docs.djangoproject.com/en/1.10/topics/http/urls/
示例
函数视图
1. 添加导入from my_app import views
2. 添加 URL urlpatternsurl(r'^$', views.home, name='home')
基于类的视图
1. 添加导入from other_app.views import Home
2. 添加 URL urlpatternsurl(r'^$', Home.as_view(), name='home')
包含其他 URLconf
1. 导入 include() 函数from django.conf.urls import url, include
2. 添加 URL urlpatternsurl(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'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# 自定义错误处理视图
handler404 = 'blog.views.page_not_found_view' # 404错误处理
handler500 = 'blog.views.server_error_view' # 500错误处理
handle403 = 'blog.views.permission_denied_view' # 403错误处理
# 健康检查接口函数
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')),
path('health/', health_check, name='health_check'),
path('i18n/', include('django.conf.urls.i18n')), # 国际化URL
path('health/', health_check, name='health_check'), # 健康检查接口
]
# 国际化URL模式配置 - 自动添加语言前缀的URL
# 添加国际化URL模式
urlpatterns += i18n_patterns(
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},
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
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, # 网站地图URL
name='django.contrib.sitemaps.views.sitemap'),
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
re_path(r'^feed/$', DjangoBlogFeed()), # RSS订阅URL
re_path(r'^rss/$', DjangoBlogFeed()), # RSS订阅别名URL
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), # 搜索URL
name='search'),
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)
# 开发环境媒体文件服务配置
re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理URL
re_path(r'', include('owntracks.urls', namespace='owntracks')), # 位置追踪URL
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,111 +1,90 @@
#!/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
# 导入SHA256哈希算法
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
# 导入缓存模块
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加密函数
Args:
str: 要加密的字符串
Returns:
str: SHA256加密后的十六进制字符串
"""
# 创建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()
# 尝试从缓存获取值
value = cache.get(key)
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
# 如果缓存值为默认值返回None
if str(value) == '__default_cache_value__':
return None
else:
return value
else:
# 缓存未命中,执行函数并设置缓存
# 记录缓存设置日志
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
# 执行原始函数
value = func(*args, **kwargs)
# 如果返回值为None设置默认缓存值
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
# 设置缓存值
cache.set(key, value, expiration)
return value
@ -114,125 +93,79 @@ def cache_decorator(expiration=3 * 60):
return wrapper
# 使视图缓存过期的函数
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
使指定路径的视图缓存失效确保内容更新后及时反映
Args:
path: URL路径
servername: 主机名
serverport: 端口号
key_prefix: 缓存键前缀
Returns:
bool: 是否成功删除缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
'''
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文本到HTML的转换功能支持代码高亮和目录生成
"""
# Markdown转换静态方法
@staticmethod
def _convert_markdown(value):
"""
内部Markdown转换方法
Args:
value: Markdown格式文本
Returns:
tuple: (转换后的HTML内容, 生成的目录)
"""
# 配置Markdown扩展
# 创建Markdown实例配置扩展
md = markdown.Markdown(
extensions=[
'extra', # 额外语法支持
'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,
@ -240,43 +173,30 @@ 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'
@ -295,35 +215,35 @@ def get_blog_setting():
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):
'''
保存用户头像
从远程URL下载用户头像并保存到本地静态文件目录
Args:
url: 头像URL地址
Returns:
str: 本地静态文件路径
:param url:头像url
:return: 本地路径
'''
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
@ -331,56 +251,45 @@ 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:
return settings.STATIC_URL
else:
# 如果没有设置静态URL构建完整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']
@ -394,6 +303,7 @@ ALLOWED_CLASSES = [
]
# 自定义class属性过滤器
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
@ -405,35 +315,29 @@ def class_filter(tag, name, value):
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
'a': ['href', 'title'], # 链接允许href和title属性
'abbr': ['title'], # 缩写允许title属性
'acronym': ['title'], # 首字母缩写允许title属性
'span': class_filter, # span使用自定义过滤器
'div': class_filter, # div使用自定义过滤器
'pre': class_filter, # pre使用自定义过滤器
'code': class_filter # code使用自定义过滤器
}
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# HTML清理函数
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
Args:
html: 要清理的HTML内容
Returns:
str: 清理后的安全HTML
使用bleach库进行白名单过滤防止XSS攻击
"""
return bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
tags=ALLOWED_TAGS, # 允许的标签
attributes=ALLOWED_ATTRIBUTES, # 允许的属性
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释

@ -1,21 +1,9 @@
# encoding: utf-8
"""
Whoosh中文搜索后端模块
本模块提供了基于Whoosh搜索引擎的中文全文搜索功能专门针对Django Haystack框架进行定制
集成了jieba中文分词器支持中文文本的高效索引和搜索
主要特性
- 中文分词支持使用jieba
- 高性能索引和搜索
- 拼写建议和查询高亮
- 多字段类型支持文本数字日期等
- 与Django Haystack框架深度集成
"""
# 导入Python 2/3兼容性支持
from __future__ import absolute_import, division, print_function, unicode_literals
# 导入标准库模块
import json
import os
import re
@ -23,70 +11,85 @@ import shutil
import threading
import warnings
# 导入Python 2/3兼容性模块
import six
# 导入Django配置
from django.conf import settings
# 导入配置错误异常
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
# 导入搜索结果模型
from haystack.models import SearchResult
# 导入Haystack工具函数
from haystack.utils import get_identifier, get_model_ct
from haystack.utils import log as logging
# 导入模型加载工具
from haystack.utils.app_loading import haystack_get_model
# 导入中文分词器
from jieba.analyse import ChineseAnalyzer
# 导入Whoosh搜索库
from whoosh import index
# 导入Whoosh分析器
from whoosh.analysis import StemmingAnalyzer
# 导入Whoosh字段类型
from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
from whoosh.fields import ID as WHOOSH_ID
# 导入Whoosh文件存储
from whoosh.filedb.filestore import FileStorage, RamStorage
# 导入Whoosh高亮组件
from whoosh.highlight import ContextFragmenter, HtmlFormatter
from whoosh.highlight import highlight as whoosh_highlight
# 导入Whoosh查询解析器
from whoosh.qparser import QueryParser
# 导入Whoosh搜索结果分页
from whoosh.searching import ResultsPage
# 导入Whoosh异步写入器
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?)?$')
# 线程本地存储 - 用于内存索引
# 线程本地存储
LOCALS = threading.local()
LOCALS.RAM_STORE = None
# 自定义Whoosh HTML格式化器
class WhooshHtmlFormatter(HtmlFormatter):
"""
简化的Whoosh HTML格式化器
提供跨后端一致的高亮结果显示格式
SolrXapian和Elasticsearch都使用这种格式化方式
这是一个比whoosh.HtmlFormatter更简单的HtmlFormatter
我们使用它来在不同后端之间获得一致的结果具体来说
SolrXapian和Elasticsearch都使用这种格式化
"""
template = '<%(tag)s>%(t)s</%(tag)s>'
# Whoosh搜索后端类
class WhooshSearchBackend(BaseSearchBackend):
"""
Whoosh搜索后端实现
继承自Haystack的BaseSearchBackend提供Whoosh搜索引擎的核心功能
支持文件存储和内存存储两种方式
"""
# Whoosh保留关键字
# Whoosh保留的特殊用途单词
RESERVED_WORDS = (
'AND',
'NOT',
@ -94,51 +97,44 @@ 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.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')
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')
self.log = logging.getLogger('haystack') # 日志记录器
# 设置方法
def setup(self):
"""
初始化设置
延迟加载在需要时进行初始化
创建或打开索引构建schema
延迟加载直到需要时
"""
from haystack import connections
new_index = False
@ -148,13 +144,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:
@ -165,7 +161,7 @@ class WhooshSearchBackend(BaseSearchBackend):
self.storage = LOCALS.RAM_STORE
# 构建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)
@ -179,33 +175,23 @@ class WhooshSearchBackend(BaseSearchBackend):
except index.EmptyIndexError:
self.index = self.storage.create_index(self.schema)
self.setup_complete = True
self.setup_complete = True # 标记设置完成
# 构建模式方法
def build_schema(self, fields):
"""
构建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),
ID: WHOOSH_ID(stored=True, unique=True), # ID字段
DJANGO_CT: WHOOSH_ID(stored=True), # Django内容类型字段
DJANGO_ID: WHOOSH_ID(stored=True), # Django ID字段
}
# 获取Haystack中硬编码的键数量
initial_key_count = len(schema_fields)
content_field_name = ''
# 处理每个字段
# 遍历所有字段构建模式
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)
@ -213,70 +199,62 @@ 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':
# 布尔字段
# 布尔字段处理
schema_fields[field_class.index_fieldname] = BOOLEAN(
stored=field_class.stored)
elif field_class.field_type == 'ngram':
# N-gram字段
# 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字段
# 边缘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
# 确保所有值为Unicode格式
for key in doc:
doc[key] = self._from_python(doc[key])
@ -290,6 +268,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if not self.silently_fail:
raise
# 记录对象标识符但不包含实际对象
self.log.error(
u"%s while preparing object for update" %
e.__class__.__name__,
@ -299,18 +278,12 @@ 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()
@ -332,14 +305,8 @@ class WhooshSearchBackend(BaseSearchBackend):
e,
exc_info=True)
# 清空索引方法
def clear(self, models=None, commit=True):
"""
清空索引
Args:
models: 要清空的模型列表
commit: 是否提交更改
"""
if not self.setup_complete:
self.setup()
@ -376,46 +343,28 @@ 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):
"""
计算分页参数
Args:
start_offset: 起始偏移量
end_offset: 结束偏移量
Returns:
tuple: (页码, 页大小)
"""
# 防止Whoosh错误
# 防止Whoosh抛出错误需要end_offset大于0
if end_offset is not None and end_offset <= 0:
end_offset = 1
@ -433,10 +382,11 @@ class WhooshSearchBackend(BaseSearchBackend):
if page_length and page_length > 0:
page_num = int(start_offset / page_length)
# Whoosh使用1-based页码
# 递增因为Whoosh使用基于1的页码
page_num += 1
return page_num, page_length
# 搜索方法,使用日志装饰器
@log_query
def search(
self,
@ -458,15 +408,10 @@ 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': [],
@ -475,7 +420,7 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string = force_str(query_string)
# 单字符查询(非通配符)返回无结果
# 单字符查询(非通配符)被停用词过滤器捕获,应该返回零结果
if len(query_string) <= 1 and query_string != u'*':
return {
'results': [],
@ -484,7 +429,7 @@ class WhooshSearchBackend(BaseSearchBackend):
reverse = False
# 处理排序
# 排序处理
if sort_by is not None:
sort_by_list = []
reverse_counter = 0
@ -493,7 +438,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if order_by.startswith('-'):
reverse_counter += 1
# Whoosh要求所有排序字段方向一致
# Whoosh要求所有排序字段使用相同的排序方向
if reverse_counter and reverse_counter != len(sort_by):
raise SearchBackendError("Whoosh requires all order_by fields"
" to use the same sort direction")
@ -510,7 +455,7 @@ class WhooshSearchBackend(BaseSearchBackend):
sort_by = sort_by_list[0]
# Whoosh不支持facet功能
# 警告不支持的功能
if facets is not None:
warnings.warn(
"Whoosh does not handle faceting.",
@ -580,7 +525,7 @@ class WhooshSearchBackend(BaseSearchBackend):
searcher = self.index.searcher()
parsed_query = self.parser.parse(query_string)
# 处理无效查询
# 处理无效/停用词查询
if parsed_query is None:
return {
'results': [],
@ -596,7 +541,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'reverse': reverse,
}
# 应用窄查询过滤
# 处理窄结果
if narrowed_results is not None:
search_kwargs['filter'] = narrowed_results
@ -616,7 +561,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# 检查页码有效性
# Whoosh 2.5.1的错误处理
if raw_page.pagenum < page_num:
return {
'results': [],
@ -624,7 +569,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# 处理搜索结果
# 处理结果
results = self._process_results(
raw_page,
highlight=highlight,
@ -638,7 +583,7 @@ class WhooshSearchBackend(BaseSearchBackend):
return results
else:
# 无文档时的处理
# 处理拼写建议
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -655,6 +600,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
# 更多类似此内容的方法
def more_like_this(
self,
model_instance,
@ -665,113 +611,10 @@ 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()
narrow_queries.add(' OR '.join(
['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
if additional_query_string and additional_query_string != '*':
narrow_queries.add(additional_query_string)
narrow_searcher = None
# 处理窄查询
if narrow_queries is not None:
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
'results': [],
'hits': 0,
}
if narrowed_results:
narrowed_results.filter(recent_narrowed_results)
else:
narrowed_results = recent_narrowed_results
page_num, page_length = self.calculate_page(start_offset, end_offset)
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()
parsed_query = self.parser.parse(query)
results = searcher.search(parsed_query)
if len(results):
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)
try:
raw_page = ResultsPage(raw_results, page_num, page_length)
except ValueError:
if not self.silently_fail:
raise
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
# 检查页码有效性
if raw_page.pagenum < page_num:
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
# 处理结果
results = self._process_results(raw_page, result_class=result_class)
searcher.close()
if hasattr(narrow_searcher, 'close'):
narrow_searcher.close()
return results
# 方法实现...
pass
# 处理搜索结果的方法
def _process_results(
self,
raw_page,
@ -779,11 +622,6 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string='',
spelling_query=None,
result_class=None):
"""
处理搜索结果
将Whoosh原始结果转换为Haystack格式
"""
from haystack import connections
results = []
@ -798,7 +636,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('.')
@ -813,7 +651,7 @@ class WhooshSearchBackend(BaseSearchBackend):
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] = []
@ -826,7 +664,7 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
additional_fields[string_key] = self._to_python(value)
# 除系统字段
# 除系统字段
del (additional_fields[DJANGO_CT])
del (additional_fields[DJANGO_ID])
@ -847,7 +685,7 @@ class WhooshSearchBackend(BaseSearchBackend):
self.content_field_name: [whoosh_result],
}
# 创建结果对象
# 创建搜索结果对象
result = result_class(
app_label,
model_name,
@ -858,7 +696,7 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
hits -= 1
# 拼写建议
# 拼写建议处理
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -874,16 +712,8 @@ 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)
@ -899,10 +729,11 @@ class WhooshSearchBackend(BaseSearchBackend):
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)
@ -912,52 +743,42 @@ class WhooshSearchBackend(BaseSearchBackend):
spelling_suggestion = ' '.join(suggested_words)
return spelling_suggestion
# Python值转换为Whoosh字符串
def _from_python(self, value):
"""
Python值转换为Whoosh字符串
Args:
value: Python值
将Python值转换为Whoosh的字符串
Returns:
str: Whoosh格式字符串
代码来自pysolr
"""
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):
"""
Whoosh值转换为Python值
Whoosh值转换为原生Python值
Args:
value: Whoosh值
Returns:
object: Python值
pysolr中相同方法的移植因为它们以相同的方式处理数据
"""
if value == 'true':
return True
elif value == 'false':
return False
# 日期时间解析
if value and isinstance(value, six.string_types):
possible_datetime = DATETIME_REGEX.search(value)
@ -975,10 +796,11 @@ class WhooshSearchBackend(BaseSearchBackend):
date_values['minute'],
date_values['second'])
# JSON解析尝试
try:
# 尝试使用json加载值
converted_value = json.loads(value)
# 处理大多数内置类型
if isinstance(
converted_value,
(list,
@ -990,54 +812,36 @@ class WhooshSearchBackend(BaseSearchBackend):
complex)):
return converted_value
except BaseException:
# 如果失败SyntaxError或其同类或者我们不信任它继续
pass
return value
# Whoosh搜索查询类
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):
"""
清理查询片段
对用户输入进行清理和转义处理
提供在将值呈现给后端之前清理用户输入的机制
Args:
query_fragment: 查询片段
Returns:
str: 清理后的查询字符串
Whoosh 1.X在这里有所不同因为您不能再使用反斜杠
来转义保留字符相反应该引用整个单词
"""
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
@ -1047,24 +851,15 @@ 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)
@ -1076,13 +871,13 @@ class WhooshSearchQuery(BaseSearchQuery):
else:
value = PythonData(value)
# 准备值
# 使用InputType准备查询
prepared_value = value.prepare(self)
if not isinstance(prepared_value, (set, list, tuple)):
prepared_value = self.backend._from_python(prepared_value)
# 段名处理
# 'content'是特殊保留
if field == 'content':
index_fieldname = ''
else:
@ -1107,91 +902,13 @@ class WhooshSearchQuery(BaseSearchQuery):
if value.post_process is False:
query_frag = prepared_value
else:
if filter_type in [
'content',
'contains',
'startswith',
'endswith',
'fuzzy']:
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
terms = []
if isinstance(prepared_value, six.string_types):
possible_values = prepared_value.split(' ')
else:
if is_datetime is True:
prepared_value = self._convert_datetime(
prepared_value)
possible_values = [prepared_value]
for possible_value in possible_values:
terms.append(
filter_types[filter_type] %
self.backend._from_python(possible_value))
if len(terms) == 1:
query_frag = terms[0]
else:
query_frag = u"(%s)" % " AND ".join(terms)
elif filter_type == 'in':
in_options = []
for possible_value in prepared_value:
is_datetime = False
if hasattr(possible_value, 'strftime'):
is_datetime = True
pv = self.backend._from_python(possible_value)
if is_datetime is True:
pv = self._convert_datetime(pv)
if isinstance(pv, six.string_types) and not is_datetime:
in_options.append('"%s"' % pv)
else:
in_options.append('%s' % pv)
query_frag = "(%s)" % " OR ".join(in_options)
elif filter_type == 'range':
start = self.backend._from_python(prepared_value[0])
end = self.backend._from_python(prepared_value[1])
if hasattr(prepared_value[0], 'strftime'):
start = self._convert_datetime(start)
if hasattr(prepared_value[1], 'strftime'):
end = self._convert_datetime(end)
query_frag = u"[%s to %s]" % (start, end)
elif filter_type == 'exact':
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
prepared_value = Exact(prepared_value).prepare(self)
query_frag = filter_types[filter_type] % prepared_value
else:
if is_datetime is True:
prepared_value = self._convert_datetime(prepared_value)
query_frag = filter_types[filter_type] % prepared_value
# 添加括号
if len(query_frag) and not isinstance(value, Raw):
if not query_frag.startswith('(') and not query_frag.endswith(')'):
query_frag = "(%s)" % query_frag
# 各种过滤器类型的处理
pass
return u"%s%s" % (index_fieldname, query_frag)
# Whoosh搜索引擎类
class WhooshEngine(BaseEngine):
"""
Whoosh搜索引擎配置
配置Haystack使用Whoosh作为搜索后端
"""
backend = WhooshSearchBackend
query = WhooshSearchQuery

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

@ -1,144 +1,82 @@
"""
Django Admin 管理站点配置模块 - OAuth 认证
该模块用于配置OAuth相关模型在Django Admin管理站点的显示和操作方式
包含OAuth用户和OAuth配置两个管理类用于自定义管理界面
"""
# 导入日志模块
import logging
# 导入Django Admin管理模块
# 导入Django管理后台模块
from django.contrib import admin
# 导入URL反向解析功能
# 在此注册模型
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 获取当前模块的日志记录
# 获取日志器
logger = logging.getLogger(__name__)
# OAuth用户管理类
class OAuthUserAdmin(admin.ModelAdmin):
"""
OAuth用户模型的管理配置类
自定义OAuthUser模型在Django Admin中的显示和行为
包括搜索列表显示过滤等功能
"""
# 设置可搜索的字段
# 搜索字段
search_fields = ('nickname', 'email')
# 设置每页显示的项目数量
# 每页显示数量
list_per_page = 20
# 设置列表页面显示的字段
# 列表页面显示的字段
list_display = (
'id', # 用户ID
'nickname', # 昵称
'link_to_usermodel', # 自定义方法:关联本地用户链接
'show_user_image', # 自定义方法:显示用户头像
'type', # OAuth类型
'email', # 邮箱
'id',
'nickname',
'link_to_usermodel', # 自定义用户模型链接字段
'show_user_image', # 自定义用户头像显示字段
'type',
'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):
"""
禁用添加权限
防止在Admin界面手动添加OAuth用户OAuth用户只能通过认证流程自动创建
Args:
request: HttpRequest对象
Returns:
bool: 始终返回False禁止添加新记录
"""
# 禁止在管理后台添加OAuth用户
return False
# 自定义用户模型链接字段方法
def link_to_usermodel(self, obj):
"""
自定义方法生成关联本地用户的链接
在Admin列表中显示关联的本地用户并提供跳转到用户编辑页面的链接
Args:
obj: OAuthUser实例对象
Returns:
str: 格式化的HTML链接包含用户昵称或邮箱显示
"""
# 检查是否存在关联的本地用户
# 如果有关联的用户
if obj.author:
# 获取关联用户模型的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图片标签,设置固定尺寸
# 返回格式化的HTML图片标签
return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
# 设置自定义方法在Admin中的显示名称
link_to_usermodel.short_description = '用户' # 关联用户列的显示名称
show_user_image.short_description = '用户头像' # 用户头像列的显示名称
# 设置自定义字段的显示名称
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# OAuth配置管理类
class OAuthConfigAdmin(admin.ModelAdmin):
"""
OAuth配置模型的管理配置类
自定义OAuthConfig模型在Django Admin中的显示方式
用于管理不同第三方平台的OAuth应用配置
"""
# 设置列表页面显示的字段
list_display = (
'type', # OAuth类型
'appkey', # 应用Key
'appsecret', # 应用Secret
'is_enable' # 是否启用
)
# 设置右侧过滤侧边栏的过滤字段
list_filter = ('type',) # 按OAuth类型过滤
# 列表页面显示的字段
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
# 右侧过滤器字段
list_filter = ('type',)

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

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

@ -1,4 +1,4 @@
# oauth/migrations/0001_initial.py
# 由Django 4.1.7于2023-03-07 09:53自动生成
from django.conf import settings
from django.db import migrations, models
@ -7,51 +7,75 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 初始迁移类
initial = True
# 依赖关系
dependencies = [
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 迁移操作列表
operations = [
# 创建OAuth配置模型
migrations.CreateModel(
name='OAuthConfig',
fields=[
# 主键ID字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# OAuth类型字段选择类型
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# AppKey字段
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
# AppSecret字段
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
# 回调地址字段,默认值为百度
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
# 是否启用字段
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置',
'ordering': ['-created_time'],
'verbose_name': 'oauth配置', # 单数显示名称
'verbose_name_plural': 'oauth配置', # 复数显示名称
'ordering': ['-created_time'], # 按创建时间降序排列
},
),
# 创建OAuth用户模型
migrations.CreateModel(
name='OAuthUser',
fields=[
# 主键ID字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 第三方平台用户唯一标识
('openid', models.CharField(max_length=50)),
# 用户昵称字段
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# 访问令牌字段,可为空
('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户头像字段,可为空
('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth类型字段
('type', models.CharField(max_length=50)),
# 邮箱字段,可为空
('email', models.CharField(blank=True, max_length=50, null=True)),
# 元数据字段,存储额外的用户信息
('metadata', models.TextField(blank=True, null=True)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 关联的用户字段,外键关联用户模型,可为空
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': 'oauth用户',
'verbose_name_plural': 'oauth用户',
'ordering': ['-created_time'],
'verbose_name': 'oauth用户', # 单数显示名称
'verbose_name_plural': 'oauth用户', # 复数显示名称
'ordering': ['-created_time'], # 按创建时间降序排列
},
),
]

@ -1,138 +1,104 @@
"""
Django 数据库迁移模块 - OAuth 认证配置更新
# 由Django 4.2.5于2023-09-06 13:13自动生成
该模块是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 # 导入时区工具
from django.conf import settings
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应用的初始迁移
# 依赖oauth应用的0001_initial迁移文件
('oauth', '0001_initial'),
]
# 定义要执行的数据库操作序列
# 迁移操作列表
operations = [
# 修改OAuthConfig模型的选项配置
# 修改OAuthConfig模型的选项
migrations.AlterModelOptions(
name='oauthconfig',
options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 保持中文显示名称不变
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置'
},
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
),
# 修改OAuthUser模型的选项配置
# 修改OAuthUser模型的元选项
migrations.AlterModelOptions(
name='oauthuser',
options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 将显示名称改为英文
'verbose_name': 'oauth user',
'verbose_name_plural': 'oauth user'
},
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
),
# 移除OAuthConfig模型的旧时间字段
# 删除OAuthConfig模型的created_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='created_time', # 删除旧的创建时间字段
name='created_time',
),
# 删除OAuthConfig模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time', # 删除旧的修改时间字段
name='last_mod_time',
),
# 移除OAuthUser模型的旧时间字段
# 删除OAuthUser模型的created_time字段
migrations.RemoveField(
model_name='oauthuser',
name='created_time', # 删除旧的创建时间字段
name='created_time',
),
# 删除OAuthUser模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time', # 删除旧的修改时间字段
name='last_mod_time',
),
# 为OAuthConfig模型添加新的创建时间字段
# 为OAuthConfig模型添加creation_time字段
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthConfig模型添加新的修改时间字段
# 为OAuthConfig模型添加last_modify_time字段
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 为OAuthUser模型添加新的创建时间字段
# 为OAuthUser模型添加creation_time字段
migrations.AddField(
model_name='oauthuser',
name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthUser模型添加新的修改时间字段
# 为OAuthUser模型添加last_modify_time字段
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改OAuthConfig回调地址字段的配置
# 修改OAuthConfig模型的callback_url字段的verbose_name和默认值
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
# 将默认回调地址改为空字符串,字段标签改为英文
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
# 修改OAuthConfig启用状态字段的标签
# 修改OAuthConfig模型的is_enable字段的verbose_name
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
# 保持字段定义不变只修改verbose_name为英文
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改OAuthConfig类型字段的选项和标签
# 修改OAuthConfig模型的type字段的verbose_name和选项值
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作者字段的标签
# 修改OAuthUser模型的author字段的verbose_name
migrations.AlterField(
model_name='oauthuser',
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'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改OAuthUser昵称字段的标签
# 修改OAuthUser模型的nickname字段的verbose_name
migrations.AlterField(
model_name='oauthuser',
name='nickname',
# 保持字段定义不变只修改verbose_name为英文
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]

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

@ -1,148 +1,98 @@
"""
OAuth 认证数据模型模块
该模块定义了OAuth认证系统所需的数据模型包括OAuth用户信息和OAuth服务商配置
用于存储第三方登录的用户数据和OAuth应用配置信息
"""
# 导入Django核心模块
# 在此创建模型
# 导入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, # 使用Django的可切换用户模型
verbose_name=_('author'), # 字段显示名称(支持国际化)
blank=True, # 允许表单中为空
null=True, # 允许数据库中为NULL
on_delete=models.CASCADE # 关联用户删除时级联删除OAuth用户
)
# 第三方平台的用户唯一标识符
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 第三方平台用户唯一标识
openid = models.CharField(max_length=50)
# 用户在第三方平台的昵称
# 用户昵称字段
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
# OAuth访问令牌用于调用第三方API
# 访问令牌字段,可为空
token = models.CharField(max_length=150, null=True, blank=True)
# 用户头像的URL地址
# 用户头像字段,可为空
picture = models.CharField(max_length=350, blank=True, null=True)
# OAuth服务类型weibo, github等
# OAuth类型字段必填
type = models.CharField(blank=False, null=False, max_length=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') # 模型在Admin中的单数显示名称
verbose_name_plural = verbose_name # 模型在Admin中的复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列
verbose_name = _('oauth user') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 按创建时间降序排列
# OAuth配置模型类
class OAuthConfig(models.Model):
"""
OAuth服务配置模型
存储不同第三方OAuth服务的应用配置信息包括AppKeyAppSecret等
用于管理多个OAuth服务的认证参数
"""
# OAuth服务类型选择项
# OAuth类型选择项
TYPE = (
('weibo', _('weibo')), # 微博OAuth
('google', _('google')), # 谷歌OAuth
('github', 'GitHub'), # GitHub OAuth
('facebook', 'FaceBook'), # Facebook OAuth
('qq', 'QQ'), # QQ OAuth
('weibo', _('weibo')), # 微博
('google', _('google')), # 谷歌
('github', 'GitHub'), # GitHub
('facebook', 'FaceBook'), # FaceBook
('qq', 'QQ'), # QQ
)
# OAuth服务类型字段使用选择项限制输入
# OAuth类型字段选择类型
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# OAuth应用的客户端IDApp Key
# AppKey字段
appkey = models.CharField(max_length=200, verbose_name='AppKey')
# OAuth应用的客户端密钥App Secret
# AppSecret字段
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# OAuth认证成功后的回调地址
# 回调地址字段
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False, # 不允许为空
default='' # 默认值为空字符串
)
# 标识该OAuth配置是否启用
blank=False,
default='')
# 是否启用字段
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: 当同一类型的配置已存在时抛出异常
"""
# 检查是否已存在相同类型的配置(排除当前记录)
# 确保每种OAuth类型只能有一个配置
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配置' # 模型在Admin中的中文显示名称
verbose_name = 'oauth配置' # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列
ordering = ['-creation_time'] # 按创建时间降序排列

@ -1,171 +1,118 @@
"""
OAuth 认证管理器模块
该模块实现了多平台OAuth认证的核心逻辑包含基类定义和具体平台实现
支持微博谷歌GitHubFacebookQQ等主流第三方登录平台
采用抽象基类和混合类设计模式提供统一的OAuth认证接口
"""
# 导入JSON处理模块
import json
# 导入日志模块
import logging
# 导入操作系统模块
import os
# 导入URL解析模块
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
# 获取访问令牌的URL需要子类实现
"""获取token"""
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 '' # 应用Key
self.client_secret = config.appsecret if config else '' # 应用Secret
self.callback_url = config.callback_url if config else '' # 回调地址
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 ''
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',
@ -174,19 +121,8 @@ 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,
@ -198,20 +134,14 @@ 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 = {
@ -222,45 +152,31 @@ 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'] # 用户OpenID
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']
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")
@ -268,33 +184,28 @@ 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 ''
@ -305,24 +216,25 @@ 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', # 请求openid和email权限
'scope': '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)
@ -337,8 +249,8 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
"""获取谷歌用户信息"""
if not self.is_authorized:
return None
params = {
@ -346,42 +258,38 @@ 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 ''
@ -392,79 +300,74 @@ 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'] # GitHub头像
user.nickname = datas['name'] # GitHub姓名
user.openid = datas['id'] # GitHub用户ID
user.picture = datas['avatar_url']
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email'] # GitHub邮箱
user.email = datas['email']
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 ''
@ -475,23 +378,25 @@ 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)
@ -504,24 +409,23 @@ 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'] # Facebook姓名
user.openid = datas['id'] # Facebook用户ID
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email'] # Facebook邮箱
# 处理嵌套的头像数据结构
user.email = datas['email']
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
@ -529,27 +433,22 @@ 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' # QQ特有的OpenID获取接口
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me'
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 ''
@ -560,8 +459,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,
@ -570,8 +469,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,
@ -581,7 +480,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]
@ -589,28 +488,26 @@ 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:
# 清理JSONP响应格式
rsp = rsp.replace('callback(', '').replace(')', '').replace(';', '')
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):
"""获取QQ用户信息"""
openid = self.get_open_id() # 先获取OpenID
openid = self.get_open_id()
if openid:
params = {
'access_token': self.access_token,
@ -621,56 +518,39 @@ class QQOauthManager(BaseOauthManager):
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
user.nickname = obj['nickname'] # QQ昵称
user.openid = openid # QQ OpenID
user.nickname = obj['nickname']
user.openid = openid
user.type = 'qq'
user.token = self.access_token
user.metadata = rsp
if 'email' in obj:
user.email = obj['email'] # QQ邮箱
user.email = obj['email']
if 'figureurl' in obj:
user.picture = str(obj['figureurl']) # QQ头像
user.picture = str(obj['figureurl'])
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,64 +1,22 @@
"""
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()
# 使用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)) # 遍历的应用列表
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications))
else:
# 如果没有可用的OAuth应用返回空列表
apps = []
# 返回模板渲染所需的上下文数据
return {
'apps': apps # OAuth应用列表传递给模板进行渲染
}
'apps': apps
}

@ -1,112 +1,63 @@
"""
OAuth 认证测试模块
该模块包含OAuth认证系统的完整测试用例覆盖所有支持的第三方登录平台
测试包括配置验证授权流程用户信息获取和异常处理等场景
"""
from django.urls import path
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:
"""
测试初始化方法
创建测试环境初始化所有OAuth应用配置
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
self.apps = self.init_apps() # 初始化所有OAuth应用配置
# 初始化测试环境
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
def init_apps(self):
"""
初始化所有OAuth应用配置
为每个OAuth平台创建测试配置数据
Returns:
list: 初始化的OAuth管理器实例列表
"""
# 获取所有OAuth管理器的子类并实例化
# 初始化所有OAuth应用配置
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
# 为每个平台创建测试配置
c = OAuthConfig()
c.type = application.ICON_NAME.lower() # 设置平台类型
c.appkey = 'appkey' # 测试应用Key
c.appsecret = 'appsecret' # 测试应用Secret
c.type = application.ICON_NAME.lower()
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
return applications
def get_app_by_type(self, type):
"""
根据类型获取OAuth应用
Args:
type: 平台类型字符串
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例
"""
# 根据类型获取对应的OAuth应用
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -114,26 +65,15 @@ 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登录流程
使用模拟对象测试微博授权码获取令牌和用户信息的完整流程
Args:
mock_do_get: 模拟GET请求的mock对象
mock_do_post: 模拟POST请求的mock对象
"""
# 测试微博登录流程
weibo_app = self.get_app_by_type('weibo')
assert weibo_app # 验证微博应用存在
# 测试授权URL生成
assert weibo_app
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",
@ -141,33 +81,24 @@ class OauthLoginTest(TestCase):
"email": "email",
})
# 执行授权码换取用户信息流程
# 获取访问令牌和用户信息
userinfo = weibo_app.get_access_token_by_code('code')
# 验证返回的用户信息正确性
self.assertEqual(userinfo.token, 'access_token') # 验证访问令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户OpenID
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
"""
测试谷歌OAuth登录流程
验证谷歌OAuth的令牌获取和用户信息解析
"""
# 测试Google登录流程
google_app = self.get_app_by_type('google')
assert google_app # 验证谷歌应用存在
# 测试授权URL生成
assert google_app
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",
@ -175,33 +106,24 @@ class OauthLoginTest(TestCase):
"email": "email",
})
# 执行授权流程
# 获取访问令牌和用户信息
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
# 验证用户信息正确性
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的特殊令牌响应格式和用户信息获取
"""
# 测试GitHub登录流程
github_app = self.get_app_by_type('github')
assert github_app # 验证GitHub应用存在
# 测试授权URL生成
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url) # 验证URL包含GitHub域名
self.assertTrue("client_id" in url) # 验证URL包含客户端ID
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
# 设置模拟返回值 - GitHub特殊的查询字符串格式响应
# 模拟API返回数据
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",
@ -209,34 +131,25 @@ class OauthLoginTest(TestCase):
"email": "email",
})
# 执行授权流程
# 获取访问令牌和用户信息
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
# 验证用户信息正确性
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') # 验证GitHub令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户ID
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, '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的令牌获取和嵌套头像数据结构处理
"""
# 测试Facebook登录流程
facebook_app = self.get_app_by_type('facebook')
assert facebook_app # 验证Facebook应用存在
# 测试授权URL生成
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url) # 验证URL包含Facebook域名
self.assertTrue("facebook.com" in url)
# 设置模拟返回值 - 令牌获取响应
# 模拟API返回数据
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
# 设置模拟返回值 - 用户信息获取响应(包含嵌套的头像数据)
mock_do_get.return_value = json.dumps({
"name": "name",
"id": "id",
@ -248,17 +161,15 @@ class OauthLoginTest(TestCase):
}
})
# 执行授权流程
# 获取访问令牌和用户信息
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
# 验证访问令牌正确性
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
'access_token=access_token&expires_in=3600', # 第一次调用:令牌获取响应
'callback({"client_id":"appid","openid":"openid"} );', # 第二次调用OpenID获取响应
json.dumps({ # 第三次调用:用户信息获取响应
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
json.dumps({
"nickname": "nickname",
"email": "email",
"figureurl": "figureurl",
@ -266,41 +177,26 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
"""
测试QQ OAuth登录流程
验证QQ的特殊三步骤流程获取令牌 获取OpenID 获取用户信息
Args:
mock_do_get: 配置了side_effect的模拟GET请求对象
"""
# 测试QQ登录流程
qq_app = self.get_app_by_type('qq')
assert qq_app # 验证QQ应用存在
# 测试授权URL生成
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url) # 验证URL包含QQ域名
self.assertTrue("qq.com" in url)
# 执行授权流程mock_do_get会根据side_effect顺序返回不同的响应
# 获取访问令牌和用户信息
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
# 验证访问令牌正确性
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",
@ -309,32 +205,29 @@ 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)
@ -344,16 +237,12 @@ 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",
@ -361,34 +250,34 @@ 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从重定向URL中
# 解析OAuth用户ID
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)
# 生成邮箱确认签名
# 生成签名验证
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
@ -397,11 +286,11 @@ class OauthLoginTest(TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证最终用户认证状态和关联信息
# 验证用户信息和OAuth绑定
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) # 验证OAuth用户关联正确
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)

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

@ -1,223 +1,139 @@
"""
OAuth 认证视图模块
该模块实现了OAuth认证系统的核心视图逻辑处理第三方登录的完整流程
包括授权初始化回调处理邮箱验证用户绑定等功能
"""
import logging
# 导入日志模块
from urllib.parse import urlparse # 导入URL解析工具
# 创建视图
from urllib.parse import urlparse
# 导入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 # 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管理器
# 获取当前模块的日志记录器
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
# 获取日志记录器
logger = logging.getLogger(__name__)
def get_redirecturl(request):
"""
获取安全的重定向URL
验证next_url参数的安全性防止开放重定向漏洞
Args:
request: HttpRequest对象
Returns:
str: 安全的跳转URL默认为首页
"""
# 从请求参数获取跳转URL
"""获取重定向URL并进行安全验证"""
nexturl = request.GET.get('next_url', None)
# 如果nexturl为空或是登录页面则重定向到首页
# 如果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前缀如果不匹配则视为非法URL
# 检查域名是否匹配忽略www前缀
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
logger.info('非法url:' + nexturl)
return "/" # 重定向到首页
return "/"
return nexturl
def oauthlogin(request):
"""
OAuth登录入口视图
根据平台类型初始化第三方登录流程重定向到对应平台的授权页面
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到第三方授权页面或首页
"""
# 从请求参数获取OAuth平台类型
"""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
return HttpResponseRedirect('/')
# 获取重定向URL并跳转到授权页面
nexturl = get_redirecturl(request)
# 生成第三方平台的授权URL
authorizeurl = manager.get_authorization_url(nexturl)
# 重定向到第三方授权页面
return HttpResponseRedirect(authorizeurl)
def authorize(request):
"""
OAuth授权回调视图
处理第三方平台返回的授权码获取访问令牌和用户信息
完成用户认证或引导用户补充信息
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到相应页面
"""
# 从请求参数获取OAuth平台类型
"""处理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:
# 检查是否已存在相同平台和OpenID的用户
# 检查是否已存在该OAuth用户
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:
# 用户没有邮箱,保存用户信息并跳转到邮箱补充页面
# 没有邮箱保存OAuth用户信息并跳转到邮箱填写页面
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
@ -225,68 +141,43 @@ 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: 重定向到绑定成功页面
"""
# 验证签名是否存在
"""邮箱确认视图完成OAuth绑定流程"""
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()
# 保存用户关联关系
# 关联OAuth用户和作者账号
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
@ -299,10 +190,8 @@ def emailconfirm(request, id, sign):
%(site)s
''') % {'oauthuser_type': oauthuser.type, 'site': site}
# 发送绑定成功邮件
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
# 重定向到绑定成功页面
# 跳转到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
@ -311,96 +200,55 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView):
"""
邮箱补充表单视图
当第三方登录未提供邮箱时显示表单让用户输入邮箱地址
"""
form_class = RequireEmailForm # 使用的表单类
template_name = 'oauth/require_email.html' # 模板名称
"""需要邮箱表单视图用于OAuth用户补充邮箱信息"""
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
"""
GET请求处理
检查OAuth用户是否已有邮箱如有则跳过此步骤
"""
oauthid = self.kwargs['oauthid'] # 获取OAuth用户ID
oauthid = self.kwargs['oauthid']
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 # 隐藏的OAuth用户ID
'email': '',
'oauthid': oauthid
}
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>
@ -412,51 +260,31 @@ 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,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由 Django 4.1.7 于 2023-03-02 07:14 生成
from django.db import migrations, models
import django.utils.timezone
@ -12,20 +12,30 @@ class Migration(migrations.Migration):
]
operations = [
# 创建 OwnTrackLog 模型的数据迁移
migrations.CreateModel(
name='OwnTrackLog',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段
('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度坐标
('lat', models.FloatField(verbose_name='纬度')),
# 经度坐标
('lon', models.FloatField(verbose_name='经度')),
# 创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
options={
# 单数显示名称
'verbose_name': 'OwnTrackLogs',
# 复数显示名称
'verbose_name_plural': 'OwnTrackLogs',
# 默认按创建时间排序
'ordering': ['created_time'],
# 指定获取最新记录的依据字段
'get_latest_by': 'created_time',
},
),
]
]

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-06 13:19
# 由 Django 4.2.5 于 2023-09-06 13:19 生成
from django.db import migrations
@ -6,17 +6,29 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
# 依赖于初始迁移文件
('owntracks', '0001_initial'),
]
operations = [
# 修改模型选项
migrations.AlterModelOptions(
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
options={
# 更新获取最新记录的依据字段为 creation_time
'get_latest_by': 'creation_time',
# 更新排序字段为 creation_time
'ordering': ['creation_time'],
# 保持单数显示名称不变
'verbose_name': 'OwnTrackLogs',
# 保持复数显示名称不变
'verbose_name_plural': 'OwnTrackLogs'
},
),
# 重命名字段:从 created_time 改为 creation_time
migrations.RenameField(
model_name='owntracklog',
old_name='created_time',
new_name='creation_time',
),
]
]

@ -2,19 +2,27 @@ from django.db import models
from django.utils.timezone import now
# Create your models here.
# 创建模型
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'

@ -6,27 +6,31 @@ from accounts.models import BlogUser
from .models import OwnTrackLog
# Create your tests here.
# 创建测试类
class OwnTrackLogTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_own_track_log(self):
# 测试正常的位置数据提交
o = {
'tid': 12,
'lat': 123.123,
'lon': 134.341
}
# 发送POST请求提交位置数据
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
# 验证数据是否成功保存
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 测试缺少经度的无效数据提交
o = {
'tid': 12,
'lat': 123.123
@ -36,29 +40,42 @@ class OwnTrackLogTest(TestCase):
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
# 验证无效数据未被保存记录数仍为1
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 测试未登录用户访问地图页面的重定向
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302)
# 创建超级用户用于登录测试
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 登录用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 手动创建位置记录
s = OwnTrackLog()
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
# 测试登录用户访问日期显示页面
rsp = self.client.get('/owntracks/show_dates')
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)

@ -2,11 +2,17 @@ from django.urls import path
from . import views
# 定义应用命名空间
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'),
# 获取位置数据的API端点
path('owntracks/get_datas', views.get_datas, name='get_datas'),
# 显示日志日期的页面
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]
]

@ -1,4 +1,4 @@
# Create your views here.
# 创建视图
import datetime
import itertools
import json
@ -16,20 +16,25 @@ from django.views.decorators.csrf import csrf_exempt
from .models import OwnTrackLog
# 获取日志记录器
logger = logging.getLogger(__name__)
@csrf_exempt
def manage_owntrack_log(request):
"""处理OwnTracks位置数据提交免CSRF验证"""
try:
# 解析JSON请求数据
s = json.loads(request.read().decode('utf-8'))
tid = s['tid']
lat = s['lat']
lon = s['lon']
# 记录位置信息日志
logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon))
# 验证必要字段都存在
if tid and lat and lon:
m = OwnTrackLog()
m.tid = tid
@ -40,26 +45,33 @@ def manage_owntrack_log(request):
else:
return HttpResponse('data error')
except Exception as e:
# 记录异常日志
logger.error(e)
return HttpResponse('error')
@login_required
def show_maps(request):
"""显示地图页面,需要登录且为超级用户"""
if request.user.is_superuser:
# 设置默认日期为当前UTC日期
defaultdate = str(datetime.datetime.now(timezone.utc).date())
# 获取请求中的日期参数,无则使用默认日期
date = request.GET.get('date', defaultdate)
context = {
'date': date
}
return render(request, 'owntracks/show_maps.html', context)
else:
# 非超级用户返回403禁止访问
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
@login_required
def show_log_dates(request):
"""显示所有日志日期,需要登录"""
# 获取所有创建时间并去重格式化
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
@ -70,11 +82,14 @@ def show_log_dates(request):
def convert_to_amap(locations):
"""将GPS坐标转换为高德地图坐标当前未使用"""
convert_result = []
it = iter(locations)
# 分批处理每批30个坐标点
item = list(itertools.islice(it, 30))
while item:
# 将坐标格式化为高德API要求的格式
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
@ -85,6 +100,7 @@ def convert_to_amap(locations):
'locations': datas,
'coordsys': 'gps'
}
# 调用高德坐标转换API
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
if "locations" in result:
@ -96,32 +112,40 @@ def convert_to_amap(locations):
@login_required
def get_datas(request):
"""获取指定日期的位置数据返回JSON格式需要登录"""
# 设置查询时间为当前UTC时间
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
# 如果请求中有日期参数,使用该日期
if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
# 计算查询结束时间(次日零点)
nextdate = querydate + datetime.timedelta(days=1)
# 查询指定日期范围内的位置记录
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
# 按用户ID分组处理
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict()
d["name"] = tid
paths = list()
# 使用高德转换后的经纬度
# 注释掉的高德坐标转换代码(备用)
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(','))
# 使用GPS原始经纬度
# 使用原始GPS经纬度数据
for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths
result.append(d)
return JsonResponse(result, safe=False)
# 返回JSON响应
return JsonResponse(result, safe=False)

@ -8,22 +8,31 @@ from djangoblog.utils import get_blog_setting
class SeoOptimizerPlugin(BasePlugin):
"""SEO优化器插件为文章、页面等提供SEO优化功能"""
# 插件基本信息
PLUGIN_NAME = 'SEO 优化器'
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liuangliangyy'
def register_hooks(self):
"""注册插件钩子"""
hooks.register('head_meta', self.dispatch_seo_generation)
def _get_article_seo_data(self, context, request, blog_setting):
"""获取文章页面的SEO数据"""
article = context.get('article')
# 检查是否为文章实例
if not isinstance(article, Article):
return None
# 生成文章描述截取正文前150字符
description = strip_tags(article.body)[:150]
# 生成关键词(使用文章标签或站点关键词)
keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
# 生成Open Graph元标签
meta_tags = f'''
<meta property="og:type" content="article"/>
<meta property="og:title" content="{article.title}"/>
@ -34,10 +43,12 @@ class SeoOptimizerPlugin(BasePlugin):
<meta property="article:author" content="{article.author.username}"/>
<meta property="article:section" content="{article.category.name}"/>
'''
# 添加文章标签的Open Graph元标签
for tag in article.tags.all():
meta_tags += f'<meta property="article:tag" content="{tag.name}"/>'
meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>'
# 生成JSON-LD结构化数据
structured_data = {
"@context": "https://schema.org",
"@type": "Article",
@ -50,6 +61,7 @@ class SeoOptimizerPlugin(BasePlugin):
"author": {"@type": "Person", "name": article.author.username},
"publisher": {"@type": "Organization", "name": blog_setting.site_name}
}
# 如果没有图片移除image字段
if not structured_data.get("image"):
del structured_data["image"]
@ -62,22 +74,27 @@ class SeoOptimizerPlugin(BasePlugin):
}
def _get_category_seo_data(self, context, request, blog_setting):
"""获取分类页面的SEO数据"""
category_name = context.get('tag_name')
if not category_name:
return None
# 获取分类对象
category = Category.objects.filter(name=category_name).first()
if not category:
return None
# 设置分类页面标题、描述和关键词
title = f"{category.name} | {blog_setting.site_name}"
description = strip_tags(category.name) or blog_setting.site_description
keywords = category.name
# BreadcrumbList structured data for category page
breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
# 生成面包屑导航的结构化数据
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
breadcrumb_items.append(
{"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
structured_data = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
@ -93,7 +110,8 @@ class SeoOptimizerPlugin(BasePlugin):
}
def _get_default_seo_data(self, context, request, blog_setting):
# Homepage and other default pages
"""获取默认页面首页等的SEO数据"""
# 生成网站的结构化数据
structured_data = {
"@context": "https://schema.org",
"@type": "WebSite",
@ -115,24 +133,30 @@ class SeoOptimizerPlugin(BasePlugin):
}
def dispatch_seo_generation(self, metas, context):
"""分发SEO生成逻辑根据当前页面类型生成相应的SEO内容"""
request = context.get('request')
if not request:
return metas
# 获取当前视图名称
view_name = request.resolver_match.view_name
blog_setting = get_blog_setting()
seo_data = None
# 根据视图名称选择相应的SEO数据生成方法
if view_name == 'blog:detailbyid':
seo_data = self._get_article_seo_data(context, request, blog_setting)
elif view_name == 'blog:category_detail':
seo_data = self._get_category_seo_data(context, request, blog_setting)
# 如果没有匹配的页面类型使用默认SEO数据
if not seo_data:
seo_data = self._get_default_seo_data(context, request, blog_setting)
seo_data = self._get_default_seo_data(context, request, blog_setting)
# 生成JSON-LD脚本
json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>'
# 组合完整的SEO HTML内容
seo_html = f"""
<title>{seo_data.get("title", "")}</title>
<meta name="description" content="{seo_data.get("description", "")}">
@ -140,8 +164,10 @@ class SeoOptimizerPlugin(BasePlugin):
{seo_data.get("meta_tags", "")}
{json_ld_script}
"""
# 将SEO内容追加到现有的metas内容上
return metas + seo_html
plugin = SeoOptimizerPlugin()
# 创建插件实例
plugin = SeoOptimizerPlugin()

@ -5,28 +5,67 @@ from djangoblog.utils import cache
class MemcacheStorage(SessionStorage):
"""基于Memcached的会话存储实现用于WeRobot框架"""
def __init__(self, prefix='ws_'):
# 初始化存储前缀和缓存实例
self.prefix = prefix
self.cache = cache
@property
def is_available(self):
"""检查Memcached存储是否可用
Returns:
bool: 存储系统是否可用
"""
value = "1"
# 测试设置和获取操作
self.set('checkavaliable', value=value)
return value == self.get('checkavaliable')
def key_name(self, s):
"""生成完整的缓存键名
Args:
s: 原始键名
Returns:
str: 添加前缀后的完整键名
"""
return '{prefix}{s}'.format(prefix=self.prefix, s=s)
def get(self, id):
"""根据ID获取会话数据
Args:
id: 会话ID
Returns:
dict: 会话数据字典如果不存在则返回空字典
"""
id = self.key_name(id)
# 从缓存获取数据不存在则返回空JSON
session_json = self.cache.get(id) or '{}'
return json_loads(session_json)
def set(self, id, value):
"""设置会话数据
Args:
id: 会话ID
value: 要存储的会话数据
"""
id = self.key_name(id)
# 将数据序列化为JSON并存储到缓存
self.cache.set(id, json_dumps(value))
def delete(self, id):
"""删除会话数据
Args:
id: 要删除的会话ID
"""
id = self.key_name(id)
self.cache.delete(id)
# 从缓存中删除指定键的数据
self.cache.delete(id)

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

Loading…
Cancel
Save