From 6f65370444a82ec2f99d36aed3b73a994240080a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=B6=B5?= Date: Fri, 17 Oct 2025 22:47:23 +0800 Subject: [PATCH] =?UTF-8?q?yh:=20=E5=AE=8C=E6=88=90=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/DjangoBlog/accounts/admin.py | 42 +++- src/DjangoBlog/accounts/apps.py | 5 +- src/DjangoBlog/accounts/forms.py | 91 ++++++-- .../accounts/migrations/0001_initial.py | 38 +++- ...s_remove_bloguser_created_time_and_more.py | 54 +++-- src/DjangoBlog/accounts/models.py | 23 +- src/DjangoBlog/accounts/tests.py | 81 ++++++- src/DjangoBlog/accounts/urls.py | 64 ++++-- src/DjangoBlog/accounts/user_login_backend.py | 18 +- src/DjangoBlog/accounts/utils.py | 19 +- src/DjangoBlog/accounts/views.py | 116 +++++++++- src/DjangoBlog/blog/admin.py | 61 ++++- src/DjangoBlog/blog/apps.py | 5 +- src/DjangoBlog/blog/context_processors.py | 56 +++-- src/DjangoBlog/blog/documents.py | 148 +++++++----- src/DjangoBlog/blog/forms.py | 14 +- .../blog/management/commands/build_index.py | 13 +- .../management/commands/build_search_words.py | 9 +- .../blog/management/commands/clear_cache.py | 9 +- .../management/commands/create_testdata.py | 22 +- .../blog/management/commands/ping_baidu.py | 45 ++-- .../management/commands/sync_user_avatar.py | 37 ++- src/DjangoBlog/blog/middleware.py | 35 ++- .../blog/migrations/0001_initial.py | 92 +++++++- ...002_blogsettings_global_footer_and_more.py | 20 +- .../0003_blogsettings_comment_need_review.py | 13 +- ...de_blogsettings_analytics_code_and_more.py | 27 ++- ...options_alter_category_options_and_more.py | 65 +++++- .../0006_alter_blogsettings_options.py | 13 +- src/DjangoBlog/blog/models.py | 215 ++++++++++-------- src/DjangoBlog/blog/search_indexes.py | 17 +- src/DjangoBlog/blog/templatetags/blog_tags.py | 96 ++++++-- src/DjangoBlog/blog/tests.py | 92 ++++++-- src/DjangoBlog/blog/urls.py | 35 ++- src/DjangoBlog/blog/views.py | 84 +++++-- src/DjangoBlog/comments/admin.py | 44 +++- src/DjangoBlog/comments/apps.py | 5 +- src/DjangoBlog/comments/forms.py | 12 +- .../comments/migrations/0001_initial.py | 24 +- .../0002_alter_comment_is_enable.py | 13 +- ...ns_remove_comment_created_time_and_more.py | 16 +- src/DjangoBlog/comments/models.py | 31 ++- .../comments/templatetags/comments_tags.py | 36 ++- src/DjangoBlog/comments/tests.py | 60 ++++- src/DjangoBlog/comments/urls.py | 14 +- src/DjangoBlog/comments/utils.py | 25 +- src/DjangoBlog/comments/views.py | 50 +++- src/DjangoBlog/djangoblog/admin_site.py | 50 ++-- src/DjangoBlog/djangoblog/apps.py | 14 +- src/DjangoBlog/djangoblog/blog_signals.py | 74 ++++-- .../djangoblog/elasticsearch_backend.py | 44 +++- src/DjangoBlog/djangoblog/feeds.py | 21 +- src/DjangoBlog/djangoblog/logentryadmin.py | 32 ++- .../djangoblog/plugin_manage/base_plugin.py | 27 ++- .../plugin_manage/hook_constants.py | 11 +- .../djangoblog/plugin_manage/hooks.py | 43 +++- .../djangoblog/plugin_manage/loader.py | 16 +- src/DjangoBlog/djangoblog/sitemap.py | 30 ++- src/DjangoBlog/djangoblog/spider_notify.py | 13 +- src/DjangoBlog/djangoblog/tests.py | 10 +- src/DjangoBlog/djangoblog/utils.py | 41 +++- src/DjangoBlog/oauth/admin.py | 26 ++- src/DjangoBlog/oauth/apps.py | 5 +- src/DjangoBlog/oauth/forms.py | 8 +- .../oauth/migrations/0001_initial.py | 33 ++- ...ptions_alter_oauthuser_options_and_more.py | 21 +- .../0003_alter_oauthuser_nickname.py | 6 +- src/DjangoBlog/oauth/models.py | 36 ++- src/DjangoBlog/oauth/oauthmanager.py | 58 ++++- src/DjangoBlog/oauth/tests.py | 49 +++- src/DjangoBlog/oauth/urls.py | 12 +- src/DjangoBlog/oauth/views.py | 15 +- src/DjangoBlog/owntracks/admin.py | 5 +- src/DjangoBlog/owntracks/apps.py | 5 +- .../owntracks/migrations/0001_initial.py | 15 +- ...0002_alter_owntracklog_options_and_more.py | 7 +- src/DjangoBlog/owntracks/models.py | 14 +- src/DjangoBlog/owntracks/tests.py | 54 +++-- src/DjangoBlog/owntracks/urls.py | 11 +- src/DjangoBlog/owntracks/views.py | 61 +++-- .../plugins/article_copyright/plugin.py | 21 +- .../plugins/external_links/plugin.py | 32 ++- .../plugins/image_lazy_loading/plugin.py | 160 ++++++------- .../plugins/seo_optimizer/plugin.py | 77 ++++--- src/DjangoBlog/plugins/view_count/plugin.py | 22 +- .../servermanager/MemcacheStorage.py | 43 +++- src/DjangoBlog/servermanager/admin.py | 10 +- src/DjangoBlog/servermanager/api/blogapi.py | 15 +- src/DjangoBlog/servermanager/api/commonapi.py | 45 +++- src/DjangoBlog/servermanager/apps.py | 5 +- .../servermanager/migrations/0001_initial.py | 44 ++-- ...002_alter_emailsendlog_options_and_more.py | 6 +- src/DjangoBlog/servermanager/models.py | 19 +- src/DjangoBlog/servermanager/robot.py | 49 +++- src/DjangoBlog/servermanager/tests.py | 55 +++-- src/DjangoBlog/servermanager/urls.py | 10 +- 96 files changed, 2850 insertions(+), 734 deletions(-) diff --git a/src/DjangoBlog/accounts/admin.py b/src/DjangoBlog/accounts/admin.py index 29d162a..00e6a98 100644 --- a/src/DjangoBlog/accounts/admin.py +++ b/src/DjangoBlog/accounts/admin.py @@ -1,52 +1,85 @@ +# 导入Django表单模块 from django import forms +# 导入Django默认的用户管理类 from django.contrib.auth.admin import UserAdmin +# 导入Django默认的用户修改表单 from django.contrib.auth.forms import UserChangeForm +# 导入用户名字段 from django.contrib.auth.forms import UsernameField +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ # Register your models here. +# 导入自定义的用户模型 from .models import BlogUser +# 自定义用户创建表单,继承自ModelForm class BlogUserCreationForm(forms.ModelForm): + # 密码字段1,使用密码输入控件 password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + # 密码确认字段,使用密码输入控件 password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + # 定义表单的元数据 class Meta: + # 指定表单对应的模型 model = BlogUser + # 指定表单包含的字段 fields = ('email',) + # 密码确认字段的清理和验证方法 def clean_password2(self): - # Check that the two password entries match + # 从清理后的数据中获取两个密码字段的值 password1 = self.cleaned_data.get("password1") password2 = self.cleaned_data.get("password2") + # 检查两个密码是否匹配 if password1 and password2 and password1 != password2: + # 如果不匹配,抛出验证错误 raise forms.ValidationError(_("passwords do not match")) + # 返回确认密码的值 return password2 + # 保存用户实例的方法 def save(self, commit=True): - # Save the provided password in hashed format + # 调用父类的save方法,但不立即提交到数据库 user = super().save(commit=False) + # 设置用户的密码(会自动进行哈希处理) user.set_password(self.cleaned_data["password1"]) + # 如果设置为立即提交 if commit: + # 设置用户来源为管理员站点 user.source = 'adminsite' + # 保存用户到数据库 user.save() + # 返回用户实例 return user +# 自定义用户修改表单,继承自Django的UserChangeForm class BlogUserChangeForm(UserChangeForm): + # 定义表单的元数据 class Meta: + # 指定表单对应的模型 model = BlogUser + # 包含所有字段 fields = '__all__' + # 指定字段类型映射 field_classes = {'username': UsernameField} + # 初始化方法 def __init__(self, *args, **kwargs): + # 调用父类的初始化方法 super().__init__(*args, **kwargs) +# 自定义用户管理类,继承自Django的UserAdmin class BlogUserAdmin(UserAdmin): + # 指定修改用户时使用的表单 form = BlogUserChangeForm + # 指定创建用户时使用的表单 add_form = BlogUserCreationForm + # 定义在管理列表页面显示的字段 list_display = ( 'id', 'nickname', @@ -55,6 +88,9 @@ class BlogUserAdmin(UserAdmin): 'last_login', 'date_joined', 'source') + # 定义可以作为链接点击进入编辑页面的字段 list_display_links = ('id', 'username') + # 定义默认排序字段(按id倒序) ordering = ('-id',) - search_fields = ('username', 'nickname', 'email') + # 定义可搜索的字段 + search_fields = ('username', 'nickname', 'email') \ No newline at end of file diff --git a/src/DjangoBlog/accounts/apps.py b/src/DjangoBlog/accounts/apps.py index 9b3fc5a..85c4573 100644 --- a/src/DjangoBlog/accounts/apps.py +++ b/src/DjangoBlog/accounts/apps.py @@ -1,5 +1,8 @@ +# 导入Django的应用配置基类 from django.apps import AppConfig +# 定义accounts应用的配置类 class AccountsConfig(AppConfig): - name = 'accounts' + # 指定应用的Python路径(Django内部使用的标识) + name = 'accounts' \ No newline at end of file diff --git a/src/DjangoBlog/accounts/forms.py b/src/DjangoBlog/accounts/forms.py index fce4137..010d3af 100644 --- a/src/DjangoBlog/accounts/forms.py +++ b/src/DjangoBlog/accounts/forms.py @@ -1,117 +1,166 @@ +# 导入Django表单模块 from django import forms +# 导入Django认证相关函数和表单 from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +# 导入验证异常类 from django.core.exceptions import ValidationError +# 导入表单控件 from django.forms import widgets +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入自定义工具模块 from . import utils +# 导入自定义用户模型 from .models import BlogUser +# 登录表单,继承自Django的AuthenticationForm class LoginForm(AuthenticationForm): def __init__(self, *args, **kwargs): + # 调用父类的初始化方法 super(LoginForm, self).__init__(*args, **kwargs) + # 自定义用户名字段的控件属性 self.fields['username'].widget = widgets.TextInput( attrs={'placeholder': "username", "class": "form-control"}) + # 自定义密码字段的控件属性 self.fields['password'].widget = widgets.PasswordInput( attrs={'placeholder': "password", "class": "form-control"}) +# 注册表单,继承自Django的UserCreationForm class RegisterForm(UserCreationForm): def __init__(self, *args, **kwargs): + # 调用父类的初始化方法 super(RegisterForm, self).__init__(*args, **kwargs) + # 自定义用户名字段的控件属性 self.fields['username'].widget = widgets.TextInput( attrs={'placeholder': "username", "class": "form-control"}) + # 自定义邮箱字段的控件属性 self.fields['email'].widget = widgets.EmailInput( attrs={'placeholder': "email", "class": "form-control"}) + # 自定义密码字段的控件属性 self.fields['password1'].widget = widgets.PasswordInput( attrs={'placeholder': "password", "class": "form-control"}) + # 自定义确认密码字段的控件属性 self.fields['password2'].widget = widgets.PasswordInput( attrs={'placeholder': "repeat password", "class": "form-control"}) + # 邮箱字段的清理和验证方法 def clean_email(self): + # 获取清理后的邮箱数据 email = self.cleaned_data['email'] + # 检查邮箱是否已存在 if get_user_model().objects.filter(email=email).exists(): + # 如果邮箱已存在,抛出验证错误 raise ValidationError(_("email already exists")) + # 返回验证通过的邮箱 return email + # 定义表单的元数据 class Meta: + # 指定表单对应的模型(使用get_user_model获取当前用户模型) model = get_user_model() + # 指定表单包含的字段 fields = ("username", "email") +# 忘记密码表单,继承自forms.Form class ForgetPasswordForm(forms.Form): + # 新密码字段 new_password1 = forms.CharField( - label=_("New password"), - widget=forms.PasswordInput( + label=_("New password"), # 字段标签 + widget=forms.PasswordInput( # 使用密码输入控件 attrs={ - "class": "form-control", - 'placeholder': _("New password") + "class": "form-control", # CSS类名 + 'placeholder': _("New password") # 占位符文本 } ), ) + # 确认新密码字段 new_password2 = forms.CharField( - label="确认密码", - widget=forms.PasswordInput( + label="确认密码", # 字段标签(中文) + widget=forms.PasswordInput( # 使用密码输入控件 attrs={ - "class": "form-control", - 'placeholder': _("Confirm password") + "class": "form-control", # CSS类名 + 'placeholder': _("Confirm password") # 占位符文本 } ), ) + # 邮箱字段 email = forms.EmailField( - label='邮箱', - widget=forms.TextInput( + label='邮箱', # 字段标签(中文) + widget=forms.TextInput( # 使用文本输入控件 attrs={ - 'class': 'form-control', - 'placeholder': _("Email") + 'class': 'form-control', # CSS类名 + 'placeholder': _("Email") # 占位符文本 } ), ) + # 验证码字段 code = forms.CharField( - label=_('Code'), - widget=forms.TextInput( + label=_('Code'), # 字段标签 + widget=forms.TextInput( # 使用文本输入控件 attrs={ - 'class': 'form-control', - 'placeholder': _("Code") + 'class': 'form-control', # CSS类名 + 'placeholder': _("Code") # 占位符文本 } ), ) + # 确认密码字段的清理和验证方法 def clean_new_password2(self): + # 从原始数据中获取两个密码字段的值 password1 = self.data.get("new_password1") password2 = self.data.get("new_password2") + # 检查两个密码是否匹配 if password1 and password2 and password1 != password2: + # 如果不匹配,抛出验证错误 raise ValidationError(_("passwords do not match")) + # 使用Django的密码验证器验证密码强度 password_validation.validate_password(password2) + # 返回验证通过的密码 return password2 + # 邮箱字段的清理和验证方法 def clean_email(self): + # 获取清理后的邮箱数据 user_email = self.cleaned_data.get("email") + # 检查邮箱是否存在对应的用户 if not BlogUser.objects.filter( email=user_email ).exists(): - # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + # 如果邮箱不存在,抛出验证错误 + # 注释说明:这里的报错提示可能会暴露邮箱是否注册,可根据安全需求修改 raise ValidationError(_("email does not exist")) + # 返回验证通过的邮箱 return user_email + # 验证码字段的清理和验证方法 def clean_code(self): + # 获取清理后的验证码数据 code = self.cleaned_data.get("code") + # 使用工具函数验证验证码的有效性 error = utils.verify( - email=self.cleaned_data.get("email"), - code=code, + email=self.cleaned_data.get("email"), # 传入邮箱 + code=code, # 传入验证码 ) + # 如果验证返回错误信息 if error: + # 抛出验证错误 raise ValidationError(error) + # 返回验证通过的验证码 return code +# 忘记密码验证码请求表单,继承自forms.Form class ForgetPasswordCodeForm(forms.Form): + # 邮箱字段 email = forms.EmailField( - label=_('Email'), - ) + label=_('Email'), # 字段标签 + ) \ No newline at end of file diff --git a/src/DjangoBlog/accounts/migrations/0001_initial.py b/src/DjangoBlog/accounts/migrations/0001_initial.py index d2fbcab..9834874 100644 --- a/src/DjangoBlog/accounts/migrations/0001_initial.py +++ b/src/DjangoBlog/accounts/migrations/0001_initial.py @@ -1,5 +1,6 @@ # Generated by Django 4.1.7 on 2023-03-02 07:14 +# 导入Django内置模块 import django.contrib.auth.models import django.contrib.auth.validators from django.db import migrations, models @@ -7,43 +8,66 @@ import django.utils.timezone class Migration(migrations.Migration): - + # 初始迁移标记 initial = True + # 依赖的迁移文件 dependencies = [ ('auth', '0012_alter_user_first_name_max_length'), ] + # 迁移操作列表 operations = [ + # 创建模型的操作 migrations.CreateModel( - name='BlogUser', + name='BlogUser', # 自定义用户模型名称 fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 密码字段,使用Django的密码哈希存储 ('password', models.CharField(max_length=128, verbose_name='password')), + # 最后登录时间,可为空 ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + # 超级用户标志位 ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + # 用户名字段,具有唯一性验证和字符格式验证 ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + # 名字字段,可为空 ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + # 姓氏字段,可为空 ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + # 邮箱字段,可为空 ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + # 管理员标志位,决定能否登录admin后台 ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + # 活跃状态标志位,用于软删除 ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + # 账户创建时间,默认使用当前时间 ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + # 自定义字段:昵称 ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), + # 自定义字段:记录创建时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 自定义字段:最后修改时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 自定义字段:用户创建来源 ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), + # 多对多关系:用户组权限 ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + # 多对多关系:用户单独权限 ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], + # 模型元数据配置 options={ - 'verbose_name': '用户', - 'verbose_name_plural': '用户', - 'ordering': ['-id'], - 'get_latest_by': 'id', + 'verbose_name': '用户', # 单数显示名称 + 'verbose_name_plural': '用户', # 复数显示名称 + 'ordering': ['-id'], # 默认按ID倒序排列 + 'get_latest_by': 'id', # 指定最新记录的依据字段 }, + # 指定模型管理器 managers=[ + # 使用Django默认的用户管理器 ('objects', django.contrib.auth.models.UserManager()), ], ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py index 1a9f509..769dedb 100644 --- a/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py +++ b/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -6,41 +6,55 @@ import django.utils.timezone class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ - ('accounts', '0001_initial'), + ('accounts', '0001_initial'), # 依赖于accounts应用中的初始迁移 ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改模型的元选项配置 migrations.AlterModelOptions( - name='bloguser', - options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, + name='bloguser', # 指定要修改的模型名称 + options={ + 'get_latest_by': 'id', # 指定获取最新记录的依据字段为id + 'ordering': ['-id'], # 设置默认排序为按id倒序排列 + 'verbose_name': 'user', # 设置单数形式的显示名称 + 'verbose_name_plural': 'user', # 设置复数形式的显示名称 + }, ), + # 删除模型中的字段:创建时间字段 migrations.RemoveField( - model_name='bloguser', - name='created_time', + model_name='bloguser', # 指定要删除字段的模型 + name='created_time', # 要删除的字段名称 ), + # 删除模型中的字段:最后修改时间字段 migrations.RemoveField( - model_name='bloguser', - name='last_mod_time', + model_name='bloguser', # 指定要删除字段的模型 + name='last_mod_time', # 要删除的字段名称 ), + # 添加新的字段:创建时间(重命名字段) migrations.AddField( - model_name='bloguser', - name='creation_time', - field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + model_name='bloguser', # 指定要添加字段的模型 + name='creation_time', # 新字段名称 + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 日期时间字段,默认值为当前时间 ), + # 添加新的字段:最后修改时间(重命名字段) migrations.AddField( - model_name='bloguser', - name='last_modify_time', - field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + model_name='bloguser', # 指定要添加字段的模型 + name='last_modify_time', # 新字段名称 + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 日期时间字段,默认值为当前时间 ), + # 修改现有字段的属性:昵称字段 migrations.AlterField( - model_name='bloguser', - name='nickname', - field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), + model_name='bloguser', # 指定要修改字段的模型 + name='nickname', # 要修改的字段名称 + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), # 更新字段的显示名称 ), + # 修改现有字段的属性:来源字段 migrations.AlterField( - model_name='bloguser', - name='source', - field=models.CharField(blank=True, max_length=100, verbose_name='create source'), + model_name='bloguser', # 指定要修改字段的模型 + name='source', # 要修改的字段名称 + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), # 更新字段的显示名称 ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/accounts/models.py b/src/DjangoBlog/accounts/models.py index 3baddbb..0b464f5 100644 --- a/src/DjangoBlog/accounts/models.py +++ b/src/DjangoBlog/accounts/models.py @@ -1,35 +1,56 @@ +# 导入Django认证系统的抽象用户基类 from django.contrib.auth.models import AbstractUser +# 导入Django数据库模型 from django.db import models +# 导入URL反向解析函数 from django.urls import reverse +# 导入时间相关工具 from django.utils.timezone import now +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入自定义工具函数 from djangoblog.utils import get_current_site # Create your models here. +# 自定义博客用户模型,继承自Django的AbstractUser class BlogUser(AbstractUser): + # 昵称字段,最大长度100字符,允许为空 nickname = models.CharField(_('nick name'), max_length=100, blank=True) + # 创建时间字段,默认值为当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间字段,默认值为当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) + # 用户创建来源字段,最大长度100字符,允许为空 source = models.CharField(_('create source'), max_length=100, blank=True) + # 获取用户详情页的绝对URL(不含域名) def get_absolute_url(self): return reverse( 'blog:author_detail', kwargs={ 'author_name': self.username}) + # 定义对象的字符串表示形式 def __str__(self): return self.email + # 获取用户详情页的完整URL(包含域名) def get_full_url(self): + # 获取当前站点的域名 site = get_current_site().domain + # 构建完整的URL url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) return url + # 定义模型的元数据 class Meta: + # 默认按id倒序排列 ordering = ['-id'] + # 单数形式的显示名称 verbose_name = _('user') + # 复数形式的显示名称(与单数相同) verbose_name_plural = verbose_name - get_latest_by = 'id' + # 指定获取最新记录的依据字段 + get_latest_by = 'id' \ No newline at end of file diff --git a/src/DjangoBlog/accounts/tests.py b/src/DjangoBlog/accounts/tests.py index 6893411..4d8d7eb 100644 --- a/src/DjangoBlog/accounts/tests.py +++ b/src/DjangoBlog/accounts/tests.py @@ -1,135 +1,185 @@ +# 导入Django测试相关模块 from django.test import Client, RequestFactory, TestCase +# 导入URL反向解析 from django.urls import reverse +# 导入时间处理工具 from django.utils import timezone +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入自定义模型 from accounts.models import BlogUser from blog.models import Article, Category +# 导入自定义工具函数 from djangoblog.utils import * +# 导入当前应用的工具模块 from . import utils # Create your tests here. +# 账户测试类,继承自TestCase class AccountTest(TestCase): + # 测试前的初始化方法 def setUp(self): + # 创建测试客户端 self.client = Client() + # 创建请求工厂 self.factory = RequestFactory() + # 创建测试用户 self.blog_user = BlogUser.objects.create_user( username="test", email="admin@admin.com", password="12345678" ) + # 设置新测试密码 self.new_test = "xxx123--=" + # 测试账户验证功能 def test_validate_account(self): + # 获取当前站点域名 site = get_current_site().domain + # 创建超级用户 user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="qwer!@#$ggg") + # 获取刚创建的用户 testuser = BlogUser.objects.get(username='liangliangyy1') + # 测试登录功能 loginresult = self.client.login( username='liangliangyy1', password='qwer!@#$ggg') + # 断言登录成功 self.assertEqual(loginresult, True) + # 访问管理员页面 response = self.client.get('/admin/') + # 断言页面访问成功 self.assertEqual(response.status_code, 200) + # 创建测试分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + # 创建测试文章 article = Article() article.title = "nicetitleaaa" article.body = "nicecontentaaa" article.author = user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 访问文章管理页面 response = self.client.get(article.get_admin_url()) + # 断言页面访问成功 self.assertEqual(response.status_code, 200) + # 测试注册功能 def test_validate_register(self): + # 断言注册前邮箱不存在 self.assertEquals( 0, len( BlogUser.objects.filter( email='user123@user.com'))) + # 发送注册请求 response = self.client.post(reverse('account:register'), { 'username': 'user1233', 'email': 'user123@user.com', 'password1': 'password123!q@wE#R$T', 'password2': 'password123!q@wE#R$T', }) + # 断言注册后邮箱存在 self.assertEquals( 1, len( BlogUser.objects.filter( email='user123@user.com'))) + # 获取刚注册的用户 user = BlogUser.objects.filter(email='user123@user.com')[0] + # 生成验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) path = reverse('accounts:result') + # 构建验证URL url = '{path}?type=validation&id={id}&sign={sign}'.format( path=path, id=user.id, sign=sign) + # 访问验证页面 response = self.client.get(url) self.assertEqual(response.status_code, 200) + # 登录新注册的用户 self.client.login(username='user1233', password='password123!q@wE#R$T') user = BlogUser.objects.filter(email='user123@user.com')[0] + # 设置为超级用户和管理员 user.is_superuser = True user.is_staff = True user.save() + # 删除侧边栏缓存 delete_sidebar_cache() + # 创建分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + # 创建文章 article = Article() article.category = category article.title = "nicetitle333" article.body = "nicecontentttt" article.author = user - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 访问文章管理页面 response = self.client.get(article.get_admin_url()) self.assertEqual(response.status_code, 200) + # 测试登出功能 response = self.client.get(reverse('account:logout')) self.assertIn(response.status_code, [301, 302, 200]) + # 登出后访问文章管理页面应该重定向 response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) + # 测试错误密码登录 response = self.client.post(reverse('account:login'), { 'username': 'user1233', - 'password': 'password123' + 'password': 'password123' # 错误的密码 }) self.assertIn(response.status_code, [301, 302, 200]) + # 用错误密码登录后访问文章管理页面应该重定向 response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) + # 测试邮箱验证码功能 def test_verify_email_code(self): to_email = "admin@admin.com" + # 生成验证码 code = generate_code() + # 设置验证码 utils.set_code(to_email, code) + # 发送验证邮件 utils.send_verify_email(to_email, code) + # 测试正确的验证码 err = utils.verify("admin@admin.com", code) self.assertEqual(err, None) + # 测试错误的邮箱 err = utils.verify("admin@123.com", code) - self.assertEqual(type(err), str) + self.assertEqual(type(err), str) # 应该返回错误信息字符串 + # 测试成功发送忘记密码验证码 def test_forget_password_email_code_success(self): resp = self.client.post( path=reverse("account:forget_password_code"), @@ -139,21 +189,26 @@ class AccountTest(TestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.content.decode("utf-8"), "ok") + # 测试发送忘记密码验证码失败的情况 def test_forget_password_email_code_fail(self): + # 测试空数据 resp = self.client.post( path=reverse("account:forget_password_code"), data=dict() ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + # 测试错误邮箱格式 resp = self.client.post( path=reverse("account:forget_password_code"), data=dict(email="admin@com") ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + # 测试成功重置密码 def test_forget_password_email_success(self): code = generate_code() + # 设置验证码 utils.set_code(self.blog_user.email, code) data = dict( new_password1=self.new_test, @@ -165,6 +220,7 @@ class AccountTest(TestCase): path=reverse("account:forget_password"), data=data ) + # 应该重定向到成功页面 self.assertEqual(resp.status_code, 302) # 验证用户密码是否修改成功 @@ -172,13 +228,15 @@ class AccountTest(TestCase): email=self.blog_user.email, ).first() # type: BlogUser self.assertNotEqual(blog_user, None) + # 检查新密码是否正确设置 self.assertEqual(blog_user.check_password(data["new_password1"]), True) + # 测试不存在的用户重置密码 def test_forget_password_email_not_user(self): data = dict( new_password1=self.new_test, new_password2=self.new_test, - email="123@123.com", + email="123@123.com", # 不存在的邮箱 code="123456", ) resp = self.client.post( @@ -186,9 +244,9 @@ class AccountTest(TestCase): data=data ) - self.assertEqual(resp.status_code, 200) - + self.assertEqual(resp.status_code, 200) # 应该停留在当前页面 + # 测试验证码错误的情况 def test_forget_password_email_code_error(self): code = generate_code() utils.set_code(self.blog_user.email, code) @@ -196,12 +254,11 @@ class AccountTest(TestCase): new_password1=self.new_test, new_password2=self.new_test, email=self.blog_user.email, - code="111111", + code="111111", # 错误的验证码 ) resp = self.client.post( path=reverse("account:forget_password"), data=data ) - self.assertEqual(resp.status_code, 200) - + self.assertEqual(resp.status_code, 200) # 应该停留在当前页面 \ No newline at end of file diff --git a/src/DjangoBlog/accounts/urls.py b/src/DjangoBlog/accounts/urls.py index 107a801..3b97486 100644 --- a/src/DjangoBlog/accounts/urls.py +++ b/src/DjangoBlog/accounts/urls.py @@ -1,28 +1,52 @@ +# 导入Django URL路由相关模块 from django.urls import path from django.urls import re_path +# 导入当前应用的视图模块 from . import views +# 导入自定义登录表单 from .forms import LoginForm +# 定义应用的命名空间 app_name = "accounts" -urlpatterns = [re_path(r'^login/$', - views.LoginView.as_view(success_url='/'), - name='login', - kwargs={'authentication_form': LoginForm}), - re_path(r'^register/$', - views.RegisterView.as_view(success_url="/"), - name='register'), - re_path(r'^logout/$', - views.LogoutView.as_view(), - name='logout'), - path(r'account/result.html', - views.account_result, - name='result'), - re_path(r'^forget_password/$', - views.ForgetPasswordView.as_view(), - name='forget_password'), - re_path(r'^forget_password_code/$', - views.ForgetPasswordEmailCode.as_view(), - name='forget_password_code'), - ] +# 定义URL模式列表 +urlpatterns = [ + # 登录URL,使用正则表达式匹配 + re_path(r'^login/$', + # 使用类视图,设置登录成功后跳转到首页 + views.LoginView.as_view(success_url='/'), + name='login', # URL名称 + # 传递额外参数,指定认证表单为自定义的LoginForm + kwargs={'authentication_form': LoginForm}), + + # 注册URL,使用正则表达式匹配 + re_path(r'^register/$', + # 使用类视图,设置注册成功后跳转到首页 + views.RegisterView.as_view(success_url="/"), + name='register'), # URL名称 + + # 登出URL,使用正则表达式匹配 + re_path(r'^logout/$', + # 使用类视图 + views.LogoutView.as_view(), + name='logout'), # URL名称 + + # 账户结果页面URL,使用path匹配精确路径 + path(r'account/result.html', + # 使用函数视图 + views.account_result, + name='result'), # URL名称 + + # 忘记密码URL,使用正则表达式匹配 + re_path(r'^forget_password/$', + # 使用类视图 + views.ForgetPasswordView.as_view(), + name='forget_password'), # URL名称 + + # 忘记密码验证码请求URL,使用正则表达式匹配 + re_path(r'^forget_password_code/$', + # 使用类视图 + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), # URL名称 +] \ No newline at end of file diff --git a/src/DjangoBlog/accounts/user_login_backend.py b/src/DjangoBlog/accounts/user_login_backend.py index 73cdca1..bddbd96 100644 --- a/src/DjangoBlog/accounts/user_login_backend.py +++ b/src/DjangoBlog/accounts/user_login_backend.py @@ -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 \ No newline at end of file diff --git a/src/DjangoBlog/accounts/utils.py b/src/DjangoBlog/accounts/utils.py index 4b94bdf..b177acc 100644 --- a/src/DjangoBlog/accounts/utils.py +++ b/src/DjangoBlog/accounts/utils.py @@ -1,15 +1,22 @@ +# 导入类型提示模块 import typing +# 导入时间间隔类 from datetime import timedelta +# 导入Django缓存模块 from django.core.cache import cache +# 导入国际化翻译函数 from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +# 导入自定义邮件发送工具 from djangoblog.utils import send_email +# 定义验证码的生存时间(5分钟) _code_ttl = timedelta(minutes=5) +# 发送验证邮件的函数 def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): """发送重设密码验证码 Args: @@ -17,12 +24,15 @@ def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")) subject: 邮件主题 code: 验证码 """ + # 构建邮件HTML内容,包含验证码信息 html_content = _( "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " "properly") % {'code': code} + # 调用邮件发送函数 send_email([to_mail], subject, html_content) +# 验证验证码的函数 def verify(email: str, code: str) -> typing.Optional[str]: """验证code是否有效 Args: @@ -34,16 +44,23 @@ def verify(email: str, code: str) -> typing.Optional[str]: 这里的错误处理不太合理,应该采用raise抛出 否测调用方也需要对error进行处理 """ + # 从缓存中获取该邮箱对应的验证码 cache_code = get_code(email) + # 比较输入的验证码和缓存中的验证码 if cache_code != code: + # 如果不匹配,返回错误信息 return gettext("Verification code error") +# 设置验证码到缓存的函数 def set_code(email: str, code: str): """设置code""" + # 将验证码存入缓存,设置过期时间为_code_ttl定义的秒数 cache.set(email, code, _code_ttl.seconds) +# 从缓存获取验证码的函数 def get_code(email: str) -> typing.Optional[str]: """获取code""" - return cache.get(email) + # 从缓存中获取指定邮箱的验证码 + return cache.get(email) \ No newline at end of file diff --git a/src/DjangoBlog/accounts/views.py b/src/DjangoBlog/accounts/views.py index ae67aec..55015d1 100644 --- a/src/DjangoBlog/accounts/views.py +++ b/src/DjangoBlog/accounts/views.py @@ -1,59 +1,92 @@ +# 导入日志模块 import logging +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入Django配置 from django.conf import settings +# 导入Django认证相关模块 from django.contrib import auth from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth import get_user_model from django.contrib.auth import logout from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.hashers import make_password +# 导入HTTP响应类 from django.http import HttpResponseRedirect, HttpResponseForbidden from django.http.request import HttpRequest from django.http.response import HttpResponse +# 导入快捷函数 from django.shortcuts import get_object_or_404 from django.shortcuts import render +# 导入URL反向解析 from django.urls import reverse +# 导入方法装饰器 from django.utils.decorators import method_decorator +# 导入URL安全验证工具 from django.utils.http import url_has_allowed_host_and_scheme +# 导入基于类的视图 from django.views import View +# 导入视图装饰器 from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, RedirectView +# 导入自定义工具函数 from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +# 导入当前应用的工具模块 from . import utils +# 导入自定义表单 from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +# 导入用户模型 from .models import BlogUser +# 获取日志器 logger = logging.getLogger(__name__) # Create your views here. +# 注册视图,继承自FormView class RegisterView(FormView): + # 指定使用的表单类 form_class = RegisterForm + # 指定模板文件 template_name = 'account/registration_form.html' + # 使用CSRF保护装饰器 @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): + # 调用父类的dispatch方法 return super(RegisterView, self).dispatch(*args, **kwargs) + # 表单验证通过后的处理 def form_valid(self, form): + # 检查表单是否有效 if form.is_valid(): + # 保存用户但不提交到数据库(commit=False) user = form.save(False) + # 设置用户为非活跃状态(需要邮箱验证) user.is_active = False + # 设置用户来源为注册 user.source = 'Register' + # 保存用户到数据库 user.save(True) + # 获取当前站点域名 site = get_current_site().domain + # 生成验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + # 如果是调试模式,使用本地地址 if settings.DEBUG: site = '127.0.0.1:8000' + # 获取结果页面的URL路径 path = reverse('account:result') + # 构建完整的验证URL url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( site=site, path=path, id=user.id, sign=sign) + # 构建邮件内容 content = """

请点击下面链接验证您的邮箱

@@ -64,6 +97,7 @@ class RegisterView(FormView): 如果上面链接无法打开,请将此链接复制至浏览器。 {url} """.format(url=url) + # 发送验证邮件 send_email( emailto=[ user.email, @@ -71,134 +105,200 @@ class RegisterView(FormView): title='验证您的电子邮箱', content=content) + # 构建注册成功重定向URL url = reverse('accounts:result') + \ '?type=register&id=' + str(user.id) + # 重定向到结果页面 return HttpResponseRedirect(url) else: + # 表单无效,重新渲染表单页面 return self.render_to_response({ 'form': form }) +# 登出视图,继承自RedirectView class LogoutView(RedirectView): + # 设置登出后重定向的URL url = '/login/' + # 使用永不缓存装饰器 @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): return super(LogoutView, self).dispatch(request, *args, **kwargs) + # 处理GET请求 def get(self, request, *args, **kwargs): + # 执行登出操作 logout(request) + # 删除侧边栏缓存 delete_sidebar_cache() + # 调用父类的GET方法进行重定向 return super(LogoutView, self).get(request, *args, **kwargs) +# 登录视图,继承自FormView class LoginView(FormView): + # 指定使用的表单类 form_class = LoginForm + # 指定模板文件 template_name = 'account/login.html' + # 设置登录成功后的默认重定向URL success_url = '/' + # 重定向字段名 redirect_field_name = REDIRECT_FIELD_NAME - login_ttl = 2626560 # 一个月的时间 + # 登录会话有效期(一个月) + login_ttl = 2626560 - @method_decorator(sensitive_post_parameters('password')) - @method_decorator(csrf_protect) - @method_decorator(never_cache) + # 使用多个装饰器保护视图 + @method_decorator(sensitive_post_parameters('password')) # 敏感参数保护 + @method_decorator(csrf_protect) # CSRF保护 + @method_decorator(never_cache) # 永不缓存 def dispatch(self, request, *args, **kwargs): - return super(LoginView, self).dispatch(request, *args, **kwargs) + # 获取上下文数据 def get_context_data(self, **kwargs): + # 获取重定向URL redirect_to = self.request.GET.get(self.redirect_field_name) + # 如果没有重定向URL,使用默认首页 if redirect_to is None: redirect_to = '/' + # 将重定向URL添加到上下文 kwargs['redirect_to'] = redirect_to return super(LoginView, self).get_context_data(**kwargs) + # 表单验证通过后的处理 def form_valid(self, form): + # 创建认证表单实例 form = AuthenticationForm(data=self.request.POST, request=self.request) + # 检查表单是否有效 if form.is_valid(): + # 删除侧边栏缓存 delete_sidebar_cache() + # 记录日志 logger.info(self.redirect_field_name) + # 执行登录操作 auth.login(self.request, form.get_user()) + # 如果用户选择"记住我",设置会话过期时间 if self.request.POST.get("remember"): self.request.session.set_expiry(self.login_ttl) + # 调用父类的form_valid方法进行重定向 return super(LoginView, self).form_valid(form) - # return HttpResponseRedirect('/') else: + # 表单无效,重新渲染表单页面 return self.render_to_response({ 'form': form }) + # 获取成功登录后的重定向URL def get_success_url(self): - + # 从POST数据中获取重定向URL redirect_to = self.request.POST.get(self.redirect_field_name) + # 验证重定向URL是否安全 if not url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts=[ self.request.get_host()]): + # 如果不安全,使用默认成功URL redirect_to = self.success_url return redirect_to +# 账户结果页面视图函数 def account_result(request): + # 获取结果类型 type = request.GET.get('type') + # 获取用户ID id = request.GET.get('id') + # 获取用户对象,如果不存在返回404 user = get_object_or_404(get_user_model(), id=id) + # 记录日志 logger.info(type) + # 如果用户已经是活跃状态,重定向到首页 if user.is_active: return HttpResponseRedirect('/') + # 处理注册和验证类型 if type and type in ['register', 'validation']: if type == 'register': + # 注册成功的内容 content = ''' 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 ''' title = '注册成功' else: + # 生成验证签名 c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + # 获取请求中的签名 sign = request.GET.get('sign') + # 验证签名是否匹配 if sign != c_sign: return HttpResponseForbidden() + # 激活用户账户 user.is_active = True user.save() + # 验证成功的内容 content = ''' 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 ''' title = '验证成功' + # 渲染结果页面 return render(request, 'account/result.html', { 'title': title, 'content': content }) else: + # 其他情况重定向到首页 return HttpResponseRedirect('/') +# 忘记密码视图,继承自FormView class ForgetPasswordView(FormView): + # 指定使用的表单类 form_class = ForgetPasswordForm + # 指定模板文件 template_name = 'account/forget_password.html' + # 表单验证通过后的处理 def form_valid(self, form): + # 检查表单是否有效 if form.is_valid(): + # 根据邮箱获取用户对象 blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + # 使用新密码的哈希值更新用户密码 blog_user.password = make_password(form.cleaned_data["new_password2"]) + # 保存用户信息 blog_user.save() + # 重定向到登录页面 return HttpResponseRedirect('/login/') else: + # 表单无效,重新渲染表单页面 return self.render_to_response({'form': form}) +# 忘记密码验证码发送视图,继承自View class ForgetPasswordEmailCode(View): + # 处理POST请求 def post(self, request: HttpRequest): + # 创建表单实例 form = ForgetPasswordCodeForm(request.POST) + # 检查表单是否有效 if not form.is_valid(): + # 表单无效,返回错误信息 return HttpResponse("错误的邮箱") + # 获取邮箱地址 to_email = form.cleaned_data["email"] + # 生成验证码 code = generate_code() + # 发送验证邮件 utils.send_verify_email(to_email, code) + # 保存验证码到缓存 utils.set_code(to_email, code) - return HttpResponse("ok") + # 返回成功响应 + return HttpResponse("ok") \ No newline at end of file diff --git a/src/DjangoBlog/blog/admin.py b/src/DjangoBlog/blog/admin.py index 69d7f8e..f594bfa 100644 --- a/src/DjangoBlog/blog/admin.py +++ b/src/DjangoBlog/blog/admin.py @@ -1,48 +1,69 @@ +# 导入Django表单模块 from django import forms +# 导入Django管理员模块 from django.contrib import admin +# 导入获取用户模型的函数 from django.contrib.auth import get_user_model +# 导入URL反向解析 from django.urls import reverse +# 导入HTML格式化工具 from django.utils.html import format_html +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ # Register your models here. +# 导入博客模型 from .models import Article, Category, Tag, Links, SideBar, BlogSettings +# 自定义文章表单 class ArticleForm(forms.ModelForm): # body = forms.CharField(widget=AdminPagedownWidget()) + # 定义表单的元数据 class Meta: + # 指定表单对应的模型 model = Article + # 包含所有字段 fields = '__all__' +# 发布文章的管理动作函数 def makr_article_publish(modeladmin, request, queryset): queryset.update(status='p') +# 将文章设为草稿的管理动作函数 def draft_article(modeladmin, request, queryset): queryset.update(status='d') +# 关闭文章评论的管理动作函数 def close_article_commentstatus(modeladmin, request, queryset): queryset.update(comment_status='c') +# 打开文章评论的管理动作函数 def open_article_commentstatus(modeladmin, request, queryset): queryset.update(comment_status='o') +# 设置管理动作的显示名称 makr_article_publish.short_description = _('Publish selected articles') draft_article.short_description = _('Draft selected articles') close_article_commentstatus.short_description = _('Close article comments') open_article_commentstatus.short_description = _('Open article comments') +# 文章模型的管理类 class ArticlelAdmin(admin.ModelAdmin): + # 每页显示20条记录 list_per_page = 20 + # 搜索字段 search_fields = ('body', 'title') + # 指定使用的表单 form = ArticleForm + # 列表页显示的字段 list_display = ( 'id', 'title', @@ -53,62 +74,92 @@ class ArticlelAdmin(admin.ModelAdmin): 'status', 'type', 'article_order') + # 可作为链接点击的字段 list_display_links = ('id', 'title') + # 右侧过滤器字段 list_filter = ('status', 'type', 'category') + # 日期层次导航 date_hierarchy = 'creation_time' + # 水平选择器字段 filter_horizontal = ('tags',) + # 排除的字段(不在表单中显示) exclude = ('creation_time', 'last_modify_time') + # 启用"在站点查看"功能 view_on_site = True + # 可用的管理动作 actions = [ makr_article_publish, draft_article, close_article_commentstatus, open_article_commentstatus] + # 使用原始ID字段(显示搜索框而不是下拉选择) raw_id_fields = ('author', 'category',) + # 自定义方法:显示分类链接 def link_to_category(self, obj): + # 获取分类模型的app和model信息 info = (obj.category._meta.app_label, obj.category._meta.model_name) + # 生成分类编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) + # 返回HTML链接 return format_html(u'%s' % (link, obj.category.name)) + # 设置自定义方法的显示名称 link_to_category.short_description = _('category') + # 重写获取表单的方法 def get_form(self, request, obj=None, **kwargs): form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) + # 限制作者字段只能选择超级用户 form.base_fields['author'].queryset = get_user_model( ).objects.filter(is_superuser=True) return form + # 重写保存模型的方法 def save_model(self, request, obj, form, change): super(ArticlelAdmin, self).save_model(request, obj, form, change) + # 重写获取站点查看URL的方法 def get_view_on_site_url(self, obj=None): if obj: + # 返回文章的完整URL url = obj.get_full_url() return url else: + # 如果没有指定对象,返回站点域名 from djangoblog.utils import get_current_site site = get_current_site().domain return site +# 标签模型的管理类 class TagAdmin(admin.ModelAdmin): - exclude = ('slug', 'last_mod_time', 'creation_time') + # 排除的字段 + exclude = ('slug', 'last_modify_time', 'creation_time') +# 分类模型的管理类 class CategoryAdmin(admin.ModelAdmin): + # 列表页显示的字段 list_display = ('name', 'parent_category', 'index') - exclude = ('slug', 'last_mod_time', 'creation_time') + # 排除的字段 + exclude = ('slug', 'last_modify_time', 'creation_time') +# 链接模型的管理类 class LinksAdmin(admin.ModelAdmin): - exclude = ('last_mod_time', 'creation_time') + # 排除的字段 + exclude = ('last_modify_time', 'creation_time') +# 侧边栏模型的管理类 class SideBarAdmin(admin.ModelAdmin): + # 列表页显示的字段 list_display = ('name', 'content', 'is_enable', 'sequence') - exclude = ('last_mod_time', 'creation_time') + # 排除的字段 + exclude = ('last_modify_time', 'creation_time') +# 博客设置模型的管理类 class BlogSettingsAdmin(admin.ModelAdmin): - pass + pass \ No newline at end of file diff --git a/src/DjangoBlog/blog/apps.py b/src/DjangoBlog/blog/apps.py index 7930587..48116b9 100644 --- a/src/DjangoBlog/blog/apps.py +++ b/src/DjangoBlog/blog/apps.py @@ -1,5 +1,8 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义blog应用的配置类 class BlogConfig(AppConfig): - name = 'blog' + # 指定应用的Python路径(Django内部使用的标识) + name = 'blog' \ No newline at end of file diff --git a/src/DjangoBlog/blog/context_processors.py b/src/DjangoBlog/blog/context_processors.py index 73e3088..7bf9539 100644 --- a/src/DjangoBlog/blog/context_processors.py +++ b/src/DjangoBlog/blog/context_processors.py @@ -1,43 +1,57 @@ +# 导入日志模块 import logging +# 导入Django时区工具 from django.utils import timezone +# 导入自定义工具函数 from djangoblog.utils import cache, get_blog_setting +# 导入模型类 from .models import Category, Article +# 获取日志记录器 logger = logging.getLogger(__name__) +# SEO上下文处理器函数 def seo_processor(requests): + # 缓存键名 key = 'seo_processor' + # 尝试从缓存获取数据 value = cache.get(key) if value: + # 如果缓存存在,直接返回缓存数据 return value else: + # 缓存不存在,记录日志并生成新数据 logger.info('set processor cache.') + # 获取博客设置 setting = get_blog_setting() + # 构建上下文数据字典 value = { - 'SITE_NAME': setting.site_name, - 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, - 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, - 'SITE_SEO_DESCRIPTION': setting.site_seo_description, - 'SITE_DESCRIPTION': setting.site_description, - 'SITE_KEYWORDS': setting.site_keywords, - 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', - 'ARTICLE_SUB_LENGTH': setting.article_sub_length, - 'nav_category_list': Category.objects.all(), + 'SITE_NAME': setting.site_name, # 网站名称 + 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # 是否显示Google广告 + 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # Google广告代码 + 'SITE_SEO_DESCRIPTION': setting.site_seo_description, # 网站SEO描述 + 'SITE_DESCRIPTION': setting.site_description, # 网站描述 + 'SITE_KEYWORDS': setting.site_keywords, # 网站关键词 + 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # 网站基础URL + 'ARTICLE_SUB_LENGTH': setting.article_sub_length, # 文章摘要长度 + 'nav_category_list': Category.objects.all(), # 导航分类列表 'nav_pages': Article.objects.filter( - type='p', - status='p'), - 'OPEN_SITE_COMMENT': setting.open_site_comment, - 'BEIAN_CODE': setting.beian_code, - 'ANALYTICS_CODE': setting.analytics_code, - "BEIAN_CODE_GONGAN": setting.gongan_beiancode, - "SHOW_GONGAN_CODE": setting.show_gongan_code, - "CURRENT_YEAR": timezone.now().year, - "GLOBAL_HEADER": setting.global_header, - "GLOBAL_FOOTER": setting.global_footer, - "COMMENT_NEED_REVIEW": setting.comment_need_review, + type='p', # 页面类型 + status='p'), # 已发布状态 + 'OPEN_SITE_COMMENT': setting.open_site_comment, # 是否开启网站评论 + 'BEIAN_CODE': setting.beian_code, # 备案号 + 'ANALYTICS_CODE': setting.analytics_code, # 网站统计代码 + "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号 + "SHOW_GONGAN_CODE": setting.show_gongan_code, # 是否显示公安备案 + "CURRENT_YEAR": timezone.now().year, # 当前年份 + "GLOBAL_HEADER": setting.global_header, # 全局头部内容 + "GLOBAL_FOOTER": setting.global_footer, # 全局尾部内容 + "COMMENT_NEED_REVIEW": setting.comment_need_review, # 评论是否需要审核 } + # 将数据存入缓存,有效期10小时 cache.set(key, value, 60 * 60 * 10) - return value + # 返回上下文数据 + return value \ No newline at end of file diff --git a/src/DjangoBlog/blog/documents.py b/src/DjangoBlog/blog/documents.py index 0f1db7b..b38fda0 100644 --- a/src/DjangoBlog/blog/documents.py +++ b/src/DjangoBlog/blog/documents.py @@ -1,26 +1,37 @@ +# 导入时间模块 import time +# 导入Elasticsearch客户端 import elasticsearch.client +# 导入Django配置 from django.conf import settings +# 导入Elasticsearch DSL相关类 from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean from elasticsearch_dsl.connections import connections +# 导入博客文章模型 from blog.models import Article +# 检查是否启用了Elasticsearch ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') if ELASTICSEARCH_ENABLED: + # 创建Elasticsearch连接 connections.create_connection( hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) from elasticsearch import Elasticsearch + # 创建Elasticsearch客户端实例 es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) from elasticsearch.client import IngestClient + # 创建Ingest客户端用于管道处理 c = IngestClient(es) try: + # 检查geoip管道是否存在 c.get_pipeline('geoip') except elasticsearch.exceptions.NotFoundError: + # 如果不存在则创建geoip管道 c.put_pipeline('geoip', body='''{ "description" : "Add geoip info", "processors" : [ @@ -33,155 +44,174 @@ if ELASTICSEARCH_ENABLED: }''') +# 定义GeoIP内部文档类 class GeoIp(InnerDoc): - continent_name = Keyword() - country_iso_code = Keyword() - country_name = Keyword() - location = GeoPoint() + continent_name = Keyword() # 大洲名称 + country_iso_code = Keyword() # 国家ISO代码 + country_name = Keyword() # 国家名称 + location = GeoPoint() # 地理位置坐标 +# 定义用户代理浏览器内部文档类 class UserAgentBrowser(InnerDoc): - Family = Keyword() - Version = Keyword() + Family = Keyword() # 浏览器家族 + Version = Keyword() # 浏览器版本 +# 定义用户代理操作系统内部文档类,继承自UserAgentBrowser class UserAgentOS(UserAgentBrowser): pass +# 定义用户代理设备内部文档类 class UserAgentDevice(InnerDoc): - Family = Keyword() - Brand = Keyword() - Model = Keyword() + Family = Keyword() # 设备家族 + Brand = Keyword() # 设备品牌 + Model = Keyword() # 设备型号 +# 定义用户代理内部文档类 class UserAgent(InnerDoc): - browser = Object(UserAgentBrowser, required=False) - os = Object(UserAgentOS, required=False) - device = Object(UserAgentDevice, required=False) - string = Text() - is_bot = Boolean() + browser = Object(UserAgentBrowser, required=False) # 浏览器信息 + os = Object(UserAgentOS, required=False) # 操作系统信息 + device = Object(UserAgentDevice, required=False) # 设备信息 + string = Text() # 原始用户代理字符串 + is_bot = Boolean() # 是否为机器人 +# 定义耗时记录文档类 class ElapsedTimeDocument(Document): - url = Keyword() - time_taken = Long() - log_datetime = Date() - ip = Keyword() - geoip = Object(GeoIp, required=False) - useragent = Object(UserAgent, required=False) + url = Keyword() # 请求URL + time_taken = Long() # 耗时(毫秒) + log_datetime = Date() # 日志时间 + ip = Keyword() # IP地址 + geoip = Object(GeoIp, required=False) # GeoIP信息 + useragent = Object(UserAgent, required=False) # 用户代理信息 class Index: - name = 'performance' + name = 'performance' # 索引名称 settings = { - "number_of_shards": 1, - "number_of_replicas": 0 + "number_of_shards": 1, # 分片数量 + "number_of_replicas": 0 # 副本数量 } class Meta: - doc_type = 'ElapsedTime' + doc_type = 'ElapsedTime' # 文档类型 +# 耗时记录文档管理器类 class ElaspedTimeDocumentManager: @staticmethod def build_index(): from elasticsearch import Elasticsearch client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 检查索引是否存在 res = client.indices.exists(index="performance") if not res: + # 如果不存在则初始化索引 ElapsedTimeDocument.init() @staticmethod def delete_index(): from elasticsearch import Elasticsearch es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 删除索引,忽略400和404错误 es.indices.delete(index='performance', ignore=[400, 404]) @staticmethod def create(url, time_taken, log_datetime, useragent, ip): + # 构建索引 ElaspedTimeDocumentManager.build_index() + # 创建用户代理对象 ua = UserAgent() ua.browser = UserAgentBrowser() - ua.browser.Family = useragent.browser.family - ua.browser.Version = useragent.browser.version_string + ua.browser.Family = useragent.browser.family # 浏览器家族 + ua.browser.Version = useragent.browser.version_string # 浏览器版本 ua.os = UserAgentOS() - ua.os.Family = useragent.os.family - ua.os.Version = useragent.os.version_string + ua.os.Family = useragent.os.family # 操作系统家族 + ua.os.Version = useragent.os.version_string # 操作系统版本 ua.device = UserAgentDevice() - ua.device.Family = useragent.device.family - ua.device.Brand = useragent.device.brand - ua.device.Model = useragent.device.model - ua.string = useragent.ua_string - ua.is_bot = useragent.is_bot + ua.device.Family = useragent.device.family # 设备家族 + ua.device.Brand = useragent.device.brand # 设备品牌 + ua.device.Model = useragent.device.model # 设备型号 + ua.string = useragent.ua_string # 原始用户代理字符串 + ua.is_bot = useragent.is_bot # 是否为机器人 + # 创建文档对象,使用时间戳作为ID doc = ElapsedTimeDocument( meta={ 'id': int( round( time.time() * - 1000)) + 1000)) # 使用当前时间戳作为文档ID }, url=url, time_taken=time_taken, log_datetime=log_datetime, useragent=ua, ip=ip) + # 保存文档,使用geoip管道处理 doc.save(pipeline="geoip") +# 定义文章文档类 class ArticleDocument(Document): - body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') - title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') + body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 正文,使用IK分词器 + title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 标题,使用IK分词器 author = Object(properties={ - 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), - 'id': Integer() + 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 作者昵称 + 'id': Integer() # 作者ID }) category = Object(properties={ - 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), - 'id': Integer() + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 分类名称 + 'id': Integer() # 分类ID }) tags = Object(properties={ - 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), - 'id': Integer() + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 标签名称 + 'id': Integer() # 标签ID }) - pub_time = Date() - status = Text() - comment_status = Text() - type = Text() - views = Integer() - article_order = Integer() + pub_time = Date() # 发布时间 + status = Text() # 文章状态 + comment_status = Text() # 评论状态 + type = Text() # 文章类型 + views = Integer() # 浏览量 + article_order = Integer() # 文章排序 class Index: - name = 'blog' + name = 'blog' # 索引名称 settings = { - "number_of_shards": 1, - "number_of_replicas": 0 + "number_of_shards": 1, # 分片数量 + "number_of_replicas": 0 # 副本数量 } class Meta: - doc_type = 'Article' + doc_type = 'Article' # 文档类型 +# 文章文档管理器类 class ArticleDocumentManager(): def __init__(self): self.create_index() def create_index(self): + # 创建文章索引 ArticleDocument.init() def delete_index(self): from elasticsearch import Elasticsearch es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 删除索引,忽略400和404错误 es.indices.delete(index='blog', ignore=[400, 404]) def convert_to_doc(self, articles): + # 将文章模型转换为文档对象 return [ ArticleDocument( meta={ - 'id': article.id}, + 'id': article.id}, # 使用文章ID作为文档ID body=article.body, title=article.title, author={ @@ -193,7 +223,7 @@ class ArticleDocumentManager(): tags=[ { 'name': t.name, - 'id': t.id} for t in article.tags.all()], + 'id': t.id} for t in article.tags.all()], # 转换标签列表 pub_time=article.pub_time, status=article.status, comment_status=article.comment_status, @@ -202,12 +232,14 @@ class ArticleDocumentManager(): article_order=article.article_order) for article in articles] def rebuild(self, articles=None): + # 重建索引 ArticleDocument.init() - articles = articles if articles else Article.objects.all() - docs = self.convert_to_doc(articles) + articles = articles if articles else Article.objects.all() # 如果没有指定文章,则获取所有文章 + docs = self.convert_to_doc(articles) # 转换为文档对象 for doc in docs: - doc.save() + doc.save() # 保存文档 def update_docs(self, docs): + # 更新文档 for doc in docs: - doc.save() + doc.save() \ No newline at end of file diff --git a/src/DjangoBlog/blog/forms.py b/src/DjangoBlog/blog/forms.py index 715be76..121f68e 100644 --- a/src/DjangoBlog/blog/forms.py +++ b/src/DjangoBlog/blog/forms.py @@ -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 \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/build_index.py b/src/DjangoBlog/blog/management/commands/build_index.py index 3c4acd7..d3a5a66 100644 --- a/src/DjangoBlog/blog/management/commands/build_index.py +++ b/src/DjangoBlog/blog/management/commands/build_index.py @@ -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() \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/build_search_words.py b/src/DjangoBlog/blog/management/commands/build_search_words.py index cfe7e0d..4992bba 100644 --- a/src/DjangoBlog/blog/management/commands/build_search_words.py +++ b/src/DjangoBlog/blog/management/commands/build_search_words.py @@ -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)) \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/clear_cache.py b/src/DjangoBlog/blog/management/commands/clear_cache.py index 0d66172..3a81284 100644 --- a/src/DjangoBlog/blog/management/commands/clear_cache.py +++ b/src/DjangoBlog/blog/management/commands/clear_cache.py @@ -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')) \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/create_testdata.py b/src/DjangoBlog/blog/management/commands/create_testdata.py index 675d2ba..531699a 100644 --- a/src/DjangoBlog/blog/management/commands/create_testdata.py +++ b/src/DjangoBlog/blog/management/commands/create_testdata.py @@ -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')) \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/ping_baidu.py b/src/DjangoBlog/blog/management/commands/ping_baidu.py index 2c7fbdd..b4c9634 100644 --- a/src/DjangoBlog/blog/management/commands/ping_baidu.py +++ b/src/DjangoBlog/blog/management/commands/ping_baidu.py @@ -1,50 +1,69 @@ +# 导入Django管理命令基类 from django.core.management.base import BaseCommand +# 导入蜘蛛通知工具 from djangoblog.spider_notify import SpiderNotify +# 导入获取当前站点工具 from djangoblog.utils import get_current_site +# 导入博客模型 from blog.models import Article, Tag, Category +# 获取当前站点域名 site = get_current_site().domain +# 自定义管理命令类,用于通知百度搜索引擎URL更新 class Command(BaseCommand): + # 命令的帮助信息 help = 'notify baidu url' + # 添加命令行参数 def add_arguments(self, parser): parser.add_argument( - 'data_type', - type=str, + 'data_type', # 参数名称 + type=str, # 参数类型 choices=[ - 'all', - 'article', - 'tag', - 'category'], - help='article : all article,tag : all tag,category: all category,all: All of these') + 'all', # 所有类型 + 'article', # 仅文章 + 'tag', # 仅标签 + 'category'], # 仅分类 + help='article : all article,tag : all tag,category: all category,all: All of these') # 帮助信息 + # 根据相对路径获取完整URL的方法 def get_full_url(self, path): url = "https://{site}{path}".format(site=site, path=path) return url + # 命令的主要处理逻辑 def handle(self, *args, **options): + # 获取命令行参数中的数据类型 type = options['data_type'] + # 输出开始处理的信息 self.stdout.write('start get %s' % type) + # 初始化URL列表 urls = [] + # 如果类型是文章或全部,获取所有已发布文章的URL if type == 'article' or type == 'all': - for article in Article.objects.filter(status='p'): + for article in Article.objects.filter(status='p'): # 只获取已发布的文章 urls.append(article.get_full_url()) + # 如果类型是标签或全部,获取所有标签的URL if type == 'tag' or type == 'all': for tag in Tag.objects.all(): - url = tag.get_absolute_url() - urls.append(self.get_full_url(url)) + url = tag.get_absolute_url() # 获取标签的相对URL + urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表 + # 如果类型是分类或全部,获取所有分类的URL if type == 'category' or type == 'all': for category in Category.objects.all(): - url = category.get_absolute_url() - urls.append(self.get_full_url(url)) + url = category.get_absolute_url() # 获取分类的相对URL + urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表 + # 输出开始通知的信息,显示URL数量 self.stdout.write( self.style.SUCCESS( 'start notify %d urls' % len(urls))) + # 调用百度蜘蛛通知接口,批量提交URL SpiderNotify.baidu_notify(urls) - self.stdout.write(self.style.SUCCESS('finish notify')) + # 输出完成通知的信息 + self.stdout.write(self.style.SUCCESS('finish notify')) \ No newline at end of file diff --git a/src/DjangoBlog/blog/management/commands/sync_user_avatar.py b/src/DjangoBlog/blog/management/commands/sync_user_avatar.py index d0f4612..ad6a993 100644 --- a/src/DjangoBlog/blog/management/commands/sync_user_avatar.py +++ b/src/DjangoBlog/blog/management/commands/sync_user_avatar.py @@ -1,47 +1,82 @@ +# 导入requests库用于HTTP请求 import requests +# 导入Django管理命令基类 from django.core.management.base import BaseCommand +# 导入静态文件URL处理 from django.templatetags.static import static +# 导入保存用户头像的工具函数 from djangoblog.utils import save_user_avatar +# 导入OAuth用户模型 from oauth.models import OAuthUser +# 导入根据类型获取OAuth管理器的函数 from oauth.oauthmanager import get_manager_by_type +# 自定义管理命令类,用于同步用户头像 class Command(BaseCommand): + # 命令的帮助信息 help = 'sync user avatar' + # 测试图片URL是否可访问的方法 def test_picture(self, url): try: + # 发送HTTP GET请求测试图片URL,设置2秒超时 if requests.get(url, timeout=2).status_code == 200: + # 返回200状态码表示图片可访问 return True except: + # 发生任何异常(超时、连接错误等)都返回False pass + # 命令的主要处理逻辑 def handle(self, *args, **options): + # 获取静态文件基础URL static_url = static("../") + # 获取所有OAuth用户 users = OAuthUser.objects.all() + # 输出开始同步的用户数量 self.stdout.write(f'开始同步{len(users)}个用户头像') + # 遍历每个用户 for u in users: + # 输出开始同步当前用户的信息 self.stdout.write(f'开始同步:{u.nickname}') + # 获取用户当前的头像URL url = u.picture + # 如果当前有头像URL if url: + # 检查URL是否以静态文件路径开头 if url.startswith(static_url): + # 测试静态图片是否可访问 if self.test_picture(url): + # 如果可以访问,跳过此用户,继续下一个 continue else: + # 如果静态图片不可访问,检查是否有元数据 if u.metadata: + # 根据用户类型获取对应的OAuth管理器 manage = get_manager_by_type(u.type) + # 从元数据中获取新的头像URL url = manage.get_picture(u.metadata) + # 保存新头像并返回本地URL url = save_user_avatar(url) else: + # 没有元数据,使用默认头像 url = static('blog/img/avatar.png') else: + # 如果不是静态文件URL,直接保存头像并返回本地URL url = save_user_avatar(url) else: + # 如果没有头像URL,使用默认头像 url = static('blog/img/avatar.png') + # 如果成功获取到头像URL if url: + # 输出同步完成的信息 self.stdout.write( f'结束同步:{u.nickname}.url:{url}') + # 更新用户的头像URL u.picture = url + # 保存用户信息 u.save() - self.stdout.write('结束同步') + # 输出同步结束信息 + self.stdout.write('结束同步') \ No newline at end of file diff --git a/src/DjangoBlog/blog/middleware.py b/src/DjangoBlog/blog/middleware.py index 94dd70c..c6bf622 100644 --- a/src/DjangoBlog/blog/middleware.py +++ b/src/DjangoBlog/blog/middleware.py @@ -1,42 +1,65 @@ +# 导入日志模块 import logging +# 导入时间模块 import time +# 导入IP获取工具 from ipware import get_client_ip +# 导入用户代理解析工具 from user_agents import parse +# 导入Elasticsearch相关配置和管理器 from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager +# 获取日志记录器 logger = logging.getLogger(__name__) +# 在线中间件类,用于记录页面渲染时间和用户访问信息 class OnlineMiddleware(object): def __init__(self, get_response=None): + # 初始化中间件,保存get_response函数 self.get_response = get_response super().__init__() def __call__(self, request): ''' page render time ''' + # 记录请求开始时间 start_time = time.time() + # 调用后续中间件和视图处理请求,获取响应 response = self.get_response(request) + # 获取用户代理字符串 http_user_agent = request.META.get('HTTP_USER_AGENT', '') + # 获取客户端IP地址 ip, _ = get_client_ip(request) + # 解析用户代理信息 user_agent = parse(http_user_agent) + # 检查响应是否为流式响应(非流式响应才能处理内容) if not response.streaming: try: + # 计算页面渲染耗时 cast_time = time.time() - start_time + # 如果启用了Elasticsearch if ELASTICSEARCH_ENABLED: + # 将耗时转换为毫秒并保留两位小数 time_taken = round((cast_time) * 1000, 2) + # 获取请求的URL路径 url = request.path + # 导入Django时区工具 from django.utils import timezone + # 创建耗时记录文档 ElaspedTimeDocumentManager.create( - url=url, - time_taken=time_taken, - log_datetime=timezone.now(), - useragent=user_agent, - ip=ip) + url=url, # 请求URL + time_taken=time_taken, # 耗时(毫秒) + log_datetime=timezone.now(), # 当前时间 + useragent=user_agent, # 用户代理信息 + ip=ip) # 客户端IP + # 在响应内容中替换占位符,显示页面加载时间 response.content = response.content.replace( b'', str.encode(str(cast_time)[:5])) except Exception as e: + # 记录处理过程中的错误 logger.error("Error OnlineMiddleware: %s" % e) - return response + # 返回响应 + return response \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0001_initial.py b/src/DjangoBlog/blog/migrations/0001_initial.py index 3d391b6..6bbeaba 100644 --- a/src/DjangoBlog/blog/migrations/0001_initial.py +++ b/src/DjangoBlog/blog/migrations/0001_initial.py @@ -1,137 +1,227 @@ # Generated by Django 4.1.7 on 2023-03-02 07:14 +# 导入Django设置 from django.conf import settings +# 导入数据库迁移相关模块 from django.db import migrations, models import django.db.models.deletion +# 导入时间工具 import django.utils.timezone +# 导入Markdown编辑器字段 import mdeditor.fields class Migration(migrations.Migration): + # 标记为初始迁移 initial = True + # 声明依赖的迁移 dependencies = [ + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义迁移操作序列 operations = [ + # 创建网站配置模型 migrations.CreateModel( name='BlogSettings', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 网站名称字段 ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')), + # 网站描述字段 ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')), + # 网站SEO描述字段 ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')), + # 网站关键词字段 ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')), + # 文章摘要长度字段 ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')), + # 侧边栏文章数量字段 ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')), + # 侧边栏评论数量字段 ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')), + # 文章页面评论数量字段 ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')), + # 是否显示谷歌广告字段 ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')), + # 谷歌广告代码字段 ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')), + # 是否开启网站评论功能字段 ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')), + # 备案号字段 ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), + # 网站统计代码字段 ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')), + # 是否显示公安备案字段 ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')), + # 公安备案号字段 ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), ], options={ + # 模型显示名称(单数) 'verbose_name': '网站配置', + # 模型显示名称(复数) 'verbose_name_plural': '网站配置', }, ), + # 创建友情链接模型 migrations.CreateModel( name='Links', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 链接名称字段,唯一 ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')), + # 链接地址字段 ('link', models.URLField(verbose_name='链接地址')), + # 排序字段,唯一 ('sequence', models.IntegerField(unique=True, verbose_name='排序')), + # 是否启用字段 ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 显示类型字段,使用选择项 ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')), + # 创建时间字段 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ], options={ + # 模型显示名称(单数) 'verbose_name': '友情链接', + # 模型显示名称(复数) 'verbose_name_plural': '友情链接', + # 默认按排序字段升序排列 'ordering': ['sequence'], }, ), + # 创建侧边栏模型 migrations.CreateModel( name='SideBar', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 标题字段 ('name', models.CharField(max_length=100, verbose_name='标题')), + # 内容字段 ('content', models.TextField(verbose_name='内容')), + # 排序字段,唯一 ('sequence', models.IntegerField(unique=True, verbose_name='排序')), + # 是否启用字段 ('is_enable', models.BooleanField(default=True, verbose_name='是否启用')), + # 创建时间字段 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ], options={ + # 模型显示名称(单数) 'verbose_name': '侧边栏', + # 模型显示名称(复数) 'verbose_name_plural': '侧边栏', + # 默认按排序字段升序排列 'ordering': ['sequence'], }, ), + # 创建标签模型 migrations.CreateModel( name='Tag', fields=[ + # 主键ID,自增AutoField ('id', models.AutoField(primary_key=True, serialize=False)), + # 创建时间字段 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 标签名称字段,唯一 ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')), + # 标签slug字段,用于URL ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), ], options={ + # 模型显示名称(单数) 'verbose_name': '标签', + # 模型显示名称(复数) 'verbose_name_plural': '标签', + # 默认按名称升序排列 'ordering': ['name'], }, ), + # 创建分类模型 migrations.CreateModel( name='Category', fields=[ + # 主键ID,自增AutoField ('id', models.AutoField(primary_key=True, serialize=False)), + # 创建时间字段 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 分类名称字段,唯一 ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')), + # 分类slug字段,用于URL ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), + # 权重排序字段,越大越靠前 ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), + # 父级分类外键,支持多级分类 ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')), ], options={ + # 模型显示名称(单数) 'verbose_name': '分类', + # 模型显示名称(复数) 'verbose_name_plural': '分类', + # 默认按权重倒序排列 'ordering': ['-index'], }, ), + # 创建文章模型 migrations.CreateModel( name='Article', fields=[ + # 主键ID,自增AutoField ('id', models.AutoField(primary_key=True, serialize=False)), + # 创建时间字段 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 文章标题字段,唯一 ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')), + # 文章正文字段,使用Markdown编辑器 ('body', mdeditor.fields.MDTextField(verbose_name='正文')), + # 发布时间字段 ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), + # 文章状态字段,使用选择项 ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')), + # 评论状态字段,使用选择项 ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')), + # 类型字段,使用选择项(文章或页面) ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')), + # 浏览量字段,正整数 ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')), + # 文章排序字段,数字越大越靠前 ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')), + # 是否显示TOC目录字段 ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')), + # 作者外键,关联用户模型 ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), + # 分类外键 ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')), + # 标签多对多关系 ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')), ], options={ + # 模型显示名称(单数) 'verbose_name': '文章', + # 模型显示名称(复数) 'verbose_name_plural': '文章', + # 默认按文章排序倒序、发布时间倒序排列 'ordering': ['-article_order', '-pub_time'], + # 指定获取最新记录的依据字段 'get_latest_by': 'id', }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py index adbaa36..c9d090f 100644 --- a/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py +++ b/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py @@ -5,19 +5,23 @@ from django.db import migrations, models class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ - ('blog', '0001_initial'), + ('blog', '0001_initial'), # 依赖于blog应用的初始迁移 ] + # 定义本迁移要执行的操作序列 operations = [ + # 向BlogSettings模型添加新字段:公共尾部 migrations.AddField( - model_name='blogsettings', - name='global_footer', - field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), + model_name='blogsettings', # 指定要修改的模型名称 + name='global_footer', # 新字段名称 + field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), # 文本字段,允许为空,默认值为空字符串 ), + # 向BlogSettings模型添加新字段:公共头部 migrations.AddField( - model_name='blogsettings', - name='global_header', - field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), + model_name='blogsettings', # 指定要修改的模型名称 + name='global_header', # 新字段名称 + field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), # 文本字段,允许为空,默认值为空字符串 ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py b/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py index e9f5502..38088b2 100644 --- a/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py +++ b/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py @@ -4,14 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ - ('blog', '0002_blogsettings_global_footer_and_more'), + ('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于blog应用的第二个迁移 ] + # 定义本迁移要执行的操作序列 operations = [ + # 向BlogSettings模型添加新字段:评论审核开关 migrations.AddField( - model_name='blogsettings', - name='comment_need_review', - field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), + model_name='blogsettings', # 指定要修改的模型名称 + name='comment_need_review', # 新字段名称 + field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), # 布尔字段,默认值为False(不需要审核) ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py index ceb1398..c546b6b 100644 --- a/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py +++ b/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py @@ -4,24 +4,29 @@ from django.db import migrations class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ - ('blog', '0003_blogsettings_comment_need_review'), + ('blog', '0003_blogsettings_comment_need_review'), # 依赖于blog应用的第三个迁移 ] + # 定义本迁移要执行的操作序列 operations = [ + # 重命名字段:将analyticscode改为analytics_code migrations.RenameField( - model_name='blogsettings', - old_name='analyticscode', - new_name='analytics_code', + model_name='blogsettings', # 指定要修改的模型名称 + old_name='analyticscode', # 原字段名称 + new_name='analytics_code', # 新字段名称 ), + # 重命名字段:将beiancode改为beian_code migrations.RenameField( - model_name='blogsettings', - old_name='beiancode', - new_name='beian_code', + model_name='blogsettings', # 指定要修改的模型名称 + old_name='beiancode', # 原字段名称 + new_name='beian_code', # 新字段名称 ), + # 重命名字段:将sitename改为site_name migrations.RenameField( - model_name='blogsettings', - old_name='sitename', - new_name='site_name', + model_name='blogsettings', # 指定要修改的模型名称 + old_name='sitename', # 原字段名称 + new_name='site_name', # 新字段名称 ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py index d08e853..5410588 100644 --- a/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py +++ b/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py @@ -9,292 +9,355 @@ import mdeditor.fields class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), + # 依赖于blog应用的第四个迁移 ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改Article模型的元选项(国际化) migrations.AlterModelOptions( name='article', options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'}, ), + # 修改Category模型的元选项(国际化) migrations.AlterModelOptions( name='category', options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'}, ), + # 修改Links模型的元选项(国际化) migrations.AlterModelOptions( name='links', options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'}, ), + # 修改Sidebar模型的元选项(国际化) migrations.AlterModelOptions( name='sidebar', options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'}, ), + # 修改Tag模型的元选项(国际化) migrations.AlterModelOptions( name='tag', options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'}, ), + # 删除Article模型的created_time字段 migrations.RemoveField( model_name='article', name='created_time', ), + # 删除Article模型的last_mod_time字段 migrations.RemoveField( model_name='article', name='last_mod_time', ), + # 删除Category模型的created_time字段 migrations.RemoveField( model_name='category', name='created_time', ), + # 删除Category模型的last_mod_time字段 migrations.RemoveField( model_name='category', name='last_mod_time', ), + # 删除Links模型的created_time字段 migrations.RemoveField( model_name='links', name='created_time', ), + # 删除Sidebar模型的created_time字段 migrations.RemoveField( model_name='sidebar', name='created_time', ), + # 删除Tag模型的created_time字段 migrations.RemoveField( model_name='tag', name='created_time', ), + # 删除Tag模型的last_mod_time字段 migrations.RemoveField( model_name='tag', name='last_mod_time', ), + # 向Article模型添加creation_time字段 migrations.AddField( model_name='article', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Article模型添加last_modify_time字段 migrations.AddField( model_name='article', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), ), + # 向Category模型添加creation_time字段 migrations.AddField( model_name='category', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Category模型添加last_modify_time字段 migrations.AddField( model_name='category', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), ), + # 向Links模型添加creation_time字段 migrations.AddField( model_name='links', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Sidebar模型添加creation_time字段 migrations.AddField( model_name='sidebar', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Tag模型添加creation_time字段 migrations.AddField( model_name='tag', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Tag模型添加last_modify_time字段 migrations.AddField( model_name='tag', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), ), + # 修改Article模型的article_order字段的显示名称 migrations.AlterField( model_name='article', name='article_order', field=models.IntegerField(default=0, verbose_name='order'), ), + # 修改Article模型的author字段的显示名称 migrations.AlterField( model_name='article', name='author', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), ), + # 修改Article模型的body字段的显示名称 migrations.AlterField( model_name='article', name='body', field=mdeditor.fields.MDTextField(verbose_name='body'), ), + # 修改Article模型的category字段的显示名称 migrations.AlterField( model_name='article', name='category', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'), ), + # 修改Article模型的comment_status字段的显示名称和选项值 migrations.AlterField( model_name='article', name='comment_status', field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'), ), + # 修改Article模型的pub_time字段的显示名称 migrations.AlterField( model_name='article', name='pub_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'), ), + # 修改Article模型的show_toc字段的显示名称 migrations.AlterField( model_name='article', name='show_toc', field=models.BooleanField(default=False, verbose_name='show toc'), ), + # 修改Article模型的status字段的显示名称和选项值 migrations.AlterField( model_name='article', name='status', field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'), ), + # 修改Article模型的tags字段的显示名称 migrations.AlterField( model_name='article', name='tags', field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'), ), + # 修改Article模型的title字段的显示名称 migrations.AlterField( model_name='article', name='title', field=models.CharField(max_length=200, unique=True, verbose_name='title'), ), + # 修改Article模型的type字段的显示名称和选项值 migrations.AlterField( model_name='article', name='type', field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'), ), + # 修改Article模型的views字段的显示名称 migrations.AlterField( model_name='article', name='views', field=models.PositiveIntegerField(default=0, verbose_name='views'), ), + # 修改BlogSettings模型的article_comment_count字段的显示名称 migrations.AlterField( model_name='blogsettings', name='article_comment_count', field=models.IntegerField(default=5, verbose_name='article comment count'), ), + # 修改BlogSettings模型的article_sub_length字段的显示名称 migrations.AlterField( model_name='blogsettings', name='article_sub_length', field=models.IntegerField(default=300, verbose_name='article sub length'), ), + # 修改BlogSettings模型的google_adsense_codes字段的显示名称 migrations.AlterField( model_name='blogsettings', name='google_adsense_codes', field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'), ), + # 修改BlogSettings模型的open_site_comment字段的显示名称 migrations.AlterField( model_name='blogsettings', name='open_site_comment', field=models.BooleanField(default=True, verbose_name='open site comment'), ), + # 修改BlogSettings模型的show_google_adsense字段的显示名称 migrations.AlterField( model_name='blogsettings', name='show_google_adsense', field=models.BooleanField(default=False, verbose_name='show adsense'), ), + # 修改BlogSettings模型的sidebar_article_count字段的显示名称 migrations.AlterField( model_name='blogsettings', name='sidebar_article_count', field=models.IntegerField(default=10, verbose_name='sidebar article count'), ), + # 修改BlogSettings模型的sidebar_comment_count字段的显示名称 migrations.AlterField( model_name='blogsettings', name='sidebar_comment_count', field=models.IntegerField(default=5, verbose_name='sidebar comment count'), ), + # 修改BlogSettings模型的site_description字段的显示名称 migrations.AlterField( model_name='blogsettings', name='site_description', field=models.TextField(default='', max_length=1000, verbose_name='site description'), ), + # 修改BlogSettings模型的site_keywords字段的显示名称 migrations.AlterField( model_name='blogsettings', name='site_keywords', field=models.TextField(default='', max_length=1000, verbose_name='site keywords'), ), + # 修改BlogSettings模型的site_name字段的显示名称 migrations.AlterField( model_name='blogsettings', name='site_name', field=models.CharField(default='', max_length=200, verbose_name='site name'), ), + # 修改BlogSettings模型的site_seo_description字段的显示名称 migrations.AlterField( model_name='blogsettings', name='site_seo_description', field=models.TextField(default='', max_length=1000, verbose_name='site seo description'), ), + # 修改Category模型的index字段的显示名称 migrations.AlterField( model_name='category', name='index', field=models.IntegerField(default=0, verbose_name='index'), ), + # 修改Category模型的name字段的显示名称 migrations.AlterField( model_name='category', name='name', field=models.CharField(max_length=30, unique=True, verbose_name='category name'), ), + # 修改Category模型的parent_category字段的显示名称 migrations.AlterField( model_name='category', name='parent_category', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'), ), + # 修改Links模型的is_enable字段的显示名称 migrations.AlterField( model_name='links', name='is_enable', field=models.BooleanField(default=True, verbose_name='is show'), ), + # 修改Links模型的last_mod_time字段的显示名称 migrations.AlterField( model_name='links', name='last_mod_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), ), + # 修改Links模型的link字段的显示名称 migrations.AlterField( model_name='links', name='link', field=models.URLField(verbose_name='link'), ), + # 修改Links模型的name字段的显示名称 migrations.AlterField( model_name='links', name='name', field=models.CharField(max_length=30, unique=True, verbose_name='link name'), ), + # 修改Links模型的sequence字段的显示名称 migrations.AlterField( model_name='links', name='sequence', field=models.IntegerField(unique=True, verbose_name='order'), ), + # 修改Links模型的show_type字段的显示名称和选项值 migrations.AlterField( model_name='links', name='show_type', field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'), ), + # 修改Sidebar模型的content字段的显示名称 migrations.AlterField( model_name='sidebar', name='content', field=models.TextField(verbose_name='content'), ), + # 修改Sidebar模型的is_enable字段的显示名称 migrations.AlterField( model_name='sidebar', name='is_enable', field=models.BooleanField(default=True, verbose_name='is enable'), ), + # 修改Sidebar模型的last_mod_time字段的显示名称 migrations.AlterField( model_name='sidebar', name='last_mod_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), ), + # 修改Sidebar模型的name字段的显示名称 migrations.AlterField( model_name='sidebar', name='name', field=models.CharField(max_length=100, verbose_name='title'), ), + # 修改Sidebar模型的sequence字段的显示名称 migrations.AlterField( model_name='sidebar', name='sequence', field=models.IntegerField(unique=True, verbose_name='order'), ), + # 修改Tag模型的name字段的显示名称 migrations.AlterField( model_name='tag', name='name', field=models.CharField(max_length=30, unique=True, verbose_name='tag name'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py b/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py index e36feb4..353100b 100644 --- a/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py +++ b/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py @@ -5,13 +5,20 @@ from django.db import migrations class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖于blog应用的第五个迁移 ('blog', '0005_alter_article_options_alter_category_options_and_more'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改BlogSettings模型的元选项(国际化) migrations.AlterModelOptions( - name='blogsettings', - options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, + name='blogsettings', # 指定要修改的模型名称 + options={ + 'verbose_name': 'Website configuration', # 单数形式的显示名称(英文) + 'verbose_name_plural': 'Website configuration' # 复数形式的显示名称(英文) + }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/blog/models.py b/src/DjangoBlog/blog/models.py index 083788b..f06e61a 100644 --- a/src/DjangoBlog/blog/models.py +++ b/src/DjangoBlog/blog/models.py @@ -1,42 +1,55 @@ +# 导入日志模块 import logging +# 导入正则表达式模块 import re +# 导入抽象方法装饰器 from abc import abstractmethod +# 导入Django配置和模型相关模块 from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ +# 导入Markdown编辑器字段 from mdeditor.fields import MDTextField +# 导入slug生成工具 from uuslug import slugify +# 导入自定义工具函数 from djangoblog.utils import cache_decorator, cache from djangoblog.utils import get_current_site +# 获取日志记录器 logger = logging.getLogger(__name__) +# 链接显示类型选择类 class LinkShowType(models.TextChoices): - I = ('i', _('index')) - L = ('l', _('list')) - P = ('p', _('post')) - A = ('a', _('all')) - S = ('s', _('slide')) + I = ('i', _('index')) # 首页显示 + L = ('l', _('list')) # 列表页显示 + P = ('p', _('post')) # 文章页面显示 + A = ('a', _('all')) # 全站显示 + S = ('s', _('slide')) # 友情链接页面显示 +# 基础模型抽象类 class BaseModel(models.Model): - id = models.AutoField(primary_key=True) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_modify_time = models.DateTimeField(_('modify time'), default=now) + id = models.AutoField(primary_key=True) # 自增主键 + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_modify_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 def save(self, *args, **kwargs): + # 检查是否为文章视图更新操作 is_update_views = isinstance( self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] if is_update_views: + # 如果是视图更新,直接更新数据库 Article.objects.filter(pk=self.pk).update(views=self.views) else: + # 否则正常保存,并处理slug字段 if 'slug' in self.__dict__: slug = getattr( self, 'title') if 'title' in self.__dict__ else getattr( @@ -45,65 +58,68 @@ class BaseModel(models.Model): super().save(*args, **kwargs) def get_full_url(self): + # 获取完整URL site = get_current_site().domain url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) return url class Meta: - abstract = True + abstract = True # 标记为抽象基类 @abstractmethod def get_absolute_url(self): + # 抽象方法,子类必须实现 pass +# 文章模型类 class Article(BaseModel): """文章""" STATUS_CHOICES = ( - ('d', _('Draft')), - ('p', _('Published')), + ('d', _('Draft')), # 草稿状态 + ('p', _('Published')), # 已发布状态 ) COMMENT_STATUS = ( - ('o', _('Open')), - ('c', _('Close')), + ('o', _('Open')), # 评论开启 + ('c', _('Close')), # 评论关闭 ) TYPE = ( - ('a', _('Article')), - ('p', _('Page')), + ('a', _('Article')), # 文章类型 + ('p', _('Page')), # 页面类型 ) - title = models.CharField(_('title'), max_length=200, unique=True) - body = MDTextField(_('body')) + title = models.CharField(_('title'), max_length=200, unique=True) # 文章标题 + body = MDTextField(_('body')) # 文章正文,使用Markdown编辑器 pub_time = models.DateTimeField( - _('publish time'), blank=False, null=False, default=now) + _('publish time'), blank=False, null=False, default=now) # 发布时间 status = models.CharField( _('status'), max_length=1, choices=STATUS_CHOICES, - default='p') + default='p') # 文章状态 comment_status = models.CharField( _('comment status'), max_length=1, choices=COMMENT_STATUS, - default='o') - type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') - views = models.PositiveIntegerField(_('views'), default=0) + default='o') # 评论状态 + type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # 内容类型 + views = models.PositiveIntegerField(_('views'), default=0) # 浏览量 author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), blank=False, null=False, - on_delete=models.CASCADE) + on_delete=models.CASCADE) # 作者外键 article_order = models.IntegerField( - _('order'), blank=False, null=False, default=0) - show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) + _('order'), blank=False, null=False, default=0) # 文章排序 + show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # 是否显示目录 category = models.ForeignKey( 'Category', verbose_name=_('category'), on_delete=models.CASCADE, blank=False, - null=False) - tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) + null=False) # 分类外键 + tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # 标签多对多关系 def body_to_string(self): return self.body @@ -112,12 +128,13 @@ class Article(BaseModel): return self.title class Meta: - ordering = ['-article_order', '-pub_time'] - verbose_name = _('article') - verbose_name_plural = verbose_name - get_latest_by = 'id' + ordering = ['-article_order', '-pub_time'] # 默认排序 + verbose_name = _('article') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + get_latest_by = 'id' # 获取最新记录的依据字段 def get_absolute_url(self): + # 获取文章绝对URL return reverse('blog:detailbyid', kwargs={ 'article_id': self.id, 'year': self.creation_time.year, @@ -125,8 +142,9 @@ class Article(BaseModel): 'day': self.creation_time.day }) - @cache_decorator(60 * 60 * 10) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_category_tree(self): + # 获取分类树 tree = self.category.get_category_tree() names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) @@ -136,10 +154,12 @@ class Article(BaseModel): super().save(*args, **kwargs) def viewed(self): + # 增加浏览量 self.views += 1 self.save(update_fields=['views']) def comment_list(self): + # 获取评论列表,带缓存 cache_key = 'article_comments_{id}'.format(id=self.id) value = cache.get(cache_key) if value: @@ -147,23 +167,24 @@ class Article(BaseModel): return value else: comments = self.comment_set.filter(is_enable=True).order_by('-id') - cache.set(cache_key, comments, 60 * 100) + cache.set(cache_key, comments, 60 * 100) # 缓存100分钟 logger.info('set article comments:{id}'.format(id=self.id)) return comments def get_admin_url(self): + # 获取管理后台URL info = (self._meta.app_label, self._meta.model_name) return reverse('admin:%s_%s_change' % info, args=(self.pk,)) - @cache_decorator(expiration=60 * 100) + @cache_decorator(expiration=60 * 100) # 缓存100分钟 def next_article(self): - # 下一篇 + # 获取下一篇文章 return Article.objects.filter( id__gt=self.id, status='p').order_by('id').first() - @cache_decorator(expiration=60 * 100) + @cache_decorator(expiration=60 * 100) # 缓存100分钟 def prev_article(self): - # 前一篇 + # 获取上一篇文章 return Article.objects.filter(id__lt=self.id, status='p').first() def get_first_image_url(self): @@ -171,30 +192,33 @@ class Article(BaseModel): Get the first image url from article.body. :return: """ + # 从文章正文中提取第一张图片URL match = re.search(r'!\[.*?\]\((.+?)\)', self.body) if match: return match.group(1) return "" +# 分类模型类 class Category(BaseModel): """文章分类""" - name = models.CharField(_('category name'), max_length=30, unique=True) + name = models.CharField(_('category name'), max_length=30, unique=True) # 分类名称 parent_category = models.ForeignKey( 'self', verbose_name=_('parent category'), blank=True, null=True, - on_delete=models.CASCADE) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) - index = models.IntegerField(default=0, verbose_name=_('index')) + on_delete=models.CASCADE) # 父级分类,支持多级分类 + slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名 + index = models.IntegerField(default=0, verbose_name=_('index')) # 排序索引 class Meta: - ordering = ['-index'] - verbose_name = _('category') - verbose_name_plural = verbose_name + ordering = ['-index'] # 按索引倒序排列 + verbose_name = _('category') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 def get_absolute_url(self): + # 获取分类绝对URL return reverse( 'blog:category_detail', kwargs={ 'category_name': self.slug}) @@ -202,7 +226,7 @@ class Category(BaseModel): def __str__(self): return self.name - @cache_decorator(60 * 60 * 10) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_category_tree(self): """ 递归获得分类目录的父级 @@ -218,7 +242,7 @@ class Category(BaseModel): parse(self) return categorys - @cache_decorator(60 * 60 * 10) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_sub_categorys(self): """ 获得当前分类目录所有子集 @@ -240,70 +264,76 @@ class Category(BaseModel): return categorys +# 标签模型类 class Tag(BaseModel): """文章标签""" - name = models.CharField(_('tag name'), max_length=30, unique=True) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) + name = models.CharField(_('tag name'), max_length=30, unique=True) # 标签名称 + slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名 def __str__(self): return self.name def get_absolute_url(self): + # 获取标签绝对URL return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) - @cache_decorator(60 * 60 * 10) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_article_count(self): + # 获取标签下的文章数量 return Article.objects.filter(tags__name=self.name).distinct().count() class Meta: - ordering = ['name'] - verbose_name = _('tag') - verbose_name_plural = verbose_name + ordering = ['name'] # 按名称排序 + verbose_name = _('tag') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 +# 友情链接模型类 class Links(models.Model): """友情链接""" - name = models.CharField(_('link name'), max_length=30, unique=True) - link = models.URLField(_('link')) - sequence = models.IntegerField(_('order'), unique=True) + name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称 + link = models.URLField(_('link')) # 链接地址 + sequence = models.IntegerField(_('order'), unique=True) # 排序序号 is_enable = models.BooleanField( - _('is show'), default=True, blank=False, null=False) + _('is show'), default=True, blank=False, null=False) # 是否启用 show_type = models.CharField( _('show type'), max_length=1, choices=LinkShowType.choices, - default=LinkShowType.I) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_mod_time = models.DateTimeField(_('modify time'), default=now) + default=LinkShowType.I) # 显示类型 + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 class Meta: - ordering = ['sequence'] - verbose_name = _('link') - verbose_name_plural = verbose_name + ordering = ['sequence'] # 按序号排序 + verbose_name = _('link') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 def __str__(self): return self.name +# 侧边栏模型类 class SideBar(models.Model): """侧边栏,可以展示一些html内容""" - name = models.CharField(_('title'), max_length=100) - content = models.TextField(_('content')) - sequence = models.IntegerField(_('order'), unique=True) - is_enable = models.BooleanField(_('is enable'), default=True) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_mod_time = models.DateTimeField(_('modify time'), default=now) + name = models.CharField(_('title'), max_length=100) # 侧边栏标题 + content = models.TextField(_('content')) # 侧边栏内容 + sequence = models.IntegerField(_('order'), unique=True) # 排序序号 + is_enable = models.BooleanField(_('is enable'), default=True) # 是否启用 + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 class Meta: - ordering = ['sequence'] - verbose_name = _('sidebar') - verbose_name_plural = verbose_name + ordering = ['sequence'] # 按序号排序 + verbose_name = _('sidebar') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 def __str__(self): return self.name +# 博客设置模型类 class BlogSettings(models.Model): """blog的配置""" site_name = models.CharField( @@ -311,66 +341,67 @@ class BlogSettings(models.Model): max_length=200, null=False, blank=False, - default='') + default='') # 网站名称 site_description = models.TextField( _('site description'), max_length=1000, null=False, blank=False, - default='') + default='') # 网站描述 site_seo_description = models.TextField( - _('site seo description'), max_length=1000, null=False, blank=False, default='') + _('site seo description'), max_length=1000, null=False, blank=False, default='') # 网站SEO描述 site_keywords = models.TextField( _('site keywords'), max_length=1000, null=False, blank=False, - default='') - article_sub_length = models.IntegerField(_('article sub length'), default=300) - sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) - sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) - article_comment_count = models.IntegerField(_('article comment count'), default=5) - show_google_adsense = models.BooleanField(_('show adsense'), default=False) + default='') # 网站关键词 + article_sub_length = models.IntegerField(_('article sub length'), default=300) # 文章摘要长度 + sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) # 侧边栏文章数量 + sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) # 侧边栏评论数量 + article_comment_count = models.IntegerField(_('article comment count'), default=5) # 文章页面评论数量 + show_google_adsense = models.BooleanField(_('show adsense'), default=False) # 是否显示Google广告 google_adsense_codes = models.TextField( - _('adsense code'), max_length=2000, null=True, blank=True, default='') - open_site_comment = models.BooleanField(_('open site comment'), default=True) - global_header = models.TextField("公共头部", null=True, blank=True, default='') - global_footer = models.TextField("公共尾部", null=True, blank=True, default='') + _('adsense code'), max_length=2000, null=True, blank=True, default='') # Google广告代码 + open_site_comment = models.BooleanField(_('open site comment'), default=True) # 是否开启网站评论 + global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部内容 + global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部内容 beian_code = models.CharField( '备案号', max_length=2000, null=True, blank=True, - default='') + default='') # 备案号 analytics_code = models.TextField( "网站统计代码", max_length=1000, null=False, blank=False, - default='') + default='') # 网站统计代码 show_gongan_code = models.BooleanField( - '是否显示公安备案号', default=False, null=False) + '是否显示公安备案号', default=False, null=False) # 是否显示公安备案 gongan_beiancode = models.TextField( '公安备案号', max_length=2000, null=True, blank=True, - default='') + default='') # 公安备案号 comment_need_review = models.BooleanField( - '评论是否需要审核', default=False, null=False) + '评论是否需要审核', default=False, null=False) # 评论是否需要审核 class Meta: - verbose_name = _('Website configuration') - verbose_name_plural = verbose_name + verbose_name = _('Website configuration') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 def __str__(self): return self.site_name def clean(self): + # 验证只能有一个配置实例 if BlogSettings.objects.exclude(id=self.id).count(): raise ValidationError(_('There can only be one configuration')) def save(self, *args, **kwargs): super().save(*args, **kwargs) from djangoblog.utils import cache - cache.clear() + cache.clear() # 保存配置后清除缓存 \ No newline at end of file diff --git a/src/DjangoBlog/blog/search_indexes.py b/src/DjangoBlog/blog/search_indexes.py index 7f1dfac..ce375b5 100644 --- a/src/DjangoBlog/blog/search_indexes.py +++ b/src/DjangoBlog/blog/search_indexes.py @@ -1,13 +1,28 @@ +# 导入Haystack搜索索引相关模块 from haystack import indexes +# 导入文章模型 from blog.models import Article +# 定义文章搜索索引类,继承自SearchIndex和Indexable class ArticleIndex(indexes.SearchIndex, indexes.Indexable): + # 定义主搜索字段,document=True表示这是主要的搜索字段 + # use_template=True表示使用模板文件来构建搜索内容 text = indexes.CharField(document=True, use_template=True) def get_model(self): + """ + 返回与此索引关联的Django模型类 + :return: Article模型类 + """ return Article def index_queryset(self, using=None): - return self.get_model().objects.filter(status='p') + """ + 返回要建立索引的查询集 + 这里只对已发布(status='p')的文章建立索引 + :param using: 使用的搜索引擎别名 + :return: 已发布文章的查询集 + """ + return self.get_model().objects.filter(status='p') \ No newline at end of file diff --git a/src/DjangoBlog/blog/templatetags/blog_tags.py b/src/DjangoBlog/blog/templatetags/blog_tags.py index 1f994bc..0b4fb06 100644 --- a/src/DjangoBlog/blog/templatetags/blog_tags.py +++ b/src/DjangoBlog/blog/templatetags/blog_tags.py @@ -1,53 +1,75 @@ +# 导入哈希库 import hashlib +# 导入日志模块 import logging +# 导入随机数模块 import random +# 导入URL处理模块 import urllib +# 导入Django模板相关模块 from django import template from django.conf import settings +# 导入数据库查询模块 from django.db.models import Q from django.shortcuts import get_object_or_404 +# 导入模板过滤器 from django.template.defaultfilters import stringfilter +# 导入静态文件处理 from django.templatetags.static import static from django.urls import reverse +# 导入安全字符串处理 from django.utils.safestring import mark_safe +# 导入博客模型 from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType +# 导入评论模型 from comments.models import Comment +# 导入自定义工具函数 from djangoblog.utils import CommonMarkdown, sanitize_html from djangoblog.utils import cache from djangoblog.utils import get_current_site +# 导入OAuth用户模型 from oauth.models import OAuthUser +# 导入插件管理模块 from djangoblog.plugin_manage import hooks +# 获取日志记录器 logger = logging.getLogger(__name__) +# 创建模板库注册器 register = template.Library() +# 注册头部meta标签的简单标签,接收上下文 @register.simple_tag(takes_context=True) def head_meta(context): return mark_safe(hooks.apply_filters('head_meta', '', context)) +# 注册时间格式化简单标签 @register.simple_tag def timeformat(data): try: + # 使用设置中的时间格式 return data.strftime(settings.TIME_FORMAT) except Exception as e: logger.error(e) return "" +# 注册日期时间格式化简单标签 @register.simple_tag def datetimeformat(data): try: + # 使用设置中的日期时间格式 return data.strftime(settings.DATE_TIME_FORMAT) except Exception as e: logger.error(e) return "" +# 注册自定义Markdown过滤器,自动处理字符串 @register.filter() @stringfilter def custom_markdown(content): @@ -55,16 +77,18 @@ def custom_markdown(content): 通用markdown过滤器,应用文章内容插件 主要用于文章内容处理 """ + # 将Markdown内容转换为HTML html_content = CommonMarkdown.get_markdown(content) - + # 然后应用插件过滤器优化HTML from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) - + return mark_safe(optimized_html) +# 注册侧边栏Markdown过滤器 @register.filter() @stringfilter def sidebar_markdown(content): @@ -72,11 +96,12 @@ def sidebar_markdown(content): return mark_safe(html_content) +# 注册文章内容渲染标签,接收上下文 @register.simple_tag(takes_context=True) def render_article_content(context, article, is_summary=False): """ 渲染文章内容,包含完整的上下文信息供插件使用 - + Args: context: 模板上下文 article: 文章对象 @@ -84,44 +109,45 @@ def render_article_content(context, article, is_summary=False): """ if not article or not hasattr(article, 'body'): return '' - + # 先转换Markdown为HTML html_content = CommonMarkdown.get_markdown(article.body) - + # 如果是摘要模式,先截断内容再应用插件 if is_summary: # 截断HTML内容到合适的长度(约300字符) from django.utils.html import strip_tags from django.template.defaultfilters import truncatechars - + # 先去除HTML标签,截断纯文本,然后重新转换为HTML plain_text = strip_tags(html_content) truncated_text = truncatechars(plain_text, 300) - + # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理) html_content = CommonMarkdown.get_markdown(truncated_text) - + # 然后应用插件过滤器,传递完整的上下文 from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME - + # 获取request对象 request = context.get('request') - + # 应用所有文章内容相关的插件 # 注意:摘要模式下某些插件(如版权声明)可能不适用 optimized_html = hooks.apply_filters( - ARTICLE_CONTENT_HOOK_NAME, - html_content, - article=article, + ARTICLE_CONTENT_HOOK_NAME, + html_content, + article=article, request=request, context=context, is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 ) - + return mark_safe(optimized_html) +# 注册获取Markdown目录的简单标签 @register.simple_tag def get_markdown_toc(content): from djangoblog.utils import CommonMarkdown @@ -129,6 +155,7 @@ def get_markdown_toc(content): return mark_safe(toc) +# 注册评论Markdown过滤器 @register.filter() @stringfilter def comment_markdown(content): @@ -136,6 +163,7 @@ def comment_markdown(content): return mark_safe(sanitize_html(content)) +# 注册内容截断过滤器,标记为安全HTML @register.filter(is_safe=True) @stringfilter def truncatechars_content(content): @@ -150,6 +178,7 @@ def truncatechars_content(content): return truncatechars_html(content, blogsetting.article_sub_length) +# 注册简单截断过滤器,标记为安全HTML @register.filter(is_safe=True) @stringfilter def truncate(content): @@ -158,6 +187,7 @@ def truncate(content): return strip_tags(content)[:150] +# 注册面包屑导航包含标签 @register.inclusion_tag('blog/tags/breadcrumb.html') def load_breadcrumb(article): """ @@ -179,6 +209,7 @@ def load_breadcrumb(article): } +# 注册文章标签列表包含标签 @register.inclusion_tag('blog/tags/article_tag_list.html') def load_articletags(article): """ @@ -199,6 +230,7 @@ def load_articletags(article): } +# 注册侧边栏包含标签 @register.inclusion_tag('blog/tags/sidebar.html') def load_sidebar(user, linktype): """ @@ -213,16 +245,23 @@ def load_sidebar(user, linktype): logger.info('load sidebar') from djangoblog.utils import get_blog_setting blogsetting = get_blog_setting() + # 获取最近文章 recent_articles = Article.objects.filter( status='p')[:blogsetting.sidebar_article_count] + # 获取所有分类 sidebar_categorys = Category.objects.all() + # 获取额外的侧边栏内容 extra_sidebars = SideBar.objects.filter( is_enable=True).order_by('sequence') + # 获取最多阅读文章 most_read_articles = Article.objects.filter(status='p').order_by( '-views')[:blogsetting.sidebar_article_count] + # 获取文章归档日期 dates = Article.objects.datetimes('creation_time', 'month', order='DESC') + # 获取友情链接 links = Links.objects.filter(is_enable=True).filter( Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A)) + # 获取最新评论 commment_list = Comment.objects.filter(is_enable=True).order_by( '-id')[:blogsetting.sidebar_comment_count] # 标签云 计算字体大小 @@ -253,12 +292,14 @@ def load_sidebar(user, linktype): 'sidebar_tags': sidebar_tags, 'extra_sidebars': extra_sidebars } + # 设置缓存,3小时过期 cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3) logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype)) value['user'] = user return value +# 注册文章meta信息包含标签 @register.inclusion_tag('blog/tags/article_meta_info.html') def load_article_metas(article, user): """ @@ -272,10 +313,12 @@ def load_article_metas(article, user): } +# 注册分页信息包含标签 @register.inclusion_tag('blog/tags/article_pagination.html') def load_pagination_info(page_obj, page_type, tag_name): previous_url = '' next_url = '' + # 处理首页分页 if page_type == '': if page_obj.has_next(): next_number = page_obj.next_page_number() @@ -285,6 +328,7 @@ def load_pagination_info(page_obj, page_type, tag_name): previous_url = reverse( 'blog:index_page', kwargs={ 'page': previous_number}) + # 处理标签分页 if page_type == '分类标签归档': tag = get_object_or_404(Tag, name=tag_name) if page_obj.has_next(): @@ -301,6 +345,7 @@ def load_pagination_info(page_obj, page_type, tag_name): kwargs={ 'page': previous_number, 'tag_name': tag.slug}) + # 处理作者文章分页 if page_type == '作者文章归档': if page_obj.has_next(): next_number = page_obj.next_page_number() @@ -316,7 +361,7 @@ def load_pagination_info(page_obj, page_type, tag_name): kwargs={ 'page': previous_number, 'author_name': tag_name}) - + # 处理分类目录分页 if page_type == '分类目录归档': category = get_object_or_404(Category, name=tag_name) if page_obj.has_next(): @@ -341,6 +386,7 @@ def load_pagination_info(page_obj, page_type, tag_name): } +# 注册文章详情包含标签 @register.inclusion_tag('blog/tags/article_info.html') def load_article_detail(article, isindex, user): """ @@ -360,7 +406,7 @@ def load_article_detail(article, isindex, user): } -# 返回用户头像URL +# 返回用户头像URL的过滤器 # 模板使用方法: {{ email|gravatar_url:150 }} @register.filter def gravatar_url(email, size=40): @@ -369,7 +415,7 @@ def gravatar_url(email, size=40): url = cache.get(cachekey) if url: return url - + # 检查OAuth用户是否有自定义头像 usermodels = OAuthUser.objects.filter(email=email) if usermodels: @@ -378,18 +424,19 @@ def gravatar_url(email, size=40): if users_with_picture: # 获取默认头像路径用于比较 default_avatar_path = static('blog/img/avatar.png') - + # 优先选择非默认头像的用户,否则选择第一个 - non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] + non_default_users = [u for u in users_with_picture if + u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] selected_user = non_default_users[0] if non_default_users else users_with_picture[0] - + url = selected_user.picture cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 - + avatar_type = 'non-default' if non_default_users else 'default' logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) return url - + # 使用默认头像 url = static('blog/img/avatar.png') cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 @@ -397,6 +444,7 @@ def gravatar_url(email, size=40): return url +# 返回用户头像HTML标签的过滤器 @register.filter def gravatar(email, size=40): """获得用户头像HTML标签""" @@ -406,6 +454,7 @@ def gravatar(email, size=40): (url, size, size)) +# 注册查询集过滤简单标签 @register.simple_tag def query(qs, **kwargs): """ template tag which allows queryset filtering. Usage: @@ -417,7 +466,8 @@ def query(qs, **kwargs): return qs.filter(**kwargs) +# 注册字符串连接过滤器 @register.filter def addstr(arg1, arg2): """concatenate arg1 & arg2""" - return str(arg1) + str(arg2) + return str(arg1) + str(arg2) \ No newline at end of file diff --git a/src/DjangoBlog/blog/tests.py b/src/DjangoBlog/blog/tests.py index ee13505..0095b24 100644 --- a/src/DjangoBlog/blog/tests.py +++ b/src/DjangoBlog/blog/tests.py @@ -1,5 +1,7 @@ +# 导入操作系统接口模块 import os +# 导入Django配置和文件处理相关模块 from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command @@ -9,34 +11,46 @@ from django.test import Client, RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +# 导入账户模型 from accounts.models import BlogUser +# 导入博客表单和模型 from blog.forms import BlogSearchForm from blog.models import Article, Category, Tag, SideBar, Links +# 导入博客模板标签 from blog.templatetags.blog_tags import load_pagination_info, load_articletags +# 导入自定义工具函数 from djangoblog.utils import get_current_site, get_sha256 +# 导入OAuth相关模型 from oauth.models import OAuthUser, OAuthConfig # Create your tests here. +# 文章测试类 class ArticleTest(TestCase): def setUp(self): + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() def test_validate_article(self): + # 获取当前站点域名 site = get_current_site().domain + # 获取或创建测试用户 user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] user.set_password("liangliangyy") - user.is_staff = True - user.is_superuser = True + user.is_staff = True # 设置为管理员 + user.is_superuser = True # 设置为超级用户 user.save() + # 测试用户详情页访问 response = self.client.get(user.get_absolute_url()) self.assertEqual(response.status_code, 200) + # 测试管理员页面访问 response = self.client.get('/admin/servermanager/emailsendlog/') response = self.client.get('admin/admin/logentry/') + # 创建侧边栏 s = SideBar() s.sequence = 1 s.name = 'test' @@ -44,131 +58,175 @@ class ArticleTest(TestCase): s.is_enable = True s.save() + # 创建分类 category = Category() category.name = "category" category.creation_time = timezone.now() category.last_mod_time = timezone.now() category.save() + # 创建标签 tag = Tag() tag.name = "nicetag" tag.save() + # 创建文章 article = Article() article.title = "nicetitle" article.body = "nicecontent" article.author = user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 验证初始标签数量为0 self.assertEqual(0, article.tags.count()) + # 添加标签 article.tags.add(tag) article.save() + # 验证标签数量为1 self.assertEqual(1, article.tags.count()) + # 批量创建20篇文章 for i in range(20): article = Article() article.title = "nicetitle" + str(i) article.body = "nicetitle" + str(i) article.author = user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() article.tags.add(tag) article.save() + # 检查是否启用Elasticsearch from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: + # 构建搜索索引 call_command("build_index") + # 测试搜索功能 response = self.client.get('/search', {'q': 'nicetitle'}) self.assertEqual(response.status_code, 200) + # 测试文章详情页访问 response = self.client.get(article.get_absolute_url()) self.assertEqual(response.status_code, 200) + # 测试蜘蛛通知功能 from djangoblog.spider_notify import SpiderNotify SpiderNotify.notify(article.get_absolute_url()) + # 测试标签页访问 response = self.client.get(tag.get_absolute_url()) self.assertEqual(response.status_code, 200) + # 测试分类页访问 response = self.client.get(category.get_absolute_url()) self.assertEqual(response.status_code, 200) + # 测试搜索页面 response = self.client.get('/search', {'q': 'django'}) self.assertEqual(response.status_code, 200) + # 测试文章标签模板标签 s = load_articletags(article) self.assertIsNotNone(s) + # 用户登录 self.client.login(username='liangliangyy', password='liangliangyy') + # 测试归档页面 response = self.client.get(reverse('blog:archives')) self.assertEqual(response.status_code, 200) + # 测试首页分页 p = Paginator(Article.objects.all(), settings.PAGINATE_BY) self.check_pagination(p, '', '') + # 测试标签分页 p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) self.check_pagination(p, '分类标签归档', tag.slug) + # 测试作者文章分页 p = Paginator( Article.objects.filter( author__username='liangliangyy'), settings.PAGINATE_BY) self.check_pagination(p, '作者文章归档', 'liangliangyy') + # 测试分类目录分页 p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) self.check_pagination(p, '分类目录归档', category.slug) + # 测试搜索表单 f = BlogSearchForm() f.search() - # self.client.login(username='liangliangyy', password='liangliangyy') + # 测试百度蜘蛛通知 from djangoblog.spider_notify import SpiderNotify SpiderNotify.baidu_notify([article.get_full_url()]) + # 测试头像相关模板标签 from blog.templatetags.blog_tags import gravatar_url, gravatar u = gravatar_url('liangliangyy@gmail.com') u = gravatar('liangliangyy@gmail.com') + # 创建友情链接 link = Links( sequence=1, name="lylinux", link='https://wwww.lylinux.net') link.save() + # 测试友情链接页面 response = self.client.get('/links.html') self.assertEqual(response.status_code, 200) + # 测试RSS订阅 response = self.client.get('/feed/') self.assertEqual(response.status_code, 200) + # 测试站点地图 response = self.client.get('/sitemap.xml') self.assertEqual(response.status_code, 200) + # 测试管理员页面访问 self.client.get("/admin/blog/article/1/delete/") self.client.get('/admin/servermanager/emailsendlog/') self.client.get('/admin/admin/logentry/') self.client.get('/admin/admin/logentry/1/change/') def check_pagination(self, p, type, value): + """ + 检查分页功能 + :param p: Paginator分页器对象 + :param type: 分页类型 + :param value: 分页值(标签名、分类名等) + """ for page in range(1, p.num_pages + 1): + # 加载分页信息 s = load_pagination_info(p.page(page), type, value) self.assertIsNotNone(s) + # 测试上一页链接 if s['previous_url']: response = self.client.get(s['previous_url']) self.assertEqual(response.status_code, 200) + # 测试下一页链接 if s['next_url']: response = self.client.get(s['next_url']) self.assertEqual(response.status_code, 200) def test_image(self): + # 测试图片上传功能 import requests + # 下载测试图片 rsp = requests.get( 'https://www.python.org/static/img/python-logo.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png') + # 保存图片到本地 with open(imagepath, 'wb') as file: file.write(rsp.content) + # 测试未授权上传 rsp = self.client.post('/upload') self.assertEqual(rsp.status_code, 403) + # 生成签名 sign = get_sha256(get_sha256(settings.SECRET_KEY)) + # 测试授权上传 with open(imagepath, 'rb') as file: imgfile = SimpleUploadedFile( 'python.png', file.read(), content_type='image/jpg') @@ -176,17 +234,21 @@ class ArticleTest(TestCase): rsp = self.client.post( '/upload?sign=' + sign, form_data, follow=True) self.assertEqual(rsp.status_code, 200) + # 清理测试文件 os.remove(imagepath) + # 测试工具函数 from djangoblog.utils import save_user_avatar, send_email send_email(['qq@qq.com'], 'testTitle', 'testContent') save_user_avatar( 'https://www.python.org/static/img/python-logo.png') def test_errorpage(self): + # 测试404错误页面 rsp = self.client.get('/eee') self.assertEqual(rsp.status_code, 404) def test_commands(self): + # 测试管理命令 user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] @@ -195,12 +257,14 @@ class ArticleTest(TestCase): user.is_superuser = True user.save() + # 创建OAuth配置 c = OAuthConfig() c.type = 'qq' c.appkey = 'appkey' c.appsecret = 'appsecret' c.save() + # 创建OAuth用户(使用默认头像) u = OAuthUser() u.type = 'qq' u.openid = 'openid' @@ -212,6 +276,7 @@ class ArticleTest(TestCase): }''' u.save() + # 创建另一个OAuth用户(使用QQ头像) u = OAuthUser() u.type = 'qq' u.openid = 'openid1' @@ -222,11 +287,12 @@ class ArticleTest(TestCase): }''' u.save() + # 执行各种管理命令 from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: - call_command("build_index") - call_command("ping_baidu", "all") - call_command("create_testdata") - call_command("clear_cache") - call_command("sync_user_avatar") - call_command("build_search_words") + call_command("build_index") # 构建搜索索引 + call_command("ping_baidu", "all") # 通知百度 + call_command("create_testdata") # 创建测试数据 + call_command("clear_cache") # 清除缓存 + call_command("sync_user_avatar") # 同步用户头像 + call_command("build_search_words") # 构建搜索关键词 \ No newline at end of file diff --git a/src/DjangoBlog/blog/urls.py b/src/DjangoBlog/blog/urls.py index adf2703..fa00948 100644 --- a/src/DjangoBlog/blog/urls.py +++ b/src/DjangoBlog/blog/urls.py @@ -1,62 +1,93 @@ +# 导入Django URL路由相关模块 from django.urls import path +# 导入缓存页面装饰器 from django.views.decorators.cache import cache_page +# 导入当前应用的视图模块 from . import views +# 定义应用的命名空间 app_name = "blog" + +# 定义URL模式列表 urlpatterns = [ + # 首页URL,使用类视图 path( r'', views.IndexView.as_view(), name='index'), + + # 首页分页URL,支持页码参数 path( r'page//', views.IndexView.as_view(), name='index_page'), + + # 文章详情页URL,包含年月日和文章ID参数 path( r'article////.html', views.ArticleDetailView.as_view(), name='detailbyid'), + + # 分类详情页URL,使用slug格式的分类名称 path( r'category/.html', views.CategoryDetailView.as_view(), name='category_detail'), + + # 分类详情分页URL,支持分类名称和页码参数 path( r'category//.html', views.CategoryDetailView.as_view(), name='category_detail_page'), + + # 作者详情页URL,使用作者名称参数 path( r'author/.html', views.AuthorDetailView.as_view(), name='author_detail'), + + # 作者详情分页URL,支持作者名称和页码参数 path( r'author//.html', views.AuthorDetailView.as_view(), name='author_detail_page'), + + # 标签详情页URL,使用slug格式的标签名称 path( r'tag/.html', views.TagDetailView.as_view(), name='tag_detail'), + + # 标签详情分页URL,支持标签名称和页码参数 path( r'tag//.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'), -] +] \ No newline at end of file diff --git a/src/DjangoBlog/blog/views.py b/src/DjangoBlog/blog/views.py index ace9e63..687eb81 100644 --- a/src/DjangoBlog/blog/views.py +++ b/src/DjangoBlog/blog/views.py @@ -1,7 +1,11 @@ +# 导入日志模块 import logging +# 导入操作系统接口模块 import os +# 导入UUID生成模块 import uuid +# 导入Django配置和核心模块 from django.conf import settings from django.core.paginator import Paginator from django.http import HttpResponse, HttpResponseForbidden @@ -13,17 +17,24 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.generic.detail import DetailView from django.views.generic.list import ListView +# 导入Haystack搜索视图 from haystack.views import SearchView +# 导入博客模型 from blog.models import Article, Category, LinkShowType, Links, Tag +# 导入评论表单 from comments.forms import CommentForm +# 导入插件管理模块 from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME +# 导入自定义工具函数 from djangoblog.utils import cache, get_blog_setting, get_sha256 +# 获取日志记录器 logger = logging.getLogger(__name__) +# 文章列表视图基类 class ArticleListView(ListView): # template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' @@ -33,15 +44,16 @@ class ArticleListView(ListView): # 页面类型,分类目录或标签列表等 page_type = '' - paginate_by = settings.PAGINATE_BY - page_kwarg = 'page' - link_type = LinkShowType.L + paginate_by = settings.PAGINATE_BY # 分页大小 + page_kwarg = 'page' # 页码参数名 + link_type = LinkShowType.L # 链接显示类型 def get_view_cache_key(self): return self.request.get['pages'] @property def page_number(self): + # 获取当前页码 page_kwarg = self.page_kwarg page = self.kwargs.get( page_kwarg) or self.request.GET.get(page_kwarg) or 1 @@ -85,10 +97,12 @@ class ArticleListView(ListView): return value def get_context_data(self, **kwargs): + # 添加上下文数据 kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) +# 首页视图 class IndexView(ArticleListView): ''' 首页 @@ -97,31 +111,40 @@ class IndexView(ArticleListView): link_type = LinkShowType.I def get_queryset_data(self): + # 获取已发布的文章列表 article_list = Article.objects.filter(type='a', status='p') return article_list def get_queryset_cache_key(self): + # 生成首页缓存键 cache_key = 'index_{page}'.format(page=self.page_number) return cache_key +# 文章详情视图 class ArticleDetailView(DetailView): ''' 文章详情页面 ''' template_name = 'blog/article_detail.html' model = Article - pk_url_kwarg = 'article_id' - context_object_name = "article" + pk_url_kwarg = 'article_id' # URL中的文章ID参数名 + context_object_name = "article" # 上下文中的文章对象名 def get_context_data(self, **kwargs): + # 创建评论表单 comment_form = CommentForm() + # 获取文章评论列表 article_comments = self.object.comment_list() + # 获取父级评论 parent_comments = article_comments.filter(parent_comment=None) + # 获取博客设置 blog_setting = get_blog_setting() + # 对父级评论进行分页 paginator = Paginator(parent_comments, blog_setting.article_comment_count) page = self.request.GET.get('comment_page', '1') + # 验证页码 if not page.isnumeric(): page = 1 else: @@ -131,16 +154,21 @@ class ArticleDetailView(DetailView): if page > paginator.num_pages: page = paginator.num_pages + # 获取当前页评论 p_comments = paginator.page(page) + # 计算下一页和上一页 next_page = p_comments.next_page_number() if p_comments.has_next() else None prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + # 构建评论分页URL if next_page: kwargs[ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' if prev_page: kwargs[ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' + + # 添加上下文数据 kwargs['form'] = comment_form kwargs['article_comments'] = article_comments kwargs['p_comments'] = p_comments @@ -157,6 +185,7 @@ class ArticleDetailView(DetailView): return context +# 分类详情视图 class CategoryDetailView(ArticleListView): ''' 分类目录列表 @@ -164,13 +193,16 @@ class CategoryDetailView(ArticleListView): page_type = "分类目录归档" def get_queryset_data(self): + # 获取分类slug slug = self.kwargs['category_name'] category = get_object_or_404(Category, slug=slug) categoryname = category.name self.categoryname = categoryname + # 获取所有子分类名称 categorynames = list( map(lambda c: c.name, category.get_sub_categorys())) + # 获取该分类下的所有文章 article_list = Article.objects.filter( category__name__in=categorynames, status='p') return article_list @@ -185,7 +217,6 @@ class CategoryDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - categoryname = self.categoryname try: categoryname = categoryname.split('/')[-1] @@ -196,6 +227,7 @@ class CategoryDetailView(ArticleListView): return super(CategoryDetailView, self).get_context_data(**kwargs) +# 作者详情视图 class AuthorDetailView(ArticleListView): ''' 作者详情页 @@ -222,6 +254,7 @@ class AuthorDetailView(ArticleListView): return super(AuthorDetailView, self).get_context_data(**kwargs) +# 标签详情视图 class TagDetailView(ArticleListView): ''' 标签列表页面 @@ -247,20 +280,20 @@ class TagDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - # tag_name = self.kwargs['tag_name'] tag_name = self.name kwargs['page_type'] = TagDetailView.page_type kwargs['tag_name'] = tag_name return super(TagDetailView, self).get_context_data(**kwargs) +# 归档视图 class ArchivesView(ArticleListView): ''' 文章归档页面 ''' page_type = '文章归档' - paginate_by = None - page_kwarg = None + paginate_by = None # 不分页 + page_kwarg = None # 无页码参数 template_name = 'blog/article_archives.html' def get_queryset_data(self): @@ -271,6 +304,7 @@ class ArchivesView(ArticleListView): return cache_key +# 友情链接视图 class LinkListView(ListView): model = Links template_name = 'blog/links_list.html' @@ -279,16 +313,19 @@ class LinkListView(ListView): return Links.objects.filter(is_enable=True) +# Elasticsearch搜索视图 class EsSearchView(SearchView): def get_context(self): + # 构建分页 paginator, page = self.build_page() context = { - "query": self.query, - "form": self.form, - "page": page, - "paginator": paginator, - "suggestion": None, + "query": self.query, # 搜索查询 + "form": self.form, # 搜索表单 + "page": page, # 当前页 + "paginator": paginator, # 分页器 + "suggestion": None, # 搜索建议 } + # 添加拼写建议 if hasattr(self.results, "query") and self.results.query.backend.include_spelling: context["suggestion"] = self.results.query.get_spelling_suggestion() context.update(self.extra_context()) @@ -296,6 +333,7 @@ class EsSearchView(SearchView): return context +# 文件上传视图(免除CSRF保护) @csrf_exempt def fileupload(request): """ @@ -304,30 +342,40 @@ def fileupload(request): :return: """ if request.method == 'POST': + # 验证签名 sign = request.GET.get('sign', None) if not sign: return HttpResponseForbidden() if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): return HttpResponseForbidden() response = [] + # 处理所有上传的文件 for filename in request.FILES: + # 生成时间路径 timestr = timezone.now().strftime('%Y/%m/%d') - imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] + imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] # 图片扩展名 fname = u''.join(str(filename)) + # 判断是否为图片 isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 + # 构建保存路径 base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) if not os.path.exists(base_dir): os.makedirs(base_dir) + # 生成唯一文件名 savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) + # 安全检查 if not savepath.startswith(base_dir): return HttpResponse("only for post") + # 保存文件 with open(savepath, 'wb+') as wfile: for chunk in request.FILES[filename].chunks(): wfile.write(chunk) + # 如果是图片,进行压缩优化 if isimage: from PIL import Image image = Image.open(savepath) image.save(savepath, quality=20, optimize=True) + # 生成静态文件URL url = static(savepath) response.append(url) return HttpResponse(response) @@ -336,6 +384,7 @@ def fileupload(request): return HttpResponse("only for post") +# 404错误页面视图 def page_not_found_view( request, exception, @@ -350,6 +399,7 @@ def page_not_found_view( status=404) +# 500错误页面视图 def server_error_view(request, template_name='blog/error_page.html'): return render(request, template_name, @@ -358,6 +408,7 @@ def server_error_view(request, template_name='blog/error_page.html'): status=500) +# 403权限拒绝视图 def permission_denied_view( request, exception, @@ -370,6 +421,7 @@ def permission_denied_view( 'statuscode': '403'}, status=403) +# 清理缓存视图 def clean_cache_view(request): cache.clear() - return HttpResponse('ok') + return HttpResponse('ok') \ No newline at end of file diff --git a/src/DjangoBlog/comments/admin.py b/src/DjangoBlog/comments/admin.py index dbde14f..fdce757 100644 --- a/src/DjangoBlog/comments/admin.py +++ b/src/DjangoBlog/comments/admin.py @@ -1,49 +1,75 @@ +# 导入Django管理员模块 from django.contrib import admin +# 导入URL反向解析 from django.urls import reverse +# 导入HTML格式化工具 from django.utils.html import format_html +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 禁用评论状态的管理动作函数 def disable_commentstatus(modeladmin, request, queryset): queryset.update(is_enable=False) +# 启用评论状态的管理动作函数 def enable_commentstatus(modeladmin, request, queryset): queryset.update(is_enable=True) +# 设置管理动作的显示名称 disable_commentstatus.short_description = _('Disable comments') enable_commentstatus.short_description = _('Enable comments') +# 评论模型的管理类 class CommentAdmin(admin.ModelAdmin): + # 每页显示20条记录 list_per_page = 20 + # 列表页显示的字段 list_display = ( - 'id', - 'body', - 'link_to_userinfo', - 'link_to_article', - 'is_enable', - 'creation_time') + 'id', # 评论ID + 'body', # 评论内容 + 'link_to_userinfo', # 用户信息链接(自定义字段) + 'link_to_article', # 文章链接(自定义字段) + 'is_enable', # 是否启用 + 'creation_time' # 创建时间 + ) + # 可作为链接点击的字段 list_display_links = ('id', 'body', 'is_enable') - list_filter = ('is_enable',) + # 右侧过滤器字段 + list_filter = ('is_enable',) # 按启用状态过滤 + # 排除的字段(不在表单中显示) exclude = ('creation_time', 'last_modify_time') + # 可用的管理动作 actions = [disable_commentstatus, enable_commentstatus] + # 使用原始ID字段(显示搜索框而不是下拉选择) raw_id_fields = ('author', 'article') - search_fields = ('body',) + # 搜索字段 + search_fields = ('body',) # 按评论内容搜索 + # 自定义方法:显示用户信息链接 def link_to_userinfo(self, obj): + # 获取用户模型的app和model信息 info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 生成用户编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回HTML链接,显示用户昵称(如果没有昵称则显示邮箱) return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + # 自定义方法:显示文章链接 def link_to_article(self, obj): + # 获取文章模型的app和model信息 info = (obj.article._meta.app_label, obj.article._meta.model_name) + # 生成文章编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + # 返回HTML链接,显示文章标题 return format_html( u'%s' % (link, obj.article.title)) + # 设置自定义方法的显示名称 link_to_userinfo.short_description = _('User') - link_to_article.short_description = _('Article') + link_to_article.short_description = _('Article') \ No newline at end of file diff --git a/src/DjangoBlog/comments/apps.py b/src/DjangoBlog/comments/apps.py index ff01b77..90f86aa 100644 --- a/src/DjangoBlog/comments/apps.py +++ b/src/DjangoBlog/comments/apps.py @@ -1,5 +1,8 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义comments应用的配置类 class CommentsConfig(AppConfig): - name = 'comments' + # 指定应用的Python路径(Django内部使用的标识) + name = 'comments' \ No newline at end of file diff --git a/src/DjangoBlog/comments/forms.py b/src/DjangoBlog/comments/forms.py index e83737d..1c22bad 100644 --- a/src/DjangoBlog/comments/forms.py +++ b/src/DjangoBlog/comments/forms.py @@ -1,13 +1,21 @@ +# 导入Django表单模块 from django import forms from django.forms import ModelForm +# 导入评论模型 from .models import Comment +# 评论表单类,继承自ModelForm class CommentForm(ModelForm): + # 父评论ID字段,使用隐藏输入控件,非必填 parent_comment_id = forms.IntegerField( - widget=forms.HiddenInput, required=False) + widget=forms.HiddenInput, # 使用隐藏输入控件(在HTML中不可见) + required=False) # 非必填字段 + # 定义表单的元数据 class Meta: + # 指定表单对应的模型 model = Comment - fields = ['body'] + # 指定表单包含的字段(只包含评论正文字段) + fields = ['body'] \ No newline at end of file diff --git a/src/DjangoBlog/comments/migrations/0001_initial.py b/src/DjangoBlog/comments/migrations/0001_initial.py index 61d1e53..df77c76 100644 --- a/src/DjangoBlog/comments/migrations/0001_initial.py +++ b/src/DjangoBlog/comments/migrations/0001_initial.py @@ -1,38 +1,58 @@ # Generated by Django 4.1.7 on 2023-03-02 07:14 +# 导入Django设置 from django.conf import settings +# 导入数据库迁移相关模块 from django.db import migrations, models import django.db.models.deletion +# 导入时间工具 import django.utils.timezone class Migration(migrations.Migration): + # 标记为初始迁移 initial = True + # 声明依赖的迁移 dependencies = [ - ('blog', '0001_initial'), + ('blog', '0001_initial'), # 依赖于blog应用的初始迁移 + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义迁移操作序列 operations = [ + # 创建评论模型 migrations.CreateModel( name='Comment', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 评论正文字段,最大长度300字符 ('body', models.TextField(max_length=300, verbose_name='正文')), + # 创建时间字段,默认值为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段,默认值为当前时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 是否启用字段,控制评论是否显示 ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 文章外键,关联到博客文章,级联删除 ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), + # 作者外键,关联到用户模型,级联删除 ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), + # 父级评论外键,支持评论回复功能,允许为空 ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), ], options={ + # 模型显示名称(单数) 'verbose_name': '评论', + # 模型显示名称(复数) 'verbose_name_plural': '评论', + # 默认按ID倒序排列(最新的评论在前) 'ordering': ['-id'], + # 指定获取最新记录的依据字段 'get_latest_by': 'id', }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py b/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py index 17c44db..feb820c 100644 --- a/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py +++ b/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py @@ -5,14 +5,17 @@ from django.db import migrations, models class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ - ('comments', '0001_initial'), + ('comments', '0001_initial'), # 依赖于comments应用的初始迁移 ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改Comment模型的is_enable字段的默认值 migrations.AlterField( - model_name='comment', - name='is_enable', - field=models.BooleanField(default=False, verbose_name='是否显示'), + model_name='comment', # 指定要修改的模型名称 + name='is_enable', # 要修改的字段名称 + field=models.BooleanField(default=False, verbose_name='是否显示'), # 将默认值从True改为False ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py index a1ca970..984d521 100644 --- a/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py +++ b/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py @@ -8,53 +8,67 @@ import django.utils.timezone class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), + # 依赖于blog应用的第五个迁移 ('blog', '0005_alter_article_options_alter_category_options_and_more'), + # 依赖于comments应用的第二个迁移 ('comments', '0002_alter_comment_is_enable'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改Comment模型的元选项(国际化) migrations.AlterModelOptions( name='comment', options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, ), + # 删除Comment模型的created_time字段 migrations.RemoveField( model_name='comment', name='created_time', ), + # 删除Comment模型的last_mod_time字段 migrations.RemoveField( model_name='comment', name='last_mod_time', ), + # 向Comment模型添加creation_time字段 migrations.AddField( model_name='comment', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向Comment模型添加last_modify_time字段 migrations.AddField( model_name='comment', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 修改Comment模型的article字段的显示名称 migrations.AlterField( model_name='comment', name='article', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), ), + # 修改Comment模型的author字段的显示名称 migrations.AlterField( model_name='comment', name='author', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), ), + # 修改Comment模型的is_enable字段的显示名称 migrations.AlterField( model_name='comment', name='is_enable', field=models.BooleanField(default=False, verbose_name='enable'), ), + # 修改Comment模型的parent_comment字段的显示名称 migrations.AlterField( model_name='comment', name='parent_comment', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/comments/models.py b/src/DjangoBlog/comments/models.py index 7c3bbc8..548a33e 100644 --- a/src/DjangoBlog/comments/models.py +++ b/src/DjangoBlog/comments/models.py @@ -1,39 +1,54 @@ +# 导入Django配置 from django.conf import settings +# 导入Django数据库模型 from django.db import models +# 导入时间工具 from django.utils.timezone import now +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入博客文章模型 from blog.models import Article # Create your models here. +# 评论模型类 class Comment(models.Model): + # 评论正文字段,最大长度300字符 body = models.TextField('正文', max_length=300) + # 创建时间字段,默认值为当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间字段,默认值为当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) + # 作者外键,关联到用户模型,级联删除 author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), on_delete=models.CASCADE) + # 文章外键,关联到博客文章,级联删除 article = models.ForeignKey( Article, verbose_name=_('article'), on_delete=models.CASCADE) + # 父级评论外键,支持评论回复功能,允许为空 parent_comment = models.ForeignKey( - 'self', + 'self', # 自关联,指向同一个模型 verbose_name=_('parent comment'), - blank=True, - null=True, + blank=True, # 在表单中允许为空 + null=True, # 在数据库中允许为NULL on_delete=models.CASCADE) + # 是否启用字段,控制评论是否显示,默认值为False(需要审核) is_enable = models.BooleanField(_('enable'), default=False, blank=False, null=False) + # 定义模型的元数据 class Meta: - ordering = ['-id'] - verbose_name = _('comment') - verbose_name_plural = verbose_name - get_latest_by = 'id' + ordering = ['-id'] # 默认按ID倒序排列(最新的评论在前) + verbose_name = _('comment') # 单数形式的显示名称 + verbose_name_plural = verbose_name # 复数形式的显示名称(与单数相同) + get_latest_by = 'id' # 指定获取最新记录的依据字段 + # 定义对象的字符串表示形式 def __str__(self): - return self.body + return self.body # 返回评论正文作为字符串表示 \ No newline at end of file diff --git a/src/DjangoBlog/comments/templatetags/comments_tags.py b/src/DjangoBlog/comments/templatetags/comments_tags.py index fde02b4..7ee7a7d 100644 --- a/src/DjangoBlog/comments/templatetags/comments_tags.py +++ b/src/DjangoBlog/comments/templatetags/comments_tags.py @@ -1,30 +1,58 @@ +# 导入Django模板模块 from django import template +# 创建模板库注册器 register = template.Library() +# 注册解析评论树的简单标签 @register.simple_tag def parse_commenttree(commentlist, comment): """获得当前评论子评论的列表 用法: {% parse_commenttree article_comments comment as childcomments %} + + Args: + commentlist: 所有评论的查询集 + comment: 当前评论对象 + + Returns: + 当前评论的所有子评论列表(包括子评论的子评论) """ + # 初始化数据列表,用于存储所有子评论 datas = [] + # 定义递归函数来解析评论树 def parse(c): + # 获取当前评论的直接子评论(已启用状态) childs = commentlist.filter(parent_comment=c, is_enable=True) + # 遍历每个子评论 for child in childs: + # 将子评论添加到数据列表 datas.append(child) + # 递归解析子评论的子评论 parse(child) + # 从当前评论开始解析评论树 parse(comment) + # 返回所有子评论的列表 return datas +# 注册显示评论项的包含标签 @register.inclusion_tag('comments/tags/comment_item.html') def show_comment_item(comment, ischild): - """评论""" + """评论项显示 + + Args: + comment: 评论对象 + ischild: 是否为子评论(布尔值) + + Returns: + 包含评论项和深度的上下文字典 + """ + # 设置评论深度:子评论为1,父评论为2 depth = 1 if ischild else 2 return { - 'comment_item': comment, - 'depth': depth - } + 'comment_item': comment, # 评论对象 + 'depth': depth # 评论深度(用于CSS样式或缩进显示) + } \ No newline at end of file diff --git a/src/DjangoBlog/comments/tests.py b/src/DjangoBlog/comments/tests.py index 2a7f55f..02a14a5 100644 --- a/src/DjangoBlog/comments/tests.py +++ b/src/DjangoBlog/comments/tests.py @@ -1,80 +1,111 @@ +# 导入Django测试相关模块 from django.test import Client, RequestFactory, TransactionTestCase from django.urls import reverse +# 导入账户模型 from accounts.models import BlogUser +# 导入博客模型 from blog.models import Category, Article +# 导入评论模型 from comments.models import Comment +# 导入评论模板标签(导入所有) from comments.templatetags.comments_tags import * +# 导入自定义工具函数 from djangoblog.utils import get_max_articleid_commentid # Create your tests here. +# 评论测试类,使用TransactionTestCase支持数据库事务 class CommentsTest(TransactionTestCase): def setUp(self): + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() + # 导入博客设置模型并创建配置 from blog.models import BlogSettings value = BlogSettings() - value.comment_need_review = True + value.comment_need_review = True # 设置评论需要审核 value.save() + # 创建超级用户用于测试 self.user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") def update_article_comment_status(self, article): + """ + 更新文章所有评论的启用状态为已启用 + :param article: 文章对象 + """ comments = article.comment_set.all() for comment in comments: comment.is_enable = True comment.save() def test_validate_comment(self): + # 用户登录 self.client.login(username='liangliangyy1', password='liangliangyy1') + # 创建分类 category = Category() category.name = "categoryccc" category.save() + # 创建文章 article = Article() article.title = "nicetitleccc" article.body = "nicecontentccc" article.author = self.user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 获取评论提交URL comment_url = reverse( 'comments:postcomment', kwargs={ 'article_id': article.id}) + # 测试提交第一条评论 response = self.client.post(comment_url, { - 'body': '123ffffffffff' + 'body': '123ffffffffff' # 评论内容 }) + # 验证重定向响应(评论提交后重定向) self.assertEqual(response.status_code, 302) + # 重新获取文章对象 article = Article.objects.get(pk=article.pk) + # 验证评论列表为空(因为评论需要审核,默认未启用) self.assertEqual(len(article.comment_list()), 0) + # 更新评论状态为已启用 self.update_article_comment_status(article) + # 验证评论列表现在有1条评论 self.assertEqual(len(article.comment_list()), 1) + # 测试提交第二条评论 response = self.client.post(comment_url, { 'body': '123ffffffffff', }) + # 验证重定向响应 self.assertEqual(response.status_code, 302) + # 重新获取文章对象并更新评论状态 article = Article.objects.get(pk=article.pk) self.update_article_comment_status(article) + # 验证现在有2条评论 self.assertEqual(len(article.comment_list()), 2) + + # 获取第一条评论的ID作为父评论 parent_comment_id = article.comment_list()[0].id + # 测试提交带Markdown格式的回复评论 response = self.client.post(comment_url, { 'body': ''' @@ -89,21 +120,34 @@ class CommentsTest(TransactionTestCase): [ddd](http://www.baidu.com) - ''', - 'parent_comment_id': parent_comment_id + ''', # 包含Markdown格式的评论内容 + 'parent_comment_id': parent_comment_id # 父评论ID }) + # 验证重定向响应 self.assertEqual(response.status_code, 302) + # 更新评论状态 self.update_article_comment_status(article) + # 重新获取文章对象 article = Article.objects.get(pk=article.pk) + # 验证现在有3条评论 self.assertEqual(len(article.comment_list()), 3) + + # 获取父评论对象 comment = Comment.objects.get(id=parent_comment_id) + # 测试解析评论树功能 tree = parse_commenttree(article.comment_list(), comment) + # 验证评论树有1个子评论 self.assertEqual(len(tree), 1) - data = show_comment_item(comment, True) + + # 测试显示评论项模板标签 + data = show_comment_item(comment, True) # 作为子评论显示 self.assertIsNotNone(data) + + # 测试获取最大文章ID和评论ID s = get_max_articleid_commentid() self.assertIsNotNone(s) + # 测试发送评论邮件功能 from comments.utils import send_comment_email - send_comment_email(comment) + send_comment_email(comment) \ No newline at end of file diff --git a/src/DjangoBlog/comments/urls.py b/src/DjangoBlog/comments/urls.py index 7df3fab..a3c929a 100644 --- a/src/DjangoBlog/comments/urls.py +++ b/src/DjangoBlog/comments/urls.py @@ -1,11 +1,17 @@ +# 导入Django URL路由相关模块 from django.urls import path +# 导入当前应用的视图模块 from . import views +# 定义应用的命名空间 app_name = "comments" + +# 定义URL模式列表 urlpatterns = [ + # 文章评论提交URL path( - 'article//postcomment', - views.CommentPostView.as_view(), - name='postcomment'), -] + 'article//postcomment', # URL路径,包含文章ID参数 + views.CommentPostView.as_view(), # 使用类视图处理请求 + name='postcomment'), # URL名称,用于反向解析 +] \ No newline at end of file diff --git a/src/DjangoBlog/comments/utils.py b/src/DjangoBlog/comments/utils.py index f01dba7..3facd83 100644 --- a/src/DjangoBlog/comments/utils.py +++ b/src/DjangoBlog/comments/utils.py @@ -1,17 +1,31 @@ +# 导入日志模块 import logging +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 导入自定义工具函数 from djangoblog.utils import get_current_site from djangoblog.utils import send_email +# 获取日志记录器 logger = logging.getLogger(__name__) +# 发送评论邮件函数 def send_comment_email(comment): + """ + 发送评论相关邮件通知 + :param comment: 评论对象 + """ + # 获取当前站点域名 site = get_current_site().domain + # 邮件主题 subject = _('Thanks for your comment') + # 构建文章完整URL article_url = f"https://{site}{comment.article.get_absolute_url()}" + + # 构建给评论作者的邮件内容(感谢评论) html_content = _("""

Thank you very much for your comments on this site

You can visit %(article_title)s to review your comments, @@ -19,10 +33,16 @@ def send_comment_email(comment):
If the link above cannot be opened, please copy this link to your browser. %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title} + + # 获取评论作者的邮箱 tomail = comment.author.email + # 发送感谢邮件给评论作者 send_email([tomail], subject, html_content) + try: + # 检查是否为回复评论(有父评论) if comment.parent_comment: + # 构建给父评论作者的邮件内容(通知有新回复) html_content = _("""Your comment on %(article_title)s
has received a reply.
%(comment_body)s
@@ -32,7 +52,10 @@ def send_comment_email(comment): %(article_url)s """) % {'article_url': article_url, 'article_title': comment.article.title, 'comment_body': comment.parent_comment.body} + # 获取父评论作者的邮箱 tomail = comment.parent_comment.author.email + # 发送回复通知邮件给父评论作者 send_email([tomail], subject, html_content) except Exception as e: - logger.error(e) + # 记录发送回复通知邮件时的错误 + logger.error(e) \ No newline at end of file diff --git a/src/DjangoBlog/comments/views.py b/src/DjangoBlog/comments/views.py index ad9b2b9..4e2a543 100644 --- a/src/DjangoBlog/comments/views.py +++ b/src/DjangoBlog/comments/views.py @@ -1,63 +1,105 @@ # Create your views here. +# 导入Django核心异常类 from django.core.exceptions import ValidationError +# 导入HTTP重定向响应 from django.http import HttpResponseRedirect +# 导入快捷函数 from django.shortcuts import get_object_or_404 +# 导入方法装饰器 from django.utils.decorators import method_decorator +# 导入CSRF保护装饰器 from django.views.decorators.csrf import csrf_protect +# 导入基于类的表单视图 from django.views.generic.edit import FormView +# 导入账户模型 from accounts.models import BlogUser +# 导入博客文章模型 from blog.models import Article +# 导入评论表单 from .forms import CommentForm +# 导入评论模型 from .models import Comment +# 评论提交视图类 class CommentPostView(FormView): + # 指定使用的表单类 form_class = CommentForm + # 指定模板文件 template_name = 'blog/article_detail.html' + # 使用CSRF保护装饰器 @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): return super(CommentPostView, self).dispatch(*args, **kwargs) def get(self, request, *args, **kwargs): + """处理GET请求,重定向到文章详情页的评论区域""" + # 从URL参数获取文章ID article_id = self.kwargs['article_id'] + # 获取文章对象,如果不存在返回404 article = get_object_or_404(Article, pk=article_id) + # 获取文章绝对URL url = article.get_absolute_url() + # 重定向到文章详情页的评论锚点区域 return HttpResponseRedirect(url + "#comments") def form_invalid(self, form): + """表单验证失败时的处理逻辑""" + # 获取文章ID article_id = self.kwargs['article_id'] + # 获取文章对象 article = get_object_or_404(Article, pk=article_id) + # 重新渲染模板,显示表单错误 return self.render_to_response({ - 'form': form, - 'article': article + 'form': form, # 包含错误信息的表单 + 'article': article # 文章对象 }) def form_valid(self, form): """提交的数据验证合法后的逻辑""" + # 获取当前用户 user = self.request.user + # 获取对应的BlogUser对象 author = BlogUser.objects.get(pk=user.pk) + # 从URL参数获取文章ID article_id = self.kwargs['article_id'] + # 获取文章对象 article = get_object_or_404(Article, pk=article_id) + # 检查文章是否关闭评论或文章状态为关闭 if article.comment_status == 'c' or article.status == 'c': raise ValidationError("该文章评论已关闭.") + + # 从表单获取评论对象但不保存到数据库 comment = form.save(False) + # 设置评论关联的文章 comment.article = article + + # 获取博客设置 from djangoblog.utils import get_blog_setting settings = get_blog_setting() + # 如果设置中评论不需要审核,则直接启用评论 if not settings.comment_need_review: comment.is_enable = True + + # 设置评论作者 comment.author = author + # 检查是否为回复评论(有父评论ID) if form.cleaned_data['parent_comment_id']: + # 获取父评论对象 parent_comment = Comment.objects.get( pk=form.cleaned_data['parent_comment_id']) + # 设置评论的父评论 comment.parent_comment = parent_comment + # 保存评论到数据库 comment.save(True) + + # 重定向到文章详情页的特定评论锚点 return HttpResponseRedirect( - "%s#div-comment-%d" % - (article.get_absolute_url(), comment.pk)) + "%s#div-comment-%d" % # 使用评论ID构建锚点 + (article.get_absolute_url(), comment.pk)) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/admin_site.py b/src/DjangoBlog/djangoblog/admin_site.py index f120405..c37dc47 100644 --- a/src/DjangoBlog/djangoblog/admin_site.py +++ b/src/DjangoBlog/djangoblog/admin_site.py @@ -1,8 +1,10 @@ +# 导入Django管理员站点相关模块 from django.contrib.admin import AdminSite from django.contrib.admin.models import LogEntry from django.contrib.sites.admin import SiteAdmin from django.contrib.sites.models import Site +# 导入各应用的管理员配置 from accounts.admin import * from blog.admin import * from blog.models import * @@ -17,16 +19,25 @@ from servermanager.admin import * from servermanager.models import * +# 自定义DjangoBlog管理员站点类 class DjangoBlogAdminSite(AdminSite): + # 站点头部标题(显示在浏览器标签页) site_header = 'djangoblog administration' + # 站点标题(显示在登录页面和主页) site_title = 'djangoblog site admin' def __init__(self, name='admin'): + # 调用父类初始化方法 super().__init__(name) def has_permission(self, request): + """ + 检查用户是否有权限访问管理员站点 + 只允许超级用户访问 + """ return request.user.is_superuser + # 注释掉的URL配置示例,可用于添加自定义管理员视图 # def get_urls(self): # urls = super().get_urls() # from django.urls import path @@ -38,27 +49,36 @@ class DjangoBlogAdminSite(AdminSite): # return urls + my_urls +# 创建DjangoBlog管理员站点实例 admin_site = DjangoBlogAdminSite(name='admin') -admin_site.register(Article, ArticlelAdmin) -admin_site.register(Category, CategoryAdmin) -admin_site.register(Tag, TagAdmin) -admin_site.register(Links, LinksAdmin) -admin_site.register(SideBar, SideBarAdmin) -admin_site.register(BlogSettings, BlogSettingsAdmin) +# 注册博客相关模型到管理员站点 +admin_site.register(Article, ArticlelAdmin) # 文章模型 +admin_site.register(Category, CategoryAdmin) # 分类模型 +admin_site.register(Tag, TagAdmin) # 标签模型 +admin_site.register(Links, LinksAdmin) # 友情链接模型 +admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 -admin_site.register(commands, CommandsAdmin) -admin_site.register(EmailSendLog, EmailSendLogAdmin) +# 注册服务器管理相关模型 +admin_site.register(commands, CommandsAdmin) # 命令模型 +admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型 -admin_site.register(BlogUser, BlogUserAdmin) +# 注册用户模型 +admin_site.register(BlogUser, BlogUserAdmin) # 博客用户模型 -admin_site.register(Comment, CommentAdmin) +# 注册评论模型 +admin_site.register(Comment, CommentAdmin) # 评论模型 -admin_site.register(OAuthUser, OAuthUserAdmin) -admin_site.register(OAuthConfig, OAuthConfigAdmin) +# 注册OAuth认证相关模型 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 -admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) +# 注册位置追踪相关模型 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置追踪日志模型 -admin_site.register(Site, SiteAdmin) +# 注册Django内置站点模型 +admin_site.register(Site, SiteAdmin) # 站点模型 -admin_site.register(LogEntry, LogEntryAdmin) +# 注册Django内置日志条目模型 +admin_site.register(LogEntry, LogEntryAdmin) # 日志条目模型 \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/apps.py b/src/DjangoBlog/djangoblog/apps.py index d29e318..21ff4b4 100644 --- a/src/DjangoBlog/djangoblog/apps.py +++ b/src/DjangoBlog/djangoblog/apps.py @@ -1,11 +1,21 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义djangoblog应用的配置类 class DjangoblogAppConfig(AppConfig): + # 指定默认的自增字段类型为BigAutoField(64位自增整数) default_auto_field = 'django.db.models.BigAutoField' + # 指定应用的Python路径(Django内部使用的标识) name = 'djangoblog' def ready(self): + """ + 应用准备就绪时调用的方法 + 当Django启动完成且所有应用都加载完毕后执行 + """ + # 调用父类的ready方法 super().ready() - # Import and load plugins here + # 导入并加载插件 from .plugin_manage.loader import load_plugins - load_plugins() \ No newline at end of file + # 调用插件加载函数 + load_plugins() \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/blog_signals.py b/src/DjangoBlog/djangoblog/blog_signals.py index 393f441..f7d6e69 100644 --- a/src/DjangoBlog/djangoblog/blog_signals.py +++ b/src/DjangoBlog/djangoblog/blog_signals.py @@ -1,6 +1,9 @@ +# 导入线程模块 import _thread +# 导入日志模块 import logging +# 导入Django信号相关模块 import django.dispatch from django.conf import settings from django.contrib.admin.models import LogEntry @@ -9,61 +12,79 @@ from django.core.mail import EmailMultiAlternatives from django.db.models.signals import post_save from django.dispatch import receiver +# 导入评论模型和工具函数 from comments.models import Comment from comments.utils import send_comment_email +# 导入蜘蛛通知工具 from djangoblog.spider_notify import SpiderNotify +# 导入自定义缓存工具函数 from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache from djangoblog.utils import get_current_site +# 导入OAuth用户模型 from oauth.models import OAuthUser +# 获取日志记录器 logger = logging.getLogger(__name__) -oauth_user_login_signal = django.dispatch.Signal(['id']) +# 定义自定义信号 +oauth_user_login_signal = django.dispatch.Signal(['id']) # OAuth用户登录信号 send_email_signal = django.dispatch.Signal( - ['emailto', 'title', 'content']) + ['emailto', 'title', 'content']) # 发送邮件信号 +# 注册发送邮件信号处理器 @receiver(send_email_signal) def send_email_signal_handler(sender, **kwargs): + # 从信号参数中获取邮件相关信息 emailto = kwargs['emailto'] title = kwargs['title'] content = kwargs['content'] + # 创建邮件对象 msg = EmailMultiAlternatives( - title, - content, - from_email=settings.DEFAULT_FROM_EMAIL, - to=emailto) - msg.content_subtype = "html" + title, # 邮件标题 + content, # 邮件内容 + from_email=settings.DEFAULT_FROM_EMAIL, # 发件人 + to=emailto) # 收件人列表 + msg.content_subtype = "html" # 设置邮件内容类型为HTML + # 导入邮件发送日志模型 from servermanager.models import EmailSendLog log = EmailSendLog() log.title = title log.content = content - log.emailto = ','.join(emailto) + log.emailto = ','.join(emailto) # 将收件人列表转换为逗号分隔的字符串 try: + # 尝试发送邮件 result = msg.send() - log.send_result = result > 0 + log.send_result = result > 0 # 记录发送结果(成功为True,失败为False) except Exception as e: + # 记录发送失败的错误日志 logger.error(f"失败邮箱号: {emailto}, {e}") log.send_result = False - log.save() + log.save() # 保存邮件发送日志 +# 注册OAuth用户登录信号处理器 @receiver(oauth_user_login_signal) def oauth_user_login_signal_handler(sender, **kwargs): id = kwargs['id'] + # 获取OAuth用户对象 oauthuser = OAuthUser.objects.get(id=id) site = get_current_site().domain + # 检查用户头像是否需要处理(如果头像URL不包含当前站点域名) if oauthuser.picture and not oauthuser.picture.find(site) >= 0: from djangoblog.utils import save_user_avatar + # 保存用户头像并更新URL oauthuser.picture = save_user_avatar(oauthuser.picture) oauthuser.save() + # 删除侧边栏缓存 delete_sidebar_cache() +# 注册模型保存后信号处理器 @receiver(post_save) def model_post_save_callback( sender, @@ -73,50 +94,67 @@ def model_post_save_callback( using, update_fields, **kwargs): - clearcache = False + clearcache = False # 是否清除缓存的标志 + + # 跳过LogEntry模型的保存操作 if isinstance(instance, LogEntry): return + + # 检查实例是否有get_full_url方法 if 'get_full_url' in dir(instance): - is_update_views = update_fields == {'views'} + is_update_views = update_fields == {'views'} # 判断是否为更新浏览量的操作 + # 如果不是测试环境且不是更新浏览量的操作 if not settings.TESTING and not is_update_views: try: + # 通知搜索引擎有新内容 notify_url = instance.get_full_url() SpiderNotify.baidu_notify([notify_url]) except Exception as ex: logger.error("notify sipder", ex) + # 如果不是更新浏览量的操作,设置清除缓存标志 if not is_update_views: clearcache = True + # 处理评论保存操作 if isinstance(instance, Comment): - if instance.is_enable: + if instance.is_enable: # 如果评论已启用 path = instance.article.get_absolute_url() site = get_current_site().domain + # 处理站点域名(移除端口号) if site.find(':') > 0: site = site[0:site.find(':')] + # 使文章详情页缓存过期 expire_view_cache( path, servername=site, serverport=80, key_prefix='blogdetail') + # 删除SEO处理器缓存 if cache.get('seo_processor'): cache.delete('seo_processor') + # 删除文章评论缓存 comment_cache_key = 'article_comments_{id}'.format( id=instance.article.id) cache.delete(comment_cache_key) + # 删除侧边栏缓存 delete_sidebar_cache() + # 删除文章评论视图缓存 delete_view_cache('article_comments', [str(instance.article.pk)]) + # 在新线程中发送评论通知邮件 _thread.start_new_thread(send_comment_email, (instance,)) + # 如果需要清除缓存,清除所有缓存 if clearcache: cache.clear() -@receiver(user_logged_in) -@receiver(user_logged_out) +# 注册用户登录和登出信号处理器 +@receiver(user_logged_in) # 用户登录信号 +@receiver(user_logged_out) # 用户登出信号 def user_auth_callback(sender, request, user, **kwargs): if user and user.username: - logger.info(user) - delete_sidebar_cache() - # cache.clear() + logger.info(user) # 记录用户信息 + delete_sidebar_cache() # 删除侧边栏缓存 + # cache.clear() # 注释掉的清除所有缓存操作 \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/elasticsearch_backend.py b/src/DjangoBlog/djangoblog/elasticsearch_backend.py index 4afe498..25843fc 100644 --- a/src/DjangoBlog/djangoblog/elasticsearch_backend.py +++ b/src/DjangoBlog/djangoblog/elasticsearch_backend.py @@ -1,93 +1,123 @@ +# 导入Django字符串编码工具 from django.utils.encoding import force_str +# 导入Elasticsearch DSL查询类 from elasticsearch_dsl import Q +# 导入Haystack搜索引擎基类 from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +# 导入Haystack表单类 from haystack.forms import ModelSearchForm +# 导入Haystack搜索结果类 from haystack.models import SearchResult +# 导入Haystack日志工具 from haystack.utils import log as logging +# 导入博客文档和文档管理器 from blog.documents import ArticleDocument, ArticleDocumentManager +# 导入博客文章模型 from blog.models import Article +# 获取日志记录器 logger = logging.getLogger(__name__) +# Elasticsearch搜索后端类 class ElasticSearchBackend(BaseSearchBackend): def __init__(self, connection_alias, **connection_options): + # 调用父类初始化方法 super( ElasticSearchBackend, self).__init__( connection_alias, **connection_options) + # 初始化文章文档管理器 self.manager = ArticleDocumentManager() + # 是否包含拼写建议 self.include_spelling = True def _get_models(self, iterable): + # 获取模型列表,如果iterable为空则获取所有文章 models = iterable if iterable and iterable[0] else Article.objects.all() + # 将模型转换为文档对象 docs = self.manager.convert_to_doc(models) return docs def _create(self, models): + # 创建索引并重建数据 self.manager.create_index() docs = self._get_models(models) self.manager.rebuild(docs) def _delete(self, models): + # 删除文档 for m in models: m.delete() return True def _rebuild(self, models): + # 重建索引数据 models = models if models else Article.objects.all() docs = self.manager.convert_to_doc(models) self.manager.update_docs(docs) def update(self, index, iterable, commit=True): - + # 更新索引文档 models = self._get_models(iterable) self.manager.update_docs(models) def remove(self, obj_or_string): + # 移除指定对象 models = self._get_models([obj_or_string]) self._delete(models) def clear(self, models=None, commit=True): + # 清空索引 self.remove(None) @staticmethod def get_suggestion(query: str) -> str: """获取推荐词, 如果没有找到添加原搜索词""" + # 执行搜索建议查询 search = ArticleDocument.search() \ .query("match", body=query) \ .suggest('suggest_search', query, term={'field': 'body'}) \ .execute() keywords = [] + # 处理搜索建议结果 for suggest in search.suggest.suggest_search: if suggest["options"]: + # 如果有建议词,使用第一个建议词 keywords.append(suggest["options"][0]["text"]) else: + # 如果没有建议词,使用原词 keywords.append(suggest["text"]) return ' '.join(keywords) @log_query def search(self, query_string, **kwargs): + # 记录搜索查询字符串 logger.info('search query_string:' + query_string) + # 获取分页偏移量 start_offset = kwargs.get('start_offset') end_offset = kwargs.get('end_offset') # 推荐词搜索 if getattr(self, "is_suggest", None): + # 如果启用建议搜索,获取搜索建议 suggestion = self.get_suggestion(query_string) else: + # 否则使用原查询字符串 suggestion = query_string + # 构建搜索查询条件 q = Q('bool', should=[Q('match', body=suggestion), Q('match', title=suggestion)], minimum_should_match="70%") + # 执行搜索查询 search = ArticleDocument.search() \ .query('bool', filter=[q]) \ .filter('term', status='p') \ @@ -122,8 +152,10 @@ class ElasticSearchBackend(BaseSearchBackend): } +# Elasticsearch搜索查询类 class ElasticSearchQuery(BaseSearchQuery): def _convert_datetime(self, date): + # 转换日期时间格式 if hasattr(date, 'hour'): return force_str(date.strftime('%Y%m%d%H%M%S')) else: @@ -155,20 +187,25 @@ class ElasticSearchQuery(BaseSearchQuery): return ' '.join(cleaned_words) def build_query_fragment(self, field, filter_type, value): + # 构建查询片段 return value.query_string def get_count(self): + # 获取搜索结果数量 results = self.get_results() return len(results) if results else 0 def get_spelling_suggestion(self, preferred_query=None): + # 获取拼写建议 return self._spelling_suggestion def build_params(self, spelling_query=None): + # 构建查询参数 kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) return kwargs +# Elasticsearch模型搜索表单类 class ElasticSearchModelSearchForm(ModelSearchForm): def search(self): @@ -178,6 +215,9 @@ class ElasticSearchModelSearchForm(ModelSearchForm): return sqs +# Elasticsearch搜索引擎类 class ElasticSearchEngine(BaseEngine): + # 指定后端类 backend = ElasticSearchBackend - query = ElasticSearchQuery + # 指定查询类 + query = ElasticSearchQuery \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/feeds.py b/src/DjangoBlog/djangoblog/feeds.py index 8c4e851..72ddf8f 100644 --- a/src/DjangoBlog/djangoblog/feeds.py +++ b/src/DjangoBlog/djangoblog/feeds.py @@ -1,40 +1,59 @@ +# 导入获取用户模型的函数 from django.contrib.auth import get_user_model +# 导入Django联合供稿(RSS/Atom)视图 from django.contrib.syndication.views import Feed +# 导入时区工具 from django.utils import timezone +# 导入RSS 2.01修订版Feed生成器 from django.utils.feedgenerator import Rss201rev2Feed +# 导入博客文章模型 from blog.models import Article +# 导入自定义Markdown工具 from djangoblog.utils import CommonMarkdown +# Django博客Feed类,用于生成RSS订阅 class DjangoBlogFeed(Feed): + # 指定Feed类型为RSS 2.01修订版 feed_type = Rss201rev2Feed + # Feed描述 description = '大巧无工,重剑无锋.' + # Feed标题 title = "且听风吟 大巧无工,重剑无锋. " + # Feed链接地址 link = "/feed/" def author_name(self): + # 返回第一个用户的昵称作为作者名 return get_user_model().objects.first().nickname def author_link(self): + # 返回第一个用户的个人主页链接 return get_user_model().objects.first().get_absolute_url() def items(self): + # 返回最近发布的5篇文章,按发布时间倒序排列 return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] def item_title(self, item): + # 返回文章标题 return item.title def item_description(self, item): + # 将文章正文从Markdown转换为HTML格式 return CommonMarkdown.get_markdown(item.body) def feed_copyright(self): + # 生成包含当前年份的版权信息 now = timezone.now() return "Copyright© {year} 且听风吟".format(year=now.year) def item_link(self, item): + # 返回文章的绝对URL return item.get_absolute_url() def item_guid(self, item): - return + # 返回文章的唯一标识符(当前返回None) + return \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/logentryadmin.py b/src/DjangoBlog/djangoblog/logentryadmin.py index 2f6a535..ce3da80 100644 --- a/src/DjangoBlog/djangoblog/logentryadmin.py +++ b/src/DjangoBlog/djangoblog/logentryadmin.py @@ -1,27 +1,40 @@ +# 导入Django管理员模块 from django.contrib import admin +# 导入日志条目删除标志 from django.contrib.admin.models import DELETION +# 导入内容类型模型 from django.contrib.contenttypes.models import ContentType +# 导入URL反向解析和异常 from django.urls import reverse, NoReverseMatch +# 导入字符串编码工具 from django.utils.encoding import force_str +# 导入HTML转义工具 from django.utils.html import escape +# 导入安全字符串处理 from django.utils.safestring import mark_safe +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# 日志条目管理类 class LogEntryAdmin(admin.ModelAdmin): + # 右侧过滤器字段 list_filter = [ 'content_type' ] + # 搜索字段 search_fields = [ 'object_repr', 'change_message' ] + # 可作为链接点击的字段 list_display_links = [ 'action_time', 'get_change_message', ] + # 列表页显示的字段 list_display = [ 'action_time', 'user_link', @@ -31,61 +44,78 @@ class LogEntryAdmin(admin.ModelAdmin): ] def has_add_permission(self, request): + # 禁用添加权限 return False def has_change_permission(self, request, obj=None): + # 只有超级用户或有修改日志权限的用户可以查看,且不允许POST修改 return ( request.user.is_superuser or request.user.has_perm('admin.change_logentry') ) and request.method != 'POST' def has_delete_permission(self, request, obj=None): + # 禁用删除权限 return False def object_link(self, obj): + # 转义对象表示字符串 object_link = escape(obj.object_repr) content_type = obj.content_type + # 如果不是删除操作且内容类型存在,尝试生成对象链接 if obj.action_flag != DELETION and content_type is not None: # try returning an actual link instead of object repr string try: + # 生成对象编辑页面的URL url = reverse( 'admin:{}_{}_change'.format(content_type.app_label, content_type.model), args=[obj.object_id] ) + # 创建HTML链接 object_link = '{}'.format(url, object_link) except NoReverseMatch: pass + # 返回安全的HTML字符串 return mark_safe(object_link) + # 设置对象链接字段的排序和显示名称 object_link.admin_order_field = 'object_repr' object_link.short_description = _('object') def user_link(self, obj): + # 获取用户的内容类型 content_type = ContentType.objects.get_for_model(type(obj.user)) + # 转义用户字符串表示 user_link = escape(force_str(obj.user)) try: # try returning an actual link instead of object repr string + # 生成用户编辑页面的URL url = reverse( 'admin:{}_{}_change'.format(content_type.app_label, content_type.model), args=[obj.user.pk] ) + # 创建HTML链接 user_link = '{}'.format(url, user_link) except NoReverseMatch: pass + # 返回安全的HTML字符串 return mark_safe(user_link) + # 设置用户链接字段的排序和显示名称 user_link.admin_order_field = 'user' user_link.short_description = _('user') def get_queryset(self, request): + # 获取查询集并预取关联的内容类型 queryset = super(LogEntryAdmin, self).get_queryset(request) return queryset.prefetch_related('content_type') def get_actions(self, request): + # 获取操作列表并移除删除选中操作 actions = super(LogEntryAdmin, self).get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] - return actions + return actions \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py b/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py index 2b4be5c..384b1c3 100644 --- a/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py +++ b/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py @@ -1,18 +1,29 @@ +# 导入日志模块 import logging +# 获取日志记录器 logger = logging.getLogger(__name__) +# 基础插件类 class BasePlugin: # 插件元数据 - PLUGIN_NAME = None - PLUGIN_DESCRIPTION = None - PLUGIN_VERSION = None + PLUGIN_NAME = None # 插件名称(子类必须定义) + PLUGIN_DESCRIPTION = None # 插件描述(子类必须定义) + PLUGIN_VERSION = None # 插件版本(子类必须定义) def __init__(self): + """ + 插件初始化方法 + 验证插件元数据并执行初始化流程 + """ + # 验证插件元数据是否完整定义 if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") + + # 执行插件初始化 self.init_plugin() + # 注册插件钩子 self.register_hooks() def init_plugin(self): @@ -20,6 +31,7 @@ class BasePlugin: 插件初始化逻辑 子类可以重写此方法来实现特定的初始化操作 """ + # 记录插件初始化日志 logger.info(f'{self.PLUGIN_NAME} initialized.') def register_hooks(self): @@ -27,6 +39,7 @@ class BasePlugin: 注册插件钩子 子类可以重写此方法来注册特定的钩子 """ + # 基类中为空实现,由子类重写 pass def get_plugin_info(self): @@ -35,7 +48,7 @@ class BasePlugin: :return: 包含插件元数据的字典 """ return { - 'name': self.PLUGIN_NAME, - 'description': self.PLUGIN_DESCRIPTION, - 'version': self.PLUGIN_VERSION - } + 'name': self.PLUGIN_NAME, # 插件名称 + 'description': self.PLUGIN_DESCRIPTION, # 插件描述 + 'version': self.PLUGIN_VERSION # 插件版本 + } \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py b/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py index 6685b7c..93cb52f 100644 --- a/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py +++ b/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py @@ -1,7 +1,14 @@ +# 文章详情加载钩子常量 - 当文章详情页面加载时触发 ARTICLE_DETAIL_LOAD = 'article_detail_load' + +# 文章创建钩子常量 - 当创建新文章时触发 ARTICLE_CREATE = 'article_create' + +# 文章更新钩子常量 - 当更新现有文章时触发 ARTICLE_UPDATE = 'article_update' -ARTICLE_DELETE = 'article_delete' -ARTICLE_CONTENT_HOOK_NAME = "the_content" +# 文章删除钩子常量 - 当删除文章时触发 +ARTICLE_DELETE = 'article_delete' +# 文章内容处理钩子常量 - 用于处理文章内容的插件钩子 +ARTICLE_CONTENT_HOOK_NAME = "the_content" \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/plugin_manage/hooks.py b/src/DjangoBlog/djangoblog/plugin_manage/hooks.py index d712540..a32eae4 100644 --- a/src/DjangoBlog/djangoblog/plugin_manage/hooks.py +++ b/src/DjangoBlog/djangoblog/plugin_manage/hooks.py @@ -1,17 +1,27 @@ +# 导入日志模块 import logging +# 获取日志记录器 logger = logging.getLogger(__name__) +# 全局钩子字典,用于存储所有注册的钩子 _hooks = {} def register(hook_name: str, callback: callable): """ 注册一个钩子回调。 + + Args: + hook_name: 钩子名称 + callback: 回调函数(可调用对象) """ + # 如果钩子名称不存在于字典中,初始化一个空列表 if hook_name not in _hooks: _hooks[hook_name] = [] + # 将回调函数添加到对应钩子的列表中 _hooks[hook_name].append(callback) + # 记录调试日志,显示注册的钩子和回调函数名称 logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") @@ -19,26 +29,53 @@ def run_action(hook_name: str, *args, **kwargs): """ 执行一个 Action Hook。 它会按顺序执行所有注册到该钩子上的回调函数。 + + Args: + hook_name: 钩子名称 + *args: 位置参数 + **kwargs: 关键字参数 """ + # 检查钩子是否存在 if hook_name in _hooks: + # 记录调试日志,显示正在运行的钩子 logger.debug(f"Running action hook '{hook_name}'") + # 遍历该钩子下的所有回调函数 for callback in _hooks[hook_name]: try: + # 执行回调函数 callback(*args, **kwargs) except Exception as e: - logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + # 记录执行过程中的错误,包含完整的堆栈信息 + logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", + exc_info=True) def apply_filters(hook_name: str, value, *args, **kwargs): """ 执行一个 Filter Hook。 它会把 value 依次传递给所有注册的回调函数进行处理。 + + Args: + hook_name: 钩子名称 + value: 需要过滤的值 + *args: 位置参数 + **kwargs: 关键字参数 + + Returns: + 经过所有回调函数处理后的值 """ + # 检查钩子是否存在 if hook_name in _hooks: + # 记录调试日志,显示正在应用的过滤器钩子 logger.debug(f"Applying filter hook '{hook_name}'") + # 遍历该钩子下的所有回调函数 for callback in _hooks[hook_name]: try: + # 将当前值传递给回调函数进行处理,并更新值 value = callback(value, *args, **kwargs) except Exception as e: - logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) - return value + # 记录处理过程中的错误,包含完整的堆栈信息 + logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", + exc_info=True) + # 返回处理后的值 + return value \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/plugin_manage/loader.py b/src/DjangoBlog/djangoblog/plugin_manage/loader.py index 12e824b..4897c15 100644 --- a/src/DjangoBlog/djangoblog/plugin_manage/loader.py +++ b/src/DjangoBlog/djangoblog/plugin_manage/loader.py @@ -1,19 +1,33 @@ +# 导入操作系统接口模块 import os +# 导入日志模块 import logging +# 导入Django配置 from django.conf import settings +# 获取日志记录器 logger = logging.getLogger(__name__) + def load_plugins(): """ Dynamically loads and initializes plugins from the 'plugins' directory. This function is intended to be called when the Django app registry is ready. + + 从'plugins'目录动态加载和初始化插件。 + 此函数应在Django应用注册表准备就绪时调用。 """ + # 遍历设置中配置的活跃插件列表 for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件路径 plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + # 检查插件目录是否存在且包含plugin.py文件 if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): try: + # 动态导入插件模块 __import__(f'plugins.{plugin_name}.plugin') + # 记录成功加载插件的日志 logger.info(f"Successfully loaded plugin: {plugin_name}") except ImportError as e: - logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file + # 记录导入插件失败的日志,包含异常信息 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/sitemap.py b/src/DjangoBlog/djangoblog/sitemap.py index 8b7d446..f52bf46 100644 --- a/src/DjangoBlog/djangoblog/sitemap.py +++ b/src/DjangoBlog/djangoblog/sitemap.py @@ -1,59 +1,87 @@ +# 导入Django站点地图相关模块 from django.contrib.sitemaps import Sitemap +# 导入URL反向解析 from django.urls import reverse +# 导入博客模型 from blog.models import Article, Category, Tag +# 静态视图站点地图类 class StaticViewSitemap(Sitemap): + # 优先级(0.0到1.0之间) priority = 0.5 + # 内容更新频率 changefreq = 'daily' def items(self): + # 返回需要包含在站点地图中的静态视图名称列表 return ['blog:index', ] def location(self, item): + # 根据视图名称生成对应的URL return reverse(item) +# 文章站点地图类 class ArticleSiteMap(Sitemap): + # 内容更新频率为每月 changefreq = "monthly" + # 优先级为0.6 priority = "0.6" def items(self): + # 返回所有已发布的文章 return Article.objects.filter(status='p') def lastmod(self, obj): + # 返回文章的最后修改时间 return obj.last_modify_time +# 分类站点地图类 class CategorySiteMap(Sitemap): + # 内容更新频率为每周 changefreq = "Weekly" + # 优先级为0.6 priority = "0.6" def items(self): + # 返回所有分类 return Category.objects.all() def lastmod(self, obj): + # 返回分类的最后修改时间 return obj.last_modify_time +# 标签站点地图类 class TagSiteMap(Sitemap): + # 内容更新频率为每周 changefreq = "Weekly" + # 优先级为0.3 priority = "0.3" def items(self): + # 返回所有标签 return Tag.objects.all() def lastmod(self, obj): + # 返回标签的最后修改时间 return obj.last_modify_time +# 用户站点地图类 class UserSiteMap(Sitemap): + # 内容更新频率为每周 changefreq = "Weekly" + # 优先级为0.3 priority = "0.3" def items(self): + # 返回所有有文章的作者列表(去重) return list(set(map(lambda x: x.author, Article.objects.all()))) def lastmod(self, obj): - return obj.date_joined + # 返回用户的注册时间 + return obj.date_joined \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/spider_notify.py b/src/DjangoBlog/djangoblog/spider_notify.py index 7b909e9..36ee1b3 100644 --- a/src/DjangoBlog/djangoblog/spider_notify.py +++ b/src/DjangoBlog/djangoblog/spider_notify.py @@ -1,21 +1,32 @@ +# 导入日志模块 import logging +# 导入HTTP请求库 import requests +# 导入Django配置 from django.conf import settings +# 获取日志记录器 logger = logging.getLogger(__name__) +# 蜘蛛通知类(用于通知搜索引擎更新内容) class SpiderNotify(): @staticmethod def baidu_notify(urls): + # 向百度搜索引擎提交URL更新通知 try: + # 将URL列表转换为换行分隔的字符串 data = '\n'.join(urls) + # 向百度站长平台提交URL更新 result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + # 记录百度返回的结果 logger.info(result.text) except Exception as e: + # 记录通知过程中的错误 logger.error(e) @staticmethod def notify(url): - SpiderNotify.baidu_notify(url) + # 单个URL通知的便捷方法 + SpiderNotify.baidu_notify(url) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/tests.py b/src/DjangoBlog/djangoblog/tests.py index 01237d9..4618efa 100644 --- a/src/DjangoBlog/djangoblog/tests.py +++ b/src/DjangoBlog/djangoblog/tests.py @@ -1,15 +1,22 @@ +# 导入Django测试框架 from django.test import TestCase +# 导入djangoblog工具模块的所有函数 from djangoblog.utils import * +# DjangoBlog测试类 class DjangoBlogTest(TestCase): def setUp(self): + # 测试初始化方法(当前为空实现) pass def test_utils(self): + # 测试工具函数 + # 测试SHA256哈希函数 md5 = get_sha256('test') self.assertIsNotNone(md5) + # 测试Markdown转换功能 c = CommonMarkdown.get_markdown(''' # Title1 @@ -24,9 +31,10 @@ class DjangoBlogTest(TestCase): ''') self.assertIsNotNone(c) + # 测试字典转URL参数字符串功能 d = { 'd': 'key1', 'd2': 'key2' } data = parse_dict_to_url(d) - self.assertIsNotNone(data) + self.assertIsNotNone(data) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/utils.py b/src/DjangoBlog/djangoblog/utils.py index 91d2b91..7394739 100644 --- a/src/DjangoBlog/djangoblog/utils.py +++ b/src/DjangoBlog/djangoblog/utils.py @@ -2,35 +2,52 @@ # encoding: utf-8 +# 导入日志模块 import logging +# 导入操作系统接口模块 import os +# 导入随机数模块 import random +# 导入字符串处理模块 import string +# 导入UUID生成模块 import uuid +# 导入哈希库 from hashlib import sha256 +# 导入HTML清理库 import bleach +# 导入Markdown处理库 import markdown +# 导入HTTP请求库 import requests +# 导入Django配置 from django.conf import settings +# 导入Django站点模型 from django.contrib.sites.models import Site +# 导入Django缓存系统 from django.core.cache import cache +# 导入静态文件处理 from django.templatetags.static import static +# 获取日志记录器 logger = logging.getLogger(__name__) +# 获取最大文章ID和评论ID def get_max_articleid_commentid(): from blog.models import Article from comments.models import Comment return (Article.objects.latest().pk, Comment.objects.latest().pk) +# 计算字符串的SHA256哈希值 def get_sha256(str): m = sha256(str.encode('utf-8')) return m.hexdigest() +# 缓存装饰器 def cache_decorator(expiration=3 * 60): def wrapper(func): def news(*args, **kwargs): @@ -67,6 +84,7 @@ def cache_decorator(expiration=3 * 60): return wrapper +# 使视图缓存过期 def expire_view_cache(path, servername, serverport, key_prefix=None): ''' 刷新视图缓存 @@ -92,12 +110,14 @@ def expire_view_cache(path, servername, serverport, key_prefix=None): return False +# 获取当前站点(带缓存) @cache_decorator() def get_current_site(): site = Site.objects.get_current() return site +# 通用Markdown处理类 class CommonMarkdown: @staticmethod def _convert_markdown(value): @@ -124,6 +144,7 @@ class CommonMarkdown: return body +# 发送邮件函数 def send_email(emailto, title, content): from djangoblog.blog_signals import send_email_signal send_email_signal.send( @@ -133,11 +154,13 @@ def send_email(emailto, title, content): content=content) +# 生成随机验证码 def generate_code() -> str: """生成随机数验证码""" return ''.join(random.sample(string.digits, 6)) +# 将字典解析为URL参数字符串 def parse_dict_to_url(dict): from urllib.parse import quote url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) @@ -145,6 +168,7 @@ def parse_dict_to_url(dict): return url +# 获取博客设置(带缓存) def get_blog_setting(): value = cache.get('get_blog_setting') if value: @@ -173,6 +197,7 @@ def get_blog_setting(): return value +# 保存用户头像 def save_user_avatar(url): ''' 保存用户头像 @@ -201,6 +226,7 @@ def save_user_avatar(url): return static('blog/img/avatar.png') +# 删除侧边栏缓存 def delete_sidebar_cache(): from blog.models import LinkShowType keys = ["sidebar" + x for x in LinkShowType.values] @@ -209,12 +235,14 @@ def delete_sidebar_cache(): cache.delete(k) +# 删除视图缓存 def delete_view_cache(prefix, keys): from django.core.cache.utils import make_template_fragment_key key = make_template_fragment_key(prefix, keys) cache.delete(key) +# 获取资源URL def get_resource_url(): if settings.STATIC_URL: return settings.STATIC_URL @@ -223,6 +251,7 @@ def get_resource_url(): return 'http://' + site.domain + '/static/' +# 允许的HTML标签白名单 ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', 'h2', 'p', 'span', 'div'] @@ -235,6 +264,7 @@ ALLOWED_CLASSES = [ 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' ] +# 自定义class属性过滤器 def class_filter(tag, name, value): """自定义class属性过滤器""" if name == 'class': @@ -245,8 +275,8 @@ def class_filter(tag, name, value): # 安全的属性白名单 ALLOWED_ATTRIBUTES = { - 'a': ['href', 'title'], - 'abbr': ['title'], + 'a': ['href', 'title'], + 'abbr': ['title'], 'acronym': ['title'], 'span': class_filter, 'div': class_filter, @@ -257,16 +287,17 @@ ALLOWED_ATTRIBUTES = { # 安全的协议白名单 - 防止javascript:等危险协议 ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] +# HTML清理函数 def sanitize_html(html): """ 安全的HTML清理函数 使用bleach库进行白名单过滤,防止XSS攻击 """ return bleach.clean( - html, - tags=ALLOWED_TAGS, + html, + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 strip=True, # 移除不允许的标签而不是转义 strip_comments=True # 移除HTML注释 - ) + ) \ No newline at end of file diff --git a/src/DjangoBlog/oauth/admin.py b/src/DjangoBlog/oauth/admin.py index 57eab5f..4f6d985 100644 --- a/src/DjangoBlog/oauth/admin.py +++ b/src/DjangoBlog/oauth/admin.py @@ -1,16 +1,25 @@ +# 导入日志模块 import logging +# 导入Django管理员模块 from django.contrib import admin # Register your models here. +# 导入URL反向解析 from django.urls import reverse +# 导入HTML格式化工具 from django.utils.html import format_html +# 获取日志记录器 logger = logging.getLogger(__name__) +# OAuth用户模型的管理类 class OAuthUserAdmin(admin.ModelAdmin): + # 搜索字段 search_fields = ('nickname', 'email') + # 每页显示20条记录 list_per_page = 20 + # 列表页显示的字段 list_display = ( 'id', 'nickname', @@ -19,36 +28,51 @@ class OAuthUserAdmin(admin.ModelAdmin): 'type', 'email', ) + # 可作为链接点击的字段 list_display_links = ('id', 'nickname') + # 右侧过滤器字段 list_filter = ('author', 'type',) + # 只读字段列表 readonly_fields = [] def get_readonly_fields(self, request, obj=None): + # 将所有字段设置为只读,防止在管理员界面修改 return list(self.readonly_fields) + \ [field.name for field in obj._meta.fields] + \ [field.name for field in obj._meta.many_to_many] def has_add_permission(self, request): + # 禁用添加权限 return False def link_to_usermodel(self, obj): + # 生成关联用户模型的链接 if obj.author: + # 获取用户模型的app和model信息 info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 生成用户编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回HTML链接,显示用户昵称(如果没有昵称则显示邮箱) return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) def show_user_image(self, obj): + # 显示用户头像 img = obj.picture + # 返回HTML图片标签,设置固定尺寸 return format_html( u'' % (img)) + # 设置自定义方法的显示名称 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',) \ No newline at end of file diff --git a/src/DjangoBlog/oauth/apps.py b/src/DjangoBlog/oauth/apps.py index 17fcea2..528d7ad 100644 --- a/src/DjangoBlog/oauth/apps.py +++ b/src/DjangoBlog/oauth/apps.py @@ -1,5 +1,8 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义oauth应用的配置类 class OauthConfig(AppConfig): - name = 'oauth' + # 指定应用的Python路径(Django内部使用的标识) + name = 'oauth' \ No newline at end of file diff --git a/src/DjangoBlog/oauth/forms.py b/src/DjangoBlog/oauth/forms.py index 0e4ede3..e4354ff 100644 --- a/src/DjangoBlog/oauth/forms.py +++ b/src/DjangoBlog/oauth/forms.py @@ -1,12 +1,18 @@ +# 导入Django表单模块 from django.contrib.auth.forms import forms from django.forms import widgets +# 需要邮箱的表单类 class RequireEmailForm(forms.Form): + # 邮箱字段,标签为'电子邮箱',必填 email = forms.EmailField(label='电子邮箱', required=True) + # OAuth ID字段,使用隐藏输入控件,非必填 oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) def __init__(self, *args, **kwargs): + # 调用父类的初始化方法 super(RequireEmailForm, self).__init__(*args, **kwargs) + # 自定义邮箱字段的控件属性 self.fields['email'].widget = widgets.EmailInput( - attrs={'placeholder': "email", "class": "form-control"}) + attrs={'placeholder': "email", "class": "form-control"}) \ No newline at end of file diff --git a/src/DjangoBlog/oauth/migrations/0001_initial.py b/src/DjangoBlog/oauth/migrations/0001_initial.py index 3aa3e03..978237e 100644 --- a/src/DjangoBlog/oauth/migrations/0001_initial.py +++ b/src/DjangoBlog/oauth/migrations/0001_initial.py @@ -8,50 +8,81 @@ import django.utils.timezone class Migration(migrations.Migration): + # 标记为初始迁移 initial = True + # 声明依赖的迁移 dependencies = [ + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义迁移操作序列 operations = [ + # 创建OAuth配置模型 migrations.CreateModel( name='OAuthConfig', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # OAuth类型字段,使用选择项 ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + # 应用密钥字段 ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), + # 应用密钥字段 ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), + # 回调地址字段,默认值为百度首页 ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), + # 是否启用字段 ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 创建时间字段,默认值为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段,默认值为当前时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ], options={ + # 模型显示名称(单数) 'verbose_name': 'oauth配置', + # 模型显示名称(复数) 'verbose_name_plural': 'oauth配置', + # 默认按创建时间倒序排列 'ordering': ['-created_time'], }, ), + # 创建OAuth用户模型 migrations.CreateModel( name='OAuthUser', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 第三方平台用户ID字段 ('openid', models.CharField(max_length=50)), + # 用户昵称字段 ('nickname', models.CharField(max_length=50, verbose_name='昵称')), + # 访问令牌字段,允许为空 ('token', models.CharField(blank=True, max_length=150, null=True)), + # 用户头像字段,允许为空 ('picture', models.CharField(blank=True, max_length=350, null=True)), + # OAuth类型字段 ('type', models.CharField(max_length=50)), + # 邮箱字段,允许为空 ('email', models.CharField(blank=True, max_length=50, null=True)), + # 元数据字段,存储额外的用户信息,允许为空 ('metadata', models.TextField(blank=True, null=True)), + # 创建时间字段,默认值为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段,默认值为当前时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 关联用户外键,允许为空(未绑定本地用户时) ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), ], options={ + # 模型显示名称(单数) 'verbose_name': 'oauth用户', + # 模型显示名称(复数) 'verbose_name_plural': 'oauth用户', + # 默认按创建时间倒序排列 'ordering': ['-created_time'], }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py index d5cc70e..1fd29b4 100644 --- a/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py +++ b/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -8,79 +8,98 @@ import django.utils.timezone class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖可交换的用户模型 migrations.swappable_dependency(settings.AUTH_USER_MODEL), + # 依赖于oauth应用的初始迁移 ('oauth', '0001_initial'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改OAuthConfig模型的元选项 migrations.AlterModelOptions( name='oauthconfig', options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, ), + # 修改OAuthUser模型的元选项(国际化) migrations.AlterModelOptions( name='oauthuser', options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, ), + # 删除OAuthConfig模型的created_time字段 migrations.RemoveField( model_name='oauthconfig', name='created_time', ), + # 删除OAuthConfig模型的last_mod_time字段 migrations.RemoveField( model_name='oauthconfig', name='last_mod_time', ), + # 删除OAuthUser模型的created_time字段 migrations.RemoveField( model_name='oauthuser', name='created_time', ), + # 删除OAuthUser模型的last_mod_time字段 migrations.RemoveField( model_name='oauthuser', name='last_mod_time', ), + # 向OAuthConfig模型添加creation_time字段 migrations.AddField( model_name='oauthconfig', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向OAuthConfig模型添加last_modify_time字段 migrations.AddField( model_name='oauthconfig', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 向OAuthUser模型添加creation_time字段 migrations.AddField( model_name='oauthuser', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 向OAuthUser模型添加last_modify_time字段 migrations.AddField( model_name='oauthuser', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 修改OAuthConfig模型的callback_url字段的显示名称和默认值 migrations.AlterField( model_name='oauthconfig', name='callback_url', field=models.CharField(default='', max_length=200, verbose_name='callback url'), ), + # 修改OAuthConfig模型的is_enable字段的显示名称 migrations.AlterField( model_name='oauthconfig', name='is_enable', field=models.BooleanField(default=True, verbose_name='is enable'), ), + # 修改OAuthConfig模型的type字段的显示名称和选项值(国际化) migrations.AlterField( model_name='oauthconfig', name='type', field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), ), + # 修改OAuthUser模型的author字段的显示名称 migrations.AlterField( model_name='oauthuser', name='author', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), ), + # 修改OAuthUser模型的nickname字段的显示名称 migrations.AlterField( model_name='oauthuser', name='nickname', field=models.CharField(max_length=50, verbose_name='nickname'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py index 6af08eb..8bd670e 100644 --- a/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py +++ b/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -5,14 +5,18 @@ from django.db import migrations, models class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖于oauth应用的第二个迁移 ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改OAuthUser模型的nickname字段的显示名称 migrations.AlterField( model_name='oauthuser', name='nickname', field=models.CharField(max_length=50, verbose_name='nick name'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/models.py b/src/DjangoBlog/oauth/models.py index be838ed..336ca51 100644 --- a/src/DjangoBlog/oauth/models.py +++ b/src/DjangoBlog/oauth/models.py @@ -1,38 +1,60 @@ # Create your models here. +# 导入Django配置 from django.conf import settings +# 导入验证异常类 from django.core.exceptions import ValidationError +# 导入Django数据库模型 from django.db import models +# 导入时间工具 from django.utils.timezone import now +# 导入国际化翻译函数 from django.utils.translation import gettext_lazy as _ +# OAuth用户模型类 class OAuthUser(models.Model): + # 关联用户外键,允许为空(未绑定本地用户时) author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), blank=True, null=True, on_delete=models.CASCADE) + # 第三方平台用户ID字段 openid = models.CharField(max_length=50) + # 用户昵称字段 nickname = models.CharField(max_length=50, verbose_name=_('nick name')) + # 访问令牌字段,允许为空 token = models.CharField(max_length=150, null=True, blank=True) + # 用户头像字段,允许为空 picture = models.CharField(max_length=350, blank=True, null=True) + # OAuth类型字段,必填 type = models.CharField(blank=False, null=False, max_length=50) + # 邮箱字段,允许为空 email = models.CharField(max_length=50, null=True, blank=True) + # 元数据字段,存储额外的用户信息,允许为空 metadata = models.TextField(null=True, blank=True) + # 创建时间字段,默认值为当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间字段,默认值为当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) def __str__(self): + # 返回用户昵称作为字符串表示 return self.nickname class Meta: + # 模型显示名称(单数) verbose_name = _('oauth user') + # 模型显示名称(复数) verbose_name_plural = verbose_name + # 默认按创建时间倒序排列 ordering = ['-creation_time'] +# OAuth配置模型类 class OAuthConfig(models.Model): + # OAuth类型选择项 TYPE = ( ('weibo', _('weibo')), ('google', _('google')), @@ -40,28 +62,40 @@ class OAuthConfig(models.Model): ('facebook', 'FaceBook'), ('qq', 'QQ'), ) + # OAuth类型字段,使用选择项 type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') + # 应用密钥字段 appkey = models.CharField(max_length=200, verbose_name='AppKey') + # 应用密钥字段 appsecret = models.CharField(max_length=200, verbose_name='AppSecret') + # 回调地址字段,必填,默认值为空字符串 callback_url = models.CharField( max_length=200, verbose_name=_('callback url'), blank=False, default='') + # 是否启用字段 is_enable = models.BooleanField( _('is enable'), default=True, blank=False, null=False) + # 创建时间字段,默认值为当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间字段,默认值为当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) def clean(self): + # 验证同类型的OAuth配置是否已存在 if OAuthConfig.objects.filter( type=self.type).exclude(id=self.id).count(): raise ValidationError(_(self.type + _('already exists'))) def __str__(self): + # 返回OAuth类型作为字符串表示 return self.type class Meta: + # 模型显示名称(单数) verbose_name = 'oauth配置' + # 模型显示名称(复数) verbose_name_plural = verbose_name - ordering = ['-creation_time'] + # 默认按创建时间倒序排列 + ordering = ['-creation_time'] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/oauthmanager.py b/src/DjangoBlog/oauth/oauthmanager.py index 2e7ceef..6931199 100644 --- a/src/DjangoBlog/oauth/oauthmanager.py +++ b/src/DjangoBlog/oauth/oauthmanager.py @@ -1,23 +1,34 @@ +# 导入JSON处理模块 import json +# 导入日志模块 import logging +# 导入操作系统接口模块 import os +# 导入URL解析模块 import urllib.parse +# 导入抽象基类 from abc import ABCMeta, abstractmethod +# 导入HTTP请求库 import requests +# 导入自定义缓存装饰器 from djangoblog.utils import cache_decorator +# 导入OAuth模型 from oauth.models import OAuthUser, OAuthConfig +# 获取日志记录器 logger = logging.getLogger(__name__) +# OAuth授权令牌异常类 class OAuthAccessTokenException(Exception): ''' oauth授权失败异常 ''' +# 基础OAuth管理器抽象类 class BaseOauthManager(metaclass=ABCMeta): """获取用户授权""" AUTH_URL = None @@ -29,48 +40,60 @@ class BaseOauthManager(metaclass=ABCMeta): ICON_NAME = None def __init__(self, access_token=None, openid=None): + # 访问令牌 self.access_token = access_token + # 用户开放ID self.openid = openid @property def is_access_token_set(self): + # 检查访问令牌是否设置 return self.access_token is not None @property def is_authorized(self): + # 检查是否已授权 return self.is_access_token_set and self.access_token is not None and self.openid is not None @abstractmethod def get_authorization_url(self, nexturl='/'): + # 抽象方法:获取授权URL pass @abstractmethod def get_access_token_by_code(self, code): + # 抽象方法:通过授权码获取访问令牌 pass @abstractmethod def get_oauth_userinfo(self): + # 抽象方法:获取OAuth用户信息 pass @abstractmethod def get_picture(self, metadata): + # 抽象方法:获取用户头像 pass def do_get(self, url, params, headers=None): + # 执行GET请求 rsp = requests.get(url=url, params=params, headers=headers) logger.info(rsp.text) return rsp.text def do_post(self, url, params, headers=None): + # 执行POST请求 rsp = requests.post(url, params, headers=headers) logger.info(rsp.text) return rsp.text def get_config(self): + # 获取OAuth配置 value = OAuthConfig.objects.filter(type=self.ICON_NAME) return value[0] if value else None +# 微博OAuth管理器 class WBOauthManager(BaseOauthManager): AUTH_URL = 'https://api.weibo.com/oauth2/authorize' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' @@ -89,6 +112,7 @@ class WBOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + # 构建微博授权URL params = { 'client_id': self.client_id, 'response_type': 'code', @@ -98,7 +122,7 @@ class WBOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): - + # 通过授权码获取微博访问令牌 params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -117,6 +141,7 @@ class WBOauthManager(BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + # 获取微博用户信息 if not self.is_authorized: return None params = { @@ -142,12 +167,15 @@ class WBOauthManager(BaseOauthManager): return None def get_picture(self, metadata): + # 从元数据中获取微博用户头像 datas = json.loads(metadata) return datas['avatar_large'] +# 代理管理器混入类 class ProxyManagerMixin: def __init__(self, *args, **kwargs): + # 设置HTTP代理 if os.environ.get("HTTP_PROXY"): self.proxies = { "http": os.environ.get("HTTP_PROXY"), @@ -157,16 +185,19 @@ class ProxyManagerMixin: self.proxies = None def do_get(self, url, params, headers=None): + # 使用代理执行GET请求 rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text def do_post(self, url, params, headers=None): + # 使用代理执行POST请求 rsp = requests.post(url, params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text +# Google OAuth管理器 class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' @@ -185,6 +216,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + # 构建Google授权URL params = { 'client_id': self.client_id, 'response_type': 'code', @@ -195,6 +227,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + # 通过授权码获取Google访问令牌 params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -216,6 +249,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + # 获取Google用户信息 if not self.is_authorized: return None params = { @@ -241,10 +275,12 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + # 从元数据中获取Google用户头像 datas = json.loads(metadata) return datas['picture'] +# GitHub OAuth管理器 class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): AUTH_URL = 'https://github.com/login/oauth/authorize' TOKEN_URL = 'https://github.com/login/oauth/access_token' @@ -263,6 +299,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + # 构建GitHub授权URL params = { 'client_id': self.client_id, 'response_type': 'code', @@ -273,6 +310,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + # 通过授权码获取GitHub访问令牌 params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -292,7 +330,7 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): - + # 获取GitHub用户信息 rsp = self.do_get(self.API_URL, params={}, headers={ "Authorization": "token " + self.access_token }) @@ -314,10 +352,12 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + # 从元数据中获取GitHub用户头像 datas = json.loads(metadata) return datas['avatar_url'] +# Facebook OAuth管理器 class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' @@ -336,6 +376,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + # 构建Facebook授权URL params = { 'client_id': self.client_id, 'response_type': 'code', @@ -346,6 +387,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + # 通过授权码获取Facebook访问令牌 params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -365,6 +407,7 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + # 获取Facebook用户信息 params = { 'access_token': self.access_token, 'fields': 'id,name,picture,email' @@ -388,10 +431,12 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + # 从元数据中获取Facebook用户头像 datas = json.loads(metadata) return str(datas['picture']['data']['url']) +# QQ OAuth管理器 class QQOauthManager(BaseOauthManager): AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' @@ -411,6 +456,7 @@ class QQOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + # 构建QQ授权URL params = { 'response_type': 'code', 'client_id': self.client_id, @@ -420,6 +466,7 @@ class QQOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): + # 通过授权码获取QQ访问令牌 params = { 'grant_type': 'authorization_code', 'client_id': self.client_id, @@ -438,6 +485,7 @@ class QQOauthManager(BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_open_id(self): + # 获取QQ用户OpenID if self.is_access_token_set: params = { 'access_token': self.access_token @@ -454,6 +502,7 @@ class QQOauthManager(BaseOauthManager): return openid def get_oauth_userinfo(self): + # 获取QQ用户信息 openid = self.get_open_id() if openid: params = { @@ -477,10 +526,12 @@ class QQOauthManager(BaseOauthManager): return user def get_picture(self, metadata): + # 从元数据中获取QQ用户头像 datas = json.loads(metadata) return str(datas['figureurl']) +# 获取启用的OAuth应用列表(带缓存) @cache_decorator(expiration=100 * 60) def get_oauth_apps(): configs = OAuthConfig.objects.filter(is_enable=True).all() @@ -492,6 +543,7 @@ def get_oauth_apps(): return apps +# 根据类型获取对应的OAuth管理器 def get_manager_by_type(type): applications = get_oauth_apps() if applications: @@ -501,4 +553,4 @@ def get_manager_by_type(type): applications)) if finds: return finds[0] - return None + return None \ No newline at end of file diff --git a/src/DjangoBlog/oauth/tests.py b/src/DjangoBlog/oauth/tests.py index bb23b9b..2f2a955 100644 --- a/src/DjangoBlog/oauth/tests.py +++ b/src/DjangoBlog/oauth/tests.py @@ -1,45 +1,60 @@ +# 导入JSON处理模块 import json +# 导入单元测试模拟模块 from unittest.mock import patch +# 导入Django配置和测试相关模块 from django.conf import settings from django.contrib import auth from django.test import Client, RequestFactory, TestCase from django.urls import reverse +# 导入自定义工具函数 from djangoblog.utils import get_sha256 +# 导入OAuth配置模型 from oauth.models import OAuthConfig +# 导入OAuth管理器基类 from oauth.oauthmanager import BaseOauthManager # Create your tests here. +# OAuth配置测试类 class OAuthConfigTest(TestCase): def setUp(self): + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() def test_oauth_login_test(self): + # 创建微博OAuth配置 c = OAuthConfig() c.type = 'weibo' c.appkey = 'appkey' c.appsecret = 'appsecret' c.save() + # 测试OAuth登录页面重定向 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试OAuth授权回调 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') +# OAuth登录测试类 class OauthLoginTest(TestCase): def setUp(self) -> None: + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() + # 初始化OAuth应用 self.apps = self.init_apps() def init_apps(self): + # 初始化所有OAuth应用配置 applications = [p() for p in BaseOauthManager.__subclasses__()] for application in applications: c = OAuthConfig() @@ -50,6 +65,7 @@ class OauthLoginTest(TestCase): return applications def get_app_by_type(self, type): + # 根据类型获取对应的OAuth应用 for app in self.apps: if app.ICON_NAME.lower() == type: return app @@ -57,9 +73,11 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_login(self, mock_do_get, mock_do_post): + # 测试微博登录功能 weibo_app = self.get_app_by_type('weibo') assert weibo_app url = weibo_app.get_authorization_url() + # 模拟微博API返回数据 mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" }) @@ -70,15 +88,18 @@ class OauthLoginTest(TestCase): "email": "email", }) userinfo = weibo_app.get_access_token_by_code('code') + # 验证用户信息 self.assertEqual(userinfo.token, 'access_token') self.assertEqual(userinfo.openid, 'id') @patch("oauth.oauthmanager.GoogleOauthManager.do_post") @patch("oauth.oauthmanager.GoogleOauthManager.do_get") def test_google_login(self, mock_do_get, mock_do_post): + # 测试Google登录功能 google_app = self.get_app_by_type('google') assert google_app url = google_app.get_authorization_url() + # 模拟Google API返回数据 mock_do_post.return_value = json.dumps({ "access_token": "access_token", "id_token": "id_token", @@ -91,17 +112,20 @@ class OauthLoginTest(TestCase): }) token = google_app.get_access_token_by_code('code') userinfo = google_app.get_oauth_userinfo() + # 验证用户信息 self.assertEqual(userinfo.token, 'access_token') self.assertEqual(userinfo.openid, 'sub') @patch("oauth.oauthmanager.GitHubOauthManager.do_post") @patch("oauth.oauthmanager.GitHubOauthManager.do_get") def test_github_login(self, mock_do_get, mock_do_post): + # 测试GitHub登录功能 github_app = self.get_app_by_type('github') assert github_app url = github_app.get_authorization_url() self.assertTrue("github.com" in url) self.assertTrue("client_id" in url) + # 模拟GitHub API返回数据 mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" mock_do_get.return_value = json.dumps({ "avatar_url": "avatar_url", @@ -111,16 +135,19 @@ class OauthLoginTest(TestCase): }) token = github_app.get_access_token_by_code('code') userinfo = github_app.get_oauth_userinfo() + # 验证用户信息 self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') self.assertEqual(userinfo.openid, 'id') @patch("oauth.oauthmanager.FaceBookOauthManager.do_post") @patch("oauth.oauthmanager.FaceBookOauthManager.do_get") def test_facebook_login(self, mock_do_get, mock_do_post): + # 测试Facebook登录功能 facebook_app = self.get_app_by_type('facebook') assert facebook_app url = facebook_app.get_authorization_url() self.assertTrue("facebook.com" in url) + # 模拟Facebook API返回数据 mock_do_post.return_value = json.dumps({ "access_token": "access_token", }) @@ -136,9 +163,11 @@ class OauthLoginTest(TestCase): }) token = facebook_app.get_access_token_by_code('code') userinfo = facebook_app.get_oauth_userinfo() + # 验证用户信息 self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ + # 模拟QQ API三次调用的返回数据 'access_token=access_token&expires_in=3600', 'callback({"client_id":"appid","openid":"openid"} );', json.dumps({ @@ -149,18 +178,22 @@ class OauthLoginTest(TestCase): }) ]) def test_qq_login(self, mock_do_get): + # 测试QQ登录功能 qq_app = self.get_app_by_type('qq') assert qq_app url = qq_app.get_authorization_url() self.assertTrue("qq.com" in url) token = qq_app.get_access_token_by_code('code') userinfo = qq_app.get_oauth_userinfo() + # 验证用户信息 self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post): + # 测试带邮箱的微博授权登录 + # 模拟微博API返回数据 mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" }) @@ -172,14 +205,17 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 测试OAuth登录重定向 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试授权回调 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') + # 验证用户认证状态 user = auth.get_user(self.client) assert user.is_authenticated self.assertTrue(user.is_authenticated) @@ -187,6 +223,7 @@ class OauthLoginTest(TestCase): self.assertEqual(user.email, mock_user_info['email']) self.client.logout() + # 重复登录测试 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') @@ -200,7 +237,9 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post): + # 测试不带邮箱的微博授权登录(需要补充邮箱) + # 模拟微博API返回数据(不含邮箱) mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" }) @@ -211,20 +250,25 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 测试OAuth登录重定向 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试授权回调(应该重定向到邮箱补充页面) response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) + # 解析OAuth用户ID oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') + # 提交邮箱信息 response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) self.assertEqual(response.status_code, 302) + # 生成验证签名 sign = get_sha256(settings.SECRET_KEY + str(oauth_user_id) + settings.SECRET_KEY) @@ -233,6 +277,7 @@ class OauthLoginTest(TestCase): }) self.assertEqual(response.url, f'{url}?type=email') + # 邮箱确认 path = reverse('oauth:email_confirm', kwargs={ 'id': oauth_user_id, 'sign': sign @@ -240,10 +285,12 @@ class OauthLoginTest(TestCase): response = self.client.get(path) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + + # 验证用户认证状态和OAuth绑定 user = auth.get_user(self.client) from oauth.models import OAuthUser oauth_user = OAuthUser.objects.get(author=user) self.assertTrue(user.is_authenticated) self.assertEqual(user.username, mock_user_info['screen_name']) self.assertEqual(user.email, 'test@gmail.com') - self.assertEqual(oauth_user.pk, oauth_user_id) + self.assertEqual(oauth_user.pk, oauth_user_id) \ No newline at end of file diff --git a/src/DjangoBlog/oauth/urls.py b/src/DjangoBlog/oauth/urls.py index c4a12a0..32a5aaf 100644 --- a/src/DjangoBlog/oauth/urls.py +++ b/src/DjangoBlog/oauth/urls.py @@ -1,25 +1,35 @@ +# 导入Django URL路由相关模块 from django.urls import path +# 导入当前应用的视图模块 from . import views +# 定义应用的命名空间 app_name = "oauth" + +# 定义URL模式列表 urlpatterns = [ + # OAuth授权回调URL,使用函数视图 path( r'oauth/authorize', views.authorize), + # 需要邮箱补充页面URL,使用类视图,包含OAuth用户ID参数 path( r'oauth/requireemail/.html', views.RequireEmailView.as_view(), name='require_email'), + # 邮箱确认URL,包含用户ID和签名参数 path( r'oauth/emailconfirm//.html', views.emailconfirm, name='email_confirm'), + # 绑定成功页面URL,包含OAuth用户ID参数 path( r'oauth/bindsuccess/.html', views.bindsuccess, name='bindsuccess'), + # OAuth登录入口URL path( r'oauth/oauthlogin', views.oauthlogin, - name='oauthlogin')] + name='oauthlogin')] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/views.py b/src/DjangoBlog/oauth/views.py index 12e3a6e..398b72a 100644 --- a/src/DjangoBlog/oauth/views.py +++ b/src/DjangoBlog/oauth/views.py @@ -1,7 +1,10 @@ +# 导入日志模块 import logging # Create your views here. +# 导入URL解析模块 from urllib.parse import urlparse +# 导入Django配置和认证相关模块 from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth import login @@ -16,16 +19,21 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView +# 导入自定义信号和工具函数 from djangoblog.blog_signals import oauth_user_login_signal from djangoblog.utils import get_current_site from djangoblog.utils import send_email, get_sha256 +# 导入OAuth表单 from oauth.forms import RequireEmailForm +# 导入OAuth模型和管理器 from .models import OAuthUser from .oauthmanager import get_manager_by_type, OAuthAccessTokenException +# 获取日志记录器 logger = logging.getLogger(__name__) +# 获取重定向URL def get_redirecturl(request): nexturl = request.GET.get('next_url', None) if not nexturl or nexturl == '/login/' or nexturl == '/login': @@ -40,6 +48,7 @@ def get_redirecturl(request): return nexturl +# OAuth登录入口视图 def oauthlogin(request): type = request.GET.get('type', None) if not type: @@ -52,6 +61,7 @@ def oauthlogin(request): return HttpResponseRedirect(authorizeurl) +# OAuth授权回调视图 def authorize(request): type = request.GET.get('type', None) if not type: @@ -124,6 +134,7 @@ def authorize(request): return HttpResponseRedirect(nexturl) +# 邮箱确认视图 def emailconfirm(request, id, sign): if not sign: return HttpResponseForbidden() @@ -170,6 +181,7 @@ def emailconfirm(request, id, sign): return HttpResponseRedirect(url) +# 需要邮箱补充视图类 class RequireEmailView(FormView): form_class = RequireEmailForm template_name = 'oauth/require_email.html' @@ -233,6 +245,7 @@ class RequireEmailView(FormView): return HttpResponseRedirect(url) +# 绑定成功页面视图 def bindsuccess(request, oauthid): type = request.GET.get('type', None) oauthuser = get_object_or_404(OAuthUser, pk=oauthid) @@ -250,4 +263,4 @@ def bindsuccess(request, oauthid): return render(request, 'oauth/bindsuccess.html', { 'title': title, 'content': content - }) + }) \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/admin.py b/src/DjangoBlog/owntracks/admin.py index 655b535..20e7f88 100644 --- a/src/DjangoBlog/owntracks/admin.py +++ b/src/DjangoBlog/owntracks/admin.py @@ -1,7 +1,10 @@ +# 导入Django管理员模块 from django.contrib import admin # Register your models here. +# OwnTrackLog模型的管理类 class OwnTrackLogsAdmin(admin.ModelAdmin): - pass + # 使用默认的管理员配置 + pass \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/apps.py b/src/DjangoBlog/owntracks/apps.py index 1bc5f12..858a46a 100644 --- a/src/DjangoBlog/owntracks/apps.py +++ b/src/DjangoBlog/owntracks/apps.py @@ -1,5 +1,8 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义owntracks应用的配置类 class OwntracksConfig(AppConfig): - name = 'owntracks' + # 指定应用的Python路径(Django内部使用的标识) + name = 'owntracks' \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/migrations/0001_initial.py b/src/DjangoBlog/owntracks/migrations/0001_initial.py index 9eee55c..332e75d 100644 --- a/src/DjangoBlog/owntracks/migrations/0001_initial.py +++ b/src/DjangoBlog/owntracks/migrations/0001_initial.py @@ -6,26 +6,39 @@ import django.utils.timezone class Migration(migrations.Migration): + # 标记为初始迁移 initial = True + # 声明依赖的迁移(当前无依赖) dependencies = [ ] + # 定义迁移操作序列 operations = [ + # 创建OwnTrackLog模型(位置追踪日志) migrations.CreateModel( name='OwnTrackLog', fields=[ + # 主键ID,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 用户标识字段,最大长度100字符 ('tid', models.CharField(max_length=100, verbose_name='用户')), + # 纬度字段,浮点数类型 ('lat', models.FloatField(verbose_name='纬度')), + # 经度字段,浮点数类型 ('lon', models.FloatField(verbose_name='经度')), + # 创建时间字段,默认值为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ], options={ + # 模型显示名称(单数) 'verbose_name': 'OwnTrackLogs', + # 模型显示名称(复数) 'verbose_name_plural': 'OwnTrackLogs', + # 默认按创建时间升序排列 'ordering': ['created_time'], + # 指定获取最新记录的依据字段 'get_latest_by': 'created_time', }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/src/DjangoBlog/owntracks/migrations/0002_alter_owntracklog_options_and_more.py index b4f8dec..ca34f64 100644 --- a/src/DjangoBlog/owntracks/migrations/0002_alter_owntracklog_options_and_more.py +++ b/src/DjangoBlog/owntracks/migrations/0002_alter_owntracklog_options_and_more.py @@ -5,18 +5,23 @@ from django.db import migrations class Migration(migrations.Migration): + # 声明本迁移依赖的前一个迁移文件 dependencies = [ + # 依赖于owntracks应用的初始迁移 ('owntracks', '0001_initial'), ] + # 定义本迁移要执行的操作序列 operations = [ + # 修改OwnTrackLog模型的元选项 migrations.AlterModelOptions( name='owntracklog', options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'}, ), + # 重命名字段:将created_time改为creation_time migrations.RenameField( model_name='owntracklog', old_name='created_time', new_name='creation_time', ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/models.py b/src/DjangoBlog/owntracks/models.py index 760942c..d4da77c 100644 --- a/src/DjangoBlog/owntracks/models.py +++ b/src/DjangoBlog/owntracks/models.py @@ -1,20 +1,32 @@ +# 导入Django数据库模型 from django.db import models +# 导入时间工具 from django.utils.timezone import now # Create your models here. +# OwnTrackLog模型类(位置追踪日志) class OwnTrackLog(models.Model): + # 用户标识字段,最大长度100字符,必填 tid = models.CharField(max_length=100, null=False, verbose_name='用户') + # 纬度字段,浮点数类型 lat = models.FloatField(verbose_name='纬度') + # 经度字段,浮点数类型 lon = models.FloatField(verbose_name='经度') + # 创建时间字段,默认值为当前时间 creation_time = models.DateTimeField('创建时间', default=now) def __str__(self): + # 返回用户标识作为字符串表示 return self.tid class Meta: + # 默认按创建时间升序排列 ordering = ['creation_time'] + # 模型显示名称(单数) verbose_name = "OwnTrackLogs" + # 模型显示名称(复数) verbose_name_plural = verbose_name - get_latest_by = 'creation_time' + # 指定获取最新记录的依据字段 + get_latest_by = 'creation_time' \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/tests.py b/src/DjangoBlog/owntracks/tests.py index 3b4b9d8..b949619 100644 --- a/src/DjangoBlog/owntracks/tests.py +++ b/src/DjangoBlog/owntracks/tests.py @@ -10,55 +10,79 @@ from .models import OwnTrackLog class OwnTrackLogTest(TestCase): def setUp(self): + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() def test_own_track_log(self): + # 测试用例:验证OwnTrackLog的功能 + + # 创建第一个有效的位置数据对象(包含经纬度和tid) o = { - 'tid': 12, - 'lat': 123.123, - 'lon': 134.341 + 'tid': 12, # 设备ID + 'lat': 123.123, # 纬度 + 'lon': 134.341 # 经度 } + # 发送POST请求提交位置数据 self.client.post( '/owntracks/logtracks', - json.dumps(o), - content_type='application/json') + json.dumps(o), # 将字典转换为JSON字符串 + content_type='application/json') # 设置内容类型为JSON + + # 验证数据是否成功保存到数据库 length = len(OwnTrackLog.objects.all()) - self.assertEqual(length, 1) + self.assertEqual(length, 1) # 应该有一条记录 + # 创建第二个不完整的位置数据对象(缺少经度) o = { 'tid': 12, 'lat': 123.123 + # 缺少lon字段,这个请求应该不会创建新记录 } + # 发送第二个POST请求 self.client.post( '/owntracks/logtracks', json.dumps(o), content_type='application/json') + + # 验证记录数量没有增加(因为缺少必需字段) length = len(OwnTrackLog.objects.all()) - self.assertEqual(length, 1) + self.assertEqual(length, 1) # 应该仍然只有一条记录 + # 测试未登录用户访问地图页面的重定向 rsp = self.client.get('/owntracks/show_maps') - self.assertEqual(rsp.status_code, 302) + self.assertEqual(rsp.status_code, 302) # 302表示重定向到登录页 + # 创建超级用户用于后续测试 user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") + # 使用新创建的用户登录 self.client.login(username='liangliangyy1', password='liangliangyy1') + + # 手动创建一个OwnTrackLog对象 s = OwnTrackLog() - s.tid = 12 - s.lon = 123.234 - s.lat = 34.234 - s.save() + s.tid = 12 # 设置设备ID + s.lon = 123.234 # 设置经度 + s.lat = 34.234 # 设置纬度 + s.save() # 保存到数据库 + # 测试登录后访问日期显示页面 rsp = self.client.get('/owntracks/show_dates') - self.assertEqual(rsp.status_code, 200) + self.assertEqual(rsp.status_code, 200) # 200表示成功访问 + + # 测试登录后访问地图页面 rsp = self.client.get('/owntracks/show_maps') - self.assertEqual(rsp.status_code, 200) + self.assertEqual(rsp.status_code, 200) # 现在应该能正常访问 + + # 测试获取数据接口(不带参数) rsp = self.client.get('/owntracks/get_datas') self.assertEqual(rsp.status_code, 200) + + # 测试获取数据接口(带日期参数) rsp = self.client.get('/owntracks/get_datas?date=2018-02-26') - self.assertEqual(rsp.status_code, 200) + self.assertEqual(rsp.status_code, 200) \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/urls.py b/src/DjangoBlog/owntracks/urls.py index c19ada8..31503bd 100644 --- a/src/DjangoBlog/owntracks/urls.py +++ b/src/DjangoBlog/owntracks/urls.py @@ -2,11 +2,20 @@ from django.urls import path from . import views +# 定义应用的命名空间,用于URL反向解析 app_name = "owntracks" +# URL模式配置,将URL路径映射到对应的视图函数 urlpatterns = [ + # 接收OwnTracks位置数据日志的端点 path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'), + + # 显示地图的页面 path('owntracks/show_maps', views.show_maps, name='show_maps'), + + # 获取位置数据的API端点 path('owntracks/get_datas', views.get_datas, name='get_datas'), + + # 显示日志日期的页面 path('owntracks/show_dates', views.show_log_dates, name='show_dates') -] +] \ No newline at end of file diff --git a/src/DjangoBlog/owntracks/views.py b/src/DjangoBlog/owntracks/views.py index 4c72bdd..8e9f13e 100644 --- a/src/DjangoBlog/owntracks/views.py +++ b/src/DjangoBlog/owntracks/views.py @@ -19,109 +19,124 @@ from .models import OwnTrackLog logger = logging.getLogger(__name__) -@csrf_exempt +@csrf_exempt # 免除CSRF保护,允许外部应用POST请求 def manage_owntrack_log(request): try: + # 解析JSON请求体 s = json.loads(request.read().decode('utf-8')) - tid = s['tid'] - lat = s['lat'] - lon = s['lon'] + tid = s['tid'] # 设备ID + lat = s['lat'] # 纬度 + lon = s['lon'] # 经度 + # 记录位置信息日志 logger.info( 'tid:{tid}.lat:{lat}.lon:{lon}'.format( tid=tid, lat=lat, lon=lon)) + # 验证必需字段都存在 if tid and lat and lon: m = OwnTrackLog() m.tid = tid m.lat = lat m.lon = lon - m.save() + m.save() # 保存到数据库 return HttpResponse('ok') else: return HttpResponse('data error') except Exception as e: - logger.error(e) + logger.error(e) # 记录错误日志 return HttpResponse('error') -@login_required +@login_required # 需要用户登录 def show_maps(request): - if request.user.is_superuser: + if request.user.is_superuser: # 只允许超级用户访问 + # 设置默认日期为当前UTC日期 defaultdate = str(datetime.datetime.now(timezone.utc).date()) - date = request.GET.get('date', defaultdate) + date = request.GET.get('date', defaultdate) # 从GET参数获取日期 context = { 'date': date } return render(request, 'owntracks/show_maps.html', context) else: from django.http import HttpResponseForbidden - return HttpResponseForbidden() + return HttpResponseForbidden() # 非超级用户返回403禁止访问 @login_required def show_log_dates(request): + # 获取所有日志记录的创建时间 dates = OwnTrackLog.objects.values_list('creation_time', flat=True) + # 提取唯一日期并格式化为YYYY-MM-DD results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates)))) context = { - 'results': results + 'results': results # 日期列表传递给模板 } return render(request, 'owntracks/show_log_dates.html', context) def convert_to_amap(locations): + """将GPS坐标转换为高德地图坐标系统""" convert_result = [] - it = iter(locations) + it = iter(locations) # 创建迭代器 + # 每次处理30个坐标(高德API限制) item = list(itertools.islice(it, 30)) while item: + # 将坐标格式化为经度,纬度字符串并用分号连接 datas = ';'.join( set(map(lambda x: str(x.lon) + ',' + str(x.lat), item))) - key = '8440a376dfc9743d8924bf0ad141f28e' + key = '8440a376dfc9743d8924bf0ad141f28e' # 高德API密钥 api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' query = { 'key': key, 'locations': datas, - 'coordsys': 'gps' + 'coordsys': 'gps' # 指定源坐标系为GPS } rsp = requests.get(url=api, params=query) result = json.loads(rsp.text) if "locations" in result: - convert_result.append(result['locations']) - item = list(itertools.islice(it, 30)) + convert_result.append(result['locations']) # 添加转换后的坐标 + item = list(itertools.islice(it, 30)) # 获取下一批坐标 - return ";".join(convert_result) + return ";".join(convert_result) # 返回所有转换后坐标 @login_required def get_datas(request): + # 设置默认查询日期为今天 now = django.utils.timezone.now().replace(tzinfo=timezone.utc) querydate = django.utils.timezone.datetime( now.year, now.month, now.day, 0, 0, 0) + # 如果提供了日期参数,使用该日期 if request.GET.get('date', None): date = list(map(lambda x: int(x), request.GET.get('date').split('-'))) querydate = django.utils.timezone.datetime( date[0], date[1], date[2], 0, 0, 0) + # 计算查询结束日期(第二天) nextdate = querydate + datetime.timedelta(days=1) + # 查询指定日期范围内的位置记录 models = OwnTrackLog.objects.filter( creation_time__range=(querydate, nextdate)) result = list() if models and len(models): + # 按设备ID分组处理 for tid, item in groupby( sorted(models, key=lambda k: k.tid), key=lambda k: k.tid): d = dict() - d["name"] = tid + d["name"] = tid # 设备ID作为名称 paths = list() - # 使用高德转换后的经纬度 + # 注释掉的代码:使用高德地图坐标转换 # locations = convert_to_amap( # sorted(item, key=lambda x: x.creation_time)) # for i in locations.split(';'): # paths.append(i.split(',')) - # 使用GPS原始经纬度 + + # 当前使用原始GPS坐标 for location in sorted(item, key=lambda x: x.creation_time): - paths.append([str(location.lon), str(location.lat)]) - d["path"] = paths + paths.append([str(location.lon), str(location.lat)]) # 经度,纬度 + d["path"] = paths # 设备移动路径 result.append(d) - return JsonResponse(result, safe=False) + return JsonResponse(result, safe=False) # 返回JSON响应 \ No newline at end of file diff --git a/src/DjangoBlog/plugins/article_copyright/plugin.py b/src/DjangoBlog/plugins/article_copyright/plugin.py index 5dba3b3..6b61acb 100644 --- a/src/DjangoBlog/plugins/article_copyright/plugin.py +++ b/src/DjangoBlog/plugins/article_copyright/plugin.py @@ -1,17 +1,21 @@ +# 导入插件管理的基础类和钩子相关模块 from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME +# 1. 定义文章版权插件类,继承自基础插件类 class ArticleCopyrightPlugin(BasePlugin): - PLUGIN_NAME = '文章结尾版权声明' - PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' - PLUGIN_VERSION = '0.2.0' - PLUGIN_AUTHOR = 'liangliangyy' + # 插件基本信息配置 + PLUGIN_NAME = '文章结尾版权声明' # 插件显示名称 + PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' # 插件功能描述 + PLUGIN_VERSION = '0.2.0' # 插件版本号 + PLUGIN_AUTHOR = 'liangliangyy' # 插件作者 # 2. 实现 register_hooks 方法,专门用于注册钩子 def register_hooks(self): # 在这里将插件的方法注册到指定的钩子上 + # 将 add_copyright_to_content 方法注册到文章内容钩子 hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content) def add_copyright_to_content(self, content, *args, **kwargs): @@ -19,19 +23,22 @@ class ArticleCopyrightPlugin(BasePlugin): 这个方法会被注册到 'the_content' 过滤器钩子上。 它接收原始内容,并返回添加了版权信息的新内容。 """ + # 从参数中获取文章对象 article = kwargs.get('article') if not article: - return content - + return content # 如果没有文章对象,直接返回原内容 + # 如果是摘要模式(首页),不添加版权声明 is_summary = kwargs.get('is_summary', False) if is_summary: return content + # 构建版权声明信息,包含作者用户名 copyright_info = f"\n

本文由 {article.author.username} 原创,转载请注明出处。

" + # 在原文末尾添加版权声明 return content + copyright_info # 3. 实例化插件。 # 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。 -plugin = ArticleCopyrightPlugin() +plugin = ArticleCopyrightPlugin() \ No newline at end of file diff --git a/src/DjangoBlog/plugins/external_links/plugin.py b/src/DjangoBlog/plugins/external_links/plugin.py index 5b2ef14..856a8dc 100644 --- a/src/DjangoBlog/plugins/external_links/plugin.py +++ b/src/DjangoBlog/plugins/external_links/plugin.py @@ -6,43 +6,51 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME class ExternalLinksPlugin(BasePlugin): - PLUGIN_NAME = '外部链接处理器' - PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' - PLUGIN_VERSION = '0.1.0' - PLUGIN_AUTHOR = 'liangliangyy' + # 插件基本信息配置 + PLUGIN_NAME = '外部链接处理器' # 插件显示名称 + PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' # 插件功能描述 + PLUGIN_VERSION = '0.1.0' # 插件版本号 + PLUGIN_AUTHOR = 'liangliangyy' # 插件作者 def register_hooks(self): + # 注册插件到文章内容钩子,处理外部链接 hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links) def process_external_links(self, content, *args, **kwargs): + # 导入获取当前站点域名的工具函数 from djangoblog.utils import get_current_site - site_domain = get_current_site().domain + site_domain = get_current_site().domain # 获取当前网站的域名 # 正则表达式查找所有 标签 + # 匹配模式:]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE) def replacer(match): # match.group(1) 是 ... - href = match.group(2) + href = match.group(2) # 提取链接URL - # 如果链接已经有 target 属性,则不处理 + # 如果链接已经有 target 属性,则不处理(避免重复添加) if 'target=' in match.group(0).lower(): - return match.group(0) + return match.group(0) # 返回原始标签不变 - # 解析链接 + # 解析链接URL,提取域名等信息 parsed_url = urlparse(href) # 如果链接是外部的 (有域名且域名不等于当前网站域名) if parsed_url.netloc and parsed_url.netloc != site_domain: - # 添加 target 和 rel 属性 + # 添加 target="_blank" 和 rel="noopener noreferrer" 属性 + # target="_blank" 在新窗口打开链接 + # rel="noopener noreferrer" 提供安全保护,防止标签页钓鱼攻击 return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}' - # 否则返回原样 + # 否则返回原样(内部链接不处理) return match.group(0) + # 使用正则表达式替换函数处理所有匹配的链接 return link_pattern.sub(replacer, content) -plugin = ExternalLinksPlugin() +# 实例化插件,自动注册钩子 +plugin = ExternalLinksPlugin() \ No newline at end of file diff --git a/src/DjangoBlog/plugins/image_lazy_loading/plugin.py b/src/DjangoBlog/plugins/image_lazy_loading/plugin.py index b4b9e0a..2bc2b7f 100644 --- a/src/DjangoBlog/plugins/image_lazy_loading/plugin.py +++ b/src/DjangoBlog/plugins/image_lazy_loading/plugin.py @@ -7,24 +7,26 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME class ImageOptimizationPlugin(BasePlugin): - PLUGIN_NAME = '图片性能优化插件' - PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。' - PLUGIN_VERSION = '1.0.0' - PLUGIN_AUTHOR = 'liangliangyy' + # 插件基本信息配置 + PLUGIN_NAME = '图片性能优化插件' # 插件显示名称 + PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。' # 插件功能描述 + PLUGIN_VERSION = '1.0.0' # 插件版本号 + PLUGIN_AUTHOR = 'liangliangyy' # 插件作者 def __init__(self): - # 插件配置 + # 插件配置字典,定义各种优化功能的开关 self.config = { - 'enable_lazy_loading': True, # 启用懒加载 - 'enable_async_decoding': True, # 启用异步解码 - 'add_loading_placeholder': True, # 添加加载占位符 - 'optimize_external_images': True, # 优化外部图片 - 'add_responsive_attributes': True, # 添加响应式属性 - 'skip_first_image': True, # 跳过第一张图片(LCP优化) + 'enable_lazy_loading': True, # 启用懒加载:延迟加载视口外的图片 + 'enable_async_decoding': True, # 启用异步解码:不阻塞页面渲染 + 'add_loading_placeholder': True, # 添加加载占位符:改善用户体验 + 'optimize_external_images': True, # 优化外部图片:为外部图片添加安全属性 + 'add_responsive_attributes': True, # 添加响应式属性:支持不同屏幕尺寸 + 'skip_first_image': True, # 跳过第一张图片:优化LCP(最大内容绘制)指标 } - super().__init__() + super().__init__() # 调用父类初始化方法 def register_hooks(self): + # 注册插件到文章内容钩子,处理图片优化 hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images) def optimize_images(self, content, *args, **kwargs): @@ -35,73 +37,76 @@ class ImageOptimizationPlugin(BasePlugin): return content # 正则表达式匹配 img 标签 + # 匹配模式:]*?)(?:\s*/)?>', - re.IGNORECASE | re.DOTALL + re.IGNORECASE | re.DOTALL # 忽略大小写,匹配多行 ) - image_count = 0 - + image_count = 0 # 图片计数器,用于识别第一张图片 + def replace_img_tag(match): - nonlocal image_count - image_count += 1 - - # 获取原始属性 + nonlocal image_count # 使用外部计数器 + image_count += 1 # 每处理一张图片计数器加1 + + # 获取原始属性字符串(group(1)是属性部分) original_attrs = match.group(1) - - # 解析现有属性 + + # 解析现有属性为字典格式 attrs = self._parse_img_attributes(original_attrs) - - # 应用优化 + + # 应用各种优化策略 optimized_attrs = self._apply_optimizations(attrs, image_count) - - # 重构 img 标签 + + # 重构优化后的img标签 return self._build_img_tag(optimized_attrs) - # 替换所有 img 标签 + # 使用正则表达式替换所有匹配的img标签 optimized_content = img_pattern.sub(replace_img_tag, content) - + return optimized_content def _parse_img_attributes(self, attr_string): """ - 解析 img 标签的属性 + 解析 img 标签的属性字符串为字典 """ - attrs = {} - - # 正则表达式匹配属性 + attrs = {} # 存储属性键值对 + + # 正则表达式匹配属性:属性名=引号包围的属性值 attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2') - + + # 遍历所有匹配的属性 for match in attr_pattern.finditer(attr_string): - attr_name = match.group(1).lower() - attr_value = match.group(3) + attr_name = match.group(1).lower() # 属性名转为小写 + attr_value = match.group(3) # 属性值 attrs[attr_name] = attr_value - + return attrs def _apply_optimizations(self, attrs, image_index): """ - 应用各种图片优化 + 应用各种图片优化策略 """ # 1. 懒加载优化(跳过第一张图片以优化LCP) if self.config['enable_lazy_loading']: + # 如果配置跳过第一张图片且当前是第一张,则不添加懒加载 if not (self.config['skip_first_image'] and image_index == 1): - if 'loading' not in attrs: - attrs['loading'] = 'lazy' + if 'loading' not in attrs: # 如果还没有loading属性 + attrs['loading'] = 'lazy' # 添加懒加载属性 - # 2. 异步解码 + # 2. 异步解码:不阻塞页面渲染 if self.config['enable_async_decoding']: - if 'decoding' not in attrs: - attrs['decoding'] = 'async' + if 'decoding' not in attrs: # 如果还没有decoding属性 + attrs['decoding'] = 'async' # 添加异步解码属性 + + # 3. 添加样式优化:确保图片响应式显示 + current_style = attrs.get('style', '') # 获取现有样式 - # 3. 添加样式优化 - current_style = attrs.get('style', '') - # 确保图片不会超出容器 if 'max-width' not in current_style: if current_style and not current_style.endswith(';'): - current_style += ';' - current_style += 'max-width:100%;height:auto;' + current_style += ';' # 添加分号分隔 + current_style += 'max-width:100%;height:auto;' # 添加响应式样式 attrs['style'] = current_style # 4. 添加 alt 属性(SEO和可访问性) @@ -110,73 +115,76 @@ class ImageOptimizationPlugin(BasePlugin): src = attrs.get('src', '') if src: # 从文件名生成alt文本 - filename = src.split('/')[-1].split('.')[0] + filename = src.split('/')[-1].split('.')[0] # 提取文件名(不含扩展名) # 移除常见的无意义字符 - clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash - clean_name = re.sub(r'[_-]+', ' ', clean_name).strip() - attrs['alt'] = clean_name if clean_name else '文章图片' + clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash(如MD5值) + clean_name = re.sub(r'[_-]+', ' ', clean_name).strip() # 替换下划线和横线为空格 + attrs['alt'] = clean_name if clean_name else '文章图片' # 设置alt文本 else: - attrs['alt'] = '文章图片' + attrs['alt'] = '文章图片' # 默认alt文本 - # 5. 外部图片优化 + # 5. 外部图片优化:为外部图片添加安全属性 if self.config['optimize_external_images'] and 'src' in attrs: src = attrs['src'] - parsed_url = urlparse(src) - - # 如果是外部图片,添加 referrerpolicy + parsed_url = urlparse(src) # 解析URL + + # 如果是外部图片(有域名且不是当前网站域名) if parsed_url.netloc and parsed_url.netloc != self._get_current_domain(): - attrs['referrerpolicy'] = 'no-referrer-when-downgrade' + attrs['referrerpolicy'] = 'no-referrer-when-downgrade' # 设置引用策略 # 为外部图片添加crossorigin属性以支持性能监控 if 'crossorigin' not in attrs: - attrs['crossorigin'] = 'anonymous' + attrs['crossorigin'] = 'anonymous' # 匿名跨域请求 # 6. 响应式图片属性(如果配置启用) if self.config['add_responsive_attributes']: - # 添加 sizes 属性(如果没有的话) + # 添加 sizes 属性(如果没有的话且没有srcset) if 'sizes' not in attrs and 'srcset' not in attrs: + # 设置响应式尺寸:小屏100%,中屏50%,大屏33% attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw' # 7. 添加图片唯一标识符用于性能追踪 if 'data-img-id' not in attrs and 'src' in attrs: - img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8] - attrs['data-img-id'] = f'img-{img_hash}' + # 基于图片URL生成短hash作为唯一ID + img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8] # 取前8位 + attrs['data-img-id'] = f'img-{img_hash}' # 设置数据属性 # 8. 为第一张图片添加高优先级提示(LCP优化) if image_index == 1 and self.config['skip_first_image']: - attrs['fetchpriority'] = 'high' - # 移除懒加载以确保快速加载 + attrs['fetchpriority'] = 'high' # 设置高获取优先级 + # 移除懒加载以确保快速加载(第一张图片通常是LCP元素) if 'loading' in attrs: - del attrs['loading'] + del attrs['loading'] # 删除懒加载属性 - return attrs + return attrs # 返回优化后的属性字典 def _build_img_tag(self, attrs): """ - 重新构建 img 标签 + 重新构建 img 标签字符串 """ - attr_strings = [] - - # 确保 src 属性在最前面 + attr_strings = [] # 存储属性字符串列表 + + # 确保 src 属性在最前面(HTML规范推荐) if 'src' in attrs: attr_strings.append(f'src="{attrs["src"]}"') - - # 添加其他属性 + + # 添加其他属性(按字典顺序排序以获得一致性) for key, value in attrs.items(): if key != 'src': # src 已经添加过了 attr_strings.append(f'{key}="{value}"') - + + # 构建完整的img标签 return f'' def _get_current_domain(self): """ - 获取当前网站域名 + 获取当前网站域名,用于判断图片是否为外部图片 """ try: from djangoblog.utils import get_current_site - return get_current_site().domain + return get_current_site().domain # 返回当前站点域名 except: - return '' + return '' # 如果获取失败返回空字符串 -# 实例化插件 -plugin = ImageOptimizationPlugin() +# 实例化插件,自动注册钩子 +plugin = ImageOptimizationPlugin() \ No newline at end of file diff --git a/src/DjangoBlog/plugins/seo_optimizer/plugin.py b/src/DjangoBlog/plugins/seo_optimizer/plugin.py index de12c15..2a2cdf0 100644 --- a/src/DjangoBlog/plugins/seo_optimizer/plugin.py +++ b/src/DjangoBlog/plugins/seo_optimizer/plugin.py @@ -8,22 +8,29 @@ from djangoblog.utils import get_blog_setting class SeoOptimizerPlugin(BasePlugin): - PLUGIN_NAME = 'SEO 优化器' - PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' - PLUGIN_VERSION = '0.2.0' - PLUGIN_AUTHOR = 'liuangliangyy' + # 插件基本信息配置 + PLUGIN_NAME = 'SEO 优化器' # 插件显示名称 + PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' # 插件功能描述 + PLUGIN_VERSION = '0.2.0' # 插件版本号 + PLUGIN_AUTHOR = 'liuangliangyy' # 插件作者 def register_hooks(self): + # 注册插件到头部meta钩子,用于生成SEO相关标签 hooks.register('head_meta', self.dispatch_seo_generation) def _get_article_seo_data(self, context, request, blog_setting): + """为文章页面生成SEO数据""" article = context.get('article') + # 确保上下文中的article是Article模型实例 if not isinstance(article, Article): return None + # 生成文章描述:去除HTML标签并截取前150字符 description = strip_tags(article.body)[:150] + # 生成关键词:使用文章标签或回退到站点关键词 keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords - + + # 生成Open Graph meta标签(社交媒体分享优化) meta_tags = f''' @@ -34,10 +41,13 @@ class SeoOptimizerPlugin(BasePlugin): ''' + # 为每个标签添加article:tag meta标签 for tag in article.tags.all(): meta_tags += f'' + # 添加站点名称 meta_tags += f'' + # 生成JSON-LD结构化数据(搜索引擎优化) structured_data = { "@context": "https://schema.org", "@type": "Article", @@ -50,37 +60,43 @@ class SeoOptimizerPlugin(BasePlugin): "author": {"@type": "Person", "name": article.author.username}, "publisher": {"@type": "Organization", "name": blog_setting.site_name} } + # 如果没有图片,移除image字段 if not structured_data.get("image"): del structured_data["image"] return { - "title": f"{article.title} | {blog_setting.site_name}", - "description": description, - "keywords": keywords, - "meta_tags": meta_tags, - "json_ld": structured_data + "title": f"{article.title} | {blog_setting.site_name}", # 页面标题 + "description": description, # 页面描述 + "keywords": keywords, # 页面关键词 + "meta_tags": meta_tags, # Open Graph标签 + "json_ld": structured_data # JSON-LD结构化数据 } def _get_category_seo_data(self, context, request, blog_setting): + """为分类页面生成SEO数据""" category_name = context.get('tag_name') if not category_name: return None - + + # 根据分类名称获取分类对象 category = Category.objects.filter(name=category_name).first() if not category: return None + # 生成分类页面SEO数据 title = f"{category.name} | {blog_setting.site_name}" description = strip_tags(category.name) or blog_setting.site_description keywords = category.name - # BreadcrumbList structured data for category page - breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}] - breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}) - + # 生成面包屑导航的结构化数据 + breadcrumb_items = [ + {"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}] + breadcrumb_items.append( + {"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}) + structured_data = { "@context": "https://schema.org", - "@type": "BreadcrumbList", + "@type": "BreadcrumbList", # 面包屑列表类型 "itemListElement": breadcrumb_items } @@ -93,14 +109,15 @@ class SeoOptimizerPlugin(BasePlugin): } def _get_default_seo_data(self, context, request, blog_setting): - # Homepage and other default pages + """为首页和其他默认页面生成SEO数据""" + # 生成网站级别的结构化数据 structured_data = { "@context": "https://schema.org", "@type": "WebSite", "name": blog_setting.site_name, "description": blog_setting.site_description, "url": request.build_absolute_uri('/'), - "potentialAction": { + "potentialAction": { # 潜在操作(搜索功能) "@type": "SearchAction", "target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}", "query-input": "required name=search_term_string" @@ -115,24 +132,30 @@ class SeoOptimizerPlugin(BasePlugin): } def dispatch_seo_generation(self, metas, context): + """分发SEO生成的主方法,根据当前页面类型调用相应的SEO生成函数""" request = context.get('request') if not request: return metas + # 获取当前视图名称 view_name = request.resolver_match.view_name - blog_setting = get_blog_setting() - + blog_setting = get_blog_setting() # 获取博客设置 + seo_data = None - if view_name == 'blog:detailbyid': + # 根据视图名称选择相应的SEO生成方法 + if view_name == 'blog:detailbyid': # 文章详情页 seo_data = self._get_article_seo_data(context, request, blog_setting) - elif view_name == 'blog:category_detail': + elif view_name == 'blog:category_detail': # 分类详情页 seo_data = self._get_category_seo_data(context, request, blog_setting) - + + # 如果没有匹配的页面类型,使用默认SEO数据 if not seo_data: - seo_data = self._get_default_seo_data(context, request, blog_setting) + seo_data = self._get_default_seo_data(context, request, blog_setting) + # 将JSON-LD数据转换为脚本标签 json_ld_script = f'' + # 组合所有SEO标签 seo_html = f""" {seo_data.get("title", "")} @@ -140,8 +163,10 @@ class SeoOptimizerPlugin(BasePlugin): {seo_data.get("meta_tags", "")} {json_ld_script} """ - + # 将SEO内容追加到现有的metas内容上 return metas + seo_html -plugin = SeoOptimizerPlugin() + +# 实例化插件,自动注册钩子 +plugin = SeoOptimizerPlugin() \ No newline at end of file diff --git a/src/DjangoBlog/plugins/view_count/plugin.py b/src/DjangoBlog/plugins/view_count/plugin.py index 15e9d94..3a0a225 100644 --- a/src/DjangoBlog/plugins/view_count/plugin.py +++ b/src/DjangoBlog/plugins/view_count/plugin.py @@ -1,18 +1,30 @@ +# 导入插件管理的基础类和钩子相关模块 from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage import hooks class ViewCountPlugin(BasePlugin): - PLUGIN_NAME = '文章浏览次数统计' - PLUGIN_DESCRIPTION = '统计文章的浏览次数' - PLUGIN_VERSION = '0.1.0' - PLUGIN_AUTHOR = 'liangliangyy' + # 插件基本信息配置 + PLUGIN_NAME = '文章浏览次数统计' # 插件显示名称 + PLUGIN_DESCRIPTION = '统计文章的浏览次数' # 插件功能描述 + PLUGIN_VERSION = '0.1.0' # 插件版本号 + PLUGIN_AUTHOR = 'liangliangyy' # 插件作者 def register_hooks(self): + # 注册插件到文章内容获取后的钩子 + # 当文章内容被获取后,自动触发浏览次数统计 hooks.register('after_article_body_get', self.record_view) def record_view(self, article, *args, **kwargs): + """ + 记录文章浏览次数 + 这个方法会在文章内容被获取后调用,每次调用都会增加文章的浏览次数 + """ + # 调用文章的viewed()方法来增加浏览次数计数 + # 假设Article模型有一个viewed()方法用于处理浏览次数的逻辑 article.viewed() -plugin = ViewCountPlugin() \ No newline at end of file +# 实例化插件,自动注册钩子 +# 当插件被加载时,会自动调用register_hooks方法注册到指定的钩子 +plugin = ViewCountPlugin() \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/MemcacheStorage.py b/src/DjangoBlog/servermanager/MemcacheStorage.py index 38a7990..2af6720 100644 --- a/src/DjangoBlog/servermanager/MemcacheStorage.py +++ b/src/DjangoBlog/servermanager/MemcacheStorage.py @@ -1,32 +1,63 @@ +# 导入WeRoBot会话存储基类和工具函数 from werobot.session import SessionStorage from werobot.utils import json_loads, json_dumps +# 导入Django博客的缓存工具 from djangoblog.utils import cache +# 自定义Memcache会话存储类,继承自WeRoBot的SessionStorage class MemcacheStorage(SessionStorage): def __init__(self, prefix='ws_'): + # 初始化缓存键前缀,默认为'ws_' self.prefix = prefix + # 使用Django博客的缓存实例 self.cache = cache @property def is_available(self): + """ + 检查缓存是否可用的属性 + 通过设置和获取测试值来验证缓存功能是否正常 + """ value = "1" + # 设置测试值到缓存 self.set('checkavaliable', value=value) + # 从缓存获取测试值并比较,返回缓存是否可用 return value == self.get('checkavaliable') def key_name(self, s): + """ + 生成完整的缓存键名 + :param s: 原始键名 + :return: 添加前缀后的完整键名 + """ return '{prefix}{s}'.format(prefix=self.prefix, s=s) def get(self, id): - id = self.key_name(id) - session_json = self.cache.get(id) or '{}' - return json_loads(session_json) + """ + 从缓存获取会话数据 + :param id: 会话ID + :return: 解析后的JSON数据,如果不存在返回空字典 + """ + id = self.key_name(id) # 生成完整键名 + session_json = self.cache.get(id) or '{}' # 从缓存获取数据,不存在则返回'{}' + return json_loads(session_json) # 将JSON字符串解析为Python对象 def set(self, id, value): - id = self.key_name(id) + """ + 设置会话数据到缓存 + :param id: 会话ID + :param value: 要存储的值 + """ + id = self.key_name(id) # 生成完整键名 + # 将值转换为JSON字符串并存储到缓存 self.cache.set(id, json_dumps(value)) def delete(self, id): - id = self.key_name(id) - self.cache.delete(id) + """ + 从缓存删除会话数据 + :param id: 会话ID + """ + id = self.key_name(id) # 生成完整键名 + self.cache.delete(id) # 从缓存删除指定键的数据 \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/admin.py b/src/DjangoBlog/servermanager/admin.py index f26f4f6..17144c5 100644 --- a/src/DjangoBlog/servermanager/admin.py +++ b/src/DjangoBlog/servermanager/admin.py @@ -1,13 +1,18 @@ +# 导入Django管理模块 from django.contrib import admin # Register your models here. - +# 定义Commands模型的管理界面配置 class CommandsAdmin(admin.ModelAdmin): + # 设置在管理列表页面显示的字段 list_display = ('title', 'command', 'describe') +# 定义EmailSendLog模型的管理界面配置 class EmailSendLogAdmin(admin.ModelAdmin): + # 设置在管理列表页面显示的字段 list_display = ('title', 'emailto', 'send_result', 'creation_time') + # 设置只读字段,这些字段在编辑页面不可修改 readonly_fields = ( 'title', 'emailto', @@ -15,5 +20,6 @@ class EmailSendLogAdmin(admin.ModelAdmin): 'creation_time', 'content') + # 重写添加权限方法,禁止在管理界面添加新的邮件发送日志 def has_add_permission(self, request): - return False + return False \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/api/blogapi.py b/src/DjangoBlog/servermanager/api/blogapi.py index 8a4d6ac..bd5b660 100644 --- a/src/DjangoBlog/servermanager/api/blogapi.py +++ b/src/DjangoBlog/servermanager/api/blogapi.py @@ -1,3 +1,4 @@ +# 导入Haystack搜索查询集和相关模型 from haystack.query import SearchQuerySet from blog.models import Article, Category @@ -5,23 +6,33 @@ from blog.models import Article, Category class BlogApi: def __init__(self): + # 初始化搜索查询集 self.searchqueryset = SearchQuerySet() + # 执行空查询来初始化查询集 self.searchqueryset.auto_query('') + # 设置最大返回结果数量 self.__max_takecount__ = 8 def search_articles(self, query): + # 使用Haystack搜索查询集进行文章搜索 sqs = self.searchqueryset.auto_query(query) + # 加载所有相关对象(避免N+1查询问题) sqs = sqs.load_all() + # 返回前N个搜索结果,N为最大返回数量 return sqs[:self.__max_takecount__] def get_category_lists(self): + # 获取所有文章分类 return Category.objects.all() def get_category_articles(self, categoryname): + # 根据分类名称获取该分类下的文章 articles = Article.objects.filter(category__name=categoryname) if articles: + # 如果存在文章,返回前N篇文章 return articles[:self.__max_takecount__] - return None + return None # 如果没有文章返回None def get_recent_articles(self): - return Article.objects.all()[:self.__max_takecount__] + # 获取最近的文章(按创建时间排序) + return Article.objects.all()[:self.__max_takecount__] \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/api/commonapi.py b/src/DjangoBlog/servermanager/api/commonapi.py index 83ad9ff..91da936 100644 --- a/src/DjangoBlog/servermanager/api/commonapi.py +++ b/src/DjangoBlog/servermanager/api/commonapi.py @@ -5,28 +5,44 @@ import openai from servermanager.models import commands +# 获取当前模块的日志记录器 logger = logging.getLogger(__name__) +# 设置OpenAI API密钥,从环境变量中获取 openai.api_key = os.environ.get('OPENAI_API_KEY') +# 如果设置了HTTP代理,配置OpenAI使用代理 if os.environ.get('HTTP_PROXY'): openai.proxy = os.environ.get('HTTP_PROXY') class ChatGPT: + """ChatGPT API封装类""" @staticmethod def chat(prompt): + """ + 与ChatGPT进行对话 + :param prompt: 用户输入的提示词 + :return: ChatGPT的回复内容 + """ try: - completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", - messages=[{"role": "user", "content": prompt}]) + # 调用OpenAI ChatCompletion API + completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", # 使用gpt-3.5-turbo模型 + messages=[{"role": "user", "content": prompt}]) # 构建对话消息 + # 返回第一个选择的消息内容 return completion.choices[0].message.content except Exception as e: + # 记录错误日志 logger.error(e) + # 返回友好的错误信息 return "服务器出错了" class CommandHandler: + """命令处理器类""" + def __init__(self): + # 从数据库获取所有命令 self.commands = commands.objects.all() def run(self, title): @@ -35,30 +51,49 @@ class CommandHandler: :param title: 命令 :return: 返回命令执行结果 """ + # 过滤匹配标题的命令(不区分大小写) cmd = list( filter( - lambda x: x.title.upper() == title.upper(), + lambda x: x.title.upper() == title.upper(), # 转换为大写进行比较 self.commands)) if cmd: + # 执行找到的命令 return self.__run_command__(cmd[0].command) else: + # 未找到命令时返回帮助信息 return "未找到相关命令,请输入hepme获得帮助。" def __run_command__(self, cmd): + """ + 内部方法:执行系统命令 + :param cmd: 要执行的命令字符串 + :return: 命令执行结果 + """ try: + # 使用os.popen执行系统命令并读取输出 res = os.popen(cmd).read() return res except BaseException: + # 捕获所有异常,返回错误信息 return '命令执行出错!' def get_help(self): + """ + 获取所有命令的帮助信息 + :return: 格式化的帮助字符串 + """ rsp = '' + # 遍历所有命令,生成帮助信息 for cmd in self.commands: - rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) + rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) # 格式:命令标题:命令描述 return rsp +# 主程序入口,用于测试 if __name__ == '__main__': + # 创建ChatGPT实例 chatbot = ChatGPT() + # 测试提示词 prompt = "写一篇1000字关于AI的论文" - print(chatbot.chat(prompt)) + # 打印ChatGPT的回复 + print(chatbot.chat(prompt)) \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/apps.py b/src/DjangoBlog/servermanager/apps.py index 03cc38d..7a0c178 100644 --- a/src/DjangoBlog/servermanager/apps.py +++ b/src/DjangoBlog/servermanager/apps.py @@ -1,5 +1,8 @@ +# 导入Django应用配置基类 from django.apps import AppConfig +# 定义servermanager应用的配置类 class ServermanagerConfig(AppConfig): - name = 'servermanager' + # 指定应用的Python路径(Django 3.x及以下版本使用) + name = 'servermanager' \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/migrations/0001_initial.py b/src/DjangoBlog/servermanager/migrations/0001_initial.py index bbdbf77..9c3cc29 100644 --- a/src/DjangoBlog/servermanager/migrations/0001_initial.py +++ b/src/DjangoBlog/servermanager/migrations/0001_initial.py @@ -5,41 +5,55 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True + initial = True # 标记为初始迁移 - dependencies = [ + dependencies = [ # 定义迁移依赖,初始迁移通常为空 ] - operations = [ + operations = [ # 迁移操作列表 + # 创建 commands 数据表 migrations.CreateModel( - name='commands', - fields=[ + name='commands', # 数据表名称 + fields=[ # 数据表字段定义 + # 主键字段,自增BigAutoField,Django自动创建 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 命令标题字段,最大长度300字符 ('title', models.CharField(max_length=300, verbose_name='命令标题')), + # 命令字段,存储实际执行的命令,最大长度2000字符 ('command', models.CharField(max_length=2000, verbose_name='命令')), + # 命令描述字段,最大长度300字符 ('describe', models.CharField(max_length=300, verbose_name='命令描述')), + # 创建时间字段,自动设置为对象创建时间 ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + # 最后修改时间字段,自动更新为对象最后修改时间 ('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')), ], - options={ - 'verbose_name': '命令', - 'verbose_name_plural': '命令', + options={ # 数据表选项配置 + 'verbose_name': '命令', # 单数显示名称 + 'verbose_name_plural': '命令', # 复数显示名称 }, ), + # 创建 EmailSendLog 数据表 migrations.CreateModel( - name='EmailSendLog', - fields=[ + name='EmailSendLog', # 数据表名称 + fields=[ # 数据表字段定义 + # 主键字段,自增BigAutoField ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 收件人字段,存储邮件接收者地址,最大长度300字符 ('emailto', models.CharField(max_length=300, verbose_name='收件人')), + # 邮件标题字段,最大长度2000字符 ('title', models.CharField(max_length=2000, verbose_name='邮件标题')), + # 邮件内容字段,文本类型,无长度限制 ('content', models.TextField(verbose_name='邮件内容')), + # 发送结果字段,布尔值,默认False表示发送失败 ('send_result', models.BooleanField(default=False, verbose_name='结果')), + # 创建时间字段,自动设置为日志记录创建时间 ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ], - options={ - 'verbose_name': '邮件发送log', - 'verbose_name_plural': '邮件发送log', - 'ordering': ['-created_time'], + options={ # 数据表选项配置 + 'verbose_name': '邮件发送log', # 单数显示名称 + 'verbose_name_plural': '邮件发送log', # 复数显示名称 + 'ordering': ['-created_time'], # 默认按创建时间降序排列 }, ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/src/DjangoBlog/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py index 4858857..f72d044 100644 --- a/src/DjangoBlog/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py +++ b/src/DjangoBlog/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py @@ -10,23 +10,27 @@ class Migration(migrations.Migration): ] operations = [ + # 修改EmailSendLog模型的元数据选项 migrations.AlterModelOptions( name='emailsendlog', options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'}, ), + # 将Commands模型的created_time字段重命名为creation_time migrations.RenameField( model_name='commands', old_name='created_time', new_name='creation_time', ), + # 将Commands模型的last_mod_time字段重命名为last_modify_time migrations.RenameField( model_name='commands', old_name='last_mod_time', new_name='last_modify_time', ), + # 将EmailSendLog模型的created_time字段重命名为creation_time migrations.RenameField( model_name='emailsendlog', old_name='created_time', new_name='creation_time', ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/models.py b/src/DjangoBlog/servermanager/models.py index 4326c65..5d569c1 100644 --- a/src/DjangoBlog/servermanager/models.py +++ b/src/DjangoBlog/servermanager/models.py @@ -3,31 +3,48 @@ from django.db import models # Create your models here. class commands(models.Model): + # 命令标题字段,CharField类型,最大长度300字符,用于显示命令的标题 title = models.CharField('命令标题', max_length=300) + # 命令字段,CharField类型,最大长度2000字符,存储实际要执行的命令 command = models.CharField('命令', max_length=2000) + # 命令描述字段,CharField类型,最大长度300字符,描述命令的用途 describe = models.CharField('命令描述', max_length=300) + # 创建时间字段,DateTimeField类型,自动设置为对象创建时的时间 creation_time = models.DateTimeField('创建时间', auto_now_add=True) + # 最后修改时间字段,DateTimeField类型,自动更新为对象最后修改的时间 last_modify_time = models.DateTimeField('修改时间', auto_now=True) def __str__(self): + # 定义对象的字符串表示,返回命令标题 return self.title class Meta: + # 在Django admin中显示的单数名称 verbose_name = '命令' + # 在Django admin中显示的复数名称 verbose_name_plural = verbose_name class EmailSendLog(models.Model): + # 收件人字段,CharField类型,最大长度300字符,存储邮件接收者的地址 emailto = models.CharField('收件人', max_length=300) + # 邮件标题字段,CharField类型,最大长度2000字符,存储邮件的主题 title = models.CharField('邮件标题', max_length=2000) + # 邮件内容字段,TextField类型,无长度限制,存储邮件的正文内容 content = models.TextField('邮件内容') + # 发送结果字段,BooleanField类型,默认值为False,表示邮件发送是否成功 send_result = models.BooleanField('结果', default=False) + # 创建时间字段,DateTimeField类型,自动设置为日志记录创建时的时间 creation_time = models.DateTimeField('创建时间', auto_now_add=True) def __str__(self): + # 定义对象的字符串表示,返回邮件标题 return self.title class Meta: + # 在Django admin中显示的单数名称 verbose_name = '邮件发送log' + # 在Django admin中显示的复数名称 verbose_name_plural = verbose_name - ordering = ['-creation_time'] + # 默认按创建时间降序排列,最新的记录显示在最前面 + ordering = ['-creation_time'] \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/robot.py b/src/DjangoBlog/servermanager/robot.py index 7b45736..3f8a47d 100644 --- a/src/DjangoBlog/servermanager/robot.py +++ b/src/DjangoBlog/servermanager/robot.py @@ -13,34 +13,47 @@ from servermanager.api.blogapi import BlogApi from servermanager.api.commonapi import ChatGPT, CommandHandler from .MemcacheStorage import MemcacheStorage +# 初始化WeRoBot实例,设置token和启用会话 robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN') or 'lylinux', enable_session=True) +# 创建Memcache存储实例 memstorage = MemcacheStorage() +# 检查Memcache是否可用,如果可用则使用Memcache存储会话 if memstorage.is_available: robot.config['SESSION_STORAGE'] = memstorage else: + # 如果Memcache不可用,使用文件存储会话,并清理旧的会话文件 if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')): os.remove(os.path.join(settings.BASE_DIR, 'werobot_session')) robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session') +# 初始化博客API、命令处理器 blogapi = BlogApi() cmd_handler = CommandHandler() logger = logging.getLogger(__name__) def convert_to_article_reply(articles, message): + """ + 将文章列表转换为微信文章回复格式 + :param articles: 文章列表 + :param message: 微信消息对象 + :return: 文章回复对象 + """ reply = ArticlesReply(message=message) from blog.templatetags.blog_tags import truncatechars_content for post in articles: + # 从文章内容中提取图片URL imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body) imgurl = '' if imgs: imgurl = imgs[0] + # 创建文章对象 article = Article( title=post.title, - description=truncatechars_content(post.body), - img=imgurl, - url=post.get_full_url() + description=truncatechars_content(post.body), # 截取文章内容作为描述 + img=imgurl, # 文章封面图 + url=post.get_full_url() # 文章完整URL ) reply.add_article(article) return reply @@ -48,8 +61,9 @@ def convert_to_article_reply(articles, message): @robot.filter(re.compile(r"^\?.*")) def search(message, session): + """搜索文章处理函数,以?开头的消息触发搜索""" s = message.content - searchstr = str(s).replace('?', '') + searchstr = str(s).replace('?', '') # 移除问号得到搜索关键词 result = blogapi.search_articles(searchstr) if result: articles = list(map(lambda x: x.object, result)) @@ -61,6 +75,7 @@ def search(message, session): @robot.filter(re.compile(r'^category\s*$', re.I)) def category(message, session): + """获取所有文章分类目录""" categorys = blogapi.get_category_lists() content = ','.join(map(lambda x: x.name, categorys)) return '所有文章分类目录:' + content @@ -68,6 +83,7 @@ def category(message, session): @robot.filter(re.compile(r'^recent\s*$', re.I)) def recents(message, session): + """获取最新文章""" articles = blogapi.get_recent_articles() if articles: reply = convert_to_article_reply(articles, message) @@ -78,6 +94,7 @@ def recents(message, session): @robot.filter(re.compile('^help$', re.I)) def help(message, session): + """帮助信息""" return '''欢迎关注! 默认会与图灵机器人聊天~~ 你可以通过下面这些命令来获得信息 @@ -100,56 +117,69 @@ def help(message, session): @robot.filter(re.compile(r'^weather\:.*$', re.I)) def weather(message, session): + """天气查询功能(建设中)""" return "建设中..." @robot.filter(re.compile(r'^idcard\:.*$', re.I)) def idcard(message, session): + """身份证查询功能(建设中)""" return "建设中..." @robot.handler def echo(message, session): + """默认消息处理器,处理所有未匹配特定过滤器的消息""" handler = MessageHandler(message, session) return handler.handler() class MessageHandler: + """消息处理器类,负责处理用户消息和会话状态""" def __init__(self, message, session): userid = message.source self.message = message self.session = session self.userid = userid try: + # 从会话中获取用户信息 info = session[userid] self.userinfo = jsonpickle.decode(info) except Exception as e: + # 如果会话中没有用户信息,创建新的用户信息对象 userinfo = WxUserInfo() self.userinfo = userinfo @property def is_admin(self): + """检查用户是否为管理员""" return self.userinfo.isAdmin @property def is_password_set(self): + """检查管理员密码是否已设置""" return self.userinfo.isPasswordSet def save_session(self): + """保存用户信息到会话""" info = jsonpickle.encode(self.userinfo) self.session[self.userid] = info def handler(self): + """主消息处理方法""" info = self.message.content + # 管理员退出逻辑 if self.userinfo.isAdmin and info.upper() == 'EXIT': self.userinfo = WxUserInfo() self.save_session() return "退出成功" + # 进入管理员模式 if info.upper() == 'ADMIN': self.userinfo.isAdmin = True self.save_session() return "输入管理员密码" + # 管理员密码验证 if self.userinfo.isAdmin and not self.userinfo.isPasswordSet: passwd = settings.WXADMIN if settings.TESTING: @@ -166,6 +196,7 @@ class MessageHandler: self.userinfo.Count += 1 self.save_session() return "验证失败,请重新输入管理员密码:" + # 管理员命令执行 if self.userinfo.isAdmin and self.userinfo.isPasswordSet: if self.userinfo.Command != '' and info.upper() == 'Y': return cmd_handler.run(self.userinfo.Command) @@ -176,12 +207,14 @@ class MessageHandler: self.save_session() return "确认执行: " + info + " 命令?" + # 默认使用ChatGPT回复 return ChatGPT.chat(info) class WxUserInfo(): + """微信用户信息类,存储用户状态""" def __init__(self): - self.isAdmin = False - self.isPasswordSet = False - self.Count = 0 - self.Command = '' + self.isAdmin = False # 是否为管理员 + self.isPasswordSet = False # 密码是否已验证 + self.Count = 0 # 密码尝试次数 + self.Command = '' # 待执行的命令 \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/tests.py b/src/DjangoBlog/servermanager/tests.py index 22a6689..1f71696 100644 --- a/src/DjangoBlog/servermanager/tests.py +++ b/src/DjangoBlog/servermanager/tests.py @@ -13,67 +13,86 @@ from .robot import search, category, recents # Create your tests here. class ServerManagerTest(TestCase): def setUp(self): + # 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() def test_chat_gpt(self): + # 测试ChatGPT功能是否正常工作 content = ChatGPT.chat("你好") - self.assertIsNotNone(content) + self.assertIsNotNone(content) # 验证返回内容不为空 def test_validate_comment(self): + # 创建超级用户用于测试 user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") + # 登录用户 self.client.login(username='liangliangyy1', password='liangliangyy1') + # 创建分类 c = Category() c.name = "categoryccc" c.save() + # 创建文章 article = Article() article.title = "nicetitleccc" article.body = "nicecontentccc" article.author = user article.category = c - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() - s = TextMessage([]) + + # 测试搜索功能 + s = TextMessage([]) # 创建微信文本消息对象 s.content = "nice" - rsp = search(s, None) + rsp = search(s, None) # 调用搜索函数 + + # 测试分类功能 rsp = category(None, None) - self.assertIsNotNone(rsp) + self.assertIsNotNone(rsp) # 验证返回结果不为空 + + # 测试最近文章功能 rsp = recents(None, None) - self.assertTrue(rsp != '暂时还没有文章') + self.assertTrue(rsp != '暂时还没有文章') # 验证有文章返回 + # 测试命令功能 cmd = commands() cmd.title = "test" - cmd.command = "ls" + cmd.command = "ls" # Linux列表命令 cmd.describe = "test" cmd.save() + # 测试命令处理器 cmdhandler = CommandHandler() rsp = cmdhandler.run('test') - self.assertIsNotNone(rsp) - s.source = 'u' + self.assertIsNotNone(rsp) # 验证命令执行结果不为空 + + # 测试消息处理器 + s.source = 'u' # 设置消息来源 s.content = 'test' - msghandler = MessageHandler(s, {}) + msghandler = MessageHandler(s, {}) # 创建消息处理器实例 + # 注释掉的管理员测试代码 # msghandler.userinfo.isPasswordSet = True # msghandler.userinfo.isAdmin = True - msghandler.handler() + + # 测试各种消息处理场景 + msghandler.handler() # 处理'test'消息 s.content = 'y' - msghandler.handler() + msghandler.handler() # 处理确认消息 s.content = 'idcard:12321233' - msghandler.handler() + msghandler.handler() # 处理身份证查询消息 s.content = 'weather:上海' - msghandler.handler() + msghandler.handler() # 处理天气查询消息 s.content = 'admin' - msghandler.handler() + msghandler.handler() # 处理进入管理员模式消息 s.content = '123' - msghandler.handler() + msghandler.handler() # 处理密码输入消息 s.content = 'exit' - msghandler.handler() + msghandler.handler() # 处理退出管理员模式消息 \ No newline at end of file diff --git a/src/DjangoBlog/servermanager/urls.py b/src/DjangoBlog/servermanager/urls.py index 8d134d2..00f2067 100644 --- a/src/DjangoBlog/servermanager/urls.py +++ b/src/DjangoBlog/servermanager/urls.py @@ -1,10 +1,16 @@ +# 导入Django URL路由相关模块 from django.urls import path +# 导入WeRoBot的Django视图创建函数 from werobot.contrib.django import make_view +# 导入自定义的机器人实例 from .robot import robot +# 定义应用命名空间,用于URL反向解析 app_name = "servermanager" +# 定义URL模式列表 urlpatterns = [ + # 将微信机器人处理程序映射到/robot路径 + # make_view(robot) 将WeRoBot实例转换为Django视图函数 path(r'robot', make_view(robot)), - -] +] \ No newline at end of file