Files
chunyu_project/article/views.py
T
2026-08-05 23:59:15 +08:00

630 lines
25 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 rest_framework import generics, 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.views import APIView
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
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)
@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}
)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
data = serializer.data
if request.user.is_authenticated:
from .models import ArticleFavorite
fav_ids = set(
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 = serializer.data
if request.user.is_authenticated:
from .models import ArticleFavorite
fav_ids = set(
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,
}
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
serializer.save()
track_task(request.user, 'post')
return create_standardized_response(
data=serializer.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,
}
)
def get(self, request, pk):
article = get_object_or_404(Article, pk=pk)
Article.objects.filter(pk=pk).update(views=F('views') + 1)
article.refresh_from_db()
serializer = ArticleDetailSerializer(article, context={'request': request})
data = serializer.data
if request.user.is_authenticated:
data['is_favorited'] = ArticleFavorite.objects.filter(
user=request.user, article=article
).exists()
else:
data['is_favorited'] = False
data['favorites_count'] = ArticleFavorite.objects.filter(article=article).count()
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,
}
)
def put(self, request, pk):
article = get_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():
serializer.save()
return create_standardized_response(data=serializer.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,
}
)
def delete(self, request, pk):
article = get_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.delete()
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)
@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,
}
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return create_standardized_response(data=serializer.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,
}
)
def create(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
with transaction.atomic():
comment = serializer.save(user=request.user, article=article)
comment_id = comment.id
comment_content = serializer.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
))
track_task(request.user, 'post')
return create_standardized_response(
data=serializer.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,
}
)
def post(self, request, pk):
article = get_object_or_404(Article, pk=pk)
like, created = ArticleLike.objects.get_or_create(user=request.user, article=article)
if not created:
like.delete()
article.likes = max(0, article.likes - 1)
article.save(update_fields=['likes'])
return create_standardized_response(
data={'liked': False, 'likes_count': article.likes},
code=ResponseCode.SUCCESS
)
article.likes = F('likes') + 1
article.save(update_fields=['likes'])
article.refresh_from_db()
if created and article.author != request.user:
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,
}
)
def post(self, request, pk):
article = get_object_or_404(Article, pk=pk)
fav, created = ArticleFavorite.objects.get_or_create(user=request.user, article=article)
if not created:
fav.delete()
count = article.article_favorites.count()
return create_standardized_response(
data={'favorited': False, 'favorites_count': count},
code=ResponseCode.SUCCESS
)
count = article.article_favorites.count()
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,
}
)
def post(self, request, pk):
comment = get_object_or_404(ArticleComment, pk=pk)
like, created = ArticleCommentLike.objects.get_or_create(user=request.user, comment=comment)
if not created:
like.delete()
comment.likes = max(0, comment.likes - 1)
comment.save(update_fields=['likes'])
return create_standardized_response(
data={'liked': False, 'likes_count': comment.likes},
code=ResponseCode.SUCCESS
)
comment.likes = comment.comment_likes.count()
comment.save(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')
@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,
}
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return create_standardized_response(data=serializer.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,
}
)
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 = qs.delete()[0]
elif action == 'publish':
count = qs.update(status='published')
elif action == 'draft':
count = qs.update(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,
}
)
def post(self, request, pk):
article = get_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
article.save(update_fields=['is_top'])
return create_standardized_response(
data={'is_top': article.is_top},
code=ResponseCode.SUCCESS,
message='置顶状态已更新'
)