From 4216e5b18715bd32db8e61aaaddcc94aa841d4e5 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 21 Feb 2025 15:18:16 -0500 Subject: [PATCH] Create task for initial device provisioning --- kolibri/core/device/tasks.py | 225 ++++++++++++++++++ kolibri/core/tasks/api.py | 1 + kolibri/core/tasks/permissions.py | 10 + .../assets/src/views/PostSetupModalGroup.vue | 2 +- .../assets/src/views/LibraryPage/index.vue | 2 +- .../onboarding-forms/SettingUpKolibri.vue | 82 +++++-- .../kolibri-common/utils/syncTaskUtils.js | 1 + 7 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 kolibri/core/device/tasks.py diff --git a/kolibri/core/device/tasks.py b/kolibri/core/device/tasks.py new file mode 100644 index 00000000000..e8304116d68 --- /dev/null +++ b/kolibri/core/device/tasks.py @@ -0,0 +1,225 @@ +import logging + +from django.db import transaction +from rest_framework import serializers +from rest_framework.exceptions import ParseError + +from kolibri.core.analytics.tasks import schedule_ping +from kolibri.core.auth.constants import user_kinds +from kolibri.core.auth.constants.facility_presets import choices +from kolibri.core.auth.models import Facility +from kolibri.core.auth.models import FacilityUser +from kolibri.core.auth.serializers import FacilitySerializer +from kolibri.core.device.models import DevicePermissions +from kolibri.core.device.models import OSUser +from kolibri.core.device.serializers import DeviceSerializerMixin +from kolibri.core.device.serializers import NoFacilityFacilityUserSerializer +from kolibri.core.device.utils import APP_AUTH_TOKEN_COOKIE_NAME +from kolibri.core.device.utils import provision_device +from kolibri.core.device.utils import provision_single_user_device +from kolibri.core.device.utils import valid_app_key_on_request +from kolibri.core.tasks.decorators import register_task +from kolibri.core.tasks.permissions import FirstProvisioning +from kolibri.core.tasks.validation import JobValidator +from kolibri.plugins.app.utils import GET_OS_USER +from kolibri.plugins.app.utils import interface + +logger = logging.getLogger(__name__) + +PROVISION_TASK_QUEUE = "device_provision" + + +class DeviceProvisionValidator(DeviceSerializerMixin, JobValidator): + facility = FacilitySerializer(required=False, allow_null=True) + facility_id = serializers.CharField(max_length=50, required=False, allow_null=True) + preset = serializers.ChoiceField(choices=choices, required=False, allow_null=True) + superuser = NoFacilityFacilityUserSerializer(required=False) + language_id = serializers.CharField(max_length=15) + device_name = serializers.CharField(max_length=50, allow_null=True) + settings = serializers.JSONField() + allow_guest_access = serializers.BooleanField(allow_null=True) + is_provisioned = serializers.BooleanField(default=True) + is_soud = serializers.BooleanField(default=True) + + def validate(self, data): + if ( + GET_OS_USER in interface + and "request" in self.context + and valid_app_key_on_request(self.context["request"]) + ): + data["auth_token"] = self.context["request"].COOKIES.get( + APP_AUTH_TOKEN_COOKIE_NAME + ) + elif "superuser" not in data: + raise serializers.ValidationError("Superuser is required for provisioning") + + has_facility = "facility" in data + has_facility_id = "facility_id" in data + + if (has_facility and has_facility_id) or ( + not has_facility and not has_facility_id + ): + raise serializers.ValidationError( + "Please provide one of `facility` or `facility_id`; but not both." + ) + + if has_facility and "preset" not in data: + raise serializers.ValidationError( + "Please provide `preset` if `facility` is specified" + ) + + return super(DeviceProvisionValidator, self).validate(data) + + +@register_task( + validator=DeviceProvisionValidator, + permission_classes=[FirstProvisioning], + cancellable=False, + queue=PROVISION_TASK_QUEUE, +) +def provisiondevice(**data): # noqa C901 + """ + Task for initial setup of a device. + Expects a value for: + default language - the default language of this Kolibri device + facility - the required fields for setting up a facility + facilitydataset - facility configuration options + superuser - the required fields for a facilityuser who will be set as the super user for this device + """ + with transaction.atomic(): + if data.get("facility"): + facility_data = data.pop("facility") + facility_id = None + else: + facility_id = data.pop("facility_id") + facility_data = None + + if facility_id: + try: + # We've already imported the facility to the device before provisioning + facility = Facility.objects.get(pk=facility_id) + preset = facility.dataset.preset + facility_created = False + except Facility.DoesNotExist: + raise ParseError( + "Facility with id={0} does not exist".format(facility_id) + ) + else: + try: + facility = Facility.objects.create(**facility_data) + preset = data.pop("preset") + facility.dataset.preset = preset + facility.dataset.reset_to_default_settings(preset) + facility_created = True + except Exception: + raise ParseError("Please check `facility` or `preset` fields.") + + custom_settings = data.pop("settings") + + allow_learner_download_resources = False + + if facility_created: + # We only want to update things about the facility or the facility dataset in the case + # that we are creating the facility during this provisioning process. + # If it has been imported as part of a whole facility import, then we should not be + # making edits just now. + # If it has been imported as part of a learner only device import, then editing + # these things now will a) not be synced back, and b) will actively block future + # syncing of updates to the facility or facility dataset from our 'upstream'. + + if "on_my_own_setup" in custom_settings: + facility.on_my_own_setup = custom_settings.pop("on_my_own_setup") + # If we are in on my own setup, then we want to allow learners to download resources + # to give them a seamless onboarding experience, without the need to use the device + # plugin to download resources en masse. + allow_learner_download_resources = True + + # overwrite the settings in dataset_data with data.settings + for key, value in custom_settings.items(): + if value is not None: + setattr(facility.dataset, key, value) + facility.dataset.save() + + auth_token = data.pop("auth_token", None) + + if "superuser" in data: + superuser_data = data["superuser"] + # We've imported a facility if the username exists + try: + superuser = FacilityUser.objects.get( + username=superuser_data["username"] + ) + except FacilityUser.DoesNotExist: + try: + # Otherwise we make the superuser + superuser = FacilityUser.objects.create_superuser( + superuser_data["username"], + superuser_data["password"], + facility=facility, + full_name=superuser_data.get("full_name"), + ) + except Exception: + raise ParseError( + "`username`, `password`, or `full_name` are missing in `superuser`" + ) + if auth_token: + # If we have an auth token, we need to create an OSUser for the superuser + # so that we can associate the user with the OSUser + os_username, _ = interface.get_os_user(auth_token) + OSUser.objects.update_or_create( + os_username=os_username, defaults={"user": superuser} + ) + + elif auth_token: + superuser = FacilityUser.objects.get_or_create_os_user( + auth_token, facility=facility + ) + else: + raise ParseError( + "Either `superuser` or `auth_token` must be provided for provisioning" + ) + + is_soud = data.pop("is_soud") + + if superuser: + if facility_created: + # Only do this if this is a created, not imported facility. + facility.add_role(superuser, user_kinds.ADMIN) + + if DevicePermissions.objects.count() == 0: + DevicePermissions.objects.create( + user=superuser, + is_superuser=True, + can_manage_content=True, + ) + + # Create device settings + language_id = data.pop("language_id") + allow_guest_access = data.pop("allow_guest_access") + + if allow_guest_access is None: + allow_guest_access = preset != "formal" + + provisioning_data = { + "device_name": data["device_name"], + "is_provisioned": data["is_provisioned"], + "language_id": language_id, + "default_facility": facility, + "allow_guest_access": allow_guest_access, + "allow_learner_download_resources": allow_learner_download_resources, + } + + if is_soud: + provision_single_user_device(superuser, **provisioning_data) + # Restart zeroconf before moving along when we're a SoUD + from kolibri.utils.server import update_zeroconf_broadcast + + update_zeroconf_broadcast() + else: + provision_device(**provisioning_data) + + schedule_ping() # Trigger telemetry pingback after we've provisioned + + return { + "facility_id": facility.id, + } diff --git a/kolibri/core/tasks/api.py b/kolibri/core/tasks/api.py index 9f59464f240..8f8a5512afb 100644 --- a/kolibri/core/tasks/api.py +++ b/kolibri/core/tasks/api.py @@ -100,6 +100,7 @@ def _job_to_response(self, job): "args": job.args, "kwargs": job.kwargs, "extra_metadata": job.extra_metadata, + "result": job.result, # Output is UTC naive, coerce to UTC aware. "scheduled_datetime": make_aware(orm_job.scheduled_time, utc).isoformat(), "repeat": orm_job.repeat, diff --git a/kolibri/core/tasks/permissions.py b/kolibri/core/tasks/permissions.py index b5afe6236dd..110c3469791 100644 --- a/kolibri/core/tasks/permissions.py +++ b/kolibri/core/tasks/permissions.py @@ -187,3 +187,13 @@ def user_can_read_job(self, user, job): from kolibri.core.device.utils import device_provisioned return not device_provisioned() + + +class FirstProvisioning(BasePermission): + def user_can_run_job(self, user, job): + from kolibri.core.device.utils import device_provisioned + + return not device_provisioned() + + def user_can_read_job(self, user, job): + return True diff --git a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue index af520c57650..d081f069a3e 100644 --- a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue +++ b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue @@ -95,7 +95,7 @@ computed: { importedFacility() { const [facility] = this.facilities; - if (facility && window.sessionStorage.getItem(facilityImported) === 'true') { + if (facility && window.localStorage.getItem(facilityImported) === 'true') { return facility; } return null; diff --git a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue index 4d63169620d..f967dd0e5d6 100644 --- a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue +++ b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue @@ -520,7 +520,7 @@ created() { const welcomeDismissalKey = 'DEVICE_WELCOME_MODAL_DISMISSED'; - if (window.sessionStorage.getItem(welcomeDismissalKey) !== 'true') { + if (window.localStorage.getItem(welcomeDismissalKey) !== 'true') { this.$store.commit('SET_WELCOME_MODAL_VISIBLE', true); } diff --git a/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/SettingUpKolibri.vue b/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/SettingUpKolibri.vue index f989c86b3ad..40175a25a42 100644 --- a/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/SettingUpKolibri.vue +++ b/kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/SettingUpKolibri.vue @@ -13,7 +13,7 @@ @@ -36,20 +36,22 @@