"""素材小测 —— two or three questions attached to a material. "Pressed confirm" is the evidence an auditor generally accepts, and the first four PRs of this theme are built around recording it honestly. But it can only ever prove somebody **clicked**, not that they understood. Attaching a couple of questions and requiring correct answers is the one way to write the second thing down — and, not incidentally, it is the version employees mind least: it replaces an unanswerable "did you read it?" with a thirty-second task that gives them feedback. ## Design notes worth keeping * **The question bank is the existing one.** ``secquiz_questions`` already holds the daily-quiz bank with a validated ``options``/``correct_key`` shape and an admin console. A second question model would mean a second editor, a second grader and two ways for a "correct answer" to be spelled. * **The grader is not re-implemented either.** Answer normalisation is the security-quiz domain's ``_normalize_keys`` semantics — sorted, upper-cased key concatenation — restated here as a tiny local function because domains do not import each other, with a test pinning the two against the same cases. * **Every attempt is stored, including failures.** Which questions people get wrong the first time is the most direct evidence the security team has that a passage of their material is unclear. * **Passing IS the acknowledgement.** When a material carries a quiz, the "I have read and understood" button is replaced by the quiz — two buttons for one claim would let somebody assert understanding without demonstrating it, which is exactly the gap the quiz exists to close. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException, NotFoundError from app.domains.security_awareness.catalog import STATUS_PUBLISHED from app.domains.security_awareness.models import ( SecawMaterial, SecawMaterialQuestion, SecawQuizAttempt, ) from app.domains.security_quiz import localization as quiz_localization from app.domains.security_quiz.models import SecQuizQuestion logger = logging.getLogger(__name__) #: Every question must be right. Not a percentage: two or three questions #: written specifically for one short piece of material do not support a #: meaningful partial score, and "you got 2 of 3, that counts" is a #: weaker claim than the acknowledgement it replaces. Retries are #: unlimited and the explanation is shown on a miss, so the bar costs a #: reader nothing except reading the part they missed. PASS_REQUIRES_ALL = True #: Sanity ceiling on how many questions may hang off one material. A #: quiz longer than this is a test, and this is not a test. MAX_QUESTIONS_PER_MATERIAL = 10 def normalize_answer(raw: str | list[str] | None) -> str: """Canonical form of a submitted answer. Mirrors ``security_quiz.service._normalize_keys``: upper-case, dedupe, sort, concatenate — so ``["b","a"]``, ``"ab"`` and ``"BA"`` are one answer. Restated rather than imported because domains do not import each other; ``test_quiz.py`` pins this against the security-quiz implementation over the same inputs so the two cannot drift. """ if raw is None: return "" keys = raw if isinstance(raw, list) else list(str(raw)) return "".join(sorted({str(k).strip().upper() for k in keys if str(k).strip()})) class MaterialQuizService: def __init__(self, db: AsyncSession): self.db = db # ── Reads ─────────────────────────────────────────────────────── async def assert_visible( self, material_id: uuid.UUID, *, allow_retired: bool = False, ) -> SecawMaterial: """The material, or 404 unless an employee may see it. The employee quiz routes are addressable on their own, so they need the same publication check ``SecurityAwarenessService.get_material_detail`` applies rather than inheriting it from whichever page usually links to them. A draft's questions are unreleased content, and a stale bookmark to an archived material must not still be able to earn points or clear an acknowledgement. 404, not 403 — a material outside the library is not part of it, and that is not an access-control decision about this person. ``allow_retired`` is the one exception, and the caller has to have established it: this reader owes an acknowledgement on a LIVE campaign for this material. Retiring content is a decision about the library and does not retract an obligation somebody was already given — without this, archiving a material mid-campaign would leave its stragglers being chased about a quiz they can no longer open. """ row = await self.db.get(SecawMaterial, material_id) if row is None or ( row.status != STATUS_PUBLISHED and not allow_retired ): raise NotFoundError("security awareness material", str(material_id)) return row async def questions_for( self, material_id: uuid.UUID, *, include_answers: bool = False, lang: str | None = None, ) -> list[dict[str, Any]]: """The material's questions, in order, in the reader's language. ``include_answers`` is admin-only and off by default: shipping the answer key to the employee's browser would make the whole exercise theatre, and it is the kind of thing that only gets noticed after somebody reads the network tab. ``lang`` resolves through the security-quiz bank's own localization (the questions ARE that bank's rows — see the module docstring), so a material read in English asks its quiz in English. ``None`` keeps the Chinese baseline, which is what the admin console and the miss-rate report want: those show the authored question, not one reader's view of it. """ rows = (await self.db.execute( select(SecawMaterialQuestion, SecQuizQuestion) .join( SecQuizQuestion, SecQuizQuestion.id == SecawMaterialQuestion.question_id, ) .where(SecawMaterialQuestion.material_id == material_id) .order_by( SecawMaterialQuestion.sort_order.asc(), SecawMaterialQuestion.created_at.asc(), ) )).all() resolved = quiz_localization.normalize_quiz_lang(lang) if lang else None out: list[dict[str, Any]] = [] for link, question in rows: view = quiz_localization.render( question, resolved or quiz_localization.BASELINE_LANG, ) payload: dict[str, Any] = { "question_id": str(question.id), "prompt": view["prompt"], "options": view["options"], "qtype": question.qtype, "sort_order": link.sort_order, "lang": view["lang"], "is_fallback_lang": bool(resolved) and view["is_fallback_lang"], } if include_answers: payload["correct_key"] = question.correct_key payload["explanation"] = view["explanation"] out.append(payload) return out async def has_quiz(self, material_id: uuid.UUID) -> bool: return bool((await self.db.execute( select(func.count()).select_from(SecawMaterialQuestion) .where(SecawMaterialQuestion.material_id == material_id) )).scalar()) async def has_passed( self, person_id: uuid.UUID, material_id: uuid.UUID, ) -> bool: return (await self.db.execute( select(SecawQuizAttempt.id).where( SecawQuizAttempt.person_id == person_id, SecawQuizAttempt.material_id == material_id, SecawQuizAttempt.passed.is_(True), ).limit(1) )).scalar() is not None # ── Submit ────────────────────────────────────────────────────── async def submit( self, *, person_id: uuid.UUID, material_id: uuid.UUID, answers: dict[str, str | list[str]], allow_retired: bool = False, lang: str | None = None, ) -> dict[str, Any]: """Grade one attempt, store it, and report per-question feedback. Returns ``{passed, correct_count, total_count, results}``, where each result carries the correct key and the explanation **for the questions the reader got wrong**. Feedback on a miss is the point: the quiz exists to make somebody re-read the paragraph they skimmed, not to score them. """ # Published only — unless this reader owes an acknowledgement on # a live campaign for it (see ``assert_visible``). Passing a # draft's quiz would otherwise award points and could clear an # acknowledgement for content the library has not released. material = await self.assert_visible( material_id, allow_retired=allow_retired, ) questions = await self.questions_for( material_id, include_answers=True, lang=lang, ) if not questions: raise AppException( code="SECAW_NO_QUIZ", message="this material has no quiz", status_code=409, ) results: list[dict[str, Any]] = [] correct = 0 for q in questions: submitted = normalize_answer(answers.get(q["question_id"])) expected = normalize_answer(q["correct_key"]) is_correct = submitted == expected and submitted != "" if is_correct: correct += 1 results.append({ "question_id": q["question_id"], "selected": submitted, "is_correct": is_correct, # Only revealed on a miss. Handing back the answer key for # questions they got right teaches nothing and turns a # single wrong answer into a full answer sheet on retry. "correct_key": None if is_correct else expected, "explanation": None if is_correct else q.get("explanation"), }) total = len(questions) passed = correct == total if PASS_REQUIRES_ALL else correct * 2 >= total prior = (await self.db.execute( select(func.count()).select_from(SecawQuizAttempt).where( SecawQuizAttempt.person_id == person_id, SecawQuizAttempt.material_id == material_id, ) )).scalar() or 0 self.db.add(SecawQuizAttempt( id=uuid.uuid4(), person_id=person_id, material_id=material_id, attempt_no=int(prior) + 1, material_version=material.version, correct_count=correct, total_count=total, passed=passed, answers=results, created_at=datetime.now(UTC), )) await self.db.flush() return { "passed": passed, "correct_count": correct, "total_count": total, "results": results, } # ── Admin: attaching questions ────────────────────────────────── async def set_questions( self, material_id: uuid.UUID, question_ids: list[uuid.UUID], *, actor_id: uuid.UUID, ) -> int: """Replace the material's question set, in the order given. Wholesale replacement rather than add/remove: the order is part of the set, and a per-item API would need a reorder call anyway. Existing ATTEMPTS are untouched — they carry their own graded results, so changing the quiz never re-grades somebody's past answer. """ material = await self.db.get(SecawMaterial, material_id) if material is None: raise NotFoundError("security awareness material", str(material_id)) if len(question_ids) > MAX_QUESTIONS_PER_MATERIAL: raise AppException( code="SECAW_QUIZ_TOO_LONG", message=( f"at most {MAX_QUESTIONS_PER_MATERIAL} questions per " "material — a longer quiz is a test, and this is not a test" ), status_code=400, ) wanted = list(dict.fromkeys(question_ids)) if wanted: known = set((await self.db.execute( select(SecQuizQuestion.id).where(SecQuizQuestion.id.in_(wanted)) )).scalars().all()) missing = [str(q) for q in wanted if q not in known] if missing: raise NotFoundError("security quiz question", missing[0]) existing = { row.question_id: row for row in (await self.db.execute( select(SecawMaterialQuestion).where( SecawMaterialQuestion.material_id == material_id, ) )).scalars().all() } for order, qid in enumerate(wanted): row = existing.pop(qid, None) if row is None: self.db.add(SecawMaterialQuestion( id=uuid.uuid4(), material_id=material_id, question_id=qid, sort_order=order, created_by_person_id=actor_id, )) else: row.sort_order = order for orphan in existing.values(): await self.db.delete(orphan) await self.db.flush() return len(wanted) # ── Admin: participation ──────────────────────────────────────── async def question_difficulty( self, material_id: uuid.UUID, ) -> list[dict[str, Any]]: """Per-question first-attempt miss rate. The reason failed attempts are kept. A question a lot of people get wrong the FIRST time is the security team's most direct signal that a passage of their material is unclear — later attempts are contaminated by the explanation they were just shown, so only ``attempt_no == 1`` counts. """ # ``attempt_no == 1``, not "the earliest by created_at": that # column is second-granularity, and miss-then-immediately-retry # lands both rows in the same second, so a time sort would pick # either one and make every question look easy about half the # time. rows = (await self.db.execute( select(SecawQuizAttempt) .where( SecawQuizAttempt.material_id == material_id, SecawQuizAttempt.attempt_no == 1, ) )).scalars().all() seen: set[uuid.UUID] = set() per_question: dict[str, dict[str, int]] = {} for attempt in rows: if attempt.person_id in seen: # pragma: no cover - belt & braces continue seen.add(attempt.person_id) for item in attempt.answers or []: qid = str(item.get("question_id")) bucket = per_question.setdefault(qid, {"asked": 0, "missed": 0}) bucket["asked"] += 1 if not item.get("is_correct"): bucket["missed"] += 1 prompts = { str(q["question_id"]): q["prompt"] for q in await self.questions_for(material_id) } return [ { "question_id": qid, "prompt": prompts.get(qid), "asked": v["asked"], "missed": v["missed"], "miss_rate_pct": ( int(v["missed"] * 100 // v["asked"]) if v["asked"] else 0 ), } for qid, v in sorted( per_question.items(), key=lambda kv: -(kv[1]["missed"] * 100 // (kv[1]["asked"] or 1)), ) ]