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

1"""Shared rules for the assignment UPDATE routes (teacher and common). 

2 

3Added by Allan Ninal — 2026-10-03 (found by the FE->API coverage work for EI-2587). 

4 

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

14 

15from datetime import timezone 

16 

17 

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 

26 

27 

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 

35 

36 

37def dates_inverted(update_data: dict, stored: dict) -> bool: 

38 """True when the dates the document would hold after this update are inverted. 

39 

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) 

44 

45 

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) 

51 

52 

53def is_reused_global_copy(stored: dict) -> bool: 

54 """True when the stored assignment is a teacher's copy of a global assignment. 

55 

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

59 

60 

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 

68 

69 

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