feat: add admin account detail management
This commit is contained in:
@@ -39,6 +39,10 @@ from app.schemas.admin import (
|
||||
AdminAccountOwnerSummary,
|
||||
AdminAccountSubscriptionSummary,
|
||||
AdminAccountUsageSummary,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountInviteSummary,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
@@ -307,6 +311,183 @@ def _generate_display_code() -> str:
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _generate_unique_display_code(db: AsyncSession) -> str:
|
||||
"""Generate a unique display code for a new account."""
|
||||
while True:
|
||||
display_code = _generate_display_code()
|
||||
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return display_code
|
||||
|
||||
|
||||
async def _get_account_detail_payload(
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
include_archived: bool = False,
|
||||
) -> AdminAccountDetailResponse:
|
||||
owner_user = aliased(User)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
.where(Account.id == account_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_result = await db.execute(members_query)
|
||||
members = [
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
for member in members_result.scalars().all()
|
||||
]
|
||||
|
||||
invites_result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.account_id == account_id)
|
||||
.order_by(AccountInvite.created_at.desc())
|
||||
)
|
||||
invites = [
|
||||
AdminAccountInviteSummary(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
expires_at=invite.expires_at,
|
||||
created_at=invite.created_at,
|
||||
used_at=invite.used_at,
|
||||
)
|
||||
for invite in invites_result.scalars().all()
|
||||
if invite.used_at is None
|
||||
]
|
||||
|
||||
usage = await get_account_usage(account_id, db)
|
||||
|
||||
return AdminAccountDetailResponse(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
),
|
||||
member_count=len(members),
|
||||
active_member_count=sum(1 for member in members if member.is_active),
|
||||
pending_invite_count=len(invites),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members,
|
||||
invites=invites,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_account(
|
||||
data: AdminAccountCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=data.plan,
|
||||
status="active",
|
||||
)
|
||||
db.add(new_subscription)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def get_account_detail(
|
||||
account_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
include_archived: bool = Query(False),
|
||||
):
|
||||
"""Get detailed account information for admin management."""
|
||||
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def update_account(
|
||||
account_id: UUID,
|
||||
data: AdminAccountUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update account settings from the admin panel."""
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
old_name = account.name
|
||||
account.name = data.name.strip()
|
||||
await log_audit(
|
||||
db, current_user.id, "account.update_admin", "account", account.id,
|
||||
{"old_name": old_name, "new_name": account.name},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(account.id, db)
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: AdminUserCreate,
|
||||
|
||||
@@ -86,6 +86,15 @@ class AdminAccountUsageSummary(BaseModel):
|
||||
session_count_this_month: int = 0
|
||||
|
||||
|
||||
class AdminAccountInviteSummary(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
role: str
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
used_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountListItem(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
@@ -110,6 +119,19 @@ class AdminAccountListResponse(BaseModel):
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountDetailResponse(AdminAccountListItem):
|
||||
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
plan: Literal["free", "pro", "team"] = "free"
|
||||
|
||||
|
||||
class AdminAccountUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
|
||||
@@ -53,6 +53,54 @@ class TestAdminEndpoints:
|
||||
assert "members" in payload["items"][0]
|
||||
assert "subscription" in payload["items"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test creating an empty account from admin."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/accounts",
|
||||
json={"name": "Acme Customer", "plan": "pro"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["name"] == "Acme Customer"
|
||||
assert payload["subscription"]["plan"] == "pro"
|
||||
assert payload["display_code"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_detail_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test fetching account detail for management view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert "members" in payload
|
||||
assert "invites" in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_name_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test renaming an account from admin detail view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
json={"name": "Renamed Customer Account"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert payload["name"] == "Renamed Customer Account"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_plan(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
|
||||
Reference in New Issue
Block a user