From 7c25ca85e52d5d8def7da53a840bdce19c2d8e53 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 24 Nov 2024 11:04:11 -0800 Subject: [PATCH 1/7] Remove use of deprecated abstractproperty. Make the theme not break when no theme plugin is found. --- kolibri/core/content/hooks.py | 4 ++-- kolibri/core/device/hooks.py | 6 ++++-- kolibri/core/hooks.py | 14 +++++++++----- kolibri/core/theme_hook.py | 11 ++++++++--- kolibri/core/webpack/hooks.py | 11 +++++++---- kolibri/plugins/hooks.py | 7 ++++--- 6 files changed, 34 insertions(+), 19 deletions(-) diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py index 0c5e9a6e655..7ec3a34489e 100644 --- a/kolibri/core/content/hooks.py +++ b/kolibri/core/content/hooks.py @@ -6,7 +6,6 @@ """ import json from abc import abstractmethod -from abc import abstractproperty from django.core.serializers.json import DjangoJSONEncoder from django.utils.safestring import mark_safe @@ -25,7 +24,8 @@ class ContentRendererHook(WebpackBundleHook, WebpackInclusionMixin): """ #: Set tuple of format presets that this content renderer can handle - @abstractproperty + @property + @abstractmethod def presets(self): pass diff --git a/kolibri/core/device/hooks.py b/kolibri/core/device/hooks.py index 68e7c014d61..0cae36d8f5e 100644 --- a/kolibri/core/device/hooks.py +++ b/kolibri/core/device/hooks.py @@ -1,4 +1,4 @@ -from abc import abstractproperty +from abc import abstractmethod from kolibri.plugins.hooks import define_hook from kolibri.plugins.hooks import KolibriHook @@ -9,7 +9,9 @@ class SetupHook(KolibriHook): # A hook for a plugin to use to define a url to redirect to # when Kolibri has not yet been provisioned - @abstractproperty + + @property + @abstractmethod def url(self): pass diff --git a/kolibri/core/hooks.py b/kolibri/core/hooks.py index 6156ec986b6..aa5dc708f65 100644 --- a/kolibri/core/hooks.py +++ b/kolibri/core/hooks.py @@ -10,7 +10,7 @@ Anyways, for now to get hooks started, we have some defined here... """ -from abc import abstractproperty +from abc import abstractmethod from django.utils.safestring import mark_safe @@ -37,12 +37,14 @@ class RoleBasedRedirectHook(KolibriHook): require_no_on_my_own_facility = False # User role to redirect for - @abstractproperty + @property + @abstractmethod def roles(self): pass # URL to redirect to - @abstractproperty + @property + @abstractmethod def url(self): pass @@ -65,7 +67,8 @@ class FrontEndBaseHeadHook(KolibriHook): kolibri/base.html, that means ALL pages. Use with care. """ - @abstractproperty + @property + @abstractmethod def head_html(self): pass @@ -87,7 +90,8 @@ class LogoutRedirectHook(KolibriHook): def is_enabled(cls): return len(list(cls.registered_hooks)) == 1 - @abstractproperty + @property + @abstractmethod def url(self): """ A property to be overriden by the class using this hook to provide the needed url to redirect diff --git a/kolibri/core/theme_hook.py b/kolibri/core/theme_hook.py index 642ba12f4b6..27f336fb443 100644 --- a/kolibri/core/theme_hook.py +++ b/kolibri/core/theme_hook.py @@ -1,5 +1,5 @@ import logging -from abc import abstractproperty +from abc import abstractmethod from kolibri.plugins import hooks @@ -35,10 +35,15 @@ class ThemeHook(hooks.KolibriHook): @classmethod def get_theme(cls): - theme = list(cls.registered_hooks)[0].theme + try: + theme = next(cls.registered_hooks).theme + except StopIteration: + logger.warning("No theme hooks registered, using default theme") + theme = {} _initFields(theme) return theme - @abstractproperty + @property + @abstractmethod def theme(self): pass diff --git a/kolibri/core/webpack/hooks.py b/kolibri/core/webpack/hooks.py index b313f3e34eb..e3d88531c76 100644 --- a/kolibri/core/webpack/hooks.py +++ b/kolibri/core/webpack/hooks.py @@ -12,7 +12,7 @@ import os import re import time -from abc import abstractproperty +from abc import abstractmethod from functools import partial from urllib.request import url2pathname @@ -60,7 +60,8 @@ class WebpackBundleHook(hooks.KolibriHook): # : You should set a human readable name that is unique within the # : plugin in which this is defined. - @abstractproperty + @property + @abstractmethod def bundle_id(self): pass @@ -306,11 +307,13 @@ def render_to_page_load_sync_html(self): class WebpackInclusionMixin(object): - @abstractproperty + @property + @abstractmethod def bundle_html(self): pass - @abstractproperty + @property + @abstractmethod def bundle_class(self): pass diff --git a/kolibri/plugins/hooks.py b/kolibri/plugins/hooks.py index 4ff99b30196..b726aee9bf1 100644 --- a/kolibri/plugins/hooks.py +++ b/kolibri/plugins/hooks.py @@ -26,7 +26,7 @@ Abstract hooks Are definitions of hooks that are implemented by *implementing hooks*. - These hooks are Python abstract base classes, and can use the @abstractproperty + These hooks are Python abstract base classes, and can use the @property and @abstractmethod decorators from the abc module in order to define which properties and methods their descendant registered hooks should implement. @@ -154,7 +154,7 @@ def navigation_tags(self): """ import logging -from abc import abstractproperty +from abc import abstractmethod from functools import partial from inspect import isabstract @@ -390,7 +390,8 @@ def get_hook(cls, unique_id): class KolibriHook(metaclass=KolibriHookMeta): - @abstractproperty + @property + @abstractmethod def _not_abstract(self): """ A dummy property that we set on classes that are not intended to be abstract in the register_hook function above. From ad43f28c5b4ae2ffdacc5c0f867143644b5e9c30 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 24 Nov 2024 15:49:25 -0800 Subject: [PATCH 2/7] Add hook to add magicbus plugins to the processbus. --- .../utils/{server.py => server/__init__.py} | 20 +++++++---- kolibri/utils/server/hooks.py | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) rename kolibri/utils/{server.py => server/__init__.py} (98%) create mode 100644 kolibri/utils/server/hooks.py diff --git a/kolibri/utils/server.py b/kolibri/utils/server/__init__.py similarity index 98% rename from kolibri/utils/server.py rename to kolibri/utils/server/__init__.py index c0078a2324f..8f754cb5dcf 100644 --- a/kolibri/utils/server.py +++ b/kolibri/utils/server/__init__.py @@ -27,13 +27,15 @@ from zeroconf import InterfaceChoice import kolibri -from .constants import installation_types -from .system import become_daemon -from .system import pid_exists from kolibri.utils import conf from kolibri.utils.android import on_android +from kolibri.utils.constants import installation_types from kolibri.utils.logger import cleanup_queue_logging from kolibri.utils.logger import setup_queue_logging +from kolibri.utils.server.hooks import KolibriProcessHook +from kolibri.utils.system import become_daemon +from kolibri.utils.system import pid_exists + try: FileNotFoundError @@ -296,7 +298,6 @@ def __init__(self, bus, port): # Otherwise do a dummy initialization # A frequency of less than 0 will prevent the monitor from running Monitor.__init__(self, bus, None, frequency=-1) - self.bus.subscribe("SERVING", self.SERVING) self.bus.subscribe("UPDATE_ZEROCONF", self.UPDATE_ZEROCONF) self.broadcast = None @@ -386,8 +387,6 @@ def __init__(self, bus): # Do this during initialization to set a startup lock self.set_pid_file(STATUS_STARTING_UP) - self.bus.subscribe("SERVING", self.SERVING) - self.bus.subscribe("ZIP_SERVING", self.ZIP_SERVING) for bus_status, status in status_map.items(): handler = partial(self.set_pid_file, status) handler.priority = 10 @@ -716,6 +715,9 @@ def stop(): class BaseKolibriProcessBus(ProcessBus): + + extra_channels = ("SERVING", "ZIP_SERVING") + def __init__( self, port=0, @@ -727,6 +729,8 @@ def __init__( self.zip_port = int(zip_port) super(BaseKolibriProcessBus, self).__init__() + for c in self.extra_channels: + self.listeners[c] = set() # This can be removed when a new version of magicbus is released that # includes their fix for Python 3.9 compatibility. self.thread_wait.unsubscribe() @@ -807,6 +811,10 @@ class KolibriProcessBus(KolibriServicesProcessBus): def __init__(self, *args, **kwargs): super(KolibriProcessBus, self).__init__(*args, **kwargs) + for process_hook in KolibriProcessHook.registered_hooks: + process_plugin = process_hook.MagicBusPluginClass(self) + process_plugin.subscribe() + kolibri_server = KolibriServerPlugin( self, self.port, diff --git a/kolibri/utils/server/hooks.py b/kolibri/utils/server/hooks.py new file mode 100644 index 00000000000..e09e92e49ad --- /dev/null +++ b/kolibri/utils/server/hooks.py @@ -0,0 +1,34 @@ +from abc import abstractmethod + +from kolibri.plugins.hooks import define_hook +from kolibri.plugins.hooks import KolibriHook + + +@define_hook +class KolibriProcessHook(KolibriHook): + # A hook to add a magicbus plugin to the full server lifecycle + + @property + @abstractmethod + def MagicBusPluginClass(self): + """ + The magicbus plugin class to use for this hook. + The class may define methods for each of the server lifecycle states: + - log + - INITIAL + - ENTER + - IDLE + - START + - START_ERROR + - RUN + - SERVING + - ZIP_SERVING + - STOP + - STOP_ERROR + - EXIT + - EXIT_ERROR + - EXITED + Can also set a relative priority for the plugin to run in the lifecycle + by setting a priority property on the method. + """ + pass From c78c337b414991526e49df7dad940e16fcfeb479 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 24 Nov 2024 15:49:45 -0800 Subject: [PATCH 3/7] Add class property to check if any hooks are registered. --- kolibri/plugins/hooks.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kolibri/plugins/hooks.py b/kolibri/plugins/hooks.py index b726aee9bf1..824774a79ae 100644 --- a/kolibri/plugins/hooks.py +++ b/kolibri/plugins/hooks.py @@ -296,6 +296,13 @@ def registered_hooks(cls): for hook in cls._registered_hooks.values(): yield hook + @property + def is_registered(cls): + """ + Check if any instances of the class have been registered or not. + """ + return any(cls.registered_hooks) + def _setup_base_class(cls, only_one_registered=False): """ Do any setup required specifically if this class is being setup as a hook definition From 5b952e641a609e7decc3ebdee17012f3eeffbdda Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 24 Nov 2024 15:52:30 -0800 Subject: [PATCH 4/7] Delete app plugin - transfer all functionality to core hooks. --- docs/getting_started.rst | 19 ++-- docs/manual_testing/app_plugin/index.rst | 14 ++- .../development_plugin}/__init__.py | 0 .../development_plugin/kolibri_plugin.py | 72 ++++++++++++++ .../scripts/run_kolibri_app_mode.py | 65 ------------- kolibri/__init__.py | 1 - kolibri/core/auth/api.py | 9 +- kolibri/core/auth/models.py | 4 +- kolibri/core/content/api.py | 39 ++++++++ kolibri/core/content/api_urls.py | 2 + kolibri/core/content/hooks.py | 12 +++ kolibri/core/content/utils/settings.py | 22 +---- kolibri/core/device/api.py | 56 +++++++++++ kolibri/core/device/api_urls.py | 13 +++ kolibri/core/device/hooks.py | 34 +++++++ ...ettings_allow_other_browsers_to_connect.py | 6 +- kolibri/core/device/models.py | 7 +- kolibri/core/device/permissions.py | 6 ++ kolibri/core/device/serializers.py | 20 ++-- kolibri/core/device/test/test_api.py | 74 +++++++++++---- kolibri/core/device/utils.py | 31 ++++++ kolibri/core/kolibri_plugin.py | 11 ++- kolibri/core/tasks/hooks.py | 2 +- kolibri/core/tasks/job.py | 4 +- kolibri/core/tasks/storage.py | 4 +- kolibri/plugins/app/api.py | 94 ------------------- kolibri/plugins/app/api_urls.py | 17 ---- kolibri/plugins/app/kolibri_plugin.py | 5 - kolibri/plugins/app/test/__init__.py | 0 kolibri/plugins/app/test/helpers.py | 13 --- kolibri/plugins/app/test/test_api.py | 53 ----------- kolibri/plugins/app/utils.py | 90 ------------------ package.json | 4 +- packages/kolibri/utils/appCapabilities.js | 10 +- 34 files changed, 377 insertions(+), 436 deletions(-) rename {kolibri/plugins/app => integration_testing/development_plugin}/__init__.py (100%) create mode 100644 integration_testing/development_plugin/kolibri_plugin.py delete mode 100644 integration_testing/scripts/run_kolibri_app_mode.py delete mode 100644 kolibri/plugins/app/api.py delete mode 100644 kolibri/plugins/app/api_urls.py delete mode 100644 kolibri/plugins/app/kolibri_plugin.py delete mode 100644 kolibri/plugins/app/test/__init__.py delete mode 100644 kolibri/plugins/app/test/helpers.py delete mode 100644 kolibri/plugins/app/test/test_api.py delete mode 100644 kolibri/plugins/app/utils.py diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 8aeae8c106c..8be7f9f9d73 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -296,24 +296,17 @@ and in the second terminal, start the webpack build process for frontend assets: Running in App Mode ~~~~~~~~~~~~~~~~~~~ -Some of Kolibri's functionality will differ when being run as a mobile app. In order to run the development server in that "app mode" context, you can use the following commands. +Some of Kolibri's functionality will differ when being run as a mobile app. In order to access the development server in that "app mode" context, open Kolibri using the URL logged in the terminal. -.. code-block:: bash - - # run the Python "app mode" server and the frontend server together: - yarn run app-devserver - - # you may also run the python "app mode" server by itself - # this will require you to run the frontend server in a separate terminal - yarn run app-python-devserver +When the development server is started, you will see a message with a particular URL that you will need to use in order to initialize your browser session properly. Once your browser session has been initialized for use in the app mode, your browser session will remain in this mode until you clear your cookies. -This will run the script located at ``integration_testing/scripts/run_kolibri_app_mode.py``. There you may change the port, register app capabilities (ie, ``os_user``) and make adjustments to meet your needs. +.. code-block:: bash -When the app development server is started, you will see a message with a particular URL that you will need to use in order to initialize your browser session properly. Once your browser session has been initialized for use in the app mode, your browser session will remain in this mode until you clear your cookies, even if you've started your normal Kolibri development server. + Open this URL to activate app mode: http://127.0.0.1:8000/app/api/initialize/ -.. code-block:: bash +Where `` will be a 32-digit hex string. This token is used to authenticate the app mode session. - [app-python-devserver] Kolibri running at: http://127.0.0.1:8000/app/api/initialize/6b91ec2b697042c2b360235894ad2632 +To tweak the behaviour, the plugin that controls the app integrations can be edited in `integration_testing/development_plugin`. Editor configuration diff --git a/docs/manual_testing/app_plugin/index.rst b/docs/manual_testing/app_plugin/index.rst index 6e28e7cb93a..d34df44874a 100644 --- a/docs/manual_testing/app_plugin/index.rst +++ b/docs/manual_testing/app_plugin/index.rst @@ -1,11 +1,9 @@ -Testing Kolibri with app plugin enabled -======================================= +Testing Kolibri in app mode +=========================== -The Kolibri app plugin ----------------------- +App mode +-------- -The Kolibri app plugin is designed to provide features and behavior with the mobile app user in mind. In order to test or develop Kolibri in this mode, there are commands that can be used to initialize Kolibri as needed. +Kolibri app mode is designed to provide features and behavior with the mobile app user in mind. In order to test or develop Kolibri in this mode, a browser session can be set to app mode. -By running the command: `yarn app-python-devserver` you will start Kolibri in development mode. You can also run `yarn app-devserver` to run the frontend devserver in parallel. - -When you start the server with these commands, you will see a message with a URL pointing to `http://127.0.0.1:8000/app/api/initialize/` - visiting this URL will set your browser so that it can interact with Kolibri as it runs with the app plugin. **You will only have to do this once unless you clear your browser storage.** +When you start the server, you will see a message with a URL pointing to `http://127.0.0.1:8000/app/api/initialize/` - visiting this URL will set your browser so that it can interact with Kolibri in app mode. **You will only have to do this once unless you clear your browser storage.** diff --git a/kolibri/plugins/app/__init__.py b/integration_testing/development_plugin/__init__.py similarity index 100% rename from kolibri/plugins/app/__init__.py rename to integration_testing/development_plugin/__init__.py diff --git a/integration_testing/development_plugin/kolibri_plugin.py b/integration_testing/development_plugin/kolibri_plugin.py new file mode 100644 index 00000000000..dd57854953d --- /dev/null +++ b/integration_testing/development_plugin/kolibri_plugin.py @@ -0,0 +1,72 @@ +import logging + +from magicbus.plugins import SimplePlugin + +from kolibri.core.content.hooks import ShareFileHook +from kolibri.core.device.hooks import CheckIsMeteredHook +from kolibri.core.device.hooks import GetOSUserHook +from kolibri.core.tasks.hooks import JobHook +from kolibri.plugins import KolibriPluginBase +from kolibri.plugins.hooks import register_hook +from kolibri.utils.server.hooks import KolibriProcessHook + +logger = logging.getLogger(__name__) + + +class ExampleAppPlugin(KolibriPluginBase): + pass + + +@register_hook +class ExampleAppGetOSUserHook(GetOSUserHook): + def get_os_user(self, auth_token): + return "os_user", True + + +@register_hook +class ExampleAppCheckIsMeteredHook(CheckIsMeteredHook): + def check_is_metered(self): + return True + + +@register_hook +class ExampleAppShareFileHook(ShareFileHook): + def share_file(self, file_path, message): + logger.debug(f"Sharing file {file_path} with message {message}") + + +@register_hook +class ExampleAppJobHook(JobHook): + def schedule(self, job, orm_job): + logger.debug(f"Scheduling job {job} with ORM job {orm_job}") + + def update(self, job, orm_job, state=None, **kwargs): + from kolibri.core.tasks.job import log_status + + log_status(job, orm_job, state=state, **kwargs) + + def clear(self, job, orm_job): + logger.debug(f"Clearing job {job} with ORM job {orm_job}") + + +class AppUrlLoggerPlugin(SimplePlugin): + def SERVING(self, port): + self.port = port + + def RUN(self): + from kolibri.core.device.utils import app_initialize_url + + start_url = "http://127.0.0.1:{port}".format( + port=self.port + ) + app_initialize_url(auth_token="1234") + # Use warning to make sure this message stands out in the console + logger.warning( + "Open this URL to activate app mode: {start_url}".format( + start_url=start_url + ) + ) + + +@register_hook +class DeveloperAppUrlLogger(KolibriProcessHook): + MagicBusPluginClass = AppUrlLoggerPlugin diff --git a/integration_testing/scripts/run_kolibri_app_mode.py b/integration_testing/scripts/run_kolibri_app_mode.py deleted file mode 100644 index 139f13d37a8..00000000000 --- a/integration_testing/scripts/run_kolibri_app_mode.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -import atexit -import logging -import os - -from magicbus.plugins import SimplePlugin - -from kolibri.main import disable_plugin -from kolibri.main import enable_plugin -from kolibri.plugins.app.utils import interface -from kolibri.utils.cli import initialize -from kolibri.utils.server import KolibriProcessBus - - -logger = logging.getLogger(__name__) - - -class AppPlugin(SimplePlugin): - def __init__(self, bus): - self.bus = bus - self.bus.subscribe("SERVING", self.SERVING) - - def SERVING(self, port): - self.port = port - - def RUN(self): - start_url = "http://127.0.0.1:{port}".format( - port=self.port - ) + interface.get_initialize_url(auth_token="1234") - logger.info("Kolibri running at: {start_url}".format(start_url=start_url)) - - -logger.info("Initializing Kolibri and running any upgrade routines") - -# activate app mode -enable_plugin("kolibri.plugins.app") -atexit.register(disable_plugin, "kolibri.plugins.app") - -# Add a task update hook -os.environ["KOLIBRI_UPDATE_HOOKS"] = "kolibri.core.tasks.job.log_status" - -# we need to initialize Kolibri to allow us to access the app key -initialize() - -# start kolibri server -logger.info("Starting kolibri server.") - - -def os_user(auth_token): - return ("os_user", True) - - -def check_is_metered(): - # Set this to the value that suits your needs for testing if on a metered connection - return True - - -interface.register(get_os_user=os_user) -interface.register(check_is_metered=check_is_metered) - - -kolibri_bus = KolibriProcessBus(port=8000) -app_plugin = AppPlugin(kolibri_bus) -app_plugin.subscribe() -kolibri_bus.run() diff --git a/kolibri/__init__.py b/kolibri/__init__.py index bb3be4facce..a90f5e623e1 100755 --- a/kolibri/__init__.py +++ b/kolibri/__init__.py @@ -21,7 +21,6 @@ #: Define it here to avoid introspection malarkey, and to allow for #: import in setup.py for creating a list of plugin entry points. INTERNAL_PLUGINS = [ - "kolibri.plugins.app", "kolibri.plugins.coach", "kolibri.plugins.context_translation", "kolibri.plugins.default_theme", diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index 3a6e1bfa9f6..d65e6271469 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -96,7 +96,6 @@ from kolibri.core.serializers import HexOnlyUUIDField from kolibri.core.utils.pagination import ValuesViewsetPageNumberPagination from kolibri.core.utils.urls import reverse_path -from kolibri.plugins.app.utils import interface from kolibri.utils.urls import validator logger = logging.getLogger(__name__) @@ -928,10 +927,8 @@ def create(self, request): facility_id = request.data.get("facility", None) # Only enforce this when running in an app - if ( - interface.enabled - and not allow_other_browsers_to_connect() - and not valid_app_key_on_request(request) + if not allow_other_browsers_to_connect() and not valid_app_key_on_request( + request ): return Response( [{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}], @@ -939,7 +936,7 @@ def create(self, request): ) user = None - if interface.enabled and valid_app_key_on_request(request): + if valid_app_key_on_request(request): # If we are in app context, then try to get the automatically created OS User # if it matches the username, without needing a password. user = self._check_os_user(request, username) diff --git a/kolibri/core/auth/models.py b/kolibri/core/auth/models.py index d0f6ce2d485..b38513a679f 100644 --- a/kolibri/core/auth/models.py +++ b/kolibri/core/auth/models.py @@ -71,6 +71,7 @@ from kolibri.core.auth.constants.demographics import NOT_SPECIFIED from kolibri.core.auth.constants.demographics import UniqueIdsValidator from kolibri.core.auth.constants.morango_sync import ScopeDefinitions +from kolibri.core.device.hooks import GetOSUserHook from kolibri.core.device.utils import device_provisioned from kolibri.core.device.utils import get_device_setting from kolibri.core.device.utils import is_full_facility_import @@ -79,7 +80,6 @@ from kolibri.core.fields import DateTimeTzField from kolibri.core.fields import JSONField from kolibri.core.utils.validators import JSON_Schema_Validator -from kolibri.plugins.app.utils import interface from kolibri.utils.time_utils import local_now logger = logging.getLogger(__name__) @@ -683,7 +683,7 @@ def get_or_create_os_user(self, auth_token, facility=None): If the user does not exist in the database, it is created. """ try: - os_username, is_superuser = interface.get_os_user(auth_token) + os_username, is_superuser = GetOSUserHook.retrieve_os_user(auth_token) except NotImplementedError: return None if not os_username: diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py index 83724ca071a..0f79b4a5057 100644 --- a/kolibri/core/content/api.py +++ b/kolibri/core/content/api.py @@ -44,6 +44,10 @@ from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.serializers import CharField +from rest_framework.serializers import PrimaryKeyRelatedField +from rest_framework.serializers import Serializer +from rest_framework.views import APIView from kolibri.core.api import BaseValuesViewset from kolibri.core.api import CreateModelMixin @@ -55,6 +59,7 @@ from kolibri.core.bookmarks.models import Bookmark from kolibri.core.content import models from kolibri.core.content import serializers +from kolibri.core.content.hooks import ShareFileHook from kolibri.core.content.models import ContentDownloadRequest from kolibri.core.content.models import ContentRemovalRequest from kolibri.core.content.models import ContentRequestReason @@ -75,11 +80,13 @@ get_channel_stats_from_studio, ) from kolibri.core.content.utils.paths import get_channel_lookup_url +from kolibri.core.content.utils.paths import get_content_storage_file_path from kolibri.core.content.utils.paths import get_local_content_storage_file_url from kolibri.core.content.utils.search import get_available_metadata_labels from kolibri.core.content.utils.stopwords import stopwords_set from kolibri.core.decorators import query_params_required from kolibri.core.device.models import ContentCacheKey +from kolibri.core.device.permissions import FromAppContextPermission from kolibri.core.discovery.utils.network.client import NetworkClient from kolibri.core.discovery.utils.network.errors import NetworkLocationConnectionFailure from kolibri.core.discovery.utils.network.errors import NetworkLocationNotFound @@ -1955,3 +1962,35 @@ def kolibri_studio_status(self, request, **kwargs): NetworkLocationNotFound, ): return Response({"status": "offline", "available": False}) + + +class ShareFileSerializer(Serializer): + content_node = PrimaryKeyRelatedField( + queryset=models.ContentNode.objects.filter(available=True).exclude( + kind__in=[content_kinds.TOPIC, content_kinds.EXERCISE] + ) + ) + message = CharField(max_length=1000) + + +class ShareFileView(APIView): + permission_classes = (IsAuthenticated, FromAppContextPermission) + + def post(self, request): + serializer = ShareFileSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + content_node = serializer.validated_data["content_node"] + message = serializer.validated_data["message"] + # Rely on default priority ordering + default_file = content_node.files.first() + if not default_file: + return Response( + {"error": "No files found for content node"}, + status=status.HTTP_400_BAD_REQUEST, + ) + filepath = get_content_storage_file_path(default_file.local_file.get_filename()) + try: + ShareFileHook.execute_file_share(filepath, message) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_201_CREATED) diff --git a/kolibri/core/content/api_urls.py b/kolibri/core/content/api_urls.py index 0828fde7a75..bedcadaf7a7 100644 --- a/kolibri/core/content/api_urls.py +++ b/kolibri/core/content/api_urls.py @@ -14,6 +14,7 @@ from .api import ContentRequestViewset from .api import FileViewset from .api import RemoteChannelViewSet +from .api import ShareFileView from .api import UserContentNodeViewset router = routers.SimpleRouter() @@ -54,5 +55,6 @@ ChannelThumbnailView.as_view(), name="channel-thumbnail", ), + path("sharefile/", ShareFileView.as_view(), name="sharefile"), re_path(r"^", include(router.urls)), ] diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py index 7ec3a34489e..2d72566933a 100644 --- a/kolibri/core/content/hooks.py +++ b/kolibri/core/content/hooks.py @@ -78,3 +78,15 @@ class ContentNodeDisplayHook(KolibriHook): @abstractmethod def node_url(self, content_node): pass + + +@define_hook +class ShareFileHook(KolibriHook): + @abstractmethod + def share_file(self, filename, message): + pass + + @classmethod + def execute_file_share(cls, filename, message): + for hook in cls.registered_hooks: + hook.share_file(filename, message) diff --git a/kolibri/core/content/utils/settings.py b/kolibri/core/content/utils/settings.py index 7f7a21a3394..984dbb25690 100644 --- a/kolibri/core/content/utils/settings.py +++ b/kolibri/core/content/utils/settings.py @@ -1,6 +1,7 @@ -""" -Avoid adding global imports, as `using_metered_connection` is overridden by the app plugin -""" +from kolibri.core.device.utils import get_device_setting +from kolibri.core.device.utils import using_metered_connection +from kolibri.utils.conf import OPTIONS +from kolibri.utils.system import get_free_space def automatic_download_enabled(): @@ -9,19 +10,10 @@ def automatic_download_enabled(): provisioned, we allow this because the default will be True after provisioning :return: a boolean indicating whether automatic download is enabled """ - from kolibri.core.device.utils import get_device_setting return get_device_setting("enable_automatic_download") -def using_metered_connection(): - """ - Overridden by the app plugin - :return: a boolean indicating whether the device is using a metered connection - """ - return False - - def allow_non_local_download(): """ Checks whether the device is allowed to download content over a metered connection @@ -29,8 +21,6 @@ def allow_non_local_download(): :return: A boolean indicating whether the device is allowed to download content over a metered connection """ - from kolibri.core.device.utils import get_device_setting - return not using_metered_connection() or get_device_setting( "allow_download_on_metered_connection" ) @@ -40,10 +30,6 @@ def get_free_space_for_downloads(completed_size=0): """ :return: The number of bytes of free space on the device, or allocated for automatic downloads """ - from kolibri.core.device.utils import get_device_setting - from kolibri.utils.conf import OPTIONS - - from kolibri.utils.system import get_free_space free_space = get_free_space(OPTIONS["Paths"]["CONTENT_DIR"]) diff --git a/kolibri/core/device/api.py b/kolibri/core/device/api.py index 06dc4bbe0a8..260edc22e2d 100644 --- a/kolibri/core/device/api.py +++ b/kolibri/core/device/api.py @@ -4,6 +4,7 @@ from django.conf import settings from django.contrib.auth import login +from django.core.exceptions import ValidationError from django.db.models import Exists from django.db.models import F from django.db.models import Max @@ -11,9 +12,12 @@ from django.db.models.expressions import Subquery from django.db.models.query import Q from django.http import Http404 +from django.http import HttpResponseRedirect from django.http.response import HttpResponseBadRequest from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.http import urlunquote from django.utils.translation import get_language from django.views.decorators.csrf import csrf_protect from django_filters.rest_framework import DjangoFilterBackend @@ -26,9 +30,11 @@ from rest_framework import status from rest_framework import views from rest_framework import viewsets +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.serializers import Serializer +from rest_framework.views import APIView import kolibri from .models import DevicePermissions @@ -57,7 +63,11 @@ from kolibri.core.content.utils.channels import get_mounted_drives_with_channel_info from kolibri.core.device.models import SyncQueueStatus from kolibri.core.device.permissions import IsSuperuser +from kolibri.core.device.utils import device_provisioned from kolibri.core.device.utils import get_device_setting +from kolibri.core.device.utils import set_app_key_on_response +from kolibri.core.device.utils import using_metered_connection +from kolibri.core.device.utils import valid_app_key from kolibri.core.discovery.models import NetworkLocation from kolibri.core.fields import DateTimeTzField from kolibri.core.public.constants.user_sync_options import DELAYED_SYNC @@ -508,3 +518,49 @@ def get(self, request): "path": resolve_path(pathname), } ) + + +class InitializeAppView(APIView): + def get(self, request, token): + if not valid_app_key(token): + raise PermissionDenied("You have provided an invalid token") + auth_token = request.GET.get("auth_token") + if request.user.is_anonymous and device_provisioned() and auth_token: + # If we are in app context, then login as the automatically created OS User + try: + user = FacilityUser.objects.get_or_create_os_user(auth_token) + if user is not None: + login(request, user) + else: + # If the user is not found, then we should not persist the auth_token + auth_token = None + except ValidationError as e: + logger.error(e) + redirect_url = request.GET.get("next", "/") + # Copied and modified from https://github.com/django/django/blob/stable/1.11.x/django/views/i18n.py#L40 + if ( + redirect_url or not request.is_ajax() + ) and not url_has_allowed_host_and_scheme( + url=redirect_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + redirect_url = request.META.get("HTTP_REFERER") + if redirect_url: + redirect_url = urlunquote(redirect_url) # HTTP_REFERER may be encoded. + if not url_has_allowed_host_and_scheme( + url=redirect_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + redirect_url = "/" + response = HttpResponseRedirect(redirect_url) + set_app_key_on_response(response, auth_token) + return response + + +class CheckMeteredConnectionView(APIView): + permission_classes = (IsAuthenticated,) + + def get(self, request): + return Response(using_metered_connection()) diff --git a/kolibri/core/device/api_urls.py b/kolibri/core/device/api_urls.py index 29a4b4ac689..db45d5ad82a 100644 --- a/kolibri/core/device/api_urls.py +++ b/kolibri/core/device/api_urls.py @@ -1,7 +1,9 @@ from django.urls import include +from django.urls import path from django.urls import re_path from rest_framework import routers +from .api import CheckMeteredConnectionView from .api import DeviceInfoView from .api import DeviceNameView from .api import DevicePermissionsViewSet @@ -10,6 +12,7 @@ from .api import DeviceSettingsView from .api import DriveInfoViewSet from .api import FreeSpaceView +from .api import InitializeAppView from .api import PathPermissionView from .api import UserSyncStatusViewSet @@ -34,4 +37,14 @@ re_path(r"^devicename/", DeviceNameView.as_view(), name="devicename"), re_path(r"^devicerestart/", DeviceRestartView.as_view(), name="devicerestart"), re_path(r"^pathpermission/", PathPermissionView.as_view(), name="pathpermission"), + re_path( + r"^initialize/([0-9a-f]{32})$", + InitializeAppView.as_view(), + name="initialize_app", + ), + path( + "check_metered_connection/", + CheckMeteredConnectionView.as_view(), + name="check_metered_connection", + ), ] diff --git a/kolibri/core/device/hooks.py b/kolibri/core/device/hooks.py index 0cae36d8f5e..3ae4e3ac465 100644 --- a/kolibri/core/device/hooks.py +++ b/kolibri/core/device/hooks.py @@ -21,3 +21,37 @@ def plugin_url(self, plugin_class, url_name): @classmethod def provision_url(cls): return next(hook.url for hook in cls.registered_hooks) + + +@define_hook(only_one_registered=True) +class GetOSUserHook(KolibriHook): + @abstractmethod + def get_os_user(self, auth_token): + pass + + @classmethod + def retrieve_os_user(cls, auth_token): + try: + hook = next(cls.registered_hooks) + except StopIteration: + raise NotImplementedError( + "Getting the OS user is not supported on this platform" + ) + return hook.get_os_user(auth_token) + + +@define_hook(only_one_registered=True) +class CheckIsMeteredHook(KolibriHook): + @abstractmethod + def check_is_metered(self): + pass + + @classmethod + def execute_is_metered_check(cls): + try: + hook = next(cls.registered_hooks) + except StopIteration: + raise NotImplementedError( + "Checking if the connection is metered is not supported on this platform" + ) + return hook.check_is_metered() diff --git a/kolibri/core/device/migrations/0008_devicesettings_allow_other_browsers_to_connect.py b/kolibri/core/device/migrations/0008_devicesettings_allow_other_browsers_to_connect.py index d046872b047..a2310aa337c 100644 --- a/kolibri/core/device/migrations/0008_devicesettings_allow_other_browsers_to_connect.py +++ b/kolibri/core/device/migrations/0008_devicesettings_allow_other_browsers_to_connect.py @@ -3,8 +3,6 @@ from django.db import migrations from django.db import models -import kolibri.core.device.models - class Migration(migrations.Migration): @@ -14,8 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name="devicesettings", name="allow_other_browsers_to_connect", - field=models.BooleanField( - default=kolibri.core.device.models.app_is_enabled - ), + field=models.BooleanField(default=True), ) ] diff --git a/kolibri/core/device/models.py b/kolibri/core/device/models.py index 5f171b16254..83254e5a079 100644 --- a/kolibri/core/device/models.py +++ b/kolibri/core/device/models.py @@ -33,7 +33,6 @@ from kolibri.core.utils.lock import retry_on_db_lock 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.data import ChoicesEnum from kolibri.utils.options import update_options_file @@ -91,10 +90,6 @@ def get_device_hostname(): return hostname[:50] -def app_is_enabled(): - return interface.enabled - - DEFAULT_DEMOGRAPHIC_FIELDS_KEY = "default_demographic_field_schema" @@ -161,7 +156,7 @@ class DeviceSettings(models.Model): # What's the name of this device? name = models.CharField(max_length=50, default=get_device_hostname) # Should this device allow browser sessions from non-localhost devices? - allow_other_browsers_to_connect = models.BooleanField(default=app_is_enabled) + allow_other_browsers_to_connect = models.BooleanField(default=True) # Is this a device that only synchronizes data about a subset of users? subset_of_users_device = models.BooleanField(default=False) diff --git a/kolibri/core/device/permissions.py b/kolibri/core/device/permissions.py index 3d3ae119b65..baf6d896e2f 100644 --- a/kolibri/core/device/permissions.py +++ b/kolibri/core/device/permissions.py @@ -1,6 +1,7 @@ from rest_framework.permissions import BasePermission from kolibri.core.auth.permissions.general import DenyAll +from kolibri.core.device.utils import valid_app_key_on_request class NotProvisionedCanPost(BasePermission): @@ -41,3 +42,8 @@ def has_permission(self, request, view): class IsNotAnonymous(DenyAll): def has_permission(self, request, view): return request.user.is_authenticated + + +class FromAppContextPermission(BasePermission): + def has_permission(self, request, view): + return valid_app_key_on_request(request) diff --git a/kolibri/core/device/serializers.py b/kolibri/core/device/serializers.py index eebe98feb86..a8312f7d622 100644 --- a/kolibri/core/device/serializers.py +++ b/kolibri/core/device/serializers.py @@ -11,6 +11,7 @@ from kolibri.core.auth.serializers import FacilitySerializer from kolibri.core.content.tasks import automatic_resource_import from kolibri.core.content.tasks import automatic_synchronize_content_requests_and_import +from kolibri.core.device.hooks import GetOSUserHook from kolibri.core.device.models import DevicePermissions from kolibri.core.device.models import DeviceSettings from kolibri.core.device.models import OSUser @@ -18,8 +19,6 @@ from kolibri.core.device.utils import provision_device from kolibri.core.device.utils import provision_single_user_device from kolibri.core.device.utils import valid_app_key_on_request -from kolibri.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 @@ -77,7 +76,7 @@ class Meta: def validate(self, data): if ( - GET_OS_USER in interface + GetOSUserHook.is_registered and "request" in self.context and valid_app_key_on_request(self.context["request"]) ): @@ -192,10 +191,15 @@ def create(self, validated_data): # noqa C901 if auth_token: # If we have an auth token, we need to create an OSUser for the superuser # so that we can associate the user with the OSUser - os_username, _ = interface.get_os_user(auth_token) - OSUser.objects.update_or_create( - os_username=os_username, defaults={"user": superuser} - ) + try: + os_username, _ = GetOSUserHook.retrieve_os_user(auth_token) + OSUser.objects.update_or_create( + os_username=os_username, defaults={"user": superuser} + ) + except NotImplementedError: + raise ParseError( + "Getting the OS user is not supported on this platform" + ) elif auth_token: superuser = FacilityUser.objects.get_or_create_os_user( @@ -234,6 +238,8 @@ def create(self, validated_data): # noqa C901 "default_facility": facility, "allow_guest_access": allow_guest_access, "allow_learner_download_resources": allow_learner_download_resources, + # If we're setting up in an app context, set this to False + "allow_other_browsers_to_connect": not auth_token, } if is_soud: diff --git a/kolibri/core/device/test/test_api.py b/kolibri/core/device/test/test_api.py index 17e7f31e03f..3d5afe7971c 100644 --- a/kolibri/core/device/test/test_api.py +++ b/kolibri/core/device/test/test_api.py @@ -5,6 +5,7 @@ import mock from django.conf import settings +from django.contrib.auth import SESSION_KEY from django.core.exceptions import ValidationError from django.urls import reverse from django.utils import timezone @@ -40,13 +41,9 @@ from kolibri.core.device.models import StatusSentiment from kolibri.core.device.models import SyncQueueStatus from kolibri.core.device.models import UserSyncStatus +from kolibri.core.device.utils import app_initialize_url from kolibri.core.public.constants import user_sync_statuses from kolibri.core.public.constants.user_sync_options import DELAYED_SYNC -from kolibri.plugins.app.test.helpers import register_capabilities -from kolibri.plugins.app.utils import GET_OS_USER -from kolibri.plugins.app.utils import interface -from kolibri.plugins.utils.test.helpers import plugin_disabled -from kolibri.plugins.utils.test.helpers import plugin_enabled from kolibri.utils.conf import OPTIONS from kolibri.utils.tests.helpers import override_option @@ -201,18 +198,17 @@ def test_create_superuser_error(self): response = self._post_deviceprovision(data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_osuser_superuser_error_no_app(self): - with plugin_disabled("kolibri.plugins.app"): - data = self._default_provision_data() - del data["superuser"] - response = self._post_deviceprovision(data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_osuser_superuser_created(self): - with plugin_enabled("kolibri.plugins.app"), register_capabilities( - **{GET_OS_USER: lambda x: ("test_user", True)} - ): - initialize_url = interface.get_initialize_url(auth_token="test") + with mock.patch( + "kolibri.core.device.serializers.GetOSUserHook", autospec=True + ) as mock_hook1, mock.patch( + "kolibri.core.auth.models.GetOSUserHook" + ) as mock_hook2: + mock_hook1.retrieve_os_user.return_value = ( + mock_hook2.retrieve_os_user.return_value + ) = ("test_user", True) + mock_hook1.is_registered = mock_hook2.is_registered = True + initialize_url = app_initialize_url(auth_token="test") self.client.get(initialize_url) data = self._default_provision_data() del data["superuser"] @@ -224,6 +220,9 @@ def test_osuser_superuser_created(self): FacilityUser.objects.get().devicepermissions, ) self.assertTrue(FacilityUser.objects.get().os_user) + self.assertFalse( + DeviceSettings.objects.get().allow_other_browsers_to_connect + ) def test_imported_facility_no_update(self): facility = Facility.objects.create(name="This is a test") @@ -1008,3 +1007,46 @@ def test_csrf_protected_deviceprovision(self): format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class InitializeEndpointTestCase(APITestCase): + @classmethod + def setUpTestData(cls): + cls.facility = FacilityFactory.create() + provision_device(default_facility=cls.facility) + cls.superuser = create_superuser(cls.facility) + + def test_os_user_capability_enabled_log_in(self): + with mock.patch( + "kolibri.core.auth.models.GetOSUserHook.retrieve_os_user", + return_value=("test_user", False), + ): + initialize_url = app_initialize_url(auth_token="test") + self.client.get(initialize_url) + session_data = self.client.session.load() + user_id = session_data.get(SESSION_KEY) + user = FacilityUser.objects.get(id=user_id) + self.assertTrue(user.os_user) + self.assertEqual(user.os_user.os_username, "test_user") + self.assertNotEqual(self.superuser.id, user.id) + + def test_no_os_user_capability_no_log_in(self): + initialize_url = app_initialize_url() + self.client.get(initialize_url) + session_data = self.client.session.load() + user_id = session_data.get(SESSION_KEY) + self.assertIsNone(user_id) + + def test_os_user_capability_enabled_already_logged_in_no_change(self): + with mock.patch( + "kolibri.core.auth.models.GetOSUserHook.retrieve_os_user", + return_value=("test_user", False), + ): + self.client.login(username=self.superuser.username, password="password") + initialize_url = app_initialize_url(auth_token="test") + self.client.get(initialize_url) + session_data = self.client.session.load() + user_id = session_data.get(SESSION_KEY) + user = FacilityUser.objects.get(id=user_id) + self.assertFalse(hasattr(user, "os_user")) + self.assertEqual(self.superuser.id, user.id) diff --git a/kolibri/core/device/utils.py b/kolibri/core/device/utils.py index 7209bd878e2..f16be90acb6 100644 --- a/kolibri/core/device/utils.py +++ b/kolibri/core/device/utils.py @@ -13,10 +13,13 @@ from django.db import transaction from django.db.utils import OperationalError from django.db.utils import ProgrammingError +from django.http import QueryDict +from django.urls import reverse import kolibri from kolibri.core.auth.constants.facility_presets import mappings from kolibri.core.content.constants.schema_versions import MIN_CONTENT_SCHEMA_VERSION +from kolibri.core.device.hooks import CheckIsMeteredHook from kolibri.utils.android import ANDROID_PLATFORM_SYSTEM_VALUE from kolibri.utils.android import on_android from kolibri.utils.lru_cache import lru_cache @@ -494,3 +497,31 @@ def is_full_facility_import(dataset_id): .filter(scope_definition_id=ScopeDefinitions.FULL_FACILITY) .exists() ) + + +def using_metered_connection(): + """ + :return: a boolean indicating whether the device is using a metered connection + """ + try: + return CheckIsMeteredHook.execute_is_metered_check() + except NotImplementedError: + return False + + +def app_initialize_url(next_url=None, auth_token=None): + from kolibri.core.device.models import DeviceAppKey + + url = reverse( + "kolibri:core:initialize_app", + args=(DeviceAppKey.get_app_key(),), + ) + query_dict = QueryDict(mutable=True) + + if auth_token is not None: + query_dict["auth_token"] = auth_token + + if next_url is not None: + query_dict["next"] = next_url + query_string = query_dict.urlencode() + return url + ("?" + query_string if query_string else "") diff --git a/kolibri/core/kolibri_plugin.py b/kolibri/core/kolibri_plugin.py index 5d2739df422..bdc82665253 100644 --- a/kolibri/core/kolibri_plugin.py +++ b/kolibri/core/kolibri_plugin.py @@ -9,17 +9,18 @@ from django_js_reverse.core import generate_json import kolibri +from kolibri.core.content.hooks import ShareFileHook from kolibri.core.content.utils.paths import get_content_storage_url from kolibri.core.content.utils.paths import get_hashi_path from kolibri.core.content.utils.paths import get_zip_content_base_path from kolibri.core.content.utils.paths import get_zip_content_config +from kolibri.core.device.hooks import CheckIsMeteredHook from kolibri.core.device.utils import allow_other_browsers_to_connect from kolibri.core.hooks import FrontEndBaseHeadHook from kolibri.core.hooks import NavigationHook from kolibri.core.oidc_provider_hook import OIDCProviderHook from kolibri.core.theme_hook import ThemeHook from kolibri.core.webpack.hooks import WebpackBundleHook -from kolibri.plugins.app.utils import interface from kolibri.plugins.hooks import register_hook from kolibri.utils import i18n from kolibri.utils.conf import OPTIONS @@ -90,9 +91,11 @@ def plugin_data(self): "fullCSSFileBasic": full_file.format( static_root, language_code, "basic", kolibri.__version__ ), - "allowRemoteAccess": allow_other_browsers_to_connect() - or not interface.enabled, - "appCapabilities": interface.capabilities, + "allowRemoteAccess": allow_other_browsers_to_connect(), + "appCapabilities": { + "check_is_metered": CheckIsMeteredHook.is_registered, + "share_file": ShareFileHook.is_registered, + }, "languageGlobals": self.language_globals(), "oidcProviderEnabled": OIDCProviderHook.is_enabled(), "kolibriTheme": ThemeHook.get_theme(), diff --git a/kolibri/core/tasks/hooks.py b/kolibri/core/tasks/hooks.py index ce567f0d26a..280d703f136 100644 --- a/kolibri/core/tasks/hooks.py +++ b/kolibri/core/tasks/hooks.py @@ -5,7 +5,7 @@ @define_hook -class StorageHook(KolibriHook): +class JobHook(KolibriHook): @abstractmethod def schedule(self, job, orm_job): pass diff --git a/kolibri/core/tasks/job.py b/kolibri/core/tasks/job.py index 6c32e46f0cb..5359c83c6ea 100644 --- a/kolibri/core/tasks/job.py +++ b/kolibri/core/tasks/job.py @@ -395,6 +395,6 @@ def log_status(job, orm_job, state=None, **kwargs): status = job.status(translation.get_language()) if status: - logging.info(status.title) + logging.debug(status.title) if status.text: - logging.info(status.text) + logging.debug(status.text) diff --git a/kolibri/core/tasks/storage.py b/kolibri/core/tasks/storage.py index 5f4145c3965..052527ab2a3 100644 --- a/kolibri/core/tasks/storage.py +++ b/kolibri/core/tasks/storage.py @@ -24,7 +24,7 @@ from kolibri.core.tasks.exceptions import JobNotFound from kolibri.core.tasks.exceptions import JobNotRestartable from kolibri.core.tasks.exceptions import JobRunning -from kolibri.core.tasks.hooks import StorageHook +from kolibri.core.tasks.hooks import JobHook from kolibri.core.tasks.job import Job from kolibri.core.tasks.job import State from kolibri.core.tasks.validation import validate_interval @@ -102,7 +102,7 @@ def __init__(self, connection, Base=Base): self.Base = Base self.Base.metadata.create_all(self.engine) self.sessionmaker = sessionmaker(bind=self.engine) - self._hooks = list(StorageHook.registered_hooks) + self._hooks = list(JobHook.registered_hooks) @contextmanager def session_scope(self): diff --git a/kolibri/plugins/app/api.py b/kolibri/plugins/app/api.py deleted file mode 100644 index bfa1f3e835d..00000000000 --- a/kolibri/plugins/app/api.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging - -from django.contrib.auth import login -from django.core.exceptions import ValidationError -from django.http import HttpResponseRedirect -from django.utils.http import url_has_allowed_host_and_scheme -from django.utils.http import urlunquote -from rest_framework.decorators import action -from rest_framework.exceptions import APIException -from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import BasePermission -from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework.viewsets import ViewSet - -from kolibri.core.auth.models import FacilityUser -from kolibri.core.device.utils import device_provisioned -from kolibri.core.device.utils import set_app_key_on_response -from kolibri.core.device.utils import valid_app_key -from kolibri.core.device.utils import valid_app_key_on_request -from kolibri.plugins.app.utils import CHECK_IS_METERED -from kolibri.plugins.app.utils import interface -from kolibri.plugins.app.utils import SHARE_FILE - - -logger = logging.getLogger(__name__) - - -class FromAppContextPermission(BasePermission): - def has_permission(self, request, view): - return valid_app_key_on_request(request) - - -class AppCommandsViewset(ViewSet): - - permission_classes = (FromAppContextPermission,) - - if SHARE_FILE in interface: - - @action(detail=False, methods=["post"]) - def share_file(self, request): - filename = request.data.get("filename") - message = request.data.get("message") - if filename is None or message is None: - raise APIException( - "filename and message parameters must be defined", code=412 - ) - interface.share_file(filename, message) - return Response() - - if CHECK_IS_METERED in interface: - - @action(detail=False, methods=["get"]) - def check_is_metered(self, request): - return Response({"value": interface.check_is_metered()}) - - -class InitializeAppView(APIView): - def get(self, request, token): - if not valid_app_key(token): - raise PermissionDenied("You have provided an invalid token") - auth_token = request.GET.get("auth_token") - if request.user.is_anonymous and device_provisioned() and auth_token: - # If we are in app context, then login as the automatically created OS User - try: - user = FacilityUser.objects.get_or_create_os_user(auth_token) - if user is not None: - login(request, user) - else: - # If the user is not found, then we should not persist the auth_token - auth_token = None - except ValidationError as e: - logger.error(e) - redirect_url = request.GET.get("next", "/") - # Copied and modified from https://github.com/django/django/blob/stable/1.11.x/django/views/i18n.py#L40 - if ( - redirect_url or not request.is_ajax() - ) and not url_has_allowed_host_and_scheme( - url=redirect_url, - allowed_hosts={request.get_host()}, - require_https=request.is_secure(), - ): - redirect_url = request.META.get("HTTP_REFERER") - if redirect_url: - redirect_url = urlunquote(redirect_url) # HTTP_REFERER may be encoded. - if not url_has_allowed_host_and_scheme( - url=redirect_url, - allowed_hosts={request.get_host()}, - require_https=request.is_secure(), - ): - redirect_url = "/" - response = HttpResponseRedirect(redirect_url) - set_app_key_on_response(response, auth_token) - return response diff --git a/kolibri/plugins/app/api_urls.py b/kolibri/plugins/app/api_urls.py deleted file mode 100644 index af248231d35..00000000000 --- a/kolibri/plugins/app/api_urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import include -from django.urls import re_path -from rest_framework import routers - -from .api import AppCommandsViewset -from .api import InitializeAppView - -router = routers.DefaultRouter() - -router.register(r"appcommands", AppCommandsViewset, basename="appcommands") - -urlpatterns = [ - re_path(r"^", include(router.urls)), - re_path( - r"^initialize/([0-9a-f]{32})$", InitializeAppView.as_view(), name="initialize" - ), -] diff --git a/kolibri/plugins/app/kolibri_plugin.py b/kolibri/plugins/app/kolibri_plugin.py deleted file mode 100644 index 5943f63728d..00000000000 --- a/kolibri/plugins/app/kolibri_plugin.py +++ /dev/null @@ -1,5 +0,0 @@ -from kolibri.plugins import KolibriPluginBase - - -class App(KolibriPluginBase): - untranslated_view_urls = "api_urls" diff --git a/kolibri/plugins/app/test/__init__.py b/kolibri/plugins/app/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/kolibri/plugins/app/test/helpers.py b/kolibri/plugins/app/test/helpers.py deleted file mode 100644 index 95077d43961..00000000000 --- a/kolibri/plugins/app/test/helpers.py +++ /dev/null @@ -1,13 +0,0 @@ -from contextlib import contextmanager - -from kolibri.plugins.app.utils import interface - - -@contextmanager -def register_capabilities(**capabilities): - interface.register(**capabilities) - try: - yield - finally: - for capability in capabilities: - del interface._capabilities[capability] diff --git a/kolibri/plugins/app/test/test_api.py b/kolibri/plugins/app/test/test_api.py deleted file mode 100644 index 0546596cef2..00000000000 --- a/kolibri/plugins/app/test/test_api.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.contrib.auth import SESSION_KEY -from rest_framework.test import APITestCase - -from kolibri.core.auth.models import FacilityUser -from kolibri.core.auth.test.helpers import create_superuser -from kolibri.core.auth.test.helpers import provision_device -from kolibri.core.auth.test.test_api import FacilityFactory -from kolibri.plugins.app.test.helpers import register_capabilities -from kolibri.plugins.app.utils import GET_OS_USER -from kolibri.plugins.app.utils import interface -from kolibri.plugins.utils.test.helpers import plugin_enabled - - -class InitializeEndpointTestCase(APITestCase): - @classmethod - def setUpTestData(cls): - cls.facility = FacilityFactory.create() - provision_device(default_facility=cls.facility) - cls.superuser = create_superuser(cls.facility) - - def test_os_user_capability_enabled_log_in(self): - with plugin_enabled("kolibri.plugins.app"), register_capabilities( - **{GET_OS_USER: lambda x: ("test_user", False)} - ): - initialize_url = interface.get_initialize_url(auth_token="test") - self.client.get(initialize_url) - session_data = self.client.session.load() - user_id = session_data.get(SESSION_KEY) - user = FacilityUser.objects.get(id=user_id) - self.assertTrue(user.os_user) - self.assertEqual(user.os_user.os_username, "test_user") - self.assertNotEqual(self.superuser.id, user.id) - - def test_no_os_user_capability_no_log_in(self): - with plugin_enabled("kolibri.plugins.app"): - initialize_url = interface.get_initialize_url() - self.client.get(initialize_url) - session_data = self.client.session.load() - user_id = session_data.get(SESSION_KEY) - self.assertIsNone(user_id) - - def test_os_user_capability_enabled_already_logged_in_no_change(self): - with plugin_enabled("kolibri.plugins.app"), register_capabilities( - **{GET_OS_USER: lambda x: ("test_user", False)} - ): - self.client.login(username=self.superuser.username, password="password") - initialize_url = interface.get_initialize_url(auth_token="test") - self.client.get(initialize_url) - session_data = self.client.session.load() - user_id = session_data.get(SESSION_KEY) - user = FacilityUser.objects.get(id=user_id) - self.assertFalse(hasattr(user, "os_user")) - self.assertEqual(self.superuser.id, user.id) diff --git a/kolibri/plugins/app/utils.py b/kolibri/plugins/app/utils.py deleted file mode 100644 index 5727ecb0eb6..00000000000 --- a/kolibri/plugins/app/utils.py +++ /dev/null @@ -1,90 +0,0 @@ -from django.http.request import QueryDict -from django.urls import reverse - -from kolibri.core.content.utils import settings -from kolibri.plugins.app.kolibri_plugin import App -from kolibri.plugins.registry import registered_plugins - - -SHARE_FILE = "share_file" - -GET_OS_USER = "get_os_user" - -CHECK_IS_METERED = "check_is_metered" - -CAPABILITES = ( - SHARE_FILE, - GET_OS_USER, - CHECK_IS_METERED, -) - - -class AppInterface(object): - __slot__ = "_capabilities" - - def __init__(self): - self._capabilities = {} - - def __contains__(self, capability): - return self.enabled and (capability in self._capabilities) - - def register(self, **kwargs): - for capability in CAPABILITES: - if capability in kwargs: - self._capabilities[capability] = kwargs[capability] - # override the settings module with the function - if capability == CHECK_IS_METERED: - settings.using_metered_connection = kwargs[capability] - - def get_initialize_url(self, next_url=None, auth_token=None): - if not self.enabled: - raise RuntimeError("App plugin is not enabled") - # Import here to prevent a circular import - from kolibri.core.device.models import DeviceAppKey - - url = reverse( - "kolibri:kolibri.plugins.app:initialize", - args=(DeviceAppKey.get_app_key(),), - ) - query_dict = QueryDict(mutable=True) - - if auth_token is not None: - query_dict["auth_token"] = auth_token - - if next_url is not None: - query_dict["next"] = next_url - query_string = query_dict.urlencode() - return url + ("?" + query_string if query_string else "") - - @property - def enabled(self): - return App in registered_plugins - - @property - def capabilities(self): - if self.enabled: - return {key: (key in self._capabilities) for key in CAPABILITES} - return {key: False for key in CAPABILITES} - - def share_file(self, filename, message): - if SHARE_FILE not in self._capabilities: - raise NotImplementedError("Sharing files is not supported on this platform") - return self._capabilities[SHARE_FILE](filename=filename, message=message) - - def check_is_metered(self): - if CHECK_IS_METERED not in self._capabilities: - raise NotImplementedError( - "Checking if the connection is metered is not supported on this platform" - ) - return self._capabilities[CHECK_IS_METERED]() - - def get_os_user(self, auth_token): - if GET_OS_USER not in self._capabilities: - raise NotImplementedError( - "Getting the OS user is not supported on this platform" - ) - os_user, is_superuser = self._capabilities[GET_OS_USER](auth_token) - return os_user, is_superuser - - -interface = AppInterface() diff --git a/package.json b/package.json index e2db71ecb99..a9d3c432e61 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,9 @@ "transfercontext": "kolibri-tools i18n-transfer-context --pluginFile ./build_tools/build_plugins.txt --namespace kolibri-common --searchPath ./packages/kolibri-common", "watch": "kolibri-tools build dev --file ./build_tools/build_plugins.txt --cache", "watch-hot": "yarn run watch --hot", - "app-python-devserver": "DJANGO_SETTINGS_MODULE=kolibri.deployment.default.settings.dev python ./integration_testing/scripts/run_kolibri_app_mode.py", - "python-devserver": "kolibri start --debug --foreground --port=8000 --settings=kolibri.deployment.default.settings.dev", + "python-devserver": "KOLIBRI_PLUGIN_ENABLE=development_plugin kolibri start --debug --foreground --port=8000 --settings=kolibri.deployment.default.settings.dev --pythonpath=./integration_testing", "python-devserver-no-update": "kolibri start --debug --skip-update --foreground --port=8000 --settings=kolibri.deployment.default.settings.dev", "frontend-devserver": "concurrently --passthrough-arguments --kill-others \"yarn:watch --watchonly {1}\" yarn:hashi-dev --", - "app-devserver": "concurrently --passthrough-arguments --kill-others \"yarn:watch --watchonly {1}\" yarn:app-python-devserver yarn:hashi-dev --", "devserver": "concurrently --passthrough-arguments --kill-others \"yarn:watch --watchonly {1}\" yarn:python-devserver yarn:hashi-dev --", "devserver-hot": "concurrently --passthrough-arguments --kill-others \"yarn:watch-hot --watchonly {1}\" yarn:python-devserver yarn:hashi-dev --", "devserver-with-kds": "concurrently --passthrough-arguments --kill-others \"yarn:watch --watchonly={2} --require-kds-path --kds-path={1}\" yarn:python-devserver yarn:hashi-dev --", diff --git a/packages/kolibri/utils/appCapabilities.js b/packages/kolibri/utils/appCapabilities.js index 4757bccbdbb..06f596f06de 100644 --- a/packages/kolibri/utils/appCapabilities.js +++ b/packages/kolibri/utils/appCapabilities.js @@ -32,12 +32,12 @@ export default { return Promise.resolve(null); } - const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands_check_is_metered']; + const urlFunction = urls['kolibri:core:check_metered_connection']; if (!urlFunction || !checkCapability('check_is_metered')) { logging.warn('Checking if the device is metered is not supported on this platform'); return Promise.resolve(null); } - return client({ url: urlFunction(), method: 'GET' }).then(response => response.data.value); + return client({ url: urlFunction(), method: 'GET' }).then(response => response.data); }, get shareFile() { if (!checkCapability('share_file')) { @@ -48,8 +48,8 @@ export default { // maintain backwards compatibility. // It would be more elegant to use a proxy for this, but that would require // adding a polyfill for this specific usage, so this works just as well. - return ({ filename, message }) => { - const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands_share_file']; + return ({ content_node, message }) => { + const urlFunction = urls['kolibri:core:sharefile']; if (!urlFunction) { logging.warn('Sharing a file is not supported on this platform'); return Promise.reject(); @@ -57,7 +57,7 @@ export default { return client({ url: urlFunction(), method: 'POST', - data: { filename, message }, + data: { content_node, message }, }); }; }, From 7d94bc5b6a155f7fa60a764f8d861f0766aef2a3 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 25 Nov 2024 08:41:55 -0800 Subject: [PATCH 5/7] Move auto reloader attachment into development plugin. --- .../development_plugin/kolibri_plugin.py | 18 ++++++++++++++++++ kolibri/utils/server/__init__.py | 9 --------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/integration_testing/development_plugin/kolibri_plugin.py b/integration_testing/development_plugin/kolibri_plugin.py index dd57854953d..533011de59a 100644 --- a/integration_testing/development_plugin/kolibri_plugin.py +++ b/integration_testing/development_plugin/kolibri_plugin.py @@ -1,6 +1,8 @@ import logging +import os from magicbus.plugins import SimplePlugin +from magicbus.plugins.tasks import Autoreloader from kolibri.core.content.hooks import ShareFileHook from kolibri.core.device.hooks import CheckIsMeteredHook @@ -70,3 +72,19 @@ def RUN(self): @register_hook class DeveloperAppUrlLogger(KolibriProcessHook): MagicBusPluginClass = AppUrlLoggerPlugin + + +class KolibriAutoReloader(Autoreloader): + def __init__(self, bus): + super().__init__(bus) + from kolibri.utils import conf + + plugins = os.path.join(conf.KOLIBRI_HOME, "plugins.json") + options = os.path.join(conf.KOLIBRI_HOME, "options.ini") + self.files.add(plugins) + self.files.add(options) + + +@register_hook +class KolibriAutoReloadHook(KolibriProcessHook): + MagicBusPluginClass = KolibriAutoReloader diff --git a/kolibri/utils/server/__init__.py b/kolibri/utils/server/__init__.py index 8f754cb5dcf..b80d3904e85 100644 --- a/kolibri/utils/server/__init__.py +++ b/kolibri/utils/server/__init__.py @@ -21,7 +21,6 @@ from magicbus.plugins.servers import wait_for_free_port from magicbus.plugins.servers import wait_for_occupied_port from magicbus.plugins.signalhandler import SignalHandler as BaseSignalHandler -from magicbus.plugins.tasks import Autoreloader from magicbus.plugins.tasks import Monitor from zeroconf import get_all_addresses from zeroconf import InterfaceChoice @@ -755,14 +754,6 @@ def __init__( signal_handler.subscribe() - if getattr(settings, "DEVELOPER_MODE", False): - autoreloader = Autoreloader(self) - plugins = os.path.join(conf.KOLIBRI_HOME, "plugins.json") - options = os.path.join(conf.KOLIBRI_HOME, "options.ini") - autoreloader.files.add(plugins) - autoreloader.files.add(options) - autoreloader.subscribe() - reload_plugin = ProcessControlPlugin(self) reload_plugin.subscribe() From 0a923cf1105ec4875575ee3679ec1ea7a40a03ed Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Thu, 19 Dec 2024 09:20:36 -0800 Subject: [PATCH 6/7] Remove use of checkCapability function in favour of more explicit checks. --- .../src/views/DeviceSettingsPage/index.vue | 4 +-- kolibri/plugins/device/kolibri_plugin.py | 2 ++ .../MeteredConnectionNotificationModal.vue | 7 +++-- .../assets/src/views/LibraryPage/index.vue | 11 ++++---- kolibri/plugins/learn/kolibri_plugin.py | 2 ++ .../assets/src/machines/wizardMachine.js | 16 +++++++---- .../assets/src/views/SetupWizardIndex.vue | 11 ++++++-- .../onboarding-forms/SettingUpKolibri.vue | 11 ++++++-- .../plugins/setup_wizard/kolibri_plugin.py | 5 +++- .../utils/checkMeteredConnection.js | 12 ++++++++ packages/kolibri/utils/appCapabilities.js | 28 ------------------- 11 files changed, 58 insertions(+), 51 deletions(-) rename {packages/kolibri-common/components => kolibri/plugins/learn/assets/src/views/LibraryPage}/MeteredConnectionNotificationModal.vue (95%) create mode 100644 packages/kolibri-common/utils/checkMeteredConnection.js diff --git a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue index d0912e9dda2..45a4d549889 100644 --- a/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue +++ b/kolibri/plugins/device/assets/src/views/DeviceSettingsPage/index.vue @@ -378,12 +378,12 @@ import urls from 'kolibri/urls'; import logger from 'kolibri-logging'; import { ref, watch } from 'vue'; + import pluginData from 'kolibri-plugin-data'; import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import UiAlert from 'kolibri-design-system/lib/keen/UiAlert'; import { availableLanguages, currentLanguage, sortLanguages } from 'kolibri/utils/i18n'; import BottomAppBar from 'kolibri/components/BottomAppBar'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; - import { checkCapability } from 'kolibri/utils/appCapabilities'; import useUser from 'kolibri/composables/useUser'; import useSnackbar from 'kolibri/composables/useSnackbar'; import commonDeviceStrings from '../commonDeviceStrings'; @@ -603,7 +603,7 @@ } }, canCheckMeteredConnection() { - return checkCapability('check_is_metered'); + return pluginData.canCheckMeteredConnection; }, showDisabledAlert() { return this.isRemoteContent || !this.canRestart; diff --git a/kolibri/plugins/device/kolibri_plugin.py b/kolibri/plugins/device/kolibri_plugin.py index 9945202c4d3..af5cda87131 100644 --- a/kolibri/plugins/device/kolibri_plugin.py +++ b/kolibri/plugins/device/kolibri_plugin.py @@ -1,4 +1,5 @@ from kolibri.core.auth.constants.user_kinds import SUPERUSER +from kolibri.core.device.hooks import CheckIsMeteredHook from kolibri.core.hooks import NavigationHook from kolibri.core.hooks import RoleBasedRedirectHook from kolibri.core.webpack.hooks import WebpackBundleHook @@ -28,6 +29,7 @@ def plugin_data(self): return { "isRemoteContent": OPTIONS["Deployment"]["REMOTE_CONTENT"], "canRestart": bool(OPTIONS["Deployment"]["RESTART_HOOKS"]), + "canCheckMeteredConnection": CheckIsMeteredHook.is_registered, "deprecationWarnings": { "ie11": any_ie11_users(), }, diff --git a/packages/kolibri-common/components/MeteredConnectionNotificationModal.vue b/kolibri/plugins/learn/assets/src/views/LibraryPage/MeteredConnectionNotificationModal.vue similarity index 95% rename from packages/kolibri-common/components/MeteredConnectionNotificationModal.vue rename to kolibri/plugins/learn/assets/src/views/LibraryPage/MeteredConnectionNotificationModal.vue index 9e83bdc511b..6b53cc1828e 100644 --- a/packages/kolibri-common/components/MeteredConnectionNotificationModal.vue +++ b/kolibri/plugins/learn/assets/src/views/LibraryPage/MeteredConnectionNotificationModal.vue @@ -35,12 +35,13 @@