diff --git a/kolibri/core/auth/models.py b/kolibri/core/auth/models.py
index 5b9a6d485a..3b971d82ba 100644
--- a/kolibri/core/auth/models.py
+++ b/kolibri/core/auth/models.py
@@ -64,6 +64,7 @@
from .permissions.general import IsFromSameFacility
from .permissions.general import IsOwn
from .permissions.general import IsSelf
+from .permissions.general import AllowCoach
from kolibri.core.auth.constants.morango_scope_definitions import FULL_FACILITY
from kolibri.core.auth.constants.morango_scope_definitions import SINGLE_USER
from kolibri.core.errors import KolibriValidationError
@@ -522,6 +523,7 @@ class FacilityUser(KolibriAbstractBaseUser, AbstractFacilityDataModel):
permissions = (
IsSelf() | # FacilityUser can be read and written by itself
IsAdminForOwnFacility() | # FacilityUser can be read and written by a facility admin
+ AllowCoach() |
RoleBasedPermissions( # FacilityUser can be read by admin or coach, and updated by admin, but not created/deleted by non-facility admin
target_field=".",
can_be_created_by=(), # we can't check creation permissions by role, as user doesn't exist yet
@@ -903,6 +905,7 @@ class Membership(AbstractFacilityDataModel):
morango_model_name = "membership"
permissions = (
+ AllowCoach() |
IsOwn(read_only=True) | # users can read their own Memberships
RoleBasedPermissions( # Memberships can be read and written by admins, and read by coaches, for the member user
target_field="user",
diff --git a/kolibri/core/auth/permissions/general.py b/kolibri/core/auth/permissions/general.py
index 356e0aa928..32c210167d 100644
--- a/kolibri/core/auth/permissions/general.py
+++ b/kolibri/core/auth/permissions/general.py
@@ -180,3 +180,52 @@ def readable_by_user_filter(self, user, queryset):
return queryset.filter(dataset=user.dataset)
else:
return queryset.none()
+
+
+class AllowCoach(BasePermissions):
+
+ def __init__(self, field_name=".", read_only=False):
+ self.read_only = read_only
+
+ def _user_is_coach(self, user, obj=None):
+
+ # Check if the current user is a coach
+
+ from ..models import Classroom
+
+ if obj:
+ if not hasattr(obj, "dataset") or not user.dataset == obj.dataset:
+ return False
+
+ classrooms = Classroom.objects.filter(dataset=user.dataset)
+
+ is_coach = 0
+
+ for classroom in classrooms:
+ if user.has_role_for_collection(role_kinds.COACH, classroom):
+ is_coach += 1
+
+ return is_coach > 0
+
+
+
+
+
+ def user_can_create_object(self, user, obj):
+ return (not self.read_only) and self._user_is_coach(user, obj)
+
+ def user_can_read_object(self, user, obj):
+ return self._user_is_coach(user, obj)
+
+ def user_can_update_object(self, user, obj):
+ return (not self.read_only) and self._user_is_coach(user, obj)
+
+ def user_can_delete_object(self, user, obj):
+ return (not self.read_only) and self._user_is_coach(user, obj)
+
+ def readable_by_user_filter(self, user, queryset):
+ if self._user_is_coach(user):
+ return queryset.filter(dataset=user.dataset)
+ else:
+ return queryset.none()
+
diff --git a/kolibri/plugins/coach/assets/src/modules/pluginModule.js b/kolibri/plugins/coach/assets/src/modules/pluginModule.js
index 855c0f6bb1..61aa231f60 100644
--- a/kolibri/plugins/coach/assets/src/modules/pluginModule.js
+++ b/kolibri/plugins/coach/assets/src/modules/pluginModule.js
@@ -1,4 +1,5 @@
import userManagement from '../../../../facility_management/assets/src/modules/userManagement';
+import classAssignMembers from '../../../../facility_management/assets/src/modules/classAssignMembers';
import getters from './coreCoach/getters';
import * as actions from './coreCoach/actions';
import examCreation from './examCreation';
@@ -56,6 +57,7 @@ export default {
lessonSummary,
lessonsRoot,
userManagement,
+ classAssignMembers,
reports,
},
};
diff --git a/kolibri/plugins/coach/assets/src/views/ClassListPage.vue b/kolibri/plugins/coach/assets/src/views/ClassListPage.vue
index 1b2f36ab4c..0af42e5409 100644
--- a/kolibri/plugins/coach/assets/src/views/ClassListPage.vue
+++ b/kolibri/plugins/coach/assets/src/views/ClassListPage.vue
@@ -43,7 +43,7 @@
|
@@ -84,10 +84,10 @@
import { PageNames } from '../constants';
import { filterAndSortUsers } from '../../../../facility_management/assets/src/userSearchUtils';
- function learnerPageLink(classId) {
+ function learnerPageLink(classId, className) {
return {
name: PageNames.LEARNER_LIST,
- params: { classId },
+ params: { classId, className },
};
}
diff --git a/kolibri/plugins/coach/assets/src/views/reports/CoachUserCreateModal.vue b/kolibri/plugins/coach/assets/src/views/reports/CoachUserCreateModal.vue
index b528136540..333beb0e97 100644
--- a/kolibri/plugins/coach/assets/src/views/reports/CoachUserCreateModal.vue
+++ b/kolibri/plugins/coach/assets/src/views/reports/CoachUserCreateModal.vue
@@ -1,7 +1,7 @@
-
-
@@ -73,16 +58,20 @@
import { mapActions, mapState, mapGetters } from 'vuex';
import { UserKinds, ERROR_CONSTANTS } from 'kolibri.coreVue.vuex.constants';
+ import { FacilityUsernameResource } from 'kolibri.resources';
import { validateUsername } from 'kolibri.utils.validators';
import CatchErrors from 'kolibri.utils.CatchErrors';
- import KRadioButton from 'kolibri.coreVue.components.KRadioButton';
import KModal from 'kolibri.coreVue.components.KModal';
import KTextbox from 'kolibri.coreVue.components.KTextbox';
+ import {
+ filterAndSortUsers,
+ userMatchesFilter,
+ } from '../../../../../device_management/assets/src/userSearchUtils';
export default {
name: 'CoachUserCreateModal',
$trs: {
- createNewUserHeader: 'Create new learner',
+ createNewUserHeader: 'Create new learner in {thisClassName}',
cancel: 'Cancel',
name: 'Full name',
username: 'Username',
@@ -91,13 +80,6 @@
userType: 'User type',
saveUserButtonLabel: 'Save',
learner: 'Learner',
- coach: 'Coach',
- admin: 'Admin',
- coachSelectorHeader: 'Coach type',
- classCoachLabel: 'Class coach',
- classCoachDescription: "Can only instruct classes that they're assigned to",
- facilityCoachLabel: 'Facility coach',
- facilityCoachDescription: 'Can instruct all classes in your facility',
usernameAlreadyExists: 'Username already exists',
usernameNotAlphaNumUnderscore: 'Username can only contain letters, numbers, and underscores',
pwMismatchError: 'Passwords do not match',
@@ -106,12 +88,23 @@
required: 'This field is required',
},
components: {
- KRadioButton,
KModal,
KTextbox,
},
+ props: {
+ classId: {
+ type: String,
+ required: true,
+ },
+ className: {
+ type: String,
+ required: true,
+ },
+ },
data() {
return {
+ thisClassName: this.className,
+ thisClassId: this.classId,
fullName: '',
username: '',
password: '',
@@ -128,24 +121,17 @@
passwordBlurred: false,
confirmedPasswordBlurred: false,
formSubmitted: false,
+ newUser: [],
+ usernames: [],
};
},
computed: {
...mapGetters(['currentFacilityId']),
...mapState('userManagement', ['facilityUsers']),
newUserRole() {
- if (this.coachIsSelected) {
- if (this.classCoach) {
- return UserKinds.ASSIGNABLE_COACH;
- }
- return UserKinds.COACH;
- }
// Admin or Learner
return this.kind.value;
},
- coachIsSelected() {
- return this.kind.value === UserKinds.COACH;
- },
nameIsInvalidText() {
if (this.nameBlurred || this.formSubmitted) {
if (this.fullName === '') {
@@ -158,8 +144,8 @@
return Boolean(this.nameIsInvalidText);
},
usernameAlreadyExists() {
- return this.facilityUsers.find(
- ({ username }) => username.toLowerCase() === this.username.toLowerCase()
+ return this.usernames.find(
+ username => username.toLowerCase() === this.username.toLowerCase()
);
},
usernameIsInvalidText() {
@@ -213,9 +199,13 @@
);
},
},
+ beforeMount() {
+ this.setSuggestions();
+ },
methods: {
...mapActions('userManagement', ['createUser', 'displayModal']),
...mapActions(['handleApiError']),
+ ...mapActions('classAssignMembers', ['enrollLearnersInClass']),
createNewUser() {
this.usernameAlreadyExistsOnServer = false;
this.formSubmitted = true;
@@ -231,7 +221,9 @@
password: this.password,
}).then(
() => {
+ this.enrollLearnersInClass({ classId: this.thisClassId, users: this.getUsers() });
this.close();
+ location.reload();
},
error => {
const usernameAlreadyExistsError = CatchErrors(error, [
@@ -249,6 +241,26 @@
this.focusOnInvalidField();
}
},
+ setSuggestions() {
+ FacilityUsernameResource.fetchCollection({
+ getParams: {
+ facility: this.currentFacilityId,
+ },
+ })
+ .then(users => {
+ this.usernames = users.map(user => user.username);
+ })
+ .catch(() => {
+ this.usernames = [];
+ });
+ },
+ getUsers() {
+ this.newUser.push(
+ filterAndSortUsers(this.facilityUsers, user => userMatchesFilter(user, this.username))[0]
+ .id
+ );
+ return this.newUser;
+ },
focusOnInvalidField() {
if (this.nameIsInvalid) {
this.$refs.name.focus();
diff --git a/kolibri/plugins/coach/assets/src/views/reports/LearnerListPage.vue b/kolibri/plugins/coach/assets/src/views/reports/LearnerListPage.vue
index 2173f2c30f..a14502fbbc 100644
--- a/kolibri/plugins/coach/assets/src/views/reports/LearnerListPage.vue
+++ b/kolibri/plugins/coach/assets/src/views/reports/LearnerListPage.vue
@@ -23,6 +23,7 @@
{{ $tr('noLearners') }}
-
+
@@ -117,6 +122,7 @@
metaInfo() {
return {
title: this.documentTitle,
+ className: this.className,
};
},
components: {
@@ -154,7 +160,7 @@
documentTitleForLearners: 'Learners',
},
computed: {
- ...mapState(['classId', 'pageName', 'reportRefreshInterval']),
+ ...mapState(['classId', 'className', 'pageName', 'reportRefreshInterval']),
...mapGetters('reports', ['standardDataTable', 'exerciseCount', 'contentCount']),
...mapState('reports', ['channelId', 'contentScopeSummary', 'contentScopeId', 'userScope']),
...mapGetters(['currentUserId', 'isSuperuser', 'isCoach']),
@@ -173,6 +179,12 @@
isExercisePage() {
return this.contentScopeSummary.kind === ContentNodeKinds.EXERCISE;
},
+ thisClassName() {
+ return this.className;
+ },
+ thisClassId() {
+ return this.classId;
+ },
isRootLearnerPage() {
return this.pageName === PageNames.LEARNER_LIST;
},
|