Skip to content

Require an attendance record's user to share its session's dataset - #15667

Open
namanjain24-sudo wants to merge 5 commits into
learningequality:developfrom
namanjain24-sudo:fix-attendance-record-dataset
Open

namanjain24-sudo wants to merge 5 commits into
learningequality:developfrom
namanjain24-sudo:fix-attendance-record-dataset

Conversation

@namanjain24-sudo

@namanjain24-sudo namanjain24-sudo commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AttendanceRecord links a FacilityUser to an AttendanceSession, which belongs to a Collection — the same user-to-collection pairing that Membership and Role model. Both of those reject the two sides belonging to different datasets, and both enforce it in infer_dataset:

def infer_dataset(self, *args, **kwargs):
    user_dataset_id = self.cached_related_dataset_lookup("user")
    collection_dataset_id = self.cached_related_dataset_lookup("collection")
    if user_dataset_id != collection_dataset_id:
        raise KolibriValidationError(
            "Collection and user for a Membership object must be in same dataset."
        )
    return user_dataset_id

AttendanceRecord.infer_dataset only looked up the session's dataset, and the model has no pre_save, so nothing checked its user at all — the base pre_save only calls ensure_dataset(), and the cross-dataset guard enforce_authoring_user_field is opt-in per field and, within the attendance app, is wired only to AttendanceSession.created_by. (It is used more widely elsewhere — lessons, exams and courses all call it — so this is a gap in this module rather than an unused mechanism; corrected after @rtibblesbot flagged the original wording as too broad on #15666.)

That gap is reachable from the API: the nested record serializer accepts any user on the device (user = PrimaryKeyRelatedField(queryset=FacilityUser.objects.all())), permissions are evaluated against the session's collection, and AttendanceSessionSerializer.create then creates each record without relating its user back to that collection. On a device hosting more than one facility, a coach in facility A could record attendance for a learner in facility B and get a 201 — storing a row in facility A's dataset whose user foreign key points into facility B's.

This checks both sides in infer_dataset, matching the sibling models.

References

Fixes #15666

Reviewer guidance

  • pytest kolibri/core/attendance/test/test_models.py -k cross_dataset_user is the regression test. Against develop it fails with AssertionError: IntegrityError not raised; it passes here.
  • pytest kolibri/core/attendance/ — 60 passed. pytest kolibri/core/auth/test/ — 822 passed, 16 skipped (the base model is shared, so I ran it too). No regressions.
  • prek run --files … clean.
  • I verified the API behaviour end-to-end with a throwaway test rather than inferring it. Before: the cross-facility POST returned 201 and the row was committed. After: the legitimate same-facility POST still returns 201, the cross-facility one is refused, no record is written, and the surrounding transaction.atomic() in create rolls the session back too, so no orphan session is left behind.
  • One thing worth your call: the refusal surfaces as IntegrityError, which means a 500 rather than a 400. That is exactly how Membership behaves today for the equivalent pairing, so this is consistent rather than new, and I preferred consistency over inventing a different contract for one model. If you'd rather it be a validation error, the natural place is the record serializer, and I'm happy to add that here or in a follow-up.
  • Deliberately left out of scope: a coach can also record attendance for a learner in their own facility who is not in the classroom — same API path, same missing relation between the submitted user and the session's collection, but that is a membership-validation question rather than a dataset-integrity one, and the answer ("must the learner be enrolled?") is a product decision. I noted it in AttendanceRecord does not require its user and session to share a dataset #15666 rather than deciding it here.

AI usage

Claude Code (on my instruction to scan the newer code deeply for a real bug and then fix it) found this by comparing AttendanceRecord against the two existing models that link a user to a collection and noticing it was the only one not enforcing the shared-dataset invariant. It confirmed the behaviour by running code rather than by reading: one probe creating the same cross-facility pairing through both models, showing Membership raising while AttendanceRecord committed a mismatched row, and another driving the real API as a logged-in coach, which returned 201. It then worked test-first — the regression test went in before the change and was confirmed red — and re-ran the API probe afterwards to check the legitimate path still returns 201 and that no orphan session survives the rollback. It also caught and corrected its own mistake mid-change, having first imported KolibriValidationError from the wrong module. I reviewed the comparison against Membership/Role, the decision to match their IntegrityError behaviour rather than invent a 400, the choice to leave the classroom-membership question out of scope, and every test run above before opening this PR.

Membership and Role both reject a user and collection that belong to
different datasets, enforcing it in infer_dataset. AttendanceRecord links
the same two things through its session but only looked up the session's
dataset, and it has no pre_save, so nothing checked its user at all.

On a device hosting more than one facility that let a coach record
attendance for a learner in another facility: the row was stored in the
coach's dataset while its user foreign key pointed into the other one.
The record serializer accepts any user on the device, so the API returned
201 for it.

Check both sides in infer_dataset, matching the sibling models.

Fixes learningequality#15666
@namanjain24-sudo
namanjain24-sudo requested review from a team and leq-bot October 23, 2026 21:07
@github-actions github-actions Bot added DEV: backend Python, databases, networking, filesystem... SIZE: small labels Oct 23, 2026
@learning-equality-bot

Copy link
Copy Markdown
Contributor

👋 Hi @namanjain24-sudo, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

📢✨ Before we assign a reviewer:

  • @rtibblesbot will pre-review this pull request. Its comments are generated by an LLM, and should be evaluated accordingly.
  • We'll also invite community pre-review. See the community review guidance for both authors and reviewers.

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Confirming both checklist items:

  • Contributing guidelines: narrowly-scoped bug fix with a regression test, linked to AttendanceRecord does not require its user and session to share a dataset #15666 which describes the reproduction, and I ran pytest kolibri/core/attendance/ (60 passed) plus pytest kolibri/core/auth/test/ (822 passed, 16 skipped, since the base model is shared) and prek locally before opening it. Same flag as on my other two open PRs: AttendanceRecord does not require its user and session to share a dataset #15666 is a bug I reported myself and hasn't been triaged or assigned yet, so this isn't against an assigned help wanted issue — say the word and I'll close it and wait for triage.
  • AI usage: the section in the description is accurate per DEEP. Claude Code found this by comparing AttendanceRecord with Membership and Role and wrote the fix and test on my instruction, test-first — the test went in before the change and was confirmed red (IntegrityError not raised). It verified the API behaviour by actually driving the endpoint as a coach both before and after, rather than inferring it. I reviewed the comparison against the sibling models and made two judgement calls I've flagged in the reviewer guidance: matching Membership's IntegrityError (hence 500) instead of inventing a 400 for one model, and leaving the separate "must the learner be enrolled in the classroom?" question to AttendanceRecord does not require its user and session to share a dataset #15666 rather than deciding it here. Genuinely happy to go either way on both.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

One thing I wanted to check before leaving this sitting: changing infer_dataset means the new lookup also runs during deserialization, so the obvious risk is a record arriving before the user it references — and cached_related_dataset_lookup raises ValidationError when the related row is absent.

I traced it and then measured it rather than reasoning about it:

  • clean_fields defers FK validation during sync (deferred_clean_fields), but pre_save → ensure_dataset() → infer_dataset() still runs at save time, so the new user lookup genuinely does execute on the deserialization path.
  • The question is therefore whether it introduces a new failure, not whether it runs. It does not — a record whose user is absent was never persistable:
record with a user id that doesn't exist locally
develop IntegrityError: FOREIGN KEY constraint failed
this branch ValidationError: FacilityUser matching query does not exist.

Both reject it; the change only moves the rejection slightly earlier and makes the exception match what Membership already raises for the same situation. Morango records the deserialization error and retries on later syncs (skip_erroring defaults to False and Kolibri never passes True), so an out-of-order record still lands once the user arrives — the same way Membership has always behaved.

Worth noting one measurement trap in case anyone re-runs this: under TestCase the dangling-FK insert appears to succeed, because SQLite defers the constraint to commit and TestCase rolls back instead. It only shows its real behaviour under TransactionTestCase, which is what the table above uses.

The guard added to infer_dataset does not cover bulk_update: it creates
records with bulk_create, which skips pre_save, so ensure_dataset never
runs there. A coach could still post a learner from another facility to
that endpoint and get a 200.

Validate it in BulkUpdateSerializer, where the existing user-id check
already lives, so the endpoint answers 400 rather than writing a record
whose dataset disagrees with its user's.
@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit, because the original fix was incomplete and I'd rather flag that myself than have it found in review.

infer_dataset does not cover the bulk_update endpoint. That path creates records with bulk_create, which skips pre_save — the module already knows this and works around it by setting dataset_id and the UUID by hand (the #14242 comments). The consequence is that ensure_dataset never runs there, so the model-level guard I added simply does not apply, and POST /api/attendance/attendancerecord/bulk-update/ still returned 200 for a learner from another facility with the guard in place. I confirmed that against this branch rather than assuming it.

The follow-up validates it in BulkUpdateSerializer, next to the existing user-id check, so the endpoint answers 400 instead of writing a record whose dataset disagrees with its user's. pytest kolibri/core/attendance/ is 61 passed; the new test fails with 200 != 400 without the change.

Worth saying plainly: the model-level guard on its own is not sufficient for this module, because any future code path using bulk_create will bypass it the same way. If you'd prefer a different shape — validating centrally rather than per-endpoint — I'm happy to rework it.

@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

One coverage caveat worth knowing when judging this, since it is easy to read the green tick as broader than it is.

.github/workflows/morango_integration.yml is path-filtered to kolibri/core/auth/**/*.py (plus the dependency files and the workflow itself), so the Morango integration suite does not run for changes under kolibri/core/attendance/. This PR changes infer_dataset, which morango calls on the deserialization path, and those ten integration jobs are absent from this PR's checks — they ran on my two core/auth PRs and did not run here.

So CI green on this PR does not represent sync coverage for the change. That is why I verified the deserialization behaviour locally instead and reported it in the comment above. Flagging it in case you'd like the path filter widened, or would rather I add coverage somewhere that does run.

@rtibbles
rtibbles requested a review from rtibblesbot October 27, 2026 16:31
@learning-equality-bot

Copy link
Copy Markdown
Contributor

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot

rtibblesbot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Waiting for changes

Outcome: reviewed ed76fa3

Last updated: 2026-10-28 21:23 UTC

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #15667: the session endpoint now returns 500 for a cross-dataset user. It returned 201 before; the issue's API repro ran there.

The model-level check mirrors Membership/Role. The bulk endpoint correctly returns 400. CI passing.

  • suggestion: reject a cross-dataset user in nested attendance_records with 400, and test it (models.py:113).
  • nitpick: delete the two test comments that repeat the code and test names.

Next step: Needs a full code review by a human engineer.

  • The author is outside the team: confirm this should be built at all.
  • Decide whether the session endpoint should reject a cross-dataset nested record with 400, or whether the 500 is acceptable now that the model invariant holds.
  • Confirm that adding this check to infer_dataset cannot break syncing in existing attendance records whose user and session already have different datasets.

Written by rtibblesbot, an LLM-based coding agent.

Comment thread kolibri/core/attendance/models.py
Comment thread kolibri/core/attendance/test/test_api.py Outdated
The nested attendance_records write path saved records straight through the
model, so a user from another facility reached infer_dataset and came back as
an IntegrityError the exception handler does not catch -- a 500 where the
endpoint returned 201 before this branch.

Validate the records against the collection's dataset first, sharing the check
and its message with the bulk endpoint.
@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Good catch on the 500 — that is a regression this branch introduced and I had missed it. Fixed and pushed.

suggestion — 400 for a cross-dataset user in nested attendance_records. AttendanceSessionSerializer.validate now checks the records against the collection's dataset before anything reaches the model, so the session endpoint returns 400 and creates nothing. I confirmed the failure first rather than taking it on trust: the new test fails on the previous commit with KolibriValidationError: ['User and attendance session for an AttendanceRecord object must be in same dataset.'] escaping as a 500, and passes now.

The check is shared with BulkUpdateSerializer.validate as you suggested, in kolibri/core/attendance/utils.py. The two call sites reach it differently — the bulk endpoint has raw ids and must also check existence, the session endpoint already has resolved FacilityUser instances — so the helper takes (user_id, dataset_id) pairs and owns the message, rather than re-querying users the serializer has already fetched.

nitpick — test comments. Both deleted.

On your third question for the human reviewer — whether this check can break syncing for existing records whose user and session already differ — it can, and it is worth a decision rather than an assumption:

  • The write endpoints that allow such a record shipped in stable: 63938c419a ("Add AttendanceRecord API with bulk_update endpoint") is contained in v0.19.3, v0.19.4 and v0.19.5. So this is not a develop-only shape; real databases can hold one.
  • Creating one needs a client to send a user id from another facility. The coach UI would not, so I would expect this to be rare rather than impossible.
  • If one exists, after this change it will not deserialize: pre_save raises, morango records the exception on the store record, and because deserialization selects on dirty_bit regardless of that exception, it retries and fails the same way on every later sync — the same shape as Soft-deleted learners keep their picture password, so it is handed out again while the constraint still holds it #15662.

So the options look like: accept it, add a data migration that clears or re-points such rows, or let infer_dataset tolerate the mismatch on the deserialization path only. I have not implemented any of them here because it is a product call about existing data, not a code cleanup. Happy to add whichever you prefer.

pytest kolibri/core/attendance/ — 62 passed. prek clean.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #15667: both prior findings are resolved. One new nitpick is inline. CI is pending.

Prior-finding status

RESOLVED — kolibri/core/attendance/models.py:113 — Cross-dataset user in nested attendance_records returns 500, not 400
RESOLVED — kolibri/core/attendance/test/test_api.py — Test comment repeats the code/test name

Next step: Needs a full code review by a human engineer.

  • The author is outside the team: confirm this should be built at all.

Written by rtibblesbot, an LLM-based coding agent.

Comment thread kolibri/core/attendance/utils.py Outdated

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #15667: all 3 prior findings resolved. No new findings. CI still running.

Prior-finding status

RESOLVED — kolibri/core/attendance/models.py:113 — cross-dataset user in nested attendance_records gets a 500, not a 400
RESOLVED — kolibri/core/attendance/test/test_api.py — comment repeats viewsets/attendance_record.py:77
RESOLVED — kolibri/core/attendance/utils.py:6 — docstring restates the signature

Next step: Needs a full code review by a human engineer.

  • The author is outside the team: confirm this should be built at all.

Written by rtibblesbot, an LLM-based coding agent.

@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Following up on my own sentence rather than leaving it as an assumption. I wrote above that "the coach UI would not" send a cross-facility user id, which narrows how much existing bad data there could be — but I had not actually checked it. I have now, and it is a stronger guarantee than I claimed.

The records the UI sends are built from the class summary's learners:

// kolibri/plugins/coach/frontend/composables/useAttendanceForm.js:158-162
return sortedLearners.value.map(learner => ({
  user: learner.id,
  present: !!attendanceMap.value[learner.id],
}));

and sortedLearners is store.getters['classSummary/learners'] (:36-37) — the classroom's members. Classroom membership is itself dataset-constrained, by the same mechanism this PR adds for attendance records (models.py:1466-1473):

if user_dataset_id != collection_dataset_id:
    raise KolibriValidationError(
        "Collection and user for a Membership object must be in same dataset."
    )

So a learner cannot be a member of a classroom in another facility in the first place, which means the coach UI has no path to producing a cross-dataset record — not merely that it happens not to. One could still only arise from a hand-written API call against v0.19.3–v0.19.5.

That does not make the question go away, since such a row would still fail to deserialize forever if one exists. But it does suggest the likelihood is low enough that accepting it, rather than writing a data migration, may be the proportionate call. Still your decision — I just did not want the "the UI would not" part resting on my assumption.

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The infer_dataset check and the tests are right; the 400 handling is overbuilt.

  • blocking: drop utils.py and do each endpoint's check inline (inline comments).
  • Keep the model change and all three tests.

CI passing.

Comment thread kolibri/core/attendance/utils.py Outdated
from rest_framework.serializers import ValidationError


def validate_users_share_dataset(user_dataset_ids, dataset_id, field):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Please delete this module. Its two callers have to reshape their data to fit it, and each endpoint can do the check in a line or two (see the other comments).

Comment on lines +68 to +78
session = data["attendance_session"]
user_ids = {r["user"] for r in records}
existing_ids = set(
FacilityUser.objects.filter(id__in=user_ids).values_list("id", flat=True)
users = list(
FacilityUser.objects.filter(id__in=user_ids).values_list("id", "dataset_id")
)
missing = user_ids - existing_ids
missing = user_ids - {user_id for user_id, _ in users}
if missing:
raise ValidationError(
{"records": f"Invalid user IDs: {', '.join(sorted(missing))}"}
)
validate_users_share_dataset(users, session.dataset_id, "records")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Scope the existing lookup and revert the rest:

existing_ids = set(
    FacilityUser.objects.filter(
        id__in=user_ids, dataset_id=data["attendance_session"].dataset_id
    ).values_list("id", flat=True)
)

A cross-dataset user then gets the existing "Invalid user IDs" 400.

Comment on lines +68 to +75
records = attrs.get("attendance_records")
if records:
collection = attrs.get("collection") or self.instance.collection
validate_users_share_dataset(
[(record["user"].id, record["user"].dataset_id) for record in records],
collection.dataset_id,
"attendance_records",
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: The records are already FacilityUser objects, so this can be inline:

collection = attrs.get("collection") or self.instance.collection
if any(
    r["user"].dataset_id != collection.dataset_id
    for r in attrs.get("attendance_records", [])
):
    raise ValidationError({"attendance_records": "Invalid user IDs"})

The helper made both callers reshape their data to fit it. The bulk endpoint
scopes its existing user lookup by the session's dataset instead, so a
cross-dataset user falls into the "Invalid user IDs" response it already
returns, and the session endpoint compares the records it has already
resolved.
@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Done — and the scoped-lookup version is better than what I had, since it reuses the response the endpoint already gives.

blocking — utils.py deleted. You were right that both callers were reshaping their data to fit it.

blocking — bulk endpoint. The existing user lookup is now scoped by data["attendance_session"].dataset_id, so a cross-dataset user simply is not found and falls into the existing "Invalid user IDs" 400.

blocking — session endpoint. Inline any(...) over the records, which are already resolved FacilityUser objects.

Model change and all three tests kept. I checked both inlined checks are red when reverted, so the two API tests still cover what they claim.

pytest kolibri/core/attendance/ — 62 passed. prek clean.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #15667: all 3 prior findings resolved. No new findings. CI pending.

Prior-finding status

RESOLVED — kolibri/core/attendance/models.py:113 — cross-dataset user in nested attendance_records gets a 500, not a 400
RESOLVED — kolibri/core/attendance/test/test_api.py — comment repeats the one at viewsets/attendance_record.py:77
RESOLVED — kolibri/core/attendance/utils.py — docstring restates the signature (file deleted)

Next step: Needs a full code review by a human engineer.

  • The author is outside the team: confirm this should be built at all.

Written by rtibblesbot, an LLM-based coding agent.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: small

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AttendanceRecord does not require its user and session to share a dataset

3 participants