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

1"""One live class per (teacher, title, section) — enforced by the database. 

2 

3Added by Allan Ninal — 2026-10-03 (EI-T473; found by teacher-student-automation #479). 

4 

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). 

13 

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. 

21 

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

30 

31from __future__ import annotations 

32 

33import hashlib 

34 

35CLASS_DEDUPE_INDEX_NAME = "class_dedupe_key_unique_live" 

36 

37 

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

42 

43 

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) 

50 

51 

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 )