yh: 完成项目代码注释

yh_branch
云涵 2 months ago
parent e5e92251f1
commit 6f65370444

@ -1,52 +1,85 @@
# 导入Django表单模块
from django import forms
# 导入Django默认的用户管理类
from django.contrib.auth.admin import UserAdmin
# 导入Django默认的用户修改表单
from django.contrib.auth.forms import UserChangeForm
# 导入用户名字段
from django.contrib.auth.forms import UsernameField
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 导入自定义的用户模型
from .models import BlogUser
# 自定义用户创建表单继承自ModelForm
class BlogUserCreationForm(forms.ModelForm):
# 密码字段1使用密码输入控件
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
# 调用父类的save方法但不立即提交到数据库
user = super().save(commit=False)
# 设置用户的密码(会自动进行哈希处理)
user.set_password(self.cleaned_data["password1"])
# 如果设置为立即提交
if commit:
# 设置用户来源为管理员站点
user.source = 'adminsite'
# 保存用户到数据库
user.save()
# 返回用户实例
return user
# 自定义用户修改表单继承自Django的UserChangeForm
class BlogUserChangeForm(UserChangeForm):
# 定义表单的元数据
class Meta:
# 指定表单对应的模型
model = BlogUser
# 包含所有字段
fields = '__all__'
# 指定字段类型映射
field_classes = {'username': UsernameField}
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
super().__init__(*args, **kwargs)
# 自定义用户管理类继承自Django的UserAdmin
class BlogUserAdmin(UserAdmin):
# 指定修改用户时使用的表单
form = BlogUserChangeForm
# 指定创建用户时使用的表单
add_form = BlogUserCreationForm
# 定义在管理列表页面显示的字段
list_display = (
'id',
'nickname',
@ -55,6 +88,9 @@ class BlogUserAdmin(UserAdmin):
'last_login',
'date_joined',
'source')
# 定义可以作为链接点击进入编辑页面的字段
list_display_links = ('id', 'username')
# 定义默认排序字段按id倒序
ordering = ('-id',)
search_fields = ('username', 'nickname', 'email')
# 定义可搜索的字段
search_fields = ('username', 'nickname', 'email')

@ -1,5 +1,8 @@
# 导入Django的应用配置基类
from django.apps import AppConfig
# 定义accounts应用的配置类
class AccountsConfig(AppConfig):
name = 'accounts'
# 指定应用的Python路径Django内部使用的标识
name = 'accounts'

@ -1,117 +1,166 @@
# 导入Django表单模块
from django import forms
# 导入Django认证相关函数和表单
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
# 登录表单继承自Django的AuthenticationForm
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"})
# 注册表单继承自Django的UserCreationForm
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:
# 指定表单对应的模型使用get_user_model获取当前用户模型
model = get_user_model()
# 指定表单包含的字段
fields = ("username", "email")
# 忘记密码表单继承自forms.Form
class ForgetPasswordForm(forms.Form):
# 新密码字段
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
label=_("New password"), # 字段标签
widget=forms.PasswordInput( # 使用密码输入控件
attrs={
"class": "form-control",
'placeholder': _("New password")
"class": "form-control", # CSS类名
'placeholder': _("New password") # 占位符文本
}
),
)
# 确认新密码字段
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
label="确认密码", # 字段标签(中文)
widget=forms.PasswordInput( # 使用密码输入控件
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
"class": "form-control", # CSS类名
'placeholder': _("Confirm password") # 占位符文本
}
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
label='邮箱', # 字段标签(中文)
widget=forms.TextInput( # 使用文本输入控件
attrs={
'class': 'form-control',
'placeholder': _("Email")
'class': 'form-control', # CSS类名
'placeholder': _("Email") # 占位符文本
}
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
label=_('Code'), # 字段标签
widget=forms.TextInput( # 使用文本输入控件
attrs={
'class': 'form-control',
'placeholder': _("Code")
'class': 'form-control', # CSS类名
'placeholder': _("Code") # 占位符文本
}
),
)
# 确认密码字段的清理和验证方法
def clean_new_password2(self):
# 从原始数据中获取两个密码字段的值
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
# 检查两个密码是否匹配
if password1 and password2 and password1 != password2:
# 如果不匹配,抛出验证错误
raise ValidationError(_("passwords do not match"))
# 使用Django的密码验证器验证密码强度
password_validation.validate_password(password2)
# 返回验证通过的密码
return password2
# 邮箱字段的清理和验证方法
def clean_email(self):
# 获取清理后的邮箱数据
user_email = self.cleaned_data.get("email")
# 检查邮箱是否存在对应的用户
if not BlogUser.objects.filter(
email=user_email
).exists():
# 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,
email=self.cleaned_data.get("email"), # 传入邮箱
code=code, # 传入验证码
)
# 如果验证返回错误信息
if error:
# 抛出验证错误
raise ValidationError(error)
# 返回验证通过的验证码
return code
# 忘记密码验证码请求表单继承自forms.Form
class ForgetPasswordCodeForm(forms.Form):
# 邮箱字段
email = forms.EmailField(
label=_('Email'),
)
label=_('Email'), # 字段标签
)

@ -1,5 +1,6 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django内置模块
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
@ -7,43 +8,66 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 初始迁移标记
initial = True
# 依赖的迁移文件
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
# 迁移操作列表
operations = [
# 创建模型的操作
migrations.CreateModel(
name='BlogUser',
name='BlogUser', # 自定义用户模型名称
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段使用Django的密码哈希存储
('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间,可为空
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 超级用户标志位
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
# 用户名字段,具有唯一性验证和字符格式验证
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
# 名字字段,可为空
('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')),
# 管理员标志位决定能否登录admin后台
('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',
'verbose_name': '用户', # 单数显示名称
'verbose_name_plural': '用户', # 复数显示名称
'ordering': ['-id'], # 默认按ID倒序排列
'get_latest_by': 'id', # 指定最新记录的依据字段
},
# 指定模型管理器
managers=[
# 使用Django默认的用户管理器
('objects', django.contrib.auth.models.UserManager()),
],
),
]
]

@ -6,41 +6,55 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('accounts', '0001_initial'),
('accounts', '0001_initial'), # 依赖于accounts应用中的初始迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 修改模型的元选项配置
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
name='bloguser', # 指定要修改的模型名称
options={
'get_latest_by': 'id', # 指定获取最新记录的依据字段为id
'ordering': ['-id'], # 设置默认排序为按id倒序排列
'verbose_name': 'user', # 设置单数形式的显示名称
'verbose_name_plural': 'user', # 设置复数形式的显示名称
},
),
# 删除模型中的字段:创建时间字段
migrations.RemoveField(
model_name='bloguser',
name='created_time',
model_name='bloguser', # 指定要删除字段的模型
name='created_time', # 要删除的字段名称
),
# 删除模型中的字段:最后修改时间字段
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
model_name='bloguser', # 指定要删除字段的模型
name='last_mod_time', # 要删除的字段名称
),
# 添加新的字段:创建时间(重命名字段)
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
model_name='bloguser', # 指定要添加字段的模型
name='creation_time', # 新字段名称
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 日期时间字段,默认值为当前时间
),
# 添加新的字段:最后修改时间(重命名字段)
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
model_name='bloguser', # 指定要添加字段的模型
name='last_modify_time', # 新字段名称
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 日期时间字段,默认值为当前时间
),
# 修改现有字段的属性:昵称字段
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
model_name='bloguser', # 指定要修改字段的模型
name='nickname', # 要修改的字段名称
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), # 更新字段的显示名称
),
# 修改现有字段的属性:来源字段
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
model_name='bloguser', # 指定要修改字段的模型
name='source', # 要修改的字段名称
field=models.CharField(blank=True, max_length=100, verbose_name='create source'), # 更新字段的显示名称
),
]
]

@ -1,35 +1,56 @@
# 导入Django认证系统的抽象用户基类
from django.contrib.auth.models import AbstractUser
# 导入Django数据库模型
from django.db import models
# 导入URL反向解析函数
from django.urls import reverse
# 导入时间相关工具
from django.utils.timezone import now
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入自定义工具函数
from djangoblog.utils import get_current_site
# Create your models here.
# 自定义博客用户模型继承自Django的AbstractUser
class BlogUser(AbstractUser):
# 昵称字段最大长度100字符允许为空
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 用户创建来源字段最大长度100字符允许为空
source = models.CharField(_('create source'), max_length=100, blank=True)
# 获取用户详情页的绝对URL不含域名
def get_absolute_url(self):
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
# 定义对象的字符串表示形式
def __str__(self):
return self.email
# 获取用户详情页的完整URL包含域名
def get_full_url(self):
# 获取当前站点的域名
site = get_current_site().domain
# 构建完整的URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 定义模型的元数据
class Meta:
# 默认按id倒序排列
ordering = ['-id']
# 单数形式的显示名称
verbose_name = _('user')
# 复数形式的显示名称(与单数相同)
verbose_name_plural = verbose_name
get_latest_by = 'id'
# 指定获取最新记录的依据字段
get_latest_by = 'id'

@ -1,135 +1,185 @@
# 导入Django测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析
from django.urls import reverse
# 导入时间处理工具
from django.utils import timezone
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入自定义模型
from accounts.models import BlogUser
from blog.models import Article, Category
# 导入自定义工具函数
from djangoblog.utils import *
# 导入当前应用的工具模块
from . import utils
# Create your tests here.
# 账户测试类继承自TestCase
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.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 访问文章管理页面
response = self.client.get(article.get_admin_url())
# 断言页面访问成功
self.assertEqual(response.status_code, 200)
# 测试注册功能
def test_validate_register(self):
# 断言注册前邮箱不存在
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 发送注册请求
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
# 断言注册后邮箱存在
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 获取刚注册的用户
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 生成验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
# 构建验证URL
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
# 访问验证页面
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# 登录新注册的用户
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 设置为超级用户和管理员
user.is_superuser = True
user.is_staff = True
user.save()
# 删除侧边栏缓存
delete_sidebar_cache()
# 创建分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建文章
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 访问文章管理页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 测试登出功能
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
# 登出后访问文章管理页面应该重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 测试错误密码登录
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
'password': 'password123' # 错误的密码
})
self.assertIn(response.status_code, [301, 302, 200])
# 用错误密码登录后访问文章管理页面应该重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 测试邮箱验证码功能
def test_verify_email_code(self):
to_email = "admin@admin.com"
# 生成验证码
code = generate_code()
# 设置验证码
utils.set_code(to_email, code)
# 发送验证邮件
utils.send_verify_email(to_email, code)
# 测试正确的验证码
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
# 测试错误的邮箱
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
self.assertEqual(type(err), str) # 应该返回错误信息字符串
# 测试成功发送忘记密码验证码
def test_forget_password_email_code_success(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
@ -139,21 +189,26 @@ class AccountTest(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
# 测试发送忘记密码验证码失败的情况
def test_forget_password_email_code_fail(self):
# 测试空数据
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
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,
@ -165,6 +220,7 @@ class AccountTest(TestCase):
path=reverse("account:forget_password"),
data=data
)
# 应该重定向到成功页面
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
@ -172,13 +228,15 @@ class AccountTest(TestCase):
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",
email="123@123.com", # 不存在的邮箱
code="123456",
)
resp = self.client.post(
@ -186,9 +244,9 @@ class AccountTest(TestCase):
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) # 应该停留在当前页面
# 测试验证码错误的情况
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
@ -196,12 +254,11 @@ class AccountTest(TestCase):
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
code="111111", # 错误的验证码
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) # 应该停留在当前页面

@ -1,28 +1,52 @@
# 导入Django URL路由相关模块
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'),
]
# 定义URL模式列表
urlpatterns = [
# 登录URL使用正则表达式匹配
re_path(r'^login/$',
# 使用类视图,设置登录成功后跳转到首页
views.LoginView.as_view(success_url='/'),
name='login', # URL名称
# 传递额外参数指定认证表单为自定义的LoginForm
kwargs={'authentication_form': LoginForm}),
# 注册URL使用正则表达式匹配
re_path(r'^register/$',
# 使用类视图,设置注册成功后跳转到首页
views.RegisterView.as_view(success_url="/"),
name='register'), # URL名称
# 登出URL使用正则表达式匹配
re_path(r'^logout/$',
# 使用类视图
views.LogoutView.as_view(),
name='logout'), # URL名称
# 账户结果页面URL使用path匹配精确路径
path(r'account/result.html',
# 使用函数视图
views.account_result,
name='result'), # URL名称
# 忘记密码URL使用正则表达式匹配
re_path(r'^forget_password/$',
# 使用类视图
views.ForgetPasswordView.as_view(),
name='forget_password'), # URL名称
# 忘记密码验证码请求URL使用正则表达式匹配
re_path(r'^forget_password_code/$',
# 使用类视图
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'), # URL名称
]

@ -1,26 +1,42 @@
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入Django认证后端基类
from django.contrib.auth.backends import ModelBackend
# 自定义认证后端,允许使用邮箱或用户名登录
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
# 用户认证方法
def authenticate(self, request, username=None, password=None, **kwargs):
# 判断输入的用户名是否包含@符号(即是否为邮箱格式)
if '@' in username:
# 如果是邮箱格式使用email字段进行查询
kwargs = {'email': username}
else:
# 如果不是邮箱格式使用username字段进行查询
kwargs = {'username': username}
try:
# 根据用户名或邮箱获取用户对象
user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password):
# 密码正确,返回用户对象
return user
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
# 用户不存在返回None
return None
# 根据用户ID获取用户对象的方法
def get_user(self, username):
try:
# 根据主键用户ID获取用户对象
return get_user_model().objects.get(pk=username)
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
return None
# 用户不存在返回None
return None

@ -1,15 +1,22 @@
# 导入类型提示模块
import typing
# 导入时间间隔类
from datetime import timedelta
# 导入Django缓存模块
from django.core.cache import cache
# 导入国际化翻译函数
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
# 导入自定义邮件发送工具
from djangoblog.utils import send_email
# 定义验证码的生存时间5分钟
_code_ttl = timedelta(minutes=5)
# 发送验证邮件的函数
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
@ -17,12 +24,15 @@ def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email"))
subject: 邮件主题
code: 验证码
"""
# 构建邮件HTML内容包含验证码信息
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
# 调用邮件发送函数
send_email([to_mail], subject, html_content)
# 验证验证码的函数
def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效
Args:
@ -34,16 +44,23 @@ def verify(email: str, code: str) -> typing.Optional[str]:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email)
# 比较输入的验证码和缓存中的验证码
if cache_code != code:
# 如果不匹配,返回错误信息
return gettext("Verification code error")
# 设置验证码到缓存的函数
def set_code(email: str, code: str):
"""设置code"""
# 将验证码存入缓存设置过期时间为_code_ttl定义的秒数
cache.set(email, code, _code_ttl.seconds)
# 从缓存获取验证码的函数
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
return cache.get(email)
# 从缓存中获取指定邮箱的验证码
return cache.get(email)

@ -1,59 +1,92 @@
# 导入日志模块
import logging
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入Django配置
from django.conf import settings
# 导入Django认证相关模块
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import make_password
# 导入HTTP响应类
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
# 导入快捷函数
from django.shortcuts import get_object_or_404
from django.shortcuts import render
# 导入URL反向解析
from django.urls import reverse
# 导入方法装饰器
from django.utils.decorators import method_decorator
# 导入URL安全验证工具
from django.utils.http import url_has_allowed_host_and_scheme
# 导入基于类的视图
from django.views import View
# 导入视图装饰器
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
# 导入自定义工具函数
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
# 导入当前应用的工具模块
from . import utils
# 导入自定义表单
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
# 导入用户模型
from .models import BlogUser
# 获取日志器
logger = logging.getLogger(__name__)
# Create your views here.
# 注册视图继承自FormView
class RegisterView(FormView):
# 指定使用的表单类
form_class = RegisterForm
# 指定模板文件
template_name = 'account/registration_form.html'
# 使用CSRF保护装饰器
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法
return super(RegisterView, self).dispatch(*args, **kwargs)
# 表单验证通过后的处理
def form_valid(self, form):
# 检查表单是否有效
if form.is_valid():
# 保存用户但不提交到数据库commit=False
user = form.save(False)
# 设置用户为非活跃状态(需要邮箱验证)
user.is_active = False
# 设置用户来源为注册
user.source = 'Register'
# 保存用户到数据库
user.save(True)
# 获取当前站点域名
site = get_current_site().domain
# 生成验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 如果是调试模式,使用本地地址
if settings.DEBUG:
site = '127.0.0.1:8000'
# 获取结果页面的URL路径
path = reverse('account:result')
# 构建完整的验证URL
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
# 构建邮件内容
content = """
<p>请点击下面链接验证您的邮箱</p>
@ -64,6 +97,7 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
user.email,
@ -71,134 +105,200 @@ class RegisterView(FormView):
title='验证您的电子邮箱',
content=content)
# 构建注册成功重定向URL
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
# 重定向到结果页面
return HttpResponseRedirect(url)
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({
'form': form
})
# 登出视图继承自RedirectView
class LogoutView(RedirectView):
# 设置登出后重定向的URL
url = '/login/'
# 使用永不缓存装饰器
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs)
# 处理GET请求
def get(self, request, *args, **kwargs):
# 执行登出操作
logout(request)
# 删除侧边栏缓存
delete_sidebar_cache()
# 调用父类的GET方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图继承自FormView
class LoginView(FormView):
# 指定使用的表单类
form_class = LoginForm
# 指定模板文件
template_name = 'account/login.html'
# 设置登录成功后的默认重定向URL
success_url = '/'
# 重定向字段名
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
# 登录会话有效期(一个月)
login_ttl = 2626560
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
# 使用多个装饰器保护视图
@method_decorator(sensitive_post_parameters('password')) # 敏感参数保护
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 永不缓存
def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)
# 获取上下文数据
def get_context_data(self, **kwargs):
# 获取重定向URL
redirect_to = self.request.GET.get(self.redirect_field_name)
# 如果没有重定向URL使用默认首页
if redirect_to is None:
redirect_to = '/'
# 将重定向URL添加到上下文
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)
# 调用父类的form_valid方法进行重定向
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({
'form': form
})
# 获取成功登录后的重定向URL
def get_success_url(self):
# 从POST数据中获取重定向URL
redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证重定向URL是否安全
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
# 如果不安全使用默认成功URL
redirect_to = self.success_url
return redirect_to
# 账户结果页面视图函数
def account_result(request):
# 获取结果类型
type = request.GET.get('type')
# 获取用户ID
id = request.GET.get('id')
# 获取用户对象如果不存在返回404
user = get_object_or_404(get_user_model(), id=id)
# 记录日志
logger.info(type)
# 如果用户已经是活跃状态,重定向到首页
if user.is_active:
return HttpResponseRedirect('/')
# 处理注册和验证类型
if type and type in ['register', 'validation']:
if type == 'register':
# 注册成功的内容
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
# 生成验证签名
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 获取请求中的签名
sign = request.GET.get('sign')
# 验证签名是否匹配
if sign != c_sign:
return HttpResponseForbidden()
# 激活用户账户
user.is_active = True
user.save()
# 验证成功的内容
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
# 其他情况重定向到首页
return HttpResponseRedirect('/')
# 忘记密码视图继承自FormView
class ForgetPasswordView(FormView):
# 指定使用的表单类
form_class = ForgetPasswordForm
# 指定模板文件
template_name = 'account/forget_password.html'
# 表单验证通过后的处理
def form_valid(self, form):
# 检查表单是否有效
if form.is_valid():
# 根据邮箱获取用户对象
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 使用新密码的哈希值更新用户密码
blog_user.password = make_password(form.cleaned_data["new_password2"])
# 保存用户信息
blog_user.save()
# 重定向到登录页面
return HttpResponseRedirect('/login/')
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({'form': form})
# 忘记密码验证码发送视图继承自View
class ForgetPasswordEmailCode(View):
# 处理POST请求
def post(self, request: HttpRequest):
# 创建表单实例
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")
# 返回成功响应
return HttpResponse("ok")

@ -1,48 +1,69 @@
# 导入Django表单模块
from django import forms
# 导入Django管理员模块
from django.contrib import admin
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 导入博客模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 自定义文章表单
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):
# 每页显示20条记录
list_per_page = 20
# 搜索字段
search_fields = ('body', 'title')
# 指定使用的表单
form = ArticleForm
# 列表页显示的字段
list_display = (
'id',
'title',
@ -53,62 +74,92 @@ class ArticlelAdmin(admin.ModelAdmin):
'status',
'type',
'article_order')
# 可作为链接点击的字段
list_display_links = ('id', 'title')
# 右侧过滤器字段
list_filter = ('status', 'type', 'category')
# 日期层次导航
date_hierarchy = 'creation_time'
# 水平选择器字段
filter_horizontal = ('tags',)
# 排除的字段(不在表单中显示)
exclude = ('creation_time', 'last_modify_time')
# 启用"在站点查看"功能
view_on_site = True
# 可用的管理动作
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# 使用原始ID字段显示搜索框而不是下拉选择
raw_id_fields = ('author', 'category',)
# 自定义方法:显示分类链接
def link_to_category(self, obj):
# 获取分类模型的app和model信息
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 生成分类编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 返回HTML链接
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
# 设置自定义方法的显示名称
link_to_category.short_description = _('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)
# 重写获取站点查看URL的方法
def get_view_on_site_url(self, obj=None):
if obj:
# 返回文章的完整URL
url = obj.get_full_url()
return url
else:
# 如果没有指定对象,返回站点域名
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
# 标签模型的管理类
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
# 排除的字段
exclude = ('slug', 'last_modify_time', 'creation_time')
# 分类模型的管理类
class CategoryAdmin(admin.ModelAdmin):
# 列表页显示的字段
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
# 排除的字段
exclude = ('slug', 'last_modify_time', 'creation_time')
# 链接模型的管理类
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
# 排除的字段
exclude = ('last_modify_time', 'creation_time')
# 侧边栏模型的管理类
class SideBarAdmin(admin.ModelAdmin):
# 列表页显示的字段
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
# 排除的字段
exclude = ('last_modify_time', 'creation_time')
# 博客设置模型的管理类
class BlogSettingsAdmin(admin.ModelAdmin):
pass
pass

@ -1,5 +1,8 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义blog应用的配置类
class BlogConfig(AppConfig):
name = 'blog'
# 指定应用的Python路径Django内部使用的标识
name = 'blog'

@ -1,43 +1,57 @@
# 导入日志模块
import logging
# 导入Django时区工具
from django.utils import timezone
# 导入自定义工具函数
from djangoblog.utils import cache, get_blog_setting
# 导入模型类
from .models import Category, Article
# 获取日志记录器
logger = logging.getLogger(__name__)
# SEO上下文处理器函数
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(),
'SITE_NAME': setting.site_name, # 网站名称
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # 是否显示Google广告
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # Google广告代码
'SITE_SEO_DESCRIPTION': setting.site_seo_description, # 网站SEO描述
'SITE_DESCRIPTION': setting.site_description, # 网站描述
'SITE_KEYWORDS': setting.site_keywords, # 网站关键词
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # 网站基础URL
'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,
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, # 评论是否需要审核
}
# 将数据存入缓存有效期10小时
cache.set(key, value, 60 * 60 * 10)
return value
# 返回上下文数据
return value

@ -1,26 +1,37 @@
# 导入时间模块
import time
# 导入Elasticsearch客户端
import elasticsearch.client
# 导入Django配置
from django.conf import settings
# 导入Elasticsearch DSL相关类
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
from elasticsearch_dsl.connections import connections
# 导入博客文章模型
from blog.models import Article
# 检查是否启用了Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
# 创建Elasticsearch客户端实例
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
# 创建Ingest客户端用于管道处理
c = IngestClient(es)
try:
# 检查geoip管道是否存在
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
# 如果不存在则创建geoip管道
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
@ -33,155 +44,174 @@ if ELASTICSEARCH_ENABLED:
}''')
# 定义GeoIP内部文档类
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
continent_name = Keyword() # 大洲名称
country_iso_code = Keyword() # 国家ISO代码
country_name = Keyword() # 国家名称
location = GeoPoint() # 地理位置坐标
# 定义用户代理浏览器内部文档类
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
Family = Keyword() # 浏览器家族
Version = Keyword() # 浏览器版本
# 定义用户代理操作系统内部文档类继承自UserAgentBrowser
class UserAgentOS(UserAgentBrowser):
pass
# 定义用户代理设备内部文档类
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
Family = Keyword() # 设备家族
Brand = Keyword() # 设备品牌
Model = Keyword() # 设备型号
# 定义用户代理内部文档类
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
browser = Object(UserAgentBrowser, required=False) # 浏览器信息
os = Object(UserAgentOS, required=False) # 操作系统信息
device = Object(UserAgentDevice, required=False) # 设备信息
string = Text() # 原始用户代理字符串
is_bot = Boolean() # 是否为机器人
# 定义耗时记录文档类
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
useragent = Object(UserAgent, required=False)
url = Keyword() # 请求URL
time_taken = Long() # 耗时(毫秒)
log_datetime = Date() # 日志时间
ip = Keyword() # IP地址
geoip = Object(GeoIp, required=False) # GeoIP信息
useragent = Object(UserAgent, required=False) # 用户代理信息
class Index:
name = 'performance'
name = 'performance' # 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'ElapsedTime'
doc_type = 'ElapsedTime' # 文档类型
# 耗时记录文档管理器类
class ElaspedTimeDocumentManager:
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 检查索引是否存在
res = client.indices.exists(index="performance")
if not res:
# 如果不存在则初始化索引
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
# 构建索引
ElaspedTimeDocumentManager.build_index()
# 创建用户代理对象
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
ua.browser.Family = useragent.browser.family # 浏览器家族
ua.browser.Version = useragent.browser.version_string # 浏览器版本
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
ua.os.Family = useragent.os.family # 操作系统家族
ua.os.Version = useragent.os.version_string # 操作系统版本
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
ua.device.Family = useragent.device.family # 设备家族
ua.device.Brand = useragent.device.brand # 设备品牌
ua.device.Model = useragent.device.model # 设备型号
ua.string = useragent.ua_string # 原始用户代理字符串
ua.is_bot = useragent.is_bot # 是否为机器人
# 创建文档对象使用时间戳作为ID
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
1000)) # 使用当前时间戳作为文档ID
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
# 保存文档使用geoip管道处理
doc.save(pipeline="geoip")
# 定义文章文档类
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 正文使用IK分词器
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 标题使用IK分词器
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 作者昵称
'id': Integer() # 作者ID
})
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 分类名称
'id': Integer() # 分类ID
})
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 标签名称
'id': Integer() # 标签ID
})
pub_time = Date()
status = Text()
comment_status = Text()
type = Text()
views = Integer()
article_order = Integer()
pub_time = Date() # 发布时间
status = Text() # 文章状态
comment_status = Text() # 评论状态
type = Text() # 文章类型
views = Integer() # 浏览量
article_order = Integer() # 文章排序
class Index:
name = 'blog'
name = 'blog' # 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'Article'
doc_type = 'Article' # 文档类型
# 文章文档管理器类
class ArticleDocumentManager():
def __init__(self):
self.create_index()
def create_index(self):
# 创建文章索引
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
# 将文章模型转换为文档对象
return [
ArticleDocument(
meta={
'id': article.id},
'id': article.id}, # 使用文章ID作为文档ID
body=article.body,
title=article.title,
author={
@ -193,7 +223,7 @@ class ArticleDocumentManager():
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
'id': t.id} for t in article.tags.all()], # 转换标签列表
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
@ -202,12 +232,14 @@ class ArticleDocumentManager():
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
# 重建索引
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
articles = articles if articles else Article.objects.all() # 如果没有指定文章,则获取所有文章
docs = self.convert_to_doc(articles) # 转换为文档对象
for doc in docs:
doc.save()
doc.save() # 保存文档
def update_docs(self, docs):
# 更新文档
for doc in docs:
doc.save()
doc.save()

@ -1,19 +1,31 @@
# 导入日志模块
import logging
# 导入Django表单相关模块
from django import forms
# 导入Haystack搜索表单基类
from haystack.forms import SearchForm
# 获取日志记录器
logger = logging.getLogger(__name__)
# 自定义博客搜索表单继承自Haystack的SearchForm
class BlogSearchForm(SearchForm):
# 定义查询数据字段,设置为必填
querydata = forms.CharField(required=True)
# 重写搜索方法
def search(self):
# 调用父类的搜索方法获取基础数据
datas = super(BlogSearchForm, self).search()
# 检查表单数据是否有效
if not self.is_valid():
# 如果表单无效,返回无查询结果
return self.no_query_found()
# 如果查询数据存在,记录日志
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas
# 返回搜索结果数据
return datas

@ -1,18 +1,29 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入Elasticsearch相关文档和管理器
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
# TODO 参数化
# 自定义管理命令类,用于构建搜索索引
class Command(BaseCommand):
# 命令的帮助信息
help = 'build search index'
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 检查Elasticsearch是否启用
if ELASTICSEARCH_ENABLED:
# 构建耗时文档的索引
ElaspedTimeDocumentManager.build_index()
# 创建耗时文档管理器实例并初始化
manager = ElapsedTimeDocument()
manager.init()
# 创建文章文档管理器实例
manager = ArticleDocumentManager()
# 删除现有索引
manager.delete_index()
manager.rebuild()
# 重新构建索引
manager.rebuild()

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

@ -1,11 +1,18 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入缓存工具
from djangoblog.utils import cache
# 自定义管理命令类,用于清除整个缓存
class Command(BaseCommand):
# 命令的帮助信息
help = 'clear the whole cache'
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 清除所有缓存
cache.clear()
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
# 输出成功信息到标准输出
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))

@ -1,40 +1,60 @@
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入密码哈希函数
from django.contrib.auth.hashers import make_password
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入博客模型
from blog.models import Article, Tag, Category
# 自定义管理命令类,用于创建测试数据
class Command(BaseCommand):
# 命令的帮助信息
help = 'create test datas'
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 获取或创建测试用户
user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
# 获取或创建父类目
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
# 获取或创建子类目
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
# 保存子类目
category.save()
# 创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 循环创建20篇测试文章
for i in range(1, 20):
# 获取或创建文章
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
# 创建新标签
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
# 为文章添加标签
article.tags.add(tag)
article.tags.add(basetag)
# 保存文章
article.save()
# 导入缓存工具
from djangoblog.utils import cache
# 清除缓存
cache.clear()
self.stdout.write(self.style.SUCCESS('created test datas \n'))
# 输出成功信息
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -1,50 +1,69 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入蜘蛛通知工具
from djangoblog.spider_notify import SpiderNotify
# 导入获取当前站点工具
from djangoblog.utils import get_current_site
# 导入博客模型
from blog.models import Article, Tag, Category
# 获取当前站点域名
site = get_current_site().domain
# 自定义管理命令类用于通知百度搜索引擎URL更新
class Command(BaseCommand):
# 命令的帮助信息
help = 'notify baidu url'
# 添加命令行参数
def add_arguments(self, parser):
parser.add_argument(
'data_type',
type=str,
'data_type', # 参数名称
type=str, # 参数类型
choices=[
'all',
'article',
'tag',
'category'],
help='article : all article,tag : all tag,category: all category,all: All of these')
'all', # 所有类型
'article', # 仅文章
'tag', # 仅标签
'category'], # 仅分类
help='article : all article,tag : all tag,category: all category,all: All of these') # 帮助信息
# 根据相对路径获取完整URL的方法
def get_full_url(self, path):
url = "https://{site}{path}".format(site=site, path=path)
return url
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 获取命令行参数中的数据类型
type = options['data_type']
# 输出开始处理的信息
self.stdout.write('start get %s' % type)
# 初始化URL列表
urls = []
# 如果类型是文章或全部获取所有已发布文章的URL
if type == 'article' or type == 'all':
for article in Article.objects.filter(status='p'):
for article in Article.objects.filter(status='p'): # 只获取已发布的文章
urls.append(article.get_full_url())
# 如果类型是标签或全部获取所有标签的URL
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
url = tag.get_absolute_url() # 获取标签的相对URL
urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表
# 如果类型是分类或全部获取所有分类的URL
if type == 'category' or type == 'all':
for category in Category.objects.all():
url = category.get_absolute_url()
urls.append(self.get_full_url(url))
url = category.get_absolute_url() # 获取分类的相对URL
urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表
# 输出开始通知的信息显示URL数量
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
# 调用百度蜘蛛通知接口批量提交URL
SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify'))
# 输出完成通知的信息
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -1,47 +1,82 @@
# 导入requests库用于HTTP请求
import requests
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入静态文件URL处理
from django.templatetags.static import static
# 导入保存用户头像的工具函数
from djangoblog.utils import save_user_avatar
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 导入根据类型获取OAuth管理器的函数
from oauth.oauthmanager import get_manager_by_type
# 自定义管理命令类,用于同步用户头像
class Command(BaseCommand):
# 命令的帮助信息
help = 'sync user avatar'
# 测试图片URL是否可访问的方法
def test_picture(self, url):
try:
# 发送HTTP GET请求测试图片URL设置2秒超时
if requests.get(url, timeout=2).status_code == 200:
# 返回200状态码表示图片可访问
return True
except:
# 发生任何异常超时、连接错误等都返回False
pass
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 获取静态文件基础URL
static_url = static("../")
# 获取所有OAuth用户
users = OAuthUser.objects.all()
# 输出开始同步的用户数量
self.stdout.write(f'开始同步{len(users)}个用户头像')
# 遍历每个用户
for u in users:
# 输出开始同步当前用户的信息
self.stdout.write(f'开始同步:{u.nickname}')
# 获取用户当前的头像URL
url = u.picture
# 如果当前有头像URL
if url:
# 检查URL是否以静态文件路径开头
if url.startswith(static_url):
# 测试静态图片是否可访问
if self.test_picture(url):
# 如果可以访问,跳过此用户,继续下一个
continue
else:
# 如果静态图片不可访问,检查是否有元数据
if u.metadata:
# 根据用户类型获取对应的OAuth管理器
manage = get_manager_by_type(u.type)
# 从元数据中获取新的头像URL
url = manage.get_picture(u.metadata)
# 保存新头像并返回本地URL
url = save_user_avatar(url)
else:
# 没有元数据,使用默认头像
url = static('blog/img/avatar.png')
else:
# 如果不是静态文件URL直接保存头像并返回本地URL
url = save_user_avatar(url)
else:
# 如果没有头像URL使用默认头像
url = static('blog/img/avatar.png')
# 如果成功获取到头像URL
if url:
# 输出同步完成的信息
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
# 更新用户的头像URL
u.picture = url
# 保存用户信息
u.save()
self.stdout.write('结束同步')
# 输出同步结束信息
self.stdout.write('结束同步')

@ -1,42 +1,65 @@
# 导入日志模块
import logging
# 导入时间模块
import time
# 导入IP获取工具
from ipware import get_client_ip
# 导入用户代理解析工具
from user_agents import parse
# 导入Elasticsearch相关配置和管理器
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
# 获取日志记录器
logger = logging.getLogger(__name__)
# 在线中间件类,用于记录页面渲染时间和用户访问信息
class OnlineMiddleware(object):
def __init__(self, get_response=None):
# 初始化中间件保存get_response函数
self.get_response = get_response
super().__init__()
def __call__(self, request):
''' page render time '''
# 记录请求开始时间
start_time = time.time()
# 调用后续中间件和视图处理请求,获取响应
response = self.get_response(request)
# 获取用户代理字符串
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
# 获取客户端IP地址
ip, _ = get_client_ip(request)
# 解析用户代理信息
user_agent = parse(http_user_agent)
# 检查响应是否为流式响应(非流式响应才能处理内容)
if not response.streaming:
try:
# 计算页面渲染耗时
cast_time = time.time() - start_time
# 如果启用了Elasticsearch
if ELASTICSEARCH_ENABLED:
# 将耗时转换为毫秒并保留两位小数
time_taken = round((cast_time) * 1000, 2)
# 获取请求的URL路径
url = request.path
# 导入Django时区工具
from django.utils import timezone
# 创建耗时记录文档
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
url=url, # 请求URL
time_taken=time_taken, # 耗时(毫秒)
log_datetime=timezone.now(), # 当前时间
useragent=user_agent, # 用户代理信息
ip=ip) # 客户端IP
# 在响应内容中替换占位符,显示页面加载时间
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
# 记录处理过程中的错误
logger.error("Error OnlineMiddleware: %s" % e)
return response
# 返回响应
return response

@ -1,137 +1,227 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django设置
from django.conf import settings
# 导入数据库迁移相关模块
from django.db import migrations, models
import django.db.models.deletion
# 导入时间工具
import django.utils.timezone
# 导入Markdown编辑器字段
import mdeditor.fields
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
# 声明依赖的迁移
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义迁移操作序列
operations = [
# 创建网站配置模型
migrations.CreateModel(
name='BlogSettings',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 网站名称字段
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
# 网站描述字段
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
# 网站SEO描述字段
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
# 网站关键词字段
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
# 文章摘要长度字段
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
# 侧边栏文章数量字段
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
# 侧边栏评论数量字段
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
# 文章页面评论数量字段
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
# 是否显示谷歌广告字段
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
# 谷歌广告代码字段
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
# 是否开启网站评论功能字段
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
# 备案号字段
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
# 网站统计代码字段
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
# 是否显示公安备案字段
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
# 公安备案号字段
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
# 模型显示名称(单数)
'verbose_name': '网站配置',
# 模型显示名称(复数)
'verbose_name_plural': '网站配置',
},
),
# 创建友情链接模型
migrations.CreateModel(
name='Links',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 链接名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
# 链接地址字段
('link', models.URLField(verbose_name='链接地址')),
# 排序字段,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# 是否启用字段
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 显示类型字段,使用选择项
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# 模型显示名称(单数)
'verbose_name': '友情链接',
# 模型显示名称(复数)
'verbose_name_plural': '友情链接',
# 默认按排序字段升序排列
'ordering': ['sequence'],
},
),
# 创建侧边栏模型
migrations.CreateModel(
name='SideBar',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 标题字段
('name', models.CharField(max_length=100, verbose_name='标题')),
# 内容字段
('content', models.TextField(verbose_name='内容')),
# 排序字段,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# 是否启用字段
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# 模型显示名称(单数)
'verbose_name': '侧边栏',
# 模型显示名称(复数)
'verbose_name_plural': '侧边栏',
# 默认按排序字段升序排列
'ordering': ['sequence'],
},
),
# 创建标签模型
migrations.CreateModel(
name='Tag',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 标签名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
# 标签slug字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
# 模型显示名称(单数)
'verbose_name': '标签',
# 模型显示名称(复数)
'verbose_name_plural': '标签',
# 默认按名称升序排列
'ordering': ['name'],
},
),
# 创建分类模型
migrations.CreateModel(
name='Category',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 分类名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
# 分类slug字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
# 权重排序字段,越大越靠前
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
# 父级分类外键,支持多级分类
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
# 模型显示名称(单数)
'verbose_name': '分类',
# 模型显示名称(复数)
'verbose_name_plural': '分类',
# 默认按权重倒序排列
'ordering': ['-index'],
},
),
# 创建文章模型
migrations.CreateModel(
name='Article',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 文章标题字段,唯一
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
# 文章正文字段使用Markdown编辑器
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
# 发布时间字段
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
# 文章状态字段,使用选择项
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
# 评论状态字段,使用选择项
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
# 类型字段,使用选择项(文章或页面)
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
# 浏览量字段,正整数
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
# 文章排序字段,数字越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
# 是否显示TOC目录字段
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
# 作者外键,关联用户模型
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# 分类外键
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
# 标签多对多关系
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
options={
# 模型显示名称(单数)
'verbose_name': '文章',
# 模型显示名称(复数)
'verbose_name_plural': '文章',
# 默认按文章排序倒序、发布时间倒序排列
'ordering': ['-article_order', '-pub_time'],
# 指定获取最新记录的依据字段
'get_latest_by': 'id',
},
),
]
]

@ -5,19 +5,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0001_initial'),
('blog', '0001_initial'), # 依赖于blog应用的初始迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 向BlogSettings模型添加新字段公共尾部
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
model_name='blogsettings', # 指定要修改的模型名称
name='global_footer', # 新字段名称
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), # 文本字段,允许为空,默认值为空字符串
),
# 向BlogSettings模型添加新字段公共头部
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
model_name='blogsettings', # 指定要修改的模型名称
name='global_header', # 新字段名称
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), # 文本字段,允许为空,默认值为空字符串
),
]
]

@ -4,14 +4,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于blog应用的第二个迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 向BlogSettings模型添加新字段评论审核开关
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
model_name='blogsettings', # 指定要修改的模型名称
name='comment_need_review', # 新字段名称
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), # 布尔字段默认值为False不需要审核
),
]
]

@ -4,24 +4,29 @@ from django.db import migrations
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
('blog', '0003_blogsettings_comment_need_review'), # 依赖于blog应用的第三个迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 重命名字段将analyticscode改为analytics_code
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
model_name='blogsettings', # 指定要修改的模型名称
old_name='analyticscode', # 原字段名称
new_name='analytics_code', # 新字段名称
),
# 重命名字段将beiancode改为beian_code
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
model_name='blogsettings', # 指定要修改的模型名称
old_name='beiancode', # 原字段名称
new_name='beian_code', # 新字段名称
),
# 重命名字段将sitename改为site_name
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
model_name='blogsettings', # 指定要修改的模型名称
old_name='sitename', # 原字段名称
new_name='site_name', # 新字段名称
),
]
]

@ -9,292 +9,355 @@ import mdeditor.fields
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖于blog应用的第四个迁移
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改Article模型的元选项国际化
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# 修改Category模型的元选项国际化
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# 修改Links模型的元选项国际化
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# 修改Sidebar模型的元选项国际化
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# 修改Tag模型的元选项国际化
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# 删除Article模型的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# 删除Article模型的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# 删除Category模型的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# 删除Category模型的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# 删除Links模型的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# 删除Sidebar模型的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# 删除Tag模型的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# 删除Tag模型的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# 向Article模型添加creation_time字段
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Article模型添加last_modify_time字段
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Category模型添加creation_time字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Category模型添加last_modify_time字段
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Links模型添加creation_time字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Sidebar模型添加creation_time字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加creation_time字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加last_modify_time字段
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Article模型的article_order字段的显示名称
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# 修改Article模型的author字段的显示名称
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改Article模型的body字段的显示名称
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# 修改Article模型的category字段的显示名称
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# 修改Article模型的comment_status字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
# 修改Article模型的pub_time字段的显示名称
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# 修改Article模型的show_toc字段的显示名称
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# 修改Article模型的status字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# 修改Article模型的tags字段的显示名称
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# 修改Article模型的title字段的显示名称
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# 修改Article模型的type字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# 修改Article模型的views字段的显示名称
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# 修改BlogSettings模型的article_comment_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
# 修改BlogSettings模型的article_sub_length字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# 修改BlogSettings模型的google_adsense_codes字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# 修改BlogSettings模型的open_site_comment字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# 修改BlogSettings模型的show_google_adsense字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# 修改BlogSettings模型的sidebar_article_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# 修改BlogSettings模型的sidebar_comment_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# 修改BlogSettings模型的site_description字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# 修改BlogSettings模型的site_keywords字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# 修改BlogSettings模型的site_name字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# 修改BlogSettings模型的site_seo_description字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# 修改Category模型的index字段的显示名称
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# 修改Category模型的name字段的显示名称
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# 修改Category模型的parent_category字段的显示名称
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# 修改Links模型的is_enable字段的显示名称
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# 修改Links模型的last_mod_time字段的显示名称
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Links模型的link字段的显示名称
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# 修改Links模型的name字段的显示名称
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# 修改Links模型的sequence字段的显示名称
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Links模型的show_type字段的显示名称和选项值
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# 修改Sidebar模型的content字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# 修改Sidebar模型的is_enable字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改Sidebar模型的last_mod_time字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Sidebar模型的name字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# 修改Sidebar模型的sequence字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Tag模型的name字段的显示名称
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]
]

@ -5,13 +5,20 @@ from django.db import migrations
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖于blog应用的第五个迁移
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改BlogSettings模型的元选项国际化
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
name='blogsettings', # 指定要修改的模型名称
options={
'verbose_name': 'Website configuration', # 单数形式的显示名称(英文)
'verbose_name_plural': 'Website configuration' # 复数形式的显示名称(英文)
},
),
]
]

@ -1,42 +1,55 @@
# 导入日志模块
import logging
# 导入正则表达式模块
import re
# 导入抽象方法装饰器
from abc import abstractmethod
# 导入Django配置和模型相关模块
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
# 导入Markdown编辑器字段
from mdeditor.fields import MDTextField
# 导入slug生成工具
from uuslug import slugify
# 导入自定义工具函数
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
# 获取日志记录器
logger = logging.getLogger(__name__)
# 链接显示类型选择类
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
I = ('i', _('index')) # 首页显示
L = ('l', _('list')) # 列表页显示
P = ('p', _('post')) # 文章页面显示
A = ('a', _('all')) # 全站显示
S = ('s', _('slide')) # 友情链接页面显示
# 基础模型抽象类
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
id = models.AutoField(primary_key=True) # 自增主键
creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间
last_modify_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间
def save(self, *args, **kwargs):
# 检查是否为文章视图更新操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 如果是视图更新,直接更新数据库
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 否则正常保存并处理slug字段
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
@ -45,65 +58,68 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
def get_full_url(self):
# 获取完整URL
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
abstract = True # 标记为抽象基类
@abstractmethod
def get_absolute_url(self):
# 抽象方法,子类必须实现
pass
# 文章模型类
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
('d', _('Draft')), # 草稿状态
('p', _('Published')), # 已发布状态
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
('o', _('Open')), # 评论开启
('c', _('Close')), # 评论关闭
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
('a', _('Article')), # 文章类型
('p', _('Page')), # 页面类型
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
title = models.CharField(_('title'), max_length=200, unique=True) # 文章标题
body = MDTextField(_('body')) # 文章正文使用Markdown编辑器
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
_('publish time'), blank=False, null=False, default=now) # 发布时间
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
default='p') # 文章状态
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
default='o') # 评论状态
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # 内容类型
views = models.PositiveIntegerField(_('views'), default=0) # 浏览量
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
on_delete=models.CASCADE) # 作者外键
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
_('order'), blank=False, null=False, default=0) # 文章排序
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # 是否显示目录
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
null=False) # 分类外键
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # 标签多对多关系
def body_to_string(self):
return self.body
@ -112,12 +128,13 @@ class Article(BaseModel):
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'] # 默认排序
verbose_name = _('article') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
get_latest_by = 'id' # 获取最新记录的依据字段
def get_absolute_url(self):
# 获取文章绝对URL
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -125,8 +142,9 @@ class Article(BaseModel):
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_category_tree(self):
# 获取分类树
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
@ -136,10 +154,12 @@ class Article(BaseModel):
super().save(*args, **kwargs)
def viewed(self):
# 增加浏览量
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
# 获取评论列表,带缓存
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
@ -147,23 +167,24 @@ class Article(BaseModel):
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
cache.set(cache_key, comments, 60 * 100) # 缓存100分钟
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
# 获取管理后台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)
@cache_decorator(expiration=60 * 100) # 缓存100分钟
def next_article(self):
# 下一篇
# 获取下一篇文章
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
@cache_decorator(expiration=60 * 100) # 缓存100分钟
def prev_article(self):
# 前一篇
# 获取上一篇文章
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
@ -171,30 +192,33 @@ class Article(BaseModel):
Get the first image url from article.body.
:return:
"""
# 从文章正文中提取第一张图片URL
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# 分类模型类
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
name = models.CharField(_('category name'), max_length=30, unique=True) # 分类名称
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
on_delete=models.CASCADE) # 父级分类,支持多级分类
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
index = models.IntegerField(default=0, verbose_name=_('index')) # 排序索引
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
ordering = ['-index'] # 按索引倒序排列
verbose_name = _('category') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
def get_absolute_url(self):
# 获取分类绝对URL
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
@ -202,7 +226,7 @@ class Category(BaseModel):
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_category_tree(self):
"""
递归获得分类目录的父级
@ -218,7 +242,7 @@ class Category(BaseModel):
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
@ -240,70 +264,76 @@ class Category(BaseModel):
return categorys
# 标签模型类
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
name = models.CharField(_('tag name'), max_length=30, unique=True) # 标签名称
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
def __str__(self):
return self.name
def get_absolute_url(self):
# 获取标签绝对URL
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_article_count(self):
# 获取标签下的文章数量
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
ordering = ['name'] # 按名称排序
verbose_name = _('tag') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
# 友情链接模型类
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称
link = models.URLField(_('link')) # 链接地址
sequence = models.IntegerField(_('order'), unique=True) # 排序序号
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
_('is show'), default=True, blank=False, null=False) # 是否启用
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
default=LinkShowType.I) # 显示类型
creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间
last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
ordering = ['sequence'] # 按序号排序
verbose_name = _('link') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
def __str__(self):
return self.name
# 侧边栏模型类
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
name = models.CharField(_('title'), max_length=100) # 侧边栏标题
content = models.TextField(_('content')) # 侧边栏内容
sequence = models.IntegerField(_('order'), unique=True) # 排序序号
is_enable = models.BooleanField(_('is enable'), default=True) # 是否启用
creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间
last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
ordering = ['sequence'] # 按序号排序
verbose_name = _('sidebar') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
def __str__(self):
return self.name
# 博客设置模型类
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
@ -311,66 +341,67 @@ class BlogSettings(models.Model):
max_length=200,
null=False,
blank=False,
default='')
default='') # 网站名称
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
default='') # 网站描述
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
_('site seo description'), max_length=1000, null=False, blank=False, default='') # 网站SEO描述
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
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广告
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
_('adsense code'), max_length=2000, null=True, blank=True, default='') # Google广告代码
open_site_comment = models.BooleanField(_('open site comment'), default=True) # 是否开启网站评论
global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部内容
global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部内容
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
default='') # 备案号
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
default='') # 网站统计代码
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
'是否显示公安备案号', default=False, null=False) # 是否显示公安备案
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
default='') # 公安备案号
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
'评论是否需要审核', default=False, null=False) # 评论是否需要审核
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
verbose_name = _('Website configuration') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
def __str__(self):
return self.site_name
def clean(self):
# 验证只能有一个配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()
cache.clear() # 保存配置后清除缓存

@ -1,13 +1,28 @@
# 导入Haystack搜索索引相关模块
from haystack import indexes
# 导入文章模型
from blog.models import Article
# 定义文章搜索索引类继承自SearchIndex和Indexable
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
# 定义主搜索字段document=True表示这是主要的搜索字段
# use_template=True表示使用模板文件来构建搜索内容
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
"""
返回与此索引关联的Django模型类
:return: Article模型类
"""
return Article
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')
"""
返回要建立索引的查询集
这里只对已发布(status='p')的文章建立索引
:param using: 使用的搜索引擎别名
:return: 已发布文章的查询集
"""
return self.get_model().objects.filter(status='p')

@ -1,53 +1,75 @@
# 导入哈希库
import hashlib
# 导入日志模块
import logging
# 导入随机数模块
import random
# 导入URL处理模块
import urllib
# 导入Django模板相关模块
from django import template
from django.conf import settings
# 导入数据库查询模块
from django.db.models import Q
from django.shortcuts import get_object_or_404
# 导入模板过滤器
from django.template.defaultfilters import stringfilter
# 导入静态文件处理
from django.templatetags.static import static
from django.urls import reverse
# 导入安全字符串处理
from django.utils.safestring import mark_safe
# 导入博客模型
from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
# 导入评论模型
from comments.models import Comment
# 导入自定义工具函数
from djangoblog.utils import CommonMarkdown, sanitize_html
from djangoblog.utils import cache
from djangoblog.utils import get_current_site
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 导入插件管理模块
from djangoblog.plugin_manage import hooks
# 获取日志记录器
logger = logging.getLogger(__name__)
# 创建模板库注册器
register = template.Library()
# 注册头部meta标签的简单标签接收上下文
@register.simple_tag(takes_context=True)
def head_meta(context):
return mark_safe(hooks.apply_filters('head_meta', '', context))
# 注册时间格式化简单标签
@register.simple_tag
def timeformat(data):
try:
# 使用设置中的时间格式
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
# 注册日期时间格式化简单标签
@register.simple_tag
def datetimeformat(data):
try:
# 使用设置中的日期时间格式
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
# 注册自定义Markdown过滤器自动处理字符串
@register.filter()
@stringfilter
def custom_markdown(content):
@ -55,16 +77,18 @@ def custom_markdown(content):
通用markdown过滤器应用文章内容插件
主要用于文章内容处理
"""
# 将Markdown内容转换为HTML
html_content = CommonMarkdown.get_markdown(content)
# 然后应用插件过滤器优化HTML
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
return mark_safe(optimized_html)
# 注册侧边栏Markdown过滤器
@register.filter()
@stringfilter
def sidebar_markdown(content):
@ -72,11 +96,12 @@ def sidebar_markdown(content):
return mark_safe(html_content)
# 注册文章内容渲染标签,接收上下文
@register.simple_tag(takes_context=True)
def render_article_content(context, article, is_summary=False):
"""
渲染文章内容包含完整的上下文信息供插件使用
Args:
context: 模板上下文
article: 文章对象
@ -84,44 +109,45 @@ def render_article_content(context, article, is_summary=False):
"""
if not article or not hasattr(article, 'body'):
return ''
# 先转换Markdown为HTML
html_content = CommonMarkdown.get_markdown(article.body)
# 如果是摘要模式,先截断内容再应用插件
if is_summary:
# 截断HTML内容到合适的长度约300字符
from django.utils.html import strip_tags
from django.template.defaultfilters import truncatechars
# 先去除HTML标签截断纯文本然后重新转换为HTML
plain_text = strip_tags(html_content)
truncated_text = truncatechars(plain_text, 300)
# 重新转换截断后的文本为HTML简化版避免复杂的插件处理
html_content = CommonMarkdown.get_markdown(truncated_text)
# 然后应用插件过滤器,传递完整的上下文
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 获取request对象
request = context.get('request')
# 应用所有文章内容相关的插件
# 注意:摘要模式下某些插件(如版权声明)可能不适用
optimized_html = hooks.apply_filters(
ARTICLE_CONTENT_HOOK_NAME,
html_content,
article=article,
ARTICLE_CONTENT_HOOK_NAME,
html_content,
article=article,
request=request,
context=context,
is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
)
return mark_safe(optimized_html)
# 注册获取Markdown目录的简单标签
@register.simple_tag
def get_markdown_toc(content):
from djangoblog.utils import CommonMarkdown
@ -129,6 +155,7 @@ def get_markdown_toc(content):
return mark_safe(toc)
# 注册评论Markdown过滤器
@register.filter()
@stringfilter
def comment_markdown(content):
@ -136,6 +163,7 @@ def comment_markdown(content):
return mark_safe(sanitize_html(content))
# 注册内容截断过滤器标记为安全HTML
@register.filter(is_safe=True)
@stringfilter
def truncatechars_content(content):
@ -150,6 +178,7 @@ def truncatechars_content(content):
return truncatechars_html(content, blogsetting.article_sub_length)
# 注册简单截断过滤器标记为安全HTML
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
@ -158,6 +187,7 @@ def truncate(content):
return strip_tags(content)[:150]
# 注册面包屑导航包含标签
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
@ -179,6 +209,7 @@ def load_breadcrumb(article):
}
# 注册文章标签列表包含标签
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
@ -199,6 +230,7 @@ def load_articletags(article):
}
# 注册侧边栏包含标签
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
@ -213,16 +245,23 @@ def load_sidebar(user, linktype):
logger.info('load sidebar')
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
# 获取最近文章
recent_articles = Article.objects.filter(
status='p')[:blogsetting.sidebar_article_count]
# 获取所有分类
sidebar_categorys = Category.objects.all()
# 获取额外的侧边栏内容
extra_sidebars = SideBar.objects.filter(
is_enable=True).order_by('sequence')
# 获取最多阅读文章
most_read_articles = Article.objects.filter(status='p').order_by(
'-views')[:blogsetting.sidebar_article_count]
# 获取文章归档日期
dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
# 获取友情链接
links = Links.objects.filter(is_enable=True).filter(
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
# 获取最新评论
commment_list = Comment.objects.filter(is_enable=True).order_by(
'-id')[:blogsetting.sidebar_comment_count]
# 标签云 计算字体大小
@ -253,12 +292,14 @@ def load_sidebar(user, linktype):
'sidebar_tags': sidebar_tags,
'extra_sidebars': extra_sidebars
}
# 设置缓存3小时过期
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
return value
# 注册文章meta信息包含标签
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
@ -272,10 +313,12 @@ def load_article_metas(article, user):
}
# 注册分页信息包含标签
@register.inclusion_tag('blog/tags/article_pagination.html')
def load_pagination_info(page_obj, page_type, tag_name):
previous_url = ''
next_url = ''
# 处理首页分页
if page_type == '':
if page_obj.has_next():
next_number = page_obj.next_page_number()
@ -285,6 +328,7 @@ def load_pagination_info(page_obj, page_type, tag_name):
previous_url = reverse(
'blog:index_page', kwargs={
'page': previous_number})
# 处理标签分页
if page_type == '分类标签归档':
tag = get_object_or_404(Tag, name=tag_name)
if page_obj.has_next():
@ -301,6 +345,7 @@ def load_pagination_info(page_obj, page_type, tag_name):
kwargs={
'page': previous_number,
'tag_name': tag.slug})
# 处理作者文章分页
if page_type == '作者文章归档':
if page_obj.has_next():
next_number = page_obj.next_page_number()
@ -316,7 +361,7 @@ def load_pagination_info(page_obj, page_type, tag_name):
kwargs={
'page': previous_number,
'author_name': tag_name})
# 处理分类目录分页
if page_type == '分类目录归档':
category = get_object_or_404(Category, name=tag_name)
if page_obj.has_next():
@ -341,6 +386,7 @@ def load_pagination_info(page_obj, page_type, tag_name):
}
# 注册文章详情包含标签
@register.inclusion_tag('blog/tags/article_info.html')
def load_article_detail(article, isindex, user):
"""
@ -360,7 +406,7 @@ def load_article_detail(article, isindex, user):
}
# 返回用户头像URL
# 返回用户头像URL的过滤器
# 模板使用方法: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
@ -369,7 +415,7 @@ def gravatar_url(email, size=40):
url = cache.get(cachekey)
if url:
return url
# 检查OAuth用户是否有自定义头像
usermodels = OAuthUser.objects.filter(email=email)
if usermodels:
@ -378,18 +424,19 @@ def gravatar_url(email, size=40):
if users_with_picture:
# 获取默认头像路径用于比较
default_avatar_path = static('blog/img/avatar.png')
# 优先选择非默认头像的用户,否则选择第一个
non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
non_default_users = [u for u in users_with_picture if
u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
selected_user = non_default_users[0] if non_default_users else users_with_picture[0]
url = selected_user.picture
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
avatar_type = 'non-default' if non_default_users else 'default'
logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type))
return url
# 使用默认头像
url = static('blog/img/avatar.png')
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
@ -397,6 +444,7 @@ def gravatar_url(email, size=40):
return url
# 返回用户头像HTML标签的过滤器
@register.filter
def gravatar(email, size=40):
"""获得用户头像HTML标签"""
@ -406,6 +454,7 @@ def gravatar(email, size=40):
(url, size, size))
# 注册查询集过滤简单标签
@register.simple_tag
def query(qs, **kwargs):
""" template tag which allows queryset filtering. Usage:
@ -417,7 +466,8 @@ def query(qs, **kwargs):
return qs.filter(**kwargs)
# 注册字符串连接过滤器
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)
return str(arg1) + str(arg2)

@ -1,5 +1,7 @@
# 导入操作系统接口模块
import os
# 导入Django配置和文件处理相关模块
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
@ -9,34 +11,46 @@ 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
# 导入OAuth相关模型
from oauth.models import OAuthUser, OAuthConfig
# 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
# 获取或创建测试用户
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.is_staff = True # 设置为管理员
user.is_superuser = True # 设置为超级用户
user.save()
# 测试用户详情页访问
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试管理员页面访问
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# 创建侧边栏
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -44,131 +58,175 @@ class ArticleTest(TestCase):
s.is_enable = True
s.save()
# 创建分类
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
# 创建标签
tag = Tag()
tag.name = "nicetag"
tag.save()
# 创建文章
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 验证初始标签数量为0
self.assertEqual(0, article.tags.count())
# 添加标签
article.tags.add(tag)
article.save()
# 验证标签数量为1
self.assertEqual(1, article.tags.count())
# 批量创建20篇文章
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
article.body = "nicetitle" + str(i)
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
article.tags.add(tag)
article.save()
# 检查是否启用Elasticsearch
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
# 构建搜索索引
call_command("build_index")
# 测试搜索功能
response = self.client.get('/search', {'q': 'nicetitle'})
self.assertEqual(response.status_code, 200)
# 测试文章详情页访问
response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试蜘蛛通知功能
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url())
# 测试标签页访问
response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试分类页访问
response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试搜索页面
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# 测试文章标签模板标签
s = load_articletags(article)
self.assertIsNotNone(s)
# 用户登录
self.client.login(username='liangliangyy', password='liangliangyy')
# 测试归档页面
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# 测试首页分页
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
# 测试标签分页
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug)
# 测试作者文章分页
p = Paginator(
Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy')
# 测试分类目录分页
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单
f = BlogSearchForm()
f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
# 测试百度蜘蛛通知
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
# 测试头像相关模板标签
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
# 创建友情链接
link = Links(
sequence=1,
name="lylinux",
link='https://wwww.lylinux.net')
link.save()
# 测试友情链接页面
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# 测试RSS订阅
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
# 测试站点地图
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# 测试管理员页面访问
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):
"""
检查分页功能
:param p: Paginator分页器对象
:param type: 分页类型
:param value: 分页值标签名分类名等
"""
for page in range(1, p.num_pages + 1):
# 加载分页信息
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
# 测试上一页链接
if s['previous_url']:
response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200)
# 测试下一页链接
if s['next_url']:
response = self.client.get(s['next_url'])
self.assertEqual(response.status_code, 200)
def test_image(self):
# 测试图片上传功能
import requests
# 下载测试图片
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
# 保存图片到本地
with open(imagepath, 'wb') as file:
file.write(rsp.content)
# 测试未授权上传
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
# 生成签名
sign = get_sha256(get_sha256(settings.SECRET_KEY))
# 测试授权上传
with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile(
'python.png', file.read(), content_type='image/jpg')
@ -176,17 +234,21 @@ class ArticleTest(TestCase):
rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200)
# 清理测试文件
os.remove(imagepath)
# 测试工具函数
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar(
'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self):
# 测试404错误页面
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_commands(self):
# 测试管理命令
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -195,12 +257,14 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
# 创建OAuth配置
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 创建OAuth用户使用默认头像
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
@ -212,6 +276,7 @@ class ArticleTest(TestCase):
}'''
u.save()
# 创建另一个OAuth用户使用QQ头像
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
@ -222,11 +287,12 @@ class ArticleTest(TestCase):
}'''
u.save()
# 执行各种管理命令
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") # 构建搜索索引
call_command("ping_baidu", "all") # 通知百度
call_command("create_testdata") # 创建测试数据
call_command("clear_cache") # 清除缓存
call_command("sync_user_avatar") # 同步用户头像
call_command("build_search_words") # 构建搜索关键词

@ -1,62 +1,93 @@
# 导入Django URL路由相关模块
from django.urls import path
# 导入缓存页面装饰器
from django.views.decorators.cache import cache_page
# 导入当前应用的视图模块
from . import views
# 定义应用的命名空间
app_name = "blog"
# 定义URL模式列表
urlpatterns = [
# 首页URL使用类视图
path(
r'',
views.IndexView.as_view(),
name='index'),
# 首页分页URL支持页码参数
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# 文章详情页URL包含年月日和文章ID参数
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# 分类详情页URL使用slug格式的分类名称
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# 分类详情分页URL支持分类名称和页码参数
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# 作者详情页URL使用作者名称参数
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# 作者详情分页URL支持作者名称和页码参数
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# 标签详情页URL使用slug格式的标签名称
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# 标签详情分页URL支持标签名称和页码参数
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
# 归档页面URL使用缓存装饰器缓存1小时
path(
'archives.html',
cache_page(
60 * 60)(
60 * 60)( # 缓存1小时60分钟 * 60秒
views.ArchivesView.as_view()),
name='archives'),
# 友情链接页面URL
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
# 文件上传URL使用函数视图
path(
r'upload',
views.fileupload,
name='upload'),
# 清理缓存URL
path(
r'clean',
views.clean_cache_view,
name='clean'),
]
]

@ -1,7 +1,11 @@
# 导入日志模块
import logging
# 导入操作系统接口模块
import os
# 导入UUID生成模块
import uuid
# 导入Django配置和核心模块
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
@ -13,17 +17,24 @@ 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
# 导入Haystack搜索视图
from haystack.views import SearchView
# 导入博客模型
from blog.models import Article, Category, LinkShowType, Links, Tag
# 导入评论表单
from comments.forms import CommentForm
# 导入插件管理模块
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 导入自定义工具函数
from djangoblog.utils import cache, get_blog_setting, get_sha256
# 获取日志记录器
logger = logging.getLogger(__name__)
# 文章列表视图基类
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
@ -33,15 +44,16 @@ class ArticleListView(ListView):
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
paginate_by = settings.PAGINATE_BY # 分页大小
page_kwarg = 'page' # 页码参数名
link_type = LinkShowType.L # 链接显示类型
def get_view_cache_key(self):
return self.request.get['pages']
@property
def page_number(self):
# 获取当前页码
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -85,10 +97,12 @@ class ArticleListView(ListView):
return value
def get_context_data(self, **kwargs):
# 添加上下文数据
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
# 首页视图
class IndexView(ArticleListView):
'''
首页
@ -97,31 +111,40 @@ class IndexView(ArticleListView):
link_type = LinkShowType.I
def get_queryset_data(self):
# 获取已发布的文章列表
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
# 生成首页缓存键
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
# 文章详情视图
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
pk_url_kwarg = 'article_id' # URL中的文章ID参数名
context_object_name = "article" # 上下文中的文章对象名
def get_context_data(self, **kwargs):
# 创建评论表单
comment_form = CommentForm()
# 获取文章评论列表
article_comments = self.object.comment_list()
# 获取父级评论
parent_comments = article_comments.filter(parent_comment=None)
# 获取博客设置
blog_setting = get_blog_setting()
# 对父级评论进行分页
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
# 验证页码
if not page.isnumeric():
page = 1
else:
@ -131,16 +154,21 @@ class ArticleDetailView(DetailView):
if page > paginator.num_pages:
page = paginator.num_pages
# 获取当前页评论
p_comments = paginator.page(page)
# 计算下一页和上一页
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 构建评论分页URL
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
# 添加上下文数据
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
@ -157,6 +185,7 @@ class ArticleDetailView(DetailView):
return context
# 分类详情视图
class CategoryDetailView(ArticleListView):
'''
分类目录列表
@ -164,13 +193,16 @@ class CategoryDetailView(ArticleListView):
page_type = "分类目录归档"
def get_queryset_data(self):
# 获取分类slug
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
# 获取所有子分类名称
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
# 获取该分类下的所有文章
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
@ -185,7 +217,6 @@ class CategoryDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
@ -196,6 +227,7 @@ class CategoryDetailView(ArticleListView):
return super(CategoryDetailView, self).get_context_data(**kwargs)
# 作者详情视图
class AuthorDetailView(ArticleListView):
'''
作者详情页
@ -222,6 +254,7 @@ class AuthorDetailView(ArticleListView):
return super(AuthorDetailView, self).get_context_data(**kwargs)
# 标签详情视图
class TagDetailView(ArticleListView):
'''
标签列表页面
@ -247,20 +280,20 @@ class TagDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
# 归档视图
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
paginate_by = None # 不分页
page_kwarg = None # 无页码参数
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
@ -271,6 +304,7 @@ class ArchivesView(ArticleListView):
return cache_key
# 友情链接视图
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
@ -279,16 +313,19 @@ class LinkListView(ListView):
return Links.objects.filter(is_enable=True)
# Elasticsearch搜索视图
class EsSearchView(SearchView):
def get_context(self):
# 构建分页
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
"query": self.query, # 搜索查询
"form": self.form, # 搜索表单
"page": page, # 当前页
"paginator": paginator, # 分页器
"suggestion": None, # 搜索建议
}
# 添加拼写建议
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
@ -296,6 +333,7 @@ class EsSearchView(SearchView):
return context
# 文件上传视图免除CSRF保护
@csrf_exempt
def fileupload(request):
"""
@ -304,30 +342,40 @@ def fileupload(request):
:return:
"""
if request.method == 'POST':
# 验证签名
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
# 处理所有上传的文件
for filename in request.FILES:
# 生成时间路径
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] # 图片扩展名
fname = u''.join(str(filename))
# 判断是否为图片
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
# 构建保存路径
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 生成唯一文件名
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
# 安全检查
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
# 生成静态文件URL
url = static(savepath)
response.append(url)
return HttpResponse(response)
@ -336,6 +384,7 @@ def fileupload(request):
return HttpResponse("only for post")
# 404错误页面视图
def page_not_found_view(
request,
exception,
@ -350,6 +399,7 @@ def page_not_found_view(
status=404)
# 500错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -358,6 +408,7 @@ def server_error_view(request, template_name='blog/error_page.html'):
status=500)
# 403权限拒绝视图
def permission_denied_view(
request,
exception,
@ -370,6 +421,7 @@ def permission_denied_view(
'statuscode': '403'}, status=403)
# 清理缓存视图
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
return HttpResponse('ok')

@ -1,49 +1,75 @@
# 导入Django管理员模块
from django.contrib import admin
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 禁用评论状态的管理动作函数
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
# 启用评论状态的管理动作函数
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
# 设置管理动作的显示名称
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# 评论模型的管理类
class CommentAdmin(admin.ModelAdmin):
# 每页显示20条记录
list_per_page = 20
# 列表页显示的字段
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
'id', # 评论ID
'body', # 评论内容
'link_to_userinfo', # 用户信息链接(自定义字段)
'link_to_article', # 文章链接(自定义字段)
'is_enable', # 是否启用
'creation_time' # 创建时间
)
# 可作为链接点击的字段
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
# 右侧过滤器字段
list_filter = ('is_enable',) # 按启用状态过滤
# 排除的字段(不在表单中显示)
exclude = ('creation_time', 'last_modify_time')
# 可用的管理动作
actions = [disable_commentstatus, enable_commentstatus]
# 使用原始ID字段显示搜索框而不是下拉选择
raw_id_fields = ('author', 'article')
search_fields = ('body',)
# 搜索字段
search_fields = ('body',) # 按评论内容搜索
# 自定义方法:显示用户信息链接
def link_to_userinfo(self, obj):
# 获取用户模型的app和model信息
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回HTML链接显示用户昵称如果没有昵称则显示邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
# 自定义方法:显示文章链接
def link_to_article(self, obj):
# 获取文章模型的app和model信息
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# 生成文章编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# 返回HTML链接显示文章标题
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# 设置自定义方法的显示名称
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
link_to_article.short_description = _('Article')

@ -1,5 +1,8 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义comments应用的配置类
class CommentsConfig(AppConfig):
name = 'comments'
# 指定应用的Python路径Django内部使用的标识
name = 'comments'

@ -1,13 +1,21 @@
# 导入Django表单模块
from django import forms
from django.forms import ModelForm
# 导入评论模型
from .models import Comment
# 评论表单类继承自ModelForm
class CommentForm(ModelForm):
# 父评论ID字段使用隐藏输入控件非必填
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
widget=forms.HiddenInput, # 使用隐藏输入控件在HTML中不可见
required=False) # 非必填字段
# 定义表单的元数据
class Meta:
# 指定表单对应的模型
model = Comment
fields = ['body']
# 指定表单包含的字段(只包含评论正文字段)
fields = ['body']

@ -1,38 +1,58 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django设置
from django.conf import settings
# 导入数据库迁移相关模块
from django.db import migrations, models
import django.db.models.deletion
# 导入时间工具
import django.utils.timezone
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
# 声明依赖的迁移
dependencies = [
('blog', '0001_initial'),
('blog', '0001_initial'), # 依赖于blog应用的初始迁移
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义迁移操作序列
operations = [
# 创建评论模型
migrations.CreateModel(
name='Comment',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 评论正文字段最大长度300字符
('body', models.TextField(max_length=300, verbose_name='正文')),
# 创建时间字段,默认值为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段,默认值为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 是否启用字段,控制评论是否显示
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 文章外键,关联到博客文章,级联删除
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
# 作者外键,关联到用户模型,级联删除
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# 父级评论外键,支持评论回复功能,允许为空
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
options={
# 模型显示名称(单数)
'verbose_name': '评论',
# 模型显示名称(复数)
'verbose_name_plural': '评论',
# 默认按ID倒序排列最新的评论在前
'ordering': ['-id'],
# 指定获取最新记录的依据字段
'get_latest_by': 'id',
},
),
]
]

@ -5,14 +5,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('comments', '0001_initial'),
('comments', '0001_initial'), # 依赖于comments应用的初始迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 修改Comment模型的is_enable字段的默认值
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='是否显示'),
model_name='comment', # 指定要修改的模型名称
name='is_enable', # 要修改的字段名称
field=models.BooleanField(default=False, verbose_name='是否显示'), # 将默认值从True改为False
),
]
]

@ -8,53 +8,67 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖于blog应用的第五个迁移
('blog', '0005_alter_article_options_alter_category_options_and_more'),
# 依赖于comments应用的第二个迁移
('comments', '0002_alter_comment_is_enable'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改Comment模型的元选项国际化
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
# 删除Comment模型的created_time字段
migrations.RemoveField(
model_name='comment',
name='created_time',
),
# 删除Comment模型的last_mod_time字段
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
# 向Comment模型添加creation_time字段
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Comment模型添加last_modify_time字段
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改Comment模型的article字段的显示名称
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
# 修改Comment模型的author字段的显示名称
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改Comment模型的is_enable字段的显示名称
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
# 修改Comment模型的parent_comment字段的显示名称
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]
]

@ -1,39 +1,54 @@
# 导入Django配置
from django.conf import settings
# 导入Django数据库模型
from django.db import models
# 导入时间工具
from django.utils.timezone import now
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入博客文章模型
from blog.models import Article
# Create your models here.
# 评论模型类
class Comment(models.Model):
# 评论正文字段最大长度300字符
body = models.TextField('正文', max_length=300)
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 作者外键,关联到用户模型,级联删除
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 文章外键,关联到博客文章,级联删除
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 父级评论外键,支持评论回复功能,允许为空
parent_comment = models.ForeignKey(
'self',
'self', # 自关联,指向同一个模型
verbose_name=_('parent comment'),
blank=True,
null=True,
blank=True, # 在表单中允许为空
null=True, # 在数据库中允许为NULL
on_delete=models.CASCADE)
# 是否启用字段控制评论是否显示默认值为False需要审核
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# 定义模型的元数据
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
ordering = ['-id'] # 默认按ID倒序排列最新的评论在前
verbose_name = _('comment') # 单数形式的显示名称
verbose_name_plural = verbose_name # 复数形式的显示名称(与单数相同)
get_latest_by = 'id' # 指定获取最新记录的依据字段
# 定义对象的字符串表示形式
def __str__(self):
return self.body
return self.body # 返回评论正文作为字符串表示

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

@ -1,80 +1,111 @@
# 导入Django测试相关模块
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
# 导入账户模型
from accounts.models import BlogUser
# 导入博客模型
from blog.models import Category, Article
# 导入评论模型
from comments.models import Comment
# 导入评论模板标签(导入所有)
from comments.templatetags.comments_tags import *
# 导入自定义工具函数
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
# 评论测试类使用TransactionTestCase支持数据库事务
class CommentsTest(TransactionTestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
# 导入博客设置模型并创建配置
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True
value.comment_need_review = True # 设置评论需要审核
value.save()
# 创建超级用户用于测试
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article):
"""
更新文章所有评论的启用状态为已启用
:param article: 文章对象
"""
comments = article.comment_set.all()
for comment in comments:
comment.is_enable = True
comment.save()
def test_validate_comment(self):
# 用户登录
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建分类
category = Category()
category.name = "categoryccc"
category.save()
# 创建文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 获取评论提交URL
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
# 测试提交第一条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
'body': '123ffffffffff' # 评论内容
})
# 验证重定向响应(评论提交后重定向)
self.assertEqual(response.status_code, 302)
# 重新获取文章对象
article = Article.objects.get(pk=article.pk)
# 验证评论列表为空(因为评论需要审核,默认未启用)
self.assertEqual(len(article.comment_list()), 0)
# 更新评论状态为已启用
self.update_article_comment_status(article)
# 验证评论列表现在有1条评论
self.assertEqual(len(article.comment_list()), 1)
# 测试提交第二条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
# 验证重定向响应
self.assertEqual(response.status_code, 302)
# 重新获取文章对象并更新评论状态
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
# 验证现在有2条评论
self.assertEqual(len(article.comment_list()), 2)
# 获取第一条评论的ID作为父评论
parent_comment_id = article.comment_list()[0].id
# 测试提交带Markdown格式的回复评论
response = self.client.post(comment_url,
{
'body': '''
@ -89,21 +120,34 @@ class CommentsTest(TransactionTestCase):
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
''', # 包含Markdown格式的评论内容
'parent_comment_id': parent_comment_id # 父评论ID
})
# 验证重定向响应
self.assertEqual(response.status_code, 302)
# 更新评论状态
self.update_article_comment_status(article)
# 重新获取文章对象
article = Article.objects.get(pk=article.pk)
# 验证现在有3条评论
self.assertEqual(len(article.comment_list()), 3)
# 获取父评论对象
comment = Comment.objects.get(id=parent_comment_id)
# 测试解析评论树功能
tree = parse_commenttree(article.comment_list(), comment)
# 验证评论树有1个子评论
self.assertEqual(len(tree), 1)
data = show_comment_item(comment, True)
# 测试显示评论项模板标签
data = show_comment_item(comment, True) # 作为子评论显示
self.assertIsNotNone(data)
# 测试获取最大文章ID和评论ID
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
# 测试发送评论邮件功能
from comments.utils import send_comment_email
send_comment_email(comment)
send_comment_email(comment)

@ -1,11 +1,17 @@
# 导入Django URL路由相关模块
from django.urls import path
# 导入当前应用的视图模块
from . import views
# 定义应用的命名空间
app_name = "comments"
# 定义URL模式列表
urlpatterns = [
# 文章评论提交URL
path(
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
]
'article/<int:article_id>/postcomment', # URL路径包含文章ID参数
views.CommentPostView.as_view(), # 使用类视图处理请求
name='postcomment'), # URL名称用于反向解析
]

@ -1,17 +1,31 @@
# 导入日志模块
import logging
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入自定义工具函数
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
# 获取日志记录器
logger = logging.getLogger(__name__)
# 发送评论邮件函数
def send_comment_email(comment):
"""
发送评论相关邮件通知
:param comment: 评论对象
"""
# 获取当前站点域名
site = get_current_site().domain
# 邮件主题
subject = _('Thanks for your comment')
# 构建文章完整URL
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 构建给评论作者的邮件内容(感谢评论)
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
@ -19,10 +33,16 @@ def send_comment_email(comment):
<br />
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
# 获取评论作者的邮箱
tomail = comment.author.email
# 发送感谢邮件给评论作者
send_email([tomail], subject, html_content)
try:
# 检查是否为回复评论(有父评论)
if comment.parent_comment:
# 构建给父评论作者的邮件内容(通知有新回复)
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
@ -32,7 +52,10 @@ def send_comment_email(comment):
%(article_url)s
""") % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body}
# 获取父评论作者的邮箱
tomail = comment.parent_comment.author.email
# 发送回复通知邮件给父评论作者
send_email([tomail], subject, html_content)
except Exception as e:
logger.error(e)
# 记录发送回复通知邮件时的错误
logger.error(e)

@ -1,63 +1,105 @@
# Create your views here.
# 导入Django核心异常类
from django.core.exceptions import ValidationError
# 导入HTTP重定向响应
from django.http import HttpResponseRedirect
# 导入快捷函数
from django.shortcuts import get_object_or_404
# 导入方法装饰器
from django.utils.decorators import method_decorator
# 导入CSRF保护装饰器
from django.views.decorators.csrf import csrf_protect
# 导入基于类的表单视图
from django.views.generic.edit import FormView
# 导入账户模型
from accounts.models import BlogUser
# 导入博客文章模型
from blog.models import Article
# 导入评论表单
from .forms import CommentForm
# 导入评论模型
from .models import Comment
# 评论提交视图类
class CommentPostView(FormView):
# 指定使用的表单类
form_class = CommentForm
# 指定模板文件
template_name = 'blog/article_detail.html'
# 使用CSRF保护装饰器
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
"""处理GET请求重定向到文章详情页的评论区域"""
# 从URL参数获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象如果不存在返回404
article = get_object_or_404(Article, pk=article_id)
# 获取文章绝对URL
url = article.get_absolute_url()
# 重定向到文章详情页的评论锚点区域
return HttpResponseRedirect(url + "#comments")
def form_invalid(self, form):
"""表单验证失败时的处理逻辑"""
# 获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象
article = get_object_or_404(Article, pk=article_id)
# 重新渲染模板,显示表单错误
return self.render_to_response({
'form': form,
'article': article
'form': form, # 包含错误信息的表单
'article': article # 文章对象
})
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
# 获取当前用户
user = self.request.user
# 获取对应的BlogUser对象
author = BlogUser.objects.get(pk=user.pk)
# 从URL参数获取文章ID
article_id = self.kwargs['article_id']
# 获取文章对象
article = get_object_or_404(Article, pk=article_id)
# 检查文章是否关闭评论或文章状态为关闭
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
# 从表单获取评论对象但不保存到数据库
comment = form.save(False)
# 设置评论关联的文章
comment.article = article
# 获取博客设置
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
# 如果设置中评论不需要审核,则直接启用评论
if not settings.comment_need_review:
comment.is_enable = True
# 设置评论作者
comment.author = author
# 检查是否为回复评论有父评论ID
if form.cleaned_data['parent_comment_id']:
# 获取父评论对象
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])
# 设置评论的父评论
comment.parent_comment = parent_comment
# 保存评论到数据库
comment.save(True)
# 重定向到文章详情页的特定评论锚点
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))
"%s#div-comment-%d" % # 使用评论ID构建锚点
(article.get_absolute_url(), comment.pk))

@ -1,8 +1,10 @@
# 导入Django管理员站点相关模块
from django.contrib.admin import AdminSite
from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site
# 导入各应用的管理员配置
from accounts.admin import *
from blog.admin import *
from blog.models import *
@ -17,16 +19,25 @@ from servermanager.admin import *
from servermanager.models import *
# 自定义DjangoBlog管理员站点类
class DjangoBlogAdminSite(AdminSite):
# 站点头部标题(显示在浏览器标签页)
site_header = 'djangoblog administration'
# 站点标题(显示在登录页面和主页)
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
# 调用父类初始化方法
super().__init__(name)
def has_permission(self, request):
"""
检查用户是否有权限访问管理员站点
只允许超级用户访问
"""
return request.user.is_superuser
# 注释掉的URL配置示例可用于添加自定义管理员视图
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
@ -38,27 +49,36 @@ class DjangoBlogAdminSite(AdminSite):
# return urls + my_urls
# 创建DjangoBlog管理员站点实例
admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
admin_site.register(Links, LinksAdmin)
admin_site.register(SideBar, SideBarAdmin)
admin_site.register(BlogSettings, BlogSettingsAdmin)
# 注册博客相关模型到管理员站点
admin_site.register(Article, ArticlelAdmin) # 文章模型
admin_site.register(Category, CategoryAdmin) # 分类模型
admin_site.register(Tag, TagAdmin) # 标签模型
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
# 注册服务器管理相关模型
admin_site.register(commands, CommandsAdmin) # 命令模型
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型
admin_site.register(BlogUser, BlogUserAdmin)
# 注册用户模型
admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型
admin_site.register(Comment, CommentAdmin)
# 注册评论模型
admin_site.register(Comment, CommentAdmin) # 评论模型
admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
# 注册OAuth认证相关模型
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# 注册位置追踪相关模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置追踪日志模型
admin_site.register(Site, SiteAdmin)
# 注册Django内置站点模型
admin_site.register(Site, SiteAdmin) # 站点模型
admin_site.register(LogEntry, LogEntryAdmin)
# 注册Django内置日志条目模型
admin_site.register(LogEntry, LogEntryAdmin) # 日志条目模型

@ -1,11 +1,21 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义djangoblog应用的配置类
class DjangoblogAppConfig(AppConfig):
# 指定默认的自增字段类型为BigAutoField64位自增整数
default_auto_field = 'django.db.models.BigAutoField'
# 指定应用的Python路径Django内部使用的标识
name = 'djangoblog'
def ready(self):
"""
应用准备就绪时调用的方法
当Django启动完成且所有应用都加载完毕后执行
"""
# 调用父类的ready方法
super().ready()
# Import and load plugins here
# 导入并加载插件
from .plugin_manage.loader import load_plugins
load_plugins()
# 调用插件加载函数
load_plugins()

@ -1,6 +1,9 @@
# 导入线程模块
import _thread
# 导入日志模块
import logging
# 导入Django信号相关模块
import django.dispatch
from django.conf import settings
from django.contrib.admin.models import LogEntry
@ -9,61 +12,79 @@ from django.core.mail import EmailMultiAlternatives
from django.db.models.signals import post_save
from django.dispatch import receiver
# 导入评论模型和工具函数
from comments.models import Comment
from comments.utils import send_comment_email
# 导入蜘蛛通知工具
from djangoblog.spider_notify import SpiderNotify
# 导入自定义缓存工具函数
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 获取日志记录器
logger = logging.getLogger(__name__)
oauth_user_login_signal = django.dispatch.Signal(['id'])
# 定义自定义信号
oauth_user_login_signal = django.dispatch.Signal(['id']) # OAuth用户登录信号
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
['emailto', 'title', 'content']) # 发送邮件信号
# 注册发送邮件信号处理器
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
# 从信号参数中获取邮件相关信息
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
# 创建邮件对象
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
msg.content_subtype = "html"
title, # 邮件标题
content, # 邮件内容
from_email=settings.DEFAULT_FROM_EMAIL, # 发件人
to=emailto) # 收件人列表
msg.content_subtype = "html" # 设置邮件内容类型为HTML
# 导入邮件发送日志模型
from servermanager.models import EmailSendLog
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto)
log.emailto = ','.join(emailto) # 将收件人列表转换为逗号分隔的字符串
try:
# 尝试发送邮件
result = msg.send()
log.send_result = result > 0
log.send_result = result > 0 # 记录发送结果成功为True失败为False
except Exception as e:
# 记录发送失败的错误日志
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False
log.save()
log.save() # 保存邮件发送日志
# 注册OAuth用户登录信号处理器
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
id = kwargs['id']
# 获取OAuth用户对象
oauthuser = OAuthUser.objects.get(id=id)
site = get_current_site().domain
# 检查用户头像是否需要处理如果头像URL不包含当前站点域名
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
# 保存用户头像并更新URL
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
# 删除侧边栏缓存
delete_sidebar_cache()
# 注册模型保存后信号处理器
@receiver(post_save)
def model_post_save_callback(
sender,
@ -73,50 +94,67 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
clearcache = False
clearcache = False # 是否清除缓存的标志
# 跳过LogEntry模型的保存操作
if isinstance(instance, LogEntry):
return
# 检查实例是否有get_full_url方法
if 'get_full_url' in dir(instance):
is_update_views = update_fields == {'views'}
is_update_views = update_fields == {'views'} # 判断是否为更新浏览量的操作
# 如果不是测试环境且不是更新浏览量的操作
if not settings.TESTING and not is_update_views:
try:
# 通知搜索引擎有新内容
notify_url = instance.get_full_url()
SpiderNotify.baidu_notify([notify_url])
except Exception as ex:
logger.error("notify sipder", ex)
# 如果不是更新浏览量的操作,设置清除缓存标志
if not is_update_views:
clearcache = True
# 处理评论保存操作
if isinstance(instance, Comment):
if instance.is_enable:
if instance.is_enable: # 如果评论已启用
path = instance.article.get_absolute_url()
site = get_current_site().domain
# 处理站点域名(移除端口号)
if site.find(':') > 0:
site = site[0:site.find(':')]
# 使文章详情页缓存过期
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
# 删除SEO处理器缓存
if cache.get('seo_processor'):
cache.delete('seo_processor')
# 删除文章评论缓存
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
cache.delete(comment_cache_key)
# 删除侧边栏缓存
delete_sidebar_cache()
# 删除文章评论视图缓存
delete_view_cache('article_comments', [str(instance.article.pk)])
# 在新线程中发送评论通知邮件
_thread.start_new_thread(send_comment_email, (instance,))
# 如果需要清除缓存,清除所有缓存
if clearcache:
cache.clear()
@receiver(user_logged_in)
@receiver(user_logged_out)
# 注册用户登录和登出信号处理器
@receiver(user_logged_in) # 用户登录信号
@receiver(user_logged_out) # 用户登出信号
def user_auth_callback(sender, request, user, **kwargs):
if user and user.username:
logger.info(user)
delete_sidebar_cache()
# cache.clear()
logger.info(user) # 记录用户信息
delete_sidebar_cache() # 删除侧边栏缓存
# cache.clear() # 注释掉的清除所有缓存操作

@ -1,93 +1,123 @@
# 导入Django字符串编码工具
from django.utils.encoding import force_str
# 导入Elasticsearch DSL查询类
from elasticsearch_dsl import Q
# 导入Haystack搜索引擎基类
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
# 导入Haystack表单类
from haystack.forms import ModelSearchForm
# 导入Haystack搜索结果类
from haystack.models import SearchResult
# 导入Haystack日志工具
from haystack.utils import log as logging
# 导入博客文档和文档管理器
from blog.documents import ArticleDocument, ArticleDocumentManager
# 导入博客文章模型
from blog.models import Article
# 获取日志记录器
logger = logging.getLogger(__name__)
# Elasticsearch搜索后端类
class ElasticSearchBackend(BaseSearchBackend):
def __init__(self, connection_alias, **connection_options):
# 调用父类初始化方法
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
# 初始化文章文档管理器
self.manager = ArticleDocumentManager()
# 是否包含拼写建议
self.include_spelling = True
def _get_models(self, iterable):
# 获取模型列表如果iterable为空则获取所有文章
models = iterable if iterable and iterable[0] else Article.objects.all()
# 将模型转换为文档对象
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
# 创建索引并重建数据
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
def _delete(self, models):
# 删除文档
for m in models:
m.delete()
return True
def _rebuild(self, 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):
# 更新索引文档
models = self._get_models(iterable)
self.manager.update_docs(models)
def remove(self, obj_or_string):
# 移除指定对象
models = self._get_models([obj_or_string])
self._delete(models)
def clear(self, models=None, commit=True):
# 清空索引
self.remove(None)
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
# 执行搜索建议查询
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
# 处理搜索建议结果
for suggest in search.suggest.suggest_search:
if suggest["options"]:
# 如果有建议词,使用第一个建议词
keywords.append(suggest["options"][0]["text"])
else:
# 如果没有建议词,使用原词
keywords.append(suggest["text"])
return ' '.join(keywords)
@log_query
def search(self, query_string, **kwargs):
# 记录搜索查询字符串
logger.info('search query_string:' + query_string)
# 获取分页偏移量
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
# 推荐词搜索
if getattr(self, "is_suggest", None):
# 如果启用建议搜索,获取搜索建议
suggestion = self.get_suggestion(query_string)
else:
# 否则使用原查询字符串
suggestion = query_string
# 构建搜索查询条件
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
# 执行搜索查询
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
@ -122,8 +152,10 @@ class ElasticSearchBackend(BaseSearchBackend):
}
# Elasticsearch搜索查询类
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
# 转换日期时间格式
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
@ -155,20 +187,25 @@ class ElasticSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
# 构建查询片段
return value.query_string
def get_count(self):
# 获取搜索结果数量
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
# 获取拼写建议
return self._spelling_suggestion
def build_params(self, spelling_query=None):
# 构建查询参数
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
# Elasticsearch模型搜索表单类
class ElasticSearchModelSearchForm(ModelSearchForm):
def search(self):
@ -178,6 +215,9 @@ class ElasticSearchModelSearchForm(ModelSearchForm):
return sqs
# Elasticsearch搜索引擎类
class ElasticSearchEngine(BaseEngine):
# 指定后端类
backend = ElasticSearchBackend
query = ElasticSearchQuery
# 指定查询类
query = ElasticSearchQuery

@ -1,40 +1,59 @@
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入Django联合供稿RSS/Atom视图
from django.contrib.syndication.views import Feed
# 导入时区工具
from django.utils import timezone
# 导入RSS 2.01修订版Feed生成器
from django.utils.feedgenerator import Rss201rev2Feed
# 导入博客文章模型
from blog.models import Article
# 导入自定义Markdown工具
from djangoblog.utils import CommonMarkdown
# Django博客Feed类用于生成RSS订阅
class DjangoBlogFeed(Feed):
# 指定Feed类型为RSS 2.01修订版
feed_type = Rss201rev2Feed
# Feed描述
description = '大巧无工,重剑无锋.'
# Feed标题
title = "且听风吟 大巧无工,重剑无锋. "
# Feed链接地址
link = "/feed/"
def author_name(self):
# 返回第一个用户的昵称作为作者名
return get_user_model().objects.first().nickname
def author_link(self):
# 返回第一个用户的个人主页链接
return get_user_model().objects.first().get_absolute_url()
def items(self):
# 返回最近发布的5篇文章按发布时间倒序排列
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
def item_title(self, item):
# 返回文章标题
return item.title
def item_description(self, item):
# 将文章正文从Markdown转换为HTML格式
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
# 生成包含当前年份的版权信息
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
# 返回文章的绝对URL
return item.get_absolute_url()
def item_guid(self, item):
return
# 返回文章的唯一标识符当前返回None
return

@ -1,27 +1,40 @@
# 导入Django管理员模块
from django.contrib import admin
# 导入日志条目删除标志
from django.contrib.admin.models import DELETION
# 导入内容类型模型
from django.contrib.contenttypes.models import ContentType
# 导入URL反向解析和异常
from django.urls import reverse, NoReverseMatch
# 导入字符串编码工具
from django.utils.encoding import force_str
# 导入HTML转义工具
from django.utils.html import escape
# 导入安全字符串处理
from django.utils.safestring import mark_safe
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 日志条目管理类
class LogEntryAdmin(admin.ModelAdmin):
# 右侧过滤器字段
list_filter = [
'content_type'
]
# 搜索字段
search_fields = [
'object_repr',
'change_message'
]
# 可作为链接点击的字段
list_display_links = [
'action_time',
'get_change_message',
]
# 列表页显示的字段
list_display = [
'action_time',
'user_link',
@ -31,61 +44,78 @@ class LogEntryAdmin(admin.ModelAdmin):
]
def has_add_permission(self, request):
# 禁用添加权限
return False
def has_change_permission(self, request, obj=None):
# 只有超级用户或有修改日志权限的用户可以查看且不允许POST修改
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
def has_delete_permission(self, request, obj=None):
# 禁用删除权限
return False
def object_link(self, obj):
# 转义对象表示字符串
object_link = escape(obj.object_repr)
content_type = obj.content_type
# 如果不是删除操作且内容类型存在,尝试生成对象链接
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
# 生成对象编辑页面的URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
# 创建HTML链接
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
pass
# 返回安全的HTML字符串
return mark_safe(object_link)
# 设置对象链接字段的排序和显示名称
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
def user_link(self, obj):
# 获取用户的内容类型
content_type = ContentType.objects.get_for_model(type(obj.user))
# 转义用户字符串表示
user_link = escape(force_str(obj.user))
try:
# try returning an actual link instead of object repr string
# 生成用户编辑页面的URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
# 创建HTML链接
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
pass
# 返回安全的HTML字符串
return mark_safe(user_link)
# 设置用户链接字段的排序和显示名称
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
def get_queryset(self, request):
# 获取查询集并预取关联的内容类型
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
def get_actions(self, request):
# 获取操作列表并移除删除选中操作
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
return actions

@ -1,18 +1,29 @@
# 导入日志模块
import logging
# 获取日志记录器
logger = logging.getLogger(__name__)
# 基础插件类
class BasePlugin:
# 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
PLUGIN_NAME = None # 插件名称(子类必须定义)
PLUGIN_DESCRIPTION = None # 插件描述(子类必须定义)
PLUGIN_VERSION = None # 插件版本(子类必须定义)
def __init__(self):
"""
插件初始化方法
验证插件元数据并执行初始化流程
"""
# 验证插件元数据是否完整定义
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
# 执行插件初始化
self.init_plugin()
# 注册插件钩子
self.register_hooks()
def init_plugin(self):
@ -20,6 +31,7 @@ class BasePlugin:
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
"""
# 记录插件初始化日志
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
@ -27,6 +39,7 @@ class BasePlugin:
注册插件钩子
子类可以重写此方法来注册特定的钩子
"""
# 基类中为空实现,由子类重写
pass
def get_plugin_info(self):
@ -35,7 +48,7 @@ class BasePlugin:
:return: 包含插件元数据的字典
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION
}
'name': self.PLUGIN_NAME, # 插件名称
'description': self.PLUGIN_DESCRIPTION, # 插件描述
'version': self.PLUGIN_VERSION # 插件版本
}

@ -1,7 +1,14 @@
# 文章详情加载钩子常量 - 当文章详情页面加载时触发
ARTICLE_DETAIL_LOAD = 'article_detail_load'
# 文章创建钩子常量 - 当创建新文章时触发
ARTICLE_CREATE = 'article_create'
# 文章更新钩子常量 - 当更新现有文章时触发
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 文章删除钩子常量 - 当删除文章时触发
ARTICLE_DELETE = 'article_delete'
# 文章内容处理钩子常量 - 用于处理文章内容的插件钩子
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -1,17 +1,27 @@
# 导入日志模块
import logging
# 获取日志记录器
logger = logging.getLogger(__name__)
# 全局钩子字典,用于存储所有注册的钩子
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调
Args:
hook_name: 钩子名称
callback: 回调函数可调用对象
"""
# 如果钩子名称不存在于字典中,初始化一个空列表
if hook_name not in _hooks:
_hooks[hook_name] = []
# 将回调函数添加到对应钩子的列表中
_hooks[hook_name].append(callback)
# 记录调试日志,显示注册的钩子和回调函数名称
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
@ -19,26 +29,53 @@ def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
Args:
hook_name: 钩子名称
*args: 位置参数
**kwargs: 关键字参数
"""
# 检查钩子是否存在
if hook_name in _hooks:
# 记录调试日志,显示正在运行的钩子
logger.debug(f"Running action hook '{hook_name}'")
# 遍历该钩子下的所有回调函数
for callback in _hooks[hook_name]:
try:
# 执行回调函数
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
# 记录执行过程中的错误,包含完整的堆栈信息
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
Args:
hook_name: 钩子名称
value: 需要过滤的值
*args: 位置参数
**kwargs: 关键字参数
Returns:
经过所有回调函数处理后的值
"""
# 检查钩子是否存在
if hook_name in _hooks:
# 记录调试日志,显示正在应用的过滤器钩子
logger.debug(f"Applying filter hook '{hook_name}'")
# 遍历该钩子下的所有回调函数
for callback in _hooks[hook_name]:
try:
# 将当前值传递给回调函数进行处理,并更新值
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
# 记录处理过程中的错误,包含完整的堆栈信息
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True)
# 返回处理后的值
return value

@ -1,19 +1,33 @@
# 导入操作系统接口模块
import os
# 导入日志模块
import logging
# 导入Django配置
from django.conf import settings
# 获取日志记录器
logger = logging.getLogger(__name__)
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
'plugins'目录动态加载和初始化插件
此函数应在Django应用注册表准备就绪时调用
"""
# 遍历设置中配置的活跃插件列表
for plugin_name in settings.ACTIVE_PLUGINS:
# 构建插件路径
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# 检查插件目录是否存在且包含plugin.py文件
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# 动态导入插件模块
__import__(f'plugins.{plugin_name}.plugin')
# 记录成功加载插件的日志
logger.info(f"Successfully loaded plugin: {plugin_name}")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
# 记录导入插件失败的日志,包含异常信息
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

@ -1,59 +1,87 @@
# 导入Django站点地图相关模块
from django.contrib.sitemaps import Sitemap
# 导入URL反向解析
from django.urls import reverse
# 导入博客模型
from blog.models import Article, Category, Tag
# 静态视图站点地图类
class StaticViewSitemap(Sitemap):
# 优先级0.0到1.0之间)
priority = 0.5
# 内容更新频率
changefreq = 'daily'
def items(self):
# 返回需要包含在站点地图中的静态视图名称列表
return ['blog:index', ]
def location(self, item):
# 根据视图名称生成对应的URL
return reverse(item)
# 文章站点地图类
class ArticleSiteMap(Sitemap):
# 内容更新频率为每月
changefreq = "monthly"
# 优先级为0.6
priority = "0.6"
def items(self):
# 返回所有已发布的文章
return Article.objects.filter(status='p')
def lastmod(self, obj):
# 返回文章的最后修改时间
return obj.last_modify_time
# 分类站点地图类
class CategorySiteMap(Sitemap):
# 内容更新频率为每周
changefreq = "Weekly"
# 优先级为0.6
priority = "0.6"
def items(self):
# 返回所有分类
return Category.objects.all()
def lastmod(self, obj):
# 返回分类的最后修改时间
return obj.last_modify_time
# 标签站点地图类
class TagSiteMap(Sitemap):
# 内容更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
def items(self):
# 返回所有标签
return Tag.objects.all()
def lastmod(self, obj):
# 返回标签的最后修改时间
return obj.last_modify_time
# 用户站点地图类
class UserSiteMap(Sitemap):
# 内容更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
def items(self):
# 返回所有有文章的作者列表(去重)
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
return obj.date_joined
# 返回用户的注册时间
return obj.date_joined

@ -1,21 +1,32 @@
# 导入日志模块
import logging
# 导入HTTP请求库
import requests
# 导入Django配置
from django.conf import settings
# 获取日志记录器
logger = logging.getLogger(__name__)
# 蜘蛛通知类(用于通知搜索引擎更新内容)
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
# 向百度搜索引擎提交URL更新通知
try:
# 将URL列表转换为换行分隔的字符串
data = '\n'.join(urls)
# 向百度站长平台提交URL更新
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
# 记录百度返回的结果
logger.info(result.text)
except Exception as e:
# 记录通知过程中的错误
logger.error(e)
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
# 单个URL通知的便捷方法
SpiderNotify.baidu_notify(url)

@ -1,15 +1,22 @@
# 导入Django测试框架
from django.test import TestCase
# 导入djangoblog工具模块的所有函数
from djangoblog.utils import *
# DjangoBlog测试类
class DjangoBlogTest(TestCase):
def setUp(self):
# 测试初始化方法(当前为空实现)
pass
def test_utils(self):
# 测试工具函数
# 测试SHA256哈希函数
md5 = get_sha256('test')
self.assertIsNotNone(md5)
# 测试Markdown转换功能
c = CommonMarkdown.get_markdown('''
# Title1
@ -24,9 +31,10 @@ class DjangoBlogTest(TestCase):
''')
self.assertIsNotNone(c)
# 测试字典转URL参数字符串功能
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)
self.assertIsNotNone(data)

@ -2,35 +2,52 @@
# encoding: utf-8
# 导入日志模块
import logging
# 导入操作系统接口模块
import os
# 导入随机数模块
import random
# 导入字符串处理模块
import string
# 导入UUID生成模块
import uuid
# 导入哈希库
from hashlib import sha256
# 导入HTML清理库
import bleach
# 导入Markdown处理库
import markdown
# 导入HTTP请求库
import requests
# 导入Django配置
from django.conf import settings
# 导入Django站点模型
from django.contrib.sites.models import Site
# 导入Django缓存系统
from django.core.cache import cache
# 导入静态文件处理
from django.templatetags.static import static
# 获取日志记录器
logger = logging.getLogger(__name__)
# 获取最大文章ID和评论ID
def get_max_articleid_commentid():
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
# 计算字符串的SHA256哈希值
def get_sha256(str):
m = sha256(str.encode('utf-8'))
return m.hexdigest()
# 缓存装饰器
def cache_decorator(expiration=3 * 60):
def wrapper(func):
def news(*args, **kwargs):
@ -67,6 +84,7 @@ def cache_decorator(expiration=3 * 60):
return wrapper
# 使视图缓存过期
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
@ -92,12 +110,14 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
return False
# 获取当前站点(带缓存)
@cache_decorator()
def get_current_site():
site = Site.objects.get_current()
return site
# 通用Markdown处理类
class CommonMarkdown:
@staticmethod
def _convert_markdown(value):
@ -124,6 +144,7 @@ class CommonMarkdown:
return body
# 发送邮件函数
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
@ -133,11 +154,13 @@ def send_email(emailto, title, content):
content=content)
# 生成随机验证码
def generate_code() -> str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
# 将字典解析为URL参数字符串
def parse_dict_to_url(dict):
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
@ -145,6 +168,7 @@ def parse_dict_to_url(dict):
return url
# 获取博客设置(带缓存)
def get_blog_setting():
value = cache.get('get_blog_setting')
if value:
@ -173,6 +197,7 @@ def get_blog_setting():
return value
# 保存用户头像
def save_user_avatar(url):
'''
保存用户头像
@ -201,6 +226,7 @@ def save_user_avatar(url):
return static('blog/img/avatar.png')
# 删除侧边栏缓存
def delete_sidebar_cache():
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
@ -209,12 +235,14 @@ def delete_sidebar_cache():
cache.delete(k)
# 删除视图缓存
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
# 获取资源URL
def get_resource_url():
if settings.STATIC_URL:
return settings.STATIC_URL
@ -223,6 +251,7 @@ def get_resource_url():
return 'http://' + site.domain + '/static/'
# 允许的HTML标签白名单
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div']
@ -235,6 +264,7 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
]
# 自定义class属性过滤器
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
@ -245,8 +275,8 @@ def class_filter(tag, name, value):
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'span': class_filter,
'div': class_filter,
@ -257,16 +287,17 @@ ALLOWED_ATTRIBUTES = {
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# HTML清理函数
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
"""
return bleach.clean(
html,
tags=ALLOWED_TAGS,
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
)
)

@ -1,16 +1,25 @@
# 导入日志模块
import logging
# 导入Django管理员模块
from django.contrib import admin
# Register your models here.
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 获取日志记录器
logger = logging.getLogger(__name__)
# OAuth用户模型的管理类
class OAuthUserAdmin(admin.ModelAdmin):
# 搜索字段
search_fields = ('nickname', 'email')
# 每页显示20条记录
list_per_page = 20
# 列表页显示的字段
list_display = (
'id',
'nickname',
@ -19,36 +28,51 @@ class OAuthUserAdmin(admin.ModelAdmin):
'type',
'email',
)
# 可作为链接点击的字段
list_display_links = ('id', 'nickname')
# 右侧过滤器字段
list_filter = ('author', 'type',)
# 只读字段列表
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
# 将所有字段设置为只读,防止在管理员界面修改
return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request):
# 禁用添加权限
return False
def link_to_usermodel(self, obj):
# 生成关联用户模型的链接
if obj.author:
# 获取用户模型的app和model信息
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回HTML链接显示用户昵称如果没有昵称则显示邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def show_user_image(self, obj):
# 显示用户头像
img = obj.picture
# 返回HTML图片标签设置固定尺寸
return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
# 设置自定义方法的显示名称
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# OAuth配置模型的管理类
class OAuthConfigAdmin(admin.ModelAdmin):
# 列表页显示的字段
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
list_filter = ('type',)
# 右侧过滤器字段
list_filter = ('type',)

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

@ -1,12 +1,18 @@
# 导入Django表单模块
from django.contrib.auth.forms import forms
from django.forms import widgets
# 需要邮箱的表单类
class RequireEmailForm(forms.Form):
# 邮箱字段,标签为'电子邮箱',必填
email = forms.EmailField(label='电子邮箱', required=True)
# OAuth ID字段使用隐藏输入控件非必填
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
super(RequireEmailForm, self).__init__(*args, **kwargs)
# 自定义邮箱字段的控件属性
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={'placeholder': "email", "class": "form-control"})

@ -8,50 +8,81 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
# 声明依赖的迁移
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义迁移操作序列
operations = [
# 创建OAuth配置模型
migrations.CreateModel(
name='OAuthConfig',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# OAuth类型字段使用选择项
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# 应用密钥字段
('appkey', 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='是否显示')),
# 创建时间字段,默认值为当前时间
('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'],
},
),
# 创建OAuth用户模型
migrations.CreateModel(
name='OAuthUser',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 第三方平台用户ID字段
('openid', models.CharField(max_length=50)),
# 用户昵称字段
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# 访问令牌字段,允许为空
('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户头像字段,允许为空
('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth类型字段
('type', models.CharField(max_length=50)),
# 邮箱字段,允许为空
('email', models.CharField(blank=True, max_length=50, null=True)),
# 元数据字段,存储额外的用户信息,允许为空
('metadata', models.TextField(blank=True, null=True)),
# 创建时间字段,默认值为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段,默认值为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 关联用户外键,允许为空(未绑定本地用户时)
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
# 模型显示名称(单数)
'verbose_name': 'oauth用户',
# 模型显示名称(复数)
'verbose_name_plural': 'oauth用户',
# 默认按创建时间倒序排列
'ordering': ['-created_time'],
},
),
]
]

@ -8,79 +8,98 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖于oauth应用的初始迁移
('oauth', '0001_initial'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改OAuthConfig模型的元选项
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
),
# 修改OAuthUser模型的元选项国际化
migrations.AlterModelOptions(
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
),
# 删除OAuthConfig模型的created_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='created_time',
),
# 删除OAuthConfig模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time',
),
# 删除OAuthUser模型的created_time字段
migrations.RemoveField(
model_name='oauthuser',
name='created_time',
),
# 删除OAuthUser模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time',
),
# 向OAuthConfig模型添加creation_time字段
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向OAuthConfig模型添加last_modify_time字段
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 向OAuthUser模型添加creation_time字段
migrations.AddField(
model_name='oauthuser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向OAuthUser模型添加last_modify_time字段
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改OAuthConfig模型的callback_url字段的显示名称和默认值
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
# 修改OAuthConfig模型的is_enable字段的显示名称
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改OAuthConfig模型的type字段的显示名称和选项值国际化
migrations.AlterField(
model_name='oauthconfig',
name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
),
# 修改OAuthUser模型的author字段的显示名称
migrations.AlterField(
model_name='oauthuser',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改OAuthUser模型的nickname字段的显示名称
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]
]

@ -5,14 +5,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖于oauth应用的第二个迁移
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改OAuthUser模型的nickname字段的显示名称
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nick name'),
),
]
]

@ -1,38 +1,60 @@
# Create your models here.
# 导入Django配置
from django.conf import settings
# 导入验证异常类
from django.core.exceptions import ValidationError
# 导入Django数据库模型
from django.db import models
# 导入时间工具
from django.utils.timezone import now
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# OAuth用户模型类
class OAuthUser(models.Model):
# 关联用户外键,允许为空(未绑定本地用户时)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 第三方平台用户ID字段
openid = models.CharField(max_length=50)
# 用户昵称字段
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
# 访问令牌字段,允许为空
token = models.CharField(max_length=150, null=True, blank=True)
# 用户头像字段,允许为空
picture = models.CharField(max_length=350, blank=True, null=True)
# OAuth类型字段必填
type = models.CharField(blank=False, null=False, max_length=50)
# 邮箱字段,允许为空
email = models.CharField(max_length=50, null=True, blank=True)
# 元数据字段,存储额外的用户信息,允许为空
metadata = models.TextField(null=True, blank=True)
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self):
# 返回用户昵称作为字符串表示
return self.nickname
class Meta:
# 模型显示名称(单数)
verbose_name = _('oauth user')
# 模型显示名称(复数)
verbose_name_plural = verbose_name
# 默认按创建时间倒序排列
ordering = ['-creation_time']
# OAuth配置模型类
class OAuthConfig(models.Model):
# OAuth类型选择项
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
@ -40,28 +62,40 @@ class OAuthConfig(models.Model):
('facebook', 'FaceBook'),
('qq', 'QQ'),
)
# OAuth类型字段使用选择项
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# 应用密钥字段
appkey = models.CharField(max_length=200, verbose_name='AppKey')
# 应用密钥字段
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# 回调地址字段,必填,默认值为空字符串
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
# 是否启用字段
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
# 验证同类型的OAuth配置是否已存在
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
# 返回OAuth类型作为字符串表示
return self.type
class Meta:
# 模型显示名称(单数)
verbose_name = 'oauth配置'
# 模型显示名称(复数)
verbose_name_plural = verbose_name
ordering = ['-creation_time']
# 默认按创建时间倒序排列
ordering = ['-creation_time']

@ -1,23 +1,34 @@
# 导入JSON处理模块
import json
# 导入日志模块
import logging
# 导入操作系统接口模块
import os
# 导入URL解析模块
import urllib.parse
# 导入抽象基类
from abc import ABCMeta, abstractmethod
# 导入HTTP请求库
import requests
# 导入自定义缓存装饰器
from djangoblog.utils import cache_decorator
# 导入OAuth模型
from oauth.models import OAuthUser, OAuthConfig
# 获取日志记录器
logger = logging.getLogger(__name__)
# OAuth授权令牌异常类
class OAuthAccessTokenException(Exception):
'''
oauth授权失败异常
'''
# 基础OAuth管理器抽象类
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
AUTH_URL = None
@ -29,48 +40,60 @@ class BaseOauthManager(metaclass=ABCMeta):
ICON_NAME = None
def __init__(self, access_token=None, openid=None):
# 访问令牌
self.access_token = access_token
# 用户开放ID
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='/'):
# 抽象方法获取授权URL
pass
@abstractmethod
def get_access_token_by_code(self, code):
# 抽象方法:通过授权码获取访问令牌
pass
@abstractmethod
def get_oauth_userinfo(self):
# 抽象方法获取OAuth用户信息
pass
@abstractmethod
def get_picture(self, metadata):
# 抽象方法:获取用户头像
pass
def do_get(self, url, params, headers=None):
# 执行GET请求
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
# 执行POST请求
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
return rsp.text
def get_config(self):
# 获取OAuth配置
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
# 微博OAuth管理器
class WBOauthManager(BaseOauthManager):
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
@ -89,6 +112,7 @@ class WBOauthManager(BaseOauthManager):
openid=openid)
def get_authorization_url(self, nexturl='/'):
# 构建微博授权URL
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -98,7 +122,7 @@ class WBOauthManager(BaseOauthManager):
return url
def get_access_token_by_code(self, code):
# 通过授权码获取微博访问令牌
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -117,6 +141,7 @@ class WBOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
# 获取微博用户信息
if not self.is_authorized:
return None
params = {
@ -142,12 +167,15 @@ class WBOauthManager(BaseOauthManager):
return None
def get_picture(self, metadata):
# 从元数据中获取微博用户头像
datas = json.loads(metadata)
return datas['avatar_large']
# 代理管理器混入类
class ProxyManagerMixin:
def __init__(self, *args, **kwargs):
# 设置HTTP代理
if os.environ.get("HTTP_PROXY"):
self.proxies = {
"http": os.environ.get("HTTP_PROXY"),
@ -157,16 +185,19 @@ class ProxyManagerMixin:
self.proxies = None
def do_get(self, url, params, headers=None):
# 使用代理执行GET请求
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
# 使用代理执行POST请求
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
# Google OAuth管理器
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
@ -185,6 +216,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, nexturl='/'):
# 构建Google授权URL
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -195,6 +227,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
# 通过授权码获取Google访问令牌
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -216,6 +249,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
# 获取Google用户信息
if not self.is_authorized:
return None
params = {
@ -241,10 +275,12 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
# 从元数据中获取Google用户头像
datas = json.loads(metadata)
return datas['picture']
# GitHub OAuth管理器
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
@ -263,6 +299,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
# 构建GitHub授权URL
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -273,6 +310,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
# 通过授权码获取GitHub访问令牌
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -292,7 +330,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
# 获取GitHub用户信息
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
@ -314,10 +352,12 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
# 从元数据中获取GitHub用户头像
datas = json.loads(metadata)
return datas['avatar_url']
# Facebook OAuth管理器
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
@ -336,6 +376,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
# 构建Facebook授权URL
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -346,6 +387,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
# 通过授权码获取Facebook访问令牌
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -365,6 +407,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
# 获取Facebook用户信息
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
@ -388,10 +431,12 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
# 从元数据中获取Facebook用户头像
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
# QQ OAuth管理器
class QQOauthManager(BaseOauthManager):
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
@ -411,6 +456,7 @@ class QQOauthManager(BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
# 构建QQ授权URL
params = {
'response_type': 'code',
'client_id': self.client_id,
@ -420,6 +466,7 @@ class QQOauthManager(BaseOauthManager):
return url
def get_access_token_by_code(self, code):
# 通过授权码获取QQ访问令牌
params = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
@ -438,6 +485,7 @@ class QQOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_open_id(self):
# 获取QQ用户OpenID
if self.is_access_token_set:
params = {
'access_token': self.access_token
@ -454,6 +502,7 @@ class QQOauthManager(BaseOauthManager):
return openid
def get_oauth_userinfo(self):
# 获取QQ用户信息
openid = self.get_open_id()
if openid:
params = {
@ -477,10 +526,12 @@ class QQOauthManager(BaseOauthManager):
return user
def get_picture(self, metadata):
# 从元数据中获取QQ用户头像
datas = json.loads(metadata)
return str(datas['figureurl'])
# 获取启用的OAuth应用列表带缓存
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
configs = OAuthConfig.objects.filter(is_enable=True).all()
@ -492,6 +543,7 @@ def get_oauth_apps():
return apps
# 根据类型获取对应的OAuth管理器
def get_manager_by_type(type):
applications = get_oauth_apps()
if applications:
@ -501,4 +553,4 @@ def get_manager_by_type(type):
applications))
if finds:
return finds[0]
return None
return None

@ -1,45 +1,60 @@
# 导入JSON处理模块
import json
# 导入单元测试模拟模块
from unittest.mock import patch
# 导入Django配置和测试相关模块
from django.conf import settings
from django.contrib import auth
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
# 导入自定义工具函数
from djangoblog.utils import get_sha256
# 导入OAuth配置模型
from oauth.models import OAuthConfig
# 导入OAuth管理器基类
from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
# OAuth配置测试类
class OAuthConfigTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_oauth_login_test(self):
# 创建微博OAuth配置
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 测试OAuth登录页面重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试OAuth授权回调
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
# OAuth登录测试类
class OauthLoginTest(TestCase):
def setUp(self) -> None:
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
# 初始化OAuth应用
self.apps = self.init_apps()
def init_apps(self):
# 初始化所有OAuth应用配置
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
c = OAuthConfig()
@ -50,6 +65,7 @@ class OauthLoginTest(TestCase):
return applications
def get_app_by_type(self, type):
# 根据类型获取对应的OAuth应用
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -57,9 +73,11 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
# 测试微博登录功能
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
url = weibo_app.get_authorization_url()
# 模拟微博API返回数据
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -70,15 +88,18 @@ class OauthLoginTest(TestCase):
"email": "email",
})
userinfo = weibo_app.get_access_token_by_code('code')
# 验证用户信息
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
# 测试Google登录功能
google_app = self.get_app_by_type('google')
assert google_app
url = google_app.get_authorization_url()
# 模拟Google API返回数据
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
@ -91,17 +112,20 @@ class OauthLoginTest(TestCase):
})
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
# 验证用户信息
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'sub')
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
# 测试GitHub登录功能
github_app = self.get_app_by_type('github')
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
# 模拟GitHub API返回数据
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url",
@ -111,16 +135,19 @@ class OauthLoginTest(TestCase):
})
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
# 验证用户信息
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
# 测试Facebook登录功能
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
# 模拟Facebook API返回数据
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
@ -136,9 +163,11 @@ class OauthLoginTest(TestCase):
})
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
# 验证用户信息
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
# 模拟QQ API三次调用的返回数据
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
json.dumps({
@ -149,18 +178,22 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
# 测试QQ登录功能
qq_app = self.get_app_by_type('qq')
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
# 验证用户信息
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
# 测试带邮箱的微博授权登录
# 模拟微博API返回数据
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -172,14 +205,17 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
# 验证用户认证状态
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
@ -187,6 +223,7 @@ class OauthLoginTest(TestCase):
self.assertEqual(user.email, mock_user_info['email'])
self.client.logout()
# 重复登录测试
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
@ -200,7 +237,9 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
# 测试不带邮箱的微博授权登录(需要补充邮箱)
# 模拟微博API返回数据不含邮箱
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -211,20 +250,25 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调(应该重定向到邮箱补充页面)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
# 解析OAuth用户ID
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
# 提交邮箱信息
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
# 生成验证签名
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
@ -233,6 +277,7 @@ class OauthLoginTest(TestCase):
})
self.assertEqual(response.url, f'{url}?type=email')
# 邮箱确认
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
@ -240,10 +285,12 @@ class OauthLoginTest(TestCase):
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证用户认证状态和OAuth绑定
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
self.assertEqual(oauth_user.pk, oauth_user_id)

@ -1,25 +1,35 @@
# 导入Django URL路由相关模块
from django.urls import path
# 导入当前应用的视图模块
from . import views
# 定义应用的命名空间
app_name = "oauth"
# 定义URL模式列表
urlpatterns = [
# OAuth授权回调URL使用函数视图
path(
r'oauth/authorize',
views.authorize),
# 需要邮箱补充页面URL使用类视图包含OAuth用户ID参数
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
# 邮箱确认URL包含用户ID和签名参数
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
# 绑定成功页面URL包含OAuth用户ID参数
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
# OAuth登录入口URL
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
name='oauthlogin')]

@ -1,7 +1,10 @@
# 导入日志模块
import logging
# Create your views here.
# 导入URL解析模块
from urllib.parse import urlparse
# 导入Django配置和认证相关模块
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login
@ -16,16 +19,21 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
# 导入自定义信号和工具函数
from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email, get_sha256
# 导入OAuth表单
from oauth.forms import RequireEmailForm
# 导入OAuth模型和管理器
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
# 获取日志记录器
logger = logging.getLogger(__name__)
# 获取重定向URL
def get_redirecturl(request):
nexturl = request.GET.get('next_url', None)
if not nexturl or nexturl == '/login/' or nexturl == '/login':
@ -40,6 +48,7 @@ def get_redirecturl(request):
return nexturl
# OAuth登录入口视图
def oauthlogin(request):
type = request.GET.get('type', None)
if not type:
@ -52,6 +61,7 @@ def oauthlogin(request):
return HttpResponseRedirect(authorizeurl)
# OAuth授权回调视图
def authorize(request):
type = request.GET.get('type', None)
if not type:
@ -124,6 +134,7 @@ def authorize(request):
return HttpResponseRedirect(nexturl)
# 邮箱确认视图
def emailconfirm(request, id, sign):
if not sign:
return HttpResponseForbidden()
@ -170,6 +181,7 @@ def emailconfirm(request, id, sign):
return HttpResponseRedirect(url)
# 需要邮箱补充视图类
class RequireEmailView(FormView):
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
@ -233,6 +245,7 @@ class RequireEmailView(FormView):
return HttpResponseRedirect(url)
# 绑定成功页面视图
def bindsuccess(request, oauthid):
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
@ -250,4 +263,4 @@ def bindsuccess(request, oauthid):
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
})

@ -1,7 +1,10 @@
# 导入Django管理员模块
from django.contrib import admin
# Register your models here.
# OwnTrackLog模型的管理类
class OwnTrackLogsAdmin(admin.ModelAdmin):
pass
# 使用默认的管理员配置
pass

@ -1,5 +1,8 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义owntracks应用的配置类
class OwntracksConfig(AppConfig):
name = 'owntracks'
# 指定应用的Python路径Django内部使用的标识
name = 'owntracks'

@ -6,26 +6,39 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
# 声明依赖的迁移(当前无依赖)
dependencies = [
]
# 定义迁移操作序列
operations = [
# 创建OwnTrackLog模型位置追踪日志
migrations.CreateModel(
name='OwnTrackLog',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段最大长度100字符
('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度字段,浮点数类型
('lat', models.FloatField(verbose_name='纬度')),
# 经度字段,浮点数类型
('lon', models.FloatField(verbose_name='经度')),
# 创建时间字段,默认值为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
options={
# 模型显示名称(单数)
'verbose_name': 'OwnTrackLogs',
# 模型显示名称(复数)
'verbose_name_plural': 'OwnTrackLogs',
# 默认按创建时间升序排列
'ordering': ['created_time'],
# 指定获取最新记录的依据字段
'get_latest_by': 'created_time',
},
),
]
]

@ -5,18 +5,23 @@ from django.db import migrations
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖于owntracks应用的初始迁移
('owntracks', '0001_initial'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改OwnTrackLog模型的元选项
migrations.AlterModelOptions(
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
),
# 重命名字段将created_time改为creation_time
migrations.RenameField(
model_name='owntracklog',
old_name='created_time',
new_name='creation_time',
),
]
]

@ -1,20 +1,32 @@
# 导入Django数据库模型
from django.db import models
# 导入时间工具
from django.utils.timezone import now
# Create your models here.
# OwnTrackLog模型类位置追踪日志
class OwnTrackLog(models.Model):
# 用户标识字段最大长度100字符必填
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
# 纬度字段,浮点数类型
lat = models.FloatField(verbose_name='纬度')
# 经度字段,浮点数类型
lon = models.FloatField(verbose_name='经度')
# 创建时间字段,默认值为当前时间
creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
# 返回用户标识作为字符串表示
return self.tid
class Meta:
# 默认按创建时间升序排列
ordering = ['creation_time']
# 模型显示名称(单数)
verbose_name = "OwnTrackLogs"
# 模型显示名称(复数)
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'
# 指定获取最新记录的依据字段
get_latest_by = 'creation_time'

@ -10,55 +10,79 @@ from .models import OwnTrackLog
class OwnTrackLogTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_own_track_log(self):
# 测试用例验证OwnTrackLog的功能
# 创建第一个有效的位置数据对象包含经纬度和tid
o = {
'tid': 12,
'lat': 123.123,
'lon': 134.341
'tid': 12, # 设备ID
'lat': 123.123, # 纬度
'lon': 134.341 # 经度
}
# 发送POST请求提交位置数据
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
json.dumps(o), # 将字典转换为JSON字符串
content_type='application/json') # 设置内容类型为JSON
# 验证数据是否成功保存到数据库
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
self.assertEqual(length, 1) # 应该有一条记录
# 创建第二个不完整的位置数据对象(缺少经度)
o = {
'tid': 12,
'lat': 123.123
# 缺少lon字段这个请求应该不会创建新记录
}
# 发送第二个POST请求
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
# 验证记录数量没有增加(因为缺少必需字段)
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
self.assertEqual(length, 1) # 应该仍然只有一条记录
# 测试未登录用户访问地图页面的重定向
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302)
self.assertEqual(rsp.status_code, 302) # 302表示重定向到登录页
# 创建超级用户用于后续测试
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 使用新创建的用户登录
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 手动创建一个OwnTrackLog对象
s = OwnTrackLog()
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
s.tid = 12 # 设置设备ID
s.lon = 123.234 # 设置经度
s.lat = 34.234 # 设置纬度
s.save() # 保存到数据库
# 测试登录后访问日期显示页面
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200)
self.assertEqual(rsp.status_code, 200) # 200表示成功访问
# 测试登录后访问地图页面
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
self.assertEqual(rsp.status_code, 200) # 现在应该能正常访问
# 测试获取数据接口(不带参数)
rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200)
# 测试获取数据接口(带日期参数)
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200)
self.assertEqual(rsp.status_code, 200)

@ -2,11 +2,20 @@ from django.urls import path
from . import views
# 定义应用的命名空间用于URL反向解析
app_name = "owntracks"
# URL模式配置将URL路径映射到对应的视图函数
urlpatterns = [
# 接收OwnTracks位置数据日志的端点
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
# 显示地图的页面
path('owntracks/show_maps', views.show_maps, name='show_maps'),
# 获取位置数据的API端点
path('owntracks/get_datas', views.get_datas, name='get_datas'),
# 显示日志日期的页面
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]
]

@ -19,109 +19,124 @@ from .models import OwnTrackLog
logger = logging.getLogger(__name__)
@csrf_exempt
@csrf_exempt # 免除CSRF保护允许外部应用POST请求
def manage_owntrack_log(request):
try:
# 解析JSON请求体
s = json.loads(request.read().decode('utf-8'))
tid = s['tid']
lat = s['lat']
lon = s['lon']
tid = s['tid'] # 设备ID
lat = s['lat'] # 纬度
lon = s['lon'] # 经度
# 记录位置信息日志
logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon))
# 验证必需字段都存在
if tid and lat and lon:
m = OwnTrackLog()
m.tid = tid
m.lat = lat
m.lon = lon
m.save()
m.save() # 保存到数据库
return HttpResponse('ok')
else:
return HttpResponse('data error')
except Exception as e:
logger.error(e)
logger.error(e) # 记录错误日志
return HttpResponse('error')
@login_required
@login_required # 需要用户登录
def show_maps(request):
if request.user.is_superuser:
if request.user.is_superuser: # 只允许超级用户访问
# 设置默认日期为当前UTC日期
defaultdate = str(datetime.datetime.now(timezone.utc).date())
date = request.GET.get('date', defaultdate)
date = request.GET.get('date', defaultdate) # 从GET参数获取日期
context = {
'date': date
}
return render(request, 'owntracks/show_maps.html', context)
else:
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
return HttpResponseForbidden() # 非超级用户返回403禁止访问
@login_required
def show_log_dates(request):
# 获取所有日志记录的创建时间
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
# 提取唯一日期并格式化为YYYY-MM-DD
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
context = {
'results': results
'results': results # 日期列表传递给模板
}
return render(request, 'owntracks/show_log_dates.html', context)
def convert_to_amap(locations):
"""将GPS坐标转换为高德地图坐标系统"""
convert_result = []
it = iter(locations)
it = iter(locations) # 创建迭代器
# 每次处理30个坐标高德API限制
item = list(itertools.islice(it, 30))
while item:
# 将坐标格式化为经度,纬度字符串并用分号连接
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
key = '8440a376dfc9743d8924bf0ad141f28e'
key = '8440a376dfc9743d8924bf0ad141f28e' # 高德API密钥
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
query = {
'key': key,
'locations': datas,
'coordsys': 'gps'
'coordsys': 'gps' # 指定源坐标系为GPS
}
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
if "locations" in result:
convert_result.append(result['locations'])
item = list(itertools.islice(it, 30))
convert_result.append(result['locations']) # 添加转换后的坐标
item = list(itertools.islice(it, 30)) # 获取下一批坐标
return ";".join(convert_result)
return ";".join(convert_result) # 返回所有转换后坐标
@login_required
def get_datas(request):
# 设置默认查询日期为今天
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
# 如果提供了日期参数,使用该日期
if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
# 计算查询结束日期(第二天)
nextdate = querydate + datetime.timedelta(days=1)
# 查询指定日期范围内的位置记录
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
# 按设备ID分组处理
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict()
d["name"] = tid
d["name"] = tid # 设备ID作为名称
paths = list()
# 使用高德转换后的经纬度
# 注释掉的代码:使用高德地图坐标转换
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(','))
# 使用GPS原始经纬度
# 当前使用原始GPS坐标
for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths
paths.append([str(location.lon), str(location.lat)]) # 经度,纬度
d["path"] = paths # 设备移动路径
result.append(d)
return JsonResponse(result, safe=False)
return JsonResponse(result, safe=False) # 返回JSON响应

@ -1,17 +1,21 @@
# 导入插件管理的基础类和钩子相关模块
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 1. 定义文章版权插件类,继承自基础插件类
class ArticleCopyrightPlugin(BasePlugin):
PLUGIN_NAME = '文章结尾版权声明'
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 插件基本信息配置
PLUGIN_NAME = '文章结尾版权声明' # 插件显示名称
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' # 插件功能描述
PLUGIN_VERSION = '0.2.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
# 2. 实现 register_hooks 方法,专门用于注册钩子
def register_hooks(self):
# 在这里将插件的方法注册到指定的钩子上
# 将 add_copyright_to_content 方法注册到文章内容钩子
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content)
def add_copyright_to_content(self, content, *args, **kwargs):
@ -19,19 +23,22 @@ class ArticleCopyrightPlugin(BasePlugin):
这个方法会被注册到 'the_content' 过滤器钩子上
它接收原始内容并返回添加了版权信息的新内容
"""
# 从参数中获取文章对象
article = kwargs.get('article')
if not article:
return content
return content # 如果没有文章对象,直接返回原内容
# 如果是摘要模式(首页),不添加版权声明
is_summary = kwargs.get('is_summary', False)
if is_summary:
return content
# 构建版权声明信息,包含作者用户名
copyright_info = f"\n<hr><p>本文由 {article.author.username} 原创,转载请注明出处。</p>"
# 在原文末尾添加版权声明
return content + copyright_info
# 3. 实例化插件。
# 这会自动调用 BasePlugin.__init__然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。
plugin = ArticleCopyrightPlugin()
plugin = ArticleCopyrightPlugin()

@ -6,43 +6,51 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ExternalLinksPlugin(BasePlugin):
PLUGIN_NAME = '外部链接处理器'
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 插件基本信息配置
PLUGIN_NAME = '外部链接处理器' # 插件显示名称
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' # 插件功能描述
PLUGIN_VERSION = '0.1.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
def register_hooks(self):
# 注册插件到文章内容钩子,处理外部链接
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links)
def process_external_links(self, content, *args, **kwargs):
# 导入获取当前站点域名的工具函数
from djangoblog.utils import get_current_site
site_domain = get_current_site().domain
site_domain = get_current_site().domain # 获取当前网站的域名
# 正则表达式查找所有 <a> 标签
# 匹配模式:<a开头href属性链接URL以及标签的剩余部分
link_pattern = re.compile(r'(<a\s+(?:[^>]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE)
def replacer(match):
# match.group(1) 是 <a ... href="
# match.group(2) 是链接 URL
# match.group(3) 是 ">...</a>
href = match.group(2)
href = match.group(2) # 提取链接URL
# 如果链接已经有 target 属性,则不处理
# 如果链接已经有 target 属性,则不处理(避免重复添加)
if 'target=' in match.group(0).lower():
return match.group(0)
return match.group(0) # 返回原始标签不变
# 解析链接
# 解析链接URL提取域名等信息
parsed_url = urlparse(href)
# 如果链接是外部的 (有域名且域名不等于当前网站域名)
if parsed_url.netloc and parsed_url.netloc != site_domain:
# 添加 target 和 rel 属性
# 添加 target="_blank" 和 rel="noopener noreferrer" 属性
# target="_blank" 在新窗口打开链接
# rel="noopener noreferrer" 提供安全保护,防止标签页钓鱼攻击
return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}'
# 否则返回原样
# 否则返回原样(内部链接不处理)
return match.group(0)
# 使用正则表达式替换函数处理所有匹配的链接
return link_pattern.sub(replacer, content)
plugin = ExternalLinksPlugin()
# 实例化插件,自动注册钩子
plugin = ExternalLinksPlugin()

@ -7,24 +7,26 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ImageOptimizationPlugin(BasePlugin):
PLUGIN_NAME = '图片性能优化插件'
PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。'
PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 插件基本信息配置
PLUGIN_NAME = '图片性能优化插件' # 插件显示名称
PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。' # 插件功能描述
PLUGIN_VERSION = '1.0.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
def __init__(self):
# 插件配置
# 插件配置字典,定义各种优化功能的开关
self.config = {
'enable_lazy_loading': True, # 启用懒加载
'enable_async_decoding': True, # 启用异步解码
'add_loading_placeholder': True, # 添加加载占位符
'optimize_external_images': True, # 优化外部图片
'add_responsive_attributes': True, # 添加响应式属性
'skip_first_image': True, # 跳过第一张图片LCP优化
'enable_lazy_loading': True, # 启用懒加载:延迟加载视口外的图片
'enable_async_decoding': True, # 启用异步解码:不阻塞页面渲染
'add_loading_placeholder': True, # 添加加载占位符:改善用户体验
'optimize_external_images': True, # 优化外部图片:为外部图片添加安全属性
'add_responsive_attributes': True, # 添加响应式属性:支持不同屏幕尺寸
'skip_first_image': True, # 跳过第一张图片优化LCP最大内容绘制指标
}
super().__init__()
super().__init__() # 调用父类初始化方法
def register_hooks(self):
# 注册插件到文章内容钩子,处理图片优化
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images)
def optimize_images(self, content, *args, **kwargs):
@ -35,73 +37,76 @@ class ImageOptimizationPlugin(BasePlugin):
return content
# 正则表达式匹配 img 标签
# 匹配模式:<img开头捕获所有属性支持自闭合标签
img_pattern = re.compile(
r'<img\s+([^>]*?)(?:\s*/)?>',
re.IGNORECASE | re.DOTALL
re.IGNORECASE | re.DOTALL # 忽略大小写,匹配多行
)
image_count = 0
image_count = 0 # 图片计数器,用于识别第一张图片
def replace_img_tag(match):
nonlocal image_count
image_count += 1
# 获取原始属性
nonlocal image_count # 使用外部计数器
image_count += 1 # 每处理一张图片计数器加1
# 获取原始属性字符串group(1)是属性部分)
original_attrs = match.group(1)
# 解析现有属性
# 解析现有属性为字典格式
attrs = self._parse_img_attributes(original_attrs)
# 应用优化
# 应用各种优化策略
optimized_attrs = self._apply_optimizations(attrs, image_count)
# 重构 img 标签
# 重构优化后的img标签
return self._build_img_tag(optimized_attrs)
# 替换所有 img 标签
# 使用正则表达式替换所有匹配的img标签
optimized_content = img_pattern.sub(replace_img_tag, content)
return optimized_content
def _parse_img_attributes(self, attr_string):
"""
解析 img 标签的属性
解析 img 标签的属性字符串为字典
"""
attrs = {}
# 正则表达式匹配属性
attrs = {} # 存储属性键值对
# 正则表达式匹配属性:属性名=引号包围的属性值
attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2')
# 遍历所有匹配的属性
for match in attr_pattern.finditer(attr_string):
attr_name = match.group(1).lower()
attr_value = match.group(3)
attr_name = match.group(1).lower() # 属性名转为小写
attr_value = match.group(3) # 属性值
attrs[attr_name] = attr_value
return attrs
def _apply_optimizations(self, attrs, image_index):
"""
应用各种图片优化
应用各种图片优化策略
"""
# 1. 懒加载优化跳过第一张图片以优化LCP
if self.config['enable_lazy_loading']:
# 如果配置跳过第一张图片且当前是第一张,则不添加懒加载
if not (self.config['skip_first_image'] and image_index == 1):
if 'loading' not in attrs:
attrs['loading'] = 'lazy'
if 'loading' not in attrs: # 如果还没有loading属性
attrs['loading'] = 'lazy' # 添加懒加载属性
# 2. 异步解码
# 2. 异步解码:不阻塞页面渲染
if self.config['enable_async_decoding']:
if 'decoding' not in attrs:
attrs['decoding'] = 'async'
if 'decoding' not in attrs: # 如果还没有decoding属性
attrs['decoding'] = 'async' # 添加异步解码属性
# 3. 添加样式优化:确保图片响应式显示
current_style = attrs.get('style', '') # 获取现有样式
# 3. 添加样式优化
current_style = attrs.get('style', '')
# 确保图片不会超出容器
if 'max-width' not in current_style:
if current_style and not current_style.endswith(';'):
current_style += ';'
current_style += 'max-width:100%;height:auto;'
current_style += ';' # 添加分号分隔
current_style += 'max-width:100%;height:auto;' # 添加响应式样式
attrs['style'] = current_style
# 4. 添加 alt 属性SEO和可访问性
@ -110,73 +115,76 @@ class ImageOptimizationPlugin(BasePlugin):
src = attrs.get('src', '')
if src:
# 从文件名生成alt文本
filename = src.split('/')[-1].split('.')[0]
filename = src.split('/')[-1].split('.')[0] # 提取文件名(不含扩展名)
# 移除常见的无意义字符
clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash
clean_name = re.sub(r'[_-]+', ' ', clean_name).strip()
attrs['alt'] = clean_name if clean_name else '文章图片'
clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash如MD5值
clean_name = re.sub(r'[_-]+', ' ', clean_name).strip() # 替换下划线和横线为空格
attrs['alt'] = clean_name if clean_name else '文章图片' # 设置alt文本
else:
attrs['alt'] = '文章图片'
attrs['alt'] = '文章图片' # 默认alt文本
# 5. 外部图片优化
# 5. 外部图片优化:为外部图片添加安全属性
if self.config['optimize_external_images'] and 'src' in attrs:
src = attrs['src']
parsed_url = urlparse(src)
# 如果是外部图片,添加 referrerpolicy
parsed_url = urlparse(src) # 解析URL
# 如果是外部图片(有域名且不是当前网站域名)
if parsed_url.netloc and parsed_url.netloc != self._get_current_domain():
attrs['referrerpolicy'] = 'no-referrer-when-downgrade'
attrs['referrerpolicy'] = 'no-referrer-when-downgrade' # 设置引用策略
# 为外部图片添加crossorigin属性以支持性能监控
if 'crossorigin' not in attrs:
attrs['crossorigin'] = 'anonymous'
attrs['crossorigin'] = 'anonymous' # 匿名跨域请求
# 6. 响应式图片属性(如果配置启用)
if self.config['add_responsive_attributes']:
# 添加 sizes 属性(如果没有的话
# 添加 sizes 属性(如果没有的话且没有srcset
if 'sizes' not in attrs and 'srcset' not in attrs:
# 设置响应式尺寸小屏100%中屏50%大屏33%
attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
# 7. 添加图片唯一标识符用于性能追踪
if 'data-img-id' not in attrs and 'src' in attrs:
img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8]
attrs['data-img-id'] = f'img-{img_hash}'
# 基于图片URL生成短hash作为唯一ID
img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8] # 取前8位
attrs['data-img-id'] = f'img-{img_hash}' # 设置数据属性
# 8. 为第一张图片添加高优先级提示LCP优化
if image_index == 1 and self.config['skip_first_image']:
attrs['fetchpriority'] = 'high'
# 移除懒加载以确保快速加载
attrs['fetchpriority'] = 'high' # 设置高获取优先级
# 移除懒加载以确保快速加载第一张图片通常是LCP元素
if 'loading' in attrs:
del attrs['loading']
del attrs['loading'] # 删除懒加载属性
return attrs
return attrs # 返回优化后的属性字典
def _build_img_tag(self, attrs):
"""
重新构建 img 标签
重新构建 img 标签字符串
"""
attr_strings = []
# 确保 src 属性在最前面
attr_strings = [] # 存储属性字符串列表
# 确保 src 属性在最前面HTML规范推荐
if 'src' in attrs:
attr_strings.append(f'src="{attrs["src"]}"')
# 添加其他属性
# 添加其他属性(按字典顺序排序以获得一致性)
for key, value in attrs.items():
if key != 'src': # src 已经添加过了
attr_strings.append(f'{key}="{value}"')
# 构建完整的img标签
return f'<img {" ".join(attr_strings)}>'
def _get_current_domain(self):
"""
获取当前网站域名
获取当前网站域名用于判断图片是否为外部图片
"""
try:
from djangoblog.utils import get_current_site
return get_current_site().domain
return get_current_site().domain # 返回当前站点域名
except:
return ''
return '' # 如果获取失败返回空字符串
# 实例化插件
plugin = ImageOptimizationPlugin()
# 实例化插件,自动注册钩子
plugin = ImageOptimizationPlugin()

@ -8,22 +8,29 @@ from djangoblog.utils import get_blog_setting
class SeoOptimizerPlugin(BasePlugin):
PLUGIN_NAME = 'SEO 优化器'
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liuangliangyy'
# 插件基本信息配置
PLUGIN_NAME = 'SEO 优化器' # 插件显示名称
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' # 插件功能描述
PLUGIN_VERSION = '0.2.0' # 插件版本号
PLUGIN_AUTHOR = 'liuangliangyy' # 插件作者
def register_hooks(self):
# 注册插件到头部meta钩子用于生成SEO相关标签
hooks.register('head_meta', self.dispatch_seo_generation)
def _get_article_seo_data(self, context, request, blog_setting):
"""为文章页面生成SEO数据"""
article = context.get('article')
# 确保上下文中的article是Article模型实例
if not isinstance(article, Article):
return None
# 生成文章描述去除HTML标签并截取前150字符
description = strip_tags(article.body)[:150]
# 生成关键词:使用文章标签或回退到站点关键词
keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
# 生成Open Graph meta标签社交媒体分享优化
meta_tags = f'''
<meta property="og:type" content="article"/>
<meta property="og:title" content="{article.title}"/>
@ -34,10 +41,13 @@ class SeoOptimizerPlugin(BasePlugin):
<meta property="article:author" content="{article.author.username}"/>
<meta property="article:section" content="{article.category.name}"/>
'''
# 为每个标签添加article:tag meta标签
for tag in article.tags.all():
meta_tags += f'<meta property="article:tag" content="{tag.name}"/>'
# 添加站点名称
meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>'
# 生成JSON-LD结构化数据搜索引擎优化
structured_data = {
"@context": "https://schema.org",
"@type": "Article",
@ -50,37 +60,43 @@ class SeoOptimizerPlugin(BasePlugin):
"author": {"@type": "Person", "name": article.author.username},
"publisher": {"@type": "Organization", "name": blog_setting.site_name}
}
# 如果没有图片移除image字段
if not structured_data.get("image"):
del structured_data["image"]
return {
"title": f"{article.title} | {blog_setting.site_name}",
"description": description,
"keywords": keywords,
"meta_tags": meta_tags,
"json_ld": structured_data
"title": f"{article.title} | {blog_setting.site_name}", # 页面标题
"description": description, # 页面描述
"keywords": keywords, # 页面关键词
"meta_tags": meta_tags, # Open Graph标签
"json_ld": structured_data # JSON-LD结构化数据
}
def _get_category_seo_data(self, context, request, blog_setting):
"""为分类页面生成SEO数据"""
category_name = context.get('tag_name')
if not category_name:
return None
# 根据分类名称获取分类对象
category = Category.objects.filter(name=category_name).first()
if not category:
return None
# 生成分类页面SEO数据
title = f"{category.name} | {blog_setting.site_name}"
description = strip_tags(category.name) or blog_setting.site_description
keywords = category.name
# BreadcrumbList structured data for category page
breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
# 生成面包屑导航的结构化数据
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
breadcrumb_items.append(
{"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
structured_data = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"@type": "BreadcrumbList", # 面包屑列表类型
"itemListElement": breadcrumb_items
}
@ -93,14 +109,15 @@ class SeoOptimizerPlugin(BasePlugin):
}
def _get_default_seo_data(self, context, request, blog_setting):
# Homepage and other default pages
"""为首页和其他默认页面生成SEO数据"""
# 生成网站级别的结构化数据
structured_data = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": blog_setting.site_name,
"description": blog_setting.site_description,
"url": request.build_absolute_uri('/'),
"potentialAction": {
"potentialAction": { # 潜在操作(搜索功能)
"@type": "SearchAction",
"target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}",
"query-input": "required name=search_term_string"
@ -115,24 +132,30 @@ class SeoOptimizerPlugin(BasePlugin):
}
def dispatch_seo_generation(self, metas, context):
"""分发SEO生成的主方法根据当前页面类型调用相应的SEO生成函数"""
request = context.get('request')
if not request:
return metas
# 获取当前视图名称
view_name = request.resolver_match.view_name
blog_setting = get_blog_setting()
blog_setting = get_blog_setting() # 获取博客设置
seo_data = None
if view_name == 'blog:detailbyid':
# 根据视图名称选择相应的SEO生成方法
if view_name == 'blog:detailbyid': # 文章详情页
seo_data = self._get_article_seo_data(context, request, blog_setting)
elif view_name == 'blog:category_detail':
elif view_name == 'blog:category_detail': # 分类详情页
seo_data = self._get_category_seo_data(context, request, blog_setting)
# 如果没有匹配的页面类型使用默认SEO数据
if not seo_data:
seo_data = self._get_default_seo_data(context, request, blog_setting)
seo_data = self._get_default_seo_data(context, request, blog_setting)
# 将JSON-LD数据转换为脚本标签
json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>'
# 组合所有SEO标签
seo_html = f"""
<title>{seo_data.get("title", "")}</title>
<meta name="description" content="{seo_data.get("description", "")}">
@ -140,8 +163,10 @@ class SeoOptimizerPlugin(BasePlugin):
{seo_data.get("meta_tags", "")}
{json_ld_script}
"""
# 将SEO内容追加到现有的metas内容上
return metas + seo_html
plugin = SeoOptimizerPlugin()
# 实例化插件,自动注册钩子
plugin = SeoOptimizerPlugin()

@ -1,18 +1,30 @@
# 导入插件管理的基础类和钩子相关模块
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
class ViewCountPlugin(BasePlugin):
PLUGIN_NAME = '文章浏览次数统计'
PLUGIN_DESCRIPTION = '统计文章的浏览次数'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 插件基本信息配置
PLUGIN_NAME = '文章浏览次数统计' # 插件显示名称
PLUGIN_DESCRIPTION = '统计文章的浏览次数' # 插件功能描述
PLUGIN_VERSION = '0.1.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
def register_hooks(self):
# 注册插件到文章内容获取后的钩子
# 当文章内容被获取后,自动触发浏览次数统计
hooks.register('after_article_body_get', self.record_view)
def record_view(self, article, *args, **kwargs):
"""
记录文章浏览次数
这个方法会在文章内容被获取后调用每次调用都会增加文章的浏览次数
"""
# 调用文章的viewed()方法来增加浏览次数计数
# 假设Article模型有一个viewed()方法用于处理浏览次数的逻辑
article.viewed()
plugin = ViewCountPlugin()
# 实例化插件,自动注册钩子
# 当插件被加载时会自动调用register_hooks方法注册到指定的钩子
plugin = ViewCountPlugin()

@ -1,32 +1,63 @@
# 导入WeRoBot会话存储基类和工具函数
from werobot.session import SessionStorage
from werobot.utils import json_loads, json_dumps
# 导入Django博客的缓存工具
from djangoblog.utils import cache
# 自定义Memcache会话存储类继承自WeRoBot的SessionStorage
class MemcacheStorage(SessionStorage):
def __init__(self, prefix='ws_'):
# 初始化缓存键前缀,默认为'ws_'
self.prefix = prefix
# 使用Django博客的缓存实例
self.cache = cache
@property
def is_available(self):
"""
检查缓存是否可用的属性
通过设置和获取测试值来验证缓存功能是否正常
"""
value = "1"
# 设置测试值到缓存
self.set('checkavaliable', value=value)
# 从缓存获取测试值并比较,返回缓存是否可用
return value == self.get('checkavaliable')
def key_name(self, s):
"""
生成完整的缓存键名
:param s: 原始键名
:return: 添加前缀后的完整键名
"""
return '{prefix}{s}'.format(prefix=self.prefix, s=s)
def get(self, id):
id = self.key_name(id)
session_json = self.cache.get(id) or '{}'
return json_loads(session_json)
"""
从缓存获取会话数据
:param id: 会话ID
:return: 解析后的JSON数据如果不存在返回空字典
"""
id = self.key_name(id) # 生成完整键名
session_json = self.cache.get(id) or '{}' # 从缓存获取数据,不存在则返回'{}'
return json_loads(session_json) # 将JSON字符串解析为Python对象
def set(self, id, value):
id = self.key_name(id)
"""
设置会话数据到缓存
:param id: 会话ID
:param value: 要存储的值
"""
id = self.key_name(id) # 生成完整键名
# 将值转换为JSON字符串并存储到缓存
self.cache.set(id, json_dumps(value))
def delete(self, id):
id = self.key_name(id)
self.cache.delete(id)
"""
从缓存删除会话数据
:param id: 会话ID
"""
id = self.key_name(id) # 生成完整键名
self.cache.delete(id) # 从缓存删除指定键的数据

@ -1,13 +1,18 @@
# 导入Django管理模块
from django.contrib import admin
# Register your models here.
# 定义Commands模型的管理界面配置
class CommandsAdmin(admin.ModelAdmin):
# 设置在管理列表页面显示的字段
list_display = ('title', 'command', 'describe')
# 定义EmailSendLog模型的管理界面配置
class EmailSendLogAdmin(admin.ModelAdmin):
# 设置在管理列表页面显示的字段
list_display = ('title', 'emailto', 'send_result', 'creation_time')
# 设置只读字段,这些字段在编辑页面不可修改
readonly_fields = (
'title',
'emailto',
@ -15,5 +20,6 @@ class EmailSendLogAdmin(admin.ModelAdmin):
'creation_time',
'content')
# 重写添加权限方法,禁止在管理界面添加新的邮件发送日志
def has_add_permission(self, request):
return False
return False

@ -1,3 +1,4 @@
# 导入Haystack搜索查询集和相关模型
from haystack.query import SearchQuerySet
from blog.models import Article, Category
@ -5,23 +6,33 @@ from blog.models import Article, Category
class BlogApi:
def __init__(self):
# 初始化搜索查询集
self.searchqueryset = SearchQuerySet()
# 执行空查询来初始化查询集
self.searchqueryset.auto_query('')
# 设置最大返回结果数量
self.__max_takecount__ = 8
def search_articles(self, query):
# 使用Haystack搜索查询集进行文章搜索
sqs = self.searchqueryset.auto_query(query)
# 加载所有相关对象避免N+1查询问题
sqs = sqs.load_all()
# 返回前N个搜索结果N为最大返回数量
return sqs[:self.__max_takecount__]
def get_category_lists(self):
# 获取所有文章分类
return Category.objects.all()
def get_category_articles(self, categoryname):
# 根据分类名称获取该分类下的文章
articles = Article.objects.filter(category__name=categoryname)
if articles:
# 如果存在文章返回前N篇文章
return articles[:self.__max_takecount__]
return None
return None # 如果没有文章返回None
def get_recent_articles(self):
return Article.objects.all()[:self.__max_takecount__]
# 获取最近的文章(按创建时间排序)
return Article.objects.all()[:self.__max_takecount__]

@ -5,28 +5,44 @@ import openai
from servermanager.models import commands
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)
# 设置OpenAI API密钥从环境变量中获取
openai.api_key = os.environ.get('OPENAI_API_KEY')
# 如果设置了HTTP代理配置OpenAI使用代理
if os.environ.get('HTTP_PROXY'):
openai.proxy = os.environ.get('HTTP_PROXY')
class ChatGPT:
"""ChatGPT API封装类"""
@staticmethod
def chat(prompt):
"""
与ChatGPT进行对话
:param prompt: 用户输入的提示词
:return: ChatGPT的回复内容
"""
try:
completion = openai.ChatCompletion.create(model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}])
# 调用OpenAI ChatCompletion API
completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", # 使用gpt-3.5-turbo模型
messages=[{"role": "user", "content": prompt}]) # 构建对话消息
# 返回第一个选择的消息内容
return completion.choices[0].message.content
except Exception as e:
# 记录错误日志
logger.error(e)
# 返回友好的错误信息
return "服务器出错了"
class CommandHandler:
"""命令处理器类"""
def __init__(self):
# 从数据库获取所有命令
self.commands = commands.objects.all()
def run(self, title):
@ -35,30 +51,49 @@ class CommandHandler:
:param title: 命令
:return: 返回命令执行结果
"""
# 过滤匹配标题的命令(不区分大小写)
cmd = list(
filter(
lambda x: x.title.upper() == title.upper(),
lambda x: x.title.upper() == title.upper(), # 转换为大写进行比较
self.commands))
if cmd:
# 执行找到的命令
return self.__run_command__(cmd[0].command)
else:
# 未找到命令时返回帮助信息
return "未找到相关命令请输入hepme获得帮助。"
def __run_command__(self, cmd):
"""
内部方法执行系统命令
:param cmd: 要执行的命令字符串
:return: 命令执行结果
"""
try:
# 使用os.popen执行系统命令并读取输出
res = os.popen(cmd).read()
return res
except BaseException:
# 捕获所有异常,返回错误信息
return '命令执行出错!'
def get_help(self):
"""
获取所有命令的帮助信息
:return: 格式化的帮助字符串
"""
rsp = ''
# 遍历所有命令,生成帮助信息
for cmd in self.commands:
rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) # 格式:命令标题:命令描述
return rsp
# 主程序入口,用于测试
if __name__ == '__main__':
# 创建ChatGPT实例
chatbot = ChatGPT()
# 测试提示词
prompt = "写一篇1000字关于AI的论文"
print(chatbot.chat(prompt))
# 打印ChatGPT的回复
print(chatbot.chat(prompt))

@ -1,5 +1,8 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义servermanager应用的配置类
class ServermanagerConfig(AppConfig):
name = 'servermanager'
# 指定应用的Python路径Django 3.x及以下版本使用
name = 'servermanager'

@ -5,41 +5,55 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
initial = True # 标记为初始迁移
dependencies = [
dependencies = [ # 定义迁移依赖,初始迁移通常为空
]
operations = [
operations = [ # 迁移操作列表
# 创建 commands 数据表
migrations.CreateModel(
name='commands',
fields=[
name='commands', # 数据表名称
fields=[ # 数据表字段定义
# 主键字段自增BigAutoFieldDjango自动创建
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 命令标题字段最大长度300字符
('title', models.CharField(max_length=300, verbose_name='命令标题')),
# 命令字段存储实际执行的命令最大长度2000字符
('command', models.CharField(max_length=2000, verbose_name='命令')),
# 命令描述字段最大长度300字符
('describe', models.CharField(max_length=300, verbose_name='命令描述')),
# 创建时间字段,自动设置为对象创建时间
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# 最后修改时间字段,自动更新为对象最后修改时间
('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')),
],
options={
'verbose_name': '命令',
'verbose_name_plural': '命令',
options={ # 数据表选项配置
'verbose_name': '命令', # 单数显示名称
'verbose_name_plural': '命令', # 复数显示名称
},
),
# 创建 EmailSendLog 数据表
migrations.CreateModel(
name='EmailSendLog',
fields=[
name='EmailSendLog', # 数据表名称
fields=[ # 数据表字段定义
# 主键字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 收件人字段存储邮件接收者地址最大长度300字符
('emailto', models.CharField(max_length=300, verbose_name='收件人')),
# 邮件标题字段最大长度2000字符
('title', models.CharField(max_length=2000, verbose_name='邮件标题')),
# 邮件内容字段,文本类型,无长度限制
('content', models.TextField(verbose_name='邮件内容')),
# 发送结果字段布尔值默认False表示发送失败
('send_result', models.BooleanField(default=False, verbose_name='结果')),
# 创建时间字段,自动设置为日志记录创建时间
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '邮件发送log',
'verbose_name_plural': '邮件发送log',
'ordering': ['-created_time'],
options={ # 数据表选项配置
'verbose_name': '邮件发送log', # 单数显示名称
'verbose_name_plural': '邮件发送log', # 复数显示名称
'ordering': ['-created_time'], # 默认按创建时间降序排列
},
),
]
]

@ -10,23 +10,27 @@ class Migration(migrations.Migration):
]
operations = [
# 修改EmailSendLog模型的元数据选项
migrations.AlterModelOptions(
name='emailsendlog',
options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
),
# 将Commands模型的created_time字段重命名为creation_time
migrations.RenameField(
model_name='commands',
old_name='created_time',
new_name='creation_time',
),
# 将Commands模型的last_mod_time字段重命名为last_modify_time
migrations.RenameField(
model_name='commands',
old_name='last_mod_time',
new_name='last_modify_time',
),
# 将EmailSendLog模型的created_time字段重命名为creation_time
migrations.RenameField(
model_name='emailsendlog',
old_name='created_time',
new_name='creation_time',
),
]
]

@ -3,31 +3,48 @@ from django.db import models
# Create your models here.
class commands(models.Model):
# 命令标题字段CharField类型最大长度300字符用于显示命令的标题
title = models.CharField('命令标题', max_length=300)
# 命令字段CharField类型最大长度2000字符存储实际要执行的命令
command = models.CharField('命令', max_length=2000)
# 命令描述字段CharField类型最大长度300字符描述命令的用途
describe = models.CharField('命令描述', max_length=300)
# 创建时间字段DateTimeField类型自动设置为对象创建时的时间
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
# 最后修改时间字段DateTimeField类型自动更新为对象最后修改的时间
last_modify_time = models.DateTimeField('修改时间', auto_now=True)
def __str__(self):
# 定义对象的字符串表示,返回命令标题
return self.title
class Meta:
# 在Django admin中显示的单数名称
verbose_name = '命令'
# 在Django admin中显示的复数名称
verbose_name_plural = verbose_name
class EmailSendLog(models.Model):
# 收件人字段CharField类型最大长度300字符存储邮件接收者的地址
emailto = models.CharField('收件人', max_length=300)
# 邮件标题字段CharField类型最大长度2000字符存储邮件的主题
title = models.CharField('邮件标题', max_length=2000)
# 邮件内容字段TextField类型无长度限制存储邮件的正文内容
content = models.TextField('邮件内容')
# 发送结果字段BooleanField类型默认值为False表示邮件发送是否成功
send_result = models.BooleanField('结果', default=False)
# 创建时间字段DateTimeField类型自动设置为日志记录创建时的时间
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
def __str__(self):
# 定义对象的字符串表示,返回邮件标题
return self.title
class Meta:
# 在Django admin中显示的单数名称
verbose_name = '邮件发送log'
# 在Django admin中显示的复数名称
verbose_name_plural = verbose_name
ordering = ['-creation_time']
# 默认按创建时间降序排列,最新的记录显示在最前面
ordering = ['-creation_time']

@ -13,34 +13,47 @@ from servermanager.api.blogapi import BlogApi
from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
# 初始化WeRoBot实例设置token和启用会话
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
or 'lylinux', enable_session=True)
# 创建Memcache存储实例
memstorage = MemcacheStorage()
# 检查Memcache是否可用如果可用则使用Memcache存储会话
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
# 如果Memcache不可用使用文件存储会话并清理旧的会话文件
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
# 初始化博客API、命令处理器
blogapi = BlogApi()
cmd_handler = CommandHandler()
logger = logging.getLogger(__name__)
def convert_to_article_reply(articles, message):
"""
将文章列表转换为微信文章回复格式
:param articles: 文章列表
:param message: 微信消息对象
:return: 文章回复对象
"""
reply = ArticlesReply(message=message)
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
# 从文章内容中提取图片URL
imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body)
imgurl = ''
if imgs:
imgurl = imgs[0]
# 创建文章对象
article = Article(
title=post.title,
description=truncatechars_content(post.body),
img=imgurl,
url=post.get_full_url()
description=truncatechars_content(post.body), # 截取文章内容作为描述
img=imgurl, # 文章封面图
url=post.get_full_url() # 文章完整URL
)
reply.add_article(article)
return reply
@ -48,8 +61,9 @@ def convert_to_article_reply(articles, message):
@robot.filter(re.compile(r"^\?.*"))
def search(message, session):
"""搜索文章处理函数,以?开头的消息触发搜索"""
s = message.content
searchstr = str(s).replace('?', '')
searchstr = str(s).replace('?', '') # 移除问号得到搜索关键词
result = blogapi.search_articles(searchstr)
if result:
articles = list(map(lambda x: x.object, result))
@ -61,6 +75,7 @@ def search(message, session):
@robot.filter(re.compile(r'^category\s*$', re.I))
def category(message, session):
"""获取所有文章分类目录"""
categorys = blogapi.get_category_lists()
content = ','.join(map(lambda x: x.name, categorys))
return '所有文章分类目录:' + content
@ -68,6 +83,7 @@ def category(message, session):
@robot.filter(re.compile(r'^recent\s*$', re.I))
def recents(message, session):
"""获取最新文章"""
articles = blogapi.get_recent_articles()
if articles:
reply = convert_to_article_reply(articles, message)
@ -78,6 +94,7 @@ def recents(message, session):
@robot.filter(re.compile('^help$', re.I))
def help(message, session):
"""帮助信息"""
return '''欢迎关注!
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
@ -100,56 +117,69 @@ def help(message, session):
@robot.filter(re.compile(r'^weather\:.*$', re.I))
def weather(message, session):
"""天气查询功能(建设中)"""
return "建设中..."
@robot.filter(re.compile(r'^idcard\:.*$', re.I))
def idcard(message, session):
"""身份证查询功能(建设中)"""
return "建设中..."
@robot.handler
def echo(message, session):
"""默认消息处理器,处理所有未匹配特定过滤器的消息"""
handler = MessageHandler(message, session)
return handler.handler()
class MessageHandler:
"""消息处理器类,负责处理用户消息和会话状态"""
def __init__(self, message, session):
userid = message.source
self.message = message
self.session = session
self.userid = userid
try:
# 从会话中获取用户信息
info = session[userid]
self.userinfo = jsonpickle.decode(info)
except Exception as e:
# 如果会话中没有用户信息,创建新的用户信息对象
userinfo = WxUserInfo()
self.userinfo = userinfo
@property
def is_admin(self):
"""检查用户是否为管理员"""
return self.userinfo.isAdmin
@property
def is_password_set(self):
"""检查管理员密码是否已设置"""
return self.userinfo.isPasswordSet
def save_session(self):
"""保存用户信息到会话"""
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
def handler(self):
"""主消息处理方法"""
info = self.message.content
# 管理员退出逻辑
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
self.save_session()
return "退出成功"
# 进入管理员模式
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
self.save_session()
return "输入管理员密码"
# 管理员密码验证
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
if settings.TESTING:
@ -166,6 +196,7 @@ class MessageHandler:
self.userinfo.Count += 1
self.save_session()
return "验证失败,请重新输入管理员密码:"
# 管理员命令执行
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
if self.userinfo.Command != '' and info.upper() == 'Y':
return cmd_handler.run(self.userinfo.Command)
@ -176,12 +207,14 @@ class MessageHandler:
self.save_session()
return "确认执行: " + info + " 命令?"
# 默认使用ChatGPT回复
return ChatGPT.chat(info)
class WxUserInfo():
"""微信用户信息类,存储用户状态"""
def __init__(self):
self.isAdmin = False
self.isPasswordSet = False
self.Count = 0
self.Command = ''
self.isAdmin = False # 是否为管理员
self.isPasswordSet = False # 密码是否已验证
self.Count = 0 # 密码尝试次数
self.Command = '' # 待执行的命令

@ -13,67 +13,86 @@ from .robot import search, category, recents
# Create your tests here.
class ServerManagerTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_chat_gpt(self):
# 测试ChatGPT功能是否正常工作
content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
self.assertIsNotNone(content) # 验证返回内容不为空
def test_validate_comment(self):
# 创建超级用户用于测试
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 登录用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建分类
c = Category()
c.name = "categoryccc"
c.save()
# 创建文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = user
article.category = c
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
s = TextMessage([])
# 测试搜索功能
s = TextMessage([]) # 创建微信文本消息对象
s.content = "nice"
rsp = search(s, None)
rsp = search(s, None) # 调用搜索函数
# 测试分类功能
rsp = category(None, None)
self.assertIsNotNone(rsp)
self.assertIsNotNone(rsp) # 验证返回结果不为空
# 测试最近文章功能
rsp = recents(None, None)
self.assertTrue(rsp != '暂时还没有文章')
self.assertTrue(rsp != '暂时还没有文章') # 验证有文章返回
# 测试命令功能
cmd = commands()
cmd.title = "test"
cmd.command = "ls"
cmd.command = "ls" # Linux列表命令
cmd.describe = "test"
cmd.save()
# 测试命令处理器
cmdhandler = CommandHandler()
rsp = cmdhandler.run('test')
self.assertIsNotNone(rsp)
s.source = 'u'
self.assertIsNotNone(rsp) # 验证命令执行结果不为空
# 测试消息处理器
s.source = 'u' # 设置消息来源
s.content = 'test'
msghandler = MessageHandler(s, {})
msghandler = MessageHandler(s, {}) # 创建消息处理器实例
# 注释掉的管理员测试代码
# msghandler.userinfo.isPasswordSet = True
# msghandler.userinfo.isAdmin = True
msghandler.handler()
# 测试各种消息处理场景
msghandler.handler() # 处理'test'消息
s.content = 'y'
msghandler.handler()
msghandler.handler() # 处理确认消息
s.content = 'idcard:12321233'
msghandler.handler()
msghandler.handler() # 处理身份证查询消息
s.content = 'weather:上海'
msghandler.handler()
msghandler.handler() # 处理天气查询消息
s.content = 'admin'
msghandler.handler()
msghandler.handler() # 处理进入管理员模式消息
s.content = '123'
msghandler.handler()
msghandler.handler() # 处理密码输入消息
s.content = 'exit'
msghandler.handler()
msghandler.handler() # 处理退出管理员模式消息

@ -1,10 +1,16 @@
# 导入Django URL路由相关模块
from django.urls import path
# 导入WeRoBot的Django视图创建函数
from werobot.contrib.django import make_view
# 导入自定义的机器人实例
from .robot import robot
# 定义应用命名空间用于URL反向解析
app_name = "servermanager"
# 定义URL模式列表
urlpatterns = [
# 将微信机器人处理程序映射到/robot路径
# make_view(robot) 将WeRoBot实例转换为Django视图函数
path(r'robot', make_view(robot)),
]
]
Loading…
Cancel
Save