From f0b418f4fdba7df9d0cefebc28133544f141962d Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 23 Oct 2025 22:15:50 -0700 Subject: [PATCH 01/45] feat: prevent enrolling coaches as learners in same classroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validation to MembershipListSerializer that silently filters out membership creation when user already has a coach role for the same classroom collection. Refs #13847 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- kolibri/core/auth/serializers.py | 26 ++++++++++++++++++++++++ kolibri/core/auth/test/test_api.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 55f15f29889..4b319a3db59 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -141,6 +141,32 @@ def validate(self, attrs): class MembershipListSerializer(serializers.ListSerializer): + def validate(self, attrs): + from .constants import collection_kinds, role_kinds + + validated = [] + for membership_data in attrs: + user_id = membership_data["user"].id + collection = membership_data["collection"] + + # Only check classroom-level memberships + if collection.kind != collection_kinds.CLASSROOM: + validated.append(membership_data) + continue + + # Check if user is already a coach for this classroom + has_coach_role = Role.objects.filter( + user_id=user_id, + collection_id=collection.id, + kind__in=[role_kinds.COACH, role_kinds.ASSIGNABLE_COACH], + ).exists() + + if not has_coach_role: + validated.append(membership_data) + # else: silently skip this membership + + return validated + def create(self, validated_data): created_objects = [] diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 5eb2804756b..588dbdae620 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2332,6 +2332,38 @@ def test_delete_does_not_affect_other_user_memberships(self): expected_count, ) + def test_cannot_enroll_coach_as_learner_in_same_classroom(self): + """Test that enrolling a coach as a learner in the same classroom is silently skipped""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # Create a new user to test with + test_user = FacilityUserFactory.create(facility=self.facility) + + # Create a coach role for the user in the classroom + models.Role.objects.create( + user=test_user, collection=self.classroom, kind=role_kinds.COACH + ) + + # Try to enroll the same user as a learner in the same classroom + response = self.client.post( + reverse("kolibri:core:membership-list"), + data=[{"user": test_user.id, "collection": self.classroom.id}], + format="json", + ) + + # Should return 201 but with empty array (silently filtered) + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 0) + + # Verify no membership was created + self.assertFalse( + models.Membership.objects.filter( + user=test_user, collection=self.classroom + ).exists() + ) + class GroupMembership(APITestCase): @classmethod From c77c91310558ae2f98b24e11fbb6836a9c0b71c3 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 23 Oct 2025 22:25:35 -0700 Subject: [PATCH 02/45] feat: prevent assigning learners as coaches in same classroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validation to RoleListSerializer that silently filters out coach role creation when user already has a membership in the same classroom collection. Refs #13847 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- kolibri/core/auth/serializers.py | 29 +++++++++++++++++ kolibri/core/auth/test/test_api.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 4b319a3db59..5360c6ce081 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -28,6 +28,35 @@ class RoleListSerializer(serializers.ListSerializer): + def validate(self, attrs): + from .constants import collection_kinds, role_kinds + + validated = [] + for role_data in attrs: + user_id = role_data["user"].id + collection = role_data["collection"] + kind = role_data["kind"] + + # Only check classroom-level coach roles + if collection.kind != collection_kinds.CLASSROOM: + validated.append(role_data) + continue + + if kind not in [role_kinds.COACH, role_kinds.ASSIGNABLE_COACH]: + validated.append(role_data) + continue + + # Check if user is already enrolled in this classroom + has_membership = Membership.objects.filter( + user_id=user_id, collection_id=collection.id + ).exists() + + if not has_membership: + validated.append(role_data) + # else: silently skip this role + + return validated + def create(self, validated_data): created_objects = [] diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 588dbdae620..6dea14cdce4 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2365,6 +2365,57 @@ def test_cannot_enroll_coach_as_learner_in_same_classroom(self): ) +class RoleAPITestCase(APITestCase): + @classmethod + def setUpTestData(cls): + provision_device() + cls.facility = FacilityFactory.create() + cls.superuser = create_superuser(cls.facility) + cls.user1 = FacilityUserFactory.create(facility=cls.facility) + cls.user2 = FacilityUserFactory.create(facility=cls.facility) + cls.classroom = ClassroomFactory.create(parent=cls.facility) + + def login_superuser(self): + self.client.login( + username=self.superuser.username, + password=DUMMY_PASSWORD, + facility=self.facility, + ) + + def test_cannot_assign_learner_as_coach_in_same_classroom(self): + """Test that assigning a learner as coach in the same classroom is silently skipped""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # Create a membership (learner enrollment) for the user in the classroom + models.Membership.objects.create(user=self.user1, collection=self.classroom) + + # Try to assign the same user as a coach to the same classroom + response = self.client.post( + reverse("kolibri:core:role-list"), + data=[ + { + "user": self.user1.id, + "collection": self.classroom.id, + "kind": role_kinds.COACH, + } + ], + format="json", + ) + + # Should return 201 but with empty array (silently filtered) + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 0) + + # Verify no coach role was created + self.assertFalse( + models.Role.objects.filter( + user=self.user1, collection=self.classroom, kind=role_kinds.COACH + ).exists() + ) + + class GroupMembership(APITestCase): @classmethod def setUpTestData(cls): From 08e0ba12a36b0edb76bad48de0f404579c7edfeb Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 23 Oct 2025 22:43:13 -0700 Subject: [PATCH 03/45] test: add bulk operation tests for dual enrollment prevention Verify that bulk membership/role creation properly filters out conflicting items while still creating valid ones. Refs #13847 --- kolibri/core/auth/test/test_api.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 6dea14cdce4..8218f5b8a75 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2364,6 +2364,51 @@ def test_cannot_enroll_coach_as_learner_in_same_classroom(self): ).exists() ) + def test_bulk_enroll_filters_coaches_silently(self): + """Test that bulk enrollment filters out coaches but creates valid memberships""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # Create two new users for this test + user1 = FacilityUserFactory.create(facility=self.facility) + user2 = FacilityUserFactory.create(facility=self.facility) + + # user1 is a coach for classroom + models.Role.objects.create( + user=user1, collection=self.classroom, kind=role_kinds.COACH + ) + + # user2 is not a coach + # Try to enroll both users + response = self.client.post( + reverse("kolibri:core:membership-list"), + data=[ + {"user": user1.id, "collection": self.classroom.id}, + {"user": user2.id, "collection": self.classroom.id}, + ], + format="json", + ) + + # Should return 201 with only user2's membership + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["user"], user2.id) + + # Verify user1 was not enrolled + self.assertFalse( + models.Membership.objects.filter( + user=user1, collection=self.classroom + ).exists() + ) + + # Verify user2 was enrolled + self.assertTrue( + models.Membership.objects.filter( + user=user2, collection=self.classroom + ).exists() + ) + class RoleAPITestCase(APITestCase): @classmethod @@ -2415,6 +2460,53 @@ def test_cannot_assign_learner_as_coach_in_same_classroom(self): ).exists() ) + def test_bulk_assign_coaches_filters_learners_silently(self): + """Test that bulk coach assignment filters out learners but creates valid roles""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # user1 is enrolled as learner in classroom + models.Membership.objects.create(user=self.user1, collection=self.classroom) + + # user2 is not enrolled + # Try to assign both as coaches + response = self.client.post( + reverse("kolibri:core:role-list"), + data=[ + { + "user": self.user1.id, + "collection": self.classroom.id, + "kind": role_kinds.COACH, + }, + { + "user": self.user2.id, + "collection": self.classroom.id, + "kind": role_kinds.COACH, + }, + ], + format="json", + ) + + # Should return 201 with only user2's role + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["user"], self.user2.id) + + # Verify user1 was not assigned as coach + self.assertFalse( + models.Role.objects.filter( + user=self.user1, collection=self.classroom, kind=role_kinds.COACH + ).exists() + ) + + # Verify user2 was assigned as coach + self.assertTrue( + models.Role.objects.filter( + user=self.user2, collection=self.classroom, kind=role_kinds.COACH + ).exists() + ) + class GroupMembership(APITestCase): @classmethod From 5b740da0e897fb27803e5e58836f2956e9a21a95 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 23 Oct 2025 22:50:47 -0700 Subject: [PATCH 04/45] test: verify LearnerGroup and facility operations unaffected by dual enrollment prevention Ensure that the classroom-only validation doesn't inadvertently block valid LearnerGroup memberships or facility-level role assignments. Refs #13847 --- kolibri/core/auth/test/test_api.py | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 8218f5b8a75..58f5a21e343 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2409,6 +2409,39 @@ def test_bulk_enroll_filters_coaches_silently(self): ).exists() ) + def test_can_enroll_in_learnergroup(self): + """Test that LearnerGroup memberships work normally (validation only applies to classrooms)""" + self.login_superuser() + + # Create a new user for this test + user1 = FacilityUserFactory.create(facility=self.facility) + + # First enroll user in classroom (required for learnergroup membership) + models.Membership.objects.create(user=user1, collection=self.classroom) + + # Create learner group + learner_group = LearnerGroupFactory.create( + name="Test Group", parent=self.classroom + ) + + # Try to enroll user in the learner group (should succeed) + response = self.client.post( + reverse("kolibri:core:membership-list"), + data=[{"user": user1.id, "collection": learner_group.id}], + format="json", + ) + + # Should succeed + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 1) + + # Verify membership was created + self.assertTrue( + models.Membership.objects.filter( + user=user1, collection=learner_group + ).exists() + ) + class RoleAPITestCase(APITestCase): @classmethod @@ -2507,6 +2540,39 @@ def test_bulk_assign_coaches_filters_learners_silently(self): ).exists() ) + def test_can_assign_learner_as_facility_admin(self): + """Test that learners CAN be assigned as facility admins (validation only for classroom coaches)""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # Enroll user as learner in classroom + models.Membership.objects.create(user=self.user1, collection=self.classroom) + + # Try to assign user as facility admin (should succeed) + response = self.client.post( + reverse("kolibri:core:role-list"), + data=[ + { + "user": self.user1.id, + "collection": self.facility.id, + "kind": role_kinds.ADMIN, + } + ], + format="json", + ) + + # Should succeed + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 1) + + # Verify role was created + self.assertTrue( + models.Role.objects.filter( + user=self.user1, collection=self.facility, kind=role_kinds.ADMIN + ).exists() + ) + class GroupMembership(APITestCase): @classmethod From 980a0bf1453eaa9d67b97ba75a5b29575b06c811 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Sun, 26 Oct 2025 21:51:10 -0700 Subject: [PATCH 05/45] perf: optimize dual enrollment validation to avoid N+1 queries Refactor MembershipListSerializer and RoleListSerializer validate() methods to group items by collection and perform bulk queries instead of one query per item. Before: N queries (one per membership/role being created) After: M queries (one per unique collection in the batch) For bulk operations enrolling 100 learners into the same classroom, this reduces from 100 queries to 1 query. Implementation details: - Group classroom items by collection - Single user_id__in query per collection to find conflicts - Filter items based on set membership lookup - Maintains same silent filtering behavior All 172 auth API tests pass with no regressions. Refs #13847 --- kolibri/core/auth/serializers.py | 123 ++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 36 deletions(-) diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 5360c6ce081..e282c89ef3d 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -30,32 +30,58 @@ class RoleListSerializer(serializers.ListSerializer): def validate(self, attrs): from .constants import collection_kinds, role_kinds + from collections import defaultdict + + # Separate classroom-level coach roles from others + classroom_coach_items = [] + other_items = [] - validated = [] for role_data in attrs: - user_id = role_data["user"].id collection = role_data["collection"] kind = role_data["kind"] - # Only check classroom-level coach roles - if collection.kind != collection_kinds.CLASSROOM: - validated.append(role_data) - continue - - if kind not in [role_kinds.COACH, role_kinds.ASSIGNABLE_COACH]: - validated.append(role_data) - continue + # Only validate classroom-level coach roles + if collection.kind == collection_kinds.CLASSROOM and kind in [ + role_kinds.COACH, + role_kinds.ASSIGNABLE_COACH, + ]: + classroom_coach_items.append(role_data) + else: + other_items.append(role_data) + + # If no classroom coach items, return everything as-is + if not classroom_coach_items: + return attrs - # Check if user is already enrolled in this classroom - has_membership = Membership.objects.filter( - user_id=user_id, collection_id=collection.id - ).exists() + # Group classroom coach items by collection to minimize queries + items_by_collection = defaultdict(list) + for item in classroom_coach_items: + collection_id = item["collection"].id + items_by_collection[collection_id].append(item) + + # For each collection, do one bulk query to get all conflicting users + members_by_collection = {} + for collection_id, items in items_by_collection.items(): + user_ids = [item["user"].id for item in items] + # Single query per collection instead of N queries + member_user_ids = set( + Membership.objects.filter( + collection_id=collection_id, user_id__in=user_ids + ).values_list("user_id", flat=True) + ) + members_by_collection[collection_id] = member_user_ids - if not has_membership: - validated.append(role_data) - # else: silently skip this role + # Filter out items where user is already a member + validated_coach_items = [] + for item in classroom_coach_items: + user_id = item["user"].id + collection_id = item["collection"].id + if user_id not in members_by_collection[collection_id]: + validated_coach_items.append(item) + # else: silently skip - user is already a member - return validated + # Return other items + validated coach items + return other_items + validated_coach_items def create(self, validated_data): created_objects = [] @@ -172,29 +198,54 @@ def validate(self, attrs): class MembershipListSerializer(serializers.ListSerializer): def validate(self, attrs): from .constants import collection_kinds, role_kinds + from collections import defaultdict + + # Separate classroom memberships from others + classroom_items = [] + non_classroom_items = [] - validated = [] for membership_data in attrs: - user_id = membership_data["user"].id collection = membership_data["collection"] + if collection.kind == collection_kinds.CLASSROOM: + classroom_items.append(membership_data) + else: + non_classroom_items.append(membership_data) - # Only check classroom-level memberships - if collection.kind != collection_kinds.CLASSROOM: - validated.append(membership_data) - continue - - # Check if user is already a coach for this classroom - has_coach_role = Role.objects.filter( - user_id=user_id, - collection_id=collection.id, - kind__in=[role_kinds.COACH, role_kinds.ASSIGNABLE_COACH], - ).exists() - - if not has_coach_role: - validated.append(membership_data) - # else: silently skip this membership + # If no classroom items, return everything as-is + if not classroom_items: + return attrs - return validated + # Group classroom items by collection to minimize queries + items_by_collection = defaultdict(list) + for item in classroom_items: + collection_id = item["collection"].id + items_by_collection[collection_id].append(item) + + # For each collection, do one bulk query to get all conflicting users + coaches_by_collection = {} + for collection_id, items in items_by_collection.items(): + user_ids = [item["user"].id for item in items] + # Single query per collection instead of N queries + coach_user_ids = set( + Role.objects.filter( + collection_id=collection_id, + user_id__in=user_ids, + kind__in=[role_kinds.COACH, role_kinds.ASSIGNABLE_COACH], + ).values_list("user_id", flat=True) + ) + coaches_by_collection[collection_id] = coach_user_ids + + # Filter out items where user is already a coach + validated_classroom_items = [] + for item in classroom_items: + user_id = item["user"].id + collection_id = item["collection"].id + if user_id not in coaches_by_collection[collection_id]: + validated_classroom_items.append(item) + # else: silently skip - user is already a coach + + # Return non-classroom items + validated classroom items + return non_classroom_items + validated_classroom_items def create(self, validated_data): created_objects = [] From 809e5d14f51db206731dbac50350f8ee45f24c64 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Sun, 26 Oct 2025 22:20:38 -0700 Subject: [PATCH 06/45] test: add multi-collection bulk enrollment validation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that bulk operations correctly filter dual enrollment conflicts on a per-collection basis when operating across multiple classrooms simultaneously. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- kolibri/core/auth/test/test_api.py | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 58f5a21e343..71b7ec27e90 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2409,6 +2409,66 @@ def test_bulk_enroll_filters_coaches_silently(self): ).exists() ) + def test_bulk_enroll_multiple_classrooms_filters_correctly(self): + """Test that bulk enrollment across multiple classrooms filters per-collection""" + from kolibri.core.auth.constants import role_kinds + + self.login_superuser() + + # Create a second classroom + classroom2 = ClassroomFactory.create(parent=self.facility) + + # Create two users + user1 = FacilityUserFactory.create(facility=self.facility) + user2 = FacilityUserFactory.create(facility=self.facility) + + # user1 is a coach for classroom1, user2 is a coach for classroom2 + models.Role.objects.create( + user=user1, collection=self.classroom, kind=role_kinds.COACH + ) + models.Role.objects.create( + user=user2, collection=classroom2, kind=role_kinds.COACH + ) + + # Try to enroll both users in both classrooms (4 memberships total) + response = self.client.post( + reverse("kolibri:core:membership-list"), + data=[ + { + "user": user1.id, + "collection": self.classroom.id, + }, # CONFLICT - filtered + {"user": user1.id, "collection": classroom2.id}, # OK + {"user": user2.id, "collection": self.classroom.id}, # OK + {"user": user2.id, "collection": classroom2.id}, # CONFLICT - filtered + ], + format="json", + ) + + # Should return 201 with only the 2 non-conflicting memberships + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), 2) + + # Verify user1 enrolled in classroom2 (not classroom1) + self.assertFalse( + models.Membership.objects.filter( + user=user1, collection=self.classroom + ).exists() + ) + self.assertTrue( + models.Membership.objects.filter(user=user1, collection=classroom2).exists() + ) + + # Verify user2 enrolled in classroom1 (not classroom2) + self.assertTrue( + models.Membership.objects.filter( + user=user2, collection=self.classroom + ).exists() + ) + self.assertFalse( + models.Membership.objects.filter(user=user2, collection=classroom2).exists() + ) + def test_can_enroll_in_learnergroup(self): """Test that LearnerGroup memberships work normally (validation only applies to classrooms)""" self.login_superuser() From ae8d723c89fe6b2835bf5eefb828daa5dad1378e Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 27 Oct 2025 14:53:42 -0700 Subject: [PATCH 07/45] return 207 when skipping items during bulk role creation; +test updates & cleanup --- kolibri/core/auth/api.py | 22 ++++++++++++ kolibri/core/auth/serializers.py | 56 ++++++++++++++---------------- kolibri/core/auth/test/test_api.py | 35 +++++++++++-------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index 34abba20c3a..bc970156d15 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -810,6 +810,28 @@ class RoleViewSet(BulkDeleteMixin, BulkCreateMixin, viewsets.ModelViewSet): filterset_class = RoleFilter filterset_fields = ["user", "collection", "kind", "user_ids"] + def create(self, request): + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + + self.perform_create(serializer) + + response_data = {"created": serializer.data} + status_code = status.HTTP_201_CREATED + + if hasattr(serializer, "skipped_items") and serializer.skipped_items: + status_code = status.HTTP_207_MULTI_STATUS + response_data["skipped"] = [ + { + "user": item["user"].id, + "collection": item["collection"].id, + "kind": item["kind"], + } + for item in serializer.skipped_items + ] + + return Response(response_data, status=status_code) + dataset_keys = [ "dataset__id", diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index e282c89ef3d..67bfb3c79e0 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -1,4 +1,5 @@ import logging +from collections import defaultdict from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import MinLengthValidator @@ -7,7 +8,9 @@ from rest_framework.exceptions import ParseError from rest_framework.validators import UniqueTogetherValidator +from .constants import collection_kinds from .constants import facility_presets +from .constants import role_kinds from .errors import IncompatibleDeviceSettingError from .errors import InvalidCollectionHierarchy from .errors import InvalidMembershipError @@ -28,14 +31,24 @@ class RoleListSerializer(serializers.ListSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.skipped_items = [] + def validate(self, attrs): - from .constants import collection_kinds, role_kinds - from collections import defaultdict # Separate classroom-level coach roles from others classroom_coach_items = [] + + # Other items is for items that don't need to be validated for + # whether or not they're enrolled in a class already other_items = [] + # Tracking the collections & users to query against for validating + # if they're members of the class already + assignable_coach_ids = [] + class_collection_ids = [] + for role_data in attrs: collection = role_data["collection"] kind = role_data["kind"] @@ -46,6 +59,8 @@ def validate(self, attrs): role_kinds.ASSIGNABLE_COACH, ]: classroom_coach_items.append(role_data) + assignable_coach_ids.append(role_data["user"].id) + class_collection_ids.append(collection.id) else: other_items.append(role_data) @@ -53,35 +68,19 @@ def validate(self, attrs): if not classroom_coach_items: return attrs - # Group classroom coach items by collection to minimize queries - items_by_collection = defaultdict(list) - for item in classroom_coach_items: - collection_id = item["collection"].id - items_by_collection[collection_id].append(item) + existing_memberships = Membership.objects.filter( + collection_id__in=class_collection_ids, user_id__in=assignable_coach_ids + ).values_list("collection_id", "user_id") - # For each collection, do one bulk query to get all conflicting users - members_by_collection = {} - for collection_id, items in items_by_collection.items(): - user_ids = [item["user"].id for item in items] - # Single query per collection instead of N queries - member_user_ids = set( - Membership.objects.filter( - collection_id=collection_id, user_id__in=user_ids - ).values_list("user_id", flat=True) - ) - members_by_collection[collection_id] = member_user_ids + valid_items = [] - # Filter out items where user is already a member - validated_coach_items = [] for item in classroom_coach_items: - user_id = item["user"].id - collection_id = item["collection"].id - if user_id not in members_by_collection[collection_id]: - validated_coach_items.append(item) - # else: silently skip - user is already a member + if (item["collection"].id, item["user"].id) in existing_memberships: + self.skipped_items.append(item) + else: + valid_items.append(item) - # Return other items + validated coach items - return other_items + validated_coach_items + return other_items + valid_items def create(self, validated_data): created_objects = [] @@ -197,9 +196,6 @@ def validate(self, attrs): class MembershipListSerializer(serializers.ListSerializer): def validate(self, attrs): - from .constants import collection_kinds, role_kinds - from collections import defaultdict - # Separate classroom memberships from others classroom_items = [] non_classroom_items = [] diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 71b7ec27e90..5018073595e 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2334,7 +2334,6 @@ def test_delete_does_not_affect_other_user_memberships(self): def test_cannot_enroll_coach_as_learner_in_same_classroom(self): """Test that enrolling a coach as a learner in the same classroom is silently skipped""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2366,7 +2365,6 @@ def test_cannot_enroll_coach_as_learner_in_same_classroom(self): def test_bulk_enroll_filters_coaches_silently(self): """Test that bulk enrollment filters out coaches but creates valid memberships""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2411,7 +2409,6 @@ def test_bulk_enroll_filters_coaches_silently(self): def test_bulk_enroll_multiple_classrooms_filters_correctly(self): """Test that bulk enrollment across multiple classrooms filters per-collection""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2522,7 +2519,6 @@ def login_superuser(self): def test_cannot_assign_learner_as_coach_in_same_classroom(self): """Test that assigning a learner as coach in the same classroom is silently skipped""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2542,9 +2538,14 @@ def test_cannot_assign_learner_as_coach_in_same_classroom(self): format="json", ) - # Should return 201 but with empty array (silently filtered) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 0) + # Should return 207 with skipped items + self.assertEqual(response.status_code, 207) + self.assertIn("created", response.data) + self.assertIn("skipped", response.data) + self.assertEqual(len(response.data["created"]), 0) + self.assertEqual(len(response.data["skipped"]), 1) + self.assertEqual(response.data["skipped"][0]["user"], self.user1.id) + self.assertEqual(response.data["skipped"][0]["collection"], self.classroom.id) # Verify no coach role was created self.assertFalse( @@ -2555,7 +2556,6 @@ def test_cannot_assign_learner_as_coach_in_same_classroom(self): def test_bulk_assign_coaches_filters_learners_silently(self): """Test that bulk coach assignment filters out learners but creates valid roles""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2581,10 +2581,14 @@ def test_bulk_assign_coaches_filters_learners_silently(self): format="json", ) - # Should return 201 with only user2's role - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]["user"], self.user2.id) + # Should return 207 with mixed results + self.assertEqual(response.status_code, 207) + self.assertIn("created", response.data) + self.assertIn("skipped", response.data) + self.assertEqual(len(response.data["created"]), 1) + self.assertEqual(len(response.data["skipped"]), 1) + self.assertEqual(response.data["created"][0]["user"], self.user2.id) + self.assertEqual(response.data["skipped"][0]["user"], self.user1.id) # Verify user1 was not assigned as coach self.assertFalse( @@ -2602,7 +2606,6 @@ def test_bulk_assign_coaches_filters_learners_silently(self): def test_can_assign_learner_as_facility_admin(self): """Test that learners CAN be assigned as facility admins (validation only for classroom coaches)""" - from kolibri.core.auth.constants import role_kinds self.login_superuser() @@ -2622,9 +2625,11 @@ def test_can_assign_learner_as_facility_admin(self): format="json", ) - # Should succeed + # Should succeed with all items created self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 1) + self.assertIn("created", response.data) + self.assertEqual(len(response.data["created"]), 1) + self.assertEqual(response.data["created"][0]["user"], self.user1.id) # Verify role was created self.assertTrue( From b3bb684fe8597a43f915e798f11da9b2b561aaac Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 27 Oct 2025 15:08:51 -0700 Subject: [PATCH 08/45] skipped -> invalid instead --- kolibri/core/auth/api.py | 6 +++--- kolibri/core/auth/serializers.py | 4 ++-- kolibri/core/auth/test/test_api.py | 21 ++++++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index bc970156d15..b31a2aed007 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -819,15 +819,15 @@ def create(self, request): response_data = {"created": serializer.data} status_code = status.HTTP_201_CREATED - if hasattr(serializer, "skipped_items") and serializer.skipped_items: + if hasattr(serializer, "invalid_items") and serializer.invalid_items: status_code = status.HTTP_207_MULTI_STATUS - response_data["skipped"] = [ + response_data["invalid"] = [ { "user": item["user"].id, "collection": item["collection"].id, "kind": item["kind"], } - for item in serializer.skipped_items + for item in serializer.invalid_items ] return Response(response_data, status=status_code) diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 67bfb3c79e0..3bdbe1b2516 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -33,7 +33,7 @@ class RoleListSerializer(serializers.ListSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.skipped_items = [] + self.invalid_items = [] def validate(self, attrs): @@ -76,7 +76,7 @@ def validate(self, attrs): for item in classroom_coach_items: if (item["collection"].id, item["user"].id) in existing_memberships: - self.skipped_items.append(item) + self.invalid_items.append(item) else: valid_items.append(item) diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 5018073595e..55a3106fc02 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2333,7 +2333,10 @@ def test_delete_does_not_affect_other_user_memberships(self): ) def test_cannot_enroll_coach_as_learner_in_same_classroom(self): - """Test that enrolling a coach as a learner in the same classroom is silently skipped""" + """ + Test that enrolling a coach as a learner in the same classroom is included in + the invalid_items + """ self.login_superuser() @@ -2538,14 +2541,14 @@ def test_cannot_assign_learner_as_coach_in_same_classroom(self): format="json", ) - # Should return 207 with skipped items + # Should return 207 with invalid items self.assertEqual(response.status_code, 207) self.assertIn("created", response.data) - self.assertIn("skipped", response.data) + self.assertIn("invalid", response.data) self.assertEqual(len(response.data["created"]), 0) - self.assertEqual(len(response.data["skipped"]), 1) - self.assertEqual(response.data["skipped"][0]["user"], self.user1.id) - self.assertEqual(response.data["skipped"][0]["collection"], self.classroom.id) + self.assertEqual(len(response.data["invalid"]), 1) + self.assertEqual(response.data["invalid"][0]["user"], self.user1.id) + self.assertEqual(response.data["invalid"][0]["collection"], self.classroom.id) # Verify no coach role was created self.assertFalse( @@ -2584,11 +2587,11 @@ def test_bulk_assign_coaches_filters_learners_silently(self): # Should return 207 with mixed results self.assertEqual(response.status_code, 207) self.assertIn("created", response.data) - self.assertIn("skipped", response.data) + self.assertIn("invalid", response.data) self.assertEqual(len(response.data["created"]), 1) - self.assertEqual(len(response.data["skipped"]), 1) + self.assertEqual(len(response.data["invalid"]), 1) self.assertEqual(response.data["created"][0]["user"], self.user2.id) - self.assertEqual(response.data["skipped"][0]["user"], self.user1.id) + self.assertEqual(response.data["invalid"][0]["user"], self.user1.id) # Verify user1 was not assigned as coach self.assertFalse( From 731040287ae64eca823b655cce9bc5f38fe1e806 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 27 Oct 2025 15:37:32 -0700 Subject: [PATCH 09/45] membershipviewset & serializer return valid & invalid items allowing us to inform the user --- kolibri/core/auth/api.py | 21 ++++++++++++ kolibri/core/auth/serializers.py | 52 +++++++++++++----------------- kolibri/core/auth/test/test_api.py | 35 +++++++++++++------- 3 files changed, 67 insertions(+), 41 deletions(-) diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index b31a2aed007..ab98fe5e39e 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -786,6 +786,27 @@ class MembershipViewSet(BulkDeleteMixin, BulkCreateMixin, viewsets.ModelViewSet) serializer_class = MembershipSerializer filterset_class = MembershipFilter + def create(self, request): + serializer = self.get_serializer(data=request.data, many=True) + 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"] = [ + { + "user": item["user"].id, + "collection": item["collection"].id, + } + for item in serializer.invalid_items + ] + + return Response(response_data, status=status_code) + class RoleFilter(FilterSet): user_ids = CharFilter(method="filter_user_ids") diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 3bdbe1b2516..8795b9ff31a 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -1,5 +1,4 @@ import logging -from collections import defaultdict from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import MinLengthValidator @@ -195,53 +194,46 @@ def validate(self, attrs): class MembershipListSerializer(serializers.ListSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.invalid_items = [] + def validate(self, attrs): # Separate classroom memberships from others classroom_items = [] - non_classroom_items = [] + other_items = [] + user_ids_to_validate = [] + class_collection_ids = [] for membership_data in attrs: collection = membership_data["collection"] if collection.kind == collection_kinds.CLASSROOM: classroom_items.append(membership_data) + class_collection_ids.append(collection.id) + user_ids_to_validate.append(membership_data["user"].id) else: - non_classroom_items.append(membership_data) + other_items.append(membership_data) # If no classroom items, return everything as-is if not classroom_items: return attrs - # Group classroom items by collection to minimize queries - items_by_collection = defaultdict(list) - for item in classroom_items: - collection_id = item["collection"].id - items_by_collection[collection_id].append(item) - - # For each collection, do one bulk query to get all conflicting users - coaches_by_collection = {} - for collection_id, items in items_by_collection.items(): - user_ids = [item["user"].id for item in items] - # Single query per collection instead of N queries - coach_user_ids = set( - Role.objects.filter( - collection_id=collection_id, - user_id__in=user_ids, - kind__in=[role_kinds.COACH, role_kinds.ASSIGNABLE_COACH], - ).values_list("user_id", flat=True) - ) - coaches_by_collection[collection_id] = coach_user_ids + 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], + ).values_list("collection_id", "user_id") + + valid_items = [] - # Filter out items where user is already a coach - validated_classroom_items = [] for item in classroom_items: - user_id = item["user"].id - collection_id = item["collection"].id - if user_id not in coaches_by_collection[collection_id]: - validated_classroom_items.append(item) - # else: silently skip - user is already a coach + if (item["collection"].id, item["user"].id) in existing_roles: + self.invalid_items.append(item) + else: + valid_items.append(item) # Return non-classroom items + validated classroom items - return non_classroom_items + validated_classroom_items + return other_items + valid_items def create(self, validated_data): created_objects = [] diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 55a3106fc02..bf48487259f 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2355,9 +2355,14 @@ def test_cannot_enroll_coach_as_learner_in_same_classroom(self): format="json", ) - # Should return 201 but with empty array (silently filtered) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 0) + # Should return 207 with invalid items + self.assertEqual(response.status_code, 207) + self.assertIn("created", response.data) + self.assertIn("invalid", response.data) + self.assertEqual(len(response.data["created"]), 0) + self.assertEqual(len(response.data["invalid"]), 1) + self.assertEqual(response.data["invalid"][0]["user"], test_user.id) + self.assertEqual(response.data["invalid"][0]["collection"], self.classroom.id) # Verify no membership was created self.assertFalse( @@ -2391,10 +2396,14 @@ def test_bulk_enroll_filters_coaches_silently(self): format="json", ) - # Should return 201 with only user2's membership - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]["user"], user2.id) + # Should return 207 with mixed results + self.assertEqual(response.status_code, 207) + self.assertIn("created", response.data) + self.assertIn("invalid", response.data) + self.assertEqual(len(response.data["created"]), 1) + self.assertEqual(len(response.data["invalid"]), 1) + self.assertEqual(response.data["created"][0]["user"], user2.id) + self.assertEqual(response.data["invalid"][0]["user"], user1.id) # Verify user1 was not enrolled self.assertFalse( @@ -2445,9 +2454,12 @@ def test_bulk_enroll_multiple_classrooms_filters_correctly(self): format="json", ) - # Should return 201 with only the 2 non-conflicting memberships - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 2) + # Should return 207 with mixed results + self.assertEqual(response.status_code, 207) + self.assertIn("created", response.data) + self.assertIn("invalid", response.data) + self.assertEqual(len(response.data["created"]), 2) + self.assertEqual(len(response.data["invalid"]), 2) # Verify user1 enrolled in classroom2 (not classroom1) self.assertFalse( @@ -2493,7 +2505,8 @@ def test_can_enroll_in_learnergroup(self): # Should succeed self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data), 1) + self.assertIn("created", response.data) + self.assertEqual(len(response.data["created"]), 1) # Verify membership was created self.assertTrue( From 1c58c9e2b30bc80a11c36b711295e4aa427ec83f Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 27 Oct 2025 21:40:50 -0700 Subject: [PATCH 10/45] useActionWithUndo updated w/ flag to allow disabling undo conditionally --- .../assets/src/composables/useActionWithUndo.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js index 8fc9a372454..7fd0c0dbb0e 100644 --- a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js +++ b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js @@ -1,4 +1,6 @@ +import { computed } from 'vue'; import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; +import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; /** @@ -26,9 +28,13 @@ import useSnackbar from 'kolibri/composables/useSnackbar'; * @property {() => Promise} performAction - A method to manually trigger the main action * with all the undo machinery set up. * + * @param {ComputedRef} options.canUndo - A computed ref that will return true when the undo + * can be performed (Default: ComputedRef -> true) + * * @returns {UseActionWithUndoObject} */ export default function useActionWithUndo({ + canUndo = (computed(() => true)), action, actionNotice$, undoAction, @@ -39,6 +45,8 @@ export default function useActionWithUndo({ const { createSnackbar, clearSnackbar } = useSnackbar(); const performUndoAction = async () => { + if(!canUndo.value) return; + clearSnackbar(); try { await undoAction(); @@ -59,7 +67,7 @@ export default function useActionWithUndo({ autofocus: true, autoDismiss: true, duration: 6000, - actionText: undoAction$(), + actionText: (canUndo.value ? undoAction$() : coreStrings.dismissAction$()), onBlur, actionCallback: performUndoAction, }); From d2954efb632f9bcf9e707eb0bf4d9788cacc2dc5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:46:16 +0000 Subject: [PATCH 11/45] [pre-commit.ci lite] apply automatic fixes --- .../facility/assets/src/composables/useActionWithUndo.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js index 7fd0c0dbb0e..3998706d700 100644 --- a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js +++ b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js @@ -34,7 +34,7 @@ import useSnackbar from 'kolibri/composables/useSnackbar'; * @returns {UseActionWithUndoObject} */ export default function useActionWithUndo({ - canUndo = (computed(() => true)), + canUndo = computed(() => true), action, actionNotice$, undoAction, @@ -45,7 +45,7 @@ export default function useActionWithUndo({ const { createSnackbar, clearSnackbar } = useSnackbar(); const performUndoAction = async () => { - if(!canUndo.value) return; + if (!canUndo.value) return; clearSnackbar(); try { @@ -67,7 +67,7 @@ export default function useActionWithUndo({ autofocus: true, autoDismiss: true, duration: 6000, - actionText: (canUndo.value ? undoAction$() : coreStrings.dismissAction$()), + actionText: canUndo.value ? undoAction$() : coreStrings.dismissAction$(), onBlur, actionCallback: performUndoAction, }); From 96c6ab384ffe5792ac67cf20a5ef7ab3d8b6db07 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 27 Oct 2025 22:01:15 -0700 Subject: [PATCH 12/45] lint manually; enroll & assign front-end logic --- .../src/composables/useActionWithUndo.js | 8 +++-- .../sidePanels/AssignCoachesSidePanel.vue | 31 ++++++++++++++++--- .../sidePanels/EnrollLearnersSidePanel.vue | 31 +++++++++++++++++-- .../strings/bulkUserManagementStrings.js | 20 ++++++++++-- 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js index 3998706d700..cbd801f92cb 100644 --- a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js +++ b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js @@ -1,7 +1,9 @@ import { computed } from 'vue'; -import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; +import { + bulkUserManagementStrings +} from 'kolibri-common/strings/bulkUserManagementStrings'; /** * @@ -28,8 +30,8 @@ import useSnackbar from 'kolibri/composables/useSnackbar'; * @property {() => Promise} performAction - A method to manually trigger the main action * with all the undo machinery set up. * - * @param {ComputedRef} options.canUndo - A computed ref that will return true when the undo - * can be performed (Default: ComputedRef -> true) + * @param {ComputedRef} options.canUndo - A computed ref that will return true when the + * undo can be performed (Default: ComputedRef -> true) * * @returns {UseActionWithUndoObject} */ diff --git a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue index 3a54fcbcb59..2db4dbbe670 100644 --- a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue +++ b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue @@ -116,6 +116,8 @@ import SidePanelModal from 'kolibri-common/components/SidePanelModal'; import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import { UserKinds } from 'kolibri/constants'; + import client from 'kolibri/client'; + import urls from 'kolibri/urls'; import RoleResource from 'kolibri-common/apiResources/RoleResource'; import { useGoBack } from 'kolibri-common/composables/usePreviousRoute'; import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; @@ -140,6 +142,7 @@ const isLoading = ref(false); const showErrorWarning = ref(false); const createdRoles = ref(null); + const invalidRoles = ref(null); const facilityUsers = ref([]); const route = useRoute(); const closeConfirmationGuardRef = ref(null); @@ -153,7 +156,9 @@ }); const { - coachesAssignedNotice$, + coachesAllAssignedNotice$, + coachesAllInvalidNotice$, + coachesSomeInvalidNotice$, assignCoachUndoneNotice$, usersInClassNotAffected$, assignAction$, @@ -259,13 +264,19 @@ })), ); - const newRoles = await RoleResource.saveCollection({ + // Errors will be caught in _handleAssign + const response = await client({ + url: urls['kolibri:core:role_list'](), + method: 'POST', data: roleData, }); + const { created, invalid } = response.data; + // Only add roles that were actually created (have an id) - const actuallyCreatedRoles = newRoles.filter(role => role.id); + const actuallyCreatedRoles = created.filter(role => role.id); createdRoles.value = actuallyCreatedRoles; + invalidRoles.value = invalid || []; } async function handleUndoAssignments() { @@ -278,12 +289,24 @@ } } + const snackbarMessage$ = () => { + if(createdRoles.value.length) { + if(invalidRoles.value.length) { + return coachesSomeInvalidNotice$(); + } else { + return coachesAllAssignedNotice$(); + } + } + return coachesAllInvalidNotice$(); + }; + const { performAction: handleAssign } = useActionWithUndo({ action: _handleAssign, - actionNotice$: coachesAssignedNotice$, + actionNotice$: snackbarMessage$, undoAction: handleUndoAssignments, undoActionNotice$: assignCoachUndoneNotice$, onBlur: props.onBlur, + canUndo: computed(() => createdRoles.value.length), }); function closeSidePanel() { diff --git a/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue b/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue index 3ee203a49b1..8f350241ee2 100644 --- a/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue +++ b/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue @@ -113,6 +113,8 @@ diff --git a/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/NormalLayout.vue b/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/NormalLayout.vue index 2b43307aac5..6910ae079ce 100644 --- a/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/NormalLayout.vue +++ b/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/NormalLayout.vue @@ -32,6 +32,7 @@ +
diff --git a/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/SmallWindowLayout.vue b/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/SmallWindowLayout.vue index 7733b41d77b..dcebc0fe112 100644 --- a/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/SmallWindowLayout.vue +++ b/kolibri/plugins/facility/assets/src/views/users/common/UsersTableToolbar/SmallWindowLayout.vue @@ -43,6 +43,7 @@ +
+ diff --git a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue index 2db4dbbe670..2e9cc063676 100644 --- a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue +++ b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue @@ -112,7 +112,7 @@ diff --git a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue index 6ea4261ae0a..68e6552f2a2 100644 --- a/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue +++ b/kolibri/plugins/facility/assets/src/views/users/sidePanels/AssignCoachesSidePanel.vue @@ -319,7 +319,7 @@ } } if(invalidRoles.value?.length) { - return unableToAssignAlert$(); + return someFailedToAssign$(); } }; diff --git a/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue b/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue index c9116c4c3f4..92dff9a5876 100644 --- a/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue +++ b/kolibri/plugins/facility/assets/src/views/users/sidePanels/EnrollLearnersSidePanel.vue @@ -137,14 +137,17 @@ CloseConfirmationGuard, }, mixins: [commonCoreStrings], - setup(props) { + setup(props, { emit }) { const store = getCurrentInstance().proxy.$store; const route = useRoute(); const router = useRouter(); const goBack = useGoBack({ getFallbackRoute: () => { + const { query } = route; + delete query.failedActionType; return overrideRoute(route, { name: getRootRouteName(route), + query, }); }, }); @@ -220,7 +223,6 @@ async function _enrollLearners() { loading.value = true; const enrollments = selectedOptions.value.flatMap(collection_id => { - const alreadyEnrolled = classMembershipsByUser.value; return Array.from(props.selectedUsers) .map(user => ({ collection: collection_id, user })); }); @@ -239,9 +241,10 @@ // nextTick to give the hasUnsavedChanges a chance to react // so that the confirmation guard doesn't trigger in this case - nextTick(() => { - if(invalid?.length) { - return router.push( + return nextTick(() => { + // This doesn't need to be done in the assign side panel but IDK for sure why + if(Boolean(invalid)) { + router.push( overrideRoute( route, { @@ -253,8 +256,9 @@ }, ) ); + emit('clearSelection'); + return true; } else { - goBack(); return true; } }); @@ -285,7 +289,7 @@ return usersEnrolledNotice$(); } } - if(invalidRoles.value?.length) { + if(invalidMemberships.value?.length) { return someFailedToEnroll$(); } }; From cbb71a266ec73bd969955380072e24cfeca09355 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Wed, 29 Oct 2025 13:23:08 -0700 Subject: [PATCH 21/45] Apply alert handling for invalid responses to New Users page --- .../assets/src/views/users/NewUsersPage.vue | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue b/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue index a9ec1d72e8d..02ad63d8c14 100644 --- a/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue +++ b/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue @@ -139,6 +139,15 @@ :numFilteredItems="usersCount" /> +
@@ -211,18 +221,17 @@ import { useRoute, useRouter } from 'vue-router/composables'; import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useUser from 'kolibri/composables/useUser'; - + import UiAlert from 'kolibri-design-system/lib/keen/UiAlert'; import ImmersivePage from 'kolibri/components/pages/ImmersivePage'; import usePreviousRoute from 'kolibri-common/composables/usePreviousRoute'; import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; - import { UserKinds } from 'kolibri/constants'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import useUsersTableSearch from '../../composables/useUsersTableSearch'; import usePagination from '../../composables/usePagination'; import useUserManagement from '../../composables/useUserManagement'; import emptyPlusCloudSvg from '../../images/empty_plus_cloud.svg'; - import { PageNames } from '../../constants'; + import { InvalidActionTypes, PageNames } from '../../constants'; import { overrideRoute } from '../../utils'; import UsersTable from './common/UsersTable.vue'; import UsersTableToolbar from './common/UsersTableToolbar/index.vue'; @@ -240,6 +249,7 @@ MoveToTrashModal, FilterTextbox, PaginationActions, + UiAlert, }, mixins: [commonCoreStrings], setup() { @@ -312,6 +322,8 @@ filterLabel$, numUsersSelected$, clearFiltersLabel$, + someFailedToAssign$, + someFailedToEnroll$, } = bulkUserManagementStrings; function clearSelectedUsers() { @@ -358,7 +370,30 @@ fetchClasses(); }); + // Alerting users about enrollment/assignment errors + const alertDismissed = ref(false) + + function handleAlertDismissal() { + alertDismissed.value = true + } + + const showingInvalidUsers = computed(() => !alertDismissed.value && Boolean(route.query.by_ids)); + const warningMessage = computed( + () => { + switch(route.query?.failedActionType) { + case(InvalidActionTypes.ASSIGN): + return someFailedToAssign$(); + case(InvalidActionTypes.ENROLL): + return someFailedToEnroll$(); + default: + return ''; + } + } + ); + return { + warningMessage, + showingInvalidUsers, usersTableStyles, // Route utilities overrideRoute, From 2460d6f6054b64f0c0058e27a82ed874051084ab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:27:20 +0000 Subject: [PATCH 22/45] [pre-commit.ci lite] apply automatic fixes --- .../src/composables/useActionWithUndo.js | 4 +- .../src/composables/useUserManagement.js | 39 +++++++++++-------- .../assets/src/composables/useUsersFilters.js | 2 +- .../plugins/facility/assets/src/constants.js | 5 +-- .../src/views/CoachClassAssignmentPage.vue | 16 +++----- .../src/views/LearnerClassEnrollmentPage.vue | 9 ++--- .../assets/src/views/users/NewUsersPage.vue | 28 ++++++------- .../src/views/users/UsersRootPage/index.vue | 29 ++++++-------- .../sidePanels/AssignCoachesSidePanel.vue | 25 ++++++------ .../sidePanels/EnrollLearnersSidePanel.vue | 28 ++++++------- .../strings/bulkUserManagementStrings.js | 12 ++++-- 11 files changed, 91 insertions(+), 106 deletions(-) diff --git a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js index cbd801f92cb..14bec8fc895 100644 --- a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js +++ b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js @@ -1,9 +1,7 @@ import { computed } from 'vue'; import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; -import { - bulkUserManagementStrings -} from 'kolibri-common/strings/bulkUserManagementStrings'; +import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; /** * diff --git a/kolibri/plugins/facility/assets/src/composables/useUserManagement.js b/kolibri/plugins/facility/assets/src/composables/useUserManagement.js index 90d54b71b1d..cf632705b28 100644 --- a/kolibri/plugins/facility/assets/src/composables/useUserManagement.js +++ b/kolibri/plugins/facility/assets/src/composables/useUserManagement.js @@ -38,22 +38,22 @@ export default function useUserManagement({ dataLoading.value = true; try { const fetchResource = softDeletedUsers ? DeletedFacilityUserResource : FacilityUserResource; - const getParams = Boolean(by_ids.value) ? - { - by_ids: by_ids.value, - member_of: activeFacilityId, - page: page.value, - page_size: pageSize.value, - } : - pickBy({ - member_of: activeFacilityId, - date_joined__gte: dateJoinedGt?.toISOString(), - page: page.value, - page_size: pageSize.value, - search: search.value?.trim() || null, - ordering: order.value === 'desc' ? `-${ordering.value}` : ordering.value || null, - ...getBackendFilters(), - }); + const getParams = by_ids.value + ? { + by_ids: by_ids.value, + member_of: activeFacilityId, + page: page.value, + page_size: pageSize.value, + } + : pickBy({ + member_of: activeFacilityId, + date_joined__gte: dateJoinedGt?.toISOString(), + page: page.value, + page_size: pageSize.value, + search: search.value?.trim() || null, + ordering: order.value === 'desc' ? `-${ordering.value}` : ordering.value || null, + ...getBackendFilters(), + }); const resp = await fetchResource.fetchCollection({ getParams, @@ -87,7 +87,12 @@ export default function useUserManagement({ } }; - function onChange({ resetSelection = false, affectedClasses = null, created = [], invalid = [] } = {}) { + function onChange({ + resetSelection = false, + affectedClasses = null, + created = [], + invalid = [], + } = {}) { if (resetSelection) { selectedUsers.value = new Set(); } diff --git a/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js b/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js index 037497f333b..3d10df12070 100644 --- a/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js +++ b/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js @@ -124,7 +124,7 @@ export default function useUsersFilters({ classes }) { watch( routeFilters, newFilters => { - if(workingFilters.by_ids?.length) { + 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(); diff --git a/kolibri/plugins/facility/assets/src/constants.js b/kolibri/plugins/facility/assets/src/constants.js index fca56a4c6a8..111cd6114e0 100644 --- a/kolibri/plugins/facility/assets/src/constants.js +++ b/kolibri/plugins/facility/assets/src/constants.js @@ -25,10 +25,9 @@ export const PageNames = { ENROLL_LEARNERS_SIDE_PANEL__NEW_USERS: 'ENROLL_LEARNERS_SIDE_PANEL__NEW_USERS', }; - export const InvalidActionTypes = { - ASSIGN: "assign", - ENROLL: "enroll", + ASSIGN: 'assign', + ENROLL: 'enroll', }; // modal names diff --git a/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue b/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue index 4e1bcc12dc2..c7fb3ba4699 100644 --- a/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue +++ b/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue @@ -28,9 +28,9 @@ import ImmersivePage from 'kolibri/components/pages/ImmersivePage'; import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; - import ClassEnrollForm from './ClassEnrollForm'; import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import useActionWithUndo from '../composables/useActionWithUndo'; + import ClassEnrollForm from './ClassEnrollForm'; export default { name: 'CoachClassAssignmentPage', @@ -46,11 +46,8 @@ mixins: [commonCoreStrings], setup() { const { createSnackbar } = useSnackbar(); - const { - someLearnersEnrolledNotice$, - coachesAllAssignedNotice$, - someCoachesAssignedNotice$, - } = bulkUserManagementStrings; + const { someLearnersEnrolledNotice$, coachesAllAssignedNotice$, someCoachesAssignedNotice$ } = + bulkUserManagementStrings; return { someCoachesAssignedNotice$, coachesAllAssignedNotice$, @@ -81,15 +78,14 @@ this.assignCoachesToClass({ classId: this.class.id, coaches }) .then(response => { const { created, invalid } = response.data; - if(created?.length) { - if(invalid?.length) { + if (created?.length) { + if (invalid?.length) { this.createSnackbar(this.someCoachesAssignedNotice$()); } else { this.createSnackbar(this.coachesAllAssignedNotice$()); } } - this.$router - .push(this.$store.getters.facilityPageLinks.ClassEditPage(this.class.id)) + this.$router.push(this.$store.getters.facilityPageLinks.ClassEditPage(this.class.id)); }) .catch(() => { this.formIsDisabled = false; diff --git a/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue b/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue index 9dc866a917f..41f1bf64ab1 100644 --- a/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue +++ b/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue @@ -45,10 +45,7 @@ mixins: [commonCoreStrings], setup() { const { createSnackbar } = useSnackbar(); - const { - usersEnrolledNotice$, - someLearnersEnrolledNotice$, - } = bulkUserManagementStrings; + const { usersEnrolledNotice$, someLearnersEnrolledNotice$ } = bulkUserManagementStrings; return { createSnackbar, usersEnrolledNotice$, @@ -83,8 +80,8 @@ this.enrollLearnersInClass({ classId: this.class.id, users: selectedUsers }) .then(response => { const { created, invalid } = response.data; - if(created?.length) { - if(invalid?.length) { + if (created?.length) { + if (invalid?.length) { this.createSnackbar(this.someLearnersEnrolledNotice$()); } else { this.createSnackbar(this.usersEnrolledNotice$()); diff --git a/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue b/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue index 02ad63d8c14..bc228edf051 100644 --- a/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue +++ b/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue @@ -371,25 +371,25 @@ }); // Alerting users about enrollment/assignment errors - const alertDismissed = ref(false) + const alertDismissed = ref(false); function handleAlertDismissal() { - alertDismissed.value = true + alertDismissed.value = true; } - const showingInvalidUsers = computed(() => !alertDismissed.value && Boolean(route.query.by_ids)); - const warningMessage = computed( - () => { - switch(route.query?.failedActionType) { - case(InvalidActionTypes.ASSIGN): - return someFailedToAssign$(); - case(InvalidActionTypes.ENROLL): - return someFailedToEnroll$(); - default: - return ''; - } - } + const showingInvalidUsers = computed( + () => !alertDismissed.value && Boolean(route.query.by_ids), ); + const warningMessage = computed(() => { + switch (route.query?.failedActionType) { + case InvalidActionTypes.ASSIGN: + return someFailedToAssign$(); + case InvalidActionTypes.ENROLL: + return someFailedToEnroll$(); + default: + return ''; + } + }); return { warningMessage, diff --git a/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue b/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue index 751ac4aa2a5..d24e5504e14 100644 --- a/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue +++ b/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue @@ -99,7 +99,6 @@ />