from django.db.models import Avg, Count, F, Q from django.db.models.functions import Greatest from django_filters.rest_framework import DjangoFilterBackend from rest_framework import permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from .filters import ( ArticleFilter, MagazineCategoryFilter, MagazineFilter, OrganizeCategoryFilter, OrganizeFilter, ProfessorFilter, SubjectFilter, WorkGroupFilter, WorkGroupTermFilter, ) from .models import ( Article, ArticleCollect, ArticleComment, ArticleLike, ArticleShare, ArticleType, Column, Magazine, MagazineCategory, NavPosition, Organize, OrganizeCategory, Professor, RuleBand, Specialty, StarRule, Subject, WorkGroup, WorkGroupMembership, WorkGroupTerm, ) from .serializers import ( ArticleCommentSerializer, ArticleListSerializer, ArticleSerializer, ArticleTypeSerializer, ArticleWriteSerializer, ColumnSerializer, MagazineCategorySerializer, MagazineSerializer, OrganizeCategorySerializer, OrganizeListSerializer, OrganizeSerializer, ProfessorSerializer, RuleBandSerializer, SpecialtySerializer, StarRuleSerializer, SubjectSerializer, WorkGroupMembershipSerializer, WorkGroupSerializer, WorkGroupTermSerializer, ) class IsAdminOrReadOnly(permissions.BasePermission): def has_permission(self, request, view): if request.method in permissions.SAFE_METHODS: return True return bool(request.user and request.user.is_staff) class IsMember(permissions.BasePermission): """前台会员(非 staff、已绑定 Member 且未禁用)。""" def has_permission(self, request, view): user = request.user if not user or not user.is_authenticated or user.is_staff: return False member = getattr(user, "member_profile", None) return bool(member and not member.is_disabled) def _request_member(request): return getattr(request.user, "member_profile", None) # 统计未禁用文献数(反向 M2M Count 不会走 SoftDeleteManager) _ACTIVE_ARTICLE_COUNT = Count( "articles", filter=Q(articles__is_disabled=False, articles__is_deleted=False), distinct=True, ) class PublicMixin: """公众只读:非管理员默认过滤禁用项;管理员可看全量以便后台管理。""" def get_queryset(self): qs = super().get_queryset() user = getattr(self.request, "user", None) if user and user.is_staff: return qs if self.action in ( "list", "retrieve", "ranking", "nav", "home", "facets", "record_read", "record_share", "toggle_like", "toggle_collect", "my_collections", "my_engagement", ) and hasattr(qs.model, "is_disabled"): qs = qs.filter(is_disabled=False) return qs class ColumnViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Column.objects.all() serializer_class = ColumnSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["position", "parent", "is_disabled"] ordering_fields = ["sort", "id"] ordering = ["position", "sort", "id"] @action(detail=False, methods=["get"]) def nav(self, request): position = request.query_params.get("position", NavPosition.TOP) qs = self.get_queryset().filter(position=position, parent__isnull=True) return Response(ColumnSerializer(qs, many=True).data) class SubjectViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Subject.objects.all() serializer_class = SubjectSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = SubjectFilter def get_queryset(self): qs = super().get_queryset() if self.action == "list": qs = qs.annotate(article_count=_ACTIVE_ARTICLE_COUNT) return qs class OrganizeCategoryViewSet(PublicMixin, viewsets.ModelViewSet): queryset = OrganizeCategory.objects.all() serializer_class = OrganizeCategorySerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = OrganizeCategoryFilter class MagazineCategoryViewSet(PublicMixin, viewsets.ModelViewSet): queryset = MagazineCategory.objects.all() serializer_class = MagazineCategorySerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = MagazineCategoryFilter class OrganizeViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Organize.objects.all() permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = OrganizeFilter def get_queryset(self): qs = super().get_queryset() if self.action in ("list", "ranking"): qs = qs.annotate( article_count=_ACTIVE_ARTICLE_COUNT, avg_score=Avg( "articles__score", filter=Q(articles__is_disabled=False, articles__is_deleted=False), ), ) return qs def get_serializer_class(self): if self.action == "list": return OrganizeListSerializer return OrganizeSerializer @action(detail=False, methods=["get"]) def ranking(self, request): qs = self.get_queryset().order_by("-avg_score", "-article_count") page = self.paginate_queryset(qs) data = OrganizeListSerializer(page or qs, many=True).data if page is not None: return self.get_paginated_response(data) return Response(data) class MagazineViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Magazine.objects.prefetch_related("subjects").all() serializer_class = MagazineSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = MagazineFilter def get_queryset(self): qs = super().get_queryset() if self.action in ("list", "ranking"): qs = qs.annotate( article_count=_ACTIVE_ARTICLE_COUNT, avg_score=Avg( "articles__score", filter=Q(articles__is_disabled=False, articles__is_deleted=False), ), ) return qs @action(detail=False, methods=["get"]) def ranking(self, request): qs = self.get_queryset().order_by("-avg_score", "-article_count") page = self.paginate_queryset(qs) data = MagazineSerializer(page or qs, many=True).data if page is not None: return self.get_paginated_response(data) return Response(data) class WorkGroupViewSet(PublicMixin, viewsets.ModelViewSet): queryset = WorkGroup.objects.all() serializer_class = WorkGroupSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = WorkGroupFilter class WorkGroupTermViewSet(PublicMixin, viewsets.ModelViewSet): queryset = WorkGroupTerm.objects.select_related("workgroup").all() serializer_class = WorkGroupTermSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = WorkGroupTermFilter class WorkGroupMembershipViewSet(viewsets.ModelViewSet): queryset = WorkGroupMembership.objects.select_related( "professor", "term", "term__workgroup" ).all() serializer_class = WorkGroupMembershipSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["professor", "term"] class ProfessorViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Professor.objects.prefetch_related( "terms", "memberships__term__workgroup" ).all() serializer_class = ProfessorSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = ProfessorFilter class SpecialtyViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Specialty.objects.all() serializer_class = SpecialtySerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["type", "is_disabled"] class StarRuleViewSet(PublicMixin, viewsets.ModelViewSet): queryset = StarRule.objects.prefetch_related("bands").all() serializer_class = StarRuleSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["year", "is_disabled"] class RuleBandViewSet(viewsets.ModelViewSet): queryset = RuleBand.objects.all() serializer_class = RuleBandSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["rule", "is_disabled"] class ArticleTypeViewSet(PublicMixin, viewsets.ModelViewSet): queryset = ArticleType.objects.all() serializer_class = ArticleTypeSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["is_disabled"] class ArticleViewSet(PublicMixin, viewsets.ModelViewSet): queryset = Article.objects.select_related("type").prefetch_related( "organizes", "magazines", "subjects" ).all() permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_class = ArticleFilter def get_serializer_class(self): if self.action in ("create", "update", "partial_update"): return ArticleWriteSerializer if self.action == "list": return ArticleListSerializer return ArticleSerializer def _bump_counter(self, field): """前台互动计数 +1;先 get_object 以尊重禁用过滤。""" instance = self.get_object() Article.objects.filter(pk=instance.pk).update(**{field: F(field) + 1}) instance.refresh_from_db(fields=[field]) return Response({"id": instance.id, field: getattr(instance, field)}) def _toggle_relation(self, model, counter_field, active_key): """会员点赞/收藏切换;同步维护计数。""" article = self.get_object() member = _request_member(self.request) if not member: return Response( {"detail": "请先登录前台会员"}, status=status.HTTP_401_UNAUTHORIZED, ) existing = model.objects.filter(article=article, member=member).first() if existing: existing.delete() Article.objects.filter(pk=article.pk).update( **{counter_field: Greatest(F(counter_field) - 1, 0)} ) active = False else: model.objects.create(article=article, member=member) Article.objects.filter(pk=article.pk).update( **{counter_field: F(counter_field) + 1} ) active = True article.refresh_from_db(fields=[counter_field]) return Response( { "id": article.id, active_key: active, counter_field: getattr(article, counter_field), } ) @action( detail=True, methods=["post"], url_path="record-read", permission_classes=[permissions.AllowAny], ) def record_read(self, request, pk=None): """前台详情页显式上报阅读,避免 GET 详情误增。""" return self._bump_counter("read_num") @action( detail=True, methods=["post"], url_path="toggle-like", permission_classes=[IsMember], ) def toggle_like(self, request, pk=None): """会员点赞 / 取消点赞。""" return self._toggle_relation(ArticleLike, "like_num", "liked") @action( detail=True, methods=["post"], url_path="toggle-collect", permission_classes=[IsMember], ) def toggle_collect(self, request, pk=None): """会员收藏 / 取消收藏。""" return self._toggle_relation(ArticleCollect, "collect_num", "collected") @action( detail=True, methods=["post"], url_path="record-share", permission_classes=[permissions.AllowAny], ) def record_share(self, request, pk=None): """ 前台分享计数:同一人对同一文献只 +1 一次。 已登录按会员去重;未登录按 visitor_key 去重。 """ from django.db import IntegrityError article = self.get_object() member = _request_member(request) raw_key = (request.data.get("visitor_key") or "").strip() # 限制长度,避免异常长串 visitor_key = raw_key[:64] if raw_key else "" if member: exists = ArticleShare.objects.filter( article=article, member=member ).exists() if exists: return Response( { "id": article.id, "share_num": article.share_num, "counted": False, } ) try: ArticleShare.objects.create(article=article, member=member) except IntegrityError: article.refresh_from_db(fields=["share_num"]) return Response( { "id": article.id, "share_num": article.share_num, "counted": False, } ) elif visitor_key: exists = ArticleShare.objects.filter( article=article, visitor_key=visitor_key ).exists() if exists: return Response( { "id": article.id, "share_num": article.share_num, "counted": False, } ) try: ArticleShare.objects.create( article=article, visitor_key=visitor_key ) except IntegrityError: article.refresh_from_db(fields=["share_num"]) return Response( { "id": article.id, "share_num": article.share_num, "counted": False, } ) else: # 无身份标识时无法去重,仍允许计数(兼容旧客户端) Article.objects.filter(pk=article.pk).update(share_num=F("share_num") + 1) article.refresh_from_db(fields=["share_num"]) return Response( {"id": article.id, "share_num": article.share_num, "counted": True} ) Article.objects.filter(pk=article.pk).update(share_num=F("share_num") + 1) article.refresh_from_db(fields=["share_num"]) return Response( {"id": article.id, "share_num": article.share_num, "counted": True} ) @action( detail=False, methods=["get"], url_path="my-collections", permission_classes=[IsMember], ) def my_collections(self, request): """当前会员的收藏列表。""" member = _request_member(request) qs = ( self.get_queryset() .filter(collections__member=member) .order_by("-collections__created_at", "-id") .distinct() ) page = self.paginate_queryset(qs) data = ArticleListSerializer(page or qs, many=True).data if page is not None: return self.get_paginated_response(data) return Response(data) @action( detail=False, methods=["get"], url_path="my-engagement", permission_classes=[IsMember], ) def my_engagement(self, request): """批量查询当前会员对若干文献的点赞/收藏状态。""" member = _request_member(request) raw = request.query_params.get("ids") or "" ids = [x.strip() for x in raw.split(",") if x.strip().isdigit()] if not ids: return Response({"liked": [], "collected": []}) liked = list( ArticleLike.objects.filter(member=member, article_id__in=ids).values_list( "article_id", flat=True ) ) collected = list( ArticleCollect.objects.filter( member=member, article_id__in=ids ).values_list("article_id", flat=True) ) return Response({"liked": liked, "collected": collected}) @action(detail=False, methods=["get"]) def ranking(self, request): article_type = request.query_params.get("type") year = request.query_params.get("year") subject = request.query_params.get("subject") qs = self.get_queryset().order_by("-score", "-year") if article_type: qs = qs.filter(type=article_type) if year: qs = qs.filter(year=year) if subject: qs = qs.filter(subjects=subject) page = self.paginate_queryset(qs) data = ArticleListSerializer(page or qs, many=True).data if page is not None: return self.get_paginated_response(data) return Response(data) @action(detail=False, methods=["get"]) def home(self, request): """首页精选:按 rank 展示。""" qs = self.get_queryset().filter(rank__isnull=False).order_by("rank")[:20] return Response(ArticleListSerializer(qs, many=True).data) @action(detail=False, methods=["get"]) def facets(self, request): """筛选面板用:文献总量、按类型计数、实际存在的年份列表。""" qs = self.get_queryset() by_type = { row["type_id"]: row["count"] for row in qs.values("type_id").annotate(count=Count("id")) } type_rows = list( ArticleType.objects.filter(is_disabled=False).order_by("sort", "id") ) type_map = {str(t.id): by_type.get(t.id, 0) for t in type_rows} years = list( qs.exclude(year__isnull=True) .values_list("year", flat=True) .distinct() .order_by("-year") ) return Response( { "total": qs.count(), "type": type_map, "types": [ { "id": t.id, "name": t.name, "count": by_type.get(t.id, 0), } for t in type_rows ], "years": years, } ) class ArticleCommentViewSet(viewsets.ModelViewSet): """文献用户评价(后台审核)。""" queryset = ArticleComment.objects.select_related("article", "member").all() serializer_class = ArticleCommentSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [DjangoFilterBackend] filterset_fields = ["review_status", "article", "member"] def get_queryset(self): qs = super().get_queryset() user = getattr(self.request, "user", None) # 公众只看已通过;管理员看全量 if not (user and user.is_staff): qs = qs.filter(review_status=3) start = self.request.query_params.get("start_date") end = self.request.query_params.get("end_date") if start: qs = qs.filter(created_at__date__gte=start) if end: qs = qs.filter(created_at__date__lte=end) return qs