SurfSense/surfsense_backend/app/routes/admin_routes.py
Vonic 4eb6ed18d6 Epic 5 Complete: Billing, Subscriptions, and Admin Features
Resolve all 5 deferred items from Epic 5 adversarial code review:
- Migration 124: Add CASCADE to subscriptionstatus enum drop (prevent orphaned references)
- Stripe rate limiting: In-memory per-user limiter (20 calls/60s) on verify-checkout-session
- Subscription request cooldown: 24h cooldown before resubmitting rejected requests
- Token reset date: Initialize on first subscription activation
- Checkout URL validation: Confirmed HTTPS-only (Stripe always returns HTTPS)

Implement Story 5.4 (Usage Tracking & Rate Limit Enforcement):
- Page quota pre-check at HTTP upload layer
- Extend UserRead schema with token quota fields
- Frontend 402 error handling in document upload
- Quota indicator in dashboard sidebar

Story 5.5 (Admin Seed & Approval Flow):
- Seed admin user migration with default credentials warning
- Subscription approval/rejection routes with admin guard
- 24h rejection cooldown enforcement

Story 5.6 (Admin-Only Model Config):
- Global model config visible across all search spaces
- Per-search-space model configs with user access control
- Superuser CRUD for global configs

Additional fixes from code review:
- PageLimitService: PAST_DUE subscriptions enforce free-tier limits
- TokenQuotaService: PAST_DUE subscriptions enforce free-tier limits
- Config routes: Fixed user_id.is_(None) filter on mutation endpoints
- Stripe webhook: Added guard against silent plan downgrade on unrecognized price_id

All changes formatted with Ruff (Python) and Biome (TypeScript).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:54:45 +07:00

212 lines
6.8 KiB
Python

"""Admin routes — superuser-only operations."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import (
SubscriptionRequest,
SubscriptionRequestStatus,
SubscriptionStatus,
User,
get_async_session,
)
from app.users import current_superuser
router = APIRouter(prefix="/admin", tags=["admin"])
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class SubscriptionRequestItem(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
user_email: str
plan_id: str
status: str
created_at: datetime
approved_at: datetime | None = None
approved_by: uuid.UUID | None = None
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# List pending subscription requests
# ---------------------------------------------------------------------------
@router.get(
"/subscription-requests",
response_model=list[SubscriptionRequestItem],
)
async def list_subscription_requests(
admin: User = Depends(current_superuser),
db_session: AsyncSession = Depends(get_async_session),
) -> list[SubscriptionRequestItem]:
"""Return all pending subscription requests."""
result = await db_session.execute(
select(SubscriptionRequest)
.where(SubscriptionRequest.status == SubscriptionRequestStatus.PENDING)
.order_by(SubscriptionRequest.created_at.asc())
)
requests = result.scalars().all()
# Collect user IDs and batch-load to avoid N+1
user_ids = [req.user_id for req in requests]
email_map: dict[uuid.UUID, str] = {}
if user_ids:
user_rows = await db_session.execute(select(User).where(User.id.in_(user_ids)))
for u in user_rows.scalars():
email_map[u.id] = u.email
items: list[SubscriptionRequestItem] = [
SubscriptionRequestItem(
id=req.id,
user_id=req.user_id,
user_email=email_map.get(req.user_id, "<deleted>"),
plan_id=req.plan_id,
status=req.status.value,
created_at=req.created_at,
approved_at=req.approved_at,
approved_by=req.approved_by,
)
for req in requests
]
return items
# ---------------------------------------------------------------------------
# Approve a subscription request
# ---------------------------------------------------------------------------
@router.post(
"/subscription-requests/{request_id}/approve",
response_model=SubscriptionRequestItem,
)
async def approve_subscription_request(
request_id: uuid.UUID,
admin: User = Depends(current_superuser),
db_session: AsyncSession = Depends(get_async_session),
) -> SubscriptionRequestItem:
"""Approve a pending subscription request and activate the user's subscription."""
result = await db_session.execute(
select(SubscriptionRequest)
.where(SubscriptionRequest.id == request_id)
.with_for_update()
)
req = result.scalar_one_or_none()
if req is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription request not found.",
)
if req.status != SubscriptionRequestStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Request is already {req.status.value}.",
)
user_result = await db_session.execute(
select(User).where(User.id == req.user_id).with_for_update()
)
req_user = user_result.scalar_one_or_none()
if req_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found."
)
# Activate subscription
plan_limits = config.PLAN_LIMITS.get(req.plan_id, config.PLAN_LIMITS["free"])
req_user.subscription_status = SubscriptionStatus.ACTIVE
req_user.plan_id = req.plan_id
req_user.monthly_token_limit = plan_limits["monthly_token_limit"]
req_user.pages_limit = max(req_user.pages_used or 0, plan_limits["pages_limit"])
req_user.tokens_used_this_month = 0
req_user.token_reset_date = datetime.now(UTC).date()
# Mark request approved
now = datetime.now(UTC)
req.status = SubscriptionRequestStatus.APPROVED
req.approved_at = now
req.approved_by = admin.id
await db_session.commit()
await db_session.refresh(req)
user_result2 = await db_session.execute(select(User).where(User.id == req.user_id))
req_user2 = user_result2.scalar_one_or_none()
email = req_user2.email if req_user2 else "<deleted>"
return SubscriptionRequestItem(
id=req.id,
user_id=req.user_id,
user_email=email,
plan_id=req.plan_id,
status=req.status.value,
created_at=req.created_at,
approved_at=req.approved_at,
approved_by=req.approved_by,
)
# ---------------------------------------------------------------------------
# Reject a subscription request
# ---------------------------------------------------------------------------
@router.post(
"/subscription-requests/{request_id}/reject",
response_model=SubscriptionRequestItem,
)
async def reject_subscription_request(
request_id: uuid.UUID,
admin: User = Depends(current_superuser),
db_session: AsyncSession = Depends(get_async_session),
) -> SubscriptionRequestItem:
"""Reject a pending subscription request."""
result = await db_session.execute(
select(SubscriptionRequest).where(SubscriptionRequest.id == request_id)
)
req = result.scalar_one_or_none()
if req is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription request not found.",
)
if req.status != SubscriptionRequestStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Request is already {req.status.value}.",
)
req.status = SubscriptionRequestStatus.REJECTED
await db_session.commit()
await db_session.refresh(req)
user_result = await db_session.execute(select(User).where(User.id == req.user_id))
req_user = user_result.scalar_one_or_none()
email = req_user.email if req_user else "<deleted>"
return SubscriptionRequestItem(
id=req.id,
user_id=req.user_id,
user_email=email,
plan_id=req.plan_id,
status=req.status.value,
created_at=req.created_at,
approved_at=req.approved_at,
approved_by=req.approved_by,
)