Files
chunyu_project/article/views.py
T

659 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from adrf import generics
from adrf.generics import aget_object_or_404
from adrf.mixins import get_data
from adrf.views import APIView
from asgiref.sync import sync_to_async
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from django.db.models import F
from django.db import transaction
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from utils.response_codes import ResponseCode, create_standardized_response, create_standardized_error_response
from message.utils import create_message
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
from user.models import FUser
from user.utils import track_task
from .models import Article, ArticleComment, ArticleLike, ArticleFavorite, ArticleCommentLike
from .serializers import (
ArticleListSerializer, ArticleDetailSerializer,
ArticleCreateUpdateSerializer, ArticleCommentSerializer,
ArticleManageSerializer,
)
class ArticlePagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
def get_paginated_response(self, data):
return Response({
'code': 10000,
'message': 'Success',
'data': {
'count': self.page.paginator.count,
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'results': data,
}
})
class ArticleListCreateView(generics.ListCreateAPIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
pagination_class = ArticlePagination
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return [IsAuthenticated()]
def get_serializer_class(self):
if self.request.method == 'POST':
return ArticleCreateUpdateSerializer
return ArticleListSerializer
def get_queryset(self):
qs = Article.objects.filter(status='published')
category = self.request.query_params.get('category')
if category and category != 'all':
qs = qs.filter(category=category)
tag = self.request.query_params.get('tag')
if tag:
qs = qs.filter(tags__contains=[tag])
search = self.request.query_params.get('search')
if search:
from django.db.models import Q
qs = qs.filter(
Q(title__icontains=search) |
Q(excerpt__icontains=search) |
Q(tags__overlap=[search])
)
ordering = self.request.query_params.get('ordering', '-created_at')
return qs.order_by('-is_top', ordering)
async def get(self, request, *args, **kwargs):
return await self.list(request, *args, **kwargs)
async def post(self, request, *args, **kwargs):
return await self.create(request, *args, **kwargs)
@swagger_auto_schema(
tags=['文章'],
operation_summary='获取文章列表',
operation_description='分页获取已发布文章列表,支持按分类和排序筛选,登录用户可查看收藏状态',
manual_parameters=[
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('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),
],
responses={200: success_response}
)
async def list(self, request, *args, **kwargs):
queryset = await self.afilter_queryset(self.get_queryset())
page = await self.apaginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
data = await get_data(serializer)
if request.user.is_authenticated:
from .models import ArticleFavorite
fav_ids = set([
v async for v in ArticleFavorite.objects.filter(user=request.user).values_list('article_id', flat=True)
])
for item in data:
item['is_favorited'] = item['id'] in fav_ids
else:
for item in data:
item['is_favorited'] = False
return self.get_paginated_response(data)
serializer = self.get_serializer(queryset, many=True)
data = await get_data(serializer)
if request.user.is_authenticated:
from .models import ArticleFavorite
fav_ids = set([
v async for v in ArticleFavorite.objects.filter(user=request.user).values_list('article_id', flat=True)
])
for item in data:
item['is_favorited'] = item['id'] in fav_ids
else:
for item in data:
item['is_favorited'] = False
return Response({'code': 10000, 'message': 'Success', 'data': data})
@swagger_auto_schema(
tags=['文章'],
operation_summary='创建文章',
operation_description='创建新文章,需要登录',
request_body=ArticleCreateUpdateSerializer,
responses={
201: success_response,
400: error_response,
401: unauthorized_response,
}
)
async def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
await serializer.asave()
await sync_to_async(track_task)(request.user, 'post')
data = await get_data(serializer)
return create_standardized_response(
data=data,
code=ResponseCode.SUCCESS,
message='文章创建成功',
status_code=status.HTTP_201_CREATED
)
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
message='提交失败',
status_code=status.HTTP_400_BAD_REQUEST
)
class ArticleDetailView(APIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
permission_classes = [IsAuthenticatedOrReadOnly]
@swagger_auto_schema(
tags=['文章'],
operation_summary='获取文章详情',
operation_description='获取指定文章的详细信息,同时增加浏览量,登录用户可查看收藏状态',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
404: not_found_response,
}
)
async def get(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
await Article.objects.filter(pk=pk).aupdate(views=F('views') + 1)
await article.arefresh_from_db()
serializer = ArticleDetailSerializer(article, context={'request': request})
data = await get_data(serializer)
if request.user.is_authenticated:
data['is_favorited'] = await ArticleFavorite.objects.filter(
user=request.user, article=article
).aexists()
else:
data['is_favorited'] = False
data['favorites_count'] = await ArticleFavorite.objects.filter(article=article).acount()
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['文章'],
operation_summary='更新文章',
operation_description='更新指定文章,仅文章作者可操作',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=ArticleCreateUpdateSerializer,
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
403: error_response,
404: not_found_response,
}
)
async def put(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
if article.author != request.user:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='无权操作',
status_code=status.HTTP_403_FORBIDDEN
)
serializer = ArticleCreateUpdateSerializer(article, data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
await serializer.asave()
data = await get_data(serializer)
return create_standardized_response(data=data, code=ResponseCode.SUCCESS, message='文章更新成功')
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
@swagger_auto_schema(
tags=['文章'],
operation_summary='删除文章',
operation_description='删除指定文章,仅文章作者可操作',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
204: success_response,
401: unauthorized_response,
403: error_response,
404: not_found_response,
}
)
async def delete(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
if article.author != request.user:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='无权操作',
status_code=status.HTTP_403_FORBIDDEN
)
await article.adelete()
return create_standardized_response(code=ResponseCode.SUCCESS, message='文章删除成功', status_code=status.HTTP_204_NO_CONTENT)
class ArticleCommentListCreateView(generics.ListCreateAPIView):
parser_classes = [JSONParser, MultiPartParser]
pagination_class = ArticlePagination
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return [IsAuthenticated()]
serializer_class = ArticleCommentSerializer
def get_queryset(self):
article_id = self.kwargs['article_id']
return ArticleComment.objects.filter(article_id=article_id, parent__isnull=True)
async def get(self, request, *args, **kwargs):
return await self.list(request, *args, **kwargs)
async def post(self, request, *args, **kwargs):
return await self.create(request, *args, **kwargs)
@swagger_auto_schema(
tags=['文章'],
operation_summary='获取文章评论列表',
operation_description='获取指定文章的顶级评论列表,包含子评论',
manual_parameters=[
openapi.Parameter('article_id', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('page', openapi.IN_QUERY, description='页码', type=openapi.TYPE_INTEGER),
openapi.Parameter('page_size', openapi.IN_QUERY, description='每页数量,默认10', type=openapi.TYPE_INTEGER),
],
responses={
200: success_response,
404: not_found_response,
}
)
async def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
page = await self.apaginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
data = await get_data(serializer)
return self.get_paginated_response(data)
serializer = self.get_serializer(queryset, many=True)
data = await get_data(serializer)
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['文章'],
operation_summary='发表文章评论',
operation_description='对指定文章发表评论,支持回复其他评论,需要登录',
manual_parameters=[
openapi.Parameter('article_id', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=ArticleCommentSerializer,
responses={
201: success_response,
400: error_response,
401: unauthorized_response,
404: not_found_response,
}
)
async def create(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = await aget_object_or_404(Article, pk=article_id)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
def _save_and_notify():
with transaction.atomic():
comment = serializer.save(user=request.user, article=article)
comment_id = comment.id
comment_content = serializer.validated_data.get('content', '')
article_title = article.title
article_author_id = article.author_id
parent_comment_id = comment.parent_id
parent_comment_user_id = comment.parent.user_id if comment.parent else None
user_id = request.user.id
user_nickname = request.user.nickname or request.user.username
transaction.on_commit(lambda: self._send_notification_messages(
article_id, article_title, article_author_id, comment_id,
comment_content, parent_comment_id, parent_comment_user_id,
user_id, user_nickname
))
return comment
await sync_to_async(_save_and_notify)()
await sync_to_async(track_task)(request.user, 'post')
data = await get_data(serializer)
return create_standardized_response(
data=data,
code=ResponseCode.SUCCESS,
message='评论成功',
status_code=status.HTTP_201_CREATED
)
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
def _send_notification_messages(self, article_id, article_title, article_author_id,
comment_id, comment_content, parent_comment_id,
parent_comment_user_id, user_id, user_nickname):
"""异步发送通知消息"""
import re as re_module
from user.models import FUser
try:
sender_user = FUser.objects.get(id=user_id)
except FUser.DoesNotExist:
return
if parent_comment_id and parent_comment_user_id and parent_comment_user_id != user_id:
try:
parent_user = FUser.objects.get(id=parent_comment_user_id)
create_message(
recipient=parent_user,
sender=sender_user,
msg_type='reply',
title=f'{user_nickname} 回复了你的评论',
content=comment_content,
extra_info=f'在《{article_title}》文章下',
target_link=f'/article/{article_id}#comment-{comment_id}',
target_type='comment',
target_id=comment_id,
)
except FUser.DoesNotExist:
pass
if article_author_id != user_id and (not parent_comment_id or parent_comment_user_id != article_author_id):
try:
article_author = FUser.objects.get(id=article_author_id)
create_message(
recipient=article_author,
sender=sender_user,
msg_type='reply',
title=f'{user_nickname} 评论了你的文章',
content=comment_content,
extra_info=f'在《{article_title}》文章下',
target_link=f'/article/{article_id}#comment-{comment_id}',
target_type='comment',
target_id=comment_id,
)
except FUser.DoesNotExist:
pass
at_matches = re_module.findall(r'@(\w+)', comment_content)
if at_matches:
mentioned_users = FUser.objects.filter(username__in=at_matches).exclude(id=user_id)
for mentioned_user in mentioned_users:
if mentioned_user.id != article_author_id:
create_message(
recipient=mentioned_user,
sender=sender_user,
msg_type='at_me',
title=f'{user_nickname} @了你',
content=comment_content,
extra_info=f'在《{article_title}》文章下',
target_link=f'/article/{article_id}#comment-{comment_id}',
target_type='comment',
target_id=comment_id,
)
class ArticleLikeToggleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['文章'],
operation_summary='点赞/取消点赞文章',
operation_description='切换文章点赞状态,已点赞则取消,未点赞则点赞,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
}
)
async def post(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
like, created = await ArticleLike.objects.aget_or_create(user=request.user, article=article)
if not created:
await like.adelete()
await Article.objects.filter(pk=pk).aupdate(likes=F("likes") - 1)
await article.arefresh_from_db()
article.likes = max(0, article.likes)
return create_standardized_response(
data={'liked': False, 'likes_count': article.likes},
code=ResponseCode.SUCCESS
)
article.likes = F('likes') + 1
await article.asave(update_fields=['likes'])
await article.arefresh_from_db()
if created and article.author != request.user:
await sync_to_async(create_message)(
recipient=article.author,
sender=request.user,
msg_type='like',
title=f'{request.user.nickname or request.user.username} 赞了你的文章',
content=f'《{article.title}》',
extra_info='',
target_link=f'/article/{pk}',
target_type='article',
target_id=pk,
)
return create_standardized_response(
data={'liked': True, 'likes_count': article.likes},
code=ResponseCode.SUCCESS
)
class ArticleFavoriteToggleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['文章'],
operation_summary='收藏/取消收藏文章',
operation_description='切换文章收藏状态,已收藏则取消,未收藏则收藏,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
}
)
async def post(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
fav, created = await ArticleFavorite.objects.aget_or_create(user=request.user, article=article)
if not created:
await fav.adelete()
count = await article.article_favorites.acount()
return create_standardized_response(
data={'favorited': False, 'favorites_count': count},
code=ResponseCode.SUCCESS
)
count = await article.article_favorites.acount()
return create_standardized_response(
data={'favorited': True, 'favorites_count': count},
code=ResponseCode.SUCCESS
)
class ArticleCommentLikeToggleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['文章'],
operation_summary='点赞/取消点赞评论',
operation_description='切换文章评论点赞状态,已点赞则取消,未点赞则点赞,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='评论ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
}
)
async def post(self, request, pk):
comment = await aget_object_or_404(ArticleComment, pk=pk)
like, created = await ArticleCommentLike.objects.aget_or_create(user=request.user, comment=comment)
if not created:
await like.adelete()
await ArticleComment.objects.filter(pk=pk).aupdate(likes=F("likes") - 1)
await comment.arefresh_from_db()
comment.likes = max(0, comment.likes)
return create_standardized_response(
data={'liked': False, 'likes_count': comment.likes},
code=ResponseCode.SUCCESS
)
comment.likes = await comment.comment_likes.acount()
await comment.asave(update_fields=['likes'])
return create_standardized_response(
data={'liked': True, 'likes_count': comment.likes},
code=ResponseCode.SUCCESS
)
class MyArticleListView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = ArticleManageSerializer
parser_classes = [JSONParser]
def get_queryset(self):
qs = Article.objects.filter(author=self.request.user)
st = self.request.query_params.get('status')
if st:
qs = qs.filter(status=st)
return qs.order_by('-updated_at')
async def get(self, request, *args, **kwargs):
return await self.list(request, *args, **kwargs)
@swagger_auto_schema(
tags=['文章'],
operation_summary='获取我的文章列表',
operation_description='获取当前登录用户的所有文章,支持按状态筛选',
manual_parameters=[
openapi.Parameter('status', openapi.IN_QUERY, description='文章状态筛选(published/draft)', type=openapi.TYPE_STRING),
],
responses={
200: success_response,
401: unauthorized_response,
}
)
async def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
data = await get_data(serializer)
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
class MyArticleBatchView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
@swagger_auto_schema(
tags=['文章'],
operation_summary='批量操作文章',
operation_description='批量发布、转为草稿或删除文章,需要登录',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['ids', 'action'],
properties={
'ids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description='文章ID列表',
),
'action': openapi.Schema(
type=openapi.TYPE_STRING,
enum=['publish', 'draft', 'delete'],
description='操作类型:publish-发布,draft-转草稿,delete-删除',
),
},
),
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
}
)
async def post(self, request):
ids = request.data.get('ids', [])
action = request.data.get('action', '')
if not ids or action not in ('publish', 'draft', 'delete'):
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='参数错误',
status_code=status.HTTP_400_BAD_REQUEST
)
qs = Article.objects.filter(id__in=ids, author=request.user)
if action == 'delete':
count = (await qs.adelete())[0]
elif action == 'publish':
count = await qs.aupdate(status='published')
elif action == 'draft':
count = await qs.aupdate(status='draft')
return create_standardized_response(
data={'affected': count},
code=ResponseCode.SUCCESS,
message=f'批量操作成功,影响 {count} 篇文章'
)
class ArticleToggleTopView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['文章'],
operation_summary='切换文章置顶状态',
operation_description='切换文章的置顶状态,仅文章作者可操作,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='文章ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
403: error_response,
404: not_found_response,
}
)
async def post(self, request, pk):
article = await aget_object_or_404(Article, pk=pk)
if article.author != request.user:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='无权操作',
status_code=status.HTTP_403_FORBIDDEN
)
article.is_top = not article.is_top
await article.asave(update_fields=['is_top'])
return create_standardized_response(
data={'is_top': article.is_top},
code=ResponseCode.SUCCESS,
message='置顶状态已更新'
)