diff --git a/kolibri/core/assets/src/constants.js b/kolibri/core/assets/src/constants.js index f6796739d4e..589c67a7f80 100644 --- a/kolibri/core/assets/src/constants.js +++ b/kolibri/core/assets/src/constants.js @@ -186,3 +186,10 @@ export const ApplicationTypes = { KOLIBRI: 'kolibri', STUDIO: 'studio', }; + +// aliasing 'informal' to 'personal' since it's how we talk about it +export const Presets = Object.freeze({ + PERSONAL: 'informal', + FORMAL: 'formal', + NONFORMAL: 'nonformal', +}); diff --git a/kolibri/core/assets/src/mixins/notificationStrings.js b/kolibri/core/assets/src/mixins/notificationStrings.js index aa8c6420f3e..b6106798f15 100644 --- a/kolibri/core/assets/src/mixins/notificationStrings.js +++ b/kolibri/core/assets/src/mixins/notificationStrings.js @@ -139,6 +139,10 @@ export default createTranslator('NotificationStrings', { message: 'Device not removed', context: 'Snackbar message when a device fails to be removed from he sync schedule', }, + newLearningFacilityCreated: { + message: 'New learning facility created', + context: 'Snackbar message when a new facility created', + }, // TODO move more messages into this namespace: // - "Quiz started" // - "Quiz Ended" diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index d1dd722c1ee..5a3cb865582 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -60,6 +60,7 @@ from .models import Membership from .models import Role from .serializers import ClassroomSerializer +from .serializers import CreateFacilitySerializer from .serializers import ExtraFieldsSerializer from .serializers import FacilityDatasetSerializer from .serializers import FacilitySerializer @@ -74,6 +75,7 @@ from kolibri.core.auth.constants.demographics import NOT_SPECIFIED from kolibri.core.auth.permissions.general import _user_is_admin_for_own_facility from kolibri.core.auth.permissions.general import DenyAll +from kolibri.core.device.permissions import IsSuperuser from kolibri.core.device.utils import allow_guest_access from kolibri.core.device.utils import allow_other_browsers_to_connect from kolibri.core.device.utils import valid_app_key_on_request @@ -621,6 +623,13 @@ def annotate_queryset(self, queryset): ) ) + @decorators.action(methods=["post"], detail=False, permission_classes=[IsSuperuser]) + def create_facility(self, request): + serializer = CreateFacilitySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response() + class PublicFacilityViewSet(viewsets.ReadOnlyModelViewSet): queryset = Facility.objects.all() diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 4c9f8fd72bc..d9f57df98fd 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -2,10 +2,15 @@ from __future__ import print_function from __future__ import unicode_literals +import logging + from django.core.validators import MinLengthValidator +from django.db import transaction from rest_framework import serializers +from rest_framework.exceptions import ParseError from rest_framework.validators import UniqueTogetherValidator +from .constants import facility_presets from .errors import IncompatibleDeviceSettingError from .errors import InvalidCollectionHierarchy from .errors import InvalidMembershipError @@ -20,6 +25,9 @@ from kolibri.core.auth.constants.demographics import NOT_SPECIFIED +logger = logging.getLogger(__name__) + + class RoleSerializer(serializers.ModelSerializer): class Meta: model = Role @@ -137,6 +145,27 @@ class Meta: fields = ("id", "name") +class CreateFacilitySerializer(serializers.ModelSerializer): + preset = serializers.ChoiceField(choices=facility_presets.choices) + + class Meta: + model = Facility + fields = ("id", "name", "preset") + + def create(self, validated_data): + preset = validated_data.get("preset") + name = validated_data.get("name") + with transaction.atomic(): + try: + facility_dataset = FacilityDataset.objects.create(preset=preset) + facility = Facility.objects.create(name=name, dataset=facility_dataset) + facility.dataset.reset_to_default_settings(preset) + except Exception as e: + logger.error("Error occured while creating facility: %s", str(e)) + raise ParseError("Error occured while creating facility") + return facility + + class PublicFacilitySerializer(serializers.ModelSerializer): learner_can_login_with_no_password = serializers.SerializerMethodField() learner_can_sign_up = serializers.SerializerMethodField() diff --git a/kolibri/plugins/device/assets/src/constants.js b/kolibri/plugins/device/assets/src/constants.js index fd49e1efe3c..bd9057991a1 100644 --- a/kolibri/plugins/device/assets/src/constants.js +++ b/kolibri/plugins/device/assets/src/constants.js @@ -71,3 +71,7 @@ export const MeteredConnectionDownloadOptions = { DISALLOW_DOWNLOAD_ON_METERED_CONNECTION: 'DISALLOW_DOWNLOAD_ON_METERED_CONNECTION', ALLOW_DOWNLOAD_ON_METERED_CONNECTION: 'ALLOW_DOWNLOAD_ON_METERED_CONNECTION', }; + +export const ImportFacility = 'import_facility'; + +export const CreateNewFacility = 'create_new_facility'; diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/CreateNewFacilityModal.vue b/kolibri/plugins/device/assets/src/views/FacilitiesPage/CreateNewFacilityModal.vue new file mode 100644 index 00000000000..ad6a8a394c2 --- /dev/null +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/CreateNewFacilityModal.vue @@ -0,0 +1,122 @@ + + + + {{ coreString('learningFacilityDescription') }} + + {{ $tr('learningEnvironmentHeader') }} + + + + + + + + + + + diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js b/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js new file mode 100644 index 00000000000..d8133684cad --- /dev/null +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js @@ -0,0 +1,12 @@ +import client from 'kolibri.client'; +import urls from 'kolibri.urls'; + +const url = urls['kolibri:core:facility-create-facility'](); + +export function createFacility(payload) { + return client({ + url, + method: 'POST', + data: payload, + }); +} diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue index 6e6b94407fe..d518550175d 100644 --- a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue @@ -14,11 +14,18 @@ @click="showSyncAllModal = true" /> + > + + + + @@ -144,6 +151,11 @@ @success="handleStartImportSuccess" @cancel="showImportModal = false" /> + @@ -195,13 +207,14 @@ import { TaskStatuses, TaskTypes } from 'kolibri.utils.syncTaskUtils'; import some from 'lodash/some'; import DeviceAppBarPage from '../DeviceAppBarPage'; - import { PageNames } from '../../constants'; + import { PageNames, ImportFacility, CreateNewFacility } from '../../constants'; import { deviceString } from '../commonDeviceStrings'; import TasksBar from '../ManageContentPage/TasksBar'; import HeaderWithOptions from '../HeaderWithOptions'; import RemoveFacilityModal from './RemoveFacilityModal'; import SyncAllFacilitiesModal from './SyncAllFacilitiesModal'; import ImportFacilityModalGroup from './ImportFacilityModalGroup'; + import CreateNewFacilityModal from './CreateNewFacilityModal'; import facilityTaskQueue from './facilityTasksQueue'; const Options = Object.freeze({ @@ -221,6 +234,7 @@ DeviceAppBarPage, ConfirmationRegisterModal, CoreTable, + CreateNewFacilityModal, HeaderWithOptions, FacilityNameAndSyncStatus, ImportFacilityModalGroup, @@ -235,6 +249,7 @@ return { showSyncAllModal: false, showImportModal: false, + showCreateFacilityModal: false, facilities: [], facilityForSync: null, facilityForRemoval: null, @@ -245,6 +260,18 @@ }; }, computed: { + options() { + return [ + { + label: this.$tr('importFacilityLabel'), + value: ImportFacility, + }, + { + label: this.$tr('createNewFacilityLabel'), + value: CreateNewFacility, + }, + ]; + }, pageTitle() { return deviceString('deviceManagementTitle'); }, @@ -344,6 +371,10 @@ }); this.showImportModal = false; }, + handleCreateFacilitySuccess() { + this.showCreateFacilityModal = false; + this.fetchFacilites(); + }, manageSync(facilityId) { return { name: PageNames.MANAGE_SYNC_SCHEDULE, @@ -389,6 +420,13 @@ this.$emit('failure'); }); }, + handleSelect(option) { + if (option.value == ImportFacility) { + this.showImportModal = true; + } else { + this.showCreateFacilityModal = true; + } + }, }, $trs: { syncAllAction: { @@ -401,6 +439,18 @@ context: "Notification that appears after a facility has been deleted. For example, \"Removed 'Zuk Village' from this device'.", }, + createFacilityLabel: { + message: 'ADD FACILITY', + context: 'Label for a button used to create new facility.', + }, + importFacilityLabel: { + message: 'Import facility', + context: 'Label for the dropdown option of import facility', + }, + createNewFacilityLabel: { + message: 'Create new facility', + context: 'Label for the dropdown option of create new facility', + }, }, }; diff --git a/kolibri/plugins/setup_wizard/assets/src/constants.js b/kolibri/plugins/setup_wizard/assets/src/constants.js index ef2b6b1ecfd..11519fc52c6 100644 --- a/kolibri/plugins/setup_wizard/assets/src/constants.js +++ b/kolibri/plugins/setup_wizard/assets/src/constants.js @@ -1,12 +1,5 @@ import permissionPresets from '../../../../core/auth/constants/facility_configuration_presets.json'; -// aliasing 'informal' to 'personal' since it's how we talk about it -const Presets = Object.freeze({ - PERSONAL: 'informal', - FORMAL: 'formal', - NONFORMAL: 'nonformal', -}); - /** * enum identifying whether the user has gone to the on my own flow or not */ @@ -43,7 +36,6 @@ export { permissionPresets, DeviceTypePresets, FacilityTypePresets, - Presets, UsePresets, SoudQueue, LodTypePresets, diff --git a/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/CreateLearnerAccountForm.vue b/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/CreateLearnerAccountForm.vue index 811389d62da..1d0dbf7cdb6 100644 --- a/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/CreateLearnerAccountForm.vue +++ b/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/CreateLearnerAccountForm.vue @@ -35,8 +35,9 @@
{{ coreString('learningFacilityDescription') }}