Compare commits

..

No commits in common. 'gst_branch' and 'master' have entirely different histories.

@ -16,7 +16,7 @@
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src/DjangoBlog-master/DjangoBlog-master" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src/DjangoBlog-master/DjangoBlog-master" isTestSource="false" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Django" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="PyDocumentationSettings"> <component name="PyDocumentationSettings">

@ -1,7 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="Black"> <component name="ProjectRootManager" version="2" project-jdk-name="Django" project-jdk-type="Python SDK" />
<option name="sdkName" value="Python 3.12 (DjangoBlog)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (DjangoBlog)" project-jdk-type="Python SDK" />
</project> </project>

@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/DjangoBlog-gst_branch.iml" filepath="$PROJECT_DIR$/.idea/DjangoBlog-gst_branch.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/DjangoBlog.iml" filepath="$PROJECT_DIR$/.idea/DjangoBlog.iml" />
</modules> </modules>
</component> </component>
</project> </project>

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>
</project> </project>

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="357be86a-1284-4635-a32e-bf0ce39e4b81" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="PUSH_TAGS">
<GitPushTagMode>
<option name="argument" value="--follow-tags" />
<option name="title" value="Current Branch" />
</GitPushTagMode>
</option>
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="master" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="33xRndo8NxWInlIDa0Lp1mySOhn" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.OpenDjangoStructureViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "master",
"ignore.virus.scanning.warn.message": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<configuration name="zyl_django" type="Python.DjangoServer" factoryName="Django server">
<module name="zyl_django" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="launchJavascriptDebuger" value="false" />
<option name="port" value="8000" />
<option name="host" value="" />
<option name="additionalOptions" value="" />
<option name="browserUrl" value="" />
<option name="runTestServer" value="false" />
<option name="runNoReload" value="false" />
<option name="useCustomRunCommand" value="false" />
<option name="customRunCommand" value="" />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-b598e85cdad2-JavaScript-PY-252.25557.178" />
<option value="bundled-python-sdk-ce6832f46686-7b97d883f26b-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.25557.178" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="357be86a-1284-4635-a32e-bf0ce39e4b81" name="更改" comment="" />
<created>1760256904468</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1760256904468</updated>
<workItem from="1760256905584" duration="153000" />
<workItem from="1760257111491" duration="1866000" />
<workItem from="1763889357146" duration="268000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

Binary file not shown.

Binary file not shown.

@ -0,0 +1,155 @@
import math
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
# 功能登录验证码与锁定校验行14-55
class LoginForm(AuthenticationForm):
captcha = forms.CharField(
label=_("Captcha"),
widget=forms.TextInput(
attrs={'placeholder': _("Captcha"), "class": "form-control"}
),
max_length=8,
required=True,
)
error_messages = {
**AuthenticationForm.error_messages,
'invalid_captcha': _("Captcha error"),
'locked': _("Too many failed attempts. Please try again in %(minutes)d minutes."),
}
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
self.fields['captcha'].widget = widgets.TextInput(
attrs={'placeholder': _("Captcha"), "class": "form-control"})
def clean_captcha(self):
value = self.cleaned_data.get('captcha')
if not utils.validate_login_captcha(self.request, value):
raise ValidationError(self.error_messages['invalid_captcha'], code='invalid_captcha')
return value
def clean(self):
username = self.cleaned_data.get('username')
identifier = utils.get_login_identifier(username, self.request)
is_locked, remaining = utils.is_login_locked(identifier)
if is_locked:
minutes = max(1, math.ceil(remaining / 60))
raise ValidationError(
self.error_messages['locked'] % {'minutes': minutes},
code='locked'
)
return super().clean()
class RegisterForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
model = get_user_model()
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
}
),
)
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
}
),
)
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2
def clean_email(self):
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
email = forms.EmailField(
label=_('Email'),
)

@ -0,0 +1,30 @@
from django.urls import path
from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
# 功能登录验证码接口路由行14
path('login/captcha/', views.LoginCaptchaView.as_view(), name='login_captcha'),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]

@ -0,0 +1,160 @@
import io
import random
import string
import typing
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from ipware import get_client_ip
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from djangoblog.utils import send_email
# 功能登录安全缓存键配置行19-22
_code_ttl = timedelta(minutes=5)
_LOGIN_FAIL_PREFIX = 'login:fail:'
_LOGIN_LOCK_PREFIX = 'login:lock:'
LOGIN_CAPTCHA_SESSION_KEY = 'account_login_captcha'
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效
Args:
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
cache_code = get_code(email)
if cache_code != code:
return gettext("Verification code error")
def set_code(email: str, code: str):
"""设置code"""
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
return cache.get(email)
# 功能登录失败统计与锁定逻辑行65-122
def _build_identifier(username: typing.Optional[str], request: typing.Optional[HttpRequest]) -> typing.Optional[str]:
if username:
return username.strip().lower()
if request is None:
return None
client_ip, _ = get_client_ip(request)
return client_ip or request.META.get('REMOTE_ADDR')
def get_login_identifier(username: typing.Optional[str], request: typing.Optional[HttpRequest]) -> typing.Optional[str]:
"""选择合适的登录标识优先用户名其次IP"""
return _build_identifier(username, request)
def _login_fail_key(identifier: str) -> str:
return f'{_LOGIN_FAIL_PREFIX}{identifier}'
def _login_lock_key(identifier: str) -> str:
return f'{_LOGIN_LOCK_PREFIX}{identifier}'
def increase_login_failure(identifier: typing.Optional[str]) -> int:
"""记录失败次数并返回当前次数"""
if not identifier:
return 0
key = _login_fail_key(identifier)
count = cache.get(key, 0) + 1
cache.set(key, count, getattr(settings, 'LOGIN_FAIL_WINDOW', 15 * 60))
if count >= getattr(settings, 'LOGIN_MAX_ATTEMPTS', 5):
lock_login(identifier)
return count
def reset_login_failure(identifier: typing.Optional[str]):
if not identifier:
return
cache.delete(_login_fail_key(identifier))
cache.delete(_login_lock_key(identifier))
def lock_login(identifier: str):
lock_seconds = getattr(settings, 'LOGIN_LOCK_SECONDS', 30 * 60)
lock_until = timezone.now() + timedelta(seconds=lock_seconds)
cache.set(_login_lock_key(identifier), lock_until, lock_seconds)
def is_login_locked(identifier: typing.Optional[str]) -> typing.Tuple[bool, int]:
if not identifier:
return False, 0
lock_until = cache.get(_login_lock_key(identifier))
if not lock_until:
return False, 0
now = timezone.now()
if lock_until <= now:
cache.delete(_login_lock_key(identifier))
return False, 0
return True, int((lock_until - now).total_seconds())
# 功能登录验证码生成与校验行126-158
def store_login_captcha(request: HttpRequest, text: str):
request.session[LOGIN_CAPTCHA_SESSION_KEY] = text
def validate_login_captcha(request: HttpRequest, value: str) -> bool:
cached = request.session.get(LOGIN_CAPTCHA_SESSION_KEY)
if not cached or not value:
return False
if cached.lower() != value.strip().lower():
return False
request.session.pop(LOGIN_CAPTCHA_SESSION_KEY, None)
return True
def refresh_login_captcha():
"""生成验证码文本和图像字节"""
text = ''.join(random.choices(string.ascii_uppercase + string.digits, k=getattr(settings, 'LOGIN_CAPTCHA_LENGTH', 4)))
width, height = 120, 40
image = Image.new('RGB', (width, height), (255, 255, 255))
font = ImageFont.load_default()
draw = ImageDraw.Draw(image)
for _ in range(150):
draw.point((random.randint(0, width), random.randint(0, height)), fill=_random_color())
for i, char in enumerate(text):
position = (10 + i * 20, random.randint(0, 10))
draw.text(position, char, fill=_random_color(), font=font)
image = image.filter(ImageFilter.SMOOTH)
buffer = io.BytesIO()
image.save(buffer, 'PNG')
buffer.seek(0)
return text, buffer.getvalue()
def _random_color():
return tuple(random.randint(0, 200) for _ in range(3))

@ -0,0 +1,232 @@
import logging
import math
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout
from django.contrib.auth.hashers import make_password
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
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)
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)
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 = """
<p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
再次感谢您
<br />
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
send_email(
emailto=[
user.email,
],
title='验证您的电子邮箱',
content=content)
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
url = '/login/'
@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()
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)
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 = '/'
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
# 功能:为登录表单传递 request 对象行119-121
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
# 功能登录成功后重置失败计数行125-134
def form_valid(self, form):
delete_sidebar_cache()
logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user())
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
identifier = utils.get_login_identifier(
form.cleaned_data.get('username'), self.request)
utils.reset_login_failure(identifier)
return super(LoginView, self).form_valid(form)
# 功能登录失败计数与锁定提示行137-149
def form_invalid(self, form):
username = self.request.POST.get('username')
identifier = utils.get_login_identifier(username, self.request)
if identifier and not form.has_error(None, 'locked') and 'captcha' not in form.errors:
utils.increase_login_failure(identifier)
locked, remaining = utils.is_login_locked(identifier)
if locked and not form.has_error(None, 'locked'):
minutes = max(1, math.ceil(remaining / 60))
form.add_error(
None,
_('Too many failed attempts. Please try again in %(minutes)d minutes.') % {'minutes': minutes}
)
return super().form_invalid(form)
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
return redirect_to
def account_result(request):
type = request.GET.get('type')
id = request.GET.get('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
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
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()
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save()
return HttpResponseRedirect('/login/')
else:
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("ok")
# 功能登录验证码图片接口行224-232
class LoginCaptchaView(View):
def get(self, request: HttpRequest):
text, image = utils.refresh_login_captcha()
utils.store_login_captcha(request, text)
response = HttpResponse(image, content_type='image/png')
response['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response['Pragma'] = 'no-cache'
return response

@ -0,0 +1,118 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import Article
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
'id',
'title',
'author',
'link_to_category',
'creation_time',
'views',
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
filter_horizontal = ('tags',)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# 新增第67-71行添加JavaScript文件支持标签推荐功能
class Media:
js = (
'blog/js/tag_recommender.js',
)
def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('category')
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
if obj:
url = obj.get_full_url()
return url
else:
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
pass

@ -0,0 +1,183 @@
# 新增第1-118行标签推荐工具模块
"""
基于历史标签和文章内容自动生成推荐标签的功能
"""
import re
import logging
from collections import Counter
from typing import List, Dict, Tuple
from django.db.models import Count, Q
from django.utils.html import strip_tags
from .models import Article, Tag, Category
logger = logging.getLogger(__name__)
# 新增第15-20行停用词列表常见无意义词汇
STOP_WORDS = {
'', '', '', '', '', '', '', '', '', '', '', '', '一个', '', '', '', '', '', '', '', '', '', '', '没有', '', '', '自己', '',
'', '', '', '', '', '', '', '', '这样', '那样', '什么', '怎么', '如何', '可以', '应该', '可能', '如果', '因为', '所以', '但是', '然而', '不过',
'而且', '或者', '以及', '还有', '另外', '此外', '同时', '然后', '接着', '最后', '首先', '其次', '总之', '因此', '所以', '总之', '例如', '比如', '比如', '等等',
'这个', '那个', '这些', '那些', '这样', '那样', '这里', '那里', '这里', '那里', '这样', '那样', '这样', '那样'
}
def extract_keywords_from_text(text: str, max_keywords: int = 10) -> List[str]:
"""
新增第25-58从文本中提取关键词
使用简单的分词和词频统计方法
"""
if not text:
return []
# 移除HTML标签和Markdown标记
text = strip_tags(text)
text = re.sub(r'[#*`\[\](){}]', ' ', text) # 移除Markdown标记
text = re.sub(r'!\[.*?\]\(.*?\)', '', text) # 移除图片标记
text = re.sub(r'\[.*?\]\(.*?\)', '', text) # 移除链接标记
# 提取中文词汇2-6个字符
chinese_words = re.findall(r'[\u4e00-\u9fa5]{2,6}', text)
# 提取英文单词3个字符以上
english_words = re.findall(r'[a-zA-Z]{3,}', text)
# 合并所有词汇
all_words = chinese_words + [w.lower() for w in english_words]
# 过滤停用词
filtered_words = [w for w in all_words if w not in STOP_WORDS and len(w) >= 2]
# 统计词频
word_freq = Counter(filtered_words)
# 返回频率最高的关键词
keywords = [word for word, count in word_freq.most_common(max_keywords)]
return keywords
def get_tags_from_category(category: Category, limit: int = 10) -> List[Tuple[str, int]]:
"""
新增第61-75获取同一分类下最常用的标签
返回标签名和文章数量的元组列表
"""
if not category:
return []
# 获取该分类下所有已发布文章使用的标签
tags = Tag.objects.filter(
article__category=category,
article__status='p'
).annotate(
article_count=Count('article')
).order_by('-article_count')[:limit]
return [(tag.name, tag.article_count) for tag in tags]
def get_tags_from_author(author, limit: int = 10) -> List[Tuple[str, int]]:
"""
新增第78-92获取同一作者最常用的标签
返回标签名和文章数量的元组列表
"""
if not author:
return []
# 获取该作者所有已发布文章使用的标签
tags = Tag.objects.filter(
article__author=author,
article__status='p'
).annotate(
article_count=Count('article')
).order_by('-article_count')[:limit]
return [(tag.name, tag.article_count) for tag in tags]
def recommend_tags_by_content(title: str, body: str, category: Category = None,
author=None, max_recommendations: int = 10) -> List[Dict]:
"""
新增第95-118基于文章内容推荐标签
结合标题正文关键词分类历史标签和作者历史标签
返回推荐标签列表每个标签包含名称匹配度和来源
"""
recommendations = {}
# 1. 从标题和正文提取关键词
title_keywords = extract_keywords_from_text(title, max_keywords=5)
body_keywords = extract_keywords_from_text(body, max_keywords=10)
# 合并关键词,标题权重更高
all_keywords = title_keywords * 2 + body_keywords
# 2. 查找匹配的现有标签
for keyword in all_keywords:
if len(keyword) < 2:
continue
# 模糊匹配标签名
matching_tags = Tag.objects.filter(
Q(name__icontains=keyword) | Q(name=keyword)
)
for tag in matching_tags:
if tag.name not in recommendations:
# 计算匹配度:标题中的关键词权重更高
score = 2.0 if keyword in title_keywords else 1.0
recommendations[tag.name] = {
'name': tag.name,
'score': score,
'source': 'content',
'article_count': tag.get_article_count()
}
else:
# 如果已存在,增加匹配度
if keyword in title_keywords:
recommendations[tag.name]['score'] += 2.0
else:
recommendations[tag.name]['score'] += 1.0
# 3. 添加分类历史标签推荐
if category:
category_tags = get_tags_from_category(category, limit=5)
for tag_name, article_count in category_tags:
if tag_name not in recommendations:
recommendations[tag_name] = {
'name': tag_name,
'score': article_count * 0.1, # 根据使用频率给分
'source': 'category',
'article_count': article_count
}
else:
recommendations[tag_name]['score'] += article_count * 0.1
if recommendations[tag_name]['source'] == 'content':
recommendations[tag_name]['source'] = 'content+category'
# 4. 添加作者历史标签推荐
if author:
author_tags = get_tags_from_author(author, limit=5)
for tag_name, article_count in author_tags:
if tag_name not in recommendations:
recommendations[tag_name] = {
'name': tag_name,
'score': article_count * 0.05, # 作者标签权重较低
'source': 'author',
'article_count': article_count
}
else:
recommendations[tag_name]['score'] += article_count * 0.05
if 'author' not in recommendations[tag_name]['source']:
recommendations[tag_name]['source'] += '+author'
# 5. 按匹配度排序并返回
sorted_recommendations = sorted(
recommendations.values(),
key=lambda x: x['score'],
reverse=True
)[:max_recommendations]
return sorted_recommendations

@ -0,0 +1,72 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
app_name = "blog"
urlpatterns = [
path(
r'',
views.IndexView.as_view(),
name='index'),
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
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'),
# 新增第63-66行标签推荐API路由
path(
r'recommend-tags',
views.recommend_tags_view,
name='recommend_tags'),
# 功能文章AI问答接口路由行68-71
path(
r'article/<int:article_id>/ai-answer/',
views.article_ai_answer_view,
name='article_ai_answer'),
]

@ -0,0 +1,528 @@
import json
import logging
import os
import uuid
import openai
from openai.error import OpenAIError
from django.conf import settings
from django.core.paginator import Paginator
# 新增第7行导入JsonResponse用于API响应
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.html import strip_tags
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
# 新增第23-24行导入标签推荐模块
from .tag_recommender import recommend_tags_by_content
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'
link_type = LinkShowType.L
def get_view_cache_key(self):
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
return page
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
article_list = self.get_queryset_data()
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:
'''
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):
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"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
article_comments = self.object.comment_list()
# 新增第122-123行获取排序方式默认为按时间倒序
sort_by = self.request.GET.get('comment_sort', 'time_desc')
parent_comments = article_comments.filter(parent_comment=None)
# 新增第126-134行根据排序方式排序支持按时间、点赞数排序
if sort_by == 'time_asc':
parent_comments = parent_comments.order_by('creation_time')
elif sort_by == 'like_desc':
parent_comments = parent_comments.order_by('-like_count', '-creation_time')
elif sort_by == 'like_asc':
parent_comments = parent_comments.order_by('like_count', 'creation_time')
else: # time_desc (默认)
parent_comments = parent_comments.order_by('-creation_time')
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:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
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
# 新增第152-161行构建URL时保留排序参数
base_url = self.object.get_absolute_url()
sort_param = f'&comment_sort={sort_by}' if sort_by != 'time_desc' else ''
if next_page:
kwargs[
'comment_next_page_url'] = base_url + f'?comment_page={next_page}{sort_param}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = base_url + f'?comment_page={prev_page}{sort_param}#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
# 新增第167行传递排序参数到模板
kwargs['comment_sort'] = sort_by
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
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):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
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
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
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
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
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):
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
context = {
"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())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
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')
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)
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)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
# 新增第404-467行标签推荐API视图
@csrf_exempt
def recommend_tags_view(request):
"""
基于文章标题正文分类和作者历史标签推荐标签
接受POST请求参数
- title: 文章标题
- body: 文章正文
- category_id: 分类ID可选
- author_id: 作者ID可选
- max_recommendations: 最大推荐数量可选默认10
"""
if request.method != 'POST':
return JsonResponse({'error': 'Only POST method allowed'}, status=405)
try:
title = request.POST.get('title', '')
body = request.POST.get('body', '')
category_id = request.POST.get('category_id')
author_id = request.POST.get('author_id')
max_recommendations = int(request.POST.get('max_recommendations', 10))
# 验证必需参数
if not title and not body:
return JsonResponse({'error': 'Title or body is required'}, status=400)
# 获取分类和作者对象
category = None
if category_id:
try:
category = Category.objects.get(pk=category_id)
except Category.DoesNotExist:
pass
author = None
if author_id:
try:
from accounts.models import BlogUser
author = BlogUser.objects.get(pk=author_id)
except BlogUser.DoesNotExist:
pass
# 获取推荐标签
recommendations = recommend_tags_by_content(
title=title,
body=body,
category=category,
author=author,
max_recommendations=max_recommendations
)
return JsonResponse({
'success': True,
'recommendations': recommendations,
'count': len(recommendations)
})
except Exception as e:
logger.error(f"Error in recommend_tags_view: {str(e)}", exc_info=True)
return JsonResponse({
'error': 'Internal server error',
'message': str(e)
}, status=500)
# 功能文章AI问答接口行469-526
@require_POST
def article_ai_answer_view(request, article_id):
if not settings.OPENAI_API_KEY:
return JsonResponse({'error': _('AI assistant is not configured yet.')}, status=503)
try:
payload = json.loads(request.body.decode('utf-8'))
except (ValueError, json.JSONDecodeError):
payload = {}
question = (payload.get('question') or '').strip()
if not question:
return JsonResponse({'error': _('Please enter a question.')}, status=400)
if len(question) > 500:
return JsonResponse({'error': _('Question is too long, please be concise.')}, status=400)
article = get_object_or_404(Article, pk=article_id, status='p')
content_parts = [
_('Title: %(title)s') % {'title': article.title},
strip_tags(getattr(article, 'summary', '') or '')[:800],
strip_tags(article.body or '')[:4000],
]
context_text = '\n\n'.join(filter(None, content_parts))
openai.api_key = settings.OPENAI_API_KEY
messages = [
{
'role': 'system',
'content': _('You are an AI assistant embedded in a blog article page. Answer questions strictly based on '
'the provided article content. If the answer cannot be found in the article, clearly say that '
'you do not know. Provide concise answers in the language of the question.')
},
{
'role': 'user',
'content': _('Article Content:\n%(content)s\n\nQuestion: %(question)s') % {
'content': context_text,
'question': question
}
}
]
try:
response = openai.ChatCompletion.create(
model=getattr(settings, 'OPENAI_CHAT_MODEL', 'gpt-3.5-turbo'),
messages=messages,
max_tokens=getattr(settings, 'OPENAI_MAX_TOKENS', 512),
temperature=0.3,
timeout=getattr(settings, 'OPENAI_TIMEOUT', 30),
)
answer = response['choices'][0]['message']['content'].strip()
return JsonResponse({'answer': answer})
except OpenAIError as exc:
logger.error('article_ai_answer_view error: %s', exc, exc_info=True)
return JsonResponse({'error': _('AI service is unavailable, please try again later.')}, status=502)

@ -0,0 +1,71 @@
# 评论系统优化 - 数据库迁移说明
## 新增功能
本次更新为评论系统添加了以下功能:
1. **楼中楼回复** - 已支持(原有功能)
2. **点赞功能** - 新增
3. **举报功能** - 新增
4. **表情包支持** - 新增
5. **评论排序** - 新增(按时间、点赞数排序)
## 数据库迁移步骤
在应用这些更改之前,您需要运行数据库迁移:
```bash
# 1. 创建迁移文件
python manage.py makemigrations comments
# 2. 应用迁移
python manage.py migrate comments
```
## 新增的模型
1. **CommentLike** - 存储用户对评论的点赞记录
2. **CommentReport** - 存储用户对评论的举报记录
## 更新的模型
**Comment** 模型新增字段:
- `like_count` - 点赞数默认0
- `report_count` - 举报数默认0
## 新增的URL路由
- `/comments/like/<comment_id>/` - 点赞/取消点赞
- `/comments/report/<comment_id>/` - 举报评论
- `/comments/check-like/<comment_id>/` - 检查点赞状态
## 注意事项
1. 迁移后,所有现有评论的 `like_count``report_count` 将默认为 0
2. 如果之前有用户点赞数据,需要手动迁移或重新计算
3. 确保静态文件已正确收集:`python manage.py collectstatic`
## 功能说明
### 点赞功能
- 用户可以对评论进行点赞/取消点赞
- 点赞数实时更新
- 未登录用户无法点赞
### 举报功能
- 用户可以对不当评论进行举报
- 支持多种举报原因(垃圾信息、辱骂、不当内容、其他)
- 每个用户对每条评论只能举报一次
- 管理员可在后台查看和处理举报
### 表情包功能
- 评论表单中可选择表情包
- 选择的表情会自动添加到评论内容中
- 支持100+种常用表情
### 排序功能
- 支持按时间排序(最新/最早)
- 支持按点赞数排序(最热)
- 排序选项显示在评论列表顶部

@ -0,0 +1,92 @@
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 _
# 新增第6-7行导入点赞和举报模型
from .models import Comment, CommentLike, CommentReport
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
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',
# 新增第29-31行在管理界面显示点赞数和举报数
'like_count',
'report_count',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
# 新增第35-36行添加创建时间过滤器
list_filter = ('is_enable', 'creation_time')
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
# 新增第39-40行添加搜索字段
search_fields = ('body', 'author__username', 'author__email')
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'<a href="%s">%s</a>' %
(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(
u'<a href="%s">%s</a>' % (link, obj.article.title))
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
# 新增第59-65行点赞记录管理类
class CommentLikeAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = ('id', 'user', 'comment', 'created_time')
list_filter = ('created_time',)
search_fields = ('user__username', 'comment__body')
readonly_fields = ('created_time',)
# 新增第68-86行举报记录管理类
class CommentReportAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = ('id', 'user', 'comment', 'reason', 'is_handled', 'created_time')
list_filter = ('is_handled', 'reason', 'created_time')
search_fields = ('user__username', 'comment__body', 'description')
readonly_fields = ('created_time',)
# 新增第75-76行添加批量处理举报的操作
actions = ['mark_as_handled', 'mark_as_unhandled']
# 新增第78-81行标记为已处理
def mark_as_handled(self, request, queryset):
queryset.update(is_handled=True)
mark_as_handled.short_description = _('Mark as handled')
# 新增第83-86行标记为未处理
def mark_as_unhandled(self, request, queryset):
queryset.update(is_handled=False)
mark_as_unhandled.short_description = _('Mark as unhandled')
admin.site.register(Comment, CommentAdmin)
# 新增第90-92行注册点赞和举报模型到管理界面
admin.site.register(CommentLike, CommentLikeAdmin)
admin.site.register(CommentReport, CommentReportAdmin)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommentsConfig(AppConfig):
name = 'comments'

@ -0,0 +1,228 @@
from django import forms
from django.forms import ModelForm
from .models import Comment
# 新增第7-193行常用表情包列表
EMOJI_LIST = [
('😀', '😀'),
('😃', '😃'),
('😄', '😄'),
('😁', '😁'),
('😆', '😆'),
('😅', '😅'),
('🤣', '🤣'),
('😂', '😂'),
('🙂', '🙂'),
('🙃', '🙃'),
('😉', '😉'),
('😊', '😊'),
('😇', '😇'),
('🥰', '🥰'),
('😍', '😍'),
('🤩', '🤩'),
('😘', '😘'),
('😗', '😗'),
('😚', '😚'),
('😙', '😙'),
('😋', '😋'),
('😛', '😛'),
('😜', '😜'),
('🤪', '🤪'),
('😝', '😝'),
('🤑', '🤑'),
('🤗', '🤗'),
('🤭', '🤭'),
('🤫', '🤫'),
('🤔', '🤔'),
('🤐', '🤐'),
('🤨', '🤨'),
('😐', '😐'),
('😑', '😑'),
('😶', '😶'),
('😏', '😏'),
('😒', '😒'),
('🙄', '🙄'),
('😬', '😬'),
('🤥', '🤥'),
('😌', '😌'),
('😔', '😔'),
('😪', '😪'),
('🤤', '🤤'),
('😴', '😴'),
('😷', '😷'),
('🤒', '🤒'),
('🤕', '🤕'),
('🤢', '🤢'),
('🤮', '🤮'),
('🤧', '🤧'),
('🥵', '🥵'),
('🥶', '🥶'),
('😶‍🌫️', '😶‍🌫️'),
('😵', '😵'),
('😵‍💫', '😵‍💫'),
('🤯', '🤯'),
('🤠', '🤠'),
('🥳', '🥳'),
('🥸', '🥸'),
('😎', '😎'),
('🤓', '🤓'),
('🧐', '🧐'),
('😕', '😕'),
('😟', '😟'),
('🙁', '🙁'),
('☹️', '☹️'),
('😮', '😮'),
('😯', '😯'),
('😲', '😲'),
('😳', '😳'),
('🥺', '🥺'),
('😦', '😦'),
('😧', '😧'),
('😨', '😨'),
('😰', '😰'),
('😥', '😥'),
('😢', '😢'),
('😭', '😭'),
('😱', '😱'),
('😖', '😖'),
('😣', '😣'),
('😞', '😞'),
('😓', '😓'),
('😩', '😩'),
('😫', '😫'),
('🥱', '🥱'),
('😤', '😤'),
('😡', '😡'),
('😠', '😠'),
('🤬', '🤬'),
('😈', '😈'),
('👿', '👿'),
('💀', '💀'),
('☠️', '☠️'),
('💩', '💩'),
('🤡', '🤡'),
('👹', '👹'),
('👺', '👺'),
('👻', '👻'),
('👽', '👽'),
('👾', '👾'),
('🤖', '🤖'),
('😺', '😺'),
('😸', '😸'),
('😹', '😹'),
('😻', '😻'),
('😼', '😼'),
('😽', '😽'),
('🙀', '🙀'),
('😿', '😿'),
('😾', '😾'),
('👍', '👍'),
('👎', '👎'),
('👌', '👌'),
('✌️', '✌️'),
('🤞', '🤞'),
('🤟', '🤟'),
('🤘', '🤘'),
('🤙', '🤙'),
('👈', '👈'),
('👉', '👉'),
('👆', '👆'),
('🖕', '🖕'),
('👇', '👇'),
('☝️', '☝️'),
('👏', '👏'),
('🙌', '🙌'),
('👐', '👐'),
('🤲', '🤲'),
('🤝', '🤝'),
('🙏', '🙏'),
('✍️', '✍️'),
('💪', '💪'),
('🦾', '🦾'),
('🦿', '🦿'),
('🦵', '🦵'),
('🦶', '🦶'),
('👂', '👂'),
('🦻', '🦻'),
('👃', '👃'),
('🧠', '🧠'),
('🦷', '🦷'),
('🦴', '🦴'),
('👀', '👀'),
('👁️', '👁️'),
('👅', '👅'),
('👄', '👄'),
('💋', '💋'),
('🩸', '🩸'),
('💌', '💌'),
('💘', '💘'),
('💝', '💝'),
('💖', '💖'),
('💗', '💗'),
('💓', '💓'),
('💞', '💞'),
('💕', '💕'),
('💟', '💟'),
('❣️', '❣️'),
('💔', '💔'),
('❤️', '❤️'),
('🧡', '🧡'),
('💛', '💛'),
('💚', '💚'),
('💙', '💙'),
('💜', '💜'),
('🖤', '🖤'),
('🤍', '🤍'),
('🤎', '🤎'),
('💯', '💯'),
('💢', '💢'),
('💥', '💥'),
('💫', '💫'),
('💦', '💦'),
('💨', '💨'),
('🕳️', '🕳️'),
('💣', '💣'),
('💬', '💬'),
('👁️‍🗨️', '👁️‍🗨️'),
('🗨️', '🗨️'),
('🗯️', '🗯️'),
('💭', '💭'),
('💤', '💤'),
]
class CommentForm(ModelForm):
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
# 新增第200-205行表情包选择字段
emoji = forms.ChoiceField(
choices=[('', '选择表情')] + EMOJI_LIST,
required=False,
widget=forms.Select(attrs={'class': 'comment-emoji-select'})
)
class Meta:
model = Comment
fields = ['body']
# 新增第210-217行自定义评论输入框样式
widgets = {
'body': forms.Textarea(attrs={
'class': 'comment-body-input',
'rows': 5,
'placeholder': '请输入评论内容...'
})
}
# 新增第219-228行清理评论内容将选择的表情添加到评论中
def clean_body(self):
body = self.cleaned_data.get('body')
emoji = self.cleaned_data.get('emoji', '')
# 如果选择了表情,将其添加到评论内容中
if emoji:
body = body + ' ' + emoji if body else emoji
return body

@ -0,0 +1,38 @@
# 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):
initial = True
dependencies = [
('blog', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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='是否显示')),
('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': '评论',
'ordering': ['-id'],
'get_latest_by': 'id',
},
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]

@ -0,0 +1,60 @@
# 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 = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_alter_article_options_alter_category_options_and_more'),
('comments', '0002_alter_comment_is_enable'),
]
operations = [
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
migrations.RemoveField(
model_name='comment',
name='created_time',
),
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
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'),
),
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, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]

@ -0,0 +1,102 @@
# Generated by Django 5.2.4 on 2025-11-23 23:26
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
('comments', '0003_alter_comment_options_remove_comment_created_time_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CommentLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')),
],
options={
'verbose_name': 'comment like',
'verbose_name_plural': 'comment like',
},
),
migrations.CreateModel(
name='CommentReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.CharField(choices=[('spam', 'Spam'), ('abuse', 'Abuse'), ('inappropriate', 'Inappropriate Content'), ('other', 'Other')], default='other', max_length=20, verbose_name='reason')),
('description', models.TextField(blank=True, max_length=500, verbose_name='description')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')),
('is_handled', models.BooleanField(default=False, verbose_name='is handled')),
],
options={
'verbose_name': 'comment report',
'verbose_name_plural': 'comment report',
},
),
migrations.AddField(
model_name='comment',
name='like_count',
field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='like count'),
),
migrations.AddField(
model_name='comment',
name='report_count',
field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='report count'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['-creation_time'], name='comments_co_creatio_444c12_idx'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['-like_count'], name='comments_co_like_co_572784_idx'),
),
migrations.AddField(
model_name='commentlike',
name='comment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='comments.comment', verbose_name='comment'),
),
migrations.AddField(
model_name='commentlike',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='commentreport',
name='comment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='comments.comment', verbose_name='comment'),
),
migrations.AddField(
model_name='commentreport',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddIndex(
model_name='commentlike',
index=models.Index(fields=['comment', 'user'], name='comments_co_comment_e08f6a_idx'),
),
migrations.AlterUniqueTogether(
name='commentlike',
unique_together={('user', 'comment')},
),
migrations.AddIndex(
model_name='commentreport',
index=models.Index(fields=['comment', 'user'], name='comments_co_comment_22fd70_idx'),
),
migrations.AddIndex(
model_name='commentreport',
index=models.Index(fields=['is_handled'], name='comments_co_is_hand_8df8dc_idx'),
),
migrations.AlterUniqueTogether(
name='commentreport',
unique_together={('user', 'comment')},
),
]

@ -0,0 +1,127 @@
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
# 新增第5-6行导入验证器用于点赞数和举报数字段
from django.core.validators import MinValueValidator
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)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
# 新增第33-34行点赞数字段
like_count = models.PositiveIntegerField(_('like count'), default=0, validators=[MinValueValidator(0)])
# 新增第35-36行举报数字段
report_count = models.PositiveIntegerField(_('report count'), default=0, validators=[MinValueValidator(0)])
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
# 新增第43-47行添加索引以优化排序查询性能
indexes = [
models.Index(fields=['-creation_time']),
models.Index(fields=['-like_count']),
]
def __str__(self):
return self.body
# 新增第52-55行获取点赞数的方法
def get_like_count(self):
"""获取点赞数"""
return self.like_count
# 新增第57-60行获取回复数的方法
def get_reply_count(self):
"""获取回复数"""
return Comment.objects.filter(parent_comment=self, is_enable=True).count()
# 新增第63-86行评论点赞记录模型
class CommentLike(models.Model):
"""评论点赞记录"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'),
on_delete=models.CASCADE)
comment = models.ForeignKey(
Comment,
verbose_name=_('comment'),
on_delete=models.CASCADE,
related_name='likes')
created_time = models.DateTimeField(_('created time'), default=now)
class Meta:
verbose_name = _('comment like')
verbose_name_plural = verbose_name
unique_together = [['user', 'comment']]
indexes = [
models.Index(fields=['comment', 'user']),
]
def __str__(self):
return f"{self.user.username} liked comment {self.comment.id}"
# 新增第89-127行评论举报记录模型
class CommentReport(models.Model):
"""评论举报记录"""
REPORT_REASONS = (
('spam', _('Spam')),
('abuse', _('Abuse')),
('inappropriate', _('Inappropriate Content')),
('other', _('Other')),
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'),
on_delete=models.CASCADE)
comment = models.ForeignKey(
Comment,
verbose_name=_('comment'),
on_delete=models.CASCADE,
related_name='reports')
reason = models.CharField(
_('reason'),
max_length=20,
choices=REPORT_REASONS,
default='other')
description = models.TextField(_('description'), max_length=500, blank=True)
created_time = models.DateTimeField(_('created time'), default=now)
is_handled = models.BooleanField(_('is handled'), default=False)
class Meta:
verbose_name = _('comment report')
verbose_name_plural = verbose_name
unique_together = [['user', 'comment']]
indexes = [
models.Index(fields=['comment', 'user']),
models.Index(fields=['is_handled']),
]
def __str__(self):
return f"Report on comment {self.comment.id} by {self.user.username}"

@ -0,0 +1,30 @@
from django import template
register = template.Library()
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
datas = []
def parse(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):
"""评论"""
depth = 1 if ischild else 2
return {
'comment_item': comment,
'depth': depth
}

@ -0,0 +1,109 @@
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
# Create your tests here.
class CommentsTest(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):
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()
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
self.assertEqual(response.status_code, 302)
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_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)
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
from comments.utils import send_comment_email
send_comment_email(comment)

@ -0,0 +1,26 @@
from django.urls import path
from . import views
app_name = "comments"
urlpatterns = [
path(
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
# 新增第11-15行点赞/取消点赞评论的URL路由
path(
'like/<int:comment_id>/',
views.comment_like,
name='comment_like'),
# 新增第16-20行举报评论的URL路由
path(
'report/<int:comment_id>/',
views.comment_report,
name='comment_report'),
# 新增第21-25行检查点赞状态的URL路由
path(
'check-like/<int:comment_id>/',
views.check_comment_like_status,
name='check_comment_like_status'),
]

@ -0,0 +1,38 @@
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')
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,158 @@
# Create your views here.
# 新增第2-3行导入JSON处理模块
import json
# 新增第4-5行导入登录验证装饰器
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
# 新增第7-8行导入JsonResponse用于AJAX响应
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
# 新增第12-13行导入HTTP方法限制装饰器
from django.views.decorators.http import require_http_methods
from django.views.generic.edit import FormView
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
# 新增第19-20行导入点赞和举报模型
from .models import Comment, CommentLike, CommentReport
class CommentPostView(FormView):
form_class = CommentForm
template_name = 'blog/article_detail.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
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)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
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))
# 新增第74-100行点赞/取消点赞评论的视图函数
@login_required
@require_http_methods(["POST"])
def comment_like(request, comment_id):
"""点赞/取消点赞评论"""
comment = get_object_or_404(Comment, pk=comment_id, is_enable=True)
user = request.user
try:
like = CommentLike.objects.get(user=user, comment=comment)
# 取消点赞
like.delete()
comment.like_count = max(0, comment.like_count - 1)
comment.save(update_fields=['like_count'])
is_liked = False
except CommentLike.DoesNotExist:
# 点赞
CommentLike.objects.create(user=user, comment=comment)
comment.like_count += 1
comment.save(update_fields=['like_count'])
is_liked = True
return JsonResponse({
'success': True,
'is_liked': is_liked,
'like_count': comment.like_count
})
# 新增第103-141行举报评论的视图函数
@login_required
@require_http_methods(["POST"])
def comment_report(request, comment_id):
"""举报评论"""
comment = get_object_or_404(Comment, pk=comment_id, is_enable=True)
user = request.user
# 检查是否已经举报过
if CommentReport.objects.filter(user=user, comment=comment).exists():
return JsonResponse({
'success': False,
'message': '您已经举报过这条评论了'
}, status=400)
try:
data = json.loads(request.body)
reason = data.get('reason', 'other')
description = data.get('description', '')
CommentReport.objects.create(
user=user,
comment=comment,
reason=reason,
description=description
)
comment.report_count += 1
comment.save(update_fields=['report_count'])
return JsonResponse({
'success': True,
'message': '举报成功,我们会尽快处理'
})
except Exception as e:
return JsonResponse({
'success': False,
'message': '举报失败,请稍后重试'
}, status=500)
# 新增第144-158行检查用户是否已点赞评论的视图函数
@require_http_methods(["GET"])
def check_comment_like_status(request, comment_id):
"""检查用户是否已点赞评论"""
if not request.user.is_authenticated:
return JsonResponse({
'is_liked': False
})
comment = get_object_or_404(Comment, pk=comment_id)
is_liked = CommentLike.objects.filter(user=request.user, comment=comment).exists()
return JsonResponse({
'is_liked': is_liked
})

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save