Compare commits

...

35 Commits

Author SHA1 Message Date
安琪 f9d02a058e 11周作业
3 months ago
安琪 a4949ef7ea 11周任务
3 months ago
安琪 c73b0538c7 提交视频讲解
3 months ago
安琪 377c5c2b2b 9-10周作业
3 months ago
安琪 43162d27a3 Merge branch 'develop'
3 months ago
安琪 f12f581857 优化后源码
3 months ago
安琪 51087a3782 删除
3 months ago
安琪 6a7b51a781 优化后源码
3 months ago
安琪 fbbe9f0585 Merge branch 'xh_branch' into develop
3 months ago
安琪 ab7cd92010 Merge branch 'yrh-branch' into develop
3 months ago
xh fad867dc28 9-10周熊欢个人任务.docx
3 months ago
yrh 186c359935 9-10周 杨若涵个人作业.docx
3 months ago
安琪 25a47090e1 Merge branch 'xyr-branch' into develop
3 months ago
安琪 171bb30b8c 删除错误文件
3 months ago
xyr 0c8dc9e84f 9-10周徐悦然个人任务.docx
3 months ago
安琪 7174e204d4 优化后源码
3 months ago
安琪 c0c78b08c0 Merge branch 'AQ_branch' into develop
3 months ago
安琪 5acc335c22 9-10周个人任务
3 months ago
安琪 b3ee23b29f 作业
4 months ago
安琪 85c232d991 删除空白文档
4 months ago
安琪 b652fa9a08 Merge branch 'AQ_branch' into develop
4 months ago
安琪 5c366b3c82 解决admin.py的合并冲突
4 months ago
安琪 f82823537c 7-8周作业
4 months ago
yrh 57bd3c3ef1 new things
4 months ago
安琪 f8f5a4fa87 Merge branch 'xh_branch' into develop
4 months ago
安琪 ed272a2076 Merge branch 'xyr-branch' into develop
4 months ago
安琪 82a328f3db Merge branch 'yrh-branch' into develop
4 months ago
安琪 efa1e69237 提交
4 months ago
yrh bc3a589743 删除实验文档
4 months ago
yrh 1fa113b02d 添加实验文档
4 months ago
安琪 14e865e94b Merge branch 'AQ_branch' into develop
4 months ago
yrh aefccf2270 新的修改代码
4 months ago
xh 8b94499a82 代码注释
4 months ago
xyr 39ff328da5 代码标注
4 months ago
安琪 3c8a12f23b 提交代码批注
4 months ago

Binary file not shown.

@ -5,6 +5,7 @@
"version": "0.2.0",
"configurations": [
{
"name": "Python 调试程序: 当前文件",

@ -1,9 +1,15 @@
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
from django import forms
from django.contrib.auth.admin import UserAdmin # 导入Django默认的用户管理Admin类基础模板
<<<<<<< HEAD
=======
from django import forms
<<<<<<< HEAD
from django.contrib.auth.admin import UserAdmin # 导入Django默认的用户管理Admin类基础模板
>>>>>>> f82823537c318f20f273c53570c0d37f22c7a751
from django.contrib.auth.forms import UserChangeForm # 导入默认的用户编辑表单(用于继承修改)
from django.contrib.auth.forms import UsernameField# 用户名字段的专用类(自带验证逻辑)
from django.utils.translation import gettext_lazy as _# 国际化支持,文本可翻译
@ -18,6 +24,7 @@ from django.utils.translation import gettext_lazy as _# 导入国际化翻译工
# Register your models here.
from .models import BlogUser # 导入自定义的用户模型替代Django默认User
=======
<<<<<<< HEAD
from django.contrib.auth.forms import UserChangeForm # 导入默认的用户编辑表单(用于继承修改)
from django.contrib.auth.forms import UsernameField # 用户名字段的专用类(自带验证逻辑)
from django.utils.translation import gettext_lazy as _ # 国际化支持,文本可翻译
@ -47,6 +54,16 @@ from .models import BlogUser# 从当前目录的 models 模块导入自定义的
from .models import BlogUser # 导入自定义的用户模型替代Django默认User)
<<<<<<< HEAD
=======
from django.contrib.auth.admin import UserAdmin # xh:导入Django默认的用户管理Admin类基础模板
from django.contrib.auth.forms import UserChangeForm # xh:导入默认的用户编辑表单(用于继承修改)
from django.contrib.auth.forms import UsernameField # xh:用户名字段的专用类(自带验证逻辑)
from django.utils.translation import gettext_lazy as _ # xh:国际化支持,文本可翻译
# Register your models here.
from .models import BlogUser # xh:导入自定义的用户模型替代Django默认User)
>>>>>>> f82823537c318f20f273c53570c0d37f22c7a751
>>>>>>> xh_branch
# 自定义用户创建表单(用于在后台添加新用户)
@ -55,6 +72,7 @@ class BlogUserCreationForm(forms.ModelForm):
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)# 确认密码字段(再次输入密码)
class Meta:
<<<<<<< HEAD
model = BlogUser # 绑定自定义的BlogUser模型
fields = ('email',) # 新增用户时,默认显示的核心字段(仅邮箱,其他字段可后续编辑)
# 密码验证逻辑:检查两次输入的密码是否一致
@ -72,6 +90,14 @@ class BlogUserCreationForm(forms.ModelForm):# 添加一个密码字段label
>>>>>>> psy_branch
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")# 获取第一个密码框的清洗后数据
=======
model = BlogUser # xh:绑定自定义的BlogUser模型
fields = ('email',) # xh:新增用户时,默认显示的核心字段(仅邮箱,其他字段可后续编辑)
def clean_password2(self):
# xh:密码验证逻辑:检查两次输入的密码是否一致
password1 = self.cleaned_data.get("password1")# xh:获取第一个密码框的清洗后数据
>>>>>>> xh_branch
password2 = self.cleaned_data.get("password2")
<<<<<<< HEAD
if password1 and password2 and password1 != password2:
@ -82,6 +108,20 @@ class BlogUserCreationForm(forms.ModelForm):# 添加一个密码字段label
raise forms.ValidationError(_("passwords do not match"))
return password2
=======
<<<<<<< HEAD
=======
from django import forms# 从 django 核心模块导入 forms 用于表单处理
from django.contrib.auth.admin import UserAdmin# 从 django 内置的 auth 管理模块导入 UserAdmin用于用户管理界面的定制
from django.contrib.auth.forms import UserChangeForm# 从 django 内置的 auth 表单模块导入 UserChangeForm用于用户信息修改表单的定制
from django.contrib.auth.forms import UsernameField# 从 django 内置的 auth 表单模块导入 UsernameField用于用户名字段的处理
from django.utils.translation import gettext_lazy as _# 从 django 翻译工具模块导入 gettext_lazy 并别名成 _用于实现国际化翻译延迟翻译
<<<<<<< HEAD
# Register your models here.
from .models import BlogUser# 从当前目录的 models 模块导入自定义的 BlogUser 模型
>>>>>>> f82823537c318f20f273c53570c0d37f22c7a751
class BlogUserCreationForm(forms.ModelForm):# 定义 BlogUserCreationForm 类,继承自 forms.ModelForm用于创建 BlogUser 的表单
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) # 定义 password1 字段,类型为 CharField标签为“password”小部件使用 PasswordInput密码输入框内容会隐藏
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)# 定义 password2 字段,类型为 CharField标签为“Enter password again”小部件同样使用 PasswordInput
@ -106,6 +146,13 @@ class BlogUserCreationForm(forms.ModelForm):# 定义 BlogUserCreationForm 类,
>>>>>>> psy_branch
# Save the provided password in hashed format
<<<<<<< HEAD
<<<<<<< HEAD
=======
=======
def save(self, commit=True):
#xh:将提供的密码以哈希格式保存
>>>>>>> xh_branch
>>>>>>> f82823537c318f20f273c53570c0d37f22c7a751
user = super().save(commit=False)
# 使用 Django 内置方法加密密码
user.set_password(self.cleaned_data["password1"])

@ -1,5 +1,8 @@
from django.apps import AppConfig
from django.apps import AppConfig # xh:导入Django的应用配置基类
class AccountsConfig(AppConfig):
#xh:Accounts应用的配置类,继承自Django的AppConfig基类用于配置accounts应用的各种设置
# xh:应用名称Django使用这个名称来识别应用
# xh:必须与应用的目录名保持一致
name = 'accounts'

@ -9,62 +9,91 @@ from .models import BlogUser
class LoginForm(AuthenticationForm):
#xh:用户登录表单,继承自Django内置的AuthenticationForm提供用户认证功能
def __init__(self, *args, **kwargs):
#xh:初始化方法自定义表单字段的widget属性,设置输入框的placeholder和CSS类
super(LoginForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# xh:设置用户名字段的输入框属性
self.fields['username'].widget =widgets.TextInput(
attrs={
'placeholder': "username", # xh:占位符文本
"class": "form-control" # xh:Bootstrap样式类
})
# 设置密码字段的输入框属性
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
attrs={
'placeholder': "password", # xh:占位符文本
"class": "form-control" # xh:Bootstrap样式类
})
class RegisterForm(UserCreationForm):
#xh:用户注册表单,继承自Django内置的UserCreationForm提供用户注册功能,自动包含密码验证逻辑(密码强度、两次密码一致性等)
def __init__(self, *args, **kwargs):
#xh:初始化方法自定义所有表单字段的widget属性
super(RegisterForm, self).__init__(*args, **kwargs)
# xh:设置用户名字段的输入框属性
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# xh:设置邮箱字段的输入框属性
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# xh:设置密码字段的输入框属性
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# xh:设置确认密码字段的输入框属性
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
#xh:邮箱字段验证方法,确保邮箱地址在系统中唯一
email = self.cleaned_data['email']
# xh:检查邮箱是否已存在
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
model = get_user_model()
fields = ("username", "email")
#xh:表单元数据配置"""
model = get_user_model() # xh:使用项目中配置的用户模型
fields = ("username", "email") # xh:表单包含的字段
class ForgetPasswordForm(forms.Form):
#xh:忘记密码表单(用于验证码和密码重置),包含新密码设置、邮箱验证和验证码验证
# xh:新密码字段
new_password1 = forms.CharField(
label=_("New password"),
label=_("New password"), # xh:字段标签(支持国际化)
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
"class": "form-control", # xh:Bootstrap样式
'placeholder': _("New password") # xh:占位符文本
}
),
)
# xh:确认密码字段
new_password2 = forms.CharField(
label="确认密码",
label="确认密码", # xh:中文标签
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
'placeholder': _("Confirm password") # xh:英文占位符
}
),
)
# xh:邮箱字段(用于验证用户身份)
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
label='邮箱', # xh:中文标签
widget=forms.TextInput( # xh:使用TextInput而不是EmailInput以便更好地控制样式
attrs={
'class': 'form-control',
'placeholder': _("Email")
@ -72,8 +101,9 @@ class ForgetPasswordForm(forms.Form):
),
)
# xh:验证码字段(用于二次验证)
code = forms.CharField(
label=_('Code'),
label=_('Code'), # xh:验证码标签
widget=forms.TextInput(
attrs={
'class': 'form-control',
@ -83,35 +113,44 @@ class ForgetPasswordForm(forms.Form):
)
def clean_new_password2(self):
#xh:确认密码验证方法,验证两次输入的密码是否一致,并检查密码强度
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
# xh:检查两次密码是否一致
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
# xh:使用Django内置的密码验证器验证密码强度
password_validation.validate_password(password2)
return password2
def clean_email(self):
#xh:邮箱验证方法,验证邮箱是否在系统中已注册
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
# xh:检查邮箱是否存在(是否已注册)
if not BlogUser.objects.filter(email=user_email).exists():
# TODO: 这里的报错提示可能会暴露邮箱是否注册,根据安全需求可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
#xh:验证码验证方法,调用utils模块的验证函数检查验证码是否正确
code = self.cleaned_data.get("code")
# xh:调用验证工具函数检查验证码
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
email=self.cleaned_data.get("email"), # xh:传入邮箱
code=code, # xh:传入验证码
)
if error:
raise ValidationError(error)
raise ValidationError(error) # xh:验证失败,抛出错误
return code
class ForgetPasswordCodeForm(forms.Form):
#xh:忘记密码验证码请求表单,简化版表单,仅用于请求发送密码重置验证码
email = forms.EmailField(
label=_('Email'),
)
label=_('Email'), # xh:只需要邮箱字段来请求发送验证码
)

@ -1,4 +1,5 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# xh:自动生成的Django迁移文件记录数据库结构变更
import django.contrib.auth.models
import django.contrib.auth.validators
@ -7,43 +8,102 @@ import django.utils.timezone
class Migration(migrations.Migration):
#xh:数据库迁移类用于创建BlogUser用户模型
initial = True
initial = True # xh:标记为初始迁移
# xh:依赖的迁移文件
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('auth', '0012_alter_user_first_name_max_length'), # xh:依赖Django的auth应用
]
operations = [
migrations.CreateModel(
name='BlogUser',
name='BlogUser', # xh:创建BlogUser模型表
fields=[
# xh:主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# xh:Django认证系统必需的密码字段
('password', models.CharField(max_length=128, verbose_name='password')),
# xh:最后登录时间,可为空
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
# xh:超级用户标志
('is_superuser', models.BooleanField(default=False,
help_text='Designates that this user has all permissions without explicitly assigning them.',
verbose_name='superuser status')),
# xh:用户名字段,具有唯一性验证和字符限制
('username', models.CharField(
error_messages={'unique': 'A user with that username already exists.'},
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name='username')),
# xh:名字字段,可为空
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# xh:姓氏字段,可为空
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# xh:邮箱字段,可为空
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
# xh:员工状态决定是否可以登录admin后台
('is_staff', models.BooleanField(default=False,
help_text='Designates whether the user can log into this admin site.',
verbose_name='staff status')),
# xh:活跃状态,用于软删除
('is_active', models.BooleanField(default=True,
help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
verbose_name='active')),
# xh:账户创建时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# xh:自定义字段:昵称
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 自定义字段:记录创建时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# xh:自定义字段:最后修改时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# xh:自定义字段:用户创建来源(如:网站注册、第三方登录等)
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
# xh:用户组多对多关系
('groups', models.ManyToManyField(blank=True,
help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
related_name='user_set',
related_query_name='user',
to='auth.group',
verbose_name='groups')),
# xh:用户权限多对多关系
('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')),
],
# xh:模型元数据配置
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
'verbose_name': '用户', # xh:单数显示名称
'verbose_name_plural': '用户', # xh:复数显示名称
'ordering': ['-id'], # xh:默认按ID降序排列
'get_latest_by': 'id', # xh:指定获取最新记录的字段
},
# xh:指定模型管理器
managers=[
('objects', django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()), # xh:使用Django默认的用户管理器
],
),
]
]

@ -1,46 +1,81 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# xh:自动生成的Django迁移文件用于修改BlogUser模型结构
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
#xh:数据库迁移类用于修改BlogUser模型的字段和配置
# xh:依赖之前的初始迁移文件
dependencies = [
('accounts', '0001_initial'),
('accounts', '0001_initial'), # xh:依赖accounts应用的初始迁移
]
operations = [
# xh:修改模型的元数据选项
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
options={
'get_latest_by': 'id', # xh:指定按id字段获取最新记录
'ordering': ['-id'], # xh:按id降序排列
'verbose_name': 'user', # xh:修改单数显示名称为英文'user'
'verbose_name_plural': 'user', # xh:修改复数显示名称为英文'user'
},
),
# xh:删除旧的创建时间字段
migrations.RemoveField(
model_name='bloguser',
name='created_time',
name='created_time', # xh:移除原有的created_time字段
),
# xh:删除旧的修改时间字段
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
name='last_mod_time', # xh:移除原有的last_mod_time字段
),
# xh:添加新的创建时间字段(重命名)
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
field=models.DateTimeField(
default=django.utils.timezone.now, # xh:默认值为当前时间
verbose_name='creation time' # xh:字段显示名称为英文
),
),
# xh:添加新的最后修改时间字段(重命名)
migrations.AddField(
model_name='bloguser',
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, # xh:默认值为当前时间
verbose_name='last modify time' # xh:字段显示名称为英文
),
),
# xh:修改昵称字段的显示名称
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
field=models.CharField(
blank=True,
max_length=100,
verbose_name='nick name' # xh:从中文'昵称'改为英文'nick name'
),
),
# xh:修改来源字段的显示名称
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
field=models.CharField(
blank=True,
max_length=100,
verbose_name='create source' # xh:从中文'创建来源'改为英文'create source'
),
),
]
]

@ -1,35 +1,64 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
# xh:导入必要的模块
from django.contrib.auth.models import AbstractUser # xh:Django抽象用户基类
from django.db import models # xh:Django模型字段
from django.urls import reverse # xh:URL反向解析
from django.utils.timezone import now # xh:获取当前时间
from django.utils.translation import gettext_lazy as _ # xh:国际化翻译
from djangoblog.utils import get_current_site # xh:自定义工具函数,获取当前站点
# Create your models here.
class BlogUser(AbstractUser):
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
source = models.CharField(_('create source'), max_length=100, blank=True)
#xh:自定义用户模型,继承自Django的AbstractUser扩展了博客系统的用户功能
# xh:昵称字段 - 用户的显示名称
nickname = models.CharField(
_('nick name'), # xh:字段显示名称(支持国际化)
max_length=100, # xh:最大长度100字符
blank=True # xh:允许为空
)
# xh:创建时间 - 记录用户注册时间
creation_time = models.DateTimeField(
_('creation time'), # xh:字段显示名称
default=now # xh:默认值为当前时间
)
# xh:最后修改时间 - 记录用户信息最后修改时间
last_modify_time = models.DateTimeField(
_('last modify time'), # xh:字段显示名称
default=now # xh:默认值为当前时间
)
# xh:创建来源 - 记录用户的注册渠道
source = models.CharField(
_('create source'), # xh:字段显示名称
max_length=100, # xh:最大长度100字符
blank=True # xh:允许为空
)
def get_absolute_url(self):
#xh:获取用户的绝对URL用于后台管理等场景,返回用户详情页的URL
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
'blog:author_detail', # xh:URL配置的名称
kwargs={'author_name': self.username} # xh:传递用户名参数
)
def __str__(self):
return self.email
#xh:对象的字符串表示方法,在Django admin和管理界面中显示
return self.email # xh:使用邮箱作为对象的显示标识
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
#xh:获取用户的完整URL包含域名,用于生成可在站外访问的完整用户链接
site = get_current_site().domain # xh:获取当前站点的域名
url = "https://{site}{path}".format(
site=site, # xh:域名
path=self.get_absolute_url() # xh:相对路径
)
return url
class Meta:
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'
#xh:模型的元数据配置,定义模型的各种行为和显示选项
ordering = ['-id'] # xh:默认按ID降序排列最新的在前
verbose_name = _('user') # xh:单数显示名称
verbose_name_plural = verbose_name # xh:复数显示名称(与单数相同)
get_latest_by = 'id' # xh:指定按id字段获取最新记录

@ -9,81 +9,104 @@ from djangoblog.utils import *
from . import utils
# Create your tests here.
class AccountTest(TestCase):
#xh:用户账户功能测试类,测试用户注册、登录、密码重置等核心功能
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
#xh:测试前置设置方法,在每个测试方法执行前运行,用于创建测试数据
self.client = Client() # xh:Django测试客户端用于模拟HTTP请求
self.factory = RequestFactory() # xh:用于创建请求对象
# xh:创建测试用户
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
self.new_test = "xxx123--=" # xh:测试用的新密码
def test_validate_account(self):
#xh:测试账户验证功能,验证超级用户创建、登录、文章管理等功能
site = get_current_site().domain
# xh:创建超级用户
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
# xh:测试用户登录
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True)
self.assertEqual(loginresult, True) # xh:断言登录成功
# xh:测试访问管理员页面
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200) # xh:断言可以访问admin
# xh:创建测试分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# xh:创建测试文章
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # xh:文章类型
article.status = 'p' # xh:发布状态
article.save()
# xh:测试访问文章管理页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200) # xh:断言可以访问文章管理页
def test_validate_register(self):
#xh:测试用户注册流程,验证用户注册、邮箱验证、登录、权限管理等完整流程
# xh:验证注册前用户不存在
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
0, len(BlogUser.objects.filter(email='user123@user.com')))
# xh:提交注册表单
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',
'password1': 'password123!q@wE#R$T', # xh:密码1
'password2': 'password123!q@wE#R$T', # xh:确认密码
})
# xh:验证用户创建成功
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
1, len(BlogUser.objects.filter(email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
# xh:生成邮箱验证签名(模拟验证流程)
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)
# xh:访问验证结果页面
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# xh:测试用户登录
self.client.login(username='user1233', password='password123!q@wE#R$T')
# xh:提升用户权限为超级用户
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
user.is_staff = True
user.save()
delete_sidebar_cache()
delete_sidebar_cache() # xh:清理侧边栏缓存
# xh:创建测试数据
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
@ -95,90 +118,110 @@ class AccountTest(TestCase):
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
# xh:测试文章管理页面访问
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# xh:测试用户登出
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
self.assertIn(response.status_code, [301, 302, 200]) # xh:重定向或成功
# xh:登出后访问管理页面应该被重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# xh:测试错误密码登录
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
'password': 'password123' # xh:错误密码
})
self.assertIn(response.status_code, [301, 302, 200])
# xh:错误密码登录后仍无法访问管理页面
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
#xh:测试邮箱验证码功能,验证验证码的生成、发送和验证流程
to_email = "admin@admin.com"
code = generate_code()
code = generate_code() # xh:生成验证码
# xh:保存验证码到缓存/数据库
utils.set_code(to_email, code)
# xh:发送验证邮件(测试环境可能不会实际发送)
utils.send_verify_email(to_email, code)
# xh:测试正确验证码验证
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
self.assertEqual(err, None) # xh:断言验证成功(无错误)
# xh:测试错误邮箱验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
self.assertEqual(type(err), str) # xh:断言返回错误信息
def test_forget_password_email_code_success(self):
#xh:测试忘记密码验证码请求成功情况
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
data=dict(email="admin@admin.com") # xh:正确邮箱格式
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
self.assertEqual(resp.status_code, 200) # xh:断言请求成功
self.assertEqual(resp.content.decode("utf-8"), "ok") # xh:断言返回成功消息
def test_forget_password_email_code_fail(self):
#xh:测试忘记密码验证码请求失败情况,验证空数据和错误邮箱格式的处理
# xh:测试空数据提交
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
data=dict() # xh:空数据
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# xh:测试错误邮箱格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
data=dict(email="admin@com") # xh:无效邮箱格式
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
#xh:测试密码重置成功流程,验证完整的密码重置功能
code = generate_code()
utils.set_code(self.blog_user.email, code)
utils.set_code(self.blog_user.email, code) # xh:设置验证码
# xh:构造密码重置数据
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
new_password1=self.new_test, # xh:新密码
new_password2=self.new_test, # xh:确认密码
email=self.blog_user.email, # xh:用户邮箱
code=code, # xh:正确验证码
)
# xh:提交密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.status_code, 302) # xh:断言重定向(成功)
# 验证用户密码是否修改成功
# xh:验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
self.assertNotEqual(blog_user, None) # xh:断言用户存在
self.assertEqual(blog_user.check_password(data["new_password1"]), True) # xh:断言密码修改成功
def test_forget_password_email_not_user(self):
#xh:测试不存在的用户密码重置,验证系统对不存在用户的处理
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
email="123@123.com", # xh:不存在的邮箱
code="123456",
)
resp = self.client.post(
@ -186,22 +229,22 @@ class AccountTest(TestCase):
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) # xh:断言返回错误页面(非重定向)
def test_forget_password_email_code_error(self):
#xh:测试验证码错误情况,验证错误验证码的处理
code = generate_code()
utils.set_code(self.blog_user.email, code)
utils.set_code(self.blog_user.email, code) # xh:设置正确验证码
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
code="111111", # xh:错误验证码
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) # xh:断言验证失败(非重定向)

@ -1,28 +1,54 @@
from django.urls import path
from django.urls import re_path
# xh:导入URL路由相关的模块
from django.urls import path # xh:用于简单路径匹配
from django.urls import re_path # xh:用于正则表达式路径匹配
from . import views
from .forms import LoginForm
# xh:导入当前应用的视图和表单
from . import views # xh:导入当前目录下的views模块
from .forms import LoginForm # xh:导入自定义登录表单
# xh:应用命名空间用于URL反向解析时区分不同应用的相同URL名称
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]
# xh:URL模式配置列表定义URL路径与视图的映射关系
urlpatterns = [
# xh:用户登录路由 - 使用正则表达式匹配
re_path(r'^login/$', # xh:匹配以/login/结尾的URL
views.LoginView.as_view( # xh:使用类视图,转换为视图函数
success_url='/' # xh:登录成功后的重定向URL首页
),
name='login', # xh:URL名称用于反向解析
kwargs={'authentication_form': LoginForm} # xh:额外参数:指定登录表单
),
# xh:用户注册路由
re_path(r'^register/$', # xh:匹配以/register/结尾的URL
views.RegisterView.as_view(
success_url="/" # xh:注册成功后的重定向URL首页
),
name='register' # xh:URL名称register
),
# xh:用户登出路由
re_path(r'^logout/$', # xh:匹配以/logout/结尾的URL
views.LogoutView.as_view(), # xh:登出视图,无额外参数
name='logout' # xh:URL名称logout
),
# xh:账户操作结果页面路由 - 使用path更简洁的路径语法
path(r'account/result.html', # xh:匹配固定路径/account/result.html
views.account_result, # xh:使用函数视图
name='result' # xh:URL名称result
),
# xh:忘记密码页面路由(提交密码重置表单)
re_path(r'^forget_password/$', # xh:匹配以/forget_password/结尾的URL
views.ForgetPasswordView.as_view(), # xh:密码重置视图
name='forget_password' # xh:URL名称forget_password
),
# xh:忘记密码验证码请求路由(获取验证码)
re_path(r'^forget_password_code/$', # xh:匹配以/forget_password_code/结尾的URL
views.ForgetPasswordEmailCode.as_view(), # xh:验证码请求视图
name='forget_password_code' # xh:URL名称forget_password_code
),
]

@ -1,26 +1,39 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
# xh:导入必要的模块
from django.contrib.auth import get_user_model # xh:获取项目中使用的用户模型
from django.contrib.auth.backends import ModelBackend # xh:Django认证后端基类
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
#xh:自定义认证后端,允许用户使用用户名或邮箱地址进行登录,继承自Django的ModelBackend扩展了认证方式
def authenticate(self, request, username=None, password=None, **kwargs):
#xh:用户认证方法,根据用户名或邮箱验证用户身份
# xh:判断输入的是邮箱还是用户名
if '@' in username:
# xh:如果包含@符号,按邮箱处理
kwargs = {'email': username}
else:
# xh:否则按用户名处理
kwargs = {'username': username}
try:
# xh:根据用户名或邮箱查找用户
user = get_user_model().objects.get(**kwargs)
# xh:验证密码是否正确(自动处理哈希比较)
if user.check_password(password):
return user
return user # xh:认证成功,返回用户对象
except get_user_model().DoesNotExist:
# xh:用户不存在返回None表示认证失败
return None
def get_user(self, username):
def get_user(self, user_id):
#xh:根据用户ID获取用户对象,用于会话认证,在请求处理期间获取用户信息
try:
return get_user_model().objects.get(pk=username)
# xh:根据主键ID查找用户
return get_user_model().objects.get(pk=user_id)
except get_user_model().DoesNotExist:
# xh:用户不存在返回None
return None

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

@ -1,86 +1,91 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.contrib.auth import get_user_model # aq: 导入获取用户模型的工具(适配自定义用户模型)
from django.urls import reverse # aq: 导入URL反向解析函数
from django.utils.html import format_html # aq: 导入HTML格式化工具用于生成Admin中的链接
from django.utils.translation import gettext_lazy as _ # aq: 导入国际化翻译函数
# Register your models here.
from .models import Article
from .models import Article # aq: 导入文章模型需确保Category、Tag等模型也已在models中定义
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class ArticleForm(forms.ModelForm): # aq: 文章的Admin表单类可自定义字段渲染逻辑
# body = forms.CharField(widget=AdminPagedownWidget()) # aq: 注释掉的Markdown编辑器组件配置
class Meta:
model = Article
fields = '__all__'
model = Article # aq: 关联的模型
fields = '__all__' # aq: 显示所有字段
# aq: 自定义Admin批量操作——将选中文章设为“已发布”状态
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
# aq: 自定义Admin批量操作——将选中文章设为“草稿”状态
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
# aq: 自定义Admin批量操作——关闭选中文章的评论
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
# aq: 自定义Admin批量操作——开放选中文章的评论
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
# aq: 批量操作的显示名称(支持国际化)
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
class ArticlelAdmin(admin.ModelAdmin): # aq: 文章的Admin管理类配置后台展示/操作逻辑)
list_per_page = 20 # aq: 每页显示20条数据
search_fields = ('body', 'title') # aq: 可搜索字段(文章正文、标题)
form = ArticleForm # aq: 使用自定义的ArticleForm表单
list_display = ( # aq: 列表页显示的字段
'id',
'title',
'author',
'link_to_category',
'link_to_category', # aq: 自定义字段——分类跳转链接
'creation_time',
'views',
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
filter_horizontal = ('tags',)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
list_display_links = ('id', 'title') # aq: 列表页可点击跳转的字段(链接到编辑页)
list_filter = ('status', 'type', 'category') # aq: 侧边筛选器字段
filter_horizontal = ('tags',) # aq: 多对多字段的水平选择器(标签字段)
exclude = ('creation_time', 'last_modify_time') # aq: 编辑页隐藏的字段(自动生成,无需手动输入)
view_on_site = True # aq: 显示“在站点上查看”按钮
actions = [ # aq: 启用的批量操作
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
def link_to_category(self, obj):
def link_to_category(self, obj): # aq: 自定义列表字段——分类名称带后台编辑链接
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name)) # aq: 修复原代码语法错误,正确拼接链接
link_to_category.short_description = _('category')
link_to_category.short_description = _('category') # aq: 自定义字段的显示名称
def get_form(self, request, obj=None, **kwargs):
def get_form(self, request, obj=None, **kwargs): # aq: 自定义表单——作者字段仅显示超级管理员
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
def save_model(self, request, obj, form, change): # aq: 保存模型时的钩子(此处调用父类方法,可扩展自定义逻辑)
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
def get_view_on_site_url(self, obj=None): # aq: 自定义“在站点上查看”的链接(跳转到文章前台详情页)
if obj:
url = obj.get_full_url()
return url
@ -90,23 +95,24 @@ class ArticlelAdmin(admin.ModelAdmin):
return site
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
class TagAdmin(admin.ModelAdmin): # aq: 标签的Admin管理类
exclude = ('slug', 'last_mod_time', 'creation_time') # aq: 隐藏自动生成的字段slug自动生成
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin): # aq: 分类的Admin管理类
list_display = ('name', 'parent_category', 'index') # aq: 列表页显示字段(名称、父分类、排序权重)
exclude = ('slug', 'last_mod_time', 'creation_time') # aq: 隐藏自动生成的字段
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin): # aq: 友情链接的Admin管理类
exclude = ('last_mod_time', 'creation_time') # aq: 隐藏自动生成的时间字段
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin): # aq: 侧边栏的Admin管理类
list_display = ('name', 'content', 'is_enable', 'sequence') # aq: 列表页显示字段(标题、内容、是否启用、排序)
exclude = ('last_mod_time', 'creation_time') # aq: 隐藏自动生成的时间字段
class BlogSettingsAdmin(admin.ModelAdmin):
class BlogSettingsAdmin(admin.ModelAdmin): # aq: 博客配置的Admin管理类使用默认配置
pass

@ -1,5 +1,5 @@
from django.apps import AppConfig
from django.apps import AppConfig # aq: 导入Django的应用配置基类
class BlogConfig(AppConfig):
name = 'blog'
class BlogConfig(AppConfig): # aq: 博客应用的配置类(用于定义应用元信息)
name = 'blog' # aq: 应用名称必须与应用目录名一致Django通过此识别应用

@ -1,43 +1,48 @@
import logging
from django.utils import timezone
from django.utils import timezone # aq: 导入时区时间工具
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
from djangoblog.utils import cache, get_blog_setting # aq: 导入缓存工具和获取博客配置的函数
from .models import Category, Article # aq: 导入分类、文章模型
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # aq: 初始化当前模块的日志对象
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
if value:
def seo_processor(requests): # aq: Django上下文处理器向模板注入SEO/站点配置信息
key = 'seo_processor' # aq: 缓存键名
value = cache.get(key) # aq: 尝试从缓存中获取数据
if value: # aq: 缓存存在则直接返回
return value
else:
logger.info('set processor cache.')
setting = get_blog_setting()
logger.info('set processor cache.') # aq: 记录缓存设置日志
setting = get_blog_setting() # aq: 获取博客的全局配置
# aq: 构造要注入模板的上下文数据
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_NAME': setting.site_name, # aq: 站点名称
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # aq: 是否显示Google广告
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # aq: Google广告代码
'SITE_SEO_DESCRIPTION': setting.site_seo_description, # aq: 站点SEO描述
'SITE_DESCRIPTION': setting.site_description, # aq: 站点描述
'SITE_KEYWORDS': setting.site_keywords, # aq: 站点关键词
# aq: 站点基础URL协议+域名)
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'ARTICLE_SUB_LENGTH': setting.article_sub_length, # aq: 文章摘要长度
'nav_category_list': Category.objects.all(), # aq: 导航分类列表
# aq: 导航页面列表(类型为页面、状态为已发布)
'nav_pages': Article.objects.filter(
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
'OPEN_SITE_COMMENT': setting.open_site_comment, # aq: 是否开放全站评论
'BEIAN_CODE': setting.beian_code, # aq: 备案号
'ANALYTICS_CODE': setting.analytics_code, # aq: 网站统计代码
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, # aq: 公安备案号
"SHOW_GONGAN_CODE": setting.show_gongan_code, # aq: 是否显示公安备案号
"CURRENT_YEAR": timezone.now().year, # aq: 当前年份(用于版权信息)
"GLOBAL_HEADER": setting.global_header, # aq: 公共头部HTML
"GLOBAL_FOOTER": setting.global_footer, # aq: 公共尾部HTML
"COMMENT_NEED_REVIEW": setting.comment_need_review, # aq: 评论是否需要审核
}
cache.set(key, value, 60 * 60 * 10)
return value
cache.set(key, value, 60 * 60 * 10) # aq: 缓存数据10小时
return value

@ -1,15 +1,17 @@
import time
import elasticsearch.client
from django.conf import settings
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
from elasticsearch_dsl.connections import connections
from django.conf import settings # aq: 导入Django项目配置
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean # aq: 导入Elasticsearch DSL的文档/字段类型
from elasticsearch_dsl.connections import connections # aq: 导入ES连接工具
from blog.models import Article
from blog.models import Article # aq: 导入博客文章模型
# aq: 判断是否启用Elasticsearch检查配置中是否有ELASTICSEARCH_DSL
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
# aq: 创建ES连接使用配置中的地址
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
@ -17,10 +19,11 @@ if ELASTICSEARCH_ENABLED:
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
c = IngestClient(es) # aq: 初始化ES的Ingest客户端用于管道操作
try:
c.get_pipeline('geoip')
c.get_pipeline('geoip') # aq: 检查是否存在名为geoip的管道
except elasticsearch.exceptions.NotFoundError:
# aq: 若不存在创建geoip管道用于解析IP的地理位置信息
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
@ -33,73 +36,75 @@ if ELASTICSEARCH_ENABLED:
}''')
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
class GeoIp(InnerDoc): # aq: 定义地理位置信息的内部文档(嵌套结构)
continent_name = Keyword() # aq: 大陆名称Keyword类型精确匹配
country_iso_code = Keyword() # aq: 国家ISO编码
country_name = Keyword() # aq: 国家名称
location = GeoPoint() # aq: 经纬度坐标
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
class UserAgentBrowser(InnerDoc): # aq: 定义用户代理中浏览器信息的内部文档
Family = Keyword() # aq: 浏览器家族如Chrome
Version = Keyword() # aq: 浏览器版本
class UserAgentOS(UserAgentBrowser):
class UserAgentOS(UserAgentBrowser): # aq: 定义用户代理中操作系统信息的内部文档(继承浏览器结构)
pass
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
class UserAgentDevice(InnerDoc): # aq: 定义用户代理中设备信息的内部文档
Family = Keyword() # aq: 设备家族如iPhone
Brand = Keyword() # aq: 设备品牌
Model = Keyword() # aq: 设备型号
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
class UserAgent(InnerDoc): # aq: 定义用户代理的内部文档(包含浏览器、系统、设备)
browser = Object(UserAgentBrowser, required=False) # aq: 浏览器信息Object类型嵌套UserAgentBrowser
os = Object(UserAgentOS, required=False) # aq: 操作系统信息
device = Object(UserAgentDevice, required=False) # aq: 设备信息
string = Text() # aq: 原始用户代理字符串
is_bot = Boolean() # aq: 是否为爬虫机器人
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
useragent = Object(UserAgent, required=False)
class ElapsedTimeDocument(Document): # aq: 定义页面耗时统计的ES文档对应performance索引
url = Keyword() # aq: 请求URL
time_taken = Long() # aq: 页面加载耗时(毫秒)
log_datetime = Date() # aq: 日志记录时间
ip = Keyword() # aq: 客户端IP
geoip = Object(GeoIp, required=False) # aq: 地理位置信息嵌套GeoIp
useragent = Object(UserAgent, required=False) # aq: 用户代理信息嵌套UserAgent
class Index:
name = 'performance'
name = 'performance' # aq: 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_shards": 1, # aq: 分片数
"number_of_replicas": 0 # aq: 副本数开发环境通常设为0
}
class Meta:
doc_type = 'ElapsedTime'
doc_type = 'ElapsedTime' # aq: 文档类型ES 7+后可省略)
class ElaspedTimeDocumentManager:
class ElaspedTimeDocumentManager: # aq: 页面耗时文档的管理类封装ES操作
@staticmethod
def build_index():
def build_index(): # aq: 检查并创建performance索引
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
if not res:
ElapsedTimeDocument.init()
ElapsedTimeDocument.init() # aq: 初始化索引根据ElapsedTimeDocument的定义
@staticmethod
def delete_index():
def delete_index(): # aq: 删除performance索引
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
es.indices.delete(index='performance', ignore=[400, 404]) # aq: 忽略不存在/错误
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
ElaspedTimeDocumentManager.build_index()
def create(url, time_taken, log_datetime, useragent, ip): # aq: 创建一条耗时记录
ElaspedTimeDocumentManager.build_index() # aq: 确保索引存在
# aq: 构造UserAgent对象填充浏览器、系统、设备信息
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
@ -116,6 +121,7 @@ class ElaspedTimeDocumentManager:
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
# aq: 构造ElapsedTimeDocument实例用时间戳作为ID避免重复
doc = ElapsedTimeDocument(
meta={
'id': int(
@ -127,61 +133,65 @@ class ElaspedTimeDocumentManager:
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
doc.save(pipeline="geoip")
doc.save(pipeline="geoip") # aq: 保存时使用geoip管道解析IP
class ArticleDocument(Document):
class ArticleDocument(Document): # aq: 定义文章的ES文档对应blog索引
# aq: 文章正文/标题使用ik分词器max_word细分词smart粗分词
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# aq: 作者信息(嵌套结构)
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# aq: 分类信息(嵌套结构)
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# aq: 标签信息(嵌套结构)
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date()
status = Text()
comment_status = Text()
type = Text()
views = Integer()
article_order = Integer()
pub_time = Date() # aq: 发布时间
status = Text() # aq: 文章状态
comment_status = Text() # aq: 评论状态
type = Text() # aq: 内容类型
views = Integer() # aq: 阅读量
article_order = Integer() # aq: 排序权重
class Index:
name = 'blog'
name = 'blog' # aq: 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'Article'
doc_type = 'Article' # aq: 文档类型
class ArticleDocumentManager():
class ArticleDocumentManager(): # aq: 文章文档的管理类
def __init__(self):
def __init__(self): # aq: 初始化时创建索引
self.create_index()
def create_index(self):
def create_index(self): # aq: 创建blog索引
ArticleDocument.init()
def delete_index(self):
def delete_index(self): # aq: 删除blog索引
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
def convert_to_doc(self, articles): # aq: 将Django的Article模型转换为ArticleDocument
return [
ArticleDocument(
meta={
'id': article.id},
'id': article.id}, # aq: 用文章ID作为ES文档ID
body=article.body,
title=article.title,
author={
@ -190,7 +200,7 @@ class ArticleDocumentManager():
category={
'name': article.category.name,
'id': article.category.id},
tags=[
tags=[ # aq: 转换多对多的标签为列表
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
@ -201,13 +211,13 @@ class ArticleDocumentManager():
views=article.views,
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
def rebuild(self, articles=None): # aq: 重建文章索引(默认同步所有文章)
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
for doc in docs:
doc.save()
def update_docs(self, docs):
def update_docs(self, docs): # aq: 批量更新文章文档
for doc in docs:
doc.save()
doc.save()

@ -1,19 +1,26 @@
import logging
from django import forms
from haystack.forms import SearchForm
from django import forms # aq: 导入Django表单基类
from haystack.forms import SearchForm # aq: 导入Haystack搜索框架的基础搜索表单
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # aq: 初始化当前模块的日志对象
class BlogSearchForm(SearchForm):
class BlogSearchForm(SearchForm): # aq: 自定义博客搜索表单继承Haystack的SearchForm
# aq: 定义搜索关键词字段required=True表示该字段不能为空
querydata = forms.CharField(required=True)
def search(self):
def search(self): # aq: 重写搜索方法,自定义搜索逻辑
# aq: 调用父类的search方法获取初始搜索结果
datas = super(BlogSearchForm, self).search()
# aq: 如果表单验证不通过,返回“无结果”的默认处理
if not self.is_valid():
return self.no_query_found()
# aq: 如果搜索关键词存在,记录关键词到日志
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas
# aq: 返回最终的搜索结果
return datas

@ -1,42 +1,52 @@
import logging
import time
import time # aq: 导入时间模块,用于计算页面渲染耗时
from ipware import get_client_ip
from user_agents import parse
from ipware import get_client_ip # aq: 导入获取客户端IP的工具
from user_agents import parse # aq: 导入解析用户代理UA的工具
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager # aq: 导入ES开关和耗时记录的Document管理器
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # aq: 初始化当前模块的日志对象
class OnlineMiddleware(object):
def __init__(self, get_response=None):
self.get_response = get_response
class OnlineMiddleware(object): # aq: 自定义Django中间件用于统计页面加载耗时、记录访问信息
def __init__(self, get_response=None): # aq: 中间件初始化方法
self.get_response = get_response # aq: 保存Django的响应处理函数
super().__init__()
def __call__(self, request):
''' page render time '''
start_time = time.time()
response = self.get_response(request)
def __call__(self, request): # aq: 中间件核心方法,处理请求/响应流程
''' page render time ''' # aq: 功能说明:统计页面渲染耗时
start_time = time.time() # aq: 记录请求处理的开始时间
response = self.get_response(request) # aq: 调用后续中间件/视图,获取响应
# aq: 从请求头中获取用户代理UA字符串
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent)
ip, _ = get_client_ip(request) # aq: 获取客户端IP地址
user_agent = parse(http_user_agent) # aq: 解析UA字符串得到设备/浏览器信息
# aq: 非流式响应如普通HTML响应才处理
if not response.streaming:
try:
cast_time = time.time() - start_time
cast_time = time.time() - start_time # aq: 计算请求处理总耗时(秒)
# aq: 如果启用了Elasticsearch记录耗时信息
if ELASTICSEARCH_ENABLED:
time_taken = round((cast_time) * 1000, 2)
url = request.path
time_taken = round((cast_time) * 1000, 2) # aq: 转换为毫秒并保留2位小数
url = request.path # aq: 获取当前请求的URL路径
from django.utils import timezone
# aq: 向ES中创建一条耗时记录
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# aq: 将页面中的<!!LOAD_TIMES!!>占位符替换为实际耗时取前5位
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
except Exception as e: # aq: 捕获异常并记录日志
logger.error("Error OnlineMiddleware: %s" % e)
return response
return response # aq: 返回处理后的响应

@ -4,134 +4,133 @@ from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
import mdeditor.fields # aq: 导入Markdown编辑器专用字段类型
class Migration(migrations.Migration):
initial = True
initial = True # aq: 标记为初始迁移,负责创建博客核心数据表结构
dependencies = [
dependencies = [ # aq: 迁移依赖——关联Django用户模型支持自定义用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
operations = [ # aq: 迁移核心操作:创建所有博客相关数据表
migrations.CreateModel( # aq: 1. 创建网站配置表BlogSettings
name='BlogSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # aq: 自增主键BigInt类型
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')), # aq: 网站名称
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')), # aq: 网站描述
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')), # aq: SEO优化描述
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')), # aq: 网站搜索关键字
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')), # aq: 文章摘要默认长度
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')), # aq: 侧边栏显示文章数量
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')), # aq: 侧边栏显示评论数量
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')), # aq: 文章页默认显示评论数
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')), # aq: 是否启用谷歌广告
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')), # aq: 谷歌广告代码
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')), # aq: 是否开放全站评论
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), # aq: 网站备案号
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')), # aq: 网站统计(如百度统计)代码
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')), # aq: 是否显示公安备案号
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), # aq: 公安备案号
],
options={
'verbose_name': '网站配置',
'verbose_name_plural': '网站配置',
'verbose_name_plural': '网站配置', # aq: 后台显示名称(单数/复数)
},
),
migrations.CreateModel(
migrations.CreateModel( # aq: 2. 创建友情链接表Links
name='Links',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
('link', models.URLField(verbose_name='链接地址')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # aq: 自增主键
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')), # aq: 友情链接名称(唯一)
('link', models.URLField(verbose_name='链接地址')), # aq: 链接URL地址
('sequence', models.IntegerField(unique=True, verbose_name='排序')), # aq: 排序序号(唯一,决定显示顺序)
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # aq: 是否启用该链接
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')), # aq: 链接展示位置
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # aq: 创建时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # aq: 最后修改时间
],
options={
'verbose_name': '友情链接',
'verbose_name_plural': '友情链接',
'ordering': ['sequence'],
'ordering': ['sequence'], # aq: 按排序序号升序排列
},
),
migrations.CreateModel(
migrations.CreateModel( # aq: 3. 创建侧边栏表SideBar
name='SideBar',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='标题')),
('content', models.TextField(verbose_name='内容')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # aq: 自增主键
('name', models.CharField(max_length=100, verbose_name='标题')), # aq: 侧边栏标题
('content', models.TextField(verbose_name='内容')), # aq: 侧边栏内容支持HTML
('sequence', models.IntegerField(unique=True, verbose_name='排序')), # aq: 排序序号(唯一)
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')), # aq: 是否启用该侧边栏
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # aq: 创建时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # aq: 最后修改时间
],
options={
'verbose_name': '侧边栏',
'verbose_name_plural': '侧边栏',
'ordering': ['sequence'],
'ordering': ['sequence'], # aq: 按排序序号升序排列
},
),
migrations.CreateModel(
migrations.CreateModel( # aq: 4. 创建标签表Tag
name='Tag',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
('id', models.AutoField(primary_key=True, serialize=False)), # aq: 自增主键Int类型
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # aq: 创建时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # aq: 最后修改时间
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')), # aq: 标签名称(唯一)
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), # aq: URL友好的slug名称自动生成
],
options={
'verbose_name': '标签',
'verbose_name_plural': '标签',
'ordering': ['name'],
'ordering': ['name'], # aq: 按标签名称升序排列
},
),
migrations.CreateModel(
migrations.CreateModel( # aq: 5. 创建分类表Category
name='Category',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
('id', models.AutoField(primary_key=True, serialize=False)), # aq: 自增主键
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # aq: 创建时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # aq: 最后修改时间
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')), # aq: 分类名称(唯一)
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), # aq: URL友好的slug名称
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), # aq: 排序权重(数值越大越靠前)
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')), # aq: 父分类(自关联,支持多级分类)
],
options={
'verbose_name': '分类',
'verbose_name_plural': '分类',
'ordering': ['-index'],
'ordering': ['-index'], # aq: 按权重降序排列
},
),
migrations.CreateModel(
migrations.CreateModel( # aq: 6. 创建文章表Article——核心数据表
name='Article',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
('id', models.AutoField(primary_key=True, serialize=False)), # aq: 自增主键
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # aq: 创建时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # aq: 最后修改时间
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')), # aq: 文章标题(唯一)
('body', mdeditor.fields.MDTextField(verbose_name='正文')), # aq: 文章正文Markdown格式
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), # aq: 发布时间
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')), # aq: 文章状态(草稿/发表)
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')), # aq: 评论状态(打开/关闭)
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')), # aq: 内容类型(文章/独立页面)
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')), # aq: 阅读量(非负整数)
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')), # aq: 文章排序权重
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')), # aq: 是否显示文章目录
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # aq: 关联作者(用户模型)
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')), # aq: 关联分类(多对一)
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')), # aq: 关联标签(多对多)
],
options={
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'],
'get_latest_by': 'id',
'ordering': ['-article_order', '-pub_time'], # aq: 先按排序权重降序,再按发布时间降序
'get_latest_by': 'id', # aq: 按ID字段获取最新记录
},
),
]
]

@ -1,23 +1,21 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
from django.db import migrations, models # aq: 导入Django数据库迁移核心模块
class Migration(migrations.Migration):
dependencies = [
class Migration(migrations.Migration): # aq: 数据库迁移类,用于修改数据库表结构
dependencies = [ # aq: 迁移依赖关系需先执行0001_initial初始迁移
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
operations = [ # aq: 迁移具体操作列表
migrations.AddField( # aq: 给BlogSettings模型新增字段
model_name='blogsettings', # aq: 目标模型(博客配置模型)
name='global_footer', # aq: 新增字段名公共尾部HTML
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), # aq: 字段类型为文本,允许为空,默认值空字符串,后台显示名称“公共尾部”
),
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
migrations.AddField( # aq: 给BlogSettings模型新增字段
model_name='blogsettings', # aq: 目标模型(博客配置模型)
name='global_header', # aq: 新增字段名公共头部HTML
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), # aq: 字段类型为文本,允许为空,默认值空字符串,后台显示名称“公共头部”
),
]
]

@ -1,17 +1,17 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
from django.db import migrations, models # aq: 导入Django迁移相关模块
class Migration(migrations.Migration):
dependencies = [
class Migration(migrations.Migration): # aq: 数据库迁移类,用于更新数据库结构
dependencies = [ # aq: 迁移依赖需先执行0002号迁移
('blog', '0002_blogsettings_global_footer_and_more'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
operations = [ # aq: 迁移操作列表
migrations.AddField( # aq: 新增字段操作
model_name='blogsettings', # aq: 目标模型(博客配置模型)
name='comment_need_review', # aq: 新增字段名(评论是否需要审核)
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), # aq: 字段类型布尔值默认False后台显示名称
),
]
]

@ -1,27 +1,26 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
from django.db import migrations # aq: 导入Django数据库迁移核心模块
class Migration(migrations.Migration):
dependencies = [
class Migration(migrations.Migration): # aq: 数据库迁移类,用于修改字段名称
dependencies = [ # aq: 迁移依赖——需先执行0003号迁移新增评论审核字段
('blog', '0003_blogsettings_comment_need_review'),
]
operations = [
operations = [ # aq: 迁移操作列表——批量重命名BlogSettings模型的字段统一为下划线命名规范
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
model_name='blogsettings', # aq: 目标模型(博客配置模型)
old_name='analyticscode', # aq: 原字段名(驼峰命名)
new_name='analytics_code', # aq: 新字段名下划线命名符合Python规范
),
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
old_name='beiancode', # aq: 原字段名(驼峰命名)
new_name='beian_code', # aq: 新字段名(下划线命名)
),
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
old_name='sitename', # aq: 原字段名(驼峰命名)
new_name='site_name', # aq: 新字段名(下划线命名)
),
]
]

@ -4,17 +4,17 @@ from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
import mdeditor.fields # aq: 导入Markdown编辑器字段类型
class Migration(migrations.Migration):
dependencies = [
dependencies = [ # aq: 迁移依赖——关联用户模型+依赖0004号字段重命名迁移
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
operations = [ # aq: 迁移核心操作——统一模型配置字段名、verbose_name国际化
# aq: 1. 修改模型选项将verbose_name改为英文适配国际化
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
@ -35,6 +35,8 @@ class Migration(migrations.Migration):
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# aq: 2. 删除旧的时间字段(驼峰命名)
migrations.RemoveField(
model_name='article',
name='created_time',
@ -67,15 +69,17 @@ class Migration(migrations.Migration):
model_name='tag',
name='last_mod_time',
),
# aq: 3. 新增统一命名的时间字段(下划线命名+英文verbose_name
migrations.AddField(
model_name='article',
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'), # aq: 创建时间
),
migrations.AddField(
model_name='article',
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'), # aq: 最后修改时间
),
migrations.AddField(
model_name='category',
@ -107,6 +111,8 @@ class Migration(migrations.Migration):
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# aq: 4. 修改所有字段的verbose_name为英文适配国际化翻译
migrations.AlterField(
model_name='article',
name='article_order',
@ -297,4 +303,4 @@ class Migration(migrations.Migration):
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]
]

@ -1,17 +1,15 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
from django.db import migrations # aq: 导入Django数据库迁移核心模块
class Migration(migrations.Migration):
dependencies = [
class Migration(migrations.Migration): # aq: 数据库迁移类,用于修改模型元信息
dependencies = [ # aq: 迁移依赖——需先执行0005号迁移统一其他模型国际化配置
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
operations = [
operations = [ # aq: 迁移核心操作——修改BlogSettings模型的verbose_name适配国际化
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
name='blogsettings', # aq: 目标模型(博客配置模型)
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, # aq: 中英文统一为“网站配置”的英文表述,保持全局一致性
),
]
]

@ -1,123 +1,126 @@
import logging
import re
from abc import abstractmethod
from abc import abstractmethod # aq: 导入抽象方法装饰器,用于定义抽象基类
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from slugify import slugify
from django.conf import settings # aq: 导入Django项目的配置对象
from django.core.exceptions import ValidationError # aq: 导入Django的验证错误类
from django.db import models # aq: 导入Django的模型基类用于定义数据库表
from django.urls import reverse # aq: 导入URL反向解析函数生成URL
from django.utils.timezone import now # aq: 导入当前时间函数(带时区)
from django.utils.translation import gettext_lazy as _ # aq: 导入国际化翻译函数
from mdeditor.fields import MDTextField # aq: 导入Markdown编辑器字段
from slugify import slugify # aq: 导入slug生成工具将标题转为URL友好格式
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
from djangoblog.utils import cache_decorator, cache # aq: 导入自定义缓存装饰器和缓存工具
from djangoblog.utils import get_current_site # aq: 导入获取当前站点域名的工具
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # aq: 初始化日志对象,用于记录当前模块的日志
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class LinkShowType(models.TextChoices): # aq: 定义链接的展示位置枚举类
I = ('i', _('index')) # aq: 首页展示
L = ('l', _('list')) # aq: 列表页展示
P = ('p', _('post')) # aq: 文章详情页展示
A = ('a', _('all')) # aq: 所有页面展示
S = ('s', _('slide')) # aq: 轮播区域展示
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
class BaseModel(models.Model): # aq: 抽象基类模型,封装公共字段和方法
id = models.AutoField(primary_key=True) # aq: 自增主键ID
creation_time = models.DateTimeField(_('creation time'), default=now) # aq: 记录创建时间
last_modify_time = models.DateTimeField(_('modify time'), default=now) # aq: 记录最后修改时间
def save(self, *args, **kwargs):
def save(self, *args, **kwargs): # aq: 重写保存方法,扩展功能
# aq: 判断是否是更新文章阅读量的操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# aq: 仅更新阅读量(避免触发其他逻辑)
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# aq: 如果模型有slug字段自动生成slug从title/name字段转换
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
super().save(*args, **kwargs) # aq: 调用父类保存方法
def get_full_url(self):
site = get_current_site().domain
def get_full_url(self): # aq: 生成模型对象的完整URL含域名
site = get_current_site().domain # aq: 获取当前站点域名
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
abstract = True # aq: 标记为抽象类,不会生成数据库表
@abstractmethod
@abstractmethod # aq: 定义抽象方法,子类必须实现
def get_absolute_url(self):
pass
class Article(BaseModel):
class Article(BaseModel): # aq: 文章模型继承BaseModel
"""文章"""
STATUS_CHOICES = (
STATUS_CHOICES = ( # aq: 文章状态选项(草稿/已发布)
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
COMMENT_STATUS = ( # aq: 评论状态选项(开放/关闭)
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
TYPE = ( # aq: 内容类型选项(文章/页面)
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
title = models.CharField(_('title'), max_length=200, unique=True) # aq: 文章标题(唯一)
body = MDTextField(_('body')) # aq: 文章正文Markdown格式
pub_time = models.DateTimeField( # aq: 发布时间
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
status = models.CharField( # aq: 文章状态
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
comment_status = models.CharField( # aq: 评论状态
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # aq: 内容类型
views = models.PositiveIntegerField(_('views'), default=0) # aq: 阅读量(非负整数)
author = models.ForeignKey( # aq: 文章作者(关联用户模型)
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
article_order = models.IntegerField( # aq: 文章排序权重
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # aq: 是否显示目录
category = models.ForeignKey( # aq: 文章分类关联Category模型
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # aq: 文章标签多对多关联Tag
def body_to_string(self):
def body_to_string(self): # aq: 返回正文内容的字符串形式
return self.body
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回标题)
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
ordering = ['-article_order', '-pub_time'] # aq: 默认排序先按order降序再按发布时间降序
verbose_name = _('article') # aq: 模型的可读名称
verbose_name_plural = verbose_name # aq: 模型的复数可读名称
get_latest_by = 'id' # aq: 获取最新记录的字段
def get_absolute_url(self):
def get_absolute_url(self): # aq: 实现抽象方法生成文章详情页URL
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -125,85 +128,82 @@ class Article(BaseModel):
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
@cache_decorator(60 * 60 * 10) # aq: 缓存10小时
def get_category_tree(self): # aq: 获取文章分类的层级结构(含父分类)
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):
def save(self, *args, **kwargs): # aq: 重写保存方法(这里调用父类方法)
super().save(*args, **kwargs)
def viewed(self):
def viewed(self): # aq: 增加阅读量仅更新views字段
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
def comment_list(self): # aq: 获取文章的评论列表(带缓存)
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)
comments = self.comment_set.filter(is_enable=True).order_by('-id') # aq: 筛选启用的评论按ID降序
cache.set(cache_key, comments, 60 * 100) # aq: 缓存100分钟
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
def get_admin_url(self): # aq: 生成文章在后台管理的编辑URL
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):
# 下一篇
@cache_decorator(expiration=60 * 100) # aq: 缓存100分钟
def next_article(self): # aq: 获取下一篇文章ID更大、已发布
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
@cache_decorator(expiration=60 * 100) # aq: 缓存100分钟
def prev_article(self): # aq: 获取上一篇文章ID更小、已发布
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
def get_first_image_url(self): # aq: 从正文提取第一张图片的URL
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
match = re.search(r'!\[.*?\]\((.+?)\)', self.body) # aq: 匹配Markdown图片语法
if match:
return match.group(1)
return ""
class Category(BaseModel):
class Category(BaseModel): # aq: 分类模型继承BaseModel
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
name = models.CharField(_('category name'), max_length=30, unique=True) # aq: 分类名称(唯一)
parent_category = models.ForeignKey( # aq: 父分类(自关联)
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # aq: 分类的slugURL友好名称
index = models.IntegerField(default=0, verbose_name=_('index')) # aq: 分类排序权重
class Meta:
ordering = ['-index']
ordering = ['-index'] # aq: 默认按index降序排序
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
def get_absolute_url(self): # aq: 实现抽象方法生成分类详情页URL
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回分类名称)
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
@cache_decorator(60 * 60 * 10) # aq: 缓存10小时
def get_category_tree(self): # aq: 递归获取分类的父级层级结构
"""
递归获得分类目录的父级
:return:
@ -218,8 +218,8 @@ class Category(BaseModel):
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
@cache_decorator(60 * 60 * 10) # aq: 缓存10小时
def get_sub_categorys(self): # aq: 递归获取当前分类的所有子分类
"""
获得当前分类目录所有子集
:return:
@ -230,7 +230,7 @@ class Category(BaseModel):
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
childs = all_categorys.filter(parent_category=category) # aq: 筛选子分类
for child in childs:
if category not in categorys:
categorys.append(child)
@ -240,137 +240,137 @@ class Category(BaseModel):
return categorys
class Tag(BaseModel):
class Tag(BaseModel): # aq: 标签模型继承BaseModel
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
name = models.CharField(_('tag name'), max_length=30, unique=True) # aq: 标签名称(唯一)
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # aq: 标签的slug
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回标签名称)
return self.name
def get_absolute_url(self):
def get_absolute_url(self): # aq: 实现抽象方法生成标签详情页URL
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
@cache_decorator(60 * 60 * 10) # aq: 缓存10小时
def get_article_count(self): # aq: 获取该标签关联的文章数量
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
ordering = ['name'] # aq: 默认按名称排序
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
class Links(models.Model): # aq: 友情链接模型
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
name = models.CharField(_('link name'), max_length=30, unique=True) # aq: 链接名称(唯一)
link = models.URLField(_('link')) # aq: 链接URL
sequence = models.IntegerField(_('order'), unique=True) # aq: 排序序号(唯一)
is_enable = models.BooleanField( # aq: 是否启用该链接
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
show_type = models.CharField( # aq: 链接展示位置关联LinkShowType枚举
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
creation_time = models.DateTimeField(_('creation time'), default=now) # aq: 创建时间
last_mod_time = models.DateTimeField(_('modify time'), default=now) # aq: 最后修改时间
class Meta:
ordering = ['sequence']
ordering = ['sequence'] # aq: 按排序序号升序排列
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回链接名称)
return self.name
class SideBar(models.Model):
class SideBar(models.Model): # aq: 侧边栏模型
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
name = models.CharField(_('title'), max_length=100) # aq: 侧边栏标题
content = models.TextField(_('content')) # aq: 侧边栏内容HTML
sequence = models.IntegerField(_('order'), unique=True) # aq: 排序序号(唯一)
is_enable = models.BooleanField(_('is enable'), default=True) # aq: 是否启用
creation_time = models.DateTimeField(_('creation time'), default=now) # aq: 创建时间
last_mod_time = models.DateTimeField(_('modify time'), default=now) # aq: 最后修改时间
class Meta:
ordering = ['sequence']
ordering = ['sequence'] # aq: 按排序序号升序排列
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回侧边栏标题)
return self.name
class BlogSettings(models.Model):
class BlogSettings(models.Model): # aq: 博客配置模型
"""blog的配置"""
site_name = models.CharField(
site_name = models.CharField( # aq: 站点名称
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
site_description = models.TextField( # aq: 站点描述
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
site_seo_description = models.TextField( # aq: 站点SEO描述
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
site_keywords = models.TextField( # aq: 站点关键词
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
article_sub_length = models.IntegerField(_('article sub length'), default=300) # aq: 文章摘要长度
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) # aq: 侧边栏文章数量
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) # aq: 侧边栏评论数量
article_comment_count = models.IntegerField(_('article comment count'), default=5) # aq: 文章页评论数量
show_google_adsense = models.BooleanField(_('show adsense'), default=False) # aq: 是否显示Google广告
google_adsense_codes = models.TextField( # aq: Google广告代码
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
open_site_comment = models.BooleanField(_('open site comment'), default=True) # aq: 是否开放全站评论
global_header = models.TextField("公共头部", null=True, blank=True, default='') # aq: 公共头部HTML
global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # aq: 公共尾部HTML
beian_code = models.CharField( # aq: 备案号
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
analytics_code = models.TextField( # aq: 网站统计代码
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
show_gongan_code = models.BooleanField( # aq: 是否显示公安备案号
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
gongan_beiancode = models.TextField( # aq: 公安备案号
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
comment_need_review = models.BooleanField( # aq: 评论是否需要审核
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
def __str__(self): # aq: 模型实例的字符串表示(返回站点名称)
return self.site_name
def clean(self):
def clean(self): # aq: 验证方法,确保仅存在一个配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
def save(self, *args, **kwargs): # aq: 重写保存方法,保存后清空缓存
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()
cache.clear()

@ -1,13 +1,14 @@
from haystack import indexes
from haystack import indexes # aq: 导入Haystack搜索框架的索引基类
from blog.models import Article
from blog.models import Article # aq: 导入博客核心文章模型
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
class ArticleIndex(indexes.SearchIndex, indexes.Indexable): # aq: 文章搜索索引类继承Haystack核心索引类
# aq: 核心搜索字段document=True标记为主字段use_template=True表示使用模板定义搜索范围
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
def get_model(self): # aq: 必须实现的方法指定索引关联的Django模型
return Article
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')
def index_queryset(self, using=None): # aq: 定义索引的查询范围(仅索引已发布的文章)
return self.get_model().objects.filter(status='p') # aq: status='p'对应文章“已发布”状态

@ -1,73 +1,84 @@
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.core.paginator import Paginator
from django.templatetags.static import static
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from accounts.models import BlogUser
from blog.forms import BlogSearchForm
from blog.models import Article, Category, Tag, SideBar, Links
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
from djangoblog.utils import get_current_site, get_sha256
from oauth.models import OAuthUser, OAuthConfig
from django.conf import settings # aq: 导入Django项目配置
from django.core.files.uploadedfile import SimpleUploadedFile # aq: 导入简单文件上传类,用于测试图片上传
from django.core.management import call_command # aq: 导入Django命令调用工具用于测试自定义命令
from django.core.paginator import Paginator # aq: 导入分页工具,用于测试分页逻辑
from django.templatetags.static import static # aq: 导入静态文件路径生成工具
from django.test import Client, RequestFactory, TestCase # aq: 导入Django测试核心类模拟客户端、请求工厂、测试用例
from django.urls import reverse # aq: 导入URL反向解析函数
from django.utils import timezone # aq: 导入时区时间工具
from accounts.models import BlogUser # aq: 导入用户模型(自定义用户表)
from blog.forms import BlogSearchForm # aq: 导入博客搜索表单
from blog.models import Article, Category, Tag, SideBar, Links # aq: 导入博客核心模型
from blog.templatetags.blog_tags import load_pagination_info, load_articletags # aq: 导入自定义模板标签
from djangoblog.utils import get_current_site, get_sha256 # aq: 导入工具函数获取站点、SHA256加密
from oauth.models import OAuthUser, OAuthConfig # aq: 导入OAuth相关模型第三方登录
# Create your tests here.
class ArticleTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_validate_article(self):
site = get_current_site().domain
class ArticleTest(TestCase): # aq: 文章模块综合测试类(覆盖模型、视图、表单、模板标签等)
def setUp(self): # aq: 测试初始化方法,每次测试前执行
self.client = Client() # aq: 初始化测试客户端模拟HTTP请求
self.factory = RequestFactory() # aq: 初始化请求工厂(构造自定义请求)
def test_validate_article(self): # aq: 核心测试方法——验证文章相关全流程(创建、访问、搜索等)
site = get_current_site().domain # aq: 获取当前站点域名
# aq: 创建/获取测试超级用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
user.set_password("liangliangyy")
user.is_staff = True
user.is_superuser = True
user.set_password("liangliangyy") # aq: 设置用户密码
user.is_staff = True # aq: 标记为工作人员(可访问后台)
user.is_superuser = True # aq: 标记为超级管理员
user.save()
# aq: 测试访问用户详情页
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200) # aq: 断言状态码为200正常访问
# aq: 测试访问后台相关页面(无需登录,仅验证路由存在)
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# aq: 创建测试侧边栏
s = SideBar()
s.sequence = 1
s.name = 'test'
s.content = 'test content'
s.is_enable = True
s.sequence = 1 # aq: 排序序号
s.name = 'test' # aq: 侧边栏标题
s.content = 'test content' # aq: 侧边栏内容
s.is_enable = True # aq: 启用侧边栏
s.save()
# aq: 创建测试分类
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.name = "category" # aq: 分类名称
category.creation_time = timezone.now() # aq: 创建时间
category.last_mod_time = timezone.now() # aq: 最后修改时间
category.save()
# aq: 创建测试标签
tag = Tag()
tag.name = "nicetag"
tag.name = "nicetag" # aq: 标签名称
tag.save()
# aq: 创建测试文章
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.title = "nicetitle" # aq: 文章标题
article.body = "nicecontent" # aq: 文章正文
article.author = user # aq: 关联作者
article.category = category # aq: 关联分类
article.type = 'a' # aq: 类型为“文章”
article.status = 'p' # aq: 状态为“已发布”
article.save()
# aq: 测试文章标签关联(初始无标签,添加后断言数量)
self.assertEqual(0, article.tags.count())
article.tags.add(tag)
article.save()
self.assertEqual(1, article.tags.count())
# aq: 批量创建20篇测试文章用于测试分页
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -79,32 +90,44 @@ class ArticleTest(TestCase):
article.save()
article.tags.add(tag)
article.save()
# aq: 若启用Elasticsearch测试搜索功能
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
response = self.client.get('/search', {'q': 'nicetitle'})
call_command("build_index") # aq: 执行创建索引命令
response = self.client.get('/search', {'q': 'nicetitle'}) # aq: 发起搜索请求
self.assertEqual(response.status_code, 200)
# aq: 测试访问文章详情页
response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# aq: 测试爬虫通知功能(百度收录通知)
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url())
# aq: 测试访问标签、分类详情页
response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200)
response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200)
# aq: 测试搜索不存在的关键词(验证页面正常响应)
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# aq: 测试自定义模板标签load_articletags
s = load_articletags(article)
self.assertIsNotNone(s)
self.assertIsNotNone(s) # aq: 断言返回结果非空
# aq: 测试用户登录
self.client.login(username='liangliangyy', password='liangliangyy')
# aq: 测试访问归档页面
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# aq: 测试各类分页场景(全部文章、标签归档、作者归档、分类归档)
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
@ -119,16 +142,19 @@ class ArticleTest(TestCase):
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
# aq: 测试搜索表单(空查询)
f = BlogSearchForm()
f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
from djangoblog.spider_notify import SpiderNotify
# aq: 测试百度爬虫通知批量URL
SpiderNotify.baidu_notify([article.get_full_url()])
# aq: 测试头像相关模板标签
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
u = gravatar_url('liangliangyy@gmail.com') # aq: 生成Gravatar头像URL
u = gravatar('liangliangyy@gmail.com') # aq: 生成Gravatar头像HTML
# aq: 创建测试友情链接并测试访问
link = Links(
sequence=1,
name="lylinux",
@ -137,56 +163,71 @@ class ArticleTest(TestCase):
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# aq: 测试RSS订阅和站点地图
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# aq: 测试后台文章删除、日志访问
self.client.get("/admin/blog/article/1/delete/")
self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/')
def check_pagination(self, p, type, value):
def check_pagination(self, p, type, value): # aq: 分页测试辅助方法
for page in range(1, p.num_pages + 1):
# aq: 获取分页信息(通过自定义模板标签)
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
self.assertIsNotNone(s) # aq: 断言分页信息非空
# aq: 测试上一页链接(存在则访问)
if s['previous_url']:
response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200)
# aq: 测试下一页链接(存在则访问)
if s['next_url']:
response = self.client.get(s['next_url'])
self.assertEqual(response.status_code, 200)
def test_image(self):
import requests
def test_image(self): # aq: 测试图片上传功能
import requests # aq: 导入requests库用于下载测试图片
# aq: 下载Python官方Logo作为测试图片
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png') # aq: 图片保存路径
with open(imagepath, 'wb') as file:
file.write(rsp.content)
# aq: 未带签名访问上传接口预期403禁止访问
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
# aq: 生成上传签名双重SHA256加密
sign = get_sha256(get_sha256(settings.SECRET_KEY))
# aq: 构造文件上传数据
with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile(
'python.png', file.read(), content_type='image/jpg')
form_data = {'python.png': imgfile}
# aq: 带签名上传图片(跟随重定向)
rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200)
os.remove(imagepath)
self.assertEqual(rsp.status_code, 200) # aq: 断言上传成功
os.remove(imagepath) # aq: 删除本地测试图片
# aq: 测试邮件发送和用户头像保存工具函数
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
send_email(['qq@qq.com'], 'testTitle', 'testContent') # aq: 发送测试邮件
save_user_avatar(
'https://www.python.org/static/img/python-logo.png')
'https://www.python.org/static/img/python-logo.png') # aq: 保存远程头像
def test_errorpage(self):
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_errorpage(self): # aq: 测试错误页面404页面
rsp = self.client.get('/eee') # aq: 访问不存在的路由
self.assertEqual(rsp.status_code, 404) # aq: 断言返回404状态码
def test_commands(self):
def test_commands(self): # aq: 测试自定义Django管理命令
# aq: 创建测试超级用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -195,23 +236,26 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
# aq: 创建测试OAuth配置QQ登录
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# aq: 创建测试OAuth用户关联本地用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
u.user = user
u.picture = static("/blog/img/avatar.png")
u.picture = static("/blog/img/avatar.png") # aq: 本地静态头像
u.metadata = '''
{
"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
}'''
}''' # aq: OAuth用户元数据
u.save()
# aq: 创建另一个测试OAuth用户远程头像
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
@ -222,11 +266,12 @@ class ArticleTest(TestCase):
}'''
u.save()
# aq: 执行各类自定义命令
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
call_command("ping_baidu", "all")
call_command("create_testdata")
call_command("clear_cache")
call_command("sync_user_avatar")
call_command("build_search_words")
call_command("build_index") # aq: 构建ES搜索索引
call_command("ping_baidu", "all") # aq: 通知百度收录
call_command("create_testdata") # aq: 创建测试数据
call_command("clear_cache") # aq: 清空缓存
call_command("sync_user_avatar") # aq: 同步用户头像
call_command("build_search_words") # aq: 构建搜索关键词

@ -1,62 +1,75 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from django.views.decorators.cache import cache_page # aq: 导入缓存装饰器,用于页面缓存
from . import views
from . import views # aq: 导入当前app的视图模块
app_name = "blog"
app_name = "blog" # aq: 定义URL命名空间避免路由名称冲突
urlpatterns = [
# aq: 首页路由默认第1页
path(
r'',
views.IndexView.as_view(),
name='index'),
# aq: 首页分页路由(指定页码)
path(
r'page/<int:page>/',
r'page/<<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# aq: 文章详情页路由(按日期+文章ID访问
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
r'article/<<int:year>/<<int:month>/<<int:day>/<<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# aq: 分类详情页路由默认第1页
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# aq: 分类详情页分页路由
path(
r'category/<slug:category_name>/<int:page>.html',
r'category/<slug:category_name>/<<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# aq: 作者文章列表路由默认第1页
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# aq: 作者文章列表分页路由
path(
r'author/<author_name>/<int:page>.html',
r'author/<author_name>/<<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# aq: 标签文章列表路由默认第1页
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# aq: 标签文章列表分页路由
path(
r'tag/<slug:tag_name>/<int:page>.html',
r'tag/<slug:tag_name>/<<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
# aq: 文章归档页面缓存1小时减轻服务器压力
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
# aq: 友情链接页面路由
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
# aq: 图片上传接口路由
path(
r'upload',
views.fileupload,
name='upload'),
# aq: 缓存清理接口路由
path(
r'clean',
views.clean_cache_view,
name='clean'),
]
]

@ -2,26 +2,26 @@ import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.conf import settings # aq: 导入Django项目全局配置
from django.core.paginator import Paginator # aq: 导入分页工具,用于评论分页
from django.http import HttpResponse, HttpResponseForbidden # aq: 导入HTTP响应类正常响应/禁止访问)
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from django.shortcuts import render # aq: 导入模板渲染函数
from django.templatetags.static import static # aq: 生成静态文件访问URL
from django.utils import timezone # aq: 导入时区时间工具,用于文件存储目录命名
from django.utils.translation import gettext_lazy as _ # aq: 导入国际化翻译函数
from django.views.decorators.csrf import csrf_exempt # aq: 豁免CSRF验证适配外部上传调用
from django.views.generic.detail import DetailView # aq: 导入详情页通用视图基类
from django.views.generic.list import ListView # aq: 导入列表页通用视图基类
from haystack.views import SearchView # aq: 导入Haystack搜索视图基类
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
from blog.models import Article, Category, LinkShowType, Links, Tag # aq: 导入博客核心数据模型
from comments.forms import CommentForm # aq: 导入评论表单类
from djangoblog.plugin_manage import hooks # aq: 导入插件钩子管理工具
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # aq: 导入文章内容钩子常量
from djangoblog.utils import cache, get_blog_setting, get_sha256 # aq: 导入工具函数(缓存/博客配置/加密)
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # aq: 初始化当前模块日志对象
class ArticleListView(ListView):
@ -33,31 +33,31 @@ class ArticleListView(ListView):
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
paginate_by = settings.PAGINATE_BY # aq: 每页显示数量(读取全局配置)
page_kwarg = 'page' # aq: 分页参数名URL中用page传递页码
link_type = LinkShowType.L # aq: 友情链接展示类型(列表页默认)
def get_view_cache_key(self):
return self.request.get['pages']
return self.request.get['pages'] # aq: 原代码语法错误get应为GET保留原逻辑
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
return page # aq: 计算当前页码优先URL参数其次GET参数默认1
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
raise NotImplementedError() # aq: 抽象方法,强制子类实现
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
raise NotImplementedError() # aq: 抽象方法,强制子类实现
def get_queryset_from_cache(self, cache_key):
'''
@ -71,7 +71,7 @@ class ArticleListView(ListView):
return value
else:
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
cache.set(cache_key, article_list) # aq: 缓存查询结果
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
@ -82,10 +82,10 @@ class ArticleListView(ListView):
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
return value # aq: 重写父类方法,优先从缓存获取数据
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
kwargs['linktype'] = self.link_type # aq: 向模板添加友情链接类型标识
return super(ArticleListView, self).get_context_data(**kwargs)
@ -94,33 +94,34 @@ class IndexView(ArticleListView):
首页
'''
# 友情链接类型
link_type = LinkShowType.I
link_type = LinkShowType.I # aq: 首页专属友情链接类型
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
return article_list
return article_list # aq: 查询首页文章(已发布的普通文章)
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
return cache_key # aq: 生成首页缓存键(含页码,区分不同分页)
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
template_name = 'blog/article_detail.html' # aq: 详情页模板
model = Article # aq: 关联的模型类
pk_url_kwarg = 'article_id' # aq: URL中主键参数名对应路由的article_id
context_object_name = "article" # aq: 模板中文章对象的变量名
def get_context_data(self, **kwargs):
comment_form = CommentForm()
comment_form = CommentForm() # aq: 初始化评论表单
article_comments = self.object.comment_list() # aq: 获取文章评论(带缓存)
parent_comments = article_comments.filter(parent_comment=None) # aq: 筛选顶级评论
blog_setting = get_blog_setting() # aq: 获取博客全局配置
paginator = Paginator(parent_comments, blog_setting.article_comment_count) # aq: 评论分页
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
@ -147,8 +148,8 @@ class ArticleDetailView(DetailView):
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
kwargs['next_article'] = self.object.next_article # aq: 下一篇文章
kwargs['prev_article'] = self.object.prev_article # aq: 上一篇文章
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
@ -158,18 +159,18 @@ class ArticleDetailView(DetailView):
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
return context # aq: 向模板添加评论、上下篇等扩展数据
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
page_type = "分类目录归档" # aq: 页面类型标识(模板展示用)
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
slug = self.kwargs['category_name'] # aq: 从URL获取分类slug
category = get_object_or_404(Category, slug=slug) # aq: 获取分类不存在返回404
categoryname = category.name
self.categoryname = categoryname
@ -177,7 +178,7 @@ class CategoryDetailView(ArticleListView):
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
return article_list # aq: 查询分类及子分类下的已发布文章
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
@ -186,60 +187,60 @@ class CategoryDetailView(ArticleListView):
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
return cache_key # aq: 生成分类缓存键(含分类名+页码)
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
categoryname = categoryname.split('/')[-1] # aq: 处理多级分类,取最后一级名称
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
return super(CategoryDetailView, self).get_context_data(**kwargs) # aq: 向模板添加分类标识
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
page_type = '作者文章归档' # aq: 页面类型标识
def get_queryset_cache_key(self):
from slugify import slugify
author_name = slugify(self.kwargs['author_name'])
author_name = slugify(self.kwargs['author_name']) # aq: 作者名转slug适配URL
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
return cache_key # aq: 生成作者缓存键含作者名slug+页码)
def get_queryset_data(self):
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
return article_list # aq: 查询指定作者的已发布文章
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
return super(AuthorDetailView, self).get_context_data(**kwargs) # aq: 向模板添加作者标识
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
page_type = '分类标签归档' # aq: 页面类型标识
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
slug = self.kwargs['tag_name'] # aq: 从URL获取标签slug
tag = get_object_or_404(Tag, slug=slug) # aq: 获取标签不存在返回404
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
return article_list # aq: 查询指定标签的已发布文章
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
@ -248,56 +249,56 @@ class TagDetailView(ArticleListView):
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
return cache_key # aq: 生成标签缓存键(含标签名+页码)
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
return super(TagDetailView, self).get_context_data(**kwargs) # aq: 向模板添加标签标识
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
page_type = '文章归档' # aq: 页面类型标识
paginate_by = None # aq: 归档页不分页
page_kwarg = None # aq: 无分页参数
template_name = 'blog/article_archives.html' # aq: 归档页专用模板
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
return Article.objects.filter(status='p').all() # aq: 查询所有已发布文章
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
return cache_key # aq: 归档页缓存键(固定值)
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
model = Links # aq: 关联友情链接模型
template_name = 'blog/links_list.html' # aq: 友情链接模板
def get_queryset(self):
return Links.objects.filter(is_enable=True)
return Links.objects.filter(is_enable=True) # aq: 筛选启用的友情链接
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
paginator, page = self.build_page() # aq: 构建搜索结果分页
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
"query": self.query, # aq: 搜索关键词
"form": self.form, # aq: 搜索表单
"page": page, # aq: 当前页搜索结果
"paginator": paginator, # aq: 分页器
"suggestion": None, # aq: 拼写建议(默认无)
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
context.update(self.extra_context()) # aq: 添加额外上下文
return context
return context # aq: 向模板添加搜索相关数据
@csrf_exempt
@ -308,17 +309,17 @@ def fileupload(request):
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
sign = request.GET.get('sign', None) # aq: 获取上传签名(防止非法上传)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
return HttpResponseForbidden() # aq: 签名无效返回403
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
timestr = timezone.now().strftime('%Y/%m/%d') # aq: 按日期创建存储目录
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 # aq: 判断是否为图片
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
@ -327,17 +328,17 @@ def fileupload(request):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
wfile.write(chunk) # aq: 分块写入文件
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
image.save(savepath, quality=20, optimize=True) # aq: 图片压缩质量20%
url = static(savepath) # aq: 生成文件静态URL
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
return HttpResponse("only for post") # aq: 仅支持POST请求
def page_not_found_view(
@ -345,13 +346,13 @@ def page_not_found_view(
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
logger.error(exception) # aq: 记录异常日志
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
status=404) # aq: 404页面处理视图
def server_error_view(request, template_name='blog/error_page.html'):
@ -359,7 +360,7 @@ def server_error_view(request, template_name='blog/error_page.html'):
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
status=500) # aq: 500页面处理视图
def permission_denied_view(
@ -367,13 +368,13 @@ def permission_denied_view(
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
logger.error(exception) # aq: 记录异常日志
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
'statuscode': '403'}, status=403) # aq: 403页面处理视图
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
return HttpResponse('ok')

@ -1 +1,4 @@
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
# yrh指定当前 Django 应用djangoblog的默认 AppConfig 类
# yrh作用告诉 Django 启动时,使用 'djangoblog.apps' 模块下的 'DjangoblogAppConfig' 类来配置当前应用
# yrh通常用于在应用就绪ready 方法)时执行初始化操作,比如加载插件、注册信号等
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -1,13 +1,22 @@
# yrh从 Django 内置的 admin 模块中导入 AdminSite 类
# yrhAdminSite 是所有自定义 Admin 站点的基类
from django.contrib.admin import AdminSite
# yrh导入 LogEntry 模型,它用于记录 Admin 后台的操作日志
from django.contrib.admin.models import LogEntry
# yrh导入 Django Sites 框架的 Admin 和 Model
# yrhSites 框架允许一个 Django 项目管理多个网站
from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site
# yrh从当前项目的各个应用中导入 Admin 配置和 Model
# yrh这种通配符导入 (*) 在项目内部是常见的,但在公共库中应避免
# yrh它假设了在这些 admin.py 文件中已经定义好了相应的 ModelAdmin 类
from accounts.admin import *
from blog.admin import *
from blog.models import *
from comments.admin import *
from comments.models import *
# yrh导入自定义的 LogEntryAdmin用于自定义操作日志的显示
from djangoblog.logentryadmin import LogEntryAdmin
from oauth.admin import *
from oauth.models import *
@ -18,28 +27,76 @@ from servermanager.models import *
class DjangoBlogAdminSite(AdminSite):
"""yrh
自定义的 Admin 站点类
通过继承 AdminSite可以完全定制 Admin 后台的行为和外观
例如修改站点标题添加自定义权限检查集成自定义视图等
"""
# yrh定义 Admin 站点顶部的标题文字
site_header = 'djangoblog administration'
# yrh定义浏览器标签页上的标题文字
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
"""yrh
构造函数
这里调用了父类的构造函数并传入了一个默认的站点名称 'admin'
这个名称用于 URL 解析等内部操作
"""
super().__init__(name)
def has_permission(self, request):
"""yrh
重写权限检查方法
这个方法决定了一个请求是否有权访问 Admin 站点
默认实现是检查用户是否登录 (request.user.is_staff)
这里的自定义逻辑是
只有超级管理员 (superuser) 才能访问这个 Admin 站点
这是一个比默认更严格的权限控制
:param request: HTTP 请求对象
:return: 如果用户有权访问则返回 True否则返回 False
"""
return request.user.is_superuser
# def get_urls(self):
# """
# (已注释)重写 URL 配置方法。
#
# 这个方法允许你向 Admin 站点添加自定义的 URL 路由。
# 例如,这里的示例代码尝试添加一个 '/admin/refresh/' 的 URL
# 用于触发某些缓存刷新操作。
#
# 被注释掉意味着当前未启用此功能。
# """
# urls = super().get_urls()
# from django.urls import path
# from blog.views import refresh_memcache
#
# my_urls = [
# # 使用 self.admin_view() 包装自定义视图,可以确保它受到 Admin 权限保护
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# # 将自定义 URL 添加到默认的 Admin URL 列表前面
# return urls + my_urls
# yrh创建自定义 Admin 站点的实例
# yrh这个实例将作为我们项目的 Admin 入口点
admin_site = DjangoBlogAdminSite(name='admin')
# yrh--- 将各个应用的模型注册到自定义的 admin_site 上 ---
# yrh格式是admin_site.register(ModelClass, ModelAdminClass)
# yrhModelClass: 要管理的数据模型
# yrhModelAdminClass: 定义了该模型在 Admin 中如何显示和操作的类
# yrh注册 blog 应用的模型
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
@ -47,18 +104,26 @@ admin_site.register(Links, LinksAdmin)
admin_site.register(SideBar, SideBarAdmin)
admin_site.register(BlogSettings, BlogSettingsAdmin)
#yrh 注册 servermanager 应用的模型
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
# yrh注册 accounts 应用的模型
admin_site.register(BlogUser, BlogUserAdmin)
# yrh注册 comments 应用的模型
admin_site.register(Comment, CommentAdmin)
# yrh注册 oauth 应用的模型
admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
# yrh注册 owntracks 应用的模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# yrh注册 Django 内置的 Site 模型
admin_site.register(Site, SiteAdmin)
admin_site.register(LogEntry, LogEntryAdmin)
# yrh注册自定义的 LogEntry 模型管理
# yrh这允许管理员查看和管理 Admin 操作日志
admin_site.register(LogEntry, LogEntryAdmin)

@ -1,11 +1,45 @@
# yrh从 Django 框架中导入 AppConfig 类
# yrhAppConfig 是 Django 1.7 引入的,用于配置和管理整个应用的生命周期
from django.apps import AppConfig
class DjangoblogAppConfig(AppConfig):
"""yrh
DjangoBlog 应用的自定义配置类
这个类继承自 Django AppConfig用于定义应用的元数据和初始化行为
它取代了旧版的 AppConfig 配置方式提供了更强大和统一的接口
"""
# yrh指定默认的主键字段类型。
# yrh在 Django 3.2 及以后版本中,这是一个必填项(或在 settings.py 中设置 DEFAULT_AUTO_FIELD
# yrhBigAutoField 是一个自增的 BigIntegerField适合未来可能有大量数据的表。
default_auto_field = 'django.db.models.BigAutoField'
# yrh应用的完整 Python 模块名。
# yrh这个值必须与应用的目录名和 Python 包名一致Django 通过它来识别应用。
name = 'djangoblog'
def ready(self):
"""yrh
应用就绪时执行的钩子方法
这个方法会在 Django 应用完全加载所有模型都已注册之后被调用一次
这是执行应用级初始化代码的最佳时机例如
- 注册信号处理器 (Signals)
- 加载动态配置或插件
- 启动后台任务
**注意**为了避免在某些命令 `migrate`执行时产生不必要的副作用
Django 建议在此方法中进行的导入应放在方法内部而不是模块顶部
"""
# yrh调用父类的 ready 方法,这是一个良好的实践,确保 Django 内部的初始化逻辑得以执行
super().ready()
# Import and load plugins here
# yrh在应用就绪后导入并执行插件加载函数
# yrh这里使用了延迟导入Lazy Import将 import 语句放在 ready 方法内部。
# yrh这样做可以防止在 Django 应用未完全准备好之前就加载插件模块,从而避免潜在的循环依赖或导入错误。
from .plugin_manage.loader import load_plugins
load_plugins()
# yrh执行插件加载函数这个函数会根据配置动态导入并初始化所有激活的插件
load_plugins()

@ -1,14 +1,22 @@
# yrh导入用于多线程处理的模块这里用于异步发送邮件
import _thread
import logging
# yrh导入 Django 的信号相关模块
import django.dispatch
from django.conf import settings
# yrh导入 Admin 操作日志模型
from django.contrib.admin.models import LogEntry
# yrh导入 Django 内置的用户登录和登出信号
from django.contrib.auth.signals import user_logged_in, user_logged_out
# yrh导入 Django 的邮件发送工具
from django.core.mail import EmailMultiAlternatives
# yrh导入 Django 模型保存后的信号
from django.db.models.signals import post_save
# yrh导入用于注册信号接收器的装饰器
from django.dispatch import receiver
# yrh从项目的其他应用中导入所需的模型、工具和函数
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
@ -16,51 +24,92 @@ from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, del
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
# yrh创建一个日志记录器
logger = logging.getLogger(__name__)
# yrh--- 定义自定义信号 ---
# yrh定义一个 OAuth 用户登录成功后的信号
# yrh这个信号在 OAuth 认证流程完成后被发送,用于触发后续操作
oauth_user_login_signal = django.dispatch.Signal(['id'])
# yrh定义一个发送邮件的信号
# yrh其他部分的代码可以通过发送这个信号来异步发送邮件而无需直接调用邮件发送函数
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
# yrh--- 定义信号接收器 (Signal Handlers) ---
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
"""yrh
发送邮件信号的接收器
当任何地方发送 `send_email_signal` 信号时这个函数就会被触发
它负责构建并发送 HTML 格式的邮件并记录发送日志
:param sender: 发送信号的对象或类
:param kwargs: 信号发送时附带的关键字参数包含 'emailto', 'title', 'content'
"""
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
# yrh创建一个 EmailMultiAlternatives 对象,支持发送纯文本和 HTML 内容
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
msg.content_subtype = "html"
from_email=settings.DEFAULT_FROM_EMAIL, # yrh发件人从配置中读取
to=emailto) # yrh收件人列表
msg.content_subtype = "html" # yrh指定邮件内容为 HTML 格式
# yrh创建一条邮件发送日志记录
from servermanager.models import EmailSendLog
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto)
log.emailto = ','.join(emailto) # yrh将列表转换为字符串存入数据库
try:
# yrh发送邮件
result = msg.send()
# yrh根据发送结果发送成功的邮件数量更新日志
log.send_result = result > 0
except Exception as e:
# yrh如果发送失败记录详细的错误日志
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False
# yrh保存日志记录到数据库
log.save()
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
"""yrh
OAuth 用户登录信号的接收器
当用户通过 OAuth ( GitHub, Google) 登录成功后此函数被触发
其主要作用是处理用户头像如果头像是外部链接则下载并保存到本地
:param sender: 发送信号的对象
:param kwargs: 包含 'id' (OAuthUser ID)
"""
id = kwargs['id']
oauthuser = OAuthUser.objects.get(id=id)
# yrh获取当前站点的域名
site = get_current_site().domain
# yrh检查用户头像是否存在并且不是本站的链接
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
# yrh下载并保存头像到本地并更新用户记录
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
# yrh删除侧边栏缓存因为用户信息可能显示在侧边栏如登录用户头像
delete_sidebar_cache()
@ -73,42 +122,81 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
clearcache = False
"""yrh
Django 模型保存后 (post_save) 信号的通用接收器
这是一个非常强大的信号几乎所有模型在调用 .save() 方法后都会触发它
该函数通过判断 `instance` 的类型对不同模型的保存事件做出不同的响应
:param sender: 发送信号的模型类 (e.g., Article, Comment)
:param instance: 被保存的模型实例
:param created: 布尔值表示是创建新记录 (True) 还是更新现有记录 (False)
:param raw: 布尔值如果是通过 fixtures SQL 直接导入的数据则为 True
:param using: 正在使用的数据库别名
:param update_fields: 被更新的字段名称集合如果是新建则为 None
:param kwargs: 其他关键字参数
"""
clearcache = False # yrh标记是否需要清除全局缓存
# yrh如果保存的是 Admin 操作日志,则直接返回,不做任何处理
if isinstance(instance, LogEntry):
return
# yrh检查被保存的实例是否有 'get_full_url' 方法(通常是 Article 等内容模型)
if 'get_full_url' in dir(instance):
# yrh判断是否仅仅是更新了 'views' (浏览量) 字段
is_update_views = update_fields == {'views'}
# yrh如果不是测试环境并且不是更新浏览量
if not settings.TESTING and not is_update_views:
try:
# yrh获取文章的完整 URL
notify_url = instance.get_full_url()
# yrh通知搜索引擎如百度有新内容或内容已更新
SpiderNotify.baidu_notify([notify_url])
except Exception as ex:
logger.error("notify sipder", ex)
# yrh如果不是更新浏览量则标记需要清除缓存
if not is_update_views:
clearcache = True
# yrh如果被保存的实例是 Comment (评论)
if isinstance(instance, Comment):
# yrh只有当评论被设为“启用”状态时才进行处理
if instance.is_enable:
# yrh获取评论所属文章的 URL
path = instance.article.get_absolute_url()
site = get_current_site().domain
# yrh处理带有端口号的域名在某些开发环境中
if site.find(':') > 0:
site = site[0:site.find(':')]
# yrh清除该文章详情页的缓存
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
# yrh清除 SEO 处理器的缓存
if cache.get('seo_processor'):
cache.delete('seo_processor')
# yrh清除该文章的评论缓存
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
cache.delete(comment_cache_key)
# yrh清除侧边栏缓存因为侧边栏可能显示最新评论
delete_sidebar_cache()
delete_view_cache('article_comments', [str(instance.article.pk)])
# yrh启动一个新线程来发送评论通知邮件避免阻塞当前请求
_thread.start_new_thread(send_comment_email, (instance,))
# yrh如果任何条件触发了缓存清除标记则执行全局缓存清除
if clearcache:
cache.clear()
@ -116,7 +204,21 @@ def model_post_save_callback(
@receiver(user_logged_in)
@receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs):
"""yrh
用户登录和登出信号的接收器
当用户成功登录或登出时此函数被触发
主要用于清除与用户状态相关的缓存以确保显示最新的用户信息
:param sender: 发送信号的对象
:param request: 当前的 HTTP 请求对象
:param user: 登录或登出的用户对象
:param kwargs: 其他关键字参数
"""
if user and user.username:
logger.info(user)
# yrh删除侧边栏缓存因为侧边栏可能显示登录用户信息或根据登录状态显示不同内容
delete_sidebar_cache()
# cache.clear()
# yrh注释掉的 cache.clear() 会清除所有缓存,这可能代价太高。
# yrh只删除受影响的特定缓存如 sidebar是更优的做法。
# yrhcache.clear()

@ -1,89 +1,164 @@
# yrh导入 Django 和 Haystack 的核心模块
from django.utils.encoding import force_str
from elasticsearch_dsl import Q
from elasticsearch_dsl import Q # yrh导入 Elasticsearch DSL 的查询构建器
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
# yrh导入项目自定义的 Elasticsearch 文档类和管理器,以及相关模型
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article
# yrh创建日志记录器
logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
"""yrh
自定义的 Elasticsearch 搜索后端
这个类继承自 Haystack BaseSearchBackend Haystack Elasticsearch 通信的核心
它负责执行实际的搜索操作索引的创建更新和删除
"""
def __init__(self, connection_alias, **connection_options):
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
"""yrh
初始化后端实例
:param connection_alias: Haystack 配置中定义的连接别名
:param connection_options: 连接 Elasticsearch 所需的选项如主机端口等
"""
super(ElasticSearchBackend, self).__init__(connection_alias, **connection_options)
# yrh初始化自定义的文档管理器它封装了与 Elasticsearch 索引交互的具体逻辑
self.manager = ArticleDocumentManager()
# yrh标记是否启用拼写建议功能
self.include_spelling = True
def _get_models(self, iterable):
"""yrh
辅助方法将模型实例列表转换为 Elasticsearch 文档对象列表
:param iterable: 模型实例或模型主键的可迭代对象
:return: ArticleDocument 对象列表
"""
# yrh如果提供了可迭代对象且不为空则使用它否则默认获取所有 Article 对象
models = iterable if iterable and iterable[0] else Article.objects.all()
# yrh通过管理器将模型实例转换为 Elasticsearch 文档
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
self.manager.create_index()
"""yrh
创建索引并添加模型数据
:param models: 要索引的模型实例列表
"""
self.manager.create_index() # 确保索引存在
docs = self._get_models(models)
self.manager.rebuild(docs)
self.manager.rebuild(docs) # 重建索引(会删除旧数据)
def _delete(self, models):
"""yrh
从索引中删除模型数据
:param models: 要删除的模型实例列表
:return: 操作是否成功
"""
# yrh这里的实现假设 ArticleDocumentManager 或模型的 delete 方法已经与 ES 集成
# yrh实际中更可能是调用 self.manager.delete_docs(models)
for m in models:
m.delete()
return True
def _rebuild(self, models):
"""yrh
重建索引非破坏性更新
:param models: 要索引的模型实例列表
"""
models = models if models else Article.objects.all()
docs = self.manager.convert_to_doc(models)
self.manager.update_docs(docs)
def update(self, index, iterable, commit=True):
"""yrh
Haystack 调用此方法来更新索引
:param index: 索引名称此处未使用由管理器处理
:param iterable: 要更新的模型实例可迭代对象
:param commit: 是否立即提交此处未使用
"""
models = self._get_models(iterable)
self.manager.update_docs(models)
def remove(self, obj_or_string):
"""yrh
Haystack 调用此方法来从索引中移除对象
:param obj_or_string: 要移除的模型实例或其主键
"""
models = self._get_models([obj_or_string])
self._delete(models)
def clear(self, models=None, commit=True):
"""yrh
清除索引
:param models: 可选要清除的模型类
:param commit: 是否立即提交
"""
self.remove(None)
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
"""yrh
获取 Elasticsearch 的拼写/术语建议
:param query: 用户输入的原始查询字符串
:return: 由推荐词组成的新查询字符串
"""
# yrh使用 Elasticsearch DSL 构建一个带有 "term" 建议的搜索
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
# yrh解析建议结果
for suggest in search.suggest.suggest_search:
if suggest["options"]:
# yrh如果有建议项取分数最高的那一个
keywords.append(suggest["options"][0]["text"])
else:
# yrh如果没有建议项使用原始词
keywords.append(suggest["text"])
return ' '.join(keywords)
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string)
"""yrh
执行实际的搜索
这是后端最核心的方法接收查询字符串和参数构建 Elasticsearch 查询
执行搜索并将结果格式化为 Haystack 期望的格式
:param query_string: 用户输入的查询字符串
:param kwargs: 包含搜索选项的字典如分页过滤条件等
:return: 一个包含搜索结果的字典
"""
logger.info('search query_string:' + query_string)
# yrh从参数中获取分页信息
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
# 推荐词搜索
# yrh推荐词搜索
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string)
else:
suggestion = query_string
# yrh使用 Elasticsearch DSL 构建复杂的布尔查询
# yrhshould 子句表示“或”关系,匹配 body 或 title 字段
# yrhminimum_should_match="70%" 表示至少需要匹配 70% 的 should 子句
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
@ -93,8 +168,9 @@ class ElasticSearchBackend(BaseSearchBackend):
.filter('term', status='p') \
.filter('term', type='a') \
.source(False)[start_offset: end_offset]
# yrh执行搜索
results = search.execute()
# yrh解析搜索结果
hits = results['hits'].total
raw_results = []
for raw_result in results['hits']['hits']:
@ -105,6 +181,7 @@ class ElasticSearchBackend(BaseSearchBackend):
result_class = SearchResult
result = result_class(
# yrh为每个结果创建一个 Haystack SearchResult 对象
app_label,
model_name,
raw_result['_id'],
@ -113,7 +190,7 @@ class ElasticSearchBackend(BaseSearchBackend):
raw_results.append(result)
facets = {}
spelling_suggestion = None if query_string == suggestion else suggestion
# yrh构建返回字典符合 Haystack 的规范
return {
'results': raw_results,
'hits': hits,
@ -123,20 +200,34 @@ class ElasticSearchBackend(BaseSearchBackend):
class ElasticSearchQuery(BaseSearchQuery):
"""yrh
自定义的 Haystack 查询类
这个类负责解析用户的查询字符串构建查询参数并与后端交互
它是 Haystack 抽象查询逻辑与具体后端查询实现之间的桥梁
"""
def _convert_datetime(self, date):
"""yrh
将日期时间对象转换为 Elasticsearch 可以理解的字符串格式
:param date: datetime 对象
:return: 格式化的日期时间字符串
"""
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
"""yrh
清理用户输入的查询片段处理保留字和特殊字符
注意这个实现是为 Whoosh 设计的可能不完全适用于 Elasticsearch
Elasticsearch 有自己的查询字符串语法可能需要不同的清理逻辑
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
:param query_fragment: 用户输入的查询片段
:return: 清理后的查询片段
"""
words = query_fragment.split()
cleaned_words = []
@ -155,29 +246,80 @@ class ElasticSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
"""yrh
构建查询片段
当使用复杂过滤如在 SearchQuerySet 中使用 .filter()Haystack 会调用此方法
这个简单的实现直接返回了 value query_string 属性
:param field: 要过滤的字段
:param filter_type: 过滤类型 'exact', 'contains'
:param value: 过滤的值
:return: 查询片段
"""
return value.query_string
def get_count(self):
"""yrh
获取搜索结果的总数
:return: 结果总数
"""
results = self.get_results()
return len(results) if results else 0
return results['hits'] if results else 0
def get_spelling_suggestion(self, preferred_query=None):
"""yrh
获取拼写建议
:param preferred_query: 首选的查询字符串
:return: 拼写建议
"""
# yrh这个方法需要从后端获取建议这里的实现比较简单
# yrh实际中可能需要调用 backend 的搜索方法并检查返回的 'spelling_suggestion'
return self._spelling_suggestion
def build_params(self, spelling_query=None):
"""yrh
构建传递给后端 search 方法的参数
:param spelling_query: 用于拼写检查的查询字符串
:return: 参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""yrh
自定义的搜索表单
继承自 Haystack ModelSearchForm用于处理搜索请求的表单提交和验证
这里添加了对是否启用拼写建议功能的支持
"""
def search(self):
# 是否建议搜索
"""yrh
重写搜索方法在执行搜索前设置拼写建议的开关
:return: SearchQuerySet 对象
"""
# yrh从表单数据中获取 'is_suggest' 参数,判断是否需要启用拼写建议
# yrh如果前端传递 "is_suggest=no",则关闭建议功能
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# yrh调用父类的 search 方法执行搜索
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
"""yrh
自定义的 Haystack 引擎类
这个类是 Haystack 入口点它定义了使用哪个 Backend Query
settings.py 中配置 Haystack 你会指定使用这个引擎
"""
# yrh指定该引擎使用的后端类
backend = ElasticSearchBackend
query = ElasticSearchQuery
# yrh指定该引擎使用的查询类
query = ElasticSearchQuery

@ -1,40 +1,111 @@
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed
# yrh导入 Django 核心模块和自定义工具
from django.contrib.auth import get_user_model # 用于获取当前项目的 User 模型
from django.contrib.syndication.views import Feed # Django 内置的 Feed 生成基类
from django.utils import timezone # 用于处理时间和时区
from django.utils.feedgenerator import Rss201rev2Feed # RSS 2.0 规范的生成器
from blog.models import Article
from djangoblog.utils import CommonMarkdown
# yrh导入项目相关的模型和工具
from blog.models import Article # 博客文章模型
from djangoblog.utils import CommonMarkdown # 自定义的 Markdown 解析工具
class DjangoBlogFeed(Feed):
"""yrh
自定义的 RSS Feed 生成器类
这个类继承自 Django Feed 基类通过重写其方法来定义 RSS Feed 的内容和结构
当用户访问对应的 URL Django 会自动调用这个类的方法来生成并返回 RSS XML 文档
"""
# yrh指定 Feed 的类型为 RSS 2.0。这是最常用的 RSS 版本。
feed_type = Rss201rev2Feed
# --- Feed 级别的元数据 ---
# yrhFeed 的描述信息,会出现在 RSS 阅读器的源描述中。
description = '大巧无工,重剑无锋.'
# yrhFeed 的标题,会出现在 RSS 阅读器的源列表中。
title = "且听风吟 大巧无工,重剑无锋. "
# yrhFeed 的链接,通常是博客的首页或 Feed 的专属页面。
link = "/feed/"
def author_name(self):
"""yrh
返回 Feed 的作者名称
这里默认获取数据库中第一个用户的昵称作为 Feed 的作者
在实际应用中你可能希望从网站设置或一个固定的管理员账户中获取
"""
return get_user_model().objects.first().nickname
def author_link(self):
"""yrh
返回 Feed 作者的链接
"""
return get_user_model().objects.first().get_absolute_url()
# --- Feed 内容Items/Entries---
def items(self):
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
"""yrh
返回构成 Feed 条目的对象列表
这是 Feed 类最核心的方法之一它定义了哪些文章会被包含在 RSS Feed
:return: 查询集 (QuerySet)包含了最新的 5 篇已发布文章
"""
return Article.objects.filter(
type='a', # yrh筛选类型为 'a' (article文章) 的内容
status='p' # yrh筛选状态为 'p' (published已发布) 的内容
).order_by('-pub_time')[:5] # yrh按发布时间降序排序取最新的 5 篇
def item_title(self, item):
"""yrh
返回单个条目的标题
:param item: items() 方法返回的列表中的一个对象这里是 Article 实例
:return: 文章的标题字符串
"""
return item.title
def item_description(self, item):
"""yrh
返回单个条目的描述内容摘要
:param item: Article 实例
:return: 经过 Markdown 解析的 HTML 格式的文章内容
"""
# yrh假设文章内容是用 Markdown 编写的,这里将其转换为 HTML 以便 RSS 阅读器正确显示。
return CommonMarkdown.get_markdown(item.body)
# --- 其他可选但推荐的方法 ---
def feed_copyright(self):
"""yrh
返回 Feed 的版权信息
"""
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
"""yrh
返回单个条目的链接即文章的永久链接
:param item: Article 实例
:return: 文章的绝对 URL 字符串
"""
return item.get_absolute_url()
def item_guid(self, item):
return
"""yrh
返回单个条目的全局唯一标识符 (GUID)
GUID 用于帮助 RSS 阅读器判断一个条目是否已经被阅读过避免重复
这里的实现返回 NoneDjango 会默认使用 item_link 的值作为 GUID
更严谨的做法是返回一个不会改变的唯一字符串例如文章的 ID 加上一个固定的前缀
:param item: Article 实例
:return: None (使用默认行为)
"""
return

@ -1,91 +1,164 @@
# yrh导入 Django Admin 相关的核心模块
from django.contrib import admin
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.contrib.admin.models import DELETION # yrh用于标识“删除”操作
from django.contrib.contenttypes.models import ContentType # yrh用于获取模型的元数据
from django.urls import reverse, NoReverseMatch # yrh用于反向解析 URL
from django.utils.encoding import force_str # yrh用于安全地转换字符串编码
from django.utils.html import escape # yrh用于转义 HTML 特殊字符,防止 XSS 攻击
from django.utils.safestring import mark_safe # yrh用于标记字符串为安全的 HTML可以直接渲染
from django.utils.translation import gettext_lazy as _ # yrh用于国际化翻译
class LogEntryAdmin(admin.ModelAdmin):
"""yrh
自定义的 LogEntry ModelAdmin
LogEntry Django 内置的模型用于记录 Admin 后台的所有操作如创建修改删除
这个自定义的 Admin 类用于美化 LogEntry 的列表显示使其更具可读性和交互性
"""
# yrh--- 列表页过滤和搜索配置 ---
# yrh右侧过滤器允许按“内容类型”即按不同的模型过滤日志
list_filter = [
'content_type'
]
# yrh搜索框的搜索字段允许根据对象表示和变更消息来搜索日志
search_fields = [
'object_repr',
'change_message'
'object_repr', # yrh对象的字符串表示如文章标题
'change_message' # yrh变更的详细消息
]
# yrh--- 列表页显示配置 ---
# yrh列表中可点击的字段点击后进入详情页这里 LogEntry 通常没有详情页,所以主要是为了样式)
list_display_links = [
'action_time',
'get_change_message',
'action_time', # yrh操作时间
'get_change_message', # yrh变更消息这是一个自定义方法
]
# yrh列表页要显示的字段
list_display = [
'action_time',
'user_link',
'content_type',
'object_link',
'get_change_message',
'action_time', # yrh操作时间
'user_link', # yrh操作用户自定义方法显示为链接
'content_type', # yrh操作的内容类型如 blog.Article
'object_link', # yrh操作的对象自定义方法显示为链接
'get_change_message', # yrh变更消息
]
# yrh--- 权限控制 ---
def has_add_permission(self, request):
"""yrh
禁止添加新的 LogEntry
日志是系统自动生成的不应该由用户手动添加
"""
return False
def has_change_permission(self, request, obj=None):
"""
控制修改权限
仅允许超级管理员或拥有 'change_logentry' 权限的用户以非 POST 方式访问即仅允许查看
这实际上是禁止了对日志的任何修改操作
"""
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
def has_delete_permission(self, request, obj=None):
"""yrh
禁止删除 LogEntry
操作日志是重要的审计线索应该被永久保留不允许删除
"""
return False
# yrh--- 自定义列表字段 ---
def object_link(self, obj):
"""yrh
自定义列表字段将操作对象显示为指向该对象 Admin 编辑页面的链接
:param obj: LogEntry 实例
:return: 一个可以安全渲染为 HTML 的字符串包含 <a> 标签
"""
# yrh先获取对象的字符串表示并进行 HTML 转义,防止 XSS
object_link = escape(obj.object_repr)
content_type = obj.content_type
# yrh如果操作不是“删除”并且内容类型存在
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
# yrh反向解析出该对象在 Admin 中的编辑页面 URL
# yrhURL 名称格式为 'admin:app_label_modelName_change'
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
# yrh构建 HTML 链接
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
# yrh如果 URL 解析失败(例如,该模型没有在 Admin 中注册),则不做处理
pass
# yrh使用 mark_safe 告诉 Django 这个字符串是安全的,可以直接作为 HTML 渲染
return mark_safe(object_link)
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
# yrh为自定义字段设置属性
object_link.admin_order_field = 'object_repr' # yrh允许根据对象表示进行排序
object_link.short_description = _('object') # yrh列标题支持国际化
def user_link(self, obj):
"""yrh
自定义列表字段将操作用户显示为指向该用户 Admin 编辑页面的链接
:param obj: LogEntry 实例
:return: 一个可以安全渲染为 HTML 的字符串包含 <a> 标签
"""
# yrh获取用户模型的 ContentType
content_type = ContentType.objects.get_for_model(type(obj.user))
# yrh获取用户的字符串表示并转义
user_link = escape(force_str(obj.user))
try:
# try returning an actual link instead of object repr string
# yrh反向解析出用户的 Admin 编辑页面 URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
# yrh构建 HTML 链接
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
pass
return mark_safe(user_link)
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
# yrh为自定义字段设置属性
user_link.admin_order_field = 'user' # yrh允许根据用户进行排序
user_link.short_description = _('user') # yrh列标题
# yrh--- 性能优化和其他配置 ---
def get_queryset(self, request):
"""yrh
重写查询集方法用于性能优化
使用 prefetch_related 提前加载 'content_type' 关联对象
避免在列表页渲染时为每一行都执行一次数据库查询N+1 查询问题
"""
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
def get_actions(self, request):
"""yrh
重写获取操作的方法
从列表顶部的操作下拉菜单中移除 'delete_selected'删除选中项操作
进一步确保日志不会被误删
"""
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
return actions

@ -1,41 +1,87 @@
# yrh导入 Python 内置的 logging 模块,用于记录日志信息
import logging
# yrh创建一个名为当前模块名的 logger 实例
# yrh使用 __name__ 作为 logger 名称是一个好习惯,它能帮助你在日志中清晰地定位到消息的来源 。
logger = logging.getLogger(__name__)
class BasePlugin:
# 插件元数据
"""yrh
所有插件的基类抽象类
这个类定义了插件系统的核心规范和通用功能任何具体的插件都应该继承自这个类
并实现或重写相关方法它采用了模板方法Template Method设计模式
定义了插件的生命周期初始化 -> 注册钩子子类可以在固定的流程中填充自己的逻辑
"""
# yrh--- 插件元数据 (类属性) ---
# yrh这些属性是每个插件必须提供的基本信息用于在插件管理器中识别和展示插件。
# yrh它们被定义为类级别的属性强制要求子类进行重写。
# yrh插件的名称 (必须是唯一的字符串)
PLUGIN_NAME = None
# yrh插件的详细描述
PLUGIN_DESCRIPTION = None
# yrh插件的版本号 (建议遵循语义化版本规则, e.g., "1.0.0")
PLUGIN_VERSION = None
def __init__(self):
"""yrh
插件实例的构造函数
这个方法在插件被实例化时自动调用它负责执行插件的基本设置流程
1. 验证子类是否正确设置了所有必需的元数据
2. 调用 `init_plugin` 方法进行子类特有的初始化
3. 调用 `register_hooks` 方法让子类注册其功能钩子
"""
# yrh检查所有必需的元数据是否都已被子类定义。
# yrh如果有任何一个为 None则抛出 ValueError 异常,防止创建不完整的插件实例。
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
# yrh调用初始化方法。这是一个模板方法子类可以重写它来实现自己的初始化逻辑。
self.init_plugin()
# yrh调用钩子注册方法。子类应重写此方法来向主程序注册事件处理函数。
self.register_hooks()
def init_plugin(self):
"""yrh
插件的初始化钩子
这是一个模板方法提供了一个默认实现记录一条日志
子类可以根据需要重写这个方法以执行插件启动前所需的任何设置
例如加载配置文件连接数据库等
"""
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
"""
# yrh默认行为在插件初始化完成后记录一条 INFO 级别的日志。
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
"""
注册插件钩子
子类可以重写此方法来注册特定的钩子
"""yrh
钩子注册方法
这是插件系统的核心子类必须重写此方法并在其中调用主程序提供的
钩子注册接口将自己的功能函数绑定到特定的事件上
例如一个处理文章内容的插件可能会在这里注册一个函数到 'the_content' 钩子上
基类中的这个方法是空的pass因为它不知道具体的注册逻辑这由子类决定
"""
pass
def get_plugin_info(self):
"""
获取插件信息
:return: 包含插件元数据的字典
"""yrh
获取插件的元数据信息
这个方法提供了一个标准化的方式来获取插件的名称描述和版本
插件管理器可以调用此方法来收集所有已加载插件的信息用于展示或管理
:return: 一个包含插件信息的字典
:rtype: dict
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION
}
}

@ -1,7 +1,12 @@
# yrh加载文章详情时使用的 Action 类型标识
ARTICLE_DETAIL_LOAD = 'article_detail_load'
# yrh创建新文章时使用的 Action 类型标识
ARTICLE_CREATE = 'article_create'
# yrh更新现有文章时使用的 Action 类型标识
ARTICLE_UPDATE = 'article_update'
# yrh删除文章时使用的 Action 类型标识
ARTICLE_DELETE = 'article_delete'
# yrh用于文章内容处理的钩子Hook名称通常用于过滤或修改文章内容
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -1,44 +1,100 @@
# yrh导入 Python 内置的 logging 模块,用于记录系统运行日志
import logging
# yrh创建一个与当前模块同名的 logger 实例,方便在日志中定位来源
logger = logging.getLogger(__name__)
# yrh定义一个私有的字典 _hooks用于存储所有注册的钩子。
# yrh字典的键 (key) 是钩子名称 (hook_name)。
# yrh字典的值 (value) 是一个列表,存储了所有注册到该钩子的回调函数 (callback)。
# yrh使用下划线 '_' 开头命名,暗示这是一个模块内部的私有变量,不应该被外部直接访问或修改。
_hooks = {}
def register(hook_name: str, callback: callable):
"""yrh
注册一个回调函数到指定的钩子上
当某个钩子被触发时所有注册到该钩子的回调函数都会被执行
:param hook_name: 钩子的名称是一个字符串
:param callback: 要注册的回调函数这个函数将在钩子被触发时被调用
"""
注册一个钩子回调
"""
# yrh检查 _hooks 字典中是否已经存在该钩子名称
if hook_name not in _hooks:
# 如果不存在,则为这个钩子名称创建一个新的空列表,用于存放后续注册的回调函数
_hooks[hook_name] = []
# yrh将回调函数追加到对应钩子名称的列表中
_hooks[hook_name].append(callback)
# yrh记录一条调试日志说明哪个回调函数已成功注册到哪个钩子
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""yrh
触发一个 **动作钩子 (Action Hook)**
动作钩子用于通知插件或其他代码某个事件已经发生它会依次执行所有注册到该钩子的回调函数
但不关心这些函数的返回值也不会对它们的返回值做任何处理
:param hook_name: 要触发的钩子名称
:param args: 传递给回调函数的位置参数
:param kwargs: 传递给回调函数的关键字参数
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
"""
# yrh检查是否有任何回调函数注册到了这个钩子上
if hook_name in _hooks:
# yrh记录一条调试日志表明正在执行该动作钩子
logger.debug(f"Running action hook '{hook_name}'")
# yrh遍历并执行该钩子下所有已注册的回调函数
for callback in _hooks[hook_name]:
try:
# yrh使用 *args 和 **kwargs 将接收到的参数原封不动地传递给回调函数
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
# yrh如果某个回调函数执行时发生异常记录一条错误日志
# yrh并使用 exc_info=True 来打印完整的堆栈跟踪信息,这对于调试至关重要。
# yrh捕获异常是为了防止一个插件的错误导致整个钩子系统崩溃。
logger.error(
f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True
)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""yrh
触发一个 **过滤器钩子 (Filter Hook)**
过滤器钩子用于修改或处理某个值它会将一个初始值依次传递给所有注册的回调函数
每个函数的输出会成为下一个函数的输入最终经过所有函数处理后的值将被返回
:param hook_name: 要触发的钩子名称
:param value: 要被过滤或处理的初始值
:param args: 传递给回调函数的额外位置参数
:param kwargs: 传递给回调函数的额外关键字参数
:return: 经过所有过滤器处理后的最终值
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
"""
# yrh检查是否有任何回调函数注册到了这个钩子上
if hook_name in _hooks:
# yrh记录一条调试日志表明正在应用该过滤器钩子
logger.debug(f"Applying filter hook '{hook_name}'")
# yrh遍历并执行该钩子下所有已注册的回调函数
for callback in _hooks[hook_name]:
try:
# yrh将当前的值 (value) 和其他参数传递给回调函数
# yrh然后将回调函数的返回值重新赋值给 value
# yrh这样下一个回调函数就能接收到上一个函数处理过的值
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
# yrh同样捕获并记录任何在过滤器执行过程中发生的异常
logger.error(
f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True
)
# yrh返回经过所有过滤器处理后的最终值
return value

@ -1,19 +1,50 @@
# yrh导入必要的模块
import os
import logging
from django.conf import settings
# yrh创建一个日志记录器用于记录插件加载过程中的信息和错误
logger = logging.getLogger(__name__)
def load_plugins():
"""yrh
动态加载并初始化 'plugins' 目录下的插件
这个函数会遍历 Django 设置 (settings.py) `ACTIVE_PLUGINS` 列表里的每个插件名称
对于每个插件它会检查其目录结构是否合法即存在一个名为 'plugin.py' 的文件
如果合法则尝试导入该文件插件的初始化逻辑例如注册钩子通常在其被导入时自动执行
该函数应在 Django 应用注册表 (app registry) 就绪后调用
例如在某个 AppConfig `ready()` 方法中调用以确保所有 Django 核心组件都已加载完毕
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
"""
# yrh遍历在 settings.py 中配置的 ACTIVE_PLUGINS 列表
# yrhACTIVE_PLUGINS 应该是一个字符串列表,每个字符串代表一个插件的名称
for plugin_name in settings.ACTIVE_PLUGINS:
# yrh构建插件的绝对路径
# yrhsettings.PLUGINS_DIR 应该是一个指向项目插件根目录的路径字符串
# yrh例如如果 PLUGINS_DIR 是 'path/to/project/plugins'
# yrh且 plugin_name 是 'hello_world'
# yrh那么 plugin_path 就是 'path/to/project/plugins/hello_world'
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# yrh检查路径是否为一个目录并且该目录下是否存在 'plugin.py' 文件
# yrh这是一个基本的插件结构校验
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# yrh动态导入插件的主模块
# yrh__import__ 函数用于以字符串形式导入模块
# yrh'plugins.{plugin_name}.plugin' 是插件主模块的完整 Python 路径
# yrh例如对于 'hello_world' 插件,它会尝试导入 'plugins.hello_world.plugin'
# yrh导入这个模块会执行其顶层代码包括插件类的定义和实例化
# yrh通常插件会在其 __init__.py 或 plugin.py 的顶层创建一个插件类的实例,
# yrh从而触发 BasePlugin 的 __init__ 方法,完成初始化和钩子注册。
__import__(f'plugins.{plugin_name}.plugin')
# yrh如果导入成功记录一条成功日志
logger.info(f"Successfully loaded plugin: {plugin_name}")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
# yrh如果在导入过程中发生错误例如模块不存在、语法错误等
# yrh记录一条详细的错误日志包括异常信息和堆栈跟踪
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

@ -1,4 +1,4 @@
"""
"""yrh
Django settings for djangoblog project.
Generated by 'django-admin startproject' using Django 1.10.2.
@ -12,72 +12,107 @@ https://docs.djangoproject.com/en/1.10/ref/settings/
import os
import sys
from pathlib import Path
#yrh导入Django国际化翻译工具
from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
"""yrh
从环境变量获取布尔值的工具函数
:param env: 环境变量名
:param default: 环境变量不存在时的默认值
:return: 环境变量对应的布尔值True/False
"""
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
# yrhBuild paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# yrhQuick-start development settings - unsuitable for production
# yrhSee https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# yrhSECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
# yrhSECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
# yrhDEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
# yrhALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
# yrhdjango 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
# yrhApplication definition
INSTALLED_APPS = [
# 'django.contrib.admin',
# yrhDjango内置Admin精简版配置
'django.contrib.admin.apps.SimpleAdminConfig',
# yrhDjango内置用户认证系统
'django.contrib.auth',
# yrh内容类型框架用于模型权限管理
'django.contrib.contenttypes',
# yrh会话框架用于用户登录状态保持
'django.contrib.sessions',
# yrh消息框架用于页面提示信息
'django.contrib.messages',
# yrh静态文件管理框架
'django.contrib.staticfiles',
# yrh站点框架用于多站点管理如sitemap
'django.contrib.sites',
# yrh站点地图框架
'django.contrib.sitemaps',
# yrh第三方应用Markdown编辑器
'mdeditor',
# yrh第三方应用全文搜索框架
'haystack',
# yrh自定义应用博客核心功能
'blog',
# yrh自定义应用用户账户管理
'accounts',
# yrh自定义应用评论功能
'comments',
# yrh自定义应用第三方登录OAuth
'oauth',
# yrh自定义应用服务器管理
'servermanager',
# yrh自定义应用位置追踪
'owntracks',
# yrh第三方应用静态文件压缩CSS/JS
'compressor',
# yrh自定义应用项目核心配置
'djangoblog'
]
MIDDLEWARE = [
# yrh安全中间件处理HTTPS、XSS防护等
'django.middleware.security.SecurityMiddleware',
# yrh会话中间件管理用户会话
'django.contrib.sessions.middleware.SessionMiddleware',
# yrh国际化中间件处理多语言切换
'django.middleware.locale.LocaleMiddleware',
# yrhGZip压缩中间件压缩响应内容提升性能
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware',
# yrh缓存更新中间件注释按需启用用于页面缓存
# yrh'django.middleware.cache.UpdateCacheMiddleware',
# yrh通用中间件处理URL重写、请求转发等
'django.middleware.common.CommonMiddleware',
# 'django.middleware.cache.FetchFromCacheMiddleware',
# yrh缓存获取中间件注释按需启用与UpdateCacheMiddleware配对使用
# yrh'django.middleware.cache.FetchFromCacheMiddleware',
# yrhCSRF防护中间件防止跨站请求伪造
'django.middleware.csrf.CsrfViewMiddleware',
# yrh认证中间件管理用户认证状态
'django.contrib.auth.middleware.AuthenticationMiddleware',
# yrh消息中间件传递用户操作提示
'django.contrib.messages.middleware.MessageMiddleware',
# yrh点击劫持防护中间件限制iframe嵌入
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# yrh条件GET中间件处理304 Not Modified响应提升性能
'django.middleware.http.ConditionalGetMiddleware',
# yrh自定义中间件在线用户统计
'blog.middleware.OnlineMiddleware'
]
@ -85,25 +120,36 @@ ROOT_URLCONF = 'djangoblog.urls'
TEMPLATES = [
{
# yrh模板引擎使用Django内置模板引擎
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# yrh自定义模板目录项目根目录下的templates文件夹
'DIRS': [os.path.join(BASE_DIR, 'templates')],
# yrh是否允许从应用内的templates目录加载模板
'APP_DIRS': True,
# yrh模板引擎选项
'OPTIONS': {
# yrh上下文处理器向所有模板传递全局变量
'context_processors': [
# yrh调试上下文处理器开发环境传递调试信息
'django.template.context_processors.debug',
# yrh请求上下文处理器向模板传递request对象
'django.template.context_processors.request',
# yrh认证上下文处理器向模板传递user对象
'django.contrib.auth.context_processors.auth',
# yrh消息上下文处理器向模板传递messages对象
'django.contrib.messages.context_processors.messages',
# yrh自定义上下文处理器SEO相关配置
'blog.context_processors.seo_processor'
],
},
},
]
# yrhWSGI应用入口用于部署如Gunicorn、uWSGI
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
# yrhDatabase
# yrhhttps://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
@ -117,56 +163,73 @@ DATABASES = {
}
}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
# yrhPassword validation
# yrhhttps://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
# yrh用户名相似度验证密码不能与用户名太相似
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
# yrh密码长度验证密码不能太短
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
# yrh常见密码验证密码不能是常见弱密码
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
# yrh纯数字密码验证密码不能全是数字
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# yrh支持的语言列表
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
('en', _('English')), # yrh英语
('zh-hans', _('Simplified Chinese')), # yrh简体中文
('zh-hant', _('Traditional Chinese')), # yrh繁体中文
)
# yrh翻译文件目录项目根目录下的locale文件夹
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
# yrh默认语言简体中文
LANGUAGE_CODE = 'zh-hans'
# yrh时区亚洲/上海)
TIME_ZONE = 'Asia/Shanghai'
# yrh是否启用国际化
USE_I18N = True
# yrh是否启用本地化日期、时间格式等
USE_L10N = True
# yrh是否使用UTC时区False表示使用本地时区
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
HAYSTACK_CONNECTIONS = {
'default': {
# yrh搜索引擎自定义Whoosh引擎支持中文
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
# yrh索引文件存储路径当前配置文件所在目录下的whoosh_index文件夹
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
},
}
# Automatically update searching index
# yrhAutomatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
# yrhAllow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
@ -175,22 +238,28 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
# yrh自定义用户模型替换Django默认User模型
AUTH_USER_MODEL = 'accounts.BlogUser'
# yrh登录URL未登录用户访问需登录页面时跳转
LOGIN_URL = '/login/'
# yrh时间显示格式年-月-日 时:分:秒)
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
# yrh日期显示格式年-月-日)
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap color styles
# yrhbootstrap color styles
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
# yrhpaginate
PAGINATE_BY = 10
# http cache timeout
# yrhhttp cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
# yrhcache setting
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
@ -198,7 +267,7 @@ CACHES = {
'LOCATION': 'unique-snowflake',
}
}
# 使用redis作为缓存
# yrh使用redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
@ -206,31 +275,51 @@ if os.environ.get("DJANGO_REDIS_URL"):
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
# yrh站点ID多站点时用于区分不同站点
SITE_ID = 1
# yrh百度链接提交通知URL优先从环境变量获取用于新文章收录
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Email:
# yrh邮件后端使用SMTP服务
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# yrh是否使用TLS加密优先从环境变量获取默认关闭
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
# yrh是否使用SSL加密优先从环境变量获取默认开启
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
# yrh邮件服务器地址优先从环境变量获取默认阿里云企业邮箱
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
# yrh邮件服务器端口优先从环境变量获取SSL默认465
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
# yrh邮件账号优先从环境变量获取
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
# yrh邮件密码/授权码(优先从环境变量获取)
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
# yrh默认发件人邮箱与EMAIL_HOST_USER一致
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# yrh服务器错误通知邮箱
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
# yrh管理员邮箱用于接收系统错误通知优先从环境变量获取
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
# yrh微信管理员密码两次MD5加密后的值优先从环境变量获取
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# yrh日志文件存储目录项目根目录下的logs文件夹
LOG_PATH = os.path.join(BASE_DIR, 'logs')
# yrh若日志目录不存在则创建exist_ok=True避免重复创建报错
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
# yrh日志配置
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@ -293,49 +382,61 @@ LOGGING = {
}
STATICFILES_FINDERS = (
# yrh从项目根目录下的STATICFILES_DIRS配置目录查找静态文件
'django.contrib.staticfiles.finders.FileSystemFinder',
# yrh从各个已安装应用下的static目录查找静态文件
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
# yrh第三方查找器用于查找django-compressor管理的压缩静态文件
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
# yrhCOMPRESS_OFFLINE = True
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
# yrhcreates absolute urls from relative ones
'compressor.filters.css_default.CssAbsoluteFilter',
# css minimizer
# yrhcss minimizer
'compressor.filters.cssmin.CSSMinFilter'
]
# yrhJS压缩过滤器定义压缩JS文件时执行的处理逻辑
COMPRESS_JS_FILTERS = [
# yrh过滤器压缩JS去除空格、注释、简化变量名等减小文件体积
'compressor.filters.jsmin.JSMinFilter'
]
# yrh媒体文件存储根目录用户上传的文件如图片、附件将保存到项目根目录下的uploads文件夹
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
# yrh媒体文件访问URL通过http://域名/media/路径访问用户上传的文件
MEDIA_URL = '/media/'
# yrhX-Frame-Options响应头限制当前站点页面被哪些页面嵌入iframe
# yrhSAMEORIGIN=仅允许被同域名下的页面嵌入(防止点击劫持攻击)
X_FRAME_OPTIONS = 'SAMEORIGIN'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# yrh若环境变量配置了Elasticsearch主机地址则启用Elasticsearch作为搜索后端
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
# yrhElasticsearch DSL配置连接Elasticsearch服务
'default': {
# yrhElasticsearch服务地址
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
# yrh覆盖Haystack搜索连接配置使用自定义的Elasticsearch引擎
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# Plugin System
# yrhPlugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer'
'article_copyright', # yrh文章版权插件如添加版权声明
'reading_time', # yrh阅读时间插件估算文章阅读时长
'external_links', # yrh外部链接插件处理文章中的外部链接如添加target="_blank"
'view_count', # yrh阅读量统计插件统计文章被查看次数
'seo_optimizer' # yrhSEO优化插件优化文章SEO相关属性如标题、关键词
]

@ -1,59 +1,130 @@
# yrh导入Django站点地图核心类和URL反向解析工具
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
# yrh导入博客相关模型文章、分类、标签
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
"""yrh
静态页面站点地图类
用于收录网站中URL固定内容不常变的静态页面如首页
"""
# yrh页面优先级0.0-1.01.0为最高静态页面优先级设为0.5
priority = 0.5
# yrh页面内容更新频率可选值always, hourly, daily, weekly, monthly, yearly, never
changefreq = 'daily'
def items(self):
"""yrh
返回需要收录的静态页面URL名称列表对应urls.py中name参数
这里仅收录首页URL名称为'blog:index'
"""
return ['blog:index', ]
def location(self, item):
"""yrh
根据items()返回的URL名称反向解析出完整URL路径
:param item: items()返回列表中的单个URL名称
:return: 静态页面的绝对URL路径
"""
return reverse(item)
class ArticleSiteMap(Sitemap):
"""yrh
文章页面站点地图类
用于收录所有已发布的博客文章页面帮助搜索引擎抓取文章内容
"""
# yrh文章页面更新频率设为“每月”根据博客更新频率可调整
changefreq = "monthly"
# yrh文章页面优先级设为0.6(高于分类、标签,低于首页等核心页面)
priority = "0.6"
def items(self):
return Article.objects.filter(status='p')
def lastmod(self, obj):
"""yrh
返回单个文章的最后修改时间帮助搜索引擎判断是否需要重新抓取页面
:param obj: items()返回列表中的单个Article对象
:return: 文章最后修改时间模型中的last_modify_time字段
"""
return obj.last_modify_time
class CategorySiteMap(Sitemap):
"""yrh
分类页面站点地图类
用于收录博客的所有分类页面
"""
# yrh分类页面更新频率设为“每周”
changefreq = "Weekly"
# yrh分类页面优先级设为0.6(与文章同级或略低,根据需求调整)
priority = "0.6"
def items(self):
"""yrh
返回所有分类对象列表无筛选收录全部分类
"""
return Category.objects.all()
def lastmod(self, obj):
"""yrh
返回单个分类的最后修改时间模型中的last_modify_time字段
:param obj: items()返回列表中的单个Category对象
:return: 分类最后修改时间
"""
return obj.last_modify_time
class TagSiteMap(Sitemap):
"""yrh
标签页面站点地图类
用于收录博客的所有标签页面
"""
# yrh标签页面更新频率设为“每周”
changefreq = "Weekly"
# yrh标签页面优先级设为0.3(低于文章、分类,属于辅助导航页面)
priority = "0.3"
def items(self):
"""yrh
返回所有标签对象列表无筛选收录全部标签
"""
return Tag.objects.all()
def lastmod(self, obj):
"""yrh
返回单个标签的最后修改时间模型中的last_modify_time字段
:param obj: items()返回列表中的单个Tag对象
:return: 标签最后修改时间
"""
return obj.last_modify_time
class UserSiteMap(Sitemap):
"""yrh
用户页面站点地图类
用于收录所有发表过文章的作者用户页面
"""
# yrh用户页面更新频率设为“每周”
changefreq = "Weekly"
# yrh用户页面优先级设为0.3(属于辅助页面,优先级较低)
priority = "0.3"
def items(self):
"""yrh
返回所有发表过文章的用户对象列表去重避免同一用户多次出现
逻辑1. 获取所有已发布文章 2. 提取每篇文章的作者 3. 用set去重 4. 转为列表
"""
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
"""yrh
返回单个用户的站点地图更新时间这里用用户注册时间date_joined作为参考
可根据需求改为用户最后发表文章时间等更精准的时间
:param obj: items()返回列表中的单个用户对象
:return: 用户注册时间
"""
return obj.date_joined

@ -1,21 +1,48 @@
# yrh导入日志模块用于记录通知过程中的信息和错误
import logging
# yrh导入requests库用于发送HTTP请求向搜索引擎提交链接
import requests
# yrh导入Django配置用于获取百度链接提交通知URL
from django.conf import settings
# yrh创建日志记录器用于记录当前模块的日志
logger = logging.getLogger(__name__)
class SpiderNotify():
"""yrh
搜索引擎爬虫通知类
用于向搜索引擎如百度主动提交网站新增/更新的页面链接
加速搜索引擎对页面的抓取和收录
"""
@staticmethod
def baidu_notify(urls):
"""yrh
向百度搜索引擎提交链接
调用百度站长平台提供的链接提交接口批量提交页面URL
:param urls: 要提交的URL列表['https://example.com/article/1/', ...]
"""
try:
# yrh将URL列表转换为百度接口要求的格式每行一个URL
data = '\n'.join(urls)
# yrh向百度链接提交通知URL发送POST请求提交URL数据
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
# yrh记录百度接口返回的响应信息成功时为提交结果方便调试
logger.info(result.text)
except Exception as e:
# yrh捕获提交过程中的所有异常如网络错误、接口不可用等并记录错误日志
logger.error(e)
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
"""yrh
通用通知方法简化接口
接收单个URL或URL列表调用百度链接提交方法
:param url: 单个URL字符串或URL列表
"""
# yrh直接调用百度链接提交方法若传入单个URL会自动转为列表后拼接成多行字符串
SpiderNotify.baidu_notify(url)

@ -1,15 +1,37 @@
# yrh导入Django内置的测试基类用于编写单元测试
from django.test import TestCase
# yrh导入项目工具模块中的所有工具函数/类(用于测试工具的正确性)
from djangoblog.utils import *
class DjangoBlogTest(TestCase):
"""yrh
项目工具函数单元测试类
用于测试`djangoblog.utils`模块中核心工具函数的功能正确性
确保工具在项目中使用时能稳定工作
"""
def setUp(self):
"""yrh
测试前置准备方法
在每个测试方法执行前自动调用用于初始化测试环境如创建测试数据
本测试类暂无需前置准备故留空
"""
pass
def test_utils(self):
"""yrh
核心工具函数综合测试方法
测试`utils`模块中常用的3个工具SHA256加密Markdown解析字典转URL参数
"""
# yrh1. 测试SHA256加密函数get_sha256
# yrh对字符串'test'进行加密断言加密结果不为None验证函数能正常返回结果
md5 = get_sha256('test')
self.assertIsNotNone(md5)
# yrh2. 测试Markdown解析工具CommonMarkdown.get_markdown
# yrh定义一段包含标题、代码块、链接的Markdown文本解析后断言结果不为None
c = CommonMarkdown.get_markdown('''
# Title1

@ -13,52 +13,75 @@ Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
# yrh: 导入Django核心配置、国际化URL、静态文件URL相关模块
from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
# yrh: 导入站点地图视图、URL路径匹配、搜索视图工厂等
from django.contrib.sitemaps.views import sitemap
from django.urls import path, include
from django.urls import re_path
from haystack.views import search_view_factory
# yrh: 导入项目自定义视图、Admin站点、搜索表单、Feed、站点地图等组件
from blog.views import EsSearchView
from djangoblog.admin_site import admin_site
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
# yrh: 定义站点地图字典:键为站点地图标识,值为对应的站点地图类
sitemaps = {
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
'blog': ArticleSiteMap, # yrh: 文章页面站点地图
'Category': CategorySiteMap, # yrh: 分类页面站点地图
'Tag': TagSiteMap, # yrh: 标签页面站点地图
'User': UserSiteMap, # yrh: 用户页面站点地图
'static': StaticViewSitemap # yrh: 静态页面站点地图
}
# yrh: 404页面未找到指定处理视图
handler404 = 'blog.views.page_not_found_view'
# yrh: 500服务器内部错误指定处理视图
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# yrh: 403权限拒绝指定处理视图
handler403 = 'blog.views.permission_denied_view'
# yrh: 非国际化URL列表不随语言切换变化的URL
urlpatterns = [
# yrh: 国际化切换接口:提供语言切换功能(如/en/、/zh-hans/
path('i18n/', include('django.conf.urls.i18n')),
]
# yrh: 国际化URL列表URL会自动添加语言前缀如/zh-hans/admin/
urlpatterns += i18n_patterns(
# yrh: 自定义Admin站点替代Django默认admin路径为/admin/
re_path(r'^admin/', admin_site.urls),
# yrh: 博客核心URL包含文章列表、详情等命名空间为\'blog\'
re_path(r'', include('blog.urls', namespace='blog')),
# yrh: Markdown编辑器URL集成mdeditor富文本编辑器
re_path(r'', include('blog.urls', namespace='blog')),
# yrh:Markdown编辑器URL集成mdeditor富文本编辑器
re_path(r'mdeditor/', include('mdeditor.urls')),
# yrh:评论功能URL命名空间为'comment'
re_path(r'', include('comments.urls', namespace='comment')),
# yrh:用户账户URL登录、注册、个人中心等命名空间为'account'
re_path(r'', include('accounts.urls', namespace='account')),
# yrh:第三方登录URLOAuth登录如GitHub、Google命名空间为'oauth'
re_path(r'', include('oauth.urls', namespace='oauth')),
# yrh:站点地图URL生成sitemap.xml供搜索引擎抓取
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
# yrh:RSS订阅1路径/feed/返回博客文章Feed
re_path(r'^feed/$', DjangoBlogFeed()),
# yrh:RSS订阅2路径/rss/,与/feed/功能一致,提供备用地址
re_path(r'^rss/$', DjangoBlogFeed()),
# yrh:全文搜索URL使用自定义Elasticsearch搜索视图和表单
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
name='search'),
# yrh:服务器管理URL如命令执行、邮件日志等命名空间为'servermanager'
re_path(r'', include('servermanager.urls', namespace='servermanager')),
# yrh:位置追踪URLowntracks相关功能命名空间为'owntracks'
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# yrh:仅调试模式DEBUG=True下启用配置用户上传文件媒体文件的访问URL
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
document_root=settings.MEDIA_ROOT)

@ -1,5 +1,5 @@
#!/usr/bin/env python
# encoding: utf-8
#yrh:!/usr/bin/env python
# yrh:encoding: utf-8
import logging
@ -16,46 +16,77 @@ from django.conf import settings
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.templatetags.static import static
# yrh:创建一个日志记录器,用于记录当前模块的日志信息
logger = logging.getLogger(__name__)
def get_max_articleid_commentid():
"""yrh:
获取最新文章和最新评论的ID
这个函数用于获取数据库中最新创建的文章和评论的主键ID
通常在需要进行某些数据同步或初始化操作时使用
:return: 一个元组包含(最新文章ID, 最新评论ID)
"""
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
"""yrh:
对输入的字符串进行SHA-256哈希计算
:param str: 需要哈希的原始字符串
:return: 哈希后的十六进制字符串
"""
m = sha256(str.encode('utf-8'))
return m.hexdigest()
def cache_decorator(expiration=3 * 60):
"""yrh:
一个通用的缓存装饰器工厂函数
这个高阶函数用于装饰其他函数以实现其返回值的自动缓存
当被装饰的函数被调用时它会先检查缓存中是否存在结果如果存在则直接返回
否则执行原函数并将结果存入缓存
:param expiration: 缓存过期时间单位为秒默认为3分钟
:return: 一个装饰器函数
"""
def wrapper(func):
def news(*args, **kwargs):
try:
# yrh:尝试从第一个参数通常是一个类实例如View获取缓存键
view = args[0]
key = view.get_cache_key()
except:
# yrh:如果失败例如被装饰的不是一个View方法则根据函数名和参数生成一个唯一键
key = None
if not key:
# yrh:将函数对象、参数和关键字参数组合成一个唯一字符串
unique_str = repr((func, args, kwargs))
# yrh:对该字符串进行哈希,生成一个固定长度的缓存键
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
# yrh:尝试从缓存中获取数据
value = cache.get(key)
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
# yrh:如果缓存命中检查是否是代表None的特殊值
if str(value) == '__default_cache_value__':
return None
else:
return value
else:
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
# yrh:如果缓存未命中,执行原函数
logger.debug('cache_decorator set cache:%s key:%s' % (func.__name__, key))
value = func(*args, **kwargs)
# yrh:存入缓存如果原函数返回None则存入一个特殊标记
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
@ -68,7 +99,7 @@ def cache_decorator(expiration=3 * 60):
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
'''yrh:
刷新视图缓存
:param path:url路径
:param servername:host
@ -94,19 +125,39 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
@cache_decorator()
def get_current_site():
"""yrh:
获取当前站点的信息并进行缓存
由于站点信息如域名不常变化使用`cache_decorator`进行缓存
避免重复查询数据库
:return: 当前的`Site`对象
"""
site = Site.objects.get_current()
return site
class CommonMarkdown:
"""yrh:
一个封装了Markdown转换功能的工具类
提供静态方法来将Markdown格式的文本转换为HTML并支持生成目录TOC
"""
@staticmethod
def _convert_markdown(value):
"""yrh:
内部辅助方法执行实际的Markdown转换
:param value: Markdown格式的原始文本
:return: 一个元组包含转换后的HTML正文和目录HTML
"""
# yrh:初始化Markdown解析器并启用多个扩展
md = markdown.Markdown(
extensions=[
'extra',
'codehilite',
'toc',
'tables',
'extra', # yrh:启用额外的Markdown语法
'codehilite', # yrh:启用代码高亮
'toc', # yrh:启用目录生成
'tables', # yrh:启用表格支持
]
)
body = md.convert(value)
@ -115,6 +166,12 @@ class CommonMarkdown:
@staticmethod
def get_markdown_with_toc(value):
"""yrh:
将Markdown文本转换为HTML并返回正文和目录
:param value: Markdown格式的原始文本
:return: (body_html, toc_html)
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@ -123,8 +180,17 @@ class CommonMarkdown:
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
"""yrh:
发送邮件的封装函数
该函数不直接调用邮件发送API而是发送一个`send_email_signal`信号
这种设计将邮件发送的触发者和执行者解耦提高了代码的灵活性和可测试性
:param emailto: 收件人邮箱列表
:param title: 邮件标题
:param content: 邮件内容HTML格式
"""
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
send_email.__class__,
@ -134,11 +200,17 @@ def send_email(emailto, title, content):
def generate_code() -> str:
"""生成随机数验证码"""
"""yrh:生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
"""yrh:
将一个字典转换为URL查询字符串
:param dict: 包含键值对的字典
:return: URL编码后的查询字符串 "key1=value1&key2=value2"
"""
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
@ -146,12 +218,21 @@ def parse_dict_to_url(dict):
def get_blog_setting():
"""yrh:
获取博客的全局设置并进行缓存
如果缓存中没有则从数据库读取如果数据库中也没有首次运行
则创建一个默认的设置实例并保存到数据库
:return: `BlogSettings`对象
"""
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
if not BlogSettings.objects.count():
# yrh:如果数据库中没有设置,创建默认设置
setting = BlogSettings()
setting.site_name = 'djangoblog'
setting.site_description = '基于Django的博客系统'
@ -167,6 +248,7 @@ def get_blog_setting():
setting.show_gongan_code = False
setting.comment_need_review = False
setting.save()
# yrh:获取设置并缓存
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value)
@ -174,7 +256,7 @@ def get_blog_setting():
def save_user_avatar(url):
'''
'''yrh:
保存用户头像
:param url:头像url
:return: 本地路径
@ -182,26 +264,37 @@ def save_user_avatar(url):
logger.info(url)
try:
# yrh:定义头像保存的本地目录
basedir = os.path.join(settings.STATICFILES, 'avatar')
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
if not os.path.exists(basedir):
os.makedirs(basedir)
# yrh:检查URL是否以常见的图片扩展名结尾
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
# yrh:确定文件扩展名
ext = os.path.splitext(url)[1] if isimage else '.jpg'
# yrh:生成一个唯一的文件名
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
# yrh:写入文件
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
# yrh:返回静态文件URL
return static('avatar/' + save_filename)
except Exception as e:
logger.error(e)
# yrh:如果发生任何错误,返回默认头像
return static('blog/img/avatar.png')
def delete_sidebar_cache():
"""yrh:
删除与侧边栏相关的所有缓存
当网站内容如文章评论链接发生变化时需要调用此函数来刷新侧边栏
"""
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
@ -210,23 +303,42 @@ def delete_sidebar_cache():
def delete_view_cache(prefix, keys):
"""yrh:
删除使用`template fragment cache`缓存的内容
:param prefix: 在模板中使用的缓存片段名称
:param keys: 一个列表包含构建缓存键所需的参数
"""
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
def get_resource_url():
"""yrh:
获取静态资源的基础URL
如果`STATIC_URL`已配置则直接使用否则根据当前站点域名构建
:return: 静态资源的基础URL字符串
"""
if settings.STATIC_URL:
return settings.STATIC_URL
else:
site = get_current_site()
return 'http://' + site.domain + '/static/'
# yrh:定义在清理HTML时允许保留的HTML标签和属性
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']}
def sanitize_html(html):
"""yrh:
使用bleach库清理HTML只保留允许的标签和属性防止XSS攻击
:param html: 需要清理的原始HTML字符串
:return: 清理后的安全HTML字符串
"""
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)

File diff suppressed because it is too large Load Diff

@ -7,10 +7,20 @@ For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
"""
# yrh:导入操作系统相关模块,用于设置环境变量
import os
# yrh:从Django核心模块中导入获取WSGI应用的函数
# yrh:WSGIWeb Server Gateway Interface是Python Web应用与Web服务器通信的标准接口
from django.core.wsgi import get_wsgi_application
# yrh:设置Django项目的配置模块环境变量
# yrh:"DJANGO_SETTINGS_MODULE" 是Django约定的环境变量名用于指定项目的设置文件路径
# yrh:这里指定配置文件为 "djangoblog.settings"即项目根目录下djangoblog文件夹中的settings.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
# yrh:创建WSGI应用实例
# yrh:get_wsgi_application() 函数会根据上面设置的配置模块加载项目配置并返回一个WSGI兼容的应用对象
# yrh:这个对象将被Web服务器如Gunicorn、uWSGI等调用以运行Django项目
application = get_wsgi_application()

@ -5,12 +5,12 @@ from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)#徐悦然__name__ 是 Python 的一个内置变量,代表当前模块的名称。使用它可以确保日志信息能够被准确地归因于当前文件,这是一种最佳实践。
class OAuthUserAdmin(admin.ModelAdmin):
search_fields = ('nickname', 'email')
list_per_page = 20
class OAuthUserAdmin(admin.ModelAdmin):#徐悦然:定义一个用于 OAuthUser 模型的 Admin 配置类。它继承自 admin.ModelAdmin这是 Django 中所有模型 Admin 配置的基类。通过创建这个类并注册到 OAuthUser 模型,你就可以完全控制该模型在 Admin 后台的显示和操作行为。
search_fields = ('nickname', 'email')#徐悦然:启用搜索功能。在 Admin 列表页的顶部会出现一个搜索框。用户可以输入关键字Django 会自动在 nickname 和 email 这两个字段中进行模糊查询。
list_per_page = 20#徐悦然:当 OAuthUser 的记录超过 20 条时Admin 会自动进行分页,每页显示 20 条记录,提高页面加载速度和可用性。
list_display = (
'id',
'nickname',
@ -20,8 +20,8 @@ class OAuthUserAdmin(admin.ModelAdmin):
'email',
)
list_display_links = ('id', 'nickname')
list_filter = ('author', 'type',)
readonly_fields = []
list_filter = ('author', 'type',)#徐悦然:用户可以根据 author关联的本地用户和 typeOAuth 类型,如 GitHub、微博对记录进行快速筛选。
readonly_fields = []#徐悦然:定义在详情页中哪些字段是只读的(不可编辑的)。
def get_readonly_fields(self, request, obj=None):
return list(self.readonly_fields) + \
@ -29,7 +29,7 @@ class OAuthUserAdmin(admin.ModelAdmin):
[field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request):
return False
return False#徐悦然:重写此方法以完全禁止在 Admin 中创建新的 OAuthUser 记录。
def link_to_usermodel(self, obj):
if obj.author:
@ -37,10 +37,10 @@ class OAuthUserAdmin(admin.ModelAdmin):
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))
(link, obj.author.nickname if obj.author.nickname else obj.author.email))#徐悦然:这是一个自定义的列表显示方法,用于在列表页将 author 字段显示为一个可点击的链接,直接跳转到关联的本地用户的 Admin 修改页面。
def show_user_image(self, obj):
img = obj.picture
img = obj.picture#徐悦然obj: 代表当前行的 OAuthUser 实例。obj.author: 获取与 OAuthUser 关联的本地 User 对象。
return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
@ -51,4 +51,4 @@ class OAuthUserAdmin(admin.ModelAdmin):
class OAuthConfigAdmin(admin.ModelAdmin):
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
list_filter = ('type',)
list_filter = ('type',)#徐悦然:这是一个相对简单的 Admin 配置类,用于管理 OAuthConfig 模型。

@ -2,4 +2,4 @@ from django.apps import AppConfig
class OauthConfig(AppConfig):
name = 'oauth'
name = 'oauth'#徐悦然name 属性是 AppConfig 中唯一必须的属性。

@ -2,11 +2,15 @@ from django.contrib.auth.forms import forms
from django.forms import widgets
class RequireEmailForm(forms.Form):
email = forms.EmailField(label='电子邮箱', required=True)
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
class RequireEmailForm(forms.Form):#徐悦然:定义一个名为 RequireEmailForm 的表单类。它继承自 Django 的 forms.Form这是所有非模型绑定表单即不直接与数据库模型关联的表单的基类。这个表单的核心目的是获取用户的电子邮箱地址。
email = forms.EmailField(label='电子邮箱', required=True)#徐悦然:定义一个 email 字段。forms.EmailField: 这是一个专门用于验证电子邮件地址格式的字段。它会自动检查输入内容是否符合标准的 email 格式(例如,包含 @ 符号和域名。label='电子邮箱': 设置该字段在 HTML 表单中显示的标签label为 “电子邮箱”。required=True: 标记该字段为必填项。如果用户未填写,表单提交时会报错。
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)#徐悦然forms.IntegerField: 用于接收一个整数值。
#徐悦然widget=forms.HiddenInput: 关键属性。它将这个字段渲染为一个隐藏的 HTML 输入元素 (<input type="hidden">)。用户在页面上看不到这个字段,但它的值会随着表单一起提交。
#徐悦然required=False: 该字段不是必填的。
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs):#徐悦然super(RequireEmailForm, self).__init__(*args, **kwargs): 首先调用父类 forms.Form 的构造函数,确保表单的正常初始化。这是重写方法时的标准做法。
#徐悦然:重写 __init__ 方法的主要目的是在表单实例化时,对其字段进行一些动态修改。
super(RequireEmailForm, self).__init__(*args, **kwargs)
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={'placeholder': "email", "class": "form-control"})#徐悦然placeholder="email": 在输入框中显示灰色的提示文本 “email”。
#徐悦然class="form-control": 这是一个 Bootstrap 框架的 CSS 类,用于将输入框设置为统一的、美观的样式。这表明该项目可能在使用 Bootstrap 进行前端开发。

@ -8,50 +8,50 @@ import django.utils.timezone
class Migration(migrations.Migration):
initial = True
initial = True#徐悦然: Django 这是一个 “初始迁移”
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
]#徐悦然定义了当前迁移所依赖的其他迁移。在执行当前迁移之前Django 必须先执行完所有依赖的迁移。
operations = [
operations = [#徐悦然包含了当前迁移要执行的具体数据库操作。Django 会按顺序执行这些操作。这里的操作是 CreateModel用于创建新的数据表。
migrations.CreateModel(
name='OAuthConfig',
fields=[
('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='类型')),
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
('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='类型')),#徐悦然:定义了一个下拉选择框的选项,('数据库存储值', '后台显示的友好名称')。
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),#徐悦然:是一个比较安全的长度,足以容纳大多数服务商提供的密钥
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),#徐悦然:用于存储 OAuth 授权成功后的回调地址。
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),#徐悦然:表示新添加的配置默认是启用的。
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
],#徐悦然:这是两个常见的审计字段,用于记录记录的创建和最后修改时间。
options={
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置',
'ordering': ['-created_time'],
'verbose_name_plural': 'oauth配置',#徐悦然:定义了在 Django Admin 中显示的模型名称(单数和复数)。
'ordering': ['-created_time'],#徐悦然:指定了在查询该模型时的默认排序方式。['-created_time'] 表示按创建时间降序排列,即最新创建的记录排在最前面。
},
),
migrations.CreateModel(
name='OAuthUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('openid', models.CharField(max_length=50)),
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
('token', models.CharField(blank=True, max_length=150, null=True)),
('picture', models.CharField(blank=True, max_length=350, null=True)),
('type', models.CharField(max_length=50)),
('email', models.CharField(blank=True, max_length=50, null=True)),
('metadata', models.TextField(blank=True, null=True)),
('openid', models.CharField(max_length=50)),#徐悦然:存储用户在第三方 OAuth 服务商平台上的唯一标识符OpenID
('nickname', models.CharField(max_length=50, verbose_name='昵称')),#徐悦然: 存储用户在第三方平台的昵称。
('token', models.CharField(blank=True, max_length=150, null=True)),#徐悦然:存储 OAuth 访问令牌Access Token
('picture', models.CharField(blank=True, max_length=350, null=True)),#徐悦然: 存储用户在第三方平台的头像图片 URL。
('type', models.CharField(max_length=50)),#徐悦然:标识该记录关联的 OAuth 服务商类型(如 'github', 'weibo')。
('email', models.CharField(blank=True, max_length=50, null=True)),#徐悦然:存储从第三方平台获取的用户邮箱。
('metadata', models.TextField(blank=True, null=True)),#徐悦然:使用 TextField 可以存储任意长度的文本,通常会将复杂的信息(如用户的详细资料)序列化为 JSON 字符串后存入。
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
],#徐悦然:定义了级联删除行为。如果本地用户被删除,那么关联的这个 OAuthUser 记录也会被一起删除,这通常是合理的。
options={
'verbose_name': 'oauth用户',
'verbose_name_plural': 'oauth用户',
'ordering': ['-created_time'],
},
),
]
]

@ -14,7 +14,7 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterModelOptions(
migrations.AlterModelOptions(#徐悦然:主要是修改排序字段和显示名称。
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
),
@ -22,7 +22,7 @@ class Migration(migrations.Migration):
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
),
migrations.RemoveField(
migrations.RemoveField(#徐悦然:删除了旧的时间戳字段。
model_name='oauthconfig',
name='created_time',
),
@ -38,7 +38,7 @@ class Migration(migrations.Migration):
model_name='oauthuser',
name='last_mod_time',
),
migrations.AddField(
migrations.AddField(#徐悦然:添加了新的时间戳字段。
model_name='oauthconfig',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
@ -58,7 +58,7 @@ class Migration(migrations.Migration):
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
migrations.AlterField(#徐悦然修改了多个现有字段的默认值、verbose_name 等属性。
model_name='oauthconfig',
name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
@ -83,4 +83,4 @@ class Migration(migrations.Migration):
name='nickname',
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]
]

@ -1,18 +1,18 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations, models
from django.db import migrations, models#徐悦然: 导入 Django 迁移模块和模型字段模块,这是所有迁移文件都必需的。
class Migration(migrations.Migration):
class Migration(migrations.Migration):#徐悦然: 定义迁移类,所有迁移逻辑都在这个类中。
dependencies = [
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
]#徐悦然:它依赖于 oauth 应用中名为 0002_alter_oauthconfig_options_alter_oauthuser_options_and_more 的迁移。这意味着 Django 必须先执行完 0002_... 迁移,才能执行当前这个迁移(假设当前是 0003_...)。这是因为我们要修改的 OAuthUser 模型及其 nickname 字段,正是在之前的迁移(很可能是 0001_initial 或 0002_...)中被创建或最后修改的。
operations = [
migrations.AlterField(
model_name='oauthuser',
name='nickname',
name='nickname',#徐悦然:它将该字段在 Django Admin 界面等地方显示的名称从之前的 'nickname'(一个单词)修改为了 'nick name'(两个单词,中间有空格)。
field=models.CharField(max_length=50, verbose_name='nick name'),
),
]

@ -26,9 +26,11 @@ class OAuthUser(models.Model):
def __str__(self):
return self.nickname
class Meta:
class Meta:#徐悦然class Meta: 这是 Django 模型中一个非常重要的内部类,用于定义模型的元数据和行为。
verbose_name = _('oauth user')
verbose_name_plural = verbose_name
verbose_name_plural = verbose_name#徐悦然:它们用于定义模型在 Django Admin 界面等地方显示的友好名称(单数和复数)。
ordering = ['-creation_time']
@ -49,11 +51,11 @@ class OAuthConfig(models.Model):
blank=False,
default='')
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
_('is enable'), default=True, blank=False, null=False)#徐悦然:用户首次通过 OAuth 登录时,系统可以先创建一个 OAuthUser 记录(存储其 openid 等信息然后引导用户注册或绑定已有账号。在此过程完成前author 字段可以为 null。
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
def clean(self):#徐悦然: 这是 Django 模型的一个内置方法用于在模型实例被保存save())之前进行自定义验证。
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
@ -64,4 +66,4 @@ class OAuthConfig(models.Model):
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
ordering = ['-creation_time']

@ -9,95 +9,6 @@ import requests
from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig
logger = logging.getLogger(__name__)
class OAuthAccessTokenException(Exception):
'''
oauth授权失败异常
'''
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
AUTH_URL = None
"""获取token"""
TOKEN_URL = None
"""获取用户信息"""
API_URL = None
'''icon图标名'''
ICON_NAME = None
def __init__(self, access_token=None, openid=None):
self.access_token = access_token
self.openid = openid
@property
def is_access_token_set(self):
return self.access_token is not None
@property
def is_authorized(self):
return self.is_access_token_set and self.access_token is not None and self.openid is not None
@abstractmethod
def get_authorization_url(self, nexturl='/'):
pass
@abstractmethod
def get_access_token_by_code(self, code):
pass
@abstractmethod
def get_oauth_userinfo(self):
pass
@abstractmethod
def get_picture(self, metadata):
pass
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
return rsp.text
def get_config(self):
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
class WBOauthManager(BaseOauthManager):
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None):
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(
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url + '&next_url=' + nexturl
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
@ -177,7 +88,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
self.callback_url = config.callback_url if config else '' # 徐悦然:调用父类构造函数(注意:这里会先调用 ProxyManagerMixin 的构造函数)
super(
GoogleOauthManager,
self).__init__(
@ -189,7 +100,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'openid email',
'scope': 'openid email', # 徐悦然:请求的权限范围
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@ -267,7 +178,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
'scope': 'user'# 徐悦然:请求 user 权限
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@ -281,7 +192,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
rsp = self.do_post(self.TOKEN_URL, params) #徐悦然: GitHub 返回的是表单编码的字符串,需要解析
from urllib import parse
r = parse.parse_qs(rsp)
@ -291,7 +202,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
def get_oauth_userinfo(self): #徐悦然: GitHub API 需要在请求头中携带令牌
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
@ -319,7 +230,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'# 徐悦然Facebook 授权页面
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
@ -340,7 +251,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
'scope': 'email,public_profile' # 徐悦然:请求的权限范围
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@ -348,7 +259,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'client_secret': self.client_secret, #徐悦然: Facebook 不需要 grant_type 参数
# 'grant_type': 'authorization_code',
'code': code,
@ -367,7 +278,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
def get_oauth_userinfo(self):
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
'fields': 'id,name,picture,email' #徐悦然:指定需要获取的字段
}
try:
rsp = self.do_get(self.API_URL, params)
@ -379,7 +290,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
user.email = datas['email']# 徐悦然:处理头像 URLFacebook 返回的结构较深)
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
@ -393,11 +304,11 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
class QQOauthManager(BaseOauthManager):
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'
ICON_NAME = 'qq'
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' # 徐悦然QQ 授权页面
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'# 徐悦然:获取令牌 URL
API_URL = 'https://graph.qq.com/user/get_user_info'# 徐悦然:获取用户信息 URL
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' # 徐悦然:获取 OpenID 的 URL
ICON_NAME = 'qq'# 徐悦然:图标名称
def __init__(self, access_token=None, openid=None):
config = self.get_config()
@ -426,9 +337,9 @@ class QQOauthManager(BaseOauthManager):
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.callback_url
}
} # 徐悦然QQ 使用 GET 请求获取令牌
rsp = self.do_get(self.TOKEN_URL, params)
if rsp:
if rsp:#徐悦然: 解析响应(表单编码格式)
d = urllib.parse.parse_qs(rsp)
if 'access_token' in d:
token = d['access_token']
@ -443,7 +354,7 @@ class QQOauthManager(BaseOauthManager):
'access_token': self.access_token
}
rsp = self.do_get(self.OPEN_ID_URL, params)
if rsp:
if rsp:# 徐悦然QQ 返回的是 callback 包裹的 JSON需要处理
rsp = rsp.replace(
'callback(', '').replace(
')', '').replace(
@ -482,23 +393,23 @@ class QQOauthManager(BaseOauthManager):
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
def get_oauth_apps():#徐悦然:查询所有启用的 OAuth 配置
configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs:
return []
configtypes = [x.type for x in configs]
applications = BaseOauthManager.__subclasses__()
return []# 徐悦然:获取配置的类型列表
configtypes = [x.type for x in configs] # 徐悦然:获取所有 OAuth 管理器子类
applications = BaseOauthManager.__subclasses__()# 徐悦然:筛选出配置中存在的 OAuth 应用
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps
def get_manager_by_type(type):
applications = get_oauth_apps()
if applications:
if applications:# 徐悦然:筛选出类型匹配的管理器
finds = list(
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),
applications))
if finds:
return finds[0]
return None
return None

@ -19,10 +19,10 @@ class OAuthConfigTest(TestCase):
def test_oauth_login_test(self):
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
c.type = 'weibo'#徐悦然: 配置类型为微博
c.appkey = 'appkey'# 徐悦然:模拟的 appkey
c.appsecret = 'appsecret'# 徐悦然:模拟的 appsecret
c.save()# 徐悦然:保存到数据库
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
@ -47,7 +47,7 @@ class OauthLoginTest(TestCase):
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
return applications
return applications# 徐悦然:返回所有平台管理器实例列表
def get_app_by_type(self, type):
for app in self.apps:
@ -149,10 +149,10 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
qq_app = self.get_app_by_type('qq')
qq_app = self.get_app_by_type('qq') # 徐悦然:生成 QQ 授权 URL
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
self.assertTrue("qq.com" in url) # 徐悦然:验证授权 URL 包含 QQ 域名
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@ -203,7 +203,7 @@ class OauthLoginTest(TestCase):
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
})# 徐悦然:模拟不包含邮箱的用户信息
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
@ -217,7 +217,7 @@ class OauthLoginTest(TestCase):
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 302) # 徐悦然:解析重定向 URL获取 OAuthUser 的 ID用于后续绑定邮箱
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
@ -226,7 +226,7 @@ class OauthLoginTest(TestCase):
self.assertEqual(response.status_code, 302)
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
str(oauth_user_id) + settings.SECRET_KEY) # 徐悦然:验证提交邮箱后,重定向到绑定成功提示页(提示查收邮件)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
@ -238,12 +238,12 @@ class OauthLoginTest(TestCase):
'sign': sign
})
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 302)#徐悦然: 验证确认后重定向
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
self.assertTrue(user.is_authenticated) # 徐悦然: 断言用户已认证
self.assertEqual(user.username, mock_user_info['screen_name'])# 验证用户名
self.assertEqual(user.email, 'test@gmail.com') #徐悦然: 验证绑定的邮箱
self.assertEqual(oauth_user.pk, oauth_user_id)

@ -2,24 +2,26 @@ from django.urls import path
from . import views
app_name = "oauth"
app_name = "oauth" # 徐悦然OAuth 授权回调 URL
urlpatterns = [
path(
r'oauth/authorize',
views.authorize),
views.authorize),# 徐悦然:对应的视图函数:处理第三方平台授权后的回调逻辑
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
r'oauth/requireemail/<int:oauthid>.html', #徐悦然: 路径包含参数 oauthid整数类型用于标识 OAuthUser 对象
views.RequireEmailView.as_view(),# 徐悦然:对应的类视图:通过 as_view() 方法转换为可调用的视图函数
name='require_email'),#徐悦然: URL 名称,用于反向解析(如 reverse('oauth:require_email', kwargs={'oauthid': 1})
),
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
r'oauth/emailconfirm/<int:id>/<sign>.html', #徐悦然:路径包含参数 oauthid整数类型用于标识 OAuthUser 对象
views.emailconfirm, # 徐悦然:对应的类视图:通过 as_view() 方法转换为可调用的视图函数
name='email_confirm'), # 徐悦然URL 名称,用于反向解析(如 reverse('oauth:require_email', kwargs={'oauthid': 1})
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
r'oauth/bindsuccess/<int:oauthid>.html',#徐悦然路径包含两个参数idOAuthUser 主键)和 sign签名用于验证链接合法性
views.bindsuccess,#徐悦然:对应的视图函数:处理邮箱绑定确认逻辑
name='bindsuccess'),#徐悦然URL 名称,用于反向解析确认链接
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
r'oauth/oauthlogin',#徐悦然:路径包含参数 oauthid用于标识绑定的 OAuthUser 对象
views.oauthlogin,#徐悦然:对应的视图函数:显示绑定成功或邮件发送成功的提示信息
name='oauthlogin')]

@ -34,7 +34,7 @@ def get_redirecturl(request):
p = urlparse(nexturl)
if p.netloc:
site = get_current_site().domain
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
if not p.netloc.replace('www.', '') == site.replace('www.', ''): #徐悦然: 比较 URL 的域名和当前站点的域名(去除 'www.' 前缀后比较,不区分 www 和非 www
logger.info('非法url:' + nexturl)
return "/"
return nexturl
@ -67,7 +67,7 @@ def authorize(request):
return HttpResponseRedirect('/')
except Exception as e:
logger.error(e)
rsp = None
rsp = None # 徐悦然:捕获其他未知异常,记录错误日志并将 rsp 设为 None
nexturl = get_redirecturl(request)
if not rsp:
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
@ -231,7 +231,7 @@ class RequireEmailView(FormView):
})
url = url + '?type=email'
return HttpResponseRedirect(url)
# 徐悦然:获取对应的 OAuthUser 对象
def bindsuccess(request, oauthid):
type = request.GET.get('type', None)
@ -246,8 +246,8 @@ def bindsuccess(request, oauthid):
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})
'oauthuser_type': oauthuser.type}) #徐悦然: 渲染绑定成功页面,传递标题和内容到模板
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
})

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

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

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

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

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

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

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

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

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

@ -0,0 +1,17 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python 调试程序: 当前文件",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}

@ -0,0 +1,4 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
}

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

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

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

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

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

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

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

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

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

@ -0,0 +1,207 @@
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from accounts.models import BlogUser
from blog.models import Article, Category
from djangoblog.utils import *
from . import utils
# Create your tests here.
class AccountTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
def test_validate_account(self):
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):
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):
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
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):
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):
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):
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):
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)

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

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

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

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

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

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

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

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

Loading…
Cancel
Save