"""Scoped editorial access for non-moderator educators. A moderator or admin manages every question. Everyone else manages only what their grants cover. A grant names any combination of exam, discipline tag, category and folder; it covers the questions matching *all* the dimensions it sets, and an unset dimension means "any". Holding several grants is a union of their coverage. A folder is the one dimension that is a list rather than a description: it covers exactly the questions somebody put in it, which is the shape the other three cannot express. `question_scope_predicate` returns None to mean "no restriction". """ from fastapi import HTTPException from sqlalchemy.orm import Session from app.models.category_grant import CategoryGrant from app.models.folder import QuestionFolderQuestion from app.models.question import Question from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.user import User _TAG_LINKS = None # A predicate that matches no question, for a user whose grants cover nothing. _NOTHING = Question.id.is_(None) def _descendants(db: Session, roots: set[int]) -> set[int]: """Expand category ids to include every category beneath them.""" if not roots: return set() children: dict[int, list[int]] = {} for cid, parent_id in db.query(QuestionCategory.id, QuestionCategory.parent_id).all(): if parent_id is not None: children.setdefault(parent_id, []).append(cid) seen, stack = set(roots), list(roots) while stack: for child in children.get(stack.pop(), ()): if child not in seen: seen.add(child) stack.append(child) return seen def granted_category_scope(db: Session, user: User) -> set[int] | None: """Categories this user's grants cover, including everything beneath them. None means every category — a moderator. An empty set means none. """ if user.is_moderator: return None roots = {row[0] for row in db.query(CategoryGrant.category_id).filter( CategoryGrant.user_id == user.id, CategoryGrant.category_id.isnot(None)).all()} return _descendants(db, roots) def can_edit_article(db: Session, user: User, article) -> bool: """Whether this user may change this article. A moderator may. Beyond that, an article is filed under a category, so a grant over that branch covers the reading in it as well as the questions — otherwise an educator given a branch can edit its questions and not the article they are meant to be read with. Authorship is not a way in. An article belongs to the library, not to whoever typed it, and the address that wrote most of this one is not even the working administrator any more. """ if user.is_moderator: return True if article.category_id is None: return False scope = granted_category_scope(db, user) return scope is not None and article.category_id in scope def question_scope_predicate(db: Session, user: User): """A filter over Question covering everything this user may edit, or None for all. Each grant contributes an AND of the dimensions it sets; the grants are ORed together. A category dimension includes that category's descendants, and matches a question through its primary category or an additional link. """ from sqlalchemy import or_, select from app.models.exam import QuestionExamLink if user.is_moderator: return None grants = db.query(CategoryGrant).filter(CategoryGrant.user_id == user.id).all() if not grants: return _NOTHING clauses = [] for grant in grants: parts = [] if grant.category_id is not None: ids = _descendants(db, {grant.category_id}) parts.append(or_( Question.question_category_id.in_(ids), Question.id.in_(select(QuestionCategoryLink.question_id).where( QuestionCategoryLink.category_id.in_(ids))), )) if grant.exam_id is not None: parts.append(Question.id.in_(select(QuestionExamLink.question_id).where( QuestionExamLink.exam_id == grant.exam_id))) if grant.tag_id is not None: parts.append(Question.id.in_( select(_tag_links().c.question_id).where(_tag_links().c.tag_id == grant.tag_id))) if grant.folder_id is not None: parts.append(Question.id.in_(select(QuestionFolderQuestion.question_id).where( QuestionFolderQuestion.folder_id == grant.folder_id))) if not parts: continue # a grant naming nothing grants nothing clause = parts[0] for extra in parts[1:]: clause = clause & extra clauses.append(clause) if not clauses: return _NOTHING combined = clauses[0] for extra in clauses[1:]: combined = combined | extra return combined def _tag_links(): """question_tag_links is created by raw DDL, so it is reflected, not mapped.""" from sqlalchemy import Column, Integer, MetaData, Table global _TAG_LINKS if _TAG_LINKS is None: _TAG_LINKS = Table( "question_tag_links", MetaData(), Column("question_id", Integer), Column("tag_id", Integer), ) return _TAG_LINKS def granted_folder_ids(db: Session, user: User) -> set[int]: """Folders whose questions this user has been granted. Empty for a moderator. A moderator reaches everything without a grant, so asking which folders they hold is the wrong question; callers check `is_moderator` first. """ return {row[0] for row in db.query(CategoryGrant.folder_id).filter( CategoryGrant.user_id == user.id, CategoryGrant.folder_id.isnot(None)).all()} def manageable_categories(db: Session, user: User) -> set[int] | None: """Category ids this user may edit questions in; None means all of them. Categories only. Someone whose access comes entirely from a folder or an exam grant gets an empty set from this and still manages questions — use `question_scope_predicate` to ask what a person reaches, and this only where the answer genuinely has to be a category (filling a category picker, checking where a new question may be filed). """ if user.is_moderator: return None # Grants naming only an exam, a tag or a folder contribute no category, and # a None left in this set turns into `IN (NULL)` further down, which matches # nothing but reads like it might. roots = {row[0] for row in db.query(CategoryGrant.category_id).filter( CategoryGrant.user_id == user.id, CategoryGrant.category_id.isnot(None)).all()} return _descendants(db, roots) def is_question_manager(db: Session, user: User) -> bool: """True when the user may edit questions somewhere.""" if user.is_moderator: return True return db.query(CategoryGrant.id).filter(CategoryGrant.user_id == user.id).first() is not None def require_question_manager(db: Session, user: User) -> set[int] | None: """Gate a question-management route; returns the caller's category scope.""" if not is_question_manager(db, user): raise HTTPException(403, "Question management requires moderator access or a grant") return manageable_categories(db, user) def assert_can_manage_category(scope: set[int] | None, category_id: int | None, what: str = "category") -> None: if scope is None: return if category_id is None or category_id not in scope: raise HTTPException(403, f"You do not have an editorial grant for this {what}") def assert_user_can_manage(db: Session, user: User, question_ids: list[int]) -> None: """Every target question must fall inside one of the user's grants. Checked against the grant predicate rather than a category set, so exam and discipline dimensions are honoured, not just categories. """ predicate = question_scope_predicate(db, user) if predicate is None or not question_ids: return allowed = { row[0] for row in db.query(Question.id).filter( Question.id.in_(question_ids)).filter(predicate).all() } existing = {row[0] for row in db.query(Question.id).filter(Question.id.in_(question_ids)).all()} # A missing row is the caller's 404 to report, not a permission error. for question_id in question_ids: if question_id in existing and question_id not in allowed: raise HTTPException(403, "You do not have an editorial grant for one of these questions") def assert_can_manage_questions(db: Session, scope: set[int] | None, question_ids: list[int]) -> None: """Every target question must sit in a granted category (primary or additional).""" if scope is None or not question_ids: return primary = dict(db.query(Question.id, Question.question_category_id).filter( Question.id.in_(question_ids)).all()) extra: dict[int, set[int]] = {} for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter( QuestionCategoryLink.question_id.in_(question_ids)).all(): extra.setdefault(qid, set()).add(cid) for question_id in question_ids: if question_id not in primary: continue # a missing row is reported as 404 by the caller, not as a permission error owned = ({primary[question_id]} if primary[question_id] else set()) | extra.get(question_id, set()) if not (owned & scope): raise HTTPException(403, "You do not have an editorial grant for one of these questions") def question_in_scope(db: Session, scope: set[int], question: Question) -> bool: """True when a question sits in a granted category (primary or additional).""" owned = {question.question_category_id} if question.question_category_id else set() owned |= {row[0] for row in db.query(QuestionCategoryLink.category_id).filter( QuestionCategoryLink.question_id == question.id).all()} return bool(owned & scope)