diff --git a/doc/20251122_203540.mp4 b/doc/20251122_203540.mp4 new file mode 100644 index 0000000..4f5488a Binary files /dev/null and b/doc/20251122_203540.mp4 differ diff --git a/doc/开源软件泛读、标注和维护报告文档.docx b/doc/开源软件泛读、标注和维护报告文档.docx new file mode 100644 index 0000000..13dbd06 Binary files /dev/null and b/doc/开源软件泛读、标注和维护报告文档.docx differ diff --git a/src/DjangoBlog/accounts/forms.py b/src/DjangoBlog/accounts/forms.py index fded37b..a54089f 100644 --- a/src/DjangoBlog/accounts/forms.py +++ b/src/DjangoBlog/accounts/forms.py @@ -1,4 +1,4 @@ -# 用户账户相关的表单定义 +# flj用户账户相关的表单定义 from django import forms from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, UserCreationForm @@ -13,10 +13,10 @@ class LoginForm(AuthenticationForm): """用户登录表单""" def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) - # 为用户名输入框添加样式和占位符 + # flj为用户名输入框添加样式和占位符 self.fields['username'].widget = widgets.TextInput( attrs={'placeholder': "username", "class": "form-control"}) - # 为密码输入框添加样式和占位符 + # flj为密码输入框添加样式和占位符 self.fields['password'].widget = widgets.PasswordInput( attrs={'placeholder': "password", "class": "form-control"}) @@ -26,7 +26,7 @@ class RegisterForm(UserCreationForm): def __init__(self, *args, **kwargs): super(RegisterForm, self).__init__(*args, **kwargs) - # 为所有输入框添加Bootstrap样式和占位符 + # flj为所有输入框添加Bootstrap样式和占位符 self.fields['username'].widget = widgets.TextInput( attrs={'placeholder': "username", "class": "form-control"}) self.fields['email'].widget = widgets.EmailInput( @@ -44,13 +44,13 @@ class RegisterForm(UserCreationForm): return email class Meta: - model = get_user_model() # 使用自定义用户模型 - fields = ("username", "email") # 只包含用户名和邮箱字段 + model = get_user_model() # flj使用自定义用户模型 + fields = ("username", "email") # flj只包含用户名和邮箱字段 class ForgetPasswordForm(forms.Form): """忘记密码重置表单""" - # 新密码字段 + # flj新密码字段 new_password1 = forms.CharField( label=_("New password"), widget=forms.PasswordInput( @@ -61,7 +61,7 @@ class ForgetPasswordForm(forms.Form): ), ) - # 确认新密码字段 + # flj确认新密码字段 new_password2 = forms.CharField( label="确认密码", widget=forms.PasswordInput( @@ -72,7 +72,7 @@ class ForgetPasswordForm(forms.Form): ), ) - # 邮箱字段 + # flj邮箱字段 email = forms.EmailField( label='邮箱', widget=forms.TextInput( @@ -83,7 +83,7 @@ class ForgetPasswordForm(forms.Form): ), ) - # 验证码字段 + # flj验证码字段 code = forms.CharField( label=_('Code'), widget=forms.TextInput( @@ -128,6 +128,7 @@ class ForgetPasswordForm(forms.Form): class ForgetPasswordCodeForm(forms.Form): """发送忘记密码验证码的表单""" + # flj邮箱字段,用于发送验证码 email = forms.EmailField( - label=_('Email'), # 邮箱字段,用于发送验证码 + label=_('Email'), # flj邮箱字段,用于发送验证码 ) diff --git a/src/DjangoBlog/accounts/migrations/0003_bloguser_avatar_bloguser_bio_bloguser_birth_date_and_more.py b/src/DjangoBlog/accounts/migrations/0003_bloguser_avatar_bloguser_bio_bloguser_birth_date_and_more.py new file mode 100644 index 0000000..a82bf33 --- /dev/null +++ b/src/DjangoBlog/accounts/migrations/0003_bloguser_avatar_bloguser_bio_bloguser_birth_date_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.2.4 on 2025-11-21 17:04 + +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 = [ + ('accounts', '0002_alter_bloguser_options_remove_bloguser_created_time_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='bloguser', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='avatars/', verbose_name='avatar'), + ), + migrations.AddField( + model_name='bloguser', + name='bio', + field=models.TextField(blank=True, max_length=500, verbose_name='biography'), + ), + migrations.AddField( + model_name='bloguser', + name='birth_date', + field=models.DateField(blank=True, null=True, verbose_name='birth date'), + ), + migrations.AddField( + model_name='bloguser', + name='location', + field=models.CharField(blank=True, max_length=100, verbose_name='location'), + ), + migrations.AddField( + model_name='bloguser', + name='website', + field=models.URLField(blank=True, verbose_name='website'), + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_type', models.CharField(choices=[('comment', 'comment notification'), ('like', 'like notification'), ('follow', 'follow notification'), ('system', 'system notification')], max_length=20, verbose_name='notification type')), + ('message', models.TextField(verbose_name='message')), + ('target_url', models.URLField(blank=True, verbose_name='target url')), + ('target_content', models.CharField(blank=True, max_length=200, verbose_name='target content')), + ('is_read', models.BooleanField(default=False, verbose_name='is read')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='recipient')), + ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_notifications', to=settings.AUTH_USER_MODEL, verbose_name='sender')), + ], + options={ + 'verbose_name': 'notification', + 'verbose_name_plural': 'notifications', + 'ordering': ['-created_time'], + }, + ), + migrations.CreateModel( + name='UserFollowing', + 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='creation time')), + ('is_confirmed', models.BooleanField(default=True, verbose_name='is confirmed')), + ('following_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL, verbose_name='followed user')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL, verbose_name='follower user')), + ], + options={ + 'verbose_name': 'user following', + 'verbose_name_plural': 'user followings', + 'ordering': ['-created_time'], + 'unique_together': {('user', 'following_user')}, + }, + ), + ] diff --git a/src/DjangoBlog/accounts/models.py b/src/DjangoBlog/accounts/models.py index acca91d..8a3a3fd 100644 --- a/src/DjangoBlog/accounts/models.py +++ b/src/DjangoBlog/accounts/models.py @@ -19,6 +19,8 @@ class BlogUser(AbstractUser): last_modify_time = models.DateTimeField(_('last modify time'), default=now) #zxm 来源 source = models.CharField(_('create source'), max_length=100, blank=True) + + #zxm 基础用户字段,移除了额外的个人资料字段 #zxm 获取用户详情页的url def get_absolute_url(self): @@ -36,9 +38,17 @@ class BlogUser(AbstractUser): url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) return url + + class Meta: ordering = ['-id'] #zxm 按ID倒序排列 verbose_name = _('user') #zxm 在管理后台显示的名称 verbose_name_plural = verbose_name #zxm 复数形式 get_latest_by = 'id' #zxm 获取最新记录的依据 + + + + + +# 已移除Notification模型,不再需要通知功能 diff --git a/src/DjangoBlog/accounts/tests.py b/src/DjangoBlog/accounts/tests.py index 6893411..d784b98 100644 --- a/src/DjangoBlog/accounts/tests.py +++ b/src/DjangoBlog/accounts/tests.py @@ -13,83 +13,110 @@ from . import utils class AccountTest(TestCase): def setUp(self): + #flj 初始化测试客户端和请求工厂 self.client = Client() self.factory = RequestFactory() + #flj 创建测试普通用户 self.blog_user = BlogUser.objects.create_user( username="test", email="admin@admin.com", password="12345678" ) + #flj 测试用新密码 self.new_test = "xxx123--=" def test_validate_account(self): + #flj 获取当前站点域名 site = get_current_site().domain + #flj 创建超级用户 user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="qwer!@#$ggg") + #flj 查询创建的超级用户 testuser = BlogUser.objects.get(username='liangliangyy1') + #flj 测试超级用户登录 loginresult = self.client.login( username='liangliangyy1', password='qwer!@#$ggg') + #flj 断言登录成功 self.assertEqual(loginresult, True) + #flj 访问管理员页面 response = self.client.get('/admin/') + #flj 断言管理员页面访问成功(状态码200) self.assertEqual(response.status_code, 200) + #flj 创建测试分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + #flj 创建测试文章 article = Article() article.title = "nicetitleaaa" article.body = "nicecontentaaa" article.author = user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' #flj 文章类型(假设'a'代表普通文章) + article.status = 'p' #flj 文章状态(假设'p'代表已发布) article.save() + #flj 访问文章的管理员编辑页面 response = self.client.get(article.get_admin_url()) + #flj 断言管理员编辑页面访问成功 self.assertEqual(response.status_code, 200) def test_validate_register(self): + #flj 断言初始状态下该邮箱的用户不存在 self.assertEquals( 0, len( BlogUser.objects.filter( email='user123@user.com'))) + #flj 发送用户注册请求 response = self.client.post(reverse('account:register'), { 'username': 'user1233', 'email': 'user123@user.com', 'password1': 'password123!q@wE#R$T', 'password2': 'password123!q@wE#R$T', }) + #flj 断言注册后该邮箱的用户已存在 self.assertEquals( 1, len( BlogUser.objects.filter( email='user123@user.com'))) + #flj 获取注册后的用户 user = BlogUser.objects.filter(email='user123@user.com')[0] + #flj 生成邮箱验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + #flj 构建邮箱验证URL path = reverse('accounts:result') url = '{path}?type=validation&id={id}&sign={sign}'.format( path=path, id=user.id, sign=sign) + #flj 访问邮箱验证URL response = self.client.get(url) + #flj 断言验证页面访问成功 self.assertEqual(response.status_code, 200) + #flj 使用注册账号登录 self.client.login(username='user1233', password='password123!q@wE#R$T') + #flj 将该用户提升为超级用户和工作人员 user = BlogUser.objects.filter(email='user123@user.com')[0] user.is_superuser = True user.is_staff = True user.save() + #flj 删除侧边栏缓存 delete_sidebar_cache() + #flj 创建测试分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + #flj 创建测试文章 article = Article() article.category = category article.title = "nicetitle333" @@ -100,52 +127,67 @@ class AccountTest(TestCase): article.status = 'p' article.save() + #flj 访问文章管理员页面 response = self.client.get(article.get_admin_url()) self.assertEqual(response.status_code, 200) + #flj 测试退出登录 response = self.client.get(reverse('account:logout')) self.assertIn(response.status_code, [301, 302, 200]) + #flj 退出后访问文章管理员页面(应重定向或拒绝访问) response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) + #flj 使用错误密码登录 response = self.client.post(reverse('account:login'), { 'username': 'user1233', 'password': 'password123' }) self.assertIn(response.status_code, [301, 302, 200]) + #flj 错误密码登录后访问文章管理员页面(应无法访问) response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) def test_verify_email_code(self): + #flj 测试邮箱 to_email = "admin@admin.com" + #flj 生成邮箱验证码 code = generate_code() + #flj 存储验证码 utils.set_code(to_email, code) + #flj 发送验证邮件 utils.send_verify_email(to_email, code) + #flj 验证正确的邮箱和验证码 err = utils.verify("admin@admin.com", code) self.assertEqual(err, None) + #flj 验证错误的邮箱和正确的验证码 err = utils.verify("admin@123.com", code) self.assertEqual(type(err), str) def test_forget_password_email_code_success(self): + #flj 发送忘记密码的验证码请求(正确邮箱) resp = self.client.post( path=reverse("account:forget_password_code"), data=dict(email="admin@admin.com") ) + #flj 断言请求成功且返回"ok" self.assertEqual(resp.status_code, 200) self.assertEqual(resp.content.decode("utf-8"), "ok") def test_forget_password_email_code_fail(self): + #flj 发送忘记密码的验证码请求(无邮箱参数) resp = self.client.post( path=reverse("account:forget_password_code"), data=dict() ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + #flj 发送忘记密码的验证码请求(无效邮箱格式) resp = self.client.post( path=reverse("account:forget_password_code"), data=dict(email="admin@com") @@ -153,21 +195,25 @@ class AccountTest(TestCase): self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") def test_forget_password_email_success(self): + #flj 生成验证码并存储 code = generate_code() utils.set_code(self.blog_user.email, code) + #flj 构造忘记密码重置数据(正确验证码和匹配密码) data = dict( new_password1=self.new_test, new_password2=self.new_test, email=self.blog_user.email, code=code, ) + #flj 发送密码重置请求 resp = self.client.post( path=reverse("account:forget_password"), data=data ) + #flj 断言重置成功(重定向状态码302) self.assertEqual(resp.status_code, 302) - # 验证用户密码是否修改成功 + #flj 验证用户密码是否修改成功 blog_user = BlogUser.objects.filter( email=self.blog_user.email, ).first() # type: BlogUser @@ -175,33 +221,38 @@ class AccountTest(TestCase): self.assertEqual(blog_user.check_password(data["new_password1"]), True) def test_forget_password_email_not_user(self): + #flj 构造忘记密码重置数据(不存在的邮箱) data = dict( new_password1=self.new_test, new_password2=self.new_test, email="123@123.com", code="123456", ) + #flj 发送密码重置请求 resp = self.client.post( path=reverse("account:forget_password"), data=data ) + #flj 断言请求失败(状态码200,未重定向) self.assertEqual(resp.status_code, 200) - def test_forget_password_email_code_error(self): + #flj 生成验证码并存储 code = generate_code() utils.set_code(self.blog_user.email, code) + #flj 构造忘记密码重置数据(错误验证码) data = dict( new_password1=self.new_test, new_password2=self.new_test, email=self.blog_user.email, code="111111", ) + #flj 发送密码重置请求 resp = self.client.post( path=reverse("account:forget_password"), data=data ) - self.assertEqual(resp.status_code, 200) - + #flj 断言请求失败(状态码200,未重定向) + self.assertEqual(resp.status_code, 200) \ No newline at end of file diff --git a/src/DjangoBlog/accounts/urls.py b/src/DjangoBlog/accounts/urls.py index 3f51980..4590962 100644 --- a/src/DjangoBlog/accounts/urls.py +++ b/src/DjangoBlog/accounts/urls.py @@ -1,4 +1,4 @@ -# 用户账户应用的URL配置文件 +# flj用户账户应用的URL配置文件 from django.urls import path from django.urls import re_path @@ -8,34 +8,47 @@ from .forms import LoginForm app_name = "accounts" # 应用命名空间 urlpatterns = [ - # 用户登录 + # flj用户登录 re_path(r'^login/$', views.LoginView.as_view(success_url='/'), name='login', kwargs={'authentication_form': LoginForm}), # 登录页面 - # 用户注册 + # flj用户注册 re_path(r'^register/$', views.RegisterView.as_view(success_url="/"), name='register'), # 注册页面 - # 用户登出 + # flj用户登出 re_path(r'^logout/$', views.LogoutView.as_view(), name='logout'), # 登出页面 - # 账户操作结果页面 + # flj账户操作结果页面 path(r'account/result.html', views.account_result, name='result'), # 注册/验证结果页面 - # 忘记密码 + # flj忘记密码 re_path(r'^forget_password/$', views.ForgetPasswordView.as_view(), name='forget_password'), # 忘记密码页面 - # 忘记密码验证码 + # flj忘记密码验证码 re_path(r'^forget_password_code/$', views.ForgetPasswordEmailCode.as_view(), name='forget_password_code'), # 发送验证码接口 + + # flj用户收藏 + path( + 'favorites/', + views.FavoriteListView.as_view(), + name='favorites'), # 我的收藏 + + # flj用户点赞列表 + re_path(r'^profile/(?P\w+)/likes/$', + views.UserLikesView.as_view(), + name='user_likes'), # 用户点赞列表 + + ] diff --git a/src/DjangoBlog/accounts/views.py b/src/DjangoBlog/accounts/views.py index 57afb09..bac2da2 100644 --- a/src/DjangoBlog/accounts/views.py +++ b/src/DjangoBlog/accounts/views.py @@ -8,7 +8,8 @@ from django.contrib.auth import get_user_model from django.contrib.auth import logout from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.hashers import make_password -from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect, HttpResponseForbidden, JsonResponse from django.http.request import HttpRequest from django.http.response import HttpResponse from django.shortcuts import get_object_or_404 @@ -20,12 +21,13 @@ 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 django.views.generic import FormView, RedirectView, ListView, DetailView 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 +from blog.models import ArticleFavorite, Article logger = logging.getLogger(__name__) @@ -124,18 +126,17 @@ class LoginView(FormView): #zxm 表单验证成功后的处理 def form_valid(self, form): - form = AuthenticationForm(data=self.request.POST, request=self.request) + # 使用已验证的表单,不再重新创建 + delete_sidebar_cache() #zxm 删除侧边栏缓存 + logger.info(self.redirect_field_name) - if form.is_valid(): - delete_sidebar_cache() #zxm 删除侧边栏缓存 - logger.info(self.redirect_field_name) + auth.login(self.request, form.get_user()) #zxm 登录用户 + if self.request.POST.get("remember"): #zxm 如果勾选记住我 + self.request.session.set_expiry(self.login_ttl) #zxm 设置会话过期时间 + return super(LoginView, self).form_valid(form) - auth.login(self.request, form.get_user()) #zxm 登录用户 - if self.request.POST.get("remember"): #zxm 如果勾选记住我 - self.request.session.set_expiry(self.login_ttl) #zxm 设置会话过期时间 - return super(LoginView, self).form_valid(form) - else: - return self.render_to_response({ + def form_invalid(self, form): + return self.render_to_response({ 'form': form }) @@ -151,11 +152,11 @@ class LoginView(FormView): #xy 账户结果处理函数 def account_result(request): - type = request.GET.get('type') #zxm 获取类型参数 - id = request.GET.get('id') #zxm 获取用户ID + result_type = request.GET.get('type') #zxm 获取类型参数 + user_id = request.GET.get('id') #zxm 获取用户ID - user = get_object_or_404(get_user_model(), id=id) #zxm 获取用户对象 - logger.info(type) + user = get_object_or_404(get_user_model(), id=user_id) #zxm 获取用户对象 + logger.info(result_type) if user.is_active: #zxm 如果用户已激活 return HttpResponseRedirect('/') #zxm 重定向到首页 if type and type in ['register', 'validation']: #zxm 处理注册或验证类型 @@ -214,3 +215,55 @@ class ForgetPasswordEmailCode(View): utils.set_code(to_email, code) #zxm 保存验证码 return HttpResponse("ok") #zxm 返回成功信息 + + +class FavoriteListView(LoginRequiredMixin, ListView): + """展示当前用户收藏的文章""" + template_name = 'account/favorites.html' + context_object_name = 'favorites' + paginate_by = 10 + + def get_queryset(self): + return ( + ArticleFavorite.objects.select_related('article') + .filter(user=self.request.user) + .order_by('-creation_time') + ) + + +class UserFavoritesView(LoginRequiredMixin, ListView): + """用户收藏文章列表""" + template_name = 'account/user_favorites.html' + context_object_name = 'favorites' + paginate_by = 10 + + def get_queryset(self): + username = self.kwargs.get('username') + user = get_object_or_404(BlogUser, username=username) + return ArticleFavorite.objects.filter(user=user).order_by('-creation_time') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['profile_user'] = get_object_or_404(BlogUser, username=self.kwargs.get('username')) + return context + + +class UserLikesView(LoginRequiredMixin, ListView): + """用户点赞文章列表""" + template_name = 'account/user_likes.html' + context_object_name = 'liked_articles' + paginate_by = 10 + + def get_queryset(self): + username = self.kwargs.get('username') + user = get_object_or_404(BlogUser, username=username) + # 查询用户点赞的文章,并按点赞时间倒序排列 + return Article.objects.filter(likes=user).order_by('-article_likes__created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['profile_user'] = get_object_or_404(BlogUser, username=self.kwargs.get('username')) + return context + + + diff --git a/src/DjangoBlog/blog/migrations/0007_article_dislikes_count_article_favorites_count_and_more.py b/src/DjangoBlog/blog/migrations/0007_article_dislikes_count_article_favorites_count_and_more.py new file mode 100644 index 0000000..de72949 --- /dev/null +++ b/src/DjangoBlog/blog/migrations/0007_article_dislikes_count_article_favorites_count_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.4 on 2025-11-20 14:58 + +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'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='dislikes_count', + field=models.PositiveIntegerField(default=0, verbose_name='dislikes count'), + ), + migrations.AddField( + model_name='article', + name='favorites_count', + field=models.PositiveIntegerField(default=0, verbose_name='favorites count'), + ), + migrations.AddField( + model_name='article', + name='likes_count', + field=models.PositiveIntegerField(default=0, verbose_name='likes count'), + ), + migrations.CreateModel( + name='ArticleFavorite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='blog.article', verbose_name='article')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_articles', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'article favorite', + 'verbose_name_plural': 'article favorite', + 'ordering': ['-creation_time'], + 'unique_together': {('article', 'user')}, + }, + ), + migrations.CreateModel( + name='ArticleReaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reaction', models.CharField(choices=[('like', 'Like'), ('dislike', 'Dislike')], max_length=7, verbose_name='reaction')), + ('creation_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='blog.article', verbose_name='article')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_reactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'article reaction', + 'verbose_name_plural': 'article reaction', + 'ordering': ['-creation_time'], + 'unique_together': {('article', 'user')}, + }, + ), + ] diff --git a/src/DjangoBlog/blog/models.py b/src/DjangoBlog/blog/models.py index 11cd326..2d74eef 100644 --- a/src/DjangoBlog/blog/models.py +++ b/src/DjangoBlog/blog/models.py @@ -1,5 +1,5 @@ -#flj 这个文件里的是博客相关的数据模型,定义了博客系统中所有的数据表结构 +#fkc这个文件里的是博客相关的数据模型,定义了博客系统中所有的数据表结构 import logging import re from abc import abstractmethod @@ -7,13 +7,14 @@ from abc import abstractmethod from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Count from django.urls import reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from mdeditor.fields import MDTextField # 用于支持Markdown编辑器的文本字段 -from uuslug import slugify # 用于生成URL友好的slug +from mdeditor.fields import MDTextField #fkc 用于支持Markdown编辑器的文本字段 +from uuslug import slugify #fkc 用于生成URL友好的slug -from djangoblog.utils import cache_decorator, cache # 缓存相关的工具函数 +from djangoblog.utils import cache_decorator, cache # fkc缓存相关的工具函数 from djangoblog.utils import get_current_site logger = logging.getLogger(__name__) @@ -22,11 +23,11 @@ logger = logging.getLogger(__name__) #zxm 友情链接的展示类型选择,用于控制链接在哪些页面显示 class LinkShowType(models.TextChoices): - I = ('i', _('index')) # 只在首页显示 - L = ('l', _('list')) # 只在列表页显示 - P = ('p', _('post')) # 只在文章页显示 - A = ('a', _('all')) # 在所有页面显示 - S = ('s', _('slide')) # 以轮播形式显示 + I = ('i', _('index')) # fkc只在首页显示 + L = ('l', _('list')) # fkc只在列表页显示 + P = ('p', _('post')) # fkc只在文章页显示 + A = ('a', _('all')) # fkc在所有页面显示 + S = ('s', _('slide')) # fkc以轮播形式显示 #fkc 所有模型的基类,包含通用字段,避免重复代码 @@ -72,114 +73,153 @@ class BaseModel(models.Model): class Article(BaseModel): #cll 文章状态选择:草稿或已发布 STATUS_CHOICES = ( - ('d', _('Draft')), # 草稿 - ('p', _('Published')), # 已发布 + ('d', _('Draft')), # fkc草稿 + ('p', _('Published')), # fkc已发布 ) #cll 评论状态选择:开放或关闭 COMMENT_STATUS = ( - ('o', _('Open')), # 开放评论 - ('c', _('Close')), # 关闭评论 + ('o', _('Open')), # fkc开放评论 + ('c', _('Close')), # fkc关闭评论 ) #cll 内容类型选择:文章或页面 TYPE = ( - ('a', _('Article')), # 普通文章 - ('p', _('Page')), # 静态页面 + ('a', _('Article')), # fkc普通文章 + ('p', _('Page')), # fkc静态页面 ) - title = models.CharField(_('title'), max_length=200, unique=True) #cll 文章标题 - body = MDTextField(_('body')) #cll 文章正文,支持Markdown格式 + title = models.CharField(_('title'), max_length=200, unique=True) #fkc 文章标题 + body = MDTextField(_('body')) #fkc 文章正文,支持Markdown格式 pub_time = models.DateTimeField( - _('publish time'), blank=False, null=False, default=now) #cll 发布时间 + _('publish time'), blank=False, null=False, default=now) #fkc 发布时间 status = models.CharField( _('status'), max_length=1, choices=STATUS_CHOICES, - default='p') #cll 文章状态 + default='p') #fkc 文章状态 comment_status = models.CharField( _('comment status'), max_length=1, choices=COMMENT_STATUS, - default='o') #cll 评论状态 - type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') #cll 内容类型 - views = models.PositiveIntegerField(_('views'), default=0) #cll 浏览次数 + default='o') #fkc 评论状态 + type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') #fkc 内容类型 + views = models.PositiveIntegerField(_('views'), default=0) #fkc 浏览次数 + likes_count = models.PositiveIntegerField(_('likes count'), default=0) + dislikes_count = models.PositiveIntegerField(_('dislikes count'), default=0) + favorites_count = models.PositiveIntegerField(_('favorites count'), default=0) author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), blank=False, null=False, - on_delete=models.CASCADE) #cll 作者,关联用户表 + on_delete=models.CASCADE) #fkc 作者,关联用户表 article_order = models.IntegerField( - _('order'), blank=False, null=False, default=0) #cll 文章排序 - show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) #cll 是否显示目录 + _('order'), blank=False, null=False, default=0) #fkc 文章排序 + show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) #fkc 是否显示目录 category = models.ForeignKey( 'Category', verbose_name=_('category'), on_delete=models.CASCADE, blank=False, - null=False) #cll 分类,关联分类表 - tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) #cll 标签,多对多关系 + null=False) #fkc 分类,关联分类表 + tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) #fkc 标签,多对多关系 - #cll 将文章内容转换为字符串 + #fkc 将文章内容转换为字符串 def body_to_string(self): return self.body - #cll 返回文章标题作为对象的字符串表示 + #fkc 返回文章标题作为对象的字符串表示 def __str__(self): return self.title class Meta: - ordering = ['-article_order', '-pub_time'] #cll 按排序字段和发布时间倒序排列 - verbose_name = _('article') #cll 在管理后台显示的名称 - verbose_name_plural = verbose_name #cll 复数形式 - get_latest_by = 'id' #cll 获取最新记录的依据 + ordering = ['-article_order', '-pub_time'] #fkc 按排序字段和发布时间倒序排列 + verbose_name = _('article') #fkc 在管理后台显示的名称 + verbose_name_plural = verbose_name #fkc 复数形式 + get_latest_by = 'id' #fkc 获取最新记录的依据 - #cll 获取文章的URL + #fkc 获取文章的URL def get_absolute_url(self): - if self.type == 'a': - return reverse('blog:detail', kwargs={'article_id': self.id, 'slug': self.slug}) - elif self.type == 'p': - return reverse('blog:page', kwargs={'article_id': self.id, 'slug': self.slug}) - - #cll 获取分类树,缓存10小时 + return reverse( + 'blog:detailbyid', + kwargs={ + 'article_id': self.id, + 'year': self.creation_time.year, + 'month': self.creation_time.month, + 'day': self.creation_time.day + }) + + #fkc 获取分类树,缓存10小时 @cache_decorator(60 * 60 * 10) # 缓存10小时 def get_category_tree(self): - category = self.category - names = [category.name] - while category.parent_category: - category = category.parent_category - names.append(category.name) + tree = self.category.get_category_tree() + names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) return names - #cll 保存文章,更新修改时间 + #fkc 保存文章,更新修改时间 def save(self, *args, **kwargs): self.last_modify_time = now() return super().save(*args, **kwargs) - #cll 增加文章浏览次数 + #fkc 增加文章浏览次数 def viewed(self): self.views += 1 self.save(update_fields=['views']) - #cll 获取文章评论列表 + def refresh_reaction_counters(self): + """ + 重新统计点赞/点踩数量,避免并发导致的计数不准 + """ + reaction_totals = { + value['reaction']: value['total'] + for value in self.reactions.values('reaction').annotate(total=Count('id')) + } + likes = reaction_totals.get(ArticleReaction.LIKE, 0) + dislikes = reaction_totals.get(ArticleReaction.DISLIKE, 0) + Article.objects.filter(pk=self.pk).update( + likes_count=likes, + dislikes_count=dislikes, + ) + self.likes_count = likes + self.dislikes_count = dislikes + + def refresh_favorites_count(self): + """ + 重新统计收藏数量 + """ + total = self.favorites.count() + Article.objects.filter(pk=self.pk).update(favorites_count=total) + self.favorites_count = total + + def get_user_reaction(self, user): + if not user.is_authenticated: + return None + return self.reactions.filter(user=user).first() + + def is_favorited_by(self, user): + if not user.is_authenticated: + return False + return self.favorites.filter(user=user).exists() + + #fkc 获取文章评论列表 def comment_list(self): comments = self.comment_set.filter(is_enable=True).order_by('-id') return comments - #cll 获取文章在管理后台的URL + #fkc 获取文章在管理后台的URL def get_admin_url(self): info = (self._meta.app_label, self._meta.model_name) return reverse('admin:%s_%s_change' % info, args=(self.id,)) - #cll 获取下一篇文章,缓存100分钟 + #fkc 获取下一篇文章,缓存100分钟 @cache_decorator(expiration=60 * 100) # 缓存100分钟 def next_article(self): return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first() - #cll 获取上一篇文章,缓存100分钟 + #fkc 获取上一篇文章,缓存100分钟 @cache_decorator(expiration=60 * 100) # 缓存100分钟 def prev_article(self): return Article.objects.filter(id__lt=self.id, status='p').order_by('-id').first() - #cll 获取文章中的第一张图片URL + #fkc 获取文章中的第一张图片URL def get_first_image_url(self): pattern = re.compile(r' {self.article} ({self.reaction})' + + +class ArticleFavorite(models.Model): + article = models.ForeignKey( + Article, + related_name='favorites', + on_delete=models.CASCADE, + verbose_name=_('article'), + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='favorite_articles', + on_delete=models.CASCADE, + verbose_name=_('user'), + ) + creation_time = models.DateTimeField(_('creation time'), default=now) + + class Meta: + unique_together = ('article', 'user') + ordering = ['-creation_time'] + verbose_name = _('article favorite') + verbose_name_plural = verbose_name + + def __str__(self): + return f'{self.user} ❤ {self.article}' + + #zxm 侧边栏模型 class SideBar(models.Model): name = models.CharField(_('title'), max_length=100) #zxm 侧边栏标题 diff --git a/src/DjangoBlog/blog/templatetags/blog_tags.py b/src/DjangoBlog/blog/templatetags/blog_tags.py index d6cd5d5..4500d9c 100644 --- a/src/DjangoBlog/blog/templatetags/blog_tags.py +++ b/src/DjangoBlog/blog/templatetags/blog_tags.py @@ -57,7 +57,7 @@ def custom_markdown(content): @register.simple_tag def get_markdown_toc(content): from djangoblog.utils import CommonMarkdown - body, toc = CommonMarkdown.get_markdown_with_toc(content) + _, toc = CommonMarkdown.get_markdown_with_toc(content) return mark_safe(toc) @@ -100,7 +100,7 @@ def load_breadcrumb(article): names = article.get_category_tree() from djangoblog.utils import get_blog_setting blogsetting = get_blog_setting() - site = get_current_site().domain + _ = get_current_site().domain # 获取域名但暂未使用 names.append((blogsetting.site_name, '/')) names = names[::-1] @@ -150,8 +150,9 @@ def load_sidebar(user, linktype): sidebar_categorys = Category.objects.all() extra_sidebars = SideBar.objects.filter( is_enable=True).order_by('sequence') + # 热度榜单,只显示前10篇文章 most_read_articles = Article.objects.filter(status='p').order_by( - '-views')[:blogsetting.sidebar_article_count] + '-views')[:10] dates = Article.objects.datetimes('creation_time', 'month', order='DESC') links = Links.objects.filter(is_enable=True).filter( Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A)) @@ -167,8 +168,8 @@ def load_sidebar(user, linktype): count = sum([t[1] for t in s]) dd = 1 if (count == 0 or not len(tags)) else count / len(tags) import random - sidebar_tags = list( - map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s)) + sidebar_tags = [ + (x[0], x[1], (x[1] / dd) * increment + 10) for x in s] random.shuffle(sidebar_tags) value = { @@ -204,68 +205,85 @@ def load_article_metas(article, user): } -@register.inclusion_tag('blog/tags/article_pagination.html') -def load_pagination_info(page_obj, page_type, tag_name): +def _get_pagination_urls_for_index(page_obj): + """获取首页分页URLs""" + previous_url = '' + next_url = '' + + if page_obj.has_next(): + next_url = reverse('blog:index_page', kwargs={'page': page_obj.next_page_number()}) + if page_obj.has_previous(): + previous_url = reverse('blog:index_page', kwargs={'page': page_obj.previous_page_number()}) + + return previous_url, next_url + +def _get_pagination_urls_for_tag(page_obj, tag_name): + """获取标签分页URLs""" + previous_url = '' + next_url = '' + tag = get_object_or_404(Tag, name=tag_name) + + if page_obj.has_next(): + next_url = reverse( + 'blog:tag_detail_page', + kwargs={'page': page_obj.next_page_number(), 'tag_name': tag.slug}) + if page_obj.has_previous(): + previous_url = reverse( + 'blog:tag_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'tag_name': tag.slug}) + + return previous_url, next_url + +def _get_pagination_urls_for_author(page_obj, author_name): + """获取作者分页URLs""" previous_url = '' next_url = '' - if page_type == '': - if page_obj.has_next(): - next_number = page_obj.next_page_number() - next_url = reverse('blog:index_page', kwargs={'page': next_number}) - if page_obj.has_previous(): - previous_number = page_obj.previous_page_number() - previous_url = reverse( - 'blog:index_page', kwargs={ - 'page': previous_number}) - if page_type == '分类标签归档': - tag = get_object_or_404(Tag, name=tag_name) - if page_obj.has_next(): - next_number = page_obj.next_page_number() - next_url = reverse( - 'blog:tag_detail_page', - kwargs={ - 'page': next_number, - 'tag_name': tag.slug}) - if page_obj.has_previous(): - previous_number = page_obj.previous_page_number() - previous_url = reverse( - 'blog:tag_detail_page', - kwargs={ - 'page': previous_number, - 'tag_name': tag.slug}) - if page_type == '作者文章归档': - if page_obj.has_next(): - next_number = page_obj.next_page_number() - next_url = reverse( - 'blog:author_detail_page', - kwargs={ - 'page': next_number, - 'author_name': tag_name}) - if page_obj.has_previous(): - previous_number = page_obj.previous_page_number() - previous_url = reverse( - 'blog:author_detail_page', - kwargs={ - 'page': previous_number, - 'author_name': tag_name}) - - if page_type == '分类目录归档': - category = get_object_or_404(Category, name=tag_name) - if page_obj.has_next(): - next_number = page_obj.next_page_number() - next_url = reverse( - 'blog:category_detail_page', - kwargs={ - 'page': next_number, - 'category_name': category.slug}) - if page_obj.has_previous(): - previous_number = page_obj.previous_page_number() - previous_url = reverse( - 'blog:category_detail_page', - kwargs={ - 'page': previous_number, - 'category_name': category.slug}) + + if page_obj.has_next(): + next_url = reverse( + 'blog:author_detail_page', + kwargs={'page': page_obj.next_page_number(), 'author_name': author_name}) + if page_obj.has_previous(): + previous_url = reverse( + 'blog:author_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'author_name': author_name}) + + return previous_url, next_url + +def _get_pagination_urls_for_category(page_obj, category_name): + """获取分类分页URLs""" + previous_url = '' + next_url = '' + category = get_object_or_404(Category, name=category_name) + + if page_obj.has_next(): + next_url = reverse( + 'blog:category_detail_page', + kwargs={'page': page_obj.next_page_number(), 'category_name': category.slug}) + if page_obj.has_previous(): + previous_url = reverse( + 'blog:category_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'category_name': category.slug}) + + return previous_url, next_url +@register.inclusion_tag('blog/tags/article_pagination.html') +def load_pagination_info(page_obj, page_type, tag_name): + """加载分页信息""" + # 使用字典映射页面类型到对应的处理函数 + pagination_handlers = { + '': _get_pagination_urls_for_index, + '分类标签归档': lambda obj: _get_pagination_urls_for_tag(obj, tag_name), + '作者文章归档': lambda obj: _get_pagination_urls_for_author(obj, tag_name), + '分类目录归档': lambda obj: _get_pagination_urls_for_category(obj, tag_name) + } + + # 获取对应的URL生成函数 + handler = pagination_handlers.get(page_type, lambda obj: ('', '')) + + # 调用处理函数生成URLs + previous_url, next_url = handler(page_obj) + return { 'previous_url': previous_url, 'next_url': next_url, diff --git a/src/DjangoBlog/blog/tests.py b/src/DjangoBlog/blog/tests.py index ee13505..2241101 100644 --- a/src/DjangoBlog/blog/tests.py +++ b/src/DjangoBlog/blog/tests.py @@ -21,22 +21,31 @@ from oauth.models import OAuthUser, OAuthConfig class ArticleTest(TestCase): def setUp(self): + #xy 初始化测试客户端(模拟HTTP请求)和请求工厂(构造原始请求对象) self.client = Client() self.factory = RequestFactory() def test_validate_article(self): + #xy 获取当前站点域名 site = get_current_site().domain + #xy 获取或创建测试超级用户(用于发布文章和访问管理后台) user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] user.set_password("liangliangyy") - user.is_staff = True - user.is_superuser = True + user.is_staff = True #xy 允许访问管理后台 + user.is_superuser = True #xy 赋予超级用户权限 user.save() + + #xy 测试访问用户个人主页 response = self.client.get(user.get_absolute_url()) self.assertEqual(response.status_code, 200) + + #xy 测试访问管理后台相关页面 response = self.client.get('/admin/servermanager/emailsendlog/') response = self.client.get('admin/admin/logentry/') + + #xy 创建测试侧边栏 s = SideBar() s.sequence = 1 s.name = 'test' @@ -44,30 +53,35 @@ class ArticleTest(TestCase): s.is_enable = True s.save() + #xy 创建测试分类 category = Category() category.name = "category" category.creation_time = timezone.now() category.last_mod_time = timezone.now() category.save() + #xy 创建测试标签 tag = Tag() tag.name = "nicetag" tag.save() + #xy 创建测试文章(已发布状态) article = Article() article.title = "nicetitle" article.body = "nicecontent" article.author = user article.category = category - article.type = 'a' - article.status = 'p' - + article.type = 'a' #xy 文章类型(假设'a'为普通文章) + article.status = 'p' #xy 文章状态(假设'p'为已发布) article.save() + + #xy 测试文章标签关联(初始无标签,添加后断言数量为1) self.assertEqual(0, article.tags.count()) article.tags.add(tag) article.save() self.assertEqual(1, article.tags.count()) + #xy 批量创建20篇测试文章(用于测试分页功能) for i in range(20): article = Article() article.title = "nicetitle" + str(i) @@ -79,56 +93,77 @@ class ArticleTest(TestCase): article.save() article.tags.add(tag) article.save() + + #xy 若启用Elasticsearch,构建搜索索引并测试搜索功能 from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: - call_command("build_index") + call_command("build_index") #xy 执行索引构建命令 response = self.client.get('/search', {'q': 'nicetitle'}) self.assertEqual(response.status_code, 200) + #xy 测试访问文章详情页 response = self.client.get(article.get_absolute_url()) self.assertEqual(response.status_code, 200) + + #xy 测试搜索引擎推送功能 from djangoblog.spider_notify import SpiderNotify SpiderNotify.notify(article.get_absolute_url()) + + #xy 测试访问标签归档页 response = self.client.get(tag.get_absolute_url()) self.assertEqual(response.status_code, 200) + #xy 测试访问分类归档页 response = self.client.get(category.get_absolute_url()) self.assertEqual(response.status_code, 200) + #xy 测试搜索功能(搜索不存在的关键词) response = self.client.get('/search', {'q': 'django'}) self.assertEqual(response.status_code, 200) + + #xy 测试文章标签模板标签 s = load_articletags(article) self.assertIsNotNone(s) + #xy 超级用户登录(用于访问需权限的页面) self.client.login(username='liangliangyy', password='liangliangyy') + #xy 测试访问归档页面 response = self.client.get(reverse('blog:archives')) self.assertEqual(response.status_code, 200) + #xy 测试全量文章分页 p = Paginator(Article.objects.all(), settings.PAGINATE_BY) self.check_pagination(p, '', '') + #xy 测试标签筛选分页 p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) self.check_pagination(p, '分类标签归档', tag.slug) + #xy 测试作者筛选分页 p = Paginator( Article.objects.filter( author__username='liangliangyy'), settings.PAGINATE_BY) self.check_pagination(p, '作者文章归档', 'liangliangyy') + #xy 测试分类筛选分页 p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) self.check_pagination(p, '分类目录归档', category.slug) + #xy 测试搜索表单功能 f = BlogSearchForm() f.search() - # self.client.login(username='liangliangyy', password='liangliangyy') + + #xy 测试百度搜索引擎推送 from djangoblog.spider_notify import SpiderNotify SpiderNotify.baidu_notify([article.get_full_url()]) + #xy 测试头像相关模板标签 from blog.templatetags.blog_tags import gravatar_url, gravatar u = gravatar_url('liangliangyy@gmail.com') u = gravatar('liangliangyy@gmail.com') + #xy 创建测试友情链接并访问链接页面 link = Links( sequence=1, name="lylinux", @@ -137,38 +172,52 @@ class ArticleTest(TestCase): response = self.client.get('/links.html') self.assertEqual(response.status_code, 200) + #xy 测试RSS订阅 feed 页面 response = self.client.get('/feed/') self.assertEqual(response.status_code, 200) + #xy 测试站点地图页面 response = self.client.get('/sitemap.xml') self.assertEqual(response.status_code, 200) + #xy 测试管理后台文章删除、日志查看等功能 self.client.get("/admin/blog/article/1/delete/") self.client.get('/admin/servermanager/emailsendlog/') self.client.get('/admin/admin/logentry/') self.client.get('/admin/admin/logentry/1/change/') def check_pagination(self, p, type, value): + #xy 遍历所有分页页面,验证分页链接有效性 for page in range(1, p.num_pages + 1): + #xy 加载分页信息(模板标签功能测试) s = load_pagination_info(p.page(page), type, value) self.assertIsNotNone(s) + #xy 测试上一页链接(存在则访问断言状态码200) if s['previous_url']: response = self.client.get(s['previous_url']) self.assertEqual(response.status_code, 200) + #xy 测试下一页链接(存在则访问断言状态码200) if s['next_url']: response = self.client.get(s['next_url']) self.assertEqual(response.status_code, 200) def test_image(self): + #xy 测试图片上传功能 import requests + #xy 下载测试图片(Python官网logo) rsp = requests.get( 'https://www.python.org/static/img/python-logo.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png') with open(imagepath, 'wb') as file: file.write(rsp.content) + + #xy 未带签名访问上传接口(断言403禁止访问) rsp = self.client.post('/upload') self.assertEqual(rsp.status_code, 403) + + #xy 生成上传接口签名(基于SECRET_KEY) sign = get_sha256(get_sha256(settings.SECRET_KEY)) + #xy 带签名上传图片(断言上传成功) with open(imagepath, 'rb') as file: imgfile = SimpleUploadedFile( 'python.png', file.read(), content_type='image/jpg') @@ -176,17 +225,24 @@ class ArticleTest(TestCase): rsp = self.client.post( '/upload?sign=' + sign, form_data, follow=True) self.assertEqual(rsp.status_code, 200) + + #xy 删除本地测试图片 os.remove(imagepath) + + #xy 测试用户头像保存和邮件发送工具函数 from djangoblog.utils import save_user_avatar, send_email send_email(['qq@qq.com'], 'testTitle', 'testContent') save_user_avatar( 'https://www.python.org/static/img/python-logo.png') def test_errorpage(self): + #xy 测试404错误页面(访问不存在的路径) rsp = self.client.get('/eee') self.assertEqual(rsp.status_code, 404) def test_commands(self): + #xy 测试自定义管理命令功能 + #xy 创建测试超级用户 user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] @@ -195,12 +251,14 @@ class ArticleTest(TestCase): user.is_superuser = True user.save() + #xy 创建OAuth第三方登录配置(QQ登录) c = OAuthConfig() c.type = 'qq' c.appkey = 'appkey' c.appsecret = 'appsecret' c.save() + #xy 创建OAuth关联用户(测试头像同步) u = OAuthUser() u.type = 'qq' u.openid = 'openid' @@ -212,6 +270,7 @@ class ArticleTest(TestCase): }''' u.save() + #xy 创建另一个OAuth关联用户(带远程头像地址) u = OAuthUser() u.type = 'qq' u.openid = 'openid1' @@ -222,11 +281,14 @@ class ArticleTest(TestCase): }''' u.save() + #xy 若启用Elasticsearch,执行索引构建命令 from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: call_command("build_index") - call_command("ping_baidu", "all") - call_command("create_testdata") - call_command("clear_cache") - call_command("sync_user_avatar") - call_command("build_search_words") + + #xy 执行各类自定义管理命令(断言无报错) + call_command("ping_baidu", "all") #xy 百度链接推送 + call_command("create_testdata") #xy 创建测试数据 + call_command("clear_cache") #xy 清除缓存 + call_command("sync_user_avatar") #xy 同步用户头像 + call_command("build_search_words") #xy 构建搜索关键词 \ No newline at end of file diff --git a/src/DjangoBlog/blog/urls.py b/src/DjangoBlog/blog/urls.py index 14aedf2..c13a026 100644 --- a/src/DjangoBlog/blog/urls.py +++ b/src/DjangoBlog/blog/urls.py @@ -1,76 +1,84 @@ -# 博客应用的URL配置文件 +# zxm博客应用的URL配置文件 from django.urls import path -from django.views.decorators.cache import cache_page # 页面缓存装饰器 +from django.views.decorators.cache import cache_page #zxm 页面缓存装饰器 from . import views -app_name = "blog" # 应用命名空间 +app_name = "blog" # zxm 应用命名空间 urlpatterns = [ - # 首页相关 + # zxm 首页相关 path( r'', views.IndexView.as_view(), - name='index'), # 首页 + name='index'), # zxm 首页 path( r'page//', views.IndexView.as_view(), - name='index_page'), # 首页分页 + name='index_page'), # zxm 首页分页 # 文章详情页 path( r'article////.html', views.ArticleDetailView.as_view(), - name='detailbyid'), # 文章详情页(按ID) + name='detailbyid'), # zxm 文章详情页(按ID) # 分类相关页面 path( r'category/.html', views.CategoryDetailView.as_view(), - name='category_detail'), # 分类页面 + name='category_detail'), # zxm 分类页面 path( r'category//.html', views.CategoryDetailView.as_view(), - name='category_detail_page'), # 分类页面分页 + name='category_detail_page'), # zxm 分类页面分页 # 作者相关页面 path( r'author/.html', views.AuthorDetailView.as_view(), - name='author_detail'), # 作者页面 + name='author_detail'), # zxm 作者页面 path( r'author//.html', views.AuthorDetailView.as_view(), - name='author_detail_page'), # 作者页面分页 + name='author_detail_page'), # zxm 作者页面分页 # 标签相关页面 path( r'tag/.html', views.TagDetailView.as_view(), - name='tag_detail'), # 标签页面 + name='tag_detail'), # zxm 标签页面 path( r'tag//.html', views.TagDetailView.as_view(), - name='tag_detail_page'), # 标签页面分页 + name='tag_detail_page'), # zxm 标签页面分页 # 其他页面 path( 'archives.html', cache_page(60 * 60)( # 缓存1小时 views.ArchivesView.as_view()), - name='archives'), # 文章归档页 + name='archives'), # zxm 文章归档页 path( 'links.html', views.LinkListView.as_view(), - name='links'), # 友情链接页 + name='links'), # zxm 友情链接页 # 功能接口 path( r'upload', views.fileupload, - name='upload'), # 文件上传接口 + name='upload'), # zxm 文件上传接口 path( r'clean', views.clean_cache_view, - name='clean'), # 清除缓存接口 + name='clean'), # zxm 清除缓存接口 + path( + r'article//react/', + views.react_article_view, + name='article_react'), # 文章点赞/点踩 + path( + r'article//favorite/', + views.toggle_favorite_view, + name='article_favorite'), # 文章收藏 ] diff --git a/src/DjangoBlog/blog/views.py b/src/DjangoBlog/blog/views.py index eaa043c..e218095 100644 --- a/src/DjangoBlog/blog/views.py +++ b/src/DjangoBlog/blog/views.py @@ -1,304 +1,518 @@ -#flj 博客视图文件,处理博客相关的页面请求 import logging import os import uuid from django.conf import settings -from django.core.paginator import Paginator # 用于分页 -from django.http import HttpResponse, HttpResponseForbidden +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +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.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt # 用于跳过CSRF验证 -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 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, + ArticleFavorite, + ArticleReaction, + Category, + LinkShowType, + Links, + Tag, +) from comments.forms import CommentForm -from djangoblog.plugin_manage import hooks # 插件管理 +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 logger = logging.getLogger(__name__) -#flj 文章列表视图基类,提供通用的文章列表功能,负责处理文章列表的展示逻辑,包括分页、缓存等功能 class ArticleListView(ListView): - template_name = 'blog/article_index.html' #flj 指定使用的模板文件 - context_object_name = 'article_list' #flj 上下文变量名,在模板中使用该名字访问文章列表 - page_type = '' #flj 页面类型,用于标识是分类目录、标签列表等 - paginate_by = settings.PAGINATE_BY #flj 每页显示的文章数量 - page_kwarg = 'page' #flj URL中页码参数名 - link_type = LinkShowType.L #flj 友情链接显示类型 - - #flj 获取视图缓存键,注意:这个方法似乎有问题,应该返回字符串而不是字典 + """文章列表视图基类:提供分页、缓存等通用功能""" + + # 指定渲染的模板 + template_name = 'blog/article_index.html' + # 模板中使用的变量名 + context_object_name = 'article_list' + # 页面类型(分类、标签等) + page_type = '' + # 每页数量 + paginate_by = settings.PAGINATE_BY + # URL 中的页码参数名 + page_kwarg = 'page' + # 友情链接展示类型 + link_type = LinkShowType.L + def get_view_cache_key(self): - return self.request.get['pages'] + """ + 返回当前页面视图级缓存 key + 原项目中未实际使用该方法,这里保持兼容写法。 + """ + return f'view_cache_{self.__class__.__name__}_{self.page_number}' @property - #flj 获取当前页码,从URL参数或kwargs中获取页码,默认为1 def page_number(self): + """获取当前页码,默认 1""" page_kwarg = self.page_kwarg - page = self.kwargs.get( - page_kwarg) or self.request.GET.get(page_kwarg) or 1 + page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 return page - #flj 子类必须重写此方法,返回查询集的缓存键,不同的列表视图需要不同的缓存键来区分 def get_queryset_cache_key(self): + """ + 子类重写:获得 queryset 的缓存 key + """ raise NotImplementedError() - #flj 子类必须重写此方法,返回查询集的数据,每个子类根据不同的需求过滤文章数据 def get_queryset_data(self): + """ + 子类重写:真正从数据库获取 queryset 数据 + """ raise NotImplementedError() - #flj 从缓存获取页面数据,提高性能,如果缓存不存在,则从数据库获取并存入缓存 def get_queryset_from_cache(self, cache_key): + """ + 根据缓存 key 读取 / 写入文章列表 + """ 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 - #flj 重写默认方法,从缓存获取数据,优先使用缓存,提高页面响应速度 + 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): + """ + 重写默认方法:优先从缓存读取 queryset + """ key = self.get_queryset_cache_key() value = self.get_queryset_from_cache(key) return value - #flj 为模板添加上下文数据,添加友情链接类型等额外信息 def get_context_data(self, **kwargs): + """ + 向模板上下文中注入友情链接展示类型 + """ kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) -#flj 首页视图,显示最新的已发布文章 class IndexView(ArticleListView): - ''' - 首页视图,显示最新的已发布文章 - ''' - #flj 友情链接类型:只在首页显示 + """ + 首页:展示最新已发布文章 + """ + + # 首页使用首页友链展示类型 link_type = LinkShowType.I - #flj 获取首页文章数据,过滤条件为类型为文章(a)且状态为已发布(p) def get_queryset_data(self): - # 获取所有已发布的文章 + # 只展示已发布的正式文章 article_list = Article.objects.filter(type='a', status='p') return article_list - #flj 生成首页的缓存键,包含页码信息 def get_queryset_cache_key(self): - # 生成首页的缓存键 cache_key = 'index_{page}'.format(page=self.page_number) return cache_key -#flj 文章详情视图,负责显示单篇文章的详细内容和评论 class ArticleDetailView(DetailView): - ''' - 文章详情页视图 - ''' - template_name = 'blog/article_detail.html' #flj 使用的模板 - model = Article #flj 关联的模型 - pk_url_kwarg = 'article_id' #flj URL中的主键参数名 - context_object_name = "article" #flj 模板中的对象变量名 - - #flj 获取文章详情页的上下文数据,包括评论表单、相关文章等 + """ + 文章详情页 + """ + + template_name = 'blog/article_detail.html' + model = Article + pk_url_kwarg = 'article_id' + context_object_name = "article" + def get_context_data(self, **kwargs): - #flj 调用父类方法获取基础上下文数据 - #flj 添加评论表单 - #flj 获取文章评论列表 - #flj 添加相关文章 - #flj 调用插件处理文章内容 - return super().get_context_data(**kwargs) + """ + 增加评论表单、评论分页、前后文章、插件处理后的正文等信息 + """ + comment_form = CommentForm() + + article_comments = self.object.comment_list() + parent_comments = article_comments.filter(parent_comment=None) + blog_setting = get_blog_setting() + paginator = Paginator(parent_comments, blog_setting.article_comment_count) + page = self.request.GET.get('comment_page', '1') + if not page.isnumeric(): + page = 1 + else: + 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 + + if next_page: + kwargs[ + 'comment_next_page_url' + ] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs[ + 'comment_prev_page_url' + ] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' + kwargs['form'] = comment_form + kwargs['article_comments'] = article_comments + kwargs['p_comments'] = p_comments + kwargs['comment_count'] = len(article_comments) if article_comments else 0 + + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article + kwargs['likes_count'] = self.object.likes_count + kwargs['dislikes_count'] = self.object.dislikes_count + kwargs['favorites_count'] = self.object.favorites_count + + if self.request.user.is_authenticated: + user = self.request.user + user_reaction = self.object.get_user_reaction(user) + kwargs['user_reaction'] = user_reaction.reaction if user_reaction else None + kwargs['user_has_favorited'] = self.object.is_favorited_by(user) + else: + kwargs['user_reaction'] = None + kwargs['user_has_favorited'] = False + + 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 -#flj 分类详情视图,显示指定分类下的文章列表 class CategoryDetailView(ArticleListView): - ''' - 分类详情页视图 - ''' - page_type = "分类目录归档" #flj 页面类型标识 + """ + 分类目录列表页 + """ + + page_type = "分类目录归档" - #flj 获取分类下的文章数据,根据URL参数中的分类ID过滤 def get_queryset_data(self): - #flj 获取分类ID - #flj 过滤该分类下的已发布文章 - #flj 返回查询结果 + 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 - #flj 生成分类页面的缓存键,包含分类ID和页码 def get_queryset_cache_key(self): - #flj 生成缓存键 + 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 - #flj 添加分类信息到上下文 def get_context_data(self, **kwargs): - #flj 获取分类对象 - #flj 添加到上下文 - return super().get_context_data(**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) -#flj 作者详情视图,显示指定作者的文章列表 class AuthorDetailView(ArticleListView): - ''' - 作者详情页视图 - ''' - page_type = '作者文章归档' #flj 页面类型标识 + """ + 作者详情页:某个作者的所有文章 + """ + + page_type = '作者文章归档' - #flj 生成作者页面的缓存键,包含作者ID和页码 def get_queryset_cache_key(self): - #flj 生成缓存键 + 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 - #flj 获取作者的文章数据,根据URL参数中的作者ID过滤 def get_queryset_data(self): - #flj 获取作者ID - #flj 过滤该作者的已发布文章 + author_name = self.kwargs['author_name'] + article_list = Article.objects.filter( + author__username=author_name, type='a', status='p' + ) return article_list - #flj 添加作者信息到上下文 def get_context_data(self, **kwargs): - #flj 获取作者对象 - #flj 添加到上下文 - return super().get_context_data(**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) -#flj 标签详情视图,显示指定标签下的文章列表 class TagDetailView(ArticleListView): - ''' - 标签详情页视图 - ''' - page_type = '分类标签归档' #flj 页面类型标识 + """ + 标签列表页 + """ + + page_type = '分类标签归档' - #flj 获取标签下的文章数据,根据URL参数中的标签ID过滤 def get_queryset_data(self): - #flj 获取标签ID - #flj 过滤该标签下的已发布文章 + 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 - #flj 生成标签页面的缓存键,包含标签ID和页码 def get_queryset_cache_key(self): - #flj 生成缓存键 + 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 - #flj 添加标签信息到上下文 def get_context_data(self, **kwargs): - #flj 获取标签对象 - #flj 添加到上下文 - return super().get_context_data(**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) -#flj 文章归档视图,显示所有文章按时间分组 class ArchivesView(ArticleListView): - ''' - 文章归档视图 - ''' - page_type = '文章归档' #flj 页面类型标识 - paginate_by = None #flj 不分页,显示所有文章 + """ + 文章归档页:不分页,展示所有已发布文章 + """ + + page_type = '文章归档' + paginate_by = None page_kwarg = None - template_name = 'blog/article_archives.html' #flj 使用归档专用模板 + template_name = 'blog/article_archives.html' - #flj 获取所有已发布文章,按时间排序 def get_queryset_data(self): - #flj 获取所有已发布文章 - return article_list + return Article.objects.filter(status='p').all() - #flj 生成归档页面的缓存键 def get_queryset_cache_key(self): - #flj 生成缓存键 + cache_key = 'archives' return cache_key -#flj 友情链接列表视图 class LinkListView(ListView): - ''' - 友情链接列表视图 - ''' - model = Links #flj 关联的模型 - template_name = 'blog/links_list.html' #flj 使用的模板 + """友情链接列表""" + + model = Links + template_name = 'blog/links_list.html' - #flj 获取友情链接数据,按显示顺序排序 def get_queryset(self): - #flj 过滤显示状态的友情链接并排序 - return links + return Links.objects.filter(is_enable=True) -#flj 搜索视图,处理文章搜索功能 class EsSearchView(SearchView): - ''' - 搜索视图 - ''' - #flj 获取搜索结果的上下文数据 + """ElasticSearch 搜索结果视图""" + def get_context(self): - #flj 获取基础上下文 - #flj 添加额外的搜索相关信息 + 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 -#flj 文件上传接口,允许上传图片等文件 -@csrf_exempt #flj 跳过CSRF验证,因为这是文件上传接口 +@csrf_exempt def fileupload(request): - ''' - 文件上传接口 - ''' - #flj 检查请求方法 - #flj 验证权限 - #flj 处理文件上传 - #flj 保存文件到指定目录 - #flj 返回文件URL - return HttpResponse(json.dumps(data), content_type="application/json") - - -#flj 404错误页面视图 -#flj 处理页面未找到的情况 + """ + 图床上传接口:需自行编写调用端 + """ + 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'): - ''' - 404错误页面 - ''' - #flj 渲染错误页面 - return render(request, template_name, context, status=404) + """404 页面""" + 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, + ) -#flj 500错误页面视图 -#flj 处理服务器内部错误的情况 def server_error_view(request, template_name='blog/error_page.html'): - ''' - 500错误页面 - ''' - #flj 渲染错误页面 - return render(request, template_name, context, status=500) + """500 页面""" + return render( + request, + template_name, + { + 'message': _( + 'Sorry, the server is busy, please click the home page to see other?' + ), + 'statuscode': '500', + }, + status=500, + ) -#flj 403错误页面视图 -#flj 处理权限拒绝的情况 def permission_denied_view( request, exception, template_name='blog/error_page.html'): - ''' - 403错误页面 - ''' - #flj 渲染错误页面 - return render(request, template_name, context, status=403) + """403 页面""" + 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, + ) -#flj 清理缓存视图,用于手动清理站点缓存 -#flj 提供管理功能,清除系统缓存 def clean_cache_view(request): - ''' - 清理缓存视图 - ''' - #flj 验证用户权限 - #flj 清理缓存 - #flj 返回成功信息 - return HttpResponse(_('清理缓存成功')) + """清空站点缓存""" + cache.clear() + return HttpResponse('ok') + + +@login_required +@require_POST +def react_article_view(request, article_id): + """处理文章的点赞/点踩""" + article = get_object_or_404(Article, pk=article_id, status='p') + reaction_value = request.POST.get('reaction') + valid_reactions = dict(ArticleReaction.REACTION_CHOICES).keys() + if reaction_value not in valid_reactions: + return JsonResponse({'message': _('无效的操作')}, status=400) + + reaction, created = ArticleReaction.objects.get_or_create( + article=article, + user=request.user, + defaults={'reaction': reaction_value}, + ) + current_reaction = reaction_value + if not created: + if reaction.reaction == reaction_value: + reaction.delete() + current_reaction = None + else: + reaction.reaction = reaction_value + reaction.creation_time = timezone.now() + reaction.save(update_fields=['reaction', 'creation_time']) + article.refresh_reaction_counters() + data = { + 'likes': article.likes_count, + 'dislikes': article.dislikes_count, + 'current_reaction': current_reaction, + 'message': _('操作成功'), + } + return JsonResponse(data) + + +@login_required +@require_POST +def toggle_favorite_view(request, article_id): + """收藏/取消收藏文章""" + article = get_object_or_404(Article, pk=article_id, status='p') + favorite, created = ArticleFavorite.objects.get_or_create( + article=article, + user=request.user, + ) + user_has_favorited = True + if not created: + favorite.delete() + user_has_favorited = False + article.refresh_favorites_count() + data = { + 'favorites': article.favorites_count, + 'favorited': user_has_favorited, + 'message': _('操作成功'), + } + return JsonResponse(data) + + diff --git a/src/DjangoBlog/comments/utils.py b/src/DjangoBlog/comments/utils.py index f01dba7..e7dc637 100644 --- a/src/DjangoBlog/comments/utils.py +++ b/src/DjangoBlog/comments/utils.py @@ -9,9 +9,13 @@ logger = logging.getLogger(__name__) def send_comment_email(comment): + #zhj 获取当前站点域名(用于拼接文章访问链接) site = get_current_site().domain + #zhj 邮件主题(支持国际化翻译) subject = _('Thanks for your comment') + #zhj 拼接文章完整访问URL(HTTPS协议) article_url = f"https://{site}{comment.article.get_absolute_url()}" + #zhj 构建邮件HTML内容(包含文章链接和标题,支持国际化) html_content = _("""

Thank you very much for your comments on this site

You can visit %(article_title)s to review your comments, @@ -19,10 +23,15 @@ def send_comment_email(comment):
If the link above cannot be opened, please copy this link to your browser. %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title} + #zhj 获取评论者邮箱(收件人) tomail = comment.author.email + #zhj 发送评论感谢邮件 send_email([tomail], subject, html_content) + try: + #zhj 判断当前评论是否为回复(存在父评论) if comment.parent_comment: + #zhj 构建父评论者的回复通知邮件内容 html_content = _("""Your comment on %(article_title)s
has received a reply.
%(comment_body)s
@@ -32,7 +41,10 @@ def send_comment_email(comment): %(article_url)s """) % {'article_url': article_url, 'article_title': comment.article.title, 'comment_body': comment.parent_comment.body} + #zhj 获取父评论者邮箱(回复通知收件人) tomail = comment.parent_comment.author.email + #zhj 发送回复通知邮件 send_email([tomail], subject, html_content) except Exception as e: - logger.error(e) + #zhj 捕获发送邮件过程中的异常并记录日志 + logger.error(e) \ No newline at end of file diff --git a/src/DjangoBlog/djangoblog/utils.py b/src/DjangoBlog/djangoblog/utils.py index 57f63dc..52de49e 100644 --- a/src/DjangoBlog/djangoblog/utils.py +++ b/src/DjangoBlog/djangoblog/utils.py @@ -101,6 +101,32 @@ def get_current_site(): class CommonMarkdown: @staticmethod def _convert_markdown(value): + # 定义常用emoji映射 + emoji_mapping = { + ':laughing:': '😂', + ':smile:': '😊', + ':heart:': '❤️', + ':thumbsup:': '👍', + ':thumbsdown:': '👎', + ':star:': '⭐', + ':fire:': '🔥', + ':rocket:': '🚀', + ':clap:': '👏', + ':thinking:': '🤔', + ':cry:': '😢', + ':angry:': '😠', + ':wink:': '😉', + ':love:': '😍', + ':cool:': '😎', + ':party:': '🎉', + ':surprise:': '😮' + } + + # 先替换emoji代码为实际emoji字符 + for emoji_code, emoji_char in emoji_mapping.items(): + value = value.replace(emoji_code, emoji_char) + + # 然后进行markdown转换 md = markdown.Markdown( extensions=[ 'extra', diff --git a/src/DjangoBlog/src/accounts/models.py b/src/DjangoBlog/src/accounts/models.py index 3baddbb..42abc10 100644 --- a/src/DjangoBlog/src/accounts/models.py +++ b/src/DjangoBlog/src/accounts/models.py @@ -33,3 +33,34 @@ class BlogUser(AbstractUser): verbose_name = _('user') verbose_name_plural = verbose_name get_latest_by = 'id' + + +class UserProfile(models.Model): + """ + 用户扩展资料 + """ + user = models.OneToOneField(BlogUser, on_delete=models.CASCADE, related_name='profile') + bio = models.TextField(_('bio'), blank=True, max_length=500) + avatar = models.ImageField(_('avatar'), upload_to='avatars/', blank=True, null=True) + location = models.CharField(_('location'), max_length=100, blank=True) + website = models.URLField(_('website'), blank=True) + birth_date = models.DateField(_('birth date'), blank=True, null=True) + + def __str__(self): + return f"Profile of {self.user.username}" + + +class UserFollowing(models.Model): + """ + 用户关注关系 + """ + follower = models.ForeignKey(BlogUser, related_name='following', on_delete=models.CASCADE) + following = models.ForeignKey(BlogUser, related_name='followers', on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('follower', 'following') + ordering = ['-created'] + + def __str__(self): + return f"{self.follower.username} follows {self.following.username}" diff --git a/src/DjangoBlog/src/accounts/urls.py b/src/DjangoBlog/src/accounts/urls.py index 107a801..4ede044 100644 --- a/src/DjangoBlog/src/accounts/urls.py +++ b/src/DjangoBlog/src/accounts/urls.py @@ -25,4 +25,22 @@ urlpatterns = [re_path(r'^login/$', re_path(r'^forget_password_code/$', views.ForgetPasswordEmailCode.as_view(), name='forget_password_code'), + + # 用户个人信息中心相关URL + re_path(r'^profile/(?P\w+)/$', + views.UserProfileView.as_view(), + name='profile'), + re_path(r'^profile/(?P\w+)/articles/$', + views.UserArticlesView.as_view(), + name='user_articles'), + re_path(r'^profile/(?P\w+)/favorites/$', + views.UserFavoritesView.as_view(), + name='user_favorites'), + # 关注功能相关路由已移除 + re_path(r'^profile/edit/$', + views.ProfileEditView.as_view(), + name='edit_profile'), + re_path(r'^profile/$', + views.my_profile_view, + name='my_profile'), ] diff --git a/src/DjangoBlog/src/accounts/views.py b/src/DjangoBlog/src/accounts/views.py index ae67aec..b7f4b24 100644 --- a/src/DjangoBlog/src/accounts/views.py +++ b/src/DjangoBlog/src/accounts/views.py @@ -10,6 +10,7 @@ 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.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404 from django.shortcuts import render from django.urls import reverse @@ -19,12 +20,13 @@ 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 django.views.generic import FormView, RedirectView, DetailView, ListView, UpdateView 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 +from .models import BlogUser, UserProfile +from blog.models import Article logger = logging.getLogger(__name__) @@ -202,3 +204,120 @@ class ForgetPasswordEmailCode(View): utils.set_code(to_email, code) return HttpResponse("ok") + + +class UserProfileView(DetailView): + """ + 用户个人信息中心页面 + """ + model = BlogUser + template_name = 'account/profile.html' + context_object_name = 'user_profile' + slug_field = 'username' + slug_url_kwarg = 'username' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user = self.object + + # 获取用户统计信息 + context['article_count'] = Article.objects.filter(author=user, status='p').count() + + # 如果是当前登录用户查看自己的资料,显示更多信息 + if self.request.user == user: + context['is_own_profile'] = True + else: + context['is_own_profile'] = False + + return context + + +class UserArticlesView(ListView): + """ + 用户发布的文章列表 + """ + model = Article + template_name = 'account/user_articles.html' + context_object_name = 'article_list' + paginate_by = 10 + + def get_queryset(self): + username = self.kwargs.get('username') + user = get_object_or_404(BlogUser, username=username) + self.user = user + return Article.objects.filter(author=user, status='p').order_by('-pub_time') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_profile'] = self.user + context['active_tab'] = 'articles' + return context + + +class UserFavoritesView(ListView): + """ + 用户收藏的文章列表 + """ + model = Article + template_name = 'account/user_favorites.html' + context_object_name = 'article_list' + paginate_by = 10 + + def get_queryset(self): + user = self.request.user + return Article.objects.filter(favorites__user=user, status='p').order_by('-favorites__creation_time') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_profile'] = self.request.user + context['active_tab'] = 'favorites' + return context + + +class ProfileEditView(UpdateView): + """ + 编辑用户个人信息 + """ + model = BlogUser + template_name = 'account/edit_profile.html' + fields = ['nickname', 'email', 'first_name', 'last_name'] + success_url = '/account/profile/' + + def get_object(self, queryset=None): + return self.request.user + + def get_success_url(self): + return reverse('accounts:profile', kwargs={'username': self.request.user.username}) + + +@login_required +def my_profile_view(request): + """ + 跳转到当前登录用户的个人信息页面 + """ + return HttpResponseRedirect(reverse('accounts:profile', kwargs={'username': request.user.username})) + + +# 关注相关功能已移除 + + +class UserLikesView(ListView): + """ + 用户点赞的文章列表 + """ + model = Article + template_name = 'account/user_likes.html' + context_object_name = 'article_list' + paginate_by = 10 + + def get_queryset(self): + username = self.kwargs.get('username') + user = get_object_or_404(BlogUser, username=username) + self.user = user + return Article.objects.filter(reactions__user=user, reactions__reaction='like', status='p').order_by('-reactions__created') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_profile'] = self.user + context['active_tab'] = 'likes' + return context diff --git a/src/DjangoBlog/templates/account/edit_profile.html b/src/DjangoBlog/templates/account/edit_profile.html new file mode 100644 index 0000000..4b66f1d --- /dev/null +++ b/src/DjangoBlog/templates/account/edit_profile.html @@ -0,0 +1,47 @@ +{% extends 'share_layout/base_account.html' %} +{% block title %}编辑个人资料 - DjangoBlog{% endblock %} + +{% block content %} +
+
+
+
+
+

个人信息

+
+
+
+
+ 默认头像 +
+
+
+ + +
+ +
+ + +
+
+
+ +
+

当前仅保留基本用户信息展示功能。

+
+ + +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog/templates/account/favorites.html b/src/DjangoBlog/templates/account/favorites.html new file mode 100644 index 0000000..c9aab5d --- /dev/null +++ b/src/DjangoBlog/templates/account/favorites.html @@ -0,0 +1,44 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% block content %} +
+
+
+
+

我的收藏

+
+
+ {% if favorites %} + + {% if is_paginated %} + + {% endif %} + {% else %} +

您还没有收藏任何文章。

+ {% endif %} +
+
+
+
+{% endblock %} +{% block sidebar %} + {% load_sidebar user "p" %} +{% endblock %} + diff --git a/src/DjangoBlog/templates/account/profile.html b/src/DjangoBlog/templates/account/profile.html new file mode 100644 index 0000000..7cf2c6e --- /dev/null +++ b/src/DjangoBlog/templates/account/profile.html @@ -0,0 +1,173 @@ +{% extends 'share_layout/base_account.html' %} +{% load static %} +{% block title %}{{ user_profile.username }} - 个人信息中心{% endblock %} + +{% block content %} +
+ +
+
+

个人资料

+
+ +
+
+ +
+
+ +
+ {% if user_profile.profile.avatar %} + {{ user_profile.username }} 的头像 + {% else %} + 默认头像 + {% endif %} +
+ + +

{{ user_profile.username }}

+ {% if user_profile.nickname %} +

{{ user_profile.nickname }}

+ {% endif %} + + +
+ {% if is_own_profile %} + + 编辑资料 + + {% else %} + {% if user.is_authenticated %} + {% if user_profile.email %} + + 发消息 + + {% endif %} + {% else %} + + 登录 + + {% endif %} + {% endif %} +
+
+
+ + +
+
+

关于我

+

+ {% if user_profile.profile.bio %} + {{ user_profile.profile.bio }} + {% else %} + 这个人很懒,还没有填写个人介绍... + {% endif %} +

+
+ +
+

基本信息

+
    +
  • + + 邮箱: {{ user_profile.email }} +
  • + {% if user_profile.profile.location %} +
  • + + 所在地: {{ user_profile.profile.location }} +
  • + {% endif %} + {% if user_profile.profile.website %} +
  • + + 个人网站: {{ user_profile.profile.website }} +
  • + {% endif %} +
  • + + 注册时间: {{ user_profile.date_joined|date:"Y年m月d日" }} +
  • +
+
+
+ + + +
+
+
+
+ + +{% endblock %} diff --git a/src/DjangoBlog/templates/account/user_articles.html b/src/DjangoBlog/templates/account/user_articles.html new file mode 100644 index 0000000..874f77e --- /dev/null +++ b/src/DjangoBlog/templates/account/user_articles.html @@ -0,0 +1,155 @@ +{% extends 'share_layout/base_account.html' %} +{% load static %} +{% block title %}{{ user_profile.username }} - 发布的文章{% endblock %} + +{% block content %} +
+
+
+
+
+

{{ user_profile.username }} 的文章 ({{ article_count }})

+
+
+ + +
+
+ {% if article_list %} +
+ {% for article in article_list %} +
+
+
+
+ +

+ {% if article.is_top %} + + {% endif %} + {{ article.title }} +

+
+ {% if user == user_profile %} + + {% endif %} +
+

+ {{ article.body|striptags|truncatechars:150 }} +

+
+
+
+ {% for tag in article.tags.all %} + {{ tag.name }} + {% endfor %} +
+ {{ article.pub_time|date:"Y-m-d H:i" }} +
+
+ + {{ article.views|default:0 }} + + {{ article.comment_set.count }} + + {{ article.likes.count }} +
+
+
+
+
+ {% endfor %} +
+ + + + {% else %} +
+ +

暂无文章

+ {% if user == user_profile %} + + 开始写文章 + + {% else %} +

等待精彩内容更新...

+ {% endif %} +
+ {% endif %} +
+
+
+
+
+
+
+ +{% endblock %} diff --git a/src/DjangoBlog/templates/account/user_favorites.html b/src/DjangoBlog/templates/account/user_favorites.html new file mode 100644 index 0000000..158b6cb --- /dev/null +++ b/src/DjangoBlog/templates/account/user_favorites.html @@ -0,0 +1,106 @@ +{% extends 'share_layout/base_account.html' %} +{% block title %}{{ user_profile.username }} - 收藏的文章{% endblock %} + +{% block content %} +
+
+ +
+ +
+
+

{{ user_profile.username }} 收藏的文章列表

+
+
+ + +
+
+ + +
+
+ {% if article_list %} + + + + + {% else %} +
+ 暂无收藏的文章 +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/src/DjangoBlog/templates/blog/article_detail.html b/src/DjangoBlog/templates/blog/article_detail.html index a74a0db..e53a5d9 100644 --- a/src/DjangoBlog/templates/blog/article_detail.html +++ b/src/DjangoBlog/templates/blog/article_detail.html @@ -8,6 +8,28 @@
{% load_article_detail article False user %} +
+ + + + + 请先登录再进行点赞或收藏 + +
+ {% if article.type == 'a' %}