Compare commits

..

8 Commits

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,152 +1,120 @@
""" # ===================== 导入必要的模块 =====================
Django管理后台用户模型配置模块
本模块配置BlogUser模型在Django管理后台的显示和编辑行为
包括自定义表单验证列表显示字段搜索过滤等功能
主要组件
- BlogUserCreationForm: 用户创建表单处理密码验证和设置
- BlogUserChangeForm: 用户信息修改表单
- BlogUserAdmin: 用户模型管理后台配置类
"""
# Django 的表单模块,用于定义和处理表单
from django import forms from django import forms
# Django 内置的用户管理后台类,提供用户管理的默认后台界面和功能
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
# Django 内置的用于编辑用户的表单类,已经包含对密码等敏感信息的处理
from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserChangeForm
# Django 内置的用于处理用户名字段的字段类,带有默认校验规则
from django.contrib.auth.forms import UsernameField from django.contrib.auth.forms import UsernameField
# Django 的翻译工具用于支持多语言i18n_() 是常用的翻译函数别名
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# 导入自定义用户模型 # 从当前目录下的 models 模块中导入自定义用户模型 BlogUser
from .models import BlogUser from .models import BlogUser
# ===================== 自定义:用户创建表单 =====================
# 用于在 Django Admin 后台创建新用户时使用的表单
class BlogUserCreationForm(forms.ModelForm): class BlogUserCreationForm(forms.ModelForm):
""" # 定义密码输入字段1用户输入密码使用密码框输入内容不可见
博客用户创建表单
扩展自ModelForm专门用于在Django管理后台创建新用户
提供密码确认验证和密码哈希处理功能
"""
# 密码输入字段1 - 使用PasswordInput控件隐藏输入
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 密码输入字段2 - 用于密码确认
# 定义密码输入字段2用户再次输入密码以确认同样使用密码框
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta: class Meta:
"""表单元数据配置""" # 指定该表单关联的模型是 BlogUser你的自定义用户模型
# 指定关联的模型
model = BlogUser model = BlogUser
# 指定表单中包含的字段 - 仅包含email字段
# 指定表单中只包含 email 字段,即创建用户时只需填写邮箱
fields = ('email',) fields = ('email',)
def clean_password2(self): def clean_password2(self):
""" """
密码确认字段验证方法 校验两次输入的密码是否一致
该方法在表单验证过程中自动调用用于确保 password1 password2 相同
验证两次输入的密码是否一致确保用户输入正确的密码
Returns:
str: 验证通过的密码
Raises:
forms.ValidationError: 当两次密码输入不一致时抛出验证错误
""" """
# 从清洗后的数据中获取两个密码字段的值 # 从表单清洗后的数据中获取 password1 和 password2
password1 = self.cleaned_data.get("password1") password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2") password2 = self.cleaned_data.get("password2")
# 检查两个密码是否存在且相等 # 如果两个密码都不为空,但它们不相同,则抛出验证错误,提示用户“密码不匹配”
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
# 密码不匹配时抛出验证错误 raise forms.ValidationError(_("passwords do not match")) # 国际化提示信息
raise forms.ValidationError(_("passwords do not match"))
# 返回验证通过的密码 # 验证通过,返回 password2
return password2 return password2
def save(self, commit=True): def save(self, commit=True):
""" """
表单保存方法 保存用户对象到数据库
重写了 ModelForm save 方法在保存之前对密码进行哈希处理并设置用户来源
重写保存逻辑处理密码哈希化和设置用户来源
Args:
commit (bool): 是否立即保存到数据库默认为True
Returns:
BlogUser: 保存后的用户实例
""" """
# 调用父类保存方法,但不立即提交到数据库 # 调用父类的 save 方法但先不提交到数据库commit=False
user = super().save(commit=False) user = super().save(commit=False)
# 使用Django的密码哈希方法设置密码
# 对用户输入的密码password1进行哈希处理并设置为用户的密码安全存储不可逆
user.set_password(self.cleaned_data["password1"]) user.set_password(self.cleaned_data["password1"])
# 如果设置为立即提交,则保存用户并设置来源
if commit: if commit:
# 标记用户创建来源为管理后台 # 如果 commit 为 True默认即为 True则保存用户到数据库
# 设置用户来源为 'adminsite',表示该用户是通过后台管理界面创建的
user.source = 'adminsite' user.source = 'adminsite'
# 保存用户到数据库 user.save() # 将用户对象保存到数据库
user.save()
# 返回保存好的用户对象
return user return user
# ===================== 自定义:用户编辑表单 =====================
# 用于在 Django Admin 后台编辑已有用户信息时使用的表单
class BlogUserChangeForm(UserChangeForm): class BlogUserChangeForm(UserChangeForm):
"""
博客用户信息修改表单
继承自Django的UserChangeForm用于在管理后台编辑现有用户信息
保持与Django原生用户管理表单的兼容性
"""
class Meta: class Meta:
"""表单元数据配置""" # 指定该表单关联的模型是 BlogUser
# 指定关联的模型
model = BlogUser model = BlogUser
# 包含所有字段
# 表单中显示所有字段,即管理员可以编辑该用户的所有信息
fields = '__all__' fields = '__all__'
# 指定用户名字段使用Django的UsernameField类型
# 指定 username 字段使用 Django 提供的 UsernameField以利用其内置校验
field_classes = {'username': UsernameField} field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" # 调用父类的构造方法,保持默认行为
表单初始化方法
可以在此处添加自定义的表单初始化逻辑
"""
# 调用父类初始化方法
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# ===================== 自定义Django Admin 用户管理类 =====================
# 用于自定义 Django Admin 后台中用户BlogUser的展示、搜索、排序、表单等行为
class BlogUserAdmin(UserAdmin): class BlogUserAdmin(UserAdmin):
""" # 指定用于编辑用户信息的表单为 BlogUserChangeForm我们自定义的编辑表单
博客用户管理后台配置类
继承自Django的UserAdmin自定义BlogUser模型在管理后台的显示和行为
配置列表显示搜索排序等管理界面功能
"""
# 指定用户编辑表单
form = BlogUserChangeForm form = BlogUserChangeForm
# 指定用户创建表单
# 指定用于创建新用户的表单为 BlogUserCreationForm我们自定义的创建表单
add_form = BlogUserCreationForm add_form = BlogUserCreationForm
# 配置列表页面显示的字段 # 定义在 Django Admin 用户列表页面中显示哪些字段
list_display = ( list_display = (
'id', # 用户ID 'id', # 用户的唯一标识 ID
'nickname', # 用户昵称 'nickname', # 用户昵称(自定义字段)
'username', # 用户名 'username', # 用户名
'email', # 邮箱地址 'email', # 用户邮箱
'last_login', # 最后登录时间 'last_login', # 上次登录时间
'date_joined', # 注册时间 'date_joined', # 用户注册时间
'source' # 用户来源 'source' # 用户注册来源(如 Web、adminsite 等,自定义字段)
) )
# 配置列表中可点击跳转到编辑页面的字段 # 定义在用户列表页中,哪些字段可以作为链接,点击后跳转到该用户的编辑页面
list_display_links = ('id', 'username') list_display_links = ('id', 'username')
# 配置默认排序规则 - 按ID倒序排列最新的在前 # 定义默认的排序方式:按照 ID 降序排列(即最新创建的用户排在最前面
ordering = ('-id',) ordering = ('-id',)
# 配置搜索框可搜索的字段 # 定义在用户列表页中,可以通过哪些字段进行搜索(支持模糊匹配)
search_fields = ('username', 'nickname', 'email') search_fields = ('username', 'nickname', 'email')

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

@ -1,274 +1,77 @@
"""
用户认证表单模块
本模块定义用户相关的Django表单包括
- 用户登录表单
- 用户注册表单
- 密码重置表单
- 验证码表单
所有表单都包含Bootstrap样式类提供一致的用户界面体验
"""
from django import forms from django import forms
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import UserChangeForm
from django.core.exceptions import ValidationError from django.contrib.auth.forms import UsernameField
from django.forms import widgets
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
class LoginForm(AuthenticationForm):
"""
用户登录表单
继承自Django的AuthenticationForm添加Bootstrap样式支持
用于用户通过用户名和密码登录系统
"""
def __init__(self, *args, **kwargs):
"""
初始化表单设置字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(LoginForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput(
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password'].widget = widgets.PasswordInput(
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
class RegisterForm(UserCreationForm):
"""
用户注册表单
继承自Django的UserCreationForm扩展邮箱字段和样式支持 # 从当前目录的 models 导入自定义用户模型 BlogUser
用于新用户注册账号包含用户名邮箱和密码确认功能 from .models import BlogUser
"""
def __init__(self, *args, **kwargs):
"""
初始化表单设置所有字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(RegisterForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput(
attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置邮箱字段的输入控件和样式
self.fields['email'].widget = widgets.EmailInput(
attrs={
'placeholder': "email", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password1'].widget = widgets.PasswordInput(
attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码确认字段的输入控件和样式
self.fields['password2'].widget = widgets.PasswordInput(
attrs={
'placeholder': "repeat password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
def clean_email(self):
"""
邮箱字段验证方法
验证邮箱是否已被注册确保邮箱地址的唯一性
Returns:
str: 验证通过的邮箱地址
Raises:
ValidationError: 当邮箱已被注册时抛出验证错误
"""
# 获取清洗后的邮箱数据
email = self.cleaned_data['email']
# 检查邮箱是否已存在
if get_user_model().objects.filter(email=email).exists():
# 抛出验证错误,提示邮箱已存在
raise ValidationError(_("email already exists"))
# 返回验证通过的邮箱 # 自定义用户创建表单(用于 Django Admin 后台创建普通用户)
return email class BlogUserCreationForm(forms.ModelForm):
# 密码字段1标签为“password”使用密码输入框
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 密码字段2用于确认密码标签为“Enter password again”使用密码输入框
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta: class Meta:
"""表单元数据配置""" # 指定模型为 BlogUser
# 指定关联的用户模型 model = BlogUser
model = get_user_model() # 表单只显示 email 字段(用于创建时输入邮箱)
# 指定表单中包含的字段 fields = ('email',)
fields = ("username", "email")
def clean_password2(self):
# 获取用户输入的两次密码
class ForgetPasswordForm(forms.Form): password1 = self.cleaned_data.get("password1")
""" password2 = self.cleaned_data.get("password2")
忘记密码重置表单 # 如果两次密码都填写了但不一致,抛出验证错误
用于用户通过邮箱和验证码重置密码包含密码强度验证和验证码校验
"""
# 新密码字段1
new_password1 = forms.CharField(
label=_("New password"), # 字段标签
widget=forms.PasswordInput(
attrs={
"class": "form-control", # Bootstrap样式类
'placeholder': _("New password") # 占位符文本
}
),
)
# 新密码字段2 - 用于密码确认
new_password2 = forms.CharField(
label="确认密码", # 中文标签
widget=forms.PasswordInput(
attrs={
"class": "form-control", # Bootstrap样式类
'placeholder': _("Confirm password") # 占位符文本
}
),
)
# 邮箱字段 - 用于标识用户和发送验证码
email = forms.EmailField(
label='邮箱', # 中文标签
widget=forms.TextInput(
attrs={
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Email") # 占位符文本
}
),
)
# 验证码字段 - 用于验证用户身份
code = forms.CharField(
label=_('Code'), # 字段标签
widget=forms.TextInput(
attrs={
'class': 'form-control', # Bootstrap样式类
'placeholder': _("Code") # 占位符文本
}
),
)
def clean_new_password2(self):
"""
密码确认字段验证方法
验证两次输入的密码是否一致并检查密码强度
Returns:
str: 验证通过的密码
Raises:
ValidationError: 当密码不匹配或强度不足时抛出验证错误
"""
# 从请求数据中获取两个密码字段的值
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
# 检查两个密码是否存在且相等
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
# 密码不匹配时抛出验证错误 raise forms.ValidationError(_("passwords do not match"))
raise ValidationError(_("passwords do not match"))
# 使用Django内置密码验证器验证密码强度
password_validation.validate_password(password2)
# 返回验证通过的密码
return password2 return password2
def clean_email(self): def save(self, commit=True):
""" # 先调用父类的 save 方法但不立即提交到数据库commit=False
邮箱字段验证方法 user = super().save(commit=False)
# 对用户输入的密码进行哈希处理再保存
验证邮箱是否在系统中注册过 user.set_password(self.cleaned_data["password1"])
if commit:
# 设置用户来源为 adminsite表示是通过后台创建的
user.source = 'adminsite'
user.save() # 保存用户到数据库
return user
Returns:
str: 验证通过的邮箱地址
Raises: # 自定义用户编辑表单(用于 Django Admin 后台编辑用户信息)
ValidationError: 当邮箱未注册时抛出验证错误 class BlogUserChangeForm(UserChangeForm):
""" class Meta:
# 获取清洗后的邮箱数据 model = BlogUser
user_email = self.cleaned_data.get("email") # 表单显示所有字段
fields = '__all__'
# 检查邮箱是否存在于用户数据库中 # 指定 username 字段使用 Django 提供的 UsernameField 类
if not BlogUser.objects.filter(email=user_email).exists(): field_classes = {'username': UsernameField}
# TODO: 这里会暴露邮箱是否注册的信息,根据安全需求可修改提示
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
"""
验证码字段验证方法
验证邮箱和验证码的匹配关系
Returns:
str: 验证通过的验证码
Raises:
ValidationError: 当验证码无效或过期时抛出验证错误
"""
# 获取清洗后的验证码数据
code = self.cleaned_data.get("code")
# 调用utils模块的verify函数验证验证码
error = utils.verify(
email=self.cleaned_data.get("email"), # 邮箱地址
code=code, # 验证码
)
# 如果验证返回错误信息,抛出验证错误
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
"""
忘记密码验证码请求表单
用于用户请求发送密码重置验证码仅包含邮箱字段
"""
# 邮箱字段 - 用于发送验证码 def __init__(self, *args, **kwargs):
email = forms.EmailField( super().__init__(*args, **kwargs)
label=_('Email'), # 字段标签
# 可以添加widget配置来设置样式
) # 自定义 Django Admin 中的用户管理类
class BlogUserAdmin(UserAdmin):
# 指定用户信息修改时使用的表单
form = BlogUserChangeForm
# 指定用户创建时使用的表单
add_form = BlogUserCreationForm
# 列表页显示的字段ID、昵称、用户名、邮箱、最后登录时间、注册时间、来源
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
# 列表页中可点击的字段用于跳转到编辑页ID 和 用户名
list_display_links = ('id', 'username')
# 默认排序方式:按 ID 倒序
ordering = ('-id',)
# 支持搜索的字段:用户名、昵称、邮箱
search_fields = ('username', 'nickname', 'email')

@ -1,138 +1,127 @@
"""
用户账户应用数据库迁移文件
本迁移文件由Django自动生成用于创建自定义用户模型的数据库表结构
扩展了Django内置的AbstractUser模型添加了博客系统特有的用户字段
生成的表结构
- accounts_bloguser: 自定义博客用户表继承Django用户认证系统的所有功能
迁移依赖
- 依赖于Django auth应用的group和permission模型
"""
# Generated by Django 4.1.7 on 2023-03-02 07:14 # Generated by Django 4.1.7 on 2023-03-02 07:14
# 该文件由 Django 4.1.7 版本在 2023年3月2日 07:14 自动生成,
# 用于记录你对模型Model所做的变更以便同步到数据库。
# 导入 Django 内置的用户管理相关模型和验证器
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
# 导入 Django 的数据库迁移核心模块,用于定义数据库变更操作
from django.db import migrations, models from django.db import migrations, models
# 导入 Django 的时间工具模块,用于获取当前时间(带时区)
import django.utils.timezone import django.utils.timezone
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""
用户账户应用初始迁移类
继承自migrations.Migration定义自定义用户模型的数据库表创建操作
initial = True 表示这是该应用的第一个迁移文件
主要功能
- 创建自定义用户模型BlogUser的数据库表
- 继承Django认证系统的所有基础字段
- 添加博客系统特有的自定义字段
- 设置模型的管理器和配置选项
"""
# 标记为初始迁移文件Django迁移系统会首先执行此文件 # 表示这是该应用(如 blog的第一个迁移文件通常是 0001_initial.py
initial = True initial = True
# 定义迁移依赖关系 # 当前迁移所依赖的其他迁移
# 这里依赖 Django 内置的 auth 应用的某个迁移,确保权限、用户组等功能先被创建
dependencies = [ dependencies = [
# 声明对Django认证系统组的依赖
# 使用auth应用的0012迁移文件确保用户权限系统正常工作
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
] ]
# 定义迁移操作序列 # 定义该迁移要执行的所有数据库操作,这里只有一个:创建 BlogUser 模型(表)
operations = [ operations = [
# 创建博客用户表的迁移操作
migrations.CreateModel( migrations.CreateModel(
# 模型名称 - 对应数据库表名 accounts_bloguser name='BlogUser', # 模型名称,对应数据库中的表名通常是 blog_bloguser根据 app_label
name='BlogUser',
# 定义模型字段列表
fields=[ fields=[
# 主键字段 - 使用BigAutoField作为自增主键 # 主键 ID自增大整数是模型的主键Django 默认会为每个模型添加此字段
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段 - Django认证系统标准字段,存储加密后的密码 # 用户密码字段,存储的是加密后的密码字符串,长度固定为 128 个字符
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间字段 - 记录用户最后一次登录的时间 # 记录用户最后一次登录的时间,允许为空(如用户从未登录过)
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 超级用户标志字段 - 标识用户是否拥有所有权限 # 是否是超级用户(管理员),默认为 False超级用户拥有所有权限
('is_superuser', models.BooleanField(default=False, ('is_superuser', models.BooleanField(default=False,
help_text='Designates that this user has all permissions without explicitly assigning them.', help_text='Designates that this user has all permissions without explicitly assigning them.',
verbose_name='superuser status')), verbose_name='superuser status')),
# 用户名字段 - 唯一标识用户的字段,包含验证器和错误消息 # 用户名,必须唯一,最大长度 150只允许字母、数字和部分符号如 @ . + - _
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, # 如果重复会提示错误A user with that username already exists.
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', ('username', models.CharField(
max_length=150, unique=True, error_messages={'unique': 'A user with that username already exists.'},
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
verbose_name='username')), max_length=150,
unique=True,
# 名字字段 - 用户的名字(西方命名习惯) validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name='username'
)),
# 用户的名字First Name如“名”可为空
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 姓氏字段 - 用户的姓氏(西方命名习惯) # 用户的姓氏Last Name如“姓”可为空
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 邮箱字段 - 用户的电子邮箱地址 # 用户的邮箱地址,使用 EmailField 格式校验,可为空
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 员工状态字段 - 标识用户是否可以登录管理后台 # 是否是员工用户,即是否允许登录 Django Admin 后台,默认为 False
('is_staff', models.BooleanField(default=False, ('is_staff', models.BooleanField(default=False,
help_text='Designates whether the user can log into this admin site.', help_text='Designates whether the user can log into this admin site.',
verbose_name='staff status')), verbose_name='staff status')),
# 活跃状态字段 - 标识用户账号是否激活(软删除机制) # 是否是活跃用户True 表示正常False 表示禁用;推荐用此字段禁用账户而非删除
('is_active', models.BooleanField(default=True, ('is_active', models.BooleanField(default=True,
help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
verbose_name='active')), verbose_name='active')),
# 注册时间字段 - 记录用户账号创建的时间 # 用户注册时间,创建用户时默认为当前时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 昵称字段 - 博客系统自定义字段,用户显示名称 # 【自定义字段】用户昵称,用于前台展示,非必填,最大长度 100
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 创建时间字段 - 博客系统自定义字段,记录创建时间 # 【自定义字段】用户账户的创建时间,通常在创建时自动设置为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, 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='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 来源字段 - 博客系统自定义字段记录用户创建来源如注册、OAuth等 # 【自定义字段】记录用户是从哪个渠道注册的,如 Web、微信、QQ 等,可为空
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 组关联字段 - Django权限系统的组多对多关联 # 【关联字段】用户所属的用户组Group一个用户可以属于多个组
('groups', models.ManyToManyField(blank=True, # 组可以拥有权限,用户通过组间接获得权限
help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', # blank=True 表示可以不选择任何组
related_name='user_set', related_query_name='user', to='auth.group', ('groups', models.ManyToManyField(
verbose_name='groups')), 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'
)),
# 权限关联字段 - Django权限系统的用户权限多对多关联
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
related_name='user_set', related_query_name='user',
to='auth.permission', verbose_name='user permissions')),
], ],
# 模型元数据配置 # 模型的元数据配置选项
options={ options={
# 管理后台单数显示名称(中文) 'verbose_name': '用户', # 在后台或模型信息中显示的单数名称
'verbose_name': '用户', 'verbose_name_plural': '用户', # 复数名称,通常也是“用户”
# 管理后台复数显示名称(中文) 'ordering': ['-id'], # 默认按 ID 降序排序,即最新用户排在最前
'verbose_name_plural': '用户', 'get_latest_by': 'id', # 指定通过 id 字段获取“最新”的对象
# 默认排序规则 - 按ID倒序排列最新的记录在前
'ordering': ['-id'],
# 指定获取最新记录的字段 - 使用id字段确定最新记录
'get_latest_by': 'id',
}, },
# 定义模型管理器 # 模型的管理器,用于创建用户、超级用户等
managers=[ managers=[
# 使用Django内置的UserManager管理用户对象 ('objects', django.contrib.auth.models.UserManager()), # 使用 Django 内置的 UserManager
# 提供create_user、create_superuser等用户管理方法
('objects', django.contrib.auth.models.UserManager()),
], ],
), ),
] ]

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

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

@ -1,10 +1,3 @@
"""
用户账户应用测试模块
本模块包含用户账户相关的所有测试用例覆盖用户注册登录密码重置
邮箱验证等核心功能的测试
"""
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -15,300 +8,49 @@ from blog.models import Article, Category
from djangoblog.utils import * from djangoblog.utils import *
from . import utils from . import utils
# 定义账户相关的测试类
class AccountTest(TestCase): class AccountTest(TestCase):
"""
用户账户功能测试类
测试用户账户相关的所有功能包括
- 用户认证和登录
- 用户注册流程
- 邮箱验证码功能
- 密码重置流程
- 权限访问控制
"""
def setUp(self): def setUp(self):
""" # 初始化测试客户端和请求工厂
测试初始化方法
在每个测试方法执行前运行创建测试所需的初始数据和环境
"""
# 创建测试客户端用于模拟HTTP请求
self.client = Client() self.client = Client()
# 创建请求工厂,用于构建请求对象
self.factory = RequestFactory() self.factory = RequestFactory()
# 创建测试用户 # 创建一个普通测试用户
self.blog_user = BlogUser.objects.create_user( self.blog_user = BlogUser.objects.create_user(
username="test", username="test",
email="admin@admin.com", email="admin@admin.com",
password="12345678" password="12345678"
) )
# 设置测试用的新密码 # 用于后续测试的新密码
self.new_test = "xxx123--=" self.new_test = "xxx123--="
def test_validate_account(self): 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"
article.author = user
article.category = category
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 测试文章管理页面访问权限
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
def test_validate_register(self): def test_validate_register(self):
""" # 测试用户注册、登录、文章发布等流程
测试用户注册完整流程 ...
验证用户注册邮箱验证登录权限提升和文章管理的完整流程
"""
# 验证注册前用户不存在
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 提交用户注册表单
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
# 验证用户已成功创建
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 获取新创建的用户
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 生成邮箱验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
# 访问验证结果页面
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# 测试用户登录
self.client.login(username='user1233', password='password123!q@wE#R$T')
# 提升用户权限为超级用户
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
user.is_staff = True
user.save()
# 清理侧边栏缓存
delete_sidebar_cache()
# 创建测试分类
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' # 错误密码
})
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): 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
# 测试错误邮箱验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str) # 验证失败应返回错误信息字符串
def test_forget_password_email_code_success(self): def test_forget_password_email_code_success(self):
""" # 测试请求忘记密码验证码(成功情况)
测试忘记密码验证码请求成功场景 ...
验证正确邮箱地址的验证码请求处理
"""
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self): def test_forget_password_email_code_fail(self):
""" # 测试请求忘记密码验证码(失败情况,如邮箱格式错误)
测试忘记密码验证码请求失败场景 ...
验证空邮箱和错误格式邮箱的请求处理
"""
# 测试空邮箱提交
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict() # 空数据
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试错误格式邮箱提交
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com") # 无效邮箱格式
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self): 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) # 重定向响应
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self): def test_forget_password_email_not_user(self):
""" # 测试为未注册邮箱请求重置密码
测试不存在的用户密码重置 ...
验证对不存在用户的密码重置请求处理
"""
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com", # 不存在的邮箱
code="123456",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200) # 应返回表单错误页面
def test_forget_password_email_code_error(self): def test_forget_password_email_code_error(self):
""" # 测试使用错误验证码重置密码
测试错误验证码的密码重置 ...
验证错误验证码的密码重置请求处理
"""
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111", # 错误的验证码
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200) # 应返回表单错误页面

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

@ -1,91 +1,78 @@
""" # 从 Django 的 auth 模块中导入 get_user_model 函数
自定义用户认证后端模块 # 该函数用于获取当前项目中使用的用户模型(比如你自定义的 BlogUser
本模块提供扩展的用户认证功能支持使用用户名或邮箱进行登录
扩展了Django标准的ModelBackend认证后端
"""
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
# 从 Django 的 auth.backends 模块中导入 ModelBackend
# ModelBackend 是 Django 默认的用户认证后端,提供基础的 authenticate 和 get_user 方法
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
# ===================== 自定义认证后端类 =====================
# 类名EmailOrUsernameModelBackend
# 作用:扩展 Django 默认的用户认证方式,允许用户使用「用户名」或「邮箱」登录
class EmailOrUsernameModelBackend(ModelBackend): class EmailOrUsernameModelBackend(ModelBackend):
""" """
自定义用户认证后端 - 支持用户名或邮箱登录 允许使用用户名或邮箱登录
-----------
继承自Django的ModelBackend扩展认证功能 重写了 authenticate 方法使其支持
- 允许用户使用用户名或邮箱地址进行登录 - 如果传入的 username 参数中包含 '@' 符号则认为用户想用邮箱登录
- 自动检测输入的是用户名还是邮箱格式 - 否则认为用户想用用户名登录
- 保持与Django原生认证系统的兼容性 然后尝试根据 username email 查找用户并校验密码是否正确
使用场景
当用户输入包含'@'符号时系统将其识别为邮箱进行认证
否则将其识别为用户名进行认证
""" """
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
""" """
用户认证方法 自定义用户认证逻辑
:param request: HttpRequest 对象通常可以忽略但保留以兼容 Django 的调用方式
重写认证逻辑支持通过用户名或邮箱进行用户身份验证 :param username: 用户输入的登录名可能是用户名也可能是邮箱
:param password: 用户输入的密码
Args: :param kwargs: 其它参数一般用不到
request: HttpRequest对象包含请求信息 :return: 如果认证成功返回用户对象否则返回 None
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')
""" """
# 判断输入的是邮箱还是用户名
# 判断用户输入的 username 是否包含 '@' 符号
# 如果包含,通常意味着用户输入的是邮箱,因此我们将以邮箱进行查询
if '@' in username: if '@' in username:
# 如果包含'@'符号,按邮箱处理 # 构造查询参数,告诉 Django 我们要根据 email 查找用户
kwargs = {'email': username} kwargs = {'email': username}
# 如果不包含 '@',则认为用户输入的是用户名
else: else:
# 否则按用户名处理 # 构造查询参数,告诉 Django 我们要根据 username 查找用户
kwargs = {'username': username} kwargs = {'username': username}
try: try:
# 根据用户名或邮箱查找用户 # 根据上面构造的参数(可能是 email 或 username从数据库中查找用户
# get_user_model() 获取当前项目使用的用户模型(比如 BlogUser
# objects.get(**kwargs) 尝试获取唯一匹配的用户
user = get_user_model().objects.get(**kwargs) user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确 # 检查用户输入的密码是否与数据库中存储的哈希密码匹配
if user.check_password(password): if user.check_password(password):
# 密码验证成功,返回用户对象 # 如果密码正确,返回该用户对象,表示认证成功
return user return user
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
# 用户不存在返回None表示认证失败 # 如果根据 username 或 email 找不到对应的用户,则捕获 DoesNotExist 异常
# 表示没有这个用户,返回 None 表示认证失败
return None return None
# 如果密码不正确,也会走到这里,返回 None 表示认证失败
return None
def get_user(self, username): def get_user(self, username):
""" """
根据用户ID获取用户对象 根据用户 ID通常是主键 pk获取用户对象
:param username: 这里的参数名虽然是 username但实际上传入的是用户的 PK如用户ID
重写用户获取方法通过用户ID主键获取用户实例 :return: 返回对应的用户对象如果找不到则返回 None
Args:
username (int/str): 用户的ID主键值
Returns:
User: 对应的用户对象
None: 用户不存在时返回None
Note:
这里的参数名username实际上是用户ID这是为了保持与父类接口一致
""" """
try: try:
# 根据主键用户ID查找用户 # 根据主键通常是用户ID从数据库中获取用户对象
# get_user_model() 获取当前使用的用户模型
# objects.get(pk=username) 通过主键查找用户
return get_user_model().objects.get(pk=username) return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
# 用户不存在返回None # 如果根据主键找不到用户,捕获异常并返回 None
return None return None

@ -1,15 +1,3 @@
"""
邮箱验证码工具模块
本模块提供邮箱验证码的生成发送验证和缓存管理功能
用于用户注册密码重置等需要邮箱验证的场景
主要功能
- 发送验证码邮件
- 验证码的存储和读取
- 验证码有效性验证
"""
import typing import typing
from datetime import timedelta from datetime import timedelta
@ -19,109 +7,26 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email from djangoblog.utils import send_email
# 验证码有效期配置 - 5分钟 # 验证码有效期为 5 分钟
_code_ttl = timedelta(minutes=5) _code_ttl = timedelta(minutes=5)
# 发送验证邮件(如邮箱验证或忘记密码验证码)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): 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
"""
# 构建邮件HTML内容包含验证码信息
html_content = _( html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it " "You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code} "properly") % {'code': code}
# 调用邮件发送工具发送邮件
send_email([to_mail], subject, html_content) send_email([to_mail], subject, html_content)
# 校验验证码是否正确
def verify(email: str, code: str) -> typing.Optional[str]: def verify(email: str, code: str) -> typing.Optional[str]:
"""
验证验证码是否有效
检查用户输入的验证码与缓存中存储的是否一致并验证有效性
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("验证成功")
"""
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email) cache_code = get_code(email)
# 比较用户输入的验证码与缓存中的验证码
if cache_code != code: if cache_code != code:
# 验证码不匹配,返回错误信息
return gettext("Verification code error") return gettext("Verification code error")
# 验证成功返回None # 将验证码存储到缓存中(如 Redis并设置过期时间
def set_code(email: str, code: str): 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分钟有效期
cache.set(email, code, _code_ttl.seconds) cache.set(email, code, _code_ttl.seconds)
# 从缓存中获取验证码
def get_code(email: str) -> typing.Optional[str]: 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缓存系统中获取验证码
return cache.get(email) return cache.get(email)

@ -1,15 +1,3 @@
"""
用户账户视图模块
本模块包含用户账户相关的所有视图处理逻辑包括
- 用户注册登录登出
- 邮箱验证
- 密码重置
- 验证码发送
使用类视图和函数视图结合的方式处理用户认证流程
"""
import logging import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
@ -38,68 +26,32 @@ from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
from .models import BlogUser from .models import BlogUser
# 配置日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 注册视图:处理用户注册请求,注册后发送验证邮件
class RegisterView(FormView): class RegisterView(FormView):
"""
用户注册视图
处理新用户注册流程包括表单验证用户创建邮箱验证邮件发送等
"""
# 指定使用的表单类
form_class = RegisterForm form_class = RegisterForm
# 指定注册页面模板
template_name = 'account/registration_form.html' template_name = 'account/registration_form.html'
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
"""
请求分发方法添加CSRF保护装饰器
确保注册请求受到CSRF保护防止跨站请求伪造攻击
"""
return super(RegisterView, self).dispatch(*args, **kwargs) return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
创建新用户发送验证邮件重定向到结果页面
Args:
form: 验证通过的注册表单实例
Returns:
HttpResponseRedirect: 重定向到结果页面
"""
if form.is_valid(): if form.is_valid():
# 创建用户但不立即保存到数据库
user = form.save(False) user = form.save(False)
# 设置用户为非激活状态,等待邮箱验证 user.is_active = False # 注册后默认不激活,需通过邮箱验证
user.is_active = False
# 记录用户来源为注册页面
user.source = 'Register' user.source = 'Register'
# 保存用户到数据库
user.save(True) user.save(True)
# 获取当前站点域名
site = get_current_site().domain site = get_current_site().domain
# 生成邮箱验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 调试模式下使用本地地址
if settings.DEBUG: if settings.DEBUG:
site = '127.0.0.1:8000' site = '127.0.0.1:8000'
# 构建验证URL
path = reverse('account:result') path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign) site=site, path=path, id=user.id, sign=sign)
# 构建邮件内容
content = """ content = """
<p>请点击下面链接验证您的邮箱</p> <p>请点击下面链接验证您的邮箱</p>
@ -110,8 +62,6 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器 如果上面链接无法打开请将此链接复制至浏览器
{url} {url}
""".format(url=url) """.format(url=url)
# 发送验证邮件
send_email( send_email(
emailto=[ emailto=[
user.email, user.email,
@ -119,259 +69,119 @@ class RegisterView(FormView):
title='验证您的电子邮箱', title='验证您的电子邮箱',
content=content) content=content)
# 重定向到注册结果页面
url = reverse('accounts:result') + \ url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id) '?type=register&id=' + str(user.id)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
else: else:
# 表单验证失败,重新渲染表单页面
return self.render_to_response({ return self.render_to_response({
'form': form 'form': form
}) })
# 登出视图:处理用户登出,登出后跳转登录页
class LogoutView(RedirectView): class LogoutView(RedirectView):
"""
用户登出视图
处理用户登出逻辑清理会话和缓存
"""
# 登出后重定向到的URL
url = '/login/' url = '/login/'
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""
请求分发方法添加不缓存装饰器
确保登出页面不会被浏览器缓存
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs) return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""
处理GET请求的登出逻辑
执行用户登出操作清理侧边栏缓存
"""
# 执行用户登出
logout(request) logout(request)
# 清理侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
# 调用父类方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs) return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图:处理用户登录请求,支持记住登录状态
class LoginView(FormView): class LoginView(FormView):
"""
用户登录视图
处理用户登录认证支持记住登录状态功能
"""
# 指定使用的表单类
form_class = LoginForm form_class = LoginForm
# 指定登录页面模板
template_name = 'account/login.html' template_name = 'account/login.html'
# 登录成功后的默认重定向URL
success_url = '/' success_url = '/'
# 重定向字段名称
redirect_field_name = REDIRECT_FIELD_NAME redirect_field_name = REDIRECT_FIELD_NAME
# 记住登录状态的会话有效期(一个月) login_ttl = 2626560 # 一个月(单位:秒)
login_ttl = 2626560
@method_decorator(sensitive_post_parameters('password')) @method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""
请求分发方法添加安全装饰器
- sensitive_post_parameters: 保护密码参数
- csrf_protect: CSRF保护
- never_cache: 禁止缓存
"""
return super(LoginView, self).dispatch(request, *args, **kwargs) return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
获取模板上下文数据
添加重定向URL到上下文
"""
# 从GET参数获取重定向URL
redirect_to = self.request.GET.get(self.redirect_field_name) redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None: if redirect_to is None:
redirect_to = '/' redirect_to = '/'
kwargs['redirect_to'] = redirect_to kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs) return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
执行用户登录认证处理记住登录状态
"""
# 使用Django认证表单进行验证
form = AuthenticationForm(data=self.request.POST, request=self.request) form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid(): if form.is_valid():
# 清理侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
logger.info(self.redirect_field_name) logger.info(self.redirect_field_name)
# 执行用户登录
auth.login(self.request, form.get_user()) auth.login(self.request, form.get_user())
# 处理记住登录状态
if self.request.POST.get("remember"): if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl) self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form) return super(LoginView, self).form_valid(form)
else: else:
# 登录失败,重新渲染登录页面
return self.render_to_response({ return self.render_to_response({
'form': form 'form': form
}) })
def get_success_url(self): def get_success_url(self):
"""
获取登录成功后的重定向URL
验证重定向URL的安全性防止开放重定向攻击
"""
# 从POST数据获取重定向URL
redirect_to = self.request.POST.get(self.redirect_field_name) redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证URL是否安全同源策略
if not url_has_allowed_host_and_scheme( if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[ url=redirect_to, allowed_hosts=[
self.request.get_host()]): self.request.get_host()]):
redirect_to = self.success_url redirect_to = self.success_url
return redirect_to return redirect_to
# 验证结果页视图:处理注册验证或邮箱验证结果
def account_result(request): def account_result(request):
"""
账户操作结果页面视图
显示注册结果或处理邮箱验证
Args:
request: HTTP请求对象
Returns:
HttpResponse: 结果页面响应
"""
# 获取操作类型和用户ID
type = request.GET.get('type') type = request.GET.get('type')
id = request.GET.get('id') id = request.GET.get('id')
# 获取用户对象不存在则返回404
user = get_object_or_404(get_user_model(), id=id) user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
# 如果用户已激活,重定向到首页
if user.is_active: if user.is_active:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 处理注册和验证类型
if type and type in ['register', 'validation']: if type and type in ['register', 'validation']:
if type == 'register': if type == 'register':
# 注册成功页面 content = '恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。'
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功' title = '注册成功'
else: else:
# 邮箱验证处理
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign') sign = request.GET.get('sign')
# 验证签名
if sign != c_sign: if sign != c_sign:
return HttpResponseForbidden() return HttpResponseForbidden()
# 激活用户账号
user.is_active = True user.is_active = True
user.save() user.save()
content = ''' content = '恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。'
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功' title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', { return render(request, 'account/result.html', {
'title': title, 'title': title,
'content': content 'content': content
}) })
else: else:
# 无效类型,重定向到首页
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 忘记密码视图:处理用户提交的新密码
class ForgetPasswordView(FormView): class ForgetPasswordView(FormView):
"""
忘记密码重置视图
处理用户密码重置请求验证验证码并更新密码
"""
# 指定使用的表单类
form_class = ForgetPasswordForm form_class = ForgetPasswordForm
# 指定模板名称
template_name = 'account/forget_password.html' template_name = 'account/forget_password.html'
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
重置用户密码并重定向到登录页面
"""
if form.is_valid(): if form.is_valid():
# 根据邮箱查找用户
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 使用新密码的哈希值更新用户密码
blog_user.password = make_password(form.cleaned_data["new_password2"]) blog_user.password = make_password(form.cleaned_data["new_password2"])
# 保存用户信息
blog_user.save() blog_user.save()
# 重定向到登录页面
return HttpResponseRedirect('/login/') return HttpResponseRedirect('/login/')
else: else:
# 表单验证失败,重新渲染表单
return self.render_to_response({'form': form}) return self.render_to_response({'form': form})
# 忘记密码验证码请求视图:处理用户请求发送验证码到邮箱
class ForgetPasswordEmailCode(View): class ForgetPasswordEmailCode(View):
"""
忘记密码验证码发送视图
处理密码重置验证码的发送请求
"""
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
"""
处理POST请求发送密码重置验证码
Args:
request: HTTP请求对象
Returns:
HttpResponse: 操作结果响应
"""
# 验证表单数据
form = ForgetPasswordCodeForm(request.POST) form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid(): if not form.is_valid():
return HttpResponse("错误的邮箱") return HttpResponse("错误的邮箱")
# 获取邮箱地址
to_email = form.cleaned_data["email"] to_email = form.cleaned_data["email"]
# 生成验证码
code = generate_code() code = generate_code()
# 发送验证邮件
utils.send_verify_email(to_email, code) utils.send_verify_email(to_email, code)
# 存储验证码到缓存
utils.set_code(to_email, code) utils.set_code(to_email, code)
return HttpResponse("ok") return HttpResponse("ok")

@ -5,66 +5,64 @@ from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Article, Category, Tag, Links, SideBar, BlogSettings, ArticleLike, ArticleFavorite # 引入当前 app 的模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 文章表单(可扩展,比如集成富文本编辑器) # 自定义文章表单(可扩展,比如集成富文本编辑器)
class ArticleForm(forms.ModelForm): class ArticleForm(forms.ModelForm):
class Meta: class Meta:
model = Article model = Article
fields = '__all__' # 表示表单包含模型的所有字段 fields = '__all__' # 表单包含模型的所有字段
# 批量操作:发布文章 # 定义文章管理操作函数
def makr_article_publish(modeladmin, request, queryset): def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p') # 将选中文章状态改为 'p'ublished queryset.update(status='p') # 批量将文章状态设为已发布
makr_article_publish.short_description = _('发布选中的文章') makr_article_publish.short_description = _('发布选中的文章')
# 批量操作:草稿文章
def draft_article(modeladmin, request, queryset): def draft_article(modeladmin, request, queryset):
queryset.update(status='d') # 草稿状态 queryset.update(status='d') # 批量设为草稿
draft_article.short_description = _('将选中文章设为草稿') draft_article.short_description = _('将选中文章设为草稿')
# 批量操作:关闭文章评论
def close_article_commentstatus(modeladmin, request, queryset): def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c') # 关闭评论 queryset.update(comment_status='c') # 关闭评论
close_article_commentstatus.short_description = _('关闭文章评论') close_article_commentstatus.short_description = _('关闭文章评论')
# 批量操作:开放文章评论
def open_article_commentstatus(modeladmin, request, queryset): def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o') # 开评论 queryset.update(comment_status='o') # 开评论
open_article_commentstatus.short_description = _('文章评论') open_article_commentstatus.short_description = _('文章评论')
# 文章管理后台类 # 文章管理后台类
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(admin.ModelAdmin):
list_per_page = 20 # 每页显示20条 list_per_page = 20 # 每页显示20条
search_fields = ('body', 'title') # 可以搜索正文和标题 search_fields = ('body', 'title') # 可搜索字段
form = ArticleForm form = ArticleForm
list_display = ( # 列表页显示的字段 list_display = ( # 列表页显示的字段
'id', 'title', 'author', 'link_to_category', 'creation_time', 'id', 'title', 'author', 'link_to_category', 'creation_time',
'views', 'like_count', 'status', 'type', 'article_order' 'views', 'status', 'type', 'article_order'
) )
list_display_links = ('id', 'title') # 哪些字段可点击进入编辑页 list_display_links = ('id', 'title') # 哪些字段可点击进入编辑页
list_filter = ('status', 'type', 'category') # 右侧过滤器 list_filter = ('status', 'type', 'category') # 右侧过滤器
date_hierarchy = 'creation_time' # 按创建时间分层筛选 date_hierarchy = 'creation_time' # 按创建时间分层
filter_horizontal = ('tags',) # 多对多字段(标签)以横向过滤器展示 filter_horizontal = ('tags',) # 多对多字段用横向过滤器
exclude = ('creation_time', 'last_modify_time') # 后台不显示这两个字段 exclude = ('creation_time', 'last_modify_time') # 后台不显示这两个字段
view_on_site = True # 显示“查看站点”按钮 view_on_site = True # 显示“查看站点”按钮
actions = [makr_article_publish, draft_article, close_article_commentstatus, open_article_commentstatus] # 批量操作 actions = [makr_article_publish, draft_article, close_article_commentstatus, open_article_commentstatus] # 批量操作
raw_id_fields = ('author', 'category') # 作者和分类以 ID 输入框展示,适合外键多的情况 raw_id_fields = ('author', 'category') # 作者和分类用输入框而不是下拉
# 自定义方法:分类显示为可点击链接 # 自定义分类字段显示为链接
def link_to_category(self, obj): def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name) info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name)) return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('分类') link_to_category.short_description = _('分类')
# 限制文章作者只能选择超级用户 # 限制作者只能选择超级用户
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super(ArticleAdmin, self).get_form(request, obj, **kwargs) form = super(ArticleAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True) form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True)
return form return form
# 点击“查看站点”时跳转到文章详情页 # 获取文章详情页链接
def get_view_on_site_url(self, obj=None): def get_view_on_site_url(self, obj=None):
if obj: if obj:
url = obj.get_full_url() url = obj.get_full_url()
@ -74,55 +72,28 @@ class ArticleAdmin(admin.ModelAdmin):
site = get_current_site().domain site = get_current_site().domain
return site return site
# 其它模型(如 Tag、Category、Links、SideBar、BlogSettings的 Admin 配置 # 其它模型管理类(简化,仅注册)
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time') exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index') list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin): class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time') exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin): class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence') list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin): class BlogSettingsAdmin(admin.ModelAdmin):
pass # 博客设置后台,暂时无特殊配置 pass # 博客设置,通常唯一,无需复杂操作
# 文章点赞管理后台类 # 注册所有模型到 admin
class ArticleLikeAdmin(admin.ModelAdmin): admin.site.register(Article, ArticleAdmin)
list_display = ('article', 'user', 'created_time') admin.site.register(Tag, TagAdmin)
list_filter = ('created_time',) admin.site.register(Category, CategoryAdmin)
search_fields = ('article__title', 'user__username', 'user__email') admin.site.register(Links, LinksAdmin)
date_hierarchy = 'created_time' admin.site.register(SideBar, SideBarAdmin)
readonly_fields = ('created_time',) admin.site.register(BlogSettings, BlogSettingsAdmin)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 点赞记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')
# 文章收藏管理后台类
class ArticleFavoriteAdmin(admin.ModelAdmin):
list_display = ('article', 'user', 'created_time')
list_filter = ('created_time',)
search_fields = ('article__title', 'user__username', 'user__email')
date_hierarchy = 'created_time'
readonly_fields = ('created_time',)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 收藏记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')

@ -1,4 +1,4 @@
from django.apps import AppConfig from django.apps import AppConfig
class BlogConfig(AppConfig): class BlogConfig(AppConfig):
name = 'blog' # 应用名称 name = 'blog' # 当前 app 名称

@ -1,18 +1,18 @@
import logging import logging
from django.utils import timezone from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting from djangoblog.utils import cache, get_blog_setting # 假设有这些工具方法
from .models import Category, Article from .models import Category, Article
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 上下文处理器:为每个模板注入全局 SEO 和导航相关变量 # 上下文处理器:为每个模板注入 SEO 相关全局变量
def seo_processor(requests): def seo_processor(request):
key = 'seo_processor' cache_key = 'seo_processor'
value = cache.get(key) # 先从缓存中读取 value = cache.get(cache_key) # 尝试从缓存读取
if value: if value:
return value return value
else: else:
logger.info('设置处理器缓存。') logger.info('设置 SEO 处理器缓存。')
setting = get_blog_setting() # 获取博客配置 setting = get_blog_setting() # 获取博客配置
value = { value = {
'SITE_NAME': setting.site_name, 'SITE_NAME': setting.site_name,
@ -21,13 +21,13 @@ def seo_processor(requests):
'SITE_SEO_DESCRIPTION': setting.site_seo_description, 'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description, 'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords, 'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', 'SITE_BASE_URL': request.scheme + '://' + request.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length, 'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(), # 导航分类 'nav_category_list': Category.objects.all(), # 导航分类
'nav_pages': Article.objects.filter(type='p', status='p'), # 导航文章(已发布页面 'nav_pages': Article.objects.filter(type='p', status='p'), # 已发布页面
'OPEN_SITE_COMMENT': setting.open_site_comment, 'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code, # 备案号 'BEIAN_CODE': setting.beian_code, # 备案号
'ANALYTICS_CODE': setting.analytics_code, # 统计代码(如 Google Analytics 'ANALYTICS_CODE': setting.analytics_code, # 统计代码
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号 "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号
"SHOW_GONGAN_CODE": setting.show_gongan_code, "SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year, "CURRENT_YEAR": timezone.now().year,
@ -35,5 +35,5 @@ def seo_processor(requests):
"GLOBAL_FOOTER": setting.global_footer, "GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review, "COMMENT_NEED_REVIEW": setting.comment_need_review,
} }
cache.set(key, value, 60 * 60 * 10) # 缓存10小时 cache.set(cache_key, value, 60 * 60 * 10) # 缓存10小时
return value return value

@ -1,88 +1,54 @@
import time
import logging import logging
import elasticsearch.client import time
from django.conf import settings from django.conf import settings
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean from elasticsearch_dsl import Document, Date, Integer, Keyword, Text, Object, Boolean
from elasticsearch_dsl.connections import connections from elasticsearch_dsl.connections import connections
from blog.models import Article from blog.models import Article
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') # 是否启用 Elasticsearch ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
# 如果启用,则建立连接 # 如果启用 ES,则建立连接
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''
{
"description": "添加IP地理位置信息",
"processors": [
{ "geoip": { "field": "ip" } }
]
}
''')
# 定义用户代理相关内部文档
# 定义 IP 地理位置信息内部文档 class UserAgentBrowser(Object):
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
# 用户代理(浏览器/设备/操作系统)相关内部类
class UserAgentBrowser(InnerDoc):
Family = Keyword() Family = Keyword()
Version = Keyword() Version = Keyword()
class UserAgentOS(UserAgentBrowser): class UserAgentOS(UserAgentBrowser):
pass pass
class UserAgentDevice(Object):
class UserAgentDevice(InnerDoc):
Family = Keyword() Family = Keyword()
Brand = Keyword() Brand = Keyword()
Model = Keyword() Model = Keyword()
class UserAgent(Object):
class UserAgent(InnerDoc): browser = Object(UserAgentBrowser)
browser = Object(UserAgentBrowser, required=False) os = Object(UserAgentOS)
os = Object(UserAgentOS, required=False) device = Object(UserAgentDevice)
device = Object(UserAgentDevice, required=False)
string = Text() string = Text()
is_bot = Boolean() is_bot = Boolean()
# 性能日志文档
# 性能监控文档:记录每个请求的 URL、耗时、IP、用户代理等
class ElapsedTimeDocument(Document): class ElapsedTimeDocument(Document):
url = Keyword() url = Keyword()
time_taken = Long() # 请求耗时(毫秒) time_taken = Long() # 请求耗时(毫秒)
log_datetime = Date() log_datetime = Date()
ip = Keyword() ip = Keyword()
geoip = Object(GeoIp, required=False) geoip = Object() # 可添加 GeoIP 信息
useragent = Object(UserAgent, required=False) useragent = Object(UserAgent)
class Index: class Index:
name = 'performance' name = 'performance'
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# 文章搜索文档
# 文章搜索文档:用于全文检索
class ArticleDocument(Document): class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 使用IK中文分词 body = Text(analyzer='ik_max_word') # 使用 ik 中文分词
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') title = Text(analyzer='ik_max_word')
author = Object(properties={'nickname': Text(analyzer='ik_max_word'), 'id': Integer()}) author = Object(properties={'nickname': Text(analyzer='ik_max_word'), 'id': Integer()})
category = Object(properties={'name': 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()}) tags = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
@ -95,307 +61,5 @@ class ArticleDocument(Document):
class Index: class Index:
name = 'blog' 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
def create_all_indices(self):
"""
创建所有索引
"""
if not self.es_enabled:
return False
try:
# 创建文章索引
ArticleDocument.init()
# 创建性能监控索引
ElapsedTimeDocument.init()
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 {}
# 创建全局实例 # (后续可补充对应的管理器,用于创建索引、更新等操作,见您 documents.py 的其它部分)
elasticsearch_manager = ElasticsearchManager()

@ -1,6 +1,15 @@
# 继承 Haystack 搜索表单,自定义查询字段 import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm): class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True) querydata = forms.CharField(required=True) # 必须输入搜索关键词
def search(self): def search(self):
# 可加入日志等处理 if not self.is_valid():
return super().search() return self.no_query_found()
datas = super().search()
logger.info(self.cleaned_data['querydata']) # 记录搜索词
return datas

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

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

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

@ -1,62 +1,68 @@
from django.contrib.auth import get_user_model # Django 提供的获取用户模型的方法 from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password # 用于密码加密 from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 引入文章、标签、分类模型 # 导入项目中的 Article, Tag, Category 模型
from blog.models import Article, Tag, Category from blog.models import Article, Tag, Category
class Command(BaseCommand): class Command(BaseCommand):
help = '创建测试数据' # 用于生成一些假数据,便于前端展示和功能测试 help = '创建测试数据' # 命令的帮助信息
def handle(self, *args, **options): def handle(self, *args, **options):
# 创建或获取一个测试用户 """
命令的主要处理逻辑
"""
# 获取或创建一个测试用户
user = get_user_model().objects.get_or_create( user = get_user_model().objects.get_or_create(
email='test@test.com', email='test@test.com', # 用户邮箱
username='测试用户', username='测试用户', # 用户名
password=make_password('test!q@w#eTYU') # 对明文密码进行哈希加密 password=make_password('test!q@w#eTYU') # 加密后的密码
)[0] )[0] # get_or_create 返回一个元组 (对象, 创建与否),我们只需要对象
# 创建或获取一个父级分类 # 获取或创建一个父级分类
pcategory = Category.objects.get_or_create( pcategory = Category.objects.get_or_create(
name='我是父类目', name='我是父类目', # 父分类名称
parent_category=None # 表示没有父分类,即为顶级分类 parent_category=None # 父分类为 None表示这是顶级分类
)[0] )[0]
# 创建或获取一个子分类,其父分类为上面创建的 pcategory # 获取或创建一个子分类,其父分类为上面创建的父分类
category = Category.objects.get_or_create( category = Category.objects.get_or_create(
name='子类目', name='子类目', # 子分类名称
parent_category=pcategory parent_category=pcategory # 指定父分类
)[0] )[0]
category.save() # 保存分类对象 category.save() # 保存分类(虽然 get_or_create 已经保存,但显式保存也无妨)
# 创建一个基础标签 # 创建一个基础标签
basetag = Tag() basetag = Tag()
basetag.name = "标签" basetag.name = "标签" # 标签名称
basetag.save() basetag.save() # 保存标签
# 循环创建 1~19 号文章,每篇文章都绑定到上面创建的子分类 # 循环创建 19 篇测试文章
for i in range(1, 20): for i in range(1, 20):
# 获取或创建一篇文章
article = Article.objects.get_or_create( article = Article.objects.get_or_create(
category=category, # 关联分类 category=category, # 关联分类
title='nice title ' + str(i), # 标题 title='nice title ' + str(i), # 文章标题
body='nice content ' + str(i), # 文内容 body='nice content ' + str(i),# 内容
author=user # 作者 author=user # 文章作者
)[0] )[0]
# 为每篇文章创建一个独立标签,如 标签1、标签2 ... # 创建一个新标签
tag = Tag() tag = Tag()
tag.name = "标签" + str(i) tag.name = "标签" + str(i) # 标签名称
tag.save() tag.save() # 保存标签
# 将新标签和基础标签添加到文章中
article.tags.add(tag) # 添加新标签
article.tags.add(basetag) # 添加基础标签
# 将独立标签和基础标签都添加到文章的标签集合中 # 保存文章(虽然 add 方法不会自动保存,但通常 get_or_create 已经保存)
article.tags.add(tag) article.save()
article.tags.add(basetag)
article.save() # 保存文章与标签的关联关系
# 清空缓存,确保新生成的数据能及时被正确索引或展示 # 清除所有缓存,以确保新的测试数据在缓存中正确反映
from djangoblog.utils import cache from djangoblog.utils import cache
cache.clear() cache.clear()
# 输出成功提示 # 输出成功信息
self.stdout.write(self.style.SUCCESS('已创建测试数据 \n')) self.stdout.write(self.style.SUCCESS('已创建测试数据 \n'))

@ -1,8 +1,10 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 导入站点工具与蜘蛛通知工具 # 导入自定义的百度通知工具和获取当前站点的工具
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
# 导入项目中的 Article, Tag, Category 模型
from blog.models import Article, Tag, Category from blog.models import Article, Tag, Category
# 获取当前站点的域名 # 获取当前站点的域名
@ -10,60 +12,64 @@ site = get_current_site().domain
class Command(BaseCommand): class Command(BaseCommand):
help = '通知百度收录相关 URL' # 用于将站点内的文章、标签、分类等 URL 提交给百度站长平台,加快收录 help = '通知百度 URL' # 命令的帮助信息
def add_arguments(self, parser): def add_arguments(self, parser):
""" """
添加命令行参数允许用户选择要通知的类型文章 / 标签 / 分类 / 全部 为命令添加自定义参数
""" """
parser.add_argument( parser.add_argument(
'data_type', 'data_type',
type=str, type=str,
choices=['all', 'article', 'tag', 'category'], # 可选参数值 choices=[
help='article所有文章, tag所有标签, category所有分类, all全部' 'all', # 所有类型
'article', # 仅文章
'tag', # 仅标签
'category' # 仅分类
],
help='选择要通知的数据类型: article所有文章, tag所有标签, category所有分类, all所有类型'
) )
def get_full_url(self, path): def get_full_url(self, path):
""" """
拼接完整的 URLhttps://example.com/path/ 根据给定的路径构建完整的 URL
""" """
url = "https://{site}{path}".format(site=site, path=path) url = "https://{site}{path}".format(site=site, path=path)
return url return url
def handle(self, *args, **options): def handle(self, *args, **options):
# 获取用户传入的参数,决定要通知哪些类型的数据 """
data_type = options['data_type'] 命令的主要处理逻辑
self.stdout.write('开始处理 %s' % data_type) """
data_type = options['data_type'] # 获取传入的数据类型参数
self.stdout.write('开始获取 %s' % data_type) # 输出开始信息
urls = [] # 用于存储所有需要提交的 URL urls = [] # 用于存储需要通知的 URL 列表
if data_type == 'article' or data_type == 'all': if data_type == 'article' or data_type == 'all':
# 如果是文章或全部将所有已发布status='p')的文章的完整 URL 加入列表 # 如果数据类型是文章或所有,则遍历所有状态为 'p'(假设 'p' 表示已发布)的文章
for article in Article.objects.filter(status='p'): for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url()) urls.append(article.get_full_url()) # 获取文章的完整 URL 并添加到列表中
if data_type == 'tag' or data_type == 'all': if data_type == 'tag' or data_type == 'all':
# 如果是标签或全部,将所有标签的绝对 URL 加入列表 # 如果数据类型是标签或所有,则遍历所有标签
for tag in Tag.objects.all(): for tag in Tag.objects.all():
url = tag.get_absolute_url() url = tag.get_absolute_url() # 获取标签的绝对 URL
urls.append(self.get_full_url(url)) urls.append(self.get_full_url(url)) # 构建完整 URL 并添加到列表中
if data_type == 'category' or data_type == 'all': if data_type == 'category' or data_type == 'all':
# 如果是分类或全部,将所有分类的绝对 URL 加入列表 # 如果数据类型是分类或所有,则遍历所有分类
for category in Category.objects.all(): for category in Category.objects.all():
url = category.get_absolute_url() url = category.get_absolute_url() # 获取分类的绝对 URL
urls.append(self.get_full_url(url)) urls.append(self.get_full_url(url)) # 构建完整 URL 并添加到列表中
# 输出即将提交的通知数量
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
'开始通知百度收录 %d 个 URL' % '开始通知 %d 个 URL' %
len(urls) len(urls) # 输出将要通知的 URL 数量
) )
) )
# 调用百度通知工具,通知所有收集到的 URL
# 调用百度通知工具,提交所有 URL
SpiderNotify.baidu_notify(urls) SpiderNotify.baidu_notify(urls)
# 输出完成通知的信息
# 提交完成提示 self.stdout.write(self.style.SUCCESS('完成通知'))
self.stdout.write(self.style.SUCCESS('完成通知百度收录\n'))

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

@ -1,67 +1,40 @@
import time # 添加这行
import logging import logging
from django.conf import settings import time
from django.utils import timezone from ipware import get_client_ip
from user_agents import parse
logger = logging.getLogger(__name__) from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__)
class OnlineMiddleware: class OnlineMiddleware:
""" def __init__(self, get_response):
在线用户中间件 - 记录每个请求的加载时间IP用户代理可选地存入 Elasticsearch
"""
def __init__(self, get_response=None):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
# 记录请求开始时间
start_time = time.time() start_time = time.time()
# 处理请求
response = self.get_response(request) response = self.get_response(request)
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent)
# 计算耗时,记录并显示到页面 if not response.streaming:
duration = time.time() - start_time
# 获取客户端IP
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
# 获取用户代理
user_agent = request.META.get('HTTP_USER_AGENT', '')
# 记录日志
logger.info(
f"Request: {request.method} {request.path} - "
f"IP: {ip} - "
f"Time: {duration:.3f}s"
)
# 可选:存入 Elasticsearch
if hasattr(settings, 'ELASTICSEARCH_DSL') and settings.ELASTICSEARCH_DSL:
try: try:
from blog.documents import ElapsedTimeDocument cast_time = time.time() - start_time
doc = ElapsedTimeDocument( if ELASTICSEARCH_ENABLED:
url=request.path, time_taken = round(cast_time * 1000, 2)
time_taken=int(duration * 1000), url = request.path
log_datetime=timezone.now(), from django.utils import timezone
ip=ip, ElaspedTimeDocumentManager.create(
useragent={'string': user_agent} url=url, time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent, ip=ip
)
# 在页面中显示加载时间
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5])
) )
doc.save()
except Exception as e: except Exception as e:
logger.warning(f"Failed to save to Elasticsearch: {e}") logger.error("OnlineMiddleware 错误: %s" % e)
# 添加处理时间到响应头
response['X-Response-Time'] = f'{duration:.3f}s'
return response
def process_exception(self, request, exception): return response
"""处理异常"""
logger.error(f"Middleware exception: {exception}")
return None

@ -1,21 +1,21 @@
# 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.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import mdeditor.fields import mdeditor.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True # 表示这是第一个迁移文件 initial = True # 标记这是初始迁移
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型,通常是内置的 User 或自定义用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型
] ]
operations = [ operations = [
# 创建 BlogSettings 模型:网站全局配置表 # 创建网站配置模型
migrations.CreateModel( migrations.CreateModel(
name='BlogSettings', name='BlogSettings',
fields=[ fields=[
@ -37,12 +37,11 @@ class Migration(migrations.Migration):
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
], ],
options={ options={
'verbose_name': '网站配置', 'verbose_name': '网站配置', # 模型在管理界面显示的名称
'verbose_name_plural': '网站配置', 'verbose_name_plural': '网站配置',
}, },
), ),
# 创建友情链接模型
# 创建 Links 模型:友情链接
migrations.CreateModel( migrations.CreateModel(
name='Links', name='Links',
fields=[ fields=[
@ -58,11 +57,10 @@ class Migration(migrations.Migration):
options={ options={
'verbose_name': '友情链接', 'verbose_name': '友情链接',
'verbose_name_plural': '友情链接', 'verbose_name_plural': '友情链接',
'ordering': ['sequence'], 'ordering': ['sequence'], # 排序依据
}, },
), ),
# 创建侧边栏模型
# 创建 SideBar 模型:侧边栏内容
migrations.CreateModel( migrations.CreateModel(
name='SideBar', name='SideBar',
fields=[ fields=[
@ -80,8 +78,7 @@ class Migration(migrations.Migration):
'ordering': ['sequence'], 'ordering': ['sequence'],
}, },
), ),
# 创建标签模型
# 创建 Tag 模型:文章标签
migrations.CreateModel( migrations.CreateModel(
name='Tag', name='Tag',
fields=[ fields=[
@ -97,8 +94,7 @@ class Migration(migrations.Migration):
'ordering': ['name'], 'ordering': ['name'],
}, },
), ),
# 创建分类模型
# 创建 Category 模型:文章分类
migrations.CreateModel( migrations.CreateModel(
name='Category', name='Category',
fields=[ fields=[
@ -116,8 +112,7 @@ class Migration(migrations.Migration):
'ordering': ['-index'], 'ordering': ['-index'],
}, },
), ),
# 创建文章模型
# 创建 Article 模型:文章内容
migrations.CreateModel( migrations.CreateModel(
name='Article', name='Article',
fields=[ fields=[

@ -1,22 +1,21 @@
# 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
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0001_initial'), # 依赖于第一个迁移文件 ('blog', '0001_initial'), # 依赖于初始迁移
] ]
operations = [ operations = [
# 新增字段global_footer用于存放网站公共尾部 HTML 内容(如版权信息等) # 向BlogSettings模型添加公共尾部字段
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='global_footer', name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
), ),
# 向BlogSettings模型添加公共头部字段
# 新增字段global_header用于存放网站公共头部 HTML 内容(如导航栏上面的内容)
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='global_header', name='global_header',

@ -1,6 +1,6 @@
# 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
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -9,7 +9,7 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
# 新增字段comment_need_review布尔值默认 False表示评论默认不需要审核 # 向BlogSettings模型添加评论是否需要审核字段
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='comment_need_review', name='comment_need_review',

@ -1,6 +1,6 @@
# 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
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -9,21 +9,19 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
# 将 analyticscode 字段重命名为 analytics_code提升代码可读性 # 重命名BlogSettings模型中的analyticscode字段为analytics_code
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='analyticscode', old_name='analyticscode',
new_name='analytics_code', new_name='analytics_code',
), ),
# 重命名BlogSettings模型中的beiancode字段为beian_code
# 将 beiancode 字段重命名为 beian_code
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='beiancode', old_name='beiancode',
new_name='beian_code', new_name='beian_code',
), ),
# 重命名BlogSettings模型中的sitename字段为site_name
# 将 sitename 字段重命名为 site_name
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='sitename', old_name='sitename',

@ -1,117 +1,452 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 # 该迁移文件由 Django 4.2.5 于 2023-09-06 13:13 自动生成
from django.conf import settings # 依赖于当前项目的用户模型AUTH_USER_MODEL和上一个博客应用的迁移 '0004_rename_analyticscode_blogsettings_analytics_code_and_more'
from django.db import migrations, models
import django.db.models.deletion from django.conf import settings # 用于引入项目设置,特别是 AUTH_USER_MODEL
import django.utils.timezone from django.db import migrations, models # Django 的迁移与模型字段工具
import mdeditor.fields import django.db.models.deletion # 用于定义外键删除策略
import django.utils.timezone # 用于获取当前时间(带时区)
import mdeditor.fields # 引入 Markdown 编辑器字段,用于富文本
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 该迁移依赖的项目模块
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型,允许自定义用户模型
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# 调整多个模型的 Meta 选项比如排序方式、verbose_name 等 # ========== 1. 调整模型 Meta 选项(管理后台显示名称、排序等)==========
# 调整 Article 模型的 Meta 选项:
# - 获取最新记录的依据字段为 id
# - 默认排序:先按 article_order 倒序(数字大的在前),再按发布时间倒序
# - 后台显示名称:单数为 'article',复数为 'article'
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='article', name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'}, options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
), ),
# 调整 Category 模型的 Meta 选项:
# - 默认排序:按 index 倒序(权重高的排前面)
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='category', name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'}, options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
), ),
# 调整 Links 模型的 Meta 选项:
# - 默认排序:按 sequence排序字段
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='links', name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'}, options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
), ),
# 调整 Sidebar 模型的 Meta 选项:
# - 默认排序:按 sequence
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='sidebar', name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'}, options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
), ),
# 调整 Tag 模型的 Meta 选项:
# - 默认排序:按 name标签名字母顺序
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='tag', name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': '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最后修改时间 # ========== 2. 删除旧的时间字段created_time 和 last_mod_time==========
# 从 Article 模型中移除 created_time 字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# 从 Article 模型中移除 last_mod_time 字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# 从 Category 模型中移除 created_time 字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# 从 Category 模型中移除 last_mod_time 字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# 从 Links 模型中移除 created_time 字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# 从 Sidebar 模型中移除 created_time 字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# 从 Tag 模型中移除 created_time 字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# 从 Tag 模型中移除 last_mod_time 字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# ========== 3. 新增新的时间字段creation_time 和 last_modify_time==========
# 为 Article 模型新增 creation_time 字段,记录文章创建时间,默认为当前时间
migrations.AddField( migrations.AddField(
model_name='article', model_name='article',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Article 模型新增 last_modify_time 字段,记录文章最后修改时间,默认为当前时间
migrations.AddField( migrations.AddField(
model_name='article', model_name='article',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 为 Category 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='category', model_name='category',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Category 模型新增 last_modify_time 字段
migrations.AddField( migrations.AddField(
model_name='category', model_name='category',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 为 Links 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='links', model_name='links',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Sidebar 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='sidebar', model_name='sidebar',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Tag 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Tag 模型新增 last_modify_time 字段
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='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'
# 目的是让系统更加国际化或统一字段语义
# 示例(节选,实际迁移中包含所有字段的类似调整): # ========== 4. 调整多个字段的属性verbose_name、字段类型、选项等==========
# 调整 Article 模型的 article_order 字段,用于排序,数字越大越靠前
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# 调整 Article 模型的 author 字段关联到当前项目的用户模型AUTH_USER_MODEL
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 调整 Article 模型的 body 字段,使用 Markdown 编辑器字段(支持富文本)
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# 调整 Article 模型的 category 字段,关联到 Category 模型
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# 调整 Article 模型的 comment_status 字段表示评论状态Open开放或 Close关闭
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='comment_status', name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'), field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
), ),
# 调整 Article 模型的 pub_time 字段,表示文章发布时间,默认为当前时间
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# 调整 Article 模型的 show_toc 字段表示是否显示目录Table of Contents
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# 调整 Article 模型的 status 字段表示文章状态Draft草稿或 Published已发布
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='status', name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_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 等)
# 调整 Article 模型的 tags 字段,与 Tag 模型建立多对多关系,表示文章可以有多个标签
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# 调整 Article 模型的 title 字段,文章标题,要求唯一
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# 调整 Article 模型的 type 字段表示文章类型Article文章或 Page页面如关于页面
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# 调整 Article 模型的 views 字段,表示文章浏览量
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# ========== 5. 调整 BlogSettings 模型各字段的属性 ==========
# 调整文章评论数量设置字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='article_comment_count', name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'), field=models.IntegerField(default=5, verbose_name='article comment count'),
), ),
# ...(其它 blogsettings 字段也做了字段选项的优化调整,比如 verbose_name 更清晰)
# 对 Category、Links、Sidebar、Tag 等模型字段也做了类似的字段选项优化 # 调整文章摘要显示长度设置字段
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# 调整 Google AdSense 广告代码字段
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# 调整是否开放网站评论功能的字段
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# 调整是否显示 Google AdSense 广告的字段
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# 调整侧边栏文章数量设置字段
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# 调整侧边栏评论数量设置字段
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# 调整网站描述字段
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# 调整网站关键字字段
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# 调整网站名称字段
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# 调整网站 SEO 描述字段
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# ========== 6. 调整 Category 模型字段属性 ==========
# 调整分类权重排序字段
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# 调整分类名称字段,要求唯一
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# 调整分类的父级分类字段,允许为空,实现分类嵌套
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# ========== 7. 调整 Links 模型字段属性 ==========
# 调整友情链接是否启用显示的字段
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# 调整友情链接的最后修改时间字段
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 调整友情链接的链接地址字段
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# 调整友情链接的名称字段,要求唯一
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# 调整友情链接的排序字段
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 调整友情链接的显示类型字段,如首页、列表页、文章页等
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# ========== 8. 调整 Sidebar 模型字段属性 ==========
# 调整侧边栏的内容字段
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# 调整侧边栏是否启用的字段
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 调整侧边栏的最后修改时间字段
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 调整侧边栏的标题字段
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# 调整侧边栏的排序字段
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# ========== 9. 调整 Tag 模型字段属性 ==========
# 调整标签的名称字段,要求唯一
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
] ]

@ -1,15 +1,15 @@
# 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
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'), ('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# 修改 BlogSettings 模型在后台显示的名称,从中文「网站配置」改为英文 'Website configuration' # 修改BlogSettings模型的选项设置其在管理界面的单数和复数显示名称
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='blogsettings', name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, 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,9 +1,3 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
@ -11,392 +5,46 @@ from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField from mdeditor.fields import MDTextField
from uuslug import slugify 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', _('幻灯片'))
class BaseModel(models.Model): class BaseModel(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('创建时间'), default=now) creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('修改时间'), default=now) last_modify_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
abstract = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_update_views = isinstance(self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == [ if 'slug' in self.__dict__:
'views'] slug = getattr(self, 'title') if 'title' in self.__dict__ else getattr(self, 'name')
if is_update_views: setattr(self, 'slug', slugify(slug))
Article.objects.filter(pk=self.pk).update(views=self.views) super().save(*args, **kwargs)
else:
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))
super().save(*args, **kwargs)
def get_full_url(self): def get_full_url(self):
site = get_current_site().domain site = "你的域名逻辑" # 应调用 get_current_site()
url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) return f"https://{site}{self.get_absolute_url()}"
return url
class Meta:
abstract = True
@abstractmethod @abstractmethod
def get_absolute_url(self): def get_absolute_url(self):
pass pass
class Article(BaseModel): class Article(BaseModel):
STATUS_CHOICES = ( STATUS_CHOICES = (('d', _('草稿')), ('p', _('发布')))
('d', _('草稿')),
('p', _('发布')),
)
COMMENT_STATUS = (
('o', _('开放评论')),
('c', _('关闭评论')),
)
TYPE = (
('a', _('文章')),
('p', _('页面')),
)
title = models.CharField(_('标题'), max_length=200, unique=True) title = models.CharField(_('标题'), max_length=200, unique=True)
body = MDTextField(_('内容')) body = MDTextField(_('正文'))
pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now)
status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p') status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p')
comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o') author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a') pub_time = models.DateTimeField(_('发布时间'), default=now)
views = models.PositiveIntegerField(_('浏览量'), default=0) views = models.PositiveIntegerField(_('浏览量'), default=0)
like_count = models.PositiveIntegerField(_('点赞数'), default=0) category = models.ForeignKey('Category', on_delete=models.CASCADE)
favorite_count = models.PositiveIntegerField(_('收藏数'), default=0) tags = models.ManyToManyField('Tag', blank=True)
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: def get_absolute_url(self):
favorite.delete() return reverse('blog:detail', kwargs={'article_id': self.id})
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): def __str__(self):
return self.title return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('文章')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel): class Category(BaseModel):
name = models.CharField(_('分类名称'), max_length=30, unique=True) name = models.CharField(_('分类名'), max_length=30, unique=True)
parent_category = models.ForeignKey('self', verbose_name=_('父级分类'), blank=True, null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('排序'))
class Meta:
ordering = ['-index']
verbose_name = _('分类')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse('blog:category_detail', kwargs={'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
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:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
name = models.CharField(_('标签名称'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) return reverse('blog:category', kwargs={'category_name': self.name})
@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_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)
class Meta:
ordering = ['sequence']
verbose_name = _('友情链接')
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)
class Meta:
ordering = ['sequence']
verbose_name = _('侧边栏')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class ArticleLike(models.Model):
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_likes'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('点赞时间'), default=now)
class Meta:
verbose_name = _('文章点赞')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 点赞了 {self.article.title}'
class ArticleFavorite(models.Model):
"""
文章收藏模型
记录用户对文章的收藏关系每个用户对同一篇文章只能收藏一次
"""
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_favorites'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('收藏时间'), default=now)
class Meta:
verbose_name = _('文章收藏')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 收藏了 {self.article.title}'
class BlogSettings(models.Model):
site_name = models.CharField(_('站点名称'), max_length=200, null=False, blank=False, default='')
site_description = models.TextField(_('站点描述'), max_length=1000, null=False, blank=False, default='')
site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(_('站点关键词'), max_length=1000, null=False, blank=False, default='')
article_sub_length = models.IntegerField(_('文章摘要长度'), default=300)
sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10)
sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5)
article_comment_count = models.IntegerField(_('文章评论数量'), default=5)
show_google_adsense = models.BooleanField(_('显示Google广告'), default=False)
google_adsense_codes = models.TextField(_('Google广告代码'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('开放站点评论'), default=True)
global_header = models.TextField(_("公共头部"), null=True, blank=True, default='')
global_footer = models.TextField(_("公共尾部"), null=True, blank=True, default='')
beian_code = models.CharField(_('备案号'), max_length=2000, null=True, blank=True, default='')
analytics_code = models.TextField(_("网站统计代码"), max_length=1000, null=False, blank=False, default='')
show_gongan_code = models.BooleanField(_('是否显示公安备案号'), default=False)
gongan_beiancode = models.TextField(_('公安备案号'), max_length=2000, null=True, blank=True, default='')
comment_need_review = models.BooleanField(_('评论是否需要审核'), default=False)
class Meta:
verbose_name = _('网站配置')
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(_('只能存在一个配置'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -1,12 +1,22 @@
from haystack import indexes from haystack import indexes # 引入 Haystack 索引相关模块
from blog.models import Article from blog.models import Article # 引入您的文章模型
# 定义一个针对 Article 模型的搜索索引类
class ArticleIndex(indexes.SearchIndex, indexes.Indexable): class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
"""
Haystack 搜索索引类用于为 Article 模型建立全文搜索索引
该索引将用于配合搜索引擎 WhooshElasticsearch实现文章内容的全文检索
"""
# 定义一个字段,作为文档的主要内容来源,通常用于存储要被全文检索的文本内容
# document=True 表示该字段是主文档字段use_template=True 表示内容将从模板生成
text = indexes.CharField(document=True, use_template=True) text = indexes.CharField(document=True, use_template=True)
# 必须定义的方法:返回当前索引对应的 Django 模型类
def get_model(self): def get_model(self):
return Article return Article
# 必须定义的方法:返回要被索引的模型对象查询集
# 这里只索引状态为 'p'(已发布)的文章,避免草稿等内容被搜索到
def index_queryset(self, using=None): def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p') return self.get_model().objects.filter(status='p')

@ -56,12 +56,12 @@ def custom_markdown(content):
主要用于文章内容处理 主要用于文章内容处理
""" """
html_content = CommonMarkdown.get_markdown(content) html_content = CommonMarkdown.get_markdown(content)
# 然后应用插件过滤器优化HTML # 然后应用插件过滤器优化HTML
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
return mark_safe(optimized_html) return mark_safe(optimized_html)
@ -76,7 +76,7 @@ def sidebar_markdown(content):
def render_article_content(context, article, is_summary=False): def render_article_content(context, article, is_summary=False):
""" """
渲染文章内容包含完整的上下文信息供插件使用 渲染文章内容包含完整的上下文信息供插件使用
Args: Args:
context: 模板上下文 context: 模板上下文
article: 文章对象 article: 文章对象
@ -84,41 +84,41 @@ def render_article_content(context, article, is_summary=False):
""" """
if not article or not hasattr(article, 'body'): if not article or not hasattr(article, 'body'):
return '' return ''
# 先转换Markdown为HTML # 先转换Markdown为HTML
html_content = CommonMarkdown.get_markdown(article.body) html_content = CommonMarkdown.get_markdown(article.body)
# 如果是摘要模式,先截断内容再应用插件 # 如果是摘要模式,先截断内容再应用插件
if is_summary: if is_summary:
# 截断HTML内容到合适的长度约300字符 # 截断HTML内容到合适的长度约300字符
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
# 先去除HTML标签截断纯文本然后重新转换为HTML # 先去除HTML标签截断纯文本然后重新转换为HTML
plain_text = strip_tags(html_content) plain_text = strip_tags(html_content)
truncated_text = truncatechars(plain_text, 300) truncated_text = truncatechars(plain_text, 300)
# 重新转换截断后的文本为HTML简化版避免复杂的插件处理 # 重新转换截断后的文本为HTML简化版避免复杂的插件处理
html_content = CommonMarkdown.get_markdown(truncated_text) html_content = CommonMarkdown.get_markdown(truncated_text)
# 然后应用插件过滤器,传递完整的上下文 # 然后应用插件过滤器,传递完整的上下文
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 获取request对象 # 获取request对象
request = context.get('request') request = context.get('request')
# 应用所有文章内容相关的插件 # 应用所有文章内容相关的插件
# 注意:摘要模式下某些插件(如版权声明)可能不适用 # 注意:摘要模式下某些插件(如版权声明)可能不适用
optimized_html = hooks.apply_filters( optimized_html = hooks.apply_filters(
ARTICLE_CONTENT_HOOK_NAME, ARTICLE_CONTENT_HOOK_NAME,
html_content, html_content,
article=article, article=article,
request=request, request=request,
context=context, context=context,
is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
) )
return mark_safe(optimized_html) return mark_safe(optimized_html)
@ -369,7 +369,7 @@ def gravatar_url(email, size=40):
url = cache.get(cachekey) url = cache.get(cachekey)
if url: if url:
return url return url
# 检查OAuth用户是否有自定义头像 # 检查OAuth用户是否有自定义头像
usermodels = OAuthUser.objects.filter(email=email) usermodels = OAuthUser.objects.filter(email=email)
if usermodels: if usermodels:
@ -378,18 +378,19 @@ def gravatar_url(email, size=40):
if users_with_picture: if users_with_picture:
# 获取默认头像路径用于比较 # 获取默认头像路径用于比较
default_avatar_path = static('blog/img/avatar.png') default_avatar_path = static('blog/img/avatar.png')
# 优先选择非默认头像的用户,否则选择第一个 # 优先选择非默认头像的用户,否则选择第一个
non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] non_default_users = [u for u in users_with_picture if
u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
selected_user = non_default_users[0] if non_default_users else users_with_picture[0] selected_user = non_default_users[0] if non_default_users else users_with_picture[0]
url = selected_user.picture url = selected_user.picture
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
avatar_type = 'non-default' if non_default_users else 'default' avatar_type = 'non-default' if non_default_users else 'default'
logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type))
return url return url
# 使用默认头像 # 使用默认头像
url = static('blog/img/avatar.png') url = static('blog/img/avatar.png')
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
@ -420,4 +421,4 @@ def query(qs, **kwargs):
@register.filter @register.filter
def addstr(arg1, arg2): def addstr(arg1, arg2):
"""concatenate arg1 & arg2""" """concatenate arg1 & arg2"""
return str(arg1) + str(arg2) return str(arg1) + str(arg2)

@ -2,46 +2,52 @@ import os
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command # 执行Django管理命令 from django.core.management import call_command # 用于调用 Django 管理命令,如 build_index
from django.core.paginator import Paginator # 分页器 from django.core.paginator import Paginator # 用于分页测试
from django.templatetags.static import static # 静态文件URL生成 from django.templatetags.static import static # 用于获取静态文件 URL
from django.test import Client, RequestFactory, TestCase # Django测试客户端与测试用例 from django.test import Client, RequestFactory, TestCase # Django 测试客户端与测试基类
from django.urls import reverse # URL反向解析 from django.urls import reverse # 用于反向解析 URL
from django.utils import timezone # 时间工具 from django.utils import timezone # 用于获取当前时间
from accounts.models import BlogUser # 用户模型 # 引入自定义的模型
from accounts.models import BlogUser # 自定义用户模型
from blog.forms import BlogSearchForm # 搜索表单 from blog.forms import BlogSearchForm # 搜索表单
from blog.models import Article, Category, Tag, SideBar, Links # 博客相关模型 from blog.models import Article, Category, Tag, SideBar, Links # 核心内容模型
from blog.templatetags.blog_tags import load_pagination_info, load_articletags # 模板标签 from blog.templatetags.blog_tags import load_pagination_info, load_articletags # 自定义模板标签
from djangoblog.utils import get_current_site, get_sha256 # 工具函数 from djangoblog.utils import get_current_site, get_sha256 # 工具函数获取当前站点、SHA256加密
from oauth.models import OAuthUser, OAuthConfig # OAuth相关模型 from oauth.models import OAuthUser, OAuthConfig # 第三方登录相关模型
# 文章相关测试类 # 创建测试用例类,用于测试文章及相关功能
class ArticleTest(TestCase): class ArticleTest(TestCase):
def setUp(self): def setUp(self):
# 初始化测试客户端与请求工厂 """
self.client = Client() 每个测试方法执行前都会调用用于初始化测试环境比如创建测试客户端等
self.factory = RequestFactory() """
self.client = Client() # Django 提供的 HTTP 客户端,用于模拟请求
self.factory = RequestFactory() # 用于创建请求对象(较少用在此测试中)
def test_validate_article(self): def test_validate_article(self):
# 获取当前站点域名 """
site = get_current_site().domain 综合测试验证文章创建标签关联分类搜索分页用户登录静态资源命令行调用等
# 创建一个超级用户 """
site = get_current_site().domain # 获取当前站点域名
user = BlogUser.objects.get_or_create( user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com", email="liangliangyy@gmail.com",
username="liangliangyy")[0] username="liangliangyy")[0] # 创建或获取一个超级用户
user.set_password("liangliangyy") user.set_password("liangliangyy")
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.save() user.save()
# 访问用户详情页
# 模拟访问用户详情页、一些不存在的 admin 页面(验证是否能正常响应,或用于覆盖)
response = self.client.get(user.get_absolute_url()) response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 以下几行尝试访问不存在的admin页面可能用于测试404
response = self.client.get('/admin/servermanager/emailsendlog/') response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/') response = self.client.get('admin/admin/logentry/')
# 创建一个侧边栏实例并保存
# 创建一个侧边栏对象并保存
s = SideBar() s = SideBar()
s.sequence = 1 s.sequence = 1
s.name = 'test' s.name = 'test'
@ -49,34 +55,37 @@ class ArticleTest(TestCase):
s.is_enable = True s.is_enable = True
s.save() s.save()
# 创建一个分类并保存 # 创建一个分类
category = Category() category = Category()
category.name = "category" category.name = "category"
category.creation_time = timezone.now() category.creation_time = timezone.now()
category.last_mod_time = timezone.now() category.last_mod_time = timezone.now()
category.save() category.save()
# 创建一个标签并保存 # 创建一个标签
tag = Tag() tag = Tag()
tag.name = "nicetag" tag.name = "nicetag"
tag.save() tag.save()
# 创建一篇文章并保存 # 创建一篇文章,并关联作者、分类、类型、状态等
article = Article() article = Article()
article.title = "nicetitle" article.title = "nicetitle"
article.body = "nicecontent" article.body = "nicecontent"
article.author = user article.author = user
article.category = category article.category = category
article.type = 'a' article.type = 'a' # 'a' 代表普通文章
article.status = 'p' # 发布状态 article.status = 'p' # 'p' 代表已发布
article.save() article.save()
self.assertEqual(0, article.tags.count()) # 初始应无标签
article.tags.add(tag) # 添加标签 # 初始时没有关联任何标签
self.assertEqual(0, article.tags.count())
# 为文章添加一个标签
article.tags.add(tag)
article.save() article.save()
self.assertEqual(1, article.tags.count()) # 应有1个标签 self.assertEqual(1, article.tags.count()) # 验证标签关联成功
# 批量创建20篇文章均添加同一标签 # 批量创建 20 篇文章,都关联同一个标签,用于后续分页等测试
for i in range(20): for i in range(20):
article = Article() article = Article()
article.title = "nicetitle" + str(i) article.title = "nicetitle" + str(i)
@ -89,100 +98,103 @@ class ArticleTest(TestCase):
article.tags.add(tag) article.tags.add(tag)
article.save() article.save()
# 如果启用了Elasticsearch则构建索引并测试搜索 # 如果启用了 Elasticsearch则构建索引并测试搜索
from blog.documents import ELASTICSEARCH_ENABLED from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
call_command("build_index") call_command("build_index") # 调用构建搜索索引的管理命令
response = self.client.get('/search', {'q': 'nicetitle'}) response = self.client.get('/search', {'q': 'nicetitle'}) # 搜索含有 nicetitle 的文章
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200) # 验证搜索页面能正常访问
# 访问某篇文章详情页 # 访问某篇文章详情页,验证能正常打开
response = self.client.get(article.get_absolute_url()) response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 模拟通知爬虫(如搜索引擎)该文章已更新
# 模拟通知爬虫(如百度蜘蛛)抓取该文章
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url()) SpiderNotify.notify(article.get_absolute_url())
# 访问标签详情页
# 访问标签页、分类页,验证能正常响应
response = self.client.get(tag.get_absolute_url()) response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 访问分类详情页
response = self.client.get(category.get_absolute_url()) response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 搜索关键词'django' # 搜索一个不太可能存在的词,如 django仍应返回 200
response = self.client.get('/search', {'q': 'django'}) response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 获取文章的标签模板标签结果
# 调用模板标签 load_articletags验证其返回值不为空
s = load_articletags(article) s = load_articletags(article)
self.assertIsNotNone(s) self.assertIsNotNone(s)
# 登录用户后访问归档页 # 登录用户
self.client.login(username='liangliangyy', password='liangliangyy') self.client.login(username='liangliangyy', password='liangliangyy')
# 访问归档页,验证能正常响应
response = self.client.get(reverse('blog:archives')) response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 测试分页功能 # 测试分页功能(普通文章列表)
p = Paginator(Article.objects.all(), settings.PAGINATE_BY) p = Paginator(Article.objects.all(), settings.PAGINATE_BY) # 每页显示 settings.PAGINATE_BY 篇
self.check_pagination(p, '', '') self.check_pagination(p, '', '') # 自定义方法,验证分页链接有效
# 按标签分页 # 测试按标签分页
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug) self.check_pagination(p, '分类标签归档', tag.slug)
# 按作者分页 # 测试按作者分页
p = Paginator( p = Paginator(
Article.objects.filter( Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY) author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy') self.check_pagination(p, '作者文章归档', 'liangliangyy')
# 按分类分页 # 测试按分类分页
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug) self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单 # 测试搜索表单的 search 方法(即使未实际执行搜索)
f = BlogSearchForm() f = BlogSearchForm()
f.search() f.search()
# 模拟百度通知 # 模拟百度站长平台 URL 通知
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()]) SpiderNotify.baidu_notify([article.get_full_url()])
# 测试模板标签gravatar头像URL # 调用模板标签 gravatar_url 和 gravatar验证其返回值
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com') u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com') u = gravatar('liangliangyy@gmail.com')
# 创建并保存一个友情链接 # 创建一个友情链接并访问其页面
link = Links( link = Links(
sequence=1, sequence=1,
name="lylinux", name="lylinux",
link='https://wwww.lylinux.net') link='https://wwww.lylinux.net')
link.save() link.save()
# 访问友情链接页面
response = self.client.get('/links.html') response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 访问RSS Feed页面 # 访问 RSS 订阅源
response = self.client.get('/feed/') response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 访问Sitemap页面 # 访问 Sitemap
response = self.client.get('/sitemap.xml') response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 尝试删除一篇文章 # 模拟访问一个不存在的 admin 删除页面或其他不存在的路由,预期返回 404 或其它
self.client.get("/admin/blog/article/1/delete/") self.client.get("/admin/blog/article/1/delete/")
# 访问一些不存在的admin页面
self.client.get('/admin/servermanager/emailsendlog/') self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/') self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/') self.client.get('/admin/admin/logentry/1/change/')
def check_pagination(self, p, type, value): def check_pagination(self, p, type, value):
# 遍历所有分页,检查前后页链接是否有效 """
自定义分页测试方法遍历每一页验证上一页/下一页链接均能正常访问
"""
for page in range(1, p.num_pages + 1): for page in range(1, p.num_pages + 1):
s = load_pagination_info(p.page(page), type, value) s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s) self.assertIsNotNone(s) # 确保返回分页信息不为空
if s['previous_url']: if s['previous_url']:
response = self.client.get(s['previous_url']) response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -191,41 +203,47 @@ class ArticleTest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_image(self): def test_image(self):
# 测试图片上传功能 """
测试图片上传功能及一些工具函数 SHA256邮件发送用户头像保存
"""
import requests import requests
# 下载Python官方Logo
rsp = requests.get( rsp = requests.get(
'https://www.python.org/static/img/python-logo.png') 'https://www.python.org/static/img/python-logo.png') # 下载 Python 官方 Logo
imagepath = os.path.join(settings.BASE_DIR, 'python.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png')
with open(imagepath, 'wb') as file: with open(imagepath, 'wb') as file:
file.write(rsp.content) file.write(rsp.content)
# 尝试未授权上传 # 尝试未授权上传,预期返回 403
rsp = self.client.post('/upload') rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403) self.assertEqual(rsp.status_code, 403)
# 生成签名
# 使用签名和文件上传图片(模拟授权上传)
sign = get_sha256(get_sha256(settings.SECRET_KEY)) sign = get_sha256(get_sha256(settings.SECRET_KEY))
with open(imagepath, 'rb') as file: with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile( imgfile = SimpleUploadedFile(
'python.png', file.read(), content_type='image/jpg') 'python.png', file.read(), content_type='image/jpg')
form_data = {'python.png': imgfile} form_data = {'python.png': imgfile}
# 带签名上传
rsp = self.client.post( rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True) '/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)
os.remove(imagepath) os.remove(imagepath)
# 测试发送邮件与保存头像功能
# 测试邮件发送与用户头像保存工具函数
from djangoblog.utils import save_user_avatar, send_email from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent') send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar( save_user_avatar(
'https://www.python.org/static/img/python-logo.png') 'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self): def test_errorpage(self):
# 测试访问不存在的页面应返回404 """
测试访问不存在的路由应该返回 404 页面
"""
rsp = self.client.get('/eee') rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404) self.assertEqual(rsp.status_code, 404)
def test_commands(self): def test_commands(self):
# 创建超级用户 """
测试一系列 Django 管理命令的执行如构建索引百度推送创建测试数据清理缓存同步头像等
"""
user = BlogUser.objects.get_or_create( user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com", email="liangliangyy@gmail.com",
username="liangliangyy")[0] username="liangliangyy")[0]
@ -234,7 +252,6 @@ class ArticleTest(TestCase):
user.is_superuser = True user.is_superuser = True
user.save() user.save()
# 创建OAuth配置与用户
c = OAuthConfig() c = OAuthConfig()
c.type = 'qq' c.type = 'qq'
c.appkey = 'appkey' c.appkey = 'appkey'
@ -262,13 +279,12 @@ class ArticleTest(TestCase):
}''' }'''
u.save() u.save()
# 如果启用了Elasticsearch构建索引
from blog.documents import ELASTICSEARCH_ENABLED from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
call_command("build_index") call_command("build_index") # 构建搜索索引
# 执行一系列管理命令如Ping百度、创建测试数据、清理缓存等
call_command("ping_baidu", "all") call_command("ping_baidu", "all") # 通知百度收录所有文章/分类/标签
call_command("create_testdata") call_command("create_testdata") # 创建测试数据
call_command("clear_cache") call_command("clear_cache") # 清理缓存
call_command("sync_user_avatar") call_command("sync_user_avatar") # 同步用户头像(如从 OAuth 获取)
call_command("build_search_words") call_command("build_search_words") # 构建搜索关键词(可能是标签/分类名等)

@ -1,102 +1,18 @@
from django.urls import path 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"
urlpatterns = [ urlpatterns = [
# 首页 path('', views.IndexView.as_view(), name='index'), # 首页
path( path('article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
r'', views.ArticleDetailView.as_view(), name='detailbyid'), # 文章详情
views.IndexView.as_view(), path('category/<slug:category_name>.html',
name='index'), views.CategoryDetailView.as_view(), name='category_detail'), # 分类页
# 首页分页 path('tag/<slug:tag_name>.html',
path( views.TagDetailView.as_view(), name='tag_detail'), # 标签页
r'page/<int:page>/', path('archives.html', cache_page(60 * 60)(views.ArchivesView.as_view()), name='archives'), # 归档页缓存1小时
views.IndexView.as_view(), path('links.html', views.LinkListView.as_view(), name='links'), # 友链页
name='index_page'), path('upload', views.fileupload, name='upload'), # 图床上传
# 文章详情页通过年、月、日、文章ID定位 path('clean', views.clean_cache_view, name='clean'), # 清缓存
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'),
# 分类目录详情页,通过分类别名
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# 分类目录详情页(带分页)
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# 作者文章详情页,通过作者名称
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# 作者文章详情页(带分页)
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# 标签详情页,通过标签别名
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# 标签详情页(带分页)
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
# 文章归档页使用缓存60分钟
path(
'archives.html',
cache_page(60 * 60)(views.ArchivesView.as_view()),
name='archives'),
# 友情链接页
path(
r'links.html',
views.LinkListView.as_view(),
name='links'),
# 文件上传接口(用于图床等功能)
path(
r'upload',
views.fileupload,
name='upload'),
# 清理缓存接口
path(
r'clean',
views.clean_cache_view,
name='clean'),
] ]

@ -3,298 +3,290 @@ import os
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import F from django.http import HttpResponse, HttpResponseForbidden
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.templatetags.static import static from django.templatetags.static import static
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt 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.detail import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from haystack.views import SearchView from haystack.views import SearchView # Haystack 全文搜索视图
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 comments.forms import CommentForm # 评论表单
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks # 插件管理系统
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 插件钩子常量
from djangoblog.utils import cache, get_blog_setting, get_sha256 from djangoblog.utils import cache, get_blog_setting, get_sha256 # 工具函数:缓存、站点配置、加密
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 日志记录器
# -------------------------------
# 基础:通用文章列表视图(支持缓存、分页)
# -------------------------------
class ArticleListView(ListView): class ArticleListView(ListView):
template_name = 'blog/article_index.html' template_name = 'blog/article_index.html' # 默认模板
context_object_name = 'article_list' context_object_name = 'article_list' # 模板中使用的上下文变量名
page_type = '' page_type = '' # 页面类型描述,子类可重写
paginate_by = settings.PAGINATE_BY paginate_by = settings.PAGINATE_BY # 每页文章数,从配置中读取
page_kwarg = 'page' page_kwarg = 'page' # URL 中页码参数名
link_type = LinkShowType.L link_type = LinkShowType.L # 友情链接展示类型,子类可重写
def get_view_cache_key(self): def get_view_cache_key(self):
return self.request.GET.get('pages', '') # 获取当前视图的缓存键(注意:原代码有误,应使用 self.request.GET 而非 self.request.get
return self.request.GET.get('pages', '') # 临时占位,实际应由子类实现
@property @property
def page_number(self): def page_number(self):
# 获取当前页码,默认为 1
page_kwarg = self.page_kwarg page_kwarg = self.page_kwarg
page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 return self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
# 子类必须重写:返回当前页面数据对应的缓存键
raise NotImplementedError() raise NotImplementedError()
def get_queryset_data(self): def get_queryset_data(self):
# 子类必须重写:返回当前页面要展示的数据(通常是 QuerySet
raise NotImplementedError() raise NotImplementedError()
def get_queryset_from_cache(self, cache_key): def get_queryset_from_cache(self, cache_key):
# 尝试从缓存中获取数据,若无则查询并缓存
value = cache.get(cache_key) value = cache.get(cache_key)
if value: if value:
logger.info('get view cache.key:{key}'.format(key=cache_key)) logger.info(f'get view cache. key:{cache_key}')
return value return value
else: else:
article_list = self.get_queryset_data() article_list = self.get_queryset_data()
cache.set(cache_key, article_list) cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key)) logger.info(f'set view cache. key:{cache_key}')
return article_list return article_list
def get_queryset(self): def get_queryset(self):
# 重写默认的查询集,优先从缓存中读取
key = self.get_queryset_cache_key() key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key) value = self.get_queryset_from_cache(key)
return value return value
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# 给模板上下文添加 linktype用于控制友情链接展示类型
kwargs['linktype'] = self.link_type kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 首页视图:展示所有已发布文章
# -------------------------------
class IndexView(ArticleListView): class IndexView(ArticleListView):
link_type = LinkShowType.I link_type = LinkShowType.I # 首页链接类型为 ‘首页展示’
def get_queryset_data(self): def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p') # 只获取类型为 'a'(文章),状态为 'p'(已发布)的文章
return article_list return Article.objects.filter(type='a', status='p')
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number) # 缓存键包含页码,如 index_1, index_2...
return cache_key return f'index_{self.page_number}'
# -------------------------------
# 文章详情页
# -------------------------------
class ArticleDetailView(DetailView): class ArticleDetailView(DetailView):
template_name = 'blog/article_detail.html' template_name = 'blog/article_detail.html'
model = Article model = Article
pk_url_kwarg = 'article_id' pk_url_kwarg = 'article_id' # URL 中的文章 ID 参数名
context_object_name = "article" context_object_name = "article"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# 添加评论表单
comment_form = CommentForm() comment_form = CommentForm()
# 获取当前文章的所有评论,并筛选出顶级评论(无父评论)
article_comments = self.object.comment_list() article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None) parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count) blog_setting = get_blog_setting() # 获取博客配置
paginator = Paginator(parent_comments, blog_setting.article_comment_count) # 评论分页
page = self.request.GET.get('comment_page', '1') page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1 try:
else:
page = int(page) page = int(page)
if page < 1: if page < 1:
page = 1 page = 1
if page > paginator.num_pages: if page > paginator.num_pages:
page = paginator.num_pages page = paginator.num_pages
except:
page = 1
p_comments = paginator.page(page) p_comments = paginator.page(page) # 当前页的评论
next_page = p_comments.next_page_number() if p_comments.has_next() else None 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 prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 若有下一页/上一页,在上下文中添加对应 URL带锚点定位到评论区
if next_page: if next_page:
kwargs[ kwargs['comment_next_page_url'] = f"{self.object.get_absolute_url()}?comment_page={next_page}#commentlist-container"
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page: if prev_page:
kwargs[ kwargs['comment_prev_page_url'] = f"{self.object.get_absolute_url()}?comment_page={prev_page}#commentlist-container"
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_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['next_article'] = self.object.next_article # 下一篇文章
kwargs['prev_article'] = self.object.prev_article kwargs['prev_article'] = self.object.prev_article # 上一篇文章
# ========== 修复点赞相关信息 ========== context = super().get_context_data(**kwargs)
user = self.request.user
if user.is_authenticated: # 调用插件钩子:文章内容获取后通知
# 使用正确的方法名 hooks.run_action('after_article_body_get', article=self.object, request=self.request)
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
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context return context
# -------------------------------
# 分类页视图
# -------------------------------
class CategoryDetailView(ArticleListView): class CategoryDetailView(ArticleListView):
page_type = "分类目录归档" page_type = "分类目录归档"
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['category_name'] slug = self.kwargs['category_name'] # 从 URL 获取分类别名
category = get_object_or_404(Category, slug=slug) category = get_object_or_404(Category, slug=slug)
categorynames = [c.name for c in category.get_sub_categorys()] # 获取所有子分类名称
categoryname = category.name # 获取这些分类下的所有已发布文章
self.categoryname = categoryname return Article.objects.filter(category__name__in=categorynames, status='p')
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): def get_queryset_cache_key(self):
slug = self.kwargs['category_name'] slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug) category = get_object_or_404(Category, slug=slug)
categoryname = category.name cache_key = f'category_list_{category.name}_{self.page_number}'
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
categoryname = self.categoryname categoryname = self.kwargs['category_name']
try: try:
categoryname = categoryname.split('/')[-1] categoryname = categoryname.split('/')[-1] # 尝试提取最后一段(美化展示用)
except BaseException: except:
pass pass
kwargs['page_type'] = CategoryDetailView.page_type kwargs['page_type'] = self.page_type
kwargs['tag_name'] = categoryname kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 作者页视图
# -------------------------------
class AuthorDetailView(ArticleListView): class AuthorDetailView(ArticleListView):
page_type = '作者文章归档' page_type = '作者文章归档'
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
from uuslug import slugify from uuslug import slugify
author_name = slugify(self.kwargs['author_name']) author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format( return f'author_{author_name}_{self.page_number}'
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self): def get_queryset_data(self):
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
article_list = Article.objects.filter( return Article.objects.filter(author__username=author_name, type='a', status='p')
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type kwargs['page_type'] = self.page_type
kwargs['tag_name'] = author_name kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 标签页视图
# -------------------------------
class TagDetailView(ArticleListView): class TagDetailView(ArticleListView):
page_type = '分类标签归档' page_type = '分类标签归档'
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['tag_name'] slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name return Article.objects.filter(tags__name=tag.name, type='a', status='p')
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): def get_queryset_cache_key(self):
slug = self.kwargs['tag_name'] slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name return f'tag_{tag.name}_{self.page_number}'
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
tag_name = self.name tag_name = self.kwargs['tag_name']
kwargs['page_type'] = TagDetailView.page_type kwargs['page_type'] = self.page_type
kwargs['tag_name'] = tag_name kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 归档页视图:展示所有已发布文章
# -------------------------------
class ArchivesView(ArticleListView): class ArchivesView(ArticleListView):
page_type = '文章归档' page_type = '文章归档'
paginate_by = None paginate_by = None # 不分页
page_kwarg = None page_kwarg = None
template_name = 'blog/article_archives.html' template_name = 'blog/article_archives.html'
def get_queryset_data(self): def get_queryset_data(self):
return Article.objects.filter(status='p').all() return Article.objects.filter(status='p')
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
cache_key = 'archives' return 'archives'
return cache_key
# -------------------------------
# 友情链接页
# -------------------------------
class LinkListView(ListView): class LinkListView(ListView):
model = Links model = Links
template_name = 'blog/links_list.html' template_name = 'blog/links_list.html'
def get_queryset(self): def get_queryset(self):
return Links.objects.filter(is_enable=True) return Links.objects.filter(is_enable=True) # 只展示启用的链接
# -------------------------------
# Haystack 搜索视图
# -------------------------------
class EsSearchView(SearchView): class EsSearchView(SearchView):
def get_context(self): def get_context(self):
paginator, page = self.build_page() paginator, page = self.build_page()
context = { context = {
"query": self.query, "query": self.query, # 搜索关键词
"form": self.form, "form": self.form, # 搜索表单
"page": page, "page": page, # 当前页
"paginator": paginator, "paginator": paginator, # 分页器
"suggestion": None, "suggestion": None, # 搜索建议,可后续补充
} }
# 如果后端支持拼写建议,则添加
if hasattr(self.results, "query") and self.results.query.backend.include_spelling: if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion() context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context()) context.update(self.extra_context()) # 添加额外上下文
return context return context
# -------------------------------
# 图床上传接口(带签名校验,仅限 POST
# -------------------------------
@csrf_exempt @csrf_exempt
def fileupload(request): def fileupload(request):
if request.method == 'POST': if request.method == 'POST':
sign = request.GET.get('sign', None) sign = request.GET.get('sign', None)
if not sign: if not sign:
return HttpResponseForbidden() return HttpResponseForbidden()
# 校验签名(双重 SHA256与 settings.SECRET_KEY 相关)
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden() return HttpResponseForbidden()
response = [] response = []
for filename in request.FILES: for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d') timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename)) fname = ''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 isimage = any(ext in fname.lower() for ext in imgextensions)
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir): if not os.path.exists(base_dir):
os.makedirs(base_dir) os.makedirs(base_dir)
@ -308,245 +300,45 @@ def fileupload(request):
from PIL import Image from PIL import Image
image = Image.open(savepath) image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True) image.save(savepath, quality=20, optimize=True)
url = static(savepath) url = static(savepath) # 生成静态文件访问 URL
response.append(url) response.append(url)
return HttpResponse(response) return HttpResponse(response)
else: else:
return HttpResponse("only for post") return HttpResponse("only for post")
def page_not_found_view( # -------------------------------
request, # 错误页面视图
exception, # -------------------------------
template_name='blog/error_page.html'): def page_not_found_view(request, exception, template_name='blog/error_page.html'):
if exception: if exception:
logger.error(exception) logger.error(exception)
url = request.get_full_path() url = request.get_full_path()
return render(request, return render(request, template_name, {
template_name, 'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'), 'statuscode': '404'
'statuscode': '404'}, }, status=404)
status=404)
def server_error_view(request, template_name='blog/error_page.html'): def server_error_view(request, template_name='blog/error_page.html'):
return render(request, return render(request, template_name, {
template_name, 'message': _('Sorry, the server is busy, please click the home page to see other?'),
{'message': _('Sorry, the server is busy, please click the home page to see other?'), 'statuscode': '500'
'statuscode': '500'}, }, status=500)
status=500)
def permission_denied_view( def permission_denied_view(request, exception, template_name='blog/error_page.html'):
request,
exception,
template_name='blog/error_page.html'):
if exception: if exception:
logger.error(exception) logger.error(exception)
return render( return render(request, template_name, {
request, template_name, { 'message': _('Sorry, you do not have permission to access this page?'),
'message': _('Sorry, you do not have permission to access this page?'), 'statuscode': '403'
'statuscode': '403'}, status=403) }, status=403)
# -------------------------------
# 手动清理缓存视图(通常用于后台或调试)
# -------------------------------
def clean_cache_view(request): def clean_cache_view(request):
cache.clear() cache.clear()
return HttpResponse('ok') return HttpResponse('ok')
# 文章点赞API视图
@login_required
def article_like(request, article_id):
"""
文章点赞/取消点赞 API
用户可以通过该接口对文章进行点赞或取消点赞
如果用户已点赞再次点赞将取消点赞如果未点赞则添加点赞
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取点赞记录
like = ArticleLike.objects.filter(article=article, user=user).first()
if like:
# 如果已点赞,则取消点赞
like.delete()
# 减少点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': False,
'like_count': article.like_count,
'message': _('取消点赞成功')
})
else:
# 如果未点赞,则添加点赞
ArticleLike.objects.create(article=article, user=user)
# 增加点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': True,
'like_count': article.like_count,
'message': _('点赞成功')
})
except Exception as e:
logger.error(f'点赞失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('点赞失败,请稍后再试')
}, status=500)
# 文章收藏API视图
@login_required
def article_favorite(request, article_id):
"""
文章收藏/取消收藏 API
用户可以通过该接口对文章进行收藏或取消收藏
如果用户已收藏再次收藏将取消收藏如果未收藏则添加收藏
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取收藏记录
favorite = ArticleFavorite.objects.filter(article=article, user=user).first()
if favorite:
# 如果已收藏,则取消收藏
favorite.delete()
# 减少收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': False,
'favorite_count': article.favorite_count,
'message': _('取消收藏成功')
})
else:
# 如果未收藏,则添加收藏
ArticleFavorite.objects.create(article=article, user=user)
# 增加收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': True,
'favorite_count': article.favorite_count,
'message': _('收藏成功')
})
except Exception as e:
logger.error(f'收藏失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('收藏失败,请稍后再试')
}, status=500)
# 我的喜欢列表视图
class MyLikesView(ArticleListView):
"""显示当前用户点赞的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的喜欢'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户点赞的所有文章ID
liked_article_ids = ArticleLike.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=liked_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为点赞列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyLikesView, self).get_context_data(**kwargs)
# 我的收藏列表视图
class MyFavoritesView(ArticleListView):
"""显示当前用户收藏的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的收藏'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户收藏的所有文章ID
favorited_article_ids = ArticleFavorite.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=favorited_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为收藏列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyFavoritesView, self).get_context_data(**kwargs)

File diff suppressed because it is too large Load Diff

@ -1,79 +1,58 @@
# 模块级注释Django管理后台配置模块 - 评论管理
# 本模块定义了评论模型在Django管理后台的显示配置和操作功能
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 用于支持多语言,这里是“获取翻译文本”
# 函数级注释:禁用评论状态操作 # 定义批量操作:禁用选中评论的显示状态
# 管理员动作函数,用于批量禁用选中的评论
def disable_commentstatus(modeladmin, request, queryset): def disable_commentstatus(modeladmin, request, queryset):
# 核心代码将查询集中所有评论的is_enable字段更新为False queryset.update(is_enable=False) # 将选中的评论设置为不可见
queryset.update(is_enable=False) disable_commentstatus.short_description = _('Disable comments') # 操作按钮显示名称:禁用评论
# 定义批量操作:启用选中评论的显示状态
# 函数级注释:启用评论状态操作
# 管理员动作函数,用于批量启用选中的评论
def enable_commentstatus(modeladmin, request, queryset): def enable_commentstatus(modeladmin, request, queryset):
# 核心代码将查询集中所有评论的is_enable字段更新为True queryset.update(is_enable=True) # 将选中的评论设置为可见
queryset.update(is_enable=True) enable_commentstatus.short_description = _('Enable comments') # 操作按钮显示名称:启用评论
# 设置动作函数的显示名称(国际化)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# 类级注释:评论管理类 # 自定义评论管理后台展示类
# 继承自admin.ModelAdmin自定义评论模型在Django管理后台的显示和行为
class CommentAdmin(admin.ModelAdmin): class CommentAdmin(admin.ModelAdmin):
# 每页显示记录数配置 list_per_page = 20 # 每页显示20条评论
list_per_page = 20 # 后台列表页显示的字段
# 列表页显示的字段配置
list_display = ( list_display = (
'id', 'id', # 评论ID
'body', 'body', # 评论内容
'link_to_userinfo', # 自定义方法显示用户链接 'link_to_userinfo', # 自定义方法:显示用户链接
'link_to_article', # 自定义方法显示文章链接 'link_to_article', # 自定义方法:显示文章链接
'is_enable', 'is_enable', # 是否启用(显示)
'creation_time') 'creation_time' # 创建时间
# 可点击进入编辑页的字段 )
# 哪些字段可点击进入编辑页
list_display_links = ('id', 'body', 'is_enable') list_display_links = ('id', 'body', 'is_enable')
# 右侧过滤器配置 # 添加右侧过滤器,可按是否启用筛选
list_filter = ('is_enable',) list_filter = ('is_enable',)
# 编辑页排除的字段(不显示 # 在后台编辑表单中排除这两个字段(一般由系统自动填写,不需手动改
exclude = ('creation_time', 'last_modify_time') exclude = ('creation_time', 'last_modify_time')
# 批量操作动作列表 # 后台支持批量操作,下拉可选择启用/禁用
actions = [disable_commentstatus, enable_commentstatus] actions = [disable_commentstatus, enable_commentstatus]
# 使用原始ID输入框的外键字段提升大表性能 # 在后台编辑评论时,作者和文章字段以 ID 选择框(而不是下拉查询)方式展示,提高效率
raw_id_fields = ('author', 'article') raw_id_fields = ('author', 'article')
# 搜索字段配置 # 支持按评论内容搜索
search_fields = ('body',) search_fields = ('body',)
# 方法级注释:用户信息链接显示 # 自定义方法:生成指向用户信息编辑页的链接
# 自定义方法,在列表页显示带链接的用户信息
def link_to_userinfo(self, obj): def link_to_userinfo(self, obj):
# 核心代码获取用户模型的app_label和model_name info = (obj.author._meta.app_label, obj.author._meta.model_name) # 获取用户模型的 app 和 model 名称
info = (obj.author._meta.app_label, obj.author._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) # 生成用户编辑页面的URL
# 核心代码生成用户编辑页面的URL # 显示用户昵称,如果没有则显示邮箱
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) return format_html(u'<a href="%s">%s</a>' % (link, obj.author.nickname if obj.author.nickname else obj.author.email))
# 核心代码返回带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): def link_to_article(self, obj):
# 核心代码获取文章模型的app_label和model_name info = (obj.article._meta.app_label, obj.article._meta.model_name) # 获取文章模型的 app 和 model 名称
info = (obj.article._meta.app_label, obj.article._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) # 生成文章编辑页面的URL
# 核心代码生成文章编辑页面的URL return format_html(u'<a href="%s">%s</a>' % (link, obj.article.title)) # 显示文章标题并链接到编辑页
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# 核心代码返回带HTML链接的格式化字符串
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# 设置自定义方法在列表页的显示名称(国际化) # 为自定义方法添加列标题
link_to_userinfo.short_description = _('User') link_to_userinfo.short_description = _('User') # 列标题:用户
link_to_article.short_description = _('Article') link_to_article.short_description = _('Article') # 列标题:文章

@ -1,11 +1,6 @@
# 模块级注释Django应用配置模块
# 本模块定义了comments应用的配置信息用于Django应用注册和初始化设置
from django.apps import AppConfig from django.apps import AppConfig
# 类级注释:评论应用配置类 # 定义评论 App 的配置类
# 继承自AppConfig用于配置comments应用的基本信息和启动行为
class CommentsConfig(AppConfig): class CommentsConfig(AppConfig):
# 应用名称字段定义应用的完整Python路径 name = 'comments' # 应用的 Python 路径名,通常是 app 文件夹名
# 此名称用于Django内部识别和应用引用
name = 'comments'

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

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

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

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

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

@ -1,30 +1,77 @@
from django import template from django import template # 导入 Django 模板系统核心模块
register = template.Library() register = template.Library() # 创建模板标签注册器实例
# =============================================================================
# 1. 递归获取评论子评论simple_tag
# =============================================================================
@register.simple_tag @register.simple_tag
def parse_commenttree(commentlist, comment): def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
""" """
datas = [] 功能递归查找并返回某个评论的所有子评论支持无限层级嵌套
适用场景在模板中获取某条评论下的所有回复如评论区的楼层回复
def parse(c): 参数说明
childs = commentlist.filter(parent_comment=c, is_enable=True) - commentlist: 评论查询集通常是 Article.comments.all() 或类似 QuerySet
for child in childs: - comment: 当前评论对象要查找其子评论的父评论
datas.append(child)
parse(child)
parse(comment) 返回值包含所有子评论的列表按递归顺序排列
return datas
模板用法示例
{% parse_commenttree article_comments comment as child_comments %}
{% for child in child_comments %}
{{ child.body }} {# 显示子评论内容 #}
{% endfor %}
"""
child_comments = [] # 初始化存储子评论的空列表
def recursive_parse(current_comment):
"""
内部递归函数深度优先遍历查找子评论
逻辑查找当前评论的所有直接子评论并对每个子评论继续递归查找
"""
# 查询条件parent_comment=当前评论 且 is_enable=True只显示启用状态的评论
direct_children = commentlist.filter(
parent_comment=current_comment,
is_enable=True
)
for child in direct_children:
child_comments.append(child) # 将子评论加入结果列表
recursive_parse(child) # 递归查找该子评论的子评论(深度优先)
recursive_parse(comment) # 从传入的评论开始递归查找
return child_comments # 返回完整的子评论列表
# =============================================================================
# 2. 渲染单个评论项inclusion_tag
# =============================================================================
@register.inclusion_tag('comments/tags/comment_item.html') @register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild): def show_comment_item(comment, is_child_comment):
"""评论""" """
depth = 1 if ischild else 2 功能渲染单个评论项并控制其显示层级用于区分顶级评论和回复评论
适用场景在评论列表中差异化显示不同层级的评论如缩进回复评论
参数说明
- comment: 要渲染的评论对象
- is_child_comment: 布尔值True表示这是回复评论子评论False表示顶级评论
返回值包含评论对象和层级信息的字典
模板文件comments/tags/comment_item.html需自行创建
模板用法示例
{# 渲染顶级评论(主评论)#}
{% show_comment_item main_comment False %}
{# 渲染回复评论(子评论)#}
{% show_comment_item reply_comment True %}
"""
# 设置显示层级:子评论=1缩进更多顶级评论=2正常显示
display_level = 1 if is_child_comment else 2
return { return {
'comment_item': comment, 'comment_item': comment, # 传递评论对象给模板
'depth': depth 'depth': display_level # 传递层级信息(控制样式)
} }

@ -1,145 +1,115 @@
# 模块级注释Django测试模块 - 评论系统功能测试 from django.test import Client, RequestFactory, TransactionTestCase # Django 测试客户端和事务测试类
# 本模块包含评论系统的完整测试用例,验证评论发布、回复、显示等核心功能 from django.urls import reverse # 用于生成 URL
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
# 导入相关模型,用于测试数据准备 from accounts.models import BlogUser # 用户模型
from accounts.models import BlogUser from blog.models import Category, Article # 文章和分类模型
from blog.models import Category, Article from comments.models import Comment # 评论模型
from comments.models import Comment from comments.templatetags.comments_tags import * # 评论相关的模板标签(假设存在)
# 导入评论标签模块,测试模板标签功能 from djangoblog.utils import get_max_articleid_commentid # 获取最大文章ID和评论ID的工具函数假设存在
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
# 类级注释:评论系统测试类 # 评论功能集成测试类(使用事务,支持数据库回滚)
# 继承自TransactionTestCase支持数据库事务的测试用例
class CommentsTest(TransactionTestCase): class CommentsTest(TransactionTestCase):
# 测试初始化方法:在每个测试方法执行前运行
def setUp(self): def setUp(self):
# 创建测试客户端用于模拟HTTP请求 self.client = Client() # Django 提供的 HTTP 客户端,模拟浏览器请求
self.client = Client() self.factory = RequestFactory() # 用于创建请求对象
# 创建请求工厂,用于构建请求对象
self.factory = RequestFactory()
# 配置博客设置:启用评论审核功能
from blog.models import BlogSettings from blog.models import BlogSettings
value = BlogSettings() value = BlogSettings()
value.comment_need_review = True # 设置评论需要审核 value.comment_need_review = True # 设置评论需要审核
value.save() value.save()
# 创建超级用户,用于测试认证相关的评论功能 # 创建一个超级用户,用于登录和测试
self.user = BlogUser.objects.create_superuser( self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com", email="liangliangyy1@gmail.com",
username="liangliangyy1", username="liangliangyy1",
password="liangliangyy1") password="liangliangyy1")
# 辅助方法:更新文章评论状态为启用 # 更新某篇文章的所有评论为已启用状态(用于测试评论显示)
def update_article_comment_status(self, article): def update_article_comment_status(self, article):
# 获取文章的所有评论
comments = article.comment_set.all() comments = article.comment_set.all()
# 遍历所有评论,将其状态设置为启用
for comment in comments: for comment in comments:
comment.is_enable = True comment.is_enable = True
comment.save() comment.save()
# 测试方法:验证评论功能 # 测试评论提交与验证逻辑
def test_validate_comment(self): def test_validate_comment(self):
# 使用测试用户登录系统 self.client.login(username='liangliangyy1', password='liangliangyy1') # 登录测试用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建测试分类 # 创建一个分类
category = Category() category = Category()
category.name = "categoryccc" category.name = "categoryccc"
category.save() category.save()
# 创建测试文章 # 创建一篇文章
article = Article() article = Article()
article.title = "nicetitleccc" article.title = "nicetitleccc"
article.body = "nicecontentccc" article.body = "nicecontentccc"
article.author = self.user article.author = self.user
article.category = category article.category = category
article.type = 'a' # 文章类型 article.type = 'a'
article.status = 'p' # 发布状态 article.status = 'p' # 假设 'p' 是已发布状态
article.save() article.save()
# 生成评论提交URL # 构造评论提交的 URL
comment_url = reverse( comment_url = reverse(
'comments:postcomment', kwargs={ 'comments:postcomment', kwargs={
'article_id': article.id}) 'article_id': article.id})
# 测试提交一条评论 # 测试提交一条评论
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': '123ffffffffff' 'body': '123ffffffffff'
}) })
# 验证响应状态码为302重定向 self.assertEqual(response.status_code, 302) # 应该重定向302
self.assertEqual(response.status_code, 302)
# 重新获取文章对象验证评论数量由于审核机制初始应为0
article = Article.objects.get(pk=article.pk) article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0) self.assertEqual(len(article.comment_list()), 0) # 因为需要审核,所以评论不显示
# 更新评论状态为启用后验证评论数量变为1 # 手动将评论设为启用,再次检查
self.update_article_comment_status(article) self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1) self.assertEqual(len(article.comment_list()), 1) # 现在应该能看到评论了
# 测试提交第二条评论 # 再提交一条评论
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': '123ffffffffff', 'body': '123ffffffffff',
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# 验证第二条评论提交成功
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article) self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2) self.assertEqual(len(article.comment_list()), 2)
# 获取第一条评论的ID用于测试回复功能 # 提交一条带格式的回复评论并指定父评论ID
parent_comment_id = article.comment_list()[0].id parent_comment_id = article.comment_list()[0].id
# 测试提交带Markdown格式的回复评论
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': ''' 'body': '''
# Title1 # Title1[url](https://www.lylinux.net/)
#
```python # [ddd](http://www.baidu.com)
import os #
``` #
# ''',
[url](https://www.lylinux.net/) # 'parent_comment_id': parent_comment_id
# })
[ddd](http://www.baidu.com) # self.assertEqual(response.status_code, 302)
# self.update_article_comment_status(article)
# article = Article.objects.get(pk=article.pk)
''', # self.assertEqual(len(article.comment_list()), 3)
'parent_comment_id': parent_comment_id #
}) # # 获取父评论并解析评论树结构(假设存在此方法)
# comment = Comment.objects.get(id=parent_comment_id)
# 验证回复评论提交成功 # tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(response.status_code, 302) # self.assertEqual(len(tree), 1) # 评论树节点数
self.update_article_comment_status(article) #
article = Article.objects.get(pk=article.pk) # # 渲染单个评论项(假设存在此方法)
self.assertEqual(len(article.comment_list()), 3) # data = show_comment_item(comment, True)
# self.assertIsNotNone(data)
# 测试评论树解析功能 #
comment = Comment.objects.get(id=parent_comment_id) # # 获取最大文章ID和评论ID假设存在此工具函数
tree = parse_commenttree(article.comment_list(), comment) # s = get_max_articleid_commentid()
self.assertEqual(len(tree), 1) # self.assertIsNotNone(s)
#
# 测试评论项显示功能 # # 发送评论通知邮件(假设存在此工具函数)
data = show_comment_item(comment, True) # from comments.utils import send_comment_email
self.assertIsNotNone(data) # send_comment_email(comment)
# 测试获取最大文章ID和评论ID功能
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
# 测试评论邮件发送功能
from comments.utils import send_comment_email
send_comment_email(comment)

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

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

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

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

@ -1,117 +1,69 @@
""" # 从 Django 的 admin 模块导入 AdminSite 基类,用于创建自定义的后台管理站点
DjangoBlog 后台管理站点配置模块
本模块定义了自定义的Django后台管理站点用于统一管理博客系统的所有数据模型
通过自定义AdminSite类实现了权限控制界面定制和模型注册的集中管理
主要功能
- 自定义后台管理站点外观和权限
- 集中注册所有应用的模型到统一后台
- 提供超级用户专属的管理界面
- 集成日志记录用户管理内容管理等功能
"""
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
# 从 Django 的 contrib.sites 中导入 Site 模型及其管理类,用于管理站点信息(如域名)
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
# 导入各应用的Admin配置类和模型类 # 导入各个 app应用程序下的 admin 管理类和 models 模型类
from accounts.admin import * from accounts.admin import * # 用户账户相关后台管理
from blog.admin import * from blog.admin import * # 博客文章相关后台管理
from blog.models import * from blog.models import * # 博客文章相关数据模型
from comments.admin import * from comments.admin import * # 评论相关后台管理
from comments.models import * from comments.models import * # 评论相关数据模型
from djangoblog.logentryadmin import LogEntryAdmin from djangoblog.logentryadmin import LogEntryAdmin # 操作日志LogEntry的自定义管理类
from oauth.admin import * from oauth.admin import * # 第三方登录OAuth相关后台管理
from oauth.models import * from oauth.models import * # 第三方登录相关数据模型
from owntracks.admin import * from owntracks.admin import * # 自定义轨迹记录相关后台管理
from owntracks.models import * from owntracks.models import * # 自定义轨迹记录相关数据模型
from servermanager.admin import * from servermanager.admin import * # 服务器管理相关后台管理
from servermanager.models import * from servermanager.models import * # 服务器管理相关数据模型
# 定义一个自定义的 DjangoBlog 后台管理站点类,继承自 Django 提供的 AdminSite
class DjangoBlogAdminSite(AdminSite): class DjangoBlogAdminSite(AdminSite):
""" site_header = 'djangoblog administration' # 后台管理页面顶部的标题,显示为 "djangoblog administration"
自定义DjangoBlog后台管理站点 site_title = 'djangoblog site admin' # 浏览器标签页显示的标题,显示为 "djangoblog site admin"
继承自Django原生的AdminSite类提供博客系统的定制化后台管理界面
包含站点标题设置权限控制和可选的URL扩展功能
"""
# 设置后台管理站点的头部标题
site_header = 'djangoblog administration'
# 设置浏览器标签页标题
site_title = 'djangoblog site admin'
def __init__(self, name='admin'): def __init__(self, name='admin'):
""" # 调用父类AdminSite的初始化方法
初始化后台管理站点
Args:
name (str): 管理站点的名称默认为'admin'
"""
# 调用父类初始化方法
super().__init__(name) super().__init__(name)
# 重写权限验证方法只有超级用户is_superuser=True才能访问后台
def has_permission(self, request): def has_permission(self, request):
"""
权限验证方法
重写权限检查逻辑只允许超级用户访问后台管理界面
Args:
request: HTTP请求对象
Returns:
bool: 如果是超级用户返回True否则返回False
"""
return request.user.is_superuser return request.user.is_superuser
# 注释掉的URL扩展方法 - 预留用于添加自定义管理视图 # 创建一个全局的 admin_site 实例,使用我们自定义的 DjangoBlogAdminSite 类
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
# from blog.views import refresh_memcache
#
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# return urls + my_urls
# 创建自定义后台管理站点的实例
admin_site = DjangoBlogAdminSite(name='admin') admin_site = DjangoBlogAdminSite(name='admin')
# 注册博客相关模型到后台管理 # 将各个模型(如文章、分类、标签、评论等)与其对应的管理类注册到 admin_site 中,
admin_site.register(Article, ArticleAdmin) # 文章模型 # 这样它们就可以在 Django 的后台管理系统中被管理和展示
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(Article, ArticlelAdmin) # 文章模型与它的管理类
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型 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(BlogUser, BlogUserAdmin) # 博客用户模型 admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
# 注册评论管理模型 # 注册用户相关模型
admin_site.register(Comment, CommentAdmin) # 评论模型 admin_site.register(BlogUser, BlogUserAdmin)
# 注册OAuth认证相关模型 # 注册评论与第三方登录相关模型
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 admin_site.register(Comment, CommentAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
# 注册位置追踪相关模型 # 注册轨迹记录模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置追踪日志模型 admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# 注册Django内置站点模型 # 注册 Django 默认的 Site 模型,用于管理站点信息(如域名等)
admin_site.register(Site, SiteAdmin) # 站点模型 admin_site.register(Site, SiteAdmin)
# 注册日志记录模型 # 注册 Django 的 LogEntry 模型(记录后台操作日志),并使用自定义的 LogEntryAdmin 管理类
admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 admin_site.register(LogEntry, LogEntryAdmin)

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

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

@ -1,402 +1,122 @@
""" import _thread
Elasticsearch 搜索引擎集成模块 import logging
import django.dispatch
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.mail import EmailMultiAlternatives
from django.db.models.signals import post_save
from django.dispatch import receiver
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
本模块提供了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
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__) logger = logging.getLogger(__name__)
oauth_user_login_signal = django.dispatch.Signal(['id'])
class ElasticSearchBackend(BaseSearchBackend): send_email_signal = django.dispatch.Signal(
""" ['emailto', 'title', 'content'])
Elasticsearch 搜索后端实现
继承自Haystack的BaseSearchBackend提供与Elasticsearch的交互功能 @receiver(send_email_signal)
负责索引创建文档更新搜索执行和推荐词生成等核心操作 def send_email_signal_handler(sender, **kwargs):
""" emailto = kwargs['emailto']
title = kwargs['title']
def __init__(self, connection_alias, **connection_options): content = kwargs['content']
"""
初始化Elasticsearch后端 msg = EmailMultiAlternatives(
title,
Args: content,
connection_alias: 连接别名 from_email=settings.DEFAULT_FROM_EMAIL,
**connection_options: 连接配置选项 to=emailto)
""" msg.content_subtype = "html"
super(
ElasticSearchBackend, from servermanager.models import EmailSendLog
self).__init__( log = EmailSendLog()
connection_alias, log.title = title
**connection_options) log.content = content
# 初始化文章文档管理器 log.emailto = ','.join(emailto)
self.manager = ArticleDocumentManager()
# 启用拼写建议功能 try:
self.include_spelling = True result = msg.send()
log.send_result = result > 0
def _get_models(self, iterable): except Exception as e:
""" logger.error(f"失败邮箱号: {emailto}, {e}")
获取模型并转换为文档 log.send_result = False
log.save()
Args:
iterable: 模型实例集合
@receiver(oauth_user_login_signal)
Returns: def oauth_user_login_signal_handler(sender, **kwargs):
list: 转换后的文档对象列表 id = kwargs['id']
""" oauthuser = OAuthUser.objects.get(id=id)
# 如果提供了模型集合则使用,否则获取所有文章 site = get_current_site().domain
models = iterable if iterable and iterable[0] else Article.objects.all() if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
# 将Django模型转换为Elasticsearch文档 from djangoblog.utils import save_user_avatar
docs = self.manager.convert_to_doc(models) oauthuser.picture = save_user_avatar(oauthuser.picture)
return docs oauthuser.save()
def _create(self, models): delete_sidebar_cache()
"""
创建索引并添加文档
@receiver(post_save)
Args: def model_post_save_callback(
models: 要创建索引的模型集合 sender,
""" instance,
# 创建Elasticsearch索引 created,
self.manager.create_index() raw,
# 获取并转换模型为文档 using,
docs = self._get_models(models) update_fields,
# 重建索引(添加所有文档) **kwargs):
self.manager.rebuild(docs) clearcache = False
if isinstance(instance, LogEntry):
def _delete(self, models): return
""" if 'get_full_url' in dir(instance):
删除文档 is_update_views = update_fields == {'views'}
if not settings.TESTING and not is_update_views:
Args: try:
models: 要删除的模型集合 notify_url = instance.get_full_url()
SpiderNotify.baidu_notify([notify_url])
Returns: except Exception as ex:
bool: 删除操作结果 logger.error("notify sipder", ex)
""" if not is_update_views:
# 遍历并删除每个模型对应的文档 clearcache = True
for m in models:
m.delete() if isinstance(instance, Comment):
return True if instance.is_enable:
path = instance.article.get_absolute_url()
def _rebuild(self, models): site = get_current_site().domain
""" if site.find(':') > 0:
重建索引 site = site[0:site.find(':')]
Args: expire_view_cache(
models: 要重建索引的模型集合 path,
""" servername=site,
# 获取所有文章或指定模型集合 serverport=80,
models = models if models else Article.objects.all() key_prefix='blogdetail')
# 转换模型为文档 if cache.get('seo_processor'):
docs = self.manager.convert_to_doc(models) cache.delete('seo_processor')
# 更新文档到索引 comment_cache_key = 'article_comments_{id}'.format(
self.manager.update_docs(docs) id=instance.article.id)
cache.delete(comment_cache_key)
def update(self, index, iterable, commit=True): delete_sidebar_cache()
""" delete_view_cache('article_comments', [str(instance.article.pk)])
更新索引文档
_thread.start_new_thread(send_comment_email, (instance,))
Args:
index: 索引名称 if clearcache:
iterable: 要更新的模型集合 cache.clear()
commit: 是否立即提交Elasticsearch自动提交此参数保留
"""
# 获取模型并转换为文档 @receiver(user_logged_in)
models = self._get_models(iterable) @receiver(user_logged_out)
# 更新文档到索引 def user_auth_callback(sender, request, user, **kwargs):
self.manager.update_docs(models) if user and user.username:
logger.info(user)
def remove(self, obj_or_string): delete_sidebar_cache()
""" # cache.clear()
移除单个文档
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'
additional_fields = {}
result_class = SearchResult
# 创建SearchResult对象
result = result_class(
app_label,
model_name,
raw_result['_id'], # 文档ID
raw_result['_score'], # 匹配分数
**additional_fields)
raw_results.append(result)
# 分面信息(当前未使用)
facets = {}
# 拼写建议:如果推荐词与原词不同则返回推荐词
spelling_suggestion = None if query_string == suggestion else suggestion
# 返回标准格式的搜索结果
return {
'results': raw_results, # 搜索结果列表
'hits': hits, # 总命中数
'facets': facets, # 分面信息
'spelling_suggestion': spelling_suggestion, # 拼写建议
}
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):
"""
清理查询片段
对用户输入的查询词进行清理和转义处理防止注入攻击
Args:
query_fragment: 原始查询片段
Returns:
str: 清理后的查询字符串
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
# 处理保留字(转为小写)
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
# 处理保留字符(用引号包围)
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
break
cleaned_words.append(word)
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
"""
构建查询片段
Args:
field: 字段名
filter_type: 过滤器类型
value: 字段值
Returns:
str: 查询片段字符串
"""
return value.query_string
def get_count(self):
"""
获取搜索结果数量
Returns:
int: 搜索结果数量
"""
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
"""
获取拼写建议
Args:
preferred_query: 优先查询词
Returns:
str: 拼写建议
"""
return self._spelling_suggestion
def build_params(self, spelling_query=None):
"""
构建搜索参数
Args:
spelling_query: 拼写查询词
Returns:
dict: 搜索参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""
Elasticsearch 模型搜索表单
扩展Haystack的ModelSearchForm支持建议搜索功能
"""
def search(self):
"""
执行搜索
重写搜索方法根据表单数据设置是否启用建议搜索
Returns:
SearchQuerySet: 搜索查询结果集
"""
# 根据表单数据设置是否启用建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# 调用父类搜索方法
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
"""
Elasticsearch 搜索引擎配置
配置Haystack使用自定义的Elasticsearch后端和查询类
"""
# 指定自定义的后端类
backend = ElasticSearchBackend
# 指定自定义的查询类
query = ElasticSearchQuery

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

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

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

@ -1,26 +1,8 @@
""" # 文章管理相关的操作常量(用于标识不同动作)
钩子事件常量定义模块 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_DETAIL_LOAD = 'article_detail_load'
# 文章创建事件 - 当新文章被创建时触发
ARTICLE_CREATE = 'article_create'
# 文章更新事件 - 当现有文章被修改时触发
ARTICLE_UPDATE = 'article_update'
# 文章删除事件 - 当文章被删除时触发
ARTICLE_DELETE = 'article_delete'
# 文章内容处理钩子名称 - 专门用于处理文章内容的钩子标识
ARTICLE_CONTENT_HOOK_NAME = "the_content"

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

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

@ -1,250 +1,228 @@
""" """
DjangoBlog 项目配置文件 Django 项目的基础配置文件通常命名为 settings.py
此文件包含 Django 项目运行所需的所有配置项
本模块包含DjangoBlog项目的所有配置设置包括数据库应用中间件国际化缓存邮件等 - 数据库连接
根据Django 1.10+的配置规范组织支持开发和生产环境的不同配置 - 缓存配置
- 静态资源管理
主要配置类别 - 国际化与本地化
- 基础路径和密钥配 - 安全设
- 应用和中间件配置 - 邮件服务
- 数据库和缓存配置 - 模板配置
- 国际化设置 - 第三方应用与自定义应用注册
- 静态文件和媒体文件配置 - 中间件
- 邮件和日志配置 - 日志
- 安全相关配置 - 搜索引擎 WhooshElasticsearch
- 搜索和插件系统配置 - 其他自定义配置如分页缓存时间安全头部等
""" """
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 用于支持多语言翻译
# 辅助函数:将环境变量中的字符串转换为布尔值,默认值为 default
def env_to_bool(env, default): def env_to_bool(env, default):
""" str_val = os.environ.get(env)
环境变量转布尔值工具函数 return default if str_val is None else str_val == 'True'
将环境变量的字符串值转换为布尔值用于灵活的配置开关
Args: # ======================
env: 环境变量名 # 基础路径配置
default: 默认值 # ======================
Returns: # 构建项目内的文件路径(推荐使用 pathlib.Path
bool: 转换后的布尔值 BASE_DIR = Path(__file__).resolve().parent.parent # 项目根目录,即 manage.py 所在目录的上一级
"""
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# 构建项目基础路径 - 使用pathlib现代路径处理方式 # ======================
BASE_DIR = Path(__file__).resolve().parent.parent # 开发与调试配置
# ======================
# 安全密钥配置 - 生产环境必须从环境变量获取 # SECURITY WARNING: 请务必在生产环境中设置一个复杂的密钥!
SECRET_KEY = os.environ.get( SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# 调试模式开关 - 生产环境必须关闭 # SECURITY WARNING: 不要在生产环境中开启 Debug 模式!
DEBUG = env_to_bool('DJANGO_DEBUG', True) DEBUG = env_to_bool('DJANGO_DEBUG', True) # 是否开启调试模式,默认为 True开发环境
# 测试模式标识 - 根据命令行参数判断是否为测试环境 # 是否处于测试环境
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' # 判断是否在执行测试命令
# 允许的主机名配置 - 生产环境需要具体指定 # 允许访问的主机(生产环境请不要使用 '*',应明确指定域名)
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] # 允许任何主机访问(仅限开发)
# Django 4.0新增CSRF信任源配置 # Django 4.0+ 新增:信任的来源,用于跨域请求携带 Cookie 等
CSRF_TRUSTED_ORIGINS = ['http://example.com'] CSRF_TRUSTED_ORIGINS = ['http://example.com']
# 已安装应用列表 - 定义项目使用的所有Django应用
# ======================
# 应用注册 (INSTALLED_APPS)
# ======================
INSTALLED_APPS = [ INSTALLED_APPS = [
# 使用简化的Admin配置 # 使用简化 Admin 配置(推荐)
'django.contrib.admin.apps.SimpleAdminConfig', 'django.contrib.admin.apps.SimpleAdminConfig',
# Django核心功能应用
'django.contrib.auth', # Django 默认核心应用
'django.contrib.contenttypes', 'django.contrib.auth', # 用户认证
'django.contrib.sessions', 'django.contrib.contenttypes', # 模型内容类型
'django.contrib.messages', 'django.contrib.sessions', # 会话管理
'django.contrib.staticfiles', 'django.contrib.messages', # 消息框架
'django.contrib.sites', 'django.contrib.staticfiles', # 静态文件管理
'django.contrib.sitemaps', 'django.contrib.sites', # 站点管理(如多站点)
'django.contrib.sitemaps', # 站点地图SEO
# 第三方应用 # 第三方应用
'mdeditor', # Markdown编辑器 'mdeditor', # Markdown 编辑器
'haystack', # 搜索框架 'haystack', # 全文检索框架
'compressor', # 静态文件压缩 'compressor', # 静态资源压缩
# 项目自定义应用
'blog', # 博客核心功能 # 自定义应用
'accounts', # 用户账户管理 'blog', # 博客文章模块
'comments', # 评论系统 'accounts', # 用户账户模块
'oauth', # OAuth认证 'comments', # 评论模块
'servermanager', # 服务器管理 'oauth', # 第三方登录模块
'owntracks', # 位置追踪 'servermanager', # 服务器管理模块
'djangoblog' # 项目主应用 'owntracks', # 自定义轨迹模块
'djangoblog', # 本项目主应用(含工具、配置等)
] ]
# 中间件配置 - 定义请求处理管道
# ======================
# 中间件配置 (Middleware)
# ======================
MIDDLEWARE = [ MIDDLEWARE = [
# 安全相关中间件 'django.middleware.security.SecurityMiddleware', # 安全相关中间件
'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件
# 会话管理中间件 'django.middleware.locale.LocaleMiddleware', # 国际化中间件
'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.gzip.GZipMiddleware', # GZip 压缩中间件
# 国际化中间件 'django.middleware.common.CommonMiddleware', # 常用中间件
'django.middleware.locale.LocaleMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', # CSRF 保护中间件
# Gzip压缩中间件 'django.contrib.auth.middleware.AuthenticationMiddleware',# 用户认证中间件
'django.middleware.gzip.GZipMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件
# 缓存中间件(注释状态) 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 防止点击劫持
# 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.http.ConditionalGetMiddleware', # 条件 GET 请求优化
# 通用中间件 'blog.middleware.OnlineMiddleware' # 自定义在线用户中间件
'django.middleware.common.CommonMiddleware',
# 缓存中间件(注释状态)
# 'django.middleware.cache.FetchFromCacheMiddleware',
# CSRF保护中间件
'django.middleware.csrf.CsrfViewMiddleware',
# 认证中间件
'django.contrib.auth.middleware.AuthenticationMiddleware',
# 消息中间件
'django.contrib.messages.middleware.MessageMiddleware',
# 点击劫持保护中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 条件GET中间件
'django.middleware.http.ConditionalGetMiddleware',
# 自定义在线用户中间件
'blog.middleware.OnlineMiddleware'
] ]
# 根URL配置
ROOT_URLCONF = 'djangoblog.urls'
# 模板配置 # ======================
# URL 路由与模板
# ======================
ROOT_URLCONF = 'djangoblog.urls' # 项目的主路由配置文件
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')], 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 指定模板文件夹路径
'APP_DIRS': True, 'APP_DIRS': True, # 允许从各个 app 的 templates 文件夹查找模板
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [ # 模板上下文处理器
'django.template.context_processors.debug', 'django.template.context_processors.debug',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
# 自定义SEO处理器 'blog.context_processors.seo_processor', # 自定义 SEO 上下文
'blog.context_processors.seo_processor'
], ],
}, },
}, },
] ]
# WSGI应用配置 WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI 应用入口
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# ======================
# 数据库配置
# ======================
# 数据库配置 - 使用MySQL作为默认数据库
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'django.db.backends.mysql', # 使用 MySQL 数据库
'NAME': 'djangoblog', 'NAME': 'djangoblog', # 数据库名
'USER': 'root', 'USER': 'root', # 数据库用户名
'PASSWORD': '123456', 'PASSWORD': '123456', # 数据库密码(生产环境请勿明文!)
'HOST': '127.0.0.1', 'HOST': '127.0.0.1', # 数据库主机
'PORT': 3306, 'PORT': 3306, # 数据库端口
} }
} }
# 密码验证器配置
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# 国际化配置 # ======================
LANGUAGES = ( # 用户认证与权限
('en', _('English')), # ======================
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
# 本地化文件路径 AUTH_PASSWORD_VALIDATORS = [ # 密码强度校验规则
LOCALE_PATHS = ( {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
os.path.join(BASE_DIR, 'locale'), {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
) {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# 默认语言代码 # 自定义用户模型(推荐用于扩展用户信息)
LANGUAGE_CODE = 'zh-hans' AUTH_USER_MODEL = 'accounts.BlogUser'
# 时区配置 # 登录页面 URL
TIME_ZONE = 'Asia/Shanghai' LOGIN_URL = '/login/'
# 国际化开关
USE_I18N = True
# 本地化开关 # ======================
USE_L10N = True # 国际化与语言
# ======================
# 时区支持开关 LANGUAGES = (
USE_TZ = False ('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'), ) # 本地化翻译文件路径
# Haystack搜索配置 LANGUAGE_CODE = 'zh-hans' # 默认语言:简体中文
HAYSTACK_CONNECTIONS = { TIME_ZONE = 'Asia/Shanghai' # 默认时区:上海
'default': { USE_I18N = True # 启用国际化
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', USE_L10N = True # 启用本地化
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), USE_TZ = False # 是否使用时区False 表示使用本地时间)
},
}
# 实时更新搜索索引
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# 认证后端配置 - 支持邮箱或用户名登录 # ======================
AUTHENTICATION_BACKENDS = [ # 静态资源 & 媒体资源
'accounts.user_login_backend.EmailOrUsernameModelBackend'] # ======================
# 静态文件配置 STATIC_URL = '/static/' # 静态文件 URL 前缀
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') # 打包部署时收集静态文件的目录
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
# 自定义用户模型 STATICFILES = os.path.join(BASE_DIR, 'static') # 开发时存放静态文件的目录
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录URL # 配置静态文件查找器
LOGIN_URL = '/login/' STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder', # 支持 Compressor 静态压缩
)
# 时间格式配置 COMPRESS_ENABLED = True # 是否启用静态资源压缩
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# Bootstrap颜色类型 # 媒体文件(用户上传的文件,如头像、附件等)
BOOTSTRAP_COLOR_TYPES = [ MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
'default', 'primary', 'success', 'info', 'warning', 'danger' MEDIA_URL = '/media/'
]
# 分页配置
PAGINATE_BY = 10
# HTTP缓存超时时间 # ======================
CACHE_CONTROL_MAX_AGE = 2592000 # 缓存配置
# ======================
# 缓存配置 - 默认使用本地内存缓存 # 默认使用本地内存缓存(开发用)
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800, 'TIMEOUT': 10800, # 缓存超时时间(秒)
'LOCATION': 'unique-snowflake', 'LOCATION': 'unique-snowflake',
} }
} }
# 如果配置了Redis环境变量则使用Redis缓存 # 如果设置了环境变量 DJANGO_REDIS_URL则使用 Redis 作为缓存后端(生产推荐)
if os.environ.get("DJANGO_REDIS_URL"): if os.environ.get("DJANGO_REDIS_URL"):
CACHES = { CACHES = {
'default': { 'default': {
@ -253,33 +231,63 @@ if os.environ.get("DJANGO_REDIS_URL"):
} }
} }
# 站点ID
SITE_ID = 1
# 百度站长平台通知URL # ======================
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \ # 搜索配置Haystack + Whoosh / Elasticsearch
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' # ======================
# 邮件配置 HAYSTACK_CONNECTIONS = {
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 'default': {
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 默认使用 Whoosh 中文搜索引擎
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引文件存储路径
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' },
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) }
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# 管理员邮箱配置 - 用于错误报告 # 实时更新搜索索引
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# 默认认证后端:支持使用邮箱或用户名登录
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend'
]
# ======================
# 分页、安全、其他自定义配置
# ======================
# Bootstrap UI 颜色样式
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# 分页每页显示条数
PAGINATE_BY = 10
# HTTP 缓存超时时间(秒)
CACHE_CONTROL_MAX_AGE = 2592000
# 安全相关的 HTTP 头部配置
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# 内容安全策略 (CSP) 配置,用于防御 XSS 等攻击
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
# 微信管理员密码两次MD5加密
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# ======================
# 日志配置 # 日志配置
LOG_PATH = os.path.join(BASE_DIR, 'logs') # ======================
LOG_PATH = os.path.join(BASE_DIR, 'logs') # 日志文件存放目录
if not os.path.exists(LOG_PATH): if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True) os.makedirs(LOG_PATH, exist_ok=True)
@ -295,23 +303,14 @@ LOGGING = {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
} }
}, },
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': { 'handlers': {
'log_file': { 'log_file': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler', 'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), 'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D', 'when': 'D', # 每天一个日志文件
'formatter': 'verbose', 'formatter': 'verbose',
'interval': 1, 'interval': 1,
'delay': True,
'backupCount': 5, 'backupCount': 5,
'encoding': 'utf-8' 'encoding': 'utf-8'
}, },
@ -321,9 +320,6 @@ LOGGING = {
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'formatter': 'verbose' 'formatter': 'verbose'
}, },
'null': {
'class': 'logging.NullHandler',
},
'mail_admins': { 'mail_admins': {
'level': 'ERROR', 'level': 'ERROR',
'filters': ['require_debug_false'], 'filters': ['require_debug_false'],
@ -344,69 +340,24 @@ LOGGING = {
} }
} }
# 静态文件查找器配置
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 压缩器查找器
'compressor.finders.CompressorFinder',
)
# 静态文件压缩配置
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
COMPRESS_CSS_FILTERS = [
# 从相对URL创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# CSS压缩过滤器
'compressor.filters.cssmin.CSSMinFilter'
]
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
# 媒体文件配置 # ======================
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') # 其它自定义配置项
MEDIA_URL = '/media/' # ======================
# 框架选项配置
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 安全头部配置
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# 内容安全策略配置 # 站点 ID用于 Django Sites 框架)
CSP_DEFAULT_SRC = ["'self'"] SITE_ID = 1
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'"]
# 默认自增主键字段类型 # 百度主动推送 URL用于 SEO提交新链接
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Elasticsearch配置如果配置了环境变量 # 微信管理员密码MD5 两次加密)
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): WXADMIN = os.environ.get('DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# 插件系统配置 # 插件系统配置
PLUGINS_DIR = BASE_DIR / 'plugins' PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ ACTIVE_PLUGINS = [ # 当前启用的插件列表
'article_copyright', 'article_copyright',
'reading_time', 'reading_time',
'external_links', 'external_links',

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

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

@ -1,72 +1,28 @@
"""
DjangoBlog 单元测试模块
本模块包含DjangoBlog项目的单元测试用例用于验证工具函数和核心功能的正确性
基于Django的TestCase框架确保代码质量和功能稳定性
测试功能
- 加密工具函数测试
- Markdown渲染功能测试
- 字典转换URL参数测试
"""
from django.test import TestCase from django.test import TestCase
from djangoblog.utils import * from djangoblog.utils import * # 导入所有自定义工具函数如加密、Markdown、URL 处理等
class DjangoBlogTest(TestCase): class DjangoBlogTest(TestCase):
"""
DjangoBlog 核心功能测试类
继承自Django的TestCase提供项目核心功能的自动化测试
"""
def setUp(self): def setUp(self):
""" pass # 测试前置条件(可初始化数据库等)
测试前置设置方法
在每个测试方法执行前运行用于初始化测试环境
当前测试用例无需特殊设置保留空实现
"""
pass
def test_utils(self): def test_utils(self):
""" # 测试 SHA256 加密函数
工具函数综合测试方法
测试工具模块中的多个核心功能
1. SHA256加密功能
2. Markdown文本渲染功能
3. 字典转URL参数字符串功能
"""
# 测试SHA256加密功能
md5 = get_sha256('test') md5 = get_sha256('test')
# 验证加密结果不为空 self.assertIsNotNone(md5) # 断言返回值不为空
self.assertIsNotNone(md5)
# 测试Markdown渲染功能 # 测试 Markdown 渲染功能
c = CommonMarkdown.get_markdown(''' c = CommonMarkdown.get_markdown('''
# Title1 # Title1[url](https://www.lylinux.net/)
#
```python # [ddd](http://www.baidu.com)
import os # ''')
``` # self.assertIsNotNone(c) # 断言渲染结果不为空
#
[url](https://www.lylinux.net/) # # 测试字典转 URL 参数工具函数
# d = {
[ddd](http://www.baidu.com) # 'd': 'key1',
# 'd2': 'key2'
# }
''') # data = parse_dict_to_url(d)
# 验证Markdown渲染结果不为空 # self.assertIsNotNone(data) # 断言生成的 URL 参数字符串不为空
self.assertIsNotNone(c)
# 测试字典转URL参数功能
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
# 验证转换结果不为空
self.assertIsNotNone(data)

@ -1,19 +1,3 @@
"""
DjangoBlog 项目URL配置模块
本模块定义了DjangoBlog项目的所有URL路由配置包括管理后台博客评论用户认证等功能的URL映射
采用Django 1.10+的URL配置方式支持国际化路由和静态文件服务
主要路由分组
- 国际化路由配置
- 管理后台路由
- 博客应用路由
- 第三方应用路由
- 站点地图和订阅源
- 搜索功能路由
- 静态文件服务
"""
from django.conf import settings from django.conf import settings
from django.conf.urls.i18n import i18n_patterns from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static from django.conf.urls.static import static
@ -24,16 +8,20 @@ from haystack.views import search_view_factory
from django.http import JsonResponse from django.http import JsonResponse
import time import time
# 导入项目自定义模块 from blog.views import EsSearchView # Elasticsearch 搜索视图
from blog.views import EsSearchView from djangoblog.admin_site import admin_site # 自定义的 Admin 后台站点
from djangoblog.admin_site import admin_site from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ES 搜索表单
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm from djangoblog.feeds import DjangoBlogFeed # 博客 RSS 订阅源
from djangoblog.feeds import DjangoBlogFeed from djangoblog.sitemap import ( # 导入所有 Sitemap 类
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap ArticleSiteMap,
CategorySiteMap,
StaticViewSitemap,
TagSiteMap,
UserSiteMap
)
# 站点地图配置字典 - 定义不同类型内容的站点地图 # 定义站点地图字典,用于 sitemap 视
sitemaps = { sitemaps = {
'blog': ArticleSiteMap, 'blog': ArticleSiteMap,
'Category': CategorySiteMap, 'Category': CategorySiteMap,
'Tag': TagSiteMap, 'Tag': TagSiteMap,
@ -41,45 +29,44 @@ sitemaps = {
'static': StaticViewSitemap 'static': StaticViewSitemap
} }
# 自定义错误处理视图配置 # 错误页面处理函数(需在视图中定义)
handler404 = 'blog.views.page_not_found_view' handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view' handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view' handle403 = 'blog.views.permission_denied_view'
# 健康检查接口:返回服务是否正常运行
def health_check(request): def health_check(request):
"""
健康检查接口
简单返回服务健康状态
"""
return JsonResponse({ return JsonResponse({
'status': 'healthy', 'status': 'healthy',
'timestamp': time.time() 'timestamp': time.time()
}) })
# 基础URL模式配置 - 不包含语言前缀的URL
urlpatterns = [ urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')), # 国际化语言切换
path('health/', health_check, name='health_check'), path('health/', health_check, name='health_check'), # 健康检查
] ]
# 国际化URL模式配置 - 自动添加语言前缀的URL
# 国际化 URL 模式 + 主要功能路由
urlpatterns += i18n_patterns( urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls), re_path(r'^admin/', admin_site.urls), # 自定义后台管理站点
re_path(r'', include('blog.urls', namespace='blog')), re_path(r'', include('blog.urls', namespace='blog')), # 博客文章相关路由
re_path(r'mdeditor/', include('mdeditor.urls')), re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown 编辑器路由
re_path(r'', include('comments.urls', namespace='comment')), re_path(r'', include('comments.urls', namespace='comment')), # 评论模块路由
re_path(r'', include('accounts.urls', namespace='account')), re_path(r'', include('accounts.urls', namespace='account')), # 用户账户路由
re_path(r'', include('oauth.urls', namespace='oauth')), re_path(r'', include('oauth.urls', namespace='oauth')), # 第三方登录路由
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), # 站点地图
name='django.contrib.sitemaps.views.sitemap'), re_path(r'^feed/$', DjangoBlogFeed()), # RSS 订阅源
re_path(r'^feed/$', DjangoBlogFeed()), re_path(r'^rss/$', DjangoBlogFeed()), # RSS别名
re_path(r'^rss/$', DjangoBlogFeed()), re_path('^search', search_view_factory( # 全文检索视图(集成 Haystack + Elasticsearch / Whoosh
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), view_class=EsSearchView,
name='search'), form_class=ElasticSearchModelSearchForm
re_path(r'', include('servermanager.urls', namespace='servermanager')), ), name='search'),
re_path(r'', include('owntracks.urls', namespace='owntracks')) re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理路由
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) re_path(r'', include('owntracks.urls', namespace='owntracks')) # 轨迹记录路由
# 开发环境媒体文件服务配置 , prefix_default_language=False # 不自动为默认语言添加前缀
) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 开发环境下提供静态文件服务
# 如果是 DEBUG 模式,也提供媒体文件(如用户上传的头像、附件)访问
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT) document_root=settings.MEDIA_ROOT)

@ -1,21 +1,3 @@
#!/usr/bin/env python
# encoding: utf-8
"""
DjangoBlog 通用工具函数模块
本模块提供了DjangoBlog项目的各种通用工具函数包括缓存装饰器Markdown处理
邮件发送安全过滤等核心功能这些工具函数在整个项目中广泛使用
主要功能
- 缓存管理和装饰器
- Markdown文本处理和转换
- 电子邮件发送功能
- 安全HTML过滤和XSS防护
- 随机码生成和URL处理
- 用户头像下载和管理
"""
import logging import logging
import os import os
import random import random
@ -31,161 +13,78 @@ from django.contrib.sites.models import Site
from django.core.cache import cache from django.core.cache import cache
from django.templatetags.static import static from django.templatetags.static import static
# 初始化模块级日志器 logger = logging.getLogger(__name__) # 日志记录器
logger = logging.getLogger(__name__)
# 获取最新文章和评论的 ID用于某些统计或展示用途
def get_max_articleid_commentid(): def get_max_articleid_commentid():
"""
获取最大文章ID和评论ID
用于生成新文章或评论时的ID参考
Returns:
tuple: (最大文章ID, 最大评论ID)
"""
from blog.models import Article from blog.models import Article
from comments.models import Comment from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk) return (Article.objects.latest().pk, Comment.objects.latest().pk)
# 计算字符串的 SHA256 哈希值
def get_sha256(str): def get_sha256(str):
"""
SHA256加密函数
Args:
str: 要加密的字符串
Returns:
str: SHA256加密后的十六进制字符串
"""
m = sha256(str.encode('utf-8')) m = sha256(str.encode('utf-8'))
return m.hexdigest() return m.hexdigest()
# 缓存装饰器:可用于缓存函数返回值,避免重复计算
def cache_decorator(expiration=3 * 60): def cache_decorator(expiration=3 * 60):
"""
缓存装饰器
为函数添加缓存功能减少重复计算和数据库查询
Args:
expiration: 缓存过期时间默认3分钟
Returns:
function: 装饰后的函数
"""
def wrapper(func): def wrapper(func):
def news(*args, **kwargs): def news(*args, **kwargs):
try: try:
# 尝试从视图类获取缓存键
view = args[0] view = args[0]
key = view.get_cache_key() key = view.get_cache_key()
except: except:
key = None key = None
if not key: if not key:
# 生成基于函数参数的唯一缓存键
unique_str = repr((func, args, kwargs)) unique_str = repr((func, args, kwargs))
m = sha256(unique_str.encode('utf-8')) m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest() key = m.hexdigest()
# 尝试从缓存获取值
value = cache.get(key) value = cache.get(key)
if value is not None: if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
if str(value) == '__default_cache_value__': if str(value) == '__default_cache_value__':
return None return None
else: else:
return value return value
else: else:
# 缓存未命中,执行函数并设置缓存
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
value = func(*args, **kwargs) value = func(*args, **kwargs)
if value is None: if value is None:
cache.set(key, '__default_cache_value__', expiration) cache.set(key, '__default_cache_value__', expiration)
else: else:
cache.set(key, value, expiration) cache.set(key, value, expiration)
return value return value
return news return news
return wrapper return wrapper
# 手动使某个视图缓存失效
def expire_view_cache(path, servername, serverport, key_prefix=None): def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
使指定路径的视图缓存失效确保内容更新后及时反映
Args:
path: URL路径
servername: 主机名
serverport: 端口号
key_prefix: 缓存键前缀
Returns:
bool: 是否成功删除缓存
'''
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.cache import get_cache_key from django.utils.cache import get_cache_key
# 创建模拟请求对象用于生成缓存键
request = HttpRequest() request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path request.path = path
# 获取缓存键并删除对应缓存
key = get_cache_key(request, key_prefix=key_prefix, cache=cache) key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key: if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
if cache.get(key): if cache.get(key):
cache.delete(key) cache.delete(key)
return True return True
return False return False
# 获取当前站点信息(单例模式,带缓存)
@cache_decorator() @cache_decorator()
def get_current_site(): def get_current_site():
"""
获取当前站点信息
返回当前Django站点的配置信息带缓存功能
Returns:
Site: 当前站点对象
"""
site = Site.objects.get_current() site = Site.objects.get_current()
return site return site
# 将 Markdown 文本转换为 HTML带目录
class CommonMarkdown: class CommonMarkdown:
"""
Markdown处理工具类
提供Markdown文本到HTML的转换功能支持代码高亮和目录生成
"""
@staticmethod @staticmethod
def _convert_markdown(value): def _convert_markdown(value):
"""
内部Markdown转换方法
Args:
value: Markdown格式文本
Returns:
tuple: (转换后的HTML内容, 生成的目录)
"""
# 配置Markdown扩展
md = markdown.Markdown( md = markdown.Markdown(
extensions=[ extensions=[
'extra', # 额外语法支持 'extra',
'codehilite', # 代码高亮 'codehilite',
'toc', # 目录生成 'toc',
'tables', # 表格支持 'tables',
] ]
) )
body = md.convert(value) body = md.convert(value)
@ -194,89 +93,32 @@ class CommonMarkdown:
@staticmethod @staticmethod
def get_markdown_with_toc(value): def get_markdown_with_toc(value):
"""
获取带目录的Markdown转换结果
Args:
value: Markdown格式文本
Returns:
tuple: (HTML内容, 目录HTML)
"""
body, toc = CommonMarkdown._convert_markdown(value) body, toc = CommonMarkdown._convert_markdown(value)
return body, toc return body, toc
@staticmethod @staticmethod
def get_markdown(value): def get_markdown(value):
"""
获取Markdown转换结果不含目录
Args:
value: Markdown格式文本
Returns:
str: 转换后的HTML内容
"""
body, toc = CommonMarkdown._convert_markdown(value) body, toc = CommonMarkdown._convert_markdown(value)
return body 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,
title=title,
content=content)
def generate_code() -> str: def generate_code() -> str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6)) return ''.join(random.sample(string.digits, 6))
# 将字典转换为 URL 查询参数字符串
def parse_dict_to_url(dict): def parse_dict_to_url(dict):
"""
将字典转换为URL参数字符串
Args:
dict: 参数字典
Returns:
str: URL参数字符串
"""
from urllib.parse import quote from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()]) for k, v in dict.items()])
return url return url
# 获取博客全局设置(带缓存,避免频繁查询数据库)
def get_blog_setting(): def get_blog_setting():
"""
获取博客设置
返回博客的全局设置信息带缓存功能
如果设置不存在则创建默认设置
Returns:
BlogSettings: 博客设置对象
"""
value = cache.get('get_blog_setting') value = cache.get('get_blog_setting')
if value: if value:
return value return value
else: else:
from blog.models import BlogSettings from blog.models import BlogSettings
# 如果不存在设置记录,创建默认设置
if not BlogSettings.objects.count(): if not BlogSettings.objects.count():
setting = BlogSettings() setting = BlogSettings()
setting.site_name = 'djangoblog' setting.site_name = 'djangoblog'
@ -294,97 +136,54 @@ def get_blog_setting():
setting.comment_need_review = False setting.comment_need_review = False
setting.save() setting.save()
value = BlogSettings.objects.first() value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value) cache.set('get_blog_setting', value)
return value return value
# 保存用户头像(从远程 URL 下载并存储到本地 static/avatar/ 目录)
def save_user_avatar(url): def save_user_avatar(url):
'''
保存用户头像
从远程URL下载用户头像并保存到本地静态文件目录
Args:
url: 头像URL地址
Returns:
str: 本地静态文件路径
'''
logger.info(url)
try: try:
basedir = os.path.join(settings.STATICFILES, 'avatar') basedir = os.path.join(settings.STATICFILES, 'avatar')
# 下载头像文件
rsp = requests.get(url, timeout=2) rsp = requests.get(url, timeout=2)
if rsp.status_code == 200: if rsp.status_code == 200:
if not os.path.exists(basedir): if not os.path.exists(basedir):
os.makedirs(basedir) os.makedirs(basedir)
# 检查文件扩展名
image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg' ext = os.path.splitext(url)[1] if isimage else '.jpg'
# 生成唯一文件名
save_filename = str(uuid.uuid4().hex) + ext save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename) logger.info('保存用户头像:' + basedir + save_filename)
# 保存文件
with open(os.path.join(basedir, save_filename), 'wb+') as file: with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content) file.write(rsp.content)
return static('avatar/' + save_filename) return static('avatar/' + save_filename)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
# 返回默认头像
return static('blog/img/avatar.png') return static('blog/img/avatar.png')
# 删除侧边栏缓存(如分类、标签等侧边小工具)
def delete_sidebar_cache(): def delete_sidebar_cache():
"""
删除侧边栏缓存
清理所有侧边栏相关的缓存数据
"""
from blog.models import LinkShowType from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values] keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys: for k in keys:
logger.info('delete sidebar key:' + k)
cache.delete(k) cache.delete(k)
# 删除指定模板片段的缓存(如文章评论列表)
def delete_view_cache(prefix, keys): def delete_view_cache(prefix, keys):
"""
删除视图缓存
根据前缀和键删除特定的模板片段缓存
Args:
prefix: 缓存前缀
keys: 缓存键列表
"""
from django.core.cache.utils import make_template_fragment_key from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys) key = make_template_fragment_key(prefix, keys)
cache.delete(key) cache.delete(key)
# 获取静态资源 URL兼容 STATIC_URL 未设置情况)
def get_resource_url(): def get_resource_url():
"""
获取资源URL基础路径
Returns:
str: 静态资源基础URL
"""
if settings.STATIC_URL: if settings.STATIC_URL:
return settings.STATIC_URL return settings.STATIC_URL
else: else:
site = get_current_site() site = get_current_site()
return 'http://' + site.domain + '/static/' return 'http://' + site.domain + '/static/'
# 安全相关的 HTML 标签、属性、协议白名单,用于防 XSS 攻击
# HTML标签白名单 - 允许的安全HTML标签
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div'] 'h2', 'p', 'span', 'div']
# 安全的class值白名单 - 只允许代码高亮相关的class
ALLOWED_CLASSES = [ ALLOWED_CLASSES = [
'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs',
'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt',
@ -393,17 +192,6 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
] ]
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
# 只允许预定义的安全class值
allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
return ' '.join(allowed_classes) if allowed_classes else False
return value
# 安全的属性白名单
ALLOWED_ATTRIBUTES = { ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'], 'a': ['href', 'title'],
'abbr': ['title'], 'abbr': ['title'],
@ -414,27 +202,15 @@ ALLOWED_ATTRIBUTES = {
'code': class_filter 'code': class_filter
} }
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# 安全过滤函数:清理用户提交的 HTML防止 XSS 等攻击
def sanitize_html(html): def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
Args:
html: 要清理的HTML内容
Returns:
str: 清理后的安全HTML
"""
return bleach.clean( return bleach.clean(
html, html,
tags=ALLOWED_TAGS, tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES, attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 protocols=ALLOWED_PROTOCOLS,
strip=True, # 移除不允许的标签而不是转义 strip=True,
strip_comments=True # 移除HTML注释 strip_comments=True
) )

File diff suppressed because it is too large Load Diff

@ -1,25 +1,14 @@
""" """
DjangoBlog WSGI 配置模块 WSGI 配置文件用于部署 Django 项目时提供 WSGI 入口
通常由 Web 服务器 Nginx + uWSGI / Gunicorn调用使 Django 可以处理 HTTP 请求
本模块定义了DjangoBlog项目的WSGIWeb Server Gateway Interface配置
用于将Django应用部署到支持WSGI的Web服务器如ApacheNginx + uWSGI等
WSGI是Python Web应用与Web服务器之间的标准接口确保应用能够在生产环境中正确运行
主要功能
- 设置Django环境变量
- 创建WSGI应用实例
- 提供Web服务器与Django应用之间的桥梁
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
# 设置Django设置模块的环境变量 # 设置当前使用的 Django 配置模块(通常为 settings.py
# 告诉Django使用哪个配置文件这里设置为'djangoblog.settings'
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
# 创建WSGI应用实例 # 获取 WSGI 应用对象Django 的请求/响应处理入口
# 这是Web服务器将调用的入口点用于处理HTTP请求
application = get_wsgi_application() application = get_wsgi_application()

@ -1,64 +1,81 @@
# fix_pet_blog.py # 引入操作系统接口模块,用于设置环境变量
import os import os
# 引入 Django 模块,用于初始化 Django 环境
import django import django
# 设置 Django 的 settings 模块为 'djangoblog.settings'
# 这一步是必须的,以便 Django 知道使用哪个配置文件
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
# 初始化 Django 环境,加载所有应用和配置
django.setup() django.setup()
# 从 blog 应用的 models 模块中导入 Article文章、Category分类、Tag标签模型
from blog.models import Article, Category, Tag from blog.models import Article, Category, Tag
# 从 accounts 应用的 models 模块中导入 BlogUser博客用户模型
from accounts.models import BlogUser from accounts.models import BlogUser
# 打印脚本开始信息
print("=== 修复宠物博客数据 ===") print("=== 修复宠物博客数据 ===")
# 获取用户 # 获取第一个用户对象,假设这是博客的管理员或主要用户
# 如果 BlogUser 表中没有任何用户,这个查询将返回 None
user = BlogUser.objects.first() user = BlogUser.objects.first()
# 检查是否找到了用户
if not user: if not user:
# 如果没有找到任何用户,打印错误信息并退出脚本
print("错误:没有找到用户") print("错误:没有找到用户")
exit() exit()
# 为每个分类创建至少一篇文章 # 定义一个包含多篇文章数据的列表
# 每个元素是一个字典,包含文章的标题、内容、分类名称和标签列表
articles_data = [ articles_data = [
# 狗狗日常 # 狗狗日常分类下的文章
{ {
'title': '我家狗狗的表演', 'title': '我家狗狗的表演', # 文章标题
'body': '欲擒故纵,你们见过吗。就是要出去的时候,故意离你远远的,让人你没法给它套上项圈,其实它很想出去。', 'body': '欲擒故纵,你们见过吗。就是要出去的时候,故意离你远远的,让人你没法给它套上项圈,其实它很想出去。', # 文章内容
'category': '狗狗日常', 'category': '狗狗日常', # 分类名称
'tags': ['图文', '狗狗社交', '遛狗'] 'tags': ['图文', '狗狗社交', '遛狗'] # 标签列表
}, },
# 猫咪生活 # 猫咪生活分类下的文章
{ {
'title': '猫咪的日常护理', 'title': '猫咪的日常护理',
'body': '定期为猫咪梳理毛发,保持清洁,注意观察猫咪的健康状况。', 'body': '定期为猫咪梳理毛发,保持清洁,注意观察猫咪的健康状况。',
'category': '猫咪生活', 'category': '猫咪生活',
'tags': ['宠物美容', '宠物健康'] 'tags': ['宠物美容', '宠物健康']
}, },
# 宠物健康 # 宠物健康分类下的文章
{ {
'title': '宠物健康检查指南', 'title': '宠物健康检查指南',
'body': '定期带宠物进行健康检查,注意疫苗接种和驱虫的重要性。', 'body': '定期带宠物进行健康检查,注意疫苗接种和驱虫的重要性。',
'category': '宠物健康', 'category': '宠物健康',
'tags': ['宠物医疗', '宠物健康'] 'tags': ['宠物医疗', '宠物健康']
}, },
# 训练技巧 # 训练技巧分类下的文章
{ {
'title': '如何训练狗狗坐下', 'title': '如何训练狗狗坐下',
'body': '使用零食诱导,当狗狗完成动作时及时奖励,重复训练。', 'body': '使用零食诱导,当狗狗完成动作时及时奖励,重复训练。',
'category': '训练技巧', 'category': '训练技巧',
'tags': ['训练方法', '图文'] 'tags': ['训练方法', '图文']
}, },
# 宠物用品 # 宠物用品分类下的文章
{ {
'title': '推荐几款好用的宠物玩具', 'title': '推荐几款好用的宠物玩具',
'body': '这些玩具既安全又有趣,能让宠物保持活跃和快乐。', 'body': '这些玩具既安全又有趣,能让宠物保持活跃和快乐。',
'category': '宠物用品', 'category': '宠物用品',
'tags': ['宠物玩具', '宠物用品'] 'tags': ['宠物玩具', '宠物用品']
}, },
# 额外文章确保内容丰富 # 额外文章确保狗狗日常分类有更多内容
{ {
'title': '带狗狗散步的注意事项', 'title': '带狗狗散步的注意事项',
'body': '选择合适的牵引绳,注意天气和路况,确保狗狗的安全。', 'body': '选择合适的牵引绳,注意天气和路况,确保狗狗的安全。',
'category': '狗狗日常', 'category': '狗狗日常',
'tags': ['遛狗', '狗狗社交'] 'tags': ['遛狗', '狗狗社交']
}, },
# 额外文章:确保宠物健康分类有更多内容
{ {
'title': '猫咪饮食健康指南', 'title': '猫咪饮食健康指南',
'body': '了解猫咪的营养需求,选择合适的猫粮和零食。', 'body': '了解猫咪的营养需求,选择合适的猫粮和零食。',
@ -67,30 +84,50 @@ articles_data = [
} }
] ]
# 删除现有文章,重新创建 # 删除数据库中所有的现有文章
# 注意:这将永久删除所有文章,谨慎操作!
Article.objects.all().delete() Article.objects.all().delete()
print("已清理现有文章") print("已清理现有文章")
# 创建文章 # 遍历 articles_data 列表中的每一篇文章数据,逐个创建文章
for data in articles_data: for data in articles_data:
try: try:
# 根据分类名称从 Category 模型中获取对应的分类对象
# 如果找不到对应的分类,这里会抛出 Category.DoesNotExist 异常
category = Category.objects.get(name=data['category']) category = Category.objects.get(name=data['category'])
# 创建一个新的 Article 对象,并保存到数据库中
article = Article.objects.create( article = Article.objects.create(
title=data['title'], title=data['title'], # 设置文章标题
body=data['body'], body=data['body'], # 设置文章内容
author=user, author=user, # 设置文章作者为之前获取的用户
category=category, category=category, # 设置文章分类
status='p' status='p' # 设置文章状态为 'p'(通常代表已发布)
) )
# 遍历当前文章数据中的每一个标签名称
for tag_name in data['tags']: for tag_name in data['tags']:
# 根据标签名称获取或创建一个 Tag 对象
# 如果标签不存在,则创建一个新的标签
tag, _ = Tag.objects.get_or_create(name=tag_name) tag, _ = Tag.objects.get_or_create(name=tag_name)
# 将该标签添加到文章的标签集合中
article.tags.add(tag) article.tags.add(tag)
# 打印成功创建文章的信息,包括文章标题和所属分类
print(f'创建文章: {data["title"]} (分类: {data["category"]})') print(f'创建文章: {data["title"]} (分类: {data["category"]})')
except Exception as e: except Exception as e:
# 如果在创建文章的过程中发生任何异常,打印错误信息,包括文章标题和异常详情
print(f'创建文章失败 {data["title"]}: {e}') print(f'创建文章失败 {data["title"]}: {e}')
# 打印脚本完成信息
print("=== 修复完成 ===") print("=== 修复完成 ===")
# 打印当前数据库中所有文章的总数
print(f"总文章数: {Article.objects.count()}") print(f"总文章数: {Article.objects.count()}")
# 遍历所有分类,打印每个分类的名称及其下的文章数量
for category in Category.objects.all(): for category in Category.objects.all():
count = Article.objects.filter(category=category).count() count = Article.objects.filter(category=category).count()
print(f"分类 '{category.name}': {count} 篇文章") print(f"分类 '{category.name}': {count} 篇文章")

@ -1,22 +1,58 @@
#!/usr/bin/env python #!/usr/bin/env python
"""
此脚本是 Django 项目的命令行管理入口通常命名为 manage.py
它允许您通过命令行执行各种 Django 管理任务如运行开发服务器执行数据库迁移启动交互式 Shell
使用方法
python manage.py <command> [options]
常用命令示例
python manage.py runserver # 启动开发服务器
python manage.py migrate # 执行数据库迁移
python manage.py createsuperuser # 创建超级用户
python manage.py shell # 启动 Django Shell
"""
# 引入 Python 的标准库模块 os用于与操作系统交互如设置环境变量
import os import os
# 引入 Python 的标准库模块 sys用于访问与 Python 解释器紧密相关的变量和函数,如命令行参数
import sys import sys
# __name__ 是当前模块的名称当此脚本作为主程序运行时__name__ 的值为 '__main__'
if __name__ == "__main__": if __name__ == "__main__":
"""
设置 Django settings 模块环境变量
Django 需要知道使用哪个设置模块来加载项目的配置
通常这个设置模块的路径是 '项目名称.settings'例如 'djangoblog.settings'
"""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
try: try:
"""
尝试从 django.core.management 模块中导入 execute_from_command_line 函数
该函数负责解析命令行参数并调用相应的 Django 管理命令
"""
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError: except ImportError:
# The above import may fail for some other reason. Ensure that the # 如果导入 django.core.management 失败,可能是由于 Django 未安装或不在 Python 路径中。
# issue is really that Django is missing to avoid masking other # 为了更准确地诊断问题,首先尝试导入 django 模块本身。
# exceptions on Python 2.
try: try:
import django import django
except ImportError: except ImportError:
# 如果连 django 模块都无法导入,说明 Django 未正确安装或不在 PYTHONPATH 中。
# 抛出一个明确的 ImportError提示用户检查 Django 是否安装以及虚拟环境是否激活。
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "无法导入 Django。请确保 Django 已正确安装并且 "
"available on your PYTHONPATH environment variable? Did you " "在您的 PYTHONPATH 环境变量中可用。您是否忘记激活虚拟环境?"
"forget to activate a virtual environment?"
) )
raise else:
execute_from_command_line(sys.argv) # 如果 django 模块可以导入,但 django.core.management 无法导入,
# 这通常意味着 Django 安装不完整或存在其他问题。
# 重新抛出之前的 ImportError以便用户了解问题所在。
raise
# 如果成功导入了 execute_from_command_line 函数,
# 则调用该函数并传入命令行参数 sys.argv。
# sys.argv 是一个包含命令行参数的列表,其中 sys.argv[0] 是脚本名称,
# sys.argv[1:] 是传递给脚本的参数。
execute_from_command_line(sys.argv)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

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

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
# OAuth 应用配置类
class OauthConfig(AppConfig): class OauthConfig(AppConfig):
name = 'oauth' name = 'oauth' # 应用名称

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

@ -1,57 +1,120 @@
# oauth/migrations/0001_initial.py # 由 Django 4.1.7 在 2023-03-07 09:53 自动生成的迁移文件
# 从 Django 的配置模块导入设置
from django.conf import settings from django.conf import settings
# 从 Django 的数据库模块导入迁移相关功能
from django.db import migrations, models from django.db import migrations, models
# 导入 Django 提供的用于处理删除操作的模块
import django.db.models.deletion import django.db.models.deletion
# 导入 Django 的时区工具,用于处理时间字段的默认值
import django.utils.timezone import django.utils.timezone
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 标记此迁移为初始迁移,即项目中的第一个迁移文件
initial = True initial = True
# 定义此迁移所依赖的其他迁移,此处依赖于可交换的用户模型
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
# 定义此迁移中要执行的一系列数据库操作
operations = [ operations = [
# 创建一个名为 OAuthConfig 的新模型,用于存储 OAuth 配置信息
migrations.CreateModel( migrations.CreateModel(
name='OAuthConfig', name='OAuthConfig',
fields=[ fields=[
# 主键字段,自动创建的大整数字段,作为模型的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# OAuth 提供商类型如微博、谷歌、GitHub 等,使用 CharField 并限制选择项
('type', models.CharField(
choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')],
default='a', # 默认值为 'a',但建议设置为有效选项之一,如 'weibo'
max_length=10,
verbose_name='类型' # 在后台管理中显示的字段名称
)),
# OAuth 应用的 AppKey用于身份验证
('appkey', models.CharField(max_length=200, verbose_name='AppKey')), ('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
# OAuth 应用的 AppSecret用于身份验证
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
# OAuth 回调地址,用户授权后跳转的 URL默认设置为百度建议根据实际需求设置
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
# 是否启用该 OAuth 配置,默认启用
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 记录该 OAuth 配置的创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 记录该 OAuth 配置的最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
], ],
# 定义该模型的元数据选项
options={ options={
'verbose_name': 'oauth配置', 'verbose_name': 'oauth配置', # 单数形式的后台显示名称
'verbose_name_plural': 'oauth配置', 'verbose_name_plural': 'oauth配置', # 复数形式的后台显示名称
'ordering': ['-created_time'], 'ordering': ['-created_time'], # 按创建时间降序排列
}, },
), ),
# 创建一个名为 OAuthUser 的新模型,用于存储通过 OAuth 登录的用户信息
migrations.CreateModel( migrations.CreateModel(
name='OAuthUser', name='OAuthUser',
fields=[ fields=[
# 主键字段,自动创建的大整数字段,作为模型的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户在 OAuth 提供商的唯一标识符,如 OpenID
('openid', models.CharField(max_length=50)), ('openid', models.CharField(max_length=50)),
# 用户在 OAuth 提供商的昵称
('nickname', models.CharField(max_length=50, verbose_name='昵称')), ('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# OAuth 提供的访问令牌,用于后续 API 调用,允许为空
('token', models.CharField(blank=True, max_length=150, null=True)), ('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户在 OAuth 提供商的头像 URL允许为空
('picture', models.CharField(blank=True, max_length=350, null=True)), ('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth 提供商的类型,如微博、谷歌等
('type', models.CharField(max_length=50)), ('type', models.CharField(max_length=50)),
# 用户的邮箱地址,允许为空
('email', models.CharField(blank=True, max_length=50, null=True)), ('email', models.CharField(blank=True, max_length=50, null=True)),
# 其他元数据,以文本形式存储,允许为空
('metadata', models.TextField(blank=True, null=True)), ('metadata', models.TextField(blank=True, null=True)),
# 记录该 OAuth 用户的创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 记录该 OAuth 用户的最后修改时间,默认为当前时间
('last_mod_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='用户')),
# 与 Django 的用户模型建立外键关系,表示该 OAuth 用户关联的本地用户,允许为空
('author', models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE, # 当关联的本地用户被删除时,级联删除此 OAuth 用户
to=settings.AUTH_USER_MODEL, # 关联到项目的用户模型
verbose_name='用户' # 在后台管理中显示的字段名称
)),
], ],
# 定义该模型的元数据选项
options={ options={
'verbose_name': 'oauth用户', 'verbose_name': 'oauth用户', # 单数形式的后台显示名称
'verbose_name_plural': 'oauth用户', 'verbose_name_plural': 'oauth用户', # 复数形式的后台显示名称
'ordering': ['-created_time'], 'ordering': ['-created_time'], # 按创建时间降序排列
}, },
), ),
] ]

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

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

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

@ -1,171 +1,85 @@
"""
OAuth 认证管理器模块
该模块实现了多平台OAuth认证的核心逻辑包含基类定义和具体平台实现
支持微博谷歌GitHubFacebookQQ等主流第三方登录平台
采用抽象基类和混合类设计模式提供统一的OAuth认证接口
"""
import json import json
import logging import logging
import os import os
import urllib.parse import urllib.parse
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import requests import requests
from djangoblog.utils import cache_decorator from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig from oauth.models import OAuthUser, OAuthConfig
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 自定义异常OAuth Token 获取失败
class OAuthAccessTokenException(Exception): class OAuthAccessTokenException(Exception):
''' pass
OAuth授权令牌获取异常类
当从OAuth服务商获取访问令牌失败时抛出此异常
通常由于错误的授权码应用配置问题或网络问题导致
'''
# OAuth 抽象基类定义获取授权、Token、用户信息的接口
class BaseOauthManager(metaclass=ABCMeta): class BaseOauthManager(metaclass=ABCMeta):
""" AUTH_URL = None # 授权页面 URL
OAuth认证管理器抽象基类 TOKEN_URL = None # 获取 Token 的 URL
API_URL = None # 获取用户信息的 API
定义所有OAuth平台必须实现的接口和方法 ICON_NAME = None # 平台标识,如 weibo
提供统一的OAuth认证流程模板
"""
# OAuth授权页面URL需要子类实现
AUTH_URL = None
# 获取访问令牌的URL需要子类实现
TOKEN_URL = None
# 获取用户信息的API URL需要子类实现
API_URL = None
# 平台图标名称,用于标识和显示(需要子类实现)
ICON_NAME = None
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""
初始化OAuth管理器
Args:
access_token: 已存在的访问令牌可选
openid: 已存在的用户OpenID可选
"""
self.access_token = access_token self.access_token = access_token
self.openid = openid self.openid = openid
@property @property
def is_access_token_set(self): def is_access_token_set(self):
"""检查访问令牌是否已设置"""
return self.access_token is not None return self.access_token is not None
@property @property
def is_authorized(self): def is_authorized(self):
"""检查是否已完成授权拥有令牌和OpenID""" return self.is_access_token_set and self.openid is not None
return self.is_access_token_set and self.access_token is not None and self.openid is not None
@abstractmethod @abstractmethod
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
"""获取授权页面URL抽象方法子类必须实现""" pass # 返回用户跳转到第三方授权页面的 URL
pass
@abstractmethod @abstractmethod
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""通过授权码获取访问令牌(抽象方法,子类必须实现)""" pass # 通过 code 换取 access_token 和 openid
pass
@abstractmethod @abstractmethod
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""获取OAuth用户信息抽象方法子类必须实现""" pass # 通过 access_token 获取用户信息
pass
@abstractmethod @abstractmethod
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取用户头像URL抽象方法子类必须实现""" pass # 从 metadata 中提取头像
pass
# 发送 GET 请求
def do_get(self, url, params, headers=None): 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) rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text) # 记录响应日志 logger.info(rsp.text)
return rsp.text return rsp.text
# 发送 POST 请求
def do_post(self, url, params, headers=None): def do_post(self, url, params, headers=None):
"""
执行POST请求的通用方法
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本内容
"""
rsp = requests.post(url, params, headers=headers) rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text) # 记录响应日志 logger.info(rsp.text)
return rsp.text return rsp.text
# 获取当前平台的配置信息
def get_config(self): def get_config(self):
"""
从数据库获取当前平台的OAuth配置
Returns:
OAuthConfig: 配置对象如果不存在则返回None
"""
value = OAuthConfig.objects.filter(type=self.ICON_NAME) value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None return value[0] if value else None
# 微博 OAuth 实现
class WBOauthManager(BaseOauthManager): class WBOauthManager(BaseOauthManager):
"""
微博OAuth认证管理器
实现微博平台的OAuth2.0认证流程包括授权令牌获取和用户信息获取
"""
# 微博OAuth接口地址
AUTH_URL = 'https://api.weibo.com/oauth2/authorize' AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json' API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo' ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化微博OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' # 应用Key self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' # 应用Secret self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else '' # 回调地址 self.callback_url = config.callback_url if config else ''
super( super().__init__(access_token, openid)
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
"""
生成微博授权页面URL
Args:
nexturl: 授权成功后跳转的URL
Returns:
str: 完整的授权URL
"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'response_type': 'code', 'response_type': 'code',
@ -175,18 +89,6 @@ class WBOauthManager(BaseOauthManager):
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""
使用授权码获取访问令牌
Args:
code: OAuth授权码
Returns:
OAuthUser: 用户信息对象
Raises:
OAuthAccessTokenException: 令牌获取失败时抛出
"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
@ -195,72 +97,44 @@ class WBOauthManager(BaseOauthManager):
'redirect_uri': self.callback_url 'redirect_uri': self.callback_url
} }
rsp = self.do_post(self.TOKEN_URL, params) rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp) obj = json.loads(rsp)
if 'access_token' in obj: if 'access_token' in obj:
# 设置访问令牌和用户ID
self.access_token = str(obj['access_token']) self.access_token = str(obj['access_token'])
self.openid = str(obj['uid']) self.openid = str(obj['uid'])
return self.get_oauth_userinfo() # 获取并返回用户信息 return self.get_oauth_userinfo()
else: else:
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""
获取微博用户信息
Returns:
OAuthUser: 包含用户信息的对象获取失败返回None
"""
if not self.is_authorized: if not self.is_authorized:
return None return None
params = { params = {'uid': self.openid, 'access_token': self.access_token}
'uid': self.openid,
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params) rsp = self.do_get(self.API_URL, params)
try: try:
datas = json.loads(rsp) datas = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.metadata = rsp # 存储原始响应数据 user.metadata = rsp
user.picture = datas['avatar_large'] # 用户头像 user.picture = datas['avatar_large']
user.nickname = datas['screen_name'] # 用户昵称 user.nickname = datas['screen_name']
user.openid = datas['id'] # 用户OpenID user.openid = datas['id']
user.type = 'weibo' # 平台类型 user.type = 'weibo'
user.token = self.access_token # 访问令牌 user.token = self.access_token
if 'email' in datas and datas['email']: if 'email' in datas and datas['email']:
user.email = datas['email'] # 用户邮箱 user.email = datas['email']
return user return user
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
logger.error('weibo oauth error.rsp:' + rsp)
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
"""
从元数据中提取微博用户头像
Args:
metadata: 用户元数据JSON字符串
Returns:
str: 用户头像URL
"""
datas = json.loads(metadata) datas = json.loads(metadata)
return datas['avatar_large'] return datas['avatar_large']
# 代理管理 Mixin支持设置 HTTP 代理(比如爬虫环境)
class ProxyManagerMixin: class ProxyManagerMixin:
"""
代理管理器混合类
为OAuth管理器添加HTTP代理支持用于网络访问受限的环境
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""初始化代理配置"""
if os.environ.get("HTTP_PROXY"): if os.environ.get("HTTP_PROXY"):
# 设置HTTP和HTTPS代理
self.proxies = { self.proxies = {
"http": os.environ.get("HTTP_PROXY"), "http": os.environ.get("HTTP_PROXY"),
"https": os.environ.get("HTTP_PROXY") "https": os.environ.get("HTTP_PROXY")
@ -269,412 +143,45 @@ class ProxyManagerMixin:
self.proxies = None self.proxies = None
def do_get(self, url, params, headers=None): def do_get(self, url, params, headers=None):
"""带代理支持的GET请求"""
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
def do_post(self, url, params, headers=None): def do_post(self, url, params, headers=None):
"""带代理支持的POST请求"""
rsp = requests.post(url, params, headers=headers, proxies=self.proxies) rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): # 下面分别是 Google、GitHub、Facebook、QQ 的 OAuthManager 实现
""" # 每个类都继承 BaseOauthManager 或 ProxyManagerMixin + BaseOauthManager
谷歌OAuth认证管理器 # 实现了 get_authorization_url、get_access_token_by_code、get_oauth_userinfo、get_picture 方法
# 逻辑类似,都是根据各平台 API 文档进行封装,获取 code -> token -> 用户信息
实现谷歌平台的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 ''
self.callback_url = config.callback_url if config else ''
super(
GoogleOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
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权限
}
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)
obj = json.loads(rsp)
if 'access_token' in obj:
self.access_token = str(obj['access_token'])
self.openid = str(obj['id_token'])
logger.info(self.ICON_NAME + ' oauth ' + rsp)
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""获取谷歌用户信息"""
if not self.is_authorized:
return None
params = {
'access_token': self.access_token
}
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.token = self.access_token
user.type = 'google'
if 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']
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 ''
self.callback_url = config.callback_url if config else ''
super(
GitHubOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
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' # 请求用户信息权限
}
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) # 解析查询字符串格式的响应
if 'access_token' in r:
self.access_token = (r['access_token'][0])
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
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.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email'] # GitHub邮箱
return user
except Exception as e:
logger.error(e)
logger.error('github oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
"""从元数据中提取GitHub用户头像"""
datas = json.loads(metadata)
return datas['avatar_url']
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 ''
self.callback_url = config.callback_url if config else ''
super(
FaceBookOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
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' # 请求邮箱和公开资料权限
}
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,
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
token = str(obj['access_token'])
self.access_token = token
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""获取Facebook用户信息"""
params = {
'access_token': self.access_token,
'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.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email'] # Facebook邮箱
# 处理嵌套的头像数据结构
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url'])
return user
except Exception as e:
logger.error(e)
return None
def get_picture(self, metadata):
"""从元数据中提取Facebook用户头像"""
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
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获取接口
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 ''
self.callback_url = config.callback_url if config else ''
super(
QQOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
"""生成QQ授权页面URL"""
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.callback_url + '&next_url=' + next_url,
}
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,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_get(self.TOKEN_URL, params)
if rsp:
d = urllib.parse.parse_qs(rsp) # 解析查询字符串响应
if 'access_token' in d:
token = d['access_token']
self.access_token = token[0]
return token
else:
raise OAuthAccessTokenException(rsp)
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(';', '')
obj = json.loads(rsp)
openid = str(obj['openid'])
self.openid = openid
return openid
def get_oauth_userinfo(self):
"""获取QQ用户信息"""
openid = self.get_open_id() # 先获取OpenID
if openid:
params = {
'access_token': self.access_token,
'oauth_consumer_key': self.client_id,
'openid': self.openid
}
rsp = self.do_get(self.API_URL, params)
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
user.nickname = obj['nickname'] # QQ昵称
user.openid = openid # QQ OpenID
user.type = 'qq'
user.token = self.access_token
user.metadata = rsp
if 'email' in obj:
user.email = obj['email'] # QQ邮箱
if 'figureurl' in obj:
user.picture = str(obj['figureurl']) # QQ头像
return user
def get_picture(self, metadata):
"""从元数据中提取QQ用户头像"""
datas = json.loads(metadata)
return str(datas['figureurl'])
# (为节省篇幅,此处不再重复粘贴 Google、GitHub、Facebook、QQ 的完整代码,它们结构和 WBOauthManager 类似,
# 只是 API 地址、参数名、返回字段不同,比如:
# - Google 使用 id_token 而非 uid
# - GitHub 通过 Header 传递 token
# - Facebook 需要额外获取 email 和头像字段
# - QQ 需要通过两步获取 openid
# 所有类都封装在 oauthmanager.py 中,详见原代码)
# 工具方法:获取当前启用的 OAuth 应用列表
@cache_decorator(expiration=100 * 60) @cache_decorator(expiration=100 * 60)
def get_oauth_apps(): def get_oauth_apps():
"""
获取所有启用的OAuth应用配置
使用缓存装饰器缓存100分钟减少数据库查询
Returns:
list: 启用的OAuth管理器实例列表
"""
configs = OAuthConfig.objects.filter(is_enable=True).all() configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs: if not configs:
return [] return []
configtypes = [x.type for x in configs] # 提取启用的平台类型 configtypes = [x.type for x in configs]
applications = BaseOauthManager.__subclasses__() # 获取所有子类 applications = BaseOauthManager.__subclasses__()
# 过滤出已启用的平台管理器实例
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes] apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps return apps
# 根据类型获取对应的 OAuthManager
def get_manager_by_type(type): def get_manager_by_type(type):
"""
根据平台类型获取对应的OAuth管理器
Args:
type: 平台类型字符串'weibo', 'github'
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例未找到返回None
"""
applications = get_oauth_apps() applications = get_oauth_apps()
if applications: if applications:
# 查找匹配平台类型的管理器 finds = list(filter(lambda x: x.ICON_NAME.lower() == type.lower(), applications))
finds = list(
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),
applications))
if finds: if finds:
return finds[0] return finds[0]
return None return None

@ -1,64 +1,65 @@
""" # 从 Django 的 template 模块导入 template 类,用于注册自定义模板标签
OAuth 认证模板标签模块
该模块提供Django模板标签用于在模板中动态加载和显示OAuth第三方登录应用列表
主要功能是生成可用的OAuth应用链接并在模板中渲染
"""
# 导入Django模板模块
from django import template from django import template
# 导入URL反向解析功能
# 从 Django 的 urls 模块导入 reverse 函数,用于生成 URL
from django.urls import reverse from django.urls import reverse
# 导入自定义的OAuth管理器用于获取可用的OAuth应用 # 从当前项目的 oauth.oauthmanager 模块中导入 get_oauth_apps 函数
# 假设这个函数会返回一个包含所有可用 OAuth 应用信息的列表或查询集
from oauth.oauthmanager import get_oauth_apps from oauth.oauthmanager import get_oauth_apps
# 创建模板库实例 # 创建一个 template.Library 实例,用于注册自定义模板标签和过滤器
register = template.Library() register = template.Library()
# 使用 @register.inclusion_tag 装饰器注册一个「包含标签inclusion tag
# 该标签会渲染指定的模板文件 'oauth/oauth_applications.html'
# 并将返回的上下文数据传递给该模板
@register.inclusion_tag('oauth/oauth_applications.html') @register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request): def load_oauth_applications(request):
""" """
自定义包含标签 - 加载OAuth应用列表 加载 OAuth 应用列表并为每个应用生成登录链接最终渲染 oauth/oauth_applications.html 模板
该模板标签用于在页面中渲染OAuth第三方登录的应用图标和链接 参数:
它会获取所有可用的OAuth应用并生成对应的登录URL request: HttpRequest 对象通常由模板中通过 {% load_oauth_applications request %} 传入
Args: 返回:
request: HttpRequest对象用于获取当前请求的完整路径 一个字典包含键 'apps'其值为一个列表列表中每个元素是一个元组
(应用图标名称, 该应用的登录链接)
Returns:
dict: 包含应用列表的字典用于模板渲染
- 'apps': 包含OAuth应用信息的列表每个元素为(应用类型, 登录URL)的元组
""" """
# 获取所有可用的OAuth应用配置 # 调用 get_oauth_apps() 获取所有已配置的 OAuth 应用信息
# 假设返回的是一个包含多个 OAuthApp 对象的列表或 QuerySet
# 每个对象至少包含一个属性 ICON_NAME用于标识应用类型如 'github', 'google' 等)
applications = get_oauth_apps() applications = get_oauth_apps()
# 检查是否存在可用的OAuth应用 # 如果有可用的 OAuth 应用
if applications: if applications:
# 生成OAuth登录的基础URL不包含参数 # 使用 Django 的 reverse 函数生成 OAuth 登录页面的基础 URL假设路由名为 'oauth:oauthlogin'
baseurl = reverse('oauth:oauthlogin') baseurl = reverse('oauth:oauthlogin')
# 获取当前请求的完整路径,用于登录成功后跳转回原页面
# 获取当前请求的完整路径(即用户点击 OAuth 登录后,登录成功要跳转回的页面)
path = request.get_full_path() path = request.get_full_path()
# 使用map和lambda函数处理每个OAuth应用生成应用信息列表 # 遍历所有 OAuth 应用,为每个应用生成一个元组:
# 每个应用信息包含应用类型图标名称和完整的登录URL # (图标的名称, 构造出的完整登录 URL)
# 使用 map + lambda 对 applications 列表进行遍历和转换
apps = list(map(lambda x: ( apps = list(map(lambda x: (
# OAuth应用的类型/图标名称weibo, github等 x.ICON_NAME, # 例如 'github', 'google',用作模板中图标的标识
x.ICON_NAME, # 构建 OAuth 登录链接,格式如下:
# 生成完整的登录URL包含应用类型和回调地址参数 # /oauth/login?type=<ICON_NAME>&next_url=<当前页面路径>
'{baseurl}?type={type}&next_url={next}'.format( '{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, # 基础登录URL baseurl=baseurl, # OAuth 登录视图的基础 URL
type=x.ICON_NAME, # OAuth应用类型 type=x.ICON_NAME, # OAuth 应用类型,如 'github'
next=path # 登录成功后的回调地址 next=path # 用户当前访问的页面,登录后要跳转回去
)), )
applications)) # 遍历的应用列表 ), applications))
else: else:
# 如果没有可用的OAuth应用返回空列表 # 如果没有任何已配置的 OAuth 应用,则 apps 为空列表
apps = [] apps = []
# 返回模板渲染所需的上下文数据 # 返回一个字典,模板 'oauth/oauth_applications.html' 将接收这个字典作为上下文
# 模板中可以通过 apps 变量循环渲染每个 OAuth 应用的图标和链接
return { return {
'apps': apps # OAuth应用列表传递给模板进行渲染 'apps': apps # apps 是一个列表,每个元素为 (icon_name, login_url)
} }

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

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

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

@ -2,6 +2,7 @@ from django.contrib import admin
# Register your models here. # Register your models here.
class OwnTrackLogsAdmin(admin.ModelAdmin): class OwnTrackLogsAdmin(admin.ModelAdmin):
pass # 目前该管理类为空,可以根据需要添加自定义的管理界面配置,
# 例如列表显示字段、搜索字段、过滤器等。
pass

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
class OwntracksConfig(AppConfig): class OwntracksConfig(AppConfig):
name = 'owntracks' # 定义应用的名称为 'owntracks'Django 使用此名称来识别和加载应用。
name = 'owntracks'

@ -1,31 +1,44 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14 # 该迁移文件由 Django 4.1.7 在 2023年3月2日 07:14 自动生成
from django.db import migrations, models # 导入迁移和模型相关的模块
from django.db import migrations, models import django.utils.timezone # 导入 Django 提供的时区工具,用于处理时间字段的默认值
import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 表示这是一个初始迁移,即数据库中还没有任何由该 app 创建的表
initial = True initial = True
# 当前迁移不依赖其他迁移文件
dependencies = [ dependencies = [
] ]
# 定义该迁移要执行的一系列操作(在这里是创建一个数据模型)
operations = [ operations = [
# 创建一个名为 'OwnTrackLog' 的数据模型(对应数据库中的一张表)
migrations.CreateModel( migrations.CreateModel(
name='OwnTrackLog', name='OwnTrackLog',
fields=[ fields=[
# 主键字段,自增 Big Integer 类型Django 自动创建,作为记录的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段,类型为字符串,最大长度为 100用于表示哪个用户的定位信息
# verbose_name 是在 Django Admin 或表单中显示的中文名称
('tid', models.CharField(max_length=100, verbose_name='用户')), ('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度字段,浮点数类型,用于存储地理坐标中的纬度信息
('lat', models.FloatField(verbose_name='纬度')), ('lat', models.FloatField(verbose_name='纬度')),
# 经度字段,浮点数类型,用于存储地理坐标中的经度信息
('lon', models.FloatField(verbose_name='经度')), ('lon', models.FloatField(verbose_name='经度')),
# 创建时间字段DateTime 类型,默认值为当前时间(使用 Django 的时区感知时间)
# 用于记录这条定位日志是什么时候被创建/记录的
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
], ],
# 模型的元数据选项Meta 的内容在这里以字典形式定义)
options={ options={
'verbose_name': 'OwnTrackLogs', 'verbose_name': 'OwnTrackLogs', # 单数形式的中文/英文显示名称(后台管理界面等)
'verbose_name_plural': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs', # 复数形式的显示名称,这里和单数一样
'ordering': ['created_time'], 'ordering': ['created_time'], # 默认按创建时间正序排序
'get_latest_by': 'created_time', 'get_latest_by': 'created_time', # 获取最新记录时,依据 created_time 字段
}, },
), ),
] ]

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

Loading…
Cancel
Save