diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index faded6f5a87..6c47d379eb6 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -31,6 +31,8 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
+ with:
+ python-version: '3.10'
- name: Use Node.js
uses: actions/setup-node@v1
with:
diff --git a/Makefile b/Makefile
index 7b057134bc3..c02a53d6284 100644
--- a/Makefile
+++ b/Makefile
@@ -156,7 +156,7 @@ test-namespaced-packages:
# This expression checks that everything in kolibri/dist has an __init__.py
# To prevent namespaced packages from suddenly showing up
# https://github.com/learningequality/kolibri/pull/2972
- ! find kolibri/dist -mindepth 1 -maxdepth 1 -type d -not -name __pycache__ -not -name cext -not -name py2only -exec ls {}/__init__.py \; 2>&1 | grep "No such file"
+ ! find kolibri/dist -mindepth 1 -maxdepth 1 -type d -not -name __pycache__ -not -name cext -not -name py2only -not -name *dist-info -exec ls {}/__init__.py \; 2>&1 | grep "No such file"
clean-staticdeps:
rm -rf kolibri/dist/* || true # remove everything
@@ -165,7 +165,6 @@ clean-staticdeps:
staticdeps: clean-staticdeps
test "${SKIP_PY_CHECK}" = "1" || python2 --version 2>&1 | grep -q 2.7 || ( echo "Only intended to run on Python 2.7" && exit 1 )
pip2 install -t kolibri/dist -r "requirements.txt"
- rm -rf kolibri/dist/*.dist-info # pip installs from PyPI will complain if we have more than one dist-info directory.
rm -rf kolibri/dist/*.egg-info
rm -r kolibri/dist/man kolibri/dist/bin || true # remove the two folders introduced by pip 10
python2 build_tools/py2only.py # move `future` and `futures` packages to `kolibri/dist/py2only`
@@ -175,8 +174,6 @@ staticdeps-cext:
rm -rf kolibri/dist/cext || true # remove everything
python build_tools/install_cexts.py --file "requirements/cext.txt" # pip install c extensions
pip install -t kolibri/dist/cext -r "requirements/cext_noarch.txt" --no-deps
- rm -rf kolibri/dist/*.dist-info # pip installs from PyPI will complain if we have more than one dist-info directory.
- rm -rf kolibri/dist/cext/*.dist-info # pip installs from PyPI will complain if we have more than one dist-info directory.
rm -rf kolibri/dist/*.egg-info
make test-namespaced-packages
diff --git a/kolibri/core/device/api.py b/kolibri/core/device/api.py
index 4e32cab1694..025259d5565 100644
--- a/kolibri/core/device/api.py
+++ b/kolibri/core/device/api.py
@@ -51,11 +51,14 @@
from kolibri.core.public.constants.user_sync_statuses import RECENTLY_SYNCED
from kolibri.core.public.constants.user_sync_statuses import SYNCING
from kolibri.core.public.constants.user_sync_statuses import UNABLE_TO_SYNC
+from kolibri.core.utils.drf_utils import swagger_auto_schema_available
from kolibri.core.utils.urls import reverse_remote
from kolibri.plugins.utils import initialize_kolibri_plugin
from kolibri.plugins.utils import iterate_plugins
from kolibri.plugins.utils import PluginDoesNotExist
from kolibri.utils.conf import OPTIONS
+from kolibri.utils.filesystem import check_is_directory
+from kolibri.utils.filesystem import get_path_permission
from kolibri.utils.server import get_status_from_pid_file
from kolibri.utils.server import get_urls
from kolibri.utils.server import installation_type
@@ -398,3 +401,20 @@ def get(self, request):
return Response({})
except Exception as e:
raise ValidationError(detail=str(e))
+
+
+class PathPermissionView(views.APIView):
+
+ permission_classes = (UserHasAnyDevicePermissions,)
+
+ @swagger_auto_schema_available(
+ [("path", "path to check permissions for", "string")]
+ )
+ def get(self, request):
+ pathname = request.query_params.get("path", OPTIONS["Paths"]["CONTENT_DIR"])
+ return Response(
+ {
+ "writable": get_path_permission(pathname),
+ "directory": check_is_directory(pathname),
+ }
+ )
diff --git a/kolibri/core/device/api_urls.py b/kolibri/core/device/api_urls.py
index 75ee8504230..5a2d2c411f0 100644
--- a/kolibri/core/device/api_urls.py
+++ b/kolibri/core/device/api_urls.py
@@ -10,6 +10,7 @@
from .api import DeviceSettingsView
from .api import DriveInfoViewSet
from .api import FreeSpaceView
+from .api import PathPermissionView
from .api import RemoteFacilitiesViewset
from .api import UserSyncStatusViewSet
@@ -36,4 +37,5 @@
url(
r"^remotefacilities", RemoteFacilitiesViewset.as_view(), name="remotefacilities"
),
+ url(r"^pathpermission/", PathPermissionView.as_view(), name="pathpermission"),
]
diff --git a/kolibri/core/device/migrations/0017_extra_settings.py b/kolibri/core/device/migrations/0017_extra_settings.py
new file mode 100644
index 00000000000..802c80aeb46
--- /dev/null
+++ b/kolibri/core/device/migrations/0017_extra_settings.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2022-10-26 18:13
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+import kolibri.core.device.models
+import kolibri.core.fields
+import kolibri.core.utils.validators
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("device", "0016_osuser"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="devicesettings",
+ name="extra_settings",
+ field=kolibri.core.fields.JSONField(
+ default=kolibri.core.device.models.extra_settings_default_values,
+ validators=[
+ kolibri.core.utils.validators.JSON_Schema_Validator(
+ kolibri.core.device.models.extra_settings_schema
+ )
+ ],
+ ),
+ ),
+ ]
diff --git a/kolibri/core/device/models.py b/kolibri/core/device/models.py
index b1421ba90f5..501858318b0 100644
--- a/kolibri/core/device/models.py
+++ b/kolibri/core/device/models.py
@@ -16,9 +16,13 @@
from kolibri.core.auth.models import FacilityUser
from kolibri.core.auth.permissions.base import RoleBasedPermissions
from kolibri.core.auth.permissions.general import IsOwn
+from kolibri.core.fields import JSONField
from kolibri.core.utils.cache import process_cache as cache
+from kolibri.core.utils.validators import JSON_Schema_Validator
from kolibri.deployment.default.sqlite_db_names import SYNC_QUEUE
from kolibri.plugins.app.utils import interface
+from kolibri.utils.conf import OPTIONS
+from kolibri.utils.options import update_options_file
device_permissions_fields = ["is_superuser", "can_manage_content"]
@@ -72,6 +76,31 @@ def app_is_enabled():
return interface.enabled
+extra_settings_schema = {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "allow_download_on_mettered_connection": {"type": "boolean"},
+ "enable_automatic_download": {"type": "boolean"},
+ "allow_learner_download_resources": {"type": "boolean"},
+ "set_limit_for_autodownload": {"type": "boolean"},
+ "limit_for_autodownload": {"type": "integer"},
+ },
+ "required": [
+ "allow_download_on_mettered_connection",
+ "enable_automatic_download",
+ ],
+}
+
+extra_settings_default_values = {
+ "allow_download_on_mettered_connection": False,
+ "enable_automatic_download": True,
+ "allow_learner_download_resources": False,
+ "set_limit_for_autodownload": False,
+ "limit_for_autodownload": 0,
+}
+
+
class DeviceSettings(models.Model):
"""
This class stores data about settings particular to this device
@@ -111,6 +140,12 @@ class DeviceSettings(models.Model):
# Is this a device that only synchronizes data about a subset of users?
subset_of_users_device = models.BooleanField(default=False)
+ extra_settings = JSONField(
+ null=False,
+ validators=[JSON_Schema_Validator(extra_settings_schema)],
+ default=extra_settings_default_values,
+ )
+
def save(self, *args, **kwargs):
self.pk = 1
self.full_clean()
@@ -123,6 +158,22 @@ def delete(self, *args, **kwargs):
cache.delete(DEVICE_SETTINGS_CACHE_KEY)
return out
+ @property
+ def primary_storage_location(self):
+ return OPTIONS["Paths"]["CONTENT_DIR"]
+
+ @primary_storage_location.setter
+ def primary_storage_location(self, value):
+ update_options_file("Paths", "CONTENT_DIR", value)
+
+ @property
+ def secondary_storage_locations(self):
+ return OPTIONS["Paths"]["CONTENT_FALLBACK_DIRS"]
+
+ @secondary_storage_locations.setter
+ def secondary_storage_locations(self, value):
+ update_options_file("Paths", "CONTENT_FALLBACK_DIRS", value)
+
CONTENT_CACHE_KEY_CACHE_KEY = "content_cache_key"
diff --git a/kolibri/core/device/permissions.py b/kolibri/core/device/permissions.py
index f72f4957521..3d3ae119b65 100644
--- a/kolibri/core/device/permissions.py
+++ b/kolibri/core/device/permissions.py
@@ -29,6 +29,9 @@ def has_permission(self, request, view):
return any(getattr(request.user, field) for field in device_permissions_fields)
+ def has_object_permission(self, request, view, obj):
+ return self.has_permission(request, view)
+
class IsSuperuser(DenyAll):
def has_permission(self, request, view):
diff --git a/kolibri/core/device/serializers.py b/kolibri/core/device/serializers.py
index b53f65d4ac9..8248afb643e 100644
--- a/kolibri/core/device/serializers.py
+++ b/kolibri/core/device/serializers.py
@@ -13,6 +13,8 @@
from kolibri.core.device.utils import valid_app_key_on_request
from kolibri.plugins.app.utils import GET_OS_USER
from kolibri.plugins.app.utils import interface
+from kolibri.utils.filesystem import check_is_directory
+from kolibri.utils.filesystem import get_path_permission
class DevicePermissionsSerializer(serializers.ModelSerializer):
@@ -132,7 +134,23 @@ def create(self, validated_data):
}
+class PathListField(serializers.ListField):
+ def to_representation(self, data):
+ return [
+ self.child.to_representation(item)
+ for item in data
+ if check_is_directory(item)
+ ]
+
+
class DeviceSettingsSerializer(DeviceSerializerMixin, serializers.ModelSerializer):
+
+ extra_settings = serializers.JSONField(required=False)
+ primary_storage_location = serializers.CharField(required=False)
+ secondary_storage_locations = PathListField(
+ child=serializers.CharField(required=False), required=False
+ )
+
class Meta:
model = DeviceSettings
fields = (
@@ -142,7 +160,38 @@ class Meta:
"allow_peer_unlisted_channel_import",
"allow_learner_unassigned_resource_access",
"allow_other_browsers_to_connect",
+ "extra_settings",
+ "primary_storage_location",
+ "secondary_storage_locations",
)
def create(self, validated_data):
raise serializers.ValidationError("Device settings can only be updated")
+
+ def validate(self, data):
+ data = super(DeviceSettingsSerializer, self).validate(data)
+ if "primary_storage_location" in data:
+ if not check_is_directory(data["primary_storage_location"]):
+ raise serializers.ValidationError(
+ {
+ "primary_storage_location": "Primary storage location must be a directory"
+ }
+ )
+ if not get_path_permission(data["primary_storage_location"]):
+ raise serializers.ValidationError(
+ {
+ "primary_storage_location": "Primary storage location must be writable"
+ }
+ )
+
+ if "secondary_storage_locations" in data:
+ for path in data["secondary_storage_locations"]:
+ if path == "" or path is None:
+ continue
+ if not check_is_directory(path):
+ raise serializers.ValidationError(
+ {
+ "secondary_storage_locations": "Secondary storage location must be a directory"
+ }
+ )
+ return data
diff --git a/kolibri/core/utils/drf_utils.py b/kolibri/core/utils/drf_utils.py
new file mode 100644
index 00000000000..6449712a069
--- /dev/null
+++ b/kolibri/core/utils/drf_utils.py
@@ -0,0 +1,73 @@
+from functools import wraps
+
+try:
+ from drf_yasg import openapi
+ from drf_yasg.utils import swagger_auto_schema
+except (ImportError, NameError):
+ swagger_auto_schema = None
+ openapi = None
+
+
+def swagger_auto_schema_available(params):
+ """
+ Decorator to be able to use drf_yasg's swagger_auto_schema only if it is installed.
+ This will allow defining schemas to be used in dev mode with http://localhost:8000/api_explorer/
+ while not breaking the app if drf_yasg is not installed (in production mode)
+
+ :param list[tuples] params: list of the params the function accepts
+ :return: decorator
+
+
+ It has to be used with this syntax:
+ @swagger_auto_schema_available([(param1_name, param1_description, param1_type), (param2_name...)])
+
+ param_type must be one of the defined in the drf_yasg.openapi:
+ TYPE_OBJECT = "object" #:
+ TYPE_STRING = "string" #:
+ TYPE_NUMBER = "number" #:
+ TYPE_INTEGER = "integer" #:
+ TYPE_BOOLEAN = "boolean" #:
+ TYPE_ARRAY = "array" #:
+ TYPE_FILE = "file" #:
+
+ example:
+ class PathPermissionView(views.APIView):
+
+ @swagger_auto_schema_available([("path", "path to check permissions for", "string")])
+ def get(self, request):
+ """
+
+ def inner(func):
+ if swagger_auto_schema:
+ if func.__name__ == "get":
+ manual_parameters = []
+ for param in params:
+ manual_parameters.append(
+ openapi.Parameter(
+ param[0],
+ openapi.IN_QUERY,
+ description=param[1],
+ type=param[2],
+ )
+ )
+ swagger_auto_schema(manual_parameters=manual_parameters)(func)
+ else: # PUT,PATCH,POST,DELETE
+ properties = {}
+ for param in params:
+ properties[param[0]] = openapi.Schema(
+ type=param[2], description=param[1]
+ )
+ swagger_auto_schema(
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties=properties,
+ ),
+ )(func)
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return inner
diff --git a/kolibri/core/utils/validators.py b/kolibri/core/utils/validators.py
new file mode 100644
index 00000000000..450d6237b10
--- /dev/null
+++ b/kolibri/core/utils/validators.py
@@ -0,0 +1,23 @@
+from django.core.exceptions import ValidationError
+from django.utils.deconstruct import deconstructible
+from json_schema_validator import errors as jsonschema_exceptions
+from json_schema_validator.schema import Schema
+from json_schema_validator.validator import Validator
+
+
+@deconstructible
+class JSON_Schema_Validator(object):
+ def __init__(self, schema):
+ self.schema = Schema(schema)
+
+ def __call__(self, value):
+ try:
+ Validator.validate(self.schema, value)
+ except jsonschema_exceptions.ValidationError as e:
+ raise ValidationError(e.message, code="invalid")
+ return value
+
+ def __eq__(self, other):
+ if not hasattr(other, "deconstruct"):
+ return False
+ return self.deconstruct() == other.deconstruct()
diff --git a/kolibri/plugins/device/assets/src/constants.js b/kolibri/plugins/device/assets/src/constants.js
index 763504aaaed..01154b9fc86 100644
--- a/kolibri/plugins/device/assets/src/constants.js
+++ b/kolibri/plugins/device/assets/src/constants.js
@@ -70,3 +70,8 @@ export const LandingPageChoices = {
SIGN_IN: 'sign-in',
LEARN: 'learn',
};
+
+export const MeteredConnectionDownloadOptions = {
+ DISALLOW_DOWNLOAD_ON_METERED_CONNECTION: 'DISALLOW_DOWNLOAD_ON_METERED_CONNECTION',
+ ALLOW_DOWNLOAD_ON_METERED_CONNECTION: 'ALLOW_DOWNLOAD_ON_METERED_CONNECTION',
+};
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/AddStorageLocationModal.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/AddStorageLocationModal.vue
new file mode 100644
index 00000000000..398d0dcef69
--- /dev/null
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/AddStorageLocationModal.vue
@@ -0,0 +1,111 @@
+
+
+
+
+ {{ $tr('newStorageLocationDescription') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/PrimaryStorageLocationModal.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/PrimaryStorageLocationModal.vue
new file mode 100644
index 00000000000..eed792f17af
--- /dev/null
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/PrimaryStorageLocationModal.vue
@@ -0,0 +1,77 @@
+
+
+
+
+ {{ $tr('primaryLocationChangeDescription') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/RemoveStorageLocationModal.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/RemoveStorageLocationModal.vue
new file mode 100644
index 00000000000..7ab94a01131
--- /dev/null
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/RemoveStorageLocationModal.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ {{ $tr('removeStorageLocationDescription') }}
+
+ {{ $tr('deleteFilesDescription') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/ServerRestartModal.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/ServerRestartModal.vue
new file mode 100644
index 00000000000..d62d3f2b4cf
--- /dev/null
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/ServerRestartModal.vue
@@ -0,0 +1,119 @@
+
+
+
+
+ {{ $tr('selectedPath', { path: path.path }) }}
+
+
+ {{ getMessage() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/__test__/DeviceSettingsPage.spec.js b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/__test__/DeviceSettingsPage.spec.js
index 2d4e7356b8b..6e4c7732bfa 100644
--- a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/__test__/DeviceSettingsPage.spec.js
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/__test__/DeviceSettingsPage.spec.js
@@ -1,12 +1,41 @@
import { mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import Vuex from 'vuex';
-import client from 'kolibri.client';
import DeviceSettingsPage from '../index.vue';
+import { getPathPermissions, getDeviceURLs, getDeviceSettings, getPathsPermissions } from '../api';
+import { getFreeSpaceOnServer } from '../../AvailableChannelsPage/api';
-jest.mock('kolibri.client');
jest.mock('kolibri.urls');
+jest.mock('../api.js', () => ({
+ getPathPermissions: jest.fn(),
+ getPathsPermissions: jest.fn(),
+ getDeviceURLs: jest.fn(),
+ getDeviceSettings: jest.fn(),
+}));
+
+jest.mock('../../AvailableChannelsPage/api.js', () => ({
+ getFreeSpaceOnServer: jest.fn(),
+}));
+
+const DeviceSettingsData = {
+ languageId: 'en',
+ landingPage: 'sign-in',
+ allowGuestAccess: false,
+ allowLearnerUnassignedResourceAccess: false,
+ allowPeerUnlistedChannelImport: true,
+ allowOtherBrowsersToConnect: false,
+ primaryStorageLocation: null,
+ secondaryStorageLocations: [],
+ extraSettings: {
+ allow_download_on_mettered_connection: false,
+ allow_learner_download_resources: false,
+ enable_automatic_download: false,
+ limit_for_autodownload: 0,
+ set_limit_for_autodownload: false,
+ },
+};
+
const localVue = createLocalVue();
localVue.use(Vuex);
const store = new Vuex.Store({
@@ -32,34 +61,34 @@ async function makeWrapper() {
}
function getButtons(wrapper) {
- const radioButtons = wrapper.findAllComponents({ name: 'KRadioButton' });
- const saveButton = wrapper.findComponent({ name: 'KButton' });
+ const saveButton = wrapper.find('[data-test="saveButton"]');
+ const learnPage = wrapper.find('[data-test="landingPageButton"]');
+ const signInPage = wrapper.find('[data-test="signInPageButton"]');
+ const allowGuestAccess = wrapper.find('[data-test="allowGuestAccessButton"]');
+ const disallowGuestAccess = wrapper.find('[data-test="disallowGuestAccessButton"]');
+ const lockedContent = wrapper.find('[data-test="lockedContentButton"]');
return {
- learnPage: radioButtons.at(0),
- signInPage: radioButtons.at(1),
- allowGuestAccess: radioButtons.at(2),
- disallowGuestAccess: radioButtons.at(3),
- lockedContent: radioButtons.at(4),
+ learnPage,
+ signInPage,
+ allowGuestAccess,
+ disallowGuestAccess,
+ lockedContent,
saveButton,
};
}
describe('DeviceSettingsPage', () => {
- afterEach(() => {
- client.mockReset();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getPathPermissions.mockResolvedValue({});
+ getPathsPermissions.mockResolvedValue({});
+ getDeviceURLs.mockResolvedValue({});
+ getDeviceSettings.mockResolvedValue(DeviceSettingsData);
+ getFreeSpaceOnServer.mockResolvedValue({ freeSpace: 0 });
});
it('loads the data from getDeviceSettings', async () => {
- client.mockResolvedValue({
- data: {
- language_id: 'en',
- landing_page: 'sign-in',
- allow_guest_access: false,
- allow_learner_unassigned_resource_access: false,
- allow_peer_unlisted_channel_import: true,
- allow_other_browsers_to_connect: false,
- },
- });
+ getDeviceSettings.mockResolvedValue(DeviceSettingsData);
const { wrapper } = await makeWrapper();
const data = wrapper.vm.$data;
expect(data.language).toMatchObject({ value: 'en', label: 'English' });
@@ -85,12 +114,10 @@ describe('DeviceSettingsPage', () => {
}
function setMockedData(allowGuestAccess, allowAllAccess) {
- client.mockResolvedValue({
- data: {
- landing_page: 'sign-in',
- allow_guest_access: allowGuestAccess,
- allow_learner_unassigned_resource_access: allowAllAccess,
- },
+ getDeviceSettings.mockResolvedValue({
+ landingPage: 'sign-in',
+ allowGuestAccess: allowGuestAccess,
+ allowLearnerUnassignedResourceAccess: allowAllAccess,
});
}
@@ -123,13 +150,12 @@ describe('DeviceSettingsPage', () => {
// The fourth possibility with guest access but no channels tab should be impossible
it('if Learn page is the landing page, sign-in page options are disabled', async () => {
- client.mockResolvedValue({
- data: {
- landing_page: 'learn',
- // The guest access button should not be checked
- allow_guest_access: true,
- },
+ getDeviceSettings.mockResolvedValue({
+ landingPage: 'learn',
+ // The guest access button should not be checked
+ allowGuestAccess: true,
});
+
const { wrapper } = await makeWrapper();
const { learnPage, allowGuestAccess, disallowGuestAccess, lockedContent } = getButtons(
wrapper
@@ -146,7 +172,9 @@ describe('DeviceSettingsPage', () => {
});
it('if switching from Learn to Sign-In, "Allow users to explore..." is selected', async () => {
- client.mockResolvedValue({ data: { landing_page: 'learn' } });
+ getDeviceSettings.mockResolvedValue({
+ landingPage: 'learn',
+ });
const { wrapper } = await makeWrapper();
const { signInPage, allowGuestAccess } = getButtons(wrapper);
await clickRadioButton(signInPage);
@@ -157,16 +185,11 @@ describe('DeviceSettingsPage', () => {
describe('submitting changes', () => {
beforeEach(() => {
- client.mockResolvedValue({
- data: {
- language_id: 'en',
- landing_page: 'sign-in',
- allow_guest_access: false,
- allow_learner_unassigned_resource_access: true,
- allow_peer_unlisted_channel_import: true,
- allow_other_browsers_to_connect: false,
- },
- });
+ jest.clearAllMocks();
+ // allow_learner_unassigned_resource_access: allowAllAccess,
+ const newData = { ...DeviceSettingsData };
+ newData.allowLearnerUnassignedResourceAccess = true;
+ getDeviceSettings.mockResolvedValue(newData);
});
it('landing page is Learn page', async () => {
@@ -229,7 +252,6 @@ describe('DeviceSettingsPage', () => {
await clickRadioButton(lockedContent);
saveButton.trigger('click');
await global.flushPromises();
- console.log(wrapper.vm.signInPageOption);
// Implications: Cannot see "explore without account" AND cannot see "channels" tab
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/api.js b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/api.js
index 9577fcb9e49..92e75b9651b 100644
--- a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/api.js
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/api.js
@@ -12,6 +12,9 @@ export function getDeviceSettings() {
allowLearnerUnassignedResourceAccess: data.allow_learner_unassigned_resource_access,
allowPeerUnlistedChannelImport: data.allow_peer_unlisted_channel_import,
allowOtherBrowsersToConnect: data.allow_other_browsers_to_connect,
+ extraSettings: data.extra_settings,
+ primaryStorageLocation: data.primary_storage_location,
+ secondaryStorageLocations: data.secondary_storage_locations,
};
});
}
@@ -28,6 +31,34 @@ export function saveDeviceSettings(settings) {
allow_learner_unassigned_resource_access: settings.allowLearnerUnassignedResourceAccess,
allow_peer_unlisted_channel_import: settings.allowPeerUnlistedChannelImport,
allow_other_browsers_to_connect: settings.allowOtherBrowsersToConnect,
+ extra_settings: settings.extraSettings,
+ primary_storage_location: settings.primaryStorageLocation,
+ secondary_storage_locations: settings.secondaryStorageLocations,
},
});
}
+
+export function getDeviceURLs() {
+ return client({ url: urls['kolibri:core:deviceinfo']() }).then(response => {
+ return {
+ deviceUrls: response.data.urls,
+ };
+ });
+}
+
+export function getPathPermissions(path) {
+ return client({
+ url: `${urls['kolibri:core:pathpermission']()}`,
+ params: { path },
+ });
+}
+
+export function getPathsPermissions(paths) {
+ let pathsInfo = [];
+ for (let path of paths) {
+ getPathPermissions(path).then(permissions => {
+ pathsInfo.push({ path, writable: permissions.data.writable });
+ });
+ }
+ return pathsInfo;
+}
diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue
index baa25ccae58..97536a0e521 100644
--- a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue
+++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue
@@ -60,12 +60,14 @@
+
+
+
+
+
+
+ {{ $tr('DownloadOnMeteredConnectionDescription') }}
+
+
+
+
+
+
+
+ {{ $tr('primaryStorage') }}
+
+
+ {{ $tr('primaryStorageDescription') }}
+
+
+ {{ primaryStorageLocation }}
+
+
+
+
+
+
+
+ {{ $tr('secondaryStorage') }}
+
+
+ {{ $tr('secondaryStorageDescription') }}
+
+
+ {{ path }} {{ isWritablePath(path) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ {{ freeSpace }}
+
+
+
+
+
+
@@ -110,8 +252,8 @@
{{ $tr('configureFacilitySettingsHeader') }}
+
+
+
+
+
+
+
+
@@ -137,10 +309,17 @@
import { availableLanguages, currentLanguage } from 'kolibri.utils.i18n';
import sortLanguages from 'kolibri.utils.sortLanguages';
import AppBarPage from 'kolibri.coreVue.components.AppBarPage';
- import { LandingPageChoices } from '../../constants';
+ import bytesForHumans from 'kolibri.utils.bytesForHumans';
+ import { LandingPageChoices, MeteredConnectionDownloadOptions } from '../../constants';
import DeviceTopNav from '../DeviceTopNav';
import { deviceString } from '../commonDeviceStrings';
- import { getDeviceSettings, saveDeviceSettings } from './api';
+ import { getFreeSpaceOnServer } from '../AvailableChannelsPage/api';
+ import useDeviceRestart from '../../composables/useDeviceRestart';
+ import { getDeviceSettings, getPathsPermissions, saveDeviceSettings, getDeviceURLs } from './api';
+ import PrimaryStorageLocationModal from './PrimaryStorageLocationModal';
+ import AddStorageLocationModal from './AddStorageLocationModal';
+ import RemoveStorageLocationModal from './RemoveStorageLocationModal';
+ import ServerRestartModal from './ServerRestartModal';
const SignInPageOptions = Object.freeze({
LOCKED_CONTENT: 'LOCKED_CONTENT',
@@ -155,8 +334,19 @@
title: this.$tr('pageHeader'),
};
},
- components: { AppBarPage, DeviceTopNav },
+ components: {
+ AppBarPage,
+ DeviceTopNav,
+ PrimaryStorageLocationModal,
+ AddStorageLocationModal,
+ RemoveStorageLocationModal,
+ ServerRestartModal,
+ },
mixins: [commonCoreStrings],
+ setup() {
+ const { restart } = useDeviceRestart();
+ return { restart };
+ },
data() {
return {
language: {},
@@ -166,10 +356,30 @@
landingPageChoices: LandingPageChoices,
signInPageOption: '',
SignInPageOptions,
+ extraSettings: {},
+ meteredConnectionDownloadOption: '',
+ meteredConnectionDownloadOptions: MeteredConnectionDownloadOptions,
+ primaryStorageLocation: null,
+ secondaryStorageLocations: [],
+ storageLocations: {},
+ enableAutomaticDownload: null,
+ allowLearnerDownloadResources: null,
+ setLimitForAutodownload: null,
+ limitForAutodownload: '0',
+ freeSpace: 0,
+ deviceUrls: [],
+ showChangePrimaryLocationModal: false,
+ showAddStorageLocationModal: false,
+ showRemoveStorageLocationModal: false,
browserDefaultOption: {
value: null,
label: this.$tr('browserDefaultLanguage'),
},
+ restartPath: {},
+ restartSetting: null,
+ showRestartModal: false,
+ writablePaths: 0,
+ readOnlyPaths: 0,
};
},
computed: {
@@ -199,18 +409,75 @@
disableSignInPageOptions() {
return this.landingPage !== LandingPageChoices.SIGN_IN;
},
+ storageLocationOptions() {
+ return [this.$tr('addStorageLocation'), this.$tr('removeStorageLocation')];
+ },
+ browserLocationMatchesServerURL() {
+ return (
+ window.location.hostname.includes('127.0.0.1') ||
+ window.location.hostname.includes('localhost')
+ );
+ },
+ notEnoughFreeSpace() {
+ return this.freeSpace === 0;
+ },
+ multipleWritablePaths() {
+ Object.values(this.storageLocations).forEach(el => {
+ if (el.writable === true) this.writablePaths += 1;
+ });
+ return this.writablePaths >= 2;
+ },
+ multipleReadOnlyPaths() {
+ Object.values(this.storageLocations).forEach(el => {
+ if (el.writable === false) this.readOnlyPaths += 1;
+ });
+ return this.readOnlyPaths >= 1;
+ },
+ sliderStyle() {
+ if (this.notEnoughFreeSpace) {
+ return {
+ background: `linear-gradient(to right, ${this.$themeTokens.primary} 0%, ${
+ this.$themeTokens.primary
+ }
+ ${((0 - 0) / (100 - 0)) * 100}%, ${this.$themeTokens.fineLine} ${((0 - 0) / (100 - 0)) *
+ 100}%, ${this.$themeTokens.fineLine} 100%)`,
+ '::-webkit-slider-thumb': {
+ background: this.$themeTokens.fineLine,
+ },
+ };
+ } else {
+ return {
+ background: `linear-gradient(to right, ${this.$themeTokens.primary} 0%, ${
+ this.$themeTokens.primary
+ }
+ ${((this.limitForAutodownload - 0) / (this.freeSpace - 0)) * 100}%,
+ ${this.$themeTokens.fineLine} ${((this.limitForAutodownload - 0) /
+ (this.freeSpace - 0)) *
+ 100}%, ${this.$themeTokens.fineLine} 100%)`,
+ '::-webkit-slider-thumb': {
+ background: this.$themeTokens.primary,
+ },
+ };
+ }
+ },
+ },
+ created() {
+ this.setDeviceURLs();
+ this.setFreeSpace();
},
beforeMount() {
this.getDeviceSettings().then(settings => {
const {
- languageId,
- landingPage,
- allowGuestAccess,
- allowLearnerUnassignedResourceAccess,
- allowPeerUnlistedChannelImport,
- allowOtherBrowsersToConnect,
+ languageId = null,
+ landingPage = '',
+ allowGuestAccess = false,
+ allowLearnerUnassignedResourceAccess = false,
+ allowPeerUnlistedChannelImport = null,
+ allowOtherBrowsersToConnect = null,
+ primaryStorageLocation = null,
+ secondaryStorageLocations = [],
+ extraSettings = {},
} = settings;
-
const match = find(this.languageOptions, { value: languageId });
if (match) {
this.language = { ...match };
@@ -222,13 +489,22 @@
this.setSignInPageOption(settings);
}
+ this.setExtraSettings(extraSettings);
+
Object.assign(this, {
landingPage,
allowGuestAccess,
allowLearnerUnassignedResourceAccess,
allowPeerUnlistedChannelImport,
allowOtherBrowsersToConnect,
+ primaryStorageLocation,
+ secondaryStorageLocations,
+ extraSettings,
});
+ this.storageLocations = getPathsPermissions([
+ ...this.secondaryStorageLocations,
+ this.primaryStorageLocation,
+ ]);
});
},
methods: {
@@ -241,6 +517,28 @@
this.signInPageOption = SignInPageOptions.DISALLOW_GUEST_ACCESS;
}
},
+ setExtraSettings(extraSettings) {
+ // Destructuring the object
+ const {
+ allow_download_on_mettered_connection = false,
+ allow_learner_download_resources = false,
+ enable_automatic_download = true,
+ limit_for_autodownload = 0,
+ set_limit_for_autodownload = false,
+ } = extraSettings;
+
+ if (allow_download_on_mettered_connection === false) {
+ this.meteredConnectionDownloadOption =
+ MeteredConnectionDownloadOptions.DISALLOW_DOWNLOAD_ON_METERED_CONNECTION;
+ } else {
+ this.meteredConnectionDownloadOption =
+ MeteredConnectionDownloadOptions.ALLOW_DOWNLOAD_ON_METERED_CONNECTION;
+ }
+ this.allowLearnerDownloadResources = allow_learner_download_resources;
+ this.enableAutomaticDownload = enable_automatic_download;
+ this.limitForAutodownload = limit_for_autodownload.toString();
+ this.setLimitForAutodownload = set_limit_for_autodownload;
+ },
getContentSettings() {
// This is the inverse of 'setSignInPageOption'
// NOTE: See screenshot in #7247 for how radio button selection should map to settings
@@ -264,6 +562,37 @@
};
}
},
+ getExtraSettings() {
+ const newExtraSettings = {
+ allow_download_on_mettered_connection:
+ this.meteredConnectionDownloadOption ===
+ MeteredConnectionDownloadOptions.DISALLOW_DOWNLOAD_ON_METERED_CONNECTION
+ ? false
+ : true,
+ allow_learner_download_resources:
+ this.enableAutomaticDownload === false ? false : this.allowLearnerDownloadResources,
+ enable_automatic_download: this.enableAutomaticDownload,
+ limit_for_autodownload:
+ this.notEnoughFreeSpace || this.setLimitForAutodownload === false
+ ? 0
+ : parseInt(this.limitForAutodownload),
+ set_limit_for_autodownload:
+ this.enableAutomaticDownload === false || this.notEnoughFreeSpace
+ ? false
+ : this.setLimitForAutodownload,
+ };
+ Object.assign(this.extraSettings, newExtraSettings);
+ },
+ setDeviceURLs() {
+ return getDeviceURLs().then(({ deviceUrls }) => {
+ this.deviceUrls = deviceUrls;
+ });
+ },
+ setFreeSpace() {
+ return getFreeSpaceOnServer().then(({ freeSpace }) => {
+ this.freeSpace = parseInt(bytesForHumans(freeSpace).substring(0, 3));
+ });
+ },
handleLandingPageChange(option) {
this.landingPage = option;
if (option === LandingPageChoices.LEARN) {
@@ -275,6 +604,9 @@
handleSignInPageChange(option) {
this.signInPageOption = option;
},
+ handleMeteredConnectionDownloadChange(option) {
+ this.meteredConnectionDownloadOption = option;
+ },
getFacilitySettingsPath(facilityId = '') {
const getUrl = urls['kolibri:kolibri.plugins.facility:facility_management'];
if (getUrl) {
@@ -291,6 +623,8 @@
allowLearnerUnassignedResourceAccess,
} = this.getContentSettings();
+ this.getExtraSettings();
+
this.saveDeviceSettings({
languageId: this.language.value,
landingPage: this.landingPage,
@@ -298,9 +632,15 @@
allowLearnerUnassignedResourceAccess,
allowPeerUnlistedChannelImport: this.allowPeerUnlistedChannelImport,
allowOtherBrowsersToConnect: this.allowOtherBrowsersToConnect,
+ extraSettings: this.extraSettings,
+ secondaryStorageLocations: this.secondaryStorageLocations,
+ primaryStorageLocation: this.primaryStorageLocation,
})
.then(() => {
this.$store.dispatch('createSnackbar', this.$tr('saveSuccessNotification'));
+ if (this.restartSetting !== null) {
+ this.restart();
+ }
})
.catch(() => {
this.$store.dispatch('createSnackbar', this.$tr('saveFailureNotification'));
@@ -308,6 +648,83 @@
},
getDeviceSettings,
saveDeviceSettings,
+ handleSelect(selectedOption) {
+ if (selectedOption === this.$tr('addStorageLocation')) {
+ this.showAddStorageLocationModal = true;
+ this.showRemoveStorageLocationModal = false;
+ } else if (selectedOption === this.$tr('removeStorageLocation')) {
+ this.showRemoveStorageLocationModal = true;
+ this.showAddStorageLocationModal = false;
+ }
+ },
+ changePrimaryLocation(path) {
+ const writable = true;
+ this.restartPath = {
+ path,
+ writable,
+ };
+ this.restartSetting = 'primary';
+ this.showRestartModal = true;
+ this.showChangePrimaryLocationModal = false;
+ },
+ addStorageLocation(path, writable) {
+ this.restartPath = {
+ path,
+ writable,
+ };
+
+ this.restartSetting = 'add';
+ this.showRestartModal = true;
+ this.showAddStorageLocationModal = false;
+ },
+ removeStorageLocation(path, writable) {
+ this.restartPath = {
+ path,
+ writable,
+ };
+
+ this.restartSetting = 'remove';
+ this.showRestartModal = true;
+ this.showRemoveStorageLocationModal = false;
+ },
+ handleServerRestart(confirmationChecked) {
+ this.showRestartModal = false;
+ if (this.restartSetting === 'add') {
+ this.storageLocations.push(this.restartPath);
+ if (confirmationChecked === true) {
+ this.secondaryStorageLocations.push(this.primaryStorageLocation);
+ this.secondaryStorageLocations = this.secondaryStorageLocations.filter(
+ el => el !== this.restartPath.path
+ );
+ this.primaryStorageLocation = this.restartPath.path;
+ } else {
+ this.secondaryStorageLocations.push(this.restartPath.path);
+ }
+ this.handleClickSave();
+ } else if (this.restartSetting === 'remove') {
+ this.storageLocations = this.storageLocations.filter(
+ el => el.path !== this.restartPath.path
+ );
+ this.secondaryStorageLocations = this.secondaryStorageLocations.filter(
+ el => el !== this.restartPath.path
+ );
+ this.handleClickSave();
+ } else if (this.restartSetting === 'primary') {
+ this.secondaryStorageLocations.push(this.primaryStorageLocation);
+ this.secondaryStorageLocations = this.secondaryStorageLocations.filter(
+ el => el !== this.restartPath.path
+ );
+ this.primaryStorageLocation = this.restartPath.path;
+ this.handleClickSave();
+ }
+ },
+ isWritablePath(path) {
+ const found = this.storageLocations.find(el => el.path === path);
+ if (found !== undefined && !found.writable) {
+ return this.$tr('readOnly');
+ }
+ return '';
+ },
},
$trs: {
browserDefaultLanguage: {
@@ -390,6 +807,98 @@
message: 'External devices',
context: 'Label for device settings controlling how Kolibri interacts with other devices.',
},
+ allowDownloadOnMeteredConnection: {
+ message: 'Download on metered connection',
+ context:
+ 'Label for device setting that allows user to determine whether or not to download data on metered connections',
+ },
+ DownloadOnMeteredConnectionDescription: {
+ message:
+ 'If users on this device are using Kolibri with a limited data plan, they may have to pay extra charges on a metered connection.',
+ context:
+ 'Warns the user of potential extra charges if using Kolibri with a limited data plan.',
+ },
+ doNotAllowDownload: {
+ message: 'Do not allow download on a metered connection',
+ context: 'Option to not allow downloads on metered connections.',
+ },
+ allowDownload: {
+ message: 'Allow download on a metered connection',
+ context: 'Option to allow downloads on metered connections.',
+ },
+ primaryStorage: {
+ message: 'Primary storage location',
+ context: 'Option to allow downloads on metered connections.',
+ },
+ primaryStorageDescription: {
+ message:
+ 'Kolibri channels are stored here. Newly downloaded resources will be added to this location.',
+ context: 'Informs user of storage location for Kolibri channels and new resources',
+ },
+ secondaryStorage: {
+ message: 'Other storage locations',
+ context: 'Secondary storage paths for users to store downloaded resources',
+ },
+ secondaryStorageDescription: {
+ message: 'Read-only locations cannot be the primary storage location.',
+ context: 'Informs user of limits for read-only locations',
+ },
+ autoDownload: {
+ message: 'Auto-download',
+ context: 'Label for Auto-download section',
+ },
+ enableAutoDownload: {
+ message: 'Enable auto-download',
+ context: "Option on 'Device settings' page.",
+ },
+ enableAutoDownloadDescription: {
+ message:
+ "Kolibri will automatically download assigned lessons, quizzes, and other resources on the 'My downloads' list.",
+ context: 'Enable auto download description.',
+ },
+ allowLearnersDownloadResources: {
+ message: 'Allow learners to download resources',
+ context: "Option on 'Device settings' page.",
+ },
+ allowLearnersDownloadDescription: {
+ message:
+ "Allow users to explore resources they don't have and mark it for Kolibri to automatically download when it's available in their network.",
+ context:
+ "Description for 'Allow learners to download resources' option under 'Auto-download' section.",
+ },
+ setStorageLimit: {
+ message: 'Set storage limit for auto-download and learner-initiated downloads',
+ context: "Option on 'Device settings' page.",
+ },
+ setStorageLimitDescription: {
+ message:
+ 'Kolibri will not auto-download more than a set amount of remaining storage on the device',
+ context: "Description for 'Set storage limit' option under 'Auto-download' section.",
+ },
+ addStorageLocation: {
+ message: 'Add storage location ',
+ context: 'Menu option for storage paths',
+ },
+ removeStorageLocation: {
+ message: 'Remove storage location',
+ context: 'Menu option for storage paths',
+ },
+ addLocation: {
+ message: 'Add Location',
+ context: 'Label for a button used to add storage location',
+ },
+ changeLocation: {
+ message: 'Change',
+ context: 'Label to change primary storage location',
+ },
+ notEnoughFreeSpace: {
+ message: 'No available storage',
+ context: 'Error text that is provided if there is not enough free storage on device',
+ },
+ readOnly: {
+ message: '(read-only)',
+ context: 'Label for read-only storage locations',
+ },
},
};
@@ -435,4 +944,58 @@
color: rgba(0, 0, 0, 0.54);
}
+ .left-margin {
+ margin-left: 32px;
+ }
+
+ .info-description {
+ color: #616161;
+ }
+
+ input[type='range'] {
+ width: 264px;
+ height: 2px;
+ margin-left: 10px;
+ outline: none;
+ appearance: none;
+ }
+
+ input[type='range']::-webkit-slider-thumb {
+ width: 12px;
+ height: 12px;
+ cursor: pointer;
+ border-radius: 10px;
+ appearance: none;
+ }
+
+ .download-limit-textbox {
+ display: inline-block;
+ width: 70px;
+ }
+
+ .slider-section {
+ position: absolute;
+ display: inline-block;
+ padding-top: 10px;
+ }
+
+ .slider-constraints {
+ display: flex;
+ justify-content: space-between;
+ margin-left: 10px;
+ }
+
+ .slider-min-max {
+ display: inline-block;
+ margin-top: 5px;
+ font-size: 14px;
+ font-weight: 400;
+ color: #686868;
+ }
+
+ .disabled {
+ color: #e0e0e0 !important;
+ pointer-events: none;
+ }
+
diff --git a/kolibri/utils/filesystem.py b/kolibri/utils/filesystem.py
new file mode 100644
index 00000000000..3908d52fc59
--- /dev/null
+++ b/kolibri/utils/filesystem.py
@@ -0,0 +1,30 @@
+import os
+
+try:
+ FileNotFoundError
+except NameError:
+ FileNotFoundError = IOError
+
+
+def get_path_permission(path):
+ """
+ Check if the path is writable by the current user.
+ :param path: Path to check
+ :return: True if the path is writable, False otherwise.
+ """
+ try:
+ return os.access(path, os.W_OK)
+ except (IOError, OSError):
+ return False
+
+
+def check_is_directory(path):
+ """
+ Check if the path is not a file.
+ :param path: Path to check
+ :return: True if the path is a directory.
+ """
+ try:
+ return os.path.exists(path)
+ except (IOError, OSError):
+ return False
diff --git a/requirements/base.txt b/requirements/base.txt
index 78dd70c645d..8a91f826a15 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -32,3 +32,4 @@ whitenoise==4.1.4
idna==2.8
ifaddr==0.1.7 # Pin as version 0.2.0 only supports Python 3.7 and above
importlib_resources==3.3.1
+json-schema-validator==2.4.1
diff --git a/requirements/build.txt b/requirements/build.txt
index ced2905377e..24bbc52536d 100644
--- a/requirements/build.txt
+++ b/requirements/build.txt
@@ -3,6 +3,7 @@
# This does not depend on runtime stuff so we do not
# include base.txt
pex<1.6
+pip>=20.1
setuptools>=20.3,<41,!=34.*,!=35.* # https://github.com/pantsbuild/pex/blob/master/pex/version.py#L6 # pyup: ignore
beautifulsoup4==4.8.2
requests==2.21.0