Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Device settings updates #9833

Merged
merged 23 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2ea2738
Decorator to use drf_yasg when it's installed
Nov 5, 2022
d899307
moving the decorator to another dir
Nov 10, 2022
a61ff57
New model for DeviceSettings and JSON validation
Nov 11, 2022
c194c23
Serialization and validation of the model changes
Nov 11, 2022
cc1594e
Frontend DeviceSettings extra settings sections added
LianaHarris360 Nov 3, 2022
a68dedb
Set storage limit slider updated and device url matching for storage …
LianaHarris360 Nov 7, 2022
171428b
Removed unnecessary spacing elements
LianaHarris360 Nov 7, 2022
49c33fa
Auto download frontend section updated
LianaHarris360 Nov 8, 2022
aaf83e9
Code refactored and kolibri theming colors added
LianaHarris360 Nov 8, 2022
847df57
Primary storage location, other storage location, and server restart …
LianaHarris360 Nov 10, 2022
7ef87c9
Adapting the frontend to the backend api changes
Nov 11, 2022
8ce8892
Sending selected path info
Nov 11, 2022
e9f7fb4
Adding locations
Nov 11, 2022
72a00a3
Remove and change primary storage location modals updated and setting…
LianaHarris360 Nov 14, 2022
cc9c754
Specifies pre-commit hoot python version to 3.10
akolson Nov 3, 2022
ebcc791
Fixing some of the frontend tests
Nov 16, 2022
05fb708
Fixing builds
Nov 17, 2022
3d34c14
more tests fixes
Nov 18, 2022
2a2ce34
Adding api for server restart
LianaHarris360 Nov 18, 2022
37692e3
Library to do json validation replaced
Nov 18, 2022
70ae22e
Some minor frontend issues fixed
LianaHarris360 Nov 21, 2022
b4dbede
Serializar does not return invalid path names for secondary locations
Nov 23, 2022
5672968
Selecting newly added storage location as primary fixed
LianaHarris360 Nov 23, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 1 addition & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions kolibri/core/device/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
)
2 changes: 2 additions & 0 deletions kolibri/core/device/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,4 +37,5 @@
url(
r"^remotefacilities", RemoteFacilitiesViewset.as_view(), name="remotefacilities"
),
url(r"^pathpermission/", PathPermissionView.as_view(), name="pathpermission"),
]
31 changes: 31 additions & 0 deletions kolibri/core/device/migrations/0017_extra_settings.py
Original file line number Diff line number Diff line change
@@ -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
)
],
),
),
]
51 changes: 51 additions & 0 deletions kolibri/core/device/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions kolibri/core/device/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
49 changes: 49 additions & 0 deletions kolibri/core/device/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = (
Expand All @@ -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
73 changes: 73 additions & 0 deletions kolibri/core/utils/drf_utils.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions kolibri/core/utils/validators.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions kolibri/plugins/device/assets/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Loading