Coverage for server / utilities / class_dedupe.py: 100%
11 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""One live class per (teacher, title, section) — enforced by the database.
3Added by Allan Ninal — 2026-10-03 (EI-T473; found by teacher-student-automation #479).
5WHY AN INDEX AND NOT ONLY THE CHECK
6-----------------------------------
7``create_new_class`` and ``class_update`` already refuse a title + section the teacher
8uses on another live class (409). That check is read-then-write: two identical creates
9arriving together both read "no such class" and both insert. Live-measured on QA
102026-10-03: two concurrent identical creates returned 201 twice in 2 of 5 runs, leaving
11two identical classes. Only the database can arbitrate that race — the same reasoning
12as the assignment dedupe index (``assignment_dedupe.py``, EI-3450).
14THE KEY
15-------
16``sha256(teacher_id | title.lower() | section.lower())`` — the same identity the 409
17check uses (case-insensitive exact title and section, per teacher). It is set on a live
18class and REMOVED when the class is soft-deleted, so the teacher can create the same
19title + section again after deleting it (the existing rule). Restoring a class sets it
20again, so restoring onto a live twin is refused instead of producing a duplicate.
22PARTIAL, NOT SPARSE
23-------------------
24A sparse index still indexes an explicit ``null``, and Beanie writes ``dedupe_key: null``
25for a class without a key — every deleted class would collide with every other. The
26index is therefore partial on ``{"dedupe_key": {"$type": "string"}}``: only real keys are
27unique. Classes written before this shipped have no key and are not indexed, so it
28builds on existing data with no backfill (the read check still covers them).
29"""
31from __future__ import annotations
33import hashlib
35CLASS_DEDUPE_INDEX_NAME = "class_dedupe_key_unique_live"
38def class_dedupe_key(teacher_id, title: str, section: str) -> str:
39 """The identity of a live class: teacher + case-insensitive title + section."""
40 raw = f"{teacher_id}|{(title or '').lower()}|{(section or '').lower()}"
41 return hashlib.sha256(raw.encode("utf-8")).hexdigest()
44def is_class_dedupe_violation(exc: Exception) -> bool:
45 """True when a DuplicateKeyError came from this index (not e.g. class_code)."""
46 details = getattr(exc, "details", None) or {}
47 return "dedupe_key" in (
48 details.get("keyPattern") or {}
49 ) or CLASS_DEDUPE_INDEX_NAME in str(exc)
52async def ensure_class_dedupe_index(collection) -> None:
53 """Create the partial unique index (idempotent)."""
54 await collection.create_index(
55 [("dedupe_key", 1)],
56 unique=True,
57 partialFilterExpression={"dedupe_key": {"$type": "string"}},
58 name=CLASS_DEDUPE_INDEX_NAME,
59 )