571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30 shared tests and 2 questions carried somebody's name — mostly daniel@danvics.com, which is not even the working administrator any more. So "who may edit this" partly depended on who happened to create it, and handing the site to somebody else would have meant rewriting every one of those rows. Migration q6a7b8c9d0e1 empties those owner columns and makes them nullable, because ownerless is now a legitimate state and a NOT NULL owner is exactly what forced a name onto every row. Nothing is deleted and nothing moves. What keeps its owner, deliberately: attempts, notes, favourites, collections, folders, study-plan progress, and the quizzes that are somebody's own sittings rather than shared bank tests. study_plans needed nothing — it never had an owner column. Then the code, so it cannot grow back. Authorship is no longer a way in anywhere: may_edit_question and can_edit_article ask the role and the grants and nothing else; the article draft, status and delete paths lost their "or you wrote it" arm; decks are the bank's, so an educator reaches any of them and a learner reaches the shared ones; documents are the corpus, so they are editors-only rather than "mine"; and every creation path writes user_id NULL. The bank listing's "mine" facet went with it — it counted nothing and could only ever count nothing. Verified against production as a real learner account: every bank write 403s, admin settings 403, documents empty. As an admin, everything opens. Also: a category grant no longer offers Editorial in the menu. It offers Questions and Images, which is what a grant covers; Editorial is the whole library's review queue and its route is moderator-only, so the entry was a door that answered "Not yours to open". Six tests changed rather than deleted — they asserted the old model, and each now asserts the new one: writing an article does not make it yours, writing a question does not make it yours, an answer image is not opened by authorship, the tutor is not opened by authorship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
237 lines
9.9 KiB
Python
237 lines
9.9 KiB
Python
"""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)
|