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>
378 lines
9.7 KiB
Python
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
|