|
|
|
|
@ -0,0 +1,408 @@
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
|
|
|
|
from abc import abstractmethod
|
|
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
from django.core.exceptions import ValidationError
|
|
|
|
|
from django.db import models
|
|
|
|
|
from django.urls import reverse
|
|
|
|
|
from django.utils.timezone import now
|
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
from mdeditor.fields import MDTextField # Markdown编辑器字段
|
|
|
|
|
from uuslug import slugify # 智能slug生成,支持中文转拼音
|
|
|
|
|
|
|
|
|
|
from djangoblog.utils import cache_decorator, cache
|
|
|
|
|
from djangoblog.utils import get_current_site
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LinkShowType(models.TextChoices):
|
|
|
|
|
"""链接显示位置枚举 - 控制友情链接在不同页面的显示"""
|
|
|
|
|
I = ('i', _('index')) # 首页显示
|
|
|
|
|
L = ('l', _('list')) # 列表页显示
|
|
|
|
|
P = ('p', _('post')) # 文章详情页显示
|
|
|
|
|
A = ('a', _('all')) # 全站显示
|
|
|
|
|
S = ('s', _('slide')) # 幻灯片形式显示
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BaseModel(models.Model):
|
|
|
|
|
"""
|
|
|
|
|
抽象基模型 - 提供所有模型的通用字段和方法
|
|
|
|
|
采用抽象基类避免代码重复,符合DRY原则
|
|
|
|
|
"""
|
|
|
|
|
id = models.AutoField(primary_key=True) # 自增主键
|
|
|
|
|
creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间,自动设置为当前时间
|
|
|
|
|
last_modify_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间
|
|
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
|
"""
|
|
|
|
|
重写save方法,实现智能slug生成和浏览量更新优化
|
|
|
|
|
特殊处理:当只更新views字段时,使用update提高性能
|
|
|
|
|
"""
|
|
|
|
|
# 检查是否是文章视图更新操作 - 性能优化点
|
|
|
|
|
is_update_views = isinstance(
|
|
|
|
|
self,
|
|
|
|
|
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
|
|
|
|
|
if is_update_views:
|
|
|
|
|
# 直接使用update语句更新浏览量,避免触发其他信号和save逻辑
|
|
|
|
|
Article.objects.filter(pk=self.pk).update(views=self.views)
|
|
|
|
|
else:
|
|
|
|
|
# 自动生成slug:如果模型有slug字段,基于title或name生成
|
|
|
|
|
if 'slug' in self.__dict__:
|
|
|
|
|
slug = getattr(
|
|
|
|
|
self, 'title') if 'title' in self.__dict__ else getattr(
|
|
|
|
|
self, 'name')
|
|
|
|
|
# 使用uuslug智能生成,支持中文转拼音
|
|
|
|
|
setattr(self, 'slug', slugify(slug))
|
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
def get_full_url(self):
|
|
|
|
|
"""生成包含域名的完整URL,用于分享和SEO"""
|
|
|
|
|
site = get_current_site().domain
|
|
|
|
|
url = "https://{site}{path}".format(site=site,
|
|
|
|
|
path=self.get_absolute_url())
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
abstract = True # 声明为抽象类,不会创建对应的数据库表
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
|
"""抽象方法 - 子类必须实现,返回对象的相对URL"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Article(BaseModel):
|
|
|
|
|
"""
|
|
|
|
|
文章模型 - 博客核心内容模型
|
|
|
|
|
包含文章的所有属性和业务逻辑
|
|
|
|
|
"""
|
|
|
|
|
# 状态选择项 - 使用元组保持Django传统,也可用TextChoices(Django3.0+)
|
|
|
|
|
STATUS_CHOICES = (
|
|
|
|
|
('d', _('Draft')), # 草稿状态
|
|
|
|
|
('p', _('Published')), # 已发布状态
|
|
|
|
|
)
|
|
|
|
|
COMMENT_STATUS = (
|
|
|
|
|
('o', _('Open')), # 开放评论
|
|
|
|
|
('c', _('Close')), # 关闭评论
|
|
|
|
|
)
|
|
|
|
|
TYPE = (
|
|
|
|
|
('a', _('Article')), # 普通文章
|
|
|
|
|
('p', _('Page')), # 独立页面(如关于页面)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 核心字段
|
|
|
|
|
title = models.CharField(_('title'), max_length=200, unique=True) # 标题,唯一约束
|
|
|
|
|
body = MDTextField(_('body')) # 正文,使用Markdown编辑器
|
|
|
|
|
pub_time = models.DateTimeField(_('publish time'), default=now) # 发布时间
|
|
|
|
|
status = models.CharField(_('status'), max_length=1, choices=STATUS_CHOICES, default='p') # 状态
|
|
|
|
|
comment_status = models.CharField(_('comment status'), max_length=1, choices=COMMENT_STATUS, default='o') # 评论状态
|
|
|
|
|
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # 类型
|
|
|
|
|
views = models.PositiveIntegerField(_('views'), default=0) # 浏览量统计
|
|
|
|
|
author = models.ForeignKey(
|
|
|
|
|
settings.AUTH_USER_MODEL, # 关联Django用户模型
|
|
|
|
|
verbose_name=_('author'),
|
|
|
|
|
on_delete=models.CASCADE) # 用户删除时级联删除文章
|
|
|
|
|
article_order = models.IntegerField(_('order'), default=0) # 文章排序权重
|
|
|
|
|
show_toc = models.BooleanField(_('show toc'), default=False) # 是否显示文章目录
|
|
|
|
|
category = models.ForeignKey(
|
|
|
|
|
'Category',
|
|
|
|
|
verbose_name=_('category'),
|
|
|
|
|
on_delete=models.CASCADE) # 文章分类
|
|
|
|
|
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # 文章标签,多对多关系
|
|
|
|
|
|
|
|
|
|
def body_to_string(self):
|
|
|
|
|
"""将body内容转为字符串 - 可能用于全文搜索或预览"""
|
|
|
|
|
return self.body
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
"""字符串表示,用于Admin后台显示"""
|
|
|
|
|
return self.title
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
"""模型元数据配置"""
|
|
|
|
|
ordering = ['-article_order', '-pub_time'] # 默认排序:先按order倒序,再按发布时间倒序
|
|
|
|
|
verbose_name = _('article') # 单数名称
|
|
|
|
|
verbose_name_plural = verbose_name # 复数名称
|
|
|
|
|
get_latest_by = 'id' # 指定获取最新记录的字段
|
|
|
|
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
|
"""
|
|
|
|
|
生成文章详情页URL
|
|
|
|
|
采用年月日+ID的URL结构,有利于SEO和归档
|
|
|
|
|
"""
|
|
|
|
|
return reverse('blog:detailbyid', kwargs={
|
|
|
|
|
'article_id': self.id,
|
|
|
|
|
'year': self.creation_time.year,
|
|
|
|
|
'month': self.creation_time.month,
|
|
|
|
|
'day': self.creation_time.day
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@cache_decorator(60 * 60 * 10) # 缓存10小时
|
|
|
|
|
def get_category_tree(self):
|
|
|
|
|
"""
|
|
|
|
|
获取分类树结构 - 用于面包屑导航
|
|
|
|
|
返回格式:[(分类名, URL), ...]
|
|
|
|
|
"""
|
|
|
|
|
tree = self.category.get_category_tree()
|
|
|
|
|
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
def viewed(self):
|
|
|
|
|
"""增加文章浏览量 - 使用update_fields优化性能"""
|
|
|
|
|
self.views += 1
|
|
|
|
|
self.save(update_fields=['views']) # 只更新views字段
|
|
|
|
|
|
|
|
|
|
def comment_list(self):
|
|
|
|
|
"""
|
|
|
|
|
获取文章评论列表 - 带缓存功能
|
|
|
|
|
缓存策略:缓存100分钟,减少数据库查询
|
|
|
|
|
"""
|
|
|
|
|
cache_key = 'article_comments_{id}'.format(id=self.id)
|
|
|
|
|
value = cache.get(cache_key)
|
|
|
|
|
if value:
|
|
|
|
|
logger.info('get article comments:{id}'.format(id=self.id))
|
|
|
|
|
return value
|
|
|
|
|
else:
|
|
|
|
|
# 获取已启用的评论,按ID倒序(新的在前)
|
|
|
|
|
comments = self.comment_set.filter(is_enable=True).order_by('-id')
|
|
|
|
|
cache.set(cache_key, comments, 60 * 100) # 缓存100分钟
|
|
|
|
|
logger.info('set article comments:{id}'.format(id=self.id))
|
|
|
|
|
return comments
|
|
|
|
|
|
|
|
|
|
def get_admin_url(self):
|
|
|
|
|
"""生成Django Admin后台编辑URL"""
|
|
|
|
|
info = (self._meta.app_label, self._meta.model_name)
|
|
|
|
|
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
|
|
|
|
|
|
|
|
|
|
@cache_decorator(expiration=60 * 100) # 缓存100分钟
|
|
|
|
|
def next_article(self):
|
|
|
|
|
"""
|
|
|
|
|
获取下一篇已发布文章
|
|
|
|
|
查询逻辑:ID大于当前文章的第一个已发布文章
|
|
|
|
|
"""
|
|
|
|
|
return Article.objects.filter(
|
|
|
|
|
id__gt=self.id, status='p').order_by('id').first()
|
|
|
|
|
|
|
|
|
|
@cache_decorator(expiration=60 * 100) # 缓存100分钟
|
|
|
|
|
def prev_article(self):
|
|
|
|
|
"""
|
|
|
|
|
获取上一篇已发布文章
|
|
|
|
|
查询逻辑:ID小于当前文章的最后一个已发布文章
|
|
|
|
|
注意:这里用了first()但实际是获取ID最大的那个
|
|
|
|
|
"""
|
|
|
|
|
return Article.objects.filter(id__lt=self.id, status='p').first()
|
|
|
|
|
|
|
|
|
|
def get_first_image_url(self):
|
|
|
|
|
"""
|
|
|
|
|
从Markdown内容中提取第一张图片URL
|
|
|
|
|
用于文章列表的缩略图显示
|
|
|
|
|
正则匹配:
|
|
|
|
|
"""
|
|
|
|
|
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
|
|
|
|
|
if match:
|
|
|
|
|
return match.group(1) # 返回第一个捕获组(图片URL)
|
|
|
|
|
return "" # 没有图片返回空字符串
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Category(BaseModel):
|
|
|
|
|
"""
|
|
|
|
|
文章分类模型 - 支持多级分类结构
|
|
|
|
|
通过parent_category自关联实现无限级分类
|
|
|
|
|
"""
|
|
|
|
|
name = models.CharField(_('category name'), max_length=30, unique=True)
|
|
|
|
|
parent_category = models.ForeignKey(
|
|
|
|
|
'self', # 自关联
|
|
|
|
|
verbose_name=_('parent category'),
|
|
|
|
|
blank=True,
|
|
|
|
|
null=True,
|
|
|
|
|
on_delete=models.CASCADE) # 父分类删除时级联删除子分类
|
|
|
|
|
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL友好标识
|
|
|
|
|
index = models.IntegerField(default=0, verbose_name=_('index')) # 分类显示顺序
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ['-index'] # 按index倒序排列,数值越大越靠前
|
|
|
|
|
verbose_name = _('category')
|
|
|
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
|
"""生成分类页面URL,使用slug作为URL参数"""
|
|
|
|
|
return reverse(
|
|
|
|
|
'blog:category_detail', kwargs={
|
|
|
|
|
'category_name': self.slug})
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
@cache_decorator(60 * 60 * 10) # 缓存10小时
|
|
|
|
|
def get_category_tree(self):
|
|
|
|
|
"""
|
|
|
|
|
递归获取分类的父级链 - 用于面包屑导航
|
|
|
|
|
返回从当前分类到根分类的列表
|
|
|
|
|
示例: [当前分类, 父分类, 祖父分类, ...]
|
|
|
|
|
"""
|
|
|
|
|
categorys = []
|
|
|
|
|
|
|
|
|
|
def parse(category):
|
|
|
|
|
"""递归解析分类层级"""
|
|
|
|
|
categorys.append(category)
|
|
|
|
|
if category.parent_category:
|
|
|
|
|
parse(category.parent_category) # 递归调用
|
|
|
|
|
|
|
|
|
|
parse(self) # 从当前分类开始
|
|
|
|
|
return categorys
|
|
|
|
|
|
|
|
|
|
@cache_decorator(60 * 60 * 10) # 缓存10小时
|
|
|
|
|
def get_sub_categorys(self):
|
|
|
|
|
"""
|
|
|
|
|
递归获取当前分类的所有子分类 - 用于分类导航
|
|
|
|
|
返回包含当前分类和所有子孙分类的列表
|
|
|
|
|
"""
|
|
|
|
|
categorys = []
|
|
|
|
|
all_categorys = Category.objects.all() # 获取所有分类
|
|
|
|
|
|
|
|
|
|
def parse(category):
|
|
|
|
|
"""递归解析子分类"""
|
|
|
|
|
if category not in categorys:
|
|
|
|
|
categorys.append(category)
|
|
|
|
|
# 查找直接子分类
|
|
|
|
|
childs = all_categorys.filter(parent_category=category)
|
|
|
|
|
for child in childs:
|
|
|
|
|
if child not in categorys:
|
|
|
|
|
categorys.append(child)
|
|
|
|
|
parse(child) # 递归查找子分类的子分类
|
|
|
|
|
|
|
|
|
|
parse(self) # 从当前分类开始
|
|
|
|
|
return categorys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Tag(BaseModel):
|
|
|
|
|
"""
|
|
|
|
|
文章标签模型 - 简单的标签系统
|
|
|
|
|
多对多关系,一篇文章可以有多个标签,一个标签可以对应多篇文章
|
|
|
|
|
"""
|
|
|
|
|
name = models.CharField(_('tag name'), max_length=30, unique=True)
|
|
|
|
|
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
|
"""生成标签页面URL"""
|
|
|
|
|
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
|
|
|
|
|
|
|
|
|
|
@cache_decorator(60 * 60 * 10) # 缓存10小时
|
|
|
|
|
def get_article_count(self):
|
|
|
|
|
"""获取使用该标签的文章数量 - 带缓存"""
|
|
|
|
|
return Article.objects.filter(tags__name=self.name).distinct().count()
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ['name'] # 按标签名字母顺序排序
|
|
|
|
|
verbose_name = _('tag')
|
|
|
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Links(models.Model):
|
|
|
|
|
"""
|
|
|
|
|
友情链接模型 - 管理外部链接
|
|
|
|
|
支持控制链接显示位置和顺序
|
|
|
|
|
"""
|
|
|
|
|
name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称
|
|
|
|
|
link = models.URLField(_('link')) # 链接地址
|
|
|
|
|
sequence = models.IntegerField(_('order'), unique=True) # 显示顺序,唯一约束
|
|
|
|
|
is_enable = models.BooleanField(_('is show'), default=True) # 是否启用
|
|
|
|
|
show_type = models.CharField(
|
|
|
|
|
_('show type'),
|
|
|
|
|
max_length=1,
|
|
|
|
|
choices=LinkShowType.choices, # 使用前面定义的枚举
|
|
|
|
|
default=LinkShowType.I) # 默认在首页显示
|
|
|
|
|
creation_time = models.DateTimeField(_('creation time'), default=now)
|
|
|
|
|
last_mod_time = models.DateTimeField(_('modify time'), default=now)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ['sequence'] # 按sequence顺序排列
|
|
|
|
|
verbose_name = _('link')
|
|
|
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SideBar(models.Model):
|
|
|
|
|
"""
|
|
|
|
|
侧边栏模型 - 管理侧边栏内容
|
|
|
|
|
支持HTML内容,可以放置广告、公告等自定义内容
|
|
|
|
|
"""
|
|
|
|
|
name = models.CharField(_('title'), max_length=100) # 侧边栏标题
|
|
|
|
|
content = models.TextField(_('content')) # HTML内容,可以包含脚本和样式
|
|
|
|
|
sequence = models.IntegerField(_('order'), unique=True) # 显示顺序
|
|
|
|
|
is_enable = models.BooleanField(_('is enable'), default=True) # 是否启用
|
|
|
|
|
creation_time = models.DateTimeField(_('creation time'), default=now)
|
|
|
|
|
last_mod_time = models.DateTimeField(_('modify time'), default=now)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ['sequence'] # 按sequence顺序排列
|
|
|
|
|
verbose_name = _('sidebar')
|
|
|
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BlogSettings(models.Model):
|
|
|
|
|
"""
|
|
|
|
|
博客全局配置模型 - 单例模式
|
|
|
|
|
存储博客的所有可配置项,整个系统应该只有一个配置实例
|
|
|
|
|
"""
|
|
|
|
|
# 站点基本信息
|
|
|
|
|
site_name = models.CharField(_('site name'), max_length=200, default='')
|
|
|
|
|
site_description = models.TextField(_('site description'), max_length=1000, default='') # 站点描述
|
|
|
|
|
site_seo_description = models.TextField(_('site seo description'), max_length=1000, default='') # SEO描述
|
|
|
|
|
site_keywords = models.TextField(_('site keywords'), max_length=1000, default='') # 站点关键词
|
|
|
|
|
|
|
|
|
|
# 内容显示设置
|
|
|
|
|
article_sub_length = models.IntegerField(_('article sub length'), default=300) # 文章摘要长度
|
|
|
|
|
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) # 侧边栏文章数量
|
|
|
|
|
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) # 侧边栏评论数量
|
|
|
|
|
article_comment_count = models.IntegerField(_('article comment count'), default=5) # 文章页评论数量
|
|
|
|
|
|
|
|
|
|
# 广告设置
|
|
|
|
|
show_google_adsense = models.BooleanField(_('show adsense'), default=False) # 是否显示Google广告
|
|
|
|
|
google_adsense_codes = models.TextField(_('adsense code'), max_length=2000, default='') # 广告代码
|
|
|
|
|
|
|
|
|
|
# 评论系统设置
|
|
|
|
|
open_site_comment = models.BooleanField(_('open site comment'), default=True) # 是否开启全站评论
|
|
|
|
|
comment_need_review = models.BooleanField('评论是否需要审核', default=False) # 评论是否需要审核
|
|
|
|
|
|
|
|
|
|
# 自定义代码和备案信息
|
|
|
|
|
global_header = models.TextField("公共头部", default='') # 全局头部HTML代码
|
|
|
|
|
global_footer = models.TextField("公共尾部", default='') # 全局尾部HTML代码
|
|
|
|
|
beian_code = models.CharField('备案号', max_length=2000, default='') # ICP备案号
|
|
|
|
|
analytics_code = models.TextField("网站统计代码", max_length=1000, default='') # 网站统计代码(Google Analytics等)
|
|
|
|
|
show_gongan_code = models.BooleanField('是否显示公安备案号', default=False) # 是否显示公安备案
|
|
|
|
|
gongan_beiancode = models.TextField('公安备案号', max_length=2000, default='') # 公安备案号
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
verbose_name = _('Website configuration') # 在Admin中显示的名称
|
|
|
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.site_name
|
|
|
|
|
|
|
|
|
|
def clean(self):
|
|
|
|
|
"""
|
|
|
|
|
数据验证 - 确保配置模型的单例特性
|
|
|
|
|
防止创建多个配置实例
|
|
|
|
|
"""
|
|
|
|
|
if BlogSettings.objects.exclude(id=self.id).count():
|
|
|
|
|
raise ValidationError(_('There can only be one configuration'))
|
|
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
|
"""
|
|
|
|
|
重写save方法 - 保存配置时清空所有缓存
|
|
|
|
|
确保配置更改立即生效
|
|
|
|
|
"""
|
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
from djangoblog.utils import cache
|
|
|
|
|
cache.clear() # 清空整个缓存系统
|