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
377 lines
17 KiB
Python
377 lines
17 KiB
Python
"""Feedback: reported by a learner, worked through by an educator.
|
|
|
|
The comment thread that used to sit under every question was a discussion
|
|
nobody moderated. What it was actually used for was telling an educator
|
|
something was wrong — a private report someone is meant to act on — so that is
|
|
what this is.
|
|
|
|
An educator sees what is outstanding, which question or article each report is
|
|
about, and can reply, resolve or delete it. Resolving keeps the report: a
|
|
question with a history of the same complaint should visibly have one.
|
|
|
|
Two things the first version got wrong, both on the educator's side:
|
|
|
|
* Reading and answering were gated on *having* a grant rather than on the grant
|
|
covering the question, so any educator could read and delete reports on the
|
|
whole bank. They now go through `question_scope_predicate`, which is the same
|
|
answer the question editor gives.
|
|
* The queue was narrowed by primary category alone, which missed a question
|
|
reachable only through an additional category link, an exam or a discipline
|
|
grant — the report existed and simply never appeared for the person who could
|
|
act on it.
|
|
|
|
And one on the learner's: a reply was written and the learner had nowhere to
|
|
read it. `/mine` closes that loop.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.article import Article
|
|
from app.models.feedback import ArticleFeedback, QuestionFeedback
|
|
from app.models.question import Question
|
|
from app.models.user import User
|
|
from app.utils.auth import get_current_user
|
|
from app.utils.category_grants import (can_edit_article, is_question_manager,
|
|
question_scope_predicate)
|
|
|
|
router = APIRouter()
|
|
|
|
MAX_MESSAGE = 2000
|
|
|
|
|
|
class FeedbackIn(BaseModel):
|
|
message: str = Field(min_length=3, max_length=MAX_MESSAGE)
|
|
|
|
|
|
class ArticleFeedbackIn(FeedbackIn):
|
|
#: A section of the article, or absent for the article as a whole.
|
|
section_id: str | None = Field(default=None, max_length=64)
|
|
|
|
|
|
class ReplyIn(BaseModel):
|
|
reply: str | None = Field(default=None, max_length=MAX_MESSAGE)
|
|
status: str | None = None
|
|
|
|
|
|
# ── questions ─────────────────────────────────────────────────────────────────
|
|
|
|
def _json(row: QuestionFeedback, senders: dict[int, User], questions: dict[int, Question]) -> dict:
|
|
sender = senders.get(row.user_id)
|
|
question = questions.get(row.question_id)
|
|
return {
|
|
"id": row.id,
|
|
"kind": "question",
|
|
"question_id": row.question_id,
|
|
# The stem, trimmed: an educator scanning a list needs to recognise the
|
|
# question, not read it.
|
|
"question_excerpt": (getattr(question, "question_text", "") or "")[:120],
|
|
"message": row.message,
|
|
"status": row.status,
|
|
"reply": row.reply,
|
|
"replied_at": row.replied_at,
|
|
"created_at": row.created_at,
|
|
"from_name": getattr(sender, "name", None) or "A learner",
|
|
"from_email": getattr(sender, "email", None),
|
|
# Where the educator goes to act on it: the editor, where the report
|
|
# sits beside the field it is about.
|
|
"href": f"/questions/{row.question_id}",
|
|
}
|
|
|
|
|
|
def _decorate(db: Session, rows: list[QuestionFeedback]) -> list[dict]:
|
|
if not rows:
|
|
return []
|
|
senders = {u.id: u for u in db.query(User).filter(
|
|
User.id.in_({r.user_id for r in rows if r.user_id}))} if any(r.user_id for r in rows) else {}
|
|
questions = {q.id: q for q in db.query(Question).filter(
|
|
Question.id.in_({r.question_id for r in rows}))}
|
|
return [_json(row, senders, questions) for row in rows]
|
|
|
|
|
|
def _require_manager(db: Session, user: User) -> None:
|
|
if not is_question_manager(db, user):
|
|
raise HTTPException(403, "Reviewing feedback requires moderator access or a grant")
|
|
|
|
|
|
def _reachable_question_ids(db: Session, user: User, question_ids: set[int]) -> set[int]:
|
|
"""Which of these questions this educator's grants actually cover."""
|
|
predicate = question_scope_predicate(db, user)
|
|
if predicate is None:
|
|
return set(question_ids)
|
|
if not question_ids:
|
|
return set()
|
|
return {row[0] for row in db.query(Question.id).filter(
|
|
Question.id.in_(question_ids)).filter(predicate).all()}
|
|
|
|
|
|
def _visible(db: Session, user: User, query):
|
|
"""Narrow to the questions this educator may edit.
|
|
|
|
The grant predicate, not a set of categories: a category set silently drops
|
|
a question reached through an additional link, an exam or a folder, and the
|
|
report on it then belongs to nobody.
|
|
"""
|
|
predicate = question_scope_predicate(db, user)
|
|
if predicate is None:
|
|
return query
|
|
return query.join(Question, Question.id == QuestionFeedback.question_id).filter(predicate)
|
|
|
|
|
|
def _own_report(db: Session, user: User, row: QuestionFeedback) -> QuestionFeedback:
|
|
"""The report, if this educator's grants reach the question it is about."""
|
|
if not _reachable_question_ids(db, user, {row.question_id}):
|
|
raise HTTPException(403, "You do not have an editorial grant for that question")
|
|
return row
|
|
|
|
|
|
@router.post("/questions/{question_id}", status_code=201)
|
|
def leave_feedback(question_id: int, data: FeedbackIn, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Report something about a question. Any signed-in learner may."""
|
|
if not db.query(Question.id).filter(Question.id == question_id).first():
|
|
raise HTTPException(404, "Question not found")
|
|
row = QuestionFeedback(question_id=question_id, user_id=current_user.id,
|
|
message=data.message.strip())
|
|
db.add(row)
|
|
db.commit()
|
|
return {"sent": True, "id": row.id}
|
|
|
|
|
|
@router.get("/questions/{question_id}/mine")
|
|
def my_feedback_on_question(question_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""What this learner has already reported here, and what came back.
|
|
|
|
Without it a report went into silence: an educator could reply and resolve,
|
|
and the person who wrote in had no way of ever seeing that they had. It is
|
|
read from the question rather than from a list of one's own reports because
|
|
that is where the learner is when they wonder.
|
|
"""
|
|
rows = (db.query(QuestionFeedback)
|
|
.filter(QuestionFeedback.question_id == question_id,
|
|
QuestionFeedback.user_id == current_user.id)
|
|
.order_by(QuestionFeedback.created_at.desc())
|
|
.all())
|
|
return [{"id": row.id, "message": row.message, "status": row.status,
|
|
"reply": row.reply, "replied_at": row.replied_at,
|
|
"created_at": row.created_at} for row in rows]
|
|
|
|
|
|
@router.get("/questions/{question_id}")
|
|
def feedback_for_question(question_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Every report about one question, for the educator editing it."""
|
|
_require_manager(db, current_user)
|
|
if not _reachable_question_ids(db, current_user, {question_id}):
|
|
raise HTTPException(403, "You do not have an editorial grant for that question")
|
|
rows = (db.query(QuestionFeedback)
|
|
.filter(QuestionFeedback.question_id == question_id)
|
|
.order_by(QuestionFeedback.status.desc(), QuestionFeedback.created_at.desc())
|
|
.all())
|
|
return _decorate(db, rows)
|
|
|
|
|
|
@router.get("/open")
|
|
def open_feedback(limit: int = Query(8, ge=1, le=50), db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""What is outstanding — the count for a badge, and the newest few.
|
|
|
|
Questions and articles in one list. They are the same job to whoever is
|
|
doing it, and a second badge for reading would be a second place to forget
|
|
to look.
|
|
|
|
Answers quietly for someone with no editorial access rather than refusing,
|
|
so the header can ask for it without first asking who is asking.
|
|
"""
|
|
if not is_question_manager(db, current_user):
|
|
return {"open": 0, "items": []}
|
|
base = _visible(db, current_user,
|
|
db.query(QuestionFeedback).filter(QuestionFeedback.status == "open"))
|
|
total = base.with_entities(func.count(QuestionFeedback.id)).scalar() or 0
|
|
items = _decorate(db, base.order_by(QuestionFeedback.created_at.desc()).limit(limit).all())
|
|
|
|
article_rows = _article_visible(db, current_user, db.query(ArticleFeedback).filter(
|
|
ArticleFeedback.status == "open").order_by(ArticleFeedback.created_at.desc()).all())
|
|
total += len(article_rows)
|
|
items = sorted(items + _article_decorate(db, article_rows[:limit]),
|
|
key=lambda item: item["created_at"] or datetime.min, reverse=True)[:limit]
|
|
return {"open": int(total), "items": items}
|
|
|
|
|
|
@router.patch("/{feedback_id}")
|
|
def answer_feedback(feedback_id: int, data: ReplyIn, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Reply to a report, resolve it, or reopen it."""
|
|
_require_manager(db, current_user)
|
|
row = db.get(QuestionFeedback, feedback_id)
|
|
if not row:
|
|
raise HTTPException(404, "Feedback not found")
|
|
_own_report(db, current_user, row)
|
|
_apply_reply(row, data, current_user)
|
|
db.commit()
|
|
return _decorate(db, [row])[0]
|
|
|
|
|
|
@router.delete("/{feedback_id}", status_code=204)
|
|
def delete_feedback(feedback_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
_require_manager(db, current_user)
|
|
row = db.get(QuestionFeedback, feedback_id)
|
|
if not row:
|
|
raise HTTPException(404, "Feedback not found")
|
|
_own_report(db, current_user, row)
|
|
db.delete(row)
|
|
db.commit()
|
|
|
|
|
|
def _apply_reply(row, data: ReplyIn, current_user: User) -> None:
|
|
"""Shared between question and article reports; the two behave the same."""
|
|
if data.status is not None:
|
|
if data.status not in ("open", "resolved"):
|
|
raise HTTPException(400, "status must be open or resolved")
|
|
row.status = data.status
|
|
if data.reply is not None:
|
|
row.reply = data.reply.strip() or None
|
|
row.replied_by = current_user.id
|
|
row.replied_at = datetime.utcnow()
|
|
|
|
|
|
# ── articles ──────────────────────────────────────────────────────────────────
|
|
|
|
def _article_decorate(db: Session, rows: list[ArticleFeedback]) -> list[dict]:
|
|
if not rows:
|
|
return []
|
|
senders = {u.id: u for u in db.query(User).filter(
|
|
User.id.in_({r.user_id for r in rows if r.user_id}))} if any(r.user_id for r in rows) else {}
|
|
articles = {a.id: a for a in db.query(Article).filter(
|
|
Article.id.in_({r.article_id for r in rows}))}
|
|
out = []
|
|
for row in rows:
|
|
article = articles.get(row.article_id)
|
|
live = {sec.get("id") for sec in (getattr(article, "sections", None) or [])
|
|
if isinstance(sec, dict)}
|
|
current = next((sec.get("title") for sec in (getattr(article, "sections", None) or [])
|
|
if isinstance(sec, dict) and sec.get("id") == row.section_id), None)
|
|
sender = senders.get(row.user_id)
|
|
out.append({
|
|
"id": row.id,
|
|
"kind": "article",
|
|
"article_id": row.article_id,
|
|
"article_title": getattr(article, "title", None) or "An article",
|
|
"section_id": row.section_id,
|
|
# The heading as it reads now, falling back to what it was called
|
|
# when the report was written — a renamed section is still the
|
|
# section the reader meant.
|
|
"section_title": current or row.section_title,
|
|
"section_gone": bool(row.section_id) and row.section_id not in live,
|
|
"message": row.message,
|
|
"status": row.status,
|
|
"reply": row.reply,
|
|
"replied_at": row.replied_at,
|
|
"created_at": row.created_at,
|
|
"from_name": getattr(sender, "name", None) or "A learner",
|
|
"from_email": getattr(sender, "email", None),
|
|
"href": (f"/articles/{row.article_id}?section={row.section_id}"
|
|
if row.section_id else f"/articles/{row.article_id}"),
|
|
})
|
|
return out
|
|
|
|
|
|
def _article_visible(db: Session, user: User, rows: list[ArticleFeedback]) -> list[ArticleFeedback]:
|
|
"""Only reports on articles this person may edit.
|
|
|
|
Filtered in Python over the fetched rows rather than in SQL, because
|
|
`can_edit_article` walks the category tree and there is no predicate form
|
|
of it; the open queue is small enough that the difference is not worth a
|
|
second implementation that could disagree with the first.
|
|
"""
|
|
if user.is_moderator:
|
|
return rows
|
|
articles = {a.id: a for a in db.query(Article).filter(
|
|
Article.id.in_({r.article_id for r in rows}))} if rows else {}
|
|
return [row for row in rows
|
|
if row.article_id in articles and can_edit_article(db, user, articles[row.article_id])]
|
|
|
|
|
|
def _article(db: Session, article_id: int) -> Article:
|
|
article = db.get(Article, article_id)
|
|
if not article:
|
|
raise HTTPException(404, "Article not found")
|
|
return article
|
|
|
|
|
|
@router.post("/articles/{article_id}", status_code=201)
|
|
def leave_article_feedback(article_id: int, data: ArticleFeedbackIn, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Report something about an article, or about one section of it."""
|
|
article = _article(db, article_id)
|
|
title = None
|
|
if data.section_id:
|
|
title = next((sec.get("title") for sec in (article.sections or [])
|
|
if isinstance(sec, dict) and sec.get("id") == data.section_id), None)
|
|
if title is None:
|
|
raise HTTPException(404, "Section not found")
|
|
row = ArticleFeedback(article_id=article_id, section_id=data.section_id or None,
|
|
section_title=title, user_id=current_user.id,
|
|
message=data.message.strip())
|
|
db.add(row)
|
|
db.commit()
|
|
return {"sent": True, "id": row.id}
|
|
|
|
|
|
@router.get("/articles/{article_id}/mine")
|
|
def my_feedback_on_article(article_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""This reader's own reports on this article, and any replies."""
|
|
rows = (db.query(ArticleFeedback)
|
|
.filter(ArticleFeedback.article_id == article_id,
|
|
ArticleFeedback.user_id == current_user.id)
|
|
.order_by(ArticleFeedback.created_at.desc())
|
|
.all())
|
|
return [{"id": row.id, "section_id": row.section_id, "message": row.message,
|
|
"status": row.status, "reply": row.reply, "replied_at": row.replied_at,
|
|
"created_at": row.created_at} for row in rows]
|
|
|
|
|
|
@router.get("/articles/{article_id}")
|
|
def feedback_for_article(article_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Every report about one article, for whoever maintains it."""
|
|
article = _article(db, article_id)
|
|
if not can_edit_article(db, current_user, article):
|
|
raise HTTPException(403, "You do not maintain this article")
|
|
rows = (db.query(ArticleFeedback)
|
|
.filter(ArticleFeedback.article_id == article_id)
|
|
.order_by(ArticleFeedback.status.desc(), ArticleFeedback.created_at.desc())
|
|
.all())
|
|
return _article_decorate(db, rows)
|
|
|
|
|
|
@router.patch("/articles/reports/{feedback_id}")
|
|
def answer_article_feedback(feedback_id: int, data: ReplyIn, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
row = db.get(ArticleFeedback, feedback_id)
|
|
if not row:
|
|
raise HTTPException(404, "Feedback not found")
|
|
if not can_edit_article(db, current_user, _article(db, row.article_id)):
|
|
raise HTTPException(403, "You do not maintain this article")
|
|
_apply_reply(row, data, current_user)
|
|
db.commit()
|
|
return _article_decorate(db, [row])[0]
|
|
|
|
|
|
@router.delete("/articles/reports/{feedback_id}", status_code=204)
|
|
def delete_article_feedback(feedback_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
row = db.get(ArticleFeedback, feedback_id)
|
|
if not row:
|
|
raise HTTPException(404, "Feedback not found")
|
|
if not can_edit_article(db, current_user, _article(db, row.article_id)):
|
|
raise HTTPException(403, "You do not maintain this article")
|
|
db.delete(row)
|
|
db.commit()
|