"""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")