这篇博客主要完成一个BBS+Blog项目,那么主要是模仿博客园的博客思路,使用Django框架进行练习。
准备:项目需求分析
在做一个项目的时候,我们首先做的就是谈清楚项目需求,功能需求,然后才开始写,要是没有和产品经理聊清楚需求,到时候改的话就非常非常麻烦。
那此次写项目的话,我会严格按着此次写的项目流程完成项目。那下面就是此次的项目流程。
1,项目流程
1.1,功能需求分析(和产品经理聊清楚需求)
1,基于用户认证组件和AJAX实现登录验证(图片验证码)
2,基于AJAX 和Forms组件实现注册功能
3,设计系统首页(完成文章列表的渲染)
4,设计个人站点页面
5,文章详情页面
6,实现一个点赞的功能
7,实现文章的评论功能
——对文章的评论
——对评论的评论(就是子评论,反驳评论的评论)
8,后台管理页面(后面新增文章的功能)——富文本编辑框
9,防止XSS攻击框
1.2,设计表结构
1.3,按着每一个功能进行开发
1.4,功能测试阶段
1.5,项目部署上线(开发人员最难熬的阶段)
2,开发功能的主要设计思路
那么下面我们要开发这个网站,而我此次是严格按照经典的软件开发所遵循的MVC设计模型。(如果不懂软件设计的MVC模式,请参考这篇博客:请点击我,后面有MVC的介绍。
下面写的内容呢,就是我在review整个BBS+Blog项目,其实整体学完,我在这里梳理一遍,做个笔记,那么下面我的记录笔记肯定是按照Django网站开发的四件套Model(模型),URL(链接),View(视图)和Template(模板)完成的。其实这四个就对应着经典的MVC。分别是:
- Django Model(模型):这个与经典MVC模式下的Model差不多。
- Django URL+View(视图):这个合起来就与经典MVC下的Controller更像。原因就在于Django的URL和View合起来才能向Template传递正确的数据。用户输入提供的数据也需要Django的View来处理。
- Django Template(模板):这个与经典MVC模式下的View一致。Django模板用来呈现Django View 传来的数据,也决定了用户界面的外观。Template里面也包含了表单,可以用来收集用户的输入。
一,Django model(模型) == Model(MVC)
1,创建项目,迁移表
1.1,创建Django项目,然后建立url路径
1.2,在mysql建数据库,然后在settings中配置
import pymysql pymysql.install_as_MySQLdb() DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME':'blog', # 要连接的数据库,连接前需要创建好 'USER':'root', # 连接数据库的用户名 'PASSWORD':'', # 连接数据库的密码 'HOST':'127.0.0.1', # 连接主机,默认本级 'PORT':3306 # 端口 默认3306 } }
1.3,设置时区和语言
Django默认使用美国时间和英语,在项目的settings文件中,如下图所示:
LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True 我们将其改为 亚洲/上海 时间和中文 LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_I18N = True USE_L10N = True USE_TZ = False
1.4,创建模型
这里模型表设计多表操作,不懂的可以先学习这篇博客:Django学习笔记(7):单表操作和多表操作。
1.4.1,设计表结构
分析表结构
跨表查询效率非常低。不建议使用。
所以为了保证查询的效率,经常会牺牲增删改的效率。
1.4.2,完成表内容
继承AbstractUser,对比继承user。
每个人的个人站点,可以添加个人标签,和随笔分类:
一个人可以创建多个分类,一个人可以拥有多个分类,人user和分类时一对多的关系
分类和站点的关系:一个站点blog有多个分类category,一个分类只能属于一个站点,所以站点和分类是一对多。
站点blog 和人user是一对一的关系。(跨表查询的问题)
一个博客存的最核心的数据就是文章,所以展示文章表:
1.5,迁移表
python manage.py makemigrations python manage.py migrate
2,Django URL+View == Controller(MVC)
2.1 url的设计
由于博客系统只有一个APP,所以我们这里不做分发路由。直接在根URL里面写即可。
from django.contrib import admin from django.urls import path, re_path from blog import views from cnblog_review import settings from django.views.static import serve urlpatterns = [ path('admin/', admin.site.urls), path('login/', views.login), path('get_validCode_image/', views.get_validCode_image), re_path(r'^$', views.index), path('register/', views.register), path('logout/', views.logout), # 点赞 path('digg/', views.digg), # 评论 path('comment/', views.comment), # 树形评论 path('get_comment_tree/', views.get_comment_tree), # media配置 re_path(r'media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), re_path(r'^(?P \w+)/articles/(?P \d+)$', views.article_detail), # 后台管理url re_path(r'cn_backend/$', views.cn_backend), re_path(r'cn_backend/add_article/$', views.add_article), # 关于个人站点的URL re_path(r'^(?P \w+)/$', views.home_site), # 关于个人站点的跳转 re_path(r'^(?P \w+)/(?P tag|category|archive)/(?P.*/$)', views.home_site), ]
2.2 登录页面的设计
在登录页面设计之前,我们可以参考我这两篇博客:
Django学习笔记(9)——开发用户注册与登录系统
Django学习笔记(16)——扩展Django自带User模型,实现用户注册与登录
下面我就不多解释,直接完成登录页面。代码如下:
views.py
def login(request): ''' 登录视图函数: get请求响应页面 post(Ajax)请求响应字典 :param request: :return: ''' if request.method == 'POST': response = {'user': None, 'msg': None} user = request.POST.get('user') pwd = request.POST.get('pwd') valid_code = request.POST.get('valid_code') valid_code_str = request.session.get('valid_code_str') if valid_code.upper() == valid_code_str.upper(): user = auth.authenticate(username=user, password=pwd) if user: # request.user == 当前登录对象 auth.login(request, user) response['user'] = user.username else: response['msg'] = '用户名或者密码错误!' else: # 校验失败了 response['msg'] = 'valid code error!' return JsonResponse(response) return render(request, 'login.html')
login.html
Title 登录页面
结果展示:
2.3 基于forms组件的注册页面设计
在注册页面设计之前,我们要学习验证码的代码。
参考这篇博客:Django学习笔记(17)——验证码功能的实现
2.3.1 注册页面总体代码展示
views.py
def get_validCode_image(request): """ 基于PIL模块动态生成响应状态码图片 :param request: :return: """ data = get_valid_code_img(request) return HttpResponse(data) def index(request): """ 系统首页 :param request: :return: """ article_list = models.Article.objects.all() return render(request, 'index.html', locals()) def logout(request): """ 注销视图 :param request: :return: """ auth.logout(request) # 等同于执行了 request.session.fulsh() return redirect('/login/') def register(request): """ 注册视图函数: get请求响应注册页面 post(Ajax)请求,校验字段,响应字典 :param request: :return: """ if request.is_ajax(): print(request.POST) form = UserForm(request.POST) response = {'user': None, 'msg': None} if form.is_valid(): response['user'] = form.cleaned_data.get('user') # 生成一条用户记录 user = form.cleaned_data.get('user') pwd = form.cleaned_data.get('pwd') email = form.cleaned_data.get('email') avatar_obj = request.FILES.get('avatar') extra = {} if avatar_obj: extra['avatar'] = avatar_obj # 要是逻辑没有用到值,我们可以不用赋值,等用到的时候,则添加 UserInfo.objects.create_user(username=user, password=pwd, email=email, avatar=avatar_obj, **extra) else: print(form.cleaned_data) print(form.errors) response['msg'] = form.errors return JsonResponse(response) form = UserForm() return render(request, 'register.html', locals())
register.html
Title 注册页面
index.html
Title Panel heading without titlePanel contentPanel heading without titlePanel contentPanel heading without titlePanel content{% for article in article_list %}{{ article.title }}
{{ article.user.username }} 发布于 {{ article.create_time|date:'Y-m-d:H:i' }} 评论({{ article.comment_count }}) 点赞({{ article.up_count }})
{% endfor %}Panel heading without titlePanel contentPanel heading without titlePanel content
结果展示:
2.3.2 头像的设置
点击头像===点击input(这里使用label标签属性方法)
首先,我们下载一个默认头像:
注册头像的预览方法
1,获取用户选中的问卷对象
2,获取文件对象的路径
3,修改img的src,src=文件路径对象
取用户的标签,基于AJAX提交formdata数据
// 基于AJAX 提交数据 $(".reg_btn").click(function () { var formdata = new FormData(); formdata.append('user', $("#id_user").val()); formdata.append('pwd', $("#id_pwd").val()); formdata.append('re_pwd', $("#id_re_pwd").val()); formdata.append('email', $("#id_email").val()); formdata.append('avatar', $("#avatar")[0].files[0]); formdata.append('csrfmiddlewaretoken', $('[name= "csrfmiddlewaretoken"]').val()); $.ajax({ url:"", type:"post", data: formdata, processData:false, contentType:false, success:function (data) { console.log(data) } }) })
效果:
2.3.3 AJAX在注册页面显示错误信息
views: form.errors
Ajax.success方法 data.msg 就是上面的errors
Title 注册页面
结果展示:
当我们用户输入后,需要清空用户名的错误信息。
2.4 使用Admin 去录入数据
(关于Django admin的详细内容,我们后面补充)
这里我们基于admin 去录入文章数据。
为了让admin界面管理我们的数据模型,我们需要先注册数据模型到admin。所以我们去 admin.py 中注册模型,代码如下:
from django.contrib import admin # Register your models here. from blog import models admin.site.register(models.UserInfo) admin.site.register(models.Blog) admin.site.register(models.Category) admin.site.register(models.Tag) admin.site.register(models.Article) admin.site.register(models.ArticleUpDown) admin.site.register(models.Article2Tag) admin.site.register(models.Comment)
注册后,我们需要通过下面命令来创建超级用户:
python manage.py createsuperuser
然后登陆Django的后台(http://127.0.0.1:8000/admin/),我们输入超级用户,进去如下:
然后我们就可以录入数据了。
2.5 个人站点页面的设计
1.我的标签,随机分类,标签列表 随机分类: /username/category/ 我的标签: /username/tag/ 随笔归档: /username/archive/ 2.模板继承 {% extends 'base.html' %} {% block content %} {% endblock content%}} 3.自定义标签 /blog/templatetags/my_tag.py @register.inclusion_tag('classification.html') def get_classification_style(username): ... return {} # 去渲染 menu.html 4.分组查询 .annotate() / extra()应用 多表分组 tag_list = Tag.objects.filter(blog=blog).annotate( count = Count('article')).values_list('title', 'count') 单表分组 / DATE_FORMAT() / extra() date_list = Article.objects.filter(user=user).extra( select={"create_ym": "DATE_FORMAT(create_time,'%%Y-%%m')"}).values('create_ym').annotate( c = Count('nid')).values_list('create_ym', 'c') 5. 时间、区域配置 TIME_ZONE = 'Asia/Shanghai' USE_TZ = False
2.5.1 个人站点设计的总体代码展示
views.py
def home_site(request, username, **kwargs): ''' 个人站点视图函数 :param request: :return: ''' print("执行的是home_site的内容") print('username:', username) user = UserInfo.objects.filter(username=username).first() # 判断用户是否存在 if not user: return render(request, 'not_found.html') # 当用户存在的话 当前用户或者当前站点对应所有文章取出来 # 1, 查询当前站点 blog = user.blog # kwargs是为了区分访问的是站点页面还是站点下的跳转页面 article_list = models.Article.objects.filter(user=user) if kwargs: condition = kwargs.get('condition') param = kwargs.get('param') print(condition) print(param) if condition == 'category': print(1) article_list = article_list.filter(category__title=param) elif condition == 'tag': print(2) article_list = article_list.filter(tags__title=param) else: print(3) year, month, day = param.split("-") article_list = article_list.filter(create_time__year=year, create_time__month=month) return render(request, 'home_site.html', {'username': username, 'blog': blog, 'article_list': article_list, })
home_site.html
{% extends 'base.html' %} {% block content %}{% for article in article_list %}{% endblock %}{{ article.title }}
{{ article.desc }}发布于 {{ article.create_time|date:"Y-m-d H:i" }} 评论({{ article.comment_count }}) 点赞({{ article.up_count }})
{% endfor %}
结果展示:
2.5.2 个人站点页面的文章查询
当博客园用户站点不存在的时候,我们发现,会返回一个下面页面:
当然,我们也可以做与上面一样的页面,其代码入下:
not_found.html
Error_404_资源不存在
2.5.3 个人站点页面的日期查询
如何只拿出来 年和月?
2.5.4 Extra函数的学习
Django对一些复杂的函数不能一一对应,所以提供了一种extra函数。
2.5.5 跳转过滤功能的实现
views.py (home_site函数)
article_list = models.Article.objects.filter(user=user) if kwargs: condition = kwargs.get('condition') param = kwargs.get('param') print(condition) print(param) if condition == 'category': print(1) article_list = article_list.filter(category__title=param) elif condition == 'tag': print(2) article_list = article_list.filter(tags__title=param) else: print(3) year, month, day = param.split("-") article_list = article_list.filter(create_time__year=year, create_time__month=month)
home_site.html
我的标签{% for tag in tag_list %} {% endfor %}随笔分类{% for cate in cate_list %} {% endfor %}随笔归档{% for data in data_list %} {% endfor %}
2.6 文章详细页的设计
1.文章详情页的设计 2.文章详情页的数据构建 3.文章详情页点赞样式的完成(基本仿照博客园) 4.文章评论样式的添加(基本仿照博客园) 5.文章评论树的添加(支持对对评论的评论) 6.文章评论中邮件发送
2.6.1 总体的代码及其样式展示
views.py
def get_classification_data(username): user = UserInfo.objects.filter(username=username).first() blog = user.blog cate_list = models.Category.objects.filter(blog=blog).values('pk').annotate(c=Count("article__title")).values_list( "title", "c") tag_list = models.Tag.objects.filter(blog=blog).values("pk").annotate(c=Count("article")).values_list("title", 'c') data_list = models.Article.objects.filter(user=user).extra( select={"y_m_date": "date_format(create_time, '%%Y-%%m-%%d')"}).values( 'y_m_date').annotate(c=Count('nid')).values_list('y_m_date', 'c') return {"blog": blog, 'cate_list': cate_list, 'tag_list': tag_list, 'data_list': data_list} def article_detail(request, username, article_id): print("执行的是article_detail的内容") user = UserInfo.objects.filter(username=username).first() blog = user.blog article_obj = models.Article.objects.filter(pk=article_id).first() comment_list = models.Comment.objects.filter(article_id=article_id) return render(request, 'article_detail.html', locals())
article_detail.html
{% extends "base.html" %} {% block content %} {% csrf_token %}{% endblock %}{{ article_obj.title }}
{{ article_obj.content|safe }}{{ article_obj.up_count }}{{ article_obj.down_count }}
结果展示:
2.6.2 点赞,评论,评论树及其发送邮件
根评论:对文章的评论
子评论:对评论的评论
区别:是否有父评论
评论: 1.构建样式
2.提交根评论
3.显示跟评论——render显示 ——AJAX显示
4.提交子评论
5.显示子评论——render显示 ——AJAX显示
6.评论树的显示
其代码展示
def digg(request): """ 点赞功能 :param request: :return: """ print(request.POST) article_id = request.POST.get("article_id") is_up = json.loads(request.POST.get("is_up")) # "true" # 点赞人即当前登录人 user_id = request.user.pk obj = models.ArticleUpDown.objects.filter(user_id=user_id, article_id=article_id).first() response = {"state": True} if not obj: ard = models.ArticleUpDown.objects.create(user_id=user_id, article_id=article_id, is_up=is_up) queryset = models.Article.objects.filter(pk=article_id) if is_up: queryset.update(up_count=F("up_count") + 1) else: queryset.update(down_count=F("down_count") + 1) else: response["state"] = False response["handled"] = obj.is_up return JsonResponse(response) def comment(request): """ 提交评论视图函数 功能: 1 保存评论 2 创建事务 3 发送邮件 :param request: :return: """ print(request.POST) article_id = request.POST.get("article_id") pid = request.POST.get("pid") content = request.POST.get("content") user_id = request.user.pk article_obj = models.Article.objects.filter(pk=article_id).first() # 事务操作 with transaction.atomic(): comment_obj = models.Comment.objects.create(user_id=user_id, article_id=article_id, content=content, parent_comment_id=pid) models.Article.objects.filter(pk=article_id).update(comment_count=F("comment_count") + 1) response = {} response["create_time"] = comment_obj.create_time.strftime("%Y-%m-%d %X") response["username"] = request.user.username response["content"] = content # 发送邮件 from django.core.mail import send_mail from cnblog import settings # send_mail( # "您的文章%s新增了一条评论内容"%article_obj.title, # content, # settings.EMAIL_HOST_USER, # ["[email protected]"] # ) ... import threading t = threading.Thread(target=send_mail, args=("您的文章%s新增了一条评论内容" % article_obj.title, content, settings.EMAIL_HOST_USER, ["[email protected]"]) ) t.start() ... return JsonResponse(response) def get_comment_tree(request): article_id = request.GET.get("article_id") response = list(models.Comment.objects.filter(article_id=article_id).order_by("pk").values("pk", "content", "parent_comment_id")) return JsonResponse(response, safe=False)
点赞的jQuery代码展示:
$("#div_digg .action").click(function () { var is_up = $(this).hasClass("diggit"); $obj = $(this).children('span'); $.ajax({ url: '/digg/', type: 'post', data: { "csrfmiddlewaretoken": $("[name='csrfmiddlewaretoken']").val(), "is_up": is_up, "article_id": "{{ article_obj.pk }}", }, success:function (data) { //alert(is_up); console.log(data); if (data.state){ var val = parseInt($obj.text()); $obj.text(val+1); {#if (is_up){#} {# var val=parseInt($("#digg_count").text());#} {# $("#digg_count").text(val+1);#} //} {#else{#} {# var val=parseInt($("#bury_count").text());#} {# $("#bury_count").text(val+1);#} //} }else { var val = data.handled?"您已经推荐过!":"您已经反对过!"; $("#digg_tips").html(val); {#if (data.handled){#} {# $("#digg_tips").html("您已经推荐过!")#} //}else { {# $("#digg_tips").html("您已经反对过!")#} //} setTimeout(function () { $("#digg_tips").html() }, 2000) } } }) });
结果展示
2.7 后台管理页面设计
1.支持文章编辑 2.支持富文本编辑器(支持渲染已有文章,并支持文本编辑器的上传功能) 3.支持删除文章(未添加,很简单,可自行添加) 4.防止Xss攻击(基于BS4)
views.py
@login_required def cn_backend(request): article_list = models.Article.objects.filter(user=request.user) return render(request, 'backend/backend.html', locals()) from bs4 import BeautifulSoup @login_required def add_article(request): if request.method == 'POST': title = request.POST.get("title") content = request.POST.get("content") # 防止XSS攻击,过滤script soup = BeautifulSoup(content, "html.parser") for tag in soup.find_all(): print(tag.name) if tag.name == 'script': tag.decompose() # 构建摘要数据,获取标签字符串的文本前150个符号 desc = soup.text[0:150] + "..." models.Article.objects.create(title=title, desc=desc, content=str(soup), user=request.user) return redirect('/cn_backend/') return render(request, "backend/add_article.html") def upload(request): ''' 编辑器上传文件接收视图函数 :param request: :return: ''' print(request.FILES) img_obj = request.FILES.get('upload_img') print(img_obj.name) path = os.path.join(settings.MEDIA_ROOT, 'add_article_img', img_obj.name) with open(path, 'wb') as f: for line in img_obj: f.write(line) response = { 'error': 0, 'url': '/media/add_article_img/%s' % img_obj.name } import json return HttpResponse(json.dumps(response))
add_articles.html
{% extends 'backend/base.html' %} {% block content %}{% endblock %}
结果展示:
添加文章
3,Django Template == View(MVC)
Django的模板与经典MVC模式下的View一致。Django模板用来呈现Django View传来的数据,也决定了用户界面的外观。Template里面也包含了表单,可以用来收集用户的输入。
那这部分内容,我决定单独写一篇博客记录内容。请参考:
4,代码优化
4.1 优化思路
1,导入包的时候,需要先导入python标准库的包,再导入第三方插件的包,最后导入我们自己定义的包。
2,冗余代码可以优化的话,自己优化
3,开发中所有的 print得去掉,我们测试的时候可以加。
# if avatar_obj: # user_obj = UserInfo.objects.create_user(username=user, password=pwd, email=email, avatar=avatar_obj) # else: # user_obj = UserInfo.objects.create_user(username=user, password=pwd, email=email) # 代码优化 extra = {} if avatar_obj: extra['avatar'] = avatar_obj # 要是逻辑没有用到值,我们可以不用赋值,等用到的时候,则添加 UserInfo.objects.create_user(username=user, password=pwd, email=email, avatar=avatar_obj, **extra)
4.2 注意问题
注意问题1:由于我们使用django自带的AbstractUser扩展之后,需要进行更改AUTH_USER_MODEL的配置。
我们在settings.py中设置:
AUTH_USER_MODEL = 'blog.UserInfo'
AUTH_USER_MODEL是等于APP blog下面的UserInfo表。因为UserInfo表集成的是自带的AbstractUser表。
然后进行迁移。
注意问题2:对于最新版的Django2.0,在使用一对一(OneToOneField)和外键(ForeignKey)时,需要加上on_delete 参数,不然就会报错。
on_delete=models.CASCADE, # 删除关联数据,与之关联也删除
如果直接执行上述代码,遇到的报错如下:
TypeError: __init__() missing 1 required positional argument: 'on_delete'
因为 on_delete 在最新版的Django中已经是位置参数了。
4.3 使用inclution_tag 优化代码
base.html
my_tags.py
from django import template from blog import models from django.db.models import Count register = template.Library() @register.simple_tag def multi_tag(x, y): return x*y @register.inclusion_tag('classification.html') def get_classification_style(username): user = models.UserInfo.objects.filter(username=username).first() blog = user.blog cate_list = models.Category.objects.filter(blog=blog).values('pk').annotate(c=Count("article__title")).values_list( "title", "c") tag_list = models.Tag.objects.filter(blog=blog).values("pk").annotate(c=Count("article")).values_list("title", 'c') data_list = models.Article.objects.filter(user=user).extra( select={"y_m_date": "date_format(create_time, '%%Y-%%m-%%d')"}).values( 'y_m_date').annotate(c=Count('nid')).values_list('y_m_date', 'c') return {"blog": blog, 'cate_list': cate_list, 'tag_list': tag_list, 'data_list': data_list}
4.4 头像设置的代码优化
取用户的标签,基于AJAX提交formdata数据
// 基于AJAX 提交数据 $(".reg_btn").click(function () { var formdata = new FormData(); formdata.append('user', $("#id_user").val()); formdata.append('pwd', $("#id_pwd").val()); formdata.append('re_pwd', $("#id_re_pwd").val()); formdata.append('email', $("#id_email").val()); formdata.append('avatar', $("#avatar")[0].files[0]); formdata.append('csrfmiddlewaretoken', $('[name= "csrfmiddlewaretoken"]').val()); $.ajax({ url:"", type:"post", data: formdata, processData:false, contentType:false, success:function (data) { console.log(data) } }) })
代码优化:
// 基于AJAX 提交数据 $(".reg_btn").click(function () { console.log($("#form").serializeArray()); var request_data = $("#form").serializeArray(); $.each(request_data, function (index, data) { formdata.append(data.name, data.value) }); formdata.append('avatar', $("#avatar")[0].files[0]); $.ajax({ url:"", type:"post", data: formdata, processData:false, contentType:false, success:function (data) { console.log(data) } }) })
4.5 admin的代码优化
我们查看之前写的admin.py的代码:
from django.contrib import admin # Register your models here. from blog import models admin.site.register(models.UserInfo) admin.site.register(models.Blog) admin.site.register(models.Category) admin.site.register(models.Tag) admin.site.register(models.Article) admin.site.register(models.ArticleUpDown) admin.site.register(models.Article2Tag) admin.site.register(models.Comment)
那如果要是存在100个表,我们需要写100个注册吗?当然不可能,为了简化代码,我们这样写。
首先在model.py中,把所有的表名称写入一个列表中,如下:
from django.db import models # Create your models here. __all__ = ['UserInfo', 'Blog', 'Category', 'Tag', 'Article', 'Article2Tag', 'ArticleUpDown', 'Comment']
然后我们在admin.py中使用一个for循环进行注册。代码如下:
from django.contrib import admin # Register your models here. from blog import models for table in models.__all__: admin.site.register(getattr(models, table))
这样我们就可以节省代码。
评论树
评论列表
{% for comment in comment_list %}-
# {{ forloop.counter }}楼
{{ comment.create_time|date:"Y-m-d H:i" }}
{{ comment.user.username }}
回复
{% if comment.parent_comment_id %}
{% endif %}
{% endfor %}
{{ comment.parent_comment.user.username }}: {{ comment.parent_comment.content }}
{{ comment.content }}
发表评论
昵称:
评论内容: