diff --git a/src/DjangoBlog/__init__.py b/src/DjangoBlog/__init__.py
new file mode 100644
index 0000000..1e205f4
--- /dev/null
+++ b/src/DjangoBlog/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
diff --git a/src/DjangoBlog/admin_site.py b/src/DjangoBlog/admin_site.py
new file mode 100644
index 0000000..f120405
--- /dev/null
+++ b/src/DjangoBlog/admin_site.py
@@ -0,0 +1,64 @@
+from django.contrib.admin import AdminSite
+from django.contrib.admin.models import LogEntry
+from django.contrib.sites.admin import SiteAdmin
+from django.contrib.sites.models import Site
+
+from accounts.admin import *
+from blog.admin import *
+from blog.models import *
+from comments.admin import *
+from comments.models import *
+from djangoblog.logentryadmin import LogEntryAdmin
+from oauth.admin import *
+from oauth.models import *
+from owntracks.admin import *
+from owntracks.models import *
+from servermanager.admin import *
+from servermanager.models import *
+
+
+class DjangoBlogAdminSite(AdminSite):
+ site_header = 'djangoblog administration'
+ site_title = 'djangoblog site admin'
+
+ def __init__(self, name='admin'):
+ super().__init__(name)
+
+ def has_permission(self, request):
+ return request.user.is_superuser
+
+ # 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
+
+
+admin_site = DjangoBlogAdminSite(name='admin')
+
+admin_site.register(Article, ArticlelAdmin)
+admin_site.register(Category, CategoryAdmin)
+admin_site.register(Tag, TagAdmin)
+admin_site.register(Links, LinksAdmin)
+admin_site.register(SideBar, SideBarAdmin)
+admin_site.register(BlogSettings, BlogSettingsAdmin)
+
+admin_site.register(commands, CommandsAdmin)
+admin_site.register(EmailSendLog, EmailSendLogAdmin)
+
+admin_site.register(BlogUser, BlogUserAdmin)
+
+admin_site.register(Comment, CommentAdmin)
+
+admin_site.register(OAuthUser, OAuthUserAdmin)
+admin_site.register(OAuthConfig, OAuthConfigAdmin)
+
+admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
+
+admin_site.register(Site, SiteAdmin)
+
+admin_site.register(LogEntry, LogEntryAdmin)
diff --git a/src/DjangoBlog/apps.py b/src/DjangoBlog/apps.py
new file mode 100644
index 0000000..d29e318
--- /dev/null
+++ b/src/DjangoBlog/apps.py
@@ -0,0 +1,11 @@
+from django.apps import AppConfig
+
+class DjangoblogAppConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'djangoblog'
+
+ def ready(self):
+ super().ready()
+ # Import and load plugins here
+ from .plugin_manage.loader import load_plugins
+ load_plugins()
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/build_index.py b/src/DjangoBlog/blog/management/commands/build_index.py
index 3c4acd7..477e0be 100644
--- a/src/DjangoBlog/blog/management/commands/build_index.py
+++ b/src/DjangoBlog/blog/management/commands/build_index.py
@@ -1,18 +1,25 @@
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 从blog.documents模块导入需要用到的文档类和管理器以及Elasticsearch启用状态常量
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
-# TODO 参数化
+# TODO 参数化 - 待办事项,提示需要将某些配置参数化
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help build_index 时会显示
help = 'build search index'
def handle(self, *args, **options):
+ # 检查是否启用了Elasticsearch功能
if ELASTICSEARCH_ENABLED:
+ # 构建时间文档索引
ElaspedTimeDocumentManager.build_index()
+ # 创建ElapsedTimeDocument实例并初始化
manager = ElapsedTimeDocument()
manager.init()
+ # 创建ArticleDocumentManager实例,删除现有索引后重新构建
manager = ArticleDocumentManager()
manager.delete_index()
- manager.rebuild()
+ manager.rebuild()
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/build_search_words.py b/src/DjangoBlog/blog/management/commands/build_search_words.py
index cfe7e0d..c6ce9f9 100644
--- a/src/DjangoBlog/blog/management/commands/build_search_words.py
+++ b/src/DjangoBlog/blog/management/commands/build_search_words.py
@@ -1,13 +1,18 @@
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 从blog.models模块导入Tag和Category模型
from blog.models import Tag, Category
-# TODO 参数化
+# TODO 参数化 - 待办事项,提示需要将某些配置参数化
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help build_search_words 时会显示
help = 'build search words'
def handle(self, *args, **options):
+ # 收集所有标签(Tag)和分类(Category)的名称,并用set去重
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
- print('\n'.join(datas))
+ # 将所有名称用换行符连接并打印输出
+ print('\n'.join(datas))
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/clear_cache.py b/src/DjangoBlog/blog/management/commands/clear_cache.py
index 0d66172..83d55b2 100644
--- a/src/DjangoBlog/blog/management/commands/clear_cache.py
+++ b/src/DjangoBlog/blog/management/commands/clear_cache.py
@@ -1,11 +1,16 @@
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 从djangoblog.utils模块导入缓存工具
from djangoblog.utils import cache
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help clear_cache 时会显示
help = 'clear the whole cache'
def handle(self, *args, **options):
+ # 清除整个缓存
cache.clear()
- self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
+ # 使用标准输出打印成功信息,显示"缓存已清除"的消息
+ self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/create_testdata.py b/src/DjangoBlog/blog/management/commands/create_testdata.py
index 675d2ba..9a301c6 100644
--- a/src/DjangoBlog/blog/management/commands/create_testdata.py
+++ b/src/DjangoBlog/blog/management/commands/create_testdata.py
@@ -1,40 +1,61 @@
+# 导入获取用户模型的函数,用于操作Django内置的用户认证系统
from django.contrib.auth import get_user_model
+# 导入密码加密函数,用于安全地存储用户密码
from django.contrib.auth.hashers import make_password
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 从blog.models模块导入文章、标签和分类模型
from blog.models import Article, Tag, Category
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help create_testdata 时会显示
help = 'create test datas'
def handle(self, *args, **options):
+ # 获取或创建一个测试用户,邮箱为test@test.com,用户名为"测试用户",密码经过加密处理
user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
+ # 获取或创建一个父级分类,名称为"我是父类目",无上级分类
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
+ # 获取或创建一个子分类,名称为"子类目",父级分类为上面创建的pcategory
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
+ # 保存分类(虽然get_or_create已经保存过,这里再次显式调用save)
category.save()
+
+ # 创建一个基础标签对象,名称为"标签"
basetag = Tag()
basetag.name = "标签"
basetag.save()
+
+ # 循环创建19篇文章(序号从1到19)
for i in range(1, 20):
+ # 获取或创建文章,设置分类、标题、内容和作者
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
+
+ # 创建一个新的标签,名称为"标签"+序号
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
+
+ # 给文章添加两个标签:新创建的标签和基础标签
article.tags.add(tag)
article.tags.add(basetag)
article.save()
+ # 导入缓存工具并清除缓存,确保新创建的数据能立即生效
from djangoblog.utils import cache
cache.clear()
- self.stdout.write(self.style.SUCCESS('created test datas \n'))
+
+ # 使用标准输出打印成功信息,显示"测试数据已创建"的消息
+ self.stdout.write(self.style.SUCCESS('created test datas \n'))
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/ping_baidu.py b/src/DjangoBlog/blog/management/commands/ping_baidu.py
index 2c7fbdd..822e1eb 100644
--- a/src/DjangoBlog/blog/management/commands/ping_baidu.py
+++ b/src/DjangoBlog/blog/management/commands/ping_baidu.py
@@ -1,50 +1,77 @@
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 从djangoblog.spider_notify模块导入SpiderNotify类,用于通知搜索引擎
from djangoblog.spider_notify import SpiderNotify
+# 从djangoblog.utils模块导入get_current_site函数,用于获取当前站点信息
from djangoblog.utils import get_current_site
+# 从blog.models模块导入文章、标签和分类模型
from blog.models import Article, Tag, Category
+# 获取当前站点的域名
site = get_current_site().domain
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help ping_baidu 时会显示
help = 'notify baidu url'
+ # 添加命令行参数
def add_arguments(self, parser):
+ # 添加data_type参数,指定要通知的数据类型
parser.add_argument(
'data_type',
type=str,
+ # 限制参数值只能是以下几种选项
choices=[
'all',
'article',
'tag',
'category'],
+ # 参数帮助说明
help='article : all article,tag : all tag,category: all category,all: All of these')
+ # 构造完整URL的方法
def get_full_url(self, path):
+ # 使用站点域名和路径拼接成完整HTTPS URL
url = "https://{site}{path}".format(site=site, path=path)
return url
+ # 命令主处理逻辑
def handle(self, *args, **options):
+ # 获取传入的数据类型参数
type = options['data_type']
+ # 输出开始处理的信息
self.stdout.write('start get %s' % type)
+ # 初始化URL列表
urls = []
+
+ # 如果是指定文章或全部,则获取所有已发布文章的URL
if type == 'article' or type == 'all':
- for article in Article.objects.filter(status='p'):
+ for article in Article.objects.filter(status='p'): # status='p'表示已发布(published)
urls.append(article.get_full_url())
+
+ # 如果是指定标签或全部,则获取所有标签页面的URL
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
- url = tag.get_absolute_url()
- urls.append(self.get_full_url(url))
+ url = tag.get_absolute_url() # 获取标签的相对路径
+ urls.append(self.get_full_url(url)) # 转换为完整URL
+
+ # 如果是指定分类或全部,则获取所有分类页面的URL
if type == 'category' or type == 'all':
for category in Category.objects.all():
- url = category.get_absolute_url()
- urls.append(self.get_full_url(url))
+ url = category.get_absolute_url() # 获取分类的相对路径
+ urls.append(self.get_full_url(url)) # 转换为完整URL
+ # 输出即将通知的URL数量
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
+
+ # 调用百度通知接口,推送所有URL
SpiderNotify.baidu_notify(urls)
- self.stdout.write(self.style.SUCCESS('finish notify'))
+
+ # 输出完成通知的信息
+ self.stdout.write(self.style.SUCCESS('finish notify'))
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/management/commands/sync_user_avatar.py b/src/DjangoBlog/blog/management/commands/sync_user_avatar.py
index d0f4612..77d75f5 100644
--- a/src/DjangoBlog/blog/management/commands/sync_user_avatar.py
+++ b/src/DjangoBlog/blog/management/commands/sync_user_avatar.py
@@ -1,47 +1,84 @@
+# 导入requests库,用于发送HTTP请求测试图片链接有效性
import requests
+# 导入Django的基础命令类,用于创建自定义管理命令
from django.core.management.base import BaseCommand
+# 导入static函数,用于获取静态文件的URL
from django.templatetags.static import static
+# 从djangoblog.utils模块导入save_user_avatar函数,用于保存用户头像
from djangoblog.utils import save_user_avatar
+# 从oauth.models模块导入OAuthUser模型,用于操作OAuth用户
from oauth.models import OAuthUser
+# 从oauth.oauthmanager模块导入get_manager_by_type函数,用于获取对应类型的OAuth管理器
from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
+ # 命令的帮助文本,在运行 python manage.py help sync_user_avatar 时会显示
help = 'sync user avatar'
+ # 测试图片URL是否有效的方法
def test_picture(self, url):
try:
+ # 发送GET请求测试URL,设置2秒超时
if requests.get(url, timeout=2).status_code == 200:
+ # 如果返回状态码为200,说明图片有效,返回True
return True
except:
+ # 捕获异常,如网络错误或超时,直接pass
pass
+ # 命令主处理逻辑
def handle(self, *args, **options):
+ # 获取静态文件根路径的URL
static_url = static("../")
+ # 获取所有OAuth用户
users = OAuthUser.objects.all()
+ # 输出开始同步的用户数量信息
self.stdout.write(f'开始同步{len(users)}个用户头像')
+
+ # 遍历所有用户进行头像同步
for u in users:
+ # 输出正在同步的用户昵称
self.stdout.write(f'开始同步:{u.nickname}')
+ # 获取用户当前头像URL
url = u.picture
+
+ # 如果用户有头像URL
if url:
+ # 如果头像URL以静态URL开头(说明是本地静态文件)
if url.startswith(static_url):
+ # 测试图片是否有效
if self.test_picture(url):
+ # 如果有效则跳过该用户,继续下一个
continue
else:
+ # 如果无效但用户有元数据
if u.metadata:
+ # 根据用户类型获取对应的OAuth管理器
manage = get_manager_by_type(u.type)
+ # 通过管理器和元数据获取新的头像URL
url = manage.get_picture(u.metadata)
+ # 保存用户头像到本地
url = save_user_avatar(url)
else:
+ # 如果没有元数据,则使用默认头像
url = static('blog/img/avatar.png')
else:
+ # 如果不是本地静态文件,则保存用户头像到本地
url = save_user_avatar(url)
else:
+ # 如果用户没有头像,则使用默认头像
url = static('blog/img/avatar.png')
+
+ # 如果获取到了有效的头像URL
if url:
+ # 输出同步完成的信息和新的头像URL
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
+ # 更新用户的头像URL并保存
u.picture = url
u.save()
- self.stdout.write('结束同步')
+
+ # 输出同步结束信息
+ self.stdout.write('结束同步')
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0001_initial.py b/src/DjangoBlog/blog/migrations/0001_initial.py
index 3d391b6..130ffe4 100644
--- a/src/DjangoBlog/blog/migrations/0001_initial.py
+++ b/src/DjangoBlog/blog/migrations/0001_initial.py
@@ -1,137 +1,219 @@
-# Generated by Django 4.1.7 on 2023-03-02 07:14
+# 由Django 4.1.7在2023年3月2日生成的初始数据库迁移文件
+# 导入Django配置模块
from django.conf import settings
+# 导入Django数据库迁移相关模块
from django.db import migrations, models
+# 导入Django模型关系相关模块
import django.db.models.deletion
+# 导入Django时区工具
import django.utils.timezone
+# 导入Markdown编辑器字段
import mdeditor.fields
class Migration(migrations.Migration):
-
+ # 标记这是一个初始迁移文件
initial = True
+ # 定义依赖关系,依赖于用户模型
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
+ # 定义具体的操作
operations = [
+ # 创建BlogSettings模型,用于存储网站配置信息
migrations.CreateModel(
name='BlogSettings',
fields=[
+ # 主键字段,自动增长的大整数
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ # 网站名称,最大长度200,默认为空字符串
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
+ # 网站描述,文本字段,最大长度1000,默认为空字符串
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
+ # 网站SEO描述,文本字段,最大长度1000,默认为空字符串
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
+ # 网站关键字,文本字段,最大长度1000,默认为空字符串
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
+ # 文章摘要长度,整数类型,默认300
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
+ # 侧边栏文章数目,整数类型,默认10
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
+ # 侧边栏评论数目,整数类型,默认5
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
+ # 文章页面默认显示评论数目,整数类型,默认5
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
+ # 是否显示谷歌广告,布尔类型,默认False
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
+ # 广告内容,文本字段,最大长度2000,可为空,默认为空字符串
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
+ # 是否打开网站评论功能,布尔类型,默认True
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
+ # 备案号,字符字段,最大长度2000,可为空,默认为空字符串
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
+ # 网站统计代码,文本字段,最大长度1000,默认为空字符串
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
+ # 是否显示公安备案号,布尔类型,默认False
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
+ # 公安备案号,文本字段,最大长度2000,可为空,默认为空字符串
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
+ # 模型选项设置
options={
- 'verbose_name': '网站配置',
- 'verbose_name_plural': '网站配置',
+ 'verbose_name': '网站配置', # 单数形式的可读名称
+ 'verbose_name_plural': '网站配置', # 复数形式的可读名称
},
),
+
+ # 创建Links模型,用于存储友情链接信息
migrations.CreateModel(
name='Links',
fields=[
+ # 主键字段,自动增长的大整数
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ # 链接名称,最大长度30,唯一约束
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
+ # 链接地址,URL字段
('link', models.URLField(verbose_name='链接地址')),
+ # 排序,整数类型,唯一约束
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ # 是否显示,布尔类型,默认True
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ # 显示类型,字符字段,最大长度1,可选值包括首页、列表页、文章页面、全站、友情链接页面,默认为首页
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
+ # 创建时间,日期时间字段,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ # 修改时间,日期时间字段,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
+ # 模型选项设置
options={
- 'verbose_name': '友情链接',
- 'verbose_name_plural': '友情链接',
- 'ordering': ['sequence'],
+ 'verbose_name': '友情链接', # 单数形式的可读名称
+ 'verbose_name_plural': '友情链接', # 复数形式的可读名称
+ 'ordering': ['sequence'], # 默认排序按sequence字段升序排列
},
),
+
+ # 创建SideBar模型,用于存储侧边栏信息
migrations.CreateModel(
name='SideBar',
fields=[
+ # 主键字段,自动增长的大整数
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ # 标题,最大长度100
('name', models.CharField(max_length=100, verbose_name='标题')),
+ # 内容,文本字段
('content', models.TextField(verbose_name='内容')),
+ # 排序,整数类型,唯一约束
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ # 是否启用,布尔类型,默认True
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
+ # 创建时间,日期时间字段,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ # 修改时间,日期时间字段,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
+ # 模型选项设置
options={
- 'verbose_name': '侧边栏',
- 'verbose_name_plural': '侧边栏',
- 'ordering': ['sequence'],
+ 'verbose_name': '侧边栏', # 单数形式的可读名称
+ 'verbose_name_plural': '侧边栏', # 复数形式的可读名称
+ 'ordering': ['sequence'], # 默认排序按sequence字段升序排列
},
),
+
+ # 创建Tag模型,用于存储文章标签
migrations.CreateModel(
name='Tag',
fields=[
+ # 主键字段,自动增长的整数
('id', models.AutoField(primary_key=True, serialize=False)),
+ # 创建时间,日期时间字段,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ # 修改时间,日期时间字段,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ # 标签名,最大长度30,唯一约束
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
+ # slug,SlugField类型,最大长度60,可为空,默认为'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
+ # 模型选项设置
options={
- 'verbose_name': '标签',
- 'verbose_name_plural': '标签',
- 'ordering': ['name'],
+ 'verbose_name': '标签', # 单数形式的可读名称
+ 'verbose_name_plural': '标签', # 复数形式的可读名称
+ 'ordering': ['name'], # 默认排序按name字段升序排列
},
),
+
+ # 创建Category模型,用于存储文章分类
migrations.CreateModel(
name='Category',
fields=[
+ # 主键字段,自动增长的整数
('id', models.AutoField(primary_key=True, serialize=False)),
+ # 创建时间,日期时间字段,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ # 修改时间,日期时间字段,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ # 分类名,最大长度30,唯一约束
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
+ # slug,SlugField类型,最大长度60,可为空,默认为'no-slug'
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ # 权重排序,整数类型,默认0,数值越大越靠前
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
+ # 父级分类,外键关联到自身,可为空,级联删除
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
+ # 模型选项设置
options={
- 'verbose_name': '分类',
- 'verbose_name_plural': '分类',
- 'ordering': ['-index'],
+ 'verbose_name': '分类', # 单数形式的可读名称
+ 'verbose_name_plural': '分类', # 复数形式的可读名称
+ 'ordering': ['-index'], # 默认排序按index字段降序排列
},
),
+
+ # 创建Article模型,用于存储文章信息
migrations.CreateModel(
name='Article',
fields=[
+ # 主键字段,自动增长的整数
('id', models.AutoField(primary_key=True, serialize=False)),
+ # 创建时间,日期时间字段,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ # 修改时间,日期时间字段,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ # 标题,最大长度200,唯一约束
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
+ # 正文,使用MDTextField类型(Markdown编辑器字段)
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
+ # 发布时间,日期时间字段,默认为当前时间
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
+ # 文章状态,字符字段,最大长度1,可选值为草稿和发表,默认为发表
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
+ # 评论状态,字符字段,最大长度1,可选值为打开和关闭,默认为打开
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
+ # 类型,字符字段,最大长度1,可选值为文章和页面,默认为文章
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
+ # 浏览量,正整数类型,默认0
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
+ # 文章排序,整数类型,默认0,数值越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
+ # 是否显示toc目录,布尔类型,默认False
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
+ # 作者,外键关联到用户模型,级联删除
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ # 分类,外键关联到Category模型,级联删除
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
+ # 标签集合,多对多关系关联到Tag模型,可为空
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
+ # 模型选项设置
options={
- 'verbose_name': '文章',
- 'verbose_name_plural': '文章',
- 'ordering': ['-article_order', '-pub_time'],
- 'get_latest_by': 'id',
+ 'verbose_name': '文章', # 单数形式的可读名称
+ 'verbose_name_plural': '文章', # 复数形式的可读名称
+ 'ordering': ['-article_order', '-pub_time'], # 默认排序先按article_order降序,再按pub_time降序
+ 'get_latest_by': 'id', # latest()方法使用的默认字段
},
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py
index adbaa36..cc9ccdb 100644
--- a/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py
+++ b/src/DjangoBlog/blog/migrations/0002_blogsettings_global_footer_and_more.py
@@ -1,23 +1,28 @@
-# Generated by Django 4.1.7 on 2023-03-29 06:08
+# 由Django 4.1.7在2023年3月29日生成的数据库迁移文件
+# 导入Django数据库迁移相关模块
from django.db import migrations, models
class Migration(migrations.Migration):
-
+ # 定义依赖关系,依赖于上一个迁移文件0001_initial
dependencies = [
('blog', '0001_initial'),
]
+ # 定义具体的操作
operations = [
+ # 向BlogSettings模型添加新字段global_footer(公共尾部)
migrations.AddField(
- model_name='blogsettings',
- name='global_footer',
- field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
+ model_name='blogsettings', # 目标模型名称
+ name='global_footer', # 新增字段名
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), # 字段定义:文本字段,可为空,默认为空字符串,可为NULL,显示名为"公共尾部"
),
+
+ # 向BlogSettings模型添加新字段global_header(公共头部)
migrations.AddField(
- model_name='blogsettings',
- name='global_header',
- field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
+ model_name='blogsettings', # 目标模型名称
+ name='global_header', # 新增字段名
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), # 字段定义:文本字段,可为空,默认为空字符串,可为NULL,显示名为"公共头部"
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py b/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py
index e9f5502..2f77014 100644
--- a/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py
+++ b/src/DjangoBlog/blog/migrations/0003_blogsettings_comment_need_review.py
@@ -1,17 +1,21 @@
-# Generated by Django 4.2.1 on 2023-05-09 07:45
+# 由Django 4.2.1在2023年5月9日生成的数据库迁移文件
+# 导入Django数据库迁移相关模块
from django.db import migrations, models
class Migration(migrations.Migration):
+ # 定义依赖关系,依赖于上一个迁移文件0002_blogsettings_global_footer_and_more
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
]
+ # 定义具体的操作
operations = [
+ # 向BlogSettings模型添加新字段comment_need_review(评论是否需要审核)
migrations.AddField(
- model_name='blogsettings',
- name='comment_need_review',
- field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
+ model_name='blogsettings', # 目标模型名称
+ name='comment_need_review', # 新增字段名
+ field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), # 字段定义:布尔字段,默认值为False,显示名为"评论是否需要审核"
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
index ceb1398..548f2b0 100644
--- a/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
+++ b/src/DjangoBlog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
@@ -1,27 +1,35 @@
-# Generated by Django 4.2.1 on 2023-05-09 07:51
+# 由Django 4.2.1在2023年5月9日生成的数据库迁移文件
+# 导入Django数据库迁移相关模块
from django.db import migrations
class Migration(migrations.Migration):
+ # 定义依赖关系,依赖于上一个迁移文件0003_blogsettings_comment_need_review
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
]
+ # 定义具体的操作
operations = [
+ # 重命名BlogSettings模型中的字段:analyticscode -> analytics_code
migrations.RenameField(
- model_name='blogsettings',
- old_name='analyticscode',
- new_name='analytics_code',
+ model_name='blogsettings', # 目标模型名称
+ old_name='analyticscode', # 原字段名
+ new_name='analytics_code', # 新字段名
),
+
+ # 重命名BlogSettings模型中的字段:beiancode -> beian_code
migrations.RenameField(
- model_name='blogsettings',
- old_name='beiancode',
- new_name='beian_code',
+ model_name='blogsettings', # 目标模型名称
+ old_name='beiancode', # 原字段名
+ new_name='beian_code', # 新字段名
),
+
+ # 重命名BlogSettings模型中的字段:sitename -> site_name
migrations.RenameField(
- model_name='blogsettings',
- old_name='sitename',
- new_name='site_name',
+ model_name='blogsettings', # 目标模型名称
+ old_name='sitename', # 原字段名
+ new_name='site_name', # 新字段名
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
index d08e853..4207ef0 100644
--- a/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
+++ b/src/DjangoBlog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
@@ -1,172 +1,216 @@
-# Generated by Django 4.2.5 on 2023-09-06 13:13
+# 由Django 4.2.5在2023年9月6日生成的数据库迁移文件
+# 导入Django配置模块
from django.conf import settings
+# 导入Django数据库迁移相关模块
from django.db import migrations, models
+# 导入Django模型关系相关模块
import django.db.models.deletion
+# 导入Django时区工具
import django.utils.timezone
+# 导入Markdown编辑器字段
import mdeditor.fields
class Migration(migrations.Migration):
-
+ # 定义依赖关系,依赖于用户模型和上一个迁移文件
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
+ # 定义具体的操作
operations = [
+ # 修改Article模型的选项设置
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
+ # 修改Category模型的选项设置
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
+ # 修改Links模型的选项设置
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
+ # 修改SideBar模型的选项设置
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
+ # 修改Tag模型的选项设置
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
+
+ # 移除Article模型中的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
+ # 移除Article模型中的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
+ # 移除Category模型中的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
+ # 移除Category模型中的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
+ # 移除Links模型中的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
+ # 移除SideBar模型中的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
+ # 移除Tag模型中的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
+ # 移除Tag模型中的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
+
+ # 为Article模型添加creation_time字段(创建时间)
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ # 为Article模型添加last_modify_time字段(最后修改时间)
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
+ # 为Category模型添加creation_time字段(创建时间)
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ # 为Category模型添加last_modify_time字段(最后修改时间)
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
+ # 为Links模型添加creation_time字段(创建时间)
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ # 为SideBar模型添加creation_time字段(创建时间)
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ # 为Tag模型添加creation_time字段(创建时间)
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ # 为Tag模型添加last_modify_time字段(最后修改时间)
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
+
+ # 修改Article模型的article_order字段显示名称
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
+ # 修改Article模型的author字段显示名称
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
+ # 修改Article模型的body字段显示名称
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
+ # 修改Article模型的category字段显示名称
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
+ # 修改Article模型的comment_status字段选项和显示名称
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
+ # 修改Article模型的pub_time字段显示名称
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
+ # 修改Article模型的show_toc字段显示名称
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
+ # 修改Article模型的status字段选项和显示名称
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
+ # 修改Article模型的tags字段显示名称
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
+ # 修改Article模型的title字段显示名称
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
+ # 修改Article模型的type字段选项和显示名称
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
+ # 修改Article模型的views字段显示名称
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
+
+ # 修改BlogSettings模型的多个字段显示名称(英文化)
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
@@ -222,6 +266,8 @@ class Migration(migrations.Migration):
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
+
+ # 修改Category模型的多个字段显示名称
migrations.AlterField(
model_name='category',
name='index',
@@ -237,6 +283,8 @@ class Migration(migrations.Migration):
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
+
+ # 修改Links模型的多个字段显示名称
migrations.AlterField(
model_name='links',
name='is_enable',
@@ -267,6 +315,8 @@ class Migration(migrations.Migration):
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
+
+ # 修改SideBar模型的多个字段显示名称
migrations.AlterField(
model_name='sidebar',
name='content',
@@ -292,9 +342,11 @@ class Migration(migrations.Migration):
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
+
+ # 修改Tag模型的name字段显示名称
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py b/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py
index e36feb4..4b4c43f 100644
--- a/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py
+++ b/src/DjangoBlog/blog/migrations/0006_alter_blogsettings_options.py
@@ -1,17 +1,20 @@
-# Generated by Django 4.2.7 on 2024-01-26 02:41
+# 由Django 4.2.7在2024年1月26日生成的数据库迁移文件
+# 导入Django数据库迁移相关模块
from django.db import migrations
class Migration(migrations.Migration):
-
+ # 定义依赖关系,依赖于上一个迁移文件0005_alter_article_options_alter_category_options_and_more
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
+ # 定义具体的操作
operations = [
+ # 修改BlogSettings模型的选项设置
migrations.AlterModelOptions(
- name='blogsettings',
- options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
+ name='blogsettings', # 目标模型名称
+ options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, # 新的模型选项:将单数和复数形式的显示名称都改为"Website configuration"
),
- ]
+ ]
\ No newline at end of file
diff --git a/src/DjangoBlog/blog/templatetags/blog_tags.py b/src/DjangoBlog/blog/templatetags/blog_tags.py
index d6cd5d5..abcc963 100644
--- a/src/DjangoBlog/blog/templatetags/blog_tags.py
+++ b/src/DjangoBlog/blog/templatetags/blog_tags.py
@@ -1,107 +1,156 @@
+#zf:导入所需的Python标准库
+#zf:用于生成MD5哈希值
import hashlib
+#zf:用于日志记录
import logging
+#zf:用于随机选择
import random
+#zf:用于URL编码
import urllib
+#zf:导入Django相关模块
+#zf:Django模板系统
from django import template
+#zf:Django配置
from django.conf import settings
+#zf:Django查询对象
from django.db.models import Q
+#zf:获取对象或返回404
from django.shortcuts import get_object_or_404
+#zf:字符串过滤器装饰器
from django.template.defaultfilters import stringfilter
+#zf:静态文件处理
from django.templatetags.static import static
+#zf:URL反向解析
from django.urls import reverse
+#zf:标记安全字符串
from django.utils.safestring import mark_safe
+#zf:导入项目相关模块
+#zf:博客模型
from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
+#zf:评论模型
from comments.models import Comment
+#zf:Markdown处理和HTML清理
from djangoblog.utils import CommonMarkdown, sanitize_html
+#zf:缓存工具
from djangoblog.utils import cache
+#zf:获取当前站点
from djangoblog.utils import get_current_site
+#zf:OAuth用户模型
from oauth.models import OAuthUser
+#zf:插件管理
from djangoblog.plugin_manage import hooks
+#zf:创建日志记录器
logger = logging.getLogger(__name__)
+#zf:注册模板标签库
register = template.Library()
+#zf:定义head_meta简单标签,用于在模板中插入头部元信息
@register.simple_tag(takes_context=True)
def head_meta(context):
+ #zf:应用插件过滤器,返回安全的HTML字符串
return mark_safe(hooks.apply_filters('head_meta', '', context))
+#zf:定义timeformat简单标签,用于格式化时间
@register.simple_tag
def timeformat(data):
try:
+ #zf:使用settings中定义的时间格式格式化数据
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
+ #zf:记录错误日志并返回空字符串
logger.error(e)
return ""
+#zf:定义datetimeformat简单标签,用于格式化日期时间
@register.simple_tag
def datetimeformat(data):
try:
+ #zf:使用settings中定义的日期时间格式格式化数据
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
+ #zf:记录错误日志并返回空字符串
logger.error(e)
return ""
+#zf:定义custom_markdown过滤器,用于将Markdown内容转换为HTML
@register.filter()
@stringfilter
def custom_markdown(content):
+ #zf:使用CommonMarkdown工具转换Markdown并标记为安全HTML
return mark_safe(CommonMarkdown.get_markdown(content))
+#zf:定义get_markdown_toc简单标签,用于获取Markdown内容的目录
@register.simple_tag
def get_markdown_toc(content):
from djangoblog.utils import CommonMarkdown
+ #zf:获取Markdown内容和目录
body, toc = CommonMarkdown.get_markdown_with_toc(content)
+ #zf:标记目录为安全HTML并返回
return mark_safe(toc)
+#zf:定义comment_markdown过滤器,用于处理评论中的Markdown内容
@register.filter()
@stringfilter
def comment_markdown(content):
+ #zf:转换Markdown内容
content = CommonMarkdown.get_markdown(content)
+ #zf:清理HTML并标记为安全
return mark_safe(sanitize_html(content))
+#zf:定义truncatechars_content过滤器,用于截取文章内容摘要
@register.filter(is_safe=True)
@stringfilter
def truncatechars_content(content):
"""
获得文章内容的摘要
- :param content:
- :return:
+ :param content: 文章内容
+ :return: 截取后的摘要
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
+ #zf:获取博客设置
blogsetting = get_blog_setting()
+ #zf:根据设置的文章摘要长度截取内容
return truncatechars_html(content, blogsetting.article_sub_length)
+#zf:定义truncate过滤器,用于截取内容前150个字符
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
from django.utils.html import strip_tags
-
+ #zf:去除HTML标签并截取前150个字符
return strip_tags(content)[:150]
+#zf:定义load_breadcrumb包含标签,用于加载文章面包屑导航
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
获得文章面包屑
- :param article:
- :return:
+ :param article: 文章对象
+ :return: 面包屑数据
"""
+ #zf:获取文章的分类树
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
+ #zf:获取博客设置和当前站点
blogsetting = get_blog_setting()
site = get_current_site().domain
+ #zf:添加站点名称到面包屑
names.append((blogsetting.site_name, '/'))
+ #zf:反转面包屑顺序
names = names[::-1]
return {
@@ -111,18 +160,22 @@ def load_breadcrumb(article):
}
+#zf:定义load_articletags包含标签,用于加载文章标签列表
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
- :param article:
- :return:
+ :param article: 文章对象
+ :return: 标签列表数据
"""
+ #zf:获取文章的所有标签
tags = article.tags.all()
tags_list = []
for tag in tags:
+ #zf:获取标签URL和文章数量
url = tag.get_absolute_url()
count = tag.get_article_count()
+ #zf:添加标签信息到列表,随机选择Bootstrap颜色
tags_list.append((
url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
))
@@ -131,214 +184,17 @@ def load_articletags(article):
}
+#zf:定义load_sidebar包含标签,用于加载侧边栏内容
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
加载侧边栏
- :return:
+ :return: 侧边栏数据
"""
+ #zf:尝试从缓存获取侧边栏数据
value = cache.get("sidebar" + linktype)
if value:
+ #zf:如果缓存存在,添加用户信息并返回
value['user'] = user
return value
- else:
- logger.info('load sidebar')
- from djangoblog.utils import get_blog_setting
- blogsetting = get_blog_setting()
- recent_articles = Article.objects.filter(
- status='p')[:blogsetting.sidebar_article_count]
- sidebar_categorys = Category.objects.all()
- extra_sidebars = SideBar.objects.filter(
- is_enable=True).order_by('sequence')
- most_read_articles = Article.objects.filter(status='p').order_by(
- '-views')[:blogsetting.sidebar_article_count]
- dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
- links = Links.objects.filter(is_enable=True).filter(
- Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
- commment_list = Comment.objects.filter(is_enable=True).order_by(
- '-id')[:blogsetting.sidebar_comment_count]
- # 标签云 计算字体大小
- # 根据总数计算出平均值 大小为 (数目/平均值)*步长
- increment = 5
- tags = Tag.objects.all()
- sidebar_tags = None
- if tags and len(tags) > 0:
- s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
- count = sum([t[1] for t in s])
- dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
- import random
- sidebar_tags = list(
- map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
- random.shuffle(sidebar_tags)
-
- value = {
- 'recent_articles': recent_articles,
- 'sidebar_categorys': sidebar_categorys,
- 'most_read_articles': most_read_articles,
- 'article_dates': dates,
- 'sidebar_comments': commment_list,
- 'sidabar_links': links,
- 'show_google_adsense': blogsetting.show_google_adsense,
- 'google_adsense_codes': blogsetting.google_adsense_codes,
- 'open_site_comment': blogsetting.open_site_comment,
- 'show_gongan_code': blogsetting.show_gongan_code,
- 'sidebar_tags': sidebar_tags,
- 'extra_sidebars': extra_sidebars
- }
- cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
- logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
- value['user'] = user
- return value
-
-
-@register.inclusion_tag('blog/tags/article_meta_info.html')
-def load_article_metas(article, user):
- """
- 获得文章meta信息
- :param article:
- :return:
- """
- return {
- 'article': article,
- 'user': user
- }
-
-
-@register.inclusion_tag('blog/tags/article_pagination.html')
-def load_pagination_info(page_obj, page_type, tag_name):
- previous_url = ''
- next_url = ''
- if page_type == '':
- if page_obj.has_next():
- next_number = page_obj.next_page_number()
- next_url = reverse('blog:index_page', kwargs={'page': next_number})
- if page_obj.has_previous():
- previous_number = page_obj.previous_page_number()
- previous_url = reverse(
- 'blog:index_page', kwargs={
- 'page': previous_number})
- if page_type == '分类标签归档':
- tag = get_object_or_404(Tag, name=tag_name)
- if page_obj.has_next():
- next_number = page_obj.next_page_number()
- next_url = reverse(
- 'blog:tag_detail_page',
- kwargs={
- 'page': next_number,
- 'tag_name': tag.slug})
- if page_obj.has_previous():
- previous_number = page_obj.previous_page_number()
- previous_url = reverse(
- 'blog:tag_detail_page',
- kwargs={
- 'page': previous_number,
- 'tag_name': tag.slug})
- if page_type == '作者文章归档':
- if page_obj.has_next():
- next_number = page_obj.next_page_number()
- next_url = reverse(
- 'blog:author_detail_page',
- kwargs={
- 'page': next_number,
- 'author_name': tag_name})
- if page_obj.has_previous():
- previous_number = page_obj.previous_page_number()
- previous_url = reverse(
- 'blog:author_detail_page',
- kwargs={
- 'page': previous_number,
- 'author_name': tag_name})
-
- if page_type == '分类目录归档':
- category = get_object_or_404(Category, name=tag_name)
- if page_obj.has_next():
- next_number = page_obj.next_page_number()
- next_url = reverse(
- 'blog:category_detail_page',
- kwargs={
- 'page': next_number,
- 'category_name': category.slug})
- if page_obj.has_previous():
- previous_number = page_obj.previous_page_number()
- previous_url = reverse(
- 'blog:category_detail_page',
- kwargs={
- 'page': previous_number,
- 'category_name': category.slug})
-
- return {
- 'previous_url': previous_url,
- 'next_url': next_url,
- 'page_obj': page_obj
- }
-
-
-@register.inclusion_tag('blog/tags/article_info.html')
-def load_article_detail(article, isindex, user):
- """
- 加载文章详情
- :param article:
- :param isindex:是否列表页,若是列表页只显示摘要
- :return:
- """
- from djangoblog.utils import get_blog_setting
- blogsetting = get_blog_setting()
-
- return {
- 'article': article,
- 'isindex': isindex,
- 'user': user,
- 'open_site_comment': blogsetting.open_site_comment,
- }
-
-
-# return only the URL of the gravatar
-# TEMPLATE USE: {{ email|gravatar_url:150 }}
-@register.filter
-def gravatar_url(email, size=40):
- """获得gravatar头像"""
- cachekey = 'gravatat/' + email
- url = cache.get(cachekey)
- if url:
- return url
- else:
- usermodels = OAuthUser.objects.filter(email=email)
- if usermodels:
- o = list(filter(lambda x: x.picture is not None, usermodels))
- if o:
- return o[0].picture
- email = email.encode('utf-8')
-
- default = static('blog/img/avatar.png')
-
- url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
- email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
- cache.set(cachekey, url, 60 * 60 * 10)
- logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
- return url
-
-
-@register.filter
-def gravatar(email, size=40):
- """获得gravatar头像"""
- url = gravatar_url(email, size)
- return mark_safe(
- '
' %
- (url, size, size))
-
-
-@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 %}
- """
- return qs.filter(**kwargs)
-
-
-@register.filter
-def addstr(arg1, arg2):
- """concatenate arg1 & arg2"""
- return str(arg1) + str(arg2)
+ else
\ No newline at end of file
diff --git a/src/DjangoBlog/blog_signals.py b/src/DjangoBlog/blog_signals.py
new file mode 100644
index 0000000..8679c87
--- /dev/null
+++ b/src/DjangoBlog/blog_signals.py
@@ -0,0 +1,247 @@
+import _thread
+import logging
+from math import ceil
+
+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 uuslug import slugify
+
+from blog.models import Article, BlogSettings, Category, Tag
+from comments.models import Comment
+from comments.utils import send_comment_email
+from djangoblog.spider_notify import SpiderNotify
+from djangoblog.utils import cache, delete_sidebar_cache, delete_view_cache, expire_view_cache
+from djangoblog.utils import get_current_site
+from oauth.models import OAuthUser
+
+logger = logging.getLogger(__name__)
+
+oauth_user_login_signal = django.dispatch.Signal(['id'])
+send_email_signal = django.dispatch.Signal(
+ ['emailto', 'title', 'content'])
+
+
+def _get_site_domain():
+ # szy:返回不带端口号的域名,统一缓存键格式
+ site = get_current_site().domain
+ if ':' in site:
+ site = site.split(':')[0]
+ return site
+
+
+def _expire_object_cache(instance):
+ # szy:根据对象 URL 精准刷新页面缓存,避免全量清空
+ if not hasattr(instance, 'get_absolute_url'):
+ return
+ try:
+ path = instance.get_absolute_url()
+ except Exception:
+ return
+ expire_view_cache(
+ path,
+ servername=_get_site_domain(),
+ serverport=80,
+ key_prefix='blogdetail')
+
+
+def _invalidate_nav_and_seo_cache():
+ # szy:侧边栏和 SEO 上下文依赖全局配置,需要单独失效
+ delete_sidebar_cache()
+ if cache.get('seo_processor'):
+ cache.delete('seo_processor')
+
+
+def _delete_paginated_cache(key_prefix, total_items):
+ # szy:按分页数量批量删除缓存键,避免 cache.clear()
+ page_size = settings.PAGINATE_BY or 1
+ total_pages = max(1, ceil(total_items / page_size))
+ keys = [f'{key_prefix}_{page}' for page in range(1, total_pages + 1)]
+ cache.delete_many(keys)
+
+
+def _invalidate_index_cache():
+ # szy:首页/归档依赖文章数据,文章变化时定点清除
+ total = Article.objects.filter(type='a', status='p').count()
+ _delete_paginated_cache('index', total)
+ cache.delete('archives')
+
+
+def _invalidate_category_cache(category: Category):
+ # szy:分类及其子分类都有联动,需要逐个刷新
+ if not category:
+ return
+ _expire_object_cache(category)
+ category_names = [c.name for c in category.get_sub_categorys()]
+ total = Article.objects.filter(
+ category__name__in=category_names,
+ status='p').count()
+ _delete_paginated_cache(f'category_list_{category.name}', total)
+
+
+def _invalidate_tag_cache(tag: Tag):
+ # szy:标签列表缓存独立,按标签名称清理
+ if not tag:
+ return
+ _expire_object_cache(tag)
+ total = Article.objects.filter(
+ tags__name=tag.name,
+ type='a',
+ status='p').distinct().count()
+ _delete_paginated_cache(f'tag_{tag.name}', total)
+
+
+def _invalidate_author_cache(username: str):
+ # szy:作者归档页按用户名 slug 生成缓存键
+ if not username:
+ return
+ author_slug = slugify(username)
+ total = Article.objects.filter(
+ author__username=username,
+ type='a',
+ status='p').count()
+ _delete_paginated_cache(f'author_{author_slug}', total)
+
+
+def _notify_spider(instance):
+ # szy:文章/分类更新后推送搜索引擎,保持抓取实时
+ if settings.TESTING or not hasattr(instance, 'get_full_url'):
+ return
+ try:
+ notify_url = instance.get_full_url()
+ SpiderNotify.baidu_notify([notify_url])
+ except Exception as ex:
+ logger.error("notify sipder", ex)
+
+
+def _invalidate_article_related_cache(article: Article):
+ # szy:文章更新会影响多个页面,这里集中处理相关缓存
+ _expire_object_cache(article)
+ _invalidate_index_cache()
+ _invalidate_author_cache(article.author.username if article.author else None)
+ if article.category_id:
+ _invalidate_category_cache(article.category)
+ for tag in article.tags.all():
+ _invalidate_tag_cache(tag)
+
+
+@receiver(send_email_signal)
+def send_email_signal_handler(sender, **kwargs):
+ emailto = kwargs['emailto']
+ title = kwargs['title']
+ content = kwargs['content']
+
+ msg = EmailMultiAlternatives(
+ title,
+ content,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ to=emailto)
+ msg.content_subtype = "html"
+
+ from servermanager.models import EmailSendLog
+ log = EmailSendLog()
+ log.title = title
+ log.content = content
+ log.emailto = ','.join(emailto)
+
+ try:
+ result = msg.send()
+ log.send_result = result > 0
+ except Exception as e:
+ logger.error(f"失败邮箱号: {emailto}, {e}")
+ log.send_result = False
+ log.save()
+
+
+@receiver(oauth_user_login_signal)
+def oauth_user_login_signal_handler(sender, **kwargs):
+ id = kwargs['id']
+ oauthuser = OAuthUser.objects.get(id=id)
+ site = get_current_site().domain
+ if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
+ from djangoblog.utils import save_user_avatar
+ oauthuser.picture = save_user_avatar(oauthuser.picture)
+ oauthuser.save()
+
+ delete_sidebar_cache()
+
+
+@receiver(post_save)
+def model_post_save_callback(
+ sender,
+ instance,
+ created,
+ raw,
+ using,
+ update_fields,
+ **kwargs):
+ if isinstance(instance, LogEntry):
+ return
+ is_update_views = update_fields == {'views'}
+
+ if isinstance(instance, BlogSettings):
+ # szy:站点配置变化时同步刷新缓存和侧边栏
+ cache.delete('get_blog_setting')
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Article):
+ if is_update_views:
+ return
+ _notify_spider(instance)
+ _invalidate_article_related_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Category):
+ _notify_spider(instance)
+ _invalidate_category_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Tag):
+ _notify_spider(instance)
+ _invalidate_tag_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if hasattr(instance, 'get_full_url') and not is_update_views:
+ _notify_spider(instance)
+ _expire_object_cache(instance)
+ _invalidate_nav_and_seo_cache()
+
+ if isinstance(instance, Comment):
+ if instance.is_enable:
+ # szy:评论通过后清理详情页与评论区缓存,保证实时显示
+ path = instance.article.get_absolute_url()
+ site = get_current_site().domain
+ if site.find(':') > 0:
+ site = site[0:site.find(':')]
+
+ expire_view_cache(
+ path,
+ servername=site,
+ serverport=80,
+ key_prefix='blogdetail')
+ if cache.get('seo_processor'):
+ cache.delete('seo_processor')
+ comment_cache_key = 'article_comments_{id}'.format(
+ id=instance.article.id)
+ cache.delete(comment_cache_key)
+ delete_sidebar_cache()
+ delete_view_cache('article_comments', [str(instance.article.pk)])
+
+ _thread.start_new_thread(send_comment_email, (instance,))
+
+
+@receiver(user_logged_in)
+@receiver(user_logged_out)
+def user_auth_callback(sender, request, user, **kwargs):
+ if user and user.username:
+ logger.info(user)
+ delete_sidebar_cache()
+ # cache.clear()
diff --git a/src/DjangoBlog/comments/migrations/0001_initial.py b/src/DjangoBlog/comments/migrations/0001_initial.py
index 61d1e53..837acbd 100644
--- a/src/DjangoBlog/comments/migrations/0001_initial.py
+++ b/src/DjangoBlog/comments/migrations/0001_initial.py
@@ -1,3 +1,4 @@
+#zr 初始数据库迁移文件:创建评论表结构
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings
@@ -5,29 +6,42 @@ from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
-
+#zr 数据库迁移类
class Migration(migrations.Migration):
+ #zr 初始迁移
initial = True
+ #zr 依赖关系
dependencies = [
('blog', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
+ #zr 迁移操作
operations = [
+ #zr 创建评论表
migrations.CreateModel(
name='Comment',
fields=[
+ #zr 主键ID字段
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ #zr 评论正文字段
('body', models.TextField(max_length=300, verbose_name='正文')),
+ #zr 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ #zr 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ #zr 是否显示字段
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ #zr 文章外键关联
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
+ #zr 作者外键关联
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ #zr 父评论自关联
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
+ #zr 模型元选项
options={
'verbose_name': '评论',
'verbose_name_plural': '评论',
diff --git a/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py b/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py
index 17c44db..481bcfc 100644
--- a/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py
+++ b/src/DjangoBlog/comments/migrations/0002_alter_comment_is_enable.py
@@ -1,18 +1,23 @@
+#zr 数据库迁移文件:修改评论是否显示字段的默认值
# Generated by Django 4.1.7 on 2023-04-24 13:48
from django.db import migrations, models
-
+#zr 数据库迁移类
class Migration(migrations.Migration):
+ #zr 依赖的迁移文件
dependencies = [
('comments', '0001_initial'),
]
+ #zr 迁移操作
operations = [
+ #zr 修改comment模型的is_enable字段
migrations.AlterField(
model_name='comment',
name='is_enable',
+ #zr 将默认值改为False,并更新显示名称
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]
diff --git a/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
index a1ca970..2dfda8b 100644
--- a/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
+++ b/src/DjangoBlog/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
@@ -1,3 +1,4 @@
+#zr 数据库迁移文件:更新评论模型字段和选项
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
@@ -5,56 +6,68 @@ from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
-
+#zr 数据库迁移类
class Migration(migrations.Migration):
+ #zr 依赖的迁移文件
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_alter_article_options_alter_category_options_and_more'),
('comments', '0002_alter_comment_is_enable'),
]
+ #zr 迁移操作列表
operations = [
+ #zr 更新评论模型的元选项
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
+ #zr 移除旧的创建时间字段
migrations.RemoveField(
model_name='comment',
name='created_time',
),
+ #zr 移除旧的最后修改时间字段
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
+ #zr 添加新的创建时间字段
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
+ #zr 添加新的最后修改时间字段
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
+ #zr 更新文章外键字段配置
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
+ #zr 更新作者外键字段配置
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
+ #zr 更新是否启用字段配置
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
+ #zr 更新父评论外键字段配置
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]
+
diff --git a/src/DjangoBlog/djangoblog/__init__.py b/src/DjangoBlog/djangoblog/__init__.py
index 4592301..1e205f4 100644
--- a/src/DjangoBlog/djangoblog/__init__.py
+++ b/src/DjangoBlog/djangoblog/__init__.py
@@ -1,2 +1 @@
-# szy:此文件用于将当前目录识别为一个Python包
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
diff --git a/src/DjangoBlog/djangoblog/admin_site.py b/src/DjangoBlog/djangoblog/admin_site.py
index 7f1194e..f120405 100644
--- a/src/DjangoBlog/djangoblog/admin_site.py
+++ b/src/DjangoBlog/djangoblog/admin_site.py
@@ -1,4 +1,3 @@
-# szy:功能描述:自定义Django后台管理站点,并注册各个模型
from django.contrib.admin import AdminSite
from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin
@@ -17,16 +16,14 @@ from owntracks.models import *
from servermanager.admin import *
from servermanager.models import *
-# szy:自定义Django后台管理站点,并注册各个模型
+
class DjangoBlogAdminSite(AdminSite):
site_header = 'djangoblog administration'
site_title = 'djangoblog site admin'
- # szy:初始化管理站点,设置站点名称
def __init__(self, name='admin'):
super().__init__(name)
- # szy:检查用户权限,是否为超级管理员
def has_permission(self, request):
return request.user.is_superuser
@@ -40,7 +37,7 @@ class DjangoBlogAdminSite(AdminSite):
# ]
# return urls + my_urls
-# szy:注册各个模型到后台管理
+
admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin)
diff --git a/src/DjangoBlog/djangoblog/apps.py b/src/DjangoBlog/djangoblog/apps.py
index 469dbdd..d29e318 100644
--- a/src/DjangoBlog/djangoblog/apps.py
+++ b/src/DjangoBlog/djangoblog/apps.py
@@ -1,11 +1,9 @@
from django.apps import AppConfig
-# szy:Django应用配置类,用于加载插件
class DjangoblogAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'djangoblog'
- # szy:应用准备时加载插件
def ready(self):
super().ready()
# Import and load plugins here
diff --git a/src/DjangoBlog/djangoblog/blog_signals.py b/src/DjangoBlog/djangoblog/blog_signals.py
index fa381a9..8679c87 100644
--- a/src/DjangoBlog/djangoblog/blog_signals.py
+++ b/src/DjangoBlog/djangoblog/blog_signals.py
@@ -1,6 +1,6 @@
-# szy:定义Django信号并处理相关业务逻辑
import _thread
import logging
+from math import ceil
import django.dispatch
from django.conf import settings
@@ -9,11 +9,13 @@ 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 uuslug import slugify
+from blog.models import Article, BlogSettings, Category, Tag
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 cache, delete_sidebar_cache, delete_view_cache, expire_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
@@ -23,7 +25,110 @@ oauth_user_login_signal = django.dispatch.Signal(['id'])
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
-# szy:处理发送邮件的信号
+
+def _get_site_domain():
+ # szy:返回不带端口号的域名,统一缓存键格式
+ site = get_current_site().domain
+ if ':' in site:
+ site = site.split(':')[0]
+ return site
+
+
+def _expire_object_cache(instance):
+ # szy:根据对象 URL 精准刷新页面缓存,避免全量清空
+ if not hasattr(instance, 'get_absolute_url'):
+ return
+ try:
+ path = instance.get_absolute_url()
+ except Exception:
+ return
+ expire_view_cache(
+ path,
+ servername=_get_site_domain(),
+ serverport=80,
+ key_prefix='blogdetail')
+
+
+def _invalidate_nav_and_seo_cache():
+ # szy:侧边栏和 SEO 上下文依赖全局配置,需要单独失效
+ delete_sidebar_cache()
+ if cache.get('seo_processor'):
+ cache.delete('seo_processor')
+
+
+def _delete_paginated_cache(key_prefix, total_items):
+ # szy:按分页数量批量删除缓存键,避免 cache.clear()
+ page_size = settings.PAGINATE_BY or 1
+ total_pages = max(1, ceil(total_items / page_size))
+ keys = [f'{key_prefix}_{page}' for page in range(1, total_pages + 1)]
+ cache.delete_many(keys)
+
+
+def _invalidate_index_cache():
+ # szy:首页/归档依赖文章数据,文章变化时定点清除
+ total = Article.objects.filter(type='a', status='p').count()
+ _delete_paginated_cache('index', total)
+ cache.delete('archives')
+
+
+def _invalidate_category_cache(category: Category):
+ # szy:分类及其子分类都有联动,需要逐个刷新
+ if not category:
+ return
+ _expire_object_cache(category)
+ category_names = [c.name for c in category.get_sub_categorys()]
+ total = Article.objects.filter(
+ category__name__in=category_names,
+ status='p').count()
+ _delete_paginated_cache(f'category_list_{category.name}', total)
+
+
+def _invalidate_tag_cache(tag: Tag):
+ # szy:标签列表缓存独立,按标签名称清理
+ if not tag:
+ return
+ _expire_object_cache(tag)
+ total = Article.objects.filter(
+ tags__name=tag.name,
+ type='a',
+ status='p').distinct().count()
+ _delete_paginated_cache(f'tag_{tag.name}', total)
+
+
+def _invalidate_author_cache(username: str):
+ # szy:作者归档页按用户名 slug 生成缓存键
+ if not username:
+ return
+ author_slug = slugify(username)
+ total = Article.objects.filter(
+ author__username=username,
+ type='a',
+ status='p').count()
+ _delete_paginated_cache(f'author_{author_slug}', total)
+
+
+def _notify_spider(instance):
+ # szy:文章/分类更新后推送搜索引擎,保持抓取实时
+ if settings.TESTING or not hasattr(instance, 'get_full_url'):
+ return
+ try:
+ notify_url = instance.get_full_url()
+ SpiderNotify.baidu_notify([notify_url])
+ except Exception as ex:
+ logger.error("notify sipder", ex)
+
+
+def _invalidate_article_related_cache(article: Article):
+ # szy:文章更新会影响多个页面,这里集中处理相关缓存
+ _expire_object_cache(article)
+ _invalidate_index_cache()
+ _invalidate_author_cache(article.author.username if article.author else None)
+ if article.category_id:
+ _invalidate_category_cache(article.category)
+ for tag in article.tags.all():
+ _invalidate_tag_cache(tag)
+
+
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
emailto = kwargs['emailto']
@@ -51,7 +156,7 @@ def send_email_signal_handler(sender, **kwargs):
log.send_result = False
log.save()
-# szy:处理OAuth用户登录信号
+
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
id = kwargs['id']
@@ -74,22 +179,44 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
- clearcache = False
if isinstance(instance, LogEntry):
return
- if 'get_full_url' in dir(instance):
- is_update_views = update_fields == {'views'}
- if not settings.TESTING and not is_update_views:
- try:
- notify_url = instance.get_full_url()
- SpiderNotify.baidu_notify([notify_url])
- except Exception as ex:
- logger.error("notify sipder", ex)
- if not is_update_views:
- clearcache = True
+ is_update_views = update_fields == {'views'}
+
+ if isinstance(instance, BlogSettings):
+ # szy:站点配置变化时同步刷新缓存和侧边栏
+ cache.delete('get_blog_setting')
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Article):
+ if is_update_views:
+ return
+ _notify_spider(instance)
+ _invalidate_article_related_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Category):
+ _notify_spider(instance)
+ _invalidate_category_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if isinstance(instance, Tag):
+ _notify_spider(instance)
+ _invalidate_tag_cache(instance)
+ _invalidate_nav_and_seo_cache()
+ return
+
+ if hasattr(instance, 'get_full_url') and not is_update_views:
+ _notify_spider(instance)
+ _expire_object_cache(instance)
+ _invalidate_nav_and_seo_cache()
if isinstance(instance, Comment):
if instance.is_enable:
+ # szy:评论通过后清理详情页与评论区缓存,保证实时显示
path = instance.article.get_absolute_url()
site = get_current_site().domain
if site.find(':') > 0:
@@ -110,9 +237,6 @@ def model_post_save_callback(
_thread.start_new_thread(send_comment_email, (instance,))
- if clearcache:
- cache.clear()
-
@receiver(user_logged_in)
@receiver(user_logged_out)
diff --git a/src/DjangoBlog/djangoblog/elasticsearch_backend.py b/src/DjangoBlog/djangoblog/elasticsearch_backend.py
index fbc92b4..4afe498 100644
--- a/src/DjangoBlog/djangoblog/elasticsearch_backend.py
+++ b/src/DjangoBlog/djangoblog/elasticsearch_backend.py
@@ -10,7 +10,7 @@ from blog.models import Article
logger = logging.getLogger(__name__)
-# szy:定义Elasticsearch后端,处理索引和查询
+
class ElasticSearchBackend(BaseSearchBackend):
def __init__(self, connection_alias, **connection_options):
super(
@@ -21,46 +21,38 @@ class ElasticSearchBackend(BaseSearchBackend):
self.manager = ArticleDocumentManager()
self.include_spelling = True
- # szy:获取要索引的模型数据
def _get_models(self, iterable):
models = iterable if iterable and iterable[0] else Article.objects.all()
docs = self.manager.convert_to_doc(models)
return docs
- # szy:创建索引
def _create(self, models):
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
- # szy:删除索引
def _delete(self, models):
for m in models:
m.delete()
return True
- # szy:重组索引
def _rebuild(self, models):
models = models if models else Article.objects.all()
docs = self.manager.convert_to_doc(models)
self.manager.update_docs(docs)
- # szy:更新索引
def update(self, index, iterable, commit=True):
models = self._get_models(iterable)
self.manager.update_docs(models)
- # szy:移除索引
def remove(self, obj_or_string):
models = self._get_models([obj_or_string])
self._delete(models)
- # szy:清空索引
def clear(self, models=None, commit=True):
self.remove(None)
- # szy:获取搜索建议词
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
@@ -79,7 +71,6 @@ class ElasticSearchBackend(BaseSearchBackend):
return ' '.join(keywords)
- # szy:执行搜索并返回结果
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string)
@@ -93,13 +84,10 @@ class ElasticSearchBackend(BaseSearchBackend):
else:
suggestion = query_string
-
- # szy:构建查询条件,匹配标题或正文,设置最小匹配度
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
- # szy:执行搜索查询,过滤已发布的状态和文章类型
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
@@ -109,8 +97,6 @@ class ElasticSearchBackend(BaseSearchBackend):
results = search.execute()
hits = results['hits'].total
raw_results = []
-
- # szy:处理搜索结果,构建SearchResult对象
for raw_result in results['hits']['hits']:
app_label = 'blog'
model_name = 'Article'
@@ -126,8 +112,6 @@ class ElasticSearchBackend(BaseSearchBackend):
**additional_fields)
raw_results.append(result)
facets = {}
-
- # szy:设置拼写建议,如果查询词与建议词不同则返回建议词
spelling_suggestion = None if query_string == suggestion else suggestion
return {
@@ -137,7 +121,7 @@ class ElasticSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
-# szy:定义Elasticsearch查询类
+
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
@@ -145,7 +129,6 @@ class ElasticSearchQuery(BaseSearchQuery):
else:
return force_str(date.strftime('%Y%m%d000000'))
- # szy:清理查询片段,处理保留字和特殊字符
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
@@ -171,35 +154,30 @@ class ElasticSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
-
- # szy:构建查询片段
def build_query_fragment(self, field, filter_type, value):
return value.query_string
- # szy:获取查询结果数量
def get_count(self):
results = self.get_results()
return len(results) if results else 0
- # szy:获取拼写建议
def get_spelling_suggestion(self, preferred_query=None):
return self._spelling_suggestion
- # szy:构建查询参数
def build_params(self, spelling_query=None):
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
-# szy:定义Elasticsearch模型搜索表单
+
class ElasticSearchModelSearchForm(ModelSearchForm):
- # szy:重写搜索方法,支持建议搜索功能
+
def search(self):
# 是否建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
sqs = super().search()
return sqs
-# szy:定义Elasticsearch搜索引擎
+
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend
query = ElasticSearchQuery
diff --git a/src/DjangoBlog/djangoblog/feeds.py b/src/DjangoBlog/djangoblog/feeds.py
index 2675951..8c4e851 100644
--- a/src/DjangoBlog/djangoblog/feeds.py
+++ b/src/DjangoBlog/djangoblog/feeds.py
@@ -6,44 +6,35 @@ from django.utils.feedgenerator import Rss201rev2Feed
from blog.models import Article
from djangoblog.utils import CommonMarkdown
-# szy:定义Django博客的RSS订阅类
+
class DjangoBlogFeed(Feed):
feed_type = Rss201rev2Feed
- # szy:设置Feed的描述、标题和链接
description = '大巧无工,重剑无锋.'
title = "且听风吟 大巧无工,重剑无锋. "
link = "/feed/"
- # szy:获取作者名称(博客主昵称)
def author_name(self):
return get_user_model().objects.first().nickname
- # szy:获取作者链接地址
def author_link(self):
return get_user_model().objects.first().get_absolute_url()
- # szy:获取要包含在Feed中的文章项
def items(self):
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
- # szy:获取文章项的标题
def item_title(self, item):
return item.title
- # szy:获取文章项的描述(使用Markdown渲染正文)
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body)
- # szy:设置Feed的版权信息
def feed_copyright(self):
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
- # szy:获取文章项的链接地址
def item_link(self, item):
return item.get_absolute_url()
- # szy:获取文章项的全局唯一标识符
def item_guid(self, item):
return
diff --git a/src/DjangoBlog/djangoblog/logentryadmin.py b/src/DjangoBlog/djangoblog/logentryadmin.py
index 3b14550..2f6a535 100644
--- a/src/DjangoBlog/djangoblog/logentryadmin.py
+++ b/src/DjangoBlog/djangoblog/logentryadmin.py
@@ -7,26 +7,21 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
-# szy:定义日志条目管理类
+
class LogEntryAdmin(admin.ModelAdmin):
- # szy:设置列表页过滤器字段
list_filter = [
'content_type'
]
- # szy:设置搜索字段
search_fields = [
'object_repr',
'change_message'
]
- # szy:设置列表页可点击链接的字段
list_display_links = [
'action_time',
'get_change_message',
]
-
- # szy:设置列表页显示的字段
list_display = [
'action_time',
'user_link',
@@ -35,22 +30,18 @@ class LogEntryAdmin(admin.ModelAdmin):
'get_change_message',
]
- # szy:禁用添加权限
def has_add_permission(self, request):
return False
- # szy:设置修改权限,仅超级用户或有特定权限的用户可以查看
def has_change_permission(self, request, obj=None):
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
- # szy:禁用删除权限
def has_delete_permission(self, request, obj=None):
return False
- # szy:创建对象链接,如果是删除操作则不生成链接
def object_link(self, obj):
object_link = escape(obj.object_repr)
content_type = obj.content_type
@@ -71,7 +62,6 @@ class LogEntryAdmin(admin.ModelAdmin):
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
- # szy:创建用户链接,链接到用户编辑页面
def user_link(self, obj):
content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user))
@@ -90,12 +80,10 @@ class LogEntryAdmin(admin.ModelAdmin):
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
- # szy:优化查询集,预取关联的内容类型数据
def get_queryset(self, request):
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
- # szy:重写获取actions的方法,移除删除选中项的action
def get_actions(self, request):
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
diff --git a/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py b/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py
index 63cbbe6..df1ce0b 100644
--- a/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py
+++ b/src/DjangoBlog/djangoblog/plugin_manage/base_plugin.py
@@ -1,22 +1,45 @@
import logging
+from pathlib import Path
+
+from django.template import TemplateDoesNotExist
+from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
-# szy:基础插件类,所有插件都应该继承此类
+
class BasePlugin:
- # szy:插件元数据定义
+ # 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
+ PLUGIN_AUTHOR = None
+
+ # 插件配置
+ SUPPORTED_POSITIONS = [] # 支持的显示位置
+ DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高)
+ POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80}
- # szy:插件初始化方法
def __init__(self):
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
+
+ # 设置插件路径
+ self.plugin_dir = self._get_plugin_directory()
+ self.plugin_slug = self._get_plugin_slug()
+
self.init_plugin()
self.register_hooks()
- # szy:插件初始化逻辑,子类可重写实现特定初始化
+ def _get_plugin_directory(self):
+ """获取插件目录路径"""
+ import inspect
+ plugin_file = inspect.getfile(self.__class__)
+ return Path(plugin_file).parent
+
+ def _get_plugin_slug(self):
+ """获取插件标识符(目录名)"""
+ return self.plugin_dir.name
+
def init_plugin(self):
"""
插件初始化逻辑
@@ -24,7 +47,6 @@ class BasePlugin:
"""
logger.info(f'{self.PLUGIN_NAME} initialized.')
- # szy:注册插件钩子,子类可重写实现特定钩子注册
def register_hooks(self):
"""
注册插件钩子
@@ -32,7 +54,129 @@ class BasePlugin:
"""
pass
- # szy:获取插件信息
+ # === 位置渲染系统 ===
+ def render_position_widget(self, position, context, **kwargs):
+ """
+ 根据位置渲染插件组件
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ dict: {'html': 'HTML内容', 'priority': 优先级} 或 None
+ """
+ if position not in self.SUPPORTED_POSITIONS:
+ return None
+
+ # 检查条件显示
+ if not self.should_display(position, context, **kwargs):
+ return None
+
+ # 调用具体的位置渲染方法
+ method_name = f'render_{position}_widget'
+ if hasattr(self, method_name):
+ html = getattr(self, method_name)(context, **kwargs)
+ if html:
+ priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY)
+ return {
+ 'html': html,
+ 'priority': priority,
+ 'plugin_name': self.PLUGIN_NAME
+ }
+
+ return None
+
+ def should_display(self, position, context, **kwargs):
+ """
+ 判断插件是否应该在指定位置显示
+ 子类可重写此方法实现条件显示逻辑
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ bool: 是否显示
+ """
+ return True
+
+ # === 各位置渲染方法 - 子类重写 ===
+ def render_sidebar_widget(self, context, **kwargs):
+ """渲染侧边栏组件"""
+ return None
+
+ def render_article_bottom_widget(self, context, **kwargs):
+ """渲染文章底部组件"""
+ return None
+
+ def render_article_top_widget(self, context, **kwargs):
+ """渲染文章顶部组件"""
+ return None
+
+ def render_header_widget(self, context, **kwargs):
+ """渲染页头组件"""
+ return None
+
+ def render_footer_widget(self, context, **kwargs):
+ """渲染页脚组件"""
+ return None
+
+ def render_comment_before_widget(self, context, **kwargs):
+ """渲染评论前组件"""
+ return None
+
+ def render_comment_after_widget(self, context, **kwargs):
+ """渲染评论后组件"""
+ return None
+
+ # === 模板系统 ===
+ def render_template(self, template_name, context=None):
+ """
+ 渲染插件模板
+
+ Args:
+ template_name: 模板文件名
+ context: 模板上下文
+
+ Returns:
+ HTML字符串
+ """
+ if context is None:
+ context = {}
+
+ template_path = f"plugins/{self.plugin_slug}/{template_name}"
+
+ try:
+ return render_to_string(template_path, context)
+ except TemplateDoesNotExist:
+ logger.warning(f"Plugin template not found: {template_path}")
+ return ""
+
+ # === 静态资源系统 ===
+ def get_static_url(self, static_file):
+ """获取插件静态文件URL"""
+ from django.templatetags.static import static
+ return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}")
+
+ def get_css_files(self):
+ """获取插件CSS文件列表"""
+ return []
+
+ def get_js_files(self):
+ """获取插件JavaScript文件列表"""
+ return []
+
+ def get_head_html(self, context=None):
+ """获取需要插入到
中的HTML内容"""
+ return ""
+
+ def get_body_html(self, context=None):
+ """获取需要插入到底部的HTML内容"""
+ return ""
+
def get_plugin_info(self):
"""
获取插件信息
@@ -41,5 +185,10 @@ class BasePlugin:
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
- 'version': self.PLUGIN_VERSION
+ '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
}
diff --git a/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py b/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py
index ccc9813..8ed4e89 100644
--- a/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py
+++ b/src/DjangoBlog/djangoblog/plugin_manage/hook_constants.py
@@ -1,10 +1,22 @@
-# szy:文章详情加载事件钩子名称
ARTICLE_DETAIL_LOAD = 'article_detail_load'
-# szy:文章创建事件钩子名称
ARTICLE_CREATE = 'article_create'
-# szy:文章更新事件钩子名称
ARTICLE_UPDATE = 'article_update'
-# szy:文章删除事件钩子名称
ARTICLE_DELETE = 'article_delete'
-# szy:文章内容处理钩子名称
-ARTICLE_CONTENT_HOOK_NAME = "the_content"
\ No newline at end of file
+
+ARTICLE_CONTENT_HOOK_NAME = "the_content"
+
+# 位置钩子常量
+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',
+}
+
+# 资源注入钩子
+HEAD_RESOURCES_HOOK = 'head_resources'
+BODY_RESOURCES_HOOK = 'body_resources'
+
diff --git a/src/DjangoBlog/djangoblog/plugin_manage/hooks.py b/src/DjangoBlog/djangoblog/plugin_manage/hooks.py
index 49ab083..d712540 100644
--- a/src/DjangoBlog/djangoblog/plugin_manage/hooks.py
+++ b/src/DjangoBlog/djangoblog/plugin_manage/hooks.py
@@ -1,7 +1,7 @@
import logging
logger = logging.getLogger(__name__)
-# szy:全局钩子字典,存储所有注册的钩子
+
_hooks = {}
@@ -34,15 +34,11 @@ def apply_filters(hook_name: str, value, *args, **kwargs):
执行一个 Filter Hook。
它会把 value 依次传递给所有注册的回调函数进行处理。
"""
- # szy:检查钩子是否存在,存在则依次应用所有过滤器
if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'")
for callback in _hooks[hook_name]:
try:
- # szy:将当前值传递给回调函数进行处理,并更新值
value = callback(value, *args, **kwargs)
except Exception as e:
- # szy:捕获并记录过滤器执行过程中的异常
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
- # szy:返回处理后的最终值
return value
diff --git a/src/DjangoBlog/djangoblog/plugin_manage/loader.py b/src/DjangoBlog/djangoblog/plugin_manage/loader.py
index a732c75..ee750d0 100644
--- a/src/DjangoBlog/djangoblog/plugin_manage/loader.py
+++ b/src/DjangoBlog/djangoblog/plugin_manage/loader.py
@@ -4,21 +4,61 @@ from django.conf import settings
logger = logging.getLogger(__name__)
+# 全局插件注册表
+_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.
"""
- # szy:遍历settings中配置的激活插件列表
+ global _loaded_plugins
+ _loaded_plugins = []
+
for plugin_name in settings.ACTIVE_PLUGINS:
- # szy:构建插件路径
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
- # szy:检查插件目录是否存在且包含plugin.py文件
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
- # szy:动态导入插件模块
- __import__(f'plugins.{plugin_name}.plugin')
- logger.info(f"Successfully loaded plugin: {plugin_name}")
+ # 导入插件模块
+ plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin'])
+
+ # 获取插件实例
+ if hasattr(plugin_module, 'plugin'):
+ plugin_instance = plugin_module.plugin
+ _loaded_plugins.append(plugin_instance)
+ logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}")
+ else:
+ logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance")
+
except ImportError as e:
- # szy:记录插件导入失败的错误信息
- logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
\ No newline at end of file
+ logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
+ except AttributeError as e:
+ logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e)
+ except Exception as e:
+ logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e)
+
+def get_loaded_plugins():
+ """获取所有已加载的插件"""
+ return _loaded_plugins
+
+def get_plugin_by_name(plugin_name):
+ """根据名称获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_name:
+ return plugin
+ return None
+
+def get_plugin_by_slug(plugin_slug):
+ """根据slug获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_slug:
+ return plugin
+ return None
+
+def get_plugins_info():
+ """获取所有插件的信息"""
+ 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]
\ No newline at end of file
diff --git a/src/DjangoBlog/djangoblog/settings.py b/src/DjangoBlog/djangoblog/settings.py
index 50f46b8..667d918 100644
--- a/src/DjangoBlog/djangoblog/settings.py
+++ b/src/DjangoBlog/djangoblog/settings.py
@@ -13,35 +13,47 @@ import os
import sys
from pathlib import Path
+from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
-# szy:将环境变量转换为布尔值
+
def env_to_bool(env, default):
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
-# szy:构建项目基础路径
+
# Build paths inside the project like this: 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/
+_ENV_SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
+# Default to DEBUG=True only when no DJANGO_SECRET_KEY is provided, so local development works out of the box.
+DEBUG = env_to_bool('DJANGO_DEBUG', _ENV_SECRET_KEY is None)
+
+
+def get_secret_key():
+ if _ENV_SECRET_KEY:
+ return _ENV_SECRET_KEY
+ if DEBUG:
+ # Provide a deterministic key for convenience in local development.
+ return 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
+ raise ImproperlyConfigured('DJANGO_SECRET_KEY environment variable is required when DEBUG=False')
+
+
# SECURITY WARNING: keep the secret key used in production secret!
-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
+SECRET_KEY = get_secret_key()
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
-# ALLOWED_HOSTS = []
-ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
+# Allow configuring host whitelist via environment, fallback to safe defaults
+_default_hosts = '127.0.0.1,localhost'
+ALLOWED_HOSTS = [host.strip() for host in os.environ.get('DJANGO_ALLOWED_HOSTS', _default_hosts).split(',') if host.strip()]
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
-# szy:已安装的应用列表
+
INSTALLED_APPS = [
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
@@ -64,7 +76,6 @@ INSTALLED_APPS = [
'djangoblog'
]
-# szy:中间件配置
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@@ -84,7 +95,6 @@ MIDDLEWARE = [
ROOT_URLCONF = 'djangoblog.urls'
-# szy:模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
@@ -108,21 +118,22 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application'
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
-# szy:MySQL数据库配置
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',
+ 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '12345678',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
- 'charset': 'utf8mb4'},
+ 'charset': 'utf8mb4',
+ 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"
+ },
+ 'CONN_MAX_AGE': int(os.environ.get('DJANGO_DB_CONN_MAX_AGE', 60)),
}}
-# szy:密码验证配置
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
@@ -141,26 +152,23 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
-# szy:国际化配置 - 支持的语言
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
-# szy:本地化文件路径
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
-# szy:默认语言代码
+
LANGUAGE_CODE = 'zh-hans'
-# szy:时区设置
TIME_ZONE = 'Asia/Shanghai'
-# szy:启用国际化
+
USE_I18N = True
-# szy:启用本地化
+
USE_L10N = True
-# szy:使用时区支持
+
USE_TZ = False
# Static files (CSS, JavaScript, Images)
@@ -184,10 +192,15 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
+# szy:同时收集项目静态目录与插件资源,避免部署缺文件
+STATICFILES_DIRS = [
+ STATICFILES,
+ os.path.join(BASE_DIR, 'plugins'),
+]
+
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
-# szy:时间和日期格式
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
@@ -196,13 +209,11 @@ BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
-# szy:分页设置
# paginate
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
-# szy:缓存配置
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
@@ -218,12 +229,11 @@ if os.environ.get("DJANGO_REDIS_URL"):
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
-# szy:站点ID
+
SITE_ID = 1
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
-# szy:邮件配置
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
@@ -240,7 +250,6 @@ ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
-# szy:日志配置
LOG_PATH = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
@@ -297,11 +306,6 @@ LOGGING = {
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
- },
- 'django.request': {
- 'handlers': ['mail_admins'],
- 'level': 'ERROR',
- 'propagate': False,
}
}
}
@@ -312,26 +316,66 @@ STATICFILES_FINDERS = (
# other
'compressor.finders.CompressorFinder',
)
-# szy:启用压缩
COMPRESS_ENABLED = True
-# COMPRESS_OFFLINE = True
+# 根据环境变量决定是否启用离线压缩
+COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
+# 压缩输出目录
+COMPRESS_OUTPUT_DIR = 'compressed'
+# 压缩文件名模板 - 包含哈希值用于缓存破坏
+COMPRESS_CSS_HASHING_METHOD = 'mtime'
+COMPRESS_JS_HASHING_METHOD = 'mtime'
+
+# 高级CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
- # creates absolute urls from relative ones
+ # 创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
- # css minimizer
- 'compressor.filters.cssmin.CSSMinFilter'
+ # CSS压缩器 - 高压缩等级
+ 'compressor.filters.cssmin.CSSCompressorFilter',
]
+
+# 高级JS压缩过滤器
COMPRESS_JS_FILTERS = [
- 'compressor.filters.jsmin.JSMinFilter'
+ # JS压缩器 - 高压缩等级
+ 'compressor.filters.jsmin.SlimItFilter',
]
-# szy:媒体文件配置
+# 压缩缓存配置
+COMPRESS_CACHE_BACKEND = 'default'
+COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
+
+# 预压缩配置
+COMPRESS_PRECOMPILERS = (
+ # 支持SCSS/SASS
+ ('text/x-scss', 'django_libsass.SassCompiler'),
+ ('text/x-sass', 'django_libsass.SassCompiler'),
+)
+
+# 压缩性能优化
+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'
+
+# 静态文件缓存配置
+STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
+
+# 浏览器缓存配置(通过中间件或服务器配置)
+COMPRESS_URL = STATIC_URL
+COMPRESS_ROOT = STATIC_ROOT
+
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
+AVATAR_ROOT = os.path.join(MEDIA_ROOT, 'avatars')
+AVATAR_URL = f'{MEDIA_URL}avatars/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
+
+
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
@@ -346,7 +390,6 @@ if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
},
}
-# szy:插件系统配置
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
@@ -354,5 +397,7 @@ ACTIVE_PLUGINS = [
'reading_time',
'external_links',
'view_count',
- 'seo_optimizer'
-]
\ No newline at end of file
+ 'seo_optimizer',
+ 'image_lazy_loading',
+ 'article_recommendation',
+]
diff --git a/src/DjangoBlog/djangoblog/sitemap.py b/src/DjangoBlog/djangoblog/sitemap.py
index bb2ed3b..8b7d446 100644
--- a/src/DjangoBlog/djangoblog/sitemap.py
+++ b/src/DjangoBlog/djangoblog/sitemap.py
@@ -3,72 +3,57 @@ from django.urls import reverse
from blog.models import Article, Category, Tag
-# szy:静态视图站点地图类
+
class StaticViewSitemap(Sitemap):
priority = 0.5
changefreq = 'daily'
- # szy:定义包含在站点地图中的项
def items(self):
return ['blog:index', ]
- # szy:获取项的URL位置
def location(self, item):
return reverse(item)
-# szy:文章站点地图类
+
class ArticleSiteMap(Sitemap):
- # szy:设置文章更新频率和优先级
changefreq = "monthly"
priority = "0.6"
- # szy:获取所有已发布的文章
def items(self):
return Article.objects.filter(status='p')
- # szy:获取文章的最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
-# szy:分类站点地图类
+
class CategorySiteMap(Sitemap):
- # szy:设置分类更新频率和优先级
changefreq = "Weekly"
priority = "0.6"
- # szy:获取所有分类
def items(self):
return Category.objects.all()
- # szy:获取分类的最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
-# szy:标签站点地图类
+
class TagSiteMap(Sitemap):
- # szy:设置标签更新频率和优先级
changefreq = "Weekly"
priority = "0.3"
- # szy:获取所有标签
def items(self):
return Tag.objects.all()
- # szy:获取标签的最后修改时间
def lastmod(self, obj):
return obj.last_modify_time
-# szy:用户站点地图类
class UserSiteMap(Sitemap):
- # szy:设置用户更新频率和优先级
changefreq = "Weekly"
priority = "0.3"
- # szy:获取所有有文章的作者(去重)
def items(self):
return list(set(map(lambda x: x.author, Article.objects.all())))
- # szy:获取用户的注册时间
def lastmod(self, obj):
return obj.date_joined
diff --git a/src/DjangoBlog/djangoblog/spider_notify.py b/src/DjangoBlog/djangoblog/spider_notify.py
index c2a8864..7b909e9 100644
--- a/src/DjangoBlog/djangoblog/spider_notify.py
+++ b/src/DjangoBlog/djangoblog/spider_notify.py
@@ -6,21 +6,16 @@ from django.conf import settings
logger = logging.getLogger(__name__)
-# szy:蜘蛛爬虫通知类,用于向搜索引擎推送URL
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
try:
- # szy:将URL列表转换为换行分隔的字符串
data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
- # szy:记录推送结果日志
logger.info(result.text)
except Exception as e:
- # szy:记录推送异常日志
logger.error(e)
- # szy:推送单个URL的便捷方法
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
diff --git a/src/DjangoBlog/djangoblog/tests.py b/src/DjangoBlog/djangoblog/tests.py
index 9bb0876..01237d9 100644
--- a/src/DjangoBlog/djangoblog/tests.py
+++ b/src/DjangoBlog/djangoblog/tests.py
@@ -3,18 +3,13 @@ from django.test import TestCase
from djangoblog.utils import *
-# szy:DjangoBlog测试类,用于测试工具函数
class DjangoBlogTest(TestCase):
- # szy:测试初始化设置
def setUp(self):
pass
- # szy:测试工具函数功能
def test_utils(self):
- # szy:测试SHA256加密函数
md5 = get_sha256('test')
self.assertIsNotNone(md5)
- # szy:测试Markdown解析函数
c = CommonMarkdown.get_markdown('''
# Title1
@@ -29,8 +24,6 @@ class DjangoBlogTest(TestCase):
''')
self.assertIsNotNone(c)
-
- # szy:测试字典转URL参数字符串函数
d = {
'd': 'key1',
'd2': 'key2'
diff --git a/src/DjangoBlog/djangoblog/urls.py b/src/DjangoBlog/djangoblog/urls.py
index d414ff4..cd43ce3 100644
--- a/src/DjangoBlog/djangoblog/urls.py
+++ b/src/DjangoBlog/djangoblog/urls.py
@@ -20,6 +20,8 @@ from django.contrib.sitemaps.views import sitemap
from django.urls import path, include
from django.urls import re_path
from haystack.views import search_view_factory
+from django.http import JsonResponse
+import time
from blog.views import EsSearchView
from djangoblog.admin_site import admin_site
@@ -27,7 +29,6 @@ from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
-# szy:定义站点地图配置
sitemaps = {
'blog': ArticleSiteMap,
@@ -37,45 +38,42 @@ sitemaps = {
'static': StaticViewSitemap
}
-# szy:定义错误处理视图
+# szy:自定义错误页面,保证异常提示保持博客风格
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
-handle403 = 'blog.views.permission_denied_view'
+handler403 = 'blog.views.permission_denied_view'
+
+
+def health_check(request):
+ """
+ 健康检查接口
+ 简单返回服务健康状态
+ """
+ return JsonResponse({
+ 'status': 'healthy',
+ 'timestamp': time.time()
+ })
-# szy:基础URL模式配置
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')),
+ path('health/', health_check, name='health_check'),
]
-# szy:国际化URL模式配置
urlpatterns += i18n_patterns(
- # szy:管理员后台URL
re_path(r'^admin/', admin_site.urls),
- # szy:博客应用URL
re_path(r'', include('blog.urls', namespace='blog')),
- # szy:Markdown编辑器URL
re_path(r'mdeditor/', include('mdeditor.urls')),
- # szy:评论系统URL
re_path(r'', include('comments.urls', namespace='comment')),
- # szy:账户管理URL
re_path(r'', include('accounts.urls', namespace='account')),
- # szy:OAuth认证URL
re_path(r'', include('oauth.urls', namespace='oauth')),
- # szy:站点地图URL
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
- # szy:Feed订阅URL
re_path(r'^feed/$', DjangoBlogFeed()),
- # szy:RSS订阅URL
re_path(r'^rss/$', DjangoBlogFeed()),
- # szy:搜索功能URL,使用ElasticSearch视图和表单
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
name='search'),
- # szy:服务器管理URL
re_path(r'', include('servermanager.urls', namespace='servermanager')),
- # szy:位置追踪URL
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
-# szy:调试模式下添加媒体文件服务
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
diff --git a/src/DjangoBlog/djangoblog/utils.py b/src/DjangoBlog/djangoblog/utils.py
index f2c9a3a..50e221e 100644
--- a/src/DjangoBlog/djangoblog/utils.py
+++ b/src/DjangoBlog/djangoblog/utils.py
@@ -19,18 +19,18 @@ from django.templatetags.static import static
logger = logging.getLogger(__name__)
-# szy:获取最大文章ID和评论ID
+
def get_max_articleid_commentid():
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
-# szy:计算字符串的SHA256哈希值
+
def get_sha256(str):
m = sha256(str.encode('utf-8'))
return m.hexdigest()
-# szy:缓存装饰器,用于函数结果缓存
+
def cache_decorator(expiration=3 * 60):
def wrapper(func):
def news(*args, **kwargs):
@@ -67,7 +67,6 @@ def cache_decorator(expiration=3 * 60):
return wrapper
-# szy:刷新视图缓存
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
@@ -92,15 +91,14 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
return True
return False
-# szy:获取当前站点信息(带缓存)
+
@cache_decorator()
def get_current_site():
site = Site.objects.get_current()
return site
-# szy:通用Markdown处理类
+
class CommonMarkdown:
- # szy:转换Markdown文本为HTML
@staticmethod
def _convert_markdown(value):
md = markdown.Markdown(
@@ -115,21 +113,17 @@ class CommonMarkdown:
toc = md.toc
return body, toc
-
- # szy:获取带目录的Markdown内容
@staticmethod
def get_markdown_with_toc(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
- # szy:获取Markdown内容
@staticmethod
def get_markdown(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body
-# szy:发送邮件函数
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
@@ -138,13 +132,12 @@ def send_email(emailto, title, content):
title=title,
content=content)
-# szy:生成6位随机数字验证码
+
def generate_code() -> str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
-# szy:将字典转换为URL参数字符串
def parse_dict_to_url(dict):
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
@@ -152,7 +145,6 @@ def parse_dict_to_url(dict):
return url
-# szy:获取博客设置
def get_blog_setting():
value = cache.get('get_blog_setting')
if value:
@@ -181,7 +173,6 @@ def get_blog_setting():
return value
-# szy:保存用户头像到本地
def save_user_avatar(url):
'''
保存用户头像
@@ -191,26 +182,25 @@ def save_user_avatar(url):
logger.info(url)
try:
- basedir = os.path.join(settings.STATICFILES, 'avatar')
+ basedir = settings.AVATAR_ROOT
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
- if not os.path.exists(basedir):
- os.makedirs(basedir)
+ os.makedirs(basedir, exist_ok=True)
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg'
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
- with open(os.path.join(basedir, save_filename), 'wb+') as file:
+ avatar_path = os.path.join(basedir, save_filename)
+ with open(avatar_path, 'wb+') as file:
file.write(rsp.content)
- return static('avatar/' + save_filename)
+ return f'{settings.AVATAR_URL}{save_filename}'
except Exception as e:
logger.error(e)
return static('blog/img/avatar.png')
-# szy:删除侧边栏缓存
def delete_sidebar_cache():
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
@@ -219,14 +209,12 @@ def delete_sidebar_cache():
cache.delete(k)
-# szy:删除视图缓存
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
-# szy:获取资源URL
def get_resource_url():
if settings.STATIC_URL:
return settings.STATIC_URL
@@ -236,9 +224,49 @@ def get_resource_url():
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
- 'h2', 'p']
-ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']}
+ 'h2', 'p', 'span', 'div']
+
+# 安全的class值白名单 - 只允许代码高亮相关的class
+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',
+ 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn',
+ 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2',
+ 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
+]
+
+def class_filter(tag, name, value):
+ """自定义class属性过滤器"""
+ if name == 'class':
+ # 只允许预定义的安全class值
+ allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
+ return ' '.join(allowed_classes) if allowed_classes else False
+ return value
+
+# 安全的属性白名单
+ALLOWED_ATTRIBUTES = {
+ 'a': ['href', 'title'],
+ 'abbr': ['title'],
+ 'acronym': ['title'],
+ 'span': class_filter,
+ 'div': class_filter,
+ 'pre': class_filter,
+ 'code': class_filter
+}
+
+# 安全的协议白名单 - 防止javascript:等危险协议
+ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
-# szy:清洗HTML内容,移除不安全的标签和属性
def sanitize_html(html):
- return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
+ """
+ 安全的HTML清理函数
+ 使用bleach库进行白名单过滤,防止XSS攻击
+ """
+ return bleach.clean(
+ html,
+ tags=ALLOWED_TAGS,
+ attributes=ALLOWED_ATTRIBUTES,
+ protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
+ strip=True, # 移除不允许的标签而不是转义
+ strip_comments=True # 移除HTML注释
+ )
diff --git a/src/DjangoBlog/djangoblog/whoosh_cn_backend.py b/src/DjangoBlog/djangoblog/whoosh_cn_backend.py
index 88f1770..04e3f7f 100644
--- a/src/DjangoBlog/djangoblog/whoosh_cn_backend.py
+++ b/src/DjangoBlog/djangoblog/whoosh_cn_backend.py
@@ -40,7 +40,6 @@ except ImportError:
raise MissingDependency(
"The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
-# szy:处理最低版本要求
# Handle minimum requirement.
if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
raise MissingDependency(
@@ -48,13 +47,12 @@ if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
# Bubble up the correct error.
-# szy:日期时间正则表达式,用于解析日期字符串
DATETIME_REGEX = re.compile(
'^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$')
LOCALS = threading.local()
LOCALS.RAM_STORE = None
-# szy:自定义Whoosh HTML格式化器,用于保持跨后端一致的高亮结果
+
class WhooshHtmlFormatter(HtmlFormatter):
"""
This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
@@ -64,7 +62,6 @@ class WhooshHtmlFormatter(HtmlFormatter):
template = '<%(tag)s>%(t)s%(tag)s>'
-# szy:Whoosh搜索后端主类
class WhooshSearchBackend(BaseSearchBackend):
# Word reserved by Whoosh for special use.
RESERVED_WORDS = (
@@ -82,7 +79,6 @@ class WhooshSearchBackend(BaseSearchBackend):
'[', ']', '^', '"', '~', '*', '?', ':', '.',
)
- # szy:初始化Whoosh后端
def __init__(self, connection_alias, **connection_options):
super(
WhooshSearchBackend,
@@ -107,7 +103,6 @@ class WhooshSearchBackend(BaseSearchBackend):
self.log = logging.getLogger('haystack')
- # szy:设置Whoosh索引和配置
def setup(self):
"""
Defers loading until needed.
@@ -115,7 +110,6 @@ class WhooshSearchBackend(BaseSearchBackend):
from haystack import connections
new_index = False
- # szy:确保索引目录存在,如果不存在则创建
# Make sure the index is there.
if self.use_file_storage and not os.path.exists(self.path):
os.makedirs(self.path)
@@ -126,7 +120,6 @@ class WhooshSearchBackend(BaseSearchBackend):
"The path to your Whoosh index '%s' is not writable for the current user/group." %
self.path)
- # szy:根据配置选择文件存储或内存存储
if self.use_file_storage:
self.storage = FileStorage(self.path)
else:
@@ -141,7 +134,6 @@ class WhooshSearchBackend(BaseSearchBackend):
connections[self.connection_alias].get_unified_index().all_searchfields())
self.parser = QueryParser(self.content_field_name, schema=self.schema)
- # szy:创建或打开索引
if new_index is True:
self.index = self.storage.create_index(self.schema)
else:
@@ -152,7 +144,6 @@ class WhooshSearchBackend(BaseSearchBackend):
self.setup_complete = True
- # szy:构建Whoosh schema,定义字段类型
def build_schema(self, fields):
schema_fields = {
ID: WHOOSH_ID(stored=True, unique=True),
@@ -208,7 +199,6 @@ class WhooshSearchBackend(BaseSearchBackend):
return (content_field_name, Schema(**schema_fields))
- # szy:更新索引
def update(self, index, iterable, commit=True):
if not self.setup_complete:
self.setup()
@@ -216,7 +206,6 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
writer = AsyncWriter(self.index)
- # szy:遍历对象并更新索引
for obj in iterable:
try:
doc = index.full_prepare(obj)
@@ -255,7 +244,6 @@ class WhooshSearchBackend(BaseSearchBackend):
# otherwise.
writer.commit()
- # szy:从索引中移除对象
def remove(self, obj_or_string, commit=True):
if not self.setup_complete:
self.setup()
@@ -278,7 +266,6 @@ class WhooshSearchBackend(BaseSearchBackend):
e,
exc_info=True)
- # szy:清空索引
def clear(self, models=None, commit=True):
if not self.setup_complete:
self.setup()
@@ -316,8 +303,6 @@ class WhooshSearchBackend(BaseSearchBackend):
self.log.error(
"Failed to clear Whoosh index: %s", e, exc_info=True)
-
- # szy:删除整个索引
def delete_index(self):
# Per the Whoosh mailing list, if wiping out everything from the index,
# it's much more efficient to simply delete the index files.
@@ -326,11 +311,9 @@ class WhooshSearchBackend(BaseSearchBackend):
elif not self.use_file_storage:
self.storage.clean()
- # szy:重新创建所有内容
# Recreate everything.
self.setup()
- # szy:优化索引
def optimize(self):
if not self.setup_complete:
self.setup()
@@ -338,14 +321,12 @@ class WhooshSearchBackend(BaseSearchBackend):
self.index = self.index.refresh()
self.index.optimize()
- # szy:计算分页信息
def calculate_page(self, start_offset=0, end_offset=None):
# Prevent against Whoosh throwing an error. Requires an end_offset
# greater than 0.
if end_offset is not None and end_offset <= 0:
end_offset = 1
- # szy:确定页码
# Determine the page.
page_num = 0
@@ -364,8 +345,6 @@ class WhooshSearchBackend(BaseSearchBackend):
page_num += 1
return page_num, page_length
-
- # szy:执行搜索查询
@log_query
def search(
self,
@@ -409,8 +388,6 @@ class WhooshSearchBackend(BaseSearchBackend):
reverse = False
-
- # szy:处理排序
if sort_by is not None:
# Determine if we need to reverse the results and if Whoosh can
# handle what it's being asked to sort by. Reversing is an
@@ -583,8 +560,6 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
-
- # szy:实现"更多类似此结果"功能
def more_like_this(
self,
model_instance,
@@ -700,8 +675,6 @@ class WhooshSearchBackend(BaseSearchBackend):
return results
-
- # szy:处理原始搜索结果,转换为Haystack格式
def _process_results(
self,
raw_page,
@@ -794,8 +767,6 @@ class WhooshSearchBackend(BaseSearchBackend):
'spelling_suggestion': spelling_suggestion,
}
-
- # szy:创建拼写建议
def create_spelling_suggestion(self, query_string):
spelling_suggestion = None
reader = self.index.reader()
@@ -848,8 +819,6 @@ class WhooshSearchBackend(BaseSearchBackend):
value = force_str(value)
return value
-
- # szy:将Whoosh值转换为Python原生值
def _to_python(self, value):
"""
Converts values from Whoosh to native Python values.
@@ -901,7 +870,6 @@ class WhooshSearchBackend(BaseSearchBackend):
return value
-# szy:Whoosh搜索查询类
class WhooshSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
@@ -909,8 +877,6 @@ class WhooshSearchQuery(BaseSearchQuery):
else:
return force_str(date.strftime('%Y%m%d000000'))
-
- # szy:清理查询片段,处理保留字和特殊字符
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
@@ -936,8 +902,6 @@ class WhooshSearchQuery(BaseSearchQuery):
return ' '.join(cleaned_words)
-
- # szy:构建查询片段
def build_query_fragment(self, field, filter_type, value):
from haystack import connections
query_frag = ''
@@ -1075,7 +1039,6 @@ class WhooshSearchQuery(BaseSearchQuery):
# value = self.backend._from_python(value)
-# szy:Whoosh搜索引擎类
class WhooshEngine(BaseEngine):
backend = WhooshSearchBackend
query = WhooshSearchQuery
diff --git a/src/DjangoBlog/djangoblog/wsgi.py b/src/DjangoBlog/djangoblog/wsgi.py
index 6795850..2295efd 100644
--- a/src/DjangoBlog/djangoblog/wsgi.py
+++ b/src/DjangoBlog/djangoblog/wsgi.py
@@ -11,8 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
-# szy:设置Django的默认设置模块环境变量
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
-# szy:获取WSGI应用实例,用于服务器部署
application = get_wsgi_application()
diff --git a/src/DjangoBlog/elasticsearch_backend.py b/src/DjangoBlog/elasticsearch_backend.py
new file mode 100644
index 0000000..4afe498
--- /dev/null
+++ b/src/DjangoBlog/elasticsearch_backend.py
@@ -0,0 +1,183 @@
+from django.utils.encoding import force_str
+from elasticsearch_dsl import Q
+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 blog.documents import ArticleDocument, ArticleDocumentManager
+from blog.models import Article
+
+logger = logging.getLogger(__name__)
+
+
+class ElasticSearchBackend(BaseSearchBackend):
+ def __init__(self, connection_alias, **connection_options):
+ super(
+ ElasticSearchBackend,
+ self).__init__(
+ connection_alias,
+ **connection_options)
+ self.manager = ArticleDocumentManager()
+ self.include_spelling = True
+
+ def _get_models(self, iterable):
+ models = iterable if iterable and iterable[0] else Article.objects.all()
+ docs = self.manager.convert_to_doc(models)
+ return docs
+
+ def _create(self, models):
+ self.manager.create_index()
+ docs = self._get_models(models)
+ self.manager.rebuild(docs)
+
+ def _delete(self, models):
+ for m in models:
+ m.delete()
+ return True
+
+ def _rebuild(self, models):
+ models = models if models else Article.objects.all()
+ docs = self.manager.convert_to_doc(models)
+ self.manager.update_docs(docs)
+
+ def update(self, index, iterable, commit=True):
+
+ models = self._get_models(iterable)
+ self.manager.update_docs(models)
+
+ def remove(self, obj_or_string):
+ models = self._get_models([obj_or_string])
+ self._delete(models)
+
+ def clear(self, models=None, commit=True):
+ self.remove(None)
+
+ @staticmethod
+ def get_suggestion(query: str) -> str:
+ """获取推荐词, 如果没有找到添加原搜索词"""
+
+ search = ArticleDocument.search() \
+ .query("match", body=query) \
+ .suggest('suggest_search', query, term={'field': 'body'}) \
+ .execute()
+
+ keywords = []
+ for suggest in search.suggest.suggest_search:
+ if suggest["options"]:
+ keywords.append(suggest["options"][0]["text"])
+ else:
+ keywords.append(suggest["text"])
+
+ return ' '.join(keywords)
+
+ @log_query
+ def search(self, query_string, **kwargs):
+ logger.info('search query_string:' + query_string)
+
+ start_offset = kwargs.get('start_offset')
+ end_offset = kwargs.get('end_offset')
+
+ # 推荐词搜索
+ if getattr(self, "is_suggest", None):
+ suggestion = self.get_suggestion(query_string)
+ else:
+ suggestion = query_string
+
+ q = Q('bool',
+ should=[Q('match', body=suggestion), Q('match', title=suggestion)],
+ minimum_should_match="70%")
+
+ search = ArticleDocument.search() \
+ .query('bool', filter=[q]) \
+ .filter('term', status='p') \
+ .filter('term', type='a') \
+ .source(False)[start_offset: end_offset]
+
+ results = search.execute()
+ hits = results['hits'].total
+ raw_results = []
+ for raw_result in results['hits']['hits']:
+ app_label = 'blog'
+ model_name = 'Article'
+ additional_fields = {}
+
+ result_class = SearchResult
+
+ result = result_class(
+ app_label,
+ model_name,
+ raw_result['_id'],
+ raw_result['_score'],
+ **additional_fields)
+ raw_results.append(result)
+ facets = {}
+ spelling_suggestion = None if query_string == suggestion else suggestion
+
+ return {
+ 'results': raw_results,
+ 'hits': hits,
+ 'facets': facets,
+ 'spelling_suggestion': spelling_suggestion,
+ }
+
+
+class ElasticSearchQuery(BaseSearchQuery):
+ def _convert_datetime(self, date):
+ if hasattr(date, 'hour'):
+ return force_str(date.strftime('%Y%m%d%H%M%S'))
+ else:
+ 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.
+ """
+ words = query_fragment.split()
+ cleaned_words = []
+
+ for word in words:
+ if word in self.backend.RESERVED_WORDS:
+ word = word.replace(word, word.lower())
+
+ for char in self.backend.RESERVED_CHARACTERS:
+ if char in word:
+ word = "'%s'" % word
+ break
+
+ cleaned_words.append(word)
+
+ return ' '.join(cleaned_words)
+
+ def build_query_fragment(self, field, filter_type, value):
+ return value.query_string
+
+ def get_count(self):
+ results = self.get_results()
+ return len(results) if results else 0
+
+ def get_spelling_suggestion(self, preferred_query=None):
+ return self._spelling_suggestion
+
+ def build_params(self, spelling_query=None):
+ kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
+ return kwargs
+
+
+class ElasticSearchModelSearchForm(ModelSearchForm):
+
+ def search(self):
+ # 是否建议搜索
+ self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
+ sqs = super().search()
+ return sqs
+
+
+class ElasticSearchEngine(BaseEngine):
+ backend = ElasticSearchBackend
+ query = ElasticSearchQuery
diff --git a/src/DjangoBlog/feeds.py b/src/DjangoBlog/feeds.py
new file mode 100644
index 0000000..8c4e851
--- /dev/null
+++ b/src/DjangoBlog/feeds.py
@@ -0,0 +1,40 @@
+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
+
+
+class DjangoBlogFeed(Feed):
+ feed_type = Rss201rev2Feed
+
+ description = '大巧无工,重剑无锋.'
+ title = "且听风吟 大巧无工,重剑无锋. "
+ link = "/feed/"
+
+ def author_name(self):
+ return get_user_model().objects.first().nickname
+
+ def author_link(self):
+ return get_user_model().objects.first().get_absolute_url()
+
+ def items(self):
+ return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
+
+ def item_title(self, item):
+ return item.title
+
+ def item_description(self, item):
+ return CommonMarkdown.get_markdown(item.body)
+
+ def feed_copyright(self):
+ now = timezone.now()
+ return "Copyright© {year} 且听风吟".format(year=now.year)
+
+ def item_link(self, item):
+ return item.get_absolute_url()
+
+ def item_guid(self, item):
+ return
diff --git a/src/DjangoBlog/logentryadmin.py b/src/DjangoBlog/logentryadmin.py
new file mode 100644
index 0000000..2f6a535
--- /dev/null
+++ b/src/DjangoBlog/logentryadmin.py
@@ -0,0 +1,91 @@
+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 _
+
+
+class LogEntryAdmin(admin.ModelAdmin):
+ list_filter = [
+ 'content_type'
+ ]
+
+ search_fields = [
+ 'object_repr',
+ 'change_message'
+ ]
+
+ list_display_links = [
+ 'action_time',
+ 'get_change_message',
+ ]
+ list_display = [
+ 'action_time',
+ 'user_link',
+ 'content_type',
+ 'object_link',
+ 'get_change_message',
+ ]
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return (
+ request.user.is_superuser or
+ request.user.has_perm('admin.change_logentry')
+ ) and request.method != 'POST'
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def object_link(self, obj):
+ object_link = escape(obj.object_repr)
+ content_type = obj.content_type
+
+ if obj.action_flag != DELETION and content_type is not None:
+ # try returning an actual link instead of object repr string
+ try:
+ url = reverse(
+ 'admin:{}_{}_change'.format(content_type.app_label,
+ content_type.model),
+ args=[obj.object_id]
+ )
+ object_link = '{}'.format(url, object_link)
+ except NoReverseMatch:
+ pass
+ return mark_safe(object_link)
+
+ object_link.admin_order_field = 'object_repr'
+ object_link.short_description = _('object')
+
+ def user_link(self, obj):
+ content_type = ContentType.objects.get_for_model(type(obj.user))
+ user_link = escape(force_str(obj.user))
+ try:
+ # try returning an actual link instead of object repr string
+ url = reverse(
+ 'admin:{}_{}_change'.format(content_type.app_label,
+ content_type.model),
+ args=[obj.user.pk]
+ )
+ user_link = '{}'.format(url, user_link)
+ except NoReverseMatch:
+ pass
+ return mark_safe(user_link)
+
+ user_link.admin_order_field = 'user'
+ user_link.short_description = _('user')
+
+ def get_queryset(self, request):
+ queryset = super(LogEntryAdmin, self).get_queryset(request)
+ return queryset.prefetch_related('content_type')
+
+ def get_actions(self, request):
+ actions = super(LogEntryAdmin, self).get_actions(request)
+ if 'delete_selected' in actions:
+ del actions['delete_selected']
+ return actions
diff --git a/src/DjangoBlog/owntracks/admin.py b/src/DjangoBlog/owntracks/admin.py
index 91e6673..ea2071b 100644
--- a/src/DjangoBlog/owntracks/admin.py
+++ b/src/DjangoBlog/owntracks/admin.py
@@ -1,9 +1,26 @@
-# 导入Django管理后台模块 #zqx: 引入Django的admin模块,用于注册和管理模型
from django.contrib import admin
+from .models import OwnTrackLog
-# 注册你的模型到管理后台(待实现) #zqx: 这是一个占位注释,提示需要注册模型到管理后台
-# 定义OwnTrackLogs模型在Django管理后台中的配置类 #zqx: 创建OwnTrackLogsAdmin类,继承自ModelAdmin,用于配置OwnTrackLog模型在管理后台的行为
-class OwnTrackLogsAdmin(admin.ModelAdmin):
- # 目前为空,后续可以添加管理后台的自定义配置 #zqx: 当前类体为空,预留空间用于添加管理后台的自定义配置选项
- pass
+@admin.register(OwnTrackLog)
+class OwnTrackLogAdmin(admin.ModelAdmin):
+ """
+ OwnTrackLog模型管理配置
+ """
+ list_display = ('tid', 'lat', 'lon', 'creation_time', 'accuracy')
+ list_filter = ('tid', 'creation_time')
+ search_fields = ('tid',)
+ date_hierarchy = 'creation_time'
+ readonly_fields = ('creation_time',)
+ fieldsets = (
+ ('基本信息', {
+ 'fields': ('tid', 'creation_time')
+ }),
+ ('位置信息', {
+ 'fields': ('lat', 'lon', 'accuracy', 'battery')
+ }),
+ )
+
+ def get_queryset(self, request):
+ """优化查询,减少数据库访问"""
+ return super().get_queryset(request).select_related()
diff --git a/src/DjangoBlog/owntracks/models.py b/src/DjangoBlog/owntracks/models.py
index 05bfebb..2910e83 100644
--- a/src/DjangoBlog/owntracks/models.py
+++ b/src/DjangoBlog/owntracks/models.py
@@ -1,32 +1,70 @@
-# 导入Django数据库模型模块 #zqx: 引入Django的models模块,用于定义数据库模型
from django.db import models
-# 从Django时区工具中导入now函数,用于获取当前时间 #zqx: 从django.utils.timezone导入now函数,用于设置默认时间值
from django.utils.timezone import now
+from django.core.validators import MinValueValidator, MaxValueValidator
-# Create your models here. #zqx: Django模型定义的标准注释,标记模型定义区域开始
-# 定义OwnTrackLog数据模型,继承自Django的Model基类 #zqx: 定义OwnTrackLog类,继承自models.Model,创建一个数据库模型
class OwnTrackLog(models.Model):
- # 用户标识字段,字符类型,最大长度100,不允许为空 #zqx: 定义tid字段,类型为CharField,最大长度100,null=False表示不允许为空,verbose_name设置字段显示名称
- tid = models.CharField(max_length=100, null=False, verbose_name='用户')
- # 纬度字段,浮点数类型 #zqx: 定义lat字段,类型为FloatField,verbose_name设置字段显示名称
- lat = models.FloatField(verbose_name='纬度')
- # 经度字段,浮点数类型 #zqx: 定义lon字段,类型为FloatField,verbose_name设置字段显示名称
- lon = models.FloatField(verbose_name='经度')
- # 创建时间字段,日期时间类型,默认值为当前时间 #zqx: 定义creation_time字段,类型为DateTimeField,第一个参数是字段名,default设置默认值为now函数
- creation_time = models.DateTimeField('创建时间', default=now)
+ """
+ OwnTracks位置数据模型
+ 用于存储移动设备上报的GPS位置信息
+ """
+ # 添加更严格的字段验证
+ tid = models.CharField(
+ max_length=100,
+ null=False,
+ verbose_name='用户ID',
+ help_text='设备或用户唯一标识'
+ )
+ lat = models.FloatField(
+ verbose_name='纬度',
+ validators=[
+ MinValueValidator(-90.0),
+ MaxValueValidator(90.0)
+ ],
+ help_text='纬度坐标,范围-90到90'
+ )
+ lon = models.FloatField(
+ verbose_name='经度',
+ validators=[
+ MinValueValidator(-180.0),
+ MaxValueValidator(180.0)
+ ],
+ help_text='经度坐标,范围-180到180'
+ )
+ creation_time = models.DateTimeField(
+ '创建时间',
+ default=now,
+ db_index=True, # zqx: 添加索引提升查询性能
+ help_text='数据创建时间'
+ )
+
+ # zqx: 添加额外有用字段
+ accuracy = models.FloatField(
+ '精度',
+ null=True,
+ blank=True,
+ help_text='GPS定位精度(米)'
+ )
+ battery = models.FloatField(
+ '电量',
+ null=True,
+ blank=True,
+ help_text='设备电量百分比'
+ )
- # 定义对象的字符串表示方法,返回用户的tid #zqx: 定义__str__方法,返回对象的tid属性,用于在管理后台等地方显示对象信息
def __str__(self):
- return self.tid
+ return f"用户{self.tid}在{self.creation_time.strftime('%Y-%m-%d %H:%M')}的位置"
+
+ def get_coordinates(self):
+ """获取坐标元组"""
+ return (self.lat, self.lon)
- # 定义模型的元数据选项 #zqx: 定义Meta内部类,用于配置模型的元数据选项
class Meta:
- # 设置查询结果的默认排序方式,按创建时间升序排列 #zqx: 设置ordering属性,指定查询结果按creation_time字段升序排列
ordering = ['creation_time']
- # 设置模型在管理后台显示的单数名称 #zqx: 设置verbose_name属性,指定模型在管理后台的单数显示名称
- verbose_name = "OwnTrackLogs"
- # 设置模型在管理后台显示的复数名称,这里与单数名称相同 #zqx: 设置verbose_name_plural属性,指定模型在管理后台的复数显示名称,这里与单数名称相同
- verbose_name_plural = verbose_name
- # 设置获取最新记录时依据的字段 #zqx: 设置get_latest_by属性,指定获取最新记录时使用的字段为creation_time
+ verbose_name = "位置记录"
+ verbose_name_plural = "位置记录"
get_latest_by = 'creation_time'
+ indexes = [
+ models.Index(fields=['tid', 'creation_time']),
+ models.Index(fields=['creation_time']),
+ ]
diff --git a/src/DjangoBlog/owntracks/settings.py b/src/DjangoBlog/owntracks/settings.py
new file mode 100644
index 0000000..266f336
--- /dev/null
+++ b/src/DjangoBlog/owntracks/settings.py
@@ -0,0 +1,36 @@
+# 高德地图API配置
+AMAP_API_KEY = os.getenv('AMAP_API_KEY', 'your-default-key-here')
+
+# 缓存配置
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.redis.RedisCache',
+ 'LOCATION': 'redis://127.0.0.1:6379/1',
+ }
+}
+#management / commands / cleanup_old_locations.py
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from owntracks.models import OwnTrackLog
+
+
+class Command(BaseCommand):
+ help = '清理过期的位置记录'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--days',
+ type=int,
+ default=365,
+ help='保留多少天内的数据'
+ )
+
+ def handle(self, *args, **options):
+ cutoff_date = timezone.now() - timezone.timedelta(days=options['days'])
+ deleted_count, _ = OwnTrackLog.objects.filter(
+ creation_time__lt=cutoff_date
+ ).delete()
+
+ self.stdout.write(
+ self.style.SUCCESS(f'成功删除 {deleted_count} 条过期记录')
+ )
diff --git a/src/DjangoBlog/owntracks/tests.py b/src/DjangoBlog/owntracks/tests.py
index b3d4a3a..e5e1c97 100644
--- a/src/DjangoBlog/owntracks/tests.py
+++ b/src/DjangoBlog/owntracks/tests.py
@@ -1,83 +1,101 @@
-# 导入json模块用于处理JSON数据 #zqx: 引入json模块,用于处理JSON格式数据的编码和解码
import json
-
-# 从Django测试模块导入测试客户端、请求工厂和测试用例基类 #zqx: 从django.test导入Client(测试客户端)、RequestFactory(请求工厂)和TestCase(测试用例基类)
-from django.test import Client, RequestFactory, TestCase
-
-# 从accounts应用导入BlogUser模型 #zqx: 从accounts应用的models模块导入BlogUser用户模型
-from accounts.models import BlogUser
-# 从当前应用导入OwnTrackLog模型 #zqx: 从当前应用(.)的models模块导入OwnTrackLog模型
+from datetime import datetime
+from django.test import TestCase, Client
+from django.contrib.auth import get_user_model
from .models import OwnTrackLog
-# Create your tests here. #zqx: Django测试文件的标准注释,标记测试代码区域开始
+User = get_user_model()
+
-# 定义OwnTrackLogTest测试类,继承自Django的TestCase #zqx: 定义OwnTrackLogTest测试类,继承Django的TestCase类,用于测试OwnTrackLog相关功能
class OwnTrackLogTest(TestCase):
- # 测试初始化方法,在每个测试方法执行前运行 #zqx: setUp方法,在每个测试方法执行前自动调用,用于初始化测试环境
+ """OwnTrackLog模型和视图测试"""
+
def setUp(self):
- # 创建测试客户端实例 #zqx: 创建Client实例,用于模拟HTTP请求
self.client = Client()
- # 创建请求工厂实例 #zqx: 创建RequestFactory实例,用于创建测试请求对象
- self.factory = RequestFactory()
-
- # 测试owntracks功能的主要测试方法 #zqx: 定义test_own_track_log测试方法,用于测试owntracks功能
- def test_own_track_log(self):
- # 创建包含完整位置信息的测试数据 #zqx: 创建包含tid、lat、lon字段的字典对象,作为完整位置信息测试数据
- o = {
- 'tid': 12, #zqx: 用户ID字段,值为12
- 'lat': 123.123, #zqx: 纬度字段,值为123.123
- 'lon': 134.341 #zqx: 经度字段,值为134.341
- }
+ self.superuser = User.objects.create_superuser(
+ email="admin@example.com",
+ username="admin",
+ password="testpassword123"
+ )
+ self.normal_user = User.objects.create_user(
+ email="user@example.com",
+ username="user",
+ password="testpassword123"
+ )
- # 使用客户端发送POST请求,将位置数据以JSON格式发送到/logtracks端点 #zqx: 使用client.post方法向/owntracks/logtracks路径发送POST请求,数据为JSON格式
- self.client.post(
- '/owntracks/logtracks', #zqx: 请求的目标URL路径
- json.dumps(o), #zqx: 将字典o转换为JSON字符串
- content_type='application/json') #zqx: 设置请求的内容类型为application/json
- # 检查数据库中OwnTrackLog记录数量是否为1 #zqx: 查询OwnTrackLog模型的所有记录,检查记录数量是否为1
- length = len(OwnTrackLog.objects.all()) #zqx: 获取OwnTrackLog所有对象的数量
- self.assertEqual(length, 1) #zqx: 断言记录数量等于1
-
- # 创建不完整的位置数据(缺少经度) #zqx: 创建缺少lon字段的字典对象,作为不完整位置信息测试数据
- o = {
- 'tid': 12, #zqx: 用户ID字段,值为12
- 'lat': 123.123 #zqx: 纬度字段,值为123.123
+ def test_create_valid_location(self):
+ """测试创建有效位置记录"""
+ data = {
+ 'tid': 'test-user-1',
+ 'lat': 39.9042,
+ 'lon': 116.4074,
+ 'acc': 10.5,
+ 'batt': 85.0
}
- # 再次发送POST请求 #zqx: 使用client.post方法再次发送POST请求,数据为不完整的JSON格式
- self.client.post(
- '/owntracks/logtracks', #zqx: 请求的目标URL路径
- json.dumps(o), #zqx: 将不完整的字典o转换为JSON字符串
- content_type='application/json') #zqx: 设置请求的内容类型为application/json
- # 检查数据库记录数量是否仍为1(不完整数据应该不被保存) #zqx: 查询OwnTrackLog模型的所有记录,检查记录数量是否仍为1
- length = len(OwnTrackLog.objects.all()) #zqx: 获取OwnTrackLog所有对象的数量
- self.assertEqual(length, 1) #zqx: 断言记录数量仍等于1,验证不完整数据未被保存
-
- # 测试未登录用户访问/show_maps端点,应该返回302重定向 #zqx: 测试未登录用户访问/show_maps端点的行为
- rsp = self.client.get('/owntracks/show_maps') #zqx: 使用client.get方法向/owntracks/show_maps路径发送GET请求
- self.assertEqual(rsp.status_code, 302) #zqx: 断言响应状态码为302,表示重定向
-
- # 创建超级用户用于测试 #zqx: 使用create_superuser方法创建超级用户用于后续测试
- user = BlogUser.objects.create_superuser( #zqx: 调用BlogUser模型的create_superuser方法
- email="liangliangyy1@gmail.com", #zqx: 设置用户邮箱
- username="liangliangyy1", #zqx: 设置用户名
- password="liangliangyy1") #zqx: 设置用户密码
-
- # 使用创建的用户登录 #zqx: 使用client.login方法以创建的用户身份登录
- self.client.login(username='liangliangyy1', password='liangliangyy1') #zqx: 使用用户名和密码登录
- # 手动创建并保存一个OwnTrackLog实例 #zqx: 手动创建OwnTrackLog对象并保存到数据库
- s = OwnTrackLog() #zqx: 创建OwnTrackLog实例
- s.tid = 12 #zqx: 设置tid属性为12
- s.lon = 123.234 #zqx: 设置lon属性为123.234
- s.lat = 34.234 #zqx: 设置lat属性为34.234
- s.save() #zqx: 保存对象到数据库
-
- # 测试已登录用户访问各个端点,都应该返回200成功状态码 #zqx: 测试已登录用户访问不同端点的响应状态
- rsp = self.client.get('/owntracks/show_dates') #zqx: 向/owntracks/show_dates路径发送GET请求
- self.assertEqual(rsp.status_code, 200) #zqx: 断言响应状态码为200,表示请求成功
- rsp = self.client.get('/owntracks/show_maps') #zqx: 向/owntracks/show_maps路径发送GET请求
- self.assertEqual(rsp.status_code, 200) #zqx: 断言响应状态码为200,表示请求成功
- rsp = self.client.get('/owntracks/get_datas') #zqx: 向/owntracks/get_datas路径发送GET请求
- self.assertEqual(rsp.status_code, 200) #zqx: 断言响应状态码为200,表示请求成功
- rsp = self.client.get('/owntracks/get_datas?date=2018-02-26') #zqx: 向带日期参数的/owntracks/get_datas路径发送GET请求
- self.assertEqual(rsp.status_code, 200) #zqx: 断言响应状态码为200,表示请求成功
+ response = self.client.post(
+ '/owntracks/logtracks',
+ data=json.dumps(data),
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(OwnTrackLog.objects.count(), 1)
+
+ location = OwnTrackLog.objects.first()
+ self.assertEqual(location.tid, 'test-user-1')
+ self.assertEqual(location.lat, 39.9042)
+
+ def test_create_invalid_location(self):
+ """测试创建无效位置记录"""
+ # zqx: 测试缺少必需字段
+ data = {'tid': 'test-user'}
+ response = self.client.post(
+ '/owntracks/logtracks',
+ data=json.dumps(data),
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 400)
+
+ # zqx: 测试无效坐标
+ data = {'tid': 'test', 'lat': 1000, 'lon': 2000}
+ response = self.client.post(
+ '/owntracks/logtracks',
+ data=json.dumps(data),
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 400)
+
+ def test_permission_access(self):
+ """测试权限访问控制"""
+ # zqx: 未登录用户访问受限页面
+ response = self.client.get('/owntracks/show_maps')
+ self.assertEqual(response.status_code, 302) # 重定向到登录
+
+ # 普通用户登录
+ self.client.login(username='user', password='testpassword123')
+ response = self.client.get('/owntracks/show_maps')
+ self.assertEqual(response.status_code, 403) # 禁止访问
+
+ # zqx: 超级用户登录
+ self.client.login(username='admin', password='testpassword123')
+ response = self.client.get('/owntracks/show_maps')
+ self.assertEqual(response.status_code, 200)
+
+ def test_get_location_data(self):
+ """测试获取位置数据"""
+ # zqx: 创建测试数据
+ OwnTrackLog.objects.create(
+ tid='user1', lat=39.9, lon=116.4
+ )
+ OwnTrackLog.objects.create(
+ tid='user1', lat=39.91, lon=116.41
+ )
+
+ self.client.login(username='admin', password='testpassword123')
+ response = self.client.get('/owntracks/get_datas')
+
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(len(data[0]['path']), 2)
diff --git a/src/DjangoBlog/owntracks/urls.py b/src/DjangoBlog/owntracks/urls.py
index b36a3a1..97c02cd 100644
--- a/src/DjangoBlog/owntracks/urls.py
+++ b/src/DjangoBlog/owntracks/urls.py
@@ -1,22 +1,29 @@
-# 从Django URL模块导入path函数用于定义URL模式 #zqx: 从django.urls模块导入path函数,用于定义URL路由模式
from django.urls import path
-
-# 从当前应用导入视图模块 #zqx: 从当前目录(.)导入views模块,包含处理请求的视图函数
from . import views
-# 定义应用命名空间为"owntracks" #zqx: 设置app_name变量为"owntracks",定义该应用的命名空间
app_name = "owntracks"
-# 定义URL模式列表 #zqx: 定义urlpatterns列表,包含该应用的所有URL路由模式
urlpatterns = [
- # 定义日志跟踪接口URL,将请求路由到manage_owntrack_log视图函数 #zqx: 使用path函数定义URL模式,将'owntracks/logtracks'路径映射到views.manage_owntrack_log函数,命名为'logtracks'
- path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
- # 定义地图展示页面URL,将请求路由到show_maps视图函数 #zqx: 使用path函数定义URL模式,将'owntracks/show_maps'路径映射到views.show_maps函数,命名为'show_maps'
- path('owntracks/show_maps', views.show_maps, name='show_maps'),
- # 定义数据获取接口URL,将请求路由到get_datas视图函数 #zqx: 使用path函数定义URL模式,将'owntracks/get_datas'路径映射到views.get_datas函数,命名为'get_datas'
- path('owntracks/get_datas', views.get_datas, name='get_datas'),
- # 定义日期展示页面URL,将请求路由到show_log_dates视图函数 #zqx: 使用path函数定义URL模式,将'owntracks/show_dates'路径映射到views.show_log_dates函数,命名为'show_dates'
- path('owntracks/show_dates', views.show_log_dates, name='show_dates')
+ path('logtracks',
+ views.manage_owntrack_log,
+ name='logtracks'),
+ path('show_maps',
+ views.show_maps,
+ name='show_maps'),
+ path('get_datas',
+ views.get_datas,
+ name='get_datas'),
+ path('show_dates',
+ views.show_log_dates,
+ name='show_dates')
]
+#zqx: 应该添加API版本控制
+# urlpatterns = [
+# path('api/v1/tracks', views.manage_owntrack_log, name='log-tracks'),
+# path('api/v1/tracks/dates', views.show_log_dates, name='track-dates'),
+# path('api/v1/tracks/', views.get_datas, name='track-data'),
+# ]
+
+
diff --git a/src/DjangoBlog/owntracks/views.py b/src/DjangoBlog/owntracks/views.py
index ade1103..825481d 100644
--- a/src/DjangoBlog/owntracks/views.py
+++ b/src/DjangoBlog/owntracks/views.py
@@ -1,161 +1,236 @@
-# Create your views here. #zqx: Django视图文件标准注释,标记视图代码开始
-# 导入所需的Python标准库和第三方库 #zqx: 导入项目需要的各种标准库和第三方库
-import datetime #zqx: 导入datetime模块,用于处理日期时间相关操作
-import itertools #zqx: 导入itertools模块,用于高效的循环迭代操作
-import json #zqx: 导入json模块,用于处理JSON数据格式
-import logging #zqx: 导入logging模块,用于记录日志信息
-from datetime import timezone #zqx: 从datetime模块导入timezone,用于处理时区相关操作
-from itertools import groupby #zqx: 从itertools模块导入groupby,用于对数据进行分组操作
-
-import django #zqx: 导入django模块
-import requests #zqx: 导入requests库,用于发送HTTP请求
-# 导入Django的装饰器、HTTP响应类和视图相关模块 #zqx: 导入Django框架的各种视图相关组件
-from django.contrib.auth.decorators import login_required #zqx: 从django.contrib.auth.decorators导入login_required装饰器,用于限制视图只能由登录用户访问
-from django.http import HttpResponse #zqx: 从django.http导入HttpResponse,用于返回HTTP响应
-from django.http import JsonResponse #zqx: 从django.http导入JsonResponse,用于返回JSON格式的HTTP响应
-from django.shortcuts import render #zqx: 从django.shortcuts导入render函数,用于渲染模板
-from django.views.decorators.csrf import csrf_exempt #zqx: 从django.views.decorators导入csrf_exempt装饰器,用于免除CSRF验证
-
-# 导入当前应用的OwnTrackLog模型 #zqx: 从当前应用的models模块导入OwnTrackLog数据模型
+import datetime
+import json
+import logging
+from typing import Dict, Any, List
+from datetime import timezone
+import requests
+
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.http import HttpResponse, JsonResponse, HttpRequest
+from django.shortcuts import render
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_http_methods
+from django.db import transaction
+from django.core.cache import cache
+from django.conf import settings
+
from .models import OwnTrackLog
-# 获取日志记录器实例 #zqx: 获取名为__name__的日志记录器实例
logger = logging.getLogger(__name__)
-# 装饰器,免除CSRF验证,用于接收外部系统POST请求 #zqx: 使用@csrf_exempt装饰器,免除该视图函数的CSRF验证,允许外部系统POST请求
+
+def is_superuser(user):
+ """检查用户是否为超级用户"""
+ return user.is_superuser
+
+
@csrf_exempt
-def manage_owntrack_log(request): #zqx: 定义manage_owntrack_log视图函数,接收request参数
- try: #zqx: 开始异常处理块
- # 解析请求体中的JSON数据 #zqx: 解析HTTP请求体中的JSON数据
- s = json.loads(request.read().decode('utf-8')) #zqx: 读取请求体内容并解码为utf-8,然后解析为JSON对象
- tid = s['tid'] #zqx: 从JSON对象中获取tid字段值(用户标识)
- lat = s['lat'] #zqx: 从JSON对象中获取lat字段值(纬度)
- lon = s['lon'] #zqx: 从JSON对象中获取lon字段值(经度)
-
- # 记录接收到的位置信息日志 #zqx: 记录接收到的位置信息到日志
- logger.info( #zqx: 使用logger记录info级别的日志信息
- 'tid:{tid}.lat:{lat}.lon:{lon}'.format( #zqx: 格式化日志信息字符串
- tid=tid, lat=lat, lon=lon)) #zqx: 填充格式化参数
- # 验证必要字段是否存在 #zqx: 验证必需的字段是否存在且不为空
- if tid and lat and lon: #zqx: 判断tid、lat、lon三个字段是否都存在且不为空
- # 创建并保存位置记录 #zqx: 创建OwnTrackLog实例并保存位置记录
- m = OwnTrackLog() #zqx: 创建OwnTrackLog模型实例
- m.tid = tid #zqx: 设置实例的tid属性
- m.lat = lat #zqx: 设置实例的lat属性
- m.lon = lon #zqx: 设置实例的lon属性
- m.save() #zqx: 保存实例到数据库
- return HttpResponse('ok') #zqx: 返回'ok'字符串响应
- else: #zqx: 如果必要字段不完整
- return HttpResponse('data error') #zqx: 返回'data error'字符串响应
- except Exception as e: #zqx: 捕获所有异常
- # 记录错误日志并返回错误响应 #zqx: 记录错误日志并返回错误响应
- logger.error(e) #zqx: 使用logger记录error级别的异常信息
- return HttpResponse('error') #zqx: 返回'error'字符串响应
-
-# 装饰器,要求用户登录才能访问 #zqx: 使用@login_required装饰器,限制该视图只能由登录用户访问
-@login_required
-def show_maps(request): #zqx: 定义show_maps视图函数,接收request参数
- # 检查用户是否为超级用户 #zqx: 检查当前登录用户是否为超级用户
- if request.user.is_superuser: #zqx: 判断请求用户是否为超级用户
- # 设置默认日期为当前UTC日期 #zqx: 设置默认日期为当前UTC日期
- defaultdate = str(datetime.datetime.now(timezone.utc).date()) #zqx: 获取当前UTC时间的日期部分并转换为字符串
- # 从GET参数获取日期,如果没有则使用默认日期 #zqx: 从请求GET参数中获取date参数,如果没有则使用默认日期
- date = request.GET.get('date', defaultdate) #zqx: 获取GET参数中的date值,不存在时使用defaultdate
- # 构造上下文数据 #zqx: 构造传递给模板的上下文数据
- context = { #zqx: 定义context字典
- 'date': date #zqx: 将date变量添加到context字典中
- }
- # 渲染地图展示页面 #zqx: 渲染show_maps.html模板并返回响应
- return render(request, 'owntracks/show_maps.html', context) #zqx: 使用render函数渲染模板并返回响应
- else: #zqx: 如果用户不是超级用户
- # 非超级用户返回403禁止访问 #zqx: 为非超级用户返回403禁止访问响应
- from django.http import HttpResponseForbidden #zqx: 从django.http导入HttpResponseForbidden
- return HttpResponseForbidden() #zqx: 返回403禁止访问响应
-
-# 装饰器,要求用户登录才能访问 #zqx: 使用@login_required装饰器,限制该视图只能由登录用户访问
+@require_http_methods(["POST"])
+@transaction.atomic
+def manage_owntrack_log(request: HttpRequest) -> HttpResponse:
+ """
+ 处理OwnTracks位置数据上报
+ """
+ try:
+ # 验证内容类型
+ if request.content_type != 'application/json':
+ return HttpResponse('Unsupported media type', status=415)
+
+ # zqx: 解析请求数据
+ try:
+ raw_data = request.body.decode('utf-8')
+ data = json.loads(raw_data)
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
+ logger.warning(f"JSON解析失败: {e}")
+ return HttpResponse('Invalid JSON', status=400)
+
+ # zqx: 验证必需字段
+ required_fields = ['tid', 'lat', 'lon']
+ if not all(field in data for field in required_fields):
+ missing = [field for field in required_fields if field not in data]
+ logger.warning(f"缺少必需字段: {missing}")
+ return HttpResponse(f'Missing required fields: {missing}', status=400)
+
+ # zqx: 验证数据类型
+ try:
+ tid = str(data['tid'])
+ lat = float(data['lat'])
+ lon = float(data['lon'])
+ except (ValueError, TypeError) as e:
+ logger.warning(f"数据类型错误: {e}")
+ return HttpResponse('Invalid data types', status=400)
+
+ # zqx: 验证坐标范围
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
+ logger.warning(f"坐标超出范围: lat={lat}, lon={lon}")
+ return HttpResponse('Coordinates out of range', status=400)
+
+ # zqx: 创建位置记录
+ track_log = OwnTrackLog(
+ tid=tid,
+ lat=lat,
+ lon=lon,
+ accuracy=data.get('acc'),
+ battery=data.get('batt')
+ )
+ track_log.full_clean() # 模型验证
+ track_log.save()
+
+ logger.info(f"位置记录创建成功: 用户{tid}在({lat}, {lon})")
+ return HttpResponse('OK', status=201)
+
+ except Exception as e:
+ logger.error(f"位置记录处理异常: {e}", exc_info=True)
+ return HttpResponse('Server error', status=500)
+
+
@login_required
-def show_log_dates(request): #zqx: 定义show_log_dates视图函数,接收request参数
- # 从数据库获取所有记录的创建时间 #zqx: 从数据库中查询OwnTrackLog模型的所有creation_time字段值
- dates = OwnTrackLog.objects.values_list('creation_time', flat=True) #zqx: 使用values_list获取creation_time字段值,flat=True返回扁平化结果
- # 提取日期部分并去重排序 #zqx: 提取日期部分,去重并排序
- results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates)))) #zqx: 使用map提取日期格式化字符串,set去重,sorted排序,list转换为列表
-
- # 构造上下文数据 #zqx: 构造传递给模板的上下文数据
- context = { #zqx: 定义context字典
- 'results': results #zqx: 将results变量添加到context字典中
+@user_passes_test(is_superuser)
+def show_maps(request: HttpRequest) -> HttpResponse:
+ # zqx:
+ 显示位置地图页面
+ today = datetime.datetime.now(timezone.utc).date()
+ date_str = request.GET.get('date', str(today))
+
+ # zqx: 验证日期格式
+ try:
+ datetime.datetime.strptime(date_str, '%Y-%m-%d')
+ except ValueError:
+ date_str = str(today)
+
+ context = {
+ 'date': date_str,
+ 'map_api_key': getattr(settings, 'AMAP_API_KEY', '')
}
- # 渲染日期展示页面 #zqx: 渲染show_log_dates.html模板并返回响应
- return render(request, 'owntracks/show_log_dates.html', context) #zqx: 使用render函数渲染模板并返回响应
-
-# 将GPS坐标转换为高德地图坐标(批量处理,每次30个) #zqx: 定义convert_to_amap函数,用于将GPS坐标批量转换为高德地图坐标,每次处理30个点
-def convert_to_amap(locations): #zqx: 定义convert_to_amap函数,接收locations参数(位置列表)
- convert_result = [] #zqx: 初始化转换结果列表
- # 创建迭代器 #zqx: 创建locations列表的迭代器
- it = iter(locations) #zqx: 使用iter函数创建locations的迭代器
-
- # 每次取30个位置点进行处理 #zqx: 每次从迭代器中取出30个位置点进行处理
- item = list(itertools.islice(it, 30)) #zqx: 使用itertools.islice从迭代器中取出前30个元素
- while item: #zqx: 当item列表不为空时循环处理
- # 将经纬度格式化为高德API需要的格式 #zqx: 将经纬度数据格式化为高德API所需的格式
- datas = ';'.join( #zqx: 使用';'连接符连接所有坐标字符串
- set(map(lambda x: str(x.lon) + ',' + str(x.lat), item))) #zqx: 使用map提取每个位置的经度和纬度并格式化,set去重,join连接
-
- # 高德地图API配置 #zqx: 配置高德地图坐标转换API的参数
- key = '8440a376dfc9743d8924bf0ad141f28e' #zqx: 设置高德地图API的key
- api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' #zqx: 设置高德地图API的URL
- query = { #zqx: 定义API请求参数字典
- 'key': key, #zqx: API密钥参数
- 'locations': datas, #zqx: 需要转换的坐标数据
- 'coordsys': 'gps' #zqx: 源坐标系为GPS
+ return render(request, 'owntracks/show_maps.html', context)
+
+
+@login_required
+@user_passes_test(is_superuser)
+def show_log_dates(request: HttpRequest) -> HttpResponse:
+ # zqx
+ 显示有位置记录的日期列表
+ cache_key = 'owntracks_dates_list'
+ dates = cache.get(cache_key)
+
+ if dates is None:
+ # zqx: 使用数据库的日期函数提高性能
+ dates = OwnTrackLog.objects.dates('creation_time', 'day', order='DESC')
+ dates = [date.strftime('%Y-%m-%d') for date in dates]
+ cache.set(cache_key, dates, timeout=3600) # 缓存1小时
+
+ context = {'results': dates}
+ return render(request, 'owntracks/show_log_dates.html', context)
+
+
+def convert_coordinates_batch(locations: List[OwnTrackLog]) -> str:
+ # zqx:
+ 批量转换GPS坐标到高德坐标系
+
+ if not locations:
+ return ""
+
+ # zqx: 坐标去重
+ unique_coords = set()
+ for location in locations:
+ coord_key = f"{location.lon:.6f},{location.lat:.6f}"
+ unique_coords.add(coord_key)
+
+ coordinates_list = list(unique_coords)
+ batch_size = 30 # 高德API批量限制
+ all_converted = []
+
+ for i in range(0, len(coordinates_list), batch_size):
+ batch = coordinates_list[i:i + batch_size]
+ locations_str = ';'.join(batch)
+
+ # zqx: 调用高德坐标转换API
+ api_key = getattr(settings, 'AMAP_API_KEY', '8440a376dfc9743d8924bf0ad141f28e')
+ api_url = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
+
+ params = {
+ 'key': api_key,
+ 'locations': locations_str,
+ 'coordsys': 'gps'
}
- # 发送请求到高德API #zqx: 向高德地图API发送GET请求
- rsp = requests.get(url=api, params=query) #zqx: 使用requests.get发送带参数的GET请求
- result = json.loads(rsp.text) #zqx: 解析API响应的JSON数据
- # 处理API响应结果 #zqx: 处理API返回的结果
- if "locations" in result: #zqx: 判断响应结果中是否包含locations字段
- convert_result.append(result['locations']) #zqx: 如果包含则将locations值添加到转换结果列表中
- # 继续处理下一批数据 #zqx: 继续处理下一批30个数据
- item = list(itertools.islice(it, 30)) #zqx: 从迭代器中继续取出下一批30个元素
-
- # 返回转换后的坐标字符串 #zqx: 返回所有转换后的坐标字符串,用分号连接
- return ";".join(convert_result) #zqx: 使用";"连接符连接所有转换结果并返回
-
-# 装饰器,要求用户登录才能访问 #zqx: 使用@login_required装饰器,限制该视图只能由登录用户访问
+
+ try:
+ response = requests.get(api_url, params=params, timeout=10)
+ response.raise_for_status()
+ result = response.json()
+
+ if result.get('status') == '1' and 'locations' in result:
+ all_converted.append(result['locations'])
+ else:
+ logger.error(f"高德API错误: {result.get('info', 'Unknown error')}")
+ # 使用原始坐标作为备选
+ all_converted.append(locations_str)
+
+ except requests.RequestException as e:
+ logger.error(f"坐标转换API请求失败: {e}")
+ all_converted.append(locations_str) # 使用原始坐标
+
+ return ";".join(all_converted)
+
+
@login_required
-def get_datas(request): #zqx: 定义get_datas视图函数,接收request参数
- # 获取当前UTC时间并设置为当天0点 #zqx: 获取当前UTC时间并设置为当天的0点0分0秒
- now = django.utils.timezone.now().replace(tzinfo=timezone.utc) #zqx: 获取当前时间并设置时区为UTC
- querydate = django.utils.timezone.datetime( #zqx: 创建查询开始日期时间对象
- now.year, now.month, now.day, 0, 0, 0) #zqx: 设置为当年当月当日的0时0分0秒
- # 如果GET参数中有指定日期,则使用指定日期 #zqx: 如果请求GET参数中包含date,则使用指定日期
- if request.GET.get('date', None): #zqx: 判断GET参数中是否存在date参数
- date = list(map(lambda x: int(x), request.GET.get('date').split('-'))) #zqx: 将date参数按'-'分割并转换为整数列表
- querydate = django.utils.timezone.datetime( #zqx: 根据指定日期创建查询开始日期时间对象
- date[0], date[1], date[2], 0, 0, 0) #zqx: 使用指定年月日创建datetime对象
- # 计算查询结束时间(第二天0点) #zqx: 计算查询结束时间,为查询开始时间的下一天0点
- nextdate = querydate + datetime.timedelta(days=1) #zqx: 查询结束时间为开始时间加上1天
- # 查询指定日期范围内的位置记录 #zqx: 查询creation_time在指定日期范围内的OwnTrackLog记录
- models = OwnTrackLog.objects.filter( #zqx: 使用filter方法筛选记录
- creation_time__range=(querydate, nextdate)) #zqx: 筛选creation_time在querydate到nextdate范围内的记录
- result = list() #zqx: 初始化结果列表
- # 如果查询到数据,则按用户分组处理 #zqx: 如果查询到数据则按用户进行分组处理
- if models and len(models): #zqx: 判断models是否存在且不为空
- for tid, item in groupby( #zqx: 使用groupby按tid分组遍历models
- sorted(models, key=lambda k: k.tid), key=lambda k: k.tid): #zqx: 先按tid排序,然后按tid分组
-
- d = dict() #zqx: 创建字典对象存储用户轨迹数据
- d["name"] = tid #zqx: 设置字典的name字段为用户标识tid
- paths = list() #zqx: 初始化路径坐标列表
- # 目前使用原始GPS坐标,注释掉的代码是使用高德转换坐标的部分 #zqx: 当前使用原始GPS坐标,注释掉的是高德坐标转换的代码
- # locations = convert_to_amap( #zqx: 调用convert_to_amap函数转换坐标(已注释)
- # sorted(item, key=lambda x: x.creation_time)) #zqx: 按创建时间排序后转换(已注释)
- # for i in locations.split(';'): #zqx: 遍历转换后的坐标字符串(已注释)
- # paths.append(i.split(',')) #zqx: 将坐标分割后添加到路径列表(已注释)
- # 使用GPS原始经纬度按时间排序 #zqx: 使用原始GPS坐标按时间排序
- for location in sorted(item, key=lambda x: x.creation_time): #zqx: 遍历分组后的记录并按创建时间排序
- paths.append([str(location.lon), str(location.lat)]) #zqx: 将经度和纬度转换为字符串并添加到路径列表
- d["path"] = paths #zqx: 设置字典的path字段为路径坐标列表
- result.append(d) #zqx: 将用户轨迹数据字典添加到结果列表
- # 返回JSON格式的轨迹数据 #zqx: 返回JSON格式的轨迹数据响应
- return JsonResponse(result, safe=False) #zqx: 使用JsonResponse返回结果,safe=False允许非字典对象
+@user_passes_test(is_superuser)
+def get_datas(request: HttpRequest) -> JsonResponse:
+ """
+ 获取指定日期的位置数据(JSON API)
+ """
+ # zqx: 日期处理
+ date_str = request.GET.get('date')
+ if date_str:
+ try:
+ query_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').replace(
+ tzinfo=timezone.utc
+ )
+ except ValueError:
+ return JsonResponse(
+ {'error': 'Invalid date format. Use YYYY-MM-DD.'},
+ status=400
+ )
+ else:
+ query_date = datetime.datetime.now(timezone.utc).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+
+ next_date = query_date + datetime.timedelta(days=1)
+
+ # zqx: 数据库查询优化
+ locations = OwnTrackLog.objects.filter(
+ creation_time__range=(query_date, next_date)
+ ).order_by('tid', 'creation_time')
+
+ if not locations.exists():
+ return JsonResponse([], safe=False)
+
+ # zqx: 按用户分组处理数据
+ result = []
+ current_tid = None
+ user_paths = []
+
+ for location in locations:
+ if location.tid != current_tid:
+ # zqx: 保存前一个用户的数据
+ if current_tid is not None and user_paths:
+ result.append({
+ "name": current_tid,
+ "path": user_paths.copy()
+ })
+ # zqx; 开始新用户
+ current_tid = location.tid
+ user_paths = []
+
+ user_paths.append([str(location.lon), str(location.lat)])
+
+ # zqx: 添加最后一个用户的数据
+ if current_tid is not None and user_paths:
+ result.append({
+ "name": current_tid,
+ "path": user_paths
+ })
+
+ return JsonResponse(result, safe=False)
diff --git a/src/DjangoBlog/plugin_manage/base_plugin.py b/src/DjangoBlog/plugin_manage/base_plugin.py
new file mode 100644
index 0000000..df1ce0b
--- /dev/null
+++ b/src/DjangoBlog/plugin_manage/base_plugin.py
@@ -0,0 +1,194 @@
+import logging
+from pathlib import Path
+
+from django.template import TemplateDoesNotExist
+from django.template.loader import render_to_string
+
+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}
+
+ def __init__(self):
+ if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
+ raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
+
+ # 设置插件路径
+ self.plugin_dir = self._get_plugin_directory()
+ self.plugin_slug = self._get_plugin_slug()
+
+ self.init_plugin()
+ self.register_hooks()
+
+ def _get_plugin_directory(self):
+ """获取插件目录路径"""
+ import inspect
+ plugin_file = inspect.getfile(self.__class__)
+ return Path(plugin_file).parent
+
+ def _get_plugin_slug(self):
+ """获取插件标识符(目录名)"""
+ return self.plugin_dir.name
+
+ def init_plugin(self):
+ """
+ 插件初始化逻辑
+ 子类可以重写此方法来实现特定的初始化操作
+ """
+ logger.info(f'{self.PLUGIN_NAME} initialized.')
+
+ def register_hooks(self):
+ """
+ 注册插件钩子
+ 子类可以重写此方法来注册特定的钩子
+ """
+ pass
+
+ # === 位置渲染系统 ===
+ def render_position_widget(self, position, context, **kwargs):
+ """
+ 根据位置渲染插件组件
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ dict: {'html': 'HTML内容', 'priority': 优先级} 或 None
+ """
+ if position not in self.SUPPORTED_POSITIONS:
+ return None
+
+ # 检查条件显示
+ if not self.should_display(position, context, **kwargs):
+ return None
+
+ # 调用具体的位置渲染方法
+ method_name = f'render_{position}_widget'
+ if hasattr(self, method_name):
+ html = getattr(self, method_name)(context, **kwargs)
+ if html:
+ priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY)
+ return {
+ 'html': html,
+ 'priority': priority,
+ 'plugin_name': self.PLUGIN_NAME
+ }
+
+ return None
+
+ def should_display(self, position, context, **kwargs):
+ """
+ 判断插件是否应该在指定位置显示
+ 子类可重写此方法实现条件显示逻辑
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ bool: 是否显示
+ """
+ return True
+
+ # === 各位置渲染方法 - 子类重写 ===
+ def render_sidebar_widget(self, context, **kwargs):
+ """渲染侧边栏组件"""
+ return None
+
+ def render_article_bottom_widget(self, context, **kwargs):
+ """渲染文章底部组件"""
+ return None
+
+ def render_article_top_widget(self, context, **kwargs):
+ """渲染文章顶部组件"""
+ return None
+
+ def render_header_widget(self, context, **kwargs):
+ """渲染页头组件"""
+ return None
+
+ def render_footer_widget(self, context, **kwargs):
+ """渲染页脚组件"""
+ return None
+
+ def render_comment_before_widget(self, context, **kwargs):
+ """渲染评论前组件"""
+ return None
+
+ def render_comment_after_widget(self, context, **kwargs):
+ """渲染评论后组件"""
+ return None
+
+ # === 模板系统 ===
+ def render_template(self, template_name, context=None):
+ """
+ 渲染插件模板
+
+ Args:
+ template_name: 模板文件名
+ context: 模板上下文
+
+ Returns:
+ HTML字符串
+ """
+ if context is None:
+ context = {}
+
+ template_path = f"plugins/{self.plugin_slug}/{template_name}"
+
+ try:
+ return render_to_string(template_path, context)
+ except TemplateDoesNotExist:
+ logger.warning(f"Plugin template not found: {template_path}")
+ return ""
+
+ # === 静态资源系统 ===
+ def get_static_url(self, static_file):
+ """获取插件静态文件URL"""
+ from django.templatetags.static import static
+ return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}")
+
+ def get_css_files(self):
+ """获取插件CSS文件列表"""
+ return []
+
+ def get_js_files(self):
+ """获取插件JavaScript文件列表"""
+ return []
+
+ def get_head_html(self, context=None):
+ """获取需要插入到中的HTML内容"""
+ return ""
+
+ def get_body_html(self, context=None):
+ """获取需要插入到底部的HTML内容"""
+ return ""
+
+ def get_plugin_info(self):
+ """
+ 获取插件信息
+ :return: 包含插件元数据的字典
+ """
+ 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
+ }
diff --git a/src/DjangoBlog/plugin_manage/hook_constants.py b/src/DjangoBlog/plugin_manage/hook_constants.py
new file mode 100644
index 0000000..8ed4e89
--- /dev/null
+++ b/src/DjangoBlog/plugin_manage/hook_constants.py
@@ -0,0 +1,22 @@
+ARTICLE_DETAIL_LOAD = 'article_detail_load'
+ARTICLE_CREATE = 'article_create'
+ARTICLE_UPDATE = 'article_update'
+ARTICLE_DELETE = 'article_delete'
+
+ARTICLE_CONTENT_HOOK_NAME = "the_content"
+
+# 位置钩子常量
+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',
+}
+
+# 资源注入钩子
+HEAD_RESOURCES_HOOK = 'head_resources'
+BODY_RESOURCES_HOOK = 'body_resources'
+
diff --git a/src/DjangoBlog/plugin_manage/hooks.py b/src/DjangoBlog/plugin_manage/hooks.py
new file mode 100644
index 0000000..d712540
--- /dev/null
+++ b/src/DjangoBlog/plugin_manage/hooks.py
@@ -0,0 +1,44 @@
+import logging
+
+logger = logging.getLogger(__name__)
+
+_hooks = {}
+
+
+def register(hook_name: str, callback: callable):
+ """
+ 注册一个钩子回调。
+ """
+ if hook_name not in _hooks:
+ _hooks[hook_name] = []
+ _hooks[hook_name].append(callback)
+ logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
+
+
+def run_action(hook_name: str, *args, **kwargs):
+ """
+ 执行一个 Action Hook。
+ 它会按顺序执行所有注册到该钩子上的回调函数。
+ """
+ if hook_name in _hooks:
+ logger.debug(f"Running action hook '{hook_name}'")
+ for callback in _hooks[hook_name]:
+ try:
+ callback(*args, **kwargs)
+ except Exception as e:
+ logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
+
+
+def apply_filters(hook_name: str, value, *args, **kwargs):
+ """
+ 执行一个 Filter Hook。
+ 它会把 value 依次传递给所有注册的回调函数进行处理。
+ """
+ if hook_name in _hooks:
+ logger.debug(f"Applying filter hook '{hook_name}'")
+ for callback in _hooks[hook_name]:
+ try:
+ value = callback(value, *args, **kwargs)
+ except Exception as e:
+ logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
+ return value
diff --git a/src/DjangoBlog/plugin_manage/loader.py b/src/DjangoBlog/plugin_manage/loader.py
new file mode 100644
index 0000000..ee750d0
--- /dev/null
+++ b/src/DjangoBlog/plugin_manage/loader.py
@@ -0,0 +1,64 @@
+import os
+import logging
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+# 全局插件注册表
+_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.
+ """
+ global _loaded_plugins
+ _loaded_plugins = []
+
+ for plugin_name in settings.ACTIVE_PLUGINS:
+ plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
+ if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
+ try:
+ # 导入插件模块
+ plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin'])
+
+ # 获取插件实例
+ if hasattr(plugin_module, 'plugin'):
+ plugin_instance = plugin_module.plugin
+ _loaded_plugins.append(plugin_instance)
+ logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}")
+ else:
+ logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance")
+
+ except ImportError as e:
+ logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
+ except AttributeError as e:
+ logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e)
+ except Exception as e:
+ logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e)
+
+def get_loaded_plugins():
+ """获取所有已加载的插件"""
+ return _loaded_plugins
+
+def get_plugin_by_name(plugin_name):
+ """根据名称获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_name:
+ return plugin
+ return None
+
+def get_plugin_by_slug(plugin_slug):
+ """根据slug获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_slug:
+ return plugin
+ return None
+
+def get_plugins_info():
+ """获取所有插件的信息"""
+ 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]
\ No newline at end of file
diff --git a/src/DjangoBlog/settings.py b/src/DjangoBlog/settings.py
new file mode 100644
index 0000000..667d918
--- /dev/null
+++ b/src/DjangoBlog/settings.py
@@ -0,0 +1,403 @@
+"""
+Django settings for djangoblog project.
+
+Generated by 'django-admin startproject' using Django 1.10.2.
+
+For more information on this file, see
+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/
+"""
+import os
+import sys
+from pathlib import Path
+
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.translation import gettext_lazy as _
+
+
+def env_to_bool(env, 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
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
+
+_ENV_SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
+# Default to DEBUG=True only when no DJANGO_SECRET_KEY is provided, so local development works out of the box.
+DEBUG = env_to_bool('DJANGO_DEBUG', _ENV_SECRET_KEY is None)
+
+
+def get_secret_key():
+ if _ENV_SECRET_KEY:
+ return _ENV_SECRET_KEY
+ if DEBUG:
+ # Provide a deterministic key for convenience in local development.
+ return 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
+ raise ImproperlyConfigured('DJANGO_SECRET_KEY environment variable is required when DEBUG=False')
+
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = get_secret_key()
+TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
+
+# Allow configuring host whitelist via environment, fallback to safe defaults
+_default_hosts = '127.0.0.1,localhost'
+ALLOWED_HOSTS = [host.strip() for host in os.environ.get('DJANGO_ALLOWED_HOSTS', _default_hosts).split(',') if host.strip()]
+# django 4.0新增配置
+CSRF_TRUSTED_ORIGINS = ['http://example.com']
+# Application definition
+
+
+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'
+]
+
+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'
+]
+
+ROOT_URLCONF = 'djangoblog.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [os.path.join(BASE_DIR, 'templates')],
+ 'APP_DIRS': True,
+ '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'
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'djangoblog.wsgi.application'
+
+# Database
+# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
+
+
+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 '12345678',
+ 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
+ 'PORT': int(
+ os.environ.get('DJANGO_MYSQL_PORT') or 3306),
+ 'OPTIONS': {
+ 'charset': 'utf8mb4',
+ 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"
+ },
+ 'CONN_MAX_AGE': int(os.environ.get('DJANGO_DB_CONN_MAX_AGE', 60)),
+ }}
+
+# Password validation
+# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+LANGUAGES = (
+ ('en', _('English')),
+ ('zh-hans', _('Simplified Chinese')),
+ ('zh-hant', _('Traditional Chinese')),
+)
+LOCALE_PATHS = (
+ os.path.join(BASE_DIR, 'locale'),
+)
+
+LANGUAGE_CODE = 'zh-hans'
+
+TIME_ZONE = 'Asia/Shanghai'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = False
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.10/howto/static-files/
+
+
+HAYSTACK_CONNECTIONS = {
+ 'default': {
+ 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
+ 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
+ },
+}
+# Automatically update searching index
+HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
+# Allow user login with username and password
+AUTHENTICATION_BACKENDS = [
+ 'accounts.user_login_backend.EmailOrUsernameModelBackend']
+
+STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
+
+STATIC_URL = '/static/'
+STATICFILES = os.path.join(BASE_DIR, 'static')
+
+# szy:同时收集项目静态目录与插件资源,避免部署缺文件
+STATICFILES_DIRS = [
+ STATICFILES,
+ os.path.join(BASE_DIR, 'plugins'),
+]
+
+AUTH_USER_MODEL = 'accounts.BlogUser'
+LOGIN_URL = '/login/'
+
+TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
+DATE_TIME_FORMAT = '%Y-%m-%d'
+
+# bootstrap color styles
+BOOTSTRAP_COLOR_TYPES = [
+ 'default', 'primary', 'success', 'info', 'warning', 'danger'
+]
+
+# paginate
+PAGINATE_BY = 10
+# http cache timeout
+CACHE_CONTROL_MAX_AGE = 2592000
+# cache setting
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'TIMEOUT': 10800,
+ 'LOCATION': 'unique-snowflake',
+ }
+}
+# 使用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")}',
+ }
+ }
+
+SITE_ID = 1
+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
+ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
+# WX ADMIN password(Two times md5)
+WXADMIN = os.environ.get(
+ 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
+
+LOG_PATH = os.path.join(BASE_DIR, 'logs')
+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'],
+ },
+ 'formatters': {
+ 'verbose': {
+ 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
+ }
+ },
+ 'filters': {
+ 'require_debug_false': {
+ '()': 'django.utils.log.RequireDebugFalse',
+ },
+ 'require_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'
+ },
+ 'console': {
+ 'level': 'DEBUG',
+ 'filters': ['require_debug_true'],
+ 'class': 'logging.StreamHandler',
+ 'formatter': 'verbose'
+ },
+ 'null': {
+ 'class': 'logging.NullHandler',
+ },
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'filters': ['require_debug_false'],
+ 'class': 'django.utils.log.AdminEmailHandler'
+ }
+ },
+ 'loggers': {
+ 'djangoblog': {
+ 'handlers': ['log_file', 'console'],
+ 'level': 'INFO',
+ 'propagate': True,
+ }
+ }
+}
+
+STATICFILES_FINDERS = (
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+ # other
+ 'compressor.finders.CompressorFinder',
+)
+COMPRESS_ENABLED = True
+# 根据环境变量决定是否启用离线压缩
+COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
+
+# 压缩输出目录
+COMPRESS_OUTPUT_DIR = 'compressed'
+
+# 压缩文件名模板 - 包含哈希值用于缓存破坏
+COMPRESS_CSS_HASHING_METHOD = 'mtime'
+COMPRESS_JS_HASHING_METHOD = 'mtime'
+
+# 高级CSS压缩过滤器
+COMPRESS_CSS_FILTERS = [
+ # 创建绝对URL
+ 'compressor.filters.css_default.CssAbsoluteFilter',
+ # CSS压缩器 - 高压缩等级
+ 'compressor.filters.cssmin.CSSCompressorFilter',
+]
+
+# 高级JS压缩过滤器
+COMPRESS_JS_FILTERS = [
+ # JS压缩器 - 高压缩等级
+ 'compressor.filters.jsmin.SlimItFilter',
+]
+
+# 压缩缓存配置
+COMPRESS_CACHE_BACKEND = 'default'
+COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
+
+# 预压缩配置
+COMPRESS_PRECOMPILERS = (
+ # 支持SCSS/SASS
+ ('text/x-scss', 'django_libsass.SassCompiler'),
+ ('text/x-sass', 'django_libsass.SassCompiler'),
+)
+
+# 压缩性能优化
+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'
+
+# 静态文件缓存配置
+STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
+
+# 浏览器缓存配置(通过中间件或服务器配置)
+COMPRESS_URL = STATIC_URL
+COMPRESS_ROOT = STATIC_ROOT
+
+MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
+MEDIA_URL = '/media/'
+AVATAR_ROOT = os.path.join(MEDIA_ROOT, 'avatars')
+AVATAR_URL = f'{MEDIA_URL}avatars/'
+X_FRAME_OPTIONS = 'SAMEORIGIN'
+
+
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
+ ELASTICSEARCH_DSL = {
+ 'default': {
+ 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
+ },
+ }
+ HAYSTACK_CONNECTIONS = {
+ 'default': {
+ 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
+ },
+ }
+
+# Plugin System
+PLUGINS_DIR = BASE_DIR / 'plugins'
+ACTIVE_PLUGINS = [
+ 'article_copyright',
+ 'reading_time',
+ 'external_links',
+ 'view_count',
+ 'seo_optimizer',
+ 'image_lazy_loading',
+ 'article_recommendation',
+]
diff --git a/src/DjangoBlog/sitemap.py b/src/DjangoBlog/sitemap.py
new file mode 100644
index 0000000..8b7d446
--- /dev/null
+++ b/src/DjangoBlog/sitemap.py
@@ -0,0 +1,59 @@
+from django.contrib.sitemaps import Sitemap
+from django.urls import reverse
+
+from blog.models import Article, Category, Tag
+
+
+class StaticViewSitemap(Sitemap):
+ priority = 0.5
+ changefreq = 'daily'
+
+ def items(self):
+ return ['blog:index', ]
+
+ def location(self, item):
+ return reverse(item)
+
+
+class ArticleSiteMap(Sitemap):
+ changefreq = "monthly"
+ priority = "0.6"
+
+ def items(self):
+ return Article.objects.filter(status='p')
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class CategorySiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.6"
+
+ def items(self):
+ return Category.objects.all()
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class TagSiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.3"
+
+ def items(self):
+ return Tag.objects.all()
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class UserSiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.3"
+
+ def items(self):
+ return list(set(map(lambda x: x.author, Article.objects.all())))
+
+ def lastmod(self, obj):
+ return obj.date_joined
diff --git a/src/DjangoBlog/spider_notify.py b/src/DjangoBlog/spider_notify.py
new file mode 100644
index 0000000..7b909e9
--- /dev/null
+++ b/src/DjangoBlog/spider_notify.py
@@ -0,0 +1,21 @@
+import logging
+
+import requests
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SpiderNotify():
+ @staticmethod
+ def baidu_notify(urls):
+ try:
+ data = '\n'.join(urls)
+ result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
+ logger.info(result.text)
+ except Exception as e:
+ logger.error(e)
+
+ @staticmethod
+ def notify(url):
+ SpiderNotify.baidu_notify(url)
diff --git a/src/DjangoBlog/tests.py b/src/DjangoBlog/tests.py
new file mode 100644
index 0000000..01237d9
--- /dev/null
+++ b/src/DjangoBlog/tests.py
@@ -0,0 +1,32 @@
+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)
diff --git a/src/DjangoBlog/urls.py b/src/DjangoBlog/urls.py
new file mode 100644
index 0000000..cd43ce3
--- /dev/null
+++ b/src/DjangoBlog/urls.py
@@ -0,0 +1,79 @@
+"""djangoblog URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/1.10/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.conf.urls import url, include
+ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
+"""
+from django.conf import settings
+from django.conf.urls.i18n import i18n_patterns
+from django.conf.urls.static import static
+from django.contrib.sitemaps.views import sitemap
+from django.urls import path, include
+from django.urls import re_path
+from haystack.views import search_view_factory
+from django.http import JsonResponse
+import time
+
+from blog.views import EsSearchView
+from djangoblog.admin_site import admin_site
+from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
+from djangoblog.feeds import DjangoBlogFeed
+from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
+
+sitemaps = {
+
+ 'blog': ArticleSiteMap,
+ 'Category': CategorySiteMap,
+ 'Tag': TagSiteMap,
+ 'User': UserSiteMap,
+ 'static': StaticViewSitemap
+}
+
+# szy:自定义错误页面,保证异常提示保持博客风格
+handler404 = 'blog.views.page_not_found_view'
+handler500 = 'blog.views.server_error_view'
+handler403 = 'blog.views.permission_denied_view'
+
+
+def health_check(request):
+ """
+ 健康检查接口
+ 简单返回服务健康状态
+ """
+ return JsonResponse({
+ 'status': 'healthy',
+ 'timestamp': time.time()
+ })
+
+urlpatterns = [
+ path('i18n/', include('django.conf.urls.i18n')),
+ path('health/', health_check, name='health_check'),
+]
+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'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
+ name='django.contrib.sitemaps.views.sitemap'),
+ re_path(r'^feed/$', DjangoBlogFeed()),
+ re_path(r'^rss/$', DjangoBlogFeed()),
+ 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)
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL,
+ document_root=settings.MEDIA_ROOT)
diff --git a/src/DjangoBlog/utils.py b/src/DjangoBlog/utils.py
new file mode 100644
index 0000000..50e221e
--- /dev/null
+++ b/src/DjangoBlog/utils.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python
+# encoding: utf-8
+
+
+import logging
+import os
+import random
+import string
+import uuid
+from hashlib import sha256
+
+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
+
+logger = logging.getLogger(__name__)
+
+
+def get_max_articleid_commentid():
+ from blog.models import Article
+ from comments.models import Comment
+ return (Article.objects.latest().pk, Comment.objects.latest().pk)
+
+
+def get_sha256(str):
+ m = sha256(str.encode('utf-8'))
+ return m.hexdigest()
+
+
+def cache_decorator(expiration=3 * 60):
+ def wrapper(func):
+ def news(*args, **kwargs):
+ try:
+ view = args[0]
+ key = view.get_cache_key()
+ except:
+ key = None
+ if not key:
+ unique_str = repr((func, args, kwargs))
+
+ m = sha256(unique_str.encode('utf-8'))
+ key = m.hexdigest()
+ value = cache.get(key)
+ if value is not None:
+ # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
+ if str(value) == '__default_cache_value__':
+ return None
+ else:
+ return value
+ else:
+ logger.debug(
+ 'cache_decorator set cache:%s key:%s' %
+ (func.__name__, key))
+ value = func(*args, **kwargs)
+ if value is None:
+ cache.set(key, '__default_cache_value__', expiration)
+ else:
+ cache.set(key, value, expiration)
+ return value
+
+ return news
+
+ return wrapper
+
+
+def expire_view_cache(path, servername, serverport, key_prefix=None):
+ '''
+ 刷新视图缓存
+ :param path:url路径
+ :param servername:host
+ :param serverport:端口
+ :param key_prefix:前缀
+ :return:是否成功
+ '''
+ from django.http import HttpRequest
+ from django.utils.cache import get_cache_key
+
+ request = HttpRequest()
+ request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
+ request.path = path
+
+ key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
+ if key:
+ logger.info('expire_view_cache:get key:{path}'.format(path=path))
+ if cache.get(key):
+ cache.delete(key)
+ return True
+ return False
+
+
+@cache_decorator()
+def get_current_site():
+ site = Site.objects.get_current()
+ return site
+
+
+class CommonMarkdown:
+ @staticmethod
+ def _convert_markdown(value):
+ md = markdown.Markdown(
+ extensions=[
+ 'extra',
+ 'codehilite',
+ 'toc',
+ 'tables',
+ ]
+ )
+ body = md.convert(value)
+ toc = md.toc
+ return body, toc
+
+ @staticmethod
+ def get_markdown_with_toc(value):
+ body, toc = CommonMarkdown._convert_markdown(value)
+ return body, toc
+
+ @staticmethod
+ def get_markdown(value):
+ body, toc = CommonMarkdown._convert_markdown(value)
+ return body
+
+
+def send_email(emailto, title, content):
+ from djangoblog.blog_signals import send_email_signal
+ send_email_signal.send(
+ send_email.__class__,
+ emailto=emailto,
+ title=title,
+ content=content)
+
+
+def generate_code() -> str:
+ """生成随机数验证码"""
+ return ''.join(random.sample(string.digits, 6))
+
+
+def parse_dict_to_url(dict):
+ from urllib.parse import quote
+ url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
+ for k, v in dict.items()])
+ return url
+
+
+def get_blog_setting():
+ value = cache.get('get_blog_setting')
+ if value:
+ return value
+ else:
+ from blog.models import BlogSettings
+ if not BlogSettings.objects.count():
+ setting = BlogSettings()
+ setting.site_name = 'djangoblog'
+ setting.site_description = '基于Django的博客系统'
+ setting.site_seo_description = '基于Django的博客系统'
+ setting.site_keywords = 'Django,Python'
+ setting.article_sub_length = 300
+ setting.sidebar_article_count = 10
+ setting.sidebar_comment_count = 5
+ setting.show_google_adsense = False
+ setting.open_site_comment = True
+ setting.analytics_code = ''
+ setting.beian_code = ''
+ setting.show_gongan_code = False
+ setting.comment_need_review = False
+ setting.save()
+ value = BlogSettings.objects.first()
+ logger.info('set cache get_blog_setting')
+ cache.set('get_blog_setting', value)
+ return value
+
+
+def save_user_avatar(url):
+ '''
+ 保存用户头像
+ :param url:头像url
+ :return: 本地路径
+ '''
+ logger.info(url)
+
+ try:
+ basedir = settings.AVATAR_ROOT
+ rsp = requests.get(url, timeout=2)
+ if rsp.status_code == 200:
+ os.makedirs(basedir, exist_ok=True)
+
+ image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
+ isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
+ ext = os.path.splitext(url)[1] if isimage else '.jpg'
+ save_filename = str(uuid.uuid4().hex) + ext
+ logger.info('保存用户头像:' + basedir + save_filename)
+ avatar_path = os.path.join(basedir, save_filename)
+ with open(avatar_path, 'wb+') as file:
+ file.write(rsp.content)
+ return f'{settings.AVATAR_URL}{save_filename}'
+ except Exception as e:
+ logger.error(e)
+ return static('blog/img/avatar.png')
+
+
+def delete_sidebar_cache():
+ from blog.models import LinkShowType
+ keys = ["sidebar" + x for x in LinkShowType.values]
+ for k in keys:
+ logger.info('delete sidebar key:' + k)
+ cache.delete(k)
+
+
+def delete_view_cache(prefix, keys):
+ from django.core.cache.utils import make_template_fragment_key
+ key = make_template_fragment_key(prefix, keys)
+ cache.delete(key)
+
+
+def get_resource_url():
+ if settings.STATIC_URL:
+ return settings.STATIC_URL
+ else:
+ site = get_current_site()
+ return 'http://' + site.domain + '/static/'
+
+
+ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
+ 'h2', 'p', 'span', 'div']
+
+# 安全的class值白名单 - 只允许代码高亮相关的class
+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',
+ 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn',
+ 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2',
+ 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
+]
+
+def class_filter(tag, name, value):
+ """自定义class属性过滤器"""
+ if name == 'class':
+ # 只允许预定义的安全class值
+ allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
+ return ' '.join(allowed_classes) if allowed_classes else False
+ return value
+
+# 安全的属性白名单
+ALLOWED_ATTRIBUTES = {
+ 'a': ['href', 'title'],
+ 'abbr': ['title'],
+ 'acronym': ['title'],
+ 'span': class_filter,
+ 'div': class_filter,
+ 'pre': class_filter,
+ 'code': class_filter
+}
+
+# 安全的协议白名单 - 防止javascript:等危险协议
+ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
+
+def sanitize_html(html):
+ """
+ 安全的HTML清理函数
+ 使用bleach库进行白名单过滤,防止XSS攻击
+ """
+ return bleach.clean(
+ html,
+ tags=ALLOWED_TAGS,
+ attributes=ALLOWED_ATTRIBUTES,
+ protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
+ strip=True, # 移除不允许的标签而不是转义
+ strip_comments=True # 移除HTML注释
+ )
diff --git a/src/DjangoBlog/whoosh_cn_backend.py b/src/DjangoBlog/whoosh_cn_backend.py
new file mode 100644
index 0000000..04e3f7f
--- /dev/null
+++ b/src/DjangoBlog/whoosh_cn_backend.py
@@ -0,0 +1,1044 @@
+# encoding: utf-8
+
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import json
+import os
+import re
+import shutil
+import threading
+import warnings
+
+import six
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from datetime import datetime
+from django.utils.encoding import force_str
+from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
+from haystack.constants import DJANGO_CT, DJANGO_ID, ID
+from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
+from haystack.inputs import Clean, Exact, PythonData, Raw
+from haystack.models import SearchResult
+from haystack.utils import get_identifier, get_model_ct
+from haystack.utils import log as logging
+from haystack.utils.app_loading import haystack_get_model
+from jieba.analyse import ChineseAnalyzer
+from whoosh import index
+from whoosh.analysis import StemmingAnalyzer
+from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
+from whoosh.fields import ID as WHOOSH_ID
+from whoosh.filedb.filestore import FileStorage, RamStorage
+from whoosh.highlight import ContextFragmenter, HtmlFormatter
+from whoosh.highlight import highlight as whoosh_highlight
+from whoosh.qparser import QueryParser
+from whoosh.searching import ResultsPage
+from whoosh.writing import AsyncWriter
+
+try:
+ import whoosh
+except ImportError:
+ raise MissingDependency(
+ "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
+
+# Handle minimum requirement.
+if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
+ raise MissingDependency(
+ "The 'whoosh' backend requires version 2.5.0 or greater.")
+
+# Bubble up the correct error.
+
+DATETIME_REGEX = re.compile(
+ '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$')
+LOCALS = threading.local()
+LOCALS.RAM_STORE = None
+
+
+class WhooshHtmlFormatter(HtmlFormatter):
+ """
+ This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
+ We use it to have consistent results across backends. Specifically,
+ Solr, Xapian and Elasticsearch are using this formatting.
+ """
+ template = '<%(tag)s>%(t)s%(tag)s>'
+
+
+class WhooshSearchBackend(BaseSearchBackend):
+ # Word reserved by Whoosh for special use.
+ RESERVED_WORDS = (
+ 'AND',
+ 'NOT',
+ 'OR',
+ 'TO',
+ )
+
+ # Characters reserved by Whoosh for special use.
+ # The '\\' must come first, so as not to overwrite the other slash
+ # replacements.
+ RESERVED_CHARACTERS = (
+ '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}',
+ '[', ']', '^', '"', '~', '*', '?', ':', '.',
+ )
+
+ def __init__(self, connection_alias, **connection_options):
+ super(
+ WhooshSearchBackend,
+ self).__init__(
+ connection_alias,
+ **connection_options)
+ self.setup_complete = False
+ self.use_file_storage = True
+ self.post_limit = getattr(
+ connection_options,
+ 'POST_LIMIT',
+ 128 * 1024 * 1024)
+ self.path = connection_options.get('PATH')
+
+ if connection_options.get('STORAGE', 'file') != 'file':
+ self.use_file_storage = False
+
+ if self.use_file_storage and not self.path:
+ raise ImproperlyConfigured(
+ "You must specify a 'PATH' in your settings for connection '%s'." %
+ connection_alias)
+
+ self.log = logging.getLogger('haystack')
+
+ def setup(self):
+ """
+ Defers loading until needed.
+ """
+ from haystack import connections
+ new_index = False
+
+ # Make sure the index is there.
+ if self.use_file_storage and not os.path.exists(self.path):
+ os.makedirs(self.path)
+ new_index = True
+
+ if self.use_file_storage and not os.access(self.path, os.W_OK):
+ raise IOError(
+ "The path to your Whoosh index '%s' is not writable for the current user/group." %
+ self.path)
+
+ if self.use_file_storage:
+ self.storage = FileStorage(self.path)
+ else:
+ global LOCALS
+
+ if getattr(LOCALS, 'RAM_STORE', None) is None:
+ LOCALS.RAM_STORE = RamStorage()
+
+ self.storage = LOCALS.RAM_STORE
+
+ self.content_field_name, self.schema = self.build_schema(
+ connections[self.connection_alias].get_unified_index().all_searchfields())
+ self.parser = QueryParser(self.content_field_name, schema=self.schema)
+
+ if new_index is True:
+ self.index = self.storage.create_index(self.schema)
+ else:
+ try:
+ self.index = self.storage.open_index(schema=self.schema)
+ except index.EmptyIndexError:
+ self.index = self.storage.create_index(self.schema)
+
+ self.setup_complete = True
+
+ def build_schema(self, fields):
+ schema_fields = {
+ ID: WHOOSH_ID(stored=True, unique=True),
+ DJANGO_CT: WHOOSH_ID(stored=True),
+ DJANGO_ID: WHOOSH_ID(stored=True),
+ }
+ # Grab the number of keys that are hard-coded into Haystack.
+ # We'll use this to (possibly) fail slightly more gracefully later.
+ initial_key_count = len(schema_fields)
+ content_field_name = ''
+
+ for field_name, field_class in fields.items():
+ if field_class.is_multivalued:
+ if field_class.indexed is False:
+ schema_fields[field_class.index_fieldname] = IDLIST(
+ stored=True, field_boost=field_class.boost)
+ else:
+ schema_fields[field_class.index_fieldname] = KEYWORD(
+ stored=True, commas=True, scorable=True, field_boost=field_class.boost)
+ elif field_class.field_type in ['date', 'datetime']:
+ schema_fields[field_class.index_fieldname] = DATETIME(
+ stored=field_class.stored, sortable=True)
+ elif field_class.field_type == 'integer':
+ schema_fields[field_class.index_fieldname] = NUMERIC(
+ stored=field_class.stored, numtype=int, field_boost=field_class.boost)
+ elif field_class.field_type == 'float':
+ schema_fields[field_class.index_fieldname] = NUMERIC(
+ stored=field_class.stored, numtype=float, field_boost=field_class.boost)
+ elif field_class.field_type == 'boolean':
+ # Field boost isn't supported on BOOLEAN as of 1.8.2.
+ schema_fields[field_class.index_fieldname] = BOOLEAN(
+ stored=field_class.stored)
+ elif field_class.field_type == 'ngram':
+ schema_fields[field_class.index_fieldname] = NGRAM(
+ minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost)
+ elif field_class.field_type == 'edge_ngram':
+ schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start',
+ stored=field_class.stored,
+ field_boost=field_class.boost)
+ else:
+ # schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True)
+ schema_fields[field_class.index_fieldname] = TEXT(
+ stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True)
+ if field_class.document is True:
+ content_field_name = field_class.index_fieldname
+ schema_fields[field_class.index_fieldname].spelling = True
+
+ # Fail more gracefully than relying on the backend to die if no fields
+ # are found.
+ if len(schema_fields) <= initial_key_count:
+ raise SearchBackendError(
+ "No fields were found in any search_indexes. Please correct this before attempting to search.")
+
+ return (content_field_name, Schema(**schema_fields))
+
+ def update(self, index, iterable, commit=True):
+ if not self.setup_complete:
+ self.setup()
+
+ self.index = self.index.refresh()
+ writer = AsyncWriter(self.index)
+
+ for obj in iterable:
+ try:
+ doc = index.full_prepare(obj)
+ except SkipDocument:
+ self.log.debug(u"Indexing for object `%s` skipped", obj)
+ else:
+ # Really make sure it's unicode, because Whoosh won't have it any
+ # other way.
+ for key in doc:
+ doc[key] = self._from_python(doc[key])
+
+ # Document boosts aren't supported in Whoosh 2.5.0+.
+ if 'boost' in doc:
+ del doc['boost']
+
+ try:
+ writer.update_document(**doc)
+ except Exception as e:
+ if not self.silently_fail:
+ raise
+
+ # We'll log the object identifier but won't include the actual object
+ # to avoid the possibility of that generating encoding errors while
+ # processing the log message:
+ self.log.error(
+ u"%s while preparing object for update" %
+ e.__class__.__name__,
+ exc_info=True,
+ extra={
+ "data": {
+ "index": index,
+ "object": get_identifier(obj)}})
+
+ if len(iterable) > 0:
+ # For now, commit no matter what, as we run into locking issues
+ # otherwise.
+ writer.commit()
+
+ def remove(self, obj_or_string, commit=True):
+ if not self.setup_complete:
+ self.setup()
+
+ self.index = self.index.refresh()
+ whoosh_id = get_identifier(obj_or_string)
+
+ try:
+ self.index.delete_by_query(
+ q=self.parser.parse(
+ u'%s:"%s"' %
+ (ID, whoosh_id)))
+ except Exception as e:
+ if not self.silently_fail:
+ raise
+
+ self.log.error(
+ "Failed to remove document '%s' from Whoosh: %s",
+ whoosh_id,
+ e,
+ exc_info=True)
+
+ def clear(self, models=None, commit=True):
+ if not self.setup_complete:
+ self.setup()
+
+ self.index = self.index.refresh()
+
+ if models is not None:
+ assert isinstance(models, (list, tuple))
+
+ try:
+ if models is None:
+ self.delete_index()
+ else:
+ models_to_delete = []
+
+ for model in models:
+ models_to_delete.append(
+ u"%s:%s" %
+ (DJANGO_CT, get_model_ct(model)))
+
+ self.index.delete_by_query(
+ q=self.parser.parse(
+ u" OR ".join(models_to_delete)))
+ except Exception as e:
+ if not self.silently_fail:
+ raise
+
+ if models is not None:
+ self.log.error(
+ "Failed to clear Whoosh index of models '%s': %s",
+ ','.join(models_to_delete),
+ e,
+ exc_info=True)
+ else:
+ self.log.error(
+ "Failed to clear Whoosh index: %s", e, exc_info=True)
+
+ def delete_index(self):
+ # Per the Whoosh mailing list, if wiping out everything from the index,
+ # it's much more efficient to simply delete the index files.
+ if self.use_file_storage and os.path.exists(self.path):
+ shutil.rmtree(self.path)
+ elif not self.use_file_storage:
+ self.storage.clean()
+
+ # Recreate everything.
+ self.setup()
+
+ def optimize(self):
+ if not self.setup_complete:
+ self.setup()
+
+ self.index = self.index.refresh()
+ self.index.optimize()
+
+ def calculate_page(self, start_offset=0, end_offset=None):
+ # Prevent against Whoosh throwing an error. Requires an end_offset
+ # greater than 0.
+ if end_offset is not None and end_offset <= 0:
+ end_offset = 1
+
+ # Determine the page.
+ page_num = 0
+
+ if end_offset is None:
+ end_offset = 1000000
+
+ if start_offset is None:
+ start_offset = 0
+
+ page_length = end_offset - start_offset
+
+ if page_length and page_length > 0:
+ page_num = int(start_offset / page_length)
+
+ # Increment because Whoosh uses 1-based page numbers.
+ page_num += 1
+ return page_num, page_length
+
+ @log_query
+ def search(
+ self,
+ query_string,
+ sort_by=None,
+ start_offset=0,
+ end_offset=None,
+ fields='',
+ highlight=False,
+ facets=None,
+ date_facets=None,
+ query_facets=None,
+ narrow_queries=None,
+ spelling_query=None,
+ within=None,
+ dwithin=None,
+ distance_point=None,
+ models=None,
+ limit_to_registered_models=None,
+ result_class=None,
+ **kwargs):
+ if not self.setup_complete:
+ self.setup()
+
+ # A zero length query should return no results.
+ if len(query_string) == 0:
+ return {
+ 'results': [],
+ 'hits': 0,
+ }
+
+ query_string = force_str(query_string)
+
+ # A one-character query (non-wildcard) gets nabbed by a stopwords
+ # filter and should yield zero results.
+ if len(query_string) <= 1 and query_string != u'*':
+ return {
+ 'results': [],
+ 'hits': 0,
+ }
+
+ reverse = False
+
+ if sort_by is not None:
+ # Determine if we need to reverse the results and if Whoosh can
+ # handle what it's being asked to sort by. Reversing is an
+ # all-or-nothing action, unfortunately.
+ sort_by_list = []
+ reverse_counter = 0
+
+ for order_by in sort_by:
+ if order_by.startswith('-'):
+ reverse_counter += 1
+
+ if reverse_counter and reverse_counter != len(sort_by):
+ raise SearchBackendError("Whoosh requires all order_by fields"
+ " to use the same sort direction")
+
+ for order_by in sort_by:
+ if order_by.startswith('-'):
+ sort_by_list.append(order_by[1:])
+
+ if len(sort_by_list) == 1:
+ reverse = True
+ else:
+ sort_by_list.append(order_by)
+
+ if len(sort_by_list) == 1:
+ reverse = False
+
+ sort_by = sort_by_list[0]
+
+ if facets is not None:
+ warnings.warn(
+ "Whoosh does not handle faceting.",
+ Warning,
+ stacklevel=2)
+
+ if date_facets is not None:
+ warnings.warn(
+ "Whoosh does not handle date faceting.",
+ Warning,
+ stacklevel=2)
+
+ if query_facets is not None:
+ warnings.warn(
+ "Whoosh does not handle query faceting.",
+ Warning,
+ stacklevel=2)
+
+ narrowed_results = None
+ self.index = self.index.refresh()
+
+ if limit_to_registered_models is None:
+ limit_to_registered_models = getattr(
+ settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
+
+ if models and len(models):
+ model_choices = sorted(get_model_ct(model) for model in models)
+ elif limit_to_registered_models:
+ # Using narrow queries, limit the results to only models handled
+ # with the current routers.
+ model_choices = self.build_models_list()
+ else:
+ model_choices = []
+
+ if len(model_choices) > 0:
+ if narrow_queries is None:
+ narrow_queries = set()
+
+ narrow_queries.add(' OR '.join(
+ ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
+
+ narrow_searcher = None
+
+ if narrow_queries is not None:
+ # Potentially expensive? I don't see another way to do it in
+ # Whoosh...
+ narrow_searcher = self.index.searcher()
+
+ for nq in narrow_queries:
+ recent_narrowed_results = narrow_searcher.search(
+ self.parser.parse(force_str(nq)), limit=None)
+
+ if len(recent_narrowed_results) <= 0:
+ return {
+ 'results': [],
+ 'hits': 0,
+ }
+
+ if narrowed_results:
+ narrowed_results.filter(recent_narrowed_results)
+ else:
+ narrowed_results = recent_narrowed_results
+
+ self.index = self.index.refresh()
+
+ if self.index.doc_count():
+ searcher = self.index.searcher()
+ parsed_query = self.parser.parse(query_string)
+
+ # In the event of an invalid/stopworded query, recover gracefully.
+ if parsed_query is None:
+ return {
+ 'results': [],
+ 'hits': 0,
+ }
+
+ page_num, page_length = self.calculate_page(
+ start_offset, end_offset)
+
+ search_kwargs = {
+ 'pagelen': page_length,
+ 'sortedby': sort_by,
+ 'reverse': reverse,
+ }
+
+ # Handle the case where the results have been narrowed.
+ if narrowed_results is not None:
+ search_kwargs['filter'] = narrowed_results
+
+ try:
+ raw_page = searcher.search_page(
+ parsed_query,
+ page_num,
+ **search_kwargs
+ )
+ except ValueError:
+ if not self.silently_fail:
+ raise
+
+ return {
+ 'results': [],
+ 'hits': 0,
+ 'spelling_suggestion': None,
+ }
+
+ # Because as of Whoosh 2.5.1, it will return the wrong page of
+ # results if you request something too high. :(
+ if raw_page.pagenum < page_num:
+ return {
+ 'results': [],
+ 'hits': 0,
+ 'spelling_suggestion': None,
+ }
+
+ results = self._process_results(
+ raw_page,
+ highlight=highlight,
+ query_string=query_string,
+ spelling_query=spelling_query,
+ result_class=result_class)
+ searcher.close()
+
+ if hasattr(narrow_searcher, 'close'):
+ narrow_searcher.close()
+
+ return results
+ else:
+ if self.include_spelling:
+ if spelling_query:
+ spelling_suggestion = self.create_spelling_suggestion(
+ spelling_query)
+ else:
+ spelling_suggestion = self.create_spelling_suggestion(
+ query_string)
+ else:
+ spelling_suggestion = None
+
+ return {
+ 'results': [],
+ 'hits': 0,
+ 'spelling_suggestion': spelling_suggestion,
+ }
+
+ def more_like_this(
+ self,
+ model_instance,
+ additional_query_string=None,
+ start_offset=0,
+ end_offset=None,
+ models=None,
+ limit_to_registered_models=None,
+ result_class=None,
+ **kwargs):
+ if not self.setup_complete:
+ self.setup()
+
+ # Deferred models will have a different class ("RealClass_Deferred_fieldname")
+ # which won't be in our registry:
+ model_klass = model_instance._meta.concrete_model
+
+ field_name = self.content_field_name
+ narrow_queries = set()
+ narrowed_results = None
+ self.index = self.index.refresh()
+
+ if limit_to_registered_models is None:
+ limit_to_registered_models = getattr(
+ settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
+
+ if models and len(models):
+ model_choices = sorted(get_model_ct(model) for model in models)
+ elif limit_to_registered_models:
+ # Using narrow queries, limit the results to only models handled
+ # with the current routers.
+ model_choices = self.build_models_list()
+ else:
+ model_choices = []
+
+ if len(model_choices) > 0:
+ if narrow_queries is None:
+ narrow_queries = set()
+
+ narrow_queries.add(' OR '.join(
+ ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
+
+ if additional_query_string and additional_query_string != '*':
+ narrow_queries.add(additional_query_string)
+
+ narrow_searcher = None
+
+ if narrow_queries is not None:
+ # Potentially expensive? I don't see another way to do it in
+ # Whoosh...
+ narrow_searcher = self.index.searcher()
+
+ for nq in narrow_queries:
+ recent_narrowed_results = narrow_searcher.search(
+ self.parser.parse(force_str(nq)), limit=None)
+
+ if len(recent_narrowed_results) <= 0:
+ return {
+ 'results': [],
+ 'hits': 0,
+ }
+
+ if narrowed_results:
+ narrowed_results.filter(recent_narrowed_results)
+ else:
+ narrowed_results = recent_narrowed_results
+
+ page_num, page_length = self.calculate_page(start_offset, end_offset)
+
+ self.index = self.index.refresh()
+ raw_results = EmptyResults()
+
+ if self.index.doc_count():
+ query = "%s:%s" % (ID, get_identifier(model_instance))
+ searcher = self.index.searcher()
+ parsed_query = self.parser.parse(query)
+ results = searcher.search(parsed_query)
+
+ if len(results):
+ raw_results = results[0].more_like_this(
+ field_name, top=end_offset)
+
+ # Handle the case where the results have been narrowed.
+ if narrowed_results is not None and hasattr(raw_results, 'filter'):
+ raw_results.filter(narrowed_results)
+
+ try:
+ raw_page = ResultsPage(raw_results, page_num, page_length)
+ except ValueError:
+ if not self.silently_fail:
+ raise
+
+ return {
+ 'results': [],
+ 'hits': 0,
+ 'spelling_suggestion': None,
+ }
+
+ # Because as of Whoosh 2.5.1, it will return the wrong page of
+ # results if you request something too high. :(
+ if raw_page.pagenum < page_num:
+ return {
+ 'results': [],
+ 'hits': 0,
+ 'spelling_suggestion': None,
+ }
+
+ results = self._process_results(raw_page, result_class=result_class)
+ searcher.close()
+
+ if hasattr(narrow_searcher, 'close'):
+ narrow_searcher.close()
+
+ return results
+
+ def _process_results(
+ self,
+ raw_page,
+ highlight=False,
+ query_string='',
+ spelling_query=None,
+ result_class=None):
+ from haystack import connections
+ results = []
+
+ # It's important to grab the hits first before slicing. Otherwise, this
+ # can cause pagination failures.
+ hits = len(raw_page)
+
+ if result_class is None:
+ result_class = SearchResult
+
+ facets = {}
+ spelling_suggestion = None
+ unified_index = connections[self.connection_alias].get_unified_index()
+ indexed_models = unified_index.get_indexed_models()
+
+ for doc_offset, raw_result in enumerate(raw_page):
+ score = raw_page.score(doc_offset) or 0
+ app_label, model_name = raw_result[DJANGO_CT].split('.')
+ additional_fields = {}
+ model = haystack_get_model(app_label, model_name)
+
+ if model and model in indexed_models:
+ for key, value in raw_result.items():
+ index = unified_index.get_index(model)
+ string_key = str(key)
+
+ if string_key in index.fields and hasattr(
+ index.fields[string_key], 'convert'):
+ # Special-cased due to the nature of KEYWORD fields.
+ if index.fields[string_key].is_multivalued:
+ if value is None or len(value) == 0:
+ additional_fields[string_key] = []
+ else:
+ additional_fields[string_key] = value.split(
+ ',')
+ else:
+ additional_fields[string_key] = index.fields[string_key].convert(
+ value)
+ else:
+ additional_fields[string_key] = self._to_python(value)
+
+ del (additional_fields[DJANGO_CT])
+ del (additional_fields[DJANGO_ID])
+
+ if highlight:
+ sa = StemmingAnalyzer()
+ formatter = WhooshHtmlFormatter('em')
+ terms = [token.text for token in sa(query_string)]
+
+ whoosh_result = whoosh_highlight(
+ additional_fields.get(self.content_field_name),
+ terms,
+ sa,
+ ContextFragmenter(),
+ formatter
+ )
+ additional_fields['highlighted'] = {
+ self.content_field_name: [whoosh_result],
+ }
+
+ result = result_class(
+ app_label,
+ model_name,
+ raw_result[DJANGO_ID],
+ score,
+ **additional_fields)
+ results.append(result)
+ else:
+ hits -= 1
+
+ if self.include_spelling:
+ if spelling_query:
+ spelling_suggestion = self.create_spelling_suggestion(
+ spelling_query)
+ else:
+ spelling_suggestion = self.create_spelling_suggestion(
+ query_string)
+
+ return {
+ 'results': results,
+ 'hits': hits,
+ 'facets': facets,
+ 'spelling_suggestion': spelling_suggestion,
+ }
+
+ def create_spelling_suggestion(self, query_string):
+ spelling_suggestion = None
+ reader = self.index.reader()
+ corrector = reader.corrector(self.content_field_name)
+ cleaned_query = force_str(query_string)
+
+ if not query_string:
+ return spelling_suggestion
+
+ # Clean the string.
+ for rev_word in self.RESERVED_WORDS:
+ cleaned_query = cleaned_query.replace(rev_word, '')
+
+ for rev_char in self.RESERVED_CHARACTERS:
+ cleaned_query = cleaned_query.replace(rev_char, '')
+
+ # Break it down.
+ query_words = cleaned_query.split()
+ suggested_words = []
+
+ for word in query_words:
+ suggestions = corrector.suggest(word, limit=1)
+
+ if len(suggestions) > 0:
+ suggested_words.append(suggestions[0])
+
+ spelling_suggestion = ' '.join(suggested_words)
+ return spelling_suggestion
+
+ def _from_python(self, value):
+ """
+ Converts Python values to a string for Whoosh.
+
+ Code courtesy of pysolr.
+ """
+ if hasattr(value, 'strftime'):
+ if not hasattr(value, 'hour'):
+ value = datetime(value.year, value.month, value.day, 0, 0, 0)
+ elif isinstance(value, bool):
+ if value:
+ value = 'true'
+ else:
+ value = 'false'
+ elif isinstance(value, (list, tuple)):
+ value = u','.join([force_str(v) for v in value])
+ elif isinstance(value, (six.integer_types, float)):
+ # Leave it alone.
+ pass
+ else:
+ value = force_str(value)
+ return value
+
+ def _to_python(self, value):
+ """
+ Converts values from Whoosh to native Python values.
+
+ A port of the same method in pysolr, as they deal with data the same way.
+ """
+ if value == 'true':
+ return True
+ elif value == 'false':
+ return False
+
+ if value and isinstance(value, six.string_types):
+ possible_datetime = DATETIME_REGEX.search(value)
+
+ if possible_datetime:
+ date_values = possible_datetime.groupdict()
+
+ for dk, dv in date_values.items():
+ date_values[dk] = int(dv)
+
+ return datetime(
+ date_values['year'],
+ date_values['month'],
+ date_values['day'],
+ date_values['hour'],
+ date_values['minute'],
+ date_values['second'])
+
+ try:
+ # Attempt to use json to load the values.
+ converted_value = json.loads(value)
+
+ # Try to handle most built-in types.
+ if isinstance(
+ converted_value,
+ (list,
+ tuple,
+ set,
+ dict,
+ six.integer_types,
+ float,
+ complex)):
+ return converted_value
+ except BaseException:
+ # If it fails (SyntaxError or its ilk) or we don't trust it,
+ # continue on.
+ pass
+
+ return value
+
+
+class WhooshSearchQuery(BaseSearchQuery):
+ def _convert_datetime(self, date):
+ if hasattr(date, 'hour'):
+ return force_str(date.strftime('%Y%m%d%H%M%S'))
+ else:
+ 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.
+ """
+ words = query_fragment.split()
+ cleaned_words = []
+
+ for word in words:
+ if word in self.backend.RESERVED_WORDS:
+ word = word.replace(word, word.lower())
+
+ for char in self.backend.RESERVED_CHARACTERS:
+ if char in word:
+ word = "'%s'" % word
+ break
+
+ cleaned_words.append(word)
+
+ return ' '.join(cleaned_words)
+
+ def build_query_fragment(self, field, filter_type, value):
+ from haystack import connections
+ query_frag = ''
+ is_datetime = False
+
+ if not hasattr(value, 'input_type_name'):
+ # Handle when we've got a ``ValuesListQuerySet``...
+ if hasattr(value, 'values_list'):
+ value = list(value)
+
+ if hasattr(value, 'strftime'):
+ is_datetime = True
+
+ if isinstance(value, six.string_types) and value != ' ':
+ # It's not an ``InputType``. Assume ``Clean``.
+ value = Clean(value)
+ else:
+ value = PythonData(value)
+
+ # Prepare the query using the InputType.
+ prepared_value = value.prepare(self)
+
+ if not isinstance(prepared_value, (set, list, tuple)):
+ # Then convert whatever we get back to what pysolr wants if needed.
+ prepared_value = self.backend._from_python(prepared_value)
+
+ # 'content' is a special reserved word, much like 'pk' in
+ # Django's ORM layer. It indicates 'no special field'.
+ if field == 'content':
+ index_fieldname = ''
+ else:
+ index_fieldname = u'%s:' % connections[self._using].get_unified_index(
+ ).get_index_fieldname(field)
+
+ filter_types = {
+ 'content': '%s',
+ 'contains': '*%s*',
+ 'endswith': "*%s",
+ 'startswith': "%s*",
+ 'exact': '%s',
+ 'gt': "{%s to}",
+ 'gte': "[%s to]",
+ 'lt': "{to %s}",
+ 'lte': "[to %s]",
+ 'fuzzy': u'%s~',
+ }
+
+ if value.post_process is False:
+ query_frag = prepared_value
+ else:
+ if filter_type in [
+ 'content',
+ 'contains',
+ 'startswith',
+ 'endswith',
+ 'fuzzy']:
+ if value.input_type_name == 'exact':
+ query_frag = prepared_value
+ else:
+ # Iterate over terms & incorportate the converted form of
+ # each into the query.
+ terms = []
+
+ if isinstance(prepared_value, six.string_types):
+ possible_values = prepared_value.split(' ')
+ else:
+ if is_datetime is True:
+ prepared_value = self._convert_datetime(
+ prepared_value)
+
+ possible_values = [prepared_value]
+
+ for possible_value in possible_values:
+ terms.append(
+ filter_types[filter_type] %
+ self.backend._from_python(possible_value))
+
+ if len(terms) == 1:
+ query_frag = terms[0]
+ else:
+ query_frag = u"(%s)" % " AND ".join(terms)
+ elif filter_type == 'in':
+ in_options = []
+
+ for possible_value in prepared_value:
+ is_datetime = False
+
+ if hasattr(possible_value, 'strftime'):
+ is_datetime = True
+
+ pv = self.backend._from_python(possible_value)
+
+ if is_datetime is True:
+ pv = self._convert_datetime(pv)
+
+ if isinstance(pv, six.string_types) and not is_datetime:
+ in_options.append('"%s"' % pv)
+ else:
+ in_options.append('%s' % pv)
+
+ query_frag = "(%s)" % " OR ".join(in_options)
+ elif filter_type == 'range':
+ start = self.backend._from_python(prepared_value[0])
+ end = self.backend._from_python(prepared_value[1])
+
+ if hasattr(prepared_value[0], 'strftime'):
+ start = self._convert_datetime(start)
+
+ if hasattr(prepared_value[1], 'strftime'):
+ end = self._convert_datetime(end)
+
+ query_frag = u"[%s to %s]" % (start, end)
+ elif filter_type == 'exact':
+ if value.input_type_name == 'exact':
+ query_frag = prepared_value
+ else:
+ prepared_value = Exact(prepared_value).prepare(self)
+ query_frag = filter_types[filter_type] % prepared_value
+ else:
+ if is_datetime is True:
+ prepared_value = self._convert_datetime(prepared_value)
+
+ query_frag = filter_types[filter_type] % prepared_value
+
+ if len(query_frag) and not isinstance(value, Raw):
+ if not query_frag.startswith('(') and not query_frag.endswith(')'):
+ query_frag = "(%s)" % query_frag
+
+ return u"%s%s" % (index_fieldname, query_frag)
+
+ # if not filter_type in ('in', 'range'):
+ # # 'in' is a bit of a special case, as we don't want to
+ # # convert a valid list/tuple to string. Defer handling it
+ # # until later...
+ # value = self.backend._from_python(value)
+
+
+class WhooshEngine(BaseEngine):
+ backend = WhooshSearchBackend
+ query = WhooshSearchQuery
diff --git a/src/DjangoBlog/wsgi.py b/src/DjangoBlog/wsgi.py
new file mode 100644
index 0000000..2295efd
--- /dev/null
+++ b/src/DjangoBlog/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for djangoblog project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
+
+application = get_wsgi_application()