Compare commits

...

57 Commits

Author SHA1 Message Date
盛钧涛 442705b11d Merge branch 'sjt_branch' into develop
1 month ago
盛钧涛 3808109f8a add 111
2 months ago
盛钧涛 466a6864bc add new code
2 months ago
陌渝 30400814ab 1
2 months ago
陌渝 2cb5514f11 Merge remote-tracking branch 'origin' into develop
2 months ago
陌渝 54ab827919 Merge branch 'wr_branch' into develop
2 months ago
陌渝 9c3bf6e105 1
2 months ago
陌渝 4abe1ee4a9 2
2 months ago
盛钧涛 1c77fe8e22 Merge branch 'develop'
2 months ago
盛钧涛 184d73b9e4 Merge branch 'mk_branch' into develop
2 months ago
陌渝 75628202e3 1
2 months ago
陌渝 5c389a179c 1
2 months ago
陌渝 c47c8ea166 1
2 months ago
陌渝 9928651766 2
2 months ago
陌渝 24c10c00ce Merge branch 'wr_branch' of https://bdgit.educoder.net/puhanfmc3/tentest into wr_branch
2 months ago
陌渝 6352e52bed 1
2 months ago
陌渝 f9bbb4c87e 1
2 months ago
陌渝 26d4c9752f 1
2 months ago
陌渝 87d4d4e590 1
2 months ago
陌渝 c8413e7577 6
2 months ago
盛钧涛 c51b8b0c56 add oauth
2 months ago
盛钧涛 cedd0a20cf add oauth
2 months ago
mk 72b8e67060 质量分析报告
2 months ago
mk 18e424dd97 质量分析报告
2 months ago
盛钧涛 d52dc2800c Merge branch 'mk_branch'
2 months ago
盛钧涛 e9ed2007f8 Merge branch 'mk_branch' into develop
2 months ago
mk 3ad5c5a754 6
2 months ago
mk dfe9b32351 6
2 months ago
盛钧涛 48708d60dd Merge branch 'frr_branch'
2 months ago
盛钧涛 d6d92db1f3 Merge branch 'frr_branch' into develop
2 months ago
盛钧涛 8641273b52 11
2 months ago
盛钧涛 d888017d59 Merge branch 'frr_branch'
2 months ago
盛钧涛 afe4e723e0 del
2 months ago
盛钧涛 4c2e221de9 Merge branch 'frr_branch' into develop
2 months ago
盛钧涛 ebd9538c68 del
2 months ago
盛钧涛 0cc903fbc4 del
2 months ago
盛钧涛 8380c07eab del others
2 months ago
frr 2da8518e4f 修改
2 months ago
frr 37d35baab4 首次提交
2 months ago
frr db19f9ab12 111
2 months ago
frr de046165a5 2
2 months ago
frr d4b3780968 1
2 months ago
pxksbc67f 763ee35ee5 Add frrweek8work3
2 months ago
mk 645ab9b995 mk_branch
2 months ago
mk 4fc80d8a06 注释
2 months ago
mk 03e47de49f 删除
2 months ago
mk 3873336204 注释
2 months ago
mk 927b3cc2af 11
2 months ago
mk 8991e19bb0 1
2 months ago
陌渝 b59800b7a9 删除文件
3 months ago
陌渝 509ec8360d 添加文件
3 months ago
陌渝 32dd36a0e2 添加文件
3 months ago
陌渝 8e8d9ea64c 添加文件
3 months ago
陌渝 e899883087 添加文件
3 months ago
陌渝 5be757fbbf 添加文件
3 months ago
陌渝 735a5e5a66 Merge branch 'master' of https://bdgit.educoder.net/puhanfmc3/tentest
4 months ago
陌渝 8583e895d3 提交wr666
4 months ago

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (accounts)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
</module>

@ -13,26 +13,32 @@ class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
# mk: 指定表单关联的模型和字段
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
# mk: 批量发布文章操作,将选中的文章状态更新为已发布('p')
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
# mk: 批量将文章设为草稿状态,将选中的文章状态更新为草稿('d')
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
# mk: 批量关闭文章评论功能,将选中的文章评论状态更新为关闭('c')
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
# mk: 批量开启文章评论功能,将选中的文章评论状态更新为开启('o')
queryset.update(comment_status='o')
# mk: 为自定义管理操作设置显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
@ -40,9 +46,13 @@ open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
# mk: 设置文章管理界面每页显示20条记录
list_per_page = 20
# mk: 设置搜索字段,支持在文章正文和标题中搜索
search_fields = ('body', 'title')
# mk: 指定使用的表单类
form = ArticleForm
# mk: 设置在管理界面列表中显示的字段
list_display = (
'id',
'title',
@ -53,36 +63,49 @@ class ArticlelAdmin(admin.ModelAdmin):
'status',
'type',
'article_order')
# mk: 设置可以作为链接点击的字段
list_display_links = ('id', 'title')
# mk: 设置过滤器,可以在右侧边栏按状态、类型、分类筛选
list_filter = ('status', 'type', 'category')
# mk: 设置日期层级结构,用于按日期筛选文章
date_hierarchy = 'creation_time'
# mk: 设置多对多字段的横向筛选器
filter_horizontal = ('tags',)
# mk: 排除某些字段在表单中显示
exclude = ('creation_time', 'last_modify_time')
# mk: 启用"在站点上查看"功能
view_on_site = True
# mk: 注册自定义的批量操作
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# mk: 设置使用原始ID字段的外键字段提升性能
raw_id_fields = ('author', 'category',)
def link_to_category(self, obj):
# mk: 创建指向分类编辑页面的链接
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'<a href="%s">%s</a>' % (link, obj.category.name))
# mk: 设置分类链接字段的显示名称
link_to_category.short_description = _('category')
def get_form(self, request, obj=None, **kwargs):
# mk: 自定义表单,限制作者字段只能选择超级用户
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):
# mk: 保存模型实例,调用父类的保存方法
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
# mk: 返回在站点上查看文章的URL
if obj:
url = obj.get_full_url()
return url
@ -93,22 +116,29 @@ class ArticlelAdmin(admin.ModelAdmin):
class TagAdmin(admin.ModelAdmin):
# mk: 标签管理界面排除某些字段
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
# mk: 设置分类管理界面显示的字段
list_display = ('name', 'parent_category', 'index')
# mk: 分类管理界面排除某些字段
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
# mk: 链接管理界面排除某些字段
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
# mk: 设置侧边栏管理界面显示的字段
list_display = ('name', 'content', 'is_enable', 'sequence')
# mk: 侧边栏管理界面排除某些字段
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
# mk: 博客设置管理界面使用默认配置
pass

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

@ -1,43 +1,77 @@
import logging
import logging # mk导入日志记录模块#
from django.utils import timezone
from django.utils import timezone # mk导入Django的时间工具模块#
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
from djangoblog.utils import cache, get_blog_setting # mk从项目工具模块导入缓存和获取博客设置的函数#
from .models import Category, Article # mk从当前应用的模型中导入分类和文章模型#
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # mk创建当前模块的日志记录器#
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
def seo_processor(requests): # mkSEO处理器函数用于获取网站SEO相关配置信息并缓存#
"""
SEO处理器函数用于获取网站SEO相关配置信息并缓存
该函数从缓存中获取SEO配置信息如果缓存不存在则从数据库获取并设置缓存
主要包含网站基本信息SEO配置导航分类列表页面列表等数据
Args:
requests: HTTP请求对象用于获取请求协议和主机信息
Returns:
dict: 包含SEO配置信息的字典具体包含
- SITE_NAME: 网站名称
- SHOW_GOOGLE_ADSENSE: 是否显示Google Adsense
- GOOGLE_ADSENSE_CODES: Google Adsense代码
- SITE_SEO_DESCRIPTION: 网站SEO描述
- SITE_DESCRIPTION: 网站描述
- SITE_KEYWORDS: 网站关键词
- SITE_BASE_URL: 网站基础URL
- ARTICLE_SUB_LENGTH: 文章摘要长度
- nav_category_list: 导航分类列表
- nav_pages: 导航页面列表
- OPEN_SITE_COMMENT: 是否开启网站评论
- BEIAN_CODE: 备案号
- ANALYTICS_CODE: 统计代码
- BEIAN_CODE_GONGAN: 公安备案号
- SHOW_GONGAN_CODE: 是否显示公安备案号
- CURRENT_YEAR: 当前年份
- GLOBAL_HEADER: 全局头部代码
- GLOBAL_FOOTER: 全局底部代码
- COMMENT_NEED_REVIEW: 评论是否需要审核
"""
key = 'seo_processor' # mk设置缓存键名#
value = cache.get(key) # mk尝试从缓存中获取SEO数据#
# mk检查缓存是否存在如果存在则直接返回缓存数据#
if value:
return value
else:
logger.info('set processor cache.')
logger.info('set processor cache.') # mk记录设置缓存的日志信息#
# mk缓存不存在从数据库获取配置信息#
setting = get_blog_setting()
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'nav_pages': Article.objects.filter(
value = { # mk构建包含网站SEO配置和导航数据的字典对象#
'SITE_NAME': setting.site_name, # mk网站名称显示在网页标题等位置#
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # mk是否显示Google Adsense广告的开关#
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # mkGoogle Adsense广告代码内容#
'SITE_SEO_DESCRIPTION': setting.site_seo_description, # mk网站SEO描述信息用于meta标签#
'SITE_DESCRIPTION': setting.site_description, # mk网站描述信息#
'SITE_KEYWORDS': setting.site_keywords, # mk网站关键词用于SEO优化#
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # mk网站基础URL地址由请求协议和主机名组成#
'ARTICLE_SUB_LENGTH': setting.article_sub_length, # mk文章摘要显示长度设置#
'nav_category_list': Category.objects.all(), # mk获取所有文章分类用于导航菜单显示#
'nav_pages': Article.objects.filter( # mk获取所有已发布的页面文章用于导航菜单显示#
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
'OPEN_SITE_COMMENT': setting.open_site_comment, # mk网站评论功能开关设置#
'BEIAN_CODE': setting.beian_code, # mk网站备案号信息#
'ANALYTICS_CODE': setting.analytics_code, # mk网站统计分析代码#
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, # mk公安备案号信息#
"SHOW_GONGAN_CODE": setting.show_gongan_code, # mk是否显示公安备案信息的开关#
"CURRENT_YEAR": timezone.now().year, # mk当前年份用于页面底部显示版权年份#
"GLOBAL_HEADER": setting.global_header, # mk全局页头HTML代码#
"GLOBAL_FOOTER": setting.global_footer, # mk全局页脚HTML代码#
"COMMENT_NEED_REVIEW": setting.comment_need_review, # mk评论是否需要审核的设置#
}
# mk将获取到的数据缓存10小时减少数据库查询压力#
cache.set(key, value, 60 * 60 * 10)
return value
return value # mk返回构建的SEO和导航数据#

@ -7,9 +7,11 @@ from elasticsearch_dsl.connections import connections
from blog.models import Article
# mk:检查是否启用了Elasticsearch配置
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
# mk:创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
@ -19,6 +21,7 @@ if ELASTICSEARCH_ENABLED:
c = IngestClient(es)
try:
# mk:尝试获取geoip管道如果不存在则创建
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''{
@ -34,6 +37,11 @@ if ELASTICSEARCH_ENABLED:
class GeoIp(InnerDoc):
"""
mk:
地理位置信息文档类
用于存储IP地址对应的地理位置信息
"""
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
@ -41,21 +49,41 @@ class GeoIp(InnerDoc):
class UserAgentBrowser(InnerDoc):
"""
mk:
用户代理浏览器信息类
存储浏览器的家族和版本信息
"""
Family = Keyword()
Version = Keyword()
class UserAgentOS(UserAgentBrowser):
"""
mk:
用户代理操作系统信息类
继承自UserAgentBrowser存储操作系统的家族和版本信息
"""
pass
class UserAgentDevice(InnerDoc):
"""
mk:
用户代理设备信息类
存储设备的家族品牌和型号信息
"""
Family = Keyword()
Brand = Keyword()
Model = Keyword()
class UserAgent(InnerDoc):
"""
mk:
用户代理完整信息类
包含浏览器操作系统设备等完整用户代理信息
"""
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
@ -64,6 +92,11 @@ class UserAgent(InnerDoc):
class ElapsedTimeDocument(Document):
"""
mk:
性能监控文档类
用于记录页面访问性能数据包括URL响应时间访问时间等信息
"""
url = Keyword()
time_taken = Long()
log_datetime = Date()
@ -83,8 +116,19 @@ class ElapsedTimeDocument(Document):
class ElaspedTimeDocumentManager:
"""
mk:
性能监控文档管理类
提供性能监控数据的索引创建删除和保存功能
"""
@staticmethod
def build_index():
"""
mk:
构建性能监控索引
检查索引是否存在如果不存在则初始化索引
"""
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
@ -93,12 +137,28 @@ class ElaspedTimeDocumentManager:
@staticmethod
def delete_index():
"""
mk:
删除性能监控索引
删除名为'performance'的索引忽略400和404错误
"""
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
"""
mk:
创建并保存性能监控记录
Args:
url (str): 访问的URL地址
time_taken (int): 请求耗时毫秒
log_datetime (datetime): 日志记录时间
useragent (object): 用户代理对象包含浏览器系统设备信息
ip (str): 访问者IP地址
"""
ElaspedTimeDocumentManager.build_index()
ua = UserAgent()
ua.browser = UserAgentBrowser()
@ -131,6 +191,11 @@ class ElaspedTimeDocumentManager:
class ArticleDocument(Document):
"""
mk:
文章文档类
用于Elasticsearch中的文章搜索索引包含文章的完整信息
"""
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
author = Object(properties={
@ -165,19 +230,49 @@ class ArticleDocument(Document):
class ArticleDocumentManager():
"""
mk:
文章文档管理类
提供文章索引的创建删除重建和更新功能
"""
def __init__(self):
"""
mk:
初始化文章文档管理器
自动创建索引
"""
self.create_index()
def create_index(self):
"""
mk:
创建文章索引
初始化ArticleDocument索引结构
"""
ArticleDocument.init()
def delete_index(self):
"""
mk:
删除文章索引
删除名为'blog'的索引忽略400和404错误
"""
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
"""
mk:
将文章模型对象转换为文档对象
Args:
articles (list): Article模型对象列表
Returns:
list: 转换后的ArticleDocument文档对象列表
"""
return [
ArticleDocument(
meta={
@ -202,6 +297,13 @@ class ArticleDocumentManager():
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
"""
mk:
重建文章索引
Args:
articles (list, optional): 指定要重建索引的文章列表如果为None则重建所有文章
"""
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
@ -209,5 +311,12 @@ class ArticleDocumentManager():
doc.save()
def update_docs(self, docs):
"""
mk:
批量更新文档
Args:
docs (list): ArticleDocument文档对象列表
"""
for doc in docs:
doc.save()

@ -7,13 +7,33 @@ logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm):
"""
mk:
博客搜索表单类
继承自haystack的SearchForm添加了自定义的查询字段和搜索逻辑
"""
querydata = forms.CharField(required=True)
def search(self):
"""
mk:
执行搜索操作
首先调用父类的搜索方法获取基础搜索结果然后验证表单数据的有效性
如果表单数据有效且查询数据存在则记录查询日志
Returns:
搜索结果数据集
"""
# mk:调用父类的搜索方法获取基础搜索结果
datas = super(BlogSearchForm, self).search()
# mk:验证表单数据,如果无效则返回无查询结果
if not self.is_valid():
return self.no_query_found()
# mk:如果查询数据存在,记录查询日志
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas

@ -4,15 +4,37 @@ from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedT
ELASTICSEARCH_ENABLED
# TODO 参数化
# mk:TODO 参数化
class Command(BaseCommand):
"""
mk:
Django管理命令类用于构建搜索索引
该命令负责初始化和重建Elasticsearch索引包括_elapsed_time和article两种文档类型
"""
help = 'build search index'
def handle(self, *args, **options):
"""
mk:
处理命令执行逻辑
Args:
*args: 位置参数
**options: 命令行选项参数
Returns:
None
"""
# mk:检查Elasticsearch是否启用
if ELASTICSEARCH_ENABLED:
# mk:构建_elapsed_time文档的索引
ElaspedTimeDocumentManager.build_index()
manager = ElapsedTimeDocument()
manager.init()
# mk:重新构建article文档的索引
manager = ArticleDocumentManager()
manager.delete_index()
manager.rebuild()

@ -3,11 +3,34 @@ from django.core.management.base import BaseCommand
from blog.models import Tag, Category
# TODO 参数化
# mk:TODO 参数化
class Command(BaseCommand):
"""
mk:
Django管理命令类用于构建搜索词列表
该命令从Tag和Category模型中提取名称数据合并去重后输出
"""
help = 'build search words'
def handle(self, *args, **options):
"""
mk:
处理命令的主要逻辑
从Tag和Category模型中获取所有名称合并为一个去重集合
然后将每个名称作为独立行打印输出
Args:
*args: 位置参数
**options: 命令行选项参数
Returns:
None
"""
#mk: 从Tag和Category模型中提取所有名称并合并去重
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
#mk: 将去重后的名称集合按行打印输出
print('\n'.join(datas))

@ -4,8 +4,29 @@ from djangoblog.utils import cache
class Command(BaseCommand):
"""
mk:
Django管理命令类用于清除整个缓存
该类继承自Django的BaseCommand提供了一个自定义的管理命令
可以通过命令行调用来清除应用的所有缓存数据
"""
help = 'clear the whole cache'
def handle(self, *args, **options):
"""
mk:
处理管理命令的主要逻辑
参数:
*args: 位置参数元组
**options: 关键字参数字典
返回值:
None
"""
# mk:清除所有缓存数据
cache.clear()
# mk:输出成功信息到标准输出
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))

@ -6,22 +6,47 @@ from blog.models import Article, Tag, Category
class Command(BaseCommand):
"""
mk:
Django管理命令类用于创建测试数据
该命令会创建测试用户分类标签和文章数据用于开发和测试环境
继承自Django的BaseCommand基类
"""
help = 'create test datas'
def handle(self, *args, **options):
"""
mk:
处理命令执行的主要逻辑
参数:
*args: 位置参数元组
**options: 命令行选项字典
返回值:
None
"""
# mk:创建或获取测试用户
user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
# mk:创建父级分类
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
# mk:创建子级分类
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
category.save()
# mk:创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# mk:批量创建20篇文章及其对应标签
for i in range(1, 20):
article = Article.objects.get_or_create(
category=category,
@ -35,6 +60,8 @@ class Command(BaseCommand):
article.tags.add(basetag)
article.save()
#mk: 清除缓存并输出成功信息
from djangoblog.utils import cache
cache.clear()
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -8,9 +8,26 @@ site = get_current_site().domain
class Command(BaseCommand):
"""
mk:
Django管理命令类用于向百度搜索引擎推送网站URL
该命令支持推送文章标签分类等不同类型的页面URL到百度搜索引擎
以提高网站内容的收录效率
Attributes:
help (str): 命令帮助信息
"""
help = 'notify baidu url'
def add_arguments(self, parser):
"""
mk:
添加命令行参数
:param parser: 参数解析器对象用于定义命令行参数
:return: None
"""
parser.add_argument(
'data_type',
type=str,
@ -22,21 +39,39 @@ class Command(BaseCommand):
help='article : all article,tag : all tag,category: all category,all: All of these')
def get_full_url(self, path):
"""
mk:
根据相对路径生成完整的HTTPS URL
:param path: 相对路径
:return: 完整的HTTPS URL字符串
"""
url = "https://{site}{path}".format(site=site, path=path)
return url
def handle(self, *args, **options):
"""
mk:
命令处理函数根据指定的数据类型收集URL并推送到百度
:param args: 位置参数
:param options: 命令行选项字典包含data_type键
:return: None
"""
type = options['data_type']
self.stdout.write('start get %s' % type)
urls = []
# mk:收集文章URL
if type == 'article' or type == 'all':
for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url())
# mk:收集标签URL
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
# mk:收集分类URL
if type == 'category' or type == 'all':
for category in Category.objects.all():
url = category.get_absolute_url()
@ -48,3 +83,4 @@ class Command(BaseCommand):
len(urls)))
SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -8,9 +8,26 @@ from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
"""
mk:
Django管理命令类用于同步用户头像
该命令会遍历所有OAuth用户检查并更新他们的头像URL
确保头像图片可以正常访问如果无法访问则尝试重新获取或使用默认头像
"""
help = 'sync user avatar'
def test_picture(self, url):
"""
mk:
测试图片URL是否可以正常访问
Args:
url (str): 要测试的图片URL地址
Returns:
bool: 如果URL可以正常访问返回True否则返回False
"""
try:
if requests.get(url, timeout=2).status_code == 200:
return True
@ -18,6 +35,17 @@ class Command(BaseCommand):
pass
def handle(self, *args, **options):
"""
mk:
命令处理函数执行用户头像同步逻辑
Args:
*args: 位置参数
**options: 命令行选项参数
Returns:
None
"""
static_url = static("../")
users = OAuthUser.objects.all()
self.stdout.write(f'开始同步{len(users)}个用户头像')
@ -25,19 +53,25 @@ class Command(BaseCommand):
self.stdout.write(f'开始同步:{u.nickname}')
url = u.picture
if url:
# mk:如果头像URL以静态URL开头说明可能是本地资源
if url.startswith(static_url):
#mk: 测试图片是否可以正常访问
if self.test_picture(url):
continue
else:
# mk:如果无法访问且用户有元数据则通过OAuth管理器重新获取头像
if u.metadata:
manage = get_manager_by_type(u.type)
url = manage.get_picture(u.metadata)
url = save_user_avatar(url)
else:
# mk:否则使用默认头像
url = static('blog/img/avatar.png')
else:
# mk:对于非本地资源,直接保存用户头像
url = save_user_avatar(url)
else:
# mk:如果没有头像URL使用默认头像
url = static('blog/img/avatar.png')
if url:
self.stdout.write(
@ -45,3 +79,4 @@ class Command(BaseCommand):
u.picture = url
u.save()
self.stdout.write('结束同步')

@ -10,11 +10,34 @@ logger = logging.getLogger(__name__)
class OnlineMiddleware(object):
"""
mk:
在线用户中间件类用于记录页面加载时间和用户访问信息
该中间件会在每个请求处理前后记录时间计算页面渲染耗时
并将相关信息存储到Elasticsearch中如果启用的话
Args:
get_response: Django中间件的get_response回调函数
"""
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def __call__(self, request):
"""
mk:
中间件调用方法处理请求并记录页面加载时间
记录请求开始时间处理请求计算耗时并将相关信息存储到Elasticsearch
同时替换响应内容中的加载时间占位符
Args:
request: Django HttpRequest对象包含当前请求信息
Returns:
HttpResponse: 处理后的HTTP响应对象
"""
''' page render time '''
start_time = time.time()
response = self.get_response(request)
@ -23,20 +46,24 @@ class OnlineMiddleware(object):
user_agent = parse(http_user_agent)
if not response.streaming:
try:
# mk:计算页面渲染耗时
cast_time = time.time() - start_time
if ELASTICSEARCH_ENABLED:
# mk:将耗时转换为毫秒并四舍五入到小数点后两位
time_taken = round((cast_time) * 1000, 2)
url = request.path
from django.utils import timezone
# mk:创建Elasticsearch文档记录
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# mk:替换响应内容中的加载时间占位符
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
logger.error("Error OnlineMiddleware: %s" % e)
return response
return response

@ -1,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
#mk: Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings
from django.db import migrations, models
@ -8,14 +8,24 @@ import mdeditor.fields
class Migration(migrations.Migration):
"""
mk:
Django 数据库迁移类用于初始化博客应用所需的数据库表结构
该迁移文件定义了博客系统所需的核心数据模型包括网站配置友情链接侧边栏标签分类和文章等
"""
# mk:标记此迁移为初始迁移
initial = True
# mk:定义依赖关系,确保在用户模型之后执行
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# mk:定义具体的数据库操作
operations = [
# mk:创建网站配置模型 BlogSettings
migrations.CreateModel(
name='BlogSettings',
fields=[
@ -41,6 +51,8 @@ class Migration(migrations.Migration):
'verbose_name_plural': '网站配置',
},
),
# mk:创建友情链接模型 Links
migrations.CreateModel(
name='Links',
fields=[
@ -59,6 +71,8 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# mk:创建侧边栏模型 SideBar
migrations.CreateModel(
name='SideBar',
fields=[
@ -76,6 +90,8 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# mk:创建标签模型 Tag
migrations.CreateModel(
name='Tag',
fields=[
@ -91,6 +107,8 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
# mk:创建分类模型 Category
migrations.CreateModel(
name='Category',
fields=[
@ -108,6 +126,8 @@ class Migration(migrations.Migration):
'ordering': ['-index'],
},
),
# mk:创建文章模型 Article
migrations.CreateModel(
name='Article',
fields=[
@ -135,3 +155,4 @@ class Migration(migrations.Migration):
},
),
]

@ -1,23 +1,34 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
#mk: Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
"""
mk:
Django数据库迁移类用于向BlogSettings模型添加全局头部和尾部字段
该迁移依赖于blog应用的0001_initial初始迁移包含了两个操作
1. 向BlogSettings模型添加global_footer字段
2. 向BlogSettings模型添加global_header字段
"""
dependencies = [
('blog', '0001_initial'),
]
operations = [
# mk:添加全局尾部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
# mk:添加全局头部字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]

@ -1,17 +1,27 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
# mk:Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
class Migration(migrations.Migration):
"""
mk:
数据库迁移类用于向BlogSettings模型添加评论审核功能
该迁移依赖于blog应用的0002_blogsettings_global_footer_and_more迁移
主要操作是为BlogSettings模型添加一个布尔类型的字段用于控制
评论是否需要管理员审核后才能显示
"""
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
]
operations = [
# mk:添加评论审核控制字段到BlogSettings模型
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]

@ -1,27 +1,45 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
# mk:Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
class Migration(migrations.Migration):
""""
mk:
数据库迁移类用于重命名博客设置模型中的字段名
该迁移将以下字段进行重命名
- analyticscode -> analytics_code
- beiancode -> beian_code
- sitename -> site_name
继承自 migrations.Migration 遵循Django的数据库迁移机制
"""
# mk:定义迁移依赖关系确保在执行当前迁移前blog应用的0003_blogsettings_comment_need_review迁移已经完成
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
]
# mk:定义具体的迁移操作列表
operations = [
# mk:将BlogSettings模型中的analyticscode字段重命名为analytics_code
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
),
# mk:将BlogSettings模型中的beiancode字段重命名为beian_code
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
),
#mk:将BlogSettings模型中的sitename字段重命名为site_name
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
),
]

@ -1,4 +1,4 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# mk:Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
@ -29,6 +29,357 @@ class Migration(migrations.Migration):
),
migrations.AlterModelOptions(
name='sidebar',
# mk:Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
"""
mk:
Django 数据库迁移类用于更新博客应用中的模型字段和选项
此迁移主要完成以下操作
- 更新多个模型的元数据如排序方式显示名称等
- 移除旧的时间字段created_time last_mod_time
- 添加新的时间字段creation_time last_modify_time
- 修改多个模型字段的属性 verbose_namedefault 最大长度等
Attributes:
dependencies (list): 指定当前迁移所依赖的其他迁移文件
operations (list): 包含所有数据库变更操作的列表
"""
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
# mk:更新 Article 模型的默认排序规则和其他元信息
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# mk:更新 Category 模型的排序规则及显示名称
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# mk:更新 Links 模型的排序规则及显示名称
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# mk:更新 Sidebar 模型的排序规则及显示名称
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# mk:更新 Tag 模型的排序规则及显示名称
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# mk:删除 Article 模型中已废弃的时间字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# mk:删除 Category 模型中已废弃的时间字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# mk:删除 Links 模型中已废弃的时间字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# mk:删除 Sidebar 模型中已废弃的时间字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# mk:删除 Tag 模型中已废弃的时间字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# mk:为 Article 模型添加新的创建时间和最后修改时间字段
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
#mk: 为 Category 模型添加新的创建时间和最后修改时间字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# mk:为 Links 模型添加新的创建时间字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# mk:为 Sidebar 模型添加新的创建时间字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# mk:为 Tag 模型添加新的创建时间和最后修改时间字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# mk:修改 Article 模型各字段定义以增强可读性和规范性
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# mk:修改 BlogSettings 模型相关配置项字段定义
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# mk:修改 Category 模型字段定义
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# mk:修改 Links 模型字段定义
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# mk:修改 Sidebar 模型字段定义
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# mk:修改 Tag 模型字段定义
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
migrations.AlterModelOptions(

@ -1,17 +1,26 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
# mk:Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
class Migration(migrations.Migration):
"""
mk:
Django数据库迁移类用于修改BlogSettings模型的显示选项配置
该迁移类继承自Django的Migration基类主要作用是更新blogsettings模型的元数据选项
设置更友好的模型名称显示
"""
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
operations = [
# mk:修改BlogSettings模型的显示选项设置单数和复数形式的可读名称
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]

@ -18,6 +18,17 @@ logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
"""
mk:
友情链接显示类型枚举
属性:
I: 首页显示
L: 列表页显示
P: 文章页显示
A: 全部页面显示
S: 幻灯片显示
"""
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
@ -26,11 +37,31 @@ class LinkShowType(models.TextChoices):
class BaseModel(models.Model):
"""
mk:
基础模型类提供通用字段和方法
属性:
id: 主键ID
creation_time: 创建时间
last_modify_time: 最后修改时间
"""
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):
"""
mk:
保存模型实例
Args:
*args: 位置参数
**kwargs: 关键字参数
Returns:
None
"""
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
@ -45,6 +76,13 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
def get_full_url(self):
"""
mk:
获取完整的URL地址
Returns:
str: 完整的URL地址
"""
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
@ -55,11 +93,35 @@ class BaseModel(models.Model):
@abstractmethod
def get_absolute_url(self):
"""
mk:
抽象方法获取绝对URL地址
Returns:
str: 绝对URL地址
"""
pass
class Article(BaseModel):
"""文章"""
"""
mk:
文章模型类
属性:
title: 标题
body: 正文内容
pub_time: 发布时间
status: 状态草稿/发布
comment_status: 评论状态开启/关闭
type: 类型文章/页面
views: 浏览量
author: 作者
article_order: 排序
show_toc: 是否显示目录
category: 分类
tags: 标签
"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
@ -106,9 +168,23 @@ class Article(BaseModel):
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
"""
mk:
将正文内容转换为字符串
Returns:
str: 正文内容字符串
"""
return self.body
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 文章标题
"""
return self.title
class Meta:
@ -118,6 +194,13 @@ class Article(BaseModel):
get_latest_by = 'id'
def get_absolute_url(self):
"""
mk:
获取文章详情页的绝对URL
Returns:
str: 文章详情页URL
"""
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -127,19 +210,48 @@ class Article(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
mk:
获取分类树结构
Returns:
list: 分类名称和URL的元组列表
"""
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):
"""
mk:
保存文章实例
Args:
*args: 位置参数
**kwargs: 关键字参数
Returns:
None
"""
super().save(*args, **kwargs)
def viewed(self):
"""
mk:
增加文章浏览量并保存
"""
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
"""
mk:
获取文章评论列表带缓存功能
Returns:
QuerySet: 评论查询集
"""
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
@ -152,24 +264,48 @@ class Article(BaseModel):
return comments
def get_admin_url(self):
"""
mk:
获取后台管理URL
Returns:
str: 后台管理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)
def next_article(self):
"""
mk:
获取下一篇已发布的文章
Returns:
Article|None: 下一篇文章对象或None
"""
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
"""
mk:
获取上一篇已发布的文章
Returns:
Article|None: 上一篇文章对象或None
"""
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
mk:
从文章正文中提取第一张图片的URL
Returns:
str: 第一张图片的URL如果没有找到则返回空字符串
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
@ -178,7 +314,16 @@ class Article(BaseModel):
class Category(BaseModel):
"""文章分类"""
"""
mk:
文章分类模型类
属性:
name: 分类名称
parent_category: 父级分类
slug: URL别名
index: 排序索引
"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
@ -195,18 +340,35 @@ class Category(BaseModel):
verbose_name_plural = verbose_name
def get_absolute_url(self):
"""
mk:
获取分类详情页的绝对URL
Returns:
str: 分类详情页URL
"""
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 分类名称
"""
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
mk:
递归获取分类目录的父级分类树
Returns:
list: 包含当前分类及其所有父级分类的列表
"""
categorys = []
@ -221,8 +383,11 @@ class Category(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
mk:
获取当前分类目录的所有子分类
Returns:
list: 包含当前分类及其所有子分类的列表
"""
categorys = []
all_categorys = Category.objects.all()
@ -241,18 +406,46 @@ class Category(BaseModel):
class Tag(BaseModel):
"""文章标签"""
"""
mk:
文章标签模型类
属性:
name: 标签名称
slug: URL别名
"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 标签名称
"""
return self.name
def get_absolute_url(self):
"""
mk:
获取标签详情页的绝对URL
Returns:
str: 标签详情页URL
"""
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
"""
mk:
获取使用该标签的文章数量
Returns:
int: 文章数量
"""
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
@ -262,8 +455,19 @@ class Tag(BaseModel):
class Links(models.Model):
"""友情链接"""
"""
mk:
友情链接模型类
属性:
name: 链接名称
link: 链接地址
sequence: 排序
is_enable: 是否启用
show_type: 显示类型
creation_time: 创建时间
last_mod_time: 最后修改时间
"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
@ -283,11 +487,29 @@ class Links(models.Model):
verbose_name_plural = verbose_name
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 链接名称
"""
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
"""
mk:
侧边栏模型类用于展示HTML内容
属性:
name: 标题
content: 内容
sequence: 排序
is_enable: 是否启用
creation_time: 创建时间
last_mod_time: 最后修改时间
"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
@ -301,11 +523,41 @@ class SideBar(models.Model):
verbose_name_plural = verbose_name
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 侧边栏标题
"""
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
"""
mk:
博客配置模型类
属性:
site_name: 网站名称
site_description: 网站描述
site_seo_description: SEO描述
site_keywords: SEO关键词
article_sub_length: 文章摘要长度
sidebar_article_count: 侧边栏文章数量
sidebar_comment_count: 侧边栏评论数量
article_comment_count: 文章评论数量
show_google_adsense: 是否显示Google广告
google_adsense_codes: Google广告代码
open_site_comment: 是否开启网站评论
global_header: 公共头部代码
global_footer: 公共尾部代码
beian_code: 备案号
analytics_code: 网站统计代码
show_gongan_code: 是否显示公安备案号
gongan_beiancode: 公安备案号
comment_need_review: 评论是否需要审核
"""
site_name = models.CharField(
_('site name'),
max_length=200,
@ -364,13 +616,38 @@ class BlogSettings(models.Model):
verbose_name_plural = verbose_name
def __str__(self):
"""
mk:
返回对象的字符串表示
Returns:
str: 网站名称
"""
return self.site_name
def clean(self):
"""
mk:
数据验证确保只存在一个配置实例
Raises:
ValidationError: 当已存在配置实例时抛出异常
"""
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
"""
mk:
保存配置并清除缓存
Args:
*args: 位置参数
**kwargs: 关键字参数
Returns:
None
"""
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -4,10 +4,36 @@ from blog.models import Article
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
"""
mk:
文章搜索索引类
用于为Article模型创建搜索引擎索引继承自haystack的SearchIndex和Indexable类
"""
# 定义搜索文档的文本字段document=True表示这是主要的搜索字段
# use_template=True表示使用模板来定义索引内容
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
"""
mk:
获取索引关联的模型类
Returns:
Article: 返回Article模型类
"""
return Article
def index_queryset(self, using=None):
"""
mk:
获取需要建立索引的查询集
Args:
using (str, optional): 数据库别名默认为None
Returns:
QuerySet: 返回状态为'p'(已发布)的文章查询集
"""
return self.get_model().objects.filter(status='p')

@ -1881,7 +1881,7 @@ img#wpstats {
}
}
/* Minimum width of 960 pixels. */
/* mk:Minimum width of 960 pixels. */
@media screen and (min-width: 960px) {
body {
background-color: #e6e6e6;
@ -2145,14 +2145,14 @@ div {
word-break: break-all;
}
/* 评论整体布局 - 使用相对定位实现头像左侧布局 */
/* mk:评论整体布局 - 使用相对定位实现头像左侧布局 */
.commentlist .comment-body {
position: relative;
padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */
min-height: 48px; /* 确保有足够高度容纳头像 */
}
/* 评论作者信息 - 用户名和时间在同一行 */
/*mk: 评论作者信息 - 用户名和时间在同一行 */
.commentlist .comment-author {
display: inline-block;
margin: 0 10px 5px 0;
@ -2173,7 +2173,7 @@ div {
line-height: 22px;
}
/* 头像样式 - 绝对定位到左侧 */
/*mk: 头像样式 - 绝对定位到左侧 */
.commentlist .comment-author .avatar {
position: absolute !important;
left: -60px; /* 定位到容器左侧 */
@ -2187,7 +2187,7 @@ div {
border: 1px solid #ddd;
}
/* 评论作者名称样式 */
/*mk: 评论作者名称样式 */
.commentlist .comment-author .fn {
display: inline;
margin: 0;
@ -2205,7 +2205,7 @@ div {
text-decoration: underline;
}
/* 评论内容样式 */
/* mk:评论内容样式 */
.commentlist .comment-body p {
margin: 5px 0 10px 0;
line-height: 1.5;
@ -2222,7 +2222,7 @@ div {
display: none;
}
/* 通用头像样式 */
/* mk:通用头像样式 */
.commentlist .avatar {
width: 48px !important;
height: 48px !important;
@ -2266,12 +2266,12 @@ div {
font-style: normal;
}
/* pings */
/* mk:pings */
.pinglist li {
padding-left: 0;
}
/* comment text */
/*mk:comment text */
.commentlist .comment-body p {
margin-bottom: 8px;
color: #777;
@ -2294,7 +2294,7 @@ div {
list-style: square;
}
/* post author & admin comment */
/*mk: post author & admin comment */
.commentlist li.bypostauthor > .comment-body:after,
.commentlist li.comment-author-admin > .comment-body:after {
display: block;
@ -2331,7 +2331,7 @@ div {
border-radius: 100%;
}
/* child comment */
/* mk:child comment */
.commentlist li ul {
}
@ -2340,42 +2340,42 @@ div {
padding-left: 48px;
}
/* 嵌套评论整体布局 */
/*mk: 嵌套评论整体布局 */
.commentlist li li .comment-body {
padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */
min-height: 48px; /* 确保有足够高度容纳头像 */
padding-left: 60px; /* mk:为48px头像 + 12px间距留出空间 */
min-height: 48px; /* mk:确保有足够高度容纳头像 */
}
/* 嵌套评论作者信息 */
/*mk: 嵌套评论作者信息 */
.commentlist li li .comment-author {
display: inline-block;
margin: 0 8px 5px 0;
font-size: 12px; /* 稍小一点 */
font-size: 12px; /*mk: 稍小一点 */
}
.commentlist li li .comment-meta {
display: inline-block;
margin: 0 0 8px 0;
font-size: 11px; /* 稍小一点 */
font-size: 11px; /*mk: 稍小一点 */
color: #666;
}
/* 评论容器整体左移 - 使用更高优先级 */
/* mk:评论容器整体左移 - 使用更高优先级 */
#comments #commentlist-container.comment-tab {
margin-left: -15px !important; /* 在小屏幕上向左移动15px */
padding-left: 0 !important; /* 移除左内边距 */
position: relative !important; /* 确保定位正确 */
margin-left: -15px !important; /* mk:在小屏幕上向左移动15px */
padding-left: 0 !important; /* mk:移除左内边距 */
position: relative !important; /*mk: 确保定位正确 */
}
/* 在较大屏幕上进一步左移 */
/* mk:在较大屏幕上进一步左移 */
@media screen and (min-width: 600px) {
#comments #commentlist-container.comment-tab {
margin-left: -30px !important; /* 在大屏幕上向左移动30px */
margin-left: -30px !important; /* mk:在大屏幕上向左移动30px */
}
/* 响应式设计下的评论布局 - 保持48px头像 */
/*mk: 响应式设计下的评论布局 - 保持48px头像 */
.commentlist .comment-body {
padding-left: 60px !important; /* 为48px头像 + 12px间距留出空间 */
padding-left: 60px !important; /* mk:为48px头像 + 12px间距留出空间 */
min-height: 48px !important;
}
@ -2389,14 +2389,14 @@ div {
margin: 0 0 8px 0 !important;
}
/* 响应式设计下头像保持48px */
/* mk:响应式设计下头像保持48px */
.commentlist .comment-author .avatar {
left: -60px !important;
width: 48px !important;
height: 48px !important;
}
/* 嵌套评论在响应式设计下也保持48px头像 */
/* mk:嵌套评论在响应式设计下也保持48px头像 */
.commentlist li li .comment-body {
padding-left: 60px !important;
min-height: 48px !important;
@ -2409,10 +2409,10 @@ div {
}
}
/* 嵌套评论头像 */
/* mk:嵌套评论头像 */
.commentlist li li .comment-author .avatar {
position: absolute !important;
left: -60px; /* 定位到容器左侧 */
left: -60px; /* mk:定位到容器左侧 */
top: 0;
width: 48px !important;
height: 48px !important;
@ -2423,7 +2423,7 @@ div {
border: 1px solid #ddd;
}
/* comments : nav
/* mk:comments : nav
/* ------------------------------------ */
.comments-nav {
margin-bottom: 20px;
@ -2441,7 +2441,7 @@ div {
float: right;
}
/* comments : form
/* mk: comments : form
/* ------------------------------------ */
.logged-in-as,
.comment-notes,
@ -2626,11 +2626,12 @@ li #reply-title {
}
/* =============================================================================
mk:
============================================================================= */
/* 评论容器基础样式 */
/* mk:评论容器基础样式 */
.comment-body {
overflow-wrap: break-word;
word-wrap: break-word;
@ -2639,7 +2640,7 @@ li #reply-title {
box-sizing: border-box;
}
/* 修复评论中的代码块溢出 */
/* mk:修复评论中的代码块溢出 */
.comment-content pre,
.comment-body pre {
white-space: pre-wrap !important;
@ -2656,7 +2657,7 @@ li #reply-title {
margin: 10px 0;
}
/* 修复评论中的行内代码 */
/* mk:修复评论中的行内代码 */
.comment-content code,
.comment-body code {
word-wrap: break-word !important;
@ -2667,7 +2668,7 @@ li #reply-title {
vertical-align: top;
}
/* 修复评论中的长链接 */
/* mk:修复评论中的长链接 */
.comment-content a,
.comment-body a {
word-wrap: break-word !important;
@ -2676,7 +2677,7 @@ li #reply-title {
max-width: 100%;
}
/* 修复评论段落 */
/* mk:修复评论段落 */
.comment-content p,
.comment-body p {
word-wrap: break-word !important;
@ -2685,7 +2686,7 @@ li #reply-title {
margin: 10px 0;
}
/* 特殊处理代码高亮块 - 关键修复! */
/* mk:特殊处理代码高亮块 - 关键修复! */
.comment-content .codehilite,
.comment-body .codehilite {
max-width: 100% !important;
@ -2720,7 +2721,7 @@ li #reply-title {
box-sizing: border-box;
}
/* 修复代码高亮中的span标签 */
/* mk:修复代码高亮中的span标签 */
.comment-content .codehilite span,
.comment-body .codehilite span {
word-wrap: break-word !important;
@ -2730,7 +2731,7 @@ li #reply-title {
max-width: 100%;
}
/* 针对特定的代码高亮类 */
/* mk:针对特定的代码高亮类 */
.comment-content .codehilite .kt,
.comment-content .codehilite .nf,
.comment-content .codehilite .n,
@ -2743,7 +2744,7 @@ li #reply-title {
overflow-wrap: break-word !important;
}
/* 搜索结果高亮样式 */
/* mk:搜索结果高亮样式 */
.search-result {
margin-bottom: 30px;
padding: 20px;
@ -2785,7 +2786,7 @@ li #reply-title {
margin: 10px 0;
}
/* 搜索关键词高亮 */
/* mk:搜索关键词高亮 */
.search-excerpt em,
.search-result .entry-title em {
background-color: #fff3cd;
@ -2817,14 +2818,14 @@ li #reply-title {
overflow-wrap: break-word;
}
/* 修复评论列表项 */
/* mk:修复评论列表项 */
.commentlist li {
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
}
/* 确保评论内容不超出容器 */
/* mk:确保评论内容不超出容器 */
.commentlist .comment-body {
max-width: calc(100% - 20px); /* 留出一些边距 */
margin-left: 10px;
@ -2833,21 +2834,21 @@ li #reply-title {
word-wrap: break-word;
}
/* 重要:限制评论列表项的最大宽度 */
/* mk:重要:限制评论列表项的最大宽度 */
.commentlist li[style*="margin-left"] {
max-width: calc(100% - 2rem) !important;
overflow: hidden;
box-sizing: border-box;
}
/* 特别处理深层嵌套的评论 */
/* mk:特别处理深层嵌套的评论 */
.commentlist li[style*="margin-left: 3rem"],
.commentlist li[style*="margin-left: 6rem"],
.commentlist li[style*="margin-left: 9rem"] {
max-width: calc(100% - 1rem) !important;
}
/* 移动端优化 */
/*mk: 移动端优化 */
@media (max-width: 768px) {
.comment-content pre,
.comment-body pre {
@ -2862,14 +2863,14 @@ li #reply-title {
margin-right: 5px;
}
/* 移动端评论缩进调整 */
/* mk:移动端评论缩进调整 */
.commentlist li[style*="margin-left"] {
margin-left: 1rem !important;
max-margin-left: 2rem !important;
}
}
/* 防止表格溢出 */
/* mk:防止表格溢出 */
.comment-content table,
.comment-body table {
max-width: 100%;
@ -2878,14 +2879,14 @@ li #reply-title {
white-space: nowrap;
}
/* 修复图片溢出 */
/* mk:修复图片溢出 */
.comment-content img,
.comment-body img {
max-width: 100% !important;
height: auto !important;
}
/* 修复引用块 */
/* mk:修复引用块 */
.comment-content blockquote,
.comment-body blockquote {
max-width: 100%;

@ -1,174 +1,12 @@
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
/*
mk:
* Open Sans /300400600
* @font-face 西
* 使 WOFF2 unicode-range
* font-display: swap 使
*/
/* mk:vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -178,7 +16,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
/*mk: latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -188,7 +26,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
/*mk: latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -198,7 +36,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
/*mk: cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -208,7 +46,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
/*mk: cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -218,7 +56,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
/*mk: greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -228,7 +66,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
/*mk: greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -238,7 +76,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
/*mk: hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -248,7 +86,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
/*mk: math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -258,7 +96,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
/*mk: symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -268,7 +106,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
/*mk: vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -278,7 +116,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
/*mk: latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -288,7 +126,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
/*mk: latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
@ -298,7 +136,7 @@
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
/*mk: cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -308,17 +146,17 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
/*mk: cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
/*mk: greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -328,7 +166,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
/*mk: greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -338,7 +176,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
/*mk: hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -348,7 +186,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
/*mk: math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -358,7 +196,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
/*mk: symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -368,7 +206,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
/*mk: vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -378,7 +216,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
/*mk: latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -388,7 +226,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
/*mk: latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -398,7 +236,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
/*mk: cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -408,7 +246,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
/*mk: cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -418,7 +256,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
/*mk: greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -428,7 +266,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
/*mk: greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -438,7 +276,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
/*mk: hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -448,7 +286,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
/*mk: math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -458,7 +296,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
/*mk: symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -468,7 +306,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
/*mk: vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -478,7 +316,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
/*mk: latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -488,7 +326,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
/*mk: latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -498,7 +336,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
/*mk: cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -508,7 +346,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
/*mk: cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -518,7 +356,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
/* mk:greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -528,7 +366,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
/*mk: greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -538,7 +376,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
/* mk:hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -548,7 +386,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
/* mk:math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -558,7 +396,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
/* mk:symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -568,7 +406,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
/* mk:vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -578,7 +416,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
/* mk:latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -588,7 +426,7 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
/* mk:latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
@ -598,3 +436,4 @@
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@ -3,6 +3,11 @@
*/
/**
mk:
* 执行回复操作设置父评论ID并将评论表单移动到指定位置
* @param {string|number} parentid - 父评论的ID
*/
function do_reply(parentid) {
console.log(parentid);
$("#id_parent_comment_id").val(parentid)
@ -11,6 +16,10 @@ function do_reply(parentid) {
$("#cancel_comment").show();
}
/**
mk:
* 取消回复操作重置评论表单到初始状态
*/
function cancel_reply() {
$("#reply-title").show();
$("#cancel_comment").hide();
@ -18,23 +27,34 @@ function cancel_reply() {
$("#commentform").appendTo($("#respond"));
}
// mk:初始化页面加载进度条
NProgress.start();
NProgress.set(0.4);
//Increment
var interval = setInterval(function () {
NProgress.inc();
}, 1000);
// mk:页面加载完成后停止进度条
$(document).ready(function () {
NProgress.done();
clearInterval(interval);
});
/** 侧边栏回到顶部 */
/**mk: 侧边栏回到顶部 */
var rocket = $('#rocket');
//mk: 监听窗口滚动事件,控制回到顶部按钮的显示
$(window).on('scroll', debounce(slideTopSet, 300));
/**
mk:
* 防抖函数用于限制函数执行频率
* @param {Function} func - 需要防抖的函数
* @param {number} wait - 延迟执行的时间间隔毫秒
* @returns {Function} 返回防抖后的函数
*/
function debounce(func, wait) {
var timeout;
return function () {
@ -43,6 +63,10 @@ function debounce(func, wait) {
};
}
/**
mk:
* 根据滚动位置控制回到顶部按钮的显示状态
*/
function slideTopSet() {
var top = $(document).scrollTop();
@ -53,12 +77,15 @@ function slideTopSet() {
}
}
// mk:点击回到顶部按钮时执行动画滚动到顶部
$(document).on('click', '#rocket', function (event) {
rocket.addClass('move');
$('body, html').animate({
scrollTop: 0
}, 800);
});
// mk:动画结束后的清理处理
$(document).on('animationEnd', function () {
setTimeout(function () {
rocket.removeClass('move');
@ -72,6 +99,7 @@ $(document).on('webkitAnimationEnd', function () {
});
// mk:页面加载完成后为所有回复链接绑定点击事件
window.onload = function () {
var replyLinks = document.querySelectorAll(".comment-reply-link");
for (var i = 0; i < replyLinks.length; i++) {
@ -82,10 +110,10 @@ window.onload = function () {
}
};
// $(document).ready(function () {
//mk: $(document).ready(function () {
// var form = $('#i18n-form');
// var selector = $('.i18n-select');
// selector.on('change', function () {
// form.submit();
// });
// });
// });

@ -1,8 +1,60 @@
/*
HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag();
a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x<style>article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}</style>";
c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML="<xyz></xyz>";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode||
"undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f);
if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d<h;d++)c.createElement(e[d]);return c}};l.html5=e;q(f)})(this,document);
/**
mk:
* 获取HTML5元素列表
* @returns {Array|string} 返回元素名称数组或字符串
*/
(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}
/**
mk:
* 获取与文档关联的缓存对象
* @param {Object} a - 文档对象
* @returns {Object} 返回对应的缓存对象
*/
function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}
/**
mk:
* 创建HTML元素
* @param {string} a - 元素标签名
* @param {Object} b - 文档对象可选
* @param {Object} c - 缓存对象可选
* @returns {Object} 返回创建的元素节点
*/
function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}
/**
mk:
* 重写文档的createElement和createDocumentFragment方法以支持HTML5元素
* @param {Object} a - 文档对象
* @param {Object} b - 缓存配置对象
*/
function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag();
a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}
/**
mk:
* 初始化并应用HTML5 Shiv到指定文档
* @param {Object} a - 文档对象可选默认为当前文档
* @returns {Object} 返回处理后的文档对象
*/
function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x<style>article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}</style>";
c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}
// mk:初始化变量和配置
var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;
// mk:检测浏览器是否原生支持未知元素和相关API
(function(){try{var a=f.createElement("a");a.innerHTML="<xyz></xyz>";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode||
"undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();
// mk:定义HTML5 Shiv的核心配置和公共接口
var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f);
if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d<h;d++)c.createElement(e[d]);return c}};
// mk:应用HTML5 Shiv到当前文档
l.html5=e;q(f)})(this,document);

File diff suppressed because one or more lines are too long

@ -1,4 +1,5 @@
/**
mk:
* MathJax 智能加载器
* 检测页面是否包含数学公式如果有则动态加载和配置MathJax
*/
@ -6,6 +7,7 @@
'use strict';
/**
mk:
* 检测页面是否包含数学公式
* @returns {boolean} 是否包含数学公式
*/
@ -16,34 +18,35 @@
}
/**
mk:
* 配置MathJax
*/
function configureMathJax() {
window.MathJax = {
tex: {
// 行内公式和块级公式分隔符
// mk:行内公式和块级公式分隔符
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
// 处理转义字符和LaTeX环境
// mk:处理转义字符和LaTeX环境
processEscapes: true,
processEnvironments: true,
// 自动换行
// mk:自动换行
tags: 'ams'
},
options: {
// 跳过这些HTML标签避免处理代码块等
// mk:跳过这些HTML标签避免处理代码块等
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'a'],
// CSS类控制
// mk:CSS类控制
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
},
// 启动配置
// mk:启动配置
startup: {
ready() {
console.log('MathJax配置完成开始初始化...');
MathJax.startup.defaultReady();
// 处理特定区域的数学公式
// mk:处理特定区域的数学公式
const contentEl = document.getElementById('content');
const commentsEl = document.getElementById('comments');
@ -55,17 +58,17 @@
promises.push(MathJax.typesetPromise([commentsEl]));
}
// 等待所有渲染完成
// mk:等待所有渲染完成
Promise.all(promises).then(() => {
console.log('MathJax渲染完成');
// 触发自定义事件通知其他脚本MathJax已就绪
// mk:触发自定义事件通知其他脚本MathJax已就绪
document.dispatchEvent(new CustomEvent('mathjaxReady'));
}).catch(error => {
console.error('MathJax渲染失败:', error);
});
}
},
// 输出配置
// mk:输出配置
chtml: {
scale: 1,
minScale: 0.5,
@ -77,6 +80,7 @@
}
/**
mk:
* 加载MathJax库
*/
function loadMathJax() {
@ -109,18 +113,19 @@
}
/**
mk:
* 初始化函数
*/
function init() {
// 等待DOM完全加载
// mk:等待DOM完全加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
return;
}
// 检测是否需要加载MathJax
// mk:检测是否需要加载MathJax
if (hasMathFormulas()) {
// 先配置,再加载
// mk:先配置,再加载
configureMathJax();
loadMathJax();
} else {
@ -128,7 +133,7 @@
}
}
// 提供重新渲染的全局方法,供动态内容使用
// mk:提供重新渲染的全局方法,供动态内容使用
window.rerenderMathJax = function(element) {
if (window.MathJax && window.MathJax.typesetPromise) {
const target = element || document.body;
@ -137,6 +142,6 @@
return Promise.resolve();
};
// 启动初始化
// mk:启动初始化
init();
})();

@ -1,6 +1,11 @@
/**
mk:
* Handles toggling the navigation menu for small screens and
* accessibility for submenu items.
*
* This self-invoking function manages the mobile navigation toggle behavior.
* It adds click event to the menu button to show/hide the navigation menu
* by toggling CSS classes.
*/
( function() {
var nav = document.getElementById( 'site-navigation' ), button, menu;
@ -14,7 +19,7 @@
return;
}
// Hide button if menu is missing or empty.
// mk:Hide button if menu is missing or empty.
if ( ! menu || ! menu.childNodes.length ) {
button.style.display = 'none';
return;
@ -35,12 +40,23 @@
};
} )();
// Better focus for hidden submenu items for accessibility.
/**
mk:
* Enhances focus handling for submenu items to improve accessibility
* and handles touch events for mobile devices.
*
* @param {Object} $ - jQuery object
*
* This self-invoking function improves the accessibility of navigation menus
* by adding focus and blur event handlers to menu items. It also handles
* touch events for mobile devices to properly display submenus.
*/
( function( $ ) {
$( '.main-navigation' ).find( 'a' ).on( 'focus.twentytwelve blur.twentytwelve', function() {
$( this ).parents( '.menu-item, .page_item' ).toggleClass( 'focus' );
} );
// mk:Handle touch events for mobile devices to properly display submenus
if ( 'ontouchstart' in window ) {
$('body').on( 'touchstart.twentytwelve', '.menu-item-has-children > a, .page_item_has_children > a', function( e ) {
var el = $( this ).parent( 'li' );

@ -1,4 +1,4 @@
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
/* mk:NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
* @license MIT */
;(function(root, factory) {
@ -31,6 +31,7 @@
};
/**
mk:
* Updates configuration.
*
* NProgress.configure({
@ -48,12 +49,14 @@
};
/**
mk:
* Last number.
*/
NProgress.status = null;
/**
mk:
* Sets the progress bar status, where `n` is a number from `0.0` to `1.0`.
*
* NProgress.set(0.4);
@ -71,13 +74,13 @@
speed = Settings.speed,
ease = Settings.easing;
progress.offsetWidth; /* Repaint */
progress.offsetWidth; /*mk:Repaint */
queue(function(next) {
// Set positionUsing if it hasn't already been set
// mk:Set positionUsing if it hasn't already been set
if (Settings.positionUsing === '') Settings.positionUsing = NProgress.getPositioningCSS();
// Add transition
// mk:Add transition
css(bar, barPositionCSS(n, speed, ease));
if (n === 1) {
@ -111,6 +114,7 @@
};
/**
mk:
* Shows the progress bar.
* This is the same as setting the status to 0%, except that it doesn't go backwards.
*
@ -134,6 +138,7 @@
};
/**
mk:
* Hides the progress bar.
* This is the *sort of* the same as setting the status to 100%, with the
* difference being `done()` makes some placebo effect of some realistic motion.
@ -152,6 +157,7 @@
};
/**
mk:
* Increments by a random amount.
*/
@ -181,6 +187,7 @@
};
/**
mk:
* Waits for all supplied jQuery promises and
* increases the progress as the promises resolve.
*
@ -217,6 +224,7 @@
})();
/**
mk:
* (Internal) renders the progress bar markup based on the `template`
* setting.
*/
@ -254,6 +262,7 @@
};
/**
mk:
* Removes the element. Opposite of render().
*/
@ -265,6 +274,7 @@
};
/**
mk:
* Checks if the progress bar is rendered.
*/
@ -273,32 +283,34 @@
};
/**
mk:
* Determine which positioning CSS rule to use.
*/
NProgress.getPositioningCSS = function() {
// Sniff on document.body.style
// mk:Sniff on document.body.style
var bodyStyle = document.body.style;
// Sniff prefixes
// mk:Sniff prefixes
var vendorPrefix = ('WebkitTransform' in bodyStyle) ? 'Webkit' :
('MozTransform' in bodyStyle) ? 'Moz' :
('msTransform' in bodyStyle) ? 'ms' :
('OTransform' in bodyStyle) ? 'O' : '';
if (vendorPrefix + 'Perspective' in bodyStyle) {
// Modern browsers with 3D support, e.g. Webkit, IE10
//mk: Modern browsers with 3D support, e.g. Webkit, IE10
return 'translate3d';
} else if (vendorPrefix + 'Transform' in bodyStyle) {
// Browsers without 3D support, e.g. IE9
// mk:Browsers without 3D support, e.g. IE9
return 'translate';
} else {
// Browsers without translate() support, e.g. IE7-8
// mk:Browsers without translate() support, e.g. IE7-8
return 'margin';
}
};
/**
mk:
* Helpers
*/
@ -309,6 +321,7 @@
}
/**
mk:
* (Internal) converts a percentage (`0..1`) to a bar translateX
* percentage (`-100%..0%`).
*/
@ -319,6 +332,7 @@
/**
mk:
* (Internal) returns the correct CSS for changing the bar's
* position given an n percentage, and speed and ease from Settings
*/
@ -340,6 +354,7 @@
}
/**
mk:
* (Internal) Queues a function to be executed.
*/
@ -360,6 +375,7 @@
})();
/**
mk:
* (Internal) Applies css properties to an element, similar to the jQuery
* css method.
*
@ -419,6 +435,7 @@
})();
/**
mk:
* (Internal) Determines if an element or space separated list of class names contains a class name.
*/
@ -427,7 +444,7 @@
return list.indexOf(' ' + name + ' ') >= 0;
}
/**
/**mk:
* (Internal) Adds a class to an element.
*/
@ -442,6 +459,7 @@
}
/**
mk:
* (Internal) Removes a class from an element.
*/
@ -451,14 +469,15 @@
if (!hasClass(element, name)) return;
// Replace the class name.
// mk:Replace the class name.
newList = oldList.replace(' ' + name + ' ', ' ');
// Trim the opening and closing spaces.
// mk:Trim the opening and closing spaces.
element.className = newList.substring(1, newList.length - 1);
}
/**
mk:
* (Internal) Gets a space separated list of the class names on the element.
* The list is wrapped with a single space on each end to facilitate finding
* matches within the list.
@ -469,6 +488,7 @@
}
/**
mk:
* (Internal) Removes an element from the DOM.
*/

@ -10,284 +10,284 @@
color: #177500
}
/* Comment */
/*mk: Comment */
.codehilite .err {
color: #000000
}
/* Error */
/*mk: Error */
.codehilite .k {
color: #A90D91
}
/* Keyword */
/*mk: Keyword */
.codehilite .l {
color: #1C01CE
}
/* Literal */
/*mk: Literal */
.codehilite .n {
color: #000000
}
/* Name */
/*mk: Name */
.codehilite .o {
color: #000000
}
/* Operator */
/*mk: Operator */
.codehilite .ch {
color: #177500
}
/* Comment.Hashbang */
/*mk: Comment.Hashbang */
.codehilite .cm {
color: #177500
}
/* Comment.Multiline */
/*mk: Comment.Multiline */
.codehilite .cp {
color: #633820
}
/* Comment.Preproc */
/*mk: Comment.Preproc */
.codehilite .cpf {
color: #177500
}
/* Comment.PreprocFile */
/*mk: Comment.PreprocFile */
.codehilite .c1 {
color: #177500
}
/* Comment.Single */
/*mk: Comment.Single */
.codehilite .cs {
color: #177500
}
/* Comment.Special */
/*mk: Comment.Special */
.codehilite .kc {
color: #A90D91
}
/* Keyword.Constant */
/*mk: Keyword.Constant */
.codehilite .kd {
color: #A90D91
}
/* Keyword.Declaration */
/*mk: Keyword.Declaration */
.codehilite .kn {
color: #A90D91
}
/* Keyword.Namespace */
/*mk: Keyword.Namespace */
.codehilite .kp {
color: #A90D91
}
/* Keyword.Pseudo */
/*mk: Keyword.Pseudo */
.codehilite .kr {
color: #A90D91
}
/* Keyword.Reserved */
/*mk: Keyword.Reserved */
.codehilite .kt {
color: #A90D91
}
/* Keyword.Type */
/*mk: Keyword.Type */
.codehilite .ld {
color: #1C01CE
}
/* Literal.Date */
/*mk: Literal.Date */
.codehilite .m {
color: #1C01CE
}
/* Literal.Number */
/*mk: Literal.Number */
.codehilite .s {
color: #C41A16
}
/* Literal.String */
/*mk: Literal.String */
.codehilite .na {
color: #836C28
}
/* Name.Attribute */
/*mk: Name.Attribute */
.codehilite .nb {
color: #A90D91
}
/* Name.Builtin */
/*mk: Name.Builtin */
.codehilite .nc {
color: #3F6E75
}
/* Name.Class */
/*mk: Name.Class */
.codehilite .no {
color: #000000
}
/* Name.Constant */
/*mk: Name.Constant */
.codehilite .nd {
color: #000000
}
/* Name.Decorator */
/*mk: Name.Decorator */
.codehilite .ni {
color: #000000
}
/* Name.Entity */
/*mk: Name.Entity */
.codehilite .ne {
color: #000000
}
/* Name.Exception */
/*mk: Name.Exception */
.codehilite .nf {
color: #000000
}
/* Name.Function */
/*mk: Name.Function */
.codehilite .nl {
color: #000000
}
/* Name.Label */
/*mk: Name.Label */
.codehilite .nn {
color: #000000
}
/* Name.Namespace */
/*mk: Name.Namespace */
.codehilite .nx {
color: #000000
}
/* Name.Other */
/*mk: Name.Other */
.codehilite .py {
color: #000000
}
/* Name.Property */
/*mk: Name.Property */
.codehilite .nt {
color: #000000
}
/* Name.Tag */
/*mk: Name.Tag */
.codehilite .nv {
color: #000000
}
/* Name.Variable */
/*mk: Name.Variable */
.codehilite .ow {
color: #000000
}
/* Operator.Word */
/*mk: Operator.Word */
.codehilite .mb {
color: #1C01CE
}
/* Literal.Number.Bin */
/*mk: Literal.Number.Bin */
.codehilite .mf {
color: #1C01CE
}
/* Literal.Number.Float */
/*mk: Literal.Number.Float */
.codehilite .mh {
color: #1C01CE
}
/* Literal.Number.Hex */
/*mk: Literal.Number.Hex */
.codehilite .mi {
color: #1C01CE
}
/* Literal.Number.Integer */
/*mk: Literal.Number.Integer */
.codehilite .mo {
color: #1C01CE
}
/* Literal.Number.Oct */
/*mk: Literal.Number.Oct */
.codehilite .sb {
color: #C41A16
}
/* Literal.String.Backtick */
/*mk: Literal.String.Backtick */
.codehilite .sc {
color: #2300CE
}
/* Literal.String.Char */
/*mk: Literal.String.Char */
.codehilite .sd {
color: #C41A16
}
/* Literal.String.Doc */
/*mk: Literal.String.Doc */
.codehilite .s2 {
color: #C41A16
}
/* Literal.String.Double */
/*mk: Literal.String.Double */
.codehilite .se {
color: #C41A16
}
/* Literal.String.Escape */
/*mk: Literal.String.Escape */
.codehilite .sh {
color: #C41A16
}
/* Literal.String.Heredoc */
/*mk: Literal.String.Heredoc */
.codehilite .si {
color: #C41A16
}
/* Literal.String.Interpol */
/*mk: Literal.String.Interpol */
.codehilite .sx {
color: #C41A16
}
/* Literal.String.Other */
/*mk: Literal.String.Other */
.codehilite .sr {
color: #C41A16
}
/* Literal.String.Regex */
/*mk: Literal.String.Regex */
.codehilite .s1 {
color: #C41A16
}
/* Literal.String.Single */
/*mk: Literal.String.Single */
.codehilite .ss {
color: #C41A16
}
/* Literal.String.Symbol */
/*mk: Literal.String.Symbol */
.codehilite .bp {
color: #5B269A
}
/* Name.Builtin.Pseudo */
/*mk: Name.Builtin.Pseudo */
.codehilite .vc {
color: #000000
}
/* Name.Variable.Class */
/*mk: Name.Variable.Class */
.codehilite .vg {
color: #000000
}
/* Name.Variable.Global */
/*mk: Name.Variable.Global */
.codehilite .vi {
color: #000000
}
/* Name.Variable.Instance */
/*mk: Name.Variable.Instance */
.codehilite .il {
color: #1C01CE
}
/* Literal.Number.Integer.Long */
/*mk: Literal.Number.Integer.Long */

@ -27,11 +27,31 @@ register = template.Library()
@register.simple_tag(takes_context=True)
def head_meta(context):
"""
mk:
渲染页面头部元数据通过插件系统扩展
Args:
context: 模板上下文
Returns:
经过插件处理后的HTML字符串
"""
return mark_safe(hooks.apply_filters('head_meta', '', context))
@register.simple_tag
def timeformat(data):
"""
mk:
格式化时间显示
Args:
data: 时间对象
Returns:
格式化后的时间字符串
"""
try:
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
@ -41,6 +61,16 @@ def timeformat(data):
@register.simple_tag
def datetimeformat(data):
"""
mk:
格式化日期时间显示
Args:
data: 日期时间对象
Returns:
格式化后的日期时间字符串
"""
try:
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
@ -52,22 +82,39 @@ def datetimeformat(data):
@stringfilter
def custom_markdown(content):
"""
mk:
通用markdown过滤器应用文章内容插件
主要用于文章内容处理
Args:
content: 原始markdown文本内容
Returns:
经过markdown转换和插件优化后的安全HTML内容
"""
html_content = CommonMarkdown.get_markdown(content)
# 然后应用插件过滤器优化HTML
# mk:然后应用插件过滤器优化HTML
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
return mark_safe(optimized_html)
@register.filter()
@stringfilter
def sidebar_markdown(content):
"""
mk:
侧边栏专用markdown转换器
Args:
content: 原始markdown文本内容
Returns:
转换后的安全HTML内容
"""
html_content = CommonMarkdown.get_markdown(content)
return mark_safe(html_content)
@ -75,55 +122,69 @@ def sidebar_markdown(content):
@register.simple_tag(takes_context=True)
def render_article_content(context, article, is_summary=False):
"""
mk:
渲染文章内容包含完整的上下文信息供插件使用
Args:
context: 模板上下文
article: 文章对象
is_summary: 是否为摘要模式首页使用
Returns:
经过处理的安全HTML内容
"""
if not article or not hasattr(article, 'body'):
return ''
# 先转换Markdown为HTML
# mk:先转换Markdown为HTML
html_content = CommonMarkdown.get_markdown(article.body)
# 如果是摘要模式,先截断内容再应用插件
# mk:如果是摘要模式,先截断内容再应用插件
if is_summary:
# 截断HTML内容到合适的长度约300字符
# mk:截断HTML内容到合适的长度约300字符
from django.utils.html import strip_tags
from django.template.defaultfilters import truncatechars
# 先去除HTML标签截断纯文本然后重新转换为HTML
# mk:先去除HTML标签截断纯文本然后重新转换为HTML
plain_text = strip_tags(html_content)
truncated_text = truncatechars(plain_text, 300)
# 重新转换截断后的文本为HTML简化版避免复杂的插件处理
# mk:重新转换截断后的文本为HTML简化版避免复杂的插件处理
html_content = CommonMarkdown.get_markdown(truncated_text)
# 然后应用插件过滤器,传递完整的上下文
# mk:然后应用插件过滤器,传递完整的上下文
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 获取request对象
# mk:获取request对象
request = context.get('request')
# 应用所有文章内容相关的插件
# 注意:摘要模式下某些插件(如版权声明)可能不适用
# mk:应用所有文章内容相关的插件
# mk:注意:摘要模式下某些插件(如版权声明)可能不适用
optimized_html = hooks.apply_filters(
ARTICLE_CONTENT_HOOK_NAME,
html_content,
article=article,
ARTICLE_CONTENT_HOOK_NAME,
html_content,
article=article,
request=request,
context=context,
is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
is_summary=is_summary # mk:传递摘要标志,插件可以据此调整行为
)
return mark_safe(optimized_html)
@register.simple_tag
def get_markdown_toc(content):
"""
mk:
获取markdown内容的目录结构
Args:
content: markdown文本内容
Returns:
目录HTML结构
"""
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
return mark_safe(toc)
@ -132,6 +193,16 @@ def get_markdown_toc(content):
@register.filter()
@stringfilter
def comment_markdown(content):
"""
mk:
评论内容markdown转换器带HTML净化功能
Args:
content: 原始markdown文本内容
Returns:
转换并净化后的安全HTML内容
"""
content = CommonMarkdown.get_markdown(content)
return mark_safe(sanitize_html(content))
@ -140,9 +211,14 @@ def comment_markdown(content):
@stringfilter
def truncatechars_content(content):
"""
mk:
获得文章内容的摘要
:param content:
:return:
Args:
content: 原始文章内容
Returns:
截断后的文章摘要
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
@ -153,6 +229,16 @@ def truncatechars_content(content):
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
"""
mk:
截取纯文本内容前150个字符
Args:
content: 原始文本内容
Returns:
截断后的文本内容
"""
from django.utils.html import strip_tags
return strip_tags(content)[:150]
@ -161,9 +247,14 @@ def truncate(content):
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
获得文章面包屑
:param article:
:return:
mk:
获得文章面包屑导航
Args:
article: 文章对象
Returns:
面包屑导航所需的数据字典
"""
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
@ -182,9 +273,14 @@ def load_breadcrumb(article):
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
:param article:
:return:
mk:
加载文章标签列表
Args:
article: 文章对象
Returns:
文章标签列表所需的数据字典
"""
tags = article.tags.all()
tags_list = []
@ -202,8 +298,15 @@ def load_articletags(article):
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
加载侧边栏
:return:
mk:
加载侧边栏内容包括缓存机制
Args:
user: 当前用户对象
linktype: 链接显示类型
Returns:
侧边栏所需的所有数据字典
"""
value = cache.get("sidebar" + linktype)
if value:
@ -225,8 +328,8 @@ def load_sidebar(user, linktype):
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
commment_list = Comment.objects.filter(is_enable=True).order_by(
'-id')[:blogsetting.sidebar_comment_count]
# 标签云 计算字体大小
# 根据总数计算出平均值 大小为 (数目/平均值)*步长
# mk:标签云 计算字体大小
# mk:根据总数计算出平均值 大小为 (数目/平均值)*步长
increment = 5
tags = Tag.objects.all()
sidebar_tags = None
@ -262,9 +365,15 @@ def load_sidebar(user, linktype):
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
mk:
获得文章meta信息
:param article:
:return:
Args:
article: 文章对象
user: 当前用户对象
Returns:
文章meta信息所需的数据字典
"""
return {
'article': article,
@ -274,6 +383,18 @@ def load_article_metas(article, user):
@register.inclusion_tag('blog/tags/article_pagination.html')
def load_pagination_info(page_obj, page_type, tag_name):
"""
mk:
加载分页信息
Args:
page_obj: 分页对象
page_type: 页面类型标识
tag_name: 标签名或分类名等
Returns:
分页信息所需的数据字典
"""
previous_url = ''
next_url = ''
if page_type == '':
@ -344,10 +465,16 @@ def load_pagination_info(page_obj, page_type, tag_name):
@register.inclusion_tag('blog/tags/article_info.html')
def load_article_detail(article, isindex, user):
"""
mk:
加载文章详情
:param article:
:param isindex:是否列表页若是列表页只显示摘要
:return:
Args:
article: 文章对象
isindex: 是否为列表页决定是否只显示摘要
user: 当前用户对象
Returns:
文章详情所需的数据字典
"""
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
@ -360,37 +487,47 @@ def load_article_detail(article, isindex, user):
}
# 返回用户头像URL
# 模板使用方法: {{ email|gravatar_url:150 }}
# mk:返回用户头像URL
# mk:模板使用方法: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
"""获得用户头像 - 优先使用OAuth头像否则使用默认头像"""
"""
mk:
获得用户头像 - 优先使用OAuth头像否则使用默认头像
Args:
email: 用户邮箱地址
size: 头像尺寸默认40px
Returns:
用户头像URL地址
"""
cachekey = 'avatar/' + email
url = cache.get(cachekey)
if url:
return url
# 检查OAuth用户是否有自定义头像
# mk:检查OAuth用户是否有自定义头像
usermodels = OAuthUser.objects.filter(email=email)
if usermodels:
# 过滤出有头像的用户
# mk:过滤出有头像的用户
users_with_picture = list(filter(lambda x: x.picture is not None, usermodels))
if users_with_picture:
# 获取默认头像路径用于比较
# mk:获取默认头像路径用于比较
default_avatar_path = static('blog/img/avatar.png')
# 优先选择非默认头像的用户,否则选择第一个
# mk:优先选择非默认头像的用户,否则选择第一个
non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
selected_user = non_default_users[0] if non_default_users else users_with_picture[0]
url = selected_user.picture
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
avatar_type = 'non-default' if non_default_users else 'default'
logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type))
return url
# 使用默认头像
# mk:使用默认头像
url = static('blog/img/avatar.png')
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
logger.info('Using default avatar for {}'.format(email))
@ -399,7 +536,17 @@ def gravatar_url(email, size=40):
@register.filter
def gravatar(email, size=40):
"""获得用户头像HTML标签"""
"""
mk:
获得用户头像HTML标签
Args:
email: 用户邮箱地址
size: 头像尺寸默认40px
Returns:
包含用户头像的img标签HTML字符串
"""
url = gravatar_url(email, size)
return mark_safe(
'<img src="%s" height="%d" width="%d" class="avatar" alt="用户头像">' %
@ -408,40 +555,60 @@ def gravatar(email, size=40):
@register.simple_tag
def query(qs, **kwargs):
""" template tag which allows queryset filtering. Usage:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
"""
mk:
模板标签允许查询集过滤使用方法:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
Args:
qs: 查询集对象
**kwargs: 过滤条件键值对
Returns:
过滤后的查询集
"""
return qs.filter(**kwargs)
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
"""
mk:
连接两个字符串参数
Args:
arg1: 第一个字符串
arg2: 第二个字符串
Returns:
连接后的字符串
"""
return str(arg1) + str(arg2)
# === 插件系统模板标签 ===
# mk:=== 插件系统模板标签 ===
@register.simple_tag(takes_context=True)
def render_plugin_widgets(context, position, **kwargs):
"""
mk:
渲染指定位置的所有插件组件
Args:
context: 模板上下文
position: 位置标识
**kwargs: 传递给插件的额外参数
Returns:
按优先级排序的所有插件HTML内容
"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
widgets = []
for plugin in get_loaded_plugins():
try:
widget_data = plugin.render_position_widget(
@ -453,95 +620,133 @@ def render_plugin_widgets(context, position, **kwargs):
widgets.append(widget_data)
except Exception as e:
logger.error(f"Error rendering widget from plugin {plugin.PLUGIN_NAME}: {e}")
# 按优先级排序(数字越小优先级越高)
# mk:按优先级排序(数字越小优先级越高)
widgets.sort(key=lambda x: x['priority'])
# 合并HTML内容
# mk:合并HTML内容
html_parts = [widget['html'] for widget in widgets]
return mark_safe(''.join(html_parts))
@register.simple_tag(takes_context=True)
def plugin_head_resources(context):
"""渲染所有插件的head资源仅自定义HTMLCSS已集成到压缩系统"""
"""
mk:
渲染所有插件的head资源仅自定义HTMLCSS已集成到压缩系统
Args:
context: 模板上下文
Returns:
所有插件head资源的HTML字符串
"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
resources = []
for plugin in get_loaded_plugins():
try:
# 只处理自定义head HTMLCSS文件已通过压缩系统处理
# mk:只处理自定义head HTMLCSS文件已通过压缩系统处理
head_html = plugin.get_head_html(context)
if head_html:
resources.append(head_html)
except Exception as e:
logger.error(f"Error loading head resources from plugin {plugin.PLUGIN_NAME}: {e}")
return mark_safe('\n'.join(resources))
@register.simple_tag(takes_context=True)
def plugin_body_resources(context):
"""渲染所有插件的body资源仅自定义HTMLJS已集成到压缩系统"""
"""
mk:
渲染所有插件的body资源仅自定义HTMLJS已集成到压缩系统
Args:
context: 模板上下文
Returns:
所有插件body资源的HTML字符串
"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
resources = []
for plugin in get_loaded_plugins():
try:
# 只处理自定义body HTMLJS文件已通过压缩系统处理
# mk:只处理自定义body HTMLJS文件已通过压缩系统处理
body_html = plugin.get_body_html(context)
if body_html:
resources.append(body_html)
except Exception as e:
logger.error(f"Error loading body resources from plugin {plugin.PLUGIN_NAME}: {e}")
return mark_safe('\n'.join(resources))
@register.inclusion_tag('plugins/css_includes.html')
def plugin_compressed_css():
"""插件CSS压缩包含模板"""
"""
mk:
插件CSS压缩包含模板
Returns:
插件CSS文件列表数据字典
"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
css_files = []
for plugin in get_loaded_plugins():
for css_file in plugin.get_css_files():
css_url = plugin.get_static_url(css_file)
css_files.append(css_url)
return {'css_files': css_files}
@register.inclusion_tag('plugins/js_includes.html')
def plugin_compressed_js():
"""插件JS压缩包含模板"""
"""
mk:
插件JS压缩包含模板
Returns:
插件JS文件列表数据字典
"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
js_files = []
for plugin in get_loaded_plugins():
for js_file in plugin.get_js_files():
js_url = plugin.get_static_url(js_file)
js_files.append(js_url)
return {'js_files': js_files}
return {'js_files': js_files}
@register.simple_tag(takes_context=True)
def plugin_widget(context, plugin_name, widget_type='default', **kwargs):
"""
mk:
渲染指定插件的组件
使用方式
{% plugin_widget 'article_recommendation' 'bottom' article=article count=5 %}
Args:
context: 模板上下文
plugin_name: 插件名称
widget_type: 组件类型默认'default'
**kwargs: 传递给插件的额外参数
Returns:
插件组件渲染后的HTML内容
"""
from djangoblog.plugin_manage.loader import get_plugin_by_slug
plugin = get_plugin_by_slug(plugin_name)
if plugin and hasattr(plugin, 'render_template'):
try:
@ -550,5 +755,5 @@ def plugin_widget(context, plugin_name, widget_type='default', **kwargs):
return mark_safe(plugin.render_template(template_name, widget_context))
except Exception as e:
logger.error(f"Error rendering plugin widget {plugin_name}.{widget_type}: {e}")
return ""
return ""

@ -20,11 +20,31 @@ from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
class ArticleTest(TestCase):
"""
mk:
文章相关功能测试类
包含文章创建验证分页搜索图片上传等功能的集成测试
"""
def setUp(self):
"""
mk:
测试初始化方法
创建测试客户端和请求工厂实例用于后续测试
"""
self.client = Client()
self.factory = RequestFactory()
def test_validate_article(self):
"""
mk:
验证文章功能测试方法
测试文章的创建展示分类标签搜索等核心功能
包括侧边栏分类标签文章的增删改查操作
"""
site = get_current_site().domain
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
@ -37,6 +57,8 @@ class ArticleTest(TestCase):
self.assertEqual(response.status_code, 200)
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# mk:创建侧边栏
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -44,16 +66,19 @@ class ArticleTest(TestCase):
s.is_enable = True
s.save()
# mk:创建分类
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
# mk:创建标签
tag = Tag()
tag.name = "nicetag"
tag.save()
# mk:创建文章
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
@ -68,6 +93,7 @@ class ArticleTest(TestCase):
article.save()
self.assertEqual(1, article.tags.count())
# mk:批量创建文章用于测试
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -79,6 +105,7 @@ class ArticleTest(TestCase):
article.save()
article.tags.add(tag)
article.save()
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
@ -105,6 +132,7 @@ class ArticleTest(TestCase):
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# mk:测试各种分页情况
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
@ -129,6 +157,7 @@ class ArticleTest(TestCase):
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
# mk:测试链接功能
link = Links(
sequence=1,
name="lylinux",
@ -149,6 +178,17 @@ class ArticleTest(TestCase):
self.client.get('/admin/admin/logentry/1/change/')
def check_pagination(self, p, type, value):
"""
mk:
检查分页功能
遍历所有分页页面验证分页信息加载和前后页链接的有效性
Args:
p (Paginator): Django分页器对象
type (str): 分页类型标识
value (str): 分页值如标签slug用户名等
"""
for page in range(1, p.num_pages + 1):
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
@ -160,6 +200,12 @@ class ArticleTest(TestCase):
self.assertEqual(response.status_code, 200)
def test_image(self):
"""
mk:
图片上传功能测试方法
测试图片下载上传权限验证文件上传等流程
"""
import requests
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
@ -183,10 +229,22 @@ class ArticleTest(TestCase):
'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self):
"""
mk:
错误页面测试方法
测试访问不存在页面时返回404状态码
"""
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_commands(self):
"""
mk:
Django管理命令测试方法
测试各种自定义管理命令的执行包括索引构建百度推送测试数据创建等
"""
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -195,6 +253,7 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
#mk: 创建OAuth配置和用户
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'

@ -3,58 +3,86 @@ from django.views.decorators.cache import cache_page
from . import views
# mk:定义应用的命名空间用于URL反向解析时区分不同应用的同名URL
app_name = "blog"
# mk:URL模式配置列表定义了博客应用的所有路由规则
urlpatterns = [
# mk:首页路由,显示文章列表,默认第一页
path(
r'',
views.IndexView.as_view(),
name='index'),
# mk:分页首页路由,显示指定页码的文章列表
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# mk:文章详情页路由通过年月日和文章ID访问具体文章
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# mk:分类详情页路由,通过分类名称访问该分类下的所有文章
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# mk:分类详情页分页路由,通过分类名称和页码访问该分类下指定页码的文章
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# mk:作者详情页路由,通过作者名称访问该作者发布的所有文章
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# mk:作者详情页分页路由,通过作者名称和页码访问该作者发布指定页码的文章
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# mk:标签详情页路由,通过标签名称访问带有该标签的所有文章
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# mk:标签详情页分页路由,通过标签名称和页码访问带有该标签的指定页码文章
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
# mk:归档页面路由缓存1小时显示所有文章的归档信息
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
# mk:友情链接页面路由,显示所有友情链接
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
# mk:文件上传路由,处理文件上传请求
path(
r'upload',
views.fileupload,
name='upload'),
# mk:清除缓存路由,处理清除缓存的请求
path(
r'clean',
views.clean_cache_view,

@ -25,23 +25,50 @@ logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
"""
mk:
文章列表视图基类继承自Django的ListView
Attributes:
template_name (str): 模板文件路径
context_object_name (str): 上下文对象名称
page_type (str): 页面类型标识
paginate_by (int): 每页显示的文章数量
page_kwarg (str): 分页参数名称
link_type (LinkShowType): 链接显示类型
"""
# mk:template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
# mk:context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
# mk:页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
"""
mk:
获取视图缓存键
Returns:
str: 缓存键值
"""
return self.request.get['pages']
@property
def page_number(self):
"""
mk:
获取当前页码
Returns:
int: 当前页码
"""
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -49,22 +76,35 @@ class ArticleListView(ListView):
def get_queryset_cache_key(self):
"""
mk:
子类重写.获得queryset的缓存key
Raises:
NotImplementedError: 子类必须实现此方法
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
mk:
子类重写.获取queryset的数据
Raises:
NotImplementedError: 子类必须实现此方法
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
"""
mk:
从缓存获取查询结果集
Args:
cache_key (str): 缓存键
Returns:
QuerySet: 查询结果集
"""
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
@ -76,45 +116,92 @@ class ArticleListView(ListView):
return article_list
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
'''
"""
mk:
重写默认方法从缓存获取数据
Returns:
QuerySet: 查询结果集
"""
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
"""
mk:
获取上下文数据
Args:
**kwargs: 额外的关键字参数
Returns:
dict: 上下文字典
"""
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
'''
"""
mk:
首页视图类显示所有已发布的文章列表
"""
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
"""
mk:
获取首页文章数据
Returns:
QuerySet: 已发布文章的查询集
"""
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
"""
mk:
获取首页缓存键
Returns:
str: 首页缓存键
"""
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
"""
mk:
文章详情页面视图类
Attributes:
template_name (str): 模板文件路径
model: 数据模型类
pk_url_kwarg (str): URL中的主键参数名
context_object_name (str): 上下文对象名称
"""
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
"""
mk:
获取文章详情页上下文数据包括评论分页前后文章等信息
Args:
**kwargs: 额外的关键字参数
Returns:
dict: 包含文章详情和相关数据的上下文字典
"""
comment_form = CommentForm()
article_comments = self.object.comment_list()
@ -152,23 +239,32 @@ class ArticleDetailView(DetailView):
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# 触发文章详情加载钩子,让插件可以添加额外的上下文数据
# mk:触发文章详情加载钩子,让插件可以添加额外的上下文数据
from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD
hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request)
# Action Hook, 通知插件"文章详情已获取"
# mk:Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
"""
mk:
分类目录列表视图类显示指定分类下的文章列表
"""
page_type = "分类目录归档"
def get_queryset_data(self):
"""
mk:
根据分类名称获取该分类及其子分类下的所有文章
Returns:
QuerySet: 指定分类下的文章查询集
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
@ -181,6 +277,13 @@ class CategoryDetailView(ArticleListView):
return article_list
def get_queryset_cache_key(self):
"""
mk:
获取分类页面缓存键
Returns:
str: 分类页面缓存键
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
@ -190,7 +293,16 @@ class CategoryDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
"""
mk:
获取分类页面上下文数据
Args:
**kwargs: 额外的关键字参数
Returns:
dict: 包含分类信息的上下文字典
"""
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
@ -202,12 +314,21 @@ class CategoryDetailView(ArticleListView):
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
"""
mk:
作者详情页视图类显示指定作者的文章列表
"""
page_type = '作者文章归档'
def get_queryset_cache_key(self):
"""
mk:
获取作者页面缓存键
Returns:
str: 作者页面缓存键
"""
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
@ -215,12 +336,29 @@ class AuthorDetailView(ArticleListView):
return cache_key
def get_queryset_data(self):
"""
mk:
根据作者用户名获取该作者的所有文章
Returns:
QuerySet: 指定作者的文章查询集
"""
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):
"""
mk:
获取作者页面上下文数据
Args:
**kwargs: 额外的关键字参数
Returns:
dict: 包含作者信息的上下文字典
"""
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
@ -228,12 +366,21 @@ class AuthorDetailView(ArticleListView):
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
"""
mk:
标签列表页面视图类显示指定标签下的文章列表
"""
page_type = '分类标签归档'
def get_queryset_data(self):
"""
mk:
根据标签名称获取该标签下的所有文章
Returns:
QuerySet: 指定标签下的文章查询集
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
@ -243,6 +390,13 @@ class TagDetailView(ArticleListView):
return article_list
def get_queryset_cache_key(self):
"""
mk:
获取标签页面缓存键
Returns:
str: 标签页面缓存键
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
@ -252,6 +406,16 @@ class TagDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
"""
mk:
获取标签页面上下文数据
Args:
**kwargs: 额外的关键字参数
Returns:
dict: 包含标签信息的上下文字典
"""
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
@ -260,32 +424,76 @@ class TagDetailView(ArticleListView):
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
"""
mk:
文章归档页面视图类显示所有文章的时间归档
"""
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
"""
mk:
获取所有已发布文章的数据
Returns:
QuerySet: 所有已发布文章的查询集
"""
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
"""
mk:
获取归档页面缓存键
Returns:
str: 归档页面缓存键
"""
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
"""
mk:
友情链接列表视图类
Attributes:
model: 链接数据模型
template_name (str): 模板文件路径
"""
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
"""
mk:
获取启用的友情链接查询集
Returns:
QuerySet: 启用的友情链接查询集
"""
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
"""
mk:
Elasticsearch搜索视图类继承自haystack的SearchView
"""
def get_context(self):
"""
mk:
获取搜索结果上下文数据
Returns:
dict: 包含搜索结果的上下文字典
"""
paginator, page = self.build_page()
context = {
"query": self.query,
@ -304,9 +512,14 @@ class EsSearchView(SearchView):
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
mk:
文件上传处理函数提供图床功能
Args:
request (HttpRequest): HTTP请求对象
Returns:
HttpResponse: 包含上传文件URL的响应对象
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
@ -345,6 +558,18 @@ def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
"""
mk:
404页面未找到错误处理函数
Args:
request (HttpRequest): HTTP请求对象
exception (Exception): 异常对象
template_name (str): 错误页面模板名称
Returns:
HttpResponse: 404错误页面响应
"""
if exception:
logger.error(exception)
url = request.get_full_path()
@ -356,6 +581,17 @@ def page_not_found_view(
def server_error_view(request, template_name='blog/error_page.html'):
"""
mk:
500服务器内部错误处理函数
Args:
request (HttpRequest): HTTP请求对象
template_name (str): 错误页面模板名称
Returns:
HttpResponse: 500错误页面响应
"""
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
@ -367,6 +603,18 @@ def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
"""
mk:
403权限拒绝错误处理函数
Args:
request (HttpRequest): HTTP请求对象
exception (Exception): 异常对象
template_name (str): 错误页面模板名称
Returns:
HttpResponse: 403错误页面响应
"""
if exception:
logger.error(exception)
return render(
@ -376,5 +624,15 @@ def permission_denied_view(
def clean_cache_view(request):
"""
mk:
清除缓存视图函数
Args:
request (HttpRequest): HTTP请求对象
Returns:
HttpResponse: 返回'ok'表示清除成功
"""
cache.clear()
return HttpResponse('ok')

@ -1,49 +1,77 @@
# FRR该模块用于配置Django后台系统中评论(Comment)模型的管理界面,
# 包括自定义列表展示、批量操作、字段过滤及关联对象链接等功能,
# 方便管理员在后台对评论数据进行高效管理。
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# FRR自定义批量操作函数用于将选中的评论设置为"禁用"状态
def disable_commentstatus(modeladmin, request, queryset):
# FRR通过queryset批量更新is_enable字段为False
queryset.update(is_enable=False)
# FRR自定义批量操作函数用于将选中的评论设置为"启用"状态
def enable_commentstatus(modeladmin, request, queryset):
# FRR通过queryset批量更新is_enable字段为True
queryset.update(is_enable=True)
# FRR为批量操作函数设置在后台显示的名称支持国际化
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# FRR评论模型(Comment)的后台管理配置类继承自Django默认的ModelAdmin
class CommentAdmin(admin.ModelAdmin):
# FRR每页显示20条评论数据
list_per_page = 20
# FRR列表页展示的字段包括自定义的关联对象链接字段
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
'id', # 评论ID
'body', # 评论内容
'link_to_userinfo', # 自定义字段:评论作者的链接
'link_to_article', # 自定义字段:评论所属文章的链接
'is_enable', # 是否启用
'creation_time' # 创建时间
)
# FRR列表页中可点击跳转详情页的字段
list_display_links = ('id', 'body', 'is_enable')
# LGM右侧过滤栏可按"是否启用"筛选评论
list_filter = ('is_enable',)
# FRR编辑页中排除的字段创建时间和最后修改时间通常自动生成不允许手动编辑
exclude = ('creation_time', 'last_modify_time')
# FRR注册批量操作函数在列表页顶部"动作"下拉框中显示
actions = [disable_commentstatus, enable_commentstatus]
# FRR将外键字段(author和article)显示为输入框(而非下拉框),适合数据量大的场景
raw_id_fields = ('author', 'article')
# FRR设置搜索框可搜索的字段按评论内容搜索
search_fields = ('body',)
# FRR自定义列表字段显示评论作者的链接点击可跳转到作者的后台编辑页
def link_to_userinfo(self, obj):
# FRR获取作者模型(author)的元数据用于生成admin链接
info = (obj.author._meta.app_label, obj.author._meta.model_name)
#FRR生成作者在admin后台的编辑页URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
#FRR返回HTML格式的链接显示作者昵称若无昵称则显示邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
# FRR自定义列表字段显示评论所属文章的链接点击可跳转到文章的后台编辑页
def link_to_article(self, obj):
# FRR获取文章模型(article)的元数据用于生成admin链接
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# FRR生成文章在admin后台的编辑页URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# FRR返回HTML格式的链接显示文章标题
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# FRR设置自定义字段在列表页的表头名称支持国际化
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
link_to_article.short_description = _('Article')

@ -1,5 +1,10 @@
# FRR该模块定义了Django应用"comments"的配置类,
# 用于告诉Django如何识别和配置该应用是Django应用的核心配置文件之一。
from django.apps import AppConfig
# FRR评论应用(comments)的配置类继承自Django的AppConfig基类
class CommentsConfig(AppConfig):
name = 'comments'
# FRR应用的名称必须与应用目录名一致Django通过该名称识别应用
name = 'comments'

@ -1,13 +1,21 @@
# FRR该模块定义评论相关的表单类用于处理用户提交评论的数据验证和交互
# 继承Django的ModelForm实现与Comment模型的绑定简化表单开发流程。
from django import forms
from django.forms import ModelForm
from .models import Comment
from .models import Comment # 导入评论模型,用于表单与模型的关联
# FRR评论表单类继承ModelForm实现基于Comment模型的表单自动生成
class CommentForm(ModelForm):
# FRR定义父评论ID字段用于支持评论回复功能
# 采用HiddenInput控件隐藏输入框用户不可见但表单会提交该值
# required=False表示允许为空即该评论可以是一级评论无父评论
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
# FRRMeta内部类用于配置表单与模型的关联信息
class Meta:
model = Comment
fields = ['body']
model = Comment # 指定关联的模型为Comment
fields = ['body'] # 表单中需要包含的模型字段此处仅包含评论内容字段body

@ -1,39 +1,61 @@
# FRR该模块定义了评论(Comment)数据模型,用于存储用户对文章的评论信息,
# 包括评论内容、作者、关联文章、创建时间等字段,同时支持评论回复功能(父子评论关联)。
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
from django.utils.timezone import now # 用于获取当前时间
from django.utils.translation import gettext_lazy as _ # 用于国际化翻译
from blog.models import Article # 导入文章模型,建立评论与文章的关联
# Create your models here.
# FRR评论模型类继承自Django的Model基类映射数据库中的评论表
class Comment(models.Model):
# FRR评论正文字段TextField支持长文本max_length限制最大长度为300字符
body = models.TextField('正文', max_length=300)
# FRR评论创建时间字段默认值为当前时间verbose_name用于后台显示的字段名国际化
creation_time = models.DateTimeField(_('creation time'), default=now)
# FRR评论最后修改时间字段默认值为当前时间用于记录评论更新时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# FRR评论作者外键关联Django内置的用户模型settings.AUTH_USER_MODEL
# on_delete=models.CASCADE表示若用户被删除其评论也会被级联删除
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# FRR关联的文章外键评论属于某篇文章
# on_delete=models.CASCADE表示若文章被删除其下所有评论也会被级联删除
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# FRR父评论外键实现评论回复功能自关联
# 'self'表示关联自身模型blank=True和null=True允许为空即一级评论无父评论
# on_delete=models.CASCADE表示若父评论被删除其下所有子评论也会被级联删除
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# FRR评论是否启用的状态字段默认值为False可能需要管理员审核后启用
# blank=False和null=False表示该字段为必填项不允许为空
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# FRRMeta内部类用于配置模型的元数据
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
ordering = ['-id'] # 数据查询时按id降序排列最新评论在前
verbose_name = _('comment') # 模型的单数显示名称(国际化)
verbose_name_plural = verbose_name # 模型的复数显示名称(与单数相同)
get_latest_by = 'id' # 指定通过id字段获取最新记录
# FRR定义模型实例的字符串表示返回评论正文便于在后台和调试时识别对象
def __str__(self):
return self.body
return self.body

@ -1,109 +1,106 @@
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
# FRR该模块为评论功能(comments)的单元测试类,
# 主要测试评论提交、评论列表展示、评论嵌套结构解析及邮件通知等核心功能,
# 确保评论功能在各种场景下正常工作。
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse # 用于生成URL反向解析
from accounts.models import BlogUser # 导入用户模型,用于创建测试用户
from blog.models import Category, Article # 导入分类和文章模型,用于创建测试文章
from comments.models import Comment # 导入评论模型,用于测试评论数据
from comments.templatetags.comments_tags import * # 导入评论相关模板标签,测试评论渲染
from djangoblog.utils import get_max_articleid_commentid # 导入工具函数测试ID获取功能
# Create your tests here.
# FRR评论功能测试类继承TransactionTestCase以支持事务性测试避免测试数据污染
class CommentsTest(TransactionTestCase):
# LGM测试前的初始化方法会在每个测试方法执行前运行
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.client = Client() # 创建测试客户端,模拟用户请求
self.factory = RequestFactory() # 创建请求工厂,用于构造复杂请求
# FRR配置博客评论设置需要审核才能显示
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True
value.save()
value.comment_need_review = True # 评论需要审核
value.save() # 保存设置到数据库
# FRR创建超级用户用于测试登录状态下的评论功能
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
password="liangliangyy1") # 用户名和密码用于测试登录
# FRR辅助方法批量更新文章下所有评论为"启用"状态(模拟管理员审核通过)
def update_article_comment_status(self, article):
comments = article.comment_set.all()
comments = article.comment_set.all() # 获取文章下所有评论
for comment in comments:
comment.is_enable = True
comment.save()
comment.is_enable = True # 设为启用
comment.save() # 保存修改
# FRR核心测试方法验证评论提交、嵌套回复、模板渲染等功能
def test_validate_comment(self):
# 1. 登录测试用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 2. 创建测试分类和文章(评论必须关联到具体文章)
category = Category()
category.name = "categoryccc"
category.save()
category.name = "categoryccc" # 分类名称
category.save() # 保存分类
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
article.title = "nicetitleccc" # 文章标题
article.body = "nicecontentccc" # 文章内容
article.author = self.user # 文章作者(关联测试用户)
article.category = category # 关联分类
article.type = 'a' # 文章类型(假设'a'表示普通文章)
article.status = 'p' # 发布状态(假设'p'表示已发布)
article.save() # 保存文章
# 3. 测试提交一级评论(无父评论)
# 生成评论提交URL通过文章ID反向解析
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
# 模拟POST请求提交评论
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
'body': '123ffffffffff' # 评论内容
})
# 验证提交成功302表示重定向通常是提交后跳回文章页
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
# 由于评论需要审核(默认未启用),此时评论列表应为空
article = Article.objects.get(pk=article.pk) # 重新获取文章(刷新数据)
self.assertEqual(len(article.comment_list()), 0) # 评论列表长度为0
# 手动审核评论(设为启用)
self.update_article_comment_status(article)
# 审核后评论列表应包含1条评论
self.assertEqual(len(article.comment_list()), 1)
# 4. 再次提交一条评论,测试多条评论场景
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 302) # 验证提交成功
# 审核后评论列表应包含2条评论
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
# 5. 测试提交嵌套回复(针对第一条评论的回复)
parent_comment_id = article.comment_list()[0].id # 获取第一条评论ID作为父评论
# 提交带Markdown格式的回复内容测试富文本支持
response = self.client.post(comment_url,
{
'body': '''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
})
self.assertEqual(response.status_code, 302)
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 3)
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
from comments.utils import send_comment_email
send_comment_email(comment)
import os

@ -1,11 +1,20 @@
from django.urls import path
# FRR该模块定义评论应用(comments)的URL路由配置
# 映射评论相关的视图函数/类,实现前端请求与后端处理逻辑的关联。
from . import views
from django.urls import path # 导入Django的path函数用于定义URL路径
app_name = "comments"
from . import views # 导入当前应用的views模块关联评论处理视图
app_name = "comments" # 定义应用命名空间避免不同应用间URL名称冲突
# FRRURL路由列表每个path对应一个评论相关的请求路径
urlpatterns = [
# FRR评论提交路由用于处理用户提交评论的请求
# 路径包含文章ID(article_id),通过<int:article_id>捕获整数类型的文章ID参数
# 关联视图类CommentPostView的as_view()方法(将类视图转为可调用视图函数)
# 命名为'postcomment',便于在模板中通过{% url 'comments:postcomment' article_id %}反向解析
path(
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
]
]

@ -1,17 +1,33 @@
import logging
# FRR该模块提供评论相关的邮件通知功能
# 当用户提交评论或收到回复时,自动发送自动发送邮件通知,提升用户交互体验。
from django.utils.translation import gettext_lazy as _
import logging # 导入日志模块,用于记录记录发送邮件过程中的错误信息
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
from django.utils.translation import gettext_lazy as _ # 导入国际化翻译函数
from djangoblog.utils import get_current_site # 导入获取当前站点信息的工具函数
from djangoblog.utils import send_email # 导入发送邮件的工具函数
# FRR创建日志记录器用于记录当前模块的日志信息
logger = logging.getLogger(__name__)
def send_comment_email(comment):
"""
FRR发送评论相关邮件通知
1. 向评论作者发送评论提交成功的感谢邮件
2. 若当前评论是回复有父评论向父评论作者发送回复通知邮件
:param comment: 评论对象Comment实例
"""
# FRR获取当前网站域名用于构建文章链接
site = get_current_site().domain
# FRR邮件主题支持国际化
subject = _('Thanks for your comment')
# FRR构建文章详情页的完整URL包含协议和域名
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# FRR1. 向当前评论作者发送感谢邮件
# 构建HTML格式的邮件内容支持国际化和变量替换
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
@ -19,10 +35,13 @@ def send_comment_email(comment):
<br />
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
tomail = comment.author.email
send_email([tomail], subject, html_content)
tomail = comment.author.email # 收件人邮箱(当前评论作者的邮箱)
send_email([tomail], subject, html_content) # 调用工具函数发送邮件
# FRR2. 若当前评论是回复(有父评论),通知父评论作者
try:
if comment.parent_comment:
if comment.parent_comment: # 判断当前评论是否有父评论(即是否为回复)
# 构建回复通知的HTML邮件内容
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
@ -32,7 +51,8 @@ def send_comment_email(comment):
%(article_url)s
""") % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body}
tomail = comment.parent_comment.author.email
send_email([tomail], subject, html_content)
tomail = comment.parent_comment.author.email # 收件人邮箱(父评论作者的邮箱)
send_email([tomail], subject, html_content) # 发送回复通知邮件
except Exception as e:
logger.error(e)
# FRR捕获发送过程中的异常并记录日志不中断主流程
logger.error(e)

@ -1,63 +1,82 @@
# Create your views here.
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
from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
# FRR该模块定义评论提交的视图类负责处理用户提交评论的表单验证、
# 数据存储及页面跳转等逻辑,是评论功能与用户交互的核心处理层。
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
from django.core.exceptions import ValidationError # 导入验证异常类,用于处理评论关闭等错误
from django.http import HttpResponseRedirect # 导入重定向响应类,用于提交后跳转
from django.shortcuts import get_object_or_404 # 导入对象获取工具不存在时返回404
from django.utils.decorators import method_decorator # 导入方法装饰器工具
from django.views.decorators.csrf import csrf_protect # 导入CSRF保护装饰器
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 # 导入评论模型,用于存储评论数据
# FRR评论提交视图类继承FormView处理表单提交逻辑
class CommentPostView(FormView):
form_class = CommentForm
template_name = 'blog/article_detail.html'
form_class = CommentForm # 指定使用的表单类为CommentForm
template_name = 'blog/article_detail.html' # 表单验证失败时渲染的模板(文章详情页)
# FRR为视图方法添加CSRF保护装饰器防止跨站请求伪造攻击
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法确保视图正常处理请求
return super(CommentPostView, self).dispatch(*args, **kwargs)
# FRR处理GET请求直接访问评论提交URL时
def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
article_id = self.kwargs['article_id'] # 从URL中获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取对应的文章不存在则返回404
url = article.get_absolute_url() # 获取文章详情页的URL
# 重定向到文章详情页的评论区(#comments为评论区锚点
return HttpResponseRedirect(url + "#comments")
# FRR表单验证失败时的处理逻辑
def form_invalid(self, form):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
article_id = self.kwargs['article_id'] # 获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
# 渲染文章详情页,传递错误的表单对象和文章对象(便于前端显示错误信息)
return self.render_to_response({
'form': form,
'article': article
})
# FRR表单验证成功后的处理逻辑核心方法
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
user = self.request.user
author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
user = self.request.user # 获取当前登录用户
author = BlogUser.objects.get(pk=user.pk) # 获取用户对应的BlogUser实例
article_id = self.kwargs['article_id'] # 从URL获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
# FRR检查文章是否允许评论状态为关闭则抛出验证异常
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
# FRR创建评论对象但不保存到数据库save(False)
comment = form.save(False)
comment.article = article
comment.article = article # 关联评论到当前文章
# FRR根据网站设置决定评论是否需要审核默认需要审核is_enable=False
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review:
comment.is_enable = True
comment.author = author
settings = get_blog_setting() # 获取博客全局设置
if not settings.comment_need_review: # 若无需审核
comment.is_enable = True # 直接设为启用状态
comment.author = author # 关联评论作者为当前登录用户
# FRR处理回复功能若存在父评论ID则关联父评论
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment
pk=form.cleaned_data['parent_comment_id']) # 获取父评论
comment.parent_comment = parent_comment # 关联到父评论
# FRR保存评论到数据库save(True)触发模型的save方法和信号
comment.save(True)
# FRR重定向到文章详情页的当前评论位置锚点定位到具体评论
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))
(article.get_absolute_url(), comment.pk))

@ -3,6 +3,3 @@
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (accounts)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
</project>

@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/accounts.iml" filepath="$PROJECT_DIR$/.idea/accounts.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/djangoblog.iml" filepath="$PROJECT_DIR$/.idea/djangoblog.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
</component>
</project>

@ -1 +1,7 @@
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
#wr 配置当前应用的默认配置类
# 当Django加载该应用时若未在INSTALLED_APPS中显式指定配置类会自动使用此处指定的配置类
# 'djangoblog.apps.DjangoblogAppConfig' 表示配置类的完整路径:
# - djangoblog应用名称
# - apps存放配置类的模块
# - DjangoblogAppConfig具体的配置类通常包含应用初始化、信号注册等逻辑
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -15,50 +15,75 @@ from owntracks.admin import *
from owntracks.models import *
from servermanager.admin import *
from servermanager.models import *
from django.contrib.admin import AdminSite #wr 导入Django内置的AdminSite基类用于构建管理后台
class DjangoBlogAdminSite(AdminSite):
"""
自定义管理站点类继承自Django的AdminSite
用于个性化配置博客系统的管理后台如标题权限控制等
"""
#wr 管理后台页面顶部的标题(显示在登录页和管理首页的顶部横幅)
site_header = 'djangoblog administration'
#wr 管理后台的页面标题(显示在浏览器标签页上)
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
"""
初始化方法调用父类的初始化逻辑
:param name: 管理站点的标识名称默认使用'admin'与Django默认管理站点名称一致便于路由匹配
"""
super().__init__(name)
def has_permission(self, request):
"""
控制访问管理后台的权限校验
仅允许超级用户is_superuser为True访问管理后台
:param request: 当前HTTP请求对象包含用户信息等
:return: 布尔值True表示有权限访问False表示无权限
"""
return request.user.is_superuser
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
# from blog.views import refresh_memcache
#
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# return urls + my_urls
#wr 创建自定义管理站点的实例,名称为'admin'与Django默认管理站点标识一致便于在urls.py中配置路由
admin_site = DjangoBlogAdminSite(name='admin')
#wr 以下为注册模型到自定义管理站点:
#wr 将数据模型与对应的Admin配置类绑定使模型在管理后台可见并可操作
#wr 注册文章模型(Article)及其实例配置类(ArticlelAdmin),用于管理博客文章
admin_site.register(Article, ArticlelAdmin)
#wr 注册分类模型(Category)及其实例配置类(CategoryAdmin),用于管理文章分类
admin_site.register(Category, CategoryAdmin)
#wr 注册标签模型(Tag)及其实例配置类(TagAdmin),用于管理文章标签
admin_site.register(Tag, TagAdmin)
#wr 注册链接模型(Links)及其实例配置类(LinksAdmin),用于管理友情链接
admin_site.register(Links, LinksAdmin)
#wr 注册侧边栏模型(SideBar)及其实例配置类(SideBarAdmin),用于管理网站侧边栏内容
admin_site.register(SideBar, SideBarAdmin)
#wr 注册博客设置模型(BlogSettings)及其实例配置类(BlogSettingsAdmin),用于管理博客全局设置
admin_site.register(BlogSettings, BlogSettingsAdmin)
#wr 注册命令日志模型(commands)及其实例配置类(CommandsAdmin),用于管理系统命令执行记录
admin_site.register(commands, CommandsAdmin)
#wr 注册邮件发送日志模型(EmailSendLog)及其实例配置类(EmailSendLogAdmin),用于管理邮件发送记录
admin_site.register(EmailSendLog, EmailSendLogAdmin)
#wr 注册自定义用户模型(BlogUser)及其实例配置类(BlogUserAdmin),用于管理网站用户
admin_site.register(BlogUser, BlogUserAdmin)
#wr 注册评论模型(Comment)及其实例配置类(CommentAdmin),用于管理文章评论
admin_site.register(Comment, CommentAdmin)
#wr 注册第三方登录用户模型(OAuthUser)及其实例配置类(OAuthUserAdmin),用于管理第三方登录用户
admin_site.register(OAuthUser, OAuthUserAdmin)
#wr 注册第三方登录配置模型(OAuthConfig)及其实例配置类(OAuthConfigAdmin),用于管理第三方登录平台配置
admin_site.register(OAuthConfig, OAuthConfigAdmin)
#wr 注册位置追踪日志模型(OwnTrackLog)及其实例配置类(OwnTrackLogsAdmin),用于管理位置追踪记录
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
#wr 注册站点模型(Site)及其实例配置类(SiteAdmin)用于管理多站点配置Django自带的sites框架
admin_site.register(Site, SiteAdmin)
admin_site.register(LogEntry, LogEntryAdmin)
#wr 注册操作日志模型(LogEntry)及其实例配置类(LogEntryAdmin),用于管理用户在管理后台的操作记录
admin_site.register(LogEntry, LogEntryAdmin)

@ -1,11 +1,29 @@
#wr 从Django的apps模块导入AppConfig类该类是所有应用配置的基类
from django.apps import AppConfig
class DjangoblogAppConfig(AppConfig):
"""
wr 自定义应用配置类用于配置'djangoblog'应用的行为
继承自Django的AppConfig可自定义应用初始化信号注册插件加载等逻辑
"""
#wr 指定模型默认的自增字段类型Django 3.2+新增配置)
#wr BigAutoField是大整数类型的自增字段可支持更大范围的ID值避免整数溢出
default_auto_field = 'django.db.models.BigAutoField'
#wr 应用的名称必须与应用的目录名一致Django通过此名称识别应用
name = 'djangoblog'
def ready(self):
"""
wr 应用就绪时执行的方法Django加载完应用后自动调用
通常用于执行初始化操作如注册信号加载插件启动定时任务等
"""
#wr 调用父类的ready方法确保基类的初始化逻辑正常执行
super().ready()
# Import and load plugins here
#wr 导入并加载插件(应用就绪后加载插件,确保插件依赖的资源已初始化)
#wr 从当前应用的plugin_manage.loader模块导入load_plugins函数
from .plugin_manage.loader import load_plugins
load_plugins()
#wr 执行插件加载函数,完成插件的注册和初始化
load_plugins()

@ -1,66 +1,94 @@
import _thread
import logging
import django.dispatch
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.mail import EmailMultiAlternatives
from django.db.models.signals import post_save
from django.dispatch import receiver
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
import _thread # 导入线程模块,用于异步执行任务(如发送邮件避免阻塞主进程)
import logging # 导入日志模块,用于记录系统运行信息和错误
import django.dispatch # 导入Django信号工具用于定义和处理自定义信号
from django.conf import settings # 导入Django项目配置
from django.contrib.admin.models import LogEntry # 导入管理员操作日志模型
from django.contrib.auth.signals import user_logged_in, user_logged_out # 导入用户登录/登出内置信号
from django.core.mail import EmailMultiAlternatives # 导入Django邮件工具支持HTML内容
from django.db.models.signals import post_save # 导入模型保存后触发的内置信号
from django.dispatch import receiver # 导入信号接收器装饰器
from comments.models import Comment # 导入评论模型
from comments.utils import send_comment_email # 导入发送评论通知邮件的工具函数
from djangoblog.spider_notify import SpiderNotify # 导入搜索引擎通知工具(如百度收录推送)
from djangoblog.utils import ( # 导入项目自定义工具函数
cache, # 缓存操作工具
expire_view_cache, # 清除视图缓存
delete_sidebar_cache, # 清除侧边栏缓存
delete_view_cache # 清除指定视图缓存
)
from djangoblog.utils import get_current_site # 导入获取当前站点信息的工具
from oauth.models import OAuthUser # 导入第三方登录用户模型
#wr 初始化日志记录器(指定记录器名称为当前模块)
logger = logging.getLogger(__name__)
#wr 定义自定义信号第三方登录用户登录信号携带用户ID参数
oauth_user_login_signal = django.dispatch.Signal(['id'])
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
#wr 定义自定义信号:发送邮件信号(携带收件人、标题、内容参数)
send_email_signal = django.dispatch.Signal(['emailto', 'title', 'content'])
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
"""
wr发送邮件信号的接收器处理邮件发送逻辑并记录发送日志
当send_email_signal信号触发时自动执行此函数
"""
#wr 从信号参数中获取邮件信息
emailto = kwargs['emailto'] #wr 收件人列表
title = kwargs['title'] #wr 邮件标题
content = kwargs['content'] #wr 邮件内容HTML格式
#wr 创建邮件对象:指定标题、内容、发件人(从配置获取)、收件人
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
msg.content_subtype = "html"
to=emailto
)
msg.content_subtype = "html" #wr 声明邮件内容为HTML格式
from servermanager.models import EmailSendLog
#wr 初始化邮件发送日志记录
from servermanager.models import EmailSendLog #wr 导入邮件发送日志模型
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto)
log.title = title #wr 记录邮件标题
log.content = content #wr 记录邮件内容
log.emailto = ','.join(emailto) #wr 记录收件人(用逗号拼接列表)
try:
#wr 发送邮件:返回成功发送的数量
result = msg.send()
log.send_result = result > 0
log.send_result = result > 0 #wr 发送成功标识大于0表示至少成功发送1封
except Exception as e:
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False
log.save()
#wr 记录发送失败日志
logger.error(f"邮件发送失败,收件人: {emailto}, 错误: {e}")
log.send_result = False #wr 标记发送失败
log.save() #wr 保存日志记录到数据库
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
"""
wr 第三方登录用户登录信号的接收器处理用户头像本地化及缓存清理
当oauth_user_login_signal信号触发时自动执行此函数
"""
#wr 从信号参数中获取第三方用户ID
id = kwargs['id']
#wr 查询对应的第三方用户对象
oauthuser = OAuthUser.objects.get(id=id)
#wr 获取当前站点域名(用于判断头像是否为本地地址)
site = get_current_site().domain
#wr 若用户头像存在且不是本地地址(未包含当前站点域名),则本地化头像
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
from djangoblog.utils import save_user_avatar #wr 导入头像保存工具
#wr 下载并保存头像到本地返回本地头像URL
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
oauthuser.save() #wr 更新用户信息
#wr 清除侧边栏缓存(用户信息可能影响侧边栏显示,如登录状态)
delete_sidebar_cache()
@ -71,52 +99,79 @@ def model_post_save_callback(
created,
raw,
using,
update_fields,
**kwargs):
clearcache = False
update_fields,** kwargs):
"""
wr 模型保存后信号的接收器处理模型保存后的后续操作如通知搜索引擎清理缓存等
当任意模型执行save()自动触发此函数
"""
clearcache = False # 缓存清理标识
#wr 忽略管理员操作日志模型(避免递归触发或无效处理)
if isinstance(instance, LogEntry):
return
#wr 若模型实例有get_full_url方法通常为可访问的内容模型如文章
if 'get_full_url' in dir(instance):
#wr 判断是否仅更新了浏览量字段(避免不必要的操作)
is_update_views = update_fields == {'views'}
#wr 非测试环境且非仅更新浏览量时,通知搜索引擎(如百度)更新收录
if not settings.TESTING and not is_update_views:
try:
notify_url = instance.get_full_url()
SpiderNotify.baidu_notify([notify_url])
notify_url = instance.get_full_url() # 获取模型实例的完整URL
SpiderNotify.baidu_notify([notify_url]) # 通知百度收录该URL
except Exception as ex:
logger.error("notify sipder", ex)
logger.error("通知搜索引擎失败", ex) # 记录通知失败日志
#wr 非仅更新浏览量时,标记需要清理缓存
if not is_update_views:
clearcache = True
#wr 若保存的是评论模型实例
if isinstance(instance, Comment):
#wr 仅处理已启用的评论
if instance.is_enable:
#wr 获取评论所属文章的URL
path = instance.article.get_absolute_url()
#wr 获取当前站点域名(处理端口号,避免缓存键错误)
site = get_current_site().domain
if site.find(':') > 0:
site = site[0:site.find(':')]
site = site[0:site.find(':')] # 移除端口号,仅保留域名
#wr 清除文章详情页的视图缓存(评论更新后需刷新页面)
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
key_prefix='blogdetail'
)
#wr 清除SEO处理器缓存评论可能影响页面元数据
if cache.get('seo_processor'):
cache.delete('seo_processor')
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
#wr 清除该文章的评论缓存
comment_cache_key = f'article_comments_{instance.article.id}'
cache.delete(comment_cache_key)
#wr 清除侧边栏缓存(侧边栏可能显示最新评论)
delete_sidebar_cache()
#wr 清除评论列表视图的缓存
delete_view_cache('article_comments', [str(instance.article.pk)])
#wr 启动新线程发送评论通知邮件(异步执行,避免阻塞主请求)
_thread.start_new_thread(send_comment_email, (instance,))
#wr 若标记需要清理缓存,则清除全局缓存
if clearcache:
cache.clear()
@receiver(user_logged_in)
@receiver(user_logged_out)
@receiver(user_logged_in) #wr 绑定用户登录信号
@receiver(user_logged_out) #wr 绑定用户登出信号
def user_auth_callback(sender, request, user, **kwargs):
"""
wr 用户登录/登出信号的接收器处理用户身份变更后的缓存清理
当用户登录或登出时自动触发此函数
"""
#wr 若用户存在且用户名有效
if user and user.username:
logger.info(user)
delete_sidebar_cache()
# cache.clear()
logger.info(user) # 记录用户登录/登出日志
delete_sidebar_cache() # 清除侧边栏缓存(侧边栏可能显示用户状态)
#wr cache.clear() # 注释:可选清除全局缓存(视业务需求启用)

@ -1,118 +1,183 @@
from django.utils.encoding import force_str
from elasticsearch_dsl import Q
#wr 导入必要模块
from django.utils.encoding import force_str #wr 用于字符串编码转换
from elasticsearch_dsl import Q #wr Elasticsearch查询构建工具
#wr 导入Haystack核心组件引擎、后端、查询基类及日志工具
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
from haystack.forms import ModelSearchForm
from haystack.models import SearchResult
from haystack.utils import log as logging
from haystack.forms import ModelSearchForm #wr Haystack默认搜索表单
from haystack.models import SearchResult #wr Haystack搜索结果封装类
from haystack.utils import log as logging #wr Haystack日志工具
#wr 导入项目自定义的Elasticsearch文档和文档管理器用于文章索引管理
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article
from blog.models import Article #wr 博客文章模型
#wr 初始化日志记录器
logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
"""
wr自定义Elasticsearch搜索后端继承自Haystack的BaseSearchBackend
负责与Elasticsearch交互处理索引创建更新删除及搜索逻辑
"""
def __init__(self, connection_alias, **connection_options):
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
self.manager = ArticleDocumentManager()
self.include_spelling = True
"""
wr初始化方法设置Elasticsearch连接参数及文档管理器
:param connection_alias: 连接别名用于多后端区分
:param connection_options: 连接选项如主机端口等
"""
super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options)
self.manager = ArticleDocumentManager() #wr 初始化文章文档管理器(处理索引操作)
self.include_spelling = True #wr 启用拼写建议功能
def _get_models(self, iterable):
"""
wr将模型实例转换为Elasticsearch文档对象
:param iterable: 模型实例列表如文章列表
:return: 转换后的Elasticsearch文档列表
"""
#wr 若输入为空,默认获取所有文章;否则使用输入的模型实例
models = iterable if iterable and iterable[0] else Article.objects.all()
#wr 调用文档管理器将模型转换为文档
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
"""
wr创建索引并初始化数据
:param models: 需要索引的模型实例列表
"""
self.manager.create_index() #wr 创建Elasticsearch索引
docs = self._get_models(models) #wr 转换模型为文档
self.manager.rebuild(docs) #wr 重建索引(全量覆盖)
def _delete(self, models):
"""
wr从索引中删除模型对应的文档
:param models: 需要删除的模型实例列表
:return: 操作结果True表示成功
"""
for m in models:
m.delete()
m.delete() #wr 调用文档的删除方法实际由ArticleDocument实现
return True
def _rebuild(self, models):
models = models if models else Article.objects.all()
docs = self.manager.convert_to_doc(models)
self.manager.update_docs(docs)
"""
wr重建索引增量更新
:param models: 需要更新的模型实例列表为空则更新所有文章
"""
models = models if models else Article.objects.all() #wr 处理空输入
docs = self.manager.convert_to_doc(models) #wr 转换模型为文档
self.manager.update_docs(docs) #wr 增量更新索引
def update(self, index, iterable, commit=True):
models = self._get_models(iterable)
self.manager.update_docs(models)
"""
wrHaystack接口更新索引用于Haystack的信号触发更新
:param index: 索引名称当前实现未使用
:param iterable: 需更新的模型实例列表
:param commit: 是否立即提交当前实现未使用
"""
models = self._get_models(iterable) #wr 转换模型为文档
self.manager.update_docs(models) #wr 执行更新
def remove(self, obj_or_string):
models = self._get_models([obj_or_string])
self._delete(models)
"""
wrHaystack接口从索引中移除对象
:param obj_or_string: 模型实例或标识字符串
"""
models = self._get_models([obj_or_string]) #wr 转换为文档
self._delete(models) #wr 执行删除
def clear(self, models=None, commit=True):
self.remove(None)
"""
wrHaystack接口清空索引
:param models: 需清空的模型类当前实现未使用默认清空所有
:param commit: 是否立即提交当前实现未使用
"""
self.remove(None) #wr 调用删除方法清空
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
"""
wr获取搜索建议词基于Elasticsearch的term suggest功能
:param query: 原始搜索词
:return: 推荐的搜索词多个词用空格拼接
"""
#wr 构建搜索:匹配文章内容,并添加拼写建议
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
#wr 提取建议结果:若有建议则取第一个,否则保留原词
for suggest in search.suggest.suggest_search:
if suggest["options"]:
keywords.append(suggest["options"][0]["text"])
else:
keywords.append(suggest["text"])
return ' '.join(keywords)
return ' '.join(keywords) # 拼接建议词为字符串
@log_query
@log_query #wr Haystack装饰器记录查询日志
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string)
"""
wr核心方法执行搜索查询
:param query_string: 搜索关键词
:param kwargs: 额外参数如分页偏移量过滤条件等
:return: 搜索结果字典包含结果列表总命中数建议词等
"""
logger.info('search query_string:' + query_string) #wr 记录搜索关键词
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
#wr 获取分页参数(起始偏移量和结束偏移量)
start_offset = kwargs.get('start_offset', 0)
end_offset = kwargs.get('end_offset', 10) #wr 默认返回10条结果
# 推荐词搜索
#wr 处理搜索建议:若启用建议模式,则获取推荐词;否则使用原词
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string)
else:
suggestion = query_string
#wr 构建Elasticsearch查询
#wr 1. 布尔查询匹配标题或内容至少70%匹配度)
#wr 2. 过滤条件状态为已发布status='p'、类型为文章type='a'
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
should=[Q('match', body=suggestion), Q('match', title=suggestion)], #wr 匹配标题或内容
minimum_should_match="70%") #wr 最小匹配度
#wr 执行搜索排除原始文档内容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
results = search.execute() #wr 执行查询
hits = results['hits'].total #wr 总命中数
raw_results = []
for raw_result in results['hits']['hits']:
app_label = 'blog'
model_name = 'Article'
additional_fields = {}
result_class = SearchResult
#wr 转换Elasticsearch结果为Haystack的SearchResult对象适配Haystack接口
for raw_result in results['hits']['hits']:
app_label = 'blog' #wr 应用标签文章属于blog应用
model_name = 'Article' #wr 模型名称
additional_fields = {} #wr 额外字段(当前未使用)
result = result_class(
#wr 构建Haystack搜索结果对象
result = SearchResult(
app_label,
model_name,
raw_result['_id'],
raw_result['_score'],
raw_result['_id'], #wr 文档ID对应文章ID
raw_result['_score'], #wr 匹配分数
**additional_fields)
raw_results.append(result)
#wr 准备返回数据:结果列表、总命中数、空 facets暂未实现、拼写建议
facets = {}
spelling_suggestion = None if query_string == suggestion else suggestion
spelling_suggestion = None if query_string == suggestion else suggestion #wr 若建议词与原词不同则返回
return {
'results': raw_results,
@ -123,61 +188,104 @@ class ElasticSearchBackend(BaseSearchBackend):
class ElasticSearchQuery(BaseSearchQuery):
"""
wr自定义搜索查询类继承自Haystack的BaseSearchQuery
负责处理查询参数解析查询构建等逻辑
"""
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
"""
wr转换日期时间为Elasticsearch支持的格式
:param date: 日期时间对象
:return: 格式化的字符串如20231001123000
"""
if hasattr(date, 'hour'): #wr 包含小时信息datetime
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
else: #wr 仅日期date默认时间为00:00:00
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
wr 清理查询片段用户输入处理保留词和特殊字符
:param query_fragment: 原始查询片段
:return: 清理后的查询字符串
"""
words = query_fragment.split()
words = query_fragment.split() #wr 拆分关键词
cleaned_words = []
for word in words:
#wr 处理保留词(转为小写,避免冲突)
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
#wr 处理特殊字符:若包含保留字符则用单引号包裹
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
word = f"'{word}'"
break
cleaned_words.append(word)
return ' '.join(cleaned_words)
return ' '.join(cleaned_words) #wr 拼接清理后的关键词
def build_query_fragment(self, field, filter_type, value):
return value.query_string
"""
wr构建查询片段适配Haystack的查询构建逻辑
:param field: 查询字段
:param filter_type: 过滤类型
:param value: 查询值
:return: 查询字符串
"""
return value.query_string #wr 直接返回查询字符串(简化实现)
def get_count(self):
"""
wr获取搜索结果总数
:return: 结果数量
"""
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
return self._spelling_suggestion
"""
wr获取拼写建议适配Haystack接口
:param preferred_query: 优先查询词未使用
:return: 建议词
"""
return self._spelling_suggestion #wr 返回搜索后端生成的建议词
def build_params(self, spelling_query=None):
"""
wr 构建查询参数适配Haystack接口
:param spelling_query: 拼写建议查询词
:return: 参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""
wr 自定义搜索表单继承自Haystack的ModelSearchForm
扩展搜索功能支持搜索建议开关
"""
def search(self):
# 是否建议搜索
"""
wr 执行搜索重写父类方法添加建议模式控制
:return: 搜索结果集SearchQuerySet
"""
#wr 根据表单参数设置是否启用搜索建议("is_suggest"为"no"时禁用)
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
#wr 调用父类方法执行搜索
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend
query = ElasticSearchQuery
"""
wr 自定义Elasticsearch引擎类继承自Haystack的BaseEngine
关联后端和查询类作为Haystack的引擎入口
"""
backend = ElasticSearchBackend #wr 指定使用的搜索后端
query = ElasticSearchQuery #wr 指定使用的查询类

@ -1,40 +1,97 @@
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed
from blog.models import Article
from djangoblog.utils import CommonMarkdown
#wr 导入必要模块
from django.contrib.auth import get_user_model #wr 获取项目自定义的用户模型避免直接引用User类
from django.contrib.syndication.views import Feed #wr Django内置的RSS/Atom订阅生成基类
from django.utils import timezone #wr 处理时间相关操作(用于生成版权信息中的年份)
from django.utils.feedgenerator import Rss201rev2Feed #wr RSS 2.0标准格式生成器
from blog.models import Article #wr 博客文章模型RSS订阅的核心内容来源
from djangoblog.utils import CommonMarkdown #wr 自定义Markdown转换工具将Markdown转为HTML
class DjangoBlogFeed(Feed):
"""
wr自定义RSS订阅生成类继承自Django的Feed基类
用于生成博客文章的RSS订阅内容支持标准RSS 2.0格式
访问路径为/feed/用户可通过订阅工具获取最新文章推送
"""
#wr 指定RSS订阅的格式版本采用RSS 2.0标准(兼容性最广)
feed_type = Rss201rev2Feed
#wr RSS订阅的描述信息将显示在订阅工具的描述栏
description = '大巧无工,重剑无锋.'
#wr RSS订阅的标题显示在订阅工具的标题栏
title = "且听风吟 大巧无工,重剑无锋. "
#wr RSS订阅的访问URL路径与urls.py中配置的路由一致
link = "/feed/"
def author_name(self):
"""
wr订阅内容的作者名称
这里取项目中第一个用户的昵称默认作者可根据实际需求修改
:return: 作者昵称字符串
"""
return get_user_model().objects.first().nickname
def author_link(self):
"""
wr作者的个人页面链接
调用用户模型的get_absolute_url方法生成作者个人主页URL
:return: 作者个人页面的绝对URL
"""
return get_user_model().objects.first().get_absolute_url()
def items(self):
"""
wrRSS订阅的核心内容列表即要推送的文章
过滤条件类型为文章type='a'状态为已发布status='p'
排序规则按发布时间倒序最新文章优先
数量限制仅取前5篇避免订阅内容过多
:return: 筛选后的文章查询集
"""
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
def item_title(self, item):
"""
wr单个订阅项文章的标题
直接使用文章自身的标题
:param item: 单个Article模型实例
:return: 文章标题字符串
"""
return item.title
def item_description(self, item):
"""
wr单个订阅项文章的描述内容
将文章的Markdown格式正文转换为HTMLRSS支持HTML格式确保排版正常
:param item: 单个Article模型实例
:return: 转换后的HTML格式文章内容
"""
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
"""
wr订阅内容的版权声明
动态获取当前年份生成格式为"Copyright© 年份 且听风吟"的版权信息
:return: 版权声明字符串
"""
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
"""
wr单个订阅项文章的访问链接
调用文章模型的get_absolute_url方法生成文章详情页的绝对URL
:param item: 单个Article模型实例
:return: 文章详情页的绝对URL
"""
return item.get_absolute_url()
def item_guid(self, item):
return
"""
wr单个订阅项文章的唯一标识GUID
用于订阅工具区分不同文章避免重复推送
原代码未完成实现建议返回文章的唯一标识如文章ID+URL组合或绝对URL
示例实现return f"{item.get_absolute_url()}?id={item.id}"
:param item: 单个Article模型实例
:return: 文章的唯一标识字符串
"""
return #wr 原代码未完成,需根据实际需求补充唯一标识逻辑

@ -1,91 +1,137 @@
from django.contrib import admin
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.contrib import admin # 导入Django Admin核心模块
from django.contrib.admin.models import DELETION # 导入表示"删除"操作的常量
from django.contrib.contenttypes.models import ContentType # 导入内容类型模型(用于关联不同模型)
from django.urls import reverse, NoReverseMatch # 导入URL反向解析工具及异常
from django.utils.encoding import force_str # 用于字符串编码转换兼容Python 2/3
from django.utils.html import escape # 用于HTML转义防止XSS攻击
from django.utils.safestring import mark_safe # 标记安全的HTML字符串允许在模板中渲染
from django.utils.translation import gettext_lazy as _ # 国际化翻译工具
class LogEntryAdmin(admin.ModelAdmin):
"""
wr自定义LogEntry模型的Admin配置类
LogEntry是Django自带的模型用于记录管理员在后台的操作日志如新增修改删除对象
此类控制日志在Admin后台的显示搜索过滤及操作权限
"""
#wr 列表页的过滤条件(右侧过滤器):按内容类型(即操作的模型类型)过滤
list_filter = [
'content_type'
]
#wr 搜索字段可通过对象名称object_repr和操作描述change_message搜索日志
search_fields = [
'object_repr',
'change_message'
]
#wr 列表页中可点击的字段(点击跳转到日志详情页)
list_display_links = [
'action_time',
'get_change_message',
'action_time', #wr 操作时间
'get_change_message', #wr 操作描述(自定义方法)
]
#wr 列表页显示的字段(按顺序排列)
list_display = [
'action_time',
'user_link',
'content_type',
'object_link',
'get_change_message',
'action_time', #wr 操作时间
'user_link', #wr 操作人(带链接的自定义字段)
'content_type', #wr 操作的模型类型(如文章、用户等)
'object_link', #wr 操作的对象(带链接的自定义字段)
'get_change_message', #wr 操作描述Django原生方法返回格式化的操作信息
]
def has_add_permission(self, request):
"""
wr控制是否允许添加日志条目返回False禁止手动添加日志
原因日志是系统自动记录的不允许人工干预
"""
return False
def has_change_permission(self, request, obj=None):
"""
wr控制是否允许修改日志条目仅允许超级用户或有修改权限的用户以非POST方式访问即仅查看
原因日志记录应保持原始性禁止修改
"""
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
request.user.is_superuser or #wr 超级用户有权限
request.user.has_perm('admin.change_logentry') #wr 有明确权限的用户
) and request.method != 'POST' #wr 禁止POST请求即禁止提交修改
def has_delete_permission(self, request, obj=None):
"""
wr控制是否允许删除日志条目返回False禁止删除日志
原因日志是系统操作记录需长期保存用于审计
"""
return False
def object_link(self, obj):
object_link = escape(obj.object_repr)
content_type = obj.content_type
"""
wr自定义字段显示操作对象的链接若存在
功能如果操作不是删除且能获取到内容类型尝试生成对象的Admin修改页链接
"""
object_link = escape(obj.object_repr) #wr 转义对象名称防止XSS
content_type = obj.content_type #wr 获取操作的模型类型
#wr 若操作不是删除,且内容类型存在(即有对应的模型)
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
#wr 生成对象在Admin中的修改页URL格式admin:应用名_模型名_change
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
args=[obj.object_id] #wr 传入对象ID
)
#wr 生成带链接的对象名称
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
#wr 若URL反向解析失败如模型未注册到Admin则仅显示对象名称
pass
return mark_safe(object_link)
return mark_safe(object_link) #wr 标记为安全HTML允许在页面渲染链接
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
#wr 配置自定义字段的排序和显示名称
object_link.admin_order_field = 'object_repr' #wr 允许按对象名称排序
object_link.short_description = _('object') #wr 列表页表头显示名称(支持国际化)
def user_link(self, obj):
"""
wr自定义字段显示操作人的链接跳转到用户的Admin修改页
"""
#wr 获取操作人User模型的内容类型
content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user))
user_link = escape(force_str(obj.user)) #wr 转义用户名并确保为字符串
try:
# try returning an actual link instead of object repr string
#wr 生成用户在Admin中的修改页URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
args=[obj.user.pk] #wr 传入用户ID
)
#wr 生成带链接的用户名
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
#wr 若URL解析失败仅显示用户名
pass
return mark_safe(user_link)
return mark_safe(user_link) #wr 标记为安全HTML
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
#wr 配置自定义字段的排序和显示名称
user_link.admin_order_field = 'user' #wr 允许按用户排序
user_link.short_description = _('user') #wr 列表页表头显示名称(支持国际化)
def get_queryset(self, request):
"""
wr优化查询集预加载content_type关联数据减少数据库查询次数
提升列表页加载性能避免N+1查询问题
"""
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
return queryset.prefetch_related('content_type') #wr 预加载content_type
def get_actions(self, request):
"""
wr移除"删除选中项"操作确保日志无法通过批量操作删除
"""
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
del actions['delete_selected'] #wr 删除批量删除操作
return actions

@ -1,84 +1,116 @@
#wr 导入必要模块日志用于记录插件运行信息Path用于路径处理
#wr Django模板相关模块用于模板渲染及处理模板不存在的异常
import logging
from pathlib import Path
from django.template import TemplateDoesNotExist
from django.template.loader import render_to_string
from django.template import TemplateDoesNotExist # wrDjango模板不存在时抛出的异常
from django.template.loader import render_to_string # wrDjango的模板渲染函数
#wr创建当前模块的日志记录器用于记录插件相关日志
logger = logging.getLogger(__name__)
class BasePlugin:
# 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
PLUGIN_AUTHOR = None
# 插件配置
SUPPORTED_POSITIONS = [] # 支持的显示位置
DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高)
POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80}
"""
wr插件基类所有自定义插件需继承此类并实现必要的抽象方法
提供插件元数据管理位置渲染模板处理静态资源管理等核心功能
实现了插件系统的基础框架
"""
# ===================== 插件元数据(子类必须定义) =====================
PLUGIN_NAME = None # wr插件名称例如"文章推荐插件"
PLUGIN_DESCRIPTION = None # wr插件描述说明插件功能
PLUGIN_VERSION = None # wr插件版本例如"1.0.0"
PLUGIN_AUTHOR = None #wr 插件作者(可选,可留空)
# ===================== 插件配置(子类可根据需求重写) =====================
SUPPORTED_POSITIONS = [] # wr插件支持的显示位置列表例如['sidebar', 'article_bottom']
DEFAULT_PRIORITY = 100 #wr 默认优先级:数字越小,插件在同位置越靠前显示
POSITION_PRIORITIES = {} #wr 位置特定优先级:为不同位置单独设置优先级(覆盖默认值)
def __init__(self):
"""
wr初始化插件实例
检查元数据完整性获取插件路径和标识符执行初始化逻辑并注册钩子
"""
#wr 校验插件元数据:名称、描述、版本为必填项,未定义则抛出异常
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
raise ValueError("插件元数据PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION必须定义。")
# 设置插件路径
self.plugin_dir = self._get_plugin_directory()
self.plugin_slug = self._get_plugin_slug()
#wr 获取插件所在目录路径和唯一标识符slug
self.plugin_dir = self._get_plugin_directory() # 插件目录路径Path对象
self.plugin_slug = self._get_plugin_slug() # 插件唯一标识(默认使用目录名)
# wr执行插件初始化逻辑子类可重写
self.init_plugin()
# wr注册插件钩子子类可重写以注册自定义钩子
self.register_hooks()
def _get_plugin_directory(self):
"""获取插件目录路径"""
"""
wr获取插件所在的目录路径
通过反射获取当前插件类所在的文件路径进而得到目录路径
"""
import inspect
# 获取当前类(子类)的定义文件路径
plugin_file = inspect.getfile(self.__class__)
# 返回文件所在的目录路径
return Path(plugin_file).parent
def _get_plugin_slug(self):
"""获取插件标识符(目录名)"""
"""
wr获取插件的唯一标识符slug
默认使用插件目录的名称作为slug确保唯一性
"""
return self.plugin_dir.name
def init_plugin(self):
"""
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
wr插件初始化逻辑钩子方法
子类可重写此方法实现自定义初始化操作如加载配置连接数据库等
默认仅记录初始化日志
"""
logger.info(f'{self.PLUGIN_NAME} initialized.')
logger.info(f'{self.PLUGIN_NAME} 已初始化。')
def register_hooks(self):
"""
注册插件钩子
子类可以重写此方法来注册特定的钩子
wr注册插件钩子钩子方法
子类可重写此方法注册自定义钩子如监听系统事件注册URL路由等
默认不执行任何操作
"""
pass
# === 位置渲染系统 ===
# ===================== 位置渲染系统(核心功能) =====================
def render_position_widget(self, position, context, **kwargs):
"""
根据位置渲染插件组件
wr根据指定位置渲染插件组件是位置渲染的入口方法
Args:
position: 位置标识
context: 模板上下文
**kwargs: 额外参数
position: 位置标识'sidebar'表示侧边栏
context: Django模板上下文包含页面渲染所需数据
**kwargs: 额外参数如文章ID用户信息等按需传递
Returns:
dict: {'html': 'HTML内容', 'priority': 优先级} None
dict: 包含渲染结果的字典{'html': HTML内容, 'priority': 优先级, 'plugin_name': 插件名}
若位置不支持或无需显示则返回None
"""
#wr 检查当前位置是否在插件支持的位置列表中不支持则直接返回None
if position not in self.SUPPORTED_POSITIONS:
return None
# 检查条件显示
#wr 检查插件是否应在当前位置显示调用should_display判断
if not self.should_display(position, context, **kwargs):
return None
# 调用具体的位置渲染方法
#wr 动态拼接当前位置对应的渲染方法名如position为'sidebar',则方法名为'render_sidebar_widget'
method_name = f'render_{position}_widget'
#wr 检查当前类是否实现了对应位置的渲染方法
if hasattr(self, method_name):
#wr 调用对应方法获取HTML内容
html = getattr(self, method_name)(context, **kwargs)
#wr 若渲染成功返回非空HTML则构造结果字典
if html:
#wr 优先级:优先使用位置特定优先级,否则使用默认优先级
priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY)
return {
'html': html,
@ -86,109 +118,158 @@ class BasePlugin:
'plugin_name': self.PLUGIN_NAME
}
#wr 若未实现对应渲染方法或渲染失败返回None
return None
def should_display(self, position, context, **kwargs):
"""
判断插件是否应该在指定位置显示
子类可重写此方法实现条件显示逻辑
wr判断插件是否应在指定位置显示钩子方法
子类可重写此方法实现条件显示逻辑如仅在特定页面/用户组显示
默认返回True始终显示
Args:
position: 位置标识
context: 模板上下文
**kwargs: 额外参数
Returns:
bool: 是否显示
bool: True表示显示False表示不显示
"""
return True
# === 各位置渲染方法 - 子类重写 ===
# ===================== 位置渲染方法(子类需按需重写) =====================
def render_sidebar_widget(self, context, **kwargs):
"""渲染侧边栏组件"""
"""wr渲染侧边栏组件(钩子方法)。子类重写此方法实现侧边栏显示内容。"""
return None
def render_article_bottom_widget(self, context, **kwargs):
"""渲染文章底部组件"""
"""wr渲染文章底部组件(钩子方法)。子类重写此方法实现文章底部显示内容。"""
return None
def render_article_top_widget(self, context, **kwargs):
"""渲染文章顶部组件"""
"""wr渲染文章顶部组件(钩子方法)。子类重写此方法实现文章顶部显示内容。"""
return None
def render_header_widget(self, context, **kwargs):
"""渲染页头组件"""
"""wr渲染页头组件(钩子方法)。子类重写此方法实现页头显示内容。"""
return None
def render_footer_widget(self, context, **kwargs):
"""渲染页脚组件"""
"""wr渲染页脚组件(钩子方法)。子类重写此方法实现页脚显示内容。"""
return None
def render_comment_before_widget(self, context, **kwargs):
"""渲染评论前组件"""
"""wr渲染评论前组件(钩子方法)。子类重写此方法实现评论区前显示内容。"""
return None
def render_comment_after_widget(self, context, **kwargs):
"""渲染评论后组件"""
"""wr渲染评论后组件(钩子方法)。子类重写此方法实现评论区后显示内容。"""
return None
# === 模板系统 ===
# ===================== 模板系统(插件模板渲染) =====================
def render_template(self, template_name, context=None):
"""
渲染插件模板
wr 渲染插件自带的模板文件
模板路径固定为"plugins/[插件slug]/[模板文件名]"
Args:
template_name: 模板文件名
context: 模板上下文
template_name: 模板文件名"sidebar.html"
context: 模板上下文字典类型默认为空
Returns:
HTML字符串
str: 渲染后的HTML字符串若模板不存在返回空字符串并记录警告日志
"""
if context is None:
context = {}
context = {} # 确保上下文不为None
#wr 构造模板路径:插件模板需放在"plugins/插件slug/"目录下
template_path = f"plugins/{self.plugin_slug}/{template_name}"
try:
#wr 使用Django的render_to_string渲染模板
return render_to_string(template_path, context)
except TemplateDoesNotExist:
logger.warning(f"Plugin template not found: {template_path}")
#wr 模板不存在时记录警告日志
logger.warning(f"插件模板不存在:{template_path}")
return ""
# === 静态资源系统 ===
# ===================== 静态资源系统CSS/JS等资源管理 =====================
def get_static_url(self, static_file):
"""获取插件静态文件URL"""
from django.templatetags.static import static
"""
wr获取插件静态文件的URL
静态文件需放在插件目录的"static/[插件slug]/"遵循Django静态文件规范
Args:
static_file: 静态文件相对路径"css/style.css"
Returns:
str: 静态文件的完整URL"/static/demo_plugin/css/style.css"
"""
from django.templatetags.static import static # wr导入Django静态文件工具
#wr 构造静态文件路径并生成URL
return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}")
def get_css_files(self):
"""获取插件CSS文件列表"""
"""
wr获取插件所需的CSS文件列表钩子方法
子类重写此方法返回CSS文件路径列表系统会自动在页面加载这些CSS
Returns:
list: CSS文件路径列表["css/style.css"]
"""
return []
def get_js_files(self):
"""获取插件JavaScript文件列表"""
"""
wr获取插件所需的JavaScript文件列表钩子方法
子类重写此方法返回JS文件路径列表系统会自动在页面加载这些JS
Returns:
list: JS文件路径列表["js/script.js"]
"""
return []
def get_head_html(self, context=None):
"""获取需要插入到<head>中的HTML内容"""
"""
wr获取需要插入到HTML头部<head>标签内的内容钩子方法
子类重写此方法返回自定义头部内容如额外的CSS链接meta标签等
Args:
context: 模板上下文
Returns:
str: 需插入到<head>的HTML字符串
"""
return ""
def get_body_html(self, context=None):
"""获取需要插入到<body>底部的HTML内容"""
"""
wr获取需要插入到HTML底部</body>标签前的内容钩子方法
子类重写此方法返回自定义底部内容如额外的JS脚本
Args:
context: 模板上下文
Returns:
str: 需插入到<body>底部的HTML字符串
"""
return ""
def get_plugin_info(self):
"""
获取插件信息
:return: 包含插件元数据的字典
wr获取插件的详细信息元数据+配置
用于插件管理界面展示插件信息
Returns:
dict: 包含插件信息的字典
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION,
'author': self.PLUGIN_AUTHOR,
'slug': self.plugin_slug,
'directory': str(self.plugin_dir),
'supported_positions': self.SUPPORTED_POSITIONS,
'priorities': self.POSITION_PRIORITIES
}
'name': self.PLUGIN_NAME, #wr 插件名称
'description': self.PLUGIN_DESCRIPTION, #wr 插件描述
'version': self.PLUGIN_VERSION, #wr 插件版本
'author': self.PLUGIN_AUTHOR, #wr 插件作者
'slug': self.plugin_slug, #wr 插件唯一标识
'directory': str(self.plugin_dir), #wr 插件目录路径
'supported_positions': self.SUPPORTED_POSITIONS, # wr支持的显示位置
'priorities': self.POSITION_PRIORITIES #wr 各位置的优先级
}

@ -1,22 +1,31 @@
ARTICLE_DETAIL_LOAD = 'article_detail_load'
ARTICLE_CREATE = 'article_create'
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
# wr文章相关操作事件钩子常量
# wr用于标识文章生命周期中不同操作的事件插件可通过监听这些事件执行对应逻辑
ARTICLE_DETAIL_LOAD = 'article_detail_load' # wr文章详情页加载事件当用户查看文章详情时触发
ARTICLE_CREATE = 'article_create' # wr文章创建事件当文章被创建时触发
ARTICLE_UPDATE = 'article_update' # wr文章更新事件当文章被修改时触发
ARTICLE_DELETE = 'article_delete' # wr文章删除事件当文章被删除时触发
# wr文章内容处理钩子名称
# wr用于标识对文章内容进行处理的钩子如内容过滤、替换、添加额外信息等
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 位置钩子常量
#wr 位置钩子常量字典
#wr 键:页面中的具体位置标识(如文章顶部、侧边栏等)
#wr 值:对应位置的组件钩子名称(插件可通过该钩子注册组件,系统会在对应位置渲染这些组件)
POSITION_HOOKS = {
'article_top': 'article_top_widgets',
'article_bottom': 'article_bottom_widgets',
'sidebar': 'sidebar_widgets',
'header': 'header_widgets',
'footer': 'footer_widgets',
'comment_before': 'comment_before_widgets',
'comment_after': 'comment_after_widgets',
'article_top': 'article_top_widgets', #wr 文章顶部位置的组件钩子
'article_bottom': 'article_bottom_widgets', #wr文章底部位置的组件钩子
'sidebar': 'sidebar_widgets', # wr侧边栏位置的组件钩子
'header': 'header_widgets', #wr 页头位置的组件钩子
'footer': 'footer_widgets', # wr页脚位置的组件钩子
'comment_before': 'comment_before_widgets', # wr评论区之前位置的组件钩子
'comment_after': 'comment_after_widgets', # wr评论区之后位置的组件钩子
}
# 资源注入钩子
HEAD_RESOURCES_HOOK = 'head_resources'
BODY_RESOURCES_HOOK = 'body_resources'
#wr 资源注入钩子常量
# wr用于标识需要在HTML特定区域注入资源如CSS、JavaScript、meta标签等的钩子
HEAD_RESOURCES_HOOK = 'head_resources' # wrHTML头部<head>标签内)的资源注入钩子
BODY_RESOURCES_HOOK = 'body_resources' # wrHTML body底部</body>标签前)的资源注入钩子

@ -1,44 +1,85 @@
import logging
# wr创建当前模块的日志记录器用于记录钩子系统的运行日志如注册、执行、错误等
logger = logging.getLogger(__name__)
# wr私有字典用于存储所有注册的钩子
# wr键为钩子名称str值为该钩子对应的回调函数列表list[callable]
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调
wr注册一个回调函数到指定钩子
一个钩子可以注册多个回调函数执行时会按注册顺序依次调用
Args:
hook_name: 钩子名称需与后续执行/过滤时的钩子名称对应
callback: 回调函数当钩子触发时需要执行的函数
"""
#wr 如果钩子名称不在_hooks中先初始化一个空列表用于存储回调
if hook_name not in _hooks:
_hooks[hook_name] = []
#wr 将回调函数添加到该钩子的回调列表中
_hooks[hook_name].append(callback)
#wr 记录调试日志,说明已注册钩子及对应的回调函数名
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
wr执行指定的"动作钩子Action Hook"
动作钩子用于触发一系列操作无返回值会按注册顺序执行所有绑定的回调函数
Args:
hook_name: 要执行的钩子名称
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
"""
# wr检查该钩子是否有已注册的回调函数
if hook_name in _hooks:
logger.debug(f"Running action hook '{hook_name}'")
# wr遍历该钩子的所有回调函数并执行
for callback in _hooks[hook_name]:
try:
#wr 传递参数调用回调函数
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
# wr捕获回调执行中的异常记录错误日志包含异常详情
logger.error(
f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True #wr 记录完整的异常堆栈信息
)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
wr执行指定的"过滤钩子Filter Hook"
过滤钩子用于对一个值进行链式处理有输入有输出会将初始值依次传递给所有绑定的回调函数
每个回调的返回值作为下一个回调的输入最终返回经过所有处理后的结果
Args:
hook_name: 要执行的钩子名称
value: 初始值需要被过滤/处理的值
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
Returns:
经过所有回调函数处理后的最终值
"""
#wr 检查该钩子是否有已注册的回调函数
if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'")
# wr遍历该钩子的所有回调函数对值进行链式处理
for callback in _hooks[hook_name]:
try:
# wr调用回调函数处理当前值将返回值作为新值继续处理
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
#wr 捕获回调执行中的异常,记录错误日志(包含异常详情)
logger.error(
f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True #wr 记录完整的异常堆栈信息
)
#wr 返回最终处理后的值(若没有回调,直接返回初始值)
return value

@ -1,64 +1,116 @@
import os
import logging
from django.conf import settings
from django.conf import settings # wr导入Django配置用于获取插件相关设置
# wr创建当前模块的日志记录器用于记录插件加载过程中的日志信息
logger = logging.getLogger(__name__)
# 全局插件注册表
# wr全局插件注册表:存储所有已成功加载的插件实例
_loaded_plugins = []
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
wr动态加载并初始化'plugins'目录中的插件
此函数应在Django应用注册表就绪时调用确保Django环境已初始化
"""
global _loaded_plugins
_loaded_plugins = []
global _loaded_plugins # wr声明使用全局变量_loaded_plugins
_loaded_plugins = [] # wr清空现有插件列表重新加载
# wr遍历配置中激活的插件列表settings.ACTIVE_PLUGINS定义了需要加载的插件名
for plugin_name in settings.ACTIVE_PLUGINS:
# wr构建插件目录的绝对路径PLUGINS_DIR插件根目录 + 插件名
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# wr检查插件目录是否存在且目录下是否有必要的'plugin.py'文件(插件入口)
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# 导入插件模块
# wr导入插件模块:格式为'plugins.插件名.plugin'对应plugin.py文件
plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin'])
# 获取插件实例
# wr检查插件模块中是否有'plugin'属性(通常是插件类的实例)
if hasattr(plugin_module, 'plugin'):
plugin_instance = plugin_module.plugin
_loaded_plugins.append(plugin_instance)
plugin_instance = plugin_module.plugin # wr获取插件实例
_loaded_plugins.append(plugin_instance) # wr添加到全局注册表
# wr记录成功加载的日志包含插件名和插件元数据中的名称
logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}")
else:
# wr插件模块中没有'plugin'实例时记录警告
logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance")
except ImportError as e:
# wr导入插件模块失败时记录错误如文件缺失、语法错误等
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
except AttributeError as e:
# wr获取插件实例时发生属性错误如缺少必要属性
logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e)
except Exception as e:
# wr其他未预料的错误
logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e)
def get_loaded_plugins():
"""获取所有已加载的插件"""
"""
wr获取所有已加载的插件实例列表
Returns:
list: 已初始化的插件实例集合
"""
return _loaded_plugins
def get_plugin_by_name(plugin_name):
"""根据名称获取插件"""
"""
wr根据插件名称slug查找插件实例
实际通过插件的plugin_slug属性匹配与函数名对应可能存在命名统一设计
Args:
plugin_name: 插件的slug名称通常为插件目录名
Returns:
匹配的插件实例若未找到则返回None
"""
for plugin in _loaded_plugins:
if plugin.plugin_slug == plugin_name:
return plugin
return None
def get_plugin_by_slug(plugin_slug):
"""根据slug获取插件"""
"""
wr根据插件slug查找插件实例与get_plugin_by_name功能一致可能为兼容保留
Args:
plugin_slug: 插件的唯一标识符slug
Returns:
匹配的插件实例若未找到则返回None
"""
for plugin in _loaded_plugins:
if plugin.plugin_slug == plugin_slug:
return plugin
return None
def get_plugins_info():
"""获取所有插件的信息"""
"""
wr获取所有已加载插件的信息字典列表
信息由插件的get_plugin_info()方法提供包含元数据和配置
Returns:
list: 每个元素为一个插件的信息字典
"""
return [plugin.get_plugin_info() for plugin in _loaded_plugins]
def get_plugins_by_position(position):
"""获取支持指定位置的插件"""
return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS]
"""
wr筛选出支持指定显示位置的插件实例
Args:
position: 位置标识'sidebar''article_bottom'
Returns:
list: 所有支持该位置的插件实例
"""
return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS]

@ -1,7 +1,9 @@
"""
Django settings for djangoblog project.
项目基本配置文件包含Django运行所需的核心配置项
Generated by 'django-admin startproject' using Django 1.10.2.
基于Django 1.10.2版本创建
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
@ -9,378 +11,425 @@ https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
#wr 导入必要模块
import os
import sys
from pathlib import Path
from pathlib import Path # 用于路径处理的现代工具
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _ # 国际化翻译工具
def env_to_bool(env, default):
"""
将环境变量值转换为布尔值
:param env: 环境变量名称
:param default: 当环境变量不存在时的默认值
:return: 转换后的布尔值环境变量为'True'时返回True否则返回default
"""
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
#wr 项目路径配置
# 构建项目内部路径BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent # 项目根目录(当前文件的父级父级目录)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
#wr 快速开发设置 - 不适用于生产环境
# 安全警告:生产环境中必须保护好密钥
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
# 优先从环境变量获取密钥,不存在则使用默认(仅开发环境用)
# 安全警告:生产环境必须关闭调试模式
DEBUG = env_to_bool('DJANGO_DEBUG', True) # 调试模式开关,默认开启
# DEBUG = False # 生产环境关闭调试的示例
#wr 测试模式判断:当执行命令包含'test'时视为测试环境
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
#wr 允许访问的主机列表(生产环境需指定具体域名,不可用'*'
# ALLOWED_HOSTS = [] # 默认空列表(仅允许本地访问)
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] # 开发环境允许所有主机访问
#wr Django 4.0新增配置信任的CSRF来源跨域请求时需要
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
#wr 应用定义安装的所有Django应用
INSTALLED_APPS = [
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
'mdeditor',
'haystack',
'blog',
'accounts',
'comments',
'oauth',
'servermanager',
'owntracks',
'compressor',
'djangoblog'
# 'django.contrib.admin', # 默认管理员界面(全功能版)
'django.contrib.admin.apps.SimpleAdminConfig', # 简化版管理员界面
'django.contrib.auth', # 身份认证系统
'django.contrib.contenttypes', # 内容类型框架(用于权限管理)
'django.contrib.sessions', # 会话管理
'django.contrib.messages', # 消息提示系统
'django.contrib.staticfiles', # 静态文件管理
'django.contrib.sites', # 多站点支持框架
'django.contrib.sitemaps', # 站点地图生成
'mdeditor', # Markdown编辑器第三方应用
'haystack', # 全文搜索框架(第三方应用)
'blog', # 自定义博客应用
'accounts', # 自定义用户账户应用
'comments', # 自定义评论应用
'oauth', # 第三方登录OAuth应用
'servermanager', # 服务器管理应用
'owntracks', # 位置追踪应用
'compressor', # 静态文件压缩工具(第三方应用)
'djangoblog' # 项目主应用
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'blog.middleware.OnlineMiddleware'
#wr 中间件配置(请求/响应处理的钩子函数)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # 安全相关处理如HTTPS重定向
'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', # 处理条件请求如304响应
'blog.middleware.OnlineMiddleware' # 自定义在线状态中间件
]
ROOT_URLCONF = 'djangoblog.urls'
#wr URL根配置
ROOT_URLCONF = 'djangoblog.urls' # 主URL配置模块路径
#wr 模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'BACKEND': 'django.template.backends.django.DjangoTemplates', # 使用Django模板引擎
'DIRS': [os.path.join(BASE_DIR, 'templates')], # 全局模板目录
'APP_DIRS': True, # 允许从应用内的templates目录加载模板
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'blog.context_processors.seo_processor'
'context_processors': [ # 模板上下文处理器(全局变量)
'django.template.context_processors.debug', # 调试相关上下文
'django.template.context_processors.request', # 请求对象(request)
'django.contrib.auth.context_processors.auth', # 认证相关上下文
'django.contrib.messages.context_processors.messages', # 消息相关上下文
'blog.context_processors.seo_processor' # 自定义SEO相关上下文
],
},
},
]
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
#wr WSGI应用配置部署用
WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI应用入口路径
wr# 数据库配置
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.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 'root',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'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 'root', # 数据库密码
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', # 数据库主机地址
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
os.environ.get('DJANGO_MYSQL_PORT') or 3306), # 数据库端口默认3306
'OPTIONS': {
'charset': 'utf8mb4'},
'charset': 'utf8mb4'}, # 字符集支持emoji表情
}}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
#wr 密码验证配置
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# 验证密码与用户属性(如用户名、邮箱)的相似度
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
# 验证密码最小长度默认8位
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# 验证密码是否在常见密码列表中
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# 验证密码是否仅包含数字
},
]
#wr 国际化配置
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
('en', _('English')), # 英语
('zh-hans', _('Simplified Chinese')), # 简体中文
('zh-hant', _('Traditional Chinese')), # 繁体中文
)
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
os.path.join(BASE_DIR, 'locale'), # 翻译文件存储目录
)
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
LANGUAGE_CODE = 'zh-hans' # 默认语言(简体中文)
TIME_ZONE = 'Asia/Shanghai' # 时区(上海)
USE_I18N = True # 启用国际化
USE_L10N = True # 启用本地化
USE_TZ = False # 不使用UTC时区使用本地时区
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
#wr 静态文件配置CSS、JavaScript、图片等
#wr 全文搜索配置基于Haystack框架
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 使用Whoosh搜索引擎支持中文
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引文件存储路径
},
}
# Automatically update searching index
#wr 实时更新搜索索引(当数据变化时自动更新)
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
#wr 认证后端:允许使用用户名或邮箱登录
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
#wr 静态文件收集目录生产环境用通过collectstatic命令收集
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
#wr 静态文件URL前缀
STATIC_URL = '/static/'
#wr 静态文件主目录
STATICFILES = os.path.join(BASE_DIR, 'static')
# 添加插件静态文件目录
#wr 额外的静态文件目录(如插件静态文件)
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件静态文件
os.path.join(BASE_DIR, 'plugins'), # 插件静态文件目录
]
#wr 自定义用户模型替换Django默认用户模型
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录页面URL未登录时访问受保护页面会重定向到此处
LOGIN_URL = '/login/'
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap color styles
#wr 时间格式配置(模板中使用)
TIME_FORMAT = '%Y-%m-%d %H:%M:%S' # 完整时间格式
DATE_TIME_FORMAT = '%Y-%m-%d' # 日期格式
#wr Bootstrap样式颜色类型前端样式用
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
PAGINATE_BY = 10
# http cache timeout
#wr 分页配置
PAGINATE_BY = 10 # 每页显示10条数据
#wr HTTP缓存超时时间2592000 = 30天
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
#wr 缓存配置
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存
'TIMEOUT': 10800, # 缓存超时时间10800 = 3小时
'LOCATION': 'unique-snowflake', # 缓存位置标识(唯一即可)
}
}
# 使用redis作为缓存
#wr 若存在Redis环境变量则使用Redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
'BACKEND': 'django.core.cache.backends.redis.RedisCache', # Redis缓存引擎
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', # Redis连接地址
}
}
#wr 站点框架配置(用于多站点管理)
SITE_ID = 1
#wr 百度链接提交API地址用于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'
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
#wr 邮件配置(用于发送通知、验证码等)
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # SMTP邮件后端
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否使用TLS加密
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否使用SSL加密
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # 邮件服务器地址
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) # 邮件服务器端口
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') # 邮箱用户名
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') # 邮箱密码/授权码
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER # 默认发件人
SERVER_EMAIL = EMAIL_HOST_USER # 服务器发件人(用于错误报告)
#wr 管理员邮箱(用于接收系统错误报告)
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
#wr 微信管理员密码二次MD5加密
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
LOG_PATH = os.path.join(BASE_DIR, 'logs')
#wr 日志配置
LOG_PATH = os.path.join(BASE_DIR, 'logs') # 日志文件存储目录
#wr 若日志目录不存在则创建
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'root': {
'level': 'INFO',
'handlers': ['console', 'log_file'],
'version': 1, # 日志配置版本
'disable_existing_loggers': False, # 不禁用已存在的日志器
'root': { # 根日志器
'level': 'INFO', # 日志级别INFO及以上
'handlers': ['console', 'log_file'], # 使用的处理器
},
'formatters': {
'verbose': {
'formatters': { # 日志格式
'verbose': { # 详细格式
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'filters': { # 日志过滤器
'require_debug_false': { # 仅当DEBUG=False时生效
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'require_debug_true': { # 仅当DEBUG=True时生效
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5,
'encoding': 'utf-8'
'handlers': { # 日志处理器
'log_file': { # 文件处理器
'level': 'INFO', # 处理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'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
'console': { # 控制台处理器
'level': 'DEBUG', # 处理DEBUG及以上级别
'filters': ['require_debug_true'], # 仅调试模式生效
'class': 'logging.StreamHandler', # 流处理器(输出到控制台)
'formatter': 'verbose' # 使用详细格式
},
'null': {
'null': { # 空处理器(不处理日志)
'class': 'logging.NullHandler',
},
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
'mail_admins': { # 邮件通知处理器
'level': 'ERROR', # 仅处理ERROR及以上级别
'filters': ['require_debug_false'], # 仅生产环境生效
'class': 'django.utils.log.AdminEmailHandler' # 发送邮件给管理员
}
},
'loggers': {
'djangoblog': {
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
'loggers': { # 日志器
'djangoblog': { # 项目主日志器
'handlers': ['log_file', 'console'], # 使用文件和控制台处理器
'level': 'INFO', # 日志级别
'propagate': True, # 是否向上传播日志
}
}
}
#wr 静态文件压缩配置使用django-compressor
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', # 从文件系统查找静态文件
'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 从应用目录查找静态文件
'compressor.finders.CompressorFinder', # 压缩器查找器
)
COMPRESS_ENABLED = True
# 根据环境变量决定是否启用离线压缩
COMPRESS_ENABLED = True # 启用压缩
#wr 根据环境变量决定是否启用离线压缩(预压缩静态文件)
COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
# 压缩输出目录
#wr 压缩文件输出目录
COMPRESS_OUTPUT_DIR = 'compressed'
# 压缩文件名模板 - 包含哈希值用于缓存破坏
COMPRESS_CSS_HASHING_METHOD = 'mtime'
#wr 压缩文件名包含哈希值(用于缓存失效)
COMPRESS_CSS_HASHING_METHOD = 'mtime' # 基于修改时间生成哈希
COMPRESS_JS_HASHING_METHOD = 'mtime'
# 高级CSS压缩过滤器
#wr CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
# 创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# CSS压缩器 - 高压缩等级
'compressor.filters.cssmin.CSSCompressorFilter',
'compressor.filters.css_default.CssAbsoluteFilter', # 转换为绝对URL
'compressor.filters.cssmin.CSSCompressorFilter', # CSS压缩
]
# 高级JS压缩过滤器
#wr JS压缩过滤器
COMPRESS_JS_FILTERS = [
# JS压缩器 - 高压缩等级
'compressor.filters.jsmin.SlimItFilter',
'compressor.filters.jsmin.SlimItFilter', # JS压缩
]
# 压缩缓存配置
COMPRESS_CACHE_BACKEND = 'default'
COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
#wr 压缩缓存配置
COMPRESS_CACHE_BACKEND = 'default' # 使用默认缓存
COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey' # 缓存键生成函数
# 预压缩配置
#wr 预编译配置处理Sass/SCSS
COMPRESS_PRECOMPILERS = (
# 支持SCSS/SASS
('text/x-scss', 'django_libsass.SassCompiler'),
('text/x-sass', 'django_libsass.SassCompiler'),
('text/x-scss', 'django_libsass.SassCompiler'), # 编译SCSS
('text/x-sass', 'django_libsass.SassCompiler'), # 编译Sass
)
# 压缩性能优化
#wr 压缩性能优化
COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒)
COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟
COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时30天
# 压缩等级配置
COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor'
COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor'
#wr 压缩器配置
COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor' # CSS压缩器
COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor' # JS压缩器
# 静态文件缓存配置
#wr 静态文件缓存配置(文件名包含哈希,用于缓存破坏)
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# 浏览器缓存配置(通过中间件或服务器配置
#wr 压缩文件URL和根目录与静态文件一致
COMPRESS_URL = STATIC_URL
COMPRESS_ROOT = STATIC_ROOT
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
#wr 媒体文件配置(用户上传文件)
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') # 上传文件存储目录
MEDIA_URL = '/media/' # 媒体文件URL前缀
#wr X-Frame-Options配置允许同源页面嵌入iframe如Markdown编辑器预览
X_FRAME_OPTIONS = 'SAMEORIGIN'
#wr 默认自增字段类型Django 3.2+新增)
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # 使用BigInt类型自增ID
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#wr Elasticsearch配置若存在环境变量则启用
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch地址
},
}
#wr 替换Haystack引擎为Elasticsearch
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer',
'image_lazy_loading',
'article_recommendation',
]
#wr 插件系统配置
PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ # 激活的插件列表
'article_copyright', # 文章版权信息插件
'reading_time', # 阅读时间估算插件
'external_links', # 外部链接处理插件
'view_count', # 浏览量统计插件
'seo_optimizer', # SEO优化插件
'image_lazy_loading', # 图片懒加载插件
'article_recommendation', # 文章推荐插件
]

@ -1,59 +1,98 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
#wr 导入Django站点地图核心类及相关工具
from django.contrib.sitemaps import Sitemap # 站点地图基类,所有自定义站点地图需继承此类
from django.urls import reverse # URL反向解析工具用于生成页面URL
#wr 导入博客相关模型(站点地图需包含这些模型对应的页面)
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
"""
wr静态页面站点地图用于收录网站中固定不变的静态页面如首页
站点地图Sitemap用于告诉搜索引擎网站的页面结构帮助爬虫高效索引
"""
priority = 0.5
changefreq = 'daily'
#wr 页面更新频率可选值always, hourly, daily, weekly, monthly, yearly, never
changefreq = 'daily' #wr 静态页面假设每日更新
def items(self):
"""
wr返回需要收录的静态页面视图名称列表
这里仅包含博客首页视图名称为'blog:index'对应urls.py中的命名空间+名称
"""
return ['blog:index', ]
def location(self, item):
return reverse(item)
"""
wr生成每个静态页面的URL
:param item: items()返回的视图名称'blog:index'
:return: 页面的绝对URL
"""
return reverse(item) # 通过reverse反向解析视图名称为URL
class ArticleSiteMap(Sitemap):
changefreq = "monthly"
priority = "0.6"
"""wr文章页面站点地图用于收录所有已发布的博客文章页面"""
changefreq = "monthly" #wr 文章页面更新频率设为每月(假设文章发布后较少修改)
priority = "0.6" #wr 文章页面优先级设为0.6(高于静态页面,低于核心页面)
def items(self):
"""wr返回需要收录的文章对象列表仅包含状态为"已发布"status='p')的文章"""
return Article.objects.filter(status='p')
def lastmod(self, obj):
"""
wr返回文章的最后修改时间供搜索引擎判断页面是否更新
:param obj: items()返回的Article实例
:return: 文章最后修改时间
"""
return obj.last_modify_time
class CategorySiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.6"
"""wr分类页面站点地图用于收录所有文章分类页面"""
changefreq = "Weekly" #wr 分类页面更新频率设为每周(分类信息较少变动)
priority = "0.6" #wr 分类页面优先级与文章页面相同
def items(self):
"""wr返回所有分类对象列表所有分类页面都需要被收录"""
return Category.objects.all()
def lastmod(self, obj):
"""wr返回分类的最后修改时间"""
return obj.last_modify_time
class TagSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
"""wr标签页面站点地图用于收录所有文章标签页面"""
changefreq = "Weekly" #wr 标签页面更新频率设为每周
priority = "0.3" #wr 标签页面优先级较低0.3),因为重要性低于文章和分类
def items(self):
"""wr返回所有标签对象列表所有标签页面都需要被收录"""
return Tag.objects.all()
def lastmod(self, obj):
"""wr返回标签的最后修改时间"""
return obj.last_modify_time
class UserSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
"""wr用户页面站点地图用于收录所有发表过文章的作者页面"""
changefreq = "Weekly" #v 用户页面更新频率设为每周
priority = "0.3" #wr 用户页面优先级较低
def items(self):
"""
wr返回所有发表过文章的作者列表去重处理
逻辑通过map提取所有已发布文章的作者再用set去重最后转为列表
"""
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
return obj.date_joined
"""
wr返回用户相关的最后更新时间这里用用户注册时间代替也可改为用户最后发表文章时间
:param obj: items()返回的用户实例
:return: 用户注册时间
"""
return obj.date_joined

@ -1,21 +1,41 @@
import logging
import requests
from django.conf import settings
import logging # 导入日志模块,用于记录通知过程中的信息和错误
import requests # 导入requests库用于发送HTTP请求与搜索引擎API交互
from django.conf import settings # 导入Django配置用于获取百度推送API地址等配置项
#wr 初始化日志记录器(指定记录器名称为当前模块)
logger = logging.getLogger(__name__)
class SpiderNotify():
"""
wr搜索引擎通知工具类用于向搜索引擎如百度推送网站新内容的URL
帮助搜索引擎快速发现并收录新页面提升SEO效率
"""
@staticmethod
def baidu_notify(urls):
"""
wr向百度搜索引擎推送URL使用百度链接提交API
百度会根据推送的URL优先抓取和收录页面
:param urls: 需要推送的URL列表如文章详情页链接
"""
try:
#wr 将URL列表转换为百度API要求的格式每行一个URL用换行符拼接
data = '\n'.join(urls)
#wr 发送POST请求到百度推送APIAPI地址从Django配置中获取
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
#wr 记录API返回结果便于监控推送是否成功
logger.info(result.text)
except Exception as e:
#wr 记录推送过程中的异常如网络错误、API地址错误等
logger.error(e)
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
"""
wr通用通知方法封装百度推送逻辑便于后续扩展其他搜索引擎
目前仅实现百度推送可根据需求添加谷歌必应等其他引擎的推送逻辑
:param url: 需要推送的单个URL或URL列表根据实际调用场景调整
"""
#wr 调用百度推送方法
SpiderNotify.baidu_notify(url)

@ -1,32 +1,42 @@
from django.test import TestCase
from djangoblog.utils import *
class DjangoBlogTest(TestCase):
def setUp(self):
pass
def test_utils(self):
md5 = get_sha256('test')
self.assertIsNotNone(md5)
c = CommonMarkdown.get_markdown('''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''')
self.assertIsNotNone(c)
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)
def setUp(self):
"""
wr测试前置初始化方法
会在每个测试方法以test_开头的方法执行前自动调用
通常用于创建测试数据初始化测试环境等
本案例暂无需初始化操作故为空实现
"""
pass
def test_utils(self):
"""
wr核心测试方法验证工具函数的基本功能
测试内容包括哈希计算Markdown转换字典转URL参数等工具函数
"""
# wr 测试SHA256哈希函数对字符串"test"进行哈希计算
md5 = get_sha256('test')
# wr 断言哈希结果不为None验证函数能正常生成哈希值无异常
self.assertIsNotNone(md5)
# wr 测试Markdown转换工具处理包含多级语法的Markdown文本
# wr 输入内容包括一级标题、Python代码块、两个不同链接
c = CommonMarkdown.get_markdown('''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''')
self.assertIsNotNone(c)
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)

@ -1,78 +1,113 @@
"""djangoblog URL Configuration
Django博客项目的URL配置文件
The `urlpatterns` list routes URLs to views. For more information please see:
`urlpatterns`列表用于将URL路由到对应的视图更多信息请参考
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
函数视图示例
1. Add an import: from my_app import views
1. 导入视图from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
2. 添加URL到urlpatternsurl(r'^$', views.home, name='home')
Class-based views
类视图示例
1. Add an import: from other_app.views import Home
1. 导入类视图from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
2. 添加URL到urlpatternsurl(r'^$', Home.as_view(), name='home')
Including another URLconf
包含其他URL配置示例
1. Import the include() function: from django.conf.urls import url, include
1. 导入include()函数from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
2. 添加URL到urlpatternsurl(r'^blog/', include('blog.urls'))
"""
# wr导入Django配置
from django.conf import settings
#wr 导入国际化URL工具用于生成带语言前缀的URL
from django.conf.urls.i18n import i18n_patterns
#wr 导入静态文件URL配置工具
from django.conf.urls.static import static
# wr导入站点地图视图
from django.contrib.sitemaps.views import sitemap
# wr导入URL路径配置工具
from django.urls import path, include
from django.urls import re_path
from django.urls import re_path # wr兼容正则表达式的URL配置
#wr 导入Haystack搜索视图工厂
from haystack.views import search_view_factory
#wr 导入JSON响应工具
from django.http import JsonResponse
import time
import time #wr 时间模块,用于健康检查的时间戳
from blog.views import EsSearchView
#wr 导入自定义管理员站点
from djangoblog.admin_site import admin_site
#wr 导入ElasticSearch搜索表单
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
#wr 导入RSS订阅源
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
#wr 导入站点地图配置类
from djangoblog.sitemap import (
ArticleSiteMap, CategorySiteMap,
StaticViewSitemap, TagSiteMap, UserSiteMap
)
#wr 站点地图配置:定义不同类型页面的站点地图
sitemaps = {
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
'blog': ArticleSiteMap, #wr文章页面站点地图
'Category': CategorySiteMap, #wr 分类页面站点地图
'Tag': TagSiteMap, #wr 标签页面站点地图
'User': UserSiteMap, #wr 用户页面站点地图
'static': StaticViewSitemap # wr静态页面如首页站点地图
}
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# wr错误页面处理配置指定对应错误码的处理视图
handler404 = 'blog.views.page_not_found_view' # wr404页面不存在
handler500 = 'blog.views.server_error_view' # wr500服务器内部错误
handle403 = 'blog.views.permission_denied_view' # wr403权限拒绝
def health_check(request):
"""
健康检查接口
简单返回服务健康状态
wr健康检查接口
用于监控服务是否正常运行如容器化部署中的存活探针
"""
return JsonResponse({
'status': 'healthy',
'timestamp': time.time()
'status': 'healthy', #wr 健康状态标识
'timestamp': time.time() # wr当前时间戳用于验证响应时效性
})
# wr基础URL配置不依赖语言前缀的URL
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')),
path('health/', health_check, name='health_check'),
path('i18n/', include('django.conf.urls.i18n')), # wr国际化配置入口语言切换等
path('health/', health_check, name='health_check'), # 健康检查接口
]
# wr国际化URL配置带语言前缀的URL如/en/blog/、/zh/blog/
urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls),
re_path(r'', include('blog.urls', namespace='blog')),
re_path(r'mdeditor/', include('mdeditor.urls')),
re_path(r'', include('comments.urls', namespace='comment')),
re_path(r'', include('accounts.urls', namespace='account')),
re_path(r'', include('oauth.urls', namespace='oauth')),
re_path(r'^admin/', admin_site.urls), # wr自定义管理员后台URL
re_path(r'', include('blog.urls', namespace='blog')), # wr博客主应用URL命名空间blog
re_path(r'mdeditor/', include('mdeditor.urls')), #wr Markdown编辑器URL
re_path(r'', include('comments.urls', namespace='comment')), # wr评论应用URL命名空间comment
re_path(r'', include('accounts.urls', namespace='account')), #wr 用户账户应用URL命名空间account
re_path(r'', include('oauth.urls', namespace='oauth')), #wr 第三方登录应用URL命名空间oauth
# wr站点地图XML文件URL搜索引擎会抓取此文件
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
re_path(r'^feed/$', DjangoBlogFeed()), # wrRSS订阅源URL/feed/
re_path(r'^rss/$', DjangoBlogFeed()), #wr RSS订阅源URL/rss/与feed等价
# wr搜索功能URL使用自定义的ElasticSearch搜索视图和表单
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
name='search'),
re_path(r'', include('servermanager.urls', namespace='servermanager')),
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
re_path(r'', include('servermanager.urls', namespace='servermanager')), #wr 服务器管理应用URL
re_path(r'', include('owntracks.urls', namespace='owntracks')), # wr位置追踪应用URL
prefix_default_language=False #wr 不使用默认语言前缀(如默认语言为中文时,不强制添加/zh/
) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) #wr 静态文件URL配置
#wr 开发环境下的媒体文件URL配置生产环境通常由Web服务器处理
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
document_root=settings.MEDIA_ROOT)

@ -1,61 +1,87 @@
#!/usr/bin/env python
# encoding: utf-8
# 指定脚本解释器为Python编码格式为UTF-8
import logging
import os
import random
import string
import uuid
from hashlib import sha256
import logging #wr 日志模块,用于记录系统运行信息
import os #wr 操作系统交互模块,用于文件路径、目录操作
import random #wr 随机数模块,用于生成随机数据
import string #wr 字符串模块,包含字符串常量(如数字、字母等)
import uuid #wr 用于生成唯一标识符
from hashlib import sha256 #wr 哈希算法模块用于计算SHA-256哈希值
import bleach
import markdown
import requests
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.templatetags.static import static
import bleach #wr HTML清理库用于过滤不安全的HTML标签和属性防止XSS攻击
import markdown #wr Markdown转换库用于将Markdown文本转为HTML
import requests #wr HTTP请求库用于发送网络请求如下载图片
from django.conf import settings #wr Django配置模块用于获取项目设置
from django.contrib.sites.models import Site #wr Django站点模型用于管理网站域名等信息
from django.core.cache import cache #wr Django缓存模块用于缓存数据提升性能
from django.templatetags.static import static #wr Django静态文件工具用于生成静态文件URL
#wr 初始化日志记录器(指定记录器名称为当前模块)
logger = logging.getLogger(__name__)
def get_max_articleid_commentid():
from blog.models import Article
from comments.models import Comment
"""
wr获取文章和评论的最大ID
用于系统初始化或数据校验时获取当前最大的文章ID和评论ID
:return: 元组 (最大文章ID, 最大评论ID)
"""
from blog.models import Article # 导入文章模型
from comments.models import Comment # 导入评论模型
#wr 返回最新文章的ID和最新评论的ID假设模型有pk字段作为主键
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
"""
wr计算字符串的SHA-256哈希值
用于密码加密数据校验等场景哈希值不可逆确保数据安全
:param str: 待哈希的字符串
:return: 哈希后的十六进制字符串
"""
#wr 创建SHA-256哈希对象对字符串进行UTF-8编码后计算哈希
m = sha256(str.encode('utf-8'))
return m.hexdigest()
return m.hexdigest() #wr 返回十六进制格式的哈希结果
def cache_decorator(expiration=3 * 60):
"""
wr缓存装饰器用于缓存函数返回结果减少重复计算或数据库查询
:param expiration: 缓存过期时间默认3分钟
:return: 装饰器函数
"""
def wrapper(func):
def news(*args, **kwargs):
try:
#wr 尝试从视图对象中获取缓存键适用于Django视图函数
view = args[0]
key = view.get_cache_key()
except:
#wr 若无法从视图获取,则基于函数、参数生成唯一缓存键
key = None
if not key:
#wr 将函数和参数转换为字符串,确保唯一性
unique_str = repr((func, args, kwargs))
#wr 计算字符串的SHA-256哈希作为缓存键避免键过长
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
# wr尝试从缓存中获取数据
value = cache.get(key)
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
#wr 缓存命中:返回缓存值(处理默认空值标记)
if str(value) == '__default_cache_value__':
return None
else:
return value
else:
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
#wr 缓存未命中:执行原函数获取结果
logger.debug(f'cache_decorator set cache:{func.__name__} key:{key}')
value = func(*args, **kwargs)
# wr根据结果设置缓存空结果用特殊标记避免缓存穿透
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
@ -69,38 +95,60 @@ def cache_decorator(expiration=3 * 60):
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
wr刷新指定视图的缓存
用于在数据更新后主动清除对应视图的缓存确保用户获取最新数据
:param path: 视图对应的URL路径'/article/1/'
:param servername: 服务器域名'example.com'
:param serverport: 服务器端口'80'
:param key_prefix: 缓存键前缀可选
:return: 布尔值True表示缓存清除成功False表示未找到缓存
'''
from django.http import HttpRequest
from django.utils.cache import get_cache_key
from django.http import HttpRequest # wr导入HTTP请求类
from django.utils.cache import get_cache_key #wr 导入获取缓存键的工具
# wr构建模拟请求对象用于生成缓存键
request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path
# wr获取该请求对应的缓存键
key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
logger.info(f'expire_view_cache:get key:{path}')
# wr若缓存存在则删除
if cache.get(key):
cache.delete(key)
return True
return False
@cache_decorator()
@cache_decorator() # wr应用缓存装饰器默认缓存3分钟
def get_current_site():
site = Site.objects.get_current()
"""
wr 用于生成绝对URL网站标题等场景缓存减轻数据库压力
:return: Site模型实例
"""
site = Site.objects.get_current() # 获取当前站点Django默认功能
return site
class CommonMarkdown:
"""
wrMarkdown处理工具类提供Markdown文本到HTML的转换功能支持代码高亮和目录生成
"""
@staticmethod
def _convert_markdown(value):
"""
wr 内部方法执行Markdown转换
:param value: Markdown格式的文本
:return: 元组 (转换后的HTML内容, 目录HTML)
"""
# wr初始化Markdown转换器启用必要扩展
# - extra: 支持表格、脚注等扩展语法
# - codehilite: 代码高亮
# - toc: 生成目录
# - tables: 表格支持extra已包含这里冗余确保兼容性
md = markdown.Markdown(
extensions=[
'extra',
@ -109,23 +157,41 @@ class CommonMarkdown:
'tables',
]
)
body = md.convert(value)
toc = md.toc
body = md.convert(value) # 转换文本为HTML
toc = md.toc # 获取生成的目录HTML
return body, toc
@staticmethod
def get_markdown_with_toc(value):
"""
wr转换Markdown文本为HTML并返回内容和目录
:param value: Markdown文本
:return: 元组 (HTML内容, 目录HTML)
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@staticmethod
def get_markdown(value):
"""
wr转换Markdown文本为HTML仅返回内容忽略目录
:param value: Markdown文本
:return: HTML内容字符串
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
"""
wr 发送邮件通过Django信号机制解耦邮件发送逻辑
用于用户注册验证评论通知等场景
:param emailto: 收件人邮箱
:param title: 邮件标题
:param content: 邮件内容
"""
from djangoblog.blog_signals import send_email_signal # 导入邮件发送信号
#wr 发送信号实际发送逻辑由信号接收者实现如SMTP发送
send_email_signal.send(
send_email.__class__,
emailto=emailto,
@ -134,99 +200,154 @@ def send_email(emailto, title, content):
def generate_code() -> str:
"""生成随机数验证码"""
"""
wr生成6位数字验证码
用于用户登录注册等场景的身份验证
:return: 6位数字字符串
"""
#wr 从数字字符集中随机选择6个字符并拼接
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
from urllib.parse import quote
"""
wr将字典转换为URL查询参数字符串{'a':1, 'b':2} 'a=1&b=2'
用于构建带参数的URL
:param dict: 键值对字典
:return: URL查询参数字符串
"""
from urllib.parse import quote #wr 导入URL编码工具
#wr 对键和值进行URL编码处理特殊字符然后拼接为key=value&key=value格式
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
return url
def get_blog_setting():
"""
wr获取博客系统设置如网站名称描述等带缓存机制
用于网站全局配置的统一管理
:return: BlogSettings模型实例
"""
#wr 尝试从缓存获取
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
from blog.models import BlogSettings #wr 导入博客设置模型
#wr 若数据库中无设置记录,创建默认设置
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog'
setting.site_description = '基于Django的博客系统'
setting.site_seo_description = '基于Django的博客系统'
setting.site_keywords = 'Django,Python'
setting.article_sub_length = 300
setting.sidebar_article_count = 10
setting.sidebar_comment_count = 5
setting.show_google_adsense = False
setting.open_site_comment = True
setting.analytics_code = ''
setting.beian_code = ''
setting.show_gongan_code = False
setting.comment_need_review = False
setting.save()
setting.site_name = 'djangoblog' #wr 网站名称
setting.site_description = '基于Django的博客系统' #wr 网站描述
setting.site_seo_description = '基于Django的博客系统' #wr SEO描述
setting.site_keywords = 'Django,Python' #wr 网站关键词
setting.article_sub_length = 300 #wr 文章摘要长度
setting.sidebar_article_count = 10 #wr 侧边栏文章数量
setting.sidebar_comment_count = 5 #wr 侧边栏评论数量
setting.show_google_adsense = False #wr 是否显示谷歌广告
setting.open_site_comment = True #wr 是否开启评论
setting.analytics_code = '' #wr 统计代码如Google Analytics
setting.beian_code = '' #wr 备案号
setting.show_gongan_code = False #wr 是否显示公安备案号
setting.comment_need_review = False #wr 评论是否需要审核
setting.save() #wr 保存默认设置
#wr 从数据库获取设置
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value)
cache.set('get_blog_setting', value) #wr 缓存设置
return value
def save_user_avatar(url):
'''
保存用户头像
:param url:头像url
:return: 本地路径
wr下载并保存用户头像到本地静态文件目录
用于用户上传头像或第三方登录时获取头像
:param url: 头像图片的URL
:return: 本地头像的静态文件URL异常时返回默认头像
'''
logger.info(url)
logger.info(url) #wr 记录头像URL
try:
#wr 定义头像保存目录项目静态文件目录下的avatar文件夹
basedir = os.path.join(settings.STATICFILES, 'avatar')
#wr 发送GET请求下载图片超时2秒
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
if rsp.status_code == 200: # 下载成功
#wr 若目录不存在则创建
if not os.path.exists(basedir):
os.makedirs(basedir)
#wr 支持的图片扩展名
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
#wr 判断URL是否指向图片文件
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
#wr 提取扩展名(非图片文件默认用.jpg
ext = os.path.splitext(url)[1] if isimage else '.jpg'
#wr 生成唯一文件名UUID避免重复
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
logger.info(f'保存用户头像:{basedir}{save_filename}')
#wr 写入文件
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
#wr 返回静态文件URL如/static/avatar/xxx.jpg
return static('avatar/' + save_filename)
except Exception as e:
#wr 异常处理(如网络错误、文件写入失败等)
logger.error(e)
#wr 返回默认头像URL
return static('blog/img/avatar.png')
def delete_sidebar_cache():
from blog.models import LinkShowType
"""
wr删除侧边栏相关缓存
用于侧边栏数据如热门文章最新评论更新后刷新缓存
"""
from blog.models import LinkShowType # 导入链接展示类型模型
#wr 生成所有侧边栏缓存键基于LinkShowType的取值
keys = ["sidebar" + x for x in LinkShowType.values]
#wr 逐个删除缓存
for k in keys:
logger.info('delete sidebar key:' + k)
logger.info(f'delete sidebar key:{k}')
cache.delete(k)
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
"""
wr删除Django模板片段缓存
用于模板中通过{% cache %}标签缓存的内容更新后刷新
:param prefix: 缓存前缀对应{% cache %}的第一个参数
:param keys: 缓存键的动态部分对应{% cache %}的后续参数
"""
from django.core.cache.utils import make_template_fragment_key #wr 生成模板缓存键的工具
#wr 生成模板片段缓存键
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
cache.delete(key) #wr 删除缓存
def get_resource_url():
"""
wr获取静态资源的基础URL
用于统一管理静态文件路径如CSSJS图片等
:return: 静态资源基础URL字符串
"""
if settings.STATIC_URL:
#wr 若项目设置中定义了STATIC_URL直接使用
return settings.STATIC_URL
else:
#wr 否则使用当前站点域名拼接静态目录路径
site = get_current_site()
return 'http://' + site.domain + '/static/'
#wr HTML清理配置限制允许的HTML标签防止XSS攻击
#wr 只允许必要的标签,避免<script>、<iframe>等危险标签
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div']
# 安全的class值白名单 - 只允许代码高亮相关的class
# 允许的class属性值白名单仅包含代码高亮相关的class如codehilite、hll等
# 防止恶意注入样式类影响页面布局或触发XSS
ALLOWED_CLASSES = [
'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs',
'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt',
@ -235,38 +356,53 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
]
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
"""
wr自定义class属性过滤器仅保留白名单中的class值
用于bleach清理HTML时过滤不安全的class属性
:param tag: HTML标签名'span'
:param name: 属性名这里固定为'class'
:param value: 属性值'codehilite myclass'
:return: 过滤后的class值仅包含白名单中的类或False表示移除该属性
"""
if name == 'class':
# 只允许预定义的安全class值
#wr 拆分class值保留在白名单中的类
allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
#wr 拼接允许的类若为空则返回False移除class属性
return ' '.join(allowed_classes) if allowed_classes else False
return value
return value # 非class属性直接返回
# 安全的属性白名单
#wr 允许的HTML属性白名单为每个标签指定允许的属性
#wr 如'a'标签允许'href'和'title',防止'onclick'等危险属性
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
'span': class_filter, #wr 使用自定义过滤器处理span的class属性
'div': class_filter, #wr 使用自定义过滤器处理div的class属性
'pre': class_filter, #wr 使用自定义过滤器处理pre的class属性
'code': class_filter #wr 使用自定义过滤器处理code的class属性
}
# 安全的协议白名单 - 防止javascript:等危险协议
#wr 允许的URL协议白名单限制链接只能使用安全协议
#wr 防止javascript:、data:等危险协议触发XSS
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
wr安全清理HTML内容防止XSS攻击
使用bleach库按白名单过滤不安全的标签属性和协议
:param html: 原始HTML字符串
:return: 清理后的安全HTML字符串
"""
return bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
)
html,
tags=ALLOWED_TAGS, #wr 允许的标签
attributes=ALLOWED_ATTRIBUTES, #wr 允许的属性(含过滤器)
protocols=ALLOWED_PROTOCOLS, #wr 允许的URL协议
strip=True, #wr 移除不允许的标签(而非转义)
strip_comments=True #wr 移除HTML注释(防止注释中的恶意代码)
)

File diff suppressed because it is too large Load Diff

@ -1,16 +1,26 @@
"""
WSGI config for djangoblog project.
WSGI配置文件用于djangoblog项目
It exposes the WSGI callable as a module-level variable named ``application``.
该文件将WSGI可调用对象暴露为模块级别的变量`application`供Web服务器如NginxGunicorn调用
For more information on this file, see
关于此文件的更多信息请参考
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
"""
#wr 导入os模块用于处理操作系统环境变量
import os
#wr 从Django核心WSGI模块导入获取WSGI应用的函数
#wr get_wsgi_application()会返回Django项目的WSGI兼容应用实例
from django.core.wsgi import get_wsgi_application
#wr 设置Django使用的配置模块环境变量
#wr "DJANGO_SETTINGS_MODULE"是Django约定的环境变量用于指定项目配置文件路径
#wr 这里设置为"djangoblog.settings"即项目根目录下的settings.py文件
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
application = get_wsgi_application()
#wr 获取WSGI应用实例并赋值给application变量
#wr Web服务器如Gunicorn会通过这个变量与Django应用进行交互处理HTTP请求
application = get_wsgi_application()

@ -1,4 +1,5 @@
# Create your models here.
#zwz: Create your models here.
#zwz: 导入Django配置、异常、数据库模型等核心模块
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
@ -6,33 +7,48 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
#zwz: OAuth用户表存储第三方登录用户的关联信息
class OAuthUser(models.Model):
#zwz: 关联Django系统用户可为空未绑定本地用户时用
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
verbose_name=_('author'), # 字段显示名称(多语言支持)
blank=True,
null=True,
on_delete=models.CASCADE)
on_delete=models.CASCADE) # 关联用户删除时,此记录也删除
#zwz: 第三方平台的用户唯一标识如GitHub的ID
openid = models.CharField(max_length=50)
#zwz: 第三方平台的用户昵称
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
#zwz: 第三方平台的授权令牌(可选)
token = models.CharField(max_length=150, null=True, blank=True)
#zwz: 第三方平台的用户头像链接
picture = models.CharField(max_length=350, blank=True, null=True)
#zwz: 第三方平台类型如github/weibo
type = models.CharField(blank=False, null=False, max_length=50)
#zwz: 第三方平台的用户邮箱(可选)
email = models.CharField(max_length=50, null=True, blank=True)
#zwz: 第三方平台返回的额外元数据存JSON等
metadata = models.TextField(null=True, blank=True)
#zwz: 记录创建时间(默认当前时间)
creation_time = models.DateTimeField(_('creation time'), default=now)
#zwz: 记录最后修改时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
#zwz: 打印对象时显示昵称
def __str__(self):
return self.nickname
#zwz: 模型元信息配置
class Meta:
verbose_name = _('oauth user')
verbose_name_plural = verbose_name
ordering = ['-creation_time']
verbose_name = _('oauth user') # 模型显示名称
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 默认按创建时间倒序排列
#zwz: OAuth平台配置表存储第三方登录的AppKey、密钥等信息
class OAuthConfig(models.Model):
#zwz: 支持的第三方平台类型(固定选项)
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
@ -40,28 +56,39 @@ class OAuthConfig(models.Model):
('facebook', 'FaceBook'),
('qq', 'QQ'),
)
#zwz: 平台类型关联上面的TYPE选项
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
#zwz: 第三方平台的AppKey
appkey = models.CharField(max_length=200, verbose_name='AppKey')
#zwz: 第三方平台的AppSecret
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
#zwz: 第三方平台的授权回调地址
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
#zwz: 是否启用该平台的登录功能
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
#zwz: 配置创建时间
creation_time = models.DateTimeField(_('creation time'), default=now)
#zwz: 配置最后修改时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
#zwz: 数据验证:同一平台只能有一条配置
def clean(self):
#zwz: 排除当前记录后,检查是否已有同类型配置
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
#zwz: 打印对象时显示平台类型
def __str__(self):
return self.type
#zwz: 模型元信息配置
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
verbose_name = 'oauth配置' # 模型显示名称(中文)
verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 默认按创建时间倒序排列

@ -1,25 +1,27 @@
from django.urls import path
from . import views
app_name = "oauth"
app_name = "oauth" # zwz: 命名空间,方便模板和视图中反向解析
urlpatterns = [
path(
r'oauth/authorize',
views.authorize),
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
# zwz: 授权回调处理
path(r'oauth/authorize', views.authorize),
# zwz: 邮箱绑定页面
path(r'oauth/requireemail/<<<<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
# zwz: 邮箱验证链接修复sign参数格式
path(r'oauth/emailconfirm/<<<<int:id>/<<str:sign>.html',
views.emailconfirm,
name='email_confirm'),
# zwz: 绑定成功页面
path(r'oauth/bindsuccess/<<<<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
# zwz: 第三方登录入口
path(r'oauth/oauthlogin', views.oauthlogin, name='oauthlogin')
]

@ -1,57 +1,58 @@
import logging
# Create your views here.
#zwz: Create your views here.
from urllib.parse import urlparse
#zwz: 导入Django核心模块、工具及项目内模块
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.contrib.auth import get_user_model, login
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.http import HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email, get_sha256
from djangoblog.utils import get_current_site, send_email, get_sha256
from oauth.forms import RequireEmailForm
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 初始化日志记录器
#zwz: 处理重定向URL过滤非法地址返回安全的跳转路径
def get_redirecturl(request):
nexturl = request.GET.get('next_url', None)
if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/'
return nexturl
#zwz: 无URL或为登录页时默认跳首页
if not nexturl or nexturl in ('/login/', '/login'):
return '/'
#zwz: 校验URL是否为本站地址防止跨站跳转
p = urlparse(nexturl)
if p.netloc:
site = get_current_site().domain
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
if p.netloc.replace('www.', '') != site.replace('www.', ''):
logger.info('非法url:' + nexturl)
return "/"
return nexturl
#zwz: 第三方登录入口:根据平台类型获取授权链接
def oauthlogin(request):
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
manager = get_manager_by_type(type)
manager = get_manager_by_type(type) # 获取对应平台的授权管理器
if not manager:
return HttpResponseRedirect('/')
nexturl = get_redirecturl(request)
authorizeurl = manager.get_authorization_url(nexturl)
authorizeurl = manager.get_authorization_url(nexturl) # 生成授权链接
return HttpResponseRedirect(authorizeurl)
#zwz: 授权回调处理:获取用户信息并绑定本地账号
def authorize(request):
type = request.GET.get('type', None)
if not type:
@ -60,6 +61,7 @@ def authorize(request):
if not manager:
return HttpResponseRedirect('/')
code = request.GET.get('code', None)
#zwz: 通过授权码获取access_token
try:
rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e:
@ -71,10 +73,13 @@ def authorize(request):
nexturl = get_redirecturl(request)
if not rsp:
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
#zwz: 获取第三方用户信息
user = manager.get_oauth_userinfo()
if user:
#zwz: 补全默认昵称(防止空值)
if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
#zwz: 存在旧记录则更新,否则创建新记录
try:
temp = OAuthUser.objects.get(type=type, openid=user.openid)
temp.picture = user.picture
@ -83,20 +88,23 @@ def authorize(request):
user = temp
except ObjectDoesNotExist:
pass
# facebook的token过长
#zwz: Facebook的token过长这里清空
if type == 'facebook':
user.token = ''
#zwz: 有邮箱则直接绑定本地用户
if user.email:
with transaction.atomic():
with transaction.atomic(): # 事务保证数据一致性
author = None
try:
author = get_user_model().objects.get(id=user.author_id)
except ObjectDoesNotExist:
pass
if not author:
#zwz: 邮箱不存在则创建新用户
result = get_user_model().objects.get_or_create(email=user.email)
author = result[0]
if result[1]:
#zwz: 处理昵称重复
try:
get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
@ -105,34 +113,29 @@ def authorize(request):
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize'
author.save()
#zwz: 关联用户并登录
user.author = author
user.save()
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
oauth_user_login_signal.send(sender=authorize.__class__, id=user.id)
login(request, author)
return HttpResponseRedirect(nexturl)
#zwz: 无邮箱则跳转到邮箱绑定页
else:
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
})
url = reverse('oauth:require_email', kwargs={'oauthid': user.id})
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(nexturl)
#zwz: 邮箱验证:确认邮箱并完成绑定
def emailconfirm(request, id, sign):
if not sign:
return HttpResponseForbidden()
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
#zwz: 校验签名是否合法
if not sign or get_sha256(settings.SECRET_KEY + str(id) + settings.SECRET_KEY).upper() != sign.upper():
return HttpResponseForbidden()
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
#zwz: 关联或创建本地用户
if oauthuser.author:
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
@ -140,114 +143,69 @@ def emailconfirm(request, id, sign):
author = result[0]
if result[1]:
author.source = 'emailconfirm'
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.username = oauthuser.nickname.strip() or "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
oauthuser.author = author
oauthuser.save()
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
#zwz: 发送登录信号并完成登录
oauth_user_login_signal.send(sender=emailconfirm.__class__, id=oauthuser.id)
login(request, author)
#zwz: 发送绑定成功邮件
site = 'http://' + get_current_site().domain
content = _('''
<p>Congratulations, you have successfully bound your email address. You can use
%(oauthuser_type)s to directly log in to this website without a password.</p>
You are welcome to continue to follow this site, the address is
<a href="%(site)s" rel="bookmark">%(site)s</a>
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
%(site)s
''') % {'oauthuser_type': oauthuser.type, 'site': site}
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
url = url + '?type=success'
return HttpResponseRedirect(url)
content = _('''<p>恭喜您成功绑定邮箱,可直接用%(oauthuser_type)s登录本站。</p >
欢迎关注本站<a href=" ">%(site)s</a >''') % {'oauthuser_type': oauthuser.type, 'site': site}
send_email(emailto=[oauthuser.email, ], title=_('绑定成功!'), content=content)
return HttpResponseRedirect(reverse('oauth:bindsuccess', kwargs={'oauthid': id}) + '?type=success')
#zwz: 邮箱绑定表单页:处理无邮箱用户的邮箱填写
class RequireEmailView(FormView):
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.email:
pass
# return HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs)
get_object_or_404(OAuthUser, pk=oauthid)
return super().get(request, *args, **kwargs)
def get_initial(self):
oauthid = self.kwargs['oauthid']
return {
'email': '',
'oauthid': oauthid
}
#zwz: 初始化表单默认值
return {'email': '', 'oauthid': self.kwargs['oauthid']}
def get_context_data(self, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
#zwz: 传递用户头像到模板
oauthuser = get_object_or_404(OAuthUser, pk=self.kwargs['oauthid'])
if oauthuser.picture:
kwargs['picture'] = oauthuser.picture
return super(RequireEmailView, self).get_context_data(**kwargs)
return super().get_context_data(**kwargs)
def form_valid(self, form):
#zwz: 提交邮箱后发送验证邮件
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
site = get_current_site().domain
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('oauth:email_confirm', kwargs={
'id': oauthid,
'sign': sign
})
url = "http://{site}{path}".format(site=site, path=path)
content = _("""
<p>Please click the link below to bind your email</p>
<a href="%(url)s" rel="bookmark">%(url)s</a>
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
<br />
%(url)s
""") % {'url': url}
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
url = url + '?type=email'
return HttpResponseRedirect(url)
#zwz: 生成验证签名
sign = get_sha256(settings.SECRET_KEY + str(oauthuser.id) + settings.SECRET_KEY)
site = get_current_site().domain if not settings.DEBUG else '127.0.0.1:8000'
path = reverse('oauth:email_confirm', kwargs={'id': oauthid, 'sign': sign})
url = f"http://{site}{path}"
#zwz: 发送验证邮件
content = _(f'<p>请点击链接绑定邮箱:<a href="{url}">{url}</a ></p >')
send_email(emailto=[email, ], title=_('绑定邮箱'), content=content)
return HttpResponseRedirect(reverse('oauth:bindsuccess', kwargs={'oauthid': oauthid}) + '?type=email')
#zwz: 绑定结果页:显示成功提示
def bindsuccess(request, oauthid):
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
#zwz: 根据类型返回不同提示内容
if type == 'email':
title = _('Bind your email')
content = _(
'Congratulations, the binding is just one step away. '
'Please log in to your email to check the email to complete the binding. Thank you.')
title = _('绑定邮箱')
content = _('请登录邮箱完成最后一步绑定')
else:
title = _('Binding successful')
content = _(
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type})
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
title = _('绑定成功')
content = _(f"已用{oauthuser.type}绑定账号,可直接登录")
return render(request, 'oauth/bindsuccess.html', {'title': title, 'content': content})

@ -0,0 +1,480 @@
# 导入Django核心模块
from django.conf import settings # 用于获取项目配置(如用户模型)
from django.db import models # 数据库模型基类
from django.utils.timezone import now # 用于获取当前时间
from django.utils.translation import gettext_lazy as _ # 用于国际化翻译(多语言支持)
from blog.models import Article # 从blog应用导入Article模型评论关联的文章
# 创建评论模型继承Django的Model基类所有数据库模型都需继承此类
class Comment(models.Model):
# 评论正文TextField支持长文本max_length=300限制最大长度为300字符
# '正文'是字段的verbose_name在后台管理中显示的名称
body = models.TextField('正文', max_length=300)
# 评论创建时间DateTimeField存储日期时间
# default=now 表示默认值为当前时间(评论提交时自动记录)
# _('creation time') 是国际化翻译标记(可根据语言设置显示不同文字)
creation_time = models.DateTimeField(_('creation time'), default=now)
# 评论最后修改时间:用于记录评论是否被编辑过
# 初始默认值为创建时间,若后续编辑评论,需手动更新此字段
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 评论作者关联Django用户模型外键
# settings.AUTH_USER_MODEL 是项目配置的用户模型通常是Django内置User
# on_delete=models.CASCADE 表示:若用户被删除,其所有评论也会被级联删除
# verbose_name=_('author') 用于后台显示和国际化
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 关联的文章外键关联blog应用的Article模型
# 表示“这条评论属于哪篇文章”
# on_delete=models.CASCADE 表示:若文章被删除,其所有评论也会被级联删除
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 父评论:自关联外键,用于实现“评论回复”功能
# 'self' 表示关联当前模型Comment自身
# null=True, blank=True 表示可以为空(即顶级评论,不是回复)
# 例如用户A评论文章parent_comment为null用户B回复A的评论parent_comment指向A的评论
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 是否启用:布尔值字段,用于控制评论是否显示在前台
# default=False 表示新评论默认不显示需管理员审核后设为True
# blank=False, null=False 强制该字段必须有值(不能空)
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# 元数据配置对模型的补充说明不影响数据结构影响Django处理方式
class Meta:
ordering = ['-id'] # 排序规则按id倒序新评论在前因为id自增
verbose_name = _('comment') # 模型的单数名称(用于后台显示)
verbose_name_plural = verbose_name # 模型的复数名称(保持和单数一致)
get_latest_by = 'id' # 指定获取“最新记录”时按id字段排序
# 定义模型实例的字符串表示(在后台管理和打印对象时显示)
# 这里返回评论正文的前N个字符方便识别不同评论
def __str__(self):
return self.body
# 导入Django核心模块
from django.conf import settings # 引入项目配置(如自定义用户模型)
from django.db import models # 引入Django数据库模型基类
from django.utils.timezone import now # 引入当前时间工具(带时区支持)
from django.utils.translation import gettext_lazy as _ # 引入国际化翻译工具(支持多语言)
from blog.models import Article # 从blog应用导入Article模型评论需关联具体文章
# 定义评论模型继承models.Model所有Django数据库模型必须继承此类
class Comment(models.Model):
# 评论正文TextField支持长文本max_length=300限制最大长度防止恶意刷屏
# '正文'是字段在后台管理界面的显示名称
body = models.TextField('正文', max_length=300)
# 评论创建时间DateTimeField存储日期时间
# default=now 表示默认值为评论提交时的时间(自动记录)
# _('creation time') 用于国际化(如切换语言时显示对应语言的“创建时间”)
creation_time = models.DateTimeField(_('creation time'), default=now)
# 评论最后修改时间:记录评论是否被编辑过
# 初始值为创建时间,若后续编辑评论,需手动更新此字段(可优化为自动更新)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 评论作者:外键关联用户模型(多对一关系)
# settings.AUTH_USER_MODEL 指向项目配置的用户模型默认是Django内置的User
# on_delete=models.CASCADE 表示:若用户账号被删除,其所有评论也会被级联删除
# verbose_name=_('author') 是后台显示名称(支持国际化)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 关联的文章外键关联blog应用的Article模型多对一关系
# 表示“这条评论属于哪篇文章”
# on_delete=models.CASCADE 表示:若文章被删除,其所有评论也会被级联删除
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 父评论:自关联外键(评论可以回复其他评论)
# 'self' 表示关联当前模型Comment自身
# null=True, blank=True 允许为空(即“顶级评论”,不是回复任何评论)
# 例如用户A评论文章parent_comment为null用户B回复Aparent_comment指向A的评论ID
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 是否启用:控制评论是否在前台显示(审核机制)
# default=False 表示新评论默认“未启用”需管理员审核通过后设为True
# blank=False, null=False 强制该字段必须有值(不能为空)
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# 元数据配置定义模型的额外属性不影响数据结构影响Django处理方式
class Meta:
ordering = ['-id'] # 排序规则按id倒序新评论在前因为id是自增的
verbose_name = _('comment') # 模型的单数名称(后台显示用,支持国际化)
verbose_name_plural = verbose_name # 模型的复数名称(保持与单数一致)
get_latest_by = 'id' # 指定“获取最新记录”时按id排序与ordering一致
# 定义模型实例的字符串表示(在后台管理、打印对象时显示)
# 返回评论正文,方便快速识别不同评论
def __str__(self):
return self.body
# 导入Django核心模块
from django.core.exceptions import ValidationError # 用于抛出验证错误(如评论关闭时)
from django.http import HttpResponseRedirect # 用于重定向页面(如评论提交后跳回文章页)
from django.shortcuts import get_object_or_404 # 用于查询对象不存在则返回404
from django.utils.decorators import method_decorator # 用于给类视图方法添加装饰器
from django.views.decorators.csrf import csrf_protect # CSRF保护装饰器防止跨站请求伪造
from django.views.generic.edit import FormView # 基础表单处理视图(简化表单验证逻辑)
# 导入其他应用模型和当前应用的表单、模型
from accounts.models import BlogUser # 从accounts应用导入用户模型评论作者
from blog.models import Article # 从blog应用导入文章模型评论关联的文章
from .forms import CommentForm # 导入评论表单(用于验证用户输入)
from .models import Comment # 导入评论模型(用于创建评论数据)
class CommentPostView(FormView):
"""
评论提交视图:处理用户提交的评论,包含表单验证、评论创建、权限判断等逻辑
继承FormView无需手动编写表单渲染和基础验证代码专注业务逻辑
"""
form_class = CommentForm # 指定使用的表单类CommentForm
template_name = 'blog/article_detail.html' # 表单验证失败时渲染的模板(文章详情页)
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""
重写dispatch方法给视图添加CSRF保护
dispatch是所有请求的入口方法添加@csrf_protect确保POST请求经过CSRF验证
"""
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
"""
处理GET请求当用户直接访问评论提交URL时重定向到文章详情页的评论区
避免用户通过GET方式提交评论评论应通过POST提交
"""
article_id = self.kwargs['article_id'] # 从URL参数中获取文章ID
article = get_object_or_404(Article, pk=article_id) # 查询对应的文章
url = article.get_absolute_url() # 获取文章详情页的URL
return HttpResponseRedirect(url + "#comments") # 重定向到文章页的评论区锚点
def form_invalid(self, form):
"""
表单验证失败时的逻辑(如评论内容为空、长度超限等)
重新渲染文章详情页,并传递错误的表单(显示验证错误信息)
"""
article_id = self.kwargs['article_id'] # 获取文章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
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("该文章评论已关闭.")
# 保存表单数据但不提交到数据库commit=False便于后续补充字段
comment = form.save(False)
comment.article = article # 关联评论到当前文章
# 获取博客全局设置(判断评论是否需要审核)
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review: # 若评论无需审核
comment.is_enable = True # 直接设为“启用”(前台可见)
comment.author = author # 关联评论到当前用户
# 处理回复功能若表单中包含父评论ID则设置为回复
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id']
)
comment.parent_comment = parent_comment # 关联到父评论
# 最终保存评论到数据库commit=True
comment.save(True)
# 评论提交成功后,重定向到文章详情页的该评论位置(锚点定位)
return HttpResponseRedirect(
"%s#div-comment-%d" % (article.get_absolute_url(), comment.pk)
)
# 导入必要的模块和类
from django.core.exceptions import ValidationError # 用于抛出验证错误(如评论关闭时)
from django.http import HttpResponseRedirect # 用于重定向页面(评论提交后跳回文章页)
from django.shortcuts import get_object_or_404 # 查询对象不存在则返回404错误
from django.utils.decorators import method_decorator # 为类视图方法添加装饰器
from django.views.decorators.csrf import csrf_protect # CSRF保护防止跨站请求伪造
from django.views.generic.edit import FormView # 表单处理基类(简化表单验证流程)
# 导入关联模型和表单
from accounts.models import BlogUser # 自定义用户模型(评论作者作者)
from blog.models import Article # 博客文章模型(评论关联的文章)
from .forms import CommentForm # 评论表单(用于验证用户输入)
from .models import Comment # 评论模型(用于创建和保存评论)
class CommentPostView(FormView):
"""
评论提交视图:处理用户评论的提交、验证、保存逻辑
继承FormView复用表单渲染、验证等基础功能专注业务逻辑
"""
form_class = CommentForm # 指定使用的表单类CommentForm
template_name = 'blog/article_detail.html' # 表单验证失败时渲染的模板(文章详情页)
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""
重写dispatch方法为视图添加CSRF保护
dispatch是所有请求的入口确保POST请求经过CSRF验证防止跨站攻击
"""
return super().dispatch(*args, **kwargs) # 调用父类方法,保持原有逻辑
def get(self, request, *args, **kwargs):
"""
处理GET请求当用户直接通过URL访问评论提交地址时
重定向到文章详情页的评论区避免GET方式提交评论评论需通过POST提交
"""
article_id = self.kwargs['article_id'] # 从URL参数中获取文章ID
article = get_object_or_404(Article, pk=article_id) # 查询对应的文章
url = article.get_absolute_url() # 获取文章详情页的URL
return HttpResponseRedirect(f"{url}#comments") # 重定向到评论区锚点
def form_invalid(self, form):
"""
表单验证失败时的处理(如评论内容为空、长度超限等)
重新渲染文章详情页,并传递错误的表单,在页面上显示验证错误
"""
article_id = self.kwargs['article_id'] # 获取文章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
author = BlogUser.objects.get(pk=user.pk) # 从自定义用户模型中查询用户
# 获取URL参数中的文章ID并查询对应的文章
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
# 检查文章是否允许评论若文章评论关闭comment_status='c'或状态为草稿status='c'
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.") # 抛出验证错误
# 保存表单数据但不提交到数据库commit=False先补充其他字段
comment = form.save(commit=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则设置为回复
parent_comment_id = form.cleaned_data.get('parent_comment_id')
if parent_comment_id:
parent_comment = Comment.objects.get(pk=parent_comment_id)
comment.parent_comment = parent_comment # 关联到父评论
# 最终保存评论到数据库
comment.save()
# 评论提交成功后,重定向到文章详情页的该评论位置(通过锚点定位)
return HttpResponseRedirect(
f"{article.get_absolute_url()}#div-comment-{comment.pk}"
)
# 导入Django的URL路径处理模块
from django.urls import path
# 导入当前应用的视图模块views.py
from . import views
# 定义应用命名空间app_name用于在模板中通过命名空间引用URL避免不同应用的URL名称冲突
app_name = "comments"
# 定义URL路由列表每个path对应一个视图
urlpatterns = [
# 评论提交的URL路由
path(
'article/<int:article_id>/postcomment', # URL路径规则
views.CommentPostView.as_view(), # 对应的视图类(转换为可调用的视图函数)
name='postcomment' # 路由名称用于模板中反向解析URL
),
]
# 导入Django测试工具和核心模块
from django.test import Client, RequestFactory, TransactionTestCase # 测试客户端、请求工厂、事务测试基类
from django.urls import reverse # 用于反向解析URL
# 导入关联模型、模板标签和工具函数
from accounts.models import BlogUser # 自定义用户模型
from blog.models import Category, Article # 博客分类、文章模型
from comments.models import Comment # 评论模型
from comments.templatetags.comments_tags import * # 评论相关的模板标签(用于测试模板渲染逻辑)
from djangoblog.utils import get_max_articleid_commentid # 获取最大文章/评论ID的工具函数
# 定义评论测试类继承TransactionTestCase支持事务回滚避免测试数据污染
class CommentsTest(TransactionTestCase):
def setUp(self):
"""
测试前的初始化工作:创建测试客户端、测试用户、博客设置等
所有测试方法执行前会自动调用
"""
self.client = Client() # 创建测试客户端(模拟用户浏览器请求)
self.factory = RequestFactory() # 创建请求工厂(用于构造复杂请求对象)
# 初始化博客全局设置(评论需要审核)
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True # 评论需要审核(默认不显示)
value.save()
# 创建超级用户(用于测试登录状态下的评论提交)
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article):
"""辅助方法:将文章的所有评论设为“启用”(绕过审核,方便测试评论列表显示)"""
comments = article.comment_set.all() # 获取文章的所有评论
for comment in comments:
comment.is_enable = True # 设为启用
comment.save() # 保存修改
def test_validate_comment(self):
"""
核心测试方法:验证评论提交、显示、回复等功能的正确性
涵盖正常评论、带格式的回复、评论列表数量等场景
"""
# 1. 登录测试用户
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 2. 创建测试分类和文章(评论必须关联文章)
category = Category()
category.name = "categoryccc" # 分类名称
category.save()
article = Article()
article.title = "nicetitleccc" # 文章标题
article.body = "nicecontentccc" # 文章内容
article.author = self.user # 关联作者
article.category = category # 关联分类
article.type = 'a' # 文章类型(假设'a'表示普通文章)
article.status = 'p' # 状态(假设'p'表示已发布)
article.save()
# 3. 测试首次提交评论
# 反向解析评论提交URL使用命名空间和文章ID
comment_url = reverse(
'comments:postcomment', kwargs={'article_id': article.id})
# 发送POST请求提交评论内容为'123ffffffffff'
response = self.client.post(comment_url, {'body': '123ffffffffff'})
# 验证提交成功应重定向状态码302
self.assertEqual(response.status_code, 302)
# 验证因评论需要审核is_enable默认False评论列表应为空
article = Article.objects.get(pk=article.pk) # 重新查询文章(刷新数据)
self.assertEqual(len(article.comment_list()), 0) # 假设comment_list()返回启用的评论
# 手动启用所有评论(模拟审核通过)
self.update_article_comment_status(article)
# 验证启用后评论列表数量应为1
self.assertEqual(len(article.comment_list()), 1)
# 4. 测试再次提交评论(验证多条评论的情况)
response = self.client.post(comment_url, {'body': '123ffffffffff'})
self.assertEqual(response.status_code, 302) # 重定向成功
# 启用评论后验证数量为2
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2)
# 5. 测试回复功能(带格式的回复内容)
# 获取第一条评论的ID作为父评论
parent_comment_id = article.comment_list()[0].id
# 提交带格式的回复包含Markdown语法标题、代码块、链接
response = self.client.post(comment_url, {
'body': '''
# Title1
```python
import os
# 导入Django表单基础模块
from django import forms
from django.forms import ModelForm # 导入模型表单基类(可直接关联数据库模型)
# 导入当前应用的评论模型
from .models import Comment
class CommentForm(ModelForm):
"""
评论表单类继承ModelForm自动关联Comment模型简化表单字段定义和验证
用于处理用户提交的评论内容及回复关系
"""
# 自定义字段父评论ID用于实现回复功能
# IntegerField存储父评论的ID整数类型
# widget=forms.HiddenInput隐藏输入框不在页面显示通过前端JS动态设置值
# required=False允许为空表示“顶级评论”不是回复任何评论
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput,
required=False
)
# 元数据配置:关联模型及字段映射
class Meta:
model = Comment # 指定关联的模型Comment
fields = ['body'] # 需处理的模型字段仅包含评论正文body
# 说明其他字段如author、article、creation_time等不通过表单提交
# 而是在视图中通过后端逻辑自动填充(避免用户篡改)

Binary file not shown.

Binary file not shown.
Loading…
Cancel
Save