Arag / app /superadmin /routers /grants.py
AuthorBot
refactor: Phase 6A+6B — top-level imports and Pydantic schema upgrades
88872f0
Raw
History Blame Contribute Delete
7.11 kB
"""superadmin/routers/grants.py — Subscription grant management routes.
Routes:
GET /grants
POST /grants/{grant_id}/revoke
POST /grants/{grant_id}/bonus-tokens
POST /grants/{grant_id}/extend
POST /grants/{grant_id}/reset-tokens
POST /grant (convenience: grant by email)
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_superadmin, get_redis
from app.models.client_access import ClientAccess
from app.models.user import User
from app.schemas.admin import GrantByEmailRequest
from app.schemas.superadmin import (
RevokeAccessRequest,
AddBonusTokensRequest,
ExtendSubscriptionRequest,
)
from app.services.superadmin_service import SuperAdminService
from app.superadmin.routers._utils import _err
router = APIRouter()
@router.get("/grants")
async def list_all_grants(
limit: int = Query(100, ge=1, le=500),
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
):
"""List all subscription grants across all authors with token usage details."""
try:
result = await db.execute(
select(ClientAccess, User)
.join(User, ClientAccess.author_id == User.id)
.order_by(ClientAccess.granted_at.desc())
.limit(limit)
)
rows = result.all()
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
grants = []
for access, user in rows:
total_budget = access.token_budget + access.bonus_tokens
pct = round((access.tokens_used / total_budget * 100), 1) if total_budget > 0 else 0
exp = access.expires_at
if exp and exp.tzinfo is not None:
exp = exp.replace(tzinfo=None)
is_expired = exp < now_naive if exp else True
grants.append({
"grant_id": access.id,
"author_id": access.author_id,
"author_email": user.email,
"author_name": user.full_name,
"plan": access.plan,
"token_budget": access.token_budget,
"bonus_tokens": access.bonus_tokens,
"total_budget": total_budget,
"tokens_used": access.tokens_used,
"tokens_remaining": max(0, total_budget - access.tokens_used),
"usage_pct": pct,
"granted_at": access.granted_at.isoformat() if access.granted_at else None,
"expires_at": access.expires_at.isoformat() if access.expires_at else None,
"is_revoked": access.is_revoked,
"is_expired": is_expired,
"is_active": not access.is_revoked and not is_expired,
"auto_renew": access.auto_renew,
"notes": access.notes,
})
return {"grants": grants, "count": len(grants)}
except Exception as e:
return _err(e, "list_all_grants")
@router.post("/grants/{grant_id}/revoke", status_code=200)
async def revoke_access(
grant_id: str,
payload: RevokeAccessRequest,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Revoke a subscription instantly (Redis blacklist)."""
try:
await SuperAdminService(db, redis).revoke_access(
actor=superadmin, grant_id=grant_id, reason=payload.reason,
)
return {"message": "Access revoked"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
return _err(e, "revoke_access")
@router.post("/grants/{grant_id}/bonus-tokens", status_code=200)
async def add_bonus_tokens(
grant_id: str,
payload: AddBonusTokensRequest,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Add bonus tokens without changing subscription expiry."""
try:
updated = await SuperAdminService(db, redis).add_bonus_tokens(
actor=superadmin, grant_id=grant_id, bonus_tokens=payload.bonus_tokens,
)
return {
"message": "Bonus tokens added",
"new_bonus_tokens": updated.bonus_tokens if updated else 0,
}
except Exception as e:
return _err(e, "add_bonus_tokens")
@router.post("/grants/{grant_id}/extend", status_code=200)
async def extend_subscription(
grant_id: str,
payload: ExtendSubscriptionRequest,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Extend subscription expiry date."""
try:
updated = await SuperAdminService(db, redis).extend_subscription(
actor=superadmin, grant_id=grant_id, extend_days=payload.extend_days,
)
return {
"message": "Subscription extended",
"new_expires_at": updated.expires_at.isoformat() if updated else None,
}
except Exception as e:
return _err(e, "extend_subscription")
@router.post("/grants/{grant_id}/reset-tokens", status_code=200)
async def reset_token_usage(
grant_id: str,
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Reset tokens_used to 0 for a subscription (fresh start)."""
try:
updated = await SuperAdminService(db, redis).reset_token_usage(
actor=superadmin, grant_id=grant_id,
)
return {
"message": "Token usage reset to 0",
"grant_id": grant_id,
"tokens_used": updated.tokens_used if updated else 0,
}
except Exception as e:
return _err(e, "reset_token_usage")
@router.post("/grant", status_code=201)
async def grant_by_email(
body: "GrantByEmailRequest",
superadmin=Depends(get_current_superadmin),
db: AsyncSession = Depends(get_db),
redis=Depends(get_redis),
):
"""Convenience: grant access by author email. R-029: Validated via Pydantic schema."""
try:
email = body.author_email
result = await db.execute(select(User).where(User.email == email))
author = result.scalar_one_or_none()
if not author:
raise HTTPException(404, f"Author with email '{email}' not found. Register them first.")
plan_map = {
"starter": "monthly", "pro": "quarterly",
"enterprise": "semi_annual", "unlimited": "annual",
}
plan = plan_map.get(body.plan, body.plan)
service = SuperAdminService(db, redis)
result = await service.grant_access(
actor=superadmin,
author_id=author.id,
plan=plan,
auto_renew=False,
notes=body.notes,
custom_token_budget=body.token_budget,
)
return result
except HTTPException:
raise
except Exception as e:
return _err(e, "grant_by_email")