diff --git a/doc/DjangoBlog开源代码的泛读报告.docx b/doc/DjangoBlog开源代码的泛读报告.docx new file mode 100644 index 0000000..f2d777a Binary files /dev/null and b/doc/DjangoBlog开源代码的泛读报告.docx differ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..66d588a --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,19 @@ +# 当该包(通常是Django应用)被导入时,打印初始化信息 +# 主要用于开发调试,确认包的加载时机和状态 +print("__init__.py is running") + +# 定义Django应用的默认配置类 +# Django在启动时会根据此配置加载应用,包括应用名称、信号注册、初始化逻辑等 +# 'djangoblog.apps.DjangoblogAppConfig'表示配置类的完整路径: +# - djangoblog:应用所在的包名 +# - apps:存放配置类的模块名 +# - DjangoblogAppConfig:具体的配置类(继承自django.apps.AppConfig) +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' + +# 导入pymysql库(Python连接MySQL的第三方库) +import pymysql + +# 将pymysql伪装成MySQLdb库 +# 背景:早期Django默认使用MySQLdb库连接MySQL,但MySQLdb不支持Python3 +# 作用:让Django在使用`import MySQLdb`时实际导入pymysql,实现Python3环境下的MySQL连接兼容 +pymysql.install_as_MySQLdb() \ No newline at end of file diff --git a/src/admin.py b/src/admin.py new file mode 100644 index 0000000..5c49817 --- /dev/null +++ b/src/admin.py @@ -0,0 +1,261 @@ +<<<<<<< HEAD +<<<<<<< HEAD +from django import forms +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.forms import UsernameField +from django.utils.translation import gettext_lazy as _ + +# Register your models here. +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + + class Meta: + model = BlogUser + fields = ('email',) + + def clean_password2(self): + # Check that the two password entries match + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("passwords do not match")) + return password2 + + def save(self, commit=True): + # Save the provided password in hashed format + user = super().save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.source = 'adminsite' + user.save() + return user + + +class BlogUserChangeForm(UserChangeForm): + class Meta: + model = BlogUser + fields = '__all__' + field_classes = {'username': UsernameField} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class BlogUserAdmin(UserAdmin): + form = BlogUserChangeForm + add_form = BlogUserCreationForm + list_display = ( + 'id', + 'nickname', + 'username', + 'email', + 'last_login', + 'date_joined', + 'source') + list_display_links = ('id', 'username') + ordering = ('-id',) +======= +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html # 用于安全地生成HTML内容 +from django.utils.translation import gettext_lazy as _ # 用于国际化翻译 + + +# 自定义批量操作:禁用评论状态 +def disable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=False) # 将选中的评论记录is_enable字段设为False + + +# 自定义批量操作:启用评论状态 +def enable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=True) # 将选中的评论记录is_enable字段设为True + + +# 为批量操作设置显示名称(支持国际化) +disable_commentstatus.short_description = _('Disable comments') +enable_commentstatus.short_description = _('Enable comments') + + +class CommentAdmin(admin.ModelAdmin): + list_per_page = 20 # 每页显示20条记录 + + list_display = ( + 'id', + 'body', # 评论内容 + 'link_to_userinfo', # 自定义字段:链接到用户信息 + 'link_to_article', # 自定义字段:链接到文章 + 'is_enable', # 是否启用 + 'creation_time' # 创建时间 + ) + # 列表页中可点击跳转编辑页的字段 + list_display_links = ('id', 'body', 'is_enable') + # 可筛选的字段(右侧过滤器) + list_filter = ('is_enable',) + # 编辑页排除的字段(不允许编辑,如自动生成的时间) + exclude = ('creation_time', 'last_modify_time') + # 注册自定义批量操作 + actions = [disable_commentstatus, enable_commentstatus] + + # 自定义列表字段:生成用户信息的编辑链接 + def link_to_userinfo(self, obj): + # 获取用户模型的app标签和模型名称 + 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标签和模型名称 + 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') +>>>>>>> zh_branch +======= +from django import forms +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +# Register your models here. +from .models import Article + + +class ArticleForm(forms.ModelForm): + # body = forms.CharField(widget=AdminPagedownWidget()) + + class Meta: + model = Article + fields = '__all__' + + +# 管理员动作函数 - 发布选中文章 +def makr_article_publish(modeladmin, request, queryset): + """将选中的文章状态设置为已发布""" + queryset.update(status='p') + + +# 管理员动作函数 - 将选中文章设为草稿 +def draft_article(modeladmin, request, queryset): + """将选中的文章状态设置为草稿""" + queryset.update(status='d') + + +# 管理员动作函数 - 关闭文章评论 +def close_article_commentstatus(modeladmin, request, queryset): + """关闭选中文章的评论功能""" + queryset.update(comment_status='c') + + +# 管理员动作函数 - 开启文章评论 +def open_article_commentstatus(modeladmin, request, queryset): + """开启选中文章的评论功能""" + queryset.update(comment_status='o') + + +# 设置管理员动作的显示名称 +makr_article_publish.short_description = _('Publish selected articles') +draft_article.short_description = _('Draft selected articles') +close_article_commentstatus.short_description = _('Close article comments') +open_article_commentstatus.short_description = _('Open article comments') + + +class ArticlelAdmin(admin.ModelAdmin): + """文章模型的后台管理配置""" + list_per_page = 20 # 每页显示20条记录 + search_fields = ('body', 'title') # 搜索字段 + form = ArticleForm # 使用自定义表单 + list_display = ( + 'id', + 'title', + 'author', + 'link_to_category', + 'creation_time', + 'views', + 'status', + 'type', + 'article_order') # 列表页显示的字段 + list_display_links = ('id', 'title') # 可点击链接的字段 + list_filter = ('status', 'type', 'category') # 右侧过滤器 + filter_horizontal = ('tags',) # 水平多选控件用于标签 + exclude = ('creation_time', 'last_modify_time') # 排除的字段 + view_on_site = True # 启用"在站点查看"功能 + actions = [ # 管理员动作列表 + makr_article_publish, + draft_article, + close_article_commentstatus, + open_article_commentstatus] + + def link_to_category(self, obj): + """生成分类的管理后台链接""" + info = (obj.category._meta.app_label, obj.category._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) + return format_html(u'%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) + + def get_view_on_site_url(self, obj=None): + """获取"在站点查看"的URL""" + if obj: + url = obj.get_full_url() # 文章的完整URL + return url + else: + from djangoblog.utils import get_current_site + site = get_current_site().domain # 站点域名 + return site + + +class TagAdmin(admin.ModelAdmin): + """标签模型的后台管理配置""" + exclude = ('slug', 'last_mod_time', 'creation_time') # 排除自动生成的字段 + + +class CategoryAdmin(admin.ModelAdmin): + """分类模型的后台管理配置""" + list_display = ('name', 'parent_category', 'index') # 列表显示字段 + exclude = ('slug', 'last_mod_time', 'creation_time') # 排除自动生成的字段 + + +class LinksAdmin(admin.ModelAdmin): + """友情链接模型的后台管理配置""" + exclude = ('last_mod_time', 'creation_time') # 排除时间字段 + + +class SideBarAdmin(admin.ModelAdmin): + """侧边栏模型的后台管理配置""" + list_display = ('name', 'content', 'is_enable', 'sequence') # 列表显示字段 + exclude = ('last_mod_time', 'creation_time') # 排除时间字段 + + +class BlogSettingsAdmin(admin.ModelAdmin): + """博客设置模型的后台管理配置""" + pass # 使用默认管理配置 +>>>>>>> hyt_branch diff --git a/src/admin_site.py b/src/admin_site.py new file mode 100644 index 0000000..6cc3f4d --- /dev/null +++ b/src/admin_site.py @@ -0,0 +1,103 @@ +# 导入Django Admin相关核心组件 +from django.contrib.admin import AdminSite # Django Admin站点基类 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.sites.admin import SiteAdmin # 站点管理的默认Admin配置 +from django.contrib.sites.models import Site # Django内置的站点模型(用于多站点管理) + +# 导入各应用的Admin配置和数据模型 +from accounts.admin import * # 账户相关的Admin配置 +from blog.admin import * # 博客核心功能(文章、分类等)的Admin配置 +from blog.models import * # 博客核心数据模型 +from comments.admin import * # 评论功能的Admin配置 +from comments.models import * # 评论相关数据模型 +from djangoblog.logentryadmin import LogEntryAdmin # 自定义的操作日志Admin配置 +from oauth.admin import * # 第三方登录(OAuth)的Admin配置 +from oauth.models import * # OAuth相关数据模型 +from owntracks.admin import * # 位置追踪(OwnTracks)的Admin配置 +from owntracks.models import *# OwnTracks相关数据模型 +from servermanager.admin import * # 服务器管理的Admin配置 +from servermanager.models import * # 服务器管理相关数据模型 + + +class DjangoBlogAdminSite(AdminSite): + """ + 自定义的Django Admin站点类,继承自Django内置的AdminSite + + 作用:通过重写基类属性和方法,定制Admin后台的外观和权限控制 + """ + # 定制Admin站点的页面头部标题(显示在登录页和后台顶部导航栏) + site_header = 'djangoblog administration' + # 定制Admin站点的页面标题(显示在浏览器标签页) + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + """ + 初始化自定义Admin站点 + + :param name: 站点名称,默认'admin'(与Django默认Admin站点名称保持一致,避免路由冲突) + """ + super().__init__(name) # 调用父类构造方法初始化 + + def has_permission(self, request): + """ + 重写权限检查方法,控制谁能访问Admin后台 + + :param request: HTTP请求对象,包含当前用户信息 + :return: 布尔值,True表示允许访问,False表示拒绝访问 + 此处限制仅超级用户(is_superuser)可访问,比默认的is_staff更严格 + """ + return request.user.is_superuser + + # 以下为注释掉的自定义URL示例(可根据需求启用) + # def get_urls(self): + # """ + # 扩展Admin站点的URL路由,添加自定义功能入口 + # """ + # # 先获取父类默认的URL配置 + # urls = super().get_urls() + # # 导入URL路径处理和自定义视图 + # from django.urls import path + # from blog.views import refresh_memcache # 示例:缓存刷新视图 + # + # # 定义自定义URL规则,使用admin_view()包装确保权限检查 + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # # 合并默认URL和自定义URL(自定义URL优先) + # return urls + my_urls + + +# 实例化自定义的Admin站点,名称为'admin'(与Django默认Admin站点名称一致,接管后台) +admin_site = DjangoBlogAdminSite(name='admin') + +# 注册数据模型与对应的Admin配置到自定义Admin站点 +# 博客核心内容 +admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其Admin配置 +admin_site.register(Category, CategoryAdmin) # 分类模型 + 其Admin配置 +admin_site.register(Tag, TagAdmin) # 标签模型 + 其Admin配置 +admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其Admin配置 +admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其Admin配置 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其Admin配置 + +# 服务器管理 +admin_site.register(commands, CommandsAdmin) # 命令模型 + 其Admin配置 +admin_site.register(EmailSendLog, EmailSendLogAdmin)# 邮件发送日志模型 + 其Admin配置 + +# 账户管理 +admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其Admin配置 + +# 评论管理 +admin_site.register(Comment, CommentAdmin) # 评论模型 + 其Admin配置 + +# OAuth第三方登录 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其Admin配置 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + 其Admin配置 + +# 位置追踪 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + 其Admin配置 + +# 站点管理(Django内置) +admin_site.register(Site, SiteAdmin) # 站点模型 + Django默认的SiteAdmin配置 + +# 操作日志管理 +admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 + 自定义Admin配置 \ No newline at end of file diff --git a/src/apps.py b/src/apps.py new file mode 100644 index 0000000..45344d7 --- /dev/null +++ b/src/apps.py @@ -0,0 +1,76 @@ +<<<<<<< HEAD +<<<<<<< HEAD +<<<<<<< HEAD +# 导入Django的AppConfig类,用于配置Django应用的生命周期和元数据 +from django.apps import AppConfig + + +class DjangoblogAppConfig(AppConfig): + """ + Django博客应用(djangoblog)的配置类,用于定义应用的核心配置和生命周期钩子 + + 作用: + 1. 配置应用的数据库主键生成规则 + 2. 标识应用的唯一名称 + 3. 定义应用就绪后的初始化逻辑(如加载插件) + """ + # 配置Django模型默认的自增主键字段类型 + # BigAutoField是64位整数型自增字段,支持更大的主键范围(适用于数据量较大的博客) + # 替代默认的AutoField(32位整数),避免数据量增长后主键溢出问题 + default_auto_field = 'django.db.models.BigAutoField' + + # 应用的唯一名称,必须与项目中应用的目录名一致(此处为'djangoblog') + # Django通过该名称识别应用,用于注册路由、加载模型等核心操作 + name = 'djangoblog' + + def ready(self): + """ + Django应用就绪后的钩子方法,在应用完全加载并初始化后自动调用 + + 执行时机: + - 项目启动时(如runserver、celery启动) + - 应用注册表(app registry)完成所有应用加载后 + 注意:该方法可能会被多次调用(如开发环境自动重载时),需确保逻辑可重入 + + 核心功能: + 调用插件加载函数,在应用就绪后初始化所有已激活的插件 + """ + # 调用父类的ready()方法,确保Django默认的应用就绪逻辑正常执行 + super().ready() + + # 导入并执行插件加载函数: + # 1. 从当前应用(djangoblog)的plugin_manage.loader模块中导入load_plugins函数 + # 2. 调用load_plugins()触发插件动态加载(如导入插件模块、初始化插件实例) + # 此处是插件系统与Django应用生命周期的绑定点,确保插件在应用就绪后启动 + from .plugin_manage.loader import load_plugins + load_plugins() + +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' + +======= +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + # 配置应用的名称,对应项目中该应用的目录名) + # Django通过这个名称识别和管理该应用 + name = 'comments' +>>>>>>> zh_branch +======= +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + """博客应用配置类 + + 这个类用于配置Django博客应用的基本信息。 + 它继承自Django的AppConfig基类,用于定义应用的元数据和行为。 + """ + + # 应用的完整Python路径,Django使用这个名称来识别应用 + name = 'blog' +>>>>>>> hyt_branch diff --git a/src/article_info.html b/src/article_info.html index c6f793b..f442dd1 100644 --- a/src/article_info.html +++ b/src/article_info.html @@ -1,79 +1,83 @@ -{% load blog_tags %} // 加载自定义博客相关的自定义模板标签库 -{% load cache %} // 加载缓存功能相关的模板标签库 -{% load i18n %} // 加载国际化相关的模板标签库 +{% load blog_tags %} +{% load cache %} +{% load i18n %} +
// 加载国际化相关的模板标签库 + class="post-{{ article.pk }} post type-post status-publish format-standard hentry"> +
-
// 文章头部区域,包含标题等信息 - - -

// 文章标题元素 - {% if isindex %} // 判断当前是否为索引页(文章列表页) - {% if article.article_order > 0 %} // 若为索引页,判断文章是否有置顶顺序(大于0表示置顶) +

+ {% if isindex %} + {% if article.article_order > 0 %} + 【{% trans 'pin to top' %}】{{ article.title }}// 显示带"置顶"标识的文章标题 + rel="bookmark">【{% trans 'pin to top' %}】{{ article.title }} {% else %} + {{ article.title }}// 显示普通文章标题 + rel="bookmark">{{ article.title }} {% endif %} - {% else %} + {{ article.title }} {% endif %}

-
-
- {% if isindex %} - {{ article.body|custom_markdown|escape|truncatechars_content }}// 显示经过markdown处理、转义并截断的文章内容 - +
+ {% if isindex %} + + {{ article.body|custom_markdown|escape|truncatechars_content }} +

Read more

- {% else %} - - {% if article.show_toc %}// 检查文章是否设置显示目录 - {% get_markdown_toc article.body as toc %}// 调用自定义标签获取文章内容的markdown目录,并赋值给toc变量 - {% trans 'toc' %}: - {{ toc|safe }}// 安全地显示目录内容(允许HTML渲染) - - -
+ {% else %} + {% if article.show_toc %} + + {% get_markdown_toc article.body as toc %} + {% trans 'toc' %}: + {{ toc|safe }} +
{% endif %}
- - {{ article.body|custom_markdown|escape }}// 显示经过markdown处理和转义的完整文章内容 - + + {{ article.body|custom_markdown|escape }}
{% endif %} -
- {% load_article_metas article user %}// 调用自定义标签加载文章的元数据(如作者、发布时间等),传入文章对象和用户对象 + + {% load_article_metas article user %}
\ No newline at end of file diff --git a/src/article_meta_info.html b/src/article_meta_info.html index 0fc1df1..fb6d147 100644 --- a/src/article_meta_info.html +++ b/src/article_meta_info.html @@ -2,57 +2,62 @@ {% load blog_tags %} - \ No newline at end of file diff --git a/src/article_pagination.html b/src/article_pagination.html index ab821b8..d406df7 100644 --- a/src/article_pagination.html +++ b/src/article_pagination.html @@ -1,21 +1,28 @@ {% load i18n %} - \ No newline at end of file diff --git a/src/article_tag_list.html b/src/article_tag_list.html index 6f0ac5e..98b0a7a 100644 --- a/src/article_tag_list.html +++ b/src/article_tag_list.html @@ -1,33 +1,20 @@ -{% load i18n %} - - -{% if article_tags_list %} - -
- -
- {% trans 'tags' %} +{% load i18n %} +{% if article_tags_list %} +
+
+ {% trans 'tags' %}
+
- -
- - {% for url, count, tag, color in article_tags_list %} - - + {% for url,count,tag,color in article_tags_list %} + + {{ tag.name }} - {{ count }} + {{ count }} - {% endfor %} -
-
-{% endif %} \ No newline at end of file + {% endfor %} + +
+
+{% endif %} \ No newline at end of file diff --git a/src/base_plugin.py b/src/base_plugin.py new file mode 100644 index 0000000..f7f22c2 --- /dev/null +++ b/src/base_plugin.py @@ -0,0 +1,95 @@ +# 导入Python标准库中的logging模块,用于实现插件运行过程中的日志记录功能 +# 日志可以帮助开发者追踪插件的运行状态、排查错误等 +import logging + +# 创建一个日志记录器实例,其名称与当前模块(__name__)绑定 +# 这样可以确保日志信息能够准确关联到插件模块,便于日志的分类和筛选 +logger = logging.getLogger(__name__) + + +class BasePlugin: + """ + 插件系统的基类(抽象基类角色),所有自定义插件都必须继承此类 + + 该类的核心作用是: + 1. 定义插件必须包含的元数据规范(名称、描述、版本) + 2. 提供插件初始化和钩子注册的统一流程 + 3. 封装获取插件信息的通用方法 + 子类通过继承此类并实现特定方法,即可快速接入插件系统 + """ + + # 插件元数据字段(子类必须显式赋值,否则初始化会失败) + PLUGIN_NAME = None # 插件的唯一标识名称,用于在系统中区分不同插件,例如"DataCleanPlugin" + PLUGIN_DESCRIPTION = None # 插件功能的详细描述,说明插件的作用和使用场景,例如"用于清洗CSV格式的原始数据" + PLUGIN_VERSION = None # 插件的版本号,遵循语义化版本规范(如"1.0.0"),用于版本管理和兼容性判断 + + def __init__(self): + """ + 插件实例的构造方法,负责插件的初始化流程控制 + + 执行逻辑: + 1. 首先验证子类是否完整实现了元数据(名称、描述、版本) + 2. 若元数据不完整,抛出ValueError异常阻止实例化 + 3. 元数据验证通过后,依次调用初始化方法和钩子注册方法 + 确保插件在使用前完成必要的准备工作 + """ + # 使用all()函数检查三个元数据字段是否都有值(非None) + # 若存在任何一个未定义的字段,触发异常 + 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): + """ + 插件初始化的具体实现方法,用于执行插件启动前的准备工作 + + 基类默认实现: + - 输出一条INFO级别的日志,提示插件已完成初始化 + 子类可重写此方法实现特定逻辑,例如: + - 加载配置文件 + - 建立数据库连接 + - 初始化缓存数据结构等 + 注意:重写时若需要保留默认日志,可通过super().init_plugin()调用父类方法 + """ + # 记录插件初始化成功的日志,包含插件名称便于追踪 + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 插件钩子注册方法,用于将插件功能与系统事件关联 + + 钩子(Hook)机制说明: + 系统在运行过程中会触发一系列事件(如"数据处理前"、"任务完成后"等), + 插件通过注册钩子,可以在特定事件发生时自动执行对应逻辑。 + + 基类默认实现:空方法(pass) + 子类需根据自身功能重写此方法,例如: + - 调用系统提供的register_hook()方法注册事件回调 + - 定义需要监听的事件类型和对应的处理函数 + """ + pass + + def get_plugin_info(self): + """ + 获取插件元数据的统一接口,用于系统展示或管理插件信息 + + 返回值说明: + - 字典类型,包含三个键值对 + - 'name':对应PLUGIN_NAME + - 'description':对应PLUGIN_DESCRIPTION + - 'version':对应PLUGIN_VERSION + + 应用场景: + - 插件管理界面展示插件列表 + - 系统启动时收集所有插件信息进行校验 + - 插件间依赖关系判断时获取版本信息 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } \ No newline at end of file diff --git a/src/blog_signals.py b/src/blog_signals.py new file mode 100644 index 0000000..48eafa3 --- /dev/null +++ b/src/blog_signals.py @@ -0,0 +1,207 @@ +# 导入必要模块 +import _thread # 用于创建多线程,处理异步任务(如发送邮件) +import logging # 日志记录模块,记录信号处理过程中的关键信息和错误 + +import django.dispatch # Django信号系统,用于定义和发送自定义信号 +from django.conf import settings # 导入Django项目配置 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.auth.signals import user_logged_in, user_logged_out # Django内置的用户登录/登出信号 +from django.core.mail import EmailMultiAlternatives # 用于发送HTML格式的邮件 +from django.db.models.signals import post_save # Django模型保存后的信号 +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 # 获取当前站点信息 +from oauth.models import OAuthUser # OAuth用户模型 + +# 创建当前模块的日志记录器,用于记录信号处理相关日志 +logger = logging.getLogger(__name__) + +# 定义自定义信号:OAuth用户登录信号 +# 触发时机:当用户通过OAuth(第三方登录)成功登录时 +# 参数:['id'] 表示信号会携带OAuthUser的id +oauth_user_login_signal = django.dispatch.Signal(['id']) + +# 定义自定义信号:发送邮件信号 +# 触发时机:需要发送邮件时(解耦邮件发送逻辑,便于多处调用) +# 参数:['emailto', 'title', 'content'] 分别表示收件人、邮件标题、邮件内容 +send_email_signal = django.dispatch.Signal(['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + """ + 发送邮件信号的接收器,处理实际的邮件发送逻辑 + + :param sender: 信号发送者(通常无需关注) + :param kwargs: 信号携带的参数(emailto, title, content) + """ + # 从信号参数中提取邮件信息 + emailto = kwargs['emailto'] # 收件人列表 + title = kwargs['title'] # 邮件标题 + content = kwargs['content'] # 邮件内容(HTML格式) + + # 创建HTML格式邮件对象 + # from_email:发件人(从项目配置中获取) + # to:收件人列表 + msg = EmailMultiAlternatives( + 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) # 将收件人列表转为字符串存储 + + try: + # 发送邮件,返回成功发送的数量 + result = msg.send() + log.send_result = result > 0 # 若成功发送数量>0,标记为发送成功 + except Exception as e: + # 发送失败时记录错误日志 + logger.error(f"失败邮箱号: {emailto}, {e}") + log.send_result = False # 标记为发送失败 + log.save() # 保存日志记录到数据库 + + +@receiver(oauth_user_login_signal) +def oauth_user_login_signal_handler(sender, **kwargs): + """ + OAuth用户登录信号的接收器,处理第三方登录后的后续操作 + + :param sender: 信号发送者 + :param kwargs: 信号携带的参数(id:OAuthUser的id) + """ + id = kwargs['id'] # 获取OAuth用户ID + oauthuser = OAuthUser.objects.get(id=id) # 查询对应的OAuth用户对象 + + # 获取当前站点域名(用于判断头像是否为本站资源) + site = get_current_site().domain + + # 若用户头像存在且不是本站资源(如第三方平台的头像URL),则下载并保存到本地 + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar # 导入保存用户头像的工具函数 + oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并替换头像URL为本地路径 + oauthuser.save() # 保存更新后的用户信息 + + # 清除侧边栏缓存(用户登录状态可能影响侧边栏展示内容) + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + """ + Django模型保存后(post_save)信号的接收器,处理模型保存后的联动操作 + + :param sender: 发送信号的模型类 + :param instance: 被保存的模型实例 + :param created: 是否为新创建的记录(True表示新建,False表示更新) + :param raw: 是否为原始保存(如通过loaddata导入数据时为True) + :param using: 使用的数据库别名 + :param update_fields: 被更新的字段列表(None表示全量更新) + :param kwargs: 其他参数 + """ + clearcache = False # 标记是否需要清除缓存 + + # 若保存的是管理员操作日志(LogEntry),直接返回(无需处理) + if isinstance(instance, LogEntry): + return + + # 若模型实例有get_full_url方法(通常表示是可被搜索引擎收录的内容,如文章) + if 'get_full_url' in dir(instance): + # 判断是否仅更新了浏览量字段(views) + is_update_views = update_fields == {'views'} + + # 非测试环境且不是仅更新浏览量时,通知搜索引擎更新内容 + if not settings.TESTING and not is_update_views: + try: + notify_url = instance.get_full_url() # 获取内容的完整URL + SpiderNotify.baidu_notify([notify_url]) # 向百度搜索引擎推送更新 + except Exception as ex: + logger.error("notify spider", ex) # 推送失败时记录错误日志 + + # 若不是仅更新浏览量,标记需要清除缓存 + if not is_update_views: + clearcache = True + + # 若保存的是评论(Comment)实例 + if isinstance(instance, Comment): + # 仅处理已启用的评论(is_enable=True) + if instance.is_enable: + # 获取评论所属文章的URL路径 + 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处理器缓存(评论可能影响页面SEO信息) + if cache.get('seo_processor'): + cache.delete('seo_processor') + + # 清除该文章的评论缓存 + comment_cache_key = 'article_comments_{id}'.format(id=instance.article.id) + cache.delete(comment_cache_key) + + # 清除侧边栏缓存(侧边栏可能包含最新评论等动态内容) + delete_sidebar_cache() + + # 清除评论列表视图的缓存 + delete_view_cache('article_comments', [str(instance.article.pk)]) + + # 启动新线程异步发送评论通知邮件(避免阻塞主请求) + _thread.start_new_thread(send_comment_email, (instance,)) + + # 若标记需要清除缓存,则执行缓存清除 + if clearcache: + cache.clear() + + +@receiver(user_logged_in) # 注册为用户登录信号的接收器 +@receiver(user_logged_out) # 同时注册为用户登出信号的接收器 +def user_auth_callback(sender, request, user, **kwargs): + """ + 用户登录/登出信号的接收器,处理认证状态变化后的操作 + + :param sender: 信号发送者 + :param request: HTTP请求对象 + :param user: 当前用户对象 + :param kwargs: 其他参数 + """ + # 若用户存在且用户名有效 + if user and user.username: + logger.info(user) # 记录用户登录/登出日志 + delete_sidebar_cache() # 清除侧边栏缓存(登录状态可能影响侧边栏内容,如显示用户名) + # cache.clear() # 注释:可根据需求开启全量缓存清除(通常侧边栏缓存足够) \ No newline at end of file diff --git a/src/breadcrumb.html b/src/breadcrumb.html index cf99ea5..e494701 100644 --- a/src/breadcrumb.html +++ b/src/breadcrumb.html @@ -1,33 +1,25 @@ + + diff --git a/src/context_processors.py b/src/context_processors.py new file mode 100644 index 0000000..252a066 --- /dev/null +++ b/src/context_processors.py @@ -0,0 +1,86 @@ +import logging + +from django.utils import timezone + +from djangoblog.utils import cache, get_blog_setting +from .models import Category, Article + +logger = logging.getLogger(__name__) + + +def seo_processor(requests): + """ + SEO上下文处理器 + + 这个函数是一个Django上下文处理器,用于向所有模板传递SEO相关的变量。 + 它使用缓存来提高性能,避免每次请求都查询数据库。 + + Args: + requests: Django请求对象,包含当前请求的信息 + + Returns: + dict: 包含SEO和网站设置信息的字典,这些变量将在所有模板中可用 + """ + # 缓存键名 + key = 'seo_processor' + + # 尝试从缓存中获取数据 + value = cache.get(key) + if value: + # 如果缓存存在,直接返回缓存数据 + return value + else: + # 缓存不存在,重新生成数据 + logger.info('set processor cache.') + + # 获取博客全局设置 + setting = get_blog_setting() + + # 构建包含所有SEO和网站设置信息的字典 + value = { + # 网站基本信息 + 'SITE_NAME': setting.site_name, # 网站名称 + 'SITE_SEO_DESCRIPTION': setting.site_seo_description, # SEO描述 + 'SITE_DESCRIPTION': setting.site_description, # 网站描述 + 'SITE_KEYWORDS': setting.site_keywords, # 网站关键词 + + # 网站URL相关 + '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, # 是否开启评论 + 'COMMENT_NEED_REVIEW': setting.comment_need_review, # 评论是否需要审核 + + # 备案信息 + 'BEIAN_CODE': setting.beian_code, # ICP备案号 + "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号 + "SHOW_GONGAN_CODE": setting.show_gongan_code, # 是否显示公安备案 + + # 广告相关 + 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # 是否显示Google广告 + 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # Google广告代码 + + # 统计代码 + 'ANALYTICS_CODE': setting.analytics_code, # 网站统计代码(如百度统计) + + # 时间信息 + "CURRENT_YEAR": timezone.now().year, # 当前年份 + + # 全局页头页脚 + "GLOBAL_HEADER": setting.global_header, # 全局头部HTML + "GLOBAL_FOOTER": setting.global_footer, # 全局尾部HTML + } + + # 将数据存入缓存,有效期10小时(60 * 60 * 10秒) + cache.set(key, value, 60 * 60 * 10) + + return value \ No newline at end of file diff --git a/src/documents.py b/src/documents.py new file mode 100644 index 0000000..b9976b1 --- /dev/null +++ b/src/documents.py @@ -0,0 +1,283 @@ +import time + +import elasticsearch.client +from django.conf import settings +from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean +from elasticsearch_dsl.connections import connections + +from 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管道用于IP地理位置解析 + c.put_pipeline('geoip', body='''{ + "description" : "Add geoip info", + "processors" : [ + { + "geoip" : { + "field" : "ip" + } + } + ] + }''') + + +class GeoIp(InnerDoc): + """IP地理位置信息内嵌文档""" + continent_name = Keyword() # 大洲名称 + country_iso_code = Keyword() # 国家ISO代码 + country_name = Keyword() # 国家名称 + location = GeoPoint() # 地理位置坐标 + + +class UserAgentBrowser(InnerDoc): + """用户代理浏览器信息""" + Family = Keyword() # 浏览器家族 + Version = Keyword() # 浏览器版本 + + +class UserAgentOS(UserAgentBrowser): + """用户代理操作系统信息""" + pass # 继承自UserAgentBrowser,具有相同的字段结构 + + +class UserAgentDevice(InnerDoc): + """用户代理设备信息""" + Family = Keyword() # 设备家族 + Brand = Keyword() # 设备品牌 + Model = Keyword() # 设备型号 + + +class UserAgent(InnerDoc): + """完整的用户代理信息""" + browser = Object(UserAgentBrowser, required=False) # 浏览器信息对象 + os = Object(UserAgentOS, required=False) # 操作系统信息对象 + device = Object(UserAgentDevice, required=False) # 设备信息对象 + string = Text() # 原始用户代理字符串 + is_bot = Boolean() # 是否为爬虫/机器人 + + +class ElapsedTimeDocument(Document): + """ + 性能监控文档 - 用于记录请求响应时间等性能数据 + + 这个文档类型用于存储网站性能监控数据,包括: + - 请求URL和响应时间 + - 用户IP和地理位置 + - 用户代理信息 + """ + url = Keyword() # 请求的URL + time_taken = Long() # 请求耗时(毫秒) + log_datetime = Date() # 日志时间 + ip = Keyword() # 用户IP地址 + geoip = Object(GeoIp, required=False) # IP地理位置信息 + useragent = Object(UserAgent, required=False) # 用户代理信息 + + class Index: + """索引配置""" + name = 'performance' # 索引名称 + settings = { + "number_of_shards": 1, # 分片数量 + "number_of_replicas": 0 # 副本数量 + } + + class Meta: + 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']) + es.indices.delete(index='performance', ignore=[400, 404]) # 忽略404错误 + + @staticmethod + def create(url, time_taken, log_datetime, useragent, ip): + """ + 创建性能监控记录 + + Args: + url: 请求URL + time_taken: 请求耗时 + log_datetime: 日志时间 + useragent: 用户代理对象 + ip: 用户IP地址 + """ + ElaspedTimeDocumentManager.build_index() + + # 构建用户代理信息 + ua = UserAgent() + ua.browser = UserAgentBrowser() + ua.browser.Family = useragent.browser.family # 浏览器家族 + ua.browser.Version = useragent.browser.version_string # 浏览器版本 + + ua.os = UserAgentOS() + ua.os.Family = useragent.os.family # 操作系统家族 + ua.os.Version = useragent.os.version_string # 操作系统版本 + + ua.device = UserAgentDevice() + ua.device.Family = useragent.device.family # 设备家族 + ua.device.Brand = useragent.device.brand # 设备品牌 + ua.device.Model = useragent.device.model # 设备型号 + ua.string = useragent.ua_string # 原始UA字符串 + ua.is_bot = useragent.is_bot # 是否为机器人 + + # 创建文档并使用geoip管道处理IP地理位置 + doc = ElapsedTimeDocument( + meta={ + 'id': int(round(time.time() * 1000)) # 使用时间戳作为文档ID + }, + url=url, + time_taken=time_taken, + log_datetime=log_datetime, + useragent=ua, + ip=ip + ) + doc.save(pipeline="geoip") # 使用geoip管道自动添加地理位置信息 + + +class ArticleDocument(Document): + """ + 文章搜索文档 - 用于Elasticsearch全文搜索 + + 这个文档类型定义了文章在Elasticsearch中的索引结构, + 支持对文章标题、内容、作者、分类、标签等进行全文搜索。 + """ + 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() # 作者ID + }) + category = Object(properties={ + '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() # 标签ID + }) + + # 文章元数据字段 + pub_time = Date() # 发布时间 + status = Text() # 文章状态(发布/草稿) + comment_status = Text() # 评论状态(开启/关闭) + type = Text() # 文章类型(文章/页面) + views = Integer() # 浏览次数 + article_order = Integer() # 文章排序 + + class Index: + """索引配置""" + name = 'blog' # 索引名称 + settings = { + "number_of_shards": 1, # 分片数量 + "number_of_replicas": 0 # 副本数量 + } + + class Meta: + doc_type = 'Article' # 文档类型 + + +class ArticleDocumentManager(): + """文章文档管理器 - 负责文章搜索索引的创建、更新和管理""" + + def __init__(self): + self.create_index() + + def create_index(self): + """创建文章搜索索引""" + ArticleDocument.init() + + def delete_index(self): + """删除文章搜索索引""" + from elasticsearch import Elasticsearch + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + es.indices.delete(index='blog', ignore=[400, 404]) # 忽略404错误 + + def convert_to_doc(self, articles): + """ + 将Django文章对象转换为Elasticsearch文档对象 + + Args: + articles: Django文章查询集 + + Returns: + list: Elasticsearch文档对象列表 + """ + return [ + ArticleDocument( + meta={'id': article.id}, # 使用文章ID作为文档ID + body=article.body, + title=article.title, + author={ + 'nickname': article.author.username, + 'id': article.author.id + }, + category={ + 'name': article.category.name, + 'id': article.category.id + }, + tags=[ + {'name': t.name, 'id': t.id} for t in article.tags.all() # 转换标签列表 + ], + pub_time=article.pub_time, + status=article.status, + comment_status=article.comment_status, + type=article.type, + views=article.views, + article_order=article.article_order + ) for article in articles + ] + + def rebuild(self, articles=None): + """ + 重建文章搜索索引 + + Args: + articles: 要索引的文章列表,如果为None则索引所有文章 + """ + ArticleDocument.init() # 重新初始化索引 + articles = articles if articles else Article.objects.all() # 获取所有文章或指定文章 + docs = self.convert_to_doc(articles) # 转换为文档对象 + for doc in docs: + doc.save() # 保存到Elasticsearch + + def update_docs(self, docs): + """ + 更新文档索引 + + Args: + docs: 要更新的文档列表 + """ + for doc in docs: + doc.save() # 保存更新到Elasticsearch diff --git a/src/elasticsearch_backend.py b/src/elasticsearch_backend.py new file mode 100644 index 0000000..23fa880 --- /dev/null +++ b/src/elasticsearch_backend.py @@ -0,0 +1,316 @@ +# 导入必要模块 +from django.utils.encoding import force_str # 用于将数据转换为字符串(兼容Python 2/3) +from elasticsearch_dsl import Q # Elasticsearch DSL的查询构建工具 +from haystack.backends import ( # Haystack搜索框架的基础类 + BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +) +from haystack.forms import ModelSearchForm # Haystack默认的模型搜索表单 +from haystack.models import SearchResult # Haystack的搜索结果封装类 +from haystack.utils import log as logging # Haystack的日志工具 + +# 导入项目内部模块 +from blog.documents import ArticleDocument, ArticleDocumentManager # 文章的Elasticsearch文档定义及管理器 +from blog.models import Article # 博客文章模型 + +# 创建当前模块的日志记录器,用于记录搜索相关日志 +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + """ + 基于Elasticsearch的搜索后端实现,继承自Haystack的BaseSearchBackend + + 作用:实现与Elasticsearch的交互逻辑,包括索引的创建、更新、删除, + 以及搜索查询的执行、拼写建议等功能 + """ + def __init__(self, connection_alias, **connection_options): + """ + 初始化搜索后端 + + :param connection_alias: 数据库连接别名(用于多后端配置) + :param connection_options: 连接参数(如主机、端口等) + """ + super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options) + self.manager = ArticleDocumentManager() # 初始化文章文档管理器(处理索引操作) + self.include_spelling = True # 启用拼写建议功能 + + def _get_models(self, iterable): + """ + 将模型实例列表转换为Elasticsearch文档对象 + + :param iterable: 模型实例列表(如Article对象列表) + :return: 转换后的Elasticsearch文档列表 + """ + # 若输入为空,默认使用所有已发布的文章 + models = iterable if iterable and iterable[0] else Article.objects.all() + # 通过管理器将模型转换为文档 + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + """ + 创建索引并初始化文档(全量重建索引时使用) + + :param models: 模型实例列表 + """ + self.manager.create_index() # 创建Elasticsearch索引(若不存在) + docs = self._get_models(models) # 转换模型为文档 + self.manager.rebuild(docs) # 全量重建索引(清空旧数据后插入新数据) + + def _delete(self, models): + """ + 从索引中删除指定模型对应的文档 + + :param models: 要删除的模型实例列表 + :return: 操作是否成功(始终返回True) + """ + for m in models: + m.delete() # 调用文档的删除方法 + return True + + def _rebuild(self, models): + """ + 增量更新索引(适用于部分数据更新) + + :param models: 需要更新的模型实例列表(若为空则更新所有文章) + """ + models = models if models else Article.objects.all() # 处理空输入 + docs = self._get_models(models) # 转换模型为文档 + self.manager.update_docs(docs) # 增量更新文档 + + def update(self, index, iterable, commit=True): + """ + Haystack标准接口:更新索引(用于实时同步模型变更) + + :param index: 索引名称(当前实现未使用,由管理器处理) + :param iterable: 模型实例列表 + :param commit: 是否立即提交(当前实现未使用) + """ + models = self._get_models(iterable) # 转换模型为文档 + self.manager.update_docs(models) # 执行更新 + + def remove(self, obj_or_string): + """ + Haystack标准接口:从索引中移除指定对象 + + :param obj_or_string: 模型实例或ID字符串 + """ + models = self._get_models([obj_or_string]) # 转换为文档 + self._delete(models) # 执行删除 + + def clear(self, models=None, commit=True): + """ + Haystack标准接口:清空索引(或指定模型的索引) + + :param models: 可选,指定要清空的模型类(当前实现忽略,清空所有) + :param commit: 是否立即提交(当前实现未使用) + """ + self.remove(None) # 调用删除方法清空所有 + + @staticmethod + def get_suggestion(query: str) -> str: + """ + 获取搜索建议词(基于Elasticsearch的拼写纠错功能) + + :param query: 用户输入的搜索词 + :return: 建议的修正词(多个词用空格拼接) + """ + # 构建搜索查询:匹配文章内容,并启用拼写建议 + 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 # Haystack装饰器:记录查询日志 + def search(self, query_string, **kwargs): + """ + 执行搜索查询的核心方法 + + :param query_string: 用户输入的搜索字符串 + :param kwargs: 额外参数(如分页偏移量start_offset/end_offset) + :return: 搜索结果字典(包含结果列表、命中数、拼写建议等) + """ + logger.info('search query_string:' + query_string) # 记录搜索词 + + # 获取分页参数(用于限制返回结果范围) + start_offset = kwargs.get('start_offset', 0) + end_offset = kwargs.get('end_offset', 10) # 默认返回前10条 + + # 判断是否需要启用拼写建议(通过is_suggest参数控制) + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) # 获取建议词 + else: + suggestion = query_string # 不启用建议,使用原搜索词 + + # 构建Elasticsearch查询条件: + # 1. 布尔查询(should):匹配标题或内容,至少满足70%的条件 + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + # 构建完整搜索: + # - 应用上述查询条件 + # - 过滤:仅包含已发布(status='p')的文章(type='a') + # - 不返回原始文档内容(source=False) + # - 应用分页偏移 + search = ArticleDocument.search() \ + .query('bool', filter=[q]) \ + .filter('term', status='p') \ + .filter('term', type='a') \ + .source(False)[start_offset: end_offset] + + # 执行搜索并处理结果 + results = search.execute() + hits = results['hits'].total # 总命中数 + raw_results = [] + + # 遍历搜索结果,转换为Haystack的SearchResult格式 + for raw_result in results['hits']['hits']: + app_label = 'blog' # 应用标签(固定为博客应用) + model_name = 'Article' # 模型名称(固定为文章模型) + additional_fields = {} # 额外字段(当前未使用) + + # 创建SearchResult实例(适配Haystack的结果格式) + result = SearchResult( + app_label, + model_name, + raw_result['_id'], # 文档ID(对应文章ID) + raw_result['_score'], # 匹配得分 + **additional_fields + ) + raw_results.append(result) + + # 构建返回结果字典 + facets = {} # 分面搜索结果(当前未实现) + # 若建议词与原词不同,则返回建议词;否则为None + spelling_suggestion = None if query_string == suggestion else suggestion + + return { + 'results': raw_results, # 搜索结果列表(SearchResult实例) + 'hits': hits, # 总命中数 + 'facets': facets, # 分面数据 + 'spelling_suggestion': spelling_suggestion, # 拼写建议 + } + + +class ElasticSearchQuery(BaseSearchQuery): + """ + 自定义搜索查询类,继承自Haystack的BaseSearchQuery + + 作用:处理搜索查询的构建逻辑,包括查询字符串清洗、参数转换等 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为Elasticsearch兼容的字符串格式 + + :param date: 日期时间对象 + :return: 格式化的字符串(如'20231018123000') + """ + if hasattr(date, 'hour'): # 若包含时间信息(datetime对象) + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: # 仅日期(date对象),时间部分设为00:00:00 + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + 清洗用户输入的查询片段,处理保留字和特殊字符 + + :param query_fragment: 用户输入的查询字符串片段 + :return: 清洗后的查询字符串 + """ + words = query_fragment.split() # 按空格拆分词语 + cleaned_words = [] + + for word in words: + # 处理Elasticsearch保留字(转为小写) + if word in self.backend.RESERVED_WORDS: + word = word.replace(word, word.lower()) + + # 处理特殊字符(若包含特殊字符,用单引号包裹) + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + return ' '.join(cleaned_words) # 拼接清洗后的词语 + + def build_query_fragment(self, field, filter_type, value): + """ + 构建查询片段(适配Elasticsearch的查询语法) + + :param field: 搜索字段 + :param filter_type: 过滤类型 + :param value: 查询值 + :return: 构建的查询字符串 + """ + return value.query_string # 直接使用查询字符串(由value提供) + + def get_count(self): + """ + 获取搜索结果总数 + + :return: 结果数量 + """ + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + """ + 获取拼写建议(适配Haystack接口) + + :param preferred_query: 优先使用的查询(未使用) + :return: 拼写建议词 + """ + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + """ + 构建查询参数(适配Haystack接口) + + :param spelling_query: 拼写建议查询(未使用) + :return: 构建的参数字典 + """ + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + """ + 自定义搜索表单,继承自Haystack的ModelSearchForm + + 作用:扩展默认搜索表单,支持控制是否启用拼写建议 + """ + def search(self): + """ + 执行搜索,根据表单参数控制拼写建议 + + :return: 搜索结果集(SearchQuerySet) + """ + # 通过表单数据中的"is_suggest"参数控制是否启用拼写建议 + # 若"is_suggest"为"no",则禁用建议 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + # 调用父类方法执行搜索 + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + """ + Elasticsearch搜索引擎入口类,继承自Haystack的BaseEngine + + 作用:绑定后端实现和查询类,作为Haystack的引擎配置入口 + """ + backend = ElasticSearchBackend # 指定使用的搜索后端 + query = ElasticSearchQuery # 指定使用的查询类 \ No newline at end of file diff --git a/src/feeds.py b/src/feeds.py new file mode 100644 index 0000000..592c1ce --- /dev/null +++ b/src/feeds.py @@ -0,0 +1,83 @@ +# 导入必要的模块和类 +from django.contrib.auth import get_user_model # 用于获取自定义用户模型 +from django.contrib.syndication.views import Feed # Django内置的Feed基类,用于生成RSS/Atom订阅 +from django.utils import timezone # 处理时间相关操作 +from django.utils.feedgenerator import Rss201rev2Feed # RSS 2.0版本的生成器 + +from blog.models import Article # 导入博客文章模型 +from djangoblog.utils import CommonMarkdown # 导入Markdown处理工具,用于将文章内容转换为HTML + + +class DjangoBlogFeed(Feed): + """ + 自定义博客RSS订阅Feed类,继承自Django的Feed基类,用于生成博客文章的RSS订阅源 + """ + # 指定Feed类型为RSS 2.0版本(符合Rss201rev2Feed规范) + feed_type = Rss201rev2Feed + + # RSS源的描述信息(会显示在订阅源的描述中) + description = '大巧无工,重剑无锋.' + # RSS源的标题(订阅源的名称) + title = "且听风吟 大巧无工,重剑无锋. " + # RSS源的链接(通常指向网站的订阅页面) + link = "/feed/" + + def author_name(self): + """ + 定义订阅源的作者名称 + 这里取系统中第一个用户的昵称作为作者名 + """ + return get_user_model().objects.first().nickname + + def author_link(self): + """ + 定义订阅源作者的链接 + 这里取系统中第一个用户的个人主页链接(需用户模型实现get_absolute_url方法) + """ + return get_user_model().objects.first().get_absolute_url() + + def items(self): + """ + 定义订阅源包含的项目(即文章列表) + 返回条件:类型为'article'(type='a')、状态为'已发布'(status='p')的文章 + 排序方式:按发布时间倒序(最新发布的在前) + 数量限制:最多返回5篇文章 + """ + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + """ + 定义单个项目(文章)的标题 + 参数item:从items()方法返回的单个Article对象 + 返回文章的标题 + """ + return item.title + + def item_description(self, item): + """ + 定义单个项目(文章)的描述内容 + 使用CommonMarkdown工具将文章的Markdown格式正文转换为HTML,作为订阅中的描述 + """ + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + """ + 定义订阅源的版权信息 + 格式为"Copyright© 年份 且听风吟",年份自动获取当前时间的年份 + """ + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + """ + 定义单个项目(文章)的链接 + 返回文章的绝对URL(需Article模型实现get_absolute_url方法) + """ + return item.get_absolute_url() + + def item_guid(self, item): + """ + 定义单个项目的全局唯一标识符(GUID) + 此处未实现具体逻辑,可根据需求补充(如返回文章ID或唯一URL等) + """ + return \ No newline at end of file diff --git a/src/forms.py b/src/forms.py new file mode 100644 index 0000000..9340de6 --- /dev/null +++ b/src/forms.py @@ -0,0 +1,186 @@ +<<<<<<< HEAD +from django import forms +<<<<<<< HEAD +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser + + +class LoginForm(AuthenticationForm): + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super(RegisterForm, self).__init__(*args, **kwargs) + + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + email = self.cleaned_data['email'] + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) + return email + + class Meta: + model = get_user_model() + fields = ("username", "email") + + +class ForgetPasswordForm(forms.Form): + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter( + email=user_email + ).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + email = forms.EmailField( + label=_('Email'), + ) +======= +from django.forms import ModelForm + +from .models import Comment + + +class CommentForm(ModelForm): # 定义评论表单类,继承自ModelForm + # 添加父评论ID字段,用于实现评论回复功能 + # 使用HiddenInput控件隐藏显示,且非必填(顶级评论无需父ID) + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + class Meta: # Meta类用于配置表单与模型的关联信息 + model = Comment # 指定表单对应的模型为Comment + fields = ['body'] # 表单需要包含的模型字段,这里只包含评论内容body +>>>>>>> zh_branch +======= +import logging + +from django import forms +from haystack.forms import SearchForm + +logger = logging.getLogger(__name__) + + +class BlogSearchForm(SearchForm): + """ + 博客搜索表单类 + + 继承自Haystack的SearchForm,用于处理博客文章的搜索功能。 + 这个表单定义了搜索框的验证规则和搜索逻辑。 + """ + + # 搜索查询字段,设置为必填字段 + querydata = forms.CharField(required=True) + + def search(self): + """ + 执行搜索操作 + + 重写父类的search方法,添加自定义搜索逻辑: + 1. 调用父类的搜索方法获取基础搜索结果 + 2. 验证表单数据是否有效 + 3. 记录搜索关键词到日志 + 4. 返回搜索结果 + + Returns: + SearchQuerySet: 搜索结果的查询集 + + Raises: + 如果表单无效,返回空搜索结果 + """ + # 调用父类的search方法获取基础搜索结果 + 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 +>>>>>>> hyt_branch diff --git a/src/hook_constants.py b/src/hook_constants.py new file mode 100644 index 0000000..be2ca8a --- /dev/null +++ b/src/hook_constants.py @@ -0,0 +1,30 @@ +# 文章相关系统事件常量定义 +# 用途:统一管理插件系统中与文章操作相关的事件名称,避免硬编码导致的不一致问题 +# 所有事件名称均采用大写蛇形命名法(UPPER_SNAKE_CASE),符合Python常量命名规范 + +# 事件:文章详情页加载完成 +# 触发时机:当用户访问某篇文章的详情页,页面内容加载完成后触发 +# 应用场景:插件可监听此事件,执行详情页相关的自定义逻辑(如添加页面统计代码、注入额外内容等) +ARTICLE_DETAIL_LOAD = 'article_detail_load' + +# 事件:文章创建完成 +# 触发时机:当一篇新文章在系统中创建成功(如数据库写入完成、状态设为"已发布"或"草稿")后触发 +# 应用场景:插件可监听此事件,执行创建后的后续操作(如自动生成文章摘要、同步到外部平台、发送通知等) +ARTICLE_CREATE = 'article_create' + +# 事件:文章更新完成 +# 触发时机:当已存在的文章内容、属性(如标题、分类、状态)修改并保存成功后触发 +# 应用场景:插件可监听此事件,执行更新后的联动操作(如更新文章索引、记录修改日志、重新生成相关统计数据等) +ARTICLE_UPDATE = 'article_update' + +# 事件:文章删除完成 +# 触发时机:当一篇文章从系统中删除(物理删除或逻辑删除,如标记为"已删除"状态)后触发 +# 应用场景:插件可监听此事件,执行删除后的清理操作(如删除关联的评论、移除相关缓存、同步删除外部存储的附件等) +ARTICLE_DELETE = 'article_delete' + + +# 文章内容钩子名称常量 +# 用途:定义专门用于拦截、修改文章内容的钩子标识,与上述"操作事件"区分(事件侧重流程节点,钩子侧重内容处理) +# 命名格式与事件常量保持一致,确保插件系统中钩子名称的唯一性和可识别性 +# 应用场景:插件可注册此钩子,在文章内容渲染前(如详情页展示、导出为PDF)对内容进行自定义处理(如过滤敏感词、替换关键词、添加水印等) +ARTICLE_CONTENT_HOOK_NAME = "the_content" \ No newline at end of file diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..62ad78f --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,92 @@ +# 导入logging模块,用于记录钩子系统运行过程中的日志(如注册信息、错误信息等) +import logging + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于区分不同组件的日志输出 +logger = logging.getLogger(__name__) + +# 全局钩子存储字典,用于保存所有注册的钩子及其对应的回调函数 +# 键:钩子名称(字符串,如"article_create") +# 值:回调函数列表(所有注册到该钩子的可调用对象将按注册顺序存储) +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调函数,将其添加到指定钩子名称对应的回调列表中 + + 核心作用:建立"钩子名称"与"处理逻辑(回调函数)"的映射关系, + 使后续触发钩子时能自动执行所有注册的回调 + + :param hook_name: 钩子名称(字符串),需与触发时使用的名称一致(如ARTICLE_CREATE) + :param callback: 可调用对象(函数、方法等),当钩子被触发时会执行此对象 + 回调函数的参数需与钩子触发时传递的参数匹配 + """ + # 若钩子名称尚未在全局字典中,初始化一个空列表用于存储回调 + if hook_name not in _hooks: + _hooks[hook_name] = [] + # 将回调函数添加到对应钩子的列表中(按注册顺序存储,触发时也按此顺序执行) + _hooks[hook_name].append(callback) + # 记录DEBUG级日志,说明钩子注册成功(包含钩子名称和回调函数名,便于调试) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行指定名称的"动作钩子(Action Hook)",按注册顺序调用所有关联的回调函数 + + 动作钩子特性:用于触发一系列操作,不关注返回值,仅执行回调逻辑 + 典型场景:文章创建后发送通知、记录日志等(执行动作但无需修改数据) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param *args: 传递给回调函数的位置参数(可变参数,根据钩子场景定义) + :param **kwargs: 传递给回调函数的关键字参数(可变参数,根据钩子场景定义) + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该动作钩子 + logger.debug(f"Running action hook '{hook_name}'") + # 按注册顺序遍历所有回调函数并执行 + for callback in _hooks[hook_name]: + try: + # 传递位置参数和关键字参数给回调函数 + callback(*args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + # 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)",通过回调函数链式处理初始值并返回最终结果 + + 过滤钩子特性:用于对数据进行加工处理,每个回调函数接收上一个函数的输出作为输入, + 最终返回经过所有回调处理后的值 + 典型场景:文章内容过滤敏感词、格式化文本等(修改数据并返回新值) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param value: 初始值(需要被过滤/处理的数据,如文章内容字符串) + :param *args: 传递给回调函数的额外位置参数 + :param **kwargs: 传递给回调函数的额外关键字参数 + :return: 经过所有回调函数处理后的最终值 + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该过滤钩子 + logger.debug(f"Applying filter hook '{hook_name}'") + # 按注册顺序遍历所有回调函数,链式处理初始值 + for callback in _hooks[hook_name]: + try: + # 调用回调函数,将当前值和额外参数传入,更新值为回调返回的结果 + value = callback(value, *args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + 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/loader.py b/src/loader.py new file mode 100644 index 0000000..1e5157f --- /dev/null +++ b/src/loader.py @@ -0,0 +1,51 @@ +# 导入必要的模块 +# os: 用于处理文件路径和目录操作 +# logging: 用于记录插件加载过程中的日志信息(成功/失败状态) +# django.conf.settings: 用于获取Django项目的配置信息(如插件目录、激活的插件列表) +import os +import logging +from django.conf import settings + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于追踪插件加载相关的日志 +logger = logging.getLogger(__name__) + + +def load_plugins(): + """ + 动态加载并初始化位于'plugins'目录中的插件 + + 功能说明: + - 从Django配置中读取激活的插件列表(settings.ACTIVE_PLUGINS) + - 检查每个插件的目录结构是否合法(是否存在plugin.py文件) + - 动态导入插件的核心模块(plugin.py),触发插件的初始化流程 + - 通过日志记录每个插件的加载结果(成功/失败及原因) + + 调用时机: + 该函数应在Django应用注册表(app registry)准备就绪后调用, + 通常在项目启动时(如通过AppConfig.ready()方法触发),确保Django环境已初始化完成。 + """ + # 遍历配置中激活的所有插件名称(settings.ACTIVE_PLUGINS是一个插件名称列表) + for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件的完整目录路径: + # settings.PLUGINS_DIR是项目中存放所有插件的根目录(如"project_root/plugins") + # 拼接根目录与当前插件名称,得到具体插件的目录路径(如"project_root/plugins/my_plugin") + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + + # 验证插件目录的合法性: + # 1. 必须是一个存在的目录(os.path.isdir(plugin_path)) + # 2. 目录中必须包含核心文件plugin.py(插件的入口模块) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + # 动态导入插件的plugin.py模块: + # 导入路径格式为"plugins.{插件名称}.plugin"(基于Python包结构) + # 导入后会自动执行plugin.py中的顶级代码(如插件类的定义和注册逻辑) + __import__(f'plugins.{plugin_name}.plugin') + + # 记录INFO级日志,提示插件加载成功 + logger.info(f"Successfully loaded plugin: {plugin_name}") + + # 捕获导入过程中的异常(如模块不存在、语法错误、依赖缺失等) + except ImportError as e: + # 记录ERROR级日志,提示插件导入失败,并附带异常信息 + # exc_info=e 会将异常堆栈信息写入日志,便于排查问题 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/logentryadmin.py b/src/logentryadmin.py new file mode 100644 index 0000000..7dfb496 --- /dev/null +++ b/src/logentryadmin.py @@ -0,0 +1,145 @@ +# 导入Django管理后台核心模块 +from django.contrib import admin +# 导入日志相关常量和模型:DELETION表示删除操作的标记 +from django.contrib.admin.models import DELETION +# 导入ContentType模型,用于处理模型与数据库表的映射关系 +from django.contrib.contenttypes.models import ContentType +# 导入URL反向解析和异常处理 +from django.urls import reverse, NoReverseMatch +# 导入字符串处理工具 +from django.utils.encoding import force_str +# 导入HTML转义工具,防止XSS攻击 +from django.utils.html import escape +# 导入安全字符串标记工具,标记可信HTML +from django.utils.safestring import mark_safe +# 导入国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + """ + 自定义日志条目(LogEntry)的管理后台配置类 + 用于在Django admin中展示和管理系统操作日志 + """ + # 列表页的筛选器:按内容类型(关联的模型)筛选日志 + list_filter = [ + 'content_type' + ] + + # 搜索字段:支持按对象名称和操作消息搜索日志 + search_fields = [ + 'object_repr', # 对象的字符串表示 + 'change_message' # 操作描述消息 + ] + + # 列表页中可点击的链接字段 + list_display_links = [ + 'action_time', # 操作时间 + 'get_change_message', # 操作消息 + ] + + # 列表页展示的字段 + list_display = [ + 'action_time', # 操作时间 + 'user_link', # 操作用户(带链接) + 'content_type', # 关联的模型类型 + 'object_link', # 操作的对象(带链接) + 'get_change_message', # 操作消息 + ] + + def has_add_permission(self, request): + """ + 禁用添加日志的权限:日志由系统自动生成,不允许手动添加 + """ + return False + + def has_change_permission(self, request, obj=None): + """ + 限制修改日志的权限: + - 仅超级用户或拥有'admin.change_logentry'权限的用户可查看 + - 禁止POST请求(防止修改操作) + """ + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + """ + 禁用删除日志的权限:日志需保留,不允许删除 + """ + return False + + def object_link(self, obj): + """ + 生成操作对象的链接: + - 若操作不是删除且存在关联模型,尝试生成指向该对象编辑页的链接 + - 否则显示对象的字符串表示 + """ + object_link = escape(obj.object_repr) # 转义对象名称,防止XSS + content_type = obj.content_type + + # 非删除操作且存在内容类型时尝试生成链接 + if obj.action_flag != DELETION and content_type is not None: + try: + # 反向解析对象的admin编辑页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: + # 解析URL失败时,仅显示对象名称 + pass + # 标记为安全HTML,避免被转义 + return mark_safe(object_link) + + # 配置列表页字段的排序和显示名称 + object_link.admin_order_field = 'object_repr' # 允许按对象名称排序 + object_link.short_description = _('object') # 字段显示名称(支持国际化) + + def user_link(self, obj): + """ + 生成操作用户的链接: + - 尝试生成指向该用户编辑页的链接 + - 否则显示用户的字符串表示 + """ + # 获取用户模型对应的ContentType + content_type = ContentType.objects.get_for_model(type(obj.user)) + user_link = escape(force_str(obj.user)) # 转义用户名 + + try: + # 反向解析用户的admin编辑页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: + # 解析URL失败时,仅显示用户名 + pass + return mark_safe(user_link) + + # 配置列表页字段的排序和显示名称 + user_link.admin_order_field = 'user' # 允许按用户排序 + user_link.short_description = _('user') # 字段显示名称(支持国际化) + + def get_queryset(self, request): + """ + 优化查询集:预加载content_type关联数据,减少数据库查询次数 + """ + 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 \ No newline at end of file diff --git a/src/middleware.py b/src/middleware.py new file mode 100644 index 0000000..c3c2920 --- /dev/null +++ b/src/middleware.py @@ -0,0 +1,104 @@ +import logging +import time + +from ipware import get_client_ip +from user_agents import parse + +from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager + +logger = logging.getLogger(__name__) + + +class OnlineMiddleware(object): + """ + 在线性能监控中间件 + + 这个中间件用于监控网站的性能指标,包括: + - 页面渲染时间 + - 用户访问信息 + - 用户代理分析 + - IP地理位置(通过Elasticsearch geoip管道) + + 继承自object,是Django中间件的标准写法 + """ + + def __init__(self, get_response=None): + """ + 初始化中间件 + + Args: + get_response: Django的下一个中间件或视图函数 + """ + self.get_response = get_response + super().__init__() + + def __call__(self, request): + """ + 中间件主处理逻辑 + + 这个方在每次请求时被调用,用于: + 1. 记录请求开始时间 + 2. 执行后续中间件和视图 + 3. 计算页面渲染时间 + 4. 收集用户访问数据 + 5. 将数据存储到Elasticsearch(如果启用) + 6. 在响应内容中插入加载时间 + + Args: + request: Django请求对象 + + Returns: + HttpResponse: 处理后的响应对象 + """ + # 记录请求开始时间,用于计算页面渲染时间 + start_time = time.time() + + # 调用后续中间件和视图函数,获取响应 + response = self.get_response(request) + + # 从请求头中获取用户代理字符串 + http_user_agent = request.META.get('HTTP_USER_AGENT', '') + + # 获取客户端IP地址(使用ipware库处理代理情况) + 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: + # 将时间转换为毫秒并保留2位小数 + time_taken = round((cast_time) * 1000, 2) + + # 获取请求的URL路径 + url = request.path + + # 导入时区模块,获取当前时间 + from django.utils import timezone + + # 创建性能监控记录到Elasticsearch + ElaspedTimeDocumentManager.create( + url=url, # 请求URL + time_taken=time_taken, # 耗时(毫秒) + log_datetime=timezone.now(), # 记录时间 + useragent=user_agent, # 用户代理信息 + ip=ip # 客户端IP + ) + + # 在响应内容中替换加载时间占位符 + # 将替换为实际的加载时间(取前5位) + response.content = response.content.replace( + b'', str.encode(str(cast_time)[:5])) + + except Exception as e: + # 记录中间件执行过程中的任何错误 + logger.error("Error OnlineMiddleware: %s" % e) + + # 返回处理后的响应 + return response diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..ca315e2 --- /dev/null +++ b/src/models.py @@ -0,0 +1,455 @@ +<<<<<<< HEAD +<<<<<<< HEAD +from django.contrib.auth.models import AbstractUser +======= +import logging +import re +from abc import abstractmethod + +from django.conf import settings +from django.core.exceptions import ValidationError +>>>>>>> hyt_branch +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +<<<<<<< HEAD +from djangoblog.utils import get_current_site + + +# Create your models here. + +class BlogUser(AbstractUser): + nickname = models.CharField(_('nick name'), max_length=100, blank=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + return reverse( + 'blog:author_detail', kwargs={ + 'author_name': self.username}) + + def __str__(self): + return self.email + + def get_full_url(self): +======= +from mdeditor.fields import MDTextField +from uuslug import slugify + +from djangoblog.utils import cache_decorator, cache +from djangoblog.utils import get_current_site + +logger = logging.getLogger(__name__) + + +class LinkShowType(models.TextChoices): + """ + 链接显示类型选择 + 定义友情链接在网站中的显示位置 + """ + I = ('i', _('index')) # 首页显示 + L = ('l', _('list')) # 列表页显示 + P = ('p', _('post')) # 文章页显示 + A = ('a', _('all')) # 所有页面显示 + S = ('s', _('slide')) # 幻灯片显示 + + +class BaseModel(models.Model): + """ + 基础模型类 + 所有模型的基类,提供公共字段和方法 + """ + id = 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): + """ + 重写保存方法 + 处理文章浏览量更新和自动生成slug + """ + # 检查是否为文章视图更新操作(优化性能,避免完整保存) + 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(URL友好字符串) + if 'slug' in self.__dict__: + slug = getattr( + self, 'title') if 'title' in self.__dict__ else getattr( + self, 'name') + setattr(self, 'slug', slugify(slug)) + # 调用父类保存方法 + super().save(*args, **kwargs) + + def get_full_url(self): + """获取完整的URL地址(包含域名)""" +>>>>>>> hyt_branch + site = get_current_site().domain + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) + return url + + class Meta: +<<<<<<< HEAD + ordering = ['-id'] + verbose_name = _('user') + verbose_name_plural = verbose_name + get_latest_by = 'id' +======= +from django.conf import settings +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from blog.models import Article + + +# 评论模型,存储用户对文章的评论及评论间的嵌套关系 +class Comment(models.Model): + body = models.TextField('正文', max_length=300) # 评论内容,限制最大长度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, # 关联Django内置用户模型,便于扩展用户系统 + verbose_name=_('author'), + on_delete=models.CASCADE) # 级联删除:用户删除时,其评论也会被删除 + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE) # 级联删除:文章删除时,其下所有评论也会被删除 + parent_comment = models.ForeignKey( + 'self', # 自关联,实现评论嵌套回复功能 + verbose_name=_('parent comment'), + blank=True, + null=True, # 允许为空,表示该评论是顶级评论(不是回复) + on_delete=models.CASCADE) # 级联删除:父评论删除时,其所有子评论也会被删除 + is_enable = models.BooleanField(_('enable'), + default=False, blank=False, null=False) # 评论是否启用(可用于审核功能) + + class Meta: + ordering = ['-id'] # 默认按ID降序排列,最新评论显示在前面 + verbose_name = _('comment') + verbose_name_plural = verbose_name + get_latest_by = 'id' # 指定通过id字段获取最新记录 + + def __str__(self): + return self.body +>>>>>>> zh_branch +======= + abstract = True # 抽象基类,不会创建数据库表 + + @abstractmethod + def get_absolute_url(self): + """抽象方法:获取对象的绝对URL,子类必须实现""" + pass + + +class Article(BaseModel): + """ + 文章模型 + 博客系统的核心模型,存储所有文章内容 + """ + # 文章状态选择 + STATUS_CHOICES = ( + ('d', _('Draft')), # 草稿 + ('p', _('Published')), # 已发布 + ) + # 评论状态选择 + COMMENT_STATUS = ( + ('o', _('Open')), # 开启评论 + ('c', _('Close')), # 关闭评论 + ) + # 内容类型选择 + TYPE = ( + ('a', _('Article')), # 普通文章 + ('p', _('Page')), # 独立页面 + ) + + # 基础字段 + 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) # 发布时间 + + # 状态字段 + status = models.CharField(_('status'), max_length=1, choices=STATUS_CHOICES, default='p') # 发布状态 + comment_status = models.CharField(_('comment status'), max_length=1, choices=COMMENT_STATUS, default='o') # 评论状态 + type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # 内容类型 + + # 统计字段 + views = models.PositiveIntegerField(_('views'), default=0) # 浏览次数 + + # 关联字段 + author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('author'), + blank=False, null=False, on_delete=models.CASCADE) # 作者 + article_order = models.IntegerField(_('order'), blank=False, null=False, default=0) # 文章排序 + + # 功能字段 + show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # 是否显示目录 + category = models.ForeignKey('Category', verbose_name=_('category'), + on_delete=models.CASCADE, blank=False, null=False) # 分类 + tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # 标签,多对多关系 + + def body_to_string(self): + """将文章内容转换为字符串""" + return self.body + + def __str__(self): + """对象的字符串表示""" + return self.title + + class Meta: + ordering = ['-article_order', '-pub_time'] # 默认按排序和发布时间降序排列 + verbose_name = _('article') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + get_latest_by = 'id' # 获取最新记录的依据字段 + + def get_absolute_url(self): + """获取文章的绝对URL,包含年月日信息用于SEO""" + return reverse('blog:detailbyid', kwargs={ + 'article_id': self.id, + 'year': self.creation_time.year, + 'month': self.creation_time.month, + 'day': self.creation_time.day + }) + + @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)) + return names + + def save(self, *args, **kwargs): + """保存文章,调用父类保存逻辑""" + super().save(*args, **kwargs) + + def viewed(self): + """增加文章浏览量,使用update_fields优化性能""" + self.views += 1 + self.save(update_fields=['views']) + + def comment_list(self): + """获取文章评论列表(带缓存)""" + cache_key = 'article_comments_{id}'.format(id=self.id) + value = cache.get(cache_key) + if value: + logger.info('get article comments:{id}'.format(id=self.id)) + return value + else: + # 获取已启用的评论并按ID降序排列 + comments = self.comment_set.filter(is_enable=True).order_by('-id') + 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): + """获取文章在Admin后台的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) # 缓存100分钟 + def next_article(self): + """获取下一篇文章(按ID顺序)""" + return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first() + + @cache_decorator(expiration=60 * 100) # 缓存100分钟 + def prev_article(self): + """获取上一篇文章(按ID顺序)""" + return Article.objects.filter(id__lt=self.id, status='p').first() + + def get_first_image_url(self): + """ + 从文章内容中提取第一张图片的URL + 用于文章列表的缩略图显示 + """ + # 使用正则表达式匹配Markdown图片语法 + 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) # 分类名称,唯一 + 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) # URL友好名称 + index = models.IntegerField(default=0, verbose_name=_('index')) # 分类排序索引 + + class Meta: + ordering = ['-index'] # 按索引降序排列 + verbose_name = _('category') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + + def get_absolute_url(self): + """获取分类的绝对URL地址,使用slug作为URL参数""" + return reverse('blog:category_detail', kwargs={'category_name': self.slug}) + + def __str__(self): + """对象的字符串表示""" + return self.name + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_category_tree(self): + """ + 递归获得分类目录的父级 + 返回从当前分类到根分类的路径,用于面包屑导航 + """ + categorys = [] + + def parse(category): + categorys.append(category) + if category.parent_category: + parse(category.parent_category) + + parse(self) + return categorys + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_sub_categorys(self): + """ + 获得当前分类目录所有子集 + 返回所有子分类的列表 + """ + categorys = [] + all_categorys = Category.objects.all() + + def parse(category): + if category not in categorys: + categorys.append(category) + childs = all_categorys.filter(parent_category=category) + for child in childs: + if category not in categorys: + categorys.append(child) + parse(child) + + parse(self) + 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) # URL友好名称 + + def __str__(self): + return self.name + + def get_absolute_url(self): + """获取标签的绝对URL,使用slug作为URL参数""" + return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_article_count(self): + """获取该标签下的文章数量,使用distinct去重""" + return Article.objects.filter(tags__name=self.name).distinct().count() + + class Meta: + ordering = ['name'] # 按名称升序排列 + verbose_name = _('tag') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + + +class Links(models.Model): + """友情链接模型""" + name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称,唯一 + link = models.URLField(_('link')) # 链接地址 + sequence = models.IntegerField(_('order'), unique=True) # 显示顺序,唯一 + is_enable = models.BooleanField(_('is show'), default=True, blank=False, null=False) # 是否启用 + show_type = models.CharField(_('show type'), max_length=1, choices=LinkShowType.choices, + default=LinkShowType.I) # 显示类型 + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 + + class Meta: + ordering = ['sequence'] # 按顺序升序排列 + verbose_name = _('link') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + + def __str__(self): + return self.name + + +class SideBar(models.Model): + """侧边栏模型,可以展示一些html内容""" + name = models.CharField(_('title'), max_length=100) # 侧边栏标题 + content = models.TextField(_('content')) # 侧边栏内容(HTML) + sequence = models.IntegerField(_('order'), unique=True) # 显示顺序,唯一 + is_enable = models.BooleanField(_('is enable'), default=True) # 是否启用 + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 + + class Meta: + ordering = ['sequence'] # 按顺序升序排列 + verbose_name = _('sidebar') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + + def __str__(self): + return self.name + + +class BlogSettings(models.Model): + """博客全局配置模型,使用单例模式确保只有一份配置""" + # 网站基本信息 + site_name = models.CharField(_('site name'), max_length=200, null=False, blank=False, default='') # 网站名称 + site_description = models.TextField(_('site description'), max_length=1000, null=False, blank=False, + default='') # 网站描述 + site_seo_description = models.TextField(_('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) # 是否显示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) # 是否开启全站评论 + comment_need_review = models.BooleanField('评论是否需要审核', default=False, null=False) # 评论是否需要审核 + + # 页面布局 + global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部HTML + global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部HTML + + # 备案信息 + beian_code = models.CharField('备案号', max_length=2000, null=True, blank=True, default='') # ICP备案号 + show_gongan_code = models.BooleanField('是否显示公安备案号', default=False, null=False) # 是否显示公安备案 + gongan_beiancode = models.TextField('公安备案号', max_length=2000, null=True, blank=True, default='') # 公安备案号 + + # 统计代码 + analytics_code = models.TextField("网站统计代码", max_length=1000, null=False, blank=False, default='') # 网站统计代码 + + class Meta: + verbose_name = _('Website configuration') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + + def __str__(self): + return self.site_name + + def clean(self): + """验证配置唯一性,确保只有一个配置实例(单例模式)""" + if BlogSettings.objects.exclude(id=self.id).count(): + raise ValidationError(_('There can only be one configuration')) + + def save(self, *args, **kwargs): + """保存配置并清除缓存,确保配置变更立即生效""" + super().save(*args, **kwargs) + from djangoblog.utils import cache + cache.clear() # 清除所有缓存 +>>>>>>> hyt_branch diff --git a/src/search_indexes.py b/src/search_indexes.py new file mode 100644 index 0000000..1f3ae7d --- /dev/null +++ b/src/search_indexes.py @@ -0,0 +1,40 @@ +from haystack import indexes + +from blog.models import Article + + +class ArticleIndex(indexes.SearchIndex, indexes.Indexable): + """ + 文章搜索索引类 + + 这个类用于定义Django Haystack搜索引擎中文章的索引结构。 + 它继承自SearchIndex和Indexable,提供了文章模型的全文搜索功能。 + """ + + # 主搜索字段,document=True表示这是主要的搜索内容字段 + # use_template=True表示使用模板文件来定义索引内容 + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + """ + 获取与此索引关联的Django模型 + + Returns: + Model: 返回Article模型类 + """ + return Article + + def index_queryset(self, using=None): + """ + 定义要建立索引的查询集 + + 这个方法返回需要被索引的文章集合,这里只索引已发布(status='p')的文章, + 草稿文章不会被包含在搜索索引中。 + + Args: + using: 可选参数,指定使用的搜索引擎别名 + + Returns: + QuerySet: 包含所有已发布文章的查询集 + """ + return self.get_model().objects.filter(status='p') diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..28f916e --- /dev/null +++ b/src/settings.py @@ -0,0 +1,399 @@ +""" +Django settings for djangoblog project. +项目配置文件:包含项目核心设置、数据库、中间件、静态资源等所有全局配置 +Generated by 'django-admin startproject' using Django 1.10.2. +""" +import os +import sys +from pathlib import Path + +# 导入Django国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + """ + 环境变量转换工具函数:将环境变量的字符串值转为布尔值 + - 若环境变量未设置,返回默认值 + - 若环境变量存在,仅当值为'True'时返回True,其他情况返回False + """ + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +# -------------------------- 基础路径配置 -------------------------- +# 项目根目录:当前配置文件所在目录的父级目录(即项目根目录) +BASE_DIR = Path(__file__).resolve().parent.parent + + +# -------------------------- 安全与调试配置 -------------------------- +# 快速开发配置(生产环境需修改) +# SECURITY WARNING: 生产环境必须将SECRET_KEY通过环境变量配置,禁止硬编码 +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' + +# 调试模式:开发环境开启(True),生产环境必须关闭(False) +# 通过环境变量控制,默认开启调试 +DEBUG = env_to_bool('DJANGO_DEBUG', True) + +# 测试模式标识:当执行python manage.py test时,TESTING为True +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +# 允许访问的主机列表:生产环境需指定具体域名,禁止使用'*'(存在安全风险) +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] + +# Django 4.0+新增:信任的CSRF源列表,防止跨站请求伪造,生产环境需配置真实域名 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] + + +# -------------------------- 应用配置 -------------------------- +INSTALLED_APPS = [ + # Django内置应用(精简版Admin,仅包含核心功能) + 'django.contrib.admin.apps.SimpleAdminConfig', + 'django.contrib.auth', # 用户认证系统 + 'django.contrib.contenttypes', # 内容类型框架(关联模型与数据表) + 'django.contrib.sessions', # 会话管理 + 'django.contrib.messages', # 消息提示系统 + 'django.contrib.staticfiles', # 静态资源管理 + 'django.contrib.sites', # 多站点支持(用于sitemap等功能) + 'django.contrib.sitemaps', # 站点地图生成 + + # 第三方应用 + 'mdeditor', # Markdown编辑器(用于文章编写) + 'haystack', # 全文搜索框架 + 'compressor', # 静态资源压缩(CSS/JS合并压缩) + + # 自定义应用 + 'blog', # 博客核心功能(文章、分类等) + 'accounts', # 用户账户管理(自定义用户模型等) + 'comments', # 评论功能 + 'oauth', # 第三方登录(如GitHub、微信等) + 'servermanager',# 服务器管理(如系统监控等) + 'owntracks', # 位置追踪(可选功能) + 'djangoblog' # 项目主应用(全局配置、工具函数等) +] + + +# -------------------------- 中间件配置 -------------------------- +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', # 安全相关中间件(防XSS、点击劫持等) + 'django.contrib.sessions.middleware.SessionMiddleware', # 会话管理 + 'django.middleware.locale.LocaleMiddleware', # 国际化中间件(多语言支持) + 'django.middleware.gzip.GZipMiddleware', # GZip压缩(减少响应体积) + # 'django.middleware.cache.UpdateCacheMiddleware', # 缓存更新(注释:当前未启用) + 'django.middleware.common.CommonMiddleware', # 通用中间件(处理URL、反向解析等) + # 'django.middleware.cache.FetchFromCacheMiddleware', # 缓存读取(注释:当前未启用) + 'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件 + 'django.contrib.auth.middleware.AuthenticationMiddleware', # 用户认证中间件 + 'django.contrib.messages.middleware.MessageMiddleware', # 消息提示中间件 + 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 防点击劫持 + 'django.middleware.http.ConditionalGetMiddleware', # 处理HTTP条件请求(如304缓存) + 'blog.middleware.OnlineMiddleware' # 自定义中间件(跟踪用户在线状态) +] + + +# -------------------------- URL与模板配置 -------------------------- +# 项目主URL配置文件路径 +ROOT_URLCONF = 'djangoblog.urls' + +# 模板配置 +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', # Django模板引擎 + 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 全局模板目录(项目根目录下的templates) + 'APP_DIRS': True, # 允许从各应用的templates目录加载模板 + 'OPTIONS': { + # 模板上下文处理器:向所有模板注入全局变量 + 'context_processors': [ + 'django.template.context_processors.debug', # 调试模式变量 + 'django.template.context_processors.request', # 请求对象(request) + 'django.contrib.auth.context_processors.auth', # 用户认证变量(user) + 'django.contrib.messages.context_processors.messages', # 消息提示变量 + 'blog.context_processors.seo_processor' # 自定义SEO处理器(注入SEO相关变量) + ], + }, + }, +] + +# WSGI应用入口(用于部署,如Gunicorn、uWSGI) +WSGI_APPLICATION = 'djangoblog.wsgi.application' + + +# -------------------------- 数据库配置 -------------------------- +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', # 数据库引擎(MySQL) + 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', # 数据库名 + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', # 数据库用户名 + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'LY181828', # 数据库密码 + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', # 数据库主机 + 'PORT': int(os.environ.get('DJANGO_MYSQL_PORT') or 3306), # 数据库端口 + 'OPTIONS': {'charset': 'utf8mb4'}, # 数据库字符集(支持emoji表情) + }} + + +# -------------------------- 密码验证配置 -------------------------- +AUTH_PASSWORD_VALIDATORS = [ + # 验证密码与用户名/邮箱是否相似 + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + # 验证密码最小长度(默认8位) + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + # 验证密码是否为常见弱密码(如123456) + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + # 验证密码是否全为数字 + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + + +# -------------------------- 国际化与时间配置 -------------------------- +# 支持的语言列表(英文、简体中文、繁体中文) +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) + +# 语言文件目录(存放翻译文件的路径) +LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'),) + +# 默认语言(简体中文) +LANGUAGE_CODE = 'zh-hans' + +# 时区(上海时区,与UTC时差+8) +TIME_ZONE = 'Asia/Shanghai' + +# 启用国际化 +USE_I18N = True + +# 启用本地化(日期、时间格式等) +USE_L10N = True + +# 禁用UTC时间(使用本地时间存储数据库时间) +USE_TZ = False + + +# -------------------------- 全文搜索配置(Haystack) -------------------------- +HAYSTACK_CONNECTIONS = { + 'default': { + # 搜索引擎:自定义中文Whoosh引擎(支持中文分词) + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', + # 搜索索引存储路径(项目配置文件目录下的whoosh_index) + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), + }, +} + +# 实时更新搜索索引:当文章新增/修改/删除时,自动更新搜索索引 +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' + + +# -------------------------- 用户认证配置 -------------------------- +# 自定义认证后端:允许用户用用户名或邮箱登录 +AUTHENTICATION_BACKENDS = ['accounts.user_login_backend.EmailOrUsernameModelBackend'] + +# 自定义用户模型:替换Django内置的User模型(关联accounts应用的BlogUser) +AUTH_USER_MODEL = 'accounts.BlogUser' + +# 登录页面URL:未登录用户访问需认证页面时,重定向到该URL +LOGIN_URL = '/login/' + + +# -------------------------- 静态资源与媒体文件配置 -------------------------- +# 静态资源收集目录(生产环境使用python manage.py collectstatic收集后的路径) +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') + +# 静态资源URL前缀(前端访问静态资源的路径,如http://example.com/static/) +STATIC_URL = '/static/' + +# 全局静态资源目录(项目根目录下的static文件夹) +STATICFILES = os.path.join(BASE_DIR, 'static') + +# 媒体文件(用户上传文件,如文章图片)存储目录 +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') + +# 媒体文件URL前缀(前端访问上传文件的路径,如http://example.com/media/) +MEDIA_URL = '/media/' + +# 静态资源查找器(指定Django如何查找静态文件) +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', # 从全局STATICFILES目录查找 + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 从各应用的static目录查找 + 'compressor.finders.CompressorFinder', # 从compressor压缩后的目录查找 +) + + +# -------------------------- 静态资源压缩配置(Compressor) -------------------------- +# 启用压缩(生产环境建议开启,开发环境可关闭) +COMPRESS_ENABLED = True +# COMPRESS_OFFLINE = True # 离线压缩(注释:当前未启用,适合生产环境) + +# CSS压缩过滤器:1. 转换相对URL为绝对URL;2. 压缩CSS代码 +COMPRESS_CSS_FILTERS = [ + 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.cssmin.CSSMinFilter' +] + +# JS压缩过滤器:压缩JS代码 +COMPRESS_JS_FILTERS = ['compressor.filters.jsmin.JSMinFilter'] + + +# -------------------------- 缓存配置 -------------------------- +# HTTP缓存超时时间(单位:秒,2592000秒=30天) +CACHE_CONTROL_MAX_AGE = 2592000 + +# 默认缓存:本地内存缓存(适合开发环境,生产环境建议用Redis/Memcached) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, # 缓存超时时间(3小时) + 'LOCATION': 'unique-snowflake', # 缓存实例标识(唯一即可) + } +} + +# 若环境变量配置了Redis地址,则使用Redis作为缓存(生产环境推荐) +if os.environ.get("DJANGO_REDIS_URL"): + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', + } + } + + +# -------------------------- 其他业务配置 -------------------------- +# 多站点支持的站点ID(默认1,与django.contrib.sites配合使用) +SITE_ID = 1 + +# 百度链接提交URL:用于向百度搜索引擎提交新链接(SEO优化) +BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') or \ + 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' + +# 时间格式:全局日期时间显示格式 +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + +# Bootstrap颜色样式列表(用于文章标签、按钮等UI组件) +BOOTSTRAP_COLOR_TYPES = ['default', 'primary', 'success', 'info', 'warning', 'danger'] + +# 分页配置:每页显示的文章数量 +PAGINATE_BY = 10 + +# X-Frame-Options配置:仅允许同域嵌入iframe(防点击劫持) +X_FRAME_OPTIONS = 'SAMEORIGIN' + +# 默认自增字段类型(Django 3.2+新增,避免主键溢出) +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# 微信管理员密码(两次MD5加密,用于微信后台管理验证) +WXADMIN = os.environ.get('DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + + +# -------------------------- Elasticsearch配置(可选) -------------------------- +# 若环境变量配置了Elasticsearch地址,则使用Elasticsearch作为搜索引擎(替代Whoosh) +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': {'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')}, + } + HAYSTACK_CONNECTIONS = { + 'default': {'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine'}, + } + + +# -------------------------- 日志配置 -------------------------- +# 日志存储目录(项目根目录下的logs文件夹) +LOG_PATH = os.path.join(BASE_DIR, 'logs') +# 若目录不存在则创建(exist_ok=True避免重复创建报错) +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, # 日志配置版本(固定为1) + 'disable_existing_loggers': False, # 不禁用已存在的日志器 + 'root': { # 根日志器(所有未指定日志器的日志都会走这里) + 'level': 'INFO', # 日志级别(INFO及以上会被记录) + 'handlers': ['console', 'log_file'], # 日志处理器(控制台+文件) + }, + 'formatters': { # 日志格式 + 'verbose': { # 详细格式(包含时间、级别、模块、行号等) + 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', + } + }, + 'filters': { # 日志过滤器 + 'require_debug_false': {'()': 'django.utils.log.RequireDebugFalse'}, # 仅DEBUG=False时生效 + 'require_debug_true': {'()': 'django.utils.log.RequireDebugTrue'}, # 仅DEBUG=True时生效 + }, + 'handlers': { # 日志处理器(定义日志如何输出) + 'log_file': { # 文件处理器(按天分割日志) + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', # 按时间轮转的文件处理器 + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径 + 'when': 'D', # 轮转周期(每天一个文件) + 'formatter': 'verbose', # 使用详细格式 + 'interval': 1, # 轮转间隔(1天) + 'delay': True, # 延迟创建文件(直到有日志时才创建) + 'backupCount': 5, # 保留5个备份日志文件 + 'encoding': 'utf-8' # 日志文件编码 + }, + 'console': { # 控制台处理器(仅开发环境显示) + 'level': 'DEBUG', + 'filters': ['require_debug_true'], # 仅DEBUG=True时生效 + 'class': 'logging.StreamHandler', # 输出到控制台 + 'formatter': 'verbose' + }, + 'null': {'class': 'logging.NullHandler'}, # 空处理器(丢弃日志) + 'mail_admins': { # 邮件处理器(发生ERROR时通知管理员) + 'level': 'ERROR', + 'filters': ['require_debug_false'], # 仅生产环境(DEBUG=False)生效 + 'class': 'django.utils.log.AdminEmailHandler' # 发送邮件给ADMINS列表 + } + }, + 'loggers': { # 自定义日志器(针对特定模块) + 'djangoblog': { # 项目主模块日志 + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, # 是否向上传递日志(到root日志器) + }, + 'django.request': { # Django请求相关日志(如404、500错误) + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, # 不向上传递(避免重复记录) + } + } +} + + +# -------------------------- 邮件配置 -------------------------- +# 邮件后端(SMTP服务) +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# 是否使用TLS加密(与SSL二选一,根据邮件服务商配置) +EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) +# 是否使用SSL加密(阿里云邮箱等常用465端口+SSL) +EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) +# 邮件服务器地址(如阿里云邮箱为smtp.mxhichina.com) +EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' +# 邮件服务器端口(SSL通常为465,TLS通常为587) +EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) +# 发送邮件的用户名(邮箱地址) +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +# 发送邮件的密码(邮箱授权码,非登录密码) +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +# 默认发件人(与EMAIL_HOST_USER一致) +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +# 管理员邮件(与ADMINS配合使用) +SERVER_EMAIL = EMAIL_HOST_USER + +# 管理员列表:发生ERROR时会收到邮件通知 +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] + + +# -------------------------- 插件系统配置 -------------------------- +# 插件目录(项目根目录下的plugins文件夹) +PLUGINS_DIR = BASE_DIR / 'plugins' + +# 激活的插件列表(按需启用,提供额外功能) +ACTIVE_PLUGINS = [ + 'article_copyright', # 文章版权声明 + 'reading_time', # 文章阅读时长估算 + 'external_links', # 外部链接处理(如添加nofollow) + 'view_count', # 文章阅读量统计 + 'seo_optimize' # SEO优化(如自动生成meta标签) +] \ No newline at end of file diff --git a/src/sidebar.html b/src/sidebar.html index d8d7604..94df08a 100644 --- a/src/sidebar.html +++ b/src/sidebar.html @@ -1,223 +1,184 @@ -{% load blog_tags %} -{% load i18n %} - +{% load blog_tags %} +{% load i18n %} + + \ No newline at end of file +
\ No newline at end of file diff --git a/src/sitemap.py b/src/sitemap.py new file mode 100644 index 0000000..84e9886 --- /dev/null +++ b/src/sitemap.py @@ -0,0 +1,124 @@ +# 导入Django站点地图核心类和URL反向解析工具 +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +# 导入博客相关模型(文章、分类、标签),用于生成动态页面的站点地图 +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + """ + 静态页面站点地图类:用于生成网站静态页面(如首页)的Sitemap条目 + Sitemap用于告知搜索引擎网站的页面结构,帮助其抓取和索引 + """ + # 页面优先级(0.0-1.0):值越高,搜索引擎认为该页面越重要 + priority = 0.5 + # 页面内容更新频率:可选值包括always、hourly、daily、weekly、monthly、yearly、never + changefreq = 'daily' + + def items(self): + """ + 定义需要包含在站点地图中的静态页面URL名称列表 + 返回的是Django URL配置中定义的'name'属性(如'blog:index'对应首页URL) + """ + return ['blog:index', ] + + def location(self, item): + """ + 为items()返回的每个URL名称,生成对应的绝对URL + 通过reverse()方法解析URL名称,获取实际访问路径(如'blog:index'解析为'/') + """ + return reverse(item) + + +class ArticleSiteMap(Sitemap): + """ + 文章页面站点地图类:用于生成所有已发布文章的Sitemap条目 + """ + # 文章页面更新频率:每月(因单篇文章发布后修改频率较低) + changefreq = "monthly" + # 文章页面优先级:0.6(高于分类/标签,低于首页) + priority = "0.6" + + def items(self): + """ + 返回所有状态为"已发布"(status='p')的文章对象 + 仅包含已发布文章,避免搜索引擎抓取草稿或未公开内容 + """ + return Article.objects.filter(status='p') + + def lastmod(self, obj): + """ + 定义每个文章页面的最后修改时间 + 取值为文章的last_modify_time字段(文章最后更新的时间) + 帮助搜索引擎识别页面是否有更新,决定是否重新抓取 + """ + return obj.last_modify_time + + +class CategorySiteMap(Sitemap): + """ + 分类页面站点地图类:用于生成所有文章分类页面的Sitemap条目 + """ + # 分类页面更新频率:每周(分类下文章新增/删除会影响分类页,频率低于文章) + changefreq = "Weekly" + # 分类页面优先级:0.6(与文章同级,高于标签/用户) + priority = "0.6" + + def items(self): + """返回所有分类对象,生成每个分类页面的Sitemap条目""" + return Category.objects.all() + + def lastmod(self, obj): + """ + 分类页面的最后修改时间:取值为分类的last_modify_time字段 + 分类信息(如分类名称)修改时,会更新该时间,提示搜索引擎重新抓取 + """ + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + """ + 标签页面站点地图类:用于生成所有文章标签页面的Sitemap条目 + """ + # 标签页面更新频率:每周(标签下文章增减频率较低) + changefreq = "Weekly" + # 标签页面优先级:0.3(低于首页、文章、分类,属于次要导航页面) + priority = "0.3" + + def items(self): + """返回所有标签对象,生成每个标签页面的Sitemap条目""" + return Tag.objects.all() + + def lastmod(self, obj): + """ + 标签页面的最后修改时间:取值为标签的last_modify_time字段 + 标签信息(如标签名称)修改时更新,用于搜索引擎判断页面新鲜度 + """ + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + """ + 用户页面站点地图类:用于生成所有发布过文章的用户主页的Sitemap条目 + """ + # 用户页面更新频率:每周(用户发布新文章或修改资料时才更新) + changefreq = "Weekly" + # 用户页面优先级:0.3(与标签同级,属于次要页面) + priority = "0.3" + + def items(self): + """ + 返回所有发布过文章的独特用户对象 + 1. 通过Article.objects.all()获取所有文章,提取每篇文章的author(作者) + 2. 使用map()遍历文章列表,获取作者集合;用set()去重,避免重复用户 + 3. 转换为list(),符合items()返回可迭代对象的要求 + """ + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + """ + 用户页面的最后修改时间:取值为用户的date_joined字段(用户注册时间) + 此处可根据需求调整(如改为用户最后发布文章时间或最后修改资料时间) + """ + return obj.date_joined \ No newline at end of file diff --git a/src/spider_notify.py b/src/spider_notify.py new file mode 100644 index 0000000..65142d8 --- /dev/null +++ b/src/spider_notify.py @@ -0,0 +1,49 @@ +# 导入日志模块,用于记录通知过程中的信息和错误 +import logging + +# 导入requests库,用于发送HTTP请求(向搜索引擎提交链接) +import requests +# 导入Django项目配置,用于获取百度链接提交的URL(在settings.py中配置) +from django.conf import settings + +# 创建当前模块的日志记录器,用于输出通知相关的日志 +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + """ + 搜索引擎爬虫通知类:用于向搜索引擎(当前仅百度)提交网站新链接 + 帮助搜索引擎快速发现并收录网站新增或更新的页面,提升SEO效果 + """ + + @staticmethod + def baidu_notify(urls): + """ + 向百度搜索引擎提交链接的静态方法 + 通过百度链接提交API(BAIDU_NOTIFY_URL),将新页面URL推送给百度爬虫 + + Args: + urls (list): 需要提交的URL列表(每个元素为一个页面的完整URL或相对URL) + """ + try: + # 将URL列表转换为以换行符分隔的字符串,符合百度API的提交格式要求 + data = '\n'.join(urls) + # 发送POST请求到百度链接提交URL,提交URL数据 + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + # 记录百度返回的响应结果(成功时包含提交状态,用于调试和审计) + logger.info(result.text) + except Exception as e: + # 捕获请求过程中的所有异常(如网络错误、API地址错误等),记录错误日志 + logger.error(e) + + @staticmethod + def notify(url): + """ + 通用通知入口静态方法:统一调用百度链接提交方法 + 此处为简化设计,后续可扩展支持其他搜索引擎(如谷歌、必应) + + Args: + url (str/list): 单个URL字符串或URL列表(最终会转为列表提交给百度) + """ + # 调用百度链接提交方法,实现URL推送 + SpiderNotify.baidu_notify(url) \ No newline at end of file diff --git a/src/tests.py b/src/tests.py new file mode 100644 index 0000000..9d311c0 --- /dev/null +++ b/src/tests.py @@ -0,0 +1,859 @@ +<<<<<<< HEAD +<<<<<<< HEAD +<<<<<<< HEAD +from django.test import TestCase + +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + def setUp(self): + pass + + def test_utils(self): + md5 = get_sha256('test') + self.assertIsNotNone(md5) + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) + +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from accounts.models import BlogUser +from blog.models import Article, Category +from djangoblog.utils import * +from . import utils +======= +import os + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.paginator import Paginator +from django.templatetags.static import static +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone + +from accounts.models import BlogUser +from blog.forms import BlogSearchForm +from blog.models import Article, Category, Tag, SideBar, Links +from blog.templatetags.blog_tags import load_pagination_info, load_articletags +from djangoblog.utils import get_current_site, get_sha256 +from oauth.models import OAuthUser, OAuthConfig +>>>>>>> hyt_branch + + +# Create your tests here. + +<<<<<<< HEAD +class AccountTest(TestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + self.blog_user = BlogUser.objects.create_user( + username="test", + email="admin@admin.com", + password="12345678" + ) + self.new_test = "xxx123--=" + + def test_validate_account(self): + site = get_current_site().domain + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="qwer!@#$ggg") + testuser = BlogUser.objects.get(username='liangliangyy1') + + loginresult = self.client.login( + username='liangliangyy1', + password='qwer!@#$ggg') + self.assertEqual(loginresult, True) + response = self.client.get('/admin/') + self.assertEqual(response.status_code, 200) + + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + article = Article() + article.title = "nicetitleaaa" + article.body = "nicecontentaaa" + article.author = user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + def test_validate_register(self): +======= + +from django.test import Client, RequestFactory, TestCase + +from django.urls import reverse +# 导入时区处理模块,用于处理时间相关数据 +from django.utils import timezone +# 导入国际化翻译函数,用于多语言文本 +from django.utils.translation import gettext_lazy as _ + +# 导入用户模型,用于创建测试用户数据 +from accounts.models import BlogUser +# 导入文章、分类模型,用于创建测试内容数据 +from blog.models import Article, Category +# 导入项目工具函数,用于测试通用功能 +from djangoblog.utils import * +# 导入当前应用(accounts)的工具函数,用于测试账号相关工具功能 +from . import utils + + +# 定义账号功能测试类,继承TestCase(基础测试用例类) +class AccountTest(TestCase): + # 测试前初始化方法,每个测试方法执行前自动运行 + def setUp(self): + # 初始化测试客户端,用于模拟用户发起HTTP请求 + 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') # 登录密码 + # 断言:登录结果应为True(登录成功) + self.assertEqual(loginresult, True) + # 模拟超级用户访问管理员后台首页 + response = self.client.get('/admin/') + # 断言:响应状态码应为200(访问成功) + 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' # 文章类型(假设'a'代表普通文章) + article.status = 'p' # 文章状态(假设'p'代表已发布) + article.save() # 保存文章到测试数据库 + + # 模拟访问该文章的管理员编辑页(通过文章模型的自定义方法获取URL) + response = self.client.get(article.get_admin_url()) + # 断言:响应状态码应为200(管理员有权限访问,访问成功) + self.assertEqual(response.status_code, 200) + + # 测试账号注册功能(注册、邮箱验证、登录、权限提升、文章管理、登出) + def test_validate_register(self): + # 断言:数据库中初始不存在邮箱为'user123@user.com'的用户(计数为0) +>>>>>>> zh_branch + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) +<<<<<<< HEAD + 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', + }) +======= + # 模拟POST请求提交注册表单,访问注册接口 + 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', # 密码确认(与密码一致) + }) + # 断言:注册后数据库中应存在该邮箱用户(计数为1) +>>>>>>> zh_branch + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) +<<<<<<< HEAD + user = BlogUser.objects.filter(email='user123@user.com')[0] + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.client.login(username='user1233', password='password123!q@wE#R$T') + user = BlogUser.objects.filter(email='user123@user.com')[0] + user.is_superuser = True + user.is_staff = True + user.save() + delete_sidebar_cache() + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + article = Article() + article.category = category + article.title = "nicetitle333" + article.body = "nicecontentttt" + article.author = user + + article.type = 'a' + article.status = 'p' + article.save() + + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse('account:logout')) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.post(reverse('account:login'), { + 'username': 'user1233', + 'password': 'password123' + }) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + def test_verify_email_code(self): + to_email = "admin@admin.com" + code = generate_code() + utils.set_code(to_email, code) + utils.send_verify_email(to_email, code) + + err = utils.verify("admin@admin.com", code) + self.assertEqual(err, None) + + err = utils.verify("admin@123.com", code) + self.assertEqual(type(err), str) + + def test_forget_password_email_code_success(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@admin.com") + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content.decode("utf-8"), "ok") + + def test_forget_password_email_code_fail(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + def test_forget_password_email_success(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code=code, + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + self.assertEqual(resp.status_code, 302) + + # 验证用户密码是否修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # type: BlogUser + self.assertNotEqual(blog_user, None) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", + code="123456", + ) +======= + # 从数据库中查询刚注册的用户 + user = BlogUser.objects.filter(email='user123@user.com')[0] + # 生成用户邮箱验证的签名(双重SHA256加密,结合密钥和用户ID) + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + # 反向解析验证结果页的URL + path = reverse('accounts:result') + # 拼接完整的邮箱验证URL(包含用户ID和签名) + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + # 模拟访问邮箱验证URL,完成验证 + response = self.client.get(url) + # 断言:验证页面访问成功(状态码200) + 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 # 设置为管理员(有权访问admin后台) + user.save() # 保存权限修改 + # 调用工具函数删除侧边栏缓存(避免缓存影响测试结果) + delete_sidebar_cache() + # 创建测试分类(用于后续文章关联) + category = Category() + category.name = "categoryaaa" # 分类名称 + category.creation_time = timezone.now() # 创建时间 + category.last_modify_time = timezone.now() # 最后修改时间 + category.save() # 保存分类 + + # 创建测试文章(关联上述分类和提升权限后的用户) + article = Article() + article.category = category # 所属分类 + article.title = "nicetitle333" # 文章标题 + article.body = "nicecontentttt" # 文章内容 + article.author = user # 文章作者(提升权限后的用户) + article.type = 'a' # 文章类型 + article.status = 'p' # 文章状态(已发布) + article.save() # 保存文章 + + # 模拟访问该文章的管理员编辑页 + response = self.client.get(article.get_admin_url()) + # 断言:访问成功(状态码200,因用户已提升为管理员) + self.assertEqual(response.status_code, 200) + + # 模拟用户登出(访问登出接口) + response = self.client.get(reverse('account:logout')) + # 断言:登出响应状态码在[301,302,200]内(重定向或成功) + self.assertIn(response.status_code, [301, 302, 200]) + + # 登出后再次访问文章管理员编辑页(应无权限) + response = self.client.get(article.get_admin_url()) + # 断言:响应状态码在[301,302,200]内(可能重定向到登录页) + self.assertIn(response.status_code, [301, 302, 200]) + + # 模拟使用错误密码登录(密码不匹配) + response = self.client.post(reverse('account:login'), { + 'username': 'user1233', # 正确用户名 + 'password': 'password123' # 错误密码 + }) + # 断言:登录响应状态码在[301,302,200]内(登录失败可能重定向或返回表单) + self.assertIn(response.status_code, [301, 302, 200]) + + # 错误登录后访问文章管理员编辑页(仍无权限) + response = self.client.get(article.get_admin_url()) + # 断言:响应状态码在[301,302,200]内(可能重定向到登录页) + 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) + # 断言:验证无错误(返回None) + self.assertEqual(err, None) + + # 验证:使用错误邮箱和正确验证码 + err = utils.verify("admin@123.com", code) + # 断言:验证错误,错误类型为字符串(返回错误信息) + self.assertEqual(type(err), str) + + # 测试“忘记密码-发送验证码”功能的成功场景 + def test_forget_password_email_code_success(self): + # 模拟POST请求提交邮箱,访问“发送忘记密码验证码”接口 + resp = self.client.post( + path=reverse("account:forget_password_code"), # 反向解析接口URL + data=dict(email="admin@admin.com") # 提交已存在的测试邮箱 + ) + + # 断言:响应状态码为200(请求处理成功) + self.assertEqual(resp.status_code, 200) + # 断言:响应内容为"ok"(表示验证码发送成功) + self.assertEqual(resp.content.decode("utf-8"), "ok") + + # 测试“忘记密码-发送验证码”功能的失败场景 + def test_forget_password_email_code_fail(self): + # 模拟POST请求:不提交邮箱(空数据) + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() # 空数据 + ) + # 断言:响应内容为“错误的邮箱”(无邮箱参数,请求失败) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 模拟POST请求:提交格式错误的邮箱(无效邮箱) + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") # 格式错误的邮箱 + ) + # 断言:响应内容为“错误的邮箱”(邮箱格式无效,请求失败) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 测试“忘记密码-重置密码”功能的成功场景 + def test_forget_password_email_success(self): + # 生成随机验证码 + code = generate_code() + # 存储验证码(关联测试用户的邮箱) + utils.set_code(self.blog_user.email, code) + # 构造重置密码的请求数据 + data = dict( + new_password1=self.new_test, # 新密码 + new_password2=self.new_test, # 新密码确认(与新密码一致) + email=self.blog_user.email, # 测试用户邮箱 + code=code, # 正确的验证码 + ) + # 模拟POST请求提交重置密码数据,访问重置密码接口 + resp = self.client.post( + path=reverse("account:forget_password"), # 反向解析接口URL + data=data + ) + # 断言:响应状态码为302(重置成功,重定向到登录页或结果页) + self.assertEqual(resp.status_code, 302) + + # 验证:数据库中用户密码是否已更新 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, # 按邮箱查询测试用户 + ).first() # 获取查询结果的第一个(唯一用户) + # 断言:查询到用户(用户存在) + self.assertNotEqual(blog_user, None) + # 断言:用户密码与新密码匹配(check_password方法验证哈希密码) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + # 测试“忘记密码-重置密码”功能:邮箱不存在的失败场景 + def test_forget_password_email_not_user(self): + # 构造重置密码请求数据(使用不存在的邮箱) + data = dict( + new_password1=self.new_test, # 新密码 + new_password2=self.new_test, # 新密码确认 + email="123@123.com", # 不存在的邮箱 + code="123456", # 任意验证码 + ) + # 模拟POST请求提交数据,访问重置密码接口 +>>>>>>> zh_branch + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + +<<<<<<< HEAD + self.assertEqual(resp.status_code, 200) + + + def test_forget_password_email_code_error(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code="111111", + ) +======= + # 断言:响应状态码为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) + # 构造重置密码请求数据(使用错误的验证码) + data = dict( + new_password1=self.new_test, # 新密码 + new_password2=self.new_test, # 新密码确认 + email=self.blog_user.email, # 正确的测试用户邮箱 + code="111111", # 错误的验证码 + ) + # 模拟POST请求提交数据,访问重置密码接口 +>>>>>>> zh_branch + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + +<<<<<<< HEAD + self.assertEqual(resp.status_code, 200) + + +======= + # 断言:响应状态码为200(请求处理完成,但验证码错误,返回表单页) + self.assertEqual(resp.status_code, 200) +>>>>>>> zh_branch +======= +class ArticleTest(TestCase): + """ + 文章模型测试类 + + 这个测试类用于测试博客系统的核心功能,包括: + - 文章创建和验证 + - 搜索功能 + - 分页功能 + - 文件上传 + - 管理命令 + - 错误页面处理 + """ + + def setUp(self): + """ + 测试初始化方法 + 在每个测试方法执行前运行,用于设置测试环境 + """ + self.client = Client() # Django测试客户端,用于模拟HTTP请求 + self.factory = RequestFactory() # 请求工厂,用于创建请求对象 + + def test_validate_article(self): + """ + 测试文章验证和核心功能 + + 这个测试方法验证博客系统的核心功能: + - 用户创建和认证 + - 文章创建和关联 + - 搜索功能 + - 分页功能 + - RSS和站点地图 + - 管理后台访问 + """ + # 获取当前站点域名 + site = get_current_site().domain + + # 创建测试用户(超级用户) + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") + user.is_staff = True # 设置为管理员 + user.is_superuser = True # 设置为超级用户 + user.save() + + # 测试用户详情页访问 + response = self.client.get(user.get_absolute_url()) + self.assertEqual(response.status_code, 200) # 断言返回200状态码 + + # 测试管理后台页面访问 + response = self.client.get('/admin/servermanager/emailsendlog/') + response = self.client.get('admin/admin/logentry/') + + # 创建侧边栏测试数据 + s = SideBar() + s.sequence = 1 + s.name = 'test' + s.content = 'test content' + 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.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.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() + + # 测试百度批量推送 + from djangoblog.spider_notify import SpiderNotify + SpiderNotify.baidu_notify([article.get_full_url()]) + + # 测试Gravatar相关功能 + from blog.templatetags.blog_tags import gravatar_url, gravatar + u = gravatar_url('liangliangyy@gmail.com') + u = gravatar('liangliangyy@gmail.com') + + # 测试友情链接功能 + link = Links( + sequence=1, + name="lylinux", + link='https://wwww.lylinux.net') + link.save() + response = self.client.get('/links.html') + self.assertEqual(response.status_code, 200) + + # 测试RSS订阅 + response = self.client.get('/feed/') + self.assertEqual(response.status_code, 200) + + # 测试站点地图 + response = self.client.get('/sitemap.xml') + self.assertEqual(response.status_code, 200) + + # 测试管理后台操作 + self.client.get("/admin/blog/article/1/delete/") + self.client.get('/admin/servermanager/emailsendlog/') + self.client.get('/admin/admin/logentry/') + self.client.get('/admin/admin/logentry/1/change/') + + def check_pagination(self, p, type, value): + """ + 分页功能测试辅助方法 + + Args: + p: Paginator分页对象 + type: 分页类型(用于生成URL) + value: 分页参数值 + """ + for page in range(1, p.num_pages + 1): + # 加载分页信息 + s = load_pagination_info(p.page(page), type, value) + self.assertIsNotNone(s) + # 测试上一页链接 + if s['previous_url']: + response = self.client.get(s['previous_url']) + self.assertEqual(response.status_code, 200) + # 测试下一页链接 + if s['next_url']: + response = self.client.get(s['next_url']) + self.assertEqual(response.status_code, 200) + + def test_image(self): + """ + 图片上传和头像处理测试 + """ + import requests + # 下载测试图片 + rsp = requests.get( + 'https://www.python.org/static/img/python-logo.png') + imagepath = os.path.join(settings.BASE_DIR, 'python.png') + with open(imagepath, 'wb') as file: + file.write(rsp.content) + + # 测试未授权上传(应该返回403) + rsp = self.client.post('/upload') + self.assertEqual(rsp.status_code, 403) + + # 生成签名用于授权上传 + sign = get_sha256(get_sha256(settings.SECRET_KEY)) + with open(imagepath, 'rb') as file: + imgfile = SimpleUploadedFile( + 'python.png', file.read(), content_type='image/jpg') + form_data = {'python.png': imgfile} + # 测试授权上传 + 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): + """ + 测试Django管理命令 + + 验证系统提供的各种管理命令是否能正常执行 + """ + # 创建测试用户 + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") + user.is_staff = True + user.is_superuser = True + user.save() + + # 创建OAuth配置 + c = OAuthConfig() + c.type = 'qq' + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + + # 创建OAuth用户 + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid' + u.user = user + u.picture = static("/blog/img/avatar.png") + u.metadata = ''' +{ +"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" +}''' + u.save() + + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid1' + u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30' + u.metadata = ''' + { + "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" + }''' + 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") # 构建搜索词 +>>>>>>> hyt_branch diff --git a/src/urls.py b/src/urls.py new file mode 100644 index 0000000..835a6df --- /dev/null +++ b/src/urls.py @@ -0,0 +1,253 @@ +<<<<<<< HEAD +<<<<<<< HEAD +<<<<<<< HEAD +"""djangoblog URL Configuration +项目URL路由总配置文件:定义所有URL与视图/应用的映射关系 +核心作用是将用户访问的URL地址,分发到对应的应用或视图函数处理 +""" +# 导入项目配置,用于获取静态资源、媒体文件路径等 +from django.conf import settings +# 导入国际化URL配置工具,支持多语言URL前缀(如/en/、/zh-hans/) +from django.conf.urls.i18n import i18n_patterns +# 导入静态资源URL配置工具,用于开发环境下提供静态文件访问 +from django.conf.urls.static import static +# 导入站点地图视图,用于生成sitemap.xml +from django.contrib.sitemaps.views import sitemap +# 导入URL路径配置工具(path用于固定路径,re_path支持正则匹配,include用于包含子应用URL) +from django.urls import path, include, re_path +# 导入Haystack搜索视图工厂,用于自定义搜索视图 +from haystack.views import search_view_factory + +# 导入自定义视图和配置:博客搜索视图、自定义Admin站点、ElasticSearch搜索表单 +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm +# 导入RSS订阅Feed和站点地图类 +from djangoblog.feeds import DjangoBlogFeed +from djangoblog.sitemap import (ArticleSiteMap, CategorySiteMap, + StaticViewSitemap, TagSiteMap, UserSiteMap) + + +# -------------------------- 站点地图配置 -------------------------- +# 定义站点地图字典:关联不同类型页面的Sitemap类,用于生成sitemap.xml +sitemaps = { + 'blog': ArticleSiteMap, # 文章页面的站点地图 + 'Category': CategorySiteMap, # 分类页面的站点地图 + 'Tag': TagSiteMap, # 标签页面的站点地图 + 'User': UserSiteMap, # 用户主页的站点地图 + 'static': StaticViewSitemap # 静态页面(如首页)的站点地图 +} + + +# -------------------------- 自定义错误页面配置 -------------------------- +# 配置404(页面不存在)错误对应的处理视图 +handler404 = 'blog.views.page_not_found_view' +# 配置500(服务器内部错误)错误对应的处理视图 +handler500 = 'blog.views.server_error_view' +# 配置403(权限不足)错误对应的处理视图 +handle403 = 'blog.views.permission_denied_view' + + +# -------------------------- 基础URL配置 -------------------------- +# 非国际化URL列表:不随语言切换变化的URL +urlpatterns = [ + # 国际化切换入口:提供语言选择功能(如切换中英文) + path('i18n/', include('django.conf.urls.i18n')), +] + + +# -------------------------- 国际化URL配置 -------------------------- +# 国际化URL列表:会自动添加语言前缀(如/zh-hans/admin/、/en/admin/) +# prefix_default_language=False:默认语言(如中文)不添加语言前缀,保持URL简洁 +urlpatterns += i18n_patterns( + # 自定义Admin后台URL:使用项目自定义的admin_site(非Django默认Admin) + re_path(r'^admin/', admin_site.urls), + # 博客核心功能URL:包含文章列表、详情等,命名空间为'blog' + re_path(r'', include('blog.urls', namespace='blog')), + # Markdown编辑器URL:集成mdeditor插件的路由 + re_path(r'mdeditor/', include('mdeditor.urls')), + # 评论功能URL:包含评论提交、列表等,命名空间为'comment' + re_path(r'', include('comments.urls', namespace='comment')), + # 用户账户功能URL:包含登录、注册、个人中心等,命名空间为'account' + re_path(r'', include('accounts.urls', namespace='account')), + # 第三方登录URL:包含GitHub、微信等登录,命名空间为'oauth' + re_path(r'', include('oauth.urls', namespace='oauth')), + # 站点地图URL:生成sitemap.xml,供搜索引擎抓取 + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + # RSS订阅URL:提供两种路径(/feed/和/rss/),均指向DjangoBlogFeed + re_path(r'^feed/$', DjangoBlogFeed()), + re_path(r'^rss/$', DjangoBlogFeed()), + # 搜索功能URL:使用ElasticSearch搜索视图和表单,命名空间为'search' + re_path('^search', search_view_factory( + view_class=EsSearchView, + form_class=ElasticSearchModelSearchForm + ), name='search'), + # 服务器管理功能URL:包含系统监控等,命名空间为'servermanager' + re_path(r'', include('servermanager.urls', namespace='servermanager')), + # 位置追踪功能URL:集成owntracks的路由,命名空间为'owntracks' + re_path(r'', include('owntracks.urls', namespace='owntracks')), + prefix_default_language=False # 默认语言URL不添加语言前缀 +) + + +# -------------------------- 静态资源与媒体文件URL配置 -------------------------- +# 开发环境下:添加静态文件URL映射(生产环境由Nginx/Apache处理) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +# DEBUG模式下(开发环境):添加媒体文件(用户上传文件)的URL映射 +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) +======= +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'), + ] +>>>>>>> sh_branch +======= + +from django.urls import path + +# 导入当前应用(comments)的views模块,用于关联视图函数/类 +from . import views + +# 定义当前应用的命名空间为"comments",避免URL名称冲突 +app_name = "comments" + +# 定义URL路由列表,存储URL规则与视图的映射关系 +urlpatterns = [ + + path( + 'article//postcomment', # URL路径:包含文章ID(整数类型)的动态路径 + views.CommentPostView.as_view(), # 关联的视图类:调用CommentPostView的as_view()方法生成视图函数 + name='postcomment'), # 给该URL命名为"postcomment",用于反向解析 +] +>>>>>>> zh_branch +======= +from django.urls import path +from django.views.decorators.cache import cache_page + +from . import views + +# 应用命名空间,用于URL反向解析时区分不同应用的URL +app_name = "blog" + +# URL模式配置,定义了博客应用的所有URL路由 +urlpatterns = [ + # 首页路由 + path( + r'', # 空路径,匹配根URL(如:/ 或 /blog/) + views.IndexView.as_view(), # 使用类视图处理首页 + name='index' # URL名称,用于反向解析 + ), + + # 首页分页路由 + path( + r'page//', # 带页码的路径(如:/page/2/) + views.IndexView.as_view(), # 使用相同的类视图,但会处理分页 + name='index_page' # URL名称 + ), + + # 文章详情页路由(SEO友好URL) + path( + r'article////.html', # 包含年月日和文章ID的URL + views.ArticleDetailView.as_view(), # 文章详情类视图 + name='detailbyid' # URL名称 + ), + + # 分类详情页路由 + path( + r'category/.html', # 使用分类名称的slug格式 + views.CategoryDetailView.as_view(), # 分类详情类视图 + name='category_detail' # URL名称 + ), + + # 分类详情分页路由 + path( + r'category//.html', # 带页码的分类URL + views.CategoryDetailView.as_view(), # 相同的类视图处理分页 + name='category_detail_page' # URL名称 + ), + + # 作者详情页路由 + path( + r'author/.html', # 使用作者名称的URL + views.AuthorDetailView.as_view(), # 作者详情类视图 + name='author_detail' # URL名称 + ), + + # 作者详情分页路由 + path( + r'author//.html', # 带页码的作者URL + views.AuthorDetailView.as_view(), # 相同的类视图处理分页 + name='author_detail_page' # URL名称 + ), + + # 标签详情页路由 + path( + r'tag/.html', # 使用标签名称的slug格式 + views.TagDetailView.as_view(), # 标签详情类视图 + name='tag_detail' # URL名称 + ), + + # 标签详情分页路由 + path( + r'tag//.html', # 带页码的标签URL + views.TagDetailView.as_view(), # 相同的类视图处理分页 + name='tag_detail_page' # URL名称 + ), + + # 文章归档页路由(带缓存) + path( + 'archives.html', # 归档页面URL + cache_page(60 * 60)(views.ArchivesView.as_view()), # 使用缓存装饰器,缓存1小时 + name='archives' # URL名称 + ), + + # 友情链接页面路由 + path( + 'links.html', # 友情链接页面URL + views.LinkListView.as_view(), # 链接列表类视图 + name='links' # URL名称 + ), + + # 文件上传路由 + path( + r'upload', # 文件上传端点 + views.fileupload, # 使用函数视图处理文件上传 + name='upload' # URL名称 + ), + + # 缓存清理路由 + path( + r'clean', # 缓存清理端点 + views.clean_cache_view, # 使用函数视图处理缓存清理 + name='clean' # URL名称 + ), +] +>>>>>>> hyt_branch diff --git a/src/user_login_backend.py b/src/user_login_backend.py new file mode 100644 index 0000000..73cdca1 --- /dev/null +++ b/src/user_login_backend.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailOrUsernameModelBackend(ModelBackend): + """ + 允许使用用户名或邮箱登录 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + if '@' in username: + kwargs = {'email': username} + else: + kwargs = {'username': username} + try: + user = get_user_model().objects.get(**kwargs) + if user.check_password(password): + return user + except get_user_model().DoesNotExist: + return None + + def get_user(self, username): + try: + return get_user_model().objects.get(pk=username) + except get_user_model().DoesNotExist: + return None diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..3b4a8bf --- /dev/null +++ b/src/utils.py @@ -0,0 +1,468 @@ +<<<<<<< HEAD +<<<<<<< HEAD +#!/usr/bin/env python +# encoding: utf-8 + + +# 导入必要模块:日志、文件操作、随机数生成、加密、HTTP请求等 +import logging +import os +import random +import string +import uuid +from hashlib import sha256 + +# 导入第三方库:HTML过滤、Markdown转换、HTTP请求 +import bleach +import markdown +import requests +# 导入Django核心模块:配置、缓存、站点模型、静态文件工具 +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.templatetags.static import static + +# 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +def get_max_articleid_commentid(): + """ + 获取最大的文章ID和评论ID + 用于系统统计或数据同步场景,快速获取最新数据的ID边界 + """ + # 延迟导入模型(避免循环导入问题) + from blog.models import Article + from comments.models import Comment + # 返回最新文章和评论的主键(ID) + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + """ + 对字符串进行SHA256加密 + 用于密码加密、数据校验等场景(如生成唯一标识) + """ + m = sha256(str.encode('utf-8')) # 编码为UTF-8后加密 + return m.hexdigest() # 返回十六进制加密结果 + + +def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器:为函数添加缓存功能,减少重复计算或数据库查询 + 默认缓存时间为3分钟(180秒),可通过参数调整 + + Args: + expiration: 缓存过期时间(秒) + """ + + def wrapper(func): + def news(*args, **kwargs): + # 尝试从请求对象中获取缓存键(适用于视图函数) + try: + view = args[0] + key = view.get_cache_key() + except: + key = None # 非视图函数则生成自定义键 + + # 生成自定义缓存键(基于函数和参数的唯一标识) + if not key: + unique_str = repr((func, args, kwargs)) # 序列化函数和参数 + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() # 生成唯一哈希键 + + # 从缓存获取数据 + value = cache.get(key) + if value is not None: + # 处理空值标记(避免缓存None导致的重复计算) + if str(value) == '__default_cache_value__': + return None + else: + return value # 返回缓存数据 + else: + # 缓存未命中,执行原函数并缓存结果 + logger.debug(f'cache_decorator set cache:{func.__name__} key:{key}') + value = func(*args, **kwargs) + # 缓存空值时用特殊标记,避免缓存穿透 + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +def expire_view_cache(path, servername, serverport, key_prefix=None): + ''' + 主动刷新视图缓存:删除指定URL路径的缓存 + 用于数据更新后同步清理缓存,确保用户看到最新内容 + + Args: + path: URL路径(如'/article/1/') + servername: 服务器域名/主机名 + serverport: 服务器端口 + key_prefix: 缓存键前缀 + + Returns: + bool: 缓存是否成功删除 + ''' + from django.http import HttpRequest + from django.utils.cache import get_cache_key + + # 构造模拟请求对象(用于生成缓存键) + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + # 获取缓存键并删除缓存 + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info(f'expire_view_cache:get key:{path}') + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + """ + 获取当前站点信息(缓存装饰器确保高效获取) + 基于Django的sites框架,用于生成绝对URL等场景 + """ + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + """ + Markdown转换工具类:将Markdown文本转换为HTML,并支持生成目录(TOC) + 集成代码高亮、表格等扩展功能 + """ + + @staticmethod + def _convert_markdown(value): + """内部转换方法:执行Markdown到HTML的转换,返回内容和目录""" + md = markdown.Markdown( + extensions=[ + 'extra', # 基础扩展(表格、脚注等) + 'codehilite', # 代码高亮 + 'toc', # 目录生成 + 'tables', # 表格支持 + ] + ) + body = md.convert(value) # 转换正文为HTML + toc = md.toc # 提取目录 + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + """获取带目录的HTML内容""" + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + """仅获取转换后的HTML正文(不含目录)""" + body, toc = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + """ + 发送邮件的封装函数:通过信号机制发送邮件,解耦邮件发送逻辑 + 实际发送由信号接收者处理(如调用Django邮件后端) + """ + from djangoblog.blog_signals import send_email_signal + # 发送信号,传递邮件参数 + send_email_signal.send( + send_email.__class__, + emailto=emailto, + title=title, + content=content) + + +def generate_code() -> str: + """生成6位数字随机验证码,用于邮箱验证、登录等场景""" + return ''.join(random.sample(string.digits, 6)) # 从0-9中随机选择6个数字 + + +def parse_dict_to_url(dict): + """ + 将字典转换为URL查询参数字符串(如{'a':1,'b':2} → 'a=1&b=2') + 自动对键值进行URL编码,支持特殊字符 + + Args: + dict: 键值对字典 + + Returns: + str: URL查询参数字符串 + """ + from urllib.parse import quote + return '&'.join([ + f'{quote(k, safe="/")}={quote(v, safe="/")}' + for k, v in dict.items() + ]) + + +def get_blog_setting(): + """ + 获取博客系统设置(单例模式),并缓存结果 + 包含站点名称、描述、SEO配置等核心设置 + + Returns: + BlogSettings对象:系统设置实例 + """ + # 先从缓存获取,减少数据库查询 + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + # 若不存在设置记录,创建默认配置 + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + # 获取设置并缓存 + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像到本地静态目录,并返回访问URL + 用于处理第三方登录(如GitHub)的头像保存 + + Args: + url: 头像的远程URL + + Returns: + str: 本地头像的静态文件URL(默认返回系统默认头像) + ''' + logger.info(url) + try: + # 定义本地保存路径(static/avatar目录) + basedir = os.path.join(settings.STATICFILES, 'avatar') + # 下载头像图片 + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + # 创建目录(若不存在) + if not os.path.exists(basedir): + os.makedirs(basedir) + + # 验证文件类型并确定扩展名 + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + # 生成唯一文件名(UUID避免冲突) + save_filename = str(uuid.uuid4().hex) + ext + logger.info(f'保存用户头像:{basedir}{save_filename}') + # 写入文件 + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + # 返回静态文件URL + return static('avatar/' + save_filename) + except Exception as e: + logger.error(e) + # 失败时返回默认头像 + return static('blog/img/avatar.png') + + +def delete_sidebar_cache(): + """ + 删除侧边栏缓存:当侧边栏数据(如链接、文章列表)更新时调用 + 确保用户看到最新的侧边栏内容 + """ + from blog.models import LinkShowType + # 生成所有侧边栏缓存键(基于链接展示类型) + keys = ["sidebar" + x for x in LinkShowType.values] + for k in keys: + logger.info(f'delete sidebar key:{k}') + cache.delete(k) + + +def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存:用于删除指定前缀和参数的模板缓存 + 如文章详情页的评论区缓存 + + Args: + prefix: 缓存前缀(模板中定义) + keys: 缓存键参数列表 + """ + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + """ + 获取静态资源基础URL + 优先使用settings中的STATIC_URL,否则基于当前站点域名生成 + + Returns: + str: 静态资源URL前缀(如'http://example.com/static/') + """ + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return f'http://{site.domain}/static/' + + +# HTML过滤配置:限制允许的标签和属性,防止XSS攻击 +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', + 'li', 'ol', 'pre', 'strong', 'ul', 'h1', 'h2', 'p'] +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title'], + 'abbr': ['title'], + 'acronym': ['title'] +} + + +def sanitize_html(html): + """ + 净化HTML内容:仅保留允许的标签和属性,过滤恶意代码 + 用于处理用户输入的HTML(如评论、文章内容),防止XSS攻击 + + Args: + html: 原始HTML字符串 + + Returns: + str: 净化后的安全HTML + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES + ) +======= +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """发送重设密码验证码 + Args: + to_mail: 接受邮箱 + subject: 邮件主题 + code: 验证码 + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """验证code是否有效 + Args: + email: 请求邮箱 + code: 验证码 + Return: + 如果有错误就返回错误str + Node: + 这里的错误处理不太合理,应该采用raise抛出 + 否测调用方也需要对error进行处理 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """设置code""" + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """获取code""" + return cache.get(email) +>>>>>>> sh_branch +======= +# 导入logging模块,用于记录日志(如异常信息) +import logging + +# 导入国际化翻译函数,用于生成多语言邮件内容 +from django.utils.translation import gettext_lazy as _ + +# 导入项目工具函数,获取当前站点域名 +from djangoblog.utils import get_current_site +# 导入项目工具函数,用于发送邮件 +from djangoblog.utils import send_email + +# 创建当前模块的日志记录器,用于记录该函数的运行日志 +logger = logging.getLogger(__name__) + + +# 定义发送评论相关邮件的函数,接收评论对象作为参数 +def send_comment_email(comment): + # 获取当前站点的域名(用于拼接文章链接) + site = get_current_site().domain + # 定义邮件主题(多语言翻译,如中文为“感谢您的评论”) + subject = _('Thanks for your comment') + # 拼接评论所属文章的完整URL(HTTPS协议 + 域名 + 文章相对路径) + article_url = f"https://{site}{comment.article.get_absolute_url()}" + # 定义邮件HTML内容(多语言模板,包含感谢语、文章链接、链接提示) + # 使用字符串格式化,替换{article_url}和{article_title}为实际值 + html_content = _("""

Thank you very much for your comments on this site

+ You can visit %(article_title)s + to review your comments, + Thank you again! +
+ 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函数发送邮件:收件人列表、主题、HTML内容 + send_email([tomail], subject, html_content) + + # 尝试给父评论作者发送“评论被回复”的邮件(若当前评论是回复) + try: + # 判断当前评论是否有父评论(即是否为回复) + if comment.parent_comment: + # 定义回复通知的HTML邮件内容(多语言模板) + # 包含父评论所属文章链接、父评论内容、查看提示 + html_content = _("""Your comment on %(article_title)s
has + received a reply.
%(comment_body)s +
+ go check it out! +
+ 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, + '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) +>>>>>>> zh_branch diff --git a/src/views.py b/src/views.py new file mode 100644 index 0000000..98e5269 --- /dev/null +++ b/src/views.py @@ -0,0 +1,807 @@ +<<<<<<< HEAD +<<<<<<< HEAD +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser +======= +import logging +import os +import uuid + +from django.conf import settings +from django.core.paginator import Paginator +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.templatetags.static import static +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView +from haystack.views import SearchView + +from 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 +>>>>>>> hyt_branch + +logger = logging.getLogger(__name__) + + +<<<<<<< HEAD +# Create your views here. + +class RegisterView(FormView): + form_class = RegisterForm + template_name = 'account/registration_form.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + if form.is_valid(): + user = form.save(False) + user.is_active = False + user.source = 'Register' + user.save(True) + site = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + content = """ +

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

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + return self.render_to_response({ + 'form': form + }) + + +class LogoutView(RedirectView): + url = '/login/' + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + logout(request) + delete_sidebar_cache() + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + form_class = LoginForm + template_name = 'account/login.html' + success_url = '/' + redirect_field_name = REDIRECT_FIELD_NAME + login_ttl = 2626560 # 一个月的时间 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' + kwargs['redirect_to'] = redirect_to + + return super(LoginView, self).get_context_data(**kwargs) + + def form_valid(self, form): + form = AuthenticationForm(data=self.request.POST, request=self.request) + + if form.is_valid(): + delete_sidebar_cache() + logger.info(self.redirect_field_name) + + auth.login(self.request, form.get_user()) + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + return super(LoginView, self).form_valid(form) + # return HttpResponseRedirect('/') + else: + return self.render_to_response({ + 'form': form + }) + + def get_success_url(self): + + redirect_to = self.request.POST.get(self.redirect_field_name) + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): + redirect_to = self.success_url + return redirect_to + + +def account_result(request): + type = request.GET.get('type') + id = request.GET.get('id') + + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) + if user.is_active: + return HttpResponseRedirect('/') + if type and type in ['register', 'validation']: + if type == 'register': + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + if sign != c_sign: + return HttpResponseForbidden() + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + form_class = ForgetPasswordForm + template_name = 'account/forget_password.html' + + def form_valid(self, form): + if form.is_valid(): + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() + return HttpResponseRedirect('/login/') + else: + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + + def post(self, request: HttpRequest): + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") + to_email = form.cleaned_data["email"] + + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") +======= +from django.core.exceptions import ValidationError + +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +# 导入方法装饰器工具,用于装饰类中的方法 +from django.utils.decorators import method_decorator +# 导入CSRF保护装饰器,防止跨站请求伪造 +from django.views.decorators.csrf import csrf_protect +# 导入表单视图基类,用于处理表单提交逻辑 +from django.views.generic.edit import FormView + +# 导入用户模型,用于获取评论作者信息 +from accounts.models import BlogUser +# 导入文章模型,用于关联评论所属文章 +from blog.models import Article +# 导入评论表单类,用于处理评论提交数据 +from .forms import CommentForm +# 导入评论模型,用于创建和保存评论 +from .models import Comment + + +# 定义评论提交视图类,继承自FormView(表单处理基类) +class CommentPostView(FormView): + form_class = CommentForm # 指定使用的表单类为CommentForm + template_name = 'blog/article_detail.html' # 指定表单验证失败时渲染的模板 + + # 使用CSRF保护装饰器装饰dispatch方法,确保表单提交安全 + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + # 调用父类的dispatch方法,处理请求分发 + return super(CommentPostView, self).dispatch(*args, **kwargs) + + # 处理GET请求:重定向到文章详情页的评论区 + def get(self, request, *args, **kwargs): + # 从URL参数中获取文章ID + article_id = self.kwargs['article_id'] + # 获取对应的文章对象,不存在则返回404 + article = get_object_or_404(Article, pk=article_id) + # 获取文章详情页的URL + url = article.get_absolute_url() + # 重定向到文章详情页的评论区(通过锚点#comments定位) + return HttpResponseRedirect(url + "#comments") + + # 处理表单验证失败的逻辑 + def form_invalid(self, form): + # 从URL参数中获取文章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 + }) + + # 处理表单验证成功后的逻辑 + def form_valid(self, form): + """提交的数据验证合法后的逻辑""" + # 获取当前登录用户 + user = self.request.user + # 根据用户ID获取对应的用户对象(评论作者) + 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']: + # 根据父评论ID获取父评论对象 + parent_comment = Comment.objects.get( + pk=form.cleaned_data['parent_comment_id']) + # 设置当前评论的父评论 + comment.parent_comment = parent_comment + + # 保存评论到数据库(执行真正的保存操作) + comment.save(True) + # 重定向到文章详情页的当前评论位置(通过锚点#div-comment-{评论ID}定位) + return HttpResponseRedirect( + "%s#div-comment-%d" % + (article.get_absolute_url(), comment.pk)) +>>>>>>> zh_branch +======= +class ArticleListView(ListView): + """ + 文章列表基类视图 + 提供通用的文章列表功能和缓存机制 + 所有文章列表视图都应该继承此类 + """ + # template_name属性用于指定使用哪个模板进行渲染 + template_name = 'blog/article_index.html' + + # context_object_name属性用于给上下文变量取名(在模板中使用该名字) + context_object_name = 'article_list' + + # 页面类型,分类目录或标签列表等 + page_type = '' + paginate_by = settings.PAGINATE_BY # 每页显示的文章数量 + page_kwarg = 'page' # URL中页码参数的名称 + 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 + return page + + def get_queryset_cache_key(self): + """ + 获取查询集缓存键 + 子类必须重写此方法 + """ + raise NotImplementedError() + + def get_queryset_data(self): + """ + 获取查询集数据 + 子类必须重写此方法 + """ + raise NotImplementedError() + + def get_queryset_from_cache(self, cache_key): + """ + 从缓存获取查询集数据 + Args: + cache_key: 缓存键 + Returns: + QuerySet: 文章查询集 + """ + value = cache.get(cache_key) + if value: + logger.info('get view cache.key:{key}'.format(key=cache_key)) + return value + else: + article_list = self.get_queryset_data() + cache.set(cache_key, article_list) + logger.info('set view cache.key:{key}'.format(key=cache_key)) + return article_list + + def get_queryset(self): + """ + 获取查询集 - 从缓存获取数据 + Returns: + QuerySet: 文章查询集 + """ + key = self.get_queryset_cache_key() + value = self.get_queryset_from_cache(key) + return value + + def get_context_data(self, **kwargs): + """添加上下文数据""" + kwargs['linktype'] = self.link_type + return super(ArticleListView, self).get_context_data(**kwargs) + + +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' # URL中主键参数的名称 + 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: + page = int(page) + if page < 1: + page = 1 + if page > paginator.num_pages: + page = paginator.num_pages + + # 获取当前页的评论 + p_comments = paginator.page(page) + + # 计算下一页和上一页 + next_page = p_comments.next_page_number() if p_comments.has_next() else None + prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + + # 构建评论分页URL + if next_page: + kwargs[ + 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs[ + 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' + + # 添加上下文数据 + kwargs['form'] = comment_form + kwargs['article_comments'] = article_comments + kwargs['p_comments'] = p_comments + kwargs['comment_count'] = len(article_comments) if article_comments else 0 + + # 添加上下篇文章信息 + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article + + # 调用父类方法获取基础上下文 + context = super(ArticleDetailView, self).get_context_data(**kwargs) + article = self.object + + # Action Hook, 通知插件"文章详情已获取" + hooks.run_action('after_article_body_get', article=article, request=self.request) + + # Filter Hook, 允许插件修改文章正文 + article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, + request=self.request) + + return context + + +class CategoryDetailView(ArticleListView): + """ + 分类目录列表视图 + 显示指定分类下的所有文章(包括子分类) + """ + page_type = "分类目录归档" + + def get_queryset_data(self): + """获取分类文章数据 - 包括所有子分类的文章""" + 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 + + def get_queryset_cache_key(self): + """获取分类页面缓存键 - 基于分类名称和页码""" + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) + categoryname = category.name + self.categoryname = categoryname + cache_key = 'category_list_{categoryname}_{page}'.format( + categoryname=categoryname, page=self.page_number) + return cache_key + + def get_context_data(self, **kwargs): + """添加上下文数据""" + categoryname = self.categoryname + try: + categoryname = categoryname.split('/')[-1] # 处理多层分类名称 + except BaseException: + pass + kwargs['page_type'] = CategoryDetailView.page_type + kwargs['tag_name'] = categoryname + return super(CategoryDetailView, self).get_context_data(**kwargs) + + +class AuthorDetailView(ArticleListView): + """ + 作者详情页视图 + 显示指定作者的所有文章 + """ + page_type = '作者文章归档' + + def get_queryset_cache_key(self): + """获取作者页面缓存键 - 基于作者名称和页码""" + from uuslug import slugify + author_name = slugify(self.kwargs['author_name']) # 使用slugify处理作者名 + cache_key = 'author_{author_name}_{page}'.format( + author_name=author_name, page=self.page_number) + return cache_key + + def get_queryset_data(self): + """获取作者文章数据 - 指定作者的所有已发布文章""" + author_name = self.kwargs['author_name'] + article_list = Article.objects.filter( + author__username=author_name, type='a', status='p') + return article_list + + def get_context_data(self, **kwargs): + """添加上下文数据""" + author_name = self.kwargs['author_name'] + kwargs['page_type'] = AuthorDetailView.page_type + kwargs['tag_name'] = author_name + return super(AuthorDetailView, self).get_context_data(**kwargs) + + +class TagDetailView(ArticleListView): + """ + 标签列表页面视图 + 显示指定标签下的所有文章 + """ + page_type = '分类标签归档' + + def get_queryset_data(self): + """获取标签文章数据 - 指定标签的所有已发布文章""" + slug = self.kwargs['tag_name'] + tag = get_object_or_404(Tag, slug=slug) + tag_name = tag.name + self.name = tag_name + article_list = Article.objects.filter( + tags__name=tag_name, type='a', status='p') + return article_list + + def get_queryset_cache_key(self): + """获取标签页面缓存键 - 基于标签名称和页码""" + slug = self.kwargs['tag_name'] + tag = get_object_or_404(Tag, slug=slug) + tag_name = tag.name + self.name = tag_name + cache_key = 'tag_{tag_name}_{page}'.format( + tag_name=tag_name, page=self.page_number) + return cache_key + + def get_context_data(self, **kwargs): + """添加上下文数据""" + 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 + template_name = 'blog/article_archives.html' # 使用专门的归档模板 + + def get_queryset_data(self): + """获取归档数据 - 所有已发布文章""" + return Article.objects.filter(status='p').all() + + def get_queryset_cache_key(self): + """获取归档页面缓存键 - 固定键名""" + cache_key = 'archives' + return cache_key + + +class LinkListView(ListView): + """ + 友情链接列表视图 + 显示所有启用的友情链接 + """ + model = Links # 关联的模型 + template_name = 'blog/links_list.html' # 友情链接模板 + + def get_queryset(self): + """获取启用的友情链接""" + return Links.objects.filter(is_enable=True) + + +class EsSearchView(SearchView): + """ + Elasticsearch搜索视图 + 扩展Haystack的搜索功能 + """ + + def get_context(self): + """获取搜索上下文数据""" + paginator, page = self.build_page() # 构建分页 + context = { + "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()) + + return context + + +@csrf_exempt # 免除CSRF验证,用于文件上传 +def fileupload(request): + """ + 文件上传视图 + 提供图床功能,支持图片和文件上传 + Args: + request: HTTP请求对象 + Returns: + HttpResponse: 上传结果 + """ + if request.method == 'POST': + # 验证签名,确保上传请求合法 + sign = request.GET.get('sign', None) + if not sign: + return HttpResponseForbidden() + if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): + return HttpResponseForbidden() + + response = [] + # 处理所有上传的文件 + for filename in request.FILES: + # 按日期创建目录结构 + timestr = timezone.now().strftime('%Y/%m/%d') + imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] # 图片文件扩展名 + fname = u''.join(str(filename)) + + # 判断是否为图片文件 + isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 + + # 创建存储目录 + base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) + if not os.path.exists(base_dir): + os.makedirs(base_dir) + + # 生成唯一文件名 + savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) + + # 安全检查:确保文件保存在指定目录内 + if not savepath.startswith(base_dir): + return HttpResponse("only for post") + + # 保存文件 + with open(savepath, 'wb+') as wfile: + for chunk in request.FILES[filename].chunks(): + wfile.write(chunk) + + # 如果是图片,进行压缩优化 + if isimage: + from PIL import Image + image = Image.open(savepath) + image.save(savepath, quality=20, optimize=True) # 压缩质量20% + + # 生成静态文件URL + url = static(savepath) + response.append(url) + return HttpResponse(response) + + else: + return HttpResponse("only for post") # 只支持POST请求 + + +def page_not_found_view( + request, + exception, + template_name='blog/error_page.html'): + """ + 404页面未找到视图 + Args: + request: 请求对象 + exception: 异常信息 + template_name: 模板名称 + Returns: + HttpResponse: 404错误页面 + """ + if exception: + logger.error(exception) # 记录异常日志 + url = request.get_full_path() + return render(request, + template_name, + {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'), + 'statuscode': '404'}, + status=404) + + +def server_error_view(request, template_name='blog/error_page.html'): + """ + 500服务器错误视图 + Args: + request: 请求对象 + template_name: 模板名称 + Returns: + HttpResponse: 500错误页面 + """ + return render(request, + template_name, + {'message': _('Sorry, the server is busy, please click the home page to see other?'), + 'statuscode': '500'}, + status=500) + + +def permission_denied_view( + request, + exception, + template_name='blog/error_page.html'): + """ + 403权限拒绝视图 + Args: + request: 请求对象 + exception: 异常信息 + template_name: 模板名称 + Returns: + HttpResponse: 403错误页面 + """ + if exception: + logger.error(exception) # 记录异常日志 + return render( + request, template_name, { + 'message': _('Sorry, you do not have permission to access this page?'), + 'statuscode': '403'}, status=403) + + +def clean_cache_view(request): + """ + 清理缓存视图 + 用于手动清理系统缓存 + Args: + request: 请求对象 + Returns: + HttpResponse: 清理结果 + """ + cache.clear() + return HttpResponse('ok') +>>>>>>> hyt_branch diff --git a/src/whoosh_cn_backend.py b/src/whoosh_cn_backend.py new file mode 100644 index 0000000..44964f3 --- /dev/null +++ b/src/whoosh_cn_backend.py @@ -0,0 +1,1120 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +# 导入必要模块:JSON处理、文件操作、正则、线程、警告等 +import json +import os +import re +import shutil +import threading +import warnings + +import six # 兼容Python 2/3 +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured # Django配置异常 +from datetime import datetime +from django.utils.encoding import force_str # 字符串编码处理 +# 导入Haystack核心模块:引擎、后端、查询、结果等基础类 +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query +from haystack.constants import DJANGO_CT, DJANGO_ID, ID # Haystack常量(模型类型、ID等) +from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument # Haystack异常 +from haystack.inputs import Clean, Exact, PythonData, Raw # Haystack查询输入类型 +from haystack.models import SearchResult # Haystack搜索结果模型 +from haystack.utils import get_identifier, get_model_ct # Haystack工具函数(获取唯一标识、模型类型) +from haystack.utils import log as logging # Haystack日志 +from haystack.utils.app_loading import haystack_get_model # Haystack模型加载工具 +from jieba.analyse import ChineseAnalyzer # 结巴中文分词器(用于中文搜索) +# 导入Whoosh核心模块:索引、分析器、字段、存储、高亮、查询解析、搜索结果等 +from whoosh import index +from whoosh.analysis import StemmingAnalyzer # Whoosh英文词干分析器 +from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT # Whoosh字段类型 +from whoosh.fields import ID as WHOOSH_ID # Whoosh ID字段(避免与Haystack的ID冲突) +from whoosh.filedb.filestore import FileStorage, RamStorage # Whoosh文件存储/内存存储 +from whoosh.highlight import ContextFragmenter, HtmlFormatter # Whoosh高亮相关 +from whoosh.highlight import highlight as whoosh_highlight # Whoosh高亮函数 +from whoosh.qparser import QueryParser # Whoosh查询解析器 +from whoosh.searching import ResultsPage # Whoosh分页结果 +from whoosh.writing import AsyncWriter # Whoosh异步写入器(提高写入效率) + + +# 检查Whoosh依赖是否安装 +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +# 检查Whoosh版本(要求2.5.0及以上) +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + + +# 正则表达式:匹配ISO格式的日期时间字符串(用于Whoosh与Python datetime转换) +DATETIME_REGEX = re.compile( + '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$') +# 线程本地存储:用于共享内存存储(RamStorage),避免多线程冲突 +LOCALS = threading.local() +LOCALS.RAM_STORE = None + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + 自定义Whoosh HTML高亮格式化器 + 简化默认格式,确保与其他搜索后端(如Solr、Elasticsearch)的高亮结果格式一致 + 使用标签包裹高亮文本(默认格式) + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + """ + Whoosh搜索后端实现类:继承自Haystack的BaseSearchBackend + 负责与Whoosh交互,实现索引创建、更新、删除、搜索等核心功能 + 支持中文分词(基于结巴分词) + """ + # Whoosh保留关键字(搜索时需特殊处理,避免语法错误) + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + # Whoosh保留字符(搜索时需转义或处理,避免语法错误) + # '\\'需放在首位,防止覆盖其他斜杠替换 + RESERVED_CHARACTERS = ( + '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', + '[', ']', '^', '"', '~', '*', '?', ':', '.', + ) + + def __init__(self, connection_alias, **connection_options): + """ + 初始化Whoosh搜索后端 + :param connection_alias: 连接别名(来自Haystack配置) + :param connection_options: 连接参数(如索引路径、存储类型等) + """ + super(WhooshSearchBackend, self).__init__(connection_alias, **connection_options) + self.setup_complete = False # 初始化完成标记(延迟初始化) + self.use_file_storage = True # 默认使用文件存储(FileStorage) + # POST请求大小限制(默认128MB) + self.post_limit = getattr(connection_options, 'POST_LIMIT', 128 * 1024 * 1024) + # 索引存储路径(从配置中获取) + self.path = connection_options.get('PATH') + + # 检查存储类型:若配置为非文件存储(如内存),则使用RamStorage + if connection_options.get('STORAGE', 'file') != 'file': + self.use_file_storage = False + + # 若使用文件存储但未配置路径,抛出配置异常 + if self.use_file_storage and not self.path: + raise ImproperlyConfigured( + "You must specify a 'PATH' in your settings for connection '%s'." % connection_alias) + + # 初始化日志记录器 + self.log = logging.getLogger('haystack') + + def setup(self): + """ + 延迟初始化:创建索引存储和Schema,初始化Whoosh索引 + 避免项目启动时立即加载,仅在首次使用搜索功能时执行 + """ + from haystack import connections # 延迟导入,避免循环导入 + new_index = False # 是否为新创建的索引(首次初始化) + + # 若使用文件存储且路径不存在,创建目录并标记为新索引 + if self.use_file_storage and not os.path.exists(self.path): + os.makedirs(self.path) + new_index = True + + # 检查文件存储路径是否可写 + if self.use_file_storage and not os.access(self.path, os.W_OK): + raise IOError( + "The path to your Whoosh index '%s' is not writable for the current user/group." % self.path) + + # 初始化存储:文件存储或内存存储 + if self.use_file_storage: + self.storage = FileStorage(self.path) + else: + global LOCALS + # 内存存储共享(线程本地存储,避免多线程重复创建) + if getattr(LOCALS, 'RAM_STORE', None) is None: + LOCALS.RAM_STORE = RamStorage() + self.storage = LOCALS.RAM_STORE + + # 构建Whoosh Schema(索引结构):从Haystack统一索引获取字段 + unified_index = connections[self.connection_alias].get_unified_index() + self.content_field_name, self.schema = self.build_schema(unified_index.all_searchfields()) + # 初始化查询解析器(基于内容字段和Schema) + self.parser = QueryParser(self.content_field_name, schema=self.schema) + + # 若为新索引,创建索引;否则打开现有索引(不存在则创建) + if new_index is True: + self.index = self.storage.create_index(self.schema) + else: + try: + self.index = self.storage.open_index(schema=self.schema) + except index.EmptyIndexError: + self.index = self.storage.create_index(self.schema) + + # 标记初始化完成 + self.setup_complete = True + + def build_schema(self, fields): + """ + 构建Whoosh Schema(索引结构):将Haystack字段映射为Whoosh字段类型 + :param fields: Haystack统一索引中的所有字段(dict,key为字段名,value为字段类) + :return: (content_field_name, schema):内容字段名(主搜索字段)、Whoosh Schema对象 + """ + # 初始化Schema字段:包含Haystack默认字段(ID、模型类型、模型ID) + schema_fields = { + ID: WHOOSH_ID(stored=True, unique=True), # 文档唯一ID(Haystack标识) + DJANGO_CT: WHOOSH_ID(stored=True), # 模型类型(如blog.Article) + DJANGO_ID: WHOOSH_ID(stored=True), # 模型主键ID + } + # 初始字段数量(用于后续检查是否有有效字段) + initial_key_count = len(schema_fields) + content_field_name = '' # 主内容字段名(标记为document=True的字段) + + # 遍历Haystack字段,映射为对应的Whoosh字段 + for field_name, field_class in fields.items(): + index_fieldname = field_class.index_fieldname # 索引中的实际字段名 + # 处理多值字段(如标签、分类) + if field_class.is_multivalued: + if not field_class.indexed: + # 非索引多值字段:使用IDLIST(存储但不索引) + schema_fields[index_fieldname] = IDLIST(stored=True, field_boost=field_class.boost) + else: + # 索引多值字段:使用KEYWORD(逗号分隔,可索引、可排序) + schema_fields[index_fieldname] = KEYWORD( + stored=True, commas=True, scorable=True, field_boost=field_class.boost) + # 处理日期/日期时间字段 + elif field_class.field_type in ['date', 'datetime']: + schema_fields[index_fieldname] = DATETIME(stored=field_class.stored, sortable=True) + # 处理整数字段 + elif field_class.field_type == 'integer': + schema_fields[index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=int, field_boost=field_class.boost) + # 处理浮点数字段 + elif field_class.field_type == 'float': + schema_fields[index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=float, field_boost=field_class.boost) + # 处理布尔字段 + elif field_class.field_type == 'boolean': + # Whoosh BOOLEAN字段不支持boost(2.5.0+版本) + schema_fields[index_fieldname] = BOOLEAN(stored=field_class.stored) + # 处理NGram字段(适用于模糊搜索,如拼音、部分匹配) + elif field_class.field_type == 'ngram': + schema_fields[index_fieldname] = NGRAM( + minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + # 处理Edge NGram字段(适用于前缀匹配,如搜索"py"匹配"Python") + elif field_class.field_type == 'edge_ngram': + schema_fields[index_fieldname] = NGRAMWORDS( + minsize=2, maxsize=15, at='start', stored=field_class.stored, field_boost=field_class.boost) + # 默认字段类型:文本字段(支持中文分词) + else: + # 替换默认的StemmingAnalyzer(英文词干)为ChineseAnalyzer(结巴中文分词) + schema_fields[index_fieldname] = TEXT( + stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + + # 标记主内容字段(document=True的字段,用于默认搜索) + if field_class.document is True: + content_field_name = index_fieldname + # 启用拼写检查(仅主内容字段支持) + schema_fields[index_fieldname].spelling = True + + # 检查是否有有效字段(若仅包含初始字段,说明未配置任何搜索字段) + if len(schema_fields) <= initial_key_count: + raise SearchBackendError( + "No fields were found in any search_indexes. Please correct this before attempting to search.") + + # 创建并返回Whoosh Schema + return (content_field_name, Schema(**schema_fields)) + + def update(self, index, iterable, commit=True): + """ + 更新索引:将模型对象批量添加/更新到Whoosh索引 + :param index: Haystack索引对象(对应某个模型的索引配置) + :param iterable: 模型对象迭代器(需索引的对象列表) + :param commit: 是否立即提交(此处强制提交,避免锁问题) + """ + # 若未初始化,先执行setup + if not self.setup_complete: + self.setup() + + # 刷新索引(确保获取最新状态) + self.index = self.index.refresh() + # 使用异步写入器(提高批量写入效率,避免阻塞) + writer = AsyncWriter(self.index) + + # 遍历对象,处理并写入索引 + for obj in iterable: + try: + # 准备文档数据(调用Haystack索引的full_prepare方法,处理字段值) + doc = index.full_prepare(obj) + except SkipDocument: + # 跳过无需索引的对象(如草稿文章) + self.log.debug(u"Indexing for object `%s` skipped", obj) + else: + # 转换文档值为Whoosh支持的格式(如datetime转字符串、布尔值转'true'/'false') + for key in doc: + doc[key] = self._from_python(doc[key]) + + # Whoosh 2.5.0+不支持文档级boost,删除该字段 + if 'boost' in doc: + del doc['boost'] + + try: + # 更新文档:若ID存在则更新,不存在则新增 + writer.update_document(**doc) + except Exception as e: + # 若设置为静默失败,则仅记录日志;否则抛出异常 + if not self.silently_fail: + raise + # 记录错误日志(包含对象标识,避免编码问题) + self.log.error( + u"%s while preparing object for update" % e.__class__.__name__, + exc_info=True, + extra={"data": {"index": index, "object": get_identifier(obj)}}) + + # 批量写入后强制提交(Whoosh需提交才会持久化) + if len(iterable) > 0: + writer.commit() + + def remove(self, obj_or_string, commit=True): + """ + 删除索引:从Whoosh索引中删除指定模型对象 + :param obj_or_string: 模型对象或对象唯一标识(get_identifier返回值) + :param commit: 是否立即提交(Whoosh删除后自动提交,此处参数仅为兼容) + """ + if not self.setup_complete: + self.setup() + + # 刷新索引 + self.index = self.index.refresh() + # 获取对象的唯一标识(用于Whoosh查询删除) + whoosh_id = get_identifier(obj_or_string) + + try: + # 构造查询:根据ID删除文档 + delete_query = self.parser.parse(u'%s:"%s"' % (ID, whoosh_id)) + self.index.delete_by_query(q=delete_query) + except Exception as e: + if not self.silently_fail: + raise + # 记录删除失败日志 + self.log.error( + "Failed to remove document '%s' from Whoosh: %s", + whoosh_id, e, exc_info=True) + + def clear(self, models=None, commit=True): + """ + 清空索引:删除指定模型的所有索引,或清空整个索引 + :param models: 模型列表(如[Article, Comment]),为None则清空所有 + :param commit: 是否立即提交(Whoosh删除后自动提交) + """ + if not self.setup_complete: + self.setup() + + # 刷新索引 + self.index = self.index.refresh() + + # 验证models参数是否为列表/元组 + if models is not None: + assert isinstance(models, (list, tuple)) + + try: + # 清空整个索引(效率更高:直接删除索引文件/内存存储) + if models is None: + self.delete_index() + # 仅清空指定模型的索引 + else: + models_to_delete = [] + # 遍历模型,生成模型类型查询条件(如DJANGO_CT:blog.Article) + for model in models: + models_to_delete.append(u"%s:%s" % (DJANGO_CT, get_model_ct(model))) + # 构造OR查询,删除所有匹配模型的文档 + delete_query = self.parser.parse(u" OR ".join(models_to_delete)) + self.index.delete_by_query(q=delete_query) + except Exception as e: + if not self.silently_fail: + raise + # 记录清空失败日志 + if models is not None: + self.log.error( + "Failed to clear Whoosh index of models '%s': %s", + ','.join(models_to_delete), e, exc_info=True) + else: + self.log.error("Failed to clear Whoosh index: %s", e, exc_info=True) + + def delete_index(self): + """ + 彻底删除索引:删除索引存储(文件或内存),并重新初始化 + 比clear更彻底,适用于重建索引场景 + """ + # 文件存储:删除索引目录 + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + # 内存存储:清空存储 + elif not self.use_file_storage: + self.storage.clean() + + # 重新初始化索引(创建新的空索引) + self.setup() + + def optimize(self): + """ + 优化索引:整理Whoosh索引文件,提高搜索效率 + Whoosh会合并小索引段,减少磁盘IO + """ + if not self.setup_complete: + self.setup() + + # 刷新并优化索引 + self.index = self.index.refresh() + self.index.optimize() + + def calculate_page(self, start_offset=0, end_offset=None): + """ + 计算分页参数:将Haystack的start/end偏移量转换为Whoosh的页码和页长 + Whoosh使用页码(1-based)和页长,而非偏移量 + :param start_offset: 起始偏移量(从0开始) + :param end_offset: 结束偏移量(不包含) + :return: (page_num, page_length):页码、页长 + """ + # 处理end_offset为0或负数的情况(避免Whoosh报错) + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + # 初始化默认值 + page_num = 0 + if end_offset is None: + end_offset = 1000000 # 默认最大页长(获取所有结果) + if start_offset is None: + start_offset = 0 + + # 计算页长(end - start)和页码(start / 页长,向上取整) + page_length = end_offset - start_offset + if page_length and page_length > 0: + page_num = int(start_offset / page_length) + + # Whoosh页码为1-based,故加1 + page_num += 1 + return page_num, page_length + + @log_query + def search( + self, + query_string, + sort_by=None, + start_offset=0, + end_offset=None, + fields='', + highlight=False, + facets=None, + date_facets=None, + query_facets=None, + narrow_queries=None, + spelling_query=None, + within=None, + dwithin=None, + distance_point=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + """ + 核心搜索方法:执行查询并返回处理后的结果 + 支持分页、排序、高亮、过滤模型等功能 + :param query_string: 搜索关键词 + :param sort_by: 排序字段列表(如['-pub_time', 'title']) + :param start_offset/end_offset: 分页偏移量 + :param highlight: 是否开启结果高亮 + :param models: 限制搜索的模型列表 + :param result_class: 搜索结果类(默认SearchResult) + :return: 搜索结果字典(含results列表、hits总数、facets、拼写建议等) + """ + # 初始化检查 + if not self.setup_complete: + self.setup() + + # 空查询字符串返回空结果 + if len(query_string) == 0: + return {'results': [], 'hits': 0} + + # 转换查询字符串为Unicode(兼容Python 2) + query_string = force_str(query_string) + + # 单字符查询(非通配符)返回空结果(通常为停用词,无意义) + if len(query_string) <= 1 and query_string != u'*': + return {'results': [], 'hits': 0} + + # 处理排序:Whoosh要求所有排序字段方向一致(均升序或均降序) + reverse = False # 是否倒序(默认升序) + if sort_by is not None: + sort_by_list = [] + reverse_counter = 0 # 倒序字段计数 + + # 统计倒序字段数量 + for order_by in sort_by: + if order_by.startswith('-'): + reverse_counter += 1 + + # Whoosh不支持混合排序方向,抛出异常 + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields to use the same sort direction") + + # 提取排序字段(去掉'-'符号),确定排序方向 + for order_by in sort_by: + if order_by.startswith('-'): + sort_by_list.append(order_by[1:]) + if len(sort_by_list) == 1: + reverse = True + else: + sort_by_list.append(order_by) + if len(sort_by_list) == 1: + reverse = False + + # Whoosh仅支持单个排序字段,取第一个 + sort_by = sort_by_list[0] + + # Whoosh不支持分面搜索(facets),给出警告 + if facets is not None: + warnings.warn("Whoosh does not handle faceting.", Warning, stacklevel=2) + if date_facets is not None: + warnings.warn("Whoosh does not handle date faceting.", Warning, stacklevel=2) + if query_facets is not None: + warnings.warn("Whoosh does not handle query faceting.", Warning, stacklevel=2) + + # 处理过滤查询(narrow_queries):限制搜索结果范围 + narrowed_results = None # 过滤后的结果集 + self.index = self.index.refresh() + + # 处理模型过滤:限制仅搜索指定模型或已注册模型 + if limit_to_registered_models is None: + # 从配置获取默认值(是否仅搜索已注册模型) + limit_to_registered_models = getattr(settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + model_choices = [] + if models and len(models): + # 限制搜索指定模型(如[Article]) + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # 限制搜索所有已注册模型(通过Haystack路由获取) + model_choices = self.build_models_list() + + # 将模型过滤添加到narrow_queries + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + # 构造OR查询:匹配任一模型类型 + model_query = ' OR '.join(['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]) + narrow_queries.add(model_query) + + # 执行过滤查询:获取符合所有narrow_queries的结果集 + narrow_searcher = None + if narrow_queries is not None: + narrow_searcher = self.index.searcher() + for nq in narrow_queries: + # 解析过滤查询并执行(获取所有匹配结果) + nq_parsed = self.parser.parse(force_str(nq)) + recent_narrowed = narrow_searcher.search(nq_parsed, limit=None) + + # 若任一过滤条件无结果,直接返回空结果 + if len(recent_narrowed) <= 0: + return {'results': [], 'hits': 0} + + # 合并过滤结果(交集) + if narrowed_results: + narrowed_results.filter(recent_narrowed) + else: + narrowed_results = recent_narrowed + + # 刷新索引,准备执行主搜索 + self.index = self.index.refresh() + + # 若索引为空,返回空结果(含拼写建议) + if not self.index.doc_count(): + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) if self.include_spelling else None + return {'results': [], 'hits': 0, 'spelling_suggestion': spelling_suggestion} + + # 执行主搜索 + searcher = self.index.searcher() + try: + # 解析查询字符串 + parsed_query = self.parser.parse(query_string) + except Exception: + # 无效查询(如语法错误),返回空结果 + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 无效查询(如仅停用词),返回空结果 + if parsed_query is None: + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 计算分页参数 + page_num, page_length = self.calculate_page(start_offset, end_offset) + + # 构造搜索参数 + search_kwargs = { + 'pagelen': page_length, # 页长 + 'sortedby': sort_by, # 排序字段 + 'reverse': reverse # 是否倒序 + } + # 应用过滤结果(仅返回过滤后的子集) + if narrowed_results is not None: + search_kwargs['filter'] = narrowed_results + + # 执行搜索并获取分页结果 + try: + raw_page = searcher.search_page(parsed_query, page_num, **search_kwargs) + except ValueError: + # 页码超出范围(如请求第10页但仅5页),返回空结果 + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # Whoosh 2.5.1+ bug:页码超出时返回错误页码,需检查 + if raw_page.pagenum < page_num: + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) if self.include_spelling else None + return {'results': [], 'hits': 0, 'spelling_suggestion': spelling_suggestion} + + # 处理搜索结果(转换为Haystack SearchResult,添加高亮等) + results = self._process_results( + raw_page, + highlight=highlight, + query_string=query_string, + spelling_query=spelling_query, + result_class=result_class) + + # 关闭搜索器(释放资源) + searcher.close() + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def more_like_this( + self, + model_instance, + additional_query_string=None, + start_offset=0, + end_offset=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + """ + 相似结果搜索:根据指定模型对象,查找相似的文档 + 基于Whoosh的more_like_this功能,分析主内容字段的相似度 + :param model_instance: 参考模型对象(如某篇文章) + :return: 相似结果字典(结构同search方法) + """ + if not self.setup_complete: + self.setup() + + # 获取模型的实际类(排除延迟加载模型) + model_klass = model_instance._meta.concrete_model + # 主内容字段名(用于相似度分析) + field_name = self.content_field_name + # 过滤查询和结果集 + narrow_queries = set() + narrowed_results = None + self.index = self.index.refresh() + + # 处理模型过滤(同search方法) + if limit_to_registered_models is None: + limit_to_registered_models = getattr(settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + model_choices = [] + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + model_choices = self.build_models_list() + + # 添加模型过滤条件 + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + model_query = ' OR '.join(['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]) + narrow_queries.add(model_query) + + # 添加额外过滤条件(如关键词过滤) + if additional_query_string and additional_query_string != '*': + narrow_queries.add(additional_query_string) + + # 执行过滤查询(同search方法) + narrow_searcher = None + if narrow_queries is not None: + narrow_searcher = self.index.searcher() + for nq in narrow_queries: + nq_parsed = self.parser.parse(force_str(nq)) + recent_narrowed = narrow_searcher.search(nq_parsed, limit=None) + + if len(recent_narrowed) <= 0: + return {'results': [], 'hits': 0} + + if narrowed_results: + narrowed_results.filter(recent_narrowed) + else: + narrowed_results = recent_narrowed + + # 计算分页参数 + page_num, page_length = self.calculate_page(start_offset, end_offset) + + # 刷新索引,执行相似搜索 + self.index = self.index.refresh() + raw_results = EmptyResults() # 默认空结果 + + if self.index.doc_count(): + searcher = self.index.searcher() + # 构造查询:获取参考对象的索引文档 + query = "%s:%s" % (ID, get_identifier(model_instance)) + parsed_query = self.parser.parse(query) + results = searcher.search(parsed_query) + + # 若找到参考文档,获取相似结果 + if len(results): + # 基于主内容字段查找相似文档,限制最大数量为end_offset + raw_results = results[0].more_like_this(field_name, top=end_offset) + + # 应用过滤结果 + if narrowed_results is not None and hasattr(raw_results, 'filter'): + raw_results.filter(narrowed_results) + + # 处理分页结果 + try: + raw_page = ResultsPage(raw_results, page_num, page_length) + except ValueError: + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 检查页码有效性 + if raw_page.pagenum < page_num: + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 处理结果并关闭搜索器 + results = self._process_results(raw_page, result_class=result_class) + searcher.close() + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def _process_results( + self, + raw_page, + highlight=False, + query_string='', + spelling_query=None, + result_class=None): + """ + 处理搜索结果:将Whoosh原始结果转换为Haystack SearchResult格式 + 支持高亮、字段类型转换、拼写建议等 + :param raw_page: Whoosh ResultsPage对象(分页原始结果) + :param highlight: 是否开启高亮 + :return: 处理后的结果字典 + """ + from haystack import connections # 延迟导入 + results = [] # 最终结果列表(SearchResult对象) + hits = len(raw_page) # 总命中数(当前页) + + # 结果类默认值(Haystack SearchResult) + if result_class is None: + result_class = SearchResult + + # 初始化分面和拼写建议(Whoosh不支持分面,故为空) + facets = {} + spelling_suggestion = None + # 获取Haystack统一索引和已索引模型 + unified_index = connections[self.connection_alias].get_unified_index() + indexed_models = unified_index.get_indexed_models() + + # 遍历原始结果,转换为SearchResult + for doc_offset, raw_result in enumerate(raw_page): + # 获取文档得分(相关性) + score = raw_page.score(doc_offset) or 0 + # 提取模型类型(如blog.Article)并拆分应用标签和模型名 + app_label, model_name = raw_result[DJANGO_CT].split('.') + additional_fields = {} # 额外字段(除默认字段外的其他字段) + # 加载模型类 + model = haystack_get_model(app_label, model_name) + + # 仅处理已索引的模型 + if model and model in indexed_models: + # 遍历原始结果的所有字段,转换为Python原生类型 + for key, value in raw_result.items(): + string_key = str(key) + # 获取模型对应的Haystack索引 + index = unified_index.get_index(model) + + # 若字段在索引中定义,使用索引的convert方法转换值 + if string_key in index.fields and hasattr(index.fields[string_key], 'convert'): + field = index.fields[string_key] + # 处理多值字段(如KEYWORD类型,逗号分隔字符串转列表) + if field.is_multivalued: + if value is None or len(value) == 0: + additional_fields[string_key] = [] + else: + additional_fields[string_key] = value.split(',') + else: + # 单值字段:使用索引的convert方法转换 + additional_fields[string_key] = field.convert(value) + else: + # 未定义的字段:直接转换为Python类型 + additional_fields[string_key] = self._to_python(value) + + # 删除默认字段(DJANGO_CT、DJANGO_ID),避免重复 + del additional_fields[DJANGO_CT] + del additional_fields[DJANGO_ID] + + # 处理结果高亮 + if highlight: + # 使用英文词干分析器解析查询关键词(用于高亮匹配) + sa = StemmingAnalyzer() + # 自定义高亮格式化器(标签) + formatter = WhooshHtmlFormatter('em') + # 提取查询关键词的词干(如"running"→"run") + terms = [token.text for token in sa(query_string)] + + # 对主内容字段执行高亮 + content_value = additional_fields.get(self.content_field_name, '') + whoosh_highlighted = whoosh_highlight( + content_value, + terms, + sa, + ContextFragmenter(), # 上下文片段生成器(显示关键词前后内容) + formatter + ) + # 将高亮结果添加到额外字段 + additional_fields['highlighted'] = {self.content_field_name: [whoosh_highlighted]} + + # 创建SearchResult对象并添加到结果列表 + result = result_class( + app_label, + model_name, + raw_result[DJANGO_ID], # 模型主键ID + score, + **additional_fields + ) + results.append(result) + else: + # 跳过未索引的模型,减少命中数 + hits -= 1 + + # 生成拼写建议(若开启拼写检查) + if self.include_spelling: + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) + + # 返回处理后的结果字典 + return { + 'results': results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + def create_spelling_suggestion(self, query_string): + """ + 生成拼写建议:基于Whoosh的拼写检查功能,推荐可能的正确关键词 + :param query_string: 原始查询关键词 + :return: 拼写建议字符串(如"pytho"→"python") + """ + spelling_suggestion = None + # 获取索引阅读器和拼写校正器(基于主内容字段) + reader = self.index.reader() + corrector = reader.corrector(self.content_field_name) + cleaned_query = force_str(query_string) + + # 空查询返回None + if not query_string: + return spelling_suggestion + + # 清理查询字符串:移除Whoosh保留词和字符 + for rev_word in self.RESERVED_WORDS: + cleaned_query = cleaned_query.replace(rev_word, '') + for rev_char in self.RESERVED_CHARACTERS: + cleaned_query = cleaned_query.replace(rev_char, '') + + # 拆分关键词,逐个生成建议 + query_words = cleaned_query.split() + suggested_words = [] + for word in query_words: + # 获取每个词的最佳建议(限制1个) + suggestions = corrector.suggest(word, limit=1) + if len(suggestions) > 0: + suggested_words.append(suggestions[0]) + + # 拼接建议词为字符串 + spelling_suggestion = ' '.join(suggested_words) + return spelling_suggestion + + def _from_python(self, value): + """ + Python类型转换为Whoosh支持的格式(如datetime→字符串、布尔→'true'/'false') + 参考pysolr的转换逻辑,确保兼容性 + :param value: Python原生类型值 + :return: Whoosh支持的字符串/数值类型 + """ + # 处理日期时间:转换为ISO格式字符串(Whoosh DATETIME字段支持) + if hasattr(value, 'strftime'): + # 若仅为日期(无时间),补充时间为00:00:00 + if not hasattr(value, 'hour'): + value = datetime(value.year, value.month, value.day, 0, 0, 0) + value = value.isoformat() + # 处理布尔值:转换为'true'/'false'字符串 + elif isinstance(value, bool): + value = 'true' if value else 'false' + # 处理列表/元组:转换为逗号分隔字符串(Whoosh KEYWORD字段支持) + elif isinstance(value, (list, tuple)): + value = u','.join([force_str(v) for v in value]) + # 数值类型(整数、浮点数):保持不变(Whoosh NUMERIC字段支持) + elif isinstance(value, (six.integer_types, float)): + pass + # 其他类型:转换为字符串 + else: + value = force_str(value) + return value + + def _to_python(self, value): + """ + Whoosh返回值转换为Python原生类型(如字符串→datetime、'true'→True) + 参考pysolr的转换逻辑,确保兼容性 + :param value: Whoosh返回的字符串/数值 + :return: Python原生类型值 + """ + # 处理布尔值 + if value == 'true': + return True + elif value == 'false': + return False + + # 处理日期时间字符串(匹配ISO格式) + if value and isinstance(value, six.string_types): + possible_datetime = DATETIME_REGEX.search(value) + if possible_datetime: + # 提取日期时间组件并转换为整数 + date_values = possible_datetime.groupdict() + for dk, dv in date_values.items(): + date_values[dk] = int(dv) + # 创建datetime对象 + return datetime( + date_values['year'], + date_values['month'], + date_values['day'], + date_values['hour'], + date_values['minute'], + date_values['second'] + ) + + # 尝试JSON解析(处理列表、字典等复杂类型) + try: + converted_value = json.loads(value) + # 仅保留Python内置类型(列表、元组、集合、字典、数值等) + if isinstance(converted_value, (list, tuple, set, dict, six.integer_types, float, complex)): + return converted_value + except BaseException: + # JSON解析失败(如语法错误),跳过 + pass + + # 默认返回原始值 + return value + + +class WhooshSearchQuery(BaseSearchQuery): + """ + Whoosh搜索查询类:继承自Haystack的BaseSearchQuery + 负责构建Whoosh兼容的查询字符串,处理过滤条件、排序等 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为Whoosh范围查询格式(如20240520143000) + :param date: datetime/date对象 + :return: 格式化字符串 + """ + if hasattr(date, 'hour'): + # 日期时间:格式为YYYYMMDDHHMMSS + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + # 仅日期:时间部分补000000 + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + 清理查询片段:处理Whoosh保留词和字符,避免语法错误 + Whoosh 1.X+不支持反斜杠转义,需用引号包裹含保留字符的词 + :param query_fragment: 原始查询片段 + :return: 清理后的查询片段 + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + # 处理保留词:转换为小写(Whoosh保留词区分大小写,小写不视为保留词) + if word in self.backend.RESERVED_WORDS: + word = word.lower() + + # 处理保留字符:若词中含保留字符,用单引号包裹 + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + # 拼接清理后的词为查询片段 + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + """ + 构建查询片段:根据字段、过滤类型、值,生成Whoosh兼容的查询字符串 + 支持精确匹配、模糊匹配、范围查询等多种过滤类型 + :param field: 字段名(如'title'、'content') + :param filter_type: 过滤类型(如'exact'、'contains'、'range') + :param value: 过滤值(如'Python'、[2024-01-01, 2024-05-01]) + :return: 构建后的查询片段字符串 + """ + from haystack import connections # 延迟导入 + query_frag = '' # 最终查询片段 + is_datetime = False # 是否为日期时间类型 + + # 处理非InputType值(如普通字符串、列表、datetime对象) + if not hasattr(value, 'input_type_name'): + # 处理ValuesListQuerySet:转换为列表 + if hasattr(value, 'values_list'): + value = list(value) + # 检查是否为日期时间类型 + if hasattr(value, 'strftime'): + is_datetime = True + # 字符串值:默认使用Clean输入类型(清理特殊字符) + if isinstance(value, six.string_types) and value != ' ': + value = Clean(value) + # 其他类型:使用PythonData输入类型(直接传递值) + else: + value = PythonData(value) + + # 准备查询值(调用InputType的prepare方法,如Exact会添加引号) + prepared_value = value.prepare(self) + + # 转换值为Whoosh支持的格式(如列表→逗号分隔字符串) + if not isinstance(prepared_value, (set, list, tuple)): + prepared_value = self.backend._from_python(prepared_value) + + # 处理"content"字段(Haystack保留字段,代表"所有字段",无需指定字段名) + if field == 'content': + index_fieldname = '' + else: + # 获取字段在索引中的实际名称(支持字段别名) + index_fieldname = u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field) + + # Whoosh查询模板:不同过滤类型对应的查询格式 + filter_types = { + 'content': '%s', # 全文搜索(无字段名) + 'contains': '*%s*', # 包含匹配(如*Python*) + 'endswith': "*%s", # 后缀匹配(如*thon) + 'startswith': "%s*", # 前缀匹配(如Pyth*) + 'exact': '%s', # 精确匹配(如"Python") + 'gt': "{%s to}", # 大于(如{20240101 to}) + 'gte': "[%s to]", # 大于等于(如[20240101 to]) + 'lt': "{to %s}", # 小于(如{to 20240101}) + 'lte': "[to %s]", # 小于等于(如[to 20240101]) + 'fuzzy': u'%s~', # 模糊匹配(如Pytho~) + } + + # 处理无需后处理的值(如Raw输入类型,直接使用原始值) + if value.post_process is False: + query_frag = prepared_value + else: + # 处理文本匹配类过滤类型(content、contains、startswith等) + if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']: + # 精确匹配输入类型(Exact):直接使用准备好的值(含引号) + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # 拆分值为多个术语(如空格分隔的关键词) + terms = [] + if isinstance(prepared_value, six.string_types): + possible_values = prepared_value.split(' ') + else: + # 非字符串值(如datetime):转换为Whoosh格式 + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + possible_values = [prepared_value] + + # 为每个术语应用过滤模板 + for possible_value in possible_values: + term = filter_types[filter_type] % self.backend._from_python(possible_value) + terms.append(term) + + # 拼接术语(单个术语直接返回,多个术语用AND连接并加括号) + if len(terms) == 1: + query_frag = terms[0] + else: + query_frag = u"(%s)" % " AND ".join(terms) + # 处理IN过滤类型(匹配多个值中的任一) + elif filter_type == 'in': + in_options = [] + for possible_value in prepared_value: + is_dt = False + # 检查是否为日期时间类型 + if hasattr(possible_value, 'strftime'): + is_dt = True + # 转换值为Whoosh格式 + pv = self.backend._from_python(possible_value) + if is_dt is True: + pv = self._convert_datetime(pv) + # 字符串值加引号,其他值直接使用 + if isinstance(pv, six.string_types) and not is_dt: + in_options.append('"%s"' % pv) + else: + in_options.append('%s' % pv) + # 用OR连接所有选项并加括号(如("a" OR "b" OR "c")) + query_frag = "(%s)" % " OR ".join(in_options) + # 处理RANGE过滤类型(范围匹配) + elif filter_type == 'range': + # 提取范围的起始和结束值 + start = self.backend._from_python(prepared_value[0]) + end = self.backend._from_python(prepared_value[1]) + # 转换日期时间类型为Whoosh格式 + if hasattr(prepared_value[0], 'strftime'): + start = self._convert_datetime(start) + if hasattr(prepared_value[1], 'strftime'): + end = self._convert_datetime(end) + # 范围查询格式(如[20240101 to 20240501]) + query_frag = u"[%s to %s]" % (start, end) + # 处理EXACT过滤类型(精确匹配) + elif filter_type == 'exact': + # 精确匹配输入类型:直接使用准备好的值 + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # 其他输入类型:转换为Exact格式(加引号) + prepared_value = Exact(prepared_value).prepare(self) + query_frag = filter_types[filter_type] % prepared_value + # 其他过滤类型(如gt、gte等) + else: + # 日期时间类型转换为Whoosh格式 + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + # 应用过滤模板 + query_frag = filter_types[filter_type] % prepared_value + + # 非Raw输入类型:若查询片段无括号,添加括号(确保逻辑正确) + if len(query_frag) and not isinstance(value, Raw): + if not query_frag.startswith('(') and not query_frag.endswith(')'): + query_frag = "(%s)" % query_frag + + # 拼接字段名和查询片段(如"title:(Python)") + return u"%s%s" % (index_fieldname, query_frag) + + +class WhooshEngine(BaseEngine): + """ + Whoosh搜索引擎类:继承自Haystack的BaseEngine + 绑定Whoosh搜索后端和查询类,供Haystack调用 + """ + backend = WhooshSearchBackend # 关联Whoosh搜索后端 + query = WhooshSearchQuery # 关联Whoosh搜索查询 \ No newline at end of file diff --git a/src/wsgi.py b/src/wsgi.py new file mode 100644 index 0000000..103bea5 --- /dev/null +++ b/src/wsgi.py @@ -0,0 +1,28 @@ +""" +WSGI config for djangoblog project. +Django博客项目的WSGI配置文件 +WSGI(Web Server Gateway Interface)是Web服务器与Python Web应用之间的通信标准 +负责将Web服务器(如Nginx、Apache)接收的HTTP请求转发给Django应用,再将应用响应返回给服务器 + +It exposes the WSGI callable as a module-level variable named ``application``. +该文件将WSGI可调用对象(处理请求的核心入口)暴露为模块级变量,命名为`application` +Web服务器通过调用这个`application`对象与Django应用交互 + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +# 导入Python内置的os模块,用于读取环境变量、处理路径等 +import os + +# 导入Django的WSGI应用生成器:根据项目配置创建WSGI可调用对象 +from django.core.wsgi import get_wsgi_application + +# 设置Django项目的配置模块环境变量 +# 告诉Django使用哪个settings文件(此处为项目根目录下的djangoblog.settings) +# 生产环境中可通过服务器配置修改该环境变量,切换不同配置(如生产/测试配置) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + +# 创建WSGI应用对象:Django根据上述配置生成处理HTTP请求的核心入口 +# Web服务器(如Gunicorn、uWSGI)会加载这个`application`对象来运行项目 +application = get_wsgi_application() \ No newline at end of file