"""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()