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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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') }}