master
gzs 4 months ago
parent cac9805c3d
commit d21ad84e66

@ -1 +1,7 @@
"""djangoblog 包初始化
配置默认的 AppConfig确保应用加载时执行自定义的就绪逻辑如插件装载
"""
# 指定 Django 在加载应用时使用的 AppConfig 类
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -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)

@ -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()
load_plugins()

@ -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()

@ -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):
# 是否建议搜索

@ -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

@ -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']

@ -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')

@ -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

@ -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)

@ -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)

@ -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:

@ -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()

Loading…
Cancel
Save