Five new admin routers:
1. routers/features.py
- GET/POST/PUT/DELETE /api/features
- Feature registry CRUD
- Allows adding new limitable features without schema changes
2. routers/tiers_mgmt.py
- GET/POST/PUT/DELETE /api/tiers
- Subscription tier management
- Price configuration, sort order
3. routers/tier_limits.py
- GET /api/tier-limits - Complete Tier x Feature matrix
- PUT /api/tier-limits - Update single limit
- PUT /api/tier-limits/batch - Batch update
- DELETE /api/tier-limits - Remove limit (fallback to default)
- Matrix editor backend
4. routers/user_restrictions.py
- GET/POST/PUT/DELETE /api/user-restrictions
- User-specific feature overrides
- Highest priority in access hierarchy
- Includes reason field for documentation
5. routers/access_grants.py
- GET /api/access-grants - List grants with filters
- POST /api/access-grants - Manual grant creation
- PUT /api/access-grants/{id} - Extend/pause grants
- DELETE /api/access-grants/{id} - Revoke access
- Activity logging
All endpoints require admin authentication.
Completes backend API for v9c Phase 2.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
159 lines
4.5 KiB
Python
159 lines
4.5 KiB
Python
"""
|
|
Tier Limits Management Endpoints for Mitai Jinkendo
|
|
|
|
Admin-only matrix editor for Tier x Feature limits.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_admin
|
|
|
|
router = APIRouter(prefix="/api/tier-limits", tags=["tier-limits"])
|
|
|
|
|
|
@router.get("")
|
|
def get_tier_limits_matrix(session: dict = Depends(require_admin)):
|
|
"""
|
|
Admin: Get complete Tier x Feature matrix.
|
|
|
|
Returns:
|
|
{
|
|
"tiers": [{id, name}, ...],
|
|
"features": [{id, name, category}, ...],
|
|
"limits": {
|
|
"tier_id:feature_id": limit_value,
|
|
...
|
|
}
|
|
}
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Get all tiers
|
|
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order")
|
|
tiers = [r2d(r) for r in cur.fetchall()]
|
|
|
|
# Get all features
|
|
cur.execute("""
|
|
SELECT id, name, category, limit_type, default_limit
|
|
FROM features
|
|
WHERE active = true
|
|
ORDER BY category, name
|
|
""")
|
|
features = [r2d(r) for r in cur.fetchall()]
|
|
|
|
# Get all tier_limits
|
|
cur.execute("SELECT tier_id, feature_id, limit_value FROM tier_limits")
|
|
limits = {}
|
|
for row in cur.fetchall():
|
|
key = f"{row['tier_id']}:{row['feature_id']}"
|
|
limits[key] = row['limit_value']
|
|
|
|
return {
|
|
"tiers": tiers,
|
|
"features": features,
|
|
"limits": limits
|
|
}
|
|
|
|
|
|
@router.put("")
|
|
def update_tier_limit(data: dict, session: dict = Depends(require_admin)):
|
|
"""
|
|
Admin: Update single tier limit.
|
|
|
|
Body:
|
|
{
|
|
"tier_id": "free",
|
|
"feature_id": "weight_entries",
|
|
"limit_value": 30 // NULL = unlimited, 0 = disabled
|
|
}
|
|
"""
|
|
tier_id = data.get('tier_id')
|
|
feature_id = data.get('feature_id')
|
|
limit_value = data.get('limit_value') # Can be None (NULL)
|
|
|
|
if not tier_id or not feature_id:
|
|
raise HTTPException(400, "tier_id und feature_id fehlen")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Upsert tier_limit
|
|
cur.execute("""
|
|
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (tier_id, feature_id)
|
|
DO UPDATE SET
|
|
limit_value = EXCLUDED.limit_value,
|
|
updated = CURRENT_TIMESTAMP
|
|
""", (tier_id, feature_id, limit_value))
|
|
|
|
conn.commit()
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
@router.put("/batch")
|
|
def update_tier_limits_batch(data: dict, session: dict = Depends(require_admin)):
|
|
"""
|
|
Admin: Batch update multiple tier limits.
|
|
|
|
Body:
|
|
{
|
|
"updates": [
|
|
{"tier_id": "free", "feature_id": "weight_entries", "limit_value": 30},
|
|
{"tier_id": "free", "feature_id": "ai_calls", "limit_value": 0},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
updates = data.get('updates', [])
|
|
|
|
if not updates:
|
|
raise HTTPException(400, "updates array fehlt")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
for update in updates:
|
|
tier_id = update.get('tier_id')
|
|
feature_id = update.get('feature_id')
|
|
limit_value = update.get('limit_value')
|
|
|
|
if not tier_id or not feature_id:
|
|
continue # Skip invalid entries
|
|
|
|
cur.execute("""
|
|
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (tier_id, feature_id)
|
|
DO UPDATE SET
|
|
limit_value = EXCLUDED.limit_value,
|
|
updated = CURRENT_TIMESTAMP
|
|
""", (tier_id, feature_id, limit_value))
|
|
|
|
conn.commit()
|
|
|
|
return {"ok": True, "updated": len(updates)}
|
|
|
|
|
|
@router.delete("")
|
|
def delete_tier_limit(tier_id: str, feature_id: str, session: dict = Depends(require_admin)):
|
|
"""
|
|
Admin: Delete tier limit (falls back to feature default).
|
|
|
|
Query params: ?tier_id=...&feature_id=...
|
|
"""
|
|
if not tier_id or not feature_id:
|
|
raise HTTPException(400, "tier_id und feature_id fehlen")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
DELETE FROM tier_limits
|
|
WHERE tier_id = %s AND feature_id = %s
|
|
""", (tier_id, feature_id))
|
|
conn.commit()
|
|
|
|
return {"ok": True}
|