aifeilong 38ec52abf5 Add STAR International API apps and wire production Postgres defaults.
Introduce content/cms/accounts modules with DRF routes, and point default DB settings at the official starintldb instance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:08:01 +08:00

587 lines
20 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 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