update subtree

develop
dynastxu 3 months ago
commit 46862ad679

@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-11-13 13:53
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='users_like',
field=models.ManyToManyField(blank=True, related_name='articles_liked', to=settings.AUTH_USER_MODEL, verbose_name='点赞用户'),
),
]

@ -147,6 +147,12 @@ class Article(BaseModel):
null=False)
# bjy: 标签多对多关联到Tag模型
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
users_like = models.ManyToManyField(
settings.AUTH_USER_MODEL, # 关联到用户模型
related_name='articles_liked', # 反向关系名称user.articles_liked.all()可获取用户点赞的所有文章
blank=True, # 允许文章没有被任何用户点赞
verbose_name='点赞用户' # 在Admin后台显示的字段名称
)
# bjy: 将body字段转换为字符串
def body_to_string(self):

@ -1,8 +1,11 @@
# bjy: 导入操作系统接口模块
import json
import os
from unittest.mock import patch, MagicMock
# bjy: 从Django中导入设置、文件上传、命令调用、分页器、静态文件、测试工具、URL反向解析和时区工具
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.core.paginator import Paginator
@ -16,6 +19,7 @@ from accounts.models import BlogUser
from blog.forms import BlogSearchForm
from blog.models import Article, Category, Tag, SideBar, Links
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
from blog.views import LikeArticle
from djangoblog.utils import get_current_site, get_sha256
# bjy: 从项目中导入OAuth相关模型
from oauth.models import OAuthUser, OAuthConfig
@ -290,3 +294,284 @@ class ArticleTest(TestCase):
call_command("clear_cache")
call_command("sync_user_avatar")
call_command("build_search_words")
class TestLikeArticle(TestCase):
"""测试 LikeArticle 视图类中的 post 方法"""
def setUp(self):
"""
初始化测试所需的数据和工具
"""
self.factory = RequestFactory()
self.user = BlogUser.objects.create_user(username='testuser', password='password')
# 创建分类Article模型需要category字段
self.category = Category.objects.create(
name="Test Category",
slug="test-category"
)
self.article = Article.objects.create(
title="Test Article",
body="This is a test article.",
author=self.user,
category=self.category, # Article模型必需字段
views=0,
)
@patch('blog.models.Article.objects.get')
def test_post_like_article_successfully(self, mock_get_article):
"""
测试场景用户第一次点赞文章成功
输入
- 已登录用户
- 存在的文章 ID
- 用户尚未点赞该文章
输出
- type = 1 表示新增点赞
- like_sum 更新为 1
- state = 200 成功状态码
"""
# 设置 mock 返回值
mock_article = MagicMock()
mock_article.users_like.filter.return_value.exists.return_value = False
mock_article.users_like.count.return_value = 1
mock_get_article.return_value = mock_article
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 断言调用了 add 方法表示点赞
mock_article.users_like.add.assert_called_once_with(self.user)
mock_article.users_like.remove.assert_not_called()
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['type'], 1)
self.assertEqual(content['like_sum'], 1)
self.assertEqual(content['state'], 200)
@patch('blog.models.Article.objects.get')
def test_post_unlike_article_successfully(self, mock_get_article):
"""
测试场景用户取消点赞文章成功
输入
- 已登录用户
- 存在的文章 ID
- 用户已经点赞了该文章
输出
- type = 0 表示取消点赞
- like_sum 更新为 0
- state = 200 成功状态码
"""
# 设置 mock 返回值
mock_article = MagicMock()
mock_article.users_like.filter.return_value.exists.return_value = True
mock_article.users_like.count.return_value = 0
mock_get_article.return_value = mock_article
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 断言调用了 remove 方法表示取消点赞
mock_article.users_like.remove.assert_called_once_with(self.user)
mock_article.users_like.add.assert_not_called()
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['type'], 0)
self.assertEqual(content['like_sum'], 0)
self.assertEqual(content['state'], 200)
@patch('blog.models.Article.objects.get')
def test_post_article_does_not_exist(self, mock_get_article):
"""
测试场景提供的文章 ID 不存在
输入
- 任意用户
- 不存在的文章 ID
输出
- state = 400 错误状态码
- data 包含文章不存在提示
"""
# 设置 mock 抛出 DoesNotExist 异常
mock_get_article.side_effect = Article.DoesNotExist
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': '999'})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['state'], 400)
self.assertIn("文章不存在", content['data'])
@patch('blog.models.Article.objects.get')
def test_post_internal_server_error(self, mock_get_article):
"""
测试场景系统内部发生异常
输入
- 任意用户
- 导致异常的操作如数据库连接失败等
输出
- state = 500 错误状态码
- data 包含具体异常描述
"""
# 设置 mock 抛出通用异常
mock_get_article.side_effect = Exception("数据库连接超时")
# 构造 POST 请求
request = self.factory.post('/like/', {'article_id': str(self.article.id)})
request.user = self.user
# 执行被测函数
response = LikeArticle().post(request)
# 解析响应内容
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertEqual(content['state'], 500)
self.assertIn("服务器错误", content['data'])
class LikeIntegrationTests(TestCase):
def setUp(self):
"""设置测试数据"""
self.client = Client()
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.other_user = BlogUser.objects.create_user(
username='otheruser',
email='other@example.com',
password='testpass123'
)
# 创建分类因为Article模型需要category字段
self.category = Category.objects.create(
name='测试分类',
slug='test-category'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.user,
category=self.category, # 必须提供category
# 其他必填字段使用默认值
status='p', # 发布状态
comment_status='o', # 开放评论
type='a', # 文章类型
article_order=0,
show_toc=False
)
def test_like_workflow(self):
"""测试完整的点赞流程"""
# 1. 用户登录
self.client.login(username='testuser', password='testpass123')
# 2. 发送点赞请求
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 3. 验证响应
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['type'], 1) # 点赞操作
# 4. 验证数据库状态
self.assertTrue(self.article.users_like.filter(id=self.user.id).exists())
self.assertEqual(self.article.users_like.count(), 1)
# 5. 测试取消点赞
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 6. 验证取消点赞
self.assertEqual(response.json()['type'], 0) # 取消点赞操作
self.assertFalse(self.article.users_like.filter(id=self.user.id).exists())
self.assertEqual(self.article.users_like.count(), 0)
def test_multiple_users_liking(self):
"""测试多个用户点赞同一篇文章"""
# 第一个用户点赞
self.client.login(username='testuser', password='testpass123')
self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 第二个用户点赞
self.client.login(username='otheruser', password='testpass123')
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 验证两个用户都点赞成功
self.assertEqual(self.article.users_like.count(), 2)
self.assertTrue(self.article.users_like.filter(id=self.user.id).exists())
self.assertTrue(self.article.users_like.filter(id=self.other_user.id).exists())
def test_like_nonexistent_article(self):
"""测试给不存在的文章点赞"""
self.client.login(username='testuser', password='testpass123')
response = self.client.post(
reverse('blog:like_article'),
{'article_id': 1145}, # 不存在的文章ID
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assertEqual(response.status_code, 400)
self.assertIn('文章不存在', response.json()['data'])
def test_like_without_login(self):
"""测试未登录用户点赞"""
response = self.client.post(
reverse('blog:like_article'),
{'article_id': self.article.id},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
# 应该重定向到登录页面或者返回错误
self.assertIn(response.status_code, [302, 403]) # 重定向或权限拒绝
def test_like_with_invalid_method(self):
"""测试使用错误的HTTP方法"""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(reverse('blog:like_article')) # 使用GET而不是POST
self.assertEqual(response.status_code, 405) # Method Not Allowed

@ -76,4 +76,8 @@ urlpatterns = [
r'clean',
views.clean_cache_view,
name='clean'),
path(
'like_article/',
views.LikeArticle.as_view(),
name='like_article'),
]

@ -5,13 +5,16 @@ import uuid
# bjy: 从Django中导入设置、分页器、HTTP响应、快捷函数、静态文件、时区、国际化、CSRF豁免和基于类的视图
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
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.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
@ -360,6 +363,43 @@ class EsSearchView(SearchView):
return context
class LikeArticle(View):
"""
处理文章点赞和取消点赞
"""
@method_decorator(login_required) # 确保只有登录用户才能点赞
def post(self, request):
try:
user = request.user
article_id = request.POST.get('article_id') # 获取文章ID
article = Article.objects.get(id=article_id) # 获取文章对象
# 检查当前用户是否已经为这篇文章点过赞
if article.users_like.filter(id=user.id).exists():
# 如果点过赞,则取消点赞 (从多对多关系中移除)
article.users_like.remove(user)
action_type = 0 # 0代表取消点赞
else:
# 如果没点过赞,则添加点赞 (添加到多对多关系)
article.users_like.add(user)
action_type = 1 # 1代表点赞
# 获取更新后的点赞总数
like_count = article.users_like.count()
# 返回JSON数据给前端
return JsonResponse({
'state': 200,
'type': action_type,
'like_sum': like_count
})
except Article.DoesNotExist:
return JsonResponse({'state': 400, 'data': '文章不存在'})
except Exception as e:
return JsonResponse({'state': 500, 'data': f'服务器错误: {e}'})
# bjy: 文件上传视图使用csrf_exempt豁免CSRF验证
@csrf_exempt
def fileupload(request):

@ -122,21 +122,29 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application'
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': env('DJANGO_MYSQL_DATABASE'),
'USER': env('DJANGO_MYSQL_USER'),
'PASSWORD': env('DJANGO_MYSQL_PASSWORD'),
'HOST': env('DJANGO_MYSQL_HOST'),
'PORT': int(
env('DJANGO_MYSQL_PORT')),
'OPTIONS': {
'charset': 'utf8mb4',
'ssl_mode': 'VERIFY_IDENTITY',
'ssl': {'ca': env('DJANGO_MYSQL_SSL_CA')}
},
}}
if TESTING:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': env('DJANGO_MYSQL_DATABASE'),
'USER': env('DJANGO_MYSQL_USER'),
'PASSWORD': env('DJANGO_MYSQL_PASSWORD'),
'HOST': env('DJANGO_MYSQL_HOST'),
'PORT': int(
env('DJANGO_MYSQL_PORT')),
'OPTIONS': {
'charset': 'utf8mb4',
'ssl_mode': 'VERIFY_IDENTITY',
'ssl': {'ca': env('DJANGO_MYSQL_SSL_CA')}
},
}}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

@ -8,6 +8,16 @@
<div id="content" role="main">
{% load_article_detail article False user %}
<!-- 点赞按钮 -->
<button id="like-button" data-article-id="{{ article.pk }}"
class="btn {% if request.user in article.users_like.all %}btn-primary{% else %}btn-outline-primary{% endif %}">
{% if request.user in article.users_like.all %}
👍 已赞同 <span class="like-count">{{ article.users_like.count }}</span>
{% else %}
👍 赞同 <span class="like-count">{{ article.users_like.count }}</span>
{% endif %}
</button>
{% if article.type == 'a' %}
<nav class="nav-single">
<h3 class="assistive-text">文章导航</h3>
@ -45,6 +55,57 @@
{% endif %}
</div><!-- #primary -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).ready(function() {
$('#like-button').click(function() {
const articleId = $(this).data('article-id'); // 获取文章ID
const likeButton = $(this); // 获取按钮本身
const likeCountSpan = $('#like-count'); // 获取点赞数显示的span
// 禁用按钮防止重复点击
likeButton.prop('disabled', true).text('处理中...');
// 发送AJAX POST请求
$.ajax({
url: "{% url 'blog:like_article' %}", // 使用Django模板标签生成URL确保url的name是'like_article'
type: "POST",
data: {
'article_id': articleId,
'csrfmiddlewaretoken': '{{ csrf_token }}' // Django CSRF令牌必须携带
},
dataType: 'json',
success: function(response) {
if (response.state === 200) {
// 更新点赞数量
likeCountSpan.text(response.like_sum);
// 根据操作类型(点赞或取消)更新按钮样式
if (response.type === 1) {
likeButton.removeClass('btn-outline-primary').addClass('btn-primary')
.html('👍 已赞同 <span class="like-count">' + response.like_sum + '</span>');
} else {
likeButton.removeClass('btn-primary').addClass('btn-outline-primary')
.html('👍 赞同 <span class="like-count">' + response.like_sum + '</span>');
}
} else {
alert(response.data); // 处理错误信息,例如"文章不存在"
}
},
error: function(xhr, status, error) {
// 处理请求失败的情况,例如网络问题
console.error("AJAX request failed: " + status + ", " + error);
alert('操作失败,请稍后重试。');
},
complete: function() {
// 重新启用按钮
likeButton.prop('disabled', false);
}
});
});
});
</script>
{% endblock %}
{% block sidebar %}

Loading…
Cancel
Save