feat(admin): Phase 3 平台团队+用户管理 — 列/启停/详情/强制改密 + 团队停用拦登录
后端: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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
45a4ceae51
commit
6560633959
@@ -12,7 +12,7 @@ from rest_framework.response import Response
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
|
||||
from .models import Invitation, LoginSession, TeamMember, User, UserPreference
|
||||
from .models import Invitation, LoginSession, Team, TeamMember, User, UserPreference
|
||||
from .serializers import (
|
||||
InvitationSerializer,
|
||||
LoginSerializer,
|
||||
@@ -124,6 +124,9 @@ def login(request):
|
||||
if user is None or user.is_disabled:
|
||||
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = current_team_or_none(user)
|
||||
# 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响)
|
||||
if team is not None and team.status == Team.Status.DISABLED:
|
||||
return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
record_login_session(request, user)
|
||||
return Response(auth_payload(user, team, token))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
|
||||
|
||||
class AdminTeamSerializer(serializers.ModelSerializer):
|
||||
owner_username = serializers.CharField(source="owner.username", read_only=True, default=None)
|
||||
member_count = serializers.SerializerMethodField()
|
||||
balance = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "created_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_member_count(self, obj):
|
||||
anno = getattr(obj, "member_count_anno", None)
|
||||
return anno if anno is not None else obj.members.count()
|
||||
|
||||
def get_balance(self, obj):
|
||||
acct = getattr(obj, "credit_account", None)
|
||||
return str(acct.balance) if acct is not None else "0"
|
||||
|
||||
|
||||
class AdminTeamMemberSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source="user.username", read_only=True)
|
||||
user_status = serializers.CharField(source="user.status", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TeamMember
|
||||
fields = ["id", "username", "role", "status", "user_status", "monthly_credit_limit"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class AdminUserSerializer(serializers.ModelSerializer):
|
||||
teams = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_teams(self, obj):
|
||||
return [
|
||||
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
||||
for m in obj.team_memberships.select_related("team").all()
|
||||
]
|
||||
@@ -85,3 +85,89 @@ class AdminInvitationApiTests(TestCase):
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(reg.status_code, 400)
|
||||
|
||||
|
||||
class AdminTeamUserApiTests(TestCase):
|
||||
"""Phase 3:平台团队 + 用户管理(列/详情/启停/强制改密)+ 权限 + 团队停用拦登录。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.models import CreditAccount
|
||||
|
||||
self.admin = User.objects.create_user(username="padmin3", password="x", is_platform_admin=True)
|
||||
self.owner = User.objects.create_user(username="t-owner", password="ownerpass1")
|
||||
self.member = User.objects.create_user(username="t-member", password="memberpass1")
|
||||
self.team = Team.objects.create(name="Acme", owner=self.owner)
|
||||
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER)
|
||||
TeamMember.objects.create(team=self.team, user=self.member, role=TeamMember.Role.MEMBER)
|
||||
CreditAccount.objects.create(team=self.team, balance="123.4500")
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.normal = APIClient()
|
||||
self.normal.force_authenticate(self.owner)
|
||||
|
||||
def _login(self, username, password):
|
||||
return APIClient().post("/api/auth/login/", {"username": username, "password": password}, format="json")
|
||||
|
||||
def test_teams_list_permission_and_fields(self):
|
||||
self.assertEqual(self.normal.get("/api/admin/teams/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/teams/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
row = next(t for t in r.data["results"] if t["name"] == "Acme")
|
||||
self.assertEqual(row["member_count"], 2)
|
||||
self.assertEqual(row["owner_username"], "t-owner")
|
||||
self.assertEqual(row["balance"], "123.4500")
|
||||
|
||||
def test_team_detail_has_members(self):
|
||||
r = self.ac.get(f"/api/admin/teams/{self.team.id}/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.data["members"]), 2)
|
||||
|
||||
def test_team_toggle_blocks_then_restores_member_login(self):
|
||||
self.assertEqual(self._login("t-member", "memberpass1").status_code, 200)
|
||||
tr = self.ac.post(f"/api/admin/teams/{self.team.id}/toggle/")
|
||||
self.assertEqual(tr.status_code, 200)
|
||||
self.assertEqual(tr.data["status"], Team.Status.DISABLED)
|
||||
self.assertEqual(self._login("t-member", "memberpass1").status_code, 400) # 停用后被拒
|
||||
self.ac.post(f"/api/admin/teams/{self.team.id}/toggle/") # 恢复
|
||||
self.assertEqual(self._login("t-member", "memberpass1").status_code, 200)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="team.toggle_status").exists())
|
||||
|
||||
def test_team_toggle_requires_admin(self):
|
||||
self.assertEqual(self.normal.post(f"/api/admin/teams/{self.team.id}/toggle/").status_code, 403)
|
||||
|
||||
def test_users_list_with_teams(self):
|
||||
self.assertEqual(self.normal.get("/api/admin/users/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/users/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
member_row = next(u for u in r.data["results"] if u["username"] == "t-member")
|
||||
self.assertEqual(member_row["teams"][0]["team_name"], "Acme")
|
||||
|
||||
def test_user_toggle_blocks_login(self):
|
||||
r = self.ac.post(f"/api/admin/users/{self.member.id}/toggle/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["status"], User.Status.DISABLED)
|
||||
self.assertEqual(self._login("t-member", "memberpass1").status_code, 400)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.toggle_status").exists())
|
||||
|
||||
def test_cannot_disable_platform_admin(self):
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{self.admin.id}/toggle/").status_code, 400)
|
||||
|
||||
def test_reset_password(self):
|
||||
r = self.ac.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json")
|
||||
self.assertEqual(r.status_code, 204)
|
||||
self.assertEqual(self._login("t-member", "newpass789").status_code, 200)
|
||||
self.assertEqual(self._login("t-member", "memberpass1").status_code, 400) # 旧密码失效
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.reset_password").exists())
|
||||
|
||||
def test_reset_password_too_short_rejected(self):
|
||||
self.assertEqual(
|
||||
self.ac.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "short"}, format="json").status_code,
|
||||
400,
|
||||
)
|
||||
|
||||
def test_user_actions_require_admin(self):
|
||||
self.assertEqual(self.normal.post(f"/api/admin/users/{self.member.id}/toggle/").status_code, 403)
|
||||
self.assertEqual(
|
||||
self.normal.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json").status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import admin_invitations, admin_revoke_invitation
|
||||
from .views import (
|
||||
admin_invitations,
|
||||
admin_revoke_invitation,
|
||||
admin_team_detail,
|
||||
admin_team_toggle,
|
||||
admin_teams,
|
||||
admin_user_reset_password,
|
||||
admin_user_toggle,
|
||||
admin_users,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("invitations/", admin_invitations, name="admin-invitations"),
|
||||
path("invitations/<uuid:invite_id>/revoke/", admin_revoke_invitation, name="admin-invitation-revoke"),
|
||||
path("teams/", admin_teams, name="admin-teams"),
|
||||
path("teams/<uuid:team_id>/", admin_team_detail, name="admin-team-detail"),
|
||||
path("teams/<uuid:team_id>/toggle/", admin_team_toggle, name="admin-team-toggle"),
|
||||
path("users/", admin_users, name="admin-users"),
|
||||
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
|
||||
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
]
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
||||
|
||||
from django.db.models import Q
|
||||
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
|
||||
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])
|
||||
@@ -68,3 +78,124 @@ def admin_revoke_invitation(request, 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)
|
||||
|
||||
Reference in New Issue
Block a user