Skip to content

Commit

Permalink
Create task for initial device provisioning
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexVelezLl committed Feb 21, 2025
1 parent c78ad24 commit 4216e5b
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 28 deletions.
225 changes: 225 additions & 0 deletions kolibri/core/device/tasks.py
Original file line number Diff line number Diff line change
@@ -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,
}
1 change: 1 addition & 0 deletions kolibri/core/tasks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions kolibri/core/tasks/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<KButton
:primary="true"
:text="coreString('retryAction')"
@click="provisionDevice"
@click="startProvisionDeviceTask"
/>
</template>
</AppError>
Expand All @@ -36,20 +36,22 @@

<script>
import { mapActions } from 'vuex';
import omitBy from 'lodash/omitBy';
import get from 'lodash/get';
import AppError from 'kolibri/components/error/AppError';
import { currentLanguage } from 'kolibri/utils/i18n';
import { checkCapability } from 'kolibri/utils/appCapabilities';
import redirectBrowser from 'kolibri/utils/redirectBrowser';
import { TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
import TaskResource from 'kolibri/apiResources/TaskResource';
import KolibriLoadingSnippet from 'kolibri-common/components/KolibriLoadingSnippet';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import { Presets } from 'kolibri/constants';
import urls from 'kolibri/urls';
import client from 'kolibri/client';
import Lockr from 'lockr';
import { DeviceTypePresets } from '../../constants';
const PROVISION_TASK_QUEUE = 'device_provision';
export default {
name: 'SettingUpKolibri',
components: { AppError, KolibriLoadingSnippet },
Expand Down Expand Up @@ -178,9 +180,10 @@
},
},
created() {
this.provisionDevice();
this.startProvisionDeviceTask();
},
methods: {
...mapActions(['kolibriLogin']),
startOver() {
this.$store.dispatch('clearError');
this.wizardService.send('START_OVER');
Expand All @@ -189,28 +192,55 @@
wizardContext(key) {
return this.wizardService.state.context[key];
},
provisionDevice() {
this.$store.dispatch('clearError');
client({
url: urls['kolibri:core:deviceprovision'](),
method: 'POST',
data: this.deviceProvisioningData,
})
.then(() => {
const welcomeDismissalKey = 'DEVICE_WELCOME_MODAL_DISMISSED';
const facilityImported = 'FACILITY_IS_IMPORTED';
window.sessionStorage.setItem(welcomeDismissalKey, false);
window.sessionStorage.setItem(
facilityImported,
this.wizardContext('isImportedFacility'),
);
Lockr.rm('savedState'); // Clear out saved state machine
redirectBrowser();
})
.catch(e => {
this.$store.dispatch('handleApiError', { error: e });
async startProvisionDeviceTask() {
try {
await TaskResource.startTask({
type: TaskTypes.PROVISIONDEVICE,
...this.deviceProvisioningData,
});
this.pollProvisionTask();
} catch (e) {
this.$store.dispatch('handleApiError', { error: e });
}
},
async pollProvisionTask() {
try {
const tasks = await TaskResource.list({ queue: PROVISION_TASK_QUEUE });
const [task] = tasks || [];
if (!task) {
throw new Error('Device provisioning task not found');
}
if (task.status === TaskStatuses.COMPLETED) {
const facilityId = task.result.facility_id;
const { username, password } = this.deviceProvisioningData.superuser;
this.clearPollingTasks();
this.wrapOnboarding();
return this.kolibriLogin({
facilityId,
username,
password,
});
} else if (task.status === TaskStatuses.FAILED) {
this.$store.dispatch('handleApiError', { error: task.error });
} else {
setTimeout(() => {
this.pollProvisionTask();
}, 1000);
}
} catch (e) {
this.$store.dispatch('handleApiError', { error: e });
}
},
wrapOnboarding() {
const welcomeDismissalKey = 'DEVICE_WELCOME_MODAL_DISMISSED';
const facilityImported = 'FACILITY_IS_IMPORTED';
window.localStorage.setItem(welcomeDismissalKey, false);
window.localStorage.setItem(facilityImported, this.wizardContext('isImportedFacility'));
Lockr.rm('savedState'); // Clear out saved state machine
},
clearPollingTasks() {
TaskResource.clearAll(PROVISION_TASK_QUEUE);
},
},
$trs: {
Expand Down
1 change: 1 addition & 0 deletions packages/kolibri-common/utils/syncTaskUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const TaskTypes = {
IMPORTUSERSFROMCSV: 'kolibri.core.auth.tasks.importusersfromcsv',
EXPORTUSERSTOCSV: 'kolibri.core.auth.tasks.exportuserstocsv',
IMPORTLODUSER: 'kolibri.core.auth.tasks.peeruserimport',
PROVISIONDEVICE: 'kolibri.core.device.tasks.provisiondevice',
};

// identical to facility constants.js
Expand Down

0 comments on commit 4216e5b

Please sign in to comment.