Coverage for server / utilities / assignment_update.py: 100%
34 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"""Shared rules for the assignment UPDATE routes (teacher and common).
3Added by Allan Ninal — 2026-10-03 (found by the FE->API coverage work for EI-2587).
5``settings`` is one sub-document. ``model_dump(exclude_unset=True)`` keeps only the
6settings keys the client sent, and ``{"$set": {"settings": {...}}}`` then REPLACED the
7stored sub-document with that partial dict — every omitted setting was deleted.
8Measured on QA 0.0.0.425: an update sending only allowed_attempts + time_allowed left
9``settings`` holding exactly those two keys (shuffle, calculator and the
10after-submit feedback flags were gone, and read back as their False defaults).
11``set_settings_per_key`` turns the partial dict into dotted ``settings.<key>``
12updates, so only the keys sent change.
13"""
15from datetime import timezone
18def set_settings_per_key(update_data: dict) -> dict:
19 """Rewrite ``update_data["settings"]`` (a dict) into ``settings.<key>`` entries."""
20 settings = update_data.get("settings")
21 if isinstance(settings, dict):
22 del update_data["settings"]
23 for key, value in settings.items():
24 update_data[f"settings.{key}"] = value
25 return update_data
28def _utc(value):
29 """Request dates arrive timezone-aware (normalised to UTC by the model); dates
30 read back from Mongo are naive UTC. Comparing the two raises TypeError, so both
31 are made aware before comparing."""
32 if value is None or not hasattr(value, "tzinfo"):
33 return value
34 return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
37def dates_inverted(update_data: dict, stored: dict) -> bool:
38 """True when the dates the document would hold after this update are inverted.
40 An update may send only one date, so the stored value supplies the other."""
41 effective_open = _utc(update_data.get("date_open", stored.get("date_open")))
42 effective_close = _utc(update_data.get("date_close", stored.get("date_close")))
43 return bool(effective_open and effective_close and effective_open > effective_close)
46# EI-3437 (Allan Ninal, 2026-10-03): shared by the teacher and common update routes.
47REUSED_GLOBAL_LOCKED_DETAIL = (
48 "This assignment is a copy of an Erudition global assignment; "
49 "its title and questions cannot be changed."
50)
53def is_reused_global_copy(stored: dict) -> bool:
54 """True when the stored assignment is a teacher's copy of a global assignment.
56 The reuse flow (create_staff_assignment) ALWAYS writes ``from: "staff"``;
57 ``copy_of`` is only written when the client sends it, so either marks a copy."""
58 return stored.get("from") == "staff" or bool(stored.get("copy_of"))
61def _question_ids(questions) -> list[str]:
62 ids = []
63 for q in questions or []:
64 if isinstance(q, dict):
65 q = q.get("id", q.get("_id"))
66 ids.append(str(q))
67 return ids
70def changes_title_or_questions(update_data: dict, stored: dict) -> bool:
71 """True when the payload would change ``title`` or WHICH questions (in which
72 order) the stored assignment holds. A payload that resends the stored values
73 (the SPA submits the whole form) is not a change; the title is compared after
74 ``strip()``. ``question_points_overrides`` is a per-assignment weight, not a
75 question edit, so it is not checked here."""
76 if "title" in update_data:
77 if (update_data["title"] or "").strip() != (stored.get("title") or "").strip():
78 return True
79 if "questions" in update_data:
80 if _question_ids(update_data["questions"]) != _question_ids(
81 stored.get("questions")
82 ):
83 return True
84 return False