Four things that share a spine, so they arrive together. **Folders.** A hand-picked set of questions, and the fourth thing a grant can name beside exam, discipline and category. Deliberately not `user_collections` with a sharing flag: a library is a consequence of access — you save what you can already see — while a folder is a source of it, and one table holding thousands of private lists beside a handful that confer permission is one mistake away from a leak. Built from the question manager, granted on /access. Membership stays with the owner and moderators so a grantee cannot widen their own reach, and deleting a folder takes its grants with it. Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension` and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant failed the check and two folder grants collided on the unique index. **Per-question feedback.** The learner's half already existed. What was wrong was who could read it: any grant at all let an educator list and delete reports about the whole bank. Reports are now scoped by `question_scope_predicate`, the same predicate that decides which questions that educator can see, and a reply thread makes the report a conversation the learner can follow rather than a form that swallows what they said. **Per-section notes and article feedback.** Two tables on purpose: `article_section_notes` is private to whoever wrote it, `article_feedback` goes to whoever maintains the article. Both point at the section id inside `articles.sections` rather than at `article_section_index`, whose rows are dropped on unpublish — a cascade from there would delete a learner's writing because an educator took an article down for an afternoon. A rename keeps a note attached; a deleted section leaves it marked orphaned under the heading it was written on, for its writer alone to remove. The header's feedback badge covers both, because questions and reading are the same job to whoever is doing it. Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
188 lines
8.7 KiB
Python
188 lines
8.7 KiB
Python
"""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.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.
|
|
"""
|
|
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)
|