diff --git a/src/djangoblog/__init__.py b/src/djangoblog/__init__.py new file mode 100644 index 0000000..2ab0c39 --- /dev/null +++ b/src/djangoblog/__init__.py @@ -0,0 +1,2 @@ +"""sh:该配置类通常位于djangoblog/apps.py文件中,可用于定义应用的名称、信号注册、启动时执行的操作等""" +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/src/djangoblog/__pycache__/__init__.cpython-312.pyc b/src/djangoblog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1cf126d Binary files /dev/null and b/src/djangoblog/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/admin_site.cpython-312.pyc b/src/djangoblog/__pycache__/admin_site.cpython-312.pyc new file mode 100644 index 0000000..813f9a6 Binary files /dev/null and b/src/djangoblog/__pycache__/admin_site.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/apps.cpython-312.pyc b/src/djangoblog/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..b069847 Binary files /dev/null and b/src/djangoblog/__pycache__/apps.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/blog_signals.cpython-312.pyc b/src/djangoblog/__pycache__/blog_signals.cpython-312.pyc new file mode 100644 index 0000000..ea0acea Binary files /dev/null and b/src/djangoblog/__pycache__/blog_signals.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/elasticsearch_backend.cpython-312.pyc b/src/djangoblog/__pycache__/elasticsearch_backend.cpython-312.pyc new file mode 100644 index 0000000..c4847a7 Binary files /dev/null and b/src/djangoblog/__pycache__/elasticsearch_backend.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/feeds.cpython-312.pyc b/src/djangoblog/__pycache__/feeds.cpython-312.pyc new file mode 100644 index 0000000..2d4ee8c Binary files /dev/null and b/src/djangoblog/__pycache__/feeds.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/logentryadmin.cpython-312.pyc b/src/djangoblog/__pycache__/logentryadmin.cpython-312.pyc new file mode 100644 index 0000000..665fbb5 Binary files /dev/null and b/src/djangoblog/__pycache__/logentryadmin.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/settings.cpython-312.pyc b/src/djangoblog/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..82dede5 Binary files /dev/null and b/src/djangoblog/__pycache__/settings.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/sitemap.cpython-312.pyc b/src/djangoblog/__pycache__/sitemap.cpython-312.pyc new file mode 100644 index 0000000..17a2630 Binary files /dev/null and b/src/djangoblog/__pycache__/sitemap.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/spider_notify.cpython-312.pyc b/src/djangoblog/__pycache__/spider_notify.cpython-312.pyc new file mode 100644 index 0000000..6fdd705 Binary files /dev/null and b/src/djangoblog/__pycache__/spider_notify.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/urls.cpython-312.pyc b/src/djangoblog/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..61f9da3 Binary files /dev/null and b/src/djangoblog/__pycache__/urls.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/utils.cpython-312.pyc b/src/djangoblog/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..93e3ca8 Binary files /dev/null and b/src/djangoblog/__pycache__/utils.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/whoosh_cn_backend.cpython-312.pyc b/src/djangoblog/__pycache__/whoosh_cn_backend.cpython-312.pyc new file mode 100644 index 0000000..e61cfe1 Binary files /dev/null and b/src/djangoblog/__pycache__/whoosh_cn_backend.cpython-312.pyc differ diff --git a/src/djangoblog/__pycache__/wsgi.cpython-312.pyc b/src/djangoblog/__pycache__/wsgi.cpython-312.pyc new file mode 100644 index 0000000..09d2d05 Binary files /dev/null and b/src/djangoblog/__pycache__/wsgi.cpython-312.pyc differ diff --git a/src/djangoblog/admin_site.py b/src/djangoblog/admin_site.py new file mode 100644 index 0000000..1c43ee4 --- /dev/null +++ b/src/djangoblog/admin_site.py @@ -0,0 +1,63 @@ +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 UserAdmin, GroupAdmin +from blog.admin import PostAdmin, CategoryAdmin +from blog.models import Post, Category +from comments.admin import CommentAdmin +from comments.models import Comment +from djangoblog.logentryadmin import LogEntryAdmin +from oauth.admin import OAuthAppAdmin, OAuthUserAdmin +from oauth.models import OAuthApp, OAuthUser +from owntracks.admin import DeviceAdmin, LocationAdmin +from owntracks.models import Device, Location +from servermanager.admin import ServerAdmin, TaskAdmin +from servermanager.models import Server, Task + +class DjangoBlogAdminSite(AdminSite): + """ + sh: + 自定义Admin站点类,继承自Django的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 + + +admin_site = DjangoBlogAdminSite(name='admin') +"""注册博客核心模型及对应的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) +"""注册OAuth相关模型""" +admin_site.register(OAuthUser, OAuthUserAdmin) +admin_site.register(OAuthConfig, OAuthConfigAdmin) +"""注册OwnTracks位置追踪模型""" +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..9176a05 --- /dev/null +++ b/src/djangoblog/apps.py @@ -0,0 +1,22 @@ +from django.apps import AppConfig + +class DjangoblogAppConfig(AppConfig): + """ + sh: + 博客应用的自定义配置类,用于管理应用的初始化和生命周期 + 继承自Django的AppConfig,可重写方法实现应用启动时的自定义逻辑 + """ + """指定模型默认的自增字段类型为BigAutoField(大整数自增字段,支持更大范围的ID""" + default_auto_field = 'django.db.models.BigAutoField' + """应用的名称(与项目结构中应用的目录名一致)""" + name = 'djangoblog' + + def ready(self): + """ + 重写AppConfig的ready方法,在应用加载就绪时执行初始化操作 + 该方法会在Django项目启动、应用注册表就绪后被调用 + """ + super().ready() + """导入并加载插件(插件加载逻辑放在此处,确保应用就绪后再执行)""" + from .plugin_manage.loader import load_plugins + load_plugins() \ 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..3a9793c --- /dev/null +++ b/src/djangoblog/blog_signals.py @@ -0,0 +1,142 @@ +import _thread +import logging + +import django.dispatch +from django.conf import settings +from django.contrib.admin.models import LogEntry +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.mail import EmailMultiAlternatives +from django.db.models.signals import post_save +from django.dispatch import receiver + +from comments.models import Comment +from comments.utils import send_comment_email +from djangoblog.spider_notify import SpiderNotify +from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache +from djangoblog.utils import get_current_site +from oauth.models import OAuthUser + +logger = logging.getLogger(__name__) + +oauth_user_login_signal = django.dispatch.Signal(['id']) + +send_email_signal = django.dispatch.Signal( + ['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + """ + sh: + 发送邮件信号的接收器:处理邮件发送逻辑并记录发送日志 + """ + """从信号参数中提取邮件信息""" + emailto = kwargs['emailto'] + title = kwargs['title'] + content = kwargs['content'] + """创建HTML格式邮件""" + 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): + oauth_user_id = kwargs['id'] + """ + OAuth用户登录信号的接收器:处理用户头像本地化存储 + """ + oauthuser = OAuthUser.objects.get(id=oauth_user_id) + site = get_current_site().domain + if oauthuser.picture and oauthuser.picture.find(site) == -1: + from djangoblog.utils import save_user_avatar + oauthuser.picture = save_user_avatar(oauthuser.picture) + oauthuser.save() + + delete_sidebar_cache() + + +@receiver(post_save) +def _notify_baidu_spider(instance, is_update_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 spider: %s", ex) + return not is_update_views + + +def _handle_comment_cache(instance): + path = instance.article.get_absolute_url() + site = get_current_site().domain + if site.find(':') > 0: + site = site[: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(prefix='article_comments', keys=[str(instance.article.pk)]) + + _thread.start_new_thread(send_comment_email, args=(instance,)) + +def model_post_save_callback( + instance, + update_fields, + **kwargs +): + clearcache = False + + """跳过LogEntry类型的实例""" + if isinstance(instance, LogEntry): + return + + """处理百度蜘蛛通知逻辑""" + if 'get_full_url' in dir(instance): + is_update_views = update_fields == {'views'} + clearcache = _notify_baidu_spider(instance, is_update_views) or clearcache + + """处理评论相关逻辑""" + if isinstance(instance, Comment) and instance.is_enable: + _handle_comment_cache(instance) + clearcache = True + + """最终清理缓存""" + if clearcache: + cache.clear() + + +@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/elasticsearch_backend.py b/src/djangoblog/elasticsearch_backend.py new file mode 100644 index 0000000..c20a3a8 --- /dev/null +++ b/src/djangoblog/elasticsearch_backend.py @@ -0,0 +1,209 @@ +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): + """ + sh: + 自定义Elasticsearch搜索后端,继承Haystack的BaseSearchBackend + 实现与Elasticsearch的交互逻辑(数据同步、搜索查询、拼写建议等) + """ + def __init__(self, connection_alias, **connection_options): + super( + ElasticSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.manager = ArticleDocumentManager() + self.include_spelling = True + + def _get_models(self, iterable): + """ + 将模型实例列表转换为Elasticsearch文档对象 + 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): + """创建Elasticsearch索引并批量写入文档""" + self.manager.create_index() + docs = self._get_models(models) + self.manager.rebuild(docs) + + def _delete(self, models): + """从Elasticsearch中删除指定模型对应的文档""" + for m in models: + m.delete() + return True + + def _rebuild(self, models): + """更新Elasticsearch索引(增量同步文档)""" + 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): + """Haystack规范方法:更新搜索索引(接收Haystack的索引更新请求)""" + models = self._get_models(iterable) + self.manager.update_docs(models) + + def remove(self, obj_or_string): + """Haystack规范方法:移除索引中指定对象""" + models = self._get_models([obj_or_string]) + self._delete(models) + + def clear(self, models=None, commit=True): + """Haystack规范方法:清空索引""" + 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): + """ + 核心搜索方法:执行Elasticsearch查询并封装结果 + 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): + """ + 自定义搜索查询类,继承Haystack的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): + """ + 清洗用户输入的搜索关键词 + 处理保留词、特殊字符转义,避免查询语法错误 + """ + 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): + """构建查询片段(适配Haystack查询构建逻辑)""" + 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): + """ + 自定义搜索表单,继承Haystack的ModelSearchForm + 控制是否启用关键词推荐功能 + """ + def search(self): + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + """ + Haystack搜索引擎入口类,关联自定义的Backend和Query + 供Django项目配置使用(在settings中指定该引擎) + """ + backend = ElasticSearchBackend + query = ElasticSearchQuery diff --git a/src/djangoblog/feeds.py b/src/djangoblog/feeds.py new file mode 100644 index 0000000..9fd3d19 --- /dev/null +++ b/src/djangoblog/feeds.py @@ -0,0 +1,55 @@ +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): + """ + sh: + 自定义博客RSS订阅源生成类,继承Django的Feed类 + 用于生成符合RSS 2.0规范的博客内容订阅源 + """ + 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): + """ + 定义RSS源中包含的内容项 + 返回最新发布的5篇文章(类型为'article',状态为'published') + """ + 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): + """ + 返回单个内容项(文章)的描述 + 将Markdown格式的文章内容转换为HTML格式用于RSS展示 + """ + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + """返回RSS源的版权信息,包含当前年份""" + 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..6fb93f3 --- /dev/null +++ b/src/djangoblog/logentryadmin.py @@ -0,0 +1,112 @@ +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): + """ + sh: + 自定义Admin操作日志管理类,继承自ModelAdmin + 用于在Django Admin后台展示和管理系统操作日志(LogEntry模型) + 优化日志展示格式,提供关联对象/用户的快速链接,限制操作权限 + """ + 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): + """ + 限制修改权限:仅超级用户或拥有change_logentry权限的用户可查看 + 且禁止POST请求(防止通过表单提交修改) + """ + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + """禁用删除操作:操作日志需保留,不允许手动删除""" + return False + + def object_link(self, obj): + """ + 自定义列表字段:操作对象的名称(带跳转链接) + 非删除操作时,生成对象的Admin编辑页链接;删除操作仅显示对象名称 + """ + 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): + """ + 自定义列表字段:操作用户的名称(带跳转链接) + 生成用户的Admin编辑页链接,方便快速查看用户信息 + """ + 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): + """优化查询性能:预加载content_type关联数据,减少数据库查询次数""" + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') + + def get_actions(self, request): + """移除批量删除操作:操作日志不允许批量删除""" + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions diff --git a/src/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-312.pyc b/src/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-312.pyc new file mode 100644 index 0000000..f789faf Binary files /dev/null and b/src/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-312.pyc differ diff --git a/src/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-312.pyc b/src/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-312.pyc new file mode 100644 index 0000000..fdf3bca Binary files /dev/null and b/src/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-312.pyc differ diff --git a/src/djangoblog/plugin_manage/__pycache__/hooks.cpython-312.pyc b/src/djangoblog/plugin_manage/__pycache__/hooks.cpython-312.pyc new file mode 100644 index 0000000..96eabf6 Binary files /dev/null and b/src/djangoblog/plugin_manage/__pycache__/hooks.cpython-312.pyc differ diff --git a/src/djangoblog/plugin_manage/__pycache__/loader.cpython-312.pyc b/src/djangoblog/plugin_manage/__pycache__/loader.cpython-312.pyc new file mode 100644 index 0000000..4002f19 Binary files /dev/null and b/src/djangoblog/plugin_manage/__pycache__/loader.cpython-312.pyc differ diff --git a/src/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 0000000..df2d8c7 --- /dev/null +++ b/src/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,42 @@ +import logging + +logger = logging.getLogger(__name__) + + +class BasePlugin: + # 插件元数据 + PLUGIN_NAME = None + PLUGIN_DESCRIPTION = None + PLUGIN_VERSION = None + + 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.init_plugin() + self.register_hooks() + + def init_plugin(self): + """ + sh: + 插件初始化逻辑 + 子类可以重写此方法来实现特定的初始化操作 + """ + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 注册插件钩子 + 子类可以重写此方法来注册特定的钩子 + """ + pass + + def get_plugin_info(self): + """ + 获取插件信息 + :return: 包含插件元数据的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } diff --git a/src/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 0000000..1758f72 --- /dev/null +++ b/src/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,11 @@ +# 常量:文章详情加载事件标识 +ARTICLE_DETAIL_LOAD = 'article_detail_load' +# 常量:文章创建事件标识 +ARTICLE_CREATE = 'article_create' +# 常量:文章更新事件标识 +ARTICLE_UPDATE = 'article_update' +# 常量:文章删除事件标识 +ARTICLE_DELETE = 'article_delete' +# 常量:文章内容钩子名称(通常用于钩子函数注册,处理文章内容相关逻辑) +ARTICLE_CONTENT_HOOK_NAME = "the_content" + diff --git a/src/djangoblog/plugin_manage/hooks.py b/src/djangoblog/plugin_manage/hooks.py new file mode 100644 index 0000000..c6b8691 --- /dev/null +++ b/src/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,45 @@ +import logging + +logger = logging.getLogger(__name__) + +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + sh: + 注册一个钩子回调。 + """ + 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..474eef1 --- /dev/null +++ b/src/djangoblog/plugin_manage/loader.py @@ -0,0 +1,27 @@ +import os +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +def load_plugins(): + """ + sh: + 动态加载并初始化位于'plugins'目录中的插件。 + 此函数旨在Django应用注册表就绪时被调用(通常在项目启动阶段)。 + + 加载逻辑说明: + 1. 遍历配置中激活的插件列表(settings.ACTIVE_PLUGINS) + 2. 检查插件目录是否存在且包含必要的入口文件(plugin.py) + 3. 动态导入符合条件的插件并记录加载状态 + """ + 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: + __import__(f'plugins.{plugin_name}.plugin') + logger.info(f"Successfully loaded plugin: {plugin_name}") + except ImportError as e: + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py new file mode 100644 index 0000000..2bef06d --- /dev/null +++ b/src/djangoblog/settings.py @@ -0,0 +1,317 @@ +import os +import sys +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + """ + sh: + 环境变量转换为布尔值的工具函数 + env: 环境变量名 + default: 环境变量不存在时的默认值 + """ + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +"""项目根目录(当前文件所在目录的父级目录)""" +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' + +DEBUG = env_to_bool('DJANGO_DEBUG', True) + +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] + +CSRF_TRUSTED_ORIGINS = ['http://example.com'] + + +INSTALLED_APPS = [ + """Django内置Admin(使用简化配置SimpleAdminConfig)""" + '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.common.CommonMiddleware', + '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' + +"""数据库配置(MySQL)""" +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': '2315304313', + 'HOST': '127.0.0.1', + 'PORT': int(3306), + 'OPTIONS': { + 'charset': 'utf8mb4'}, + }} + +"""密码验证规则""" +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 + +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), + }, +} + +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' + +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') + +AUTH_USER_MODEL = 'accounts.BlogUser' +LOGIN_URL = '/login/' + +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + + +BOOTSTRAP_COLOR_TYPES = [ + 'default', 'primary', 'success', 'info', 'warning', 'danger' +] + + +PAGINATE_BY = 10 + +CACHE_CONTROL_MAX_AGE = 2592000 + +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_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 + +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] + +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, + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + } + } +} + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + # other + 'compressor.finders.CompressorFinder', +) +COMPRESS_ENABLED = True + + +COMPRESS_CSS_FILTERS = [ + 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.cssmin.CSSMinFilter' +] +COMPRESS_JS_FILTERS = [ + 'compressor.filters.jsmin.JSMinFilter' +] + +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') +MEDIA_URL = '/media/' +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', + }, + } + +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', + 'reading_time', + 'external_links', + 'view_count', + 'seo_optimizer' +] \ No newline at end of file diff --git a/src/djangoblog/sitemap.py b/src/djangoblog/sitemap.py new file mode 100644 index 0000000..03ef14d --- /dev/null +++ b/src/djangoblog/sitemap.py @@ -0,0 +1,84 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +from blog.models import Article, Category, Tag + +class StaticViewSitemap(Sitemap): + """ + sh: + 静态页面站点地图类:用于收录网站静态页面(无动态数据的页面) + """ + priority = 0.5 + changefreq = 'daily' + + def items(self): + """返回需要收录的静态页面URL名称(对应urls.py中的name属性)""" + return ['blog:index', ] + + def location(self, item): + """根据URL名称反向解析出完整URL""" + return reverse(item) + + +class ArticleSiteMap(Sitemap): + """ + 文章页面站点地图类:收录所有已发布的博客文章 + """ + changefreq = "monthly" + priority = "0.6" + + def items(self): + """返回需要收录的文章:仅筛选状态为已发布(status='p')的文章""" + 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({x.author for x in 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..5ae67ec --- /dev/null +++ b/src/djangoblog/spider_notify.py @@ -0,0 +1,35 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +class SpiderNotify(): + """ + sh: + 搜索引擎推送工具类:用于主动向搜索引擎(目前支持百度)提交网站URL + 实现新内容发布后快速通知搜索引擎抓取,提升收录效率 + """ + @staticmethod + def baidu_notify(urls): + """ + 向百度搜索引擎推送URL(批量) + 依赖settings中配置的BAIDU_NOTIFY_URL(百度站长平台的推送接口地址) + urls: 待推送的URL列表(如['https://example.com/article/1/', ...]) + """ + 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): + """ + 通用推送方法(兼容单URL推送场景) + 内部调用百度推送方法,可扩展支持其他搜索引擎 + url: 待推送的单个URL或URL列表 + """ + SpiderNotify.baidu_notify(url) diff --git a/src/djangoblog/tests.py b/src/djangoblog/tests.py new file mode 100644 index 0000000..733a3be --- /dev/null +++ b/src/djangoblog/tests.py @@ -0,0 +1,41 @@ +from django.test import TestCase + +from djangoblog.utils import get_sha256, CommonMarkdown + + +class DjangoBlogTest(TestCase): + """ + sh: + 项目核心工具类单元测试类:验证工具函数的功能正确性 + 继承Django的TestCase,提供测试环境和断言方法 + """ + def setUp(self): + """ + 测试前置准备方法:在每个测试方法执行前运行 + 此处原代码抛出未实现异常,实际使用时可添加初始化逻辑(如创建测试数据) + """ + raise NotImplementedError("setUp method is not supported yet") + + 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..83d8346 --- /dev/null +++ b/src/djangoblog/urls.py @@ -0,0 +1,53 @@ +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 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 + +"""sh:站点地图集合:关联各类型页面的站点地图类""" +sitemaps = { + 'blog': ArticleSiteMap, + 'Category': CategorySiteMap, + 'Tag': TagSiteMap, + 'User': UserSiteMap, + 'static': StaticViewSitemap +} + +"""自定义错误页面视图(覆盖Django默认错误页)""" +handler404 = 'blog.views.page_not_found_view' +handler500 = 'blog.views.server_error_view' +handle403 = 'blog.views.permission_denied_view' + +"""基础URL配置(无语言前缀)""" +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), +] + +"""多语言URL配置(自动添加语言前缀,如/en/、/zh-hans/)""" +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..78e1cf5 --- /dev/null +++ b/src/djangoblog/utils.py @@ -0,0 +1,271 @@ +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(): + """ + sh: + 获取当前最大的文章ID和评论ID + 用于数据统计或初始化场景 + """ + from blog.models import Article + from comments.models import Comment + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + """ + 对字符串进行SHA256加密 + :param str: 待加密字符串 + :return: 加密后的十六进制字符串(64位) + """ + m = sha256(str.encode('utf-8')) + return m.hexdigest() + + +def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器:为函数添加缓存功能,减少重复计算/数据库查询 + :param expiration: 缓存过期时间(秒),默认3分钟 + :return: 装饰器函数 + """ + def wrapper(func): + def news(*args, **kwargs): + try: + view = args[0] + key = view.get_cache_key() + except Exception: + 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: + 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(): + """获取当前站点信息(从Django的Site模型)""" + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + """Markdown解析工具类:将Markdown文本转为HTML,并支持生成目录""" + @staticmethod + def _convert_markdown(value): + """ + 内部转换方法:使用markdown库解析文本 + :param value: Markdown格式文本 + :return: (转换后的HTML内容, 目录HTML) + """ + 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, _ = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + """ + 发送邮件(通过信号机制解耦,避免直接依赖邮件发送逻辑) + :param emailto: 收件人列表 + :param title: 邮件标题 + :param content: 邮件内容(HTML格式) + """ + 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): + """ + 将字典转换为URL查询参数字符串 + :param dict: 键值对字典(如{'name': 'test', 'age': 18}) + :return: URL编码后的参数串(如'name=test&age=18') + """ + 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 = os.path.join(settings.STATICFILES, 'avatar') + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + if not os.path.exists(basedir): + os.makedirs(basedir) + + 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: + file.write(rsp.content) + return static('avatar/' + 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): + """ + 删除模板片段缓存 + :param prefix: 缓存前缀(与模板中cache标签的前缀一致) + :param keys: 缓存键参数(与模板中cache标签的参数一致) + """ + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + """获取静态资源基础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'] +ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} + + +def sanitize_html(html): + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) diff --git a/src/djangoblog/whoosh_cn_backend.py b/src/djangoblog/whoosh_cn_backend.py new file mode 100644 index 0000000..55171e0 --- /dev/null +++ b/src/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,944 @@ +# 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.") + +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + +"""日期时间格式正则(用于解析Whoosh返回的日期字符串)""" +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?)?$') +"""线程本地存储(用于RAM缓存)""" +LOCALS = threading.local() +LOCALS.RAM_STORE = None + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + 自定义HTML高亮格式化器:简化Whoosh默认格式化器 + 确保与其他搜索后端(Solr、Elasticsearch)的高亮结果格式一致 + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + 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') + + """若配置为非文件存储,则使用RAM存储""" + 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): + """ + 延迟初始化:在首次使用时创建索引存储和Schema + 避免项目启动时过早加载资源 + """ + from haystack import connections + new_index = False + + 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 _create_field(self, field_class): + """根据字段类型创建对应的Whoosh字段""" + if field_class.is_multivalued: + if field_class.indexed is False: + return IDLIST(stored=True, field_boost=field_class.boost) + else: + return KEYWORD(stored=True, commas=True, scorable=True, field_boost=field_class.boost) + elif field_class.field_type in ['date', 'datetime']: + return DATETIME(stored=field_class.stored, sortable=True) + elif field_class.field_type == 'integer': + return NUMERIC(stored=field_class.stored, numtype=int, field_boost=field_class.boost) + elif field_class.field_type == 'float': + return NUMERIC(stored=field_class.stored, numtype=float, field_boost=field_class.boost) + elif field_class.field_type == 'boolean': + return BOOLEAN(stored=field_class.stored) + elif field_class.field_type == 'ngram': + return NGRAM(minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + elif field_class.field_type == 'edge_ngram': + return NGRAMWORDS(minsize=2, maxsize=15, at='start', stored=field_class.stored, + field_boost=field_class.boost) + else: + return TEXT(stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=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), + } + initial_key_count = len(schema_fields) + content_field_name = '' + + """遍历并创建动态字段""" + for field_name, field_class in fields.items(): + field = _create_field(field_class) + schema_fields[field_class.index_fieldname] = field + if field_class.document is True: + content_field_name = field_class.index_fieldname + field.spelling = True + + """校验字段数量""" + 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 _process_doc(self, doc): + """处理文档字段的编码和boost字段清理""" + for key in doc: + doc[key] = self._from_python(doc[key]) + if 'boost' in doc: + del doc['boost'] + return doc + + def _handle_update_error(self, e, obj, index): + """处理更新文档时的异常""" + if not self.silently_fail: + raise + self.log.error( + u"%s while preparing object for update" % e.__class__.__name__, + exc_info=True, + extra={ + "data": { + "index": index, + "object": get_identifier(obj)}}) + + 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) + continue # 跳过当前对象,处理下一个 + # 处理文档格式 + processed_doc = self._process_doc(doc) + # 尝试更新文档 + try: + writer.update_document(**processed_doc) + except Exception as e: + self._handle_update_error(e, obj, index) + + if len(iterable) > 0: + 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): + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + elif not self.use_file_storage: + self.storage.clean() + + 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): + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + 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) + + page_num += 1 + return page_num, page_length + + @log_query + def _handle_sorting(self, sort_by): + """处理排序逻辑,返回排序字段和是否逆序""" + if sort_by is None: + return None, False + + reverse_counter = sum(1 for order_by in sort_by if order_by.startswith('-')) + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields to use the same sort direction") + + sort_by_list = [order_by[1:] if order_by.startswith('-') else order_by for order_by in sort_by] + reverse = sort_by[0].startswith('-') + return sort_by_list[0], reverse + + def _handle_model_filters(self, models, limit_to_registered_models): + """处理模型过滤逻辑,返回窄查询集合""" + if limit_to_registered_models is None: + limit_to_registered_models = getattr(settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + model_choices = [] + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + model_choices = self.build_models_list() + + narrow_queries = set() + if len(model_choices) > 0: + narrow_queries.add(' OR '.join(['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + return narrow_queries + + def _process_narrow_queries(self, narrow_queries): + """处理窄查询,返回过滤后的结果集""" + if not narrow_queries: + return None + + narrow_searcher = self.index.searcher() + narrowed_results = None + 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: + narrow_searcher.close() + return { + 'results': [], + 'hits': 0, + } + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + return narrowed_results, narrow_searcher + + def _execute_search(self, parsed_query, page_num, page_length, sort_by, reverse, narrowed_results): + """执行搜索并处理原始结果""" + searcher = self.index.searcher() + search_kwargs = { + 'pagelen': page_length, + 'sortedby': sort_by, + 'reverse': reverse, + } + 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 + searcher.close() + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + if raw_page.pagenum < page_num: + searcher.close() + 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() + return results + + def _get_spelling_suggestion(self, query_string): + """获取拼写建议""" + if not self.include_spelling: + return None + return self.create_spelling_suggestion(query_string) if query_string else None + + def _return_empty_results(self, query_string): + """返回空结果集及拼写建议""" + spelling_suggestion = self._get_spelling_suggestion(query_string) + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': spelling_suggestion, + } + + 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() + + """处理空查询和短查询""" + if len(query_string) == 0 or (len(query_string) <= 1 and query_string != u'*'): + return self._return_empty_results(query_string) + query_string = force_str(query_string) + + """处理排序""" + sort_field, reverse = self._handle_sorting(sort_by) + + """处理分面警告""" + for facet_type in [facets, date_facets, query_facets]: + if facet_type is not None: + warnings.warn(f"Whoosh does not handle {facet_type.__class__.__name__} faceting.", Warning, + stacklevel=2) + + """处理模型过滤和窄查询""" + model_narrow_queries = self._handle_model_filters(models, limit_to_registered_models) + if narrow_queries is None: + narrow_queries = set() + narrow_queries.update(model_narrow_queries) + narrowed_results, narrow_searcher = self._process_narrow_queries(narrow_queries) + if isinstance(narrowed_results, dict): + return narrowed_results + + """执行搜索前的空索引校验""" + self.index = self.index.refresh() + if not self.index.doc_count(): + return self._return_empty_results(query_string) + + """执行搜索""" + searcher = self.index.searcher() + parsed_query = self.parser.parse(query_string) + if parsed_query is None: + searcher.close() + return self._return_empty_results(query_string) + + page_num, page_length = self.calculate_page(start_offset, end_offset) + results = self._execute_search(parsed_query, page_num, page_length, sort_field, reverse, narrowed_results) + + """关闭窄查询搜索器""" + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + + 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() + + 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: + 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, + } + + 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 = [] + + 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): + 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): + 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: + converted_value = json.loads(value) + + if isinstance( + converted_value, + (list, + tuple, + set, + dict, + six.integer_types, + float, + complex)): + return converted_value + except (SyntaxError, ValueError): + pass + except BaseException as e: + """对SystemExit、KeyboardInterrupt等系统级异常重新抛出""" + if isinstance(e, (SystemExit, KeyboardInterrupt)): + raise + 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): + 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'): + if hasattr(value, 'values_list'): + value = list(value) + + if hasattr(value, 'strftime'): + is_datetime = True + + if isinstance(value, six.string_types) and value != ' ': + value = Clean(value) + else: + value = PythonData(value) + + prepared_value = value.prepare(self) + + if not isinstance(prepared_value, (set, list, tuple)): + + prepared_value = self.backend._from_python(prepared_value) + + 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: + + 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) + +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..3e9f0f1 --- /dev/null +++ b/src/djangoblog/wsgi.py @@ -0,0 +1,13 @@ +import os + +from django.core.wsgi import get_wsgi_application +""" +#设置环境变量:指定Django项目的配置文件路径 +#告诉WSGI服务器使用哪个settings.py文件(此处为djangoblog项目的settings) +""" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") +""" +# 生成WSGI应用实例:Web服务器通过该实例与Django项目交互 +# 该实例封装了Django的请求处理流程,供WSGI服务器调用 +""" +application = get_wsgi_application()