feat(C-06):关注feed+课程聚合修复
文章列表新增author_user_id/is_following与feed=following过滤;课程students_count改真实在学人数去重统计、新增total_minutes(不可解析不伪造);author_name走sync_to_async防ASGI同步阻塞。
This commit is contained in:
+11
-1
@@ -45,10 +45,12 @@ class ArticleCommentSerializer(ModelSerializer):
|
||||
class ArticleListSerializer(ModelSerializer):
|
||||
author_name = CharField(source='author.first_name', read_only=True)
|
||||
author_avatar = ImageField(source='author.avatar', read_only=True)
|
||||
author_user_id = serializers.PrimaryKeyRelatedField(source='author', read_only=True)
|
||||
comments_count = SerializerMethodField()
|
||||
read_time = SerializerMethodField()
|
||||
is_hot = SerializerMethodField()
|
||||
is_new = SerializerMethodField()
|
||||
is_following = SerializerMethodField()
|
||||
publish_date = DateTimeField(source='created_at', format='%Y-%m-%d', read_only=True)
|
||||
image = SerializerMethodField()
|
||||
|
||||
@@ -56,10 +58,18 @@ class ArticleListSerializer(ModelSerializer):
|
||||
model = Article
|
||||
fields = [
|
||||
'id', 'title', 'excerpt', 'category', 'author_name', 'author_avatar',
|
||||
'views', 'likes', 'comments_count', 'publish_date', 'image', 'tags',
|
||||
'author_user_id', 'views', 'likes', 'comments_count', 'publish_date', 'image', 'tags',
|
||||
'read_time', 'is_featured', 'is_hot', 'is_new', 'is_top', 'status',
|
||||
'is_following',
|
||||
]
|
||||
|
||||
async def get_is_following(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
from user.models import Follow
|
||||
return await Follow.objects.filter(follower=request.user, following=obj.author_id).aexists()
|
||||
return False
|
||||
|
||||
async def get_comments_count(self, obj):
|
||||
return await obj.comments.acount()
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""C-06 关注 feed 回归:关注/未关注/互关/取关/匿名/分页对齐。"""
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from article.models import Article
|
||||
from user.models import Follow, FUser
|
||||
|
||||
|
||||
class FollowingFeedTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.a = FUser.objects.create_user(username='c06fa', password='x12345678')
|
||||
self.b = FUser.objects.create_user(username='c06fb', password='x12345678')
|
||||
self.c = FUser.objects.create_user(username='c06fc', password='x12345678')
|
||||
self.art_b = Article.objects.create(title='B文', content='x' * 500, author=self.b, status='published')
|
||||
self.art_c = Article.objects.create(title='C文', content='y' * 500, author=self.c, status='published')
|
||||
|
||||
def test_following_only_shows_followed(self):
|
||||
Follow.objects.create(follower=self.a, following=self.b)
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
titles = [x['title'] for x in r.json()['data']['results']]
|
||||
self.assertEqual(titles, ['B文'])
|
||||
|
||||
def test_unfollowed_empty_state(self):
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 0)
|
||||
self.assertEqual(r.json()['data']['results'], [])
|
||||
|
||||
def test_mutual_follow_both_visible(self):
|
||||
Follow.objects.create(follower=self.a, following=self.b)
|
||||
Follow.objects.create(follower=self.a, following=self.c)
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 2)
|
||||
|
||||
def test_unfollow_removes_from_feed(self):
|
||||
f = Follow.objects.create(follower=self.a, following=self.b)
|
||||
f.delete()
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 0)
|
||||
|
||||
def test_anonymous_following_empty(self):
|
||||
Follow.objects.create(follower=self.a, following=self.b)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 0)
|
||||
|
||||
def test_normal_list_unaffected(self):
|
||||
Follow.objects.create(follower=self.a, following=self.b)
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 2)
|
||||
|
||||
def test_draft_not_in_feed(self):
|
||||
Follow.objects.create(follower=self.a, following=self.b)
|
||||
self.art_b.status = 'draft'
|
||||
self.art_b.save(update_fields=['status'])
|
||||
self.client.force_authenticate(user=self.a)
|
||||
r = self.client.get('/article/articles/', {'feed': 'following'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['count'], 0)
|
||||
@@ -77,6 +77,17 @@ class ArticleListCreateView(generics.ListCreateAPIView):
|
||||
Q(tags__overlap=[search])
|
||||
)
|
||||
|
||||
# 关注 feed:仅返回当前用户关注作者的文章
|
||||
feed = self.request.query_params.get('feed')
|
||||
if feed == 'following':
|
||||
if not self.request.user.is_authenticated:
|
||||
return Article.objects.none()
|
||||
from user.models import Follow
|
||||
followed_ids = Follow.objects.filter(
|
||||
follower=self.request.user
|
||||
).values_list('following_id', flat=True)
|
||||
qs = qs.filter(author_id__in=followed_ids)
|
||||
|
||||
ordering = self.request.query_params.get('ordering', '-created_at')
|
||||
return qs.order_by('-is_top', ordering)
|
||||
|
||||
@@ -94,6 +105,7 @@ class ArticleListCreateView(generics.ListCreateAPIView):
|
||||
openapi.Parameter('category', openapi.IN_QUERY, description='文章分类筛选', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('tag', openapi.IN_QUERY, description='文章标签筛选', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('search', openapi.IN_QUERY, description='搜索关键词(标题、摘要、标签)', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('feed', openapi.IN_QUERY, description='feed=following 时仅返回当前用户关注作者的文章(需登录)', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('ordering', openapi.IN_QUERY, description='排序方式,默认 -created_at', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('page', openapi.IN_QUERY, description='页码', type=openapi.TYPE_INTEGER),
|
||||
openapi.Parameter('page_size', openapi.IN_QUERY, description='每页数量,默认10,最大100', type=openapi.TYPE_INTEGER),
|
||||
|
||||
+81
-10
@@ -1,9 +1,67 @@
|
||||
from adrf.serializers import (
|
||||
ModelSerializer, SerializerMethodField,
|
||||
)
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from .models import Course, Chapter, ChapterContent, CourseMaterial
|
||||
|
||||
|
||||
def _author_display(obj):
|
||||
"""同步取作者展示名(跑在 sync_to_async 线程里,避免 ASGI 下 SynchronousOnlyOperation)。"""
|
||||
try:
|
||||
author = obj.author
|
||||
except Exception:
|
||||
return ''
|
||||
if author is None:
|
||||
return ''
|
||||
try:
|
||||
full = author.get_full_name()
|
||||
except Exception:
|
||||
full = ''
|
||||
try:
|
||||
username = author.username
|
||||
except Exception:
|
||||
username = ''
|
||||
return full or username or ''
|
||||
|
||||
|
||||
def parse_duration_minutes(raw):
|
||||
"""把章节 duration 字符串解析为分钟数;解析不出返回 None。
|
||||
|
||||
存量格式为中文字符串(如 '30分钟'),纯数字视为分钟。
|
||||
total_minutes 只对可解析章节求和,不可解析章节不计入(不伪造)。
|
||||
"""
|
||||
import re
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
m = re.search(r'(\d+(?:\.\d+)?)', s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(float(m.group(1)))
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _course_total_minutes(obj):
|
||||
"""同步聚合课程总分钟数(跑在 sync_to_async 线程里)。"""
|
||||
try:
|
||||
durations = list(obj.chapters.values_list('duration', flat=True))
|
||||
except Exception:
|
||||
return None
|
||||
total = 0
|
||||
any_parsed = False
|
||||
for d in durations:
|
||||
m = parse_duration_minutes(d)
|
||||
if m is not None:
|
||||
total += m
|
||||
any_parsed = True
|
||||
return total if any_parsed else None
|
||||
|
||||
|
||||
class ChapterSerializer(ModelSerializer):
|
||||
video_poster_url = SerializerMethodField()
|
||||
video_local_url = SerializerMethodField()
|
||||
@@ -100,6 +158,7 @@ class CourseListSerializer(ModelSerializer):
|
||||
author_name = SerializerMethodField()
|
||||
chapters_count = SerializerMethodField()
|
||||
students_count = SerializerMethodField()
|
||||
total_minutes = SerializerMethodField()
|
||||
cover_image_url = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -107,7 +166,7 @@ class CourseListSerializer(ModelSerializer):
|
||||
fields = [
|
||||
'id', 'title', 'description', 'category', 'level',
|
||||
'cover_image_url', 'color', 'icon_name', 'author_name',
|
||||
'chapters_count', 'students_count', 'status',
|
||||
'chapters_count', 'students_count', 'total_minutes', 'status',
|
||||
'sort_order', 'is_hot', 'is_new', 'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
@@ -115,7 +174,15 @@ class CourseListSerializer(ModelSerializer):
|
||||
return await obj.chapters.acount()
|
||||
|
||||
async def get_students_count(self, obj):
|
||||
return 0
|
||||
# 从章节阅读记录去重统计真实在学人数
|
||||
from user.models import FUser
|
||||
return await FUser.objects.filter(
|
||||
chapter_reads__course=obj
|
||||
).distinct().acount()
|
||||
|
||||
async def get_total_minutes(self, obj):
|
||||
# 可解析章节 duration 求和;全不可解析返回 None(前端不显示,不伪造)
|
||||
return await sync_to_async(_course_total_minutes)(obj)
|
||||
|
||||
async def get_cover_image_url(self, obj):
|
||||
if obj.cover_image:
|
||||
@@ -126,9 +193,7 @@ class CourseListSerializer(ModelSerializer):
|
||||
return ''
|
||||
|
||||
async def get_author_name(self, obj):
|
||||
if obj.author:
|
||||
return obj.author.get_full_name() or obj.author.username
|
||||
return ''
|
||||
return await sync_to_async(_author_display)(obj)
|
||||
|
||||
|
||||
class CourseDetailSerializer(ModelSerializer):
|
||||
@@ -136,6 +201,7 @@ class CourseDetailSerializer(ModelSerializer):
|
||||
chapters = ChapterSerializer(many=True, read_only=True)
|
||||
chapters_count = SerializerMethodField()
|
||||
students_count = SerializerMethodField()
|
||||
total_minutes = SerializerMethodField()
|
||||
cover_image_url = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -143,7 +209,7 @@ class CourseDetailSerializer(ModelSerializer):
|
||||
fields = [
|
||||
'id', 'title', 'description', 'category', 'level',
|
||||
'cover_image_url', 'color', 'icon_name', 'author_name',
|
||||
'chapters', 'chapters_count', 'students_count', 'status',
|
||||
'chapters', 'chapters_count', 'students_count', 'total_minutes', 'status',
|
||||
'sort_order', 'is_hot', 'is_new', 'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
@@ -151,7 +217,14 @@ class CourseDetailSerializer(ModelSerializer):
|
||||
return await obj.chapters.acount()
|
||||
|
||||
async def get_students_count(self, obj):
|
||||
return 0
|
||||
# 从章节阅读记录去重统计真实在学人数
|
||||
from user.models import FUser
|
||||
return await FUser.objects.filter(
|
||||
chapter_reads__course=obj
|
||||
).distinct().acount()
|
||||
|
||||
async def get_total_minutes(self, obj):
|
||||
return await sync_to_async(_course_total_minutes)(obj)
|
||||
|
||||
async def get_cover_image_url(self, obj):
|
||||
if obj.cover_image:
|
||||
@@ -162,9 +235,7 @@ class CourseDetailSerializer(ModelSerializer):
|
||||
return ''
|
||||
|
||||
async def get_author_name(self, obj):
|
||||
if obj.author:
|
||||
return obj.author.get_full_name() or obj.author.username
|
||||
return ''
|
||||
return await sync_to_async(_author_display)(obj)
|
||||
|
||||
|
||||
class CourseCreateUpdateSerializer(ModelSerializer):
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""C-06 签到幂等 + 学习聚合去重回归。"""
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from learn.models import Chapter, ChapterRead, Course
|
||||
from user.models import DailyCheckin, FUser
|
||||
|
||||
|
||||
class CheckinIdempotentTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.u = FUser.objects.create_user(username='c06sa', password='x12345678')
|
||||
|
||||
def test_double_checkin_second_400_no_double_points(self):
|
||||
self.client.force_authenticate(user=self.u)
|
||||
r1 = self.client.post('/user/wallet/checkin/')
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
earned = r1.json()['data']['earned']
|
||||
self.u.refresh_from_db()
|
||||
p1 = self.u.points
|
||||
self.assertGreaterEqual(p1, earned)
|
||||
r2 = self.client.post('/user/wallet/checkin/')
|
||||
self.assertEqual(r2.status_code, 400)
|
||||
self.u.refresh_from_db()
|
||||
self.assertEqual(self.u.points, p1)
|
||||
self.assertEqual(DailyCheckin.objects.filter(user=self.u).count(), 1)
|
||||
|
||||
def test_checkin_status_reflects_signed(self):
|
||||
self.client.force_authenticate(user=self.u)
|
||||
self.client.post('/user/wallet/checkin/')
|
||||
r = self.client.get('/user/wallet/checkin/status/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json()['data']['signed_today'])
|
||||
|
||||
def test_anonymous_checkin_401(self):
|
||||
r = self.client.post('/user/wallet/checkin/')
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
|
||||
class LearnersCountTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.u1 = FUser.objects.create_user(username='c06la', password='x12345678')
|
||||
self.u2 = FUser.objects.create_user(username='c06lb', password='x12345678')
|
||||
self.course = Course.objects.create(title='C06聚合课', author=self.u1, status='published')
|
||||
self.ch1 = Chapter.objects.create(course=self.course, title='第一章')
|
||||
self.ch2 = Chapter.objects.create(course=self.course, title='第二章')
|
||||
|
||||
def test_cross_read_dedup_two_users(self):
|
||||
ChapterRead.objects.create(user=self.u1, chapter=self.ch1, course=self.course)
|
||||
ChapterRead.objects.create(user=self.u1, chapter=self.ch2, course=self.course)
|
||||
ChapterRead.objects.create(user=self.u2, chapter=self.ch1, course=self.course)
|
||||
r = self.client.get('/learn/courses/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
items = {x['title']: x for x in r.json()['data']}
|
||||
self.assertEqual(items['C06聚合课']['students_count'], 2)
|
||||
|
||||
def test_no_read_zero(self):
|
||||
r = self.client.get('/learn/courses/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
items = {x['title']: x for x in r.json()['data']}
|
||||
self.assertEqual(items['C06聚合课']['students_count'], 0)
|
||||
|
||||
def test_detail_students_count_matches(self):
|
||||
ChapterRead.objects.create(user=self.u1, chapter=self.ch1, course=self.course)
|
||||
r = self.client.get(f'/learn/courses/{self.course.id}/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['data']['students_count'], 1)
|
||||
|
||||
|
||||
class TotalMinutesTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.u = FUser.objects.create_user(username='c06ma', password='x12345678')
|
||||
|
||||
def test_sums_parsable_durations(self):
|
||||
course = Course.objects.create(title='C06时长课', author=self.u, status='published')
|
||||
Chapter.objects.create(course=course, title='C1', duration='30分钟')
|
||||
Chapter.objects.create(course=course, title='C2', duration='45分钟')
|
||||
r = self.client.get('/learn/courses/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
items = {x['title']: x for x in r.json()['data']}
|
||||
self.assertEqual(items['C06时长课']['total_minutes'], 75)
|
||||
|
||||
def test_unparsable_returns_none(self):
|
||||
course = Course.objects.create(title='C06无时长课', author=self.u, status='published')
|
||||
Chapter.objects.create(course=course, title='C1', duration='')
|
||||
Chapter.objects.create(course=course, title='C2', duration='未知')
|
||||
r = self.client.get(f'/learn/courses/{course.id}/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIsNone(r.json()['data']['total_minutes'])
|
||||
Reference in New Issue
Block a user