diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index 34abba20c3a..587f6470647 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) + 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") @@ -810,6 +831,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) + 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, + "kind": item["kind"], + } + for item in serializer.invalid_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 55f15f29889..104b69f0fda 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -7,7 +7,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,13 +30,64 @@ class RoleListSerializer(serializers.ListSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.invalid_items = [] + + def validate(self, attrs): + + # 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"] + + # Only validate classroom-level coach roles + if collection.kind == collection_kinds.CLASSROOM and kind in [ + role_kinds.COACH, + role_kinds.ADMIN, + 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) + + # If no classroom coach items, return everything as-is + if not classroom_coach_items: + return attrs + + existing_memberships = Membership.objects.filter( + collection_id__in=class_collection_ids, user_id__in=assignable_coach_ids + ).values_list("collection_id", "user_id") + + valid_items = [] + + for item in classroom_coach_items: + if (item["collection"].id, item["user"].id) in existing_memberships: + self.invalid_items.append(item) + else: + valid_items.append(item) + + return other_items + valid_items + def create(self, validated_data): created_objects = [] for model_data in validated_data: - obj, created = Role.objects.get_or_create(**model_data) - if created: - created_objects.append(obj) + obj, _ = Role.objects.get_or_create(**model_data) + created_objects.append(obj) return created_objects @@ -141,13 +194,53 @@ 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 = [] + 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: + other_items.append(membership_data) + + # If no classroom items, return everything as-is + if not classroom_items: + return attrs + + existing_roles = Role.objects.filter( + collection_id__in=class_collection_ids, + user_id__in=user_ids_to_validate, + kind__in=[role_kinds.COACH, role_kinds.ADMIN, role_kinds.ASSIGNABLE_COACH], + ).values_list("collection_id", "user_id") + + valid_items = [] + + for item in classroom_items: + 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 other_items + valid_items + def create(self, validated_data): created_objects = [] for model_data in validated_data: - obj, created = Membership.objects.get_or_create(**model_data) - if created: - created_objects.append(obj) + obj, _ = Membership.objects.get_or_create(**model_data) + created_objects.append(obj) return created_objects diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 5eb2804756b..b301792b619 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -2332,6 +2332,331 @@ 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 included in + the invalid_items + """ + + 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 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( + models.Membership.objects.filter( + user=test_user, collection=self.classroom + ).exists() + ) + + def test_bulk_enroll_filters_coaches(self): + """Test that bulk enrollment filters out coaches but creates valid memberships""" + + 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 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( + 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() + ) + + def test_bulk_enroll_multiple_classrooms_filters_correctly(self): + """Test that bulk enrollment across multiple classrooms filters per-collection""" + + 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 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( + 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() + + # 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.assertIn("created", response.data) + self.assertEqual(len(response.data["created"]), 1) + + # Verify membership was created + self.assertTrue( + models.Membership.objects.filter( + user=user1, collection=learner_group + ).exists() + ) + + +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 results + in `invalid` returned with the skipped users + """ + + 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 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"], self.user1.id) + self.assertEqual(response.data["invalid"][0]["collection"], self.classroom.id) + + # Verify no coach role was created + self.assertFalse( + models.Role.objects.filter( + user=self.user1, collection=self.classroom, kind=role_kinds.COACH + ).exists() + ) + + def test_bulk_assign_coaches_filters_learners(self): + """Test that bulk coach assignment filters out learners but creates valid roles""" + + 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 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"], self.user2.id) + self.assertEqual(response.data["invalid"][0]["user"], self.user1.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() + ) + + def test_can_assign_learner_as_facility_admin(self): + """Test that learners CAN be assigned as facility admins (validation only for classroom coaches)""" + + 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 with all items created + self.assertEqual(response.status_code, 201) + 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( + models.Role.objects.filter( + user=self.user1, collection=self.facility, kind=role_kinds.ADMIN + ).exists() + ) + class GroupMembership(APITestCase): @classmethod diff --git a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js index 8fc9a372454..14bec8fc895 100644 --- a/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js +++ b/kolibri/plugins/facility/assets/src/composables/useActionWithUndo.js @@ -1,5 +1,7 @@ -import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; +import { computed } from 'vue'; +import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; +import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; /** * @@ -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, }); diff --git a/kolibri/plugins/facility/assets/src/composables/useUserManagement.js b/kolibri/plugins/facility/assets/src/composables/useUserManagement.js index bc1dd1047c2..3a89d0693b4 100644 --- a/kolibri/plugins/facility/assets/src/composables/useUserManagement.js +++ b/kolibri/plugins/facility/assets/src/composables/useUserManagement.js @@ -28,6 +28,7 @@ export default function useUserManagement({ const ordering = computed(() => route.value.query.ordering || null); const order = computed(() => route.value.query.order || ''); const search = computed(() => route.value.query.search || null); + const by_ids = computed(() => route.value.query.by_ids || null); const { routeFilters, numAppliedFilters, getBackendFilters, resetFilters } = useUsersFilters({ classes, @@ -37,18 +38,22 @@ export default function useUserManagement({ dataLoading.value = true; try { const fetchResource = softDeletedUsers ? DeletedFacilityUserResource : FacilityUserResource; + const getParams = pickBy({ + by_ids: by_ids.value, + 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: 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(), - }), + getParams, force: true, }); + facilityUsers.value = resp.results.map(_userState); totalPages.value = resp.total_pages; usersCount.value = resp.count; diff --git a/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js b/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js index f3c5398a499..21e6413e2bd 100644 --- a/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js +++ b/kolibri/plugins/facility/assets/src/composables/useUsersFilters.js @@ -25,6 +25,7 @@ export default function useUsersFilters({ classes }) { birthYearStart: route.query.birth_year_start || null, birthYearEnd: route.query.birth_year_end || null, creationDate: route.query.creation_date || null, + by_ids: route.query.by_ids?.split(',') || [], }; }); @@ -36,10 +37,14 @@ export default function useUsersFilters({ classes }) { end: null, }, creationDate: {}, + by_ids: [], }); const numAppliedFilters = computed(() => { let count = 0; + if (routeFilters.value.by_ids.length) { + count += 1; + } if (routeFilters.value.userTypes.length) { count += 1; } @@ -119,6 +124,7 @@ export default function useUsersFilters({ classes }) { watch( routeFilters, newFilters => { + workingFilters.by_ids = [...newFilters.by_ids]; workingFilters.userTypes = [...newFilters.userTypes]; workingFilters.classes = [...newFilters.classes]; workingFilters.birthYear.start = newFilters.birthYearStart; @@ -142,6 +148,12 @@ export default function useUsersFilters({ classes }) { const nextQuery = { ...route.query }; delete nextQuery.page; // Reset to the first page when applying filters + if (workingFilters.by_ids.length) { + nextQuery.by_ids = workingFilters.by_ids.join(','); + } else { + delete nextQuery.by_ids; + } + if (workingFilters.userTypes.length) { nextQuery.user_types = workingFilters.userTypes.join(','); } else { @@ -219,6 +231,7 @@ export default function useUsersFilters({ classes }) { workingFilters.birthYear.start = null; workingFilters.birthYear.end = null; workingFilters.creationDate = {}; + workingFilters.by_ids = []; }; const resetFilters = () => { diff --git a/kolibri/plugins/facility/assets/src/modules/classAssignMembers/actions.js b/kolibri/plugins/facility/assets/src/modules/classAssignMembers/actions.js index 069c631ae70..c2d59d3888d 100644 --- a/kolibri/plugins/facility/assets/src/modules/classAssignMembers/actions.js +++ b/kolibri/plugins/facility/assets/src/modules/classAssignMembers/actions.js @@ -3,11 +3,8 @@ import RoleResource from 'kolibri-common/apiResources/RoleResource'; import { UserKinds } from 'kolibri/constants'; import uniq from 'lodash/uniq'; -export function enrollLearnersInClass(store, { classId, users }) { +export async function enrollLearnersInClass(_, { classId, users }) { return MembershipResource.saveCollection({ - getParams: { - collection: classId, - }, data: uniq(users).map(userId => ({ collection: classId, user: userId, @@ -15,11 +12,8 @@ export function enrollLearnersInClass(store, { classId, users }) { }); } -export function assignCoachesToClass(store, { classId, coaches }) { +export async function assignCoachesToClass(_, { classId, coaches }) { return RoleResource.saveCollection({ - getParams: { - collection: classId, - }, data: uniq(coaches).map(userId => ({ collection: classId, user: userId, diff --git a/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue b/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue index a9b9661ed50..508650cd8fa 100644 --- a/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue +++ b/kolibri/plugins/facility/assets/src/views/CoachClassAssignmentPage.vue @@ -28,6 +28,7 @@ import ImmersivePage from 'kolibri/components/pages/ImmersivePage'; import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useSnackbar from 'kolibri/composables/useSnackbar'; + import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import ClassEnrollForm from './ClassEnrollForm'; export default { @@ -44,7 +45,12 @@ mixins: [commonCoreStrings], setup() { const { createSnackbar } = useSnackbar(); - return { createSnackbar }; + const { noCoachesAssigned$, someCoachesAssignedNotice$ } = bulkUserManagementStrings; + return { + someCoachesAssignedNotice$, + noCoachesAssigned$, + createSnackbar, + }; }, data() { return { @@ -68,12 +74,26 @@ assignCoaches(coaches) { this.formIsDisabled = true; this.assignCoachesToClass({ classId: this.class.id, coaches }) - .then(() => { - // do this in action? + .then(response => { + const { created, invalid } = response.data; this.$router .push(this.$store.getters.facilityPageLinks.ClassEditPage(this.class.id)) .then(() => { - this.showSnackbarNotification('coachesAssignedNoCount', { count: coaches.length }); + if (created?.length) { + if (invalid?.length) { + // Partial success + this.createSnackbar(this.someCoachesAssignedNotice$()); + } else { + // Full success + this.showSnackbarNotification('coachesAssignedNoCount', { + count: created.length, + }); + } + } else { + // Whether we have invalid or not, if we haven't created any role assignments, + // we should say "no coaches assigned". + this.createSnackbar(this.noCoachesAssigned$()); + } }); }) .catch(() => { diff --git a/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue b/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue index 6a04c9cac2a..7f2eafee5b1 100644 --- a/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue +++ b/kolibri/plugins/facility/assets/src/views/LearnerClassEnrollmentPage.vue @@ -28,6 +28,7 @@ import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import ImmersivePage from 'kolibri/components/pages/ImmersivePage'; import useSnackbar from 'kolibri/composables/useSnackbar'; + import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import ClassEnrollForm from './ClassEnrollForm'; export default { @@ -44,7 +45,12 @@ mixins: [commonCoreStrings], setup() { const { createSnackbar } = useSnackbar(); - return { createSnackbar }; + const { noLearnersEnrolled$, someLearnersEnrolledNotice$ } = bulkUserManagementStrings; + return { + noLearnersEnrolled$, + createSnackbar, + someLearnersEnrolledNotice$, + }; }, data() { return { @@ -72,13 +78,27 @@ window.localStorage.setItem(`${welcomeDismissalKey}-${id}`, false); }); this.enrollLearnersInClass({ classId: this.class.id, users: selectedUsers }) - .then(() => { + .then(response => { + const { created, invalid } = response.data; this.$router .push(this.$store.getters.facilityPageLinks.ClassEditPage(this.class.id)) .then(() => { - this.showSnackbarNotification('learnersEnrolledNoCount', { - count: selectedUsers.length, - }); + if (created?.length) { + if (invalid?.length) { + // Patrial success - some users were unable to be enrolled + // For example, if a user has been assigned as a coach to the class + // after this page loaded + this.createSnackbar(this.someLearnersEnrolledNotice$()); + } else { + this.showSnackbarNotification('learnersEnrolledNoCount', { + count: created.length, + }); + } + } else { + // Whether we have invalid or not, if we haven't created any memberships + // we should say "no learners enrolled" + this.createSnackbar(this.noLearnersEnrolled$()); + } }); }) .catch(() => { diff --git a/kolibri/plugins/facility/assets/src/views/common/ClassCopyModal.vue b/kolibri/plugins/facility/assets/src/views/common/ClassCopyModal.vue index f4fb02785ac..0d02982bb18 100644 --- a/kolibri/plugins/facility/assets/src/views/common/ClassCopyModal.vue +++ b/kolibri/plugins/facility/assets/src/views/common/ClassCopyModal.vue @@ -131,7 +131,7 @@ }); } - function assignLearnersToClass() { + function enrollLearnersToClass() { if (!copyAllLearners.value || !classLearnerIds.value.length) return Promise.resolve(); return MembershipResource.saveCollection({ data: classLearnerIds.value.map(learnerId => ({ @@ -145,7 +145,7 @@ loading.value = true; try { await createClass(); - await Promise.all([assignCoachesToClass(), assignLearnersToClass()]); + await Promise.all([assignCoachesToClass(), enrollLearnersToClass()]); // Update createdClass obj with copied data if necessary if (copyAllCoaches.value) { diff --git a/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue b/kolibri/plugins/facility/assets/src/views/users/NewUsersPage.vue index a9ec1d72e8d..fc2f4d1a2c0 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 +222,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 { ClassesActions, PageNames } from '../../constants'; import { overrideRoute } from '../../utils'; import UsersTable from './common/UsersTable.vue'; import UsersTableToolbar from './common/UsersTableToolbar/index.vue'; @@ -240,6 +250,7 @@ MoveToTrashModal, FilterTextbox, PaginationActions, + UiAlert, }, mixins: [commonCoreStrings], setup() { @@ -312,6 +323,8 @@ filterLabel$, numUsersSelected$, clearFiltersLabel$, + someFailedToAssign$, + someFailedToEnroll$, } = bulkUserManagementStrings; function clearSelectedUsers() { @@ -358,8 +371,28 @@ fetchClasses(); }); + const failedAction = ref(null); + + const showingInvalidUsers = computed(() => { + return Boolean(route.query.by_ids) && Boolean(failedAction.value); + }); + + const warningMessage = computed(() => { + switch (failedAction.value) { + case ClassesActions.ASSIGN_COACH: + return someFailedToAssign$(); + case ClassesActions.ENROLL_LEARNER: + return someFailedToEnroll$(); + default: + return ''; + } + }); + return { + warningMessage, + showingInvalidUsers, usersTableStyles, + failedAction, // Route utilities overrideRoute, PageNames, 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 2442236cc0d..8fdf854d819 100644 --- a/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue +++ b/kolibri/plugins/facility/assets/src/views/users/UsersRootPage/index.vue @@ -156,6 +156,15 @@ :numFilteredItems="usersCount" /> + @@ -201,6 +211,7 @@ import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import useFacilities from 'kolibri-common/composables/useFacilities'; + import UiAlert from 'kolibri-design-system/lib/keen/UiAlert'; import { bulkUserManagementStrings } from 'kolibri-common/strings/bulkUserManagementStrings'; import useUser from 'kolibri/composables/useUser'; import { UserKinds } from 'kolibri/constants'; @@ -208,7 +219,7 @@ import UsersTableToolbar from '../common/UsersTableToolbar/index.vue'; import useUserManagement from '../../../composables/useUserManagement'; import FacilityAppBarPage from '../../FacilityAppBarPage'; - import { PageNames } from '../../../constants'; + import { ClassesActions, PageNames } from '../../../constants'; import UsersTable from '../common/UsersTable.vue'; import { overrideRoute } from '../../../utils'; import MoveToTrashModal from '../common/MoveToTrashModal.vue'; @@ -228,6 +239,7 @@ MoveToTrashModal, FacilityAppBarPage, FilterTextbox, + UiAlert, PaginationActions, }, mixins: [commonCoreStrings], @@ -252,6 +264,8 @@ filterLabel$, numUsersSelected$, clearFiltersLabel$, + someFailedToAssign$, + someFailedToEnroll$, } = bulkUserManagementStrings; const { $store, $router } = getCurrentInstance().proxy; @@ -344,9 +358,29 @@ return {}; }); + const failedAction = ref(null); + + const showingInvalidUsers = computed(() => { + return Boolean(route.query.by_ids) && Boolean(failedAction.value); + }); + + const warningMessage = computed(() => { + switch (failedAction.value) { + case ClassesActions.ASSIGN_COACH: + return someFailedToAssign$(); + case ClassesActions.ENROLL_LEARNER: + return someFailedToEnroll$(); + default: + return ''; + } + }); + return { + warningMessage, + showingInvalidUsers, windowIsSmall, usersTableStyles, + failedAction, // Route utilities overrideRoute, PageNames, 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 3a54fcbcb59..fad99e13ce6 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 @@