Coverage for server / services / common / student_identity_sync.py: 98%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2Keep the embedded copy of a student's identity in step with the account. 

3 

4THE PROBLEM. A class document embeds a SNAPSHOT of each enrolled student, taken 

5at join time — `ClassModel.students[]` carries first_name, middle_name, 

6last_name and email alongside the enrolment fields: 

7 

8 student = await User.find({"_id": student_id}).project(StudentModel)... 

9 await ClassModel.find_one(...).update_one( 

10 {"$push": {"students": {"$each": [student], "$position": 0}}} 

11 ) 

12 

13Nothing has ever updated that copy. A repo-wide search for a positional write 

14into the array (`students.$`) returns nothing, in either API. Meanwhile the 

15Admin-Staff API's update_portal_user_profile DOES change first_name/last_name on 

16the account. 

17 

18So correcting a misspelled student name leaves every class roster showing the 

19old one, permanently — the roster serves the embedded array verbatim 

20(`roster = class_obj[0].students`) with no lookup back to the account. 

21 

22The privacy consequence follows from the same fact: a student's PII lives in as 

23many class documents as they have ever joined, so removing the account row would 

24not remove their personal information. There is no anonymisation capability 

25anywhere in this platform today. 

26 

27WHY A WHITELIST AND NOT A DICT. `_SYNCED_FIELDS` is a frozenset, and anything 

28outside it is dropped rather than written. This module builds Mongo `$set` paths 

29from caller-supplied keys, and this codebase has already been bitten by passing 

30a raw dict into `$set` — an un-whitelisted version here would let a caller write 

31`students.$[s].status` (flipping enrolment state) or any other field, through 

32what looks like a name-sync call. 

33 

34WHAT THIS IS, AND WHAT IT IS NOT. One `update_many` with `arrayFilters` fixes 

35every class a student belongs to in a single statement. That is the mechanism. 

36The REAL fix for staleness is for the writer — update_portal_user_profile, in 

37the Admin-Staff API — to call it on save, which is a one-line change in a repo 

38outside this one's scope. Until then the periodic task in 

39server/worker/student_identity_sync_task.py is a compensating control: it closes 

40the window rather than preventing it. 

41 

42Developer: Allan Ninal 

43Date: 2026-09-21 

44""" 

45 

46import logging 

47from typing import Any, Mapping 

48 

49from bson import ObjectId 

50from bson.errors import InvalidId 

51 

52logger = logging.getLogger(__name__) 

53 

54# The only fields this module will ever write into an embedded student. 

55# Enrolment state (status, is_requesting_to_leave) belongs to the CLASS and is 

56# deliberately absent: it is not identity, and a sync must never move it. 

57_SYNCED_FIELDS = frozenset({"first_name", "middle_name", "last_name", "email"}) 

58 

59_CLASS_COLLECTION = "class_collection" 

60 

61 

62def _object_id(user_id: Any) -> ObjectId | None: 

63 if isinstance(user_id, ObjectId): 

64 return user_id 

65 try: 

66 return ObjectId(str(user_id)) 

67 except (InvalidId, TypeError): 

68 logger.warning("student identity sync: unusable user id %r", user_id) 

69 return None 

70 

71 

72def sync_student_identity_in_classes( 

73 db, user_id: Any, identity: Mapping[str, Any] 

74) -> int: 

75 """Rewrite one student's embedded identity across every class they are in. 

76 

77 Args: 

78 db: a synchronous pymongo database handle. 

79 user_id: the student's ``_id``. 

80 identity: field -> value. Keys outside ``_SYNCED_FIELDS`` are IGNORED, 

81 not written. 

82 

83 Returns: 

84 How many class documents were modified. Zero is a normal answer — it 

85 means the student is in no classes, or every copy already matched. 

86 """ 

87 oid = _object_id(user_id) 

88 if oid is None: 

89 return 0 

90 

91 updates = { 

92 f"students.$[s].{field}": value 

93 for field, value in identity.items() 

94 if field in _SYNCED_FIELDS 

95 } 

96 if not updates: 

97 logger.debug("student identity sync: nothing whitelisted in %r", list(identity)) 

98 return 0 

99 

100 result = db[_CLASS_COLLECTION].update_many( 

101 {"students._id": oid}, 

102 {"$set": updates}, 

103 array_filters=[{"s._id": oid}], 

104 ) 

105 if result.modified_count: 

106 logger.info( 

107 "student identity sync: updated %s embedded cop%s for %s", 

108 result.modified_count, 

109 "y" if result.modified_count == 1 else "ies", 

110 oid, 

111 ) 

112 return result.modified_count 

113 

114 

115def sync_teacher_identity_in_classes( 

116 db, user_id: Any, identity: Mapping[str, Any] 

117) -> int: 

118 """Rewrite one teacher's embedded identity on every class they own. 

119 

120 A class embeds its teacher as a SINGLE object, not an array element, so 

121 this needs no array filter — but it needs the same whitelist, and it is the 

122 same staleness bug. A renamed teacher shows the old name on every class 

123 they teach until something rewrites the copy. 

124 """ 

125 oid = _object_id(user_id) 

126 if oid is None: 

127 return 0 

128 

129 updates = { 

130 f"teacher.{field}": value 

131 for field, value in identity.items() 

132 if field in _SYNCED_FIELDS 

133 } 

134 if not updates: 

135 return 0 

136 

137 result = db[_CLASS_COLLECTION].update_many({"teacher._id": oid}, {"$set": updates}) 

138 if result.modified_count: 

139 logger.info( 

140 "teacher identity sync: updated %s class document(s) for %s", 

141 result.modified_count, 

142 oid, 

143 ) 

144 return result.modified_count 

145 

146 

147def sync_identity_in_classes( 

148 db, user_id: Any, role: str, identity: Mapping[str, Any] 

149) -> int: 

150 """Dispatch on role. An unknown role writes nothing. 

151 

152 Both roles are embedded in a class document and both go stale, so a sweep 

153 that covered only one would leave half the bug in place. 

154 """ 

155 if role == "student": 

156 return sync_student_identity_in_classes(db, user_id, identity) 

157 if role == "teacher": 

158 return sync_teacher_identity_in_classes(db, user_id, identity) 

159 return 0 

160 

161 

162def anonymise_student_identity_in_classes(db, user_id: Any, token: str) -> int: 

163 """Replace one student's embedded identity with an irreversible token. 

164 

165 The same mechanism as the sync, with erasure values instead of current ones. 

166 It exists because removing the account row does NOT remove the student's 

167 personal information — that lives in every class they ever joined — so an 

168 erasure request cannot be satisfied without this. 

169 

170 Deliberately NOT wired to any route. Who may trigger erasure, and on what 

171 authority, is a compliance decision: COPPA 312.6 routes it through the 

172 parent, and Texas Education Code 32.155 through the district, never through 

173 the student. This provides the capability; it does not grant it to anyone. 

174 

175 It touches identity ONLY. Grades, submissions and the enrolment record stay 

176 exactly as they were, which is what Texas Local Schedule SD requires — 

177 academic achievement records carry PERMANENT retention and may not be 

178 destroyed. Erasing the name while keeping the record is the shape the law 

179 actually asks for. 

180 """ 

181 return sync_student_identity_in_classes( 

182 db, 

183 user_id, 

184 { 

185 "first_name": token, 

186 "middle_name": None, 

187 "last_name": token, 

188 "email": token, 

189 }, 

190 )