feat(admin): Phase 2 邀请码发放管理 — apps/adminpanel 跨团队端点 + Admin 邀请码页

后端:新建 apps/adminpanel(跨团队后台容器,/api/admin/);GET/POST invitations(列跨团队码+筛选分页 /
平台发 create_team 开团队码,写审计)+ revoke(写审计),全程 IsPlatformAdmin。
前端:adminApi 分层;Admin 邀请码页(类型筛选段控 + 表格 + 发码弹窗 + 复制邀请链接 + 撤销)接入 AdminApp toast;
Invitation 类型补 kind/team_name;admin-page.css 补段控/表格/弹窗(仅 token)。
测试:adminpanel 9 + accounts 22 = 31 单测过;无头 e2e _admin-p2.mjs 6 断言过 + 0 console error
+ 端到端凭 UI 发的码注册开团队 201;tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:29:55 +08:00
co-authored by Claude Opus 4.8
parent 3f8547564c
commit 45a4ceae51
14 changed files with 589 additions and 6 deletions
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AdminpanelConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.adminpanel"
verbose_name = "平台后台"
+87
View File
@@ -0,0 +1,87 @@
from rest_framework.test import APIClient
from django.test import TestCase
from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User
class AdminInvitationApiTests(TestCase):
"""Phase 2:平台超管邀请码发放管理(发开团队码 / 列跨团队码 / 撤销)+ 权限 + 审计。"""
def setUp(self):
self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal", password="x")
self.team = Team.objects.create(name="Normal Team", owner=self.normal)
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
self.admin_client = APIClient()
self.admin_client.force_authenticate(self.admin)
self.normal_client = APIClient()
self.normal_client.force_authenticate(self.normal)
def test_list_requires_platform_admin(self):
self.assertEqual(self.normal_client.get("/api/admin/invitations/").status_code, 403)
self.assertEqual(self.admin_client.get("/api/admin/invitations/").status_code, 200)
def test_create_create_team_code_and_audit(self):
r = self.admin_client.post("/api/admin/invitations/", {"email": "x@y.z"}, format="json")
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["kind"], Invitation.Kind.CREATE_TEAM)
self.assertTrue(r.data["code"].startswith("TEAM-"))
self.assertIsNone(r.data["team"])
self.assertEqual(r.data["register_url"], f"/register?invite={r.data['code']}")
self.assertTrue(AdminAuditLog.objects.filter(action="invite.issue_create_team").exists())
def test_create_requires_platform_admin(self):
self.assertEqual(self.normal_client.post("/api/admin/invitations/", {}, format="json").status_code, 403)
def test_list_is_cross_team(self):
self.admin_client.post("/api/admin/invitations/", {}, format="json")
Invitation.objects.create(kind=Invitation.Kind.JOIN_TEAM, team=self.team, role=TeamMember.Role.MEMBER)
r = self.admin_client.get("/api/admin/invitations/")
self.assertEqual(r.status_code, 200)
self.assertGreaterEqual(r.data["count"], 2)
kinds = {i["kind"] for i in r.data["results"]}
self.assertIn(Invitation.Kind.CREATE_TEAM, kinds)
self.assertIn(Invitation.Kind.JOIN_TEAM, kinds)
def test_filter_by_kind(self):
self.admin_client.post("/api/admin/invitations/", {}, format="json")
Invitation.objects.create(kind=Invitation.Kind.JOIN_TEAM, team=self.team, role=TeamMember.Role.MEMBER)
r = self.admin_client.get("/api/admin/invitations/?kind=create_team")
self.assertTrue(all(i["kind"] == Invitation.Kind.CREATE_TEAM for i in r.data["results"]))
def test_revoke_and_audit(self):
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
inv_id = created.data["id"]
r = self.admin_client.post(f"/api/admin/invitations/{inv_id}/revoke/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["status"], Invitation.Status.REVOKED)
self.assertTrue(AdminAuditLog.objects.filter(action="invite.revoke").exists())
def test_revoke_requires_platform_admin(self):
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
inv_id = created.data["id"]
self.assertEqual(self.normal_client.post(f"/api/admin/invitations/{inv_id}/revoke/").status_code, 403)
def test_issued_create_team_code_registers_new_team(self):
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
code = created.data["code"]
reg = APIClient().post(
"/api/auth/register/",
{"username": "new-via-admin-code", "password": "strong-pass-1", "team_name": "Via Admin", "invite_code": code},
format="json",
)
self.assertEqual(reg.status_code, 201)
self.assertTrue(Team.objects.filter(name="Via Admin").exists())
# 码标 used
self.assertEqual(Invitation.objects.get(code=code).status, Invitation.Status.USED)
def test_revoked_code_cannot_register(self):
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
code = created.data["code"]
self.admin_client.post(f"/api/admin/invitations/{created.data['id']}/revoke/")
reg = APIClient().post(
"/api/auth/register/",
{"username": "blocked-user", "password": "strong-pass-1", "team_name": "Blocked", "invite_code": code},
format="json",
)
self.assertEqual(reg.status_code, 400)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import admin_invitations, admin_revoke_invitation
urlpatterns = [
path("invitations/", admin_invitations, name="admin-invitations"),
path("invitations/<uuid:invite_id>/revoke/", admin_revoke_invitation, name="admin-invitation-revoke"),
]
+70
View File
@@ -0,0 +1,70 @@
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
from django.db.models import Q
from rest_framework import status
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.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.common.pagination import DefaultPagination
@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)