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
« 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.
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:
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 )
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.
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.
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.
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.
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.
42Developer: Allan Ninal
43Date: 2026-09-21
44"""
46import logging
47from typing import Any, Mapping
49from bson import ObjectId
50from bson.errors import InvalidId
52logger = logging.getLogger(__name__)
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"})
59_CLASS_COLLECTION = "class_collection"
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
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.
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.
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
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
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
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.
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
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
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
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.
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
162def anonymise_student_identity_in_classes(db, user_id: Any, token: str) -> int:
163 """Replace one student's embedded identity with an irreversible token.
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.
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.
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 )