pdf-quiz-generator/backend/tests/test_feedback.py
Daniel 392a2cc483 feat: feedback replaces comments; Qbank is a landing page; one question page
Comments are gone. A thread under every question was a discussion nobody
moderated, and what it was used for was telling an educator something was
wrong. That is now feedback: a private report, carrying the question id,
that someone is expected to act on.

  * Give feedback sits in the question bar's new "more" menu, beside Save
    and Share — occasional actions, folded away rather than each taking a
    slot in a bar read on every question.
  * An educator gets a badge of what is outstanding. Each row names the
    question and opens its editor, where the report sits beside the field
    it is about; reply, resolve, reopen or delete from there.
  * Resolving keeps the report. A question with a history of the same
    complaint should visibly have one; deleting is for the ones that were
    never about the question.
  * A granted educator sees only their own branch. The badge answers
    quietly with zero for someone with no access, so the header can ask
    without first working out who is asking.

The question bank is now the Qbank: create a session, and the last three
with Resume. Its facets, tag tree and create-a-quiz were a second copy of
the custom-session page; marking and folders belong in the player while
you are sitting a question. Import and export moved to the question
manager, which is the one place questions are managed, and which now has
a Preview that opens over the list instead of a page you have to come
back from.

Fixed while there: a session in progress analysed as 0/0 with an empty
table, because the analysis read attempt_answers — written on submit —
while the session list counted the saved progress. They read the same
thing now. The category trail is gone from the player: it named the
answer's own topic and led out of a session part-way through. An option's
reasoning opens on click and closes on the next one.

Backend 253/253, frontend 323/323.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:50:57 +02:00

114 lines
5.1 KiB
Python

"""Feedback on a question: reported by a learner, worked through by an educator.
Disposable SQLite. The rules worth pinning: any learner may report, only
someone with editorial access may read or answer, resolving keeps the report,
and the badge answers quietly for someone with no access rather than refusing.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.category_grant import CategoryGrant
from app.models.feedback import QuestionFeedback
from app.routers import feedback
class FeedbackTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(feedback.router, prefix="/feedback")
self.bank.user = self.bank.owner
def tearDown(self):
self.bank.tearDown()
def send(self, question_id=1, message="The answer key looks wrong."):
return self.client.post(f"/feedback/questions/{question_id}", json={"message": message})
def test_any_learner_may_report_and_it_names_the_question(self):
self.bank.user = self.bank.peer
response = self.send()
self.assertEqual(response.status_code, 201, response.text)
row = self.db.query(QuestionFeedback).one()
self.assertEqual((row.question_id, row.user_id, row.status), (1, self.bank.peer.id, "open"))
def test_a_report_about_nothing_is_refused(self):
self.assertEqual(self.send(question_id=9999).status_code, 404)
self.assertEqual(self.client.post("/feedback/questions/1", json={"message": "no"}).status_code, 422)
def test_only_an_educator_may_read_or_answer(self):
self.send()
row = self.db.query(QuestionFeedback).one()
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/feedback/questions/1").status_code, 403)
self.assertEqual(self.client.patch(f"/feedback/{row.id}", json={"status": "resolved"}).status_code, 403)
self.assertEqual(self.client.delete(f"/feedback/{row.id}").status_code, 403)
def test_the_badge_answers_quietly_for_someone_with_no_access(self):
self.send()
self.bank.user = self.bank.peer
body = self.client.get("/feedback/open").json()
# Not a 403: the header asks for this without first asking who is asking.
self.assertEqual(body, {"open": 0, "items": []})
def test_an_educator_sees_what_is_outstanding_with_the_question_it_is_about(self):
self.send()
self.bank.user = self.bank.mod
body = self.client.get("/feedback/open").json()
self.assertEqual(body["open"], 1)
self.assertEqual(body["items"][0]["question_id"], 1)
self.assertTrue(body["items"][0]["question_excerpt"])
self.assertEqual(body["items"][0]["from_name"], self.bank.owner.name)
def test_replying_resolves_and_keeps_the_report(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
answered = self.client.patch(f"/feedback/{row_id}",
json={"reply": "Fixed, thank you.", "status": "resolved"})
self.assertEqual(answered.status_code, 200, answered.text)
self.assertEqual(answered.json()["reply"], "Fixed, thank you.")
# Kept, not deleted: a question with a history of the same complaint
# should visibly have one.
self.db.expire_all()
self.assertEqual(self.db.query(QuestionFeedback).count(), 1)
self.assertEqual(self.client.get("/feedback/open").json()["open"], 0)
def test_a_resolved_report_can_be_reopened(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
self.client.patch(f"/feedback/{row_id}", json={"status": "resolved"})
self.client.patch(f"/feedback/{row_id}", json={"status": "open"})
self.assertEqual(self.client.get("/feedback/open").json()["open"], 1)
self.assertEqual(self.client.patch(f"/feedback/{row_id}", json={"status": "maybe"}).status_code, 400)
def test_deleting_removes_it(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f"/feedback/{row_id}").status_code, 204)
self.assertEqual(self.db.query(QuestionFeedback).count(), 0)
def test_a_granted_educator_sees_only_their_own_branch(self):
# Questions 1 and 2 sit under the Root branch; 6 is filed nowhere, so
# no grant over the tree can reach it.
self.send(question_id=1)
self.send(question_id=6)
self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
self.db.commit()
self.bank.user = self.bank.peer
body = self.client.get("/feedback/open").json()
self.assertEqual(body["open"], 1)
self.assertEqual(body["items"][0]["question_id"], 1)
# A moderator sees both, including the one filed nowhere.
self.bank.user = self.bank.mod
self.assertEqual(self.client.get("/feedback/open").json()["open"], 2)
if __name__ == "__main__":
unittest.main()