-
Notifications
You must be signed in to change notification settings - Fork 750
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -100,6 +100,7 @@ def _job_to_response(self, job): | |
"args": job.args, | ||
"kwargs": job.kwargs, | ||
"extra_metadata": job.extra_metadata, | ||
"result": job.result, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,7 +13,7 @@ | |
<KButton | ||
:primary="true" | ||
:text="coreString('retryAction')" | ||
@click="provisionDevice" | ||
@click="startProvisionDeviceTask" | ||
/> | ||
</template> | ||
</AppError> | ||
|
@@ -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 }, | ||
|
@@ -178,9 +180,10 @@ | |
}, | ||
}, | ||
created() { | ||
this.provisionDevice(); | ||
this.startProvisionDeviceTask(); | ||
}, | ||
methods: { | ||
...mapActions(['kolibriLogin']), | ||
startOver() { | ||
this.$store.dispatch('clearError'); | ||
this.wizardService.send('START_OVER'); | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add this to the |
||
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: { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.