diff --git a/.idea/djangoBlogStudy.iml b/.idea/djangoBlogStudy.iml index 8b8c395..e378968 100644 --- a/.idea/djangoBlogStudy.iml +++ b/.idea/djangoBlogStudy.iml @@ -2,7 +2,7 @@ - + diff --git a/.idea/modules.xml b/.idea/modules.xml index dc7543e..eb9a5a1 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,11 +2,7 @@ -<<<<<<< HEAD -======= - ->>>>>>> master \ No newline at end of file diff --git a/src/comments/admin.py b/src/comments/admin.py index a814f3f..8a5afc6 100644 --- a/src/comments/admin.py +++ b/src/comments/admin.py @@ -1,47 +1,106 @@ +# 导入 Django 管理后台模块 from django.contrib import admin + +# 导入 reverse 函数,用于反向解析 URL(根据命名 URL 生成实际路径) from django.urls import reverse + +# 导入 format_html,用于安全地格式化 HTML 字符串(防止 XSS) from django.utils.html import format_html + +# 导入 gettext_lazy 作为 _,用于字符串国际化(延迟翻译) from django.utils.translation import gettext_lazy as _ +# 自定义管理员操作:批量禁用选中的评论 def disable_commentstatus(modeladmin, request, queryset): + """ + 将选中的评论设置为“不启用”状态(即不显示) + 此函数将在评论管理界面作为批量操作使用 + """ + # 批量更新:将 queryset 中所有评论的 is_enable 字段设为 False queryset.update(is_enable=False) +# 自定义管理员操作:批量启用选中的评论 def enable_commentstatus(modeladmin, request, queryset): + """ + 将选中的评论设置为“启用”状态(即显示) + 此函数将在评论管理界面作为批量操作使用 + """ + # 批量更新:将 queryset 中所有评论的 is_enable 字段设为 True queryset.update(is_enable=True) -disable_commentstatus.short_description = _('Disable comments') -enable_commentstatus.short_description = _('Enable comments') +# 为自定义操作设置在管理界面中显示的描述文本(支持国际化) +disable_commentstatus.short_description = _('Disable comments') # 显示为“禁用评论” +enable_commentstatus.short_description = _('Enable comments') # 显示为“启用评论” +# 定义 Comment 模型在 Django 管理后台的显示和操作配置 class CommentAdmin(admin.ModelAdmin): + # 设置评论列表每页显示 20 条数据 list_per_page = 20 + + # 定义在评论列表页面显示的字段列 list_display = ( - 'id', - 'body', - 'link_to_userinfo', - 'link_to_article', - 'is_enable', - 'creation_time') + 'id', # 评论的数据库 ID + 'body', # 评论正文内容 + 'link_to_userinfo', # 自定义方法:显示作者信息(带链接) + 'link_to_article', # 自定义方法:显示所属文章(带链接) + 'is_enable', # 是否启用(布尔值,显示为开关) + 'creation_time' # 评论创建时间 + ) + + # 设置哪些字段可以点击,跳转到该评论的编辑页面 list_display_links = ('id', 'body', 'is_enable') + + # 添加右侧过滤侧边栏,允许按 is_enable 字段(是否启用)筛选评论 list_filter = ('is_enable',) + + # 在添加或编辑评论时,从表单中排除这两个字段 + # 因为 creation_time 和 last_modify_time 通常由代码自动处理(如默认值或保存时更新) exclude = ('creation_time', 'last_modify_time') + + # 注册自定义的批量操作,允许管理员在列表页选择多条评论执行“启用”或“禁用” actions = [disable_commentstatus, enable_commentstatus] def link_to_userinfo(self, obj): + """ + 自定义列表字段:生成指向评论作者用户信息编辑页面的超链接 + + 参数: + obj: 当前评论对象 + + 返回: + HTML 格式的链接,链接文本为用户的昵称(若有),否则为邮箱 + """ + # 获取作者用户模型的 app_label 和 model_name(如 'auth', 'user') info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 使用 reverse 构造 Django 管理后台中该用户的编辑页面 URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回一个安全的 HTML 链接 return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) def link_to_article(self, obj): + """ + 自定义列表字段:生成指向评论所属文章编辑页面的超链接 + + 参数: + obj: 当前评论对象 + + 返回: + HTML 格式的链接,链接文本为文章标题 + """ + # 获取文章模型的 app_label 和 model_name(如 'blog', 'article') info = (obj.article._meta.app_label, obj.article._meta.model_name) + # 构造该文章在管理后台的编辑页面 URL link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + # 返回一个安全的 HTML 链接 return format_html( u'%s' % (link, obj.article.title)) - link_to_userinfo.short_description = _('User') - link_to_article.short_description = _('Article') + # 为自定义的列表字段设置列标题(支持国际化) + link_to_userinfo.short_description = _('User') # 列标题显示为“用户” + link_to_article.short_description = _('Article') # 列标题显示为“文章” \ No newline at end of file diff --git a/src/comments/apps.py b/src/comments/apps.py index ff01b77..96d8d00 100644 --- a/src/comments/apps.py +++ b/src/comments/apps.py @@ -1,5 +1,9 @@ +# 导入 Django 应用配置基类 from django.apps import AppConfig +# 定义 comments 应用的配置类 class CommentsConfig(AppConfig): + # 指定该配置对应的 Django 应用的完整 Python 路径 + # 即当前应用的包名(位于 INSTALLED_APPS 中) name = 'comments' diff --git a/src/comments/forms.py b/src/comments/forms.py index e83737d..dcc6088 100644 --- a/src/comments/forms.py +++ b/src/comments/forms.py @@ -4,10 +4,16 @@ from django.forms import ModelForm from .models import Comment +# 定义一个用于处理评论数据的表单类,继承自 Django 的 ModelForm class CommentForm(ModelForm): + # 自定义字段:parent_comment_id + # 用于存储当前评论所回复的父评论的 ID parent_comment_id = forms.IntegerField( - widget=forms.HiddenInput, required=False) + widget=forms.HiddenInput, # 使用隐藏输入框(HTML ),不在页面上显示 + required=False # 非必填字段,因为一级评论没有父评论 + ) class Meta: - model = Comment - fields = ['body'] + model = Comment # 关联的数据库模型为 Comment + fields = ['body'] # 表单中需要包含的模型字段,仅包含 'body'(评论正文) + # 注意:其他字段如 author、article、creation_time 等通常在视图中自动填充,不暴露给用户 \ No newline at end of file diff --git a/src/comments/migrations/0001_initial.py b/src/comments/migrations/0001_initial.py index 61d1e53..9c4f9a1 100644 --- a/src/comments/migrations/0001_initial.py +++ b/src/comments/migrations/0001_initial.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.7 on 2023-03-02 07:14 - from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -7,32 +5,69 @@ import django.utils.timezone class Migration(migrations.Migration): - + # 表示这是一个初始迁移(即创建模型的第一次迁移) initial = True + # 定义该迁移所依赖的其他迁移 + # 只有当这些依赖的迁移执行完成后,当前迁移才会执行 dependencies = [ + # 依赖于 blog 应用下的 '0001_initial' 迁移 + # 确保 blog 应用中的模型(如 Article)已创建 ('blog', '0001_initial'), + # 依赖于用户模型的迁移 + # 使用 migrations.swappable_dependency 和 settings.AUTH_USER_MODEL + # 是为了支持自定义用户模型(即项目可能使用了非默认的 User 模型) + # Django 会自动解析为实际使用的用户模型对应的迁移 migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义本次迁移要执行的数据库操作列表 operations = [ + # 创建一个名为 'Comment' 的数据库模型(对应一张数据表) migrations.CreateModel( - name='Comment', - fields=[ + name='Comment', # 模型名称 + fields=[ # 模型包含的字段列表 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 主键字段:id + # 使用 BigAutoField(64位整数),自动递增,作为主键,不序列化为单独字段(serialize=False) ('body', models.TextField(max_length=300, verbose_name='正文')), + # 正文字段:body + # 文本字段,最大长度300字符,显示名称为“正文” ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 创建时间字段:created_time + # DateTimeField,默认值为当前时间(使用 django.utils.timezone.now 函数) + # 显示名称为“创建时间” ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 最后修改时间字段:last_mod_time + # DateTimeField,默认值也为当前时间(初始创建时与创建时间相同) + # 后续可通过逻辑更新 + # 显示名称为“修改时间” ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 是否启用字段:is_enable + # BooleanField,默认为 True,表示评论是否显示 + # 用于软删除或审核功能 ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), + # 外键字段:article + # 关联到 blog 应用中的 Article 模型('blog.article') + # on_delete=models.CASCADE:当文章被删除时,该评论也会被级联删除 + # 显示名称为“文章” ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), + # 外键字段:author + # 关联到当前项目配置的用户模型(支持自定义用户) + # 当用户被删除时,该用户的评论也会被级联删除 + # 显示名称为“作者” ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), + # 外键字段:parent_comment + # 实现评论的嵌套/回复功能,指向同一模型(comments.Comment)的其他评论 + # blank=True, null=True:允许为空,表示可以是一级评论(顶级评论) + # on_delete=CASCADE:上级评论被删除时,其子评论也一并删除 + # 显示名称为“上级评论” ], - options={ - 'verbose_name': '评论', - 'verbose_name_plural': '评论', - 'ordering': ['-id'], - 'get_latest_by': 'id', + options={ # 模型的元选项(Meta options) + 'verbose_name': '评论', # 单数形式的可读名称 + 'verbose_name_plural': '评论', # 复数形式的可读名称(此处未复数化,与单数相同) + 'ordering': ['-id'], # 默认排序:按 id 降序排列(最新的在前) + 'get_latest_by': 'id', # 使用 latest() 方法时,默认按 id 获取最新一条记录 }, ), ] diff --git a/src/comments/migrations/0002_alter_comment_is_enable.py b/src/comments/migrations/0002_alter_comment_is_enable.py index 17c44db..d0511f1 100644 --- a/src/comments/migrations/0002_alter_comment_is_enable.py +++ b/src/comments/migrations/0002_alter_comment_is_enable.py @@ -1,18 +1,25 @@ -# Generated by Django 4.1.7 on 2023-04-24 13:48 - from django.db import migrations, models - +# migrations: 用于定义数据库迁移操作 +# models: 用于定义模型字段类型 class Migration(migrations.Migration): +# 定义一个迁移类,继承自 django.db.migrations.Migration dependencies = [ ('comments', '0001_initial'), ] +# 当前迁移的依赖列表 +# 表示必须先执行 comments 应用下的 '0001_initial' 迁移(即初始创建 Comment 模型的迁移) +# 之后才能执行当前迁移 + + + operations = [ # 定义本次迁移要执行的数据库操作列表 + migrations.AlterField( # 执行一个“修改字段”的操作 + model_name='comment', # 要修改的模型名称(对应 comments 应用中的 Comment 模型) + name='is_enable', # 要修改的字段名 + field=models.BooleanField( # 新的字段定义 + default=False, # 将默认值从之前的 True 修改为 False + verbose_name='是否显示'), # 字段的可读名称保持不变 - operations = [ - migrations.AlterField( - model_name='comment', - name='is_enable', - field=models.BooleanField(default=False, verbose_name='是否显示'), ), ] diff --git a/src/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py index a1ca970..c07de08 100644 --- a/src/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py +++ b/src/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py @@ -3,55 +3,65 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import django.utils.timezone +import django.utils.timezone # 用于引用 AUTH_USER_MODEL class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('blog', '0005_alter_article_options_alter_category_options_and_more'), - ('comments', '0002_alter_comment_is_enable'), + dependencies = [ # 当前迁移所依赖的其他迁移,必须按顺序执行完这些依赖迁移后,才能执行本迁移 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖于 blog 应用的第 5 个迁移(可能修改了 Article 或 Category 的选项) + ('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖于 blog 应用的第 5 个迁移(可能修改了 Article 或 Category 的选项) + ('comments', '0002_alter_comment_is_enable'), # 依赖于 comments 应用的第 2 个迁移(之前已修改过 is_enable 字段的默认值) ] - operations = [ + operations = [ # 定义本次迁移要执行的所有数据库操作 + # 1. 修改模型的元选项(Meta options) migrations.AlterModelOptions( - name='comment', + name='comment', # 作用于 Comment 模型 options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, + # 单数名称改为英文小写 # 复数名称也改为英文 # 排序方式不变:按 id 降序(最新在前) # 获取最新记录仍按 id ), + # 2. 删除旧的创建时间字段(created_time) migrations.RemoveField( model_name='comment', name='created_time', ), + # 3. 删除旧的最后修改时间字段(last_mod_time) migrations.RemoveField( model_name='comment', name='last_mod_time', ), + # 4. 添加新的创建时间字段(creation_time),替代 created_time migrations.AddField( model_name='comment', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 5. 添加新的最后修改时间字段(last_modify_time),替代 last_mod_time migrations.AddField( model_name='comment', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 6. 修改 article 外键字段:仅更新 verbose_name 为英文 migrations.AlterField( model_name='comment', name='article', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), ), + # 7. 修改 author 外键字段:仅更新 verbose_name 为英文 migrations.AlterField( model_name='comment', name='author', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), ), + # 8. 修改 is_enable 字段:更新 verbose_name 为英文,并保持 default=False migrations.AlterField( model_name='comment', name='is_enable', field=models.BooleanField(default=False, verbose_name='enable'), ), + # 9. 修改 parent_comment 字段:更新 verbose_name 为英文 migrations.AlterField( model_name='comment', name='parent_comment', diff --git a/src/comments/models.py b/src/comments/models.py index 7c3bbc8..1baae24 100644 --- a/src/comments/models.py +++ b/src/comments/models.py @@ -6,34 +6,78 @@ from django.utils.translation import gettext_lazy as _ from blog.models import Article -# Create your models here. - +# 定义评论模型,用于存储用户对文章的评论数据 class Comment(models.Model): + # 评论正文内容 + # 使用 TextField 存储较长文本,限制最大长度为 300 字符 + # verbose_name 设置为 '正文',在管理后台等界面中显示为字段标签 body = models.TextField('正文', max_length=300) + + # 评论创建时间 + # 自动记录评论的创建时间,默认值为当前时间(now) + # verbose_name 为国际化字符串 'creation time' creation_time = models.DateTimeField(_('creation time'), default=now) + + # 评论最后修改时间 + # 记录评论最后一次被修改的时间,默认值为当前时间(通常在保存时更新) + # verbose_name 为国际化字符串 'last modify time' last_modify_time = models.DateTimeField(_('last modify time'), default=now) + + # 评论作者 + # 外键关联到用户模型(支持自定义用户模型) + # verbose_name 为 'author'(国际化),on_delete=models.CASCADE 表示用户删除时,其评论也级联删除 author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), on_delete=models.CASCADE) + + # 所属文章 + # 外键关联到 Article 模型,表示该评论属于哪篇文章 + # verbose_name 为 'article'(国际化),级联删除:文章删除时,其所有评论也被删除 article = models.ForeignKey( Article, verbose_name=_('article'), on_delete=models.CASCADE) + + # 父级评论(用于实现嵌套评论/回复功能) + # 外键指向自身('self'),实现树形结构 + # blank=True, null=True 表示可以为空(即一级评论没有父评论) + # verbose_name 为 'parent comment'(国际化),级联删除:父评论删除时,其子评论也删除 parent_comment = models.ForeignKey( 'self', verbose_name=_('parent comment'), blank=True, null=True, on_delete=models.CASCADE) + + # 是否启用(是否显示) + # BooleanField 用于控制评论是否公开显示 + # default=False 表示默认不启用(需审核或手动开启) + # blank=False, null=False 表示该字段不可为空,必须有值 + # verbose_name 为 'enable'(国际化) is_enable = models.BooleanField(_('enable'), default=False, blank=False, null=False) class Meta: + """ + 模型元数据类:定义模型的元信息,如排序、名称等 + """ + # 默认排序:按 id 降序排列(最新的评论在前) ordering = ['-id'] + + # 模型的可读名称(单数形式),用于管理后台等界面显示 verbose_name = _('comment') + + # 模型的可读名称(复数形式),此处与单数相同 verbose_name_plural = verbose_name + + # 指定获取最新一条记录时依据的字段 get_latest_by = 'id' def __str__(self): + """ + 返回该评论对象的字符串表示 + 通常在管理后台或调试时显示 + 此处返回评论的正文内容(body) + """ return self.body diff --git a/src/comments/templatetags/comments_tags.py b/src/comments/templatetags/comments_tags.py index fde02b4..de28aa1 100644 --- a/src/comments/templatetags/comments_tags.py +++ b/src/comments/templatetags/comments_tags.py @@ -1,5 +1,7 @@ +# 导入 Django 模板系统模块 from django import template +# 创建一个模板标签库实例,用于注册自定义模板标签和过滤器 register = template.Library() @@ -8,23 +10,31 @@ def parse_commenttree(commentlist, comment): """获得当前评论子评论的列表 用法: {% parse_commenttree article_comments comment as childcomments %} """ + # 存储所有子评论的列表 datas = [] def parse(c): + # 从 commentlist 中筛选出 parent_comment 指向当前评论 c 且 is_enable=True 的子评论 childs = commentlist.filter(parent_comment=c, is_enable=True) for child in childs: + # 将符合条件的子评论添加到结果列表中 datas.append(child) + # 递归查找该子评论的子评论,实现深度优先遍历 parse(child) + # 从传入的根评论开始递归解析 parse(comment) + # 返回最终收集到的所有子评论列表 return datas @register.inclusion_tag('comments/tags/comment_item.html') def show_comment_item(comment, ischild): """评论""" + # 根据是否为子评论设置缩进层级 + # 如果是子评论(ischild=True),depth=1;否则 depth=2 depth = 1 if ischild else 2 return { - 'comment_item': comment, - 'depth': depth + 'comment_item': comment, # 当前评论对象 + 'depth': depth # 缩进层级,用于模板中控制显示样式 } diff --git a/src/comments/tests.py b/src/comments/tests.py index 2a7f55f..3110226 100644 --- a/src/comments/tests.py +++ b/src/comments/tests.py @@ -10,71 +10,99 @@ from djangoblog.utils import get_max_articleid_commentid # Create your tests here. +# 定义一个基于事务的测试用例类,用于测试 comments 应用的相关功能 class CommentsTest(TransactionTestCase): def setUp(self): + # 创建 Django 测试客户端,用于模拟用户请求 self.client = Client() + + # 创建请求工厂,用于在视图测试中构造请求对象 self.factory = RequestFactory() + + # 导入博客设置模型(由于导入在方法内,可能为了延迟加载或避免循环导入) from blog.models import BlogSettings + + # 创建一个博客全局设置实例 value = BlogSettings() + # 设置评论需要审核(即新评论默认不显示,需管理员启用) value.comment_need_review = True + # 保存到数据库 value.save() + # 创建一个超级用户,用于登录和发表评论 self.user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") def update_article_comment_status(self, article): + # 获取该文章的所有评论 comments = article.comment_set.all() for comment in comments: - comment.is_enable = True - comment.save() + comment.is_enable = True # 启用评论 + comment.save() # 保存更改 def test_validate_comment(self): + # 使用测试用户登录(确保有权限发表评论) self.client.login(username='liangliangyy1', password='liangliangyy1') + # 创建文章分类 category = Category() category.name = "categoryccc" category.save() + # 创建一篇文章 article = Article() article.title = "nicetitleccc" article.body = "nicecontentccc" article.author = self.user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 普通文章类型 + article.status = 'p' # 已发布状态 article.save() + # 反向解析获取评论提交的 URL(根据命名空间和参数) comment_url = reverse( 'comments:postcomment', kwargs={ 'article_id': article.id}) + # 模拟 POST 请求发表第一条评论 response = self.client.post(comment_url, { 'body': '123ffffffffff' }) + # 断言:响应状态码应为 302(重定向),表示评论提交成功 self.assertEqual(response.status_code, 302) + # 重新获取文章对象(刷新数据) article = Article.objects.get(pk=article.pk) + # 此时评论未审核,comment_list() 返回的可见评论数应为 0 self.assertEqual(len(article.comment_list()), 0) - self.update_article_comment_status(article) + # 手动启用该评论(模拟审核通过) + self.update_article_comment_status(article) + # 此时可见评论数应为 1 self.assertEqual(len(article.comment_list()), 1) + # 再次发表第二条评论 response = self.client.post(comment_url, { 'body': '123ffffffffff', }) - + # 断言:响应成功(重定向) self.assertEqual(response.status_code, 302) + # 刷新文章数据并启用新评论 article = Article.objects.get(pk=article.pk) self.update_article_comment_status(article) + # 总共应有 2 条可见评论 self.assertEqual(len(article.comment_list()), 2) + + # 获取第一条评论的 ID,用于后续回复(作为父评论) parent_comment_id = article.comment_list()[0].id + # 发表一条回复(嵌套评论),包含复杂 Markdown 内容 response = self.client.post(comment_url, { 'body': ''' @@ -88,22 +116,37 @@ class CommentsTest(TransactionTestCase): [ddd](http://www.baidu.com) - ''', - 'parent_comment_id': parent_comment_id + 'parent_comment_id': parent_comment_id # 指定父评论 }) + # 断言:提交成功 self.assertEqual(response.status_code, 302) + + # 启用新评论并刷新文章数据 self.update_article_comment_status(article) article = Article.objects.get(pk=article.pk) + # 总评论数应为 3(2 条一级 + 1 条子评论) self.assertEqual(len(article.comment_list()), 3) + + # 获取父评论对象 comment = Comment.objects.get(id=parent_comment_id) + # 解析从该父评论开始的所有子评论树 tree = parse_commenttree(article.comment_list(), comment) + # 子评论树中应包含 1 个子评论 self.assertEqual(len(tree), 1) + + # 测试包含标签 show_comment_item 是否正常返回数据 data = show_comment_item(comment, True) + # 返回值不应为 None self.assertIsNotNone(data) + + # 调用工具函数获取最大文章ID和评论ID(可能是用于生成唯一标识) s = get_max_articleid_commentid() + # 函数应返回有效值 self.assertIsNotNone(s) + # 从 utils 模块导入发送评论邮件函数 from comments.utils import send_comment_email - send_comment_email(comment) + # 测试发送评论通知邮件功能 + send_comment_email(comment) \ No newline at end of file diff --git a/src/comments/urls.py b/src/comments/urls.py index 7df3fab..2cc8624 100644 --- a/src/comments/urls.py +++ b/src/comments/urls.py @@ -2,10 +2,28 @@ from django.urls import path from . import views +# 定义当前应用(comments)的 URL 命名空间 +# 在项目其他地方可以通过 'comments:xxx' 的方式反向解析 URL app_name = "comments" + +# 定义 comments 应用的 URL 路由列表 urlpatterns = [ + # 路由配置:将特定 URL 模式映射到对应的视图处理逻辑 path( + # URL 模式: + # - 以 'article/' 开头 + # - 接一个整数类型的 article_id 参数(通过 捕获) + # - 然后是 'postcomment' 路径 + # 例如:/comments/article/123/postcomment/ 'article//postcomment', + + # 视图处理类: + # 使用基于类的视图 (Class-Based View) CommentPostView 的 as_view() 方法 + # as_view() 将类视图转换为可调用的视图函数,供 URL 路由使用 views.CommentPostView.as_view(), + + # URL 名称: + # 为该路由设置一个唯一名称 'postcomment' + # 在模板或代码中可通过 {% url 'comments:postcomment' article_id=123 %} 的方式引用 name='postcomment'), -] +] \ No newline at end of file diff --git a/src/comments/utils.py b/src/comments/utils.py index f01dba7..f42e6cb 100644 --- a/src/comments/utils.py +++ b/src/comments/utils.py @@ -5,24 +5,44 @@ from django.utils.translation import gettext_lazy as _ from djangoblog.utils import get_current_site from djangoblog.utils import send_email +# 获取当前模块的 logger 实例,用于记录日志 logger = logging.getLogger(__name__) def send_comment_email(comment): + # 获取当前站点的域名(如 example.com) site = get_current_site().domain + + # 邮件主题(支持国际化) subject = _('Thanks for your comment') + + # 构造文章的完整绝对 URL(使用 HTTPS) article_url = f"https://{site}{comment.article.get_absolute_url()}" + + # 构造发送给评论作者的 HTML 邮件内容 + # 内容包含感谢语、文章链接和标题,并支持变量替换 html_content = _("""

Thank you very much for your comments on this site

You can visit %(article_title)s to review your comments, Thank you again!
If the link above cannot be opened, please copy this link to your browser. - %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title} + %(article_url)s""") % { + 'article_url': article_url, + 'article_title': comment.article.title + } + + # 收件人邮箱:当前发表评论的用户 tomail = comment.author.email + + # 调用发送邮件的通用函数,发送感谢邮件 send_email([tomail], subject, html_content) + try: + # 如果当前评论是回复(即存在父评论) if comment.parent_comment: + # 构造发送给被回复用户的 HTML 邮件内容 + # 通知其评论收到了新的回复 html_content = _("""Your comment on %(article_title)s
has received a reply.
%(comment_body)s
@@ -30,9 +50,18 @@ def send_comment_email(comment):
If the link above cannot be opened, please copy this link to your browser. %(article_url)s - """) % {'article_url': article_url, 'article_title': comment.article.title, - 'comment_body': comment.parent_comment.body} + """) % { + 'article_url': article_url, + 'article_title': comment.article.title, + 'comment_body': comment.parent_comment.body # 被回复的原始评论内容 + } + + # 收件人邮箱:被回复的评论的作者 tomail = comment.parent_comment.author.email + + # 发送回复通知邮件 send_email([tomail], subject, html_content) + except Exception as e: - logger.error(e) + # 如果在发送通知邮件过程中发生异常(如用户无邮箱等),记录错误日志 + logger.error(e) \ No newline at end of file diff --git a/src/comments/views.py b/src/comments/views.py index ad9b2b9..465d23e 100644 --- a/src/comments/views.py +++ b/src/comments/views.py @@ -12,8 +12,12 @@ from .forms import CommentForm from .models import Comment +# 定义一个基于类的视图,用于处理用户提交评论的请求 class CommentPostView(FormView): + # 指定该视图使用的表单类 form_class = CommentForm + + # 指定表单验证失败时,或需要渲染响应时使用的模板 template_name = 'blog/article_detail.html' @method_decorator(csrf_protect) @@ -21,15 +25,26 @@ class CommentPostView(FormView): return super(CommentPostView, self).dispatch(*args, **kwargs) def get(self, request, *args, **kwargs): + # 从 URL 参数中获取文章 ID article_id = self.kwargs['article_id'] + + # 根据 ID 获取文章对象,若不存在则返回 404 错误 article = get_object_or_404(Article, pk=article_id) + + # 获取文章的绝对 URL url = article.get_absolute_url() + + # 重定向到文章页面,并锚定到 id="comments" 的元素(通常是评论列表) return HttpResponseRedirect(url + "#comments") def form_invalid(self, form): + # 获取文章 ID article_id = self.kwargs['article_id'] + + # 获取对应的文章对象(404 安全获取) article = get_object_or_404(Article, pk=article_id) + # 渲染模板,将表单(含错误)和文章对象传递给模板 return self.render_to_response({ 'form': form, 'article': article @@ -37,27 +52,57 @@ class CommentPostView(FormView): def form_valid(self, form): """提交的数据验证合法后的逻辑""" + + # 获取当前登录的用户 user = self.request.user + + # 获取用户对应的 BlogUser 对象 author = BlogUser.objects.get(pk=user.pk) + + # 获取 URL 中的文章 ID article_id = self.kwargs['article_id'] + + # 获取对应的文章对象(404 安全获取) article = get_object_or_404(Article, pk=article_id) + # 检查文章是否允许评论 + # 如果文章的评论状态为 'c'(关闭)或文章状态为 'c'(草稿等),则禁止评论 if article.comment_status == 'c' or article.status == 'c': + # 抛出验证错误,阻止评论提交 raise ValidationError("该文章评论已关闭.") + + # 调用表单的 save 方法,但暂不提交到数据库(commit=False) comment = form.save(False) + + # 设置评论所属的文章 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 + # 检查表单中是否包含父评论 ID(即是否为回复) if form.cleaned_data['parent_comment_id']: + # 根据父评论 ID 获取父评论对象 parent_comment = Comment.objects.get( pk=form.cleaned_data['parent_comment_id']) + # 建立父子评论关系 comment.parent_comment = parent_comment + # 将评论对象保存到数据库(此时所有字段已设置完毕) comment.save(True) + + # 重定向到文章详情页,并定位到刚刚发表的评论 + # 使用锚点 #div-comment-{id} 定位到具体评论 return HttpResponseRedirect( "%s#div-comment-%d" % - (article.get_absolute_url(), comment.pk)) + (article.get_absolute_url(), comment.pk)) \ No newline at end of file