"""Who may edit what — every kind of access in one place. Access was granted in three separate screens over two tables: category grants from the question manager, media-library grants from the image bank, and nothing at all for articles. Nobody could see what one person actually had. This is one surface over the same tables. It does not invent a new permission model; it reads and writes the grants that already exist, and adds the one answer that was missing — "everything", which is not a grant but the moderator role, and says so rather than pretending otherwise. Inheritance is already how category grants work: a grant on a branch covers every category beneath it, the questions filed under them, and now the articles too. A folder is the exception to inheritance, and the reason it exists: it covers exactly the questions somebody put in it. A branch is a description of where a question is filed, so it keeps covering whatever is filed there next; a folder is a list, so it covers that list and nothing else. """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session from app.database import get_db from app.services import sso_roles from app.models.article import Article from app.models.category_grant import CategoryGrant from app.models.folder import QuestionFolder, QuestionFolderQuestion from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant from app.models.question import Question from app.models.question_category import QuestionCategory from app.models.user import User from app.utils.auth import require_moderator from app.utils.category_grants import _descendants router = APIRouter() class GrantWrite(BaseModel): kind: str # category | library | folder target_id: int class RoleWrite(BaseModel): #: "moderator" is everything; "user" is only what the grants below cover. role: str def _user_json(user: User) -> dict: return {"id": user.id, "name": user.name, "email": user.email, "role": user.role} @router.get("/") def access_overview(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Everyone who can be given access, and what each of them holds.""" users = db.query(User).order_by(User.name).all() category_grants: dict[int, list[int]] = {} for user_id, category_id in db.query(CategoryGrant.user_id, CategoryGrant.category_id).filter( CategoryGrant.category_id.isnot(None)).all(): category_grants.setdefault(user_id, []).append(category_id) library_grants: dict[int, list[int]] = {} for user_id, library_id in db.query(MediaLibraryGrant.user_id, MediaLibraryGrant.library_id).all(): library_grants.setdefault(user_id, []).append(library_id) folder_grants: dict[int, list[int]] = {} for user_id, folder_id in db.query(CategoryGrant.user_id, CategoryGrant.folder_id).filter( CategoryGrant.folder_id.isnot(None)).all(): folder_grants.setdefault(user_id, []).append(folder_id) rows = [] for user in users: categories = category_grants.get(user.id, []) # What the grants actually reach, once the tree is walked. covered = _descendants(db, set(categories)) rows.append({ **_user_json(user), "everything": bool(user.is_moderator), "categories": sorted(categories), "categories_covered": len(covered), "libraries": sorted(library_grants.get(user.id, [])), "folders": sorted(folder_grants.get(user.id, [])), }) return {"users": rows} @router.get("/tree") def access_tree(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """What there is to grant: the category tree, the image libraries, the folders.""" direct = dict(db.query(Question.question_category_id, func.count(Question.id)) .filter(Question.question_category_id.isnot(None)) .group_by(Question.question_category_id).all()) articles = dict(db.query(Article.category_id, func.count(Article.id)) .filter(Article.category_id.isnot(None)) .group_by(Article.category_id).all()) categories = [{ "id": cat.id, "name": cat.name, "parent_id": cat.parent_id, "questions": direct.get(cat.id, 0), "articles": articles.get(cat.id, 0), } for cat in db.query(QuestionCategory).order_by(QuestionCategory.name).all()] assets = dict(db.query(MediaAsset.library_id, func.count(MediaAsset.id)) .filter(MediaAsset.library_id.isnot(None)) .group_by(MediaAsset.library_id).all()) libraries = [{"id": lib.id, "name": lib.name, "assets": assets.get(lib.id, 0)} for lib in db.query(MediaLibrary).order_by(MediaLibrary.name).all()] held = dict(db.query(QuestionFolderQuestion.folder_id, func.count(QuestionFolderQuestion.id)) .group_by(QuestionFolderQuestion.folder_id).all()) folders = [{"id": folder.id, "name": folder.name, "description": folder.description, "questions": held.get(folder.id, 0)} for folder in db.query(QuestionFolder).order_by(QuestionFolder.name).all()] return {"categories": categories, "libraries": libraries, "folders": folders} def _target_user(db: Session, user_id: int) -> User: user = db.get(User, user_id) if not user: raise HTTPException(404, "User not found") return user @router.post("/{user_id}/grants", status_code=201) def add_grant(user_id: int, data: GrantWrite, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): user = _target_user(db, user_id) if user.is_moderator: raise HTTPException(400, "This person already has everything; a grant would add nothing") if data.kind == "category": if not db.get(QuestionCategory, data.target_id): raise HTTPException(404, "Category not found") if db.query(CategoryGrant.id).filter_by(category_id=data.target_id, user_id=user_id).first(): raise HTTPException(409, "Already granted") db.add(CategoryGrant(category_id=data.target_id, user_id=user_id, granted_by=current_user.id)) elif data.kind == "library": if not db.get(MediaLibrary, data.target_id): raise HTTPException(404, "Library not found") if db.query(MediaLibraryGrant.id).filter_by(library_id=data.target_id, user_id=user_id).first(): raise HTTPException(409, "Already granted") db.add(MediaLibraryGrant(library_id=data.target_id, user_id=user_id, granted_by=current_user.id)) elif data.kind == "folder": if not db.get(QuestionFolder, data.target_id): raise HTTPException(404, "Folder not found") if db.query(CategoryGrant.id).filter_by(folder_id=data.target_id, user_id=user_id).first(): raise HTTPException(409, "Already granted") db.add(CategoryGrant(folder_id=data.target_id, user_id=user_id, granted_by=current_user.id)) else: raise HTTPException(400, "kind must be category, library or folder") db.commit() return {"granted": data.kind, "target_id": data.target_id, "user_id": user_id} @router.delete("/{user_id}/grants/{kind}/{target_id}", status_code=204) def remove_grant(user_id: int, kind: str, target_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): model = {"category": CategoryGrant, "library": MediaLibraryGrant, "folder": CategoryGrant}.get(kind) if model is None: raise HTTPException(400, "kind must be category, library or folder") column = {"category": "category_id", "library": "library_id", "folder": "folder_id"}[kind] grant = db.query(model).filter_by(user_id=user_id, **{column: target_id}).first() if not grant: raise HTTPException(404, "Grant not found") db.delete(grant) db.commit() @router.put("/{user_id}/role") def set_role(user_id: int, data: RoleWrite, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Give or take away "everything". This is a role change, not a grant, and the two are kept distinct on purpose: a wildcard grant that silently meant everything would be the kind of permission nobody can audit. """ sso_roles.refuse_local_role_change() if data.role not in ("user", "moderator"): raise HTTPException(400, "role must be user or moderator") user = _target_user(db, user_id) if user.role == "admin": raise HTTPException(400, "An administrator's role is not changed here") if user.id == current_user.id: raise HTTPException(400, "Change someone else's access, not your own") user.role = data.role db.commit() return _user_json(user)