From deb2fa6a9b19b78cbc4065b445990c4d394082cf Mon Sep 17 00:00:00 2001 From: hyt <691385292@qq.com> Date: Sat, 18 Oct 2025 18:51:59 +0800 Subject: [PATCH] =?UTF-8?q?hyt=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__init__.py | 0 src/admin.py | 131 ++++++++++ src/apps.py | 12 + src/context_processors.py | 86 +++++++ src/documents.py | 283 +++++++++++++++++++++ src/forms.py | 49 ++++ src/middleware.py | 104 ++++++++ src/models.py | 380 +++++++++++++++++++++++++++++ src/search_indexes.py | 40 +++ src/tests.py | 329 +++++++++++++++++++++++++ src/urls.py | 101 ++++++++ src/views.py | 500 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 2015 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/admin.py create mode 100644 src/apps.py create mode 100644 src/context_processors.py create mode 100644 src/documents.py create mode 100644 src/forms.py create mode 100644 src/middleware.py create mode 100644 src/models.py create mode 100644 src/search_indexes.py create mode 100644 src/tests.py create mode 100644 src/urls.py create mode 100644 src/views.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/admin.py b/src/admin.py new file mode 100644 index 0000000..86b182a --- /dev/null +++ b/src/admin.py @@ -0,0 +1,131 @@ +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 # 使用默认管理配置 diff --git a/src/apps.py b/src/apps.py new file mode 100644 index 0000000..750cec7 --- /dev/null +++ b/src/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + """博客应用配置类 + + 这个类用于配置Django博客应用的基本信息。 + 它继承自Django的AppConfig基类,用于定义应用的元数据和行为。 + """ + + # 应用的完整Python路径,Django使用这个名称来识别应用 + name = 'blog' \ No newline at end of file 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/forms.py b/src/forms.py new file mode 100644 index 0000000..9cb4260 --- /dev/null +++ b/src/forms.py @@ -0,0 +1,49 @@ +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 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..3e197d2 --- /dev/null +++ b/src/models.py @@ -0,0 +1,380 @@ +import logging +import re +from abc import abstractmethod + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +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地址(包含域名)""" + site = get_current_site().domain + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) + return url + + class Meta: + 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() # 清除所有缓存 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/tests.py b/src/tests.py new file mode 100644 index 0000000..f380db8 --- /dev/null +++ b/src/tests.py @@ -0,0 +1,329 @@ +import os + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.paginator import Paginator +from django.templatetags.static import static +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone + +from accounts.models import BlogUser +from blog.forms import BlogSearchForm +from blog.models import Article, Category, Tag, SideBar, Links +from blog.templatetags.blog_tags import load_pagination_info, load_articletags +from djangoblog.utils import get_current_site, get_sha256 +from oauth.models import OAuthUser, OAuthConfig + + +# Create your tests here. + +class ArticleTest(TestCase): + """ + 文章模型测试类 + + 这个测试类用于测试博客系统的核心功能,包括: + - 文章创建和验证 + - 搜索功能 + - 分页功能 + - 文件上传 + - 管理命令 + - 错误页面处理 + """ + + def setUp(self): + """ + 测试初始化方法 + 在每个测试方法执行前运行,用于设置测试环境 + """ + self.client = Client() # Django测试客户端,用于模拟HTTP请求 + self.factory = RequestFactory() # 请求工厂,用于创建请求对象 + + def test_validate_article(self): + """ + 测试文章验证和核心功能 + + 这个测试方法验证博客系统的核心功能: + - 用户创建和认证 + - 文章创建和关联 + - 搜索功能 + - 分页功能 + - RSS和站点地图 + - 管理后台访问 + """ + # 获取当前站点域名 + site = get_current_site().domain + + # 创建测试用户(超级用户) + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") + user.is_staff = True # 设置为管理员 + user.is_superuser = True # 设置为超级用户 + user.save() + + # 测试用户详情页访问 + response = self.client.get(user.get_absolute_url()) + self.assertEqual(response.status_code, 200) # 断言返回200状态码 + + # 测试管理后台页面访问 + response = self.client.get('/admin/servermanager/emailsendlog/') + response = self.client.get('admin/admin/logentry/') + + # 创建侧边栏测试数据 + s = SideBar() + s.sequence = 1 + s.name = 'test' + s.content = 'test content' + s.is_enable = True + s.save() + + # 创建分类测试数据 + category = Category() + category.name = "category" + category.creation_time = timezone.now() + category.last_mod_time = timezone.now() + category.save() + + # 创建标签测试数据 + tag = Tag() + tag.name = "nicetag" + tag.save() + + # 创建文章测试数据 + article = Article() + article.title = "nicetitle" + article.body = "nicecontent" + article.author = user + article.category = category + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 + + article.save() + # 验证初始标签数量为0 + self.assertEqual(0, article.tags.count()) + # 添加标签到文章 + article.tags.add(tag) + article.save() + # 验证标签数量为1 + self.assertEqual(1, article.tags.count()) + + # 批量创建20篇文章用于测试分页和搜索 + for i in range(20): + article = Article() + article.title = "nicetitle" + str(i) + article.body = "nicetitle" + str(i) + article.author = user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + article.tags.add(tag) + article.save() + + # 测试搜索功能(如果启用了Elasticsearch) + from blog.documents import ELASTICSEARCH_ENABLED + if ELASTICSEARCH_ENABLED: + call_command("build_index") # 构建搜索索引 + response = self.client.get('/search', {'q': 'nicetitle'}) + self.assertEqual(response.status_code, 200) + + # 测试文章详情页访问 + response = self.client.get(article.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试百度推送通知 + from djangoblog.spider_notify import SpiderNotify + SpiderNotify.notify(article.get_absolute_url()) + + # 测试标签页访问 + response = self.client.get(tag.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试分类页访问 + response = self.client.get(category.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试搜索页面 + response = self.client.get('/search', {'q': 'django'}) + self.assertEqual(response.status_code, 200) + + # 测试模板标签函数 + s = load_articletags(article) + self.assertIsNotNone(s) + + # 用户登录测试 + self.client.login(username='liangliangyy', password='liangliangyy') + + # 测试文章归档页面 + response = self.client.get(reverse('blog:archives')) + self.assertEqual(response.status_code, 200) + + # 测试各种分页场景 + p = Paginator(Article.objects.all(), settings.PAGINATE_BY) + self.check_pagination(p, '', '') # 基础分页 + + p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) + self.check_pagination(p, '分类标签归档', tag.slug) # 标签分页 + + p = Paginator( + Article.objects.filter( + author__username='liangliangyy'), settings.PAGINATE_BY) + self.check_pagination(p, '作者文章归档', 'liangliangyy') # 作者分页 + + p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) + self.check_pagination(p, '分类目录归档', category.slug) # 分类分页 + + # 测试搜索表单 + f = BlogSearchForm() + f.search() + + # 测试百度批量推送 + from djangoblog.spider_notify import SpiderNotify + SpiderNotify.baidu_notify([article.get_full_url()]) + + # 测试Gravatar相关功能 + from blog.templatetags.blog_tags import gravatar_url, gravatar + u = gravatar_url('liangliangyy@gmail.com') + u = gravatar('liangliangyy@gmail.com') + + # 测试友情链接功能 + link = Links( + sequence=1, + name="lylinux", + link='https://wwww.lylinux.net') + link.save() + response = self.client.get('/links.html') + self.assertEqual(response.status_code, 200) + + # 测试RSS订阅 + response = self.client.get('/feed/') + self.assertEqual(response.status_code, 200) + + # 测试站点地图 + response = self.client.get('/sitemap.xml') + self.assertEqual(response.status_code, 200) + + # 测试管理后台操作 + self.client.get("/admin/blog/article/1/delete/") + self.client.get('/admin/servermanager/emailsendlog/') + self.client.get('/admin/admin/logentry/') + self.client.get('/admin/admin/logentry/1/change/') + + def check_pagination(self, p, type, value): + """ + 分页功能测试辅助方法 + + Args: + p: Paginator分页对象 + type: 分页类型(用于生成URL) + value: 分页参数值 + """ + for page in range(1, p.num_pages + 1): + # 加载分页信息 + s = load_pagination_info(p.page(page), type, value) + self.assertIsNotNone(s) + # 测试上一页链接 + if s['previous_url']: + response = self.client.get(s['previous_url']) + self.assertEqual(response.status_code, 200) + # 测试下一页链接 + if s['next_url']: + response = self.client.get(s['next_url']) + self.assertEqual(response.status_code, 200) + + def test_image(self): + """ + 图片上传和头像处理测试 + """ + import requests + # 下载测试图片 + rsp = requests.get( + 'https://www.python.org/static/img/python-logo.png') + imagepath = os.path.join(settings.BASE_DIR, 'python.png') + with open(imagepath, 'wb') as file: + file.write(rsp.content) + + # 测试未授权上传(应该返回403) + rsp = self.client.post('/upload') + self.assertEqual(rsp.status_code, 403) + + # 生成签名用于授权上传 + sign = get_sha256(get_sha256(settings.SECRET_KEY)) + with open(imagepath, 'rb') as file: + imgfile = SimpleUploadedFile( + 'python.png', file.read(), content_type='image/jpg') + form_data = {'python.png': imgfile} + # 测试授权上传 + rsp = self.client.post( + '/upload?sign=' + sign, form_data, follow=True) + self.assertEqual(rsp.status_code, 200) + # 清理测试文件 + os.remove(imagepath) + + # 测试工具函数 + from djangoblog.utils import save_user_avatar, send_email + send_email(['qq@qq.com'], 'testTitle', 'testContent') + save_user_avatar( + 'https://www.python.org/static/img/python-logo.png') + + def test_errorpage(self): + """测试404错误页面""" + rsp = self.client.get('/eee') + self.assertEqual(rsp.status_code, 404) + + def test_commands(self): + """ + 测试Django管理命令 + + 验证系统提供的各种管理命令是否能正常执行 + """ + # 创建测试用户 + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") + user.is_staff = True + user.is_superuser = True + user.save() + + # 创建OAuth配置 + c = OAuthConfig() + c.type = 'qq' + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + + # 创建OAuth用户 + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid' + u.user = user + u.picture = static("/blog/img/avatar.png") + u.metadata = ''' +{ +"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" +}''' + u.save() + + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid1' + u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30' + u.metadata = ''' + { + "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" + }''' + u.save() + + # 测试各种管理命令 + from blog.documents import ELASTICSEARCH_ENABLED + if ELASTICSEARCH_ENABLED: + call_command("build_index") # 构建搜索索引 + call_command("ping_baidu", "all") # 百度推送 + call_command("create_testdata") # 创建测试数据 + call_command("clear_cache") # 清理缓存 + call_command("sync_user_avatar") # 同步用户头像 + call_command("build_search_words") # 构建搜索词 \ No newline at end of file diff --git a/src/urls.py b/src/urls.py new file mode 100644 index 0000000..9a47767 --- /dev/null +++ b/src/urls.py @@ -0,0 +1,101 @@ +from django.urls import path +from django.views.decorators.cache import cache_page + +from . import views + +# 应用命名空间,用于URL反向解析时区分不同应用的URL +app_name = "blog" + +# URL模式配置,定义了博客应用的所有URL路由 +urlpatterns = [ + # 首页路由 + path( + r'', # 空路径,匹配根URL(如:/ 或 /blog/) + views.IndexView.as_view(), # 使用类视图处理首页 + name='index' # URL名称,用于反向解析 + ), + + # 首页分页路由 + path( + r'page//', # 带页码的路径(如:/page/2/) + views.IndexView.as_view(), # 使用相同的类视图,但会处理分页 + name='index_page' # URL名称 + ), + + # 文章详情页路由(SEO友好URL) + path( + r'article////.html', # 包含年月日和文章ID的URL + views.ArticleDetailView.as_view(), # 文章详情类视图 + name='detailbyid' # URL名称 + ), + + # 分类详情页路由 + path( + r'category/.html', # 使用分类名称的slug格式 + views.CategoryDetailView.as_view(), # 分类详情类视图 + name='category_detail' # URL名称 + ), + + # 分类详情分页路由 + path( + r'category//.html', # 带页码的分类URL + views.CategoryDetailView.as_view(), # 相同的类视图处理分页 + name='category_detail_page' # URL名称 + ), + + # 作者详情页路由 + path( + r'author/.html', # 使用作者名称的URL + views.AuthorDetailView.as_view(), # 作者详情类视图 + name='author_detail' # URL名称 + ), + + # 作者详情分页路由 + path( + r'author//.html', # 带页码的作者URL + views.AuthorDetailView.as_view(), # 相同的类视图处理分页 + name='author_detail_page' # URL名称 + ), + + # 标签详情页路由 + path( + r'tag/.html', # 使用标签名称的slug格式 + views.TagDetailView.as_view(), # 标签详情类视图 + name='tag_detail' # URL名称 + ), + + # 标签详情分页路由 + path( + r'tag//.html', # 带页码的标签URL + views.TagDetailView.as_view(), # 相同的类视图处理分页 + name='tag_detail_page' # URL名称 + ), + + # 文章归档页路由(带缓存) + path( + 'archives.html', # 归档页面URL + cache_page(60 * 60)(views.ArchivesView.as_view()), # 使用缓存装饰器,缓存1小时 + name='archives' # URL名称 + ), + + # 友情链接页面路由 + path( + 'links.html', # 友情链接页面URL + views.LinkListView.as_view(), # 链接列表类视图 + name='links' # URL名称 + ), + + # 文件上传路由 + path( + r'upload', # 文件上传端点 + views.fileupload, # 使用函数视图处理文件上传 + name='upload' # URL名称 + ), + + # 缓存清理路由 + path( + r'clean', # 缓存清理端点 + views.clean_cache_view, # 使用函数视图处理缓存清理 + name='clean' # URL名称 + ), +] \ No newline at end of file diff --git a/src/views.py b/src/views.py new file mode 100644 index 0000000..8fb3a4c --- /dev/null +++ b/src/views.py @@ -0,0 +1,500 @@ +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 + +logger = logging.getLogger(__name__) + + +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')