Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create task for initial device provisioning #13102

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

job = get_current_job()
if job:
    job.update_metadata(facility_id=facility.id)

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not add this, and just utilize extra_metadata instead.

# 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this to the extra_metadata instead, rather than updating the API endpoint to return result.

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
Loading