-
Notifications
You must be signed in to change notification settings - Fork 821
Prevent dual enrollment 13847 #13865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Prevent dual enrollment 13847 #13865
Conversation
| ).exists() | ||
| ) | ||
|
|
||
| def test_can_enroll_in_learnergroup(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure this is necessary but Claude seemed concerned during planning about how this could/would impact learnergroups -- but really there isn't a path for this to be an issue in the UI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kept it because it's something that should pass so long as we're not making huge changes to the UI and it's probably a good place to see a failure if those changes touch this API anyway
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My only thoughts are:
- Silently 201ing feels slightly icky, but I get it. As it's an edge case, the alternative would be to just raise a 400 and let the frontend try again.
- For both roles and membership, it's doing an exists query for every item that's being created, so you will have an N queries issue. Might be better to collect user lists by collection and then do a check for each collection and cross referencing the user_id lists. Alternatively, if you're just doing a 400 for the entire request, you could do an exists check for each colelction and raise the ValidationError there and then.
- Are we ever using the individual model serializer on its own, or are we always handling even a single create/update using the ListSerializer? If not, then this will still be possible one user at a time.
Build Artifacts
|
kolibri/core/auth/serializers.py
Outdated
| return attrs | ||
|
|
||
| # Group classroom coach items by collection to minimize queries | ||
| items_by_collection = defaultdict(list) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was the tree I started barking up by hand exploring - there's obviously a lot more code here and a lot of iterating over things in general @rtibbles - but it does address the N queries problem I think. As I read through it (thanks Claude) my main negative thought was "seems like a lot of code for what feels like it should be simpler to do".
At a high level, though (and to your point # 1 re: the ickiness of silent 201s), hindsight being 20/20 I think that we might have done well to deliberately make for a partial success API call that includes some info in the response about what did/didn't happen. We never intended to display such granular errors to the user, necessarily, but it could be used to answer certain questions such as "did anything happen at all?" because in this case you could select 2 coaches and try to enroll them in a class they're filtered out of and still we'll say "Users enrolled successfully".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No issues observed while manually testing - implemented as specified!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's an edge case (but so is the dual enrollment prevention), but telling a user that the coach has been assigned, when it definitely hasn't, seems pretty misleading (and is a result of our silently swallowing errors).
Screencast.From.2025-10-27.11-41-24.mp4
e6c0e45 to
e442b14
Compare
|
Hi @rtibbles and @nucleogenesis - yesterday while testing I saw the edge case based on which Richard is requesting changes here, but I thought that this is corresponding to the general idea of this fix. I don't see how this edge case is different from the entirely realistic scenario where if I go to the Users table, select exactly the same coach user and assign that user as a coach (getting a misleading/entirely wrong success message) since the coach user is actually already enrolled as a learner? |
5e0b7cd to
51db565
Compare
|
@pcenov This PR needs some additional review as we've opted to include some substantial changes that handle edge cases around partial success/failure. Please test the following scenarios in bulk user management workflow on the users table:
In the "Class Details" page, please test the edge case of multi-tab scenarios where one tab opens to see a list of users who are valid, then make it so they cannot be assigned/enrolled (based on the circumstances) in another tab, go back and try to assign the users who should no longer be allowed to be assigned/enrolled in that situation. Generally, you should see correct snackbar messages. Things to expect to see:
|
|
Hi @nucleogenesis, Users page:
the.users.shown.were.unable.to.be.enrolled.mp4Similar behavior is observed when attempting to enroll several coaches as learners while some of them are already enrolled as such: coaches.enrolled.as.learners.mp4
some.coaches.not.assigned.mp4
enroll-brought-back-to-previous-page.mp4
new.users.mp4Classes page: All looks good to me, except the following potentially confusing edge cases:
edge.case.with.coaches.enrolled.mp4Trying the same scenario with some coaches enrolled as learners and others not, results in seeing the following snackbar message some.were.some.were.not.mp4 |
8283bb9 to
45d9d66
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @nucleogenesis! Just one little regression I found on the enroll learners side panel. Everything else are more nitpicky comments!
| serializer.is_valid(raise_exception=True) | ||
|
|
||
| self.perform_create(serializer) | ||
|
|
||
| response_data = {"created": serializer.data} | ||
| status_code = status.HTTP_201_CREATED | ||
|
|
||
| if hasattr(serializer, "invalid_items") and serializer.invalid_items: | ||
| status_code = status.HTTP_207_MULTI_STATUS | ||
| response_data["invalid"] = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Nitpick) Initially, it was not evident to me why an invalid_items property would be set if the is_valid method is setting raise_exception=True and in theory it would raise an exception if the data is invalid, but this is true for bulk creations where some items may be invalid but not all of them, and therefore it wouldn't raise the exception. It would be great if we could have some comments around here explaining this.
kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue
Outdated
Show resolved
Hide resolved
kolibri/plugins/facility/assets/src/composables/useUserManagement.js
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (workingFilters.by_ids?.length) { | ||
| // If we're filtering by by_ids, it's programmatic navigation | ||
| // so we will clear all existing filters and set the ids alone | ||
| resetWorkingFilters(); | ||
| workingFilters.by_ids = newFilters.by_ids; | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's just an idea, but I feel we are adding too many edgy handlers for the by_ids filters that may be easily overseen if we update these modules in the future. I wonder if it would be better just to explicitly reset all filters when applying the by_ids filter, and let this filter co-exist with others without too many edge case handlers.
So, in the side panel modals, we may call the resetWorkingFilter and apply filters with just a by_ids filter, so this would override the filters on the route query leaving the by_ids filter always isolated, but isolated by on purposely removing all other filters from its source of truth and not by adding conditionals to cut the filters execution in the composables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need to come back to this comment tomorrow with a fresher mind but I like your suggestion here . I also think that taking pass at what you're talking about here will help me better work out if or how we should handle your comment just above re: filters still being visible
kolibri/plugins/facility/assets/src/modules/classAssignMembers/actions.js
Outdated
Show resolved
Hide resolved
kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue
Outdated
Show resolved
Hide resolved
kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue
Outdated
Show resolved
Hide resolved
|
Hi @nucleogenesis, I confirm that the issues I reported are now fixed and that sorting is working when displaying the list of unassigned users. Critical regression issue: The new user creation flow is broken now when directly enrolling or assigning the newly created user (the user is actually created but the side panel doesn't reflect that fact): new.user.broken.mp4New issue:
clear.the.unassigned.filter.mp4A couple of usability issues that should be further discussed:
not.clear.for.which.class.that.applies.mp4Here's an example of me searching for a specific user, trying to assign that user to several classes and not being sure what's failing. Note that the banner text doesn't reflect the fact that we are dealing with a single user, and that the snackbar message text working.with.a.single.user.mp4
says.1.filter.applied.but.no.filters.selected.mp4Note that it should also be considered that one can have applied some filters prior to assigning/enrolling users, in which case currently if everything is OK we are keeping the filters intact after completing the operation, while if some of the users were not assigned/enrolled we are again showing the several.filters.applied.mp4 |
|
Hi @nucleogenesis,
coach.error.mp4
new.user.not.shown.in.the.table.mp4
users.vs.new.users.mp4From the list with the usability issues in my previous comment I can see that only the last point has been fixed, so lets not forget to discuss the rest as they are important. :) |
…l can be used/cleared and now combined with other filters
…it when success occurs
a73fcb4 to
5cfd822
Compare
Co-authored-by: Claude Code <[email protected]>
|
Hi @nucleogenesis, I'm happy to let you know that I was able to identify just the following minor edge case at the
banner.not.disappearing.after.successful.enrollment.mp4This scenario is working correctly with the other message ("The users shown were unable to be enrolled."). Everything else seems to be working properly. At the class.removed.users.count.mp4 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are some significant bugs hiding here - obfuscated by an extra wrapper of verbose claude generated code in some cases.
| } else { | ||
| this.createSnackbar(this.usersEnrolledNotice$()); | ||
| } | ||
| if (invalid?.length) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this meant to be an else if with the created?.length check? Otherwise, this is just overriding the someLearnersEnrolledNotice above with noLearnersEnrolled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic made more sense when I was returning early in the side panels like this where no else-if is needed to avoid double-snackbarring
const snackbarMessage$ = () => {
if (createdRoles.value?.length) {
if (invalidRoles.value?.length) {
return someCoachesAssignedNotice$();
} else {
return coachesAllAssignedNotice$();
}
}
if (invalidRoles.value?.length) {
return someFailedToAssign$();
}
};
Will push updates here shortly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I saw the other instances where this was a return early pattern instead, and saw where this came from.
| this.createSnackbar(this.coachesAllAssignedNotice$()); | ||
| } | ||
| } | ||
| if (invalid?.length) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, shouldn't this be an else if?
| collection = role_data["collection"] | ||
| kind = role_data["kind"] | ||
|
|
||
| # Only validate classroom-level coach roles |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am a bit confused by this - why are we only preventing classroom assignable coaches from being assigned and enrolled in a class, but not putting similar restrictions on facility coaches and admins?
kolibri/core/auth/serializers.py
Outdated
| existing_roles = Role.objects.filter( | ||
| collection_id__in=class_collection_ids, | ||
| user_id__in=user_ids_to_validate, | ||
| kind__in=[role_kinds.ASSIGNABLE_COACH, role_kinds.COACH], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A user cannot have an "assignable_coach" role wrt a classroom, only wrt a facility.

Summary
Work planned interactively with Claude Code (+superpowers), some (mostly backend) code updates performed by Claude code.
Disallows in the API the ability to enroll a user to a class if they're already assigned to that class as a coach (or vice
versa).
This maintains the same behavior of enrolling/assigning users through the Class Details page, where we are filtering out the users which are not permitted.
References
Fixes #13847
Reviewer guidance
Known issues
Fixes the related issue
Basically, you should be able to use the bulk user management flows without issue such that if you try to assign/enroll a user who is not permitted to be assigned/enrolled because they're already one or the other. Users included in the selection are basically ignored/filtered out during the request to assign/enroll them - so there should be basically no effect on that user's relation to the class(es) selected.