后端:adminpanel 增 teams(列/详情/启停)+ users(列/启停/强制改密),AdminTeam/User 序列化器; login 校验团队 status,停用团队成员拒登;停用用户清 token、禁停平台超管;全程 IsPlatformAdmin + 审计。 前端:adminApi teams/users 系列;Admin 团队页(筛选+搜索+详情弹窗+启停)、用户页(启停+改密弹窗,超管行禁操作)。 测试:adminpanel 19 + accounts 22 = 41 单测过;无头 e2e _admin-p3.mjs 7 断言过 + 0 console error (UI 停用团队→成员登录 400→启用→200;UI 改密→新密码登录 200);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
8.0 KiB
Python
202 lines
8.0 KiB
Python
"""平台超管后台 · 跨团队端点。所有视图统一挂 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.common.pagination import DefaultPagination
|
|
|
|
from .serializers import AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer
|
|
|
|
|
|
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)
|