diff --git a/kolibri/core/auth/management/commands/deprovision.py b/kolibri/core/auth/management/commands/deprovision.py
index e25d68e43c1..66cdda298ff 100644
--- a/kolibri/core/auth/management/commands/deprovision.py
+++ b/kolibri/core/auth/management/commands/deprovision.py
@@ -11,7 +11,8 @@
from kolibri.core.auth.management.utils import confirm_or_exit
from kolibri.core.auth.models import FacilityDataset
from kolibri.core.auth.models import FacilityUser
-from kolibri.core.auth.utils.delete import DisablePostDeleteSignal
+from kolibri.core.auth.utils.deprovision import deprovision
+from kolibri.core.auth.utils.deprovision import get_deprovision_progress_total
from kolibri.core.device.models import DevicePermissions
from kolibri.core.device.models import DeviceSettings
from kolibri.core.logger.models import AttemptLog
@@ -57,12 +58,10 @@ def add_arguments(self, parser):
)
def deprovision(self):
- with DisablePostDeleteSignal(), self.start_progress(
- total=len(MODELS_TO_DELETE)
+ with self.start_progress(
+ total=get_deprovision_progress_total()
) as progress_update:
- for Model in MODELS_TO_DELETE:
- Model.objects.all().delete()
- progress_update(1)
+ deprovision(progress_update=progress_update)
def handle_async(self, *args, **options):
diff --git a/kolibri/core/auth/management/utils.py b/kolibri/core/auth/management/utils.py
index a969626c93c..a04184464d0 100644
--- a/kolibri/core/auth/management/utils.py
+++ b/kolibri/core/auth/management/utils.py
@@ -449,10 +449,12 @@ def _sync(self, sync_session_client, **options): # noqa: C901
if not no_provision:
with self._lock():
- if user_id:
- provision_single_user_device(
- FacilityUser.objects.get(id=user_id)
- )
+ try:
+ user = FacilityUser.all_objects.get(id=user_id)
+ except FacilityUser.DoesNotExist:
+ user = None
+ if user:
+ provision_single_user_device(user)
else:
create_superuser_and_provision_device(
username, dataset_id, noninteractive=noninteractive
diff --git a/kolibri/core/auth/utils/deprovision.py b/kolibri/core/auth/utils/deprovision.py
new file mode 100644
index 00000000000..90dab0162f6
--- /dev/null
+++ b/kolibri/core/auth/utils/deprovision.py
@@ -0,0 +1,47 @@
+from morango.models import Certificate
+from morango.models import DatabaseIDModel
+from morango.models import DatabaseMaxCounter
+from morango.models import DeletedModels
+from morango.models import HardDeletedModels
+from morango.models import Store
+
+from kolibri.core.auth.models import FacilityDataset
+from kolibri.core.auth.models import FacilityUser
+from kolibri.core.auth.utils.delete import DisablePostDeleteSignal
+from kolibri.core.device.models import DevicePermissions
+from kolibri.core.device.models import DeviceSettings
+from kolibri.core.logger.models import AttemptLog
+from kolibri.core.logger.models import ContentSessionLog
+from kolibri.core.logger.models import ContentSummaryLog
+from kolibri.core.tasks.main import job_storage
+
+MODELS_TO_DELETE = [
+ AttemptLog,
+ ContentSessionLog,
+ ContentSummaryLog,
+ FacilityUser,
+ FacilityDataset,
+ HardDeletedModels,
+ Certificate,
+ DatabaseIDModel,
+ Store,
+ DevicePermissions,
+ DeletedModels,
+ DeviceSettings,
+ DatabaseMaxCounter,
+]
+
+
+def deprovision(progress_update=None):
+ with DisablePostDeleteSignal():
+ for Model in MODELS_TO_DELETE:
+ Model.objects.all().delete()
+ if progress_update:
+ progress_update(1)
+
+ # Clear all completed, failed or cancelled jobs
+ job_storage.clear()
+
+
+def get_deprovision_progress_total():
+ return len(MODELS_TO_DELETE)
diff --git a/kolibri/core/device/soud.py b/kolibri/core/device/soud.py
index 4f2d10fece6..f23ebf8de1d 100644
--- a/kolibri/core/device/soud.py
+++ b/kolibri/core/device/soud.py
@@ -18,6 +18,7 @@
from kolibri.core.auth.constants.morango_sync import PROFILE_FACILITY_DATA
from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
+from kolibri.core.auth.models import Facility
from kolibri.core.auth.models import FacilityDataset
from kolibri.core.auth.models import FacilityUser
from kolibri.core.device.models import SyncQueue
@@ -49,7 +50,10 @@ def __init__(self, user_id, instance_id):
@cached_property
def user(self):
- return FacilityUser.objects.get(id=self.user_id)
+ try:
+ return FacilityUser.all_objects.get(id=self.user_id)
+ except FacilityUser.DoesNotExist:
+ return None
@property
def has_sync_queue(self):
@@ -382,13 +386,18 @@ def execute_sync(context):
baseurl=context.network_location.base_url,
keep_alive=True,
noninteractive=True,
+ no_provision=True,
)
if sync_session_id:
command = "resumesync"
kwargs["id"] = sync_session_id
else:
- kwargs["facility"] = context.user.facility_id
+ kwargs["facility"] = (
+ context.user.facility_id
+ if context.user
+ else Facility.get_default_facility().id
+ )
sync_queue.status = SyncQueueStatus.Syncing
sync_queue.save()
diff --git a/kolibri/core/device/tasks.py b/kolibri/core/device/tasks.py
index 4fd33f36ade..5c6e7d6c4e1 100644
--- a/kolibri/core/device/tasks.py
+++ b/kolibri/core/device/tasks.py
@@ -10,6 +10,7 @@
from kolibri.core.auth.models import Facility
from kolibri.core.auth.models import FacilityUser
from kolibri.core.auth.serializers import FacilitySerializer
+from kolibri.core.auth.utils.deprovision import deprovision
from kolibri.core.device.models import DevicePermissions
from kolibri.core.device.models import OSUser
from kolibri.core.device.serializers import DeviceSerializerMixin
@@ -20,6 +21,7 @@
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.permissions import IsDeviceUnusable
from kolibri.core.tasks.utils import get_current_job
from kolibri.core.tasks.validation import JobValidator
from kolibri.core.utils.token_generator import TokenGenerator
@@ -29,6 +31,7 @@
logger = logging.getLogger(__name__)
PROVISION_TASK_QUEUE = "device_provision"
+DEPROVISION_TASK_QUEUE = "device_deprovision"
class DeviceProvisionValidator(DeviceSerializerMixin, JobValidator):
@@ -243,3 +246,15 @@ def provisiondevice(**data): # noqa C901
updates["auth_token"] = TokenGenerator().make_token(superuser.id)
job.update_metadata(**updates)
+
+
+@register_task(
+ permission_classes=[IsDeviceUnusable],
+ cancellable=False,
+ queue=DEPROVISION_TASK_QUEUE,
+)
+def deprovisiondevice():
+ """
+ Task for deprovisioning a device.
+ """
+ deprovision()
diff --git a/kolibri/core/device/utils.py b/kolibri/core/device/utils.py
index 7209bd878e2..b22813f86a1 100644
--- a/kolibri/core/device/utils.py
+++ b/kolibri/core/device/utils.py
@@ -29,6 +29,9 @@
APP_KEY_COOKIE_NAME = "app_key_cookie"
APP_AUTH_TOKEN_COOKIE_NAME = "app_auth_token_cookie"
+DEVICE_UNUSABLE_NO_SUPERUSERS = "NO_SUPERUSERS"
+DEVICE_UNUSABLE_SUPERUSERS_SOFT_DELETED = "SUPERUSERS_SOFT_DELETED"
+
class DeviceNotProvisioned(Exception):
pass
@@ -494,3 +497,22 @@ def is_full_facility_import(dataset_id):
.filter(scope_definition_id=ScopeDefinitions.FULL_FACILITY)
.exists()
)
+
+
+def get_device_unusable_reason():
+ from kolibri.core.auth.models import FacilityUser
+
+ is_soud = get_device_setting("subset_of_users_device")
+ if not is_soud:
+ return None
+
+ superadmins = FacilityUser.all_objects.filter(devicepermissions__is_superuser=True)
+ if not superadmins.exists():
+ return DEVICE_UNUSABLE_NO_SUPERUSERS
+
+ non_soft_deleted_superadmins = FacilityUser.objects.filter(
+ devicepermissions__is_superuser=True
+ )
+ if not non_soft_deleted_superadmins.exists():
+ return DEVICE_UNUSABLE_SUPERUSERS_SOFT_DELETED
+ return None
diff --git a/kolibri/core/tasks/permissions.py b/kolibri/core/tasks/permissions.py
index 110c3469791..38ae4e06b49 100644
--- a/kolibri/core/tasks/permissions.py
+++ b/kolibri/core/tasks/permissions.py
@@ -197,3 +197,13 @@ def user_can_run_job(self, user, job):
def user_can_read_job(self, user, job):
return True
+
+
+class IsDeviceUnusable(BasePermission):
+ def user_can_run_job(self, user, job):
+ from kolibri.core.device.utils import get_device_unusable_reason
+
+ return get_device_unusable_reason() is not None
+
+ def user_can_read_job(self, user, job):
+ return True
diff --git a/kolibri/core/views.py b/kolibri/core/views.py
index b810a27ed34..273c68eab49 100644
--- a/kolibri/core/views.py
+++ b/kolibri/core/views.py
@@ -128,23 +128,13 @@ def get(self, request):
return RootURLRedirectView.as_view()(request)
-device_is_provisioned = False
-
-
-def is_provisioned():
- # First check if the device has been provisioned
- global device_is_provisioned
- device_is_provisioned = device_is_provisioned or device_provisioned()
- return device_is_provisioned
-
-
class RootURLRedirectView(View):
def get(self, request):
"""
Redirects user based on the highest role they have for which a redirect is defined.
"""
# If it has not been provisioned and we have something that can handle setup, redirect there.
- if not is_provisioned() and SetupHook.provision_url:
+ if not device_provisioned() and SetupHook.provision_url:
return redirect(SetupHook.provision_url())
if request.user.is_authenticated:
diff --git a/kolibri/plugins/user_auth/assets/src/constants.js b/kolibri/plugins/user_auth/assets/src/constants.js
index f47d7372a9a..b8a50d2c6ce 100644
--- a/kolibri/plugins/user_auth/assets/src/constants.js
+++ b/kolibri/plugins/user_auth/assets/src/constants.js
@@ -9,3 +9,8 @@ export const ComponentMap = {
export const pageNameToModuleMap = {
[ComponentMap.SIGN_IN]: 'signIn',
};
+
+export const DeviceUnusableReason = {
+ NO_SUPERUSERS: 'NO_SUPERUSERS',
+ SUPERUSERS_SOFT_DELETED: 'SUPERUSERS_SOFT_DELETED',
+};
diff --git a/kolibri/plugins/user_auth/assets/src/views/AuthBase.vue b/kolibri/plugins/user_auth/assets/src/views/AuthBase.vue
index 3d9900b0ac8..f9c0ba8b068 100644
--- a/kolibri/plugins/user_auth/assets/src/views/AuthBase.vue
+++ b/kolibri/plugins/user_auth/assets/src/views/AuthBase.vue
@@ -6,7 +6,7 @@
@@ -24,10 +24,16 @@
>
{{ logoText }}
-
- {{ $tr('restrictedAccess') }}
-
-
{{ $tr('restrictedAccessDescription') }}
+
+
+ {{ $tr('restrictedAccess') }}
+
+ {{ $tr('restrictedAccessDescription') }}
+
+
+
+
+
+ {{ strings.noSuperusersNotice$() }}
+ {{ strings.noSuperuserCallToAction$() }}
+
+
+
+ {{ strings.superusersSoftDeletedNotice$() }}
+
+
+ {{ strings.superusersSoftDeletedCallToAction$() }}
+
+
+
{{ strings.unknownIssueNotice$() }}
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/user_auth/kolibri_plugin.py b/kolibri/plugins/user_auth/kolibri_plugin.py
index 72803616c5b..bcf909e2377 100644
--- a/kolibri/plugins/user_auth/kolibri_plugin.py
+++ b/kolibri/plugins/user_auth/kolibri_plugin.py
@@ -1,5 +1,6 @@
from kolibri.core.auth.constants.user_kinds import ANONYMOUS
from kolibri.core.device.utils import get_device_setting
+from kolibri.core.device.utils import get_device_unusable_reason
from kolibri.core.device.utils import is_landing_page
from kolibri.core.device.utils import LANDING_PAGE_LEARN
from kolibri.core.hooks import NavigationHook
@@ -28,6 +29,7 @@ def plugin_data(self):
return {
"oidcProviderEnabled": OIDCProviderHook.is_enabled(),
"allowGuestAccess": get_device_setting("allow_guest_access"),
+ "deviceUnusableReason": get_device_unusable_reason(),
}
diff --git a/packages/kolibri-common/utils/syncTaskUtils.js b/packages/kolibri-common/utils/syncTaskUtils.js
index 08abf409513..cf07b57327b 100644
--- a/packages/kolibri-common/utils/syncTaskUtils.js
+++ b/packages/kolibri-common/utils/syncTaskUtils.js
@@ -25,6 +25,7 @@ export const TaskTypes = {
EXPORTUSERSTOCSV: 'kolibri.core.auth.tasks.exportuserstocsv',
IMPORTLODUSER: 'kolibri.core.auth.tasks.peeruserimport',
PROVISIONDEVICE: 'kolibri.core.device.tasks.provisiondevice',
+ DEPROVISIONDEVICE: 'kolibri.core.device.tasks.deprovisiondevice',
};
// identical to facility constants.js