diff --git a/src/DjangoBlog/accounts/models.py b/src/DjangoBlog/accounts/models.py index 3baddbb..21f4f2c 100644 --- a/src/DjangoBlog/accounts/models.py +++ b/src/DjangoBlog/accounts/models.py @@ -1,3 +1,7 @@ +""" +用户账户相关模型定义 +扩展Django默认用户模型,增加博客系统特有功能 +""" from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse @@ -6,30 +10,50 @@ from django.utils.translation import gettext_lazy as _ from djangoblog.utils import get_current_site -# Create your models here. - class BlogUser(AbstractUser): - nickname = models.CharField(_('nick name'), max_length=100, blank=True) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_modify_time = models.DateTimeField(_('last modify time'), default=now) - source = models.CharField(_('create source'), max_length=100, blank=True) + """ + 博客用户模型 + 继承Django的AbstractUser,扩展博客系统需要的字段 + """ + # 用户昵称,用于显示而非用户名 + nickname = models.CharField(_('昵称'), max_length=100, blank=True) + + # 时间戳字段 + creation_time = models.DateTimeField(_('创建时间'), default=now) + last_modify_time = models.DateTimeField(_('最后修改时间'), default=now) + + # 用户来源,记录注册渠道 + source = models.CharField(_('注册来源'), max_length=100, blank=True) def get_absolute_url(self): + """ + 获取用户主页的URL + 用于作者详情页面的链接 + """ return reverse( 'blog:author_detail', kwargs={ 'author_name': self.username}) def __str__(self): + """ + 字符串表示 + 使用邮箱作为标识,因为用户名可能不唯一 + """ return self.email def get_full_url(self): + """ + 获取包含完整域名的用户主页URL + 用于分享、RSS等需要绝对URL的场景 + """ site = get_current_site().domain url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) return url class Meta: - ordering = ['-id'] - verbose_name = _('user') - verbose_name_plural = verbose_name - get_latest_by = 'id' + """元数据配置""" + ordering = ['-id'] # 按ID降序排列,新的用户在前 + verbose_name = _('用户') # 单数名称 + verbose_name_plural = verbose_name # 复数名称 + get_latest_by = 'id' # 获取最新记录的依据字段 \ No newline at end of file diff --git a/src/DjangoBlog/accounts/views.py b/src/DjangoBlog/accounts/views.py index ae67aec..9bcfb23 100644 --- a/src/DjangoBlog/accounts/views.py +++ b/src/DjangoBlog/accounts/views.py @@ -1,3 +1,7 @@ +""" +用户账户相关视图函数 +处理用户注册、登录、退出、密码找回等功能 +""" import logging from django.utils.translation import gettext_lazy as _ from django.conf import settings @@ -29,176 +33,220 @@ from .models import BlogUser logger = logging.getLogger(__name__) -# Create your views here. - class RegisterView(FormView): - form_class = RegisterForm - template_name = 'account/registration_form.html' - - @method_decorator(csrf_protect) + """ + 用户注册视图 + 处理新用户注册流程,包括邮箱验证 + """ + form_class = RegisterForm # 注册表单类 + template_name = 'account/registration_form.html' # 注册页面模板 + + @method_decorator(csrf_protect) # CSRF保护 def dispatch(self, *args, **kwargs): return super(RegisterView, self).dispatch(*args, **kwargs) def form_valid(self, form): + """ + 表单验证通过后的处理逻辑 + 创建用户、发送验证邮件、跳转结果页面 + """ if form.is_valid(): - user = form.save(False) - user.is_active = False - user.source = 'Register' - user.save(True) + user = form.save(False) # 不立即保存,先进行其他操作 + user.is_active = False # 新用户需要邮箱验证激活 + user.source = 'Register' # 记录注册来源 + user.save(True) # 保存用户到数据库 + + # 构建邮箱验证链接 site = get_current_site().domain sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + # 开发环境使用本地域名 if settings.DEBUG: site = '127.0.0.1:8000' + path = reverse('account:result') url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( site=site, path=path, id=user.id, sign=sign) + # 构建验证邮件内容 content = """ -

请点击下面链接验证您的邮箱

- - {url} - - 再次感谢您! -
- 如果上面链接无法打开,请将此链接复制至浏览器。 - {url} - """.format(url=url) +

请点击下面链接验证您的邮箱

+ {url} + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + + # 发送验证邮件 send_email( - emailto=[ - user.email, - ], + emailto=[user.email], title='验证您的电子邮箱', content=content) - url = reverse('accounts:result') + \ - '?type=register&id=' + str(user.id) + # 跳转到注册结果页面 + url = reverse('accounts:result') + '?type=register&id=' + str(user.id) return HttpResponseRedirect(url) else: - return self.render_to_response({ - 'form': form - }) + # 表单验证失败,重新渲染表单页 + return self.render_to_response({'form': form}) class LogoutView(RedirectView): - url = '/login/' + """ + 用户退出登录视图 + 清除用户会话,重定向到登录页 + """ + url = '/login/' # 退出后重定向的URL - @method_decorator(never_cache) + @method_decorator(never_cache) # 禁止缓存 def dispatch(self, request, *args, **kwargs): return super(LogoutView, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - logout(request) - delete_sidebar_cache() + """处理GET请求,执行退出登录操作""" + logout(request) # Django内置退出函数 + delete_sidebar_cache() # 清理侧边栏缓存 return super(LogoutView, self).get(request, *args, **kwargs) class LoginView(FormView): - form_class = LoginForm - template_name = 'account/login.html' - success_url = '/' - redirect_field_name = REDIRECT_FIELD_NAME - login_ttl = 2626560 # 一个月的时间 - - @method_decorator(sensitive_post_parameters('password')) - @method_decorator(csrf_protect) - @method_decorator(never_cache) + """ + 用户登录视图 + 处理用户登录认证,支持记住登录状态 + """ + form_class = LoginForm # 登录表单类 + template_name = 'account/login.html' # 登录页面模板 + success_url = '/' # 登录成功后的默认跳转地址 + redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名 + login_ttl = 2626560 # 记住登录状态的持续时间(一个月,单位:秒) + + @method_decorator(sensitive_post_parameters('password')) # 保护密码参数 + @method_decorator(csrf_protect) # CSRF保护 + @method_decorator(never_cache) # 禁止缓存 def dispatch(self, request, *args, **kwargs): - return super(LoginView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): + """添加上下文数据,包括重定向地址""" redirect_to = self.request.GET.get(self.redirect_field_name) if redirect_to is None: - redirect_to = '/' + redirect_to = '/' # 默认重定向到首页 kwargs['redirect_to'] = redirect_to - return super(LoginView, self).get_context_data(**kwargs) def form_valid(self, form): + """表单验证通过后的登录处理""" form = AuthenticationForm(data=self.request.POST, request=self.request) if form.is_valid(): - delete_sidebar_cache() + delete_sidebar_cache() # 清理侧边栏缓存 logger.info(self.redirect_field_name) - auth.login(self.request, form.get_user()) + auth.login(self.request, form.get_user()) # 执行登录操作 + + # 处理"记住我"选项 if self.request.POST.get("remember"): - self.request.session.set_expiry(self.login_ttl) + self.request.session.set_expiry(self.login_ttl) # 设置会话过期时间 + return super(LoginView, self).form_valid(form) - # return HttpResponseRedirect('/') else: - return self.render_to_response({ - 'form': form - }) + # 登录失败,重新显示表单 + return self.render_to_response({'form': form}) def get_success_url(self): - + """获取登录成功后的跳转URL""" redirect_to = self.request.POST.get(self.redirect_field_name) + # 安全检查:确保重定向URL在允许的域名内 if not url_has_allowed_host_and_scheme( - url=redirect_to, allowed_hosts=[ - self.request.get_host()]): - redirect_to = self.success_url + url=redirect_to, allowed_hosts=[self.request.get_host()]): + redirect_to = self.success_url # 使用默认URL return redirect_to def account_result(request): - type = request.GET.get('type') - id = request.GET.get('id') + """ + 账户操作结果页面 + 显示注册结果或邮箱验证结果 + """ + type = request.GET.get('type') # 操作类型:register或validation + id = request.GET.get('id') # 用户ID user = get_object_or_404(get_user_model(), id=id) logger.info(type) + + # 如果用户已激活,直接跳转首页 if user.is_active: return HttpResponseRedirect('/') + if type and type in ['register', 'validation']: if type == 'register': + # 注册成功页面 content = ''' 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 ''' title = '注册成功' else: + # 邮箱验证处理 c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = request.GET.get('sign') if sign != c_sign: - return HttpResponseForbidden() - user.is_active = True + return HttpResponseForbidden() # 签名验证失败 + + user.is_active = True # 激活用户 user.save() content = ''' 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 ''' title = '验证成功' + + # 渲染结果页面 return render(request, 'account/result.html', { 'title': title, 'content': content }) else: - return HttpResponseRedirect('/') + return HttpResponseRedirect('/') # 参数错误,跳转首页 class ForgetPasswordView(FormView): - form_class = ForgetPasswordForm - template_name = 'account/forget_password.html' + """ + 忘记密码视图 + 通过邮箱重置用户密码 + """ + form_class = ForgetPasswordForm # 忘记密码表单 + template_name = 'account/forget_password.html' # 模板文件 def form_valid(self, form): + """表单验证通过后的密码重置处理""" if form.is_valid(): + # 根据邮箱查找用户 blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + # 使用Django的密码哈希函数设置新密码 blog_user.password = make_password(form.cleaned_data["new_password2"]) - blog_user.save() - return HttpResponseRedirect('/login/') + blog_user.save() # 保存新密码 + return HttpResponseRedirect('/login/') # 重定向到登录页 else: return self.render_to_response({'form': form}) class ForgetPasswordEmailCode(View): + """ + 忘记密码邮箱验证码视图 + 发送密码重置验证码到用户邮箱 + """ def post(self, request: HttpRequest): + """处理POST请求,发送验证码邮件""" form = ForgetPasswordCodeForm(request.POST) if not form.is_valid(): - return HttpResponse("错误的邮箱") + return HttpResponse("错误的邮箱") # 邮箱格式错误 + to_email = form.cleaned_data["email"] + # 生成并发送验证码 code = generate_code() - utils.send_verify_email(to_email, code) - utils.set_code(to_email, code) + utils.send_verify_email(to_email, code) # 发送验证邮件 + utils.set_code(to_email, code) # 保存验证码(到缓存或数据库) - return HttpResponse("ok") + return HttpResponse("ok") # 发送成功 \ No newline at end of file diff --git a/src/DjangoBlog/blog/models.py b/src/DjangoBlog/blog/models.py index 579d908..463490c 100644 --- a/src/DjangoBlog/blog/models.py +++ b/src/DjangoBlog/blog/models.py @@ -1,3 +1,7 @@ +""" +博客系统核心数据模型定义 +包含文章、分类、标签、友情链接等主要数据模型 +""" import logging import re from abc import abstractmethod @@ -18,25 +22,40 @@ logger = logging.getLogger(__name__) class LinkShowType(models.TextChoices): - I = ('i', _('index')) - L = ('l', _('list')) - P = ('p', _('post')) - A = ('a', _('all')) - S = ('s', _('slide')) + """ + 链接显示类型选择枚举 + 定义友情链接在不同页面的显示方式 + """ + I = ('i', _('首页')) # 首页显示 + L = ('l', _('列表页')) # 文章列表页显示 + P = ('p', _('文章详情页')) # 文章详情页显示 + A = ('a', _('全站显示')) # 所有页面显示 + S = ('s', _('幻灯片')) # 幻灯片形式显示 class BaseModel(models.Model): - id = models.AutoField(primary_key=True) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_modify_time = models.DateTimeField(_('modify time'), default=now) + """ + 抽象基类模型 + 为所有模型提供通用的字段和方法 + """ + 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( @@ -45,79 +64,95 @@ class BaseModel(models.Model): 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 + abstract = True # 抽象类,不会创建数据库表 @abstractmethod def get_absolute_url(self): + """抽象方法,子类必须实现获取绝对URL的方法""" pass class Article(BaseModel): - """文章""" + """ + 文章核心模型 + 存储博客文章的所有信息,支持Markdown格式 + """ + # 文章状态选择 STATUS_CHOICES = ( - ('d', _('Draft')), - ('p', _('Published')), + ('d', _('草稿')), # 草稿状态,仅作者可见 + ('p', _('已发布')), # 已发布状态,所有用户可见 ) + + # 评论状态选择 COMMENT_STATUS = ( - ('o', _('Open')), - ('c', _('Close')), + ('o', _('开启')), # 开启评论 + ('c', _('关闭')), # 关闭评论 ) + + # 文章类型选择 TYPE = ( - ('a', _('Article')), - ('p', _('Page')), + ('a', _('普通文章')), # 普通博客文章 + ('p', _('页面')), # 静态页面(如关于页面) ) - title = models.CharField(_('title'), max_length=200, unique=True) - body = MDTextField(_('body')) - pub_time = models.DateTimeField( - _('publish time'), blank=False, null=False, 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( + + # 基础字段 + 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=_('author'), + verbose_name=_('作者'), blank=False, null=False, - on_delete=models.CASCADE) - article_order = models.IntegerField( - _('order'), blank=False, null=False, default=0) - show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) - category = models.ForeignKey( + 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=_('category'), + verbose_name=_('分类'), on_delete=models.CASCADE, blank=False, - null=False) - tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) + 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 = _('article') - verbose_name_plural = verbose_name - get_latest_by = 'id' + """元数据配置""" + 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, @@ -127,7 +162,16 @@ class Article(BaseModel): @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(), @@ -161,26 +205,42 @@ class Article(BaseModel): id=self.id ).exclude( id__in=[p.id for p in related_posts] - ).order_by('?')[:remaining_count] # 随机排序 + ).order_by('?')[:remaining_count] # 使用order_by('?')随机排序 related_posts.extend(list(random_posts)) - return related_posts[:count] + return related_posts[:count] # 确保返回指定数量的文章 - @cache_decorator(60 * 60 * 10) + @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: @@ -188,29 +248,33 @@ class Article(BaseModel): return value else: comments = self.comment_set.filter(is_enable=True).order_by('-id') - cache.set(cache_key, comments, 60 * 100) + 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) + @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) + @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): """ - Get the first image url from article.body. - :return: + 从文章正文中提取第一张图片URL + 用于文章列表的缩略图显示 + + Returns: + str: 图片URL或空字符串 """ match = re.search(r'!\[.*?\]\((.+?)\)', self.body) if match: @@ -218,61 +282,87 @@ class Article(BaseModel): 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(_('category name'), max_length=30, unique=True) - parent_category = models.ForeignKey( + """ + 文章分类模型 + 支持多级分类结构 + """ + name = models.CharField(_('分类名称'), max_length=30, unique=True) # 分类名称,唯一 + parent_category = models.ForeignKey( # 父级分类,实现分类树 'self', - verbose_name=_('parent category'), + verbose_name=_('父级分类'), blank=True, null=True, - on_delete=models.CASCADE) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) - index = models.IntegerField(default=0, verbose_name=_('index')) + 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 = _('category') + 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) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_category_tree(self): """ - 递归获得分类目录的父级 - :return: + 递归获得分类目录的父级链 + 用于面包屑导航 + + Returns: + list: 从当前分类到根分类的列表 """ categorys = [] def parse(category): + """递归解析分类层级""" categorys.append(category) if category.parent_category: parse(category.parent_category) @@ -280,16 +370,20 @@ class Category(BaseModel): parse(self) return categorys - @cache_decorator(60 * 60 * 10) + @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_sub_categorys(self): """ - 获得当前分类目录所有子集 - :return: + 获得当前分类目录所有子分类 + 用于分类导航菜单 + + 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) @@ -303,45 +397,52 @@ class Category(BaseModel): class Tag(BaseModel): - """文章标签""" - name = models.CharField(_('tag name'), max_length=30, unique=True) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) + """ + 文章标签模型 + 用于文章的多标签分类 + """ + 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) + @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') + ordering = ['name'] # 按名称升序排列 + verbose_name = _('标签') 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, blank=False, null=False) - show_type = models.CharField( - _('show type'), + """ + 友情链接模型 + 管理站外友情链接 + """ + 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(_('creation time'), default=now) - last_mod_time = models.DateTimeField(_('modify time'), default=now) + creation_time = models.DateTimeField(_('创建时间'), default=now) + last_mod_time = models.DateTimeField(_('修改时间'), default=now) class Meta: - ordering = ['sequence'] - verbose_name = _('link') + ordering = ['sequence'] # 按排序字段升序排列 + verbose_name = _('友情链接') verbose_name_plural = verbose_name def __str__(self): @@ -349,17 +450,20 @@ class Links(models.Model): class SideBar(models.Model): - """侧边栏,可以展示一些html内容""" - name = models.CharField(_('title'), max_length=100) - content = models.TextField(_('content')) - 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) + """ + 侧边栏模型 + 管理网站侧边栏的自定义内容 + """ + 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 = _('sidebar') + ordering = ['sequence'] # 按排序字段升序排列 + verbose_name = _('侧边栏') verbose_name_plural = verbose_name def __str__(self): @@ -367,118 +471,111 @@ class SideBar(models.Model): class BlogSettings(models.Model): - """blog的配置""" - site_name = models.CharField( - _('site name'), - max_length=200, - null=False, - blank=False, - default='') - site_description = models.TextField( - _('site description'), - max_length=1000, - null=False, - blank=False, - default='') - site_seo_description = models.TextField( - _('site seo description'), max_length=1000, null=False, blank=False, default='') - site_keywords = models.TextField( - _('site keywords'), - max_length=1000, - null=False, - blank=False, - 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_adsense_codes = models.TextField( - _('adsense code'), max_length=2000, null=True, blank=True, default='') - open_site_comment = models.BooleanField(_('open site comment'), default=True) - global_header = models.TextField("公共头部", null=True, blank=True, default='') - global_footer = models.TextField("公共尾部", null=True, blank=True, default='') - beian_code = models.CharField( - '备案号', - max_length=2000, - null=True, - blank=True, - default='') - analytics_code = models.TextField( - "网站统计代码", - max_length=1000, - null=False, - blank=False, - default='') - show_gongan_code = models.BooleanField( - '是否显示公安备案号', default=False, null=False) - gongan_beiancode = models.TextField( - '公安备案号', - max_length=2000, - null=True, - blank=True, - default='') - comment_need_review = models.BooleanField( - '评论是否需要审核', default=False, null=False) + """ + 博客全局设置模型 + 存储博客的各种配置信息,单例模式 + """ + # 网站基本信息 + 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 = _('Website configuration') + 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(_('There can only be one configuration')) + raise ValidationError(_('只能有一个网站配置')) def save(self, *args, **kwargs): + """保存配置后清空相关缓存""" super().save(*args, **kwargs) from djangoblog.utils import cache - cache.clear() + cache.clear() # 清除所有缓存,因为配置变更可能影响多个页面 class Like(models.Model): - """文章点赞模型""" + """ + 文章点赞模型 + 记录用户对文章的点赞关系 + """ user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('用户'), - on_delete=models.CASCADE + on_delete=models.CASCADE # 用户删除时删除点赞记录 ) article = models.ForeignKey( Article, verbose_name=_('文章'), - on_delete=models.CASCADE + on_delete=models.CASCADE # 文章删除时删除点赞记录 ) - created_time = models.DateTimeField(_('点赞时间'), auto_now_add=True) + created_time = models.DateTimeField(_('点赞时间'), auto_now_add=True) # 点赞时间 class Meta: verbose_name = _('点赞') verbose_name_plural = verbose_name - unique_together = ('user', 'article') + 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 + on_delete=models.CASCADE # 用户删除时删除收藏记录 ) article = models.ForeignKey( Article, verbose_name=_('文章'), - on_delete=models.CASCADE + on_delete=models.CASCADE # 文章删除时删除收藏记录 ) - created_time = models.DateTimeField(_('收藏时间'), auto_now_add=True) + created_time = models.DateTimeField(_('收藏时间'), auto_now_add=True) # 收藏时间 class Meta: verbose_name = _('收藏') verbose_name_plural = verbose_name - unique_together = ('user', 'article') + unique_together = ('user', 'article') # 同一个用户对同一篇文章只能收藏一次 def __str__(self): return f"{self.user.username} 收藏了 {self.article.title}" \ No newline at end of file diff --git a/src/DjangoBlog/blog/urls.py b/src/DjangoBlog/blog/urls.py index cb79f4f..f65ae28 100644 --- a/src/DjangoBlog/blog/urls.py +++ b/src/DjangoBlog/blog/urls.py @@ -1,78 +1,97 @@ +""" +博客应用URL配置 +定义文章相关的所有URL路由 +""" from django.urls import path from django.views.decorators.cache import cache_page from . import views -app_name = "blog" +app_name = "blog" # 应用命名空间 + urlpatterns = [ - path( - r'', - views.IndexView.as_view(), - name='index'), - path( - r'page//', - views.IndexView.as_view(), - name='index_page'), + # 首页 + path(r'', views.IndexView.as_view(), name='index'), + + # 首页分页 + path(r'page//', views.IndexView.as_view(), name='index_page'), + + # 文章详情页(SEO友好URL:/年/月/日/文章ID.html) path( r'article////.html', views.ArticleDetailView.as_view(), - name='detailbyid'), + name='detailbyid' + ), + + # 分类详情页 path( r'category/.html', views.CategoryDetailView.as_view(), - name='category_detail'), + name='category_detail' + ), + + # 分类分页 path( r'category//.html', views.CategoryDetailView.as_view(), - name='category_detail_page'), + name='category_detail_page' + ), + + # 作者详情页 path( r'author/.html', views.AuthorDetailView.as_view(), - name='author_detail'), + name='author_detail' + ), + + # 作者文章分页 path( r'author//.html', views.AuthorDetailView.as_view(), - name='author_detail_page'), + name='author_detail_page' + ), + + # 标签详情页 path( r'tag/.html', views.TagDetailView.as_view(), - name='tag_detail'), + name='tag_detail' + ), + + # 标签分页 path( r'tag//.html', views.TagDetailView.as_view(), - name='tag_detail_page'), + name='tag_detail_page' + ), + + # 文章归档页(缓存1小时) path( 'archives.html', - cache_page( - 60 * 60)( - views.ArchivesView.as_view()), - name='archives'), - path( - 'links.html', - views.LinkListView.as_view(), - name='links'), - path( - r'upload', - views.fileupload, - name='upload'), - path( - r'clean', - views.clean_cache_view, - name='clean'), - path( - 'like//', - views.toggle_like, - name='toggle_like'), - path( - 'favorite//', - views.toggle_favorite, - name='toggle_favorite'), - path( - 'favorites/', - views.favorite_list, - name='favorite_list'), - path( - 'likes/', - views.like_list, - name='like_list'), -] + cache_page(60 * 60)(views.ArchivesView.as_view()), # 缓存1小时 + name='archives' + ), + + # 友情链接页 + path('links.html', views.LinkListView.as_view(), name='links'), + + # 文件上传接口 + path(r'upload', views.fileupload, name='upload'), + + # 缓存清理接口(管理员功能) + path(r'clean', views.clean_cache_view, name='clean'), + + # ==================== 新增的点赞收藏功能 ==================== + + # 点赞/取消点赞 + path('like//', views.toggle_like, name='toggle_like'), + + # 收藏/取消收藏 + path('favorite//', views.toggle_favorite, name='toggle_favorite'), + + # 用户收藏列表 + path('favorites/', views.favorite_list, name='favorite_list'), + + # 用户点赞列表 + path('likes/', views.like_list, name='like_list'), +] \ No newline at end of file diff --git a/src/DjangoBlog/blog/views.py b/src/DjangoBlog/blog/views.py index 49dda78..35be4ad 100644 --- a/src/DjangoBlog/blog/views.py +++ b/src/DjangoBlog/blog/views.py @@ -1,3 +1,7 @@ +""" +博客系统视图函数定义 +处理文章展示、分类、标签、搜索等核心功能 +""" import logging import os import uuid @@ -25,23 +29,33 @@ logger = logging.getLogger(__name__) class ArticleListView(ListView): + """ + 文章列表基类视图 + 提供文章列表的通用功能和缓存机制,所有列表视图的父类 + """ # template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' # context_object_name属性用于给上下文变量取名(在模板中使用该名字) context_object_name = 'article_list' - # 页面类型,分类目录或标签列表等 + # 页面类型,分类目录或标签列表等,用于模板显示标识 page_type = '' - paginate_by = settings.PAGINATE_BY - page_kwarg = 'page' + + # 分页设置 + paginate_by = settings.PAGINATE_BY # 每页显示文章数量,从settings读取 + page_kwarg = 'page' # URL中页码参数名称 + + # 链接显示类型,控制友情链接的显示位置 link_type = LinkShowType.L def get_view_cache_key(self): + """获取视图缓存键(已弃用,使用get_queryset_cache_key代替)""" return self.request.get['pages'] @property def page_number(self): + """获取当前页码的属性方法""" page_kwarg = self.page_kwarg page = self.kwargs.get( page_kwarg) or self.request.GET.get(page_kwarg) or 1 @@ -49,21 +63,23 @@ class ArticleListView(ListView): def get_queryset_cache_key(self): """ - 子类重写.获得queryset的缓存key + 子类重写。获得queryset的缓存key + 每个子类必须实现此方法以生成唯一的缓存键 """ raise NotImplementedError() def get_queryset_data(self): """ - 子类重写.获取queryset的数据 + 子类重写。获取queryset的数据 + 定义具体的查询逻辑,返回文章查询集 """ raise NotImplementedError() def get_queryset_from_cache(self, cache_key): ''' - 缓存页面数据 + 缓存页面数据,提高列表页加载速度 :param cache_key: 缓存key - :return: + :return: 文章查询集 ''' value = cache.get(cache_key) if value: @@ -71,57 +87,89 @@ class ArticleListView(ListView): return value else: article_list = self.get_queryset_data() - cache.set(cache_key, article_list) + cache.set(cache_key, article_list) # 设置缓存,默认过期时间 logger.info('set view cache.key:{key}'.format(key=cache_key)) return article_list def get_queryset(self): ''' - 重写默认,从缓存获取数据 - :return: + 重写默认方法,从缓存获取数据 + 缓存未命中时从数据库查询并缓存结果 + :return: 文章查询集 ''' key = self.get_queryset_cache_key() value = self.get_queryset_from_cache(key) return value def get_context_data(self, **kwargs): + """添加上下文数据,传递链接类型到模板""" kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) class IndexView(ArticleListView): ''' - 首页 + 首页视图 + 显示最新的文章列表,是博客的入口页面 ''' - # 友情链接类型 + # 友情链接类型:首页显示 link_type = LinkShowType.I def get_queryset_data(self): + """获取首页文章数据:所有已发布的普通文章(非页面)""" article_list = Article.objects.filter(type='a', status='p') return article_list def get_queryset_cache_key(self): + """生成首页缓存键:index_页码,支持分页缓存""" cache_key = 'index_{page}'.format(page=self.page_number) return cache_key class ArticleDetailView(DetailView): ''' - 文章详情页面 + 文章详情页面视图 + 显示单篇文章的完整内容、评论、相关文章等信息 ''' - template_name = 'blog/article_detail.html' - model = Article - pk_url_kwarg = 'article_id' - context_object_name = "article" + template_name = 'blog/article_detail.html' # 详情页模板 + model = Article # 关联的文章模型 + pk_url_kwarg = 'article_id' # URL中文章ID的参数名 + context_object_name = "article" # 模板中文章对象的变量名 + + def get(self, request, *args, **kwargs): + """重写get方法,在获取文章时增加浏览量(防刷机制)""" + response = super().get(request, *args, **kwargs) + + # 增加浏览量(使用Cookie防刷) + cookie_name = f'article_{self.object.id}_viewed' + if not request.COOKIES.get(cookie_name): + self.object.viewed() # 增加浏览量 + response.set_cookie(cookie_name, 'true', max_age=60 * 60 * 24) # 24小时内不重复计数 + + return response def get_context_data(self, **kwargs): + """ + 构建文章详情页的完整上下文数据 + 包含评论、分页、相关文章、导航等所有需要的数据 + """ + # 初始化评论表单 comment_form = CommentForm() + # 获取文章的所有评论(已启用状态) article_comments = self.object.comment_list() + + # 筛选顶级评论(没有父评论的评论) parent_comments = article_comments.filter(parent_comment=None) + + # 获取博客设置 blog_setting = get_blog_setting() + + # 评论分页 paginator = Paginator(parent_comments, blog_setting.article_comment_count) page = self.request.GET.get('comment_page', '1') + + # 页码验证和转换 if not page.isnumeric(): page = 1 else: @@ -131,56 +179,84 @@ class ArticleDetailView(DetailView): if page > paginator.num_pages: page = paginator.num_pages + # 获取当前页的评论 p_comments = paginator.page(page) + + # 生成分页导航URL next_page = p_comments.next_page_number() if p_comments.has_next() else None prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + # 构建评论分页URL if next_page: kwargs[ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' if prev_page: kwargs[ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' - kwargs['form'] = comment_form - kwargs['article_comments'] = article_comments - kwargs['p_comments'] = p_comments - kwargs['comment_count'] = len( - article_comments) if article_comments else 0 - - kwargs['next_article'] = self.object.next_article - kwargs['prev_article'] = self.object.prev_article - kwargs['related_posts'] = self.object.get_related_posts(4) + + # 添加上下文数据 + kwargs['form'] = comment_form # 评论表单 + kwargs['article_comments'] = article_comments # 所有评论 + kwargs['p_comments'] = p_comments # 当前页评论 + kwargs['comment_count'] = len(article_comments) if article_comments else 0 # 评论总数 + + # 文章导航 + kwargs['next_article'] = self.object.next_article # 下一篇文章 + kwargs['prev_article'] = self.object.prev_article # 上一篇文章 + kwargs['related_posts'] = self.object.get_related_posts(4) # 相关文章推荐 + + # 调用父类方法构建基础上下文 context = super(ArticleDetailView, self).get_context_data(**kwargs) article = self.object - + # 触发文章详情加载钩子,让插件可以添加额外的上下文数据 from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request) - + # Action Hook, 通知插件"文章详情已获取" hooks.run_action('after_article_body_get', article=article, request=self.request) + + # 添加点赞和收藏状态(用户相关功能) + user = self.request.user + if user.is_authenticated: + kwargs['is_liked'] = article.is_liked_by_user(user) + kwargs['is_favorited'] = article.is_favorited_by_user(user) + else: + kwargs['is_liked'] = False + kwargs['is_favorited'] = False + + kwargs['like_count'] = article.get_like_count() + kwargs['favorite_count'] = article.get_favorite_count() + return context class CategoryDetailView(ArticleListView): ''' - 分类目录列表 + 分类目录列表视图 + 显示指定分类下的所有文章 ''' - page_type = "分类目录归档" + page_type = "分类目录归档" # 页面类型标识 def get_queryset_data(self): - slug = self.kwargs['category_name'] - category = get_object_or_404(Category, slug=slug) + """获取分类下的文章数据,包括子分类的文章""" + slug = self.kwargs['category_name'] # 从URL获取分类slug + category = get_object_or_404(Category, slug=slug) # 获取分类对象 categoryname = category.name - self.categoryname = categoryname + self.categoryname = categoryname # 保存分类名供其他方法使用 + + # 获取所有子分类的名称(包括当前分类) categorynames = list( map(lambda c: c.name, category.get_sub_categorys())) + + # 查询这些分类下的所有已发布文章 article_list = Article.objects.filter( category__name__in=categorynames, status='p') return article_list def get_queryset_cache_key(self): + """生成分类页缓存键:category_list_分类名_页码""" slug = self.kwargs['category_name'] category = get_object_or_404(Category, slug=slug) categoryname = category.name @@ -190,89 +266,67 @@ class CategoryDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - + """添加上下文数据:页面类型和分类名称""" categoryname = self.categoryname try: - categoryname = categoryname.split('/')[-1] + categoryname = categoryname.split('/')[-1] # 处理多层分类名 except BaseException: pass kwargs['page_type'] = CategoryDetailView.page_type - kwargs['tag_name'] = categoryname + kwargs['tag_name'] = categoryname # 在模板中统一使用tag_name变量 return super(CategoryDetailView, self).get_context_data(**kwargs) class AuthorDetailView(ArticleListView): ''' - 作者详情页 + 作者详情页视图 + 显示指定作者的所有文章 ''' - page_type = '作者文章归档' - template_name = 'blog/article_detail.html' - model = Article - pk_url_kwarg = 'article_id' - context_object_name = "article" - - def get(self, request, *args, **kwargs): - """重写get方法,在获取文章时增加浏览量""" - response = super().get(request, *args, **kwargs) - - # 增加浏览量(使用Cookie防刷) - cookie_name = f'article_{self.object.id}_viewed' - if not request.COOKIES.get(cookie_name): - self.object.viewed() - response.set_cookie(cookie_name, 'true', max_age=60 * 60 * 24) # 24小时 - - return response + page_type = '作者文章归档' # 页面类型标识 def get_queryset_cache_key(self): + """生成作者页缓存键:author_作者名_页码""" from uuslug import slugify - author_name = slugify(self.kwargs['author_name']) + author_name = slugify(self.kwargs['author_name']) # 标准化作者名 cache_key = 'author_{author_name}_{page}'.format( author_name=author_name, page=self.page_number) return cache_key def get_queryset_data(self): + """获取作者的所有已发布普通文章""" author_name = self.kwargs['author_name'] article_list = Article.objects.filter( author__username=author_name, type='a', status='p') return article_list def get_context_data(self, **kwargs): + """添加上下文数据:页面类型和作者名称""" author_name = self.kwargs['author_name'] kwargs['page_type'] = AuthorDetailView.page_type - kwargs['tag_name'] = author_name + kwargs['tag_name'] = author_name # 在模板中统一使用tag_name变量 return super(AuthorDetailView, self).get_context_data(**kwargs) - # 添加点赞和收藏状态 - article = self.object - user = self.request.user - - if user.is_authenticated: - kwargs['is_liked'] = article.is_liked_by_user(user) - kwargs['is_favorited'] = article.is_favorited_by_user(user) - else: - kwargs['is_liked'] = False - kwargs['is_favorited'] = False - - kwargs['like_count'] = article.get_like_count() - kwargs['favorite_count'] = article.get_favorite_count() - class TagDetailView(ArticleListView): ''' - 标签列表页面 + 标签列表页面视图 + 显示指定标签下的所有文章 ''' - page_type = '分类标签归档' + page_type = '分类标签归档' # 页面类型标识 def get_queryset_data(self): - slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) + """获取标签下的所有已发布文章""" + slug = self.kwargs['tag_name'] # 从URL获取标签slug + tag = get_object_or_404(Tag, slug=slug) # 获取标签对象 tag_name = tag.name - self.name = tag_name + self.name = tag_name # 保存标签名供其他方法使用 + article_list = Article.objects.filter( - tags__name=tag_name, type='a', status='p') + tags__name=tag_name, type='a', status='p') # 多对多关系查询 return article_list def get_queryset_cache_key(self): + """生成标签页缓存键:tag_标签名_页码""" slug = self.kwargs['tag_name'] tag = get_object_or_404(Tag, slug=slug) tag_name = tag.name @@ -282,7 +336,7 @@ class TagDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - # tag_name = self.kwargs['tag_name'] + """添加上下文数据:页面类型和标签名称""" tag_name = self.name kwargs['page_type'] = TagDetailView.page_type kwargs['tag_name'] = tag_name @@ -291,39 +345,55 @@ class TagDetailView(ArticleListView): class ArchivesView(ArticleListView): ''' - 文章归档页面 + 文章归档页面视图 + 显示所有文章的按时间归档列表 ''' - page_type = '文章归档' - paginate_by = None - page_kwarg = None - template_name = 'blog/article_archives.html' + page_type = '文章归档' # 页面类型标识 + paginate_by = None # 归档页不分页,显示所有文章 + page_kwarg = None # 无页码参数 + template_name = 'blog/article_archives.html' # 专用模板 def get_queryset_data(self): + """获取所有已发布文章(用于归档显示)""" return Article.objects.filter(status='p').all() def get_queryset_cache_key(self): + """生成归档页缓存键:archives(单页不分页)""" cache_key = 'archives' return cache_key class LinkListView(ListView): - model = Links - template_name = 'blog/links_list.html' + """ + 友情链接页面视图 + 显示所有启用的友情链接 + """ + model = Links # 关联的友情链接模型 + template_name = 'blog/links_list.html' # 链接页模板 def get_queryset(self): + """获取所有启用的友情链接,按排序字段排列""" return Links.objects.filter(is_enable=True) class EsSearchView(SearchView): + """ + Elasticsearch搜索视图 + 提供全文搜索功能 + """ + def get_context(self): - paginator, page = self.build_page() + """构建搜索结果的上下文数据""" + paginator, page = self.build_page() # 构建分页 context = { - "query": self.query, - "form": self.form, - "page": page, - "paginator": paginator, - "suggestion": None, + "query": self.query, # 搜索关键词 + "form": self.form, # 搜索表单 + "page": page, # 当前页数据 + "paginator": paginator, # 分页器 + "suggestion": None, # 搜索建议 } + + # 添加拼写建议(如果搜索引擎支持) if hasattr(self.results, "query") and self.results.query.backend.include_spelling: context["suggestion"] = self.results.query.get_spelling_suggestion() context.update(self.extra_context()) @@ -331,52 +401,72 @@ class EsSearchView(SearchView): return context -@csrf_exempt +@csrf_exempt # 免除CSRF保护,用于文件上传 def fileupload(request): """ + 文件上传接口(图床功能) 该方法需自己写调用端来上传图片,该方法仅提供图床功能 - :param request: - :return: + :param request: HTTP请求对象 + :return: 上传文件的URL列表 """ if request.method == 'POST': + # 验证签名,防止未授权上传 sign = request.GET.get('sign', None) if not sign: return HttpResponseForbidden() if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): return HttpResponseForbidden() + response = [] + # 处理所有上传的文件 for filename in request.FILES: - timestr = timezone.now().strftime('%Y/%m/%d') + timestr = timezone.now().strftime('%Y/%m/%d') # 按日期分目录 + + # 判断文件类型 imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] fname = u''.join(str(filename)) isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 + + # 构建保存路径 base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) if not os.path.exists(base_dir): - os.makedirs(base_dir) + os.makedirs(base_dir) # 创建目录 + + # 生成唯一文件名 savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) + + # 安全检查:确保保存路径在预期目录内 if not savepath.startswith(base_dir): return HttpResponse("only for post") + + # 保存文件 with open(savepath, 'wb+') as wfile: for chunk in request.FILES[filename].chunks(): wfile.write(chunk) + + # 如果是图片,进行压缩优化 if isimage: from PIL import Image image = Image.open(savepath) - image.save(savepath, quality=20, optimize=True) - url = static(savepath) + image.save(savepath, quality=20, optimize=True) # 压缩质量20% + + url = static(savepath) # 生成静态文件URL response.append(url) return HttpResponse(response) else: - return HttpResponse("only for post") + return HttpResponse("only for post") # 只支持POST请求 def page_not_found_view( request, exception, template_name='blog/error_page.html'): + """ + 404页面未找到错误处理视图 + """ if exception: - logger.error(exception) + logger.error(exception) # 记录错误日志 url = request.get_full_path() return render(request, template_name, @@ -386,6 +476,9 @@ def page_not_found_view( def server_error_view(request, template_name='blog/error_page.html'): + """ + 500服务器错误处理视图 + """ return render(request, template_name, {'message': _('Sorry, the server is busy, please click the home page to see other?'), @@ -397,8 +490,11 @@ def permission_denied_view( request, exception, template_name='blog/error_page.html'): + """ + 403权限拒绝错误处理视图 + """ if exception: - logger.error(exception) + logger.error(exception) # 记录错误日志 return render( request, template_name, { 'message': _('Sorry, you do not have permission to access this page?'), @@ -406,33 +502,44 @@ def permission_denied_view( def clean_cache_view(request): + """ + 清理缓存视图(管理员功能) + 用于手动清除所有缓存 + """ cache.clear() return HttpResponse('ok') -# blog/views.py - 添加以下视图 +# ==================== 新增的点赞收藏功能视图 ==================== + from django.contrib.auth.decorators import login_required from django.http import JsonResponse from .models import Like, Favorite -@login_required +@login_required # 需要登录 def toggle_like(request, article_id): - """点赞/取消点赞""" + """ + 点赞/取消点赞功能 + AJAX接口,返回JSON响应 + """ if request.method == 'POST': article = get_object_or_404(Article, id=article_id) + # 使用get_or_create实现切换功能 like, created = Like.objects.get_or_create( user=request.user, article=article ) + # 如果已存在(不是新创建的),则删除(取消点赞) if not created: like.delete() is_liked = False else: is_liked = True + # 获取更新后的点赞数量 like_count = article.get_like_count() return JsonResponse({ @@ -446,21 +553,27 @@ def toggle_like(request, article_id): @login_required def toggle_favorite(request, article_id): - """收藏/取消收藏""" + """ + 收藏/取消收藏功能 + AJAX接口,返回JSON响应 + """ if request.method == 'POST': article = get_object_or_404(Article, id=article_id) + # 使用get_or_create实现切换功能 favorite, created = Favorite.objects.get_or_create( user=request.user, article=article ) + # 如果已存在(不是新创建的),则删除(取消收藏) if not created: favorite.delete() is_favorited = False else: is_favorited = True + # 获取更新后的收藏数量 favorite_count = article.get_favorite_count() return JsonResponse({ @@ -474,13 +587,21 @@ def toggle_favorite(request, article_id): @login_required def favorite_list(request): - """用户的收藏列表""" + """ + 用户的收藏列表页面 + 显示当前用户收藏的所有文章 + """ + # 使用select_related优化查询,避免N+1问题 favorites = Favorite.objects.filter(user=request.user).select_related('article') return render(request, 'blog/favorite_list.html', {'favorites': favorites}) @login_required def like_list(request): - """用户的点赞列表""" + """ + 用户的点赞列表页面 + 显示当前用户点赞的所有文章 + """ + # 使用select_related优化查询,避免N+1问题 likes = Like.objects.filter(user=request.user).select_related('article') - return render(request, 'blog/like_list.html', {'likes': likes}) + return render(request, 'blog/like_list.html', {'likes': likes}) \ No newline at end of file diff --git a/src/DjangoBlog/comments/models.py b/src/DjangoBlog/comments/models.py index 7c3bbc8..3dd008b 100644 --- a/src/DjangoBlog/comments/models.py +++ b/src/DjangoBlog/comments/models.py @@ -1,3 +1,7 @@ +""" +评论系统数据模型定义 +支持多级评论回复功能 +""" from django.conf import settings from django.db import models from django.utils.timezone import now @@ -6,34 +10,55 @@ from django.utils.translation import gettext_lazy as _ from blog.models import Article -# Create your models here. - class Comment(models.Model): - body = models.TextField('正文', max_length=300) - creation_time = models.DateTimeField(_('creation time'), default=now) - last_modify_time = models.DateTimeField(_('last modify time'), default=now) + """ + 评论模型 + 支持对文章的评论和评论之间的回复 + """ + # 评论内容 + body = models.TextField('正文', max_length=300) # 评论正文,限制300字 + + # 时间戳 + creation_time = models.DateTimeField(_('创建时间'), default=now) + last_modify_time = models.DateTimeField(_('最后修改时间'), default=now) + + # 关联关系 author = models.ForeignKey( settings.AUTH_USER_MODEL, - verbose_name=_('author'), - on_delete=models.CASCADE) + verbose_name=_('作者'), + on_delete=models.CASCADE) # 用户删除时删除其所有评论 + article = models.ForeignKey( Article, - verbose_name=_('article'), - on_delete=models.CASCADE) + verbose_name=_('文章'), + on_delete=models.CASCADE) # 文章删除时删除其所有评论 + + # 父级评论,实现评论回复功能 parent_comment = models.ForeignKey( - 'self', - verbose_name=_('parent comment'), + 'self', # 自关联 + verbose_name=_('父级评论'), blank=True, null=True, - on_delete=models.CASCADE) - is_enable = models.BooleanField(_('enable'), - default=False, blank=False, null=False) + on_delete=models.CASCADE) # 父评论删除时删除子评论 + + # 评论状态 + is_enable = models.BooleanField( + _('是否启用'), + default=False, # 默认需要审核 + blank=False, + null=False + ) class Meta: - ordering = ['-id'] - verbose_name = _('comment') + """元数据配置""" + ordering = ['-id'] # 按ID降序,新的评论在前 + verbose_name = _('评论') verbose_name_plural = verbose_name get_latest_by = 'id' def __str__(self): - return self.body + """ + 字符串表示 + 返回评论内容的前50个字符 + """ + return self.body[:50] + '...' if len(self.body) > 50 else self.body \ No newline at end of file diff --git a/src/DjangoBlog/comments/views.py b/src/DjangoBlog/comments/views.py index ad9b2b9..b96426d 100644 --- a/src/DjangoBlog/comments/views.py +++ b/src/DjangoBlog/comments/views.py @@ -1,4 +1,7 @@ -# Create your views here. +""" +评论系统视图函数 +处理评论的提交、验证和显示 +""" from django.core.exceptions import ValidationError from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -13,51 +16,70 @@ from .models import Comment class CommentPostView(FormView): - form_class = CommentForm - template_name = 'blog/article_detail.html' + """ + 评论提交视图 + 处理用户评论的提交和验证 + """ + form_class = CommentForm # 评论表单类 + template_name = 'blog/article_detail.html' # 错误时使用的模板 - @method_decorator(csrf_protect) + @method_decorator(csrf_protect) # CSRF保护 def dispatch(self, *args, **kwargs): return super(CommentPostView, self).dispatch(*args, **kwargs) def get(self, request, *args, **kwargs): + """处理GET请求,重定向到文章详情页的评论区域""" article_id = self.kwargs['article_id'] article = get_object_or_404(Article, pk=article_id) url = article.get_absolute_url() - return HttpResponseRedirect(url + "#comments") + return HttpResponseRedirect(url + "#comments") # 锚点定位到评论区域 def form_invalid(self, form): + """表单验证失败的处理""" article_id = self.kwargs['article_id'] article = get_object_or_404(Article, pk=article_id) + # 重新渲染文章详情页,显示表单错误 return self.render_to_response({ 'form': form, 'article': article }) def form_valid(self, form): - """提交的数据验证合法后的逻辑""" + """ + 表单验证通过后的评论保存逻辑 + 创建评论对象,处理父子评论关系 + """ user = self.request.user - author = BlogUser.objects.get(pk=user.pk) + author = BlogUser.objects.get(pk=user.pk) # 获取用户对象 article_id = self.kwargs['article_id'] article = get_object_or_404(Article, pk=article_id) + # 检查文章是否允许评论 if article.comment_status == 'c' or article.status == 'c': raise ValidationError("该文章评论已关闭.") + + # 创建评论对象(不立即保存) comment = form.save(False) - comment.article = article + comment.article = article # 关联文章 + + # 根据博客设置决定评论是否需要审核 from djangoblog.utils import get_blog_setting settings = get_blog_setting() if not settings.comment_need_review: - comment.is_enable = True - comment.author = author + comment.is_enable = True # 无需审核,直接启用 + comment.author = author # 设置评论作者 + + # 处理回复评论(父子评论关系) if form.cleaned_data['parent_comment_id']: parent_comment = Comment.objects.get( pk=form.cleaned_data['parent_comment_id']) - comment.parent_comment = parent_comment + comment.parent_comment = parent_comment # 设置父评论 + + comment.save(True) # 保存评论到数据库 - comment.save(True) + # 重定向到文章详情页的特定评论位置 return HttpResponseRedirect( - "%s#div-comment-%d" % - (article.get_absolute_url(), comment.pk)) + "%s#div-comment-%d" % # 使用锚点定位到新评论 + (article.get_absolute_url(), comment.pk)) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/urls.py b/src/DjangoBlog/djangoblog/urls.py index 6a9e1de..48aa069 100644 --- a/src/DjangoBlog/djangoblog/urls.py +++ b/src/DjangoBlog/djangoblog/urls.py @@ -1,78 +1,103 @@ -"""djangoblog URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.10/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.conf.urls import url, include - 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +DjangoBlog项目主URL配置 +定义项目的所有URL路由和全局配置 """ from django.conf import settings -from django.conf.urls.i18n import i18n_patterns +from django.conf.urls.i18n import i18n_patterns # 国际化URL支持 from django.conf.urls.static import static -from django.contrib.sitemaps.views import sitemap +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 haystack.views import search_view_factory # 搜索视图工厂 from django.http import JsonResponse -import time +import time # 时间相关功能 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.admin_site import admin_site # 自定义Admin站点 +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ES搜索表单 +from djangoblog.feeds import DjangoBlogFeed # RSS订阅 from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap +# 站点地图配置 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' +# 全局错误处理视图配置 +handler404 = 'blog.views.page_not_found_view' # 404错误处理 +handler500 = 'blog.views.server_error_view' # 500错误处理 +handle403 = 'blog.views.permission_denied_view' # 403错误处理 def health_check(request): """ 健康检查接口 - 简单返回服务健康状态 + 用于负载均衡健康检查和监控系统 + :param request: HTTP请求对象 + :return: JSON格式的健康状态响应 """ return JsonResponse({ - 'status': 'healthy', - 'timestamp': time.time() + 'status': 'healthy', # 服务状态 + 'timestamp': time.time() # 当前时间戳 }) + +# 基础URL模式(不包含语言前缀) urlpatterns = [ - path('i18n/', include('django.conf.urls.i18n')), - path('health/', health_check, name='health_check'), + path('i18n/', include('django.conf.urls.i18n')), # 国际化URL支持 + path('health/', health_check, name='health_check'), # 健康检查端点 ] + +# 国际化URL模式(包含语言前缀) urlpatterns += i18n_patterns( + # 管理员后台 re_path(r'^admin/', admin_site.urls), + + # 博客应用URL re_path(r'', include('blog.urls', namespace='blog')), + + # Markdown编辑器URL re_path(r'mdeditor/', include('mdeditor.urls')), + + # 评论系统URL re_path(r'', include('comments.urls', namespace='comment')), + + # 用户账户URL re_path(r'', include('accounts.urls', namespace='account')), + + # OAuth认证URL re_path(r'', include('oauth.urls', namespace='oauth')), + + # 站点地图URL re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), + + # RSS订阅源 re_path(r'^feed/$', DjangoBlogFeed()), re_path(r'^rss/$', DjangoBlogFeed()), - re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), + + # 搜索功能(使用Elasticsearch) + re_path('^search', search_view_factory( + view_class=EsSearchView, + form_class=ElasticSearchModelSearchForm), name='search'), + + # 服务器管理URL re_path(r'', include('servermanager.urls', namespace='servermanager')), + + # OwnTracks位置追踪URL re_path(r'', include('owntracks.urls', namespace='owntracks')) - , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + , prefix_default_language=False) # 不强制默认语言前缀 + +# 静态文件服务(开发环境) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +# 媒体文件服务(开发环境) if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, - document_root=settings.MEDIA_ROOT) + document_root=settings.MEDIA_ROOT) \ No newline at end of file