You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Git/src/DjangoBlog/blog/models.py

581 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
博客系统核心数据模型定义
包含文章、分类、标签、友情链接等主要数据模型
"""
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
from django.utils.text import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
"""
链接显示类型选择枚举
定义友情链接在不同页面的显示方式
"""
I = ('i', _('首页')) # 首页显示
L = ('l', _('列表页')) # 文章列表页显示
P = ('p', _('文章详情页')) # 文章详情页显示
A = ('a', _('全站显示')) # 所有页面显示
S = ('s', _('幻灯片')) # 幻灯片形式显示
class BaseModel(models.Model):
"""
抽象基类模型
为所有模型提供通用的字段和方法
"""
id = models.AutoField(primary_key=True) # 主键ID
creation_time = models.DateTimeField(_('创建时间'), default=now) # 记录创建时间
last_modify_time = models.DateTimeField(_('修改时间'), default=now) # 最后修改时间
def save(self, *args, **kwargs):
"""
重写保存方法,添加通用逻辑
包括自动生成slug、更新时间戳等
"""
# 检查是否是更新文章浏览量的操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 直接更新浏览量,避免触发其他信号
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 自动生成slug字段用于SEO友好的URL
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
"""
获取完整的绝对URL包含域名
用于分享、RSS订阅等场景
"""
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):
"""
文章核心模型
存储博客文章的所有信息支持Markdown格式
"""
# 文章状态选择
STATUS_CHOICES = (
('d', _('草稿')), # 草稿状态,仅作者可见
('p', _('已发布')), # 已发布状态,所有用户可见
)
# 评论状态选择
COMMENT_STATUS = (
('o', _('开启')), # 开启评论
('c', _('关闭')), # 关闭评论
)
# 文章类型选择
TYPE = (
('a', _('普通文章')), # 普通博客文章
('p', _('页面')), # 静态页面(如关于页面)
)
# 基础字段
title = models.CharField(_('标题'), max_length=200, unique=True) # 文章标题,唯一
body = MDTextField(_('正文')) # 文章正文支持Markdown编辑
pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now) # 发布时间
status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p') # 文章状态
comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o') # 评论开关
type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a') # 文章类型
views = models.PositiveIntegerField(_('浏览量'), default=0) # 浏览量统计
author = models.ForeignKey( # 文章作者
settings.AUTH_USER_MODEL,
verbose_name=_('作者'),
blank=False,
null=False,
on_delete=models.CASCADE) # 作者删除时级联删除文章
# 排序和显示相关字段
article_order = models.IntegerField(_('排序'), blank=False, null=False, default=0) # 文章排序权重
show_toc = models.BooleanField(_('显示目录'), blank=False, null=False, default=False) # 是否显示文章目录
# 分类和标签关系
category = models.ForeignKey( # 文章分类
'Category',
verbose_name=_('分类'),
on_delete=models.CASCADE,
blank=False,
null=False) # 必须属于一个分类
tags = models.ManyToManyField('Tag', verbose_name=_('标签'), blank=True) # 文章标签,多对多关系
def body_to_string(self):
"""将正文内容转换为字符串"""
return self.body
def __str__(self):
"""字符串表示,返回文章标题"""
return self.title
class Meta:
"""元数据配置"""
ordering = ['-article_order', '-pub_time'] # 默认按排序权重和发布时间降序排列
verbose_name = _('文章') # 单数名称
verbose_name_plural = verbose_name # 复数名称
get_latest_by = 'id' # 获取最新记录的依据字段
def get_absolute_url(self):
"""
获取文章详情页的URL
使用年月日+文章ID的SEO友好URL结构
"""
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(expiration=60 * 60 * 2) # 缓存2小时
def get_related_posts(self, count=4):
"""
获取相关文章推荐
策略:先通过相同标签获取,不够则通过相同分类补充,还不够则随机推荐
Args:
count: 需要获取的相关文章数量默认4篇
Returns:
list: 相关文章列表
"""
# 通过相同的标签获取相关文章(排除自己)
related_by_tags = Article.objects.filter(
tags__in=self.tags.all(),
status='p' # 只获取已发布的文章
).exclude(
id=self.id
).distinct()
# 如果标签相关的文章不够,通过相同分类补充
if related_by_tags.count() < count:
related_by_category = Article.objects.filter(
category=self.category,
status='p'
).exclude(
id=self.id
).exclude(
id__in=related_by_tags.values_list('id', flat=True)
)[:count - related_by_tags.count()]
# 合并两个查询集
related_posts = list(related_by_tags) + list(related_by_category)
else:
related_posts = list(related_by_tags)
# 如果还不够,随机补充一些文章
if len(related_posts) < count:
remaining_count = count - len(related_posts)
random_posts = Article.objects.filter(
status='p'
).exclude(
id=self.id
).exclude(
id__in=[p.id for p in related_posts]
).order_by('?')[:remaining_count] # 使用order_by('?')随机排序
related_posts.extend(list(random_posts))
return related_posts[:count] # 确保返回指定数量的文章
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_category_tree(self):
"""
获取文章所属分类的完整层级树
用于面包屑导航显示
Returns:
list: 包含分类名称和URL的元组列表
"""
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
"""保存文章,调用父类保存逻辑"""
super().save(*args, **kwargs)
def viewed(self):
"""
增加文章浏览量
使用update_fields只更新views字段提高性能
"""
self.views += 1
self.save(update_fields=['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:
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顺序"""
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
从文章正文中提取第一张图片URL
用于文章列表的缩略图显示
Returns:
str: 图片URL或空字符串
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# ==================== 新增的点赞收藏方法 ====================
def get_like_count(self):
"""获取文章点赞数量"""
return Like.objects.filter(article=self).count()
def get_favorite_count(self):
"""获取文章收藏数量"""
return Favorite.objects.filter(article=self).count()
def is_liked_by_user(self, user):
"""
检查指定用户是否点赞了该文章
Args:
user: 用户对象
Returns:
bool: 是否点赞
"""
if not user.is_authenticated:
return False
return Like.objects.filter(user=user, article=self).exists()
def is_favorited_by_user(self, user):
"""
检查指定用户是否收藏了该文章
Args:
user: 用户对象
Returns:
bool: 是否收藏
"""
if not user.is_authenticated:
return False
return Favorite.objects.filter(user=user, article=self).exists()
class Category(BaseModel):
"""
文章分类模型
支持多级分类结构
"""
name = models.CharField(_('分类名称'), max_length=30, unique=True) # 分类名称,唯一
parent_category = models.ForeignKey( # 父级分类,实现分类树
'self',
verbose_name=_('父级分类'),
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=_('排序索引')) # 分类排序
class Meta:
ordering = ['-index'] # 按索引降序排列
verbose_name = _('分类')
verbose_name_plural = verbose_name
def get_absolute_url(self):
"""获取分类详情页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):
"""
递归获得分类目录的父级链
用于面包屑导航
Returns:
list: 从当前分类到根分类的列表
"""
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):
"""
获得当前分类目录所有子分类
用于分类导航菜单
Returns:
list: 所有子分类列表
"""
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 category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""
文章标签模型
用于文章的多标签分类
"""
name = models.CharField(_('标签名称'), max_length=30, unique=True) # 标签名称,唯一
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
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 = _('标签')
verbose_name_plural = verbose_name
class Links(models.Model):
"""
友情链接模型
管理站外友情链接
"""
name = models.CharField(_('链接名称'), max_length=30, unique=True) # 链接名称
link = models.URLField(_('链接地址')) # 链接URL
sequence = models.IntegerField(_('排序'), unique=True) # 显示顺序
is_enable = models.BooleanField(_('是否显示'), default=True, blank=False, null=False) # 是否启用
show_type = models.CharField( # 显示位置
_('显示类型'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
ordering = ['sequence'] # 按排序字段升序排列
verbose_name = _('友情链接')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""
侧边栏模型
管理网站侧边栏的自定义内容
"""
name = models.CharField(_('标题'), max_length=100) # 侧边栏标题
content = models.TextField(_('内容')) # HTML内容
sequence = models.IntegerField(_('排序'), unique=True) # 显示顺序
is_enable = models.BooleanField(_('是否启用'), default=True) # 是否启用
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
ordering = ['sequence'] # 按排序字段升序排列
verbose_name = _('侧边栏')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""
博客全局设置模型
存储博客的各种配置信息,单例模式
"""
# 网站基本信息
site_name = models.CharField(_('网站名称'), max_length=200, null=False, blank=False, default='')
site_description = models.TextField(_('网站描述'), max_length=1000, null=False, blank=False, default='')
site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(_('网站关键词'), max_length=1000, null=False, blank=False, default='')
# 内容显示设置
article_sub_length = models.IntegerField(_('文章摘要长度'), default=300) # 文章摘要截取长度
sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10) # 侧边栏最新文章数量
sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5) # 侧边栏最新评论数量
article_comment_count = models.IntegerField(_('文章评论分页数量'), default=5) # 文章详情页评论分页大小
# 广告和统计
show_google_adsense = models.BooleanField(_('显示广告'), default=False) # 是否显示Google广告
google_adsense_codes = models.TextField(_('广告代码'), max_length=2000, null=True, blank=True, default='') # 广告代码
# 评论设置
open_site_comment = models.BooleanField(_('开启全站评论'), default=True) # 是否开启评论功能
comment_need_review = models.BooleanField('评论是否需要审核', default=False, null=False) # 评论是否需要审核
# 页面布局
global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部HTML
global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部HTML
# 备案信息
beian_code = models.CharField('备案号', max_length=2000, null=True, blank=True, default='') # ICP备案号
show_gongan_code = models.BooleanField('是否显示公安备案号', default=False, null=False) # 是否显示公安备案
gongan_beiancode = models.TextField('公安备案号', max_length=2000, null=True, blank=True, default='') # 公安备案号
# 网站统计
analytics_code = models.TextField("网站统计代码", max_length=1000, null=False, blank=False,
default='') # 统计代码如Google Analytics
class Meta:
verbose_name = _('网站配置')
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(_('只能有一个网站配置'))
def save(self, *args, **kwargs):
"""保存配置后清空相关缓存"""
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear() # 清除所有缓存,因为配置变更可能影响多个页面
class Like(models.Model):
"""
文章点赞模型
记录用户对文章的点赞关系
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE # 用户删除时删除点赞记录
)
article = models.ForeignKey(
Article,
verbose_name=_('文章'),
on_delete=models.CASCADE # 文章删除时删除点赞记录
)
created_time = models.DateTimeField(_('点赞时间'), auto_now_add=True) # 点赞时间
class Meta:
verbose_name = _('点赞')
verbose_name_plural = verbose_name
unique_together = ('user', 'article') # 同一个用户对同一篇文章只能点赞一次
def __str__(self):
return f"{self.user.username} 点赞了 {self.article.title}"
class Favorite(models.Model):
"""
文章收藏模型
记录用户对文章的收藏关系
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE # 用户删除时删除收藏记录
)
article = models.ForeignKey(
Article,
verbose_name=_('文章'),
on_delete=models.CASCADE # 文章删除时删除收藏记录
)
created_time = models.DateTimeField(_('收藏时间'), auto_now_add=True) # 收藏时间
class Meta:
verbose_name = _('收藏')
verbose_name_plural = verbose_name
unique_together = ('user', 'article') # 同一个用户对同一篇文章只能收藏一次
def __str__(self):
return f"{self.user.username} 收藏了 {self.article.title}"