Compare commits

...

2 Commits

Author SHA1 Message Date
py3rvj9lb 90b7783a2b 第7-8周代码注释
3 months ago
py3rvj9lb 5d48b79aca add new file
4 months ago

@ -0,0 +1,69 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# 批量禁用评论(将 is_enable 设为 False
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
# 批量启用评论(将 is_enable 设为 True
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
# 定义动作名称,在 Django Admin 批量操作菜单中显示的文字
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
# 每页显示评论数量
list_per_page = 20
# 在评论列表中显示哪些字段
list_display = (
'id',
'body', # 评论正文
'link_to_userinfo', # 用户信息(带链接)
'link_to_article', # 所属文章(带链接)
'is_enable', # 是否启用
'creation_time' # 创建时间
)
# 可以点击哪些字段进入编辑页面
list_display_links = ('id', 'body', 'is_enable')
# 过滤器(后台右侧筛选功能)
list_filter = ('is_enable',)
# 排除不需要在后台编辑的字段(自动时间字段不应手动修改)
exclude = ('creation_time', 'last_modify_time')
# 批量操作按钮
actions = [disable_commentstatus, enable_commentstatus]
# 显示用户信息,并可点击跳转到用户编辑页面
def link_to_userinfo(self, obj):
# 获取目标 admin change 页面的 URL 路径
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 显示用户昵称,若无昵称显示 email
return format_html(
'<a href="{}">{}</a>'.format(
link,
obj.author.nickname if obj.author.nickname else obj.author.email
)
)
# 显示所属文章,并可点击跳转到文章编辑页面
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html('<a href="{}">{}</a>'.format(link, obj.article.title))
# 设置在后台列表中显示的列标题
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -0,0 +1,10 @@
from django.apps import AppConfig
class CommentsConfig(AppConfig):
# 指定该 App 在项目中的名称(即所在目录名)
name = 'comments'
# (可选)可以在这里做初始化操作,如引入 signals
# def ready(self):
# import comments.signals

@ -0,0 +1,21 @@
from django import forms
from django.forms import ModelForm
from .models import Comment
class CommentForm(ModelForm):
# 用于存储父评论的 ID实现评论回复功能
# 该字段不会显示到页面中HiddenInput允许为空一级评论时为空
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput,
required=False
)
class Meta:
# 指定该表单操作的模型为 Comment
model = Comment
# 只允许用户输入评论内容body
# 其他字段(如 author、article、parent_comment将在视图中自动赋值
fields = ['body']

@ -0,0 +1,65 @@
# 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
import django.utils.timezone
class Migration(migrations.Migration):
# 表示这是该 app 的第一个迁移文件
initial = True
dependencies = [
# 依赖 blog 应用的第一条迁移文件,确保 Article 模型已经被创建
('blog', '0001_initial'),
# 依赖 Django 的用户模型(可自定义 AUTH_USER_MODEL
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# 创建 Comment 模型
migrations.CreateModel(
name='Comment',
fields=[
# 主键 id自动递增
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 评论内容,最大长度 300 字
('body', models.TextField(max_length=300, verbose_name='正文')),
# 评论创建时间,默认取当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 评论最后一次修改时间,默认也为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 评论是否显示,后台可控制隐藏
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 评论所属文章,一个评论只能属于一篇文章
# CASCADE 表示当文章删除时,该评论也会被删除
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
# 评论作者,关联用户模型
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# 父评论,用于实现评论/回复功能
# 允许为空(空代表这是一级评论)
# 父评论删除时,子评论也删除
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
options={
# 后台显示名
'verbose_name': '评论',
'verbose_name_plural': '评论',
# 查询时默认按 id 倒序排列(新的评论排最前)
'ordering': ['-id'],
# get_latest_by 用于 Django 的 latest() 方法
'get_latest_by': 'id',
},
),
]

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
# 当前迁移文件依赖于 comments 应用的 0001 初始迁移文件
dependencies = [
('comments', '0001_initial'),
]
operations = [
# 修改 Comment 模型中 is_enable 字段的属性
migrations.AlterField(
model_name='comment', # 要修改的模型名称
name='is_enable', # 要修改的字段名
field=models.BooleanField(
default=False, # 将默认值改为 False即默认评论不显示
verbose_name='是否显示' # 后台显示名称
),
),
]

@ -0,0 +1,111 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
# 迁移依赖顺序,确保其他相关模型先完成迁移
dependencies = [
# 依赖用户模型(可自定义 AUTH_USER_MODEL
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖 blog 应用的某次迁移文件
('blog', '0005_alter_article_options_alter_category_options_and_more'),
# 依赖 comments 应用之前的迁移调整(包括 is_enable 字段的变更)
('comments', '0002_alter_comment_is_enable'),
]
operations = [
# 修改模型的元选项Meta 类中的配置)
migrations.AlterModelOptions(
name='comment',
options={
'get_latest_by': 'id', # 使用 id 作为 latest() 的默认排序依据
'ordering': ['-id'], # 查询结果默认按 id 降序排列(新评论在前)
'verbose_name': 'comment', # 后台显示名称(单数)
'verbose_name_plural': 'comment', # 后台显示名称(复数)
},
),
# 删除原来用于记录创建时间的字段 created_time
migrations.RemoveField(
model_name='comment',
name='created_time',
),
# 删除原来用于记录修改时间的字段 last_mod_time
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
# 新增评论创建时间字段(命名和 verbose_name 英文化)
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name='creation time',
),
),
# 新增评论最后修改时间字段(命名和 verbose_name 英文化)
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name='last modify time',
),
),
# 修改外键 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',
),
),
# 修改外键 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',
),
),
# 修改评论是否显示字段,将 verbose_name 英文化,默认不显示
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(
default=False,
verbose_name='enable', # 从中文变为英文显示
),
),
# 修改父评论字段(用于实现评论回复结构)
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(
blank=True, # 表单中允许为空
null=True, # 数据库中允许为 null
on_delete=django.db.models.deletion.CASCADE, # 父评论删除时子评论也删除
to='comments.comment',
verbose_name='parent comment',
),
),
]

@ -0,0 +1,71 @@
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article
class Comment(models.Model):
# 评论正文,限制最大输入长度 300使用 TextField 以便输入多行内容
body = models.TextField('正文', max_length=300)
# 评论创建时间,默认使用 timezone.now可自动获取当前时区时间
creation_time = models.DateTimeField(
_('creation time'),
default=now
)
# 评论最后修改时间,通常用于编辑评论功能,但如果不编辑也会保持不变
last_modify_time = models.DateTimeField(
_('last modify time'),
default=now
)
# 评论作者,关联到用户模型,用户删除时,对应评论也一并删除
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE
)
# 评论所属文章,关联到 Article 模型,文章被删除时,其下所有评论也被删除
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE
)
# 父评论,用于构建"评论回复"树结构
# 若为空 → 表示为一级评论;不为空 → 表示为某条评论的子评论
parent_comment = models.ForeignKey(
'self', # 自关联
verbose_name=_('parent comment'),
blank=True, # 表单中允许为空
null=True, # 数据库允许为 null
on_delete=models.CASCADE # 父评论删除时,子评论也被删除
)
# 是否启用评论(常用于需要审核评论是否展示)
# 默认为 False → 新评论不会立刻显示,需要管理员审核启用
is_enable = models.BooleanField(
_('enable'),
default=False,
blank=False,
null=False
)
class Meta:
# 默认按 id 倒序排列 → 新评论显示在前
ordering = ['-id']
# Django Admin 后台显示的模型名称
verbose_name = _('comment')
verbose_name_plural = verbose_name
# latest() 方法依据 id 获取最新对象
get_latest_by = 'id'
def __str__(self):
# 后台及 shell 打印对象时显示评论内容
return self.body

@ -0,0 +1,55 @@
from django import template
# 注册一个自定义标签库
register = template.Library()
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""
获取指定评论的所有子评论包括多级递归子评论
用法示例在模板中:
{% parse_commenttree article_comments comment as childcomments %}
参数解释
commentlist所有评论的查询集合一般是 article.comments.all()
comment当前评论对象
返回值
datas按层级顺序递归展开的所有子评论列表
"""
datas = [] # 用于存储递归解析得到的子评论
def parse(c):
# 找到当前评论 c 的直接子评论(过滤掉未启用的)
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):
"""
渲染评论项组件
用法示例在模板中
{% show_comment_item comment True %}
参数
comment需要渲染的评论对象
ischild是否为子评论用于模板样式控制如缩进/层级
depth 解释
depth = 1 子评论缩进更深
depth = 2 顶级评论缩进较浅
"""
depth = 1 if ischild else 2
return {
'comment_item': comment, # 提供给模板的评论对象
'depth': depth # 让模板根据层级调整样式
}

@ -0,0 +1,136 @@
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
class CommentsTest(TransactionTestCase):
"""
评论相关功能测试类
使用 TransactionTestCase 允许测试包含事务的数据库操作
"""
def setUp(self):
"""
测试初始化工作
- 创建请求客户端
- 配置博客系统为评论需要审核
- 创建一个超级管理员用户用于登录发表评论
"""
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):
"""
将文章下所有评论改为 is_enable=True
模拟管理员审核通过评论使评论显示
"""
comments = article.comment_set.all()
for comment in comments:
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.save()
# 文章评论提交 URL
comment_url = reverse('comments:postcomment', kwargs={'article_id': article.id})
# 用户提交第一条评论
response = self.client.post(comment_url, {'body': '123ffffffffff'})
self.assertEqual(response.status_code, 302) # 正常应重定向(提交成功)
# 因为评论需要审核,未批准前评论数为 0
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
# 模拟管理员审核评论
self.update_article_comment_status(article)
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)
self.assertEqual(len(article.comment_list()), 2)
# 回复第一条评论(测试 parent_comment 功能)
parent_comment_id = article.comment_list()[0].id
response = self.client.post(comment_url, {
'body': '''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''',
'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)
self.assertEqual(len(article.comment_list()), 3)
# 测试评论树解析
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1) # 第一条评论应当有 1 个子评论
# 渲染评论组件标签是否正常返回
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
# 测试工具函数获取最大文章/评论 id
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
# 测试发送评论邮件通知(若配置邮件服务则会成功)
from comments.utils import send_comment_email
send_comment_email(comment)
send_comment_email(comment)

@ -0,0 +1,42 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.views.generic import View
from blog.models import Article
from .models import Comment
from .forms import CommentForm
class CommentPostView(LoginRequiredMixin, View):
"""
负责处理评论提交
"""
def post(self, request, article_id):
# 获取目标文章
article = get_object_or_404(Article, pk=article_id)
form = CommentForm(request.POST)
if form.is_valid():
# 获取评论内容
body = form.cleaned_data['body'].strip()
parent_id = form.cleaned_data.get('parent_comment_id')
comment = Comment()
comment.article = article
comment.author = request.user
comment.body = body
# 判断是否是子评论(回复)
if parent_id:
try:
parent_comment = Comment.objects.get(id=parent_id)
comment.parent_comment = parent_comment
except Comment.DoesNotExist:
pass
comment.save()
# 评论成功后返回文章页面
return HttpResponseRedirect(article.get_absolute_url())

@ -0,0 +1,67 @@
import logging
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
logger = logging.getLogger(__name__)
def send_comment_email(comment):
"""
当用户发表评论后给评论者发送邮件通知
如果该评论是回复别人的则同时给被回复的用户发送提醒邮件
"""
# 获取当前站点域名
site = get_current_site().domain
# 邮件主题
subject = _('Thanks for your comment')
# 构造文章访问 URL以便用户点进查看
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 给评论者自己发送的邮件内容
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
Thank you again!
<br />
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
}
# 收件人 = 评论的作者本人
tomail = comment.author.email
# 发送邮件
send_email([tomail], subject, html_content)
try:
# 如果该评论存在父评论(说明是回复行为)
if comment.parent_comment:
# 给被回复的人发送通知邮件
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
go check it out!
<br/>
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
}
# 父评论的作者邮箱
tomail = comment.parent_comment.author.email
# 向被回复者发送邮件
send_email([tomail], subject, html_content)
except Exception as e:
# 出现错误则记录日志,但不影响评论正常流程
logger.error(e)

@ -0,0 +1,85 @@
# Create your views here.
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
class CommentPostView(FormView):
"""
处理文章评论提交的视图
使用 FormView 来处理表单提交
"""
form_class = CommentForm # 使用的表单类
template_name = 'blog/article_detail.html' # 表单出错时重新渲染的模板
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 强制开启 CSRF 防护
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
"""
GET 请求时直接跳回文章详情页因为评论应为 POST 行为
"""
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
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) # 获取当前评论的用户对象
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
# 判断文章是否允许评论 (c 表示关闭评论状态)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
# 创建一个未保存的 comment 对象
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
# 处理父评论(即:回复某条评论)
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.save(True) # 保存评论
# 评论成功后跳回评论处
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))
Loading…
Cancel
Save