Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-11 12:31:25 +12:00
parent e2be8b3872
commit ec0a866ef3
32 changed files with 650 additions and 214 deletions
+27 -3
View File
@@ -1,3 +1,4 @@
from ..feature_access import permissions, update_permissions
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import asyncio
@@ -1199,7 +1200,7 @@ async def list_users_summary() -> Dict[str, Any]:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
results.append({**user, "stats": stats})
results.append({**user, "features": permissions(user), "stats": stats})
return {"users": results}
@router.get("/users/{username}")
@@ -1209,7 +1210,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.get("/users/id/{user_id}")
@@ -1219,7 +1220,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.post("/users/{username}/block")
@@ -2122,3 +2123,26 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": invite_id}
@router.put("/users/features/bulk")
async def bulk_feature_permissions(payload: Dict[str, Any]) -> dict:
try:
updated = update_permissions(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"updated": updated, "scope": "non-admin-users"}
@router.put("/users/{username}/features")
async def user_feature_permissions(username: str, payload: Dict[str, Any]) -> dict:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("role") == "admin":
raise HTTPException(status_code=400, detail="Administrators always have all features")
try:
update_permissions(payload, username)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"features": permissions(get_user_by_username(username))}
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
@@ -1233,7 +1234,7 @@ async def update_profile_email(payload: dict, current_user: dict = Depends(get_c
return {"status": "ok", "email": email}
@router.get("/profile/invites")
@router.get("/profile/invites", dependencies=[Depends(require_invites)])
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip()
if not username:
+19 -3
View File
@@ -9,7 +9,9 @@ from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from ..feature_guards import require_request_stream, check
from ..feature_access import permissions
from ..db import get_user_by_username
from . import requests as requests_router
router = APIRouter(prefix="/events", tags=["events"])
@@ -76,7 +78,7 @@ async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream),
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
@@ -91,6 +93,13 @@ async def events_stream(
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
@@ -148,7 +157,7 @@ async def events_stream(
async def request_events_stream(
request_id: str,
request: Request,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
@@ -164,6 +173,13 @@ async def request_events_stream(
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_stats
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
@@ -10,7 +11,7 @@ from ..services.insights_artwork import get_artwork
from ..services.monthly_reports import get_monthly_report, report_csv
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/insights", tags=["insights"])
router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
class MonthlyReportQuery(BaseModel):
+6 -4
View File
@@ -1,4 +1,5 @@
from __future__ import annotations
from ..feature_guards import require_portal_access
import logging
import re
@@ -33,7 +34,7 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
@@ -654,10 +655,11 @@ async def _notify(
@router.get("/overview")
async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
async def portal_overview(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
kind = _normalize_choice(kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True)
mine = count_portal_items(kind=kind, mine_username=str(current_user.get("username") or ""))
return {
"overview": get_portal_overview(),
"overview": get_portal_overview(kind) if kind else get_portal_overview(),
"my_items": mine,
}
+4 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user, require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
@@ -65,7 +66,7 @@ def error(exc: recaps.RecapError):
@router.get("/profile/email-recaps")
def preferences(user: dict = Depends(get_current_user)) -> dict:
def preferences(user: dict = Depends(require_stats)) -> dict:
try:
return recaps.preferences(user)
except recaps.RecapError as exc:
@@ -73,7 +74,7 @@ def preferences(user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email-recaps")
async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
try:
if payload.enabled:
return await recaps.subscribe(user, payload.automatic_monthly)
@@ -135,7 +136,7 @@ def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
@router.post('/profile/email-recaps/send', status_code=202)
def email_personal_report(payload: TestEmail, user: dict = Depends(get_current_user)) -> dict:
def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
try:
return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_request_access
from typing import Any, Dict, List, Optional, Tuple
import asyncio
import httpx
@@ -65,7 +66,7 @@ from ..services.snapshot import (
jellyfin_item_matches_request,
)
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}