Files
resolutionflow/backend/app/schemas/admin.py
Michael Chihlas ba36c47075 feat(billing): reconcile plan taxonomy and add Stripe sync script
The marketing surface (PricingPage, Stripe products) was wired for
"Starter / Pro / Enterprise" while the backend was on "free / pro / team",
leaving plan_billing unseeded and BillingPlan accepting a literal that
violated the FK to plan_limits.

This change:

- Migration 4ce3e594cb87: defensive UPDATE of any subscriptions on
  plan='team' to 'enterprise' (dev has zero), renames the plan_limits
  row team -> enterprise, inserts a starter row with caps interpolated
  between free and pro (max_trees=10, sessions=75, ai=15/mo).
- Renames the plan tier across schemas (invite_code, billing, admin,
  subscription comment), is_paid/has_pro_entitlement checks in the
  Subscription model, admin/admin_dashboard plan validators, and the
  frontend useSubscription isPaidPlan check. Resource visibility uses
  the same string 'team' in a separate domain (Tree/StepLibrary
  visibility) and is intentionally untouched.
- New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of
  plan_billing rows from Stripe products by exact name match. Picks
  the active monthly recurring price for tiers that have one; leaves
  annual fields NULL by design. Works against test or live keys.
- Test fixture updates: conftest seeds the new taxonomy, the public
  plans helper is a true upsert so tests can override max_users, and
  team -> enterprise across test_admin_plan_limits and test_invite_plan.

Verified: 86/86 passing across the subscription/billing/plan/invite/
admin sweep; sync script run against test mode populates plan_billing
correctly for all three tiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:59:42 -04:00

378 lines
9.7 KiB
Python

"""Pydantic schemas for admin panel endpoints."""
from datetime import datetime
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# --- Dashboard ---
class DashboardMetrics(BaseModel):
total_users: int
active_subscriptions: int
paid_accounts: int
total_trees: int
class ActivityEntry(BaseModel):
id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Admin Accounts & People Search ---
class AdminUserListItem(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_id: Optional[UUID] = None
account_role: Optional[str] = None
account_name: Optional[str] = None
account_display_code: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminUserListResponse(BaseModel):
items: list[AdminUserListItem]
total: int
page: int
per_page: int
class AdminAccountMember(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_role: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminAccountOwnerSummary(BaseModel):
id: UUID
name: str
email: EmailStr
class AdminAccountSubscriptionSummary(BaseModel):
id: UUID
plan: str
status: str
billing_interval: Optional[str] = None
current_period_end: Optional[datetime] = None
cancel_at_period_end: bool = False
class AdminAccountUsageSummary(BaseModel):
tree_count: int = 0
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
display_code: str
created_at: datetime
owner_id: Optional[UUID] = None
owner: Optional[AdminAccountOwnerSummary] = None
subscription: Optional[AdminAccountSubscriptionSummary] = None
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
member_count: int = 0
active_member_count: int = 0
pending_invite_count: int = 0
sso_enabled: bool = False
branding_company_name: Optional[str] = None
members: list[AdminAccountMember] = Field(default_factory=list)
class AdminAccountListResponse(BaseModel):
items: list[AdminAccountListItem]
total: int
page: int
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", "starter", "enterprise"] = "free"
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
class AdminAccountUpdate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
# --- Audit Logs ---
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID
user_email: Optional[str] = None
action: str
resource_type: str
resource_id: Optional[UUID] = None
details: Optional[dict] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
items: list[AuditLogEntry]
total: int
page: int
per_page: int
# --- Plan Limits ---
class PlanLimitResponse(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = []
class Config:
from_attributes = True
class PlanLimitWithBillingResponse(PlanLimitResponse):
"""PlanLimits + plan_billing fields merged. Billing fields are None when no
plan_billing row exists for the plan yet."""
display_name: Optional[str] = None
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
stripe_product_id: Optional[str] = None
stripe_monthly_price_id: Optional[str] = None
stripe_annual_price_id: Optional[str] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
sort_order: Optional[int] = None
class PlanLimitUpdate(BaseModel):
plan: str
max_trees: Optional[int] = None
max_sessions_per_month: Optional[int] = None
max_users: Optional[int] = None
custom_branding: bool = False
priority_support: bool = False
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
# plan_billing fields — all optional, partial-update semantics. If any are
# set in the body, the admin endpoint upserts the plan_billing row in the
# same transaction.
display_name: Optional[str] = None
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
stripe_product_id: Optional[str] = None
stripe_monthly_price_id: Optional[str] = None
stripe_annual_price_id: Optional[str] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
sort_order: Optional[int] = None
class AccountOverrideCreate(BaseModel):
account_display_code: str = Field(..., description="Account display code to look up")
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideUpdate(BaseModel):
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
class AccountOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_name: Optional[str] = None
account_display_code: Optional[str] = None
override_max_trees: Optional[int] = None
override_max_sessions_per_month: Optional[int] = None
override_max_users: Optional[int] = None
note: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# --- Feature Flags ---
class FeatureFlagCreate(BaseModel):
flag_key: str = Field(..., max_length=100)
display_name: str = Field(..., max_length=255)
description: Optional[str] = None
class FeatureFlagUpdate(BaseModel):
display_name: Optional[str] = None
description: Optional[str] = None
class PlanDefaultEntry(BaseModel):
plan: str
enabled: bool
class FeatureFlagResponse(BaseModel):
id: UUID
flag_key: str
display_name: str
description: Optional[str] = None
plan_defaults: list[PlanDefaultEntry] = []
created_at: datetime
class Config:
from_attributes = True
class PlanDefaultUpdate(BaseModel):
plan: str
flag_id: UUID
enabled: bool
class AccountFeatureOverrideCreate(BaseModel):
account_display_code: str
flag_id: UUID
enabled: bool
note: Optional[str] = None
class AccountFeatureOverrideResponse(BaseModel):
id: UUID
account_id: UUID
account_display_code: Optional[str] = None
flag_id: UUID
flag_key: Optional[str] = None
flag_display_name: Optional[str] = None
enabled: bool
note: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# --- Platform Settings ---
class SettingsResponse(BaseModel):
settings: dict
class SettingsUpdate(BaseModel):
settings: dict = Field(..., description="Key-value pairs to update")
# --- Global Categories ---
class GlobalCategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class GlobalCategoryUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=100)
slug: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
class GlobalCategoryResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
account_id: Optional[UUID] = None
tree_count: int = 0
class Config:
from_attributes = True
# --- Move User ---
class MoveUserAccount(BaseModel):
display_code: str = Field(..., description="Target account display code")
# --- Admin User Creation ---
class AdminUserCreate(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=255)
account_mode: Literal["existing", "personal"]
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
send_email: bool = True
class AdminUserCreateResponse(BaseModel):
user: dict # UserResponse fields
temporary_password: str
email_sent: bool
# --- Admin Password Reset ---
class AdminPasswordReset(BaseModel):
mode: Literal["email_link", "temp_password"]
class AdminPasswordResetResponse(BaseModel):
message: str
temporary_password: Optional[str] = None
email_sent: bool = False
# --- Hard Delete Precheck ---
class HardDeleteCheckResponse(BaseModel):
can_delete: bool
blockers: dict