diff --git a/src/djangoblog/__init__.py b/src/djangoblog/__init__.py index 1e205f4..89d3948 100644 --- a/src/djangoblog/__init__.py +++ b/src/djangoblog/__init__.py @@ -1 +1,7 @@ +"""djangoblog 包初始化 + +配置默认的 AppConfig,确保应用加载时执行自定义的就绪逻辑(如插件装载)。 +""" + +# 指定 Django 在加载应用时使用的 AppConfig 类 default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/src/djangoblog/admin_site.py b/src/djangoblog/admin_site.py index f120405..0150a7c 100644 --- a/src/djangoblog/admin_site.py +++ b/src/djangoblog/admin_site.py @@ -18,13 +18,16 @@ 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): @@ -37,9 +40,10 @@ class DjangoBlogAdminSite(AdminSite): # ] # return urls + my_urls - +# 创建自定义的后台站点实例 admin_site = DjangoBlogAdminSite(name='admin') +# 注册各应用的模型到自定义后台站点 admin_site.register(Article, ArticlelAdmin) admin_site.register(Category, CategoryAdmin) admin_site.register(Tag, TagAdmin) diff --git a/src/djangoblog/apps.py b/src/djangoblog/apps.py index d29e318..58c5164 100644 --- a/src/djangoblog/apps.py +++ b/src/djangoblog/apps.py @@ -1,11 +1,19 @@ from django.apps import AppConfig +# 应用配置类,用于在 Django 启动时执行应用级别的初始化逻辑 + 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 + load_plugins() \ No newline at end of file diff --git a/src/djangoblog/blog_signals.py b/src/djangoblog/blog_signals.py index 393f441..64e2eda 100644 --- a/src/djangoblog/blog_signals.py +++ b/src/djangoblog/blog_signals.py @@ -18,13 +18,16 @@ from oauth.models import OAuthUser logger = logging.getLogger(__name__) +# 自定义信号:OAuth 用户登录 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): + """处理发送邮件的信号,记录发送日志。""" emailto = kwargs['emailto'] title = kwargs['title'] content = kwargs['content'] @@ -53,6 +56,7 @@ def send_email_signal_handler(sender, **kwargs): @receiver(oauth_user_login_signal) def oauth_user_login_signal_handler(sender, **kwargs): + """OAuth 登录成功后处理头像缓存、本地化,并清理侧边栏缓存。""" id = kwargs['id'] oauthuser = OAuthUser.objects.get(id=id) site = get_current_site().domain @@ -73,6 +77,7 @@ def model_post_save_callback( using, update_fields, **kwargs): + """模型保存后清理缓存、推送搜索引擎等后置动作。""" clearcache = False if isinstance(instance, LogEntry): return @@ -116,6 +121,7 @@ def model_post_save_callback( @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() diff --git a/src/djangoblog/elasticsearch_backend.py b/src/djangoblog/elasticsearch_backend.py index 4afe498..89259ce 100644 --- a/src/djangoblog/elasticsearch_backend.py +++ b/src/djangoblog/elasticsearch_backend.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) class ElasticSearchBackend(BaseSearchBackend): + """基于 Elasticsearch DSL 的 Haystack 后端实现。""" def __init__(self, connection_alias, **connection_options): super( ElasticSearchBackend, @@ -22,35 +23,42 @@ class ElasticSearchBackend(BaseSearchBackend): 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 @@ -84,6 +92,7 @@ class ElasticSearchBackend(BaseSearchBackend): else: suggestion = query_string + # 组合查询:匹配标题或正文,至少匹配 70% q = Q('bool', should=[Q('match', body=suggestion), Q('match', title=suggestion)], minimum_should_match="70%") @@ -123,6 +132,7 @@ class ElasticSearchBackend(BaseSearchBackend): class ElasticSearchQuery(BaseSearchQuery): + """自定义查询类:处理转义、计数与参数构造。""" def _convert_datetime(self, date): if hasattr(date, 'hour'): return force_str(date.strftime('%Y%m%d%H%M%S')) @@ -170,6 +180,7 @@ class ElasticSearchQuery(BaseSearchQuery): class ElasticSearchModelSearchForm(ModelSearchForm): + """自定义表单:支持是否启用搜索建议。""" def search(self): # 是否建议搜索 diff --git a/src/djangoblog/feeds.py b/src/djangoblog/feeds.py index 8c4e851..a00a114 100644 --- a/src/djangoblog/feeds.py +++ b/src/djangoblog/feeds.py @@ -8,6 +8,7 @@ from djangoblog.utils import CommonMarkdown class DjangoBlogFeed(Feed): + # 指定 RSS 版本生成器 feed_type = Rss201rev2Feed description = '大巧无工,重剑无锋.' @@ -15,26 +16,34 @@ class DjangoBlogFeed(Feed): 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): + """返回最新的已发布文章(限制 5 篇)""" 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)""" return CommonMarkdown.get_markdown(item.body) def feed_copyright(self): + """Feed 版权信息(按年份动态变化)""" 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 index 2f6a535..cafe03f 100644 --- a/src/djangoblog/logentryadmin.py +++ b/src/djangoblog/logentryadmin.py @@ -9,19 +9,23 @@ 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', @@ -31,23 +35,27 @@ class LogEntryAdmin(admin.ModelAdmin): ] def has_add_permission(self, request): + # 禁止新增日志条目 return False def has_change_permission(self, request, obj=None): + # 仅允许具备权限的用户在非 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): + """返回对象的链接或纯文本表示。""" 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, @@ -63,10 +71,11 @@ class LogEntryAdmin(admin.ModelAdmin): 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), @@ -81,10 +90,12 @@ class LogEntryAdmin(admin.ModelAdmin): 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'] diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 846b4f4..8651da9 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -17,28 +17,33 @@ 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' +# 路径配置:BASE_DIR 指向项目根目录 # 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/ # SECURITY WARNING: keep the secret key used in production secret! +# 生产环境请通过环境变量提供 SECRET_KEY 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 DEBUG = env_to_bool('DJANGO_DEBUG', True) # DEBUG = False TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' # ALLOWED_HOSTS = [] +# 允许访问的主机列表(生产环境请最小化配置) ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] # django 4.0新增配置 -CSRF_TRUSTED_ORIGINS = ['http://example.com'] +CSRF_TRUSTED_ORIGINS = ['http://example.com'] # 生产按实际域名配置 # Application definition @@ -158,6 +163,7 @@ USE_TZ = False # https://docs.djangoproject.com/en/1.10/howto/static-files/ +# 全文检索(Whoosh 中文分词)配置 HAYSTACK_CONNECTIONS = { 'default': { 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', @@ -166,6 +172,7 @@ HAYSTACK_CONNECTIONS = { } # Automatically update searching index HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' +# 允许使用邮箱或用户名进行登录 # Allow user login with username and password AUTHENTICATION_BACKENDS = [ 'accounts.user_login_backend.EmailOrUsernameModelBackend'] @@ -334,6 +341,7 @@ CSP_OBJECT_SRC = ["'none'"] DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + # 如提供 ES 主机地址,则切换为 Elasticsearch 搜索后端 ELASTICSEARCH_DSL = { 'default': { 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') diff --git a/src/djangoblog/sitemap.py b/src/djangoblog/sitemap.py index 8b7d446..c46b290 100644 --- a/src/djangoblog/sitemap.py +++ b/src/djangoblog/sitemap.py @@ -5,28 +5,35 @@ from blog.models import Article, Category, Tag class StaticViewSitemap(Sitemap): + # 静态页面 sitemap 配置 priority = 0.5 changefreq = 'daily' def items(self): + # 返回需要生成 sitemap 的静态路由名称 return ['blog:index', ] def location(self, item): + # 将路由名称解析为实际 URL return reverse(item) class ArticleSiteMap(Sitemap): + # 文章 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): + # 分类 sitemap 配置 changefreq = "Weekly" priority = "0.6" @@ -38,6 +45,7 @@ class CategorySiteMap(Sitemap): class TagSiteMap(Sitemap): + # 标签 sitemap 配置 changefreq = "Weekly" priority = "0.3" @@ -49,11 +57,14 @@ class TagSiteMap(Sitemap): class UserSiteMap(Sitemap): + # 作者 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 index 7b909e9..b98dd12 100644 --- a/src/djangoblog/spider_notify.py +++ b/src/djangoblog/spider_notify.py @@ -7,9 +7,15 @@ logger = logging.getLogger(__name__) class SpiderNotify(): + """主动向搜索引擎推送站点链接的通知工具。""" @staticmethod def baidu_notify(urls): + """向百度推送一组 URL。 + + urls: 可迭代的 URL 字符串。 + """ try: + # 百度批量推送接口要求换行分隔 data = '\n'.join(urls) result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) logger.info(result.text) @@ -18,4 +24,5 @@ class SpiderNotify(): @staticmethod def notify(url): + """统一通知入口,当前仅调用百度推送。""" SpiderNotify.baidu_notify(url) diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 6a9e1de..82bd9ec 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -29,15 +29,17 @@ from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm from djangoblog.feeds import DjangoBlogFeed from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap +# sitemap 映射,用于生成不同资源的站点地图 sitemaps = { - 'blog': ArticleSiteMap, - 'Category': CategorySiteMap, - 'Tag': TagSiteMap, - 'User': UserSiteMap, - 'static': StaticViewSitemap + 'blog': ArticleSiteMap, # 文章 + 'Category': CategorySiteMap, # 分类 + 'Tag': TagSiteMap, # 标签 + 'User': UserSiteMap, # 作者 + 'static': StaticViewSitemap # 静态页面 } +# 全局错误处理视图 handler404 = 'blog.views.page_not_found_view' handler500 = 'blog.views.server_error_view' handle403 = 'blog.views.permission_denied_view' @@ -53,26 +55,29 @@ def health_check(request): 'timestamp': time.time() }) +# 非国际化前缀的基础路由 urlpatterns = [ - path('i18n/', include('django.conf.urls.i18n')), - path('health/', health_check, name='health_check'), + 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'^admin/', admin_site.urls), # 自定义后台 + re_path(r'', include('blog.urls', namespace='blog')), # 博客模块 + re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown 编辑器 + 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()), + name='django.contrib.sitemaps.views.sitemap'), # 站点地图 + re_path(r'^feed/$', DjangoBlogFeed()), # RSS Feed + re_path(r'^rss/$', DjangoBlogFeed()), # RSS Feed(兼容路径) re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), - name='search'), + name='search'), # 全文搜索(Haystack + ES) 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 index 91d2b91..b0c8ba4 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -21,17 +21,20 @@ logger = logging.getLogger(__name__) def get_max_articleid_commentid(): + """获取当前最大文章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 摘要。""" m = sha256(str.encode('utf-8')) return m.hexdigest() def cache_decorator(expiration=3 * 60): + """基于函数参数与自定义 key 的简单缓存装饰器。""" def wrapper(func): def news(*args, **kwargs): try: @@ -94,6 +97,7 @@ def expire_view_cache(path, servername, serverport, key_prefix=None): @cache_decorator() def get_current_site(): + """获取当前站点对象(带缓存)。""" site = Site.objects.get_current() return site @@ -101,6 +105,7 @@ def get_current_site(): class CommonMarkdown: @staticmethod def _convert_markdown(value): + """将 Markdown 文本转换为 HTML 与目录(TOC)。""" md = markdown.Markdown( extensions=[ 'extra', @@ -115,16 +120,19 @@ class CommonMarkdown: @staticmethod def get_markdown_with_toc(value): + """返回 HTML 渲染内容与目录。""" body, toc = CommonMarkdown._convert_markdown(value) return body, toc @staticmethod def get_markdown(value): + """仅返回 HTML 渲染内容。""" 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__, @@ -139,6 +147,7 @@ def generate_code() -> str: def parse_dict_to_url(dict): + """将 dict 序列化为查询字符串,自动进行 URL 编码。""" from urllib.parse import quote url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) for k, v in dict.items()]) @@ -146,6 +155,7 @@ def parse_dict_to_url(dict): def get_blog_setting(): + """获取站点设置,若不存在则初始化默认配置并缓存。""" value = cache.get('get_blog_setting') if value: return value @@ -202,6 +212,7 @@ def save_user_avatar(url): def delete_sidebar_cache(): + """根据 LinkShowType 删除侧边栏相关缓存键。""" from blog.models import LinkShowType keys = ["sidebar" + x for x in LinkShowType.values] for k in keys: @@ -210,12 +221,14 @@ def delete_sidebar_cache(): 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(): + """获取静态资源前缀 URL(优先使用 STATIC_URL)。""" if settings.STATIC_URL: return settings.STATIC_URL else: diff --git a/src/djangoblog/wsgi.py b/src/djangoblog/wsgi.py index 2295efd..f8e5e70 100644 --- a/src/djangoblog/wsgi.py +++ b/src/djangoblog/wsgi.py @@ -11,6 +11,8 @@ import os from django.core.wsgi import get_wsgi_application +# 设置 Django 配置模块的环境变量,供 WSGI 服务器加载项目配置 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") +# 创建 WSGI 应用对象,供部署服务器(如 gunicorn/uwsgi)调用 application = get_wsgi_application()