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
114 lines
5.1 KiB
Python
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()
|