Compare commits

...

58 Commits

Author SHA1 Message Date
dynastxu 263d370607 Merge branch 'zy_branch' into develop
2 months ago
xdqw166 756a12fe29 11周作业表格终章
2 months ago
xdqw166 e209e1b2d4 11周作业表格
2 months ago
dynastxu 1de159624a docs: 完成介绍视频
2 months ago
dynastxu bc085a06cc docs: 完成开源软件泛读、标注和维护报告文档.docx
2 months ago
dynastxu d8c637ba15 chore(gitignore): 更新.gitignore
3 months ago
dynastxu 8dda6211ec update subtree
3 months ago
dynastxu 78ead977fa Squashed 'src/DjangoBlog/' changes from cf3b252..be4c76b
3 months ago
dynastxu f8bd1d0438 Merge branch 'develop' into xjj_branch
3 months ago
dynastxu d106cb2792 Squashed 'src/DjangoBlog/' changes from cafdade..cf3b252
3 months ago
dynastxu eee1b8c98e update subtree
3 months ago
dynastxu 265045fe65 Merge remote-tracking branch 'origin/zy_branch' into develop
3 months ago
xdqw166 f30135dfd8 修改了base html的报错
3 months ago
xdqw166 90f48adaee 功能增加变换背景颜色
3 months ago
dynastxu 7316b4c8b6 Squashed 'src/DjangoBlog/' changes from b99778c..cafdade
3 months ago
dynastxu 46862ad679 update subtree
3 months ago
dynastxu 8fa2a1d76f chore: 更新.gitignore文件忽略规则
3 months ago
dynastxu 654d036823 update subtree
3 months ago
dynastxu 60561750c0 Squashed 'src/DjangoBlog/' changes from 0bb6193..b99778c
3 months ago
dynastxu fedfe15b64 feat(script): 添加 Git 清理脚本以自动化清理未跟踪文件夹
3 months ago
dynastxu d71b49f1ca docs(readme): 更新 README 文件内容
3 months ago
dynastxu e33542c8a0 chore: 添加 MIT 许可证文件
3 months ago
dynastxu 43ea653c11 update subtree
3 months ago
dynastxu 72fddfe377 Squashed 'src/DjangoBlog/' changes from 13ebbc8..0bb6193
3 months ago
dynastxu af79acffbc chore(gitignore): 更新.gitignore文件
3 months ago
dynastxu 8a37d8cc06 Merge branch 'lrj_branch' into develop
3 months ago
ZUOFEikabuto b48a67b203 feat: [lrj] 完成OAuth模块代码质量分析和注释
3 months ago
ZUOFEikabuto cd96dfe561 feat: 完成OAuth模块代码质量分析和注释
3 months ago
ZUOFEikabuto 26efdcb6f4 在更新后的代码基础上添加oauth注释
3 months ago
dynastxu 151535a74e 完善文档
3 months ago
dynastxu ef8f3f3d19 完成 编码规范.docx
3 months ago
dynastxu dcc31a2bdf 完成 开源软件的质量分析报告文档.docx 大部分内容
3 months ago
dynastxu afaacc22cf 删除无用文件
3 months ago
dynastxu 741cac2e1f Merge branch 'xjj_branch' into develop
3 months ago
dynastxu 854e8e28c7 feat(script): 添加推送子树的批处理脚本
3 months ago
dynastxu 400192beb7 Merge branch 'zy_branch' into develop
3 months ago
dynastxu aadcbfbfc3 Merge remote-tracking branch 'origin/shw_branch' into develop
3 months ago
dynastxu f285750263 Merge remote-tracking branch 'origin/bjy_branch' into develop
3 months ago
dynastxu a5637dad09 chore(subtree): 更新子树脚本逻辑
3 months ago
dynastxu eac243818d Squashed 'src/DjangoBlog/' changes from 408d19c..13ebbc8
3 months ago
dynastxu 3240542cb9 Merge commit 'eac243818d651281e841188481859d3e6e251cc8' into xjj_branch
3 months ago
dynastxu b85f85125b chore(scripts): 添加更新子树的批处理脚本
3 months ago
dynastxu 001bc85a81 Squashed 'src/DjangoBlog/' changes from 1f969cc..408d19c
3 months ago
dynastxu cf73d21b06 Merge commit '001bc85a8157f3742d79d513e5300bcf3b975edb' into xjj_branch
3 months ago
wei664 cf58b9ef18 accounts代码注释
3 months ago
xdqw166 7fc1b25b73 代码注释
3 months ago
xdqw166 e8698c02a4 代码注释
3 months ago
wei664 7c41266ac6 Merge branch 'develop' into shw_branch
3 months ago
xdqw166 5ef0f8fb0f 代码注释
3 months ago
xdqw166 0eb7232c46 Merge remote-tracking branch 'origin/develop' into zy_branch
3 months ago
wei664 285ac07e41 测试提交是否成功
3 months ago
dynastxu 7e7ba6f503 chore(submodule): 添加 DjangoBlog 子模块
3 months ago
xdqw166 78de182afd 删除错误的文档
3 months ago
xdqw166 ae476bbab2 Merge branch 'zy_branch_fix' into zy_branch
3 months ago
xdqw166 5614ee69bc 交了软件界面设计大作业
3 months ago
xdqw166 6be53f48bb 交了软件界面设计大作业
3 months ago
xdqw166 f91c8887e6 Merge remote-tracking branch 'origin/zy_branch' into zy_branch
3 months ago
xdqw166 249e858738 交了软件界面设计大作业
3 months ago

9
.gitignore vendored

@ -1 +1,8 @@
.idea
/.idea/
# DjangoBlog
.env
/logs/
/collectedstatic/
/djangoblog/
/static/

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

@ -1,2 +1,4 @@
# 阅读和分析开源软件
- 软件名:[**DjangoBlog**](https://github.com/liangliangyy/DjangoBlog)
- 协议:[**MIT License**](src/DjangoBlog/LICENSE)

Binary file not shown.

Binary file not shown.

@ -0,0 +1,22 @@
@echo off
chcp 65001 >nul
echo 正在检查将要清理的文件和文件夹...
git clean -fd -n
set /p confirm=是否确认清理这些文件?(y/N):
if /i "%confirm%" neq "y" (
echo 操作已取消
pause
exit /b
)
echo 正在执行清理操作...
git clean -fd
if %errorlevel% equ 0 (
echo 清理完成!
) else (
echo 清理过程中出现错误,错误代码: %errorlevel%
)
pause

@ -0,0 +1,9 @@
@echo off
echo Pushing Git subtree...
git subtree push --prefix=src/DjangoBlog DjangoBlog g3f-CodeEdit
if %errorlevel% equ 0 (
echo Subtree push successful!
) else (
echo Subtree push failed!
)
pause

@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

@ -77,3 +77,8 @@ uploads/
settings_production.py
werobot_session.db
bin/datas/
.env
# 目前似乎仅有测试代码会涉及到修改此文件夹,所以暂不进行版本管理
static/avatar/

@ -1,60 +1,87 @@
#shw 导入Django的表单模块
from django import forms
#shw 导入Django后台管理的基础用户管理类
from django.contrib.auth.admin import UserAdmin
#shw 导入Django后台用于修改用户信息的表单
from django.contrib.auth.forms import UserChangeForm
#shw 导入Django后台用于用户名的字段类
from django.contrib.auth.forms import UsernameField
#shw 导入Django的国际化和翻译工具
from django.utils.translation import gettext_lazy as _
# Register your models here.
#shw 注册你的模型到这里(这是一个注释提示,实际注册在文件末尾)
from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
#shw 自定义用户创建表单用于在Django Admin后台添加新用户。
#shw 它继承自 ModelForm并增加了密码输入和确认的逻辑。
#shw 定义第一个密码字段使用PasswordInput控件隐藏输入内容
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
#shw 定义第二个密码字段,用于确认密码
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
fields = ('email',)
#shw Meta类用于配置表单与模型的关联
model = BlogUser #shw 指定该表单对应的模型是 BlogUser
fields = ('email',) #shw 在创建用户时,除了密码外,只显示邮箱字段
def clean_password2(self):
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
#shw 自定义验证方法,用于检查两次输入的密码是否一致
#shw Check that the two password entries match
password1 = self.cleaned_data.get("password1") #shw 从已清洗的数据中获取第一个密码
password2 = self.cleaned_data.get("password2") #shw 从已清洗的数据中获取第二个密码
#shw 如果两个密码都存在且不相等,则抛出验证错误
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
return password2 #shw 返回第二个密码作为清洗后的数据
def save(self, commit=True):
# Save the provided password in hashed format
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
#shw 重写save方法以便在保存用户时处理密码哈希
#shw Save the provided password in hashed format
user = super().save(commit=False) #shw 调用父类的save方法但先不提交到数据库commit=False
user.set_password(self.cleaned_data["password1"]) #shw 使用Django的set_password方法将明文密码加密后存储
if commit:
user.source = 'adminsite'
user.save()
return user
user.source = 'adminsite' #shw 如果决定提交,则设置用户的来源为 'adminsite'
user.save() #shw 将用户对象保存到数据库
return user #shw 返回保存后的用户对象
class BlogUserChangeForm(UserChangeForm):
#shw 自定义用户修改表单用于在Django Admin后台编辑现有用户信息。
#shw 它继承自Django的UserChangeForm以复用大部分功能。
class Meta:
model = BlogUser
fields = '__all__'
#shw Meta类用于配置表单与模型的关联
model = BlogUser #shw 指定该表单对应的模型是 BlogUser
fields = '__all__' #shw 在修改用户时,显示模型中的所有字段
#shw 指定 'username' 字段使用的字段类为 UsernameField
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
#shw 重写初始化方法,可以在这里添加自定义的初始化逻辑
super().__init__(*args, **kwargs) #shw 调用父类的初始化方法
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
#shw 自定义用户管理类用于在Django Admin后台定制 BlogUser 模型的显示和操作方式。
#shw 它继承自Django的UserAdmin以复用用户管理的标准功能。
form = BlogUserChangeForm #shw 指定修改用户时使用的表单
add_form = BlogUserCreationForm #shw 指定添加用户时使用的表单
#shw 定义在用户列表页面显示的列
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
'id', #shw 用户ID
'nickname', #shw 昵称
'username', #shw 用户名
'email', #shw 邮箱
'last_login', #shw 最后登录时间
'date_joined', #shw 注册时间
'source') #shw 创建来源
#shw 定义在列表页面中,哪些列可以点击进入编辑页面
list_display_links = ('id', 'username')
#shw 定义用户列表的默认排序方式按ID降序排列
ordering = ('-id',)
#shw 定义搜索框可以搜索的字段
search_fields = ('username', 'nickname', 'email')

@ -1,5 +1,11 @@
#shw 导入Django的应用配置基类
from django.apps import AppConfig
class AccountsConfig(AppConfig):
#shw 这是accounts应用的配置类。
#shw 它用于定义该应用的各种元数据和行为。
#shw 指定这个配置类所属的应用的完整Python路径。
#shw Django通过这个name来找到并加载这个应用。
name = 'accounts'

@ -1,26 +1,44 @@
#shw 导入Django的表单模块
from django import forms
#shw 导入获取当前激活用户模型的函数
from django.contrib.auth import get_user_model, password_validation
#shw 导入Django内置的认证表单和用户创建表单
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
#shw 导入Django的验证错误类
from django.core.exceptions import ValidationError
#shw 导入Django的表单控件模块
from django.forms import widgets
#shw 导入Django的国际化和翻译工具
from django.utils.translation import gettext_lazy as _
from . import utils
#shw 导入本地的BlogUser模型
from .models import BlogUser
class LoginForm(AuthenticationForm):
#shw 自定义登录表单继承自Django的AuthenticationForm。
#shw 主要用于自定义前端显示的样式。
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
#shw 重写初始化方法,以自定义字段的控件属性。
super(LoginForm, self).__init__(*args, **kwargs) #shw 调用父类的初始化方法
#shw 自定义 'username' 字段的控件添加placeholder和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
#shw 自定义 'password' 字段的控件添加placeholder和CSS类
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
#shw 自定义注册表单继承自Django的UserCreationForm。
#shw 增加了邮箱唯一性验证和前端样式自定义。
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
#shw 重写初始化方法,以自定义字段的控件属性。
super(RegisterForm, self).__init__(*args, **kwargs) #shw 调用父类的初始化方法
#shw 为各个字段添加Bootstrap风格的CSS类和placeholder
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['email'].widget = widgets.EmailInput(
@ -31,17 +49,23 @@ class RegisterForm(UserCreationForm):
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
email = self.cleaned_data['email']
#shw 自定义邮箱字段的验证方法,确保邮箱在系统中是唯一的。
email = self.cleaned_data['email'] #shw 获取清洗后的邮箱数据
#shw 检查数据库中是否已存在该邮箱
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
raise ValidationError(_("email already exists")) #shw 如果存在,抛出验证错误
return email #shw 返回清洗后的邮箱
class Meta:
model = get_user_model()
fields = ("username", "email")
#shw Meta类用于配置表单与模型的关联
model = get_user_model() #shw 动态获取用户模型而不是硬编码BlogUser更具可复用性
fields = ("username", "email") #shw 指定注册表单中显示的字段
class ForgetPasswordForm(forms.Form):
#shw 忘记密码/重置密码表单继承自基础的Form类。
#shw 它不直接与模型关联,用于处理通过邮箱和验证码重置密码的流程。
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
@ -53,7 +77,7 @@ class ForgetPasswordForm(forms.Form):
)
new_password2 = forms.CharField(
label="确认密码",
label="确认密码", #shw 这里使用了中文硬编码,建议使用 _("Confirm password") 以支持国际化
widget=forms.PasswordInput(
attrs={
"class": "form-control",
@ -63,7 +87,7 @@ class ForgetPasswordForm(forms.Form):
)
email = forms.EmailField(
label='邮箱',
label='邮箱', #shw 这里使用了中文硬编码,建议使用 _("Email")
widget=forms.TextInput(
attrs={
'class': 'form-control',
@ -83,35 +107,47 @@ class ForgetPasswordForm(forms.Form):
)
def clean_new_password2(self):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
#shw 自定义验证方法,检查两次输入的新密码是否一致,并验证密码强度。
password1 = self.data.get("new_password1") #shw 从原始数据中获取密码1
password2 = self.data.get("new_password2") #shw 从原始数据中获取密码2
#shw 检查两次密码是否一致
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
#shw 使用Django内置的密码验证器来检查密码强度
password_validation.validate_password(password2)
return password2
return password2 #shw 返回验证通过的新密码
def clean_email(self):
user_email = self.cleaned_data.get("email")
#shw 自定义验证方法,检查输入的邮箱是否存在于数据库中。
user_email = self.cleaned_data.get("email") #shw 获取清洗后的邮箱
#shw 检查该邮箱是否已注册
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
#shwtodo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
#shw 这是一个安全提示,直接告诉攻击者邮箱未注册可能会被利用。
raise ValidationError(_("email does not exist"))
return user_email
return user_email #shw 返回清洗后的邮箱
def clean_code(self):
code = self.cleaned_data.get("code")
#shw 自定义验证方法,验证邮箱验证码是否正确。
code = self.cleaned_data.get("code") #shw 获取清洗后的验证码
#shw 调用工具函数验证邮箱和验证码是否匹配
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
#shw 如果工具函数返回错误信息,则抛出验证错误
if error:
raise ValidationError(error)
return code
return code #shw 返回验证通过的验证码
class ForgetPasswordCodeForm(forms.Form):
#shw 发送忘记密码验证码的表单。
#shw 它只包含一个邮箱字段,用于用户输入接收验证码的邮箱地址。
email = forms.EmailField(
label=_('Email'),
label=_('Email'), #shw 邮箱字段,标签支持国际化
)

@ -1,35 +1,54 @@
#shw 导入Django内置的抽象用户模型基类
from django.contrib.auth.models import AbstractUser
#shw 导入Django的数据库模型模块
from django.db import models
#shw 导入Django的URL反向解析函数
from django.urls import reverse
#shw 导入Django的时区工具用于获取当前时间
from django.utils.timezone import now
#shw 导入Django的国际化和翻译工具
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
# Create your models here.
#shw 在这里创建你的模型。
class BlogUser(AbstractUser):
#shw 自定义用户模型继承自Django的AbstractUser。
#shw 它扩展了默认用户模型,增加了博客系统所需的额外字段。
#shw 用户昵称字段,可为空
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
#shw 用户创建时间字段,默认为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
#shw 用户最后修改时间字段,默认为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
#shw 用户创建来源字段(如:'adminsite', 'register'),可为空
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
#shw 定义获取用户详情页绝对路径的方法。
#shw Django Admin和其他地方会使用这个方法来获取对象的URL。
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
'author_name': self.username}) #shw 反向解析到博客应用的作者详情页URL参数为用户名
def __str__(self):
return self.email
#shw 定义对象的字符串表示形式。
#shw 在Django Admin或打印对象时会显示这个字符串。
return self.email #shw 返回用户的邮箱作为其字符串表示
def get_full_url(self):
site = get_current_site().domain
#shw 定义获取用户详情页完整URL包含域名的方法。
site = get_current_site().domain #shw 获取当前站点的域名
#shw 拼接协议、域名和绝对路径形成完整的URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'
#shw Meta类用于定义模型的元数据选项。
ordering = ['-id'] #shw 默认按ID降序排列
verbose_name = _('user') #shw 在Django Admin中显示的单数名称支持国际化
verbose_name_plural = verbose_name #shw 在Django Admin中显示的复数名称
get_latest_by = 'id' #shw 当使用 .latest() 方法时,默认按 'id' 字段查找

@ -1,26 +1,39 @@
#shw 导入Django的测试客户端、请求工厂和测试用例基类
from django.test import Client, RequestFactory, TestCase
#shw 导入Django的URL反向解析函数
from django.urls import reverse
#shw 导入Django的时区工具
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
#shw 导入本地的BlogUser模型
from accounts.models import BlogUser
#shw 导入博客应用的Article和Category模型
from blog.models import Article, Category
#shw 从项目工具模块导入所有函数
from djangoblog.utils import *
#shw 导入本地的工具模块
from . import utils
# Create your tests here.
#shw 在这里创建你的测试。
class AccountTest(TestCase):
#shw 账户应用的测试用例集继承自Django的TestCase。
#shw TestCase提供了数据库事务回滚和客户端模拟等功能。
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
#shw 每个测试方法执行前都会运行的初始化方法。
#shw 用于创建测试所需的公共数据和环境。
self.client = Client() #shw 创建一个模拟的HTTP客户端用于发送请求
self.factory = RequestFactory() #shw 创建一个请求工厂,用于生成请求对象
#shw 创建一个普通用户用于测试
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
self.new_test = "xxx123--=" #shw 定义一个测试用的新密码
def test_validate_account(self):
site = get_current_site().domain
@ -28,21 +41,24 @@ class AccountTest(TestCase):
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
testuser = BlogUser.objects.get(username='liangliangyy1') #shw 从数据库中获取刚创建的超级用户
#shw 使用client模拟登录
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True)
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
self.assertEqual(loginresult, True) #shw 断言登录成功
response = self.client.get('/admin/') #shw 模拟访问后台管理页面
self.assertEqual(response.status_code, 200) #shw 断言访问成功状态码为200
#shw 创建一个文章分类用于测试
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
#shw 创建一篇文章用于测试
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
@ -52,10 +68,13 @@ class AccountTest(TestCase):
article.status = 'p'
article.save()
#shw 模拟访问文章的后台编辑页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200) #shw 断言访问成功
def test_validate_register(self):
#shw 测试用户注册、邮箱验证、登录、登出等一系列流程。
#shw 断言注册前,数据库中不存在该邮箱的用户
self.assertEquals(
0, len(
BlogUser.objects.filter(
@ -66,24 +85,30 @@ class AccountTest(TestCase):
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
#shw 断言注册后,数据库中存在该邮箱的用户
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
user = BlogUser.objects.filter(email='user123@user.com')[0] #shw 获取新注册的用户
#shw 生成用于邮箱验证的签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
path = reverse('accounts:result') #shw 获取验证结果页面的URL路径
#shw 构造完整的验证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)
response = self.client.get(url) #shw 模拟用户点击邮箱中的验证链接
self.assertEqual(response.status_code, 200) #shw 断言访问成功
#shw 模拟用户登录
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 = BlogUser.objects.filter(email='user123@user.com')[0] #shw 重新获取用户对象
user.is_superuser = True #shw 将用户提升为超级用户,以便访问后台
user.is_staff = True
user.save()
delete_sidebar_cache()
delete_sidebar_cache() #shw 删除侧边栏缓存
#shw 创建测试用的分类和文章
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
@ -95,66 +120,79 @@ class AccountTest(TestCase):
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
#shw 登录状态下访问文章后台页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
#shw 模拟用户登出
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
self.assertIn(response.status_code, [301, 302, 200]) #shw 断言登出成功重定向或OK
#shw 登出后再次访问文章后台页面
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
self.assertIn(response.status_code, [301, 302, 200]) #shw 断言被重定向到登录页
#shw 模拟使用错误的密码登录
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
'password': 'password123' #shw 错误的密码
})
self.assertIn(response.status_code, [301, 302, 200])
self.assertIn(response.status_code, [301, 302, 200]) #shw 断言登录失败,页面重定向
#shw 登录失败后访问文章后台页面
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
self.assertIn(response.status_code, [301, 302, 200]) #shw 断言依然无法访问
def test_verify_email_code(self):
#shw 测试邮箱验证码的生成、发送和验证功能。
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
code = generate_code() #shw 生成一个验证码
utils.set_code(to_email, code) #shw 将验证码与邮箱关联(通常是存入缓存或数据库)
utils.send_verify_email(to_email, code) #shw 发送验证码邮件(测试环境中可能不会真的发送)
#shw 使用正确的邮箱和验证码进行验证
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
self.assertEqual(err, None) #shw 断言验证成功,无错误信息返回
#shw 使用错误的邮箱进行验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
self.assertEqual(type(err), str) #shw 断言验证失败,返回一个字符串类型的错误信息
def test_forget_password_email_code_success(self):
#shw 测试成功发送忘记密码验证码的场景。
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
data=dict(email="admin@admin.com") #shw 使用一个已存在的邮箱
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
self.assertEqual(resp.status_code, 200) #shw 断言请求成功
self.assertEqual(resp.content.decode("utf-8"), "ok") #shw 断言返回内容为"ok"
def test_forget_password_email_code_fail(self):
#shw 测试发送忘记密码验证码失败的场景(如邮箱格式错误)。
#shw 测试不提供邮箱的情况
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") #shw 断言返回错误提示
#shw 测试提供格式错误的邮箱的情况
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") #shw 断言返回错误提示
def test_forget_password_email_success(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
#shw 测试成功重置密码的场景。
code = generate_code() #shw 生成一个验证码
utils.set_code(self.blog_user.email, code) #shw 为测试用户设置验证码
#shw 构造重置密码的请求数据
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
@ -165,20 +203,21 @@ class AccountTest(TestCase):
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.status_code, 302) #shw 断言请求成功并重定向
# 验证用户密码是否修改成功
#shw 验证用户密码是否真的被修改了
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
self.assertNotEqual(blog_user, None) #shw 断言用户依然存在
self.assertEqual(blog_user.check_password(data["new_password1"]), True) #shw 断言新密码是正确的
def test_forget_password_email_not_user(self):
#shw 测试重置一个不存在用户的密码的场景。
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
email="123@123.com", #shw 使用一个不存在的邮箱
code="123456",
)
resp = self.client.post(
@ -186,22 +225,23 @@ class AccountTest(TestCase):
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) #shw 断言请求未重定向,停留在原页面并显示错误
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
#shw 测试使用错误验证码重置密码的场景。
code = generate_code() #shw 生成一个验证码
utils.set_code(self.blog_user.email, code) #shw 为测试用户设置验证码
#shw 构造重置密码的请求数据,但验证码是错误的
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
code="111111", #shw 错误的验证码
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200) #shw 断言请求未重定向,停留在原页面并显示错误

@ -1,28 +1,44 @@
from django.urls import path
from django.urls import re_path
#shw 导入Django的path和re_path函数用于定义URL路由
from django.urls import path, re_path
#shw 导入本地的视图模块
from . import views
#shw 导入本地的LoginForm表单用于传递给登录视图
from .forms import LoginForm
#shw 定义应用的命名空间用于在模板中反向解析URL如 {% url 'accounts:login' %}
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'),
]
#shw 定义URL模式列表
urlpatterns = [
#shw 定义登录页面的URL路由
re_path(r'^login/$',
views.LoginView.as_view(success_url='/'), #shw 关联到LoginView类视图并指定登录成功后重定向到根路径
name='login', #shw 为这个URL模式命名为 'login'
kwargs={'authentication_form': LoginForm}), #shw 向LoginView传递额外的关键字参数指定使用自定义的LoginForm
#shw 定义注册页面的URL路由
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"), #shw 关联到RegisterView类视图并指定注册成功后重定向到根路径
name='register'), #shw 命名为 'register'
#shw 定义登出页面的URL路由
re_path(r'^logout/$',
views.LogoutView.as_view(), #shw 关联到LogoutView类视图
name='logout'), #shw 命名为 'logout'
#shw 定义注册/操作结果页面的URL路由
path(r'account/result.html',
views.account_result, #shw 关联到account_result函数视图
name='result'), #shw 命名为 'result'
#shw 定义忘记密码页面的URL路由用于输入新密码和验证码
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(), #shw 关联到ForgetPasswordView类视图
name='forget_password'), #shw 命名为 'forget_password'
#shw 定义发送忘记密码验证码的URL路由
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(), #shw 关联到ForgetPasswordEmailCode类视图
name='forget_password_code'), #shw 命名为 'forget_password_code'
]

@ -1,4 +1,6 @@
#shw 导入Django的获取用户模型函数
from django.contrib.auth import get_user_model
#shw 导入Django默认的基于模型的认证后端基类
from django.contrib.auth.backends import ModelBackend
@ -6,21 +8,33 @@ class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
#shw 自定义认证后端继承自ModelBackend。
#shw 它扩展了Django默认的认证功能使用户既可以使用用户名也可以使用邮箱进行登录。
def authenticate(self, request, username=None, password=None, **kwargs):
#shw 重写authenticate方法这是认证的核心逻辑。
#shw Django的login视图会调用这个方法来验证用户身份。
#shw 判断用户输入的 'username' 字段是否包含 '@' 符号,以此来区分是邮箱还是用户名
if '@' in username:
kwargs = {'email': username}
kwargs = {'email': username} #shw 如果是邮箱,则设置查询条件为 email
else:
kwargs = {'username': username}
kwargs = {'username': username} #shw 如果是用户名,则设置查询条件为 username
try:
#shw 使用动态构建的查询条件email或username去数据库中查找用户
user = get_user_model().objects.get(**kwargs)
#shw 如果找到了用户,则调用 check_password 方法来验证密码是否正确
if user.check_password(password):
return user
return user #shw 密码正确,返回用户对象,认证成功
except get_user_model().DoesNotExist:
return None
#shw 如果根据email或username找不到用户捕获异常
return None #shw 返回None表示认证失败
def get_user(self, username):
#shw 重写get_user方法。
#shw Django的认证中间件会在每个请求中调用此方法根据session中的user_id来获取用户对象。
try:
return get_user_model().objects.get(pk=username)
#shw 注意这里的参数名是username但实际传入的是用户的主键pk通常是ID
return get_user_model().objects.get(pk=username) #shw 根据主键pk查找用户
except get_user_model().DoesNotExist:
return None
#shw 如果根据主键找不到用户,捕获异常
return None #shw 返回None

@ -1,12 +1,19 @@
#shw 导入类型提示模块,用于增强代码可读性和健壮性
import typing
#shw 导入时间间隔类,用于定义验证码有效期
from datetime import timedelta
#shw 导入Django的缓存模块
from django.core.cache import cache
#shw 导入Django的翻译函数
from django.utils.translation import gettext
#shw 导入Django的延迟翻译函数用于在类或模型定义等场景
from django.utils.translation import gettext_lazy as _
#shw 从项目工具模块导入发送邮件的函数
from djangoblog.utils import send_email
#shw 定义验证码的生存时间Time To Live为5分钟
_code_ttl = timedelta(minutes=5)
@ -17,9 +24,11 @@ def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email"))
subject: 邮件主题
code: 验证码
"""
#shw 构造邮件的HTML内容使用国际化字符串并将验证码动态插入
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
#shw 调用项目通用的邮件发送函数来完成发送
send_email([to_mail], subject, html_content)
@ -32,18 +41,23 @@ def verify(email: str, code: str) -> typing.Optional[str]:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方需要对error进行处理
否测调用方需要对error进行处理
"""
#shw 从缓存中获取指定邮箱对应的验证码
cache_code = get_code(email)
#shw 比较用户输入的验证码和缓存中的验证码是否一致
if cache_code != code:
#shw 如果不一致,返回一个翻译后的错误信息字符串
return gettext("Verification code error")
def set_code(email: str, code: str):
"""设置code"""
#shw 将验证码存入缓存键为邮箱值为验证码并设置5分钟的过期时间
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
#shw 从缓存中根据邮箱(键)获取验证码(值)
return cache.get(email)

@ -1,59 +1,84 @@
#shw 导入日志模块
import logging
from django.utils.translation import gettext_lazy as _
from django.conf import settings
#shw 导入Django的认证模块
from django.contrib import auth
#shw 导入登录后重定向字段的常量名
from django.contrib.auth import REDIRECT_FIELD_NAME
#shw 导入获取用户模型的函数
from django.contrib.auth import get_user_model
#shw 导入登出函数
from django.contrib.auth import logout
#shw 导入Django内置的认证表单
from django.contrib.auth.forms import AuthenticationForm
#shw 导入密码哈希生成函数
from django.contrib.auth.hashers import make_password
#shw 导入HTTP响应相关类
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
#shw 导入Django的快捷函数
from django.shortcuts import get_object_or_404
from django.shortcuts import render
#shw 导入URL反向解析函数
from django.urls import reverse
#shw 导入方法装饰器
from django.utils.decorators import method_decorator
#shw 导入URL安全检查函数
from django.utils.http import url_has_allowed_host_and_scheme
#shw 导入Django的视图基类
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
#shw 从项目工具模块导入所需函数
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
#shw 导入本地的工具模块和表单
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
#shw 导入本地的模型
from .models import BlogUser
#shw 获取当前模块的日志记录器
logger = logging.getLogger(__name__)
# Create your views here.
#shw 在这里创建你的视图。
class RegisterView(FormView):
form_class = RegisterForm
template_name = 'account/registration_form.html'
#shw 用户注册视图继承自FormView用于处理用户注册逻辑。
form_class = RegisterForm #shw 指定使用的表单类
template_name = 'account/registration_form.html' #shw 指定渲染的模板
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
#shw 为视图的dispatch方法添加CSRF保护
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
if form.is_valid():
user = form.save(False)
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
#shw 当表单验证通过时执行此方法
if form.is_valid(): #shw 再次确认表单有效
user = form.save(False) #shw 保存表单数据但先不提交到数据库commit=False
user.is_active = False #shw 将用户状态设为未激活,需要邮箱验证
user.source = 'Register' #shw 设置用户来源为注册
user.save(True) #shw 现在将用户对象保存到数据库
site = get_current_site().domain #shw 获取当前站点域名
#shw 生成用于邮箱验证的双重哈希签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
#shw 如果是调试模式,则使用本地地址
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('account:result')
path = reverse('account:result') #shw 获取结果页面的URL路径
#shw 构造完整的邮箱验证链接
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
#shw 构造邮件内容
content = """
<p>请点击下面链接验证您的邮箱</p>
@ -64,6 +89,7 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
#shw 发送验证邮件
send_email(
emailto=[
user.email,
@ -71,134 +97,159 @@ class RegisterView(FormView):
title='验证您的电子邮箱',
content=content)
#shw 构造注册成功后的跳转URL提示用户去查收邮件
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
return HttpResponseRedirect(url) #shw 重定向到结果页面
else:
#shw 如果表单无效,重新渲染注册页面并显示错误
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
url = '/login/'
#shw 用户登出视图继承自RedirectView用于处理用户登出逻辑。
url = '/login/' #shw 登出后重定向的URL
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
#shw 为视图添加never_cache装饰器确保该页面不被缓存
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
logout(request) #shw 调用Django的logout函数清除session信息
delete_sidebar_cache() #shw 删除侧边栏缓存
return super(LogoutView, self).get(request, *args, **kwargs) #shw 执行重定向
class LoginView(FormView):
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
#shw 用户登录视图继承自FormView用于处理用户登录逻辑。
form_class = LoginForm #shw 指定使用的表单类
template_name = 'account/login.html' #shw 指定渲染的模板
success_url = '/' #shw 登录成功后默认的重定向URL
redirect_field_name = REDIRECT_FIELD_NAME #shw 指定包含重定向URL的GET参数名
login_ttl = 2626560 # 一个月的时间用于“记住我”功能的session过期时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
#shw 为视图添加多个装饰器保护密码参数、CSRF保护、禁止缓存
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
redirect_to = self.request.GET.get(self.redirect_field_name)
#shw 向模板上下文中添加额外的数据
redirect_to = self.request.GET.get(self.redirect_field_name) #shw 获取GET参数中的重定向URL
if redirect_to is None:
redirect_to = '/'
kwargs['redirect_to'] = redirect_to
redirect_to = '/' #shw 如果没有,则默认为根路径
kwargs['redirect_to'] = redirect_to #shw 将重定向URL添加到上下文
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
#shw 当表单验证通过时执行此方法
#shw 使用Django内置的AuthenticationForm再次验证因为它会调用自定义的认证后端
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
delete_sidebar_cache()
logger.info(self.redirect_field_name)
delete_sidebar_cache() #shw 删除侧边栏缓存
logger.info(self.redirect_field_name) #shw 记录日志
auth.login(self.request, form.get_user())
auth.login(self.request, form.get_user()) #shw 调用Django的login函数将用户信息存入session
#shw 如果用户勾选了“记住我”
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
self.request.session.set_expiry(self.login_ttl) #shw 设置session的过期时间为一个月
return super(LoginView, self).form_valid(form) #shw 调用父类方法,处理重定向
else:
#shw 如果验证失败,重新渲染登录页面并显示错误
return self.render_to_response({
'form': form
})
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
#shw 获取登录成功后应重定向的URL
redirect_to = self.request.POST.get(self.redirect_field_name) #shw 从POST数据中获取重定向URL
#shw 检查URL是否安全防止开放重定向攻击
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
redirect_to = self.success_url #shw 如果URL不安全则使用默认的success_url
return redirect_to
def account_result(request):
type = request.GET.get('type')
id = request.GET.get('id')
#shw 函数视图,用于处理注册和邮箱验证的结果展示。
type = request.GET.get('type') #shw 获取URL参数中的类型
id = request.GET.get('id') #shw 获取URL参数中的用户ID
user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
if user.is_active:
user = get_object_or_404(get_user_model(), id=id) #shw 根据ID获取用户对象如果不存在则返回404
logger.info(type) #shw 记录日志
if user.is_active: #shw 如果用户已经激活,则直接跳转到首页
return HttpResponseRedirect('/')
#shw 处理两种类型:注册成功提示和邮箱验证
if type and type in ['register', 'validation']:
if type == 'register':
#shw 如果是注册类型,显示注册成功、请查收邮件的提示
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
if sign != c_sign:
#shw 如果是验证类型,需要验证签名
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) #shw 重新计算正确的签名
sign = request.GET.get('sign') #shw 获取URL中的签名
if sign != c_sign: #shw 比较签名如果不一致则返回403禁止访问
return HttpResponseForbidden()
user.is_active = True
user.save()
user.is_active = True #shw 激活用户
user.save() #shw 保存用户状态
#shw 显示验证成功的提示
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
#shw 渲染结果页面
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
#shw 如果类型不匹配,则跳转到首页
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
#shw 忘记密码视图,用于处理通过验证码重置密码的逻辑。
form_class = ForgetPasswordForm #shw 指定使用的表单
template_name = 'account/forget_password.html' #shw 指定渲染的模板
def form_valid(self, form):
if form.is_valid():
#shw 当表单验证通过时执行此方法
if form.is_valid(): #shw 再次确认表单有效
#shw 根据邮箱获取用户对象
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
#shw 使用make_password对新密码进行哈希处理
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save()
return HttpResponseRedirect('/login/')
blog_user.save() #shw 保存用户的新密码
return HttpResponseRedirect('/login/') #shw 重定向到登录页面
else:
#shw 如果表单无效,重新渲染页面并显示错误
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
#shw 发送忘记密码验证码的视图继承自基础的View。
@staticmethod
def post(request: HttpRequest):
#shw 只处理POST请求
form = ForgetPasswordCodeForm(request.POST) #shw 用POST数据实例化表单
if not form.is_valid(): #shw 验证表单(主要是验证邮箱格式)
return HttpResponse("错误的邮箱") #shw 如果无效,返回错误信息
to_email = form.cleaned_data["email"] #shw 获取清洗后的邮箱
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
code = generate_code() #shw 生成一个验证码
utils.send_verify_email(to_email, code) #shw 调用工具函数发送验证邮件
utils.set_code(to_email, code) #shw 调用工具函数将验证码存入缓存
return HttpResponse("ok")
return HttpResponse("ok") #shw 返回成功信息

@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# bjy: 注册你的模型到这里。
# Register your models here.
from .models import Article, Category, Tag, Links, SideBar, BlogSettings

@ -1,11 +1,9 @@
# bjy: 从Django核心管理模块导入BaseCommand基类用于创建自定义管理命令
from django.core.management.base import BaseCommand
# bjy: 从项目工具模块导入SpiderNotify类用于通知搜索引擎抓取新内容
from djangoblog.spider_notify import SpiderNotify
# bjy: 从项目工具模块导入get_current_site函数用于获取当前站点域名等信息
from djangoblog.utils import get_current_site
# bjy: 从当前应用的models模块导入Article, Tag, Category模型用于获取待通知的URL
from blog.models import Article, Tag, Category
# bjy: 获取当前站点的域名用于拼接完整URL
@ -31,7 +29,8 @@ class Command(BaseCommand):
help='article : all article,tag : all tag,category: all category,all: All of these')
# bjy: 定义一个辅助方法用于根据路径拼接完整的URL
def get_full_url(self, path):
@staticmethod
def get_full_url(path):
# bjy: 使用https协议和当前站点域名拼接完整URL
url = "https://{site}{path}".format(site=site, path=path)
return url

@ -19,7 +19,8 @@ class Command(BaseCommand):
help = 'sync user avatar'
# bjy: 定义一个辅助方法用于测试给定的URL是否可访问返回200状态码
def test_picture(self, url):
@staticmethod
def test_picture(url):
try:
# bjy: 尝试GET请求设置2秒超时如果状态码为200则返回True
if requests.get(url, timeout=2).status_code == 200:

@ -26,6 +26,7 @@ class OnlineMiddleware(object):
# bjy: 中间件的核心调用方法,每个请求都会经过这里
def __call__(self, request):
""" page render time """
# bjy: 记录页面渲染开始时间
start_time = time.time()
# bjy: 调用下一个中间件或视图,获取响应对象

@ -1,212 +1,133 @@
# bjy: 此文件由Django 4.1.7于2023-03-02 07:14自动生成用于数据库结构迁移
# Generated by Django 4.1.7 on 2023-03-02 07:14
# bjy: 从Django配置模块导入settings用于获取AUTH_USER_MODEL等配置
from django.conf import settings
# bjy: 从Django数据库模块导入migrations和models用于定义迁移操作和模型字段
from django.db import migrations, models
# bjy: 导入django.db.models.deletion用于定义模型删除时的行为如级联删除
import django.db.models.deletion
# bjy: 导入django.utils.timezone用于为模型字段提供默认的时区感知时间
import django.utils.timezone
# bjy: 导入mdeditor.fields用于使用Markdown编辑器字段类型
import mdeditor.fields
# bjy: 定义一个迁移类,用于创建博客应用所需的数据表
class Migration(migrations.Migration):
# bjy: 标记这是该应用的初始迁移
initial = True
# bjy: 定义此迁移的依赖关系,依赖于用户模型的迁移,确保用户表先被创建
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# bjy: 定义此迁移要执行的一系列操作
operations = [
# bjy: 操作1创建BlogSettings网站配置模型对应的数据库表
migrations.CreateModel(
name='BlogSettings',
fields=[
# bjy: 主键ID大整数自增
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# bjy: 网站名称字符串类型最大长度200
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
# bjy: 网站描述文本类型最大长度1000
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
# bjy: 网站SEO描述文本类型最大长度1000
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
# bjy: 网站关键字文本类型最大长度1000
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
# bjy: 文章摘要长度整数类型默认300
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
# bjy: 侧边栏文章数目整数类型默认10
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
# bjy: 侧边栏评论数目整数类型默认5
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
# bjy: 文章页面默认显示评论数目整数类型默认5
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
# bjy: 是否显示谷歌广告布尔类型默认False
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
# bjy: 广告内容文本类型可为空最大长度2000
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
# bjy: 是否打开网站评论功能布尔类型默认True
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
# bjy: 备案号字符串类型可为空最大长度2000
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
# bjy: 网站统计代码文本类型最大长度1000
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
# bjy: 是否显示公安备案号布尔类型默认False
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
# bjy: 公安备案号文本类型可为空最大长度2000
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
# bjy: 设置模型在后台管理中的单复数名称
'verbose_name': '网站配置',
'verbose_name_plural': '网站配置',
},
),
# bjy: 操作2创建Links友情链接模型对应的数据库表
migrations.CreateModel(
name='Links',
fields=[
# bjy: 主键ID大整数自增
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# bjy: 链接名称字符串类型最大长度30唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
# bjy: 链接地址URLField类型
('link', models.URLField(verbose_name='链接地址')),
# bjy: 排序,整数类型,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# bjy: 是否显示布尔类型默认True
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# bjy: 显示类型,字符类型,提供选择项,默认'i'(首页)
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
# bjy: 创建时间,日期时间类型,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# bjy: 修改时间,日期时间类型,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# bjy: 设置模型在后台管理中的单复数名称和默认排序方式
'verbose_name': '友情链接',
'verbose_name_plural': '友情链接',
'ordering': ['sequence'],
},
),
# bjy: 操作3创建SideBar侧边栏模型对应的数据库表
migrations.CreateModel(
name='SideBar',
fields=[
# bjy: 主键ID大整数自增
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# bjy: 标题字符串类型最大长度100
('name', models.CharField(max_length=100, verbose_name='标题')),
# bjy: 内容,文本类型
('content', models.TextField(verbose_name='内容')),
# bjy: 排序,整数类型,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# bjy: 是否启用布尔类型默认True
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
# bjy: 创建时间,日期时间类型,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# bjy: 修改时间,日期时间类型,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# bjy: 设置模型在后台管理中的单复数名称和默认排序方式
'verbose_name': '侧边栏',
'verbose_name_plural': '侧边栏',
'ordering': ['sequence'],
},
),
# bjy: 操作4创建Tag标签模型对应的数据库表
migrations.CreateModel(
name='Tag',
fields=[
# bjy: 主键ID自增整数
('id', models.AutoField(primary_key=True, serialize=False)),
# bjy: 创建时间,日期时间类型,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# bjy: 修改时间,日期时间类型,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# bjy: 标签名字符串类型最大长度30唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
# bjy: 别名SlugField类型用于生成友好URL可为空默认'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
# bjy: 设置模型在后台管理中的单复数名称和默认排序方式
'verbose_name': '标签',
'verbose_name_plural': '标签',
'ordering': ['name'],
},
),
# bjy: 操作5创建Category分类模型对应的数据库表
migrations.CreateModel(
name='Category',
fields=[
# bjy: 主键ID自增整数
('id', models.AutoField(primary_key=True, serialize=False)),
# bjy: 创建时间,日期时间类型,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# bjy: 修改时间,日期时间类型,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# bjy: 分类名字符串类型最大长度30唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
# bjy: 别名SlugField类型用于生成友好URL可为空默认'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
# bjy: 权重排序整数类型默认0越大越靠前
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
# bjy: 父级分类,外键关联到自身,可为空,级联删除
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
# bjy: 设置模型在后台管理中的单复数名称和默认排序方式(按权重降序)
'verbose_name': '分类',
'verbose_name_plural': '分类',
'ordering': ['-index'],
},
),
# bjy: 操作6创建Article文章模型对应的数据库表
migrations.CreateModel(
name='Article',
fields=[
# bjy: 主键ID自增整数
('id', models.AutoField(primary_key=True, serialize=False)),
# bjy: 创建时间,日期时间类型,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# bjy: 修改时间,日期时间类型,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# bjy: 标题字符串类型最大长度200唯一
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
# bjy: 正文Markdown编辑器字段类型
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
# bjy: 发布时间,日期时间类型,默认为当前时间
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
# bjy: 文章状态,字符类型,提供选择项(草稿/发表),默认'p'(发表)
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
# bjy: 评论状态,字符类型,提供选择项(打开/关闭),默认'o'(打开)
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
# bjy: 类型,字符类型,提供选择项(文章/页面),默认'a'(文章)
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
# bjy: 浏览量正整数类型默认0
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
# bjy: 排序整数类型默认0数字越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
# bjy: 是否显示toc目录布尔类型默认False
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
# bjy: 作者,外键关联到用户模型,级联删除
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# bjy: 分类外键关联到Category模型级联删除
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
# bjy: 标签集合多对多关系关联到Tag模型可为空
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
options={
# bjy: 设置模型在后台管理中的单复数名称、默认排序方式和获取最新记录的依据字段
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'],

@ -1,364 +1,297 @@
# bjy: 此文件由Django 4.2.5于2023-09-06 13:13自动生成用于数据库结构迁移
# Generated by Django 4.2.5 on 2023-09-06 13:13
# bjy: 从Django配置模块导入settings用于获取AUTH_USER_MODEL等配置
from django.conf import settings
# bjy: 从Django数据库模块导入migrations和models用于定义迁移操作和模型字段
from django.db import migrations, models
# bjy: 导入django.db.models.deletion用于定义模型删除时的行为如级联删除
import django.db.models.deletion
# bjy: 导入django.utils.timezone用于为模型字段提供默认的时区感知时间
import django.utils.timezone
# bjy: 导入mdeditor.fields用于使用Markdown编辑器字段类型
import mdeditor.fields
# bjy: 定义一个迁移类用于对blog应用进行数据库结构变更
class Migration(migrations.Migration):
# bjy: 定义此迁移的依赖关系它依赖于用户模型和blog应用的0004迁移
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
# bjy: 定义此迁移要执行的一系列操作
operations = [
# bjy: 操作1修改Article模型的Meta选项更新verbose_name等
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# bjy: 操作2修改Category模型的Meta选项更新verbose_name等
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# bjy: 操作3修改Links模型的Meta选项更新verbose_name等
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# bjy: 操作4修改SideBar模型的Meta选项更新verbose_name等
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# bjy: 操作5修改Tag模型的Meta选项更新verbose_name等
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# bjy: 操作6删除Article模型的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# bjy: 操作7删除Article模型的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# bjy: 操作8删除Category模型的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# bjy: 操作9删除Category模型的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# bjy: 操作10删除Links模型的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# bjy: 操作11删除SideBar模型的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# bjy: 操作12删除Tag模型的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# bjy: 操作13删除Tag模型的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# bjy: 操作14为Article模型添加creation_time字段
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# bjy: 操作15为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'),
),
# bjy: 操作16为Category模型添加creation_time字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# bjy: 操作17为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'),
),
# bjy: 操作18为Links模型添加creation_time字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# bjy: 操作19为SideBar模型添加creation_time字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# bjy: 操作20为Tag模型添加creation_time字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# bjy: 操作21为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'),
),
# bjy: 操作22修改Article模型的article_order字段更新verbose_name
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# bjy: 操作23修改Article模型的author字段更新verbose_name
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# bjy: 操作24修改Article模型的body字段更新verbose_name
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# bjy: 操作25修改Article模型的category字段更新verbose_name
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# bjy: 操作26修改Article模型的comment_status字段更新choices和verbose_name
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'),
),
# bjy: 操作27修改Article模型的pub_time字段更新verbose_name
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# bjy: 操作28修改Article模型的show_toc字段更新verbose_name
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# bjy: 操作29修改Article模型的status字段更新choices和verbose_name
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# bjy: 操作30修改Article模型的tags字段更新verbose_name
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# bjy: 操作31修改Article模型的title字段更新verbose_name
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# bjy: 操作32修改Article模型的type字段更新choices和verbose_name
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# bjy: 操作33修改Article模型的views字段更新verbose_name
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# bjy: 操作34修改BlogSettings模型的article_comment_count字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
# bjy: 操作35修改BlogSettings模型的article_sub_length字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# bjy: 操作36修改BlogSettings模型的google_adsense_codes字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# bjy: 操作37修改BlogSettings模型的open_site_comment字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# bjy: 操作38修改BlogSettings模型的show_google_adsense字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# bjy: 操作39修改BlogSettings模型的sidebar_article_count字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# bjy: 操作40修改BlogSettings模型的sidebar_comment_count字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# bjy: 操作41修改BlogSettings模型的site_description字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# bjy: 操作42修改BlogSettings模型的site_keywords字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# bjy: 操作43修改BlogSettings模型的site_name字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# bjy: 操作44修改BlogSettings模型的site_seo_description字段更新verbose_name
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# bjy: 操作45修改Category模型的index字段更新verbose_name
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# bjy: 操作46修改Category模型的name字段更新verbose_name
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# bjy: 操作47修改Category模型的parent_category字段更新verbose_name
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# bjy: 操作48修改Links模型的is_enable字段更新verbose_name
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# bjy: 操作49修改Links模型的last_mod_time字段更新verbose_name
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# bjy: 操作50修改Links模型的link字段更新verbose_name
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# bjy: 操作51修改Links模型的name字段更新verbose_name
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# bjy: 操作52修改Links模型的sequence字段更新verbose_name
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# bjy: 操作53修改Links模型的show_type字段更新choices和verbose_name
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# bjy: 操作54修改SideBar模型的content字段更新verbose_name
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# bjy: 操作55修改SideBar模型的is_enable字段更新verbose_name
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# bjy: 操作56修改SideBar模型的last_mod_time字段更新verbose_name
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# bjy: 操作57修改SideBar模型的name字段更新verbose_name
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# bjy: 操作58修改SideBar模型的sequence字段更新verbose_name
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# bjy: 操作59修改Tag模型的name字段更新verbose_name
migrations.AlterField(
model_name='tag',
name='name',

@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-11-13 13:53
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='article',
name='users_like',
field=models.ManyToManyField(blank=True, related_name='articles_liked', to=settings.AUTH_USER_MODEL, verbose_name='点赞用户'),
),
]

@ -147,6 +147,12 @@ class Article(BaseModel):
null=False)
# bjy: 标签多对多关联到Tag模型
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
users_like = models.ManyToManyField(
settings.AUTH_USER_MODEL, # 关联到用户模型
related_name='articles_liked', # 反向关系名称user.articles_liked.all()可获取用户点赞的所有文章
blank=True, # 允许文章没有被任何用户点赞
verbose_name='点赞用户' # 在Admin后台显示的字段名称
)
# bjy: 将body字段转换为字符串
def body_to_string(self):

@ -0,0 +1,598 @@
/*
* DjangoBlog
* theme.css - /
* DjangoBlog
*/
:root {
/* ====== 浅色主题变量 ====== */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--border-color: #dee2e6;
--link-color: #007bff;
--link-hover-color: #0056b3;
--code-bg: #f8f9fa;
--blockquote-border: #e9ecef;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
/* 导航栏 */
--nav-bg: #f8f9fa;
--nav-text: #333333;
--nav-border: #dee2e6;
/* 按钮 */
--btn-primary-bg: #007bff;
--btn-primary-text: #ffffff;
--btn-secondary-bg: #6c757d;
--btn-secondary-text: #ffffff;
/* 输入框 */
--input-bg: #ffffff;
--input-text: #333333;
--input-border: #ced4da;
--input-focus-border: #007bff;
}
[data-theme="dark"] {
/* ====== 深色主题变量 ====== */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #404040;
--bg-card: #2d2d2d;
--text-primary: #e9ecef;
--text-secondary: #adb5bd;
--text-muted: #6c757d;
--border-color: #495057;
--link-color: #4dabf7;
--link-hover-color: #74c0fc;
--code-bg: #2d2d2d;
--blockquote-border: #495057;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
/* 导航栏 */
--nav-bg: #2d2d2d;
--nav-text: #e9ecef;
--nav-border: #495057;
/* 按钮 */
--btn-primary-bg: #4dabf7;
--btn-primary-text: #ffffff;
--btn-secondary-bg: #6c757d;
--btn-secondary-text: #ffffff;
/* 输入框 */
--input-bg: #2d2d2d;
--input-text: #e9ecef;
--input-border: #495057;
--input-focus-border: #4dabf7;
}
/* ====== 基础元素样式 ====== */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: var(--transition);
min-height: 100vh;
}
/* ====== DjangoBlog 特定样式适配 ====== */
/* 站点头部 */
.site-header {
background-color: var(--nav-bg) !important;
border-bottom: 1px solid var(--nav-border);
}
.site-title a,
.site-description {
color: var(--nav-text) !important;
}
/* 主导航 */
.main-navigation {
background-color: var(--nav-bg);
border-color: var(--nav-border);
}
.main-navigation a {
color: var(--nav-text) !important;
}
.main-navigation a:hover {
color: var(--link-hover-color) !important;
}
/* 主要内容区域 */
#page {
background-color: var(--bg-primary);
}
#main {
background-color: var(--bg-primary);
}
/* 文章和卡片样式 */
.entry-content,
.entry-header,
.entry-summary,
.type-post,
.widget,
.comment-list,
.comment-body {
background-color: var(--bg-card);
color: var(--text-primary);
border-color: var(--border-color);
}
/* 文章标题 */
.entry-title,
.entry-title a {
color: var(--text-primary) !important;
}
.entry-title a:hover {
color: var(--link-hover-color) !important;
}
/* 元信息 */
.entry-meta,
.comment-metadata {
color: var(--text-muted) !important;
}
/* 小工具 */
.widget-title {
color: var(--text-primary);
border-bottom-color: var(--border-color);
}
/* 链接 */
a {
color: var(--link-color);
}
a:hover {
color: var(--link-hover-color);
}
/* 代码块 */
pre, code {
background-color: var(--code-bg) !important;
color: var(--text-primary) !important;
border-color: var(--border-color);
}
/* 引用 */
blockquote {
border-left-color: var(--blockquote-border);
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
/* 表格 */
table {
background-color: var(--bg-card);
color: var(--text-primary);
border-color: var(--border-color);
}
th, td {
border-color: var(--border-color);
background-color: var(--bg-card);
color: var(--text-primary);
}
/* 表单 */
input, textarea, select {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--input-text);
}
input:focus, textarea:focus, select:focus {
background-color: var(--input-bg);
border-color: var(--input-focus-border);
color: var(--input-text);
}
/* 按钮 */
button, .button, input[type="submit"], input[type="button"] {
background-color: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-bg);
}
/* 页脚 */
.site-footer {
background-color: var(--bg-secondary) !important;
border-top-color: var(--border-color);
color: var(--text-secondary);
}
/* ====== 主题切换按钮样式 ====== */
.theme-toggle-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: var(--transition);
color: var(--text-primary);
margin-left: 10px;
}
.theme-toggle-btn:hover {
transform: scale(1.1);
background-color: var(--bg-tertiary);
border-color: var(--link-color);
}
.theme-toggle-btn:focus {
outline: none;
box-shadow: 0 0 0 2px var(--link-color);
}
/* ====== 导航栏集成样式 ====== */
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.theme-toggle-container {
margin: 0 15px;
display: flex;
align-items: center;
}
.user-menu {
display: flex;
align-items: center;
}
.user-dropdown {
position: relative;
display: inline-block;
}
.username {
padding: 8px 15px;
color: var(--text-primary);
cursor: pointer;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
transition: var(--transition);
font-size: 14px;
}
.username:hover {
background: var(--bg-tertiary);
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background-color: var(--bg-card);
min-width: 160px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
border-radius: 4px;
z-index: 1000;
}
.dropdown-content a {
color: var(--text-primary);
padding: 12px 16px;
text-decoration: none;
display: block;
transition: var(--transition);
border: none;
}
.dropdown-content a:hover {
background-color: var(--bg-secondary);
text-decoration: none;
}
.user-dropdown:hover .dropdown-content {
display: block;
}
.dropdown-divider {
height: 1px;
background-color: var(--border-color);
margin: 5px 0;
}
.login-link {
padding: 8px 15px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
transition: var(--transition);
font-size: 14px;
}
.login-link:hover {
background: var(--bg-tertiary);
color: var(--link-hover-color);
text-decoration: none;
}
/* ====== 平滑过渡效果 ====== */
body,
.site-header,
.main-navigation,
.entry-content,
.entry-header,
.widget,
.comment-list,
.site-footer,
a,
button,
input,
textarea,
select,
table,
pre,
code {
transition: var(--transition);
}
/* 图片过渡 */
img {
transition: opacity 0.3s ease;
}
[data-theme="dark"] img {
opacity: 0.9;
}
[data-theme="dark"] img:hover {
opacity: 1;
}
/* ====== 响应式调整 ====== */
@media (max-width: 768px) {
.theme-toggle-btn {
width: 36px;
height: 36px;
font-size: 1.1rem;
}
.nav-container {
flex-direction: column;
gap: 10px;
}
.theme-toggle-container {
margin: 10px 0;
}
.user-menu {
width: 100%;
justify-content: center;
}
}
/* ====== 导航栏集成优化样式 ====== */
/* 导航容器布局 */
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.primary-nav {
flex: 1;
}
.nav-actions {
display: flex;
align-items: center;
gap: 15px;
}
/* 主题切换按钮优化 */
.theme-toggle-container {
display: flex;
align-items: center;
}
.theme-toggle-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: var(--transition);
color: var(--text-primary);
}
.theme-toggle-btn:hover {
transform: scale(1.1);
background-color: var(--bg-tertiary);
border-color: var(--link-color);
}
/* 用户菜单优化 */
.user-menu {
display: flex;
align-items: center;
}
.user-dropdown {
position: relative;
display: inline-block;
}
.username {
padding: 8px 15px;
color: var(--text-primary);
cursor: pointer;
border: 1px solid var(--border-color);
border-radius: 20px;
background: var(--bg-secondary);
transition: var(--transition);
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.username:hover {
background: var(--bg-tertiary);
}
.user-icon {
font-size: 12px;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
top: 100%;
background-color: var(--bg-card);
min-width: 140px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
border-radius: 8px;
z-index: 1000;
margin-top: 5px;
}
.dropdown-content a {
color: var(--text-primary);
padding: 10px 15px;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
transition: var(--transition);
border: none;
font-size: 14px;
}
.dropdown-content a:hover {
background-color: var(--bg-secondary);
text-decoration: none;
}
.user-dropdown:hover .dropdown-content {
display: block;
}
.dropdown-divider {
height: 1px;
background-color: var(--border-color);
margin: 5px 0;
}
.icon {
font-size: 12px;
}
.login-link {
padding: 8px 15px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-color);
border-radius: 20px;
background: var(--bg-secondary);
transition: var(--transition);
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.login-link:hover {
background: var(--bg-tertiary);
color: var(--link-hover-color);
text-decoration: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 10px;
padding: 10px;
}
.nav-actions {
width: 100%;
justify-content: center;
gap: 10px;
}
.theme-toggle-btn {
width: 36px;
height: 36px;
font-size: 1.1rem;
}
.primary-nav {
width: 100%;
text-align: center;
}
}
/* 确保原有导航样式兼容 */
.main-navigation {
background-color: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
transition: var(--transition);
padding: 10px 0;
}
.main-navigation div {
background: transparent !important;
}
.main-navigation a {
color: var(--nav-text) !important;
transition: var(--transition);
}
.main-navigation a:hover {
color: var(--link-hover-color) !important;
background: transparent !important;
}
/* 修复可能的分层问题 */
.site-header {
position: relative;
z-index: 100;
}
.main-navigation {
position: relative;
z-index: 99;
}

@ -0,0 +1,132 @@
// blog/static/blog/js/theme-switcher.js
(function() {
'use strict';
class ThemeSwitcher {
constructor() {
this.themeToggle = null;
this.currentTheme = this.getPreferredTheme();
this.init();
}
init() {
console.log('初始化主题切换器,当前主题:', this.currentTheme);
this.setTheme(this.currentTheme);
this.bindEvents();
this.watchSystemTheme();
}
getPreferredTheme() {
try {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
} catch (error) {
console.warn('获取主题偏好失败:', error);
return 'light';
}
}
setTheme(theme) {
try {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
this.currentTheme = theme;
this.updateToggleIcon();
console.log('主题已设置为:', theme);
const event = new CustomEvent('themeChanged', {
detail: { theme: theme }
});
document.dispatchEvent(event);
} catch (error) {
console.error('设置主题失败:', error);
}
}
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
console.log('切换主题:', this.currentTheme, '->', newTheme);
this.setTheme(newTheme);
}
bindEvents() {
this.themeToggle = document.getElementById('theme-toggle');
if (this.themeToggle) {
this.themeToggle.addEventListener('click', () => {
this.toggleTheme();
});
this.updateToggleIcon();
} else {
console.warn('未找到主题切换按钮 #theme-toggle');
setTimeout(() => {
this.themeToggle = document.getElementById('theme-toggle');
if (this.themeToggle) {
this.themeToggle.addEventListener('click', () => {
this.toggleTheme();
});
this.updateToggleIcon();
}
}, 1000);
}
}
updateToggleIcon() {
if (!this.themeToggle) return;
const iconElement = this.themeToggle.querySelector('.theme-icon');
if (iconElement) {
const icon = this.currentTheme === 'light' ? '🌙' : '☀️';
const title = this.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式';
iconElement.textContent = icon;
this.themeToggle.title = title;
this.themeToggle.setAttribute('aria-label', title);
}
}
watchSystemTheme() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (e) => {
if (!localStorage.getItem('theme')) {
const newTheme = e.matches ? 'dark' : 'light';
console.log('系统主题变化,自动切换至:', newTheme);
this.setTheme(newTheme);
}
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemThemeChange);
} else {
mediaQuery.addListener(handleSystemThemeChange);
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.themeSwitcher = new ThemeSwitcher();
});
} else {
window.themeSwitcher = new ThemeSwitcher();
}
window.toggleTheme = function() {
if (window.themeSwitcher) {
window.themeSwitcher.toggleTheme();
}
};
})();

@ -1,5 +1,7 @@
# bjy: 导入操作系统接口模块
import json
import os
from unittest.mock import patch, MagicMock
# bjy: 从Django中导入设置、文件上传、命令调用、分页器、静态文件、测试工具、URL反向解析和时区工具
from django.conf import settings
@ -16,6 +18,7 @@ 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 blog.views import LikeArticle
from djangoblog.utils import get_current_site, get_sha256
# bjy: 从项目中导入OAuth相关模型
from oauth.models import OAuthUser, OAuthConfig
@ -34,9 +37,7 @@ class ArticleTest(TestCase):
# bjy: 定义一个测试方法,用于验证文章相关的功能
def test_validate_article(self):
# bjy: 获取当前站点域名
site = get_current_site().domain
# bjy: 获取或创建一个超级用户用于测试
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -49,10 +50,8 @@ class ArticleTest(TestCase):
# bjy: 测试用户详情页是否能正常访问
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
# bjy: 测试一些Admin后台页面这些可能不存在但测试不会失败
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# bjy: 创建并保存一个侧边栏实例
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -243,7 +242,8 @@ class ArticleTest(TestCase):
self.assertEqual(rsp.status_code, 404)
# bjy: 测试自定义的管理命令
def test_commands(self):
@staticmethod
def test_commands():
# bjy: 创建一个超级用户(如果不存在)
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
@ -293,3 +293,284 @@ class ArticleTest(TestCase):
call_command("clear_cache")
call_command("sync_user_avatar")
call_command("build_search_words")
class TestLikeArticle(TestCase):
"""测试 LikeArticle 视图类中的 post 方法"""
def setUp(self):
"""
初始化测试所需的数据和工具
"""
self.factory = RequestFactory()
self.user = BlogUser.objects.create_user(username='testuser', password='password')
# 创建分类Article模型需要category字段
self.category = Category.objects.create(
name="Test Category",
slug="test-category"
)
self.article = Article.objects.create(
title="Test Article",
body="This is a test article.",
author=self.user,
category=self.category, # Article模型必需字段
views=0,
)
@patch('blog.models.Article.objects.get')
def test_post_like_article_successfully(self, mock_get_article):
"""
测试场景用户第一次点赞文章成功
输入
- 已登录用户
- 存在的文章 ID
- 用户尚未点赞该文章
输出
- type = 1 表示新增点赞
- like_sum 更新为 1
- state = 200 成功状态码
"""
# 设置 mock 返回值
mock_article = MagicMock()
mock_article.users_like.filter.return_value.exists.return_value = False
mock_article.users_like.count.return_value = 1
mock_get_article.return_value = mock_article
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 断言调用了 add 方法表示点赞
mock_article.users_like.add.assert_called_once_with(self.user)
mock_article.users_like.remove.assert_not_called()
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['type'], 1)
self.assertEqual(content['like_sum'], 1)
self.assertEqual(content['state'], 200)
@patch('blog.models.Article.objects.get')
def test_post_unlike_article_successfully(self, mock_get_article):
"""
测试场景用户取消点赞文章成功
输入
- 已登录用户
- 存在的文章 ID
- 用户已经点赞了该文章
输出
- type = 0 表示取消点赞
- like_sum 更新为 0
- state = 200 成功状态码
"""
# 设置 mock 返回值
mock_article = MagicMock()
mock_article.users_like.filter.return_value.exists.return_value = True
mock_article.users_like.count.return_value = 0
mock_get_article.return_value = mock_article
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 断言调用了 remove 方法表示取消点赞
mock_article.users_like.remove.assert_called_once_with(self.user)
mock_article.users_like.add.assert_not_called()
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['type'], 0)
self.assertEqual(content['like_sum'], 0)
self.assertEqual(content['state'], 200)
@patch('blog.models.Article.objects.get')
def test_post_article_does_not_exist(self, mock_get_article):
"""
测试场景提供的文章 ID 不存在
输入
- 任意用户
- 不存在的文章 ID
输出
- state = 400 错误状态码
- data 包含文章不存在提示
"""
# 设置 mock 抛出 DoesNotExist 异常
mock_get_article.side_effect = Article.DoesNotExist
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': '999'})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['state'], 400)
self.assertIn("文章不存在", content['data'])
@patch('blog.models.Article.objects.get')
def test_post_internal_server_error(self, mock_get_article):
"""
测试场景系统内部发生异常
输入
- 任意用户
- 导致异常的操作如数据库连接失败等
输出
- state = 500 错误状态码
- data 包含具体异常描述
"""
# 设置 mock 抛出通用异常
mock_get_article.side_effect = Exception("数据库连接超时")
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['state'], 500)
self.assertIn("服务器错误", content['data'])
class LikeIntegrationTests(TestCase):
def setUp(self):
"""设置测试数据"""
self.client = Client()
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.other_user = BlogUser.objects.create_user(
username='otheruser',
email='other@example.com',
password='testpass123'
)
# 创建分类因为Article模型需要category字段
self.category = Category.objects.create(
name='测试分类',
slug='test-category'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.user,
category=self.category, # 必须提供category
# 其他必填字段使用默认值
status='p', # 发布状态
comment_status='o', # 开放评论
type='a', # 文章类型
article_order=0,
show_toc=False
)
def test_like_workflow(self):
"""测试完整的点赞流程"""
# 1. 用户登录
self.client.login(username='testuser', password='testpass123')
# 2. 发送点赞请求
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 3. 验证响应
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['type'], 1) # 点赞操作
# 4. 验证数据库状态
self.assertTrue(self.article.users_like.filter(id=self.user.id).exists())
self.assertEqual(self.article.users_like.count(), 1)
# 5. 测试取消点赞
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 6. 验证取消点赞
self.assertEqual(response.json()['type'], 0) # 取消点赞操作
self.assertFalse(self.article.users_like.filter(id=self.user.id).exists())
self.assertEqual(self.article.users_like.count(), 0)
def test_multiple_users_liking(self):
"""测试多个用户点赞同一篇文章"""
# 第一个用户点赞
self.client.login(username='testuser', password='testpass123')
self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 第二个用户点赞
self.client.login(username='otheruser', password='testpass123')
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 验证两个用户都点赞成功
self.assertEqual(self.article.users_like.count(), 2)
self.assertTrue(self.article.users_like.filter(id=self.user.id).exists())
self.assertTrue(self.article.users_like.filter(id=self.other_user.id).exists())
def test_like_nonexistent_article(self):
"""测试给不存在的文章点赞"""
self.client.login(username='testuser', password='testpass123')
response = self.client.post(
reverse('blog:like_article'),
{'article_id': 1145}, # 不存在的文章ID
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assertEqual(response.status_code, 400)
self.assertIn('文章不存在', response.json()['data'])
def test_like_without_login(self):
"""测试未登录用户点赞"""
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 应该重定向到登录页面或者返回错误
self.assertIn(response.status_code, [302, 403]) # 重定向或权限拒绝
def test_like_with_invalid_method(self):
"""测试使用错误的HTTP方法"""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(reverse('blog:like_article')) # 使用GET而不是POST
self.assertEqual(response.status_code, 405) # Method Not Allowed

@ -76,4 +76,8 @@ urlpatterns = [
r'clean',
views.clean_cache_view,
name='clean'),
path(
'like_article/',
views.LikeArticle.as_view(),
name='like_article'),
]

@ -5,13 +5,16 @@ import uuid
# bjy: 从Django中导入设置、分页器、HTTP响应、快捷函数、静态文件、时区、国际化、CSRF豁免和基于类的视图
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
@ -110,7 +113,7 @@ class IndexView(ArticleListView):
'''
首页
'''
# bjy: 首页的友情链接类型
# 友情链接类型
link_type = LinkShowType.I
# bjy: 实现父类的抽象方法,获取首页的文章数据
@ -360,6 +363,43 @@ class EsSearchView(SearchView):
return context
class LikeArticle(View):
"""
处理文章点赞和取消点赞
"""
@method_decorator(login_required) # 确保只有登录用户才能点赞
def post(self, request):
try:
user = request.user
article_id = request.POST.get('article_id') # 获取文章ID
article = Article.objects.get(id=article_id) # 获取文章对象
# 检查当前用户是否已经为这篇文章点过赞
if article.users_like.filter(id=user.id).exists():
# 如果点过赞,则取消点赞 (从多对多关系中移除)
article.users_like.remove(user)
action_type = 0 # 0代表取消点赞
else:
# 如果没点过赞,则添加点赞 (添加到多对多关系)
article.users_like.add(user)
action_type = 1 # 1代表点赞
# 获取更新后的点赞总数
like_count = article.users_like.count()
# 返回JSON数据给前端
return JsonResponse({
'state': 200,
'type': action_type,
'like_sum': like_count
})
except Article.DoesNotExist:
return JsonResponse({'state': 400, 'data': '文章不存在'})
except Exception as e:
return JsonResponse({'state': 500, 'data': f'服务器错误: {e}'})
# bjy: 文件上传视图使用csrf_exempt豁免CSRF验证
@csrf_exempt
def fileupload(request):

@ -1,49 +1,81 @@
# zy: 评论管理模块 - 配置Django后台管理中评论模型的显示和操作功能
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# zy: 禁用评论状态 - 管理员动作函数,用于批量禁用评论
def disable_commentstatus(modeladmin, request, queryset):
# zy: 将选中的评论集的is_enable字段更新为False禁用
queryset.update(is_enable=False)
# zy: 启用评论状态 - 管理员动作函数,用于批量启用评论
def enable_commentstatus(modeladmin, request, queryset):
# zy: 将选中的评论集的is_enable字段更新为True启用
queryset.update(is_enable=True)
# zy: 设置动作在后台显示的描述文字(支持国际化)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# zy: 评论管理类 - 自定义Django后台的评论管理界面
class CommentAdmin(admin.ModelAdmin):
# zy: 设置列表页每页显示20条评论
list_per_page = 20
# zy: 定义列表页显示的字段列
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
'id', # zy: 评论ID
'body', # zy: 评论内容
'link_to_userinfo', # zy: 自定义方法 - 用户信息链接
'link_to_article', # zy: 自定义方法 - 文章链接
'is_enable', # zy: 是否启用状态
'creation_time' # zy: 创建时间
)
# zy: 设置哪些字段可以作为链接点击进入编辑页
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
# zy: 设置右侧过滤侧边栏的过滤条件
list_filter = ('is_enable',) # zy: 按启用状态过滤
# zy: 排除在编辑表单中显示的字段(系统自动管理的字段)
exclude = ('creation_time', 'last_modify_time')
# zy: 定义批量操作动作列表
actions = [disable_commentstatus, enable_commentstatus]
# zy: 设置使用弹出窗口选择关联对象的字段(优化性能)
raw_id_fields = ('author', 'article')
search_fields = ('body',)
# zy: 设置可搜索的字段
search_fields = ('body',) # zy: 按评论内容搜索
# zy: 自定义方法 - 生成用户信息链接
def link_to_userinfo(self, obj):
# zy: 获取作者模型的app名称和模型名称
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# zy: 生成作者编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# zy: 返回HTML链接显示用户昵称或邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
# zy: 自定义方法 - 生成文章链接
def link_to_article(self, obj):
# zy: 获取文章模型的app名称和模型名称
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# zy: 生成文章编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# zy: 返回HTML链接显示文章标题
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# zy: 设置自定义方法在列表页显示的列标题(支持国际化)
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
link_to_article.short_description = _('Article')

@ -1,5 +1,8 @@
# zy: 评论应用配置模块 - 定义comments应用的配置信息
from django.apps import AppConfig
# zy: 评论应用配置类 - 继承自Django的AppConfig基类
class CommentsConfig(AppConfig):
# zy: 应用名称 - 指定Django内部使用的应用标识
# 这个名称应该与INSTALLED_APPS中的名称一致
name = 'comments'

@ -1,13 +1,17 @@
# zy: 评论表单模块 - 定义评论相关的表单类和验证逻辑
from django import forms
from django.forms import ModelForm
# zy: 导入评论模型,用于创建基于模型的自定义表单
from .models import Comment
# zy: 评论表单类 - 继承自ModelForm用于处理评论的创建和验证
class CommentForm(ModelForm):
# zy: 父评论ID字段 - 用于实现评论回复功能
# 这是一个隐藏字段,不在页面显示,用于记录回复的父评论
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
# zy: 表单元数据配置类 - 定义表单与模型的关联关系
class Meta:
model = Comment
fields = ['body']

@ -1,38 +1,44 @@
# zy: 评论数据模型模块 - 定义评论的数据结构和数据库表结构
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
# zy: 导入博客文章模型,用于建立评论与文章的关联
from blog.models import Article
# Create your models here.
# zy: 评论数据模型类 - 继承自Django的Model基类对应数据库中的评论表
class Comment(models.Model):
body = models.TextField('正文', max_length=300)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# zy: 作者外键字段 - 关联到用户模型,删除用户时级联删除其评论
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# zy: 文章外键字段 - 关联到博客文章模型,删除文章时级联删除相关评论
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# zy: 父评论自关联字段 - 实现评论回复功能,允许评论有父评论
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# zy: 启用状态字段 - 控制评论是否显示,用于评论审核机制
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# zy: 模型元数据配置类 - 定义模型的数据库和行为配置
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
ordering = ['-id'] # zy: 默认排序规则 - 按ID降序排列新的评论显示在前面
verbose_name = _('comment') # zy: 模型在Admin中的单数显示名称
verbose_name_plural = verbose_name # zy: 模型在Admin中的复数显示名称
get_latest_by = 'id'
def __str__(self):

@ -1,6 +1,7 @@
# zy: 评论模块测试文件 - 测试评论功能的各项业务逻辑
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
# zy: 导入相关模型类,用于测试数据准备
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
@ -8,102 +9,137 @@ from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
# zy: 评论功能测试类 - 测试评论的创建、验证、回复等功能
class CommentsTest(TransactionTestCase):
# zy: 测试初始化方法 - 在每个测试方法执行前运行,准备测试数据
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.client = Client() # zy: 创建测试客户端用于模拟HTTP请求
self.factory = RequestFactory() # zy: 创建请求工厂,用于构建请求对象
# zy: 配置博客设置,设置评论需要审核
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True
value.save()
# zy: 创建测试超级用户,用于登录和权限测试
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article):
# zy: 辅助方法 - 更新文章评论的审核状态为已通过
@staticmethod
def update_article_comment_status(article):
comments = article.comment_set.all()
for comment in comments:
comment.is_enable = True
comment.save()
# zy: 测试评论验证功能 - 测试评论的创建、回复和显示
def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1')
# zy: 创建测试分类
category = Category()
category.name = "categoryccc"
category.save()
# zy: 创建测试文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.author = self.user # zy: 设置文章作者为测试用户
article.category = category # zy: 设置文章分类
article.type = 'a' # zy: 设置文章类型
article.status = 'p' # zy: 设置文章状态为发布
article.save()
# zy: 获取发表评论的URL地址
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
# zy: 测试1: 发表第一条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
'body': '123ffffffffff' # zy: 评论内容
})
# zy: 断言响应状态码为302重定向表示评论提交成功
self.assertEqual(response.status_code, 302)
# zy: 重新从数据库获取文章对象,确保获取最新数据
article = Article.objects.get(pk=article.pk)
# zy: 断言评论列表为空(因为评论需要审核,默认不显示)
self.assertEqual(len(article.comment_list()), 0)
# zy: 更新评论状态为已审核通过
self.update_article_comment_status(article)
# zy: 断言现在评论列表中有1条评论
self.assertEqual(len(article.comment_list()), 1)
# zy: 测试2: 发表第二条评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
'body': '123ffffffffff', # zy: 第二条评论内容
})
# zy: 断言响应状态码为302重定向
self.assertEqual(response.status_code, 302)
# zy: 重新获取文章并更新评论状态
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
# zy: 断言现在有2条评论
self.assertEqual(len(article.comment_list()), 2)
# zy: 获取第一条评论的ID用于回复测试
parent_comment_id = article.comment_list()[0].id
# zy: 测试3: 发表带格式的回复评论
response = self.client.post(comment_url,
{
'body': '''
# Title1
# Title1
```python
import os
```
```python
import os
```
[url](https://www.lylinux.net/)
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
''', # zy: 测试包含Markdown格式的评论内容
'parent_comment_id': parent_comment_id # zy: 设置父评论ID表示这是回复
})
# zy: 断言响应状态码为302重定向
self.assertEqual(response.status_code, 302)
# zy: 更新评论状态并重新获取文章
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
# zy: 断言现在有3条评论2条顶级评论 + 1条回复
self.assertEqual(len(article.comment_list()), 3)
# zy: 测试评论树解析功能
comment = Comment.objects.get(id=parent_comment_id)
# zy: 解析评论树结构,检查回复关系
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
self.assertEqual(len(tree), 1) # zy: 断言评论树解析正确
# zy: 测试评论项显示功能
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
self.assertIsNotNone(data) # zy: 断言评论项数据不为空
# zy: 测试获取最大文章ID和评论ID的工具函数
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
self.assertIsNotNone(s) # zy: 断言返回值不为空
# zy: 测试评论邮件发送功能
from comments.utils import send_comment_email
send_comment_email(comment)
send_comment_email(comment) # zy: 发送评论通知邮件

@ -1,11 +1,17 @@
# zy: 评论模块URL配置 - 定义评论相关的URL路由和视图映射
from django.urls import path
# zy: 导入评论视图模块,包含评论相关的视图类
from . import views
# zy: 定义应用命名空间 - 用于URL反向解析时避免命名冲突
app_name = "comments"
# zy: URL模式列表 - 定义评论模块的所有URL路由规则
urlpatterns = [
# zy: 发表评论URL路由 - 处理用户发表评论的请求
path(
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
]
'article/<int:article_id>/postcomment', # zy: URL模式包含文章ID参数
views.CommentPostView.as_view(), # zy: 关联的基于类的视图
name='postcomment'), # zy: URL名称用于反向解析
]

@ -1,13 +1,14 @@
import logging
import logging# zy: 评论邮件通知模块 - 处理评论相关的邮件发送功能
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
from djangoblog.utils import get_current_site# zy: 导入项目工具函数,用于获取站点信息和发送邮件
from djangoblog.utils import send_email
# zy: 创建日志记录器,用于记录邮件发送过程中的错误
logger = logging.getLogger(__name__)
# zy: 发送评论邮件函数 - 处理评论发表和回复的邮件通知
def send_comment_email(comment):
site = get_current_site().domain
subject = _('Thanks for your comment')

@ -1,4 +1,4 @@
# Create your views here.
# zy: 评论视图模块 - 处理评论相关的视图逻辑和请求处理
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
@ -6,26 +6,30 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
# zy: 导入相关模型类
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
# zy: 评论发表视图类 - 基于FormView处理评论提交
class CommentPostView(FormView):
form_class = CommentForm
template_name = 'blog/article_detail.html'
# zy: 添加CSRF保护装饰器防止跨站请求伪造攻击
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs)
# zy: 处理GET请求 - 当用户直接访问评论URL时重定向到文章页面
def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments")
# zy: 表单验证失败时的处理逻辑
def form_invalid(self, form):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
@ -35,6 +39,7 @@ class CommentPostView(FormView):
'article': article
})
# zy: 表单验证成功时的处理逻辑 - 保存评论数据
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
user = self.request.user
@ -42,6 +47,7 @@ class CommentPostView(FormView):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
# zy: 检查文章是否允许评论
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
comment = form.save(False)
@ -52,6 +58,7 @@ class CommentPostView(FormView):
comment.is_enable = True
comment.author = author
# zy: 处理评论回复逻辑
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])

@ -1,3 +1,31 @@
"""`xjj`
DjangoBlog 管理站点配置
========
该模块定义了 DjangoBlog 项目的自定义管理站点配置它继承自 Django 的默认 AdminSite
并注册了项目中所有需要在管理后台进行管理的模型
主要功能
~~~~~~~~
- 创建自定义管理站点
- 设置管理站点的标题和头部显示文本
- 限制只有超级用户才能访问管理站点
- 注册各个应用中的模型到管理站点
注册的模型包括
~~~~~~~~
- 博客相关文章分类标签链接侧边栏博客设置
- 用户相关博客用户
- 评论相关评论
- OAuth 相关 OAuth 用户 OAuth 配置
- 其他命令邮件发送日志位置跟踪日志站点信息管理日志
使用方式
~~~~~~~~
通过 admin_site 实例来访问自定义的管理站点
"""
from django.contrib.admin import AdminSite
from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin
@ -18,13 +46,38 @@ from servermanager.models import *
class DjangoBlogAdminSite(AdminSite):
"""`xjj`
自定义 Django 管理站点类
继承自 Django 默认的 AdminSite 用于为 DjangoBlog 项目提供定制化的管理后台
"""
site_header = 'djangoblog administration'
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
"""`xjj`
初始化管理站点
Args:
name (str): 管理站点的名称默认为 'admin'
"""
super().__init__(name)
def has_permission(self, request):
"""`xjj`
检查用户是否有访问管理站点的权限
重写了父类的方法只允许超级用户访问管理站点
Args:
request: HTTP 请求对象
Returns:
bool: 如果用户是超级用户则返回 **True**否则返回 **False**
"""
return request.user.is_superuser
# def get_urls(self):

@ -1,10 +1,46 @@
"""`xjj`
Django 应用配置模块
========
该模块定义了 djangoblog 应用的配置类负责应用的初始化设置和插件加载
Django 项目启动时会自动调用配置类中的 ``ready()`` 方法来完成必要的初始化工作
包括加载自定义插件等操作
配置项说明
~~~~~~~~
- ``default_auto_field``: 设置默认的主键字段类型为 ``BigAutoField``
- ``name``: 指定应用名称为 'djangoblog'
初始化逻辑
~~~~~~~~
- ``ready()`` 方法中导入并执行插件加载函数 ``load_plugins()``
"""
from django.apps import AppConfig
class DjangoblogAppConfig(AppConfig):
"""`xjj`
Django 博客应用配置类
该类继承自 Django AppConfig 用于配置 jangoblog 应用的基本信息和初始化操作
主要负责设置默认的自动字段类型和应用名称并在应用准备就绪时加载插件
"""
default_auto_field = 'django.db.models.BigAutoField'
name = 'djangoblog'
def ready(self):
"""`xjj`
应用准备就绪时的回调方法
Django 应用完全加载后调用此方法主要用于执行应用级别的初始化操作
此方法会导入并加载所有已注册的插件
Returns:
None
"""
super().ready()
# Import and load plugins here
from .plugin_manage.loader import load_plugins

@ -1,3 +1,24 @@
"""`xjj`
博客信号处理模块
========
该模块负责处理 Django 博客应用中的各种信号包括
- 邮件发送信号处理
- OAuth 用户登录信号处理
- 模型保存后的回调处理
- 用户登录 / 登出事件处理
主要功能
~~~~~~~~
1. ``send_email_signal_handler``: 处理发送邮件信号支持 HTML 格式邮件发送并记录发送日志
2. ``oauth_user_login_signal_handler``: 处理 OAuth 用户登录包括用户头像保存等操作
3. ``model_post_save_callback``: 模型保存后的通用回调处理包括搜索引擎通知缓存清理等
4. ``user_auth_callback``: 用户认证状态变更处理主要负责清理侧边栏缓存
该模块通过 Django 信号机制实现解耦确保各功能模块的独立性
"""
import _thread
import logging
@ -25,6 +46,18 @@ send_email_signal = django.dispatch.Signal(
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
"""`xjj`
发送邮件信号的处理函数
Args:
sender: 信号发送者
**kwargs: 包含邮件相关信息的字典参数
- emailto (list): 收件人邮箱列表
- title (str): 邮件标题
- content (str): 邮件内容
"""
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
@ -53,6 +86,16 @@ def send_email_signal_handler(sender, **kwargs):
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
"""`xjj`
OAuth 用户登录信号处理函数
OAuth 用户登录时该函数会被触发执行相关处理逻辑包括更新用户头像和清除侧边栏缓存
Args:
sender: 信号发送者
**kwargs: 包含信号传递的额外参数必须包含 ``id`` 键表示 OAuth 用户的 ID
"""
id = kwargs['id']
oauthuser = OAuthUser.objects.get(id=id)
site = get_current_site().domain
@ -73,6 +116,19 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
"""`xjj`
模型保存后的回调函数用于处理缓存清理和搜索引擎通知等操作
Args:
sender: 发送信号的模型类
instance: 保存的模型实例
created: 布尔值指示实例是新建还是更新
raw: 布尔值指示实例是否从序列化数据加载
using: 数据库别名
update_fields: 更新字段集合如果为 None 表示所有字段都被更新
**kwargs: 其他关键字参数
"""
clearcache = False
if isinstance(instance, LogEntry):
return

@ -1,3 +1,24 @@
"""`xjj`
用于 Django 博客搜索的 Elasticsearch 后端
========
该模块实现了基于 Elasticsearch 的搜索后端用于博客文章的全文搜索功能
主要包含以下组件
- ``ElasticSearchBackend``: 核心搜索后端实现处理文档的创建更新删除和搜索操作
- ``ElasticSearchQuery``: 搜索查询处理类负责构建和执行搜索查询
- ``ElasticSearchModelSearchForm``: 搜索表单类处理用户搜索输入和选项
- ``ElasticSearchEngine``: 搜索引擎类整合后端和查询组件
功能特性
~~~~~~~~
- 支持文章内容和标题的全文搜索
- 提供搜索建议和拼写纠正功能
- 支持搜索结果分页和过滤
- 集成 Haystack 搜索框架
"""
from django.utils.encoding import force_str
from elasticsearch_dsl import Q
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
@ -12,6 +33,14 @@ logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
"""`xjj`
ElasticSearch 搜索后端实现类用于与 Elasticsearch 进行交互提供搜索更新删除等操作
:param connection_alias: 连接别名用于标识不同的搜索引擎连接
:param connection_options: 其他连接选项以关键字参数形式传入
"""
def __init__(self, connection_alias, **connection_options):
super(
ElasticSearchBackend,
@ -22,35 +51,81 @@ class ElasticSearchBackend(BaseSearchBackend):
self.include_spelling = True
def _get_models(self, iterable):
"""`xjj`
获取模型对象并转换为文档格式
:param iterable: 可迭代的模型对象列表若为空则默认获取所有 Article 对象
:return: 转换后的文档对象列表
"""
models = iterable if iterable and iterable[0] else Article.objects.all()
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
"""`xjj`
创建索引并将模型数据重建到 Elasticsearch
:param models: 模型对象列表
"""
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
def _delete(self, models):
"""`xjj`
删除指定模型对象
:param models: 待删除的模型对象列表
:return: 始终返回 True
"""
for m in models:
m.delete()
return True
def _rebuild(self, models):
"""`xjj`
重建指定模型的索引
:param models: 模型对象列表如果为空则默认获取所有 Article 对象
"""
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):
"""`xjj`
更新索引中的文档
:param index: 索引名称
:param iterable: 待更新的模型对象可迭代对象
:param commit: 是否提交更改
"""
models = self._get_models(iterable)
self.manager.update_docs(models)
def remove(self, obj_or_string):
"""`xjj`
从索引中移除指定对象
:param obj_or_string: 待移除的对象或字符串标识
"""
models = self._get_models([obj_or_string])
self._delete(models)
def clear(self, models=None, commit=True):
"""`xjj`
清空索引中的内容
:param models: 待清除的模型对象未使用
:param commit: 是否提交更改未使用
"""
self.remove(None)
@staticmethod
@ -73,6 +148,14 @@ class ElasticSearchBackend(BaseSearchBackend):
@log_query
def search(self, query_string, **kwargs):
"""`xjj`
执行搜索操作支持推荐词搜索和分页
:param query_string: 查询关键词字符串
:param kwargs: 其他参数包括 start_offset end_offset 用于分页
:return: 包含搜索结果命中数 facet 信息及拼写建议的字典
"""
logger.info('search query_string:' + query_string)
start_offset = kwargs.get('start_offset')
@ -124,6 +207,16 @@ class ElasticSearchBackend(BaseSearchBackend):
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
"""`xjj`
将日期时间对象转换为字符串格式
Args:
date: 日期时间对象
Returns:
str: 格式化后的日期时间字符串包含小时分钟秒或默认为 000000
"""
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
@ -155,23 +248,79 @@ class ElasticSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
"""`xjj`
构建查询片段
Args:
field: 查询字段
filter_type: 过滤器类型
value: 查询值对象
Returns:
str: 查询字符串
"""
return value.query_string
def get_count(self):
"""`xjj`
获取查询结果的数量
Returns:
int: 结果数量如果没有结果则返回0
"""
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
"""`xjj`
获取拼写建议
Args:
preferred_query (str, optional): 首选查询字符串
Returns:
str: 拼写建议
"""
return self._spelling_suggestion
def build_params(self, spelling_query=None):
"""`xjj`
构建查询参数
Args:
spelling_query (str, optional): 拼写查询字符串
Returns:
dict: 构建的查询参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""`xjj`
ElasticSearch 模型搜索表单类
该类继承自 ``ModelSearchForm`` 用于处理 ElasticSearch 相关的搜索功能
特别是支持建议搜索功能的配置
"""
def search(self):
"""`xjj`
执行搜索操作
该方法首先根据表单数据配置是否启用建议搜索功能
然后调用父类的 ``search`` 方法执行实际的搜索操作
Returns:
SearchQuerySet: 搜索结果查询集
"""
# 是否建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
sqs = super().search()
@ -179,5 +328,16 @@ class ElasticSearchModelSearchForm(ModelSearchForm):
class ElasticSearchEngine(BaseEngine):
"""`xjj`
ElasticSearch 搜索引擎引擎类
该类继承自 ``BaseEngine`` 用于实现 ElasticSearch 搜索引擎的核心功能
包含后端存储和查询处理的配置
Attributes:
backend: ElasticSearch 后端存储类用于数据的存储和检索
query: ElasticSearch 查询类用于构建和执行搜索查询
"""
backend = ElasticSearchBackend
query = ElasticSearchQuery

@ -1,3 +1,18 @@
"""`xjj`
DjangoBlog RSS 订阅模块
========
该模块实现了博客文章的 RSS 订阅功能基于 Django syndication 框架
提供文章标题内容发布链接等信息的 RSS 输出方便读者订阅博客更新
主要功能
~~~~~~~~
- 生成 RSS 2.0 格式的订阅源
- 提供最新的 5 篇已发布文章信息
- 支持文章标题内容作者版权等元数据展示
"""
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
from django.utils import timezone
@ -8,6 +23,13 @@ from djangoblog.utils import CommonMarkdown
class DjangoBlogFeed(Feed):
"""`xjj`
Django 博客 RSS 订阅源类
该类继承自 Django ``Feed`` 用于生成博客文章的 RSS 订阅源
提供博客文章的 RSS feed 功能包含文章标题描述作者信息等
"""
feed_type = Rss201rev2Feed
description = '大巧无工,重剑无锋.'
@ -15,25 +37,83 @@ class DjangoBlogFeed(Feed):
link = "/feed/"
def author_name(self):
"""`xjj`
获取 RSS 订阅源的作者名称
Returns:
str: 博客第一个用户的昵称如果不存在则返回 None
"""
return get_user_model().objects.first().nickname
def author_link(self):
"""`xjj`
获取 RSS 订阅源作者的链接地址
Returns:
str: 博客第一个用户的绝对 URL 地址
"""
return get_user_model().objects.first().get_absolute_url()
def items(self):
"""`xjj`
获取 RSS 订阅源的文章列表
Returns:
QuerySet: 按发布时间倒序排列的前 5 篇已发布文章
"""
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
def item_title(self, item):
"""`xjj`
获取单篇文章的标题
Args:
item (Article): 文章对象
Returns:
str: 文章的标题
"""
return item.title
def item_description(self, item):
"""`xjj`
获取单篇文章的描述内容
Args:
item (Article): 文章对象
Returns:
str: 文章正文内容转换为 Markdown 格式后的 HTML
"""
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
"""`xjj`
获取 RSS 订阅源的版权信息
Returns:
str: 包含当前年份的版权声明
"""
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
"""`xjj`
获取单篇文章的链接地址
Args:
item (Article): 文章对象
Returns:
str: 文章的绝对URL地址
"""
return item.get_absolute_url()
def item_guid(self, item):

@ -1,3 +1,20 @@
"""`xjj`
Django Admin 日志条目管理模块
========
该模块提供了对 Django 管理后台操作日志的可视化管理界面
主要功能包括
- 显示所有管理操作的日志记录包括创建修改和删除操作
- 提供日志记录的搜索和过滤功能
- 在列表中显示指向相关对象和用户的链接
- 限制权限以防止意外修改日志数据
该模块扩展了 Django 的内置 LogEntry 模型管理界面
增强了日志查看体验并保护日志不被修改
"""
from django.contrib import admin
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
@ -9,6 +26,12 @@ from django.utils.translation import gettext_lazy as _
class LogEntryAdmin(admin.ModelAdmin):
"""`xjj`
管理后台中用于展示和管理日志条目 LogEntry ``ModelAdmin``
该类自定义了日志条目的展示方式过滤条件搜索字段以及权限控制等
"""
list_filter = [
'content_type'
]
@ -34,6 +57,17 @@ class LogEntryAdmin(admin.ModelAdmin):
return False
def has_change_permission(self, request, obj=None):
"""`xjj`
控制用户是否有权限修改日志条目
Args:
request (HttpRequest): 当前的 HTTP 请求对象
obj (LogEntry, optional): 要检查权限的具体日志条目对象
Returns:
bool: 如果用户是超级用户或具有 ``admin.change_logentry`` 权限并且请求方法不是 **POST** 则返回 **True**
"""
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
@ -43,6 +77,16 @@ class LogEntryAdmin(admin.ModelAdmin):
return False
def object_link(self, obj):
"""`xjj`
生成一个指向相关对象的链接如果可能否则返回对象的字符串表示
Args:
obj (LogEntry): 日志条目实例
Return:
str: 包含HTML链接或纯文本的对象表示
"""
object_link = escape(obj.object_repr)
content_type = obj.content_type
@ -63,6 +107,16 @@ class LogEntryAdmin(admin.ModelAdmin):
object_link.short_description = _('object')
def user_link(self, obj):
"""`xjj`
生成一个指向用户编辑页面的链接
Args:
obj (LogEntry): 日志条目实例
Returns:
str: 包含HTML链接的用户名称
"""
content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user))
try:
@ -81,10 +135,30 @@ class LogEntryAdmin(admin.ModelAdmin):
user_link.short_description = _('user')
def get_queryset(self, request):
"""`xjj`
获取查询集并进行优化预加载 ``content_type`` 以减少数据库查询
Args:
request (HttpRequest): 当前的 HTTP 请求对象
Returns:
QuerySet: 经过 ``prefetch_related`` 优化的查询集
"""
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
def get_actions(self, request):
"""`xjj`
获取可用的操作列表并移除删除选中项操作
Args:
request (HttpRequest): 当前的 HTTP 请求对象
Returns:
dict: 可用的操作字典
"""
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']

@ -15,8 +15,24 @@ from pathlib import Path
from django.utils.translation import gettext_lazy as _
import environ
from dotenv import load_dotenv
load_dotenv()
env = environ.Env()
def env_to_bool(env, default):
"""`xjj`
将环境变量转换为布尔值
Args:
env (str): 环境变量名称
default (bool): 当环境变量不存在时返回的默认值
Returns:
bool: 环境变量的布尔值表示如果环境变量不存在则返回默认值
"""
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
@ -106,18 +122,29 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application'
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog',
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
'charset': 'utf8mb4'},
}}
if TESTING:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': env('DJANGO_MYSQL_DATABASE'),
'USER': env('DJANGO_MYSQL_USER'),
'PASSWORD': env('DJANGO_MYSQL_PASSWORD'),
'HOST': env('DJANGO_MYSQL_HOST'),
'PORT': int(
env('DJANGO_MYSQL_PORT')),
'OPTIONS': {
'charset': 'utf8mb4',
'ssl_mode': 'VERIFY_IDENTITY',
'ssl': {'ca': env('DJANGO_MYSQL_SSL_CA')}
},
}}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

@ -1,3 +1,21 @@
"""`xjj`
Django博客站点地图模块
========
该模块定义了网站的 sitemap 配置用于帮助搜索引擎更好地抓取网站内容
包含了以下几种类型的sitemap
1. 静态页面站点地图 (``StaticViewSitemap``) - 包含网站首页等静态页面
2. 文章站点地图 (``ArticleSiteMap``) - 包含所有已发布的文章页面
3. 分类站点地图 (``CategorySiteMap``) - 包含所有文章分类页面
4. 标签站点地图 (``TagSiteMap``) - 包含所有标签页面
5. 用户站点地图 (``UserSiteMap``) - 包含所有作者页面
每个站点地图类都继承自Django的Sitemap基类并根据不同的内容类型设置了相应的
更新频率 (``changefreq``) 和优先级 (``priority``) 参数
"""
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
@ -5,55 +23,182 @@ from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
"""`xjj`
静态视图站点地图类
~~~~~~~~
该类用于生成静态页面的 sitemap 信息继承自 Django ``Sitemap``
"""
priority = 0.5
changefreq = 'daily'
def items(self):
"""`xjj`
获取站点地图项列表
返回包含 URL 名称的列表这些 URL 将被包含在 sitemap
Returns:
list: 包含 URL 名称的列表
"""
return ['blog:index', ]
def location(self, item):
"""`xjj`
根据 URL 名称获取实际的 URL 路径
通过 Django ``reverse`` 函数将 URL 名称转换为实际的 URL 路径
Args:
item (str): URL 名称
Returns:
str: 对应的 URL 路径
"""
return reverse(item)
class ArticleSiteMap(Sitemap):
"""`xjj`
文章站点地图类用于生成文章页面的 ``sitemap.xml`` 文件
该类继承自 Django ``Sitemap`` 定义了文章页面在站点地图中的相关属性
和数据获取方法便于搜索引擎爬虫发现和索引文章内容
"""
changefreq = "monthly"
priority = "0.6"
def items(self):
"""`xjj`
获取 sitemap 中包含的所有文章对象列表
Returns:
QuerySet: 包含所有已发布状态文章的查询集
"""
return Article.objects.filter(status='p')
def lastmod(self, obj):
"""`xjj`
获取指定文章对象的最后修改时间
Args:
obj: 文章对象实例
Returns:
datetime: 文章的最后修改时间
"""
return obj.last_modify_time
class CategorySiteMap(Sitemap):
"""`xjj`
分类站点地图类
~~~~~~~~
该类用于生成网站分类页面的 sitemap 信息继承自 Django ``Sitemap``
"""
changefreq = "Weekly"
priority = "0.6"
def items(self):
"""`xjj`
获取所有分类对象
Returns:
QuerySet: 包含所有 Category 对象的查询集
"""
return Category.objects.all()
def lastmod(self, obj):
"""`xjj`
获取分类对象的最后修改时间
Args:
obj (Category): 分类对象实例
Returns:
datetime: 分类的最后修改时间
"""
return obj.last_modify_time
class TagSiteMap(Sitemap):
"""`xjj`
标签站点地图类用于生成标签页面的 sitemap.xml 文件
该类继承自 Django Sitemap 定义了标签页面在站点地图中的相关属性
和数据获取方法
"""
changefreq = "Weekly"
priority = "0.3"
def items(self):
"""`xjj`
获取所有标签对象列表
Returns:
QuerySet: 包含所有 Tag 对象的查询集
"""
return Tag.objects.all()
def lastmod(self, obj):
"""`xjj`
获取指定标签对象的最后修改时间
Args:
obj (Tag): 标签对象实例
Returns:
datetime: 标签对象的最后修改时间
"""
return obj.last_modify_time
class UserSiteMap(Sitemap):
"""`xjj`
用户站点地图类用于生成用户页面的 sitemap.xml 文件
Attributes:
changefreq (str): 页面更新频率设置为 `Weekly`
priority (str): 页面优先级设置为 `0.3`
"""
changefreq = "Weekly"
priority = "0.3"
def items(self):
"""`xjj`
获取所有需要包含在 sitemap 中的用户对象列表
通过获取所有文章的作者来构建用户列表并去重
Returns:
list: 包含所有文章作者用户的去重列表
"""
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
"""`xjj`
获取指定用户对象的最后修改时间
Args:
obj: 用户对象
Returns:
datetime: 用户的注册时间作为最后修改时间
"""
return obj.date_joined

@ -1,3 +1,23 @@
"""`xjj`
Spider Notify Module
========
此模块提供了向搜索引擎通知网站更新的功能主要用于 SEO 优化
当前支持百度搜索引擎的主动推送功能
主要功能
~~~~~~~~
- 向百度搜索引擎主动推送网站URL更新
- 记录推送结果和异常日志
依赖
~~~~~~~~
- ``requests``: 用于发送 HTTP 请求
- ``django.conf.settings``: 获取配置参数
- ``logging``: 记录日志信息
"""
import logging
import requests
@ -7,8 +27,18 @@ logger = logging.getLogger(__name__)
class SpiderNotify():
"""`xjj`
爬虫通知类用于向搜索引擎提交 URL 通知
"""
@staticmethod
def baidu_notify(urls):
"""`xjj`
向百度搜索引擎提交 URL 通知
:param urls: 要提交的 URL 列表
"""
try:
data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
@ -18,4 +48,10 @@ class SpiderNotify():
@staticmethod
def notify(url):
"""`xjj`
通用通知方法调用百度通知接口
:param url: 要通知的 URL 列表
"""
SpiderNotify.baidu_notify(url)

@ -1,13 +1,46 @@
"""`xjj`
DjangoBlog 测试模块
========
该模块包含对 DjangoBlog 应用各个功能组件的单元测试
主要用于验证工具函数模型视图等的正确性和稳定性
当前测试类:
- ``DjangoBlogTest``: 主要测试工具函数的功能正确性
测试覆盖的工具函数包括:
- ``get_sha256``: 测试 SHA256 哈希值生成功能
- ``CommonMarkdown.get_markdown``: 测试 Markdown 文本转换为 HTML 的功能
- ``parse_dict_to_url``: 测试字典转换为 URL 查询参数字符串的功能
"""
from django.test import TestCase
from djangoblog.utils import *
class DjangoBlogTest(TestCase):
"""`xjj`
Django 博客测试类
用于测试博客应用的各种功能模块
继承自 Django ``TestCase``
"""
def setUp(self):
pass
def test_utils(self):
"""`xjj`
测试工具函数功能
验证以下功能
1. SHA256哈希函数
2. Markdown文本转换功能
3. 字典转URL参数功能
"""
md5 = get_sha256('test')
self.assertIsNotNone(md5)
c = CommonMarkdown.get_markdown('''

@ -1,5 +1,21 @@
#!/usr/bin/env python
# encoding: utf-8
"""`xjj`
DjangoBlog 工具模块
========
本模块包含项目中使用的各种通用工具函数和辅助类主要功能包括
- 缓存装饰器和缓存管理
- Markdown 内容转换和处理
- 邮件发送功能
- 网站设置管理
- 用户头像处理
- HTML安全过滤
- URL和数据处理工具
该模块为整个 DjangoBlog 项目提供基础工具支持被多个应用组件调用
"""
import logging
@ -21,19 +37,73 @@ logger = logging.getLogger(__name__)
def get_max_articleid_commentid():
"""`xjj`
获取最新的文章 ID 和评论 ID
该函数从数据库中查询最新创建的文章和评论记录
并返回它们的主键值
Returns:
tuple: 包含两个元素的元组
- 第一个元素是最新文章的主键 (pk)
- 第二个元素是最新评论的主键 (pk)
"""
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
"""`xjj`
计算字符串的 SHA256 哈希值
Args:
str (str): 需要计算哈希值的输入字符串
Returns:
str: 输入字符串的 SHA256 哈希值以十六进制字符串形式表示
"""
m = sha256(str.encode('utf-8'))
return m.hexdigest()
def cache_decorator(expiration=3 * 60):
"""`xjj`
缓存装饰器工厂函数用于创建具有指定过期时间的缓存装饰器
Args:
expiration (int): 缓存过期时间单位为秒默认为 3 分钟
Returns:
function: 返回一个装饰器函数 ``wrapper``
"""
def wrapper(func):
"""`xjj`
装饰器函数用于包装需要缓存的函数
Args:
func (function): 被装饰的函数
Returns:
function: 返回包装后的函数 ``news``
"""
def news(*args, **kwargs):
"""`xjj`
实际执行缓存逻辑的函数
Args:
*args: 被装饰函数的位置参数
**kwargs: 被装饰函数的关键字参数
Returns:
任意类型: 返回被装饰函数的执行结果或缓存的结果
"""
try:
view = args[0]
key = view.get_cache_key()
@ -94,13 +164,47 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
@cache_decorator()
def get_current_site():
"""
获取当前站点信息
该函数通过 Django Sites 框架获取当前站点对象并使用缓存装饰器进行性能优化
函数会自动识别请求上下文中的站点信息避免重复查询数据库
Returns:
Site: 返回当前站点对象包含站点的基本信息如域名名称等
注意:
- 依赖 Django Sites 框架
- 使用了缓存机制提高性能
- 需要确保 SITE_ID 设置正确
"""
site = Site.objects.get_current()
return site
class CommonMarkdown:
"""`xjj`
Markdown 处理工具类
~~~~~~~~
提供 Markdown 文本转换为 HTML 的功能支持目录生成代码高亮等扩展功能
"""
@staticmethod
def _convert_markdown(value):
"""`xjj`
Markdown 文本转换为 HTML 并生成目录
Args:
value (str): 需要转换的 Markdown 格式文本
Returns:
tuple: 包含两个元素的元组
- str: 转换后的 HTML 正文内容
- str: 生成的目录 HTML 内容
"""
md = markdown.Markdown(
extensions=[
'extra',
@ -115,16 +219,50 @@ class CommonMarkdown:
@staticmethod
def get_markdown_with_toc(value):
"""`xjj`
转换 Markdown 文本并返回正文和目录
Args:
value (str): 需要转换的 Markdown 格式文本
Returns:
tuple: 包含两个元素的元组
- str: 转换后的 HTML 正文内容
- str: 生成的目录 HTML 内容
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@staticmethod
def get_markdown(value):
"""`xjj`
转换 Markdown 文本并只返回正文内容
Args:
value (str): 需要转换的 Markdown 格式文本
Returns:
str: 转换后的 HTML 正文内容
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
"""`xjj`
发送邮件函数
该函数通过触发 Django 信号来发送邮件实际的邮件发送逻辑在信号处理器中实现
Args:
emailto (str): 收件人邮箱地址
title (str): 邮件标题
content (str): 邮件内容
"""
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
send_email.__class__,
@ -139,6 +277,16 @@ def generate_code() -> str:
def parse_dict_to_url(dict):
"""`xjj`
将字典转换为 URL 查询字符串格式
Args:
dict (dict): 包含键值对的字典用于生成URL查询参数
Returns:
str: 格式化后的 URL 查询字符串格式为 `key1=value1&key2=value2...`
"""
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
@ -146,6 +294,16 @@ def parse_dict_to_url(dict):
def get_blog_setting():
"""`xjj`
获取博客设置信息
该函数首先尝试从缓存中获取博客设置如果缓存中不存在则从数据库中获取
如果数据库中没有博客设置记录则创建一条默认配置记录
Returns:
BlogSettings: 博客设置对象包含网站名称描述 SEO 设置文章显示配置等信息
"""
value = cache.get('get_blog_setting')
if value:
return value
@ -202,6 +360,13 @@ def save_user_avatar(url):
def delete_sidebar_cache():
"""`xjj`
删除侧边栏缓存
该函数用于删除所有侧边栏相关的缓存数据通过遍历 ``LinkShowType`` 枚举值
构造对应的缓存键名并逐一删除
"""
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
@ -210,12 +375,30 @@ def delete_sidebar_cache():
def delete_view_cache(prefix, keys):
"""`xjj`
删除视图缓存
该函数用于删除 Django 模板片段缓存通过构建缓存键并从缓存中删除对应的数据
Args:
prefix (str): 模板片段缓存的前缀名称
keys (list): 用于生成缓存键的变量列表
"""
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
def get_resource_url():
"""`xjj`
获取资源文件的 URL 地址
该函数优先返回配置中的静态资源 URL 如果未配置则根据当前站点信息构造默认的静态资源 URL
Returns:
str: 静态资源的完整 URL 地址
"""
if settings.STATIC_URL:
return settings.STATIC_URL
else:
@ -236,6 +419,20 @@ ALLOWED_CLASSES = [
]
def class_filter(tag, name, value):
"""`xjj`
自定义 class 属性过滤器
该函数用于过滤 HTML 标签的 class 属性值只允许预定义的安全 class 值通过
Args:
tag: HTML 标签对象
name: 属性名称
value: 属性值
Returns:
str | Any : 过滤后的 class 属性值如果没有任何允许的 class 则返回 **False**如果不是 class 属性则返回原始值
"""
"""自定义class属性过滤器"""
if name == 'class':
# 只允许预定义的安全class值

@ -1,4 +1,25 @@
# encoding: utf-8
"""`xjj`
Whoosh 中文搜索后端模块
该模块为 Django Haystack 提供了一个基于 Whoosh 的中文搜索引擎后端实现
主要特性包括
- 支持中文分词搜索使用 jieba 分词器
- 实现了完整的搜索后端功能包括索引创建更新删除和查询
- 支持多种数据类型的字段映射文本数字日期布尔值等
- 提供搜索结果高亮显示功能
- 支持搜索建议和拼写纠正
- 支持多模型搜索和过滤
- 实现了 more_like_this 相似文档查找功能
依赖
~~~~~~~~
- Whoosh >= 2.5.0
- jieba (用于中文分词)
- Django Haystack
"""
from __future__ import absolute_import, division, print_function, unicode_literals
@ -145,6 +166,18 @@ class WhooshSearchBackend(BaseSearchBackend):
self.setup_complete = True
def build_schema(self, fields):
"""`xjj`
构建用于搜索索引的 Whoosh Schema 对象
根据传入的字段定义创建对应的 Whoosh 字段类型并最终构建一个完整的 Schema 实例
:param fields: 包含字段名与字段配置对象的字典键是字段名称值是一个具有 field_typestoredboost 等属性的对象
:return: 第一个元素是文档内容字段名第二个是构建好的 Whoosh Schema 对象
:exception SearchBackendError: 当没有找到任何字段时抛出异常
"""
schema_fields = {
ID: WHOOSH_ID(stored=True, unique=True),
DJANGO_CT: WHOOSH_ID(stored=True),
@ -200,6 +233,17 @@ class WhooshSearchBackend(BaseSearchBackend):
return (content_field_name, Schema(**schema_fields))
def update(self, index, iterable, commit=True):
"""`xjj`
更新索引中的文档数据
遍历可迭代对象中的模型实例将其转换成可用于索引的数据格式并更新到索引中
Args:
index (SearchIndex): Haystack 的索引类实例
iterable (iterable): 要被索引的模型实例集合
commit (bool): 是否在更新后提交更改默认为 **True**
"""
if not self.setup_complete:
self.setup()
@ -245,6 +289,14 @@ class WhooshSearchBackend(BaseSearchBackend):
writer.commit()
def remove(self, obj_or_string, commit=True):
"""`xjj`
从索引中删除指定文档
Args:
obj_or_string (Model | str): 要删除的模型实例或其唯一标识符字符串
commit (bool): 是否立即提交更改默认为 **True**
"""
if not self.setup_complete:
self.setup()
@ -267,6 +319,14 @@ class WhooshSearchBackend(BaseSearchBackend):
exc_info=True)
def clear(self, models=None, commit=True):
"""`xjj`
清除整个索引或特定模型类型的索引数据
Args:
models (list | tuple): 可选要清除的模型列表
commit (bool): 是否立即提交更改默认为 **True**
"""
if not self.setup_complete:
self.setup()
@ -304,6 +364,10 @@ class WhooshSearchBackend(BaseSearchBackend):
"Failed to clear Whoosh index: %s", e, exc_info=True)
def delete_index(self):
"""`xjj`
完全删除当前索引目录下的文件并重新初始化索引结构
"""
# Per the Whoosh mailing list, if wiping out everything from the index,
# it's much more efficient to simply delete the index files.
if self.use_file_storage and os.path.exists(self.path):
@ -315,6 +379,10 @@ class WhooshSearchBackend(BaseSearchBackend):
self.setup()
def optimize(self):
"""`xjj`
对现有索引执行优化操作提升查询性能
"""
if not self.setup_complete:
self.setup()
@ -322,6 +390,17 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index.optimize()
def calculate_page(self, start_offset=0, end_offset=None):
"""`xjj`
根据偏移量计算分页信息
Args:
start_offset (int): 查询起始位置
end_offset (int): 查询结束位置
Returns:
tuple: 分别表示页码和每页长度
"""
# Prevent against Whoosh throwing an error. Requires an end_offset
# greater than 0.
if end_offset is not None and end_offset <= 0:
@ -366,6 +445,33 @@ class WhooshSearchBackend(BaseSearchBackend):
limit_to_registered_models=None,
result_class=None,
**kwargs):
"""`xjj`
执行全文检索操作
Args:
query_string (str): 用户输入的查询语句
sort_by (list): 排序字段列表
start_offset (int): 结果偏移起点
end_offset (int): 结果偏移终点
fields (str): 指定返回字段
highlight (bool): 是否启用高亮显示
facets (dict): 分面统计参数
date_facets (dict): 时间范围分面参数
query_facets (dict): 自定义查询分面参数
narrow_queries (set): 过滤条件集合
spelling_query (str): 用于拼写建议的原始查询
within (tuple): 地理位置过滤参数
dwithin (tuple): 距离范围内地理查询参数
distance_point (tuple): 中心点坐标
models (list): 限定搜索模型列表
limit_to_registered_models (bool): 是否仅限注册过的模型
result_class (class): 自定义结果封装类
**kwargs: 其他扩展参数
Returns:
dict: 包括匹配结果命中数拼写建议等信息
"""
if not self.setup_complete:
self.setup()
@ -570,6 +676,23 @@ class WhooshSearchBackend(BaseSearchBackend):
limit_to_registered_models=None,
result_class=None,
**kwargs):
"""`xjj`
查找与给定模型实例相似的内容
Args:
model_instance (Model): 目标模型实例
additional_query_string (str): 补充查询语句
start_offset (int): 起始偏移
end_offset (int): 终止偏移
models (list): 限制查找的模型列表
limit_to_registered_models (bool): 是否只考虑已注册模型
result_class (class): 自定义结果类
**kwargs: 扩展参数
Returns:
dict: 包含相似文档的结果集
"""
if not self.setup_complete:
self.setup()
@ -682,6 +805,20 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string='',
spelling_query=None,
result_class=None):
"""`xjj`
处理原始搜索结果转换为标准输出格式
Args:
raw_page (ResultsPage): 来自 Whoosh 的原始页面结果
highlight (bool): 是否需要高亮关键词
query_string (str): 查询语句
spelling_query (str): 用于拼写的查询语句
result_class (class): 自定义结果包装类
Returns:
dict: 标准化的搜索响应结构
"""
from haystack import connections
results = []
@ -768,6 +905,16 @@ class WhooshSearchBackend(BaseSearchBackend):
}
def create_spelling_suggestion(self, query_string):
"""`xjj`
生成针对用户查询的拼写修正建议
Args:
query_string (str): 输入的查询语句
Returns:
str: 拼写修正后的建议语句
"""
spelling_suggestion = None
reader = self.index.reader()
corrector = reader.corrector(self.content_field_name)
@ -872,6 +1019,16 @@ class WhooshSearchBackend(BaseSearchBackend):
class WhooshSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
"""`xjj`
将日期时间对象转换为 Whoosh 可识别的字符串格式
Args:
date: 日期或日期时间对象
Returns:
str: 格式化后的日期时间字符串格式为 `YYYYMMDDHHMMSS`
"""
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
@ -903,6 +1060,20 @@ class WhooshSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
"""`xjj`
构建单个查询片段
根据字段名过滤类型和值构建相应的查询表达式片段用于组合成完整的搜索查询
Args:
field (str): 要查询的字段名
filter_type (str): 过滤类型 `contains` `exact` `startswith`
value: 要查询的值可以是字符串列表或其他数据类型
Returns:
str: 构建好的查询片段字符串
"""
from haystack import connections
query_frag = ''
is_datetime = False
@ -1040,5 +1211,16 @@ class WhooshSearchQuery(BaseSearchQuery):
class WhooshEngine(BaseEngine):
"""`xjj`
Whoosh 搜索引擎引擎类
该类继承自 ``BaseEngine``用于集成 Whoosh 搜索功能
包含搜索后端和查询处理的相关配置
Attributes:
backend: 搜索后端类设置为 `WhooshSearchBackend`
query: 查询类设置为 `WhooshSearchQuery`
"""
backend = WhooshSearchBackend
query = WhooshSearchQuery

@ -0,0 +1,37 @@
import json
import subprocess
import sys
from datetime import datetime
def generate_quality_report():
report = {
'project': 'OAuth Module',
'analysis_date': datetime.now().isoformat(),
'tools_used': ['flake8', 'pylint'],
'summary': {}
}
# Flake8 分析
result = subprocess.run([
sys.executable, '-m', 'flake8', 'oauth/', '--max-line-length=120', '--statistics', '--exit-zero'
], capture_output=True, text=True)
report['flake8_issues'] = result.stdout.strip().split('\n')
# Pylint 分析
result = subprocess.run([
sys.executable, '-m', 'pylint', 'oauth/', '--output-format=json', '--exit-zero'
], capture_output=True, text=True)
try:
report['pylint_issues'] = json.loads(result.stdout)
except:
report['pylint_issues'] = result.stdout
# 保存报告
with open('oauth_quality_report.json', 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print('代码质量报告已生成: oauth_quality_report.json')
generate_quality_report()

@ -1,3 +1,9 @@
"""
lrj
OAuth 认证后台管理模块
配置Django Admin后台中OAuth相关模型的显示和操作方式
"""
import logging
from django.contrib import admin
@ -5,50 +11,128 @@ from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
# 配置日志记录器
logger = logging.getLogger(__name__)
class OAuthUserAdmin(admin.ModelAdmin):
"""
lrj
OAuth用户模型后台管理配置类
自定义OAuthUser模型在Django Admin中的显示和行为
"""
# lrj搜索字段配置支持按昵称和邮箱搜索
search_fields = ('nickname', 'email')
# 列表页每页显示记录数
list_per_page = 20
# 列表页显示的字段
list_display = (
'id',
'nickname',
'link_to_usermodel',
'show_user_image',
'type',
'email',
'id', # 用户ID
'nickname', # 用户昵称
'link_to_usermodel', # lrj关联用户链接自定义方法
'show_user_image', #lrj 用户头像显示(自定义方法)
'type', # lrjOAuth平台类型
'email', # lrj用户邮箱
)
# lrj列表页中可点击进入编辑页的字段
list_display_links = ('id', 'nickname')
# lrj右侧过滤器配置支持按关联用户和平台类型过滤
list_filter = ('author', 'type',)
# lrj只读字段列表初始为空
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
"""lrj
动态获取只读字段列表
确保在编辑页所有字段都为只读防止误操作
Args:
request: HTTP请求对象
obj: 模型实例对象
Returns:
list: 只读字段列表
"""
# 将所有模型字段和多对多字段都设为只读
return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request):
"""lrj
禁用添加权限
OAuth用户只能通过OAuth流程自动创建不能手动添加
Args:
request: HTTP请求对象
Returns:
bool: 是否允许添加这里始终返回False
"""
return False
def link_to_usermodel(self, obj):
"""lrj
显示关联用户链接的自定义方法
在列表页显示关联的本站用户链接
Args:
obj: OAuthUser模型实例
Returns:
str: 格式化的HTML链接或None
"""
if obj.author:
# lrj构建关联用户的管理后台编辑链接
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# lrj返回格式化的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
"""lrj
显示用户头像的自定义方法
在列表页以缩略图形式显示用户头像
Args:
obj: OAuthUser模型实例
Returns:
str: 格式化的HTML图片标签
"""
img = obj.picture # 获取头像URL
return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# 设置自定义方法在Admin中的显示名称
link_to_usermodel.short_description = '用户' # 关联用户列标题
show_user_image.short_description = '用户头像' # 用户头像列标题
class OAuthConfigAdmin(admin.ModelAdmin):
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
"""lrj
OAuth配置模型后台管理配置类
自定义OAuthConfig模型在Django Admin中的显示
"""
# lrj列表页显示的字段
list_display = (
'type', # lrj平台类型
'appkey', # lrj应用Key
'appsecret', # lrj应用Secret
'is_enable' # lrj是否启用
)
# lrj右侧过滤器配置支持按平台类型过滤
list_filter = ('type',)

@ -3,3 +3,4 @@ from django.apps import AppConfig
class OauthConfig(AppConfig):
name = 'oauth'

@ -1,12 +1,40 @@
"""lrj
OAuth 认证表单模块
定义OAuth认证过程中使用的Django表单类
"""
from django.contrib.auth.forms import forms
from django.forms import widgets
class RequireEmailForm(forms.Form):
"""lrj
邮箱补充表单类
当OAuth用户没有提供邮箱时用于收集用户邮箱信息
"""
# lrj邮箱字段必需字段标签为'电子邮箱'
email = forms.EmailField(label='电子邮箱', required=True)
# lrjOAuth用户ID字段隐藏字段非必需用于关联OAuth用户记录
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
def __init__(self, *args, **kwargs):
"""lrj
表单初始化方法
自定义表单字段的widget属性添加CSS类和占位符
Args:
*args: 位置参数
**kwargs: 关键字参数
"""
#lrj调用父类初始化方法
super(RequireEmailForm, self).__init__(*args, **kwargs)
# lrj自定义邮箱字段的widget添加Bootstrap样式和占位符
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={
'placeholder': "email", # lrj输入框占位符文本
"class": "form-control" # lrjBootstrap表单控件CSS类
})

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2025-11-11 10:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oauth', '0003_alter_oauthuser_nickname'),
]
operations = [
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'OAuth配置', 'verbose_name_plural': 'OAuth配置'},
),
]

@ -1,4 +1,8 @@
# Create your models here.
"""lrj
OAuth 认证模块数据模型定义
提供第三方登录用户信息和配置管理的数据结构
"""
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
@ -7,61 +11,114 @@ from django.utils.translation import gettext_lazy as _
class OAuthUser(models.Model):
"""lrj
OAuth 第三方登录用户信息模型
存储通过第三方平台(微博GitHub等)登录的用户信息
"""
# lrj关联本站用户可为空(用户首次第三方登录时尚未绑定本站账号)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
verbose_name=_('author'), # lrj翻译作者
blank=True,
null=True,
on_delete=models.CASCADE)
on_delete=models.CASCADE) # lrj级联删除本站用户删除时同步删除OAuth关联
# lrj第三方平台的用户唯一标识
openid = models.CharField(max_length=50)
# lrj第三方平台的用户昵称
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
# lrjOAuth访问令牌用于调用第三方平台API
token = models.CharField(max_length=150, null=True, blank=True)
# lrj用户头像URL
picture = models.CharField(max_length=350, blank=True, null=True)
# lrj第三方平台类型weibo, github, google等
type = models.CharField(blank=False, null=False, max_length=50)
# lrj用户邮箱(从第三方平台获取)
email = models.CharField(max_length=50, null=True, blank=True)
# lrj原始元数据存储从第三方平台返回的完整用户信息(JSON格式)
metadata = models.TextField(null=True, blank=True)
# lrj记录创建时间自动设置为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
#lrj 最后修改时间,自动更新为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self):
"""lrj管理员界面显示的用户标识"""
return self.nickname
class Meta:
verbose_name = _('oauth user')
verbose_name_plural = verbose_name
ordering = ['-creation_time']
"""lrj模型元数据配置"""
verbose_name = _('oauth user') # lrj单数显示名称
verbose_name_plural = verbose_name # lrj复数显示名称
ordering = ['-creation_time'] # lrj默认按创建时间降序排列
class OAuthConfig(models.Model):
"""lrj
OAuth 应用配置模型
存储各个第三方平台的OAuth应用配置信息
"""
# lrj支持的第三方平台类型选项
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ'),
('weibo', _('weibo')), # lrj微博
('google', _('google')), # 谷歌
('github', 'GitHub'), # lrjGitHub
('facebook', 'FaceBook'), # lrjFacebook
('qq', 'QQ'), # lrjQQ
)
# lrj平台类型选择
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# lrjOAuth应用的AppKey/Client ID
appkey = models.CharField(max_length=200, verbose_name='AppKey')
# lrjOAuth应用的AppSecret/Client Secret
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# lrjOAuth回调URL用于接收授权码
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
# lrj是否启用该平台配置
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
# lrj配置创建时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# lrj配置最后修改时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
"""lrj
数据验证方法确保同类型平台配置唯一
避免重复配置同一个第三方平台
"""
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
"""lrj管理员界面显示的配置标识"""
return self.type
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
"""lrj模型元数据配置"""
verbose_name = 'OAuth配置' # lrj单数显示名称
verbose_name_plural = verbose_name # lrj复数显示名称
ordering = ['-creation_time'] # lrj默认按创建时间降序排列

@ -0,0 +1,67 @@
# Create your models here.
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
class OAuthUser(models.Model):
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
openid = models.CharField(max_length=50)
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
token = models.CharField(max_length=150, null=True, blank=True)
picture = models.CharField(max_length=350, blank=True, null=True)
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']
class OAuthConfig(models.Model):
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ'),
)
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):
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
return self.type
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']

@ -1,3 +1,9 @@
"""lrj
OAuth 认证管理器模块
提供多平台OAuth2.0认证的抽象基类和具体实现
支持微博GoogleGitHubFacebookQQ等第三方登录
"""
import json
import logging
import os
@ -7,8 +13,9 @@ from abc import ABCMeta, abstractmethod
import requests
from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig
from oauth.models import OAuthConfig, OAuthUser
# lrj配置日志记录器
logger = logging.getLogger(__name__)
@ -19,76 +26,166 @@ class OAuthAccessTokenException(Exception):
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
"""lrj
OAuth管理器抽象基类
定义所有OAuth平台必须实现的接口和方法
"""
#lrj 授权页面URL - 需要子类实现
AUTH_URL = None
"""获取token"""
# lrj获取访问令牌URL - 需要子类实现
TOKEN_URL = None
"""获取用户信息"""
# lrj获取用户信息API URL - 需要子类实现
API_URL = None
'''icon图标名'''
# lrj平台图标名称标识 - 需要子类实现
ICON_NAME = None
def __init__(self, access_token=None, openid=None):
"""lrj
初始化OAuth管理器
Args:
access_token: 访问令牌可为None
openid: 第三方平台用户ID可为None
"""
self.access_token = access_token
self.openid = openid
@property
def is_access_token_set(self):
"""lrj检查access_token是否已设置"""
return self.access_token is not None
@property
def is_authorized(self):
"""lrj检查是否已完成授权包含access_token和openid"""
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='/'):
"""lrj
获取授权页面URL - 抽象方法
Args:
nexturl: 授权成功后跳转的URL
Returns:
str: 完整的授权页面URL
"""
pass
@abstractmethod
def get_access_token_by_code(self, code):
"""lrj
通过授权码获取访问令牌 - 抽象方法
Args:
code: OAuth回调返回的授权码
Returns:
str: 访问令牌
"""
pass
@abstractmethod
def get_oauth_userinfo(self):
"""lrj
获取用户信息 - 抽象方法
Returns:
OAuthUser: 用户信息对象
"""
pass
@abstractmethod
def get_picture(self, metadata):
"""lrj
从元数据中提取用户头像URL - 抽象方法
Args:
metadata: 用户元数据JSON字符串
Returns:
str: 头像URL
"""
pass
def do_get(self, url, params, headers=None):
"""lrj
执行GET请求到第三方API
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本
"""
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
logger.info(rsp.text) # lrj记录响应日志
return rsp.text
def do_post(self, url, params, headers=None):
"""lrj
执行POST请求到第三方API
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本
"""
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
logger.info(rsp.text) # lrj记录响应日志
return rsp.text
def get_config(self):
"""lrj
从数据库获取当前平台的OAuth配置
Returns:
OAuthConfig: 配置对象或None
"""
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
class WBOauthManager(BaseOauthManager):
"""lrj
微博OAuth管理器
实现微博平台的OAuth2.0认证流程
"""
# lrj微博OAuth接口URL
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None):
"""lrj初始化微博OAuth配置"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
super(WBOauthManager, self).__init__(access_token=access_token, openid=openid)
def get_authorization_url(self, nexturl='/'):
"""lrj
生成微博授权页面URL
Args:
nexturl: 授权成功后跳转URL
Returns:
str: 微博授权URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -98,7 +195,18 @@ class WBOauthManager(BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""lrj
使用授权码获取微博访问令牌
Args:
code: 授权码
Returns:
OAuthUser: 用户信息对象
Raises:
OAuthAccessTokenException: 获取令牌失败时抛出
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -117,8 +225,15 @@ class WBOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""lrj
获取微博用户信息
Returns:
OAuthUser: 微博用户信息对象
"""
if not self.is_authorized:
return None
params = {
'uid': self.openid,
'access_token': self.access_token
@ -127,14 +242,17 @@ class WBOauthManager(BaseOauthManager):
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['avatar_large']
user.nickname = datas['screen_name']
user.openid = datas['id']
user.type = 'weibo'
user.token = self.access_token
user.metadata = rsp # lrj保存原始响应数据
user.picture = datas['avatar_large'] # lrj用户头像
user.nickname = datas['screen_name'] # lrj用户昵称
user.openid = datas['id'] # lrj微博用户ID
user.type = 'weibo' # lrj平台类型
user.token = self.access_token # lrj访问令牌
# lrj可选邮箱信息
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
@ -142,12 +260,27 @@ class WBOauthManager(BaseOauthManager):
return None
def get_picture(self, metadata):
"""lrj
从微博用户元数据中提取头像URL
Args:
metadata: 微博用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return datas['avatar_large']
class ProxyManagerMixin:
"""lrj
代理管理器混入类
为需要代理访问的OAuth平台提供代理支持
"""
def __init__(self, *args, **kwargs):
"""lrj初始化代理配置"""
if os.environ.get("HTTP_PROXY"):
self.proxies = {
"http": os.environ.get("HTTP_PROXY"),
@ -157,239 +290,34 @@ class ProxyManagerMixin:
self.proxies = None
def do_get(self, url, params, headers=None):
"""lrj带代理的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):
"""lrj带代理的POST请求"""
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
ICON_NAME = 'google'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GoogleOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'openid email',
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
self.access_token = str(obj['access_token'])
self.openid = str(obj['id_token'])
logger.info(self.ICON_NAME + ' oauth ' + rsp)
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
# lrj它们的结构类似都是继承BaseOauthManager并实现抽象方法
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params)
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['picture']
user.nickname = datas['name']
user.openid = datas['sub']
user.token = self.access_token
user.type = 'google'
if datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('google oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['picture']
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
"""Google OAuth管理器实现"""
#lrj ... 实现细节类似微博管理器
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
ICON_NAME = 'github'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GitHubOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
from urllib import parse
r = parse.parse_qs(rsp)
if 'access_token' in r:
self.access_token = (r['access_token'][0])
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
try:
datas = json.loads(rsp)
user = OAuthUser()
user.picture = datas['avatar_url']
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('github oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_url']
"""GitHub OAuth管理器实现"""
# lrj... 实现细节类似微博管理器
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'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
FaceBookOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
# 'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
token = str(obj['access_token'])
self.access_token = token
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
}
try:
rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp)
user = OAuthUser()
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url'])
return user
except Exception as e:
logger.error(e)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
"""Facebook OAuth管理器实现"""
# lrj... 实现细节类似微博管理器
class QQOauthManager(BaseOauthManager):
@ -483,22 +411,41 @@ class QQOauthManager(BaseOauthManager):
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
"""lrj
获取所有启用的OAuth应用列表带缓存
Returns:
list: OAuth管理器实例列表
"""
configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs:
return []
# lrj提取已启用平台的类型列表
configtypes = [x.type for x in configs]
# lrj获取所有OAuth管理器子类
applications = BaseOauthManager.__subclasses__()
#lrj 过滤出已配置的平台管理器
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps
def get_manager_by_type(type):
"""lrj
根据平台类型获取对应的OAuth管理器
Args:
type: 平台类型字符串'weibo', 'github'
Returns:
BaseOauthManager: 对应的管理器实例或None
"""
applications = get_oauth_apps()
if applications:
finds = list(
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),
applications))
finds = list(filter(lambda x: x.ICON_NAME.lower() == type.lower(), applications))
if finds:
return finds[0]
return None

@ -1,3 +1,8 @@
"""lrj
OAuth 认证测试模块
包含OAuth配置登录流程和各平台OAuth功能的单元测试
"""
import json
from unittest.mock import patch
@ -13,43 +18,63 @@ from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase):
"""lrj
OAuth配置模型测试类
测试OAuth配置相关的功能
"""
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
"""lrj测试初始化设置"""
self.client = Client() # lrjDjango测试客户端
self.factory = RequestFactory() # lrj请求工厂
def test_oauth_login_test(self):
"""lrj测试OAuth登录流程"""
# lrj创建微博OAuth配置
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# lrj测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertTrue("api.weibo.com" in response.url) # lrj验证重定向到微博授权页面
# lrj测试授权回调处理
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertEqual(response.url, '/') # lrj验证重定向到首页
class OauthLoginTest(TestCase):
"""lrj
OAuth登录流程测试类
测试各平台OAuth登录的完整流程
"""
def setUp(self) -> None:
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
"""lrj测试初始化设置"""
self.client = Client() # lrjDjango测试客户端
self.factory = RequestFactory() # lrj请求工厂
self.apps = self.init_apps() # lrj初始化所有OAuth应用配置
def init_apps(self):
"""lrj初始化所有OAuth平台配置"""
# lrj获取所有OAuth管理器子类并实例化
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
# lrj为每个平台创建配置
c = OAuthConfig()
c.type = application.ICON_NAME.lower()
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.type = application.ICON_NAME.lower() # lrj平台类型
c.appkey = 'appkey' # lrj测试用AppKey
c.appsecret = 'appsecret' # lrj测试用AppSecret
c.save()
return applications
def get_app_by_type(self, type):
"""lrj根据平台类型获取对应的OAuth应用"""
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -57,9 +82,15 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
"""lrj测试微博OAuth登录流程"""
# lrj获取微博OAuth应用
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
assert weibo_app #lrj 验证应用存在
# lrj测试授权URL生成
url = weibo_app.get_authorization_url()
# lrj模拟微博API响应
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -69,16 +100,24 @@ class OauthLoginTest(TestCase):
"id": "id",
"email": "email",
})
# lrj测试获取访问令牌和用户信息
userinfo = weibo_app.get_access_token_by_code('code')
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
self.assertEqual(userinfo.token, 'access_token') # lrj验证访问令牌
self.assertEqual(userinfo.openid, 'id') #lrj 验证用户OpenID
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
"""lrj测试Google OAuth登录流程"""
# lrj获取Google OAuth应用
google_app = self.get_app_by_type('google')
assert google_app
assert google_app # lrj验证应用存在
# lrj测试授权URL生成
url = google_app.get_authorization_url()
# lrj模拟Google API响应
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
@ -91,17 +130,23 @@ 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')
self.assertEqual(userinfo.token, 'access_token') # lrj验证访问令牌
self.assertEqual(userinfo.openid, 'sub') # lrj验证用户OpenID
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
"""测试GitHub OAuth登录流程"""
# lrj获取GitHub OAuth应用
github_app = self.get_app_by_type('github')
assert github_app
assert github_app # lrj验证应用存在
# lrj测试授权URL生成
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
self.assertTrue("github.com" in url) # lrj验证GitHub域名
self.assertTrue("client_id" in url) #lrj 验证包含client_id参数
# lrj模拟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 +156,22 @@ 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')
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') # lrj验证访问令牌
self.assertEqual(userinfo.openid, 'id') # lrj验证用户OpenID
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
"""lrj测试Facebook OAuth登录流程"""
# lrj获取Facebook OAuth应用
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
assert facebook_app # lrj验证应用存在
# lrj测试授权URL生成
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
self.assertTrue("facebook.com" in url) # lrj验证Facebook域名
# lrj模拟Facebook API响应
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
@ -136,31 +187,38 @@ class OauthLoginTest(TestCase):
})
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.token, 'access_token') # lrj验证访问令牌
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
'access_token=access_token&expires_in=3600', # lrj获取token响应
'callback({"client_id":"appid","openid":"openid"} );', # lrj获取openid响应
json.dumps({
"nickname": "nickname",
"email": "email",
"figureurl": "figureurl",
"openid": "openid",
})
}) # lrj获取用户信息响应
])
def test_qq_login(self, mock_do_get):
"""lrj测试QQ OAuth登录流程"""
# lrj获取QQ OAuth应用
qq_app = self.get_app_by_type('qq')
assert qq_app
assert qq_app # lrj验证应用存在
# lrj测试授权URL生成
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
self.assertTrue("qq.com" in url) # lrj验证QQ域名
# lrj测试获取访问令牌和用户信息使用side_effect模拟多次调用
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.token, 'access_token') # lrj验证访问令牌
@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):
"""lrj测试带邮箱的微博授权登录完整流程"""
# lrj模拟微博API响应
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -172,35 +230,43 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# lrj测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertTrue("api.weibo.com" in response.url) # lrj验证重定向到微博
#lrj 测试授权回调处理
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302) #lrj 验证重定向状态码
self.assertEqual(response.url, '/') # lrj验证重定向到首页
# lrj验证用户已登录
user = auth.get_user(self.client)
assert user.is_authenticated
assert user.is_authenticated # lrj验证用户已认证
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
self.assertEqual(user.username, mock_user_info['screen_name']) # lrj验证用户名
self.assertEqual(user.email, mock_user_info['email']) # lrj验证邮箱
# lrj登出用户
self.client.logout()
# lrj再次测试登录测试重复登录情况
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertEqual(response.url, '/') # lrj证重定向到首页
# lrj再次验证用户已登录
user = auth.get_user(self.client)
assert user.is_authenticated
assert user.is_authenticated # lrj验证用户已认证
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
self.assertEqual(user.username, mock_user_info['screen_name']) # lrj验证用户名
self.assertEqual(user.email, mock_user_info['email']) # lrj验证邮箱
@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):
"""lrj测试不带邮箱的微博授权登录完整流程需要补充邮箱"""
# lrj模拟微博API响应不含邮箱
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -211,39 +277,48 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# lrj测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertTrue("api.weibo.com" in response.url) # lrj验证重定向到微博
# lrj测试授权回调处理应该重定向到邮箱补充页面
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertEqual(response.status_code, 302)
# lrj解析OAuth用户ID
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') #lrj 验证重定向到邮箱补充页面
# lrj测试邮箱补充表单提交
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302) # lrj验证重定向状态码
self.assertEqual(response.status_code, 302)
#lrj 生成安全签名
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
# lrj验证重定向到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
})
self.assertEqual(response.url, f'{url}?type=email')
# lrj测试邮箱确认链接
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
})
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
self.assertEqual(response.status_code, 302) #lrj 验证重定向状态码
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') #lrj 验证重定向到绑定成功页面
# lrj验证用户已登录
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
self.assertTrue(user.is_authenticated) # lrj验证用户已认证
self.assertEqual(user.username, mock_user_info['screen_name']) # lrj验证用户名
self.assertEqual(user.email, 'test@gmail.com') #lrj 验证补充的邮箱
self.assertEqual(oauth_user.pk, oauth_user_id) # lrj验证OAuth用户ID匹配

@ -1,25 +1,54 @@
"""lrj
OAuth 认证URL路由配置模块
定义OAuth认证相关的URL路由和视图映射关系
"""
from django.urls import path
from . import views
# lrj应用命名空间用于URL反向解析时区分不同应用的同名URL
app_name = "oauth"
# lrjURL模式配置列表
urlpatterns = [
#lrj OAuth授权回调处理URL
# lrj路径/oauth/authorize
# lrj处理第三方平台回调完成用户认证和账号绑定
path(
r'oauth/authorize',
views.authorize),
r'oauth/authorize', # lrjURL路径模式
views.authorize), # lrj对应的视图函数
# lrj邮箱补充页面URL
#lrj 路径:/oauth/requireemail/<oauthid>.html
# lrj当OAuth用户没有邮箱时显示表单要求用户补充邮箱信息
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
r'oauth/requireemail/<int:oauthid>.html', # lrjURL路径模式包含整数类型的oauthid参数
views.RequireEmailView.as_view(), #lrj 对应的类视图使用as_view()方法)
name='require_email'), # lrjURL名称用于反向解析
# lrj邮箱确认链接URL
# lrj路径/oauth/emailconfirm/<id>/<sign>.html
# lrj用户点击邮件中的确认链接完成邮箱绑定和用户登录
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
r'oauth/emailconfirm/<int:id>/<sign>.html', # lrjURL路径模式包含整数类型的id参数和字符串类型的sign签名参数
views.emailconfirm, #lrj 对应的视图函数
name='email_confirm'), # lrjURL名称用于反向解析
# lrj绑定成功提示页面URL
# lrj路径/oauth/bindsuccess/<oauthid>.html
# lrj显示绑定成功或等待确认的提示信息
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
r'oauth/bindsuccess/<int:oauthid>.html', #lrj URL路径模式包含整数类型的oauthid参数
views.bindsuccess, # lrj对应的视图函数
name='bindsuccess'), # lrjURL名称用于反向解析
# lrjOAuth登录入口URL
#lrj 路径:/oauth/oauthlogin
#lrj 重定向用户到第三方平台的授权页面
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
r'oauth/oauthlogin', # lrjURL路径模式
views.oauthlogin, #lrj 对应的视图函数
name='oauthlogin') # lrjURL名称用于反向解析
]

@ -1,39 +1,55 @@
"""lrj
OAuth 认证视图模块
处理第三方登录的完整流程授权回调用户绑定邮件验证等
"""
import logging
# Create your views here.
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.contrib.auth import get_user_model, login
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.http import HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email, get_sha256
from djangoblog.utils import get_current_site, get_sha256, send_email
from oauth.forms import RequireEmailForm
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
from .oauthmanager import OAuthAccessTokenException, get_manager_by_type
# lrj配置日志记录器
logger = logging.getLogger(__name__)
def get_redirecturl(request):
"""lrj
获取安全的重定向URL防止开放重定向漏洞
Args:
request: Django请求对象
Returns:
str: 安全的跳转URL
"""
nexturl = request.GET.get('next_url', None)
# lrj处理特殊路径避免登录循环
if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/'
return nexturl
# lrj解析URL并验证域名安全性
p = urlparse(nexturl)
if p.netloc:
site = get_current_site().domain
# 比较域名忽略www前缀防止跨站重定向
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
logger.info('非法url:' + nexturl)
return "/"
@ -41,26 +57,54 @@ def get_redirecturl(request):
def oauthlogin(request):
"""lrj
OAuth登录入口视图
重定向用户到第三方平台的授权页面
Args:
request: Django请求对象
Returns:
HttpResponseRedirect: 重定向到第三方授权页面或首页
"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
# lrj根据平台类型获取对应的OAuth管理器
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
#lrj 获取安全的重定向URL并生成授权页面URL
nexturl = get_redirecturl(request)
authorizeurl = manager.get_authorization_url(nexturl)
return HttpResponseRedirect(authorizeurl)
def authorize(request):
"""lrj
OAuth授权回调处理视图
处理第三方平台回调完成用户认证和账号绑定
Args:
request: Django请求对象
Returns:
HttpResponseRedirect: 重定向到相应页面
"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
# lrj获取授权码
code = request.GET.get('code', None)
try:
# lrj使用授权码获取访问令牌
rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e:
logger.warning("OAuthAccessTokenException:" + str(e))
@ -68,74 +112,109 @@ def authorize(request):
except Exception as e:
logger.error(e)
rsp = None
nexturl = get_redirecturl(request)
if not rsp:
# lrj获取令牌失败重新跳转到授权页面
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
#lrj 获取用户信息
user = manager.get_oauth_userinfo()
if user:
# lrj处理空昵称情况
if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try:
# lrj检查是否已存在该OAuth用户
temp = OAuthUser.objects.get(type=type, openid=user.openid)
#lrj 更新用户信息
temp.picture = user.picture
temp.metadata = user.metadata
temp.nickname = user.nickname
user = temp
except ObjectDoesNotExist:
pass
# facebook的token过长
pass # lrj新用户继续处理
#lrj Facebook的token过长特殊处理
if type == 'facebook':
user.token = ''
# lrj如果用户有邮箱直接完成绑定和登录
if user.email:
with transaction.atomic():
with transaction.atomic(): # lrj使用事务保证数据一致性
author = None
try:
author = get_user_model().objects.get(id=user.author_id)
except ObjectDoesNotExist:
pass
if not author:
# lrj创建或获取用户账号
result = get_user_model().objects.get_or_create(email=user.email)
author = result[0]
if result[1]:
if result[1]: # lrj是新创建的用户
try:
#lrj 检查用户名是否已存在
get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
author.username = user.nickname
else:
# lrj用户名冲突生成唯一用户名
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize'
author.save()
# lrj关联OAuth用户和本站用户
user.author = author
user.save()
# lrj发送登录信号
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
# lrj登录用户
login(request, author)
return HttpResponseRedirect(nexturl)
else:
#lrj 没有邮箱,需要用户补充邮箱信息
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
})
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(nexturl)
def emailconfirm(request, id, sign):
"""lrj
邮箱确认视图
通过邮件链接完成邮箱绑定和用户登录
Args:
request: Django请求对象
id: OAuth用户ID
sign: 安全签名
Returns:
HttpResponse: 重定向或错误响应
"""
if not sign:
return HttpResponseForbidden()
#lrj 验证签名安全性
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
return HttpResponseForbidden()
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
if oauthuser.author:
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
# lrj创建新用户账号
result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0]
if result[1]:
@ -143,13 +222,20 @@ def emailconfirm(request, id, sign):
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
# lrj完成绑定
oauthuser.author = author
oauthuser.save()
# lrj发送登录信号
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
# lrj登录用户
login(request, author)
#lrj 发送绑定成功邮件
site = 'http://' + get_current_site().domain
content = _('''
<p>Congratulations, you have successfully bound your email address. You can use
@ -163,6 +249,8 @@ def emailconfirm(request, id, sign):
''') % {'oauthuser_type': oauthuser.type, 'site': site}
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
# 跳转到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
@ -171,19 +259,27 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView):
"""lrj
要求邮箱表单视图
处理用户补充邮箱信息的流程
"""
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
"""lrjGET请求处理"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# lrj如果已有邮箱可能直接跳转当前注释掉了
if oauthuser.email:
pass
# return HttpResponseRedirect('/')
# lrjreturn HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
"""lrj设置表单初始值"""
oauthid = self.kwargs['oauthid']
return {
'email': '',
@ -191,6 +287,7 @@ class RequireEmailView(FormView):
}
def get_context_data(self, **kwargs):
"""lrj添加上下文数据"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.picture:
@ -198,13 +295,18 @@ class RequireEmailView(FormView):
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""lrj表单验证通过后的处理"""
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
# lrj生成安全签名
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
# lrj构建确认链接
site = get_current_site().domain
if settings.DEBUG:
site = '127.0.0.1:8000'
@ -214,6 +316,7 @@ class RequireEmailView(FormView):
})
url = "http://{site}{path}".format(site=site, path=path)
# lrj发送确认邮件
content = _("""
<p>Please click the link below to bind your email</p>
@ -226,6 +329,8 @@ class RequireEmailView(FormView):
%(url)s
""") % {'url': url}
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
# lrj跳转到绑定成功提示页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
@ -234,8 +339,20 @@ class RequireEmailView(FormView):
def bindsuccess(request, oauthid):
"""lrj
绑定成功提示页面
Args:
request: Django请求对象
oauthid: OAuth用户ID
Returns:
HttpResponse: 渲染的绑定成功页面
"""
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
#lrj 根据绑定类型显示不同内容
if type == 'email':
title = _('Bind your email')
content = _(
@ -247,7 +364,9 @@ def bindsuccess(request, oauthid):
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type})
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})

File diff suppressed because it is too large Load Diff

Binary file not shown.

@ -8,6 +8,16 @@
<div id="content" role="main">
{% load_article_detail article False user %}
<!-- 点赞按钮 -->
<button id="like-button" data-article-id="{{ article.pk }}"
class="btn {% if request.user in article.users_like.all %}btn-primary{% else %}btn-outline-primary{% endif %}">
{% if request.user in article.users_like.all %}
👍 已赞同 <span class="like-count">{{ article.users_like.count }}</span>
{% else %}
👍 赞同 <span class="like-count">{{ article.users_like.count }}</span>
{% endif %}
</button>
{% if article.type == 'a' %}
<nav class="nav-single">
<h3 class="assistive-text">文章导航</h3>
@ -45,6 +55,57 @@
{% endif %}
</div><!-- #primary -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).ready(function() {
$('#like-button').click(function() {
const articleId = $(this).data('article-id'); // 获取文章ID
const likeButton = $(this); // 获取按钮本身
const likeCountSpan = $('#like-count'); // 获取点赞数显示的span
// 禁用按钮防止重复点击
likeButton.prop('disabled', true).text('处理中...');
// 发送AJAX POST请求
$.ajax({
url: "{% url 'blog:like_article' %}", // 使用Django模板标签生成URL确保url的name是'like_article'
type: "POST",
data: {
'article_id': articleId,
'csrfmiddlewaretoken': '{{ csrf_token }}' // Django CSRF令牌必须携带
},
dataType: 'json',
success: function(response) {
if (response.state === 200) {
// 更新点赞数量
likeCountSpan.text(response.like_sum);
// 根据操作类型(点赞或取消)更新按钮样式
if (response.type === 1) {
likeButton.removeClass('btn-outline-primary').addClass('btn-primary')
.html('👍 已赞同 <span class="like-count">' + response.like_sum + '</span>');
} else {
likeButton.removeClass('btn-primary').addClass('btn-outline-primary')
.html('👍 赞同 <span class="like-count">' + response.like_sum + '</span>');
}
} else {
alert(response.data); // 处理错误信息,例如"文章不存在"
}
},
error: function(xhr, status, error) {
// 处理请求失败的情况,例如网络问题
console.error("AJAX request failed: " + status + ", " + error);
alert('操作失败,请稍后重试。');
},
complete: function() {
// 重新启用按钮
likeButton.prop('disabled', false);
}
});
});
});
</script>
{% endblock %}
{% block sidebar %}

@ -19,11 +19,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="format-detection" content="telephone=no"/>
<meta name="theme-color" content="#21759b"/>
{% load blog_tags %}
{% head_meta %}
{% block header %}
<!-- SEO插件会自动生成title、description、keywords等标签 -->
{% endblock %}
<link rel="profile" href="http://gmpg.org/xfn/11"/>
<!-- 资源提示和预加载优化 -->
@ -31,7 +34,6 @@
<link rel="dns-prefetch" href="//cdn.jsdelivr.net"/>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin/>
<!--[if lt IE 9]>
<script src="{% static 'blog/js/html5.js' %}" type="text/javascript"></script>
<![endif]-->
@ -44,21 +46,40 @@
<!-- 本地字体加载 -->
<link rel="stylesheet" href="{% static 'blog/fonts/open-sans.css' %}">
{% compress css %}
<link rel='stylesheet' id='twentytwelve-style-css' href='{% static 'blog/css/style.css' %}' type='text/css'
media='all'/>
<!-- 主题预加载脚本 - 防止闪烁 -->
<script>
(function() {
'use strict';
const storedTheme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = storedTheme || (systemDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', initialTheme);
})();
</script>
<link rel='stylesheet' id='twentytwelve-style-css' href='{% static 'blog/css/style.css' %}' type='text/css' media='all'/>
<link href="{% static 'blog/css/oauth_style.css' %}" rel="stylesheet">
<link href="{% static 'blog/css/maupassant.css' %}" rel="stylesheet">
<!-- 新增主题 CSS -->
<link href="{% static 'blog/css/theme.css' %}" rel="stylesheet">
{% comment %}<script src="https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js"></script>{% endcomment %}
<!--[if lt IE 9]>
<link rel='stylesheet' id='twentytwelve-ie-css' href='{% static 'blog/css/ie.css' %}' type='text/css' media='all' />
<![endif]-->
<link rel="stylesheet" href="{% static 'pygments/default.css' %}"/>
<link rel="stylesheet" href="{% static 'blog/css/nprogress.css' %}">
{% block compress_css %}
{% endblock %}
<!-- 插件CSS文件 - 集成到压缩系统 -->
{% plugin_compressed_css %}
{% endcompress %}
{% if GLOBAL_HEADER %}
{{ GLOBAL_HEADER|safe }}
@ -66,32 +87,73 @@
<!-- 插件head资源 -->
{% plugin_head_resources %}
{% block extra_css %}{% endblock %}
</head>
<body class="home blog custom-font-enabled">
<body class="home blog custom-font-enabled" data-theme="light">
<div id="page" class="hfeed site">
<header id="masthead" class="site-header" role="banner">
<hgroup>
<h1 class="site-title"><a href="/" title="{{ SITE_NAME }}" rel="home">{{ SITE_NAME }}</a>
</h1>
<h1 class="site-title"><a href="/" title="{{ SITE_NAME }}" rel="home">{{ SITE_NAME }}</a></h1>
<h2 class="site-description">{{ SITE_DESCRIPTION }}</h2>
</hgroup>
{% load i18n %}
{% include 'share_layout/nav.html' %}
<!-- 修改导航栏:集成主题切换和用户菜单 -->
<nav class="main-navigation" role="navigation">
<div class="nav-container">
<!-- 原有的导航菜单 -->
<div class="primary-nav">
{% include 'share_layout/nav.html' %}
</div>
<!-- 右侧功能区域 -->
<div class="nav-actions">
<!-- 主题切换按钮 -->
<div class="theme-toggle-container">
<button id="theme-toggle" class="theme-toggle-btn" title="切换主题" aria-label="切换主题">
<span class="theme-icon">🌙</span>
</button>
</div>
<!-- 用户菜单 - 修复版本 -->
<div class="user-menu">
{% if user.is_authenticated %}
<div class="user-dropdown">
<span class="username">
<i class="user-icon">👤</i>
{{ user.username }}
</span>
<div class="dropdown-content">
<!-- 使用安全的URL路径 -->
<a href="/accounts/profile/">
<i class="icon">⚙️</i>个人资料
</a>
<div class="dropdown-divider"></div>
<a href="/accounts/logout/">
<i class="icon">🚪</i>退出
</a>
</div>
</div>
{% else %}
<a class="login-link" href="/accounts/login/">
<i class="icon">🔑</i>登录
</a>
{% endif %}
</div>
</div>
</div>
</nav>
</header><!-- #masthead -->
<div id="main" class="wrapper">
<div id="main" class="wrapper">
{% block content %}
{% endblock %}
{% block sidebar %}
{% endblock %}
</div><!-- #main .wrapper -->
{% include 'share_layout/footer.html' %}
</div><!-- #page -->
@ -101,8 +163,12 @@
<script src="{% static 'blog/js/nprogress.js' %}"></script>
<script src="{% static 'blog/js/blog.js' %}"></script>
<script src="{% static 'blog/js/navigation.js' %}"></script>
<!-- 新增主题切换JS -->
<script src="{% static 'blog/js/theme-switcher.js' %}"></script>
{% block compress_js %}
{% endblock %}
<!-- 插件JS文件 - 集成到压缩系统 -->
{% plugin_compressed_js %}
{% endcompress %}
@ -115,5 +181,7 @@
<!-- 插件body资源 -->
{% plugin_body_resources %}
{% block extra_js %}{% endblock %}
</body>
</html>
</html>

@ -0,0 +1,9 @@
@echo off
echo Updating Git subtree...
git subtree pull --prefix=src/DjangoBlog DjangoBlog g3f-CodeEdit --squash -m "update subtree"
if %errorlevel% equ 0 (
echo Subtree update successful!
) else (
echo Subtree update failed!
)
pause
Loading…
Cancel
Save