Compare commits

...

3 Commits

@ -1,52 +1,81 @@
# 导入Django表单模块
from django import forms
# 导入Django用户管理模块
from django.contrib.auth.admin import UserAdmin
# 导入Django用户修改表单
from django.contrib.auth.forms import UserChangeForm
# 导入用户名字段
from django.contrib.auth.forms import UsernameField
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 在此注册模型
# 导入自定义用户模型
from .models import BlogUser
# 自定义用户创建表单
class BlogUserCreationForm(forms.ModelForm):
# 密码字段1使用密码输入控件
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 密码字段2用于确认密码使用密码输入控件
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
# 表单元数据配置
class Meta:
# 指定关联的模型
model = BlogUser
# 表单中包含的字段
fields = ('email',)
# 密码确认字段的清理验证方法
def clean_password2(self):
# Check that the two password entries match
# 检查两个密码输入是否匹配
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
# 如果两个密码都存在但不匹配,抛出验证错误
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
# 保存用户的方法
def save(self, commit=True):
# Save the provided password in hashed format
# 保存提供的密码为哈希格式
user = super().save(commit=False)
# 设置用户密码
user.set_password(self.cleaned_data["password1"])
if commit:
# 设置用户来源为管理站点
user.source = 'adminsite'
# 保存用户到数据库
user.save()
return user
# 自定义用户修改表单继承自UserChangeForm
class BlogUserChangeForm(UserChangeForm):
# 表单元数据配置
class Meta:
# 指定关联的模型
model = BlogUser
# 包含所有字段
fields = '__all__'
# 字段类型配置
field_classes = {'username': UsernameField}
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类初始化方法
super().__init__(*args, **kwargs)
# 自定义用户管理类继承自UserAdmin
class BlogUserAdmin(UserAdmin):
# 指定修改表单
form = BlogUserChangeForm
# 指定添加表单
add_form = BlogUserCreationForm
# 列表页面显示的字段
list_display = (
'id',
'nickname',
@ -55,6 +84,9 @@ class BlogUserAdmin(UserAdmin):
'last_login',
'date_joined',
'source')
# 列表页面可点击的链接字段
list_display_links = ('id', 'username')
# 默认排序字段
ordering = ('-id',)
search_fields = ('username', 'nickname', 'email')
# 搜索字段
search_fields = ('username', 'nickname', 'email')

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

@ -1,47 +1,75 @@
# 导入Django表单模块
from django import forms
# 导入获取用户模型函数和密码验证工具
from django.contrib.auth import get_user_model, password_validation
# 导入Django内置的认证表单和用户创建表单
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
# 导入验证错误异常
from django.core.exceptions import ValidationError
# 导入表单小部件
from django.forms import widgets
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入自定义工具模块
from . import utils
# 导入自定义用户模型
from .models import BlogUser
# 登录表单类继承自Django内置的AuthenticationForm
class LoginForm(AuthenticationForm):
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类初始化方法
super(LoginForm, self).__init__(*args, **kwargs)
# 设置用户名字段的小部件为文本输入框添加占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 设置密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 注册表单类继承自Django内置的UserCreationForm
class RegisterForm(UserCreationForm):
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类初始化方法
super(RegisterForm, self).__init__(*args, **kwargs)
# 设置用户名字段的小部件为文本输入框添加占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 设置邮箱字段的小部件为邮箱输入框添加占位符和CSS类
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# 设置密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 设置确认密码字段的小部件为密码输入框添加占位符和CSS类
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
# 邮箱字段的清理验证方法
def clean_email(self):
# 获取清理后的邮箱数据
email = self.cleaned_data['email']
# 检查邮箱是否已存在
if get_user_model().objects.filter(email=email).exists():
# 如果邮箱已存在,抛出验证错误
raise ValidationError(_("email already exists"))
return email
# 表单元数据配置
class Meta:
# 指定关联的模型为当前激活的用户模型
model = get_user_model()
# 表单中包含的字段
fields = ("username", "email")
# 忘记密码表单类继承自forms.Form
class ForgetPasswordForm(forms.Form):
# 新密码字段1
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
@ -52,6 +80,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 新密码字段2用于确认密码
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
@ -62,6 +91,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
@ -72,6 +102,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
@ -82,36 +113,50 @@ class ForgetPasswordForm(forms.Form):
),
)
# 确认密码字段的清理验证方法
def clean_new_password2(self):
# 获取两个密码字段的数据
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
# 检查两个密码是否匹配
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
# 使用Django的密码验证器验证密码强度
password_validation.validate_password(password2)
return password2
# 邮箱字段的清理验证方法
def clean_email(self):
# 获取清理后的邮箱数据
user_email = self.cleaned_data.get("email")
# 检查邮箱是否存在于用户模型中
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
# 如果邮箱不存在,抛出验证错误
# 注释说明:这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
# 验证码字段的清理验证方法
def clean_code(self):
# 获取清理后的验证码数据
code = self.cleaned_data.get("code")
# 使用工具函数验证验证码
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
# 如果验证失败,抛出验证错误
if error:
raise ValidationError(error)
return code
# 忘记密码验证码表单类,用于请求发送验证码
class ForgetPasswordCodeForm(forms.Form):
# 邮箱字段
email = forms.EmailField(
label=_('Email'),
)
)

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

@ -1,46 +1,55 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 由Django 4.2.5于2023-09-06 13:13自动生成
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
# 数据库迁移类
dependencies = [
# 依赖accounts应用的0001_initial迁移文件
('accounts', '0001_initial'),
]
operations = [
# 修改BlogUser模型的元选项
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
# 删除created_time字段
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
# 删除last_mod_time字段
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
# 新增creation_time字段
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 新增last_modify_time字段
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改nickname字段的verbose_name
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
# 修改source字段的verbose_name
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]
]

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

@ -1,135 +1,186 @@
# 导入Django测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析
from django.urls import reverse
# 导入时区工具
from django.utils import timezone
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入账户模型
from accounts.models import BlogUser
# 导入博客文章和分类模型
from blog.models import Article, Category
# 导入工具函数
from djangoblog.utils import *
# 导入当前模块的工具函数
from . import utils
# Create your tests here.
# 在此创建测试
# 账户测试类继承自TestCase
class AccountTest(TestCase):
# 测试前置设置方法
def setUp(self):
# 创建测试客户端
self.client = Client()
# 创建请求工厂
self.factory = RequestFactory()
# 创建测试用户
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
# 设置新测试密码
self.new_test = "xxx123--="
# 测试账户验证功能
def test_validate_account(self):
# 获取当前站点域名
site = get_current_site().domain
# 创建超级用户
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
# 获取刚创建的用户
testuser = BlogUser.objects.get(username='liangliangyy1')
# 测试登录功能
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
# 断言登录成功
self.assertEqual(loginresult, True)
# 访问管理后台
response = self.client.get('/admin/')
# 断言访问成功
self.assertEqual(response.status_code, 200)
# 创建分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建文章
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 访问文章管理页面
response = self.client.get(article.get_admin_url())
# 断言访问成功
self.assertEqual(response.status_code, 200)
# 测试注册功能
def test_validate_register(self):
# 断言邮箱尚未注册
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 发送注册请求
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
# 断言用户创建成功
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 获取新创建的用户
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 生成验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
# 构建验证URL
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
# 访问验证页面
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# 登录新用户
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
# 设置为超级用户和员工
user.is_superuser = True
user.is_staff = True
user.save()
# 删除侧边栏缓存
delete_sidebar_cache()
# 创建分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建文章
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 访问文章管理页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 测试登出功能
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
# 登出后访问管理页面应该重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 使用错误密码登录
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
self.assertIn(response.status_code, [301, 302, 200])
# 登录失败后访问管理页面应该重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 测试邮箱验证码功能
def test_verify_email_code(self):
to_email = "admin@admin.com"
# 生成验证码
code = generate_code()
# 设置验证码
utils.set_code(to_email, code)
# 发送验证邮件
utils.send_verify_email(to_email, code)
# 测试正确验证码验证
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
# 测试错误邮箱验证
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
# 测试忘记密码邮箱验证码成功情况
def test_forget_password_email_code_success(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
@ -139,21 +190,26 @@ class AccountTest(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
# 测试忘记密码邮箱验证码失败情况
def test_forget_password_email_code_fail(self):
# 测试空数据提交
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试错误邮箱格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试忘记密码邮箱成功重置
def test_forget_password_email_success(self):
code = generate_code()
# 设置验证码
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
@ -165,15 +221,18 @@ class AccountTest(TestCase):
path=reverse("account:forget_password"),
data=data
)
# 断言重定向响应
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
).first() # 类型注解:BlogUser
self.assertNotEqual(blog_user, None)
# 检查新密码是否正确设置
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
# 测试不存在的用户忘记密码
def test_forget_password_email_not_user(self):
data = dict(
new_password1=self.new_test,
@ -188,7 +247,7 @@ class AccountTest(TestCase):
self.assertEqual(resp.status_code, 200)
# 测试验证码错误情况
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
@ -196,12 +255,11 @@ class AccountTest(TestCase):
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
code="111111", # 错误的验证码
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.status_code, 200)

@ -1,28 +1,52 @@
# 导入Django URL路由相关模块
from django.urls import path
from django.urls import re_path
# 导入当前应用的视图模块
from . import views
# 导入登录表单类
from .forms import LoginForm
# 定义应用命名空间
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]
# 定义URL模式列表
urlpatterns = [
# 登录URL使用正则表达式匹配以login/结尾的路径
re_path(r'^login/$',
# 使用LoginView类视图登录成功后重定向到首页
views.LoginView.as_view(success_url='/'),
name='login', # URL名称
# 传递额外参数,指定认证表单类
kwargs={'authentication_form': LoginForm}),
# 注册URL使用正则表达式匹配以register/结尾的路径
re_path(r'^register/$',
# 使用RegisterView类视图注册成功后重定向到首页
views.RegisterView.as_view(success_url="/"),
name='register'), # URL名称
# 登出URL使用正则表达式匹配以logout/结尾的路径
re_path(r'^logout/$',
# 使用LogoutView类视图
views.LogoutView.as_view(),
name='logout'), # URL名称
# 账户结果页面URL使用path精确匹配路径
path(r'account/result.html',
# 使用account_result函数视图
views.account_result,
name='result'), # URL名称
# 忘记密码URL使用正则表达式匹配以forget_password/结尾的路径
re_path(r'^forget_password/$',
# 使用ForgetPasswordView类视图
views.ForgetPasswordView.as_view(),
name='forget_password'), # URL名称
# 忘记密码验证码URL使用正则表达式匹配以forget_password_code/结尾的路径
re_path(r'^forget_password_code/$',
# 使用ForgetPasswordEmailCode类视图
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'), # URL名称
]

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

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

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

@ -1,114 +1,173 @@
# 导入Django表单模块
from django import forms
# 导入Django管理后台模块
from django.contrib import admin
# 导入获取用户模型函数
from django.contrib.auth import get_user_model
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 在此注册模型
# 导入博客模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 文章表单类
class ArticleForm(forms.ModelForm):
# 可以在此自定义字段,例如使用特定的编辑器部件
# body = forms.CharField(widget=AdminPagedownWidget())
# 表单元数据配置
class Meta:
# 指定关联的模型
model = Article
# 包含所有字段
fields = '__all__'
# 发布文章的管理动作函数
def makr_article_publish(modeladmin, request, queryset):
# 将选中的文章状态更新为发布
queryset.update(status='p')
# 将文章设为草稿的管理动作函数
def draft_article(modeladmin, request, queryset):
# 将选中的文章状态更新为草稿
queryset.update(status='d')
# 关闭文章评论的管理动作函数
def close_article_commentstatus(modeladmin, request, queryset):
# 将选中的文章评论状态更新为关闭
queryset.update(comment_status='c')
# 打开文章评论的管理动作函数
def open_article_commentstatus(modeladmin, request, queryset):
# 将选中的文章评论状态更新为打开
queryset.update(comment_status='o')
# 设置管理动作的显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
# 文章管理类
class ArticlelAdmin(admin.ModelAdmin):
# 每页显示数量
list_per_page = 20
# 搜索字段
search_fields = ('body', 'title')
# 使用的表单类
form = ArticleForm
# 列表页面显示的字段
list_display = (
'id',
'title',
'author',
'link_to_category',
'link_to_category', # 自定义链接字段
'creation_time',
'views',
'status',
'type',
'article_order')
# 列表页面可点击的链接字段
list_display_links = ('id', 'title')
# 右侧过滤器字段
list_filter = ('status', 'type', 'category')
# 日期层次导航
date_hierarchy = 'creation_time'
# 水平筛选器字段
filter_horizontal = ('tags',)
# 排除的表单字段
exclude = ('creation_time', 'last_modify_time')
# 是否显示"在站点查看"按钮
view_on_site = True
# 管理动作列表
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# 原始ID字段用于优化大表查询
raw_id_fields = ('author', 'category',)
# 自定义分类链接字段方法
def link_to_category(self, obj):
# 获取分类模型的元信息
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 生成分类编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 返回格式化的HTML链接
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
# 设置自定义字段的显示名称
link_to_category.short_description = _('category')
# 获取表单的方法
def get_form(self, request, obj=None, **kwargs):
# 调用父类方法获取表单
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 限制作者字段只能选择超级用户
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
# 保存模型的方法
def save_model(self, request, obj, form, change):
# 调用父类保存方法
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 获取站点查看URL的方法
def get_view_on_site_url(self, obj=None):
if obj:
# 返回文章的完整URL
url = obj.get_full_url()
return url
else:
# 如果没有指定对象,返回当前站点域名
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
# 标签管理类
class TagAdmin(admin.ModelAdmin):
# 排除的表单字段
exclude = ('slug', 'last_mod_time', 'creation_time')
# 分类管理类
class CategoryAdmin(admin.ModelAdmin):
# 列表页面显示的字段
list_display = ('name', 'parent_category', 'index')
# 排除的表单字段
exclude = ('slug', 'last_mod_time', 'creation_time')
# 友情链接管理类
class LinksAdmin(admin.ModelAdmin):
# 排除的表单字段
exclude = ('last_mod_time', 'creation_time')
# 侧边栏管理类
class SideBarAdmin(admin.ModelAdmin):
# 列表页面显示的字段
list_display = ('name', 'content', 'is_enable', 'sequence')
# 排除的表单字段
exclude = ('last_mod_time', 'creation_time')
# 博客设置管理类
class BlogSettingsAdmin(admin.ModelAdmin):
pass
# 使用默认配置
pass

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

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

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

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

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

@ -1,13 +1,20 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入博客模型
from blog.models import Tag, Category
# TODO 参数化
# 构建搜索词的管理命令类
class Command(BaseCommand):
# 命令帮助信息
help = 'build search words'
# 命令处理主方法
def handle(self, *args, **options):
# 从所有标签和分类中获取名称,合并并去重创建集合
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
print('\n'.join(datas))
# 打印所有搜索词,每个词占一行
print('\n'.join(datas))

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

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

@ -1,50 +1,77 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入蜘蛛通知工具和获取当前站点函数
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site
# 导入博客模型
from blog.models import Article, Tag, Category
# 获取当前站点域名
site = get_current_site().domain
# 通知百度URL的管理命令类
class Command(BaseCommand):
# 命令帮助信息
help = 'notify baidu url'
# 添加命令行参数
def add_arguments(self, parser):
parser.add_argument(
'data_type',
type=str,
'data_type', # 参数名称
type=str, # 参数类型
choices=[
'all',
'article',
'tag',
'category'],
help='article : all article,tag : all tag,category: all category,all: All of these')
'all', # 所有类型
'article', # 仅文章
'tag', # 仅标签
'category'], # 仅分类
help='article : all article,tag : all tag,category: all category,all: All of these') # 帮助信息
# 获取完整URL的方法
def get_full_url(self, path):
# 构建完整的HTTPS URL
url = "https://{site}{path}".format(site=site, path=path)
return url
# 命令处理主方法
def handle(self, *args, **options):
# 获取数据类型参数
type = options['data_type']
# 输出开始信息
self.stdout.write('start get %s' % type)
# 初始化URL列表
urls = []
# 处理文章URL
if type == 'article' or type == 'all':
# 获取所有已发布的文章
for article in Article.objects.filter(status='p'):
# 添加文章的完整URL
urls.append(article.get_full_url())
# 处理标签URL
if type == 'tag' or type == 'all':
# 获取所有标签
for tag in Tag.objects.all():
# 获取标签的相对URL
url = tag.get_absolute_url()
# 添加标签的完整URL
urls.append(self.get_full_url(url))
# 处理分类URL
if type == 'category' or type == 'all':
# 获取所有分类
for category in Category.objects.all():
# 获取分类的相对URL
url = category.get_absolute_url()
# 添加分类的完整URL
urls.append(self.get_full_url(url))
# 输出开始通知信息
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
# 调用百度蜘蛛通知
SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify'))
# 输出完成信息
self.stdout.write(self.style.SUCCESS('finish notify'))

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

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

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

@ -1,23 +1,27 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
# 由Django 4.1.7于2023-03-29 06:08自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖blog应用的0001_initial迁移文件
('blog', '0001_initial'),
]
# 迁移操作列表
operations = [
# 添加全局尾部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
# 添加全局头部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]
]

@ -1,17 +1,21 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
# 由Django 4.2.1于2023-05-09 07:45自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖blog应用的0002_blogsettings_global_footer_and_more迁移文件
('blog', '0002_blogsettings_global_footer_and_more'),
]
# 迁移操作列表
operations = [
# 添加评论审核字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]
]

@ -1,27 +1,33 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
# 由Django 4.2.1于2023-05-09 07:51自动生成
from django.db import migrations
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖blog应用的0003_blogsettings_comment_need_review迁移文件
('blog', '0003_blogsettings_comment_need_review'),
]
# 迁移操作列表
operations = [
# 重命名字段analyticscode -> analytics_code
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
),
# 重命名字段beiancode -> beian_code
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
),
# 重命名字段sitename -> site_name
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
),
]
]

@ -1,300 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
migrations.RemoveField(
model_name='article',
name='created_time',
),
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
migrations.RemoveField(
model_name='category',
name='created_time',
),
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
migrations.RemoveField(
model_name='links',
name='created_time',
),
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
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'),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]

@ -1,17 +1,20 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
# 由Django 4.2.7于2024-01-26 02:41自动生成
from django.db import migrations
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖blog应用的0005迁移文件
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
# 迁移操作列表
operations = [
# 修改BlogSettings模型的元选项
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]
]

@ -1,122 +1,180 @@
# 导入日志模块
import logging
# 导入正则表达式模块
import re
# 导入抽象方法装饰器
from abc import abstractmethod
# 导入Django配置
from django.conf import settings
# 导入验证错误异常
from django.core.exceptions import ValidationError
# 导入数据库模型
from django.db import models
# 导入URL反向解析
from django.urls import reverse
# 导入时区工具
from django.utils.timezone import now
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入Markdown编辑器字段
from mdeditor.fields import MDTextField
# 导入slug生成工具
from uuslug import slugify
# 导入缓存装饰器和缓存工具
from djangoblog.utils import cache_decorator, cache
# 导入获取当前站点函数
from djangoblog.utils import get_current_site
# 获取日志器
logger = logging.getLogger(__name__)
# 链接显示类型选择类
class LinkShowType(models.TextChoices):
# 首页显示
I = ('i', _('index'))
# 列表页显示
L = ('l', _('list'))
# 文章页面显示
P = ('p', _('post'))
# 全站显示
A = ('a', _('all'))
# 幻灯片显示
S = ('s', _('slide'))
# 基础模型抽象类
class BaseModel(models.Model):
# 主键ID字段自增
id = models.AutoField(primary_key=True)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_modify_time = models.DateTimeField(_('modify time'), default=now)
# 保存方法重写
def save(self, *args, **kwargs):
# 检查是否为文章视图更新
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 如果是视图更新,直接更新数据库
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 如果有slug字段自动生成slug
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
# 调用父类保存方法
super().save(*args, **kwargs)
# 获取完整URL的方法
def get_full_url(self):
# 获取当前站点域名
site = get_current_site().domain
# 构建完整HTTPS URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 元数据配置 - 抽象类
class Meta:
abstract = True
# 抽象方法 - 获取绝对URL
@abstractmethod
def get_absolute_url(self):
pass
# 文章模型类继承自BaseModel
class Article(BaseModel):
"""文章"""
"""文章模型"""
# 状态选择项
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
('d', _('Draft')), # 草稿
('p', _('Published')), # 已发布
)
# 评论状态选择项
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
('o', _('Open')), # 打开评论
('c', _('Close')), # 关闭评论
)
# 类型选择项
TYPE = (
('a', _('Article')),
('p', _('Page')),
('a', _('Article')), # 文章
('p', _('Page')), # 页面
)
# 标题字段,唯一
title = models.CharField(_('title'), max_length=200, unique=True)
# 正文字段使用Markdown编辑器
body = MDTextField(_('body'))
# 发布时间字段
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
# 状态字段
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
# 评论状态字段
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
# 类型字段
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
# 浏览量字段,正整数
views = models.PositiveIntegerField(_('views'), default=0)
# 作者字段,外键关联用户模型
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
# 文章排序字段
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
# 是否显示目录字段
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
# 分类字段,外键关联分类模型
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
# 标签字段,多对多关联标签模型
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
# 将正文转换为字符串
def body_to_string(self):
return self.body
# 对象的字符串表示
def __str__(self):
return self.title
# 元数据配置
class Meta:
# 按排序和发布时间降序排列
ordering = ['-article_order', '-pub_time']
# 单数显示名称
verbose_name = _('article')
# 复数显示名称
verbose_name_plural = verbose_name
# 指定获取最新记录的字段
get_latest_by = 'id'
# 获取绝对URL的方法
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
@ -125,83 +183,109 @@ class Article(BaseModel):
'day': self.creation_time.day
})
# 获取分类树,带缓存
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
# 获取分类树
tree = self.category.get_category_tree()
# 提取分类名称和URL
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
# 保存方法
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 增加浏览量方法
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
# 获取评论列表,带缓存
def comment_list(self):
# 缓存键
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
# 如果缓存存在,记录日志并返回
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
# 从数据库获取评论并缓存
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
# 获取管理后台URL
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
# 获取下一篇文章,带缓存
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
# 获取上一篇文章,带缓存
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
# 获取文章第一张图片URL
def get_first_image_url(self):
"""
Get the first image url from article.body.
从文章正文中获取第一张图片URL
:return:
"""
# 使用正则表达式匹配Markdown图片语法
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# 分类模型类继承自BaseModel
class Category(BaseModel):
"""文章分类"""
# 分类名称字段,唯一
name = models.CharField(_('category name'), max_length=30, unique=True)
# 父级分类字段,外键自关联
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
# slug字段用于URL
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 排序索引字段
index = models.IntegerField(default=0, verbose_name=_('index'))
# 元数据配置
class Meta:
# 按索引降序排列
ordering = ['-index']
# 单数显示名称
verbose_name = _('category')
# 复数显示名称
verbose_name_plural = verbose_name
# 获取绝对URL
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
# 对象的字符串表示
def __str__(self):
return self.name
# 获取分类树,带缓存
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
@ -210,6 +294,7 @@ class Category(BaseModel):
"""
categorys = []
# 递归解析函数
def parse(category):
categorys.append(category)
if category.parent_category:
@ -218,6 +303,7 @@ class Category(BaseModel):
parse(self)
return categorys
# 获取子分类,带缓存
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
@ -227,6 +313,7 @@ class Category(BaseModel):
categorys = []
all_categorys = Category.objects.all()
# 递归解析函数
def parse(category):
if category not in categorys:
categorys.append(category)
@ -240,137 +327,200 @@ class Category(BaseModel):
return categorys
# 标签模型类继承自BaseModel
class Tag(BaseModel):
"""文章标签"""
# 标签名称字段,唯一
name = models.CharField(_('tag name'), max_length=30, unique=True)
# slug字段用于URL
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 对象的字符串表示
def __str__(self):
return self.name
# 获取绝对URL
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
# 获取文章数量,带缓存
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
# 元数据配置
class Meta:
# 按名称升序排列
ordering = ['name']
# 单数显示名称
verbose_name = _('tag')
# 复数显示名称
verbose_name_plural = verbose_name
# 友情链接模型类
class Links(models.Model):
"""友情链接"""
# 链接名称字段,唯一
name = models.CharField(_('link name'), max_length=30, unique=True)
# 链接地址字段
link = models.URLField(_('link'))
# 排序字段,唯一
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用字段
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
# 显示类型字段
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 元数据配置
class Meta:
# 按排序升序排列
ordering = ['sequence']
# 单数显示名称
verbose_name = _('link')
# 复数显示名称
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.name
# 侧边栏模型类
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
# 标题字段
name = models.CharField(_('title'), max_length=100)
# 内容字段
content = models.TextField(_('content'))
# 排序字段,唯一
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用字段
is_enable = models.BooleanField(_('is enable'), default=True)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 元数据配置
class Meta:
# 按排序升序排列
ordering = ['sequence']
# 单数显示名称
verbose_name = _('sidebar')
# 复数显示名称
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.name
# 博客设置模型类
class BlogSettings(models.Model):
"""blog的配置"""
# 网站名称字段
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
# 网站描述字段
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
# 网站SEO描述字段
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
# 网站关键词字段
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
# 文章摘要长度字段
article_sub_length = models.IntegerField(_('article sub length'), default=300)
# 侧边栏文章数量字段
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
# 侧边栏评论数量字段
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
# 文章页面评论数量字段
article_comment_count = models.IntegerField(_('article comment count'), default=5)
# 是否显示谷歌广告字段
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
# 谷歌广告代码字段
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
# 是否开启网站评论字段
open_site_comment = models.BooleanField(_('open site comment'), default=True)
# 全局头部内容字段
global_header = models.TextField("公共头部", null=True, blank=True, default='')
# 全局尾部内容字段
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
# 备案号字段
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
# 网站统计代码字段
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
# 是否显示公安备案号字段
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
# 公安备案号字段
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
# 评论是否需要审核字段
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
# 元数据配置
class Meta:
# 单数显示名称
verbose_name = _('Website configuration')
# 复数显示名称
verbose_name_plural = verbose_name
# 对象的字符串表示
def __str__(self):
return self.site_name
# 清理验证方法
def clean(self):
# 确保只能有一个配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
# 保存方法
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 导入缓存工具
from djangoblog.utils import cache
cache.clear()
# 保存后清理缓存
cache.clear()

@ -1,13 +1,22 @@
# 导入Haystack搜索索引相关模块
from haystack import indexes
# 导入文章模型
from blog.models import Article
# 文章搜索索引类继承自SearchIndex和Indexable
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
# 主搜索字段document=True表示这是主要搜索内容字段
# use_template=True表示使用模板文件来定义字段内容
text = indexes.CharField(document=True, use_template=True)
# 获取索引对应的模型类
def get_model(self):
# 返回文章模型类
return Article
# 定义索引查询集,指定哪些记录需要被索引
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')
# 只索引状态为已发布('p')的文章
return self.get_model().objects.filter(status='p')

@ -1,42 +1,69 @@
# 导入操作系统模块
import os
# 导入Django配置和文件上传相关模块
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
# 导入管理命令调用
from django.core.management import call_command
# 导入分页器
from django.core.paginator import Paginator
# 导入静态文件处理
from django.templatetags.static import static
# 导入测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析
from django.urls import reverse
# 导入时区工具
from django.utils import timezone
# 导入账户模型
from accounts.models import BlogUser
# 导入博客表单
from blog.forms import BlogSearchForm
# 导入博客模型
from blog.models import Article, Category, Tag, SideBar, Links
# 导入博客模板标签
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
# 导入工具函数
from djangoblog.utils import get_current_site, get_sha256
# 导入OAuth模型
from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
# 在此创建测试
# 文章测试类
class ArticleTest(TestCase):
# 测试前置设置方法
def setUp(self):
# 创建测试客户端
self.client = Client()
# 创建请求工厂
self.factory = RequestFactory()
# 测试文章验证功能
def test_validate_article(self):
# 获取当前站点域名
site = get_current_site().domain
# 获取或创建测试用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
# 设置用户密码
user.set_password("liangliangyy")
# 设置为员工和超级用户
user.is_staff = True
user.is_superuser = True
user.save()
# 测试用户详情页访问
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试管理后台页面访问
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# 创建侧边栏
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -44,30 +71,36 @@ class ArticleTest(TestCase):
s.is_enable = True
s.save()
# 创建分类
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
# 创建标签
tag = Tag()
tag.name = "nicetag"
tag.save()
# 创建文章
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.type = 'a' # 文章类型
article.status = 'p' # 发布状态
article.save()
# 验证初始标签数量为0
self.assertEqual(0, article.tags.count())
# 添加标签
article.tags.add(tag)
article.save()
# 验证标签数量为1
self.assertEqual(1, article.tags.count())
# 批量创建20篇文章
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -79,96 +112,136 @@ class ArticleTest(TestCase):
article.save()
article.tags.add(tag)
article.save()
# 检查Elasticsearch是否启用
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
# 构建搜索索引
call_command("build_index")
# 测试搜索功能
response = self.client.get('/search', {'q': 'nicetitle'})
self.assertEqual(response.status_code, 200)
# 测试文章详情页访问
response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试蜘蛛通知功能
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url())
# 测试标签页访问
response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试分类页访问
response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试搜索页面
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# 测试文章标签模板标签
s = load_articletags(article)
self.assertIsNotNone(s)
# 用户登录
self.client.login(username='liangliangyy', password='liangliangyy')
# 测试归档页面
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# 测试分页功能 - 所有文章
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
# 测试分页功能 - 标签归档
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug)
# 测试分页功能 - 作者归档
p = Paginator(
Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy')
# 测试分页功能 - 分类归档
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单
f = BlogSearchForm()
f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
# 测试百度蜘蛛通知
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
# 测试Gravatar相关模板标签
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
# 创建友情链接
link = Links(
sequence=1,
name="lylinux",
link='https://wwww.lylinux.net')
link.save()
# 测试友情链接页面
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# 测试RSS订阅
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
# 测试网站地图
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# 测试管理后台页面
self.client.get("/admin/blog/article/1/delete/")
self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/')
# 分页检查方法
def check_pagination(self, p, type, value):
# 遍历所有分页
for page in range(1, p.num_pages + 1):
# 加载分页信息
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
# 测试上一页链接
if s['previous_url']:
response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200)
# 测试下一页链接
if s['next_url']:
response = self.client.get(s['next_url'])
self.assertEqual(response.status_code, 200)
# 测试图片上传功能
def test_image(self):
import requests
# 下载测试图片
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
# 保存图片到本地
with open(imagepath, 'wb') as file:
file.write(rsp.content)
# 测试无签名上传应该返回403
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
# 生成签名
sign = get_sha256(get_sha256(settings.SECRET_KEY))
# 测试带签名的图片上传
with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile(
'python.png', file.read(), content_type='image/jpg')
@ -176,17 +249,23 @@ class ArticleTest(TestCase):
rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200)
# 清理测试文件
os.remove(imagepath)
# 测试工具函数
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar(
'https://www.python.org/static/img/python-logo.png')
# 测试错误页面
def test_errorpage(self):
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
# 测试管理命令
def test_commands(self):
# 创建测试用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -195,12 +274,14 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
# 创建OAuth配置
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 创建OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
@ -212,6 +293,7 @@ class ArticleTest(TestCase):
}'''
u.save()
# 创建另一个OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
@ -222,11 +304,12 @@ class ArticleTest(TestCase):
}'''
u.save()
# 测试各种管理命令
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
call_command("ping_baidu", "all")
call_command("create_testdata")
call_command("clear_cache")
call_command("sync_user_avatar")
call_command("build_search_words")
call_command("build_index") # 构建搜索索引
call_command("ping_baidu", "all") # 百度推送
call_command("create_testdata") # 创建测试数据
call_command("clear_cache") # 清理缓存
call_command("sync_user_avatar") # 同步用户头像
call_command("build_search_words") # 构建搜索词

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

@ -1,64 +1,92 @@
# 导入日志模块
import logging
# 导入操作系统模块
import os
# 导入UUID生成模块
import uuid
# 导入Django配置
from django.conf import settings
# 导入分页器
from django.core.paginator import Paginator
# 导入HTTP响应类
from django.http import HttpResponse, HttpResponseForbidden
# 导入快捷函数
from django.shortcuts import get_object_or_404
from django.shortcuts import render
# 导入静态文件处理
from django.templatetags.static import static
# 导入时区工具
from django.utils import timezone
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入CSRF豁免装饰器
from django.views.decorators.csrf import csrf_exempt
# 导入通用视图类
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
# 导入Haystack搜索视图
from haystack.views import SearchView
# 导入博客模型
from blog.models import Article, Category, LinkShowType, Links, Tag
# 导入评论表单
from comments.forms import CommentForm
# 导入插件管理钩子
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 导入工具函数
from djangoblog.utils import cache, get_blog_setting, get_sha256
# 获取日志器
logger = logging.getLogger(__name__)
# 文章列表视图基类继承自ListView
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
# 指定使用的模板名称
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
# 指定上下文对象名称(在模板中使用的变量名
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
# 页面类型,用于分类目录或标签列表等
page_type = ''
# 每页显示数量,从设置中获取
paginate_by = settings.PAGINATE_BY
# 页码参数名
page_kwarg = 'page'
# 链接显示类型
link_type = LinkShowType.L
# 获取视图缓存键的方法
def get_view_cache_key(self):
return self.request.get['pages']
# 页码属性
@property
def page_number(self):
page_kwarg = self.page_kwarg
# 从URL参数或GET参数获取页码默认为1
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
# 获取查询集缓存键的抽象方法,需要子类重写
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
# 获取查询集数据的抽象方法,需要子类重写
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
# 从缓存获取查询集数据的方法
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
@ -67,14 +95,17 @@ class ArticleListView(ListView):
'''
value = cache.get(cache_key)
if value:
# 如果缓存存在,记录日志并返回
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
# 从数据库获取数据并设置缓存
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
# 重写获取查询集的方法,使用缓存
def get_queryset(self):
'''
重写默认从缓存获取数据
@ -84,43 +115,61 @@ class ArticleListView(ListView):
value = self.get_queryset_from_cache(key)
return value
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
# 添加链接类型到上下文
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
# 首页视图继承自ArticleListView
class IndexView(ArticleListView):
'''
首页
'''
# 友情链接类型
# 设置友情链接显示类型为首页
link_type = LinkShowType.I
# 获取查询集数据的方法
def get_queryset_data(self):
# 获取所有已发布的文章
article_list = Article.objects.filter(type='a', status='p')
return article_list
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
# 文章详情视图继承自DetailView
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
# 指定模板名称
template_name = 'blog/article_detail.html'
# 指定模型
model = Article
# URL参数中的主键名
pk_url_kwarg = 'article_id'
# 上下文对象名称
context_object_name = "article"
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
# 创建评论表单实例
comment_form = CommentForm()
# 获取文章评论列表
article_comments = self.object.comment_list()
# 获取父级评论
parent_comments = article_comments.filter(parent_comment=None)
# 获取博客设置
blog_setting = get_blog_setting()
# 对父级评论进行分页
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
# 获取评论页码
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
@ -131,25 +180,32 @@ class ArticleDetailView(DetailView):
if page > paginator.num_pages:
page = paginator.num_pages
# 获取当前页的评论
p_comments = paginator.page(page)
# 计算下一页和上一页
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 构建评论分页URL
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
# 添加上下文数据
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
# 添加上下一篇文章
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
# 调用父类方法获取基础上下文
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
@ -157,24 +213,31 @@ class ArticleDetailView(DetailView):
return context
# 分类详情视图继承自ArticleListView
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
# 获取查询集数据的方法
def get_queryset_data(self):
# 从URL参数获取分类slug
slug = self.kwargs['category_name']
# 获取分类对象不存在则返回404
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
# 获取所有子分类名称
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
# 获取这些分类下的所有已发布文章
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
@ -184,10 +247,11 @@ class CategoryDetailView(ArticleListView):
categoryname=categoryname, page=self.page_number)
return cache_key
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
# 处理分类名称,取最后一部分
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
@ -196,25 +260,31 @@ class CategoryDetailView(ArticleListView):
return super(CategoryDetailView, self).get_context_data(**kwargs)
# 作者详情视图继承自ArticleListView
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
from uuslug import slugify
# 对作者名称进行slugify处理
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
# 获取查询集数据的方法
def get_queryset_data(self):
author_name = self.kwargs['author_name']
# 获取该作者的所有已发布文章
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
@ -222,21 +292,26 @@ class AuthorDetailView(ArticleListView):
return super(AuthorDetailView, self).get_context_data(**kwargs)
# 标签详情视图继承自ArticleListView
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
# 获取查询集数据的方法
def get_queryset_data(self):
slug = self.kwargs['tag_name']
# 获取标签对象不存在则返回404
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
# 获取该标签下的所有已发布文章
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
@ -246,41 +321,51 @@ class TagDetailView(ArticleListView):
tag_name=tag_name, page=self.page_number)
return cache_key
# 获取上下文数据的方法
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
# 归档视图继承自ArticleListView
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
# 不分页
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
# 获取查询集数据的方法
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
# 获取查询集缓存键的方法
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
# 友情链接列表视图继承自ListView
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
# 获取查询集的方法
def get_queryset(self):
# 只返回启用的链接
return Links.objects.filter(is_enable=True)
# Elasticsearch搜索视图继承自Haystack的SearchView
class EsSearchView(SearchView):
# 获取上下文数据的方法
def get_context(self):
# 构建分页
paginator, page = self.build_page()
context = {
"query": self.query,
@ -289,6 +374,7 @@ class EsSearchView(SearchView):
"paginator": paginator,
"suggestion": None,
}
# 添加拼写建议
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
@ -296,6 +382,7 @@ class EsSearchView(SearchView):
return context
# 文件上传视图使用CSRF豁免
@csrf_exempt
def fileupload(request):
"""
@ -304,30 +391,42 @@ def fileupload(request):
:return:
"""
if request.method == 'POST':
# 获取签名参数
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
# 验证签名
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
# 遍历所有上传的文件
for filename in request.FILES:
# 生成时间目录
timestr = timezone.now().strftime('%Y/%m/%d')
# 图片扩展名列表
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
# 判断是否为图片
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
# 构建保存路径
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 生成唯一文件名
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
# 安全检查
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
# 生成静态文件URL
url = static(savepath)
response.append(url)
return HttpResponse(response)
@ -336,6 +435,7 @@ def fileupload(request):
return HttpResponse("only for post")
# 404错误页面视图
def page_not_found_view(
request,
exception,
@ -350,6 +450,7 @@ def page_not_found_view(
status=404)
# 500服务器错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -358,6 +459,7 @@ def server_error_view(request, template_name='blog/error_page.html'):
status=500)
# 403权限拒绝页面视图
def permission_denied_view(
request,
exception,
@ -370,6 +472,7 @@ def permission_denied_view(
'statuscode': '403'}, status=403)
# 清理缓存视图
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
return HttpResponse('ok')

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

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

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

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

@ -1,18 +1,21 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48
# 由Django 4.1.7于2023-04-24 13:48自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖comments应用的0001_initial迁移文件
('comments', '0001_initial'),
]
# 迁移操作列表
operations = [
# 修改Comment模型的is_enable字段的默认值
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]
]

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 由Django 4.2.5于2023-09-06 13:13自动生成
from django.conf import settings
from django.db import migrations, models
@ -7,54 +7,67 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖blog应用的0005迁移文件
('blog', '0005_alter_article_options_alter_category_options_and_more'),
# 依赖comments应用的0002迁移文件
('comments', '0002_alter_comment_is_enable'),
]
# 迁移操作列表
operations = [
# 修改Comment模型的元选项
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
# 删除created_time字段
migrations.RemoveField(
model_name='comment',
name='created_time',
),
# 删除last_mod_time字段
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
# 新增creation_time字段
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 新增last_modify_time字段
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改article字段的verbose_name
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
# 修改author字段的verbose_name
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改is_enable字段的verbose_name
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
# 修改parent_comment字段的verbose_name
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]
]

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

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

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

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

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

@ -1,32 +1,55 @@
# 导入Django管理站点基类
from django.contrib.admin import AdminSite
# 导入日志条目模型
from django.contrib.admin.models import LogEntry
# 导入站点管理类
from django.contrib.sites.admin import SiteAdmin
# 导入站点模型
from django.contrib.sites.models import Site
# 导入账户管理类
from accounts.admin import *
# 导入博客管理类
from blog.admin import *
# 导入博客模型
from blog.models import *
# 导入评论管理类
from comments.admin import *
# 导入评论模型
from comments.models import *
# 导入日志条目管理类
from djangoblog.logentryadmin import LogEntryAdmin
# 导入OAuth管理类
from oauth.admin import *
# 导入OAuth模型
from oauth.models import *
# 导入OwnTracks管理类
from owntracks.admin import *
# 导入OwnTracks模型
from owntracks.models import *
# 导入服务器管理类
from servermanager.admin import *
# 导入服务器管理模型
from servermanager.models import *
# 自定义DjangoBlog管理站点类
class DjangoBlogAdminSite(AdminSite):
# 站点头部标题
site_header = 'djangoblog administration'
# 站点标题
site_title = 'djangoblog site admin'
# 初始化方法
def __init__(self, name='admin'):
super().__init__(name)
# 权限检查方法
def has_permission(self, request):
# 只允许超级用户访问管理后台
return request.user.is_superuser
# 注释掉的URL配置示例
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
@ -38,27 +61,36 @@ class DjangoBlogAdminSite(AdminSite):
# return urls + my_urls
# 创建DjangoBlog管理站点实例
admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
admin_site.register(Links, LinksAdmin)
admin_site.register(SideBar, SideBarAdmin)
admin_site.register(BlogSettings, BlogSettingsAdmin)
# 注册博客相关模型和管理类
admin_site.register(Article, ArticlelAdmin) # 文章模型
admin_site.register(Category, CategoryAdmin) # 分类模型
admin_site.register(Tag, TagAdmin) # 标签模型
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
# 注册服务器管理相关模型
admin_site.register(commands, CommandsAdmin) # 命令模型
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型
admin_site.register(BlogUser, BlogUserAdmin)
# 注册用户模型
admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型
admin_site.register(Comment, CommentAdmin)
# 注册评论模型
admin_site.register(Comment, CommentAdmin) # 评论模型
admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
# 注册OAuth相关模型
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# 注册OwnTracks相关模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # OwnTracks日志模型
admin_site.register(Site, SiteAdmin)
# 注册Django内置站点模型
admin_site.register(Site, SiteAdmin) # 站点模型
admin_site.register(LogEntry, LogEntryAdmin)
# 注册日志条目模型
admin_site.register(LogEntry, LogEntryAdmin) # 日志条目模型

@ -1,11 +1,18 @@
# 导入Django应用配置类
from django.apps import AppConfig
# 定义Djangoblog应用配置类
class DjangoblogAppConfig(AppConfig):
# 设置默认自增字段类型为BigAutoField
default_auto_field = 'django.db.models.BigAutoField'
# 指定应用名称
name = 'djangoblog'
# 应用准备就绪时调用的方法
def ready(self):
# 调用父类的ready方法
super().ready()
# Import and load plugins here
# 在此处导入和加载插件
from .plugin_manage.loader import load_plugins
load_plugins()
# 调用插件加载函数
load_plugins()

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

@ -1,40 +1,64 @@
# 导入获取用户模型函数
from django.contrib.auth import get_user_model
# 导入Django聚合视图
from django.contrib.syndication.views import Feed
# 导入时区工具
from django.utils import timezone
# 导入RSS feed生成器
from django.utils.feedgenerator import Rss201rev2Feed
# 导入文章模型
from blog.models import Article
# 导入Markdown工具
from djangoblog.utils import CommonMarkdown
# Django博客Feed类继承自Feed
class DjangoBlogFeed(Feed):
# 指定feed类型为RSS 2.0
feed_type = Rss201rev2Feed
# Feed描述
description = '大巧无工,重剑无锋.'
# Feed标题
title = "且听风吟 大巧无工,重剑无锋. "
# Feed链接
link = "/feed/"
# 获取作者名称的方法
def author_name(self):
# 返回第一个用户的昵称
return get_user_model().objects.first().nickname
# 获取作者链接的方法
def author_link(self):
# 返回第一个用户的绝对URL
return get_user_model().objects.first().get_absolute_url()
# 获取Feed项的方法
def items(self):
# 返回最近发布的5篇文章按发布时间降序排列
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
# 获取每个项的标题
def item_title(self, item):
return item.title
# 获取每个项的描述将Markdown转换为HTML
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body)
# 获取Feed版权信息
def feed_copyright(self):
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
# 获取每个项的链接
def item_link(self, item):
return item.get_absolute_url()
# 获取每个项的全局唯一标识符(需要实现)
def item_guid(self, item):
return
# 这里需要返回每个item的唯一标识符
# 通常可以使用文章的ID或绝对URL
pass

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

@ -9,350 +9,378 @@ https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
# 导入操作系统模块
import os
# 导入系统模块
import sys
# 导入路径处理模块
from pathlib import Path
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 环境变量转换为布尔值的辅助函数
def env_to_bool(env, default):
# 获取环境变量值
str_val = os.environ.get(env)
# 如果环境变量不存在则返回默认值,否则进行布尔值转换
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
# 构建项目内的路径BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# 快速开发配置 - 不适用于生产环境
# 参见 https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# 安全警告:在生产环境中保持密钥机密!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
# 安全警告:在生产环境中不要开启调试模式!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
# 测试模式判断
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
# 允许的主机列表
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
# Django 4.0 新增配置可信的CSRF来源
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
# 应用定义
INSTALLED_APPS = [
# 'django.contrib.admin',
# 使用简单的Admin配置
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
'mdeditor',
'haystack',
'blog',
'accounts',
'comments',
'oauth',
'servermanager',
'owntracks',
'compressor',
'djangoblog'
'django.contrib.auth', # 认证系统
'django.contrib.contenttypes', # 内容类型框架
'django.contrib.sessions', # 会话框架
'django.contrib.messages', # 消息框架
'django.contrib.staticfiles', # 静态文件管理
'django.contrib.sites', # 站点框架
'django.contrib.sitemaps', # 网站地图
'mdeditor', # Markdown编辑器
'haystack', # 搜索框架
'blog', # 博客应用
'accounts', # 账户应用
'comments', # 评论应用
'oauth', # OAuth认证
'servermanager', # 服务器管理
'owntracks', # 位置追踪
'compressor', # 静态文件压缩
'djangoblog' # 主应用
]
# 中间件配置
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.gzip.GZipMiddleware',
'django.middleware.security.SecurityMiddleware', # 安全中间件
'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件
'django.middleware.locale.LocaleMiddleware', # 国际化中间件
'django.middleware.gzip.GZipMiddleware', # GZip压缩中间件
# 缓存中间件(注释状态)
# 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.common.CommonMiddleware', # 通用中间件
# 'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'blog.middleware.OnlineMiddleware'
'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件
'django.contrib.auth.middleware.AuthenticationMiddleware', # 认证中间件
'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware', # 点击劫持保护
'django.middleware.http.ConditionalGetMiddleware', # 条件GET请求处理
'blog.middleware.OnlineMiddleware' # 自定义在线中间件
]
# 根URL配置
ROOT_URLCONF = 'djangoblog.urls'
# 模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'BACKEND': 'django.template.backends.django.DjangoTemplates', # Django模板引擎
'DIRS': [os.path.join(BASE_DIR, 'templates')], # 模板目录
'APP_DIRS': True, # 启用应用模板目录
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'blog.context_processors.seo_processor'
'context_processors': [ # 上下文处理器
'django.template.context_processors.debug', # 调试信息
'django.template.context_processors.request', # 请求对象
'django.contrib.auth.context_processors.auth', # 认证信息
'django.contrib.messages.context_processors.messages', # 消息框架
'blog.context_processors.seo_processor' # 自定义SEO处理器
],
},
},
]
# WSGI应用配置
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# 数据库配置
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': '123456',
'HOST': '127.0.0.1',
'PORT': 3306,
'ENGINE': 'django.db.backends.mysql', # MySQL数据库引擎
'NAME': 'djangoblog', # 数据库名称
'USER': 'root', # 数据库用户
'PASSWORD': '123456', # 数据库密码
'HOST': '127.0.0.1', # 数据库主机
'PORT': 3306, # 数据库端口
}
}
# Password validation
# 密码验证配置
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # 用户属性相似性验证
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # 最小长度验证
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # 常见密码验证
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # 纯数字密码验证
},
]
# 国际化配置
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
('en', _('English')), # 英语
('zh-hans', _('Simplified Chinese')), # 简体中文
('zh-hant', _('Traditional Chinese')), # 繁体中文
)
# 本地化文件路径
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
# 默认语言代码
LANGUAGE_CODE = 'zh-hans'
# 时区设置
TIME_ZONE = 'Asia/Shanghai'
# 启用国际化
USE_I18N = True
# 启用本地化
USE_L10N = True
# 使用时区
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# 静态文件配置 (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# Haystack搜索配置
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # Whoosh搜索引擎
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引路径
},
}
# Automatically update searching index
# 自动更新搜索索引
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
# 允许用户使用用户名和邮箱登录
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
# 静态文件根目录
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
# 静态文件URL
STATIC_URL = '/static/'
# 静态文件目录
STATICFILES = os.path.join(BASE_DIR, 'static')
# 自定义用户模型
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录URL
LOGIN_URL = '/login/'
# 时间格式
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
# 日期时间格式
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap color styles
# Bootstrap颜色样式
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
# 分页设置
PAGINATE_BY = 10
# http cache timeout
# HTTP缓存超时时间
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
# 缓存配置
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存
'TIMEOUT': 10800, # 缓存超时时间3小时
'LOCATION': 'unique-snowflake', # 缓存位置标识
}
}
# 使用redis作为缓存
# 如果设置了Redis环境变量使用Redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
'BACKEND': 'django.core.cache.backends.redis.RedisCache', # Redis缓存后端
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', # Redis连接地址
}
}
# 站点ID
SITE_ID = 1
# 百度推送URL
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
# 邮件配置
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # SMTP后端
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否使用TLS
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否使用SSL
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # 邮件服务器
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) # 邮件端口
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') # 邮件用户名
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') # 邮件密码
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER # 默认发件人
SERVER_EMAIL = EMAIL_HOST_USER # 服务器邮件
# 设置debug=false时处理异常邮件通知
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
# 微信管理员密码两次MD5加密
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# 日志路径
LOG_PATH = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
# 日志配置
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'version': 1, # 日志配置版本
'disable_existing_loggers': False, # 不禁用现有日志记录器
'root': {
'level': 'INFO',
'handlers': ['console', 'log_file'],
'level': 'INFO', # 根日志级别
'handlers': ['console', 'log_file'], # 处理器
},
'formatters': {
'verbose': {
'verbose': { # 详细格式
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'require_debug_false': { # 要求调试关闭的过滤器
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'require_debug_true': { # 要求调试开启的过滤器
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'log_file': { # 文件日志处理器
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5,
'encoding': 'utf-8'
'class': 'logging.handlers.TimedRotatingFileHandler', # 时间轮转文件处理器
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径
'when': 'D', # 按天轮转
'formatter': 'verbose', # 使用详细格式
'interval': 1, # 轮转间隔
'delay': True, # 延迟创建
'backupCount': 5, # 备份文件数量
'encoding': 'utf-8' # 文件编码
},
'console': {
'console': { # 控制台处理器
'level': 'DEBUG',
'filters': ['require_debug_true'],
'filters': ['require_debug_true'], # 仅在调试模式下启用
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': {
'null': { # 空处理器
'class': 'logging.NullHandler',
},
'mail_admins': {
'mail_admins': { # 管理员邮件处理器
'level': 'ERROR',
'filters': ['require_debug_false'],
'filters': ['require_debug_false'], # 仅在非调试模式下发送邮件
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': {
'djangoblog': {
'djangoblog': { # 项目日志记录器
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
'propagate': True, # 向上传播
},
'django.request': {
'django.request': { # Django请求日志记录器
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': False,
'propagate': False, # 不向上传播
}
}
}
# 静态文件查找器
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', # 文件系统查找器
'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 应用目录查找器
'compressor.finders.CompressorFinder', # 压缩器查找器
)
# 启用压缩
COMPRESS_ENABLED = True
# 离线压缩(注释状态)
# COMPRESS_OFFLINE = True
# CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
# 从相对URL创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# css minimizer
# CSS压缩器
'compressor.filters.cssmin.CSSMinFilter'
]
# JavaScript压缩过滤器
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
# 媒体文件根目录
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
# 媒体文件URL
MEDIA_URL = '/media/'
# X-Frame选项
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 安全头部配置 - 防XSS和其他攻击
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
SECURE_BROWSER_XSS_FILTER = True # 启用浏览器XSS过滤器
SECURE_CONTENT_TYPE_NOSNIFF = True # 防止MIME类型嗅探
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' # Referrer策略
# 内容安全策略 (CSP) - 防XSS攻击
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
CSP_DEFAULT_SRC = ["'self'"] # 默认来源策略
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] # 脚本来源
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] # 样式来源
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] # 图片来源
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] # 字体来源
CSP_CONNECT_SRC = ["'self'"] # 连接来源
CSP_FRAME_SRC = ["'none'"] # 框架来源(禁止)
CSP_OBJECT_SRC = ["'none'"] # 对象来源(禁止)
# 默认自增字段类型
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Elasticsearch配置如果设置了环境变量
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch主机
},
}
# 使用Elasticsearch作为搜索后端
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', # Elasticsearch引擎
},
}
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer',
'image_lazy_loading',
]
# 插件系统
PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ # 激活的插件列表
'article_copyright', # 文章版权
'reading_time', # 阅读时间
'external_links', # 外部链接
'view_count', # 浏览量统计
'seo_optimizer', # SEO优化
'image_lazy_loading', # 图片懒加载
]

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

@ -1,21 +1,90 @@
import logging
# 导入Django网站地图相关模块
from django.contrib.sitemaps import Sitemap
# 导入URL反向解析
from django.urls import reverse
import requests
from django.conf import settings
# 导入博客模型
from blog.models import Article, Category, Tag
logger = logging.getLogger(__name__)
# 静态视图网站地图类
class StaticViewSitemap(Sitemap):
# 优先级0.0 到 1.0
priority = 0.5
# 更新频率
changefreq = 'daily'
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
try:
data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
logger.info(result.text)
except Exception as e:
logger.error(e)
# 返回要包含在网站地图中的项目
def items(self):
# 只包含首页
return ['blog:index', ]
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
# 获取每个项目的绝对URL
def location(self, item):
# 使用反向解析生成URL
return reverse(item)
# 文章网站地图类
class ArticleSiteMap(Sitemap):
# 更新频率为每月
changefreq = "monthly"
# 优先级为0.6
priority = "0.6"
# 返回所有已发布的文章
def items(self):
return Article.objects.filter(status='p')
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 分类网站地图类
class CategorySiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.6
priority = "0.6"
# 返回所有分类
def items(self):
return Category.objects.all()
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 标签网站地图类
class TagSiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
# 返回所有标签
def items(self):
return Tag.objects.all()
# 获取最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
# 用户网站地图类
class UserSiteMap(Sitemap):
# 更新频率为每周
changefreq = "Weekly"
# 优先级为0.3
priority = "0.3"
# 返回所有有文章的作者(去重)
def items(self):
# 获取所有文章的作者并去重
return list(set(map(lambda x: x.author, Article.objects.all())))
# 获取用户加入时间作为最后修改时间
def lastmod(self, obj):
return obj.date_joined

@ -1,15 +1,23 @@
# 导入Django测试用例
from django.test import TestCase
# 导入工具函数
from djangoblog.utils import *
# Django博客测试类
class DjangoBlogTest(TestCase):
# 测试前置设置方法
def setUp(self):
pass
# 测试工具函数
def test_utils(self):
# 测试SHA256加密函数
md5 = get_sha256('test')
self.assertIsNotNone(md5)
# 测试Markdown转换函数
c = CommonMarkdown.get_markdown('''
# Title1
@ -24,9 +32,11 @@ class DjangoBlogTest(TestCase):
''')
self.assertIsNotNone(c)
# 测试字典转URL参数字符串函数
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)
self.assertIsNotNone(data)

@ -1,78 +1,96 @@
"""djangoblog URL Configuration
"""djangoblog URL 配置
The `urlpatterns` list routes URLs to views. For more information please see:
`urlpatterns` 列表将 URL 路由到视图更多信息请参阅
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
示例
函数视图
1. 添加导入from my_app import views
2. 添加 URL urlpatternsurl(r'^$', views.home, name='home')
基于类的视图
1. 添加导入from other_app.views import Home
2. 添加 URL urlpatternsurl(r'^$', Home.as_view(), name='home')
包含其他 URLconf
1. 导入 include() 函数from django.conf.urls import url, include
2. 添加 URL urlpatternsurl(r'^blog/', include('blog.urls'))
"""
# 导入Django配置
from django.conf import settings
# 导入国际化URL模式
from django.conf.urls.i18n import i18n_patterns
# 导入静态文件服务
from django.conf.urls.static import static
# 导入网站地图视图
from django.contrib.sitemaps.views import sitemap
# 导入URL路径相关函数
from django.urls import path, include
from django.urls import re_path
# 导入Haystack搜索视图工厂
from haystack.views import search_view_factory
# 导入JSON响应
from django.http import JsonResponse
# 导入时间模块
import time
# 导入自定义视图和组件
from blog.views import EsSearchView
from djangoblog.admin_site import admin_site
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
# 网站地图配置字典
sitemaps = {
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
'blog': ArticleSiteMap, # 文章网站地图
'Category': CategorySiteMap, # 分类网站地图
'Tag': TagSiteMap, # 标签网站地图
'User': UserSiteMap, # 用户网站地图
'static': StaticViewSitemap # 静态页面网站地图
}
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# 自定义错误处理视图
handler404 = 'blog.views.page_not_found_view' # 404错误处理
handler500 = 'blog.views.server_error_view' # 500错误处理
handle403 = 'blog.views.permission_denied_view' # 403错误处理
# 健康检查接口函数
def health_check(request):
"""
健康检查接口
简单返回服务健康状态
"""
return JsonResponse({
'status': 'healthy',
'timestamp': time.time()
'status': 'healthy', # 服务状态
'timestamp': time.time() # 时间戳
})
# 基础URL模式不包含国际化
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')),
path('health/', health_check, name='health_check'),
path('i18n/', include('django.conf.urls.i18n')), # 国际化URL
path('health/', health_check, name='health_check'), # 健康检查接口
]
# 添加国际化URL模式
urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls),
re_path(r'', include('blog.urls', namespace='blog')),
re_path(r'mdeditor/', include('mdeditor.urls')),
re_path(r'', include('comments.urls', namespace='comment')),
re_path(r'', include('accounts.urls', namespace='account')),
re_path(r'', include('oauth.urls', namespace='oauth')),
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
re_path(r'^admin/', admin_site.urls), # 管理后台URL
re_path(r'', include('blog.urls', namespace='blog')), # 博客应用URL
re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown编辑器URL
re_path(r'', include('comments.urls', namespace='comment')), # 评论应用URL
re_path(r'', include('accounts.urls', namespace='account')), # 账户应用URL
re_path(r'', include('oauth.urls', namespace='oauth')), # OAuth认证URL
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, # 网站地图URL
name='django.contrib.sitemaps.views.sitemap'),
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
re_path(r'^feed/$', DjangoBlogFeed()), # RSS订阅URL
re_path(r'^rss/$', DjangoBlogFeed()), # RSS订阅别名URL
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), # 搜索URL
name='search'),
re_path(r'', include('servermanager.urls', namespace='servermanager')),
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理URL
re_path(r'', include('owntracks.urls', namespace='owntracks')), # 位置追踪URL
prefix_default_language=False # 不添加默认语言前缀
) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 静态文件服务
# 调试模式下添加媒体文件服务
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
document_root=settings.MEDIA_ROOT)

@ -1,64 +1,90 @@
#!/usr/bin/env python
# encoding: utf-8
# 导入日志模块
import logging
# 导入操作系统模块
import os
# 导入随机数模块
import random
# 导入字符串模块
import string
# 导入UUID生成模块
import uuid
# 导入SHA256哈希算法
from hashlib import sha256
# 导入HTML清理库
import bleach
# 导入Markdown解析库
import markdown
# 导入HTTP请求库
import requests
# 导入Django配置
from django.conf import settings
# 导入站点模型
from django.contrib.sites.models import Site
# 导入缓存模块
from django.core.cache import cache
# 导入静态文件处理
from django.templatetags.static import static
# 获取日志器
logger = logging.getLogger(__name__)
# 获取最大文章ID和评论ID的函数
def get_max_articleid_commentid():
# 延迟导入避免循环依赖
from blog.models import Article
from comments.models import Comment
# 返回最新文章和评论的ID
return (Article.objects.latest().pk, Comment.objects.latest().pk)
# SHA256加密函数
def get_sha256(str):
# 创建SHA256哈希对象
m = sha256(str.encode('utf-8'))
# 返回十六进制哈希值
return m.hexdigest()
# 缓存装饰器
def cache_decorator(expiration=3 * 60):
def wrapper(func):
def news(*args, **kwargs):
try:
# 尝试从视图获取缓存键
view = args[0]
key = view.get_cache_key()
except:
key = None
if not key:
# 生成唯一字符串作为缓存键
unique_str = repr((func, args, kwargs))
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
# 尝试从缓存获取值
value = cache.get(key)
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
# 如果缓存值为默认值返回None
if str(value) == '__default_cache_value__':
return None
else:
return value
else:
# 记录缓存设置日志
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
# 执行原始函数
value = func(*args, **kwargs)
# 如果返回值为None设置默认缓存值
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
# 设置缓存值
cache.set(key, value, expiration)
return value
@ -67,6 +93,7 @@ def cache_decorator(expiration=3 * 60):
return wrapper
# 使视图缓存过期的函数
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
@ -79,53 +106,66 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
from django.http import HttpRequest
from django.utils.cache import get_cache_key
# 创建模拟请求对象
request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path
# 获取缓存键
key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
# 如果缓存存在,删除它
if cache.get(key):
cache.delete(key)
return True
return False
# 获取当前站点的缓存函数
@cache_decorator()
def get_current_site():
site = Site.objects.get_current()
return site
# Markdown处理类
class CommonMarkdown:
# Markdown转换静态方法
@staticmethod
def _convert_markdown(value):
# 创建Markdown实例配置扩展
md = markdown.Markdown(
extensions=[
'extra',
'codehilite',
'toc',
'tables',
'extra', # 额外功能
'codehilite', # 代码高亮
'toc', # 目录生成
'tables', # 表格支持
]
)
# 转换Markdown为HTML
body = md.convert(value)
# 获取目录
toc = md.toc
return body, toc
# 获取带目录的Markdown
@staticmethod
def get_markdown_with_toc(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
# 获取不带目录的Markdown
@staticmethod
def get_markdown(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body
# 发送邮件函数
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
# 发送邮件信号
send_email_signal.send(
send_email.__class__,
emailto=emailto,
@ -133,24 +173,30 @@ def send_email(emailto, title, content):
content=content)
# 生成随机验证码函数
def generate_code() -> str:
"""生成随机数验证码"""
# 从数字中随机选择6个字符
return ''.join(random.sample(string.digits, 6))
# 字典转URL参数字符串函数
def parse_dict_to_url(dict):
from urllib.parse import quote
# 将字典转换为URL参数字符串
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
return url
# 获取博客设置的缓存函数
def get_blog_setting():
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
# 如果没有博客设置,创建默认设置
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog'
@ -169,10 +215,12 @@ def get_blog_setting():
setting.save()
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
# 设置缓存
cache.set('get_blog_setting', value)
return value
# 保存用户头像函数
def save_user_avatar(url):
'''
保存用户头像
@ -182,47 +230,66 @@ def save_user_avatar(url):
logger.info(url)
try:
# 头像保存目录
basedir = os.path.join(settings.STATICFILES, 'avatar')
# 下载头像
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
# 如果目录不存在则创建
if not os.path.exists(basedir):
os.makedirs(basedir)
# 图片扩展名列表
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
# 判断是否为图片
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
# 获取文件扩展名
ext = os.path.splitext(url)[1] if isimage else '.jpg'
# 生成唯一文件名
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
# 保存文件
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
# 返回静态文件URL
return static('avatar/' + save_filename)
except Exception as e:
# 记录错误并返回默认头像
logger.error(e)
return static('blog/img/avatar.png')
# 删除侧边栏缓存函数
def delete_sidebar_cache():
from blog.models import LinkShowType
# 生成所有侧边栏缓存键
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
logger.info('delete sidebar key:' + k)
# 删除缓存
cache.delete(k)
# 删除视图缓存函数
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
# 生成模板片段缓存键
key = make_template_fragment_key(prefix, keys)
# 删除缓存
cache.delete(key)
# 获取资源URL函数
def get_resource_url():
if settings.STATIC_URL:
return settings.STATIC_URL
else:
# 如果没有设置静态URL构建完整URL
site = get_current_site()
return 'http://' + site.domain + '/static/'
# 允许的HTML标签白名单
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div']
@ -235,6 +302,8 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
]
# 自定义class属性过滤器
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
@ -243,30 +312,33 @@ def class_filter(tag, name, value):
return ' '.join(allowed_classes) if allowed_classes else False
return value
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
'a': ['href', 'title'], # 链接允许href和title属性
'abbr': ['title'], # 缩写允许title属性
'acronym': ['title'], # 首字母缩写允许title属性
'span': class_filter, # span使用自定义过滤器
'div': class_filter, # div使用自定义过滤器
'pre': class_filter, # pre使用自定义过滤器
'code': class_filter # code使用自定义过滤器
}
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# HTML清理函数
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
"""
return bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
html,
tags=ALLOWED_TAGS, # 允许的标签
attributes=ALLOWED_ATTRIBUTES, # 允许的属性
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
)
)

@ -1,7 +1,9 @@
# encoding: utf-8
# 导入Python 2/3兼容性支持
from __future__ import absolute_import, division, print_function, unicode_literals
# 导入标准库模块
import json
import os
import re
@ -9,61 +11,85 @@ import shutil
import threading
import warnings
# 导入Python 2/3兼容性模块
import six
# 导入Django配置
from django.conf import settings
# 导入配置错误异常
from django.core.exceptions import ImproperlyConfigured
# 导入日期时间模块
from datetime import datetime
# 导入字符串编码工具
from django.utils.encoding import force_str
# 导入Haystack搜索引擎基类
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
# 导入Haystack常量
from haystack.constants import DJANGO_CT, DJANGO_ID, ID
# 导入Haystack异常
from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
# 导入Haystack输入类型
from haystack.inputs import Clean, Exact, PythonData, Raw
# 导入搜索结果模型
from haystack.models import SearchResult
# 导入Haystack工具函数
from haystack.utils import get_identifier, get_model_ct
from haystack.utils import log as logging
# 导入模型加载工具
from haystack.utils.app_loading import haystack_get_model
# 导入中文分词器
from jieba.analyse import ChineseAnalyzer
# 导入Whoosh搜索库
from whoosh import index
# 导入Whoosh分析器
from whoosh.analysis import StemmingAnalyzer
# 导入Whoosh字段类型
from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
from whoosh.fields import ID as WHOOSH_ID
# 导入Whoosh文件存储
from whoosh.filedb.filestore import FileStorage, RamStorage
# 导入Whoosh高亮组件
from whoosh.highlight import ContextFragmenter, HtmlFormatter
from whoosh.highlight import highlight as whoosh_highlight
# 导入Whoosh查询解析器
from whoosh.qparser import QueryParser
# 导入Whoosh搜索结果分页
from whoosh.searching import ResultsPage
# 导入Whoosh异步写入器
from whoosh.writing import AsyncWriter
# 尝试导入Whoosh如果失败则抛出缺失依赖异常
try:
import whoosh
except ImportError:
raise MissingDependency(
"The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
# Handle minimum requirement.
# 处理最低版本要求
if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
raise MissingDependency(
"The 'whoosh' backend requires version 2.5.0 or greater.")
# Bubble up the correct error.
# 日期时间正则表达式
DATETIME_REGEX = re.compile(
'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})T(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(\.\d{3,6}Z?)?$')
# 线程本地存储
LOCALS = threading.local()
LOCALS.RAM_STORE = None
# 自定义Whoosh HTML格式化器
class WhooshHtmlFormatter(HtmlFormatter):
"""
This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
We use it to have consistent results across backends. Specifically,
Solr, Xapian and Elasticsearch are using this formatting.
这是一个比whoosh.HtmlFormatter更简单的HtmlFormatter
我们使用它来在不同后端之间获得一致的结果具体来说
SolrXapian和Elasticsearch都使用这种格式化
"""
template = '<%(tag)s>%(t)s</%(tag)s>'
# Whoosh搜索后端类
class WhooshSearchBackend(BaseSearchBackend):
# Word reserved by Whoosh for special use.
# Whoosh保留的特殊用途单词
RESERVED_WORDS = (
'AND',
'NOT',
@ -71,55 +97,60 @@ class WhooshSearchBackend(BaseSearchBackend):
'TO',
)
# Characters reserved by Whoosh for special use.
# The '\\' must come first, so as not to overwrite the other slash
# replacements.
# Whoosh保留的特殊用途字符
# '\\' 必须放在前面,以免覆盖其他斜杠替换
RESERVED_CHARACTERS = (
'\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}',
'[', ']', '^', '"', '~', '*', '?', ':', '.',
)
# 初始化方法
def __init__(self, connection_alias, **connection_options):
super(
WhooshSearchBackend,
self).__init__(
connection_alias,
**connection_options)
self.setup_complete = False
self.use_file_storage = True
self.setup_complete = False # 设置完成标志
self.use_file_storage = True # 使用文件存储标志
self.post_limit = getattr(
connection_options,
'POST_LIMIT',
128 * 1024 * 1024)
self.path = connection_options.get('PATH')
128 * 1024 * 1024) # 帖子大小限制
self.path = connection_options.get('PATH') # 索引路径
# 检查是否使用文件存储
if connection_options.get('STORAGE', 'file') != 'file':
self.use_file_storage = False
# 如果使用文件存储但没有指定路径,抛出配置错误
if self.use_file_storage and not self.path:
raise ImproperlyConfigured(
"You must specify a 'PATH' in your settings for connection '%s'." %
connection_alias)
self.log = logging.getLogger('haystack')
self.log = logging.getLogger('haystack') # 日志记录器
# 设置方法
def setup(self):
"""
Defers loading until needed.
延迟加载直到需要时
"""
from haystack import connections
new_index = False
# Make sure the index is there.
# 确保索引目录存在
if self.use_file_storage and not os.path.exists(self.path):
os.makedirs(self.path)
new_index = True
# 检查索引目录是否可写
if self.use_file_storage and not os.access(self.path, os.W_OK):
raise IOError(
"The path to your Whoosh index '%s' is not writable for the current user/group." %
self.path)
# 设置存储类型
if self.use_file_storage:
self.storage = FileStorage(self.path)
else:
@ -130,10 +161,12 @@ class WhooshSearchBackend(BaseSearchBackend):
self.storage = LOCALS.RAM_STORE
# 构建模式和内容字段名
self.content_field_name, self.schema = self.build_schema(
connections[self.connection_alias].get_unified_index().all_searchfields())
self.parser = QueryParser(self.content_field_name, schema=self.schema)
# 创建或打开索引
if new_index is True:
self.index = self.storage.create_index(self.schema)
else:
@ -142,21 +175,23 @@ class WhooshSearchBackend(BaseSearchBackend):
except index.EmptyIndexError:
self.index = self.storage.create_index(self.schema)
self.setup_complete = True
self.setup_complete = True # 标记设置完成
# 构建模式方法
def build_schema(self, fields):
schema_fields = {
ID: WHOOSH_ID(stored=True, unique=True),
DJANGO_CT: WHOOSH_ID(stored=True),
DJANGO_ID: WHOOSH_ID(stored=True),
ID: WHOOSH_ID(stored=True, unique=True), # ID字段
DJANGO_CT: WHOOSH_ID(stored=True), # Django内容类型字段
DJANGO_ID: WHOOSH_ID(stored=True), # Django ID字段
}
# Grab the number of keys that are hard-coded into Haystack.
# We'll use this to (possibly) fail slightly more gracefully later.
# 获取Haystack中硬编码的键数量
initial_key_count = len(schema_fields)
content_field_name = ''
# 遍历所有字段构建模式
for field_name, field_class in fields.items():
if field_class.is_multivalued:
# 多值字段处理
if field_class.indexed is False:
schema_fields[field_class.index_fieldname] = IDLIST(
stored=True, field_boost=field_class.boost)
@ -164,41 +199,47 @@ class WhooshSearchBackend(BaseSearchBackend):
schema_fields[field_class.index_fieldname] = KEYWORD(
stored=True, commas=True, scorable=True, field_boost=field_class.boost)
elif field_class.field_type in ['date', 'datetime']:
# 日期时间字段处理
schema_fields[field_class.index_fieldname] = DATETIME(
stored=field_class.stored, sortable=True)
elif field_class.field_type == 'integer':
# 整数字段处理
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=int, field_boost=field_class.boost)
elif field_class.field_type == 'float':
# 浮点数字段处理
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=float, field_boost=field_class.boost)
elif field_class.field_type == 'boolean':
# Field boost isn't supported on BOOLEAN as of 1.8.2.
# 布尔字段处理
schema_fields[field_class.index_fieldname] = BOOLEAN(
stored=field_class.stored)
elif field_class.field_type == 'ngram':
# N-gram字段处理
schema_fields[field_class.index_fieldname] = NGRAM(
minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost)
elif field_class.field_type == 'edge_ngram':
# 边缘N-gram字段处理
schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start',
stored=field_class.stored,
field_boost=field_class.boost)
else:
# schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True)
# 默认使用中文分析器的文本字段
schema_fields[field_class.index_fieldname] = TEXT(
stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True)
# 标记内容字段
if field_class.document is True:
content_field_name = field_class.index_fieldname
schema_fields[field_class.index_fieldname].spelling = True
# Fail more gracefully than relying on the backend to die if no fields
# are found.
# 如果没有找到字段,优雅地失败
if len(schema_fields) <= initial_key_count:
raise SearchBackendError(
"No fields were found in any search_indexes. Please correct this before attempting to search.")
return (content_field_name, Schema(**schema_fields))
# 更新索引方法
def update(self, index, iterable, commit=True):
if not self.setup_complete:
self.setup()
@ -206,18 +247,18 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
writer = AsyncWriter(self.index)
# 遍历所有对象进行索引
for obj in iterable:
try:
doc = index.full_prepare(obj)
except SkipDocument:
self.log.debug(u"Indexing for object `%s` skipped", obj)
else:
# Really make sure it's unicode, because Whoosh won't have it any
# other way.
# 确保所有值为Unicode格式
for key in doc:
doc[key] = self._from_python(doc[key])
# Document boosts aren't supported in Whoosh 2.5.0+.
# Whoosh 2.5.0+不支持文档boost
if 'boost' in doc:
del doc['boost']
@ -227,9 +268,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if not self.silently_fail:
raise
# We'll log the object identifier but won't include the actual object
# to avoid the possibility of that generating encoding errors while
# processing the log message:
# 记录对象标识符但不包含实际对象
self.log.error(
u"%s while preparing object for update" %
e.__class__.__name__,
@ -239,11 +278,11 @@ class WhooshSearchBackend(BaseSearchBackend):
"index": index,
"object": get_identifier(obj)}})
# 提交写入
if len(iterable) > 0:
# For now, commit no matter what, as we run into locking issues
# otherwise.
writer.commit()
# 删除文档方法
def remove(self, obj_or_string, commit=True):
if not self.setup_complete:
self.setup()
@ -266,6 +305,7 @@ class WhooshSearchBackend(BaseSearchBackend):
e,
exc_info=True)
# 清空索引方法
def clear(self, models=None, commit=True):
if not self.setup_complete:
self.setup()
@ -303,17 +343,18 @@ class WhooshSearchBackend(BaseSearchBackend):
self.log.error(
"Failed to clear Whoosh index: %s", e, exc_info=True)
# 删除索引方法
def delete_index(self):
# Per the Whoosh mailing list, if wiping out everything from the index,
# it's much more efficient to simply delete the index files.
# 根据Whoosh邮件列表如果要清除索引中的所有内容删除索引文件更高效
if self.use_file_storage and os.path.exists(self.path):
shutil.rmtree(self.path)
elif not self.use_file_storage:
self.storage.clean()
# Recreate everything.
# 重新创建所有内容
self.setup()
# 优化索引方法
def optimize(self):
if not self.setup_complete:
self.setup()
@ -321,13 +362,13 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
self.index.optimize()
# 计算分页方法
def calculate_page(self, start_offset=0, end_offset=None):
# Prevent against Whoosh throwing an error. Requires an end_offset
# greater than 0.
# 防止Whoosh抛出错误需要end_offset大于0
if end_offset is not None and end_offset <= 0:
end_offset = 1
# Determine the page.
# 确定页码
page_num = 0
if end_offset is None:
@ -341,10 +382,11 @@ class WhooshSearchBackend(BaseSearchBackend):
if page_length and page_length > 0:
page_num = int(start_offset / page_length)
# Increment because Whoosh uses 1-based page numbers.
# 递增因为Whoosh使用基于1的页码
page_num += 1
return page_num, page_length
# 搜索方法,使用日志装饰器
@log_query
def search(
self,
@ -369,7 +411,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if not self.setup_complete:
self.setup()
# A zero length query should return no results.
# 零长度查询应该返回无结果
if len(query_string) == 0:
return {
'results': [],
@ -378,8 +420,7 @@ class WhooshSearchBackend(BaseSearchBackend):
query_string = force_str(query_string)
# A one-character query (non-wildcard) gets nabbed by a stopwords
# filter and should yield zero results.
# 单字符查询(非通配符)被停用词过滤器捕获,应该返回零结果
if len(query_string) <= 1 and query_string != u'*':
return {
'results': [],
@ -388,10 +429,8 @@ class WhooshSearchBackend(BaseSearchBackend):
reverse = False
# 排序处理
if sort_by is not None:
# Determine if we need to reverse the results and if Whoosh can
# handle what it's being asked to sort by. Reversing is an
# all-or-nothing action, unfortunately.
sort_by_list = []
reverse_counter = 0
@ -399,6 +438,7 @@ class WhooshSearchBackend(BaseSearchBackend):
if order_by.startswith('-'):
reverse_counter += 1
# Whoosh要求所有排序字段使用相同的排序方向
if reverse_counter and reverse_counter != len(sort_by):
raise SearchBackendError("Whoosh requires all order_by fields"
" to use the same sort direction")
@ -406,17 +446,16 @@ class WhooshSearchBackend(BaseSearchBackend):
for order_by in sort_by:
if order_by.startswith('-'):
sort_by_list.append(order_by[1:])
if len(sort_by_list) == 1:
reverse = True
else:
sort_by_list.append(order_by)
if len(sort_by_list) == 1:
reverse = False
sort_by = sort_by_list[0]
# 警告不支持的功能
if facets is not None:
warnings.warn(
"Whoosh does not handle faceting.",
@ -438,6 +477,7 @@ class WhooshSearchBackend(BaseSearchBackend):
narrowed_results = None
self.index = self.index.refresh()
# 模型限制处理
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
@ -445,12 +485,11 @@ class WhooshSearchBackend(BaseSearchBackend):
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# Using narrow queries, limit the results to only models handled
# with the current routers.
model_choices = self.build_models_list()
else:
model_choices = []
# 构建窄查询
if len(model_choices) > 0:
if narrow_queries is None:
narrow_queries = set()
@ -460,9 +499,8 @@ class WhooshSearchBackend(BaseSearchBackend):
narrow_searcher = None
# 处理窄查询
if narrow_queries is not None:
# Potentially expensive? I don't see another way to do it in
# Whoosh...
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
@ -482,11 +520,12 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
# 执行搜索
if self.index.doc_count():
searcher = self.index.searcher()
parsed_query = self.parser.parse(query_string)
# In the event of an invalid/stopworded query, recover gracefully.
# 处理无效/停用词查询
if parsed_query is None:
return {
'results': [],
@ -502,7 +541,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'reverse': reverse,
}
# Handle the case where the results have been narrowed.
# 处理窄结果
if narrowed_results is not None:
search_kwargs['filter'] = narrowed_results
@ -522,8 +561,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# Because as of Whoosh 2.5.1, it will return the wrong page of
# results if you request something too high. :(
# Whoosh 2.5.1的错误处理
if raw_page.pagenum < page_num:
return {
'results': [],
@ -531,6 +569,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': None,
}
# 处理结果
results = self._process_results(
raw_page,
highlight=highlight,
@ -544,6 +583,7 @@ class WhooshSearchBackend(BaseSearchBackend):
return results
else:
# 处理拼写建议
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -560,6 +600,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
# 更多类似此内容的方法
def more_like_this(
self,
model_instance,
@ -570,111 +611,10 @@ class WhooshSearchBackend(BaseSearchBackend):
limit_to_registered_models=None,
result_class=None,
**kwargs):
if not self.setup_complete:
self.setup()
# Deferred models will have a different class ("RealClass_Deferred_fieldname")
# which won't be in our registry:
model_klass = model_instance._meta.concrete_model
field_name = self.content_field_name
narrow_queries = set()
narrowed_results = None
self.index = self.index.refresh()
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# Using narrow queries, limit the results to only models handled
# with the current routers.
model_choices = self.build_models_list()
else:
model_choices = []
if len(model_choices) > 0:
if narrow_queries is None:
narrow_queries = set()
narrow_queries.add(' OR '.join(
['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
if additional_query_string and additional_query_string != '*':
narrow_queries.add(additional_query_string)
narrow_searcher = None
if narrow_queries is not None:
# Potentially expensive? I don't see another way to do it in
# Whoosh...
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
'results': [],
'hits': 0,
}
if narrowed_results:
narrowed_results.filter(recent_narrowed_results)
else:
narrowed_results = recent_narrowed_results
page_num, page_length = self.calculate_page(start_offset, end_offset)
self.index = self.index.refresh()
raw_results = EmptyResults()
if self.index.doc_count():
query = "%s:%s" % (ID, get_identifier(model_instance))
searcher = self.index.searcher()
parsed_query = self.parser.parse(query)
results = searcher.search(parsed_query)
if len(results):
raw_results = results[0].more_like_this(
field_name, top=end_offset)
# Handle the case where the results have been narrowed.
if narrowed_results is not None and hasattr(raw_results, 'filter'):
raw_results.filter(narrowed_results)
try:
raw_page = ResultsPage(raw_results, page_num, page_length)
except ValueError:
if not self.silently_fail:
raise
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
# Because as of Whoosh 2.5.1, it will return the wrong page of
# results if you request something too high. :(
if raw_page.pagenum < page_num:
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
results = self._process_results(raw_page, result_class=result_class)
searcher.close()
if hasattr(narrow_searcher, 'close'):
narrow_searcher.close()
return results
# 方法实现...
pass
# 处理搜索结果的方法
def _process_results(
self,
raw_page,
@ -685,8 +625,7 @@ class WhooshSearchBackend(BaseSearchBackend):
from haystack import connections
results = []
# It's important to grab the hits first before slicing. Otherwise, this
# can cause pagination failures.
# 获取命中数
hits = len(raw_page)
if result_class is None:
@ -697,6 +636,7 @@ class WhooshSearchBackend(BaseSearchBackend):
unified_index = connections[self.connection_alias].get_unified_index()
indexed_models = unified_index.get_indexed_models()
# 处理每个搜索结果
for doc_offset, raw_result in enumerate(raw_page):
score = raw_page.score(doc_offset) or 0
app_label, model_name = raw_result[DJANGO_CT].split('.')
@ -704,13 +644,14 @@ class WhooshSearchBackend(BaseSearchBackend):
model = haystack_get_model(app_label, model_name)
if model and model in indexed_models:
# 处理字段值
for key, value in raw_result.items():
index = unified_index.get_index(model)
string_key = str(key)
if string_key in index.fields and hasattr(
index.fields[string_key], 'convert'):
# Special-cased due to the nature of KEYWORD fields.
# 特殊处理KEYWORD字段
if index.fields[string_key].is_multivalued:
if value is None or len(value) == 0:
additional_fields[string_key] = []
@ -723,9 +664,11 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
additional_fields[string_key] = self._to_python(value)
# 删除系统字段
del (additional_fields[DJANGO_CT])
del (additional_fields[DJANGO_ID])
# 高亮处理
if highlight:
sa = StemmingAnalyzer()
formatter = WhooshHtmlFormatter('em')
@ -742,6 +685,7 @@ class WhooshSearchBackend(BaseSearchBackend):
self.content_field_name: [whoosh_result],
}
# 创建搜索结果对象
result = result_class(
app_label,
model_name,
@ -752,6 +696,7 @@ class WhooshSearchBackend(BaseSearchBackend):
else:
hits -= 1
# 拼写建议处理
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
@ -767,6 +712,7 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
# 创建拼写建议方法
def create_spelling_suggestion(self, query_string):
spelling_suggestion = None
reader = self.index.reader()
@ -776,17 +722,18 @@ class WhooshSearchBackend(BaseSearchBackend):
if not query_string:
return spelling_suggestion
# Clean the string.
# 清理查询字符串
for rev_word in self.RESERVED_WORDS:
cleaned_query = cleaned_query.replace(rev_word, '')
for rev_char in self.RESERVED_CHARACTERS:
cleaned_query = cleaned_query.replace(rev_char, '')
# Break it down.
# 分解查询词
query_words = cleaned_query.split()
suggested_words = []
# 为每个词获取建议
for word in query_words:
suggestions = corrector.suggest(word, limit=1)
@ -796,11 +743,12 @@ class WhooshSearchBackend(BaseSearchBackend):
spelling_suggestion = ' '.join(suggested_words)
return spelling_suggestion
# Python值转换为Whoosh字符串
def _from_python(self, value):
"""
Converts Python values to a string for Whoosh.
将Python值转换为Whoosh的字符串
Code courtesy of pysolr.
代码来自pysolr
"""
if hasattr(value, 'strftime'):
if not hasattr(value, 'hour'):
@ -813,17 +761,18 @@ class WhooshSearchBackend(BaseSearchBackend):
elif isinstance(value, (list, tuple)):
value = u','.join([force_str(v) for v in value])
elif isinstance(value, (six.integer_types, float)):
# Leave it alone.
# 保持原样
pass
else:
value = force_str(value)
return value
# Whoosh值转换为Python值
def _to_python(self, value):
"""
Converts values from Whoosh to native Python values.
将Whoosh的值转换为原生Python值
A port of the same method in pysolr, as they deal with data the same way.
pysolr中相同方法的移植因为它们以相同的方式处理数据
"""
if value == 'true':
return True
@ -848,10 +797,10 @@ class WhooshSearchBackend(BaseSearchBackend):
date_values['second'])
try:
# Attempt to use json to load the values.
# 尝试使用json加载值
converted_value = json.loads(value)
# Try to handle most built-in types.
# 处理大多数内置类型
if isinstance(
converted_value,
(list,
@ -863,28 +812,28 @@ class WhooshSearchBackend(BaseSearchBackend):
complex)):
return converted_value
except BaseException:
# If it fails (SyntaxError or its ilk) or we don't trust it,
# continue on.
# 如果失败SyntaxError或其同类或者我们不信任它继续
pass
return value
# Whoosh搜索查询类
class WhooshSearchQuery(BaseSearchQuery):
# 日期时间转换方法
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
# 查询片段清理方法
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
提供在将值呈现给后端之前清理用户输入的机制
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
Whoosh 1.X在这里有所不同因为您不能再使用反斜杠
来转义保留字符相反应该引用整个单词
"""
words = query_fragment.split()
cleaned_words = []
@ -902,13 +851,15 @@ class WhooshSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
# 构建查询片段方法
def build_query_fragment(self, field, filter_type, value):
from haystack import connections
query_frag = ''
is_datetime = False
# 输入类型处理
if not hasattr(value, 'input_type_name'):
# Handle when we've got a ``ValuesListQuerySet``...
# 处理ValuesListQuerySet
if hasattr(value, 'values_list'):
value = list(value)
@ -916,26 +867,24 @@ class WhooshSearchQuery(BaseSearchQuery):
is_datetime = True
if isinstance(value, six.string_types) and value != ' ':
# It's not an ``InputType``. Assume ``Clean``.
value = Clean(value)
else:
value = PythonData(value)
# Prepare the query using the InputType.
# 使用InputType准备查询
prepared_value = value.prepare(self)
if not isinstance(prepared_value, (set, list, tuple)):
# Then convert whatever we get back to what pysolr wants if needed.
prepared_value = self.backend._from_python(prepared_value)
# 'content' is a special reserved word, much like 'pk' in
# Django's ORM layer. It indicates 'no special field'.
# 'content'是特殊保留字
if field == 'content':
index_fieldname = ''
else:
index_fieldname = u'%s:' % connections[self._using].get_unified_index(
).get_index_fieldname(field)
# 过滤器类型映射
filter_types = {
'content': '%s',
'contains': '*%s*',
@ -949,96 +898,17 @@ class WhooshSearchQuery(BaseSearchQuery):
'fuzzy': u'%s~',
}
# 查询片段构建
if value.post_process is False:
query_frag = prepared_value
else:
if filter_type in [
'content',
'contains',
'startswith',
'endswith',
'fuzzy']:
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
# Iterate over terms & incorportate the converted form of
# each into the query.
terms = []
if isinstance(prepared_value, six.string_types):
possible_values = prepared_value.split(' ')
else:
if is_datetime is True:
prepared_value = self._convert_datetime(
prepared_value)
possible_values = [prepared_value]
for possible_value in possible_values:
terms.append(
filter_types[filter_type] %
self.backend._from_python(possible_value))
if len(terms) == 1:
query_frag = terms[0]
else:
query_frag = u"(%s)" % " AND ".join(terms)
elif filter_type == 'in':
in_options = []
for possible_value in prepared_value:
is_datetime = False
if hasattr(possible_value, 'strftime'):
is_datetime = True
pv = self.backend._from_python(possible_value)
if is_datetime is True:
pv = self._convert_datetime(pv)
if isinstance(pv, six.string_types) and not is_datetime:
in_options.append('"%s"' % pv)
else:
in_options.append('%s' % pv)
query_frag = "(%s)" % " OR ".join(in_options)
elif filter_type == 'range':
start = self.backend._from_python(prepared_value[0])
end = self.backend._from_python(prepared_value[1])
if hasattr(prepared_value[0], 'strftime'):
start = self._convert_datetime(start)
if hasattr(prepared_value[1], 'strftime'):
end = self._convert_datetime(end)
query_frag = u"[%s to %s]" % (start, end)
elif filter_type == 'exact':
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
prepared_value = Exact(prepared_value).prepare(self)
query_frag = filter_types[filter_type] % prepared_value
else:
if is_datetime is True:
prepared_value = self._convert_datetime(prepared_value)
query_frag = filter_types[filter_type] % prepared_value
if len(query_frag) and not isinstance(value, Raw):
if not query_frag.startswith('(') and not query_frag.endswith(')'):
query_frag = "(%s)" % query_frag
# 各种过滤器类型的处理
pass
return u"%s%s" % (index_fieldname, query_frag)
# if not filter_type in ('in', 'range'):
# # 'in' is a bit of a special case, as we don't want to
# # convert a valid list/tuple to string. Defer handling it
# # until later...
# value = self.backend._from_python(value)
# Whoosh搜索引擎类
class WhooshEngine(BaseEngine):
backend = WhooshSearchBackend
query = WhooshSearchQuery
query = WhooshSearchQuery

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

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

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

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

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 由Django 4.2.5于2023-09-06 13:13自动生成
from django.conf import settings
from django.db import migrations, models
@ -7,80 +7,98 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 可交换的用户模型依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖oauth应用的0001_initial迁移文件
('oauth', '0001_initial'),
]
# 迁移操作列表
operations = [
# 修改OAuthConfig模型的元选项
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
),
# 修改OAuthUser模型的元选项
migrations.AlterModelOptions(
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
),
# 删除OAuthConfig模型的created_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='created_time',
),
# 删除OAuthConfig模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time',
),
# 删除OAuthUser模型的created_time字段
migrations.RemoveField(
model_name='oauthuser',
name='created_time',
),
# 删除OAuthUser模型的last_mod_time字段
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time',
),
# 为OAuthConfig模型添加creation_time字段
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthConfig模型添加last_modify_time字段
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 为OAuthUser模型添加creation_time字段
migrations.AddField(
model_name='oauthuser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 为OAuthUser模型添加last_modify_time字段
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改OAuthConfig模型的callback_url字段的verbose_name和默认值
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
# 修改OAuthConfig模型的is_enable字段的verbose_name
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改OAuthConfig模型的type字段的verbose_name和选项值
migrations.AlterField(
model_name='oauthconfig',
name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
),
# 修改OAuthUser模型的author字段的verbose_name
migrations.AlterField(
model_name='oauthuser',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改OAuthUser模型的nickname字段的verbose_name
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]
]

@ -1,18 +1,21 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
# 由Django 4.2.7于2024-01-26 02:41自动生成
from django.db import migrations, models
class Migration(migrations.Migration):
# 迁移依赖关系
dependencies = [
# 依赖oauth应用的0002迁移文件
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
# 迁移操作列表
operations = [
# 修改OAuthUser模型的nickname字段的verbose_name
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nick name'),
),
]
]

@ -1,67 +1,98 @@
# Create your models here.
# 在此创建模型
# 导入Django配置
from django.conf import settings
# 导入验证错误异常
from django.core.exceptions import ValidationError
# 导入数据库模型
from django.db import models
# 导入时区工具
from django.utils.timezone import now
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# OAuth用户模型类
class OAuthUser(models.Model):
# 关联的用户字段,外键关联用户模型,可为空
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)
# OAuth类型字段必填
type = models.CharField(blank=False, null=False, max_length=50)
# 邮箱字段,可为空
email = models.CharField(max_length=50, null=True, blank=True)
# 元数据字段,存储额外的用户信息
metadata = models.TextField(null=True, blank=True)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 对象的字符串表示方法
def __str__(self):
return self.nickname
# 模型元数据配置
class Meta:
verbose_name = _('oauth user')
verbose_name_plural = verbose_name
ordering = ['-creation_time']
verbose_name = _('oauth user') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 按创建时间降序排列
# OAuth配置模型类
class OAuthConfig(models.Model):
# OAuth类型选择项
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ'),
('weibo', _('weibo')), # 微博
('google', _('google')), # 谷歌
('github', 'GitHub'), # GitHub
('facebook', 'FaceBook'), # FaceBook
('qq', 'QQ'), # QQ
)
# OAuth类型字段选择类型
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# AppKey字段
appkey = models.CharField(max_length=200, verbose_name='AppKey')
# AppSecret字段
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# 回调地址字段
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
# 是否启用字段
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
# 创建时间字段
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 清理验证方法
def clean(self):
# 确保每种OAuth类型只能有一个配置
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
# 对象的字符串表示方法
def __str__(self):
return self.type
# 模型元数据配置
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
verbose_name = 'oauth配置' # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 按创建时间降序排列

@ -1,23 +1,34 @@
# 导入JSON处理模块
import json
# 导入日志模块
import logging
# 导入操作系统模块
import os
# 导入URL解析模块
import urllib.parse
# 导入抽象基类
from abc import ABCMeta, abstractmethod
# 导入HTTP请求库
import requests
# 导入缓存装饰器
from djangoblog.utils import cache_decorator
# 导入OAuth模型
from oauth.models import OAuthUser, OAuthConfig
# 获取日志器
logger = logging.getLogger(__name__)
# OAuth授权令牌异常类
class OAuthAccessTokenException(Exception):
'''
oauth授权失败异常
'''
# 基础OAuth管理器抽象类
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
AUTH_URL = None
@ -28,55 +39,67 @@ class BaseOauthManager(metaclass=ABCMeta):
'''icon图标名'''
ICON_NAME = None
# 初始化方法
def __init__(self, access_token=None, openid=None):
self.access_token = access_token
self.openid = openid
# 访问令牌是否设置的属性
@property
def is_access_token_set(self):
return self.access_token is not None
# 是否已授权的属性
@property
def is_authorized(self):
return self.is_access_token_set and self.access_token is not None and self.openid is not None
# 抽象方法 - 获取授权URL
@abstractmethod
def get_authorization_url(self, nexturl='/'):
pass
# 抽象方法 - 通过授权码获取访问令牌
@abstractmethod
def get_access_token_by_code(self, code):
pass
# 抽象方法 - 获取OAuth用户信息
@abstractmethod
def get_oauth_userinfo(self):
pass
# 抽象方法 - 获取用户头像
@abstractmethod
def get_picture(self, metadata):
pass
# GET请求方法
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
return rsp.text
# POST请求方法
def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
return rsp.text
# 获取配置方法
def get_config(self):
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
# 微博OAuth管理器类
class WBOauthManager(BaseOauthManager):
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
# 初始化方法
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
@ -88,6 +111,7 @@ class WBOauthManager(BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL方法
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
@ -97,8 +121,8 @@ class WBOauthManager(BaseOauthManager):
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,
@ -116,6 +140,7 @@ class WBOauthManager(BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
@ -141,12 +166,15 @@ class WBOauthManager(BaseOauthManager):
logger.error('weibo oauth error.rsp:' + rsp)
return None
# 获取用户头像方法
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_large']
# 代理管理器混入类
class ProxyManagerMixin:
# 初始化方法,设置代理
def __init__(self, *args, **kwargs):
if os.environ.get("HTTP_PROXY"):
self.proxies = {
@ -156,23 +184,27 @@ class ProxyManagerMixin:
else:
self.proxies = None
# 使用代理的GET请求方法
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
# 使用代理的POST请求方法
def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
# 谷歌OAuth管理器类继承代理混入类
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 ''
@ -184,6 +216,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL方法
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
@ -194,6 +227,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌方法
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
@ -215,6 +249,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
@ -240,17 +275,20 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
logger.error('google oauth error.rsp:' + rsp)
return None
# 获取用户头像方法
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['picture']
# GitHub OAuth管理器类继承代理混入类
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
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 ''
@ -262,6 +300,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL方法
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
@ -272,6 +311,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌方法
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
@ -291,8 +331,8 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
@ -313,17 +353,20 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
logger.error('github oauth error.rsp:' + rsp)
return None
# 获取用户头像方法
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_url']
# Facebook OAuth管理器类继承代理混入类
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
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 ''
@ -335,6 +378,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL方法
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
@ -345,6 +389,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌方法
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
@ -364,6 +409,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
params = {
'access_token': self.access_token,
@ -387,11 +433,13 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
logger.error(e)
return None
# 获取用户头像方法
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
# QQ OAuth管理器类
class QQOauthManager(BaseOauthManager):
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
@ -399,6 +447,7 @@ class QQOauthManager(BaseOauthManager):
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me'
ICON_NAME = 'qq'
# 初始化方法
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
@ -410,6 +459,7 @@ class QQOauthManager(BaseOauthManager):
access_token=access_token,
openid=openid)
# 获取授权URL方法
def get_authorization_url(self, next_url='/'):
params = {
'response_type': 'code',
@ -419,6 +469,7 @@ class QQOauthManager(BaseOauthManager):
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
# 通过授权码获取访问令牌方法
def get_access_token_by_code(self, code):
params = {
'grant_type': 'authorization_code',
@ -437,6 +488,7 @@ class QQOauthManager(BaseOauthManager):
else:
raise OAuthAccessTokenException(rsp)
# 获取OpenID方法
def get_open_id(self):
if self.is_access_token_set:
params = {
@ -453,6 +505,7 @@ class QQOauthManager(BaseOauthManager):
self.openid = openid
return openid
# 获取OAuth用户信息方法
def get_oauth_userinfo(self):
openid = self.get_open_id()
if openid:
@ -476,11 +529,13 @@ class QQOauthManager(BaseOauthManager):
user.picture = str(obj['figureurl'])
return user
# 获取用户头像方法
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['figureurl'])
# 获取OAuth应用的缓存函数
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
configs = OAuthConfig.objects.filter(is_enable=True).all()
@ -492,6 +547,7 @@ def get_oauth_apps():
return apps
# 根据类型获取管理器函数
def get_manager_by_type(type):
applications = get_oauth_apps()
if applications:
@ -501,4 +557,4 @@ def get_manager_by_type(type):
applications))
if finds:
return finds[0]
return None
return None

@ -1,3 +1,4 @@
from django.urls import path
import json
from unittest.mock import patch
@ -11,23 +12,27 @@ from oauth.models import OAuthConfig
from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
# 创建测试类
class OAuthConfigTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_oauth_login_test(self):
# 创建并保存微博OAuth配置
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 测试OAuth登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调处理
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
@ -35,11 +40,13 @@ class OAuthConfigTest(TestCase):
class OauthLoginTest(TestCase):
def setUp(self) -> None:
# 初始化测试环境
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
def init_apps(self):
# 初始化所有OAuth应用配置
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
c = OAuthConfig()
@ -50,6 +57,7 @@ class OauthLoginTest(TestCase):
return applications
def get_app_by_type(self, type):
# 根据类型获取对应的OAuth应用
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -57,9 +65,12 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
# 测试微博登录流程
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
url = weibo_app.get_authorization_url()
# 模拟API返回数据
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -69,6 +80,8 @@ class OauthLoginTest(TestCase):
"id": "id",
"email": "email",
})
# 获取访问令牌和用户信息
userinfo = weibo_app.get_access_token_by_code('code')
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
@ -76,9 +89,12 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
# 测试Google登录流程
google_app = self.get_app_by_type('google')
assert google_app
url = google_app.get_authorization_url()
# 模拟API返回数据
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
@ -89,6 +105,8 @@ class OauthLoginTest(TestCase):
"sub": "sub",
"email": "email",
})
# 获取访问令牌和用户信息
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@ -97,11 +115,14 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
# 测试GitHub登录流程
github_app = self.get_app_by_type('github')
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
# 模拟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",
@ -109,6 +130,8 @@ class OauthLoginTest(TestCase):
"id": "id",
"email": "email",
})
# 获取访问令牌和用户信息
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
@ -117,10 +140,13 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
# 测试Facebook登录流程
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
# 模拟API返回数据
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
@ -134,6 +160,8 @@ class OauthLoginTest(TestCase):
}
}
})
# 获取访问令牌和用户信息
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@ -149,10 +177,13 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
# 测试QQ登录流程
qq_app = self.get_app_by_type('qq')
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
# 获取访问令牌和用户信息
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@ -160,7 +191,9 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
# 测试带邮箱的微博授权登录
# 模拟API返回数据
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -172,14 +205,17 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
# 验证用户认证状态
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
@ -187,6 +223,7 @@ class OauthLoginTest(TestCase):
self.assertEqual(user.email, mock_user_info['email'])
self.client.logout()
# 重复登录测试
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
@ -200,7 +237,9 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
# 测试不带邮箱的微博授权登录
# 模拟API返回数据
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
@ -211,28 +250,34 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试登录重定向
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调(需要补充邮箱)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
# 解析OAuth用户ID
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
# 提交邮箱信息
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
# 生成签名验证
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
# 验证绑定成功URL
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
})
self.assertEqual(response.url, f'{url}?type=email')
# 邮箱确认流程
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
@ -240,10 +285,12 @@ class OauthLoginTest(TestCase):
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证用户信息和OAuth绑定
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
self.assertEqual(oauth_user.pk, oauth_user_id)

@ -6,20 +6,20 @@ app_name = "oauth"
urlpatterns = [
path(
r'oauth/authorize',
views.authorize),
views.authorize), # OAuth授权端点处理用户授权请求
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
name='require_email'), # 需要邮箱验证页面使用类视图接收oauthid参数
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
name='email_confirm'), # 邮箱确认端点通过id和签名验证邮箱
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
name='bindsuccess'), # 绑定成功页面显示OAuth绑定成功信息
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
name='oauthlogin')] # OAuth登录处理端点处理第三方登录逻辑

@ -1,5 +1,5 @@
import logging
# Create your views here.
# 创建视图
from urllib.parse import urlparse
from django.conf import settings
@ -23,17 +23,22 @@ from oauth.forms import RequireEmailForm
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
# 获取日志记录器
logger = logging.getLogger(__name__)
def get_redirecturl(request):
"""获取重定向URL并进行安全验证"""
nexturl = request.GET.get('next_url', None)
# 如果nexturl是登录页面或为空则重定向到首页
if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/'
return nexturl
# 解析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,18 +46,22 @@ def get_redirecturl(request):
def oauthlogin(request):
"""OAuth登录入口重定向到第三方授权页面"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
# 获取对应类型的OAuth管理器
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
# 获取重定向URL并跳转到授权页面
nexturl = get_redirecturl(request)
authorizeurl = manager.get_authorization_url(nexturl)
return HttpResponseRedirect(authorizeurl)
def authorize(request):
"""处理OAuth授权回调"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
@ -61,6 +70,7 @@ def authorize(request):
return HttpResponseRedirect('/')
code = request.GET.get('code', None)
try:
# 通过授权码获取访问令牌
rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e:
logger.warning("OAuthAccessTokenException:" + str(e))
@ -70,12 +80,16 @@ def authorize(request):
rsp = None
nexturl = get_redirecturl(request)
if not rsp:
# 如果获取令牌失败,重新跳转到授权页面
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
# 获取OAuth用户信息
user = manager.get_oauth_userinfo()
if user:
# 处理昵称为空的情况
if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try:
# 检查是否已存在该OAuth用户
temp = OAuthUser.objects.get(type=type, openid=user.openid)
temp.picture = user.picture
temp.metadata = user.metadata
@ -83,9 +97,10 @@ def authorize(request):
user = temp
except ObjectDoesNotExist:
pass
# facebook的token过长
# Facebook的token过长清空处理
if type == 'facebook':
user.token = ''
# 如果用户有邮箱,直接创建或关联用户账号
if user.email:
with transaction.atomic():
author = None
@ -94,14 +109,17 @@ def authorize(request):
except ObjectDoesNotExist:
pass
if not author:
# 根据邮箱获取或创建用户
result = get_user_model().objects.get_or_create(email=user.email)
author = result[0]
if result[1]:
# 新创建用户,设置用户名
try:
get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
author.username = user.nickname
else:
# 用户名冲突时生成唯一用户名
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize'
author.save()
@ -109,11 +127,13 @@ def authorize(request):
user.author = author
user.save()
# 发送登录信号
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
login(request, author)
return HttpResponseRedirect(nexturl)
else:
# 没有邮箱保存OAuth用户信息并跳转到邮箱填写页面
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
@ -125,8 +145,10 @@ def authorize(request):
def emailconfirm(request, id, sign):
"""邮箱确认视图完成OAuth绑定流程"""
if not sign:
return HttpResponseForbidden()
# 验证签名安全性
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
@ -134,22 +156,28 @@ def emailconfirm(request, id, sign):
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
if oauthuser.author:
# 已有关联作者,直接获取
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
# 创建新作者账号
result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0]
if result[1]:
author.source = 'emailconfirm'
# 设置用户名,处理昵称为空的情况
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
# 关联OAuth用户和作者账号
oauthuser.author = author
oauthuser.save()
# 发送登录信号
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
login(request, author)
# 发送绑定成功邮件
site = 'http://' + get_current_site().domain
content = _('''
<p>Congratulations, you have successfully bound your email address. You can use
@ -163,6 +191,7 @@ 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,6 +200,7 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView):
"""需要邮箱表单视图用于OAuth用户补充邮箱信息"""
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
@ -179,11 +209,12 @@ class RequireEmailView(FormView):
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.email:
pass
# return HttpResponseRedirect('/')
# 如果已有邮箱,可以重定向到首页或其他页面
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
"""设置表单初始值"""
oauthid = self.kwargs['oauthid']
return {
'email': '',
@ -191,6 +222,7 @@ class RequireEmailView(FormView):
}
def get_context_data(self, **kwargs):
"""添加上下文数据"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.picture:
@ -198,11 +230,13 @@ class RequireEmailView(FormView):
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""表单验证通过后的处理"""
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
# 生成签名用于邮箱验证
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
site = get_current_site().domain
@ -214,6 +248,7 @@ class RequireEmailView(FormView):
})
url = "http://{site}{path}".format(site=site, path=path)
# 发送邮箱验证邮件
content = _("""
<p>Please click the link below to bind your email</p>
@ -226,6 +261,7 @@ class RequireEmailView(FormView):
%(url)s
""") % {'url': url}
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
# 跳转到绑定成功提示页面
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
@ -234,8 +270,10 @@ class RequireEmailView(FormView):
def bindsuccess(request, oauthid):
"""绑定成功提示页面"""
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 根据类型显示不同的提示信息
if type == 'email':
title = _('Bind your email')
content = _(
@ -250,4 +288,4 @@ def bindsuccess(request, oauthid):
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
})

@ -1,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由 Django 4.1.7 于 2023-03-02 07:14 生成
from django.db import migrations, models
import django.utils.timezone
@ -12,20 +12,30 @@ class Migration(migrations.Migration):
]
operations = [
# 创建 OwnTrackLog 模型的数据迁移
migrations.CreateModel(
name='OwnTrackLog',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段
('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度坐标
('lat', models.FloatField(verbose_name='纬度')),
# 经度坐标
('lon', models.FloatField(verbose_name='经度')),
# 创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
options={
# 单数显示名称
'verbose_name': 'OwnTrackLogs',
# 复数显示名称
'verbose_name_plural': 'OwnTrackLogs',
# 默认按创建时间排序
'ordering': ['created_time'],
# 指定获取最新记录的依据字段
'get_latest_by': 'created_time',
},
),
]
]

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

@ -2,19 +2,27 @@ from django.db import models
from django.utils.timezone import now
# Create your models here.
# 创建模型
class OwnTrackLog(models.Model):
# 用户标识字段必填最大长度100字符
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
# 纬度坐标,浮点型
lat = models.FloatField(verbose_name='纬度')
# 经度坐标,浮点型
lon = models.FloatField(verbose_name='经度')
# 创建时间字段,默认为当前时间
creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
# 定义对象的字符串表示,返回用户标识
return self.tid
class Meta:
# 按创建时间升序排列
ordering = ['creation_time']
# 单数显示名称
verbose_name = "OwnTrackLogs"
# 复数显示名称与单数相同
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'
# 指定获取最新记录的依据字段
get_latest_by = 'creation_time'

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

@ -2,11 +2,17 @@ from django.urls import path
from . import views
# 定义应用命名空间
app_name = "owntracks"
# 定义URL模式
urlpatterns = [
# 处理OwnTracks位置数据提交的端点
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
# 显示地图的页面
path('owntracks/show_maps', views.show_maps, name='show_maps'),
# 获取位置数据的API端点
path('owntracks/get_datas', views.get_datas, name='get_datas'),
# 显示日志日期的页面
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]
]

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

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

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

@ -1,13 +1,18 @@
from django.contrib import admin
# Register your models here.
# 注册模型到admin后台
class CommandsAdmin(admin.ModelAdmin):
"""命令模型的Admin配置类"""
# 设置列表页显示的字段
list_display = ('title', 'command', 'describe')
class EmailSendLogAdmin(admin.ModelAdmin):
"""邮件发送日志模型的Admin配置类"""
# 设置列表页显示的字段
list_display = ('title', 'emailto', 'send_result', 'creation_time')
# 设置只读字段(在编辑页面中不可修改)
readonly_fields = (
'title',
'emailto',
@ -16,4 +21,5 @@ class EmailSendLogAdmin(admin.ModelAdmin):
'content')
def has_add_permission(self, request):
return False
"""禁止在admin后台添加新的邮件发送日志记录"""
return False

@ -4,24 +4,57 @@ from blog.models import Article, Category
class BlogApi:
"""博客API类提供搜索和文章数据获取功能"""
def __init__(self):
# 初始化搜索查询集
self.searchqueryset = SearchQuerySet()
self.searchqueryset.auto_query('')
# 设置最大返回结果数
self.__max_takecount__ = 8
def search_articles(self, query):
"""搜索文章
Args:
query: 搜索关键词
Returns:
包含搜索结果的查询集最多返回指定数量的结果
"""
# 执行自动搜索查询
sqs = self.searchqueryset.auto_query(query)
# 加载所有相关数据
sqs = sqs.load_all()
# 返回限定数量的结果
return sqs[:self.__max_takecount__]
def get_category_lists(self):
"""获取所有分类列表
Returns:
包含所有分类的查询集
"""
return Category.objects.all()
def get_category_articles(self, categoryname):
"""根据分类名称获取该分类下的文章
Args:
categoryname: 分类名称
Returns:
指定分类下的文章列表最多返回指定数量的结果如果分类不存在则返回None
"""
articles = Article.objects.filter(category__name=categoryname)
if articles:
return articles[:self.__max_takecount__]
return None
def get_recent_articles(self):
return Article.objects.all()[:self.__max_takecount__]
"""获取最近的文章
Returns:
最新的文章列表最多返回指定数量的结果
"""
return Article.objects.all()[:self.__max_takecount__]

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

@ -1,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 由 Django 4.1.7 于 2023-03-02 07:14 生成
from django.db import migrations, models
@ -11,35 +11,54 @@ class Migration(migrations.Migration):
]
operations = [
# 创建commands命令表
migrations.CreateModel(
name='commands',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 命令标题字段最大长度300字符
('title', models.CharField(max_length=300, verbose_name='命令标题')),
# 命令内容字段最大长度2000字符
('command', models.CharField(max_length=2000, verbose_name='命令')),
# 命令描述字段最大长度300字符
('describe', models.CharField(max_length=300, verbose_name='命令描述')),
# 创建时间字段,自动设置为创建时的时间
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# 最后修改时间字段,自动更新为修改时的时间
('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')),
],
options={
# 单数显示名称
'verbose_name': '命令',
# 复数显示名称
'verbose_name_plural': '命令',
},
),
# 创建EmailSendLog邮件发送日志表
migrations.CreateModel(
name='EmailSendLog',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 收件人字段最大长度300字符
('emailto', models.CharField(max_length=300, verbose_name='收件人')),
# 邮件标题字段最大长度2000字符
('title', models.CharField(max_length=2000, verbose_name='邮件标题')),
# 邮件内容字段,文本类型
('content', models.TextField(verbose_name='邮件内容')),
# 发送结果字段布尔值默认False
('send_result', models.BooleanField(default=False, verbose_name='结果')),
# 创建时间字段,自动设置为创建时的时间
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
# 单数显示名称
'verbose_name': '邮件发送log',
# 复数显示名称
'verbose_name_plural': '邮件发送log',
# 按创建时间降序排列(最新的在前)
'ordering': ['-created_time'],
},
),
]
]

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-06 13:19
# 由 Django 4.2.5 于 2023-09-06 13:19 生成
from django.db import migrations
@ -6,27 +6,39 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
# 依赖于初始迁移文件
('servermanager', '0001_initial'),
]
operations = [
# 修改EmailSendLog模型的选项
migrations.AlterModelOptions(
name='emailsendlog',
options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
options={
# 更新排序字段为creation_time降序
'ordering': ['-creation_time'],
# 保持单数显示名称不变
'verbose_name': '邮件发送log',
# 保持复数显示名称不变
'verbose_name_plural': '邮件发送log'
},
),
# 重命名commands模型的created_time字段为creation_time
migrations.RenameField(
model_name='commands',
old_name='created_time',
new_name='creation_time',
),
# 重命名commands模型的last_mod_time字段为last_modify_time
migrations.RenameField(
model_name='commands',
old_name='last_mod_time',
new_name='last_modify_time',
),
# 重命名EmailSendLog模型的created_time字段为creation_time
migrations.RenameField(
model_name='emailsendlog',
old_name='created_time',
new_name='creation_time',
),
]
]

@ -1,33 +1,54 @@
from django.db import models
# Create your models here.
# 创建模型
class commands(models.Model):
"""命令模型,用于存储系统命令信息"""
# 命令标题字符字段最大长度300
title = models.CharField('命令标题', max_length=300)
# 命令内容字符字段最大长度2000
command = models.CharField('命令', max_length=2000)
# 命令描述字符字段最大长度300
describe = models.CharField('命令描述', max_length=300)
# 创建时间,自动设置为对象创建时的时间
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
# 最后修改时间,自动更新为对象修改时的时间
last_modify_time = models.DateTimeField('修改时间', auto_now=True)
def __str__(self):
"""对象的字符串表示,返回命令标题"""
return self.title
class Meta:
"""模型的元数据配置"""
# 单数显示名称
verbose_name = '命令'
# 复数显示名称与单数相同
verbose_name_plural = verbose_name
class EmailSendLog(models.Model):
"""邮件发送日志模型,用于记录邮件发送情况"""
# 收件人地址字符字段最大长度300
emailto = models.CharField('收件人', max_length=300)
# 邮件标题字符字段最大长度2000
title = models.CharField('邮件标题', max_length=2000)
# 邮件内容,文本字段
content = models.TextField('邮件内容')
# 发送结果布尔字段默认值为False发送失败
send_result = models.BooleanField('结果', default=False)
# 创建时间,自动设置为记录创建时的时间
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
def __str__(self):
"""对象的字符串表示,返回邮件标题"""
return self.title
class Meta:
"""模型的元数据配置"""
# 单数显示名称
verbose_name = '邮件发送log'
# 复数显示名称与单数相同
verbose_name_plural = verbose_name
ordering = ['-creation_time']
# 按创建时间降序排列(最新的记录在前)
ordering = ['-creation_time']

@ -13,29 +13,36 @@ from servermanager.api.blogapi import BlogApi
from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
# 初始化微信机器人设置token和会话
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
or 'lylinux', enable_session=True)
# 尝试使用Memcached存储会话
memstorage = MemcacheStorage()
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
# 如果Memcached不可用使用文件存储并清理旧会话文件
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
# 初始化API处理器
blogapi = BlogApi()
cmd_handler = CommandHandler()
logger = logging.getLogger(__name__)
def convert_to_article_reply(articles, message):
"""将文章列表转换为微信图文回复格式"""
reply = ArticlesReply(message=message)
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
# 从文章内容中提取图片URL
imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body)
imgurl = ''
if imgs:
imgurl = imgs[0]
# 创建图文消息条目
article = Article(
title=post.title,
description=truncatechars_content(post.body),
@ -48,6 +55,7 @@ def convert_to_article_reply(articles, message):
@robot.filter(re.compile(r"^\?.*"))
def search(message, session):
"""处理搜索请求,格式:?关键词"""
s = message.content
searchstr = str(s).replace('?', '')
result = blogapi.search_articles(searchstr)
@ -61,6 +69,7 @@ def search(message, session):
@robot.filter(re.compile(r'^category\s*$', re.I))
def category(message, session):
"""获取所有文章分类目录"""
categorys = blogapi.get_category_lists()
content = ','.join(map(lambda x: x.name, categorys))
return '所有文章分类目录:' + content
@ -68,6 +77,7 @@ def category(message, session):
@robot.filter(re.compile(r'^recent\s*$', re.I))
def recents(message, session):
"""获取最新文章"""
articles = blogapi.get_recent_articles()
if articles:
reply = convert_to_article_reply(articles, message)
@ -78,6 +88,7 @@ def recents(message, session):
@robot.filter(re.compile('^help$', re.I))
def help(message, session):
"""显示帮助信息"""
return '''欢迎关注!
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
@ -100,65 +111,81 @@ def help(message, session):
@robot.filter(re.compile(r'^weather\:.*$', re.I))
def weather(message, session):
"""天气查询功能(建设中)"""
return "建设中..."
@robot.filter(re.compile(r'^idcard\:.*$', re.I))
def idcard(message, session):
"""身份证查询功能(建设中)"""
return "建设中..."
@robot.handler
def echo(message, session):
"""默认消息处理器"""
handler = MessageHandler(message, session)
return handler.handler()
class MessageHandler:
"""消息处理器类,处理用户会话和命令"""
def __init__(self, message, session):
userid = message.source
self.message = message
self.session = session
self.userid = userid
try:
# 从会话中获取用户信息
info = session[userid]
self.userinfo = jsonpickle.decode(info)
except Exception as e:
# 如果会话中没有用户信息,创建新的用户信息
userinfo = WxUserInfo()
self.userinfo = userinfo
@property
def is_admin(self):
"""检查用户是否为管理员"""
return self.userinfo.isAdmin
@property
def is_password_set(self):
"""检查管理员密码是否已设置"""
return self.userinfo.isPasswordSet
def save_session(self):
"""保存用户信息到会话"""
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
def handler(self):
"""处理消息的主方法"""
info = self.message.content
# 管理员退出命令
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
self.save_session()
return "退出成功"
# 进入管理员模式
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
self.save_session()
return "输入管理员密码"
# 管理员密码验证
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
if settings.TESTING:
passwd = '123'
# 验证密码双重SHA256加密
if passwd.upper() == get_sha256(get_sha256(info)).upper():
self.userinfo.isPasswordSet = True
self.save_session()
return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助"
else:
# 密码错误次数限制
if self.userinfo.Count >= 3:
self.userinfo = WxUserInfo()
self.save_session()
@ -166,22 +193,33 @@ class MessageHandler:
self.userinfo.Count += 1
self.save_session()
return "验证失败,请重新输入管理员密码:"
# 管理员命令执行
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
# 确认执行命令
if self.userinfo.Command != '' and info.upper() == 'Y':
return cmd_handler.run(self.userinfo.Command)
else:
# 获取命令帮助
if info.upper() == 'HELPME':
return cmd_handler.get_help()
# 保存待确认的命令
self.userinfo.Command = info
self.save_session()
return "确认执行: " + info + " 命令?"
# 默认使用ChatGPT回复
return ChatGPT.chat(info)
class WxUserInfo():
"""微信用户信息类,存储用户状态"""
def __init__(self):
# 是否为管理员
self.isAdmin = False
# 是否已设置密码
self.isPasswordSet = False
# 密码尝试次数
self.Count = 0
self.Command = ''
# 待执行的命令
self.Command = ''

@ -10,28 +10,34 @@ from .robot import MessageHandler, CommandHandler
from .robot import search, category, recents
# Create your tests here.
# 创建测试类
class ServerManagerTest(TestCase):
def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client()
self.factory = RequestFactory()
def test_chat_gpt(self):
# 测试ChatGPT功能是否正常工作
content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
def test_validate_comment(self):
# 创建超级用户用于测试
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
# 登录用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建分类
c = Category()
c.name = "categoryccc"
c.save()
# 创建文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
@ -40,40 +46,53 @@ class ServerManagerTest(TestCase):
article.type = 'a'
article.status = 'p'
article.save()
# 测试搜索功能
s = TextMessage([])
s.content = "nice"
rsp = search(s, None)
# 测试分类功能
rsp = category(None, None)
self.assertIsNotNone(rsp)
# 测试最近文章功能
rsp = recents(None, None)
self.assertTrue(rsp != '暂时还没有文章')
# 创建测试命令
cmd = commands()
cmd.title = "test"
cmd.command = "ls"
cmd.describe = "test"
cmd.save()
# 测试命令处理器
cmdhandler = CommandHandler()
rsp = cmdhandler.run('test')
self.assertIsNotNone(rsp)
# 测试消息处理器
s.source = 'u'
s.content = 'test'
msghandler = MessageHandler(s, {})
# 注释掉的管理员相关测试代码
# msghandler.userinfo.isPasswordSet = True
# msghandler.userinfo.isAdmin = True
# 测试各种消息处理场景
msghandler.handler() # 处理普通消息
s.content = 'y' # 确认执行命令
msghandler.handler()
s.content = 'y'
msghandler.handler()
s.content = 'idcard:12321233'
s.content = 'idcard:12321233' # 身份证查询(建设中功能)
msghandler.handler()
s.content = 'weather:上海'
s.content = 'weather:上海' # 天气查询(建设中功能)
msghandler.handler()
s.content = 'admin'
s.content = 'admin' # 进入管理员模式
msghandler.handler()
s.content = '123'
s.content = '123' # 输入管理员密码
msghandler.handler()
s.content = 'exit'
msghandler.handler()
s.content = 'exit' # 退出管理员模式
msghandler.handler()
Loading…
Cancel
Save