"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。""" from django.db.models import Count, Q from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from apps.accounts.audit import log_admin_action from apps.accounts.models import Invitation, Team, User from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.serializers import InvitationSerializer from apps.ai.models import QualityWord from apps.assets.models import Asset from apps.assets.review import poll_asset_review, submit_asset_for_review from apps.common.pagination import DefaultPagination from .serializers import ( AdminReviewAssetSerializer, AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer, QualityWordSerializer, ) def _team_qs(): return ( Team.objects.select_related("owner", "credit_account") .annotate(member_count_anno=Count("members", distinct=True)) ) @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_invitations(request): """GET 列所有邀请码(跨团队,可按 kind/status/search 过滤,分页); POST 平台超管发「开团队码」(create_team,不绑团队,新用户凭码开新团队当 owner)。""" if request.method == "GET": qs = Invitation.objects.select_related("team", "used_by").order_by("-created_at") kind = request.query_params.get("kind") if kind in {Invitation.Kind.JOIN_TEAM, Invitation.Kind.CREATE_TEAM}: qs = qs.filter(kind=kind) st = request.query_params.get("status") if st in dict(Invitation.Status.choices): qs = qs.filter(status=st) search = (request.query_params.get("search") or "").strip() if search: qs = qs.filter(Q(code__icontains=search) | Q(team__name__icontains=search)) paginator = DefaultPagination() page = paginator.paginate_queryset(qs, request) return paginator.get_paginated_response(InvitationSerializer(page, many=True).data) email = str(request.data.get("email") or "").strip() invite = Invitation.objects.create( kind=Invitation.Kind.CREATE_TEAM, team=None, email=email, created_by=request.user, ) log_admin_action( request, "invite.issue_create_team", target_type="invitation", target_id=invite.id, target_name=invite.code, after={"kind": invite.kind, "email": email}, ) return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_revoke_invitation(request, invite_id): """撤销一个待用邀请码(任意团队)。已用/已撤销/已过期则原样返回(幂等)。""" invite = Invitation.objects.select_related("team").filter(id=invite_id).first() if invite is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) if invite.status == Invitation.Status.PENDING: invite.status = Invitation.Status.REVOKED invite.save(update_fields=["status", "updated_at"]) log_admin_action( request, "invite.revoke", target_type="invitation", target_id=invite.id, target_name=invite.code, ) return Response(InvitationSerializer(invite).data) # ─────────────────────────── 团队管理 ─────────────────────────── @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_teams(request): """列所有团队(跨团队,可按 status/search 过滤,分页)。""" qs = _team_qs().order_by("-created_at") st = request.query_params.get("status") if st in dict(Team.Status.choices): qs = qs.filter(status=st) search = (request.query_params.get("search") or "").strip() if search: qs = qs.filter(name__icontains=search) paginator = DefaultPagination() page = paginator.paginate_queryset(qs, request) return paginator.get_paginated_response(AdminTeamSerializer(page, many=True).data) @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_team_detail(request, team_id): team = _team_qs().filter(id=team_id).first() if team is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) members = team.members.select_related("user").order_by("created_at") data = AdminTeamSerializer(team).data data["members"] = AdminTeamMemberSerializer(members, many=True).data return Response(data) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_team_toggle(request, team_id): """启停团队。团队停用后其成员登录会被拒(login 校验团队状态)。""" team = Team.objects.filter(id=team_id).first() if team is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) before = team.status team.status = Team.Status.ACTIVE if team.status == Team.Status.DISABLED else Team.Status.DISABLED team.save(update_fields=["status", "updated_at"]) log_admin_action( request, "team.toggle_status", target_type="team", target_id=team.id, target_name=team.name, before={"status": before}, after={"status": team.status}, ) return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data) # ─────────────────────────── 用户管理 ─────────────────────────── @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_users(request): """列所有用户(跨团队,可按 status/search 过滤,分页)。""" qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined") st = request.query_params.get("status") if st in dict(User.Status.choices): qs = qs.filter(status=st) search = (request.query_params.get("search") or "").strip() if search: qs = qs.filter(username__icontains=search) paginator = DefaultPagination() page = paginator.paginate_queryset(qs, request) return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_user_toggle(request, user_id): """启停用户。停用即清 token 强制下线;不允许停用平台超管(防自锁)。""" user = User.objects.filter(id=user_id).first() if user is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) if user.is_platform_admin: return Response({"detail": "不能停用平台超管"}, status=status.HTTP_400_BAD_REQUEST) before = user.status user.status = User.Status.ACTIVE if user.status == User.Status.DISABLED else User.Status.DISABLED user.save(update_fields=["status"]) if user.status == User.Status.DISABLED: Token.objects.filter(user=user).delete() log_admin_action( request, "user.toggle_status", target_type="user", target_id=user.id, target_name=user.username, before={"status": before}, after={"status": user.status}, ) return Response(AdminUserSerializer(user).data) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_user_reset_password(request, user_id): """平台超管强制改用户密码(改后清 token 强制重登)。""" user = User.objects.filter(id=user_id).first() if user is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) password = str(request.data.get("password") or "").strip() if len(password) < 8: return Response({"password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST) user.set_password(password) user.save(update_fields=["password"]) Token.objects.filter(user=user).delete() log_admin_action( request, "user.reset_password", target_type="user", target_id=user.id, target_name=user.username, ) return Response(status=status.HTTP_204_NO_CONTENT) # ─────────────────────────── 质量词(平台单层)─────────────────────────── @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_quality_words(request): """GET 列全部质量词(配置量小,不分页,前端按 stage 分组);POST 新增一条。""" if request.method == "GET": qs = QualityWord.objects.all().order_by("stage", "sort", "created_at") return Response(QualityWordSerializer(qs, many=True).data) serializer = QualityWordSerializer(data=request.data) serializer.is_valid(raise_exception=True) obj = serializer.save() log_admin_action( request, "quality_word.create", target_type="quality_word", target_id=obj.id, target_name=f"{obj.stage}:{obj.text}", after={"stage": obj.stage, "text": obj.text}, ) return Response(QualityWordSerializer(obj).data, status=status.HTTP_201_CREATED) @api_view(["PATCH", "DELETE"]) @permission_classes([IsPlatformAdmin]) def admin_quality_word_detail(request, word_id): obj = QualityWord.objects.filter(id=word_id).first() if obj is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) if request.method == "DELETE": log_admin_action( request, "quality_word.delete", target_type="quality_word", target_id=obj.id, target_name=f"{obj.stage}:{obj.text}", before={"stage": obj.stage, "text": obj.text}, ) obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) before_text = obj.text serializer = QualityWordSerializer(obj, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() log_admin_action( request, "quality_word.update", target_type="quality_word", target_id=obj.id, target_name=f"{obj.stage}:{obj.text}", before={"text": before_text}, after={"text": obj.text}, ) return Response(QualityWordSerializer(obj).data) # ─────────────────────────── 火山人像审核队列 ─────────────────────────── _REVIEW_STATUSES = {"", "processing", "active", "failed"} @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_asset_reviews(request): """跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。""" qs = ( Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False) .select_related("team") .prefetch_related("files") .order_by("-created_at") ) rs = request.query_params.get("review_status") if rs == "none": qs = qs.filter(review_status="") elif rs in {"processing", "active", "failed"}: qs = qs.filter(review_status=rs) search = (request.query_params.get("search") or "").strip() if search: qs = qs.filter(team__name__icontains=search) paginator = DefaultPagination() page = paginator.paginate_queryset(qs, request) return paginator.get_paginated_response(AdminReviewAssetSerializer(page, many=True).data) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_asset_reviews_submit(request): """批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。""" ids = request.data.get("asset_ids") or [] assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False)) for asset in assets: submit_asset_for_review(asset) statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)} log_admin_action( request, "asset_review.submit", target_type="asset", target_name=f"{len(assets)} assets", after={"count": len(assets)}, ) return Response({"submitted": len(assets), "statuses": statuses}) @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_asset_reviews_poll(request): """轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。""" ids = request.data.get("asset_ids") qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False) if ids: qs = qs.filter(id__in=ids) statuses = {} for asset in qs: statuses[str(asset.id)] = poll_asset_review(asset) return Response({"polled": len(statuses), "statuses": statuses})