Move Django apps out of apps/ and mount them under auth/, common/, content/, and cms/ so endpoint paths map directly to the owning app. Co-authored-by: Cursor <cursoragent@cursor.com>
587 lines
20 KiB
Python
587 lines
20 KiB
Python
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
|