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 article.type == 'a' %} // 检查文章类型是否为文章 - {% if not isindex %} // 如果当前不是索引页(即文章详情页) - {% cache 36000 breadcrumb article.pk %}// 缓存面包屑导航,有效期10小时(36000秒),以文章主键作为缓存键 - {% load_breadcrumb article %}// 调用自定义标签加载文章的面包屑导航 + + {% if article.type == 'a' %} + {% if not isindex %} + + {% cache 36000 breadcrumb article.pk %} + {% load_breadcrumb article %} {% endcache %} {% endif %} {% endif %} -
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 %}
- - {% for name,url in names %} - + {% for name,url in names %} +-
-
-
+
-
- {{ name }}
-
-
-
+ {{ name }}
+
-
+
- {% endfor %}
+ {% endfor %}
-
+
-
- {{ title }}
-
+ {{ title }}
+
-
-
+ 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 %} + +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