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' -# 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' + + +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()