From 923846477ef8ac5c6d684d2dd77a3563dcfb46d4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 8 Aug 2023 10:25:24 -0700 Subject: [PATCH 001/180] Change the way we check for Android to use Python internals. --- kolibri/utils/android.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kolibri/utils/android.py b/kolibri/utils/android.py index 8aaddbdf167..e382b297803 100644 --- a/kolibri/utils/android.py +++ b/kolibri/utils/android.py @@ -1,4 +1,4 @@ -from os import environ +import sys # A constant to be used in place of Python's platform.system() @@ -8,7 +8,9 @@ # Android is based on the Linux kernel, but due to security issues, we cannot # run the /proc command there, so we need a way to distinguish between the two. -# Python for Android always sets some Android environment variables, so we check -# for one of them to differentiate. This is how Kivy detects Android as well. +# When Python is built against a specific version of the Android API, this method +# is defined. Otherwise it is not. Note that this cannot be used to distinguish +# between the current runtime versions of Android, as this value is set to the minimum +# API level that this Python version was compiled against. def on_android(): - return "ANDROID_ARGUMENT" in environ + return hasattr(sys, "getandroidapilevel") From fdd49bdd742542093fd324b32aed222057348035 Mon Sep 17 00:00:00 2001 From: shivangrawat30 Date: Tue, 19 Sep 2023 22:45:46 +0530 Subject: [PATCH 002/180] solved Navigation issue from attached drive Signed-off-by: shivangrawat30 --- .../plugins/device/assets/src/views/SelectContentPage/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kolibri/plugins/device/assets/src/views/SelectContentPage/index.vue b/kolibri/plugins/device/assets/src/views/SelectContentPage/index.vue index 33ac0fbdb4e..2314874fe9c 100644 --- a/kolibri/plugins/device/assets/src/views/SelectContentPage/index.vue +++ b/kolibri/plugins/device/assets/src/views/SelectContentPage/index.vue @@ -173,7 +173,7 @@ return title; }, backRoute() { - return { name: ContentWizardPages.AVAILABLE_CHANNELS }; + return { name: PageNames.MANAGE_CONTENT_PAGE }; }, channelId() { return this.$route.params.channel_id; From 7bb6cfbcd5e74df7818db6374a1be49afd60267e Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 13:45:53 -0700 Subject: [PATCH 003/180] Add utility to manage and evict chunked files. --- kolibri/utils/file_transfer.py | 60 +++++++- kolibri/utils/tests/test_chunked_file.py | 167 ++++++++++++++++++++--- 2 files changed, 210 insertions(+), 17 deletions(-) diff --git a/kolibri/utils/file_transfer.py b/kolibri/utils/file_transfer.py index fd8c2b3d674..612d93e169d 100644 --- a/kolibri/utils/file_transfer.py +++ b/kolibri/utils/file_transfer.py @@ -101,13 +101,71 @@ class ChunkedFileDoesNotExist(Exception): pass +CHUNK_SUFFIX = ".chunks" + + +class ChunkedFileDirectoryManager(object): + """ + A class to manage all chunked files in a directory and all its descendant directories. + Its main purpose is to allow for the deletion of chunked files based on a least recently used + metric, as indicated by last access time on any of the files in the chunked file directory. + """ + + def __init__(self, chunked_file_dir): + self.chunked_file_dir = chunked_file_dir + + def _get_chunked_file_dirs(self): + """ + Returns a generator of all chunked file directories in the chunked file directory. + """ + for root, dirs, _ in os.walk(self.chunked_file_dir): + for dir in dirs: + if dir.endswith(CHUNK_SUFFIX): + yield os.path.join(root, dir) + + def _get_chunked_file_stats(self): + stats = {} + for chunked_file_dir in self._get_chunked_file_dirs(): + file_stats = {"last_access_time": 0, "size": 0} + for file in os.listdir(chunked_file_dir): + file_path = os.path.join(chunked_file_dir, file) + if os.path.isfile(file_path): + file_stats["last_access_time"] = max( + file_stats["last_access_time"], os.path.getatime(file_path) + ) + file_stats["size"] += os.path.getsize(file_path) + stats[chunked_file_dir] = file_stats + return stats + + def evict_files(self, file_size): + """ + Attempt to clean up file_size bytes of space in the chunked file directory. + Iterate through all chunked file directories, and delete the oldest chunked files + until the target file size is reached. + """ + chunked_file_stats = self._get_chunked_file_stats() + chunked_file_dirs = sorted( + chunked_file_stats.keys(), + key=lambda x: chunked_file_stats[x]["last_access_time"], + ) + evicted_file_size = 0 + for chunked_file_dir in chunked_file_dirs: + # Do the check here to catch the edge case where file_size is 0 + if file_size <= evicted_file_size: + break + file_stats = chunked_file_stats[chunked_file_dir] + evicted_file_size += file_stats["size"] + shutil.rmtree(chunked_file_dir) + return evicted_file_size + + class ChunkedFile(BufferedIOBase): # Set chunk size to 128KB chunk_size = 128 * 1024 def __init__(self, filepath): self.filepath = filepath - self.chunk_dir = filepath + ".chunks" + self.chunk_dir = filepath + CHUNK_SUFFIX mkdirp(self.chunk_dir, exist_ok=True) self.cache_dir = os.path.join(self.chunk_dir, ".cache") self.position = 0 diff --git a/kolibri/utils/tests/test_chunked_file.py b/kolibri/utils/tests/test_chunked_file.py index c130d229028..2823fd1e32f 100644 --- a/kolibri/utils/tests/test_chunked_file.py +++ b/kolibri/utils/tests/test_chunked_file.py @@ -2,40 +2,50 @@ import math import os import shutil +import tempfile import unittest +from kolibri.utils.file_transfer import CHUNK_SUFFIX from kolibri.utils.file_transfer import ChunkedFile +from kolibri.utils.file_transfer import ChunkedFileDirectoryManager from kolibri.utils.file_transfer import ChunkedFileDoesNotExist +def _write_test_data_to_chunked_file(chunked_file): + data = b"" + for i in range(chunked_file.chunks_count): + with open( + os.path.join(chunked_file.chunk_dir, ".chunk_{}".format(i)), "wb" + ) as f: + size = ( + chunked_file.chunk_size + if i < chunked_file.chunks_count - 1 + else (chunked_file.file_size % chunked_file.chunk_size) + ) + to_write = os.urandom(size) + f.write(to_write) + data += to_write + return data + + +TEST_FILE_SIZE = (1024 * 1024) + 731 + + class TestChunkedFile(unittest.TestCase): def setUp(self): self.file_path = "test_file" self.chunk_size = ChunkedFile.chunk_size - self.file_size = (1024 * 1024) + 731 + self.file_size = TEST_FILE_SIZE self.chunked_file = ChunkedFile(self.file_path) + self.chunked_file.file_size = self.file_size # Create dummy chunks self.chunks_count = int( math.ceil(float(self.file_size) / float(self.chunk_size)) ) - self.data = b"" - for i in range(self.chunks_count): - with open( - os.path.join(self.chunked_file.chunk_dir, ".chunk_{}".format(i)), "wb" - ) as f: - size = ( - self.chunk_size - if i < self.chunks_count - 1 - else (self.file_size % self.chunk_size) - ) - to_write = os.urandom(size) - f.write(to_write) - self.data += to_write - - self.chunked_file.file_size = self.file_size + self.data = _write_test_data_to_chunked_file(self.chunked_file) def tearDown(self): shutil.rmtree(self.chunked_file.chunk_dir, ignore_errors=True) @@ -287,3 +297,128 @@ def test_file_finalized_by_parallel_process_after_opening_locking(self): with self.assertRaises(ChunkedFileDoesNotExist): with self.chunked_file.lock_chunks(0): pass + + +class TestChunkedFileDirectoryManager(unittest.TestCase): + def setUp(self): + self.base_dir = tempfile.mkdtemp() + for files in [ + ["file1.txt"], + ["nested_once", "file2.txt"], + ["nested", "nested_twice", "file3.txt"], + ]: + file_path = os.path.join(self.base_dir, *files) + chunked_file = ChunkedFile(file_path) + chunked_file.file_size = TEST_FILE_SIZE + _write_test_data_to_chunked_file(chunked_file) + + def tearDown(self): + shutil.rmtree(self.base_dir, ignore_errors=True) + + def test_listing_chunked_files(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_get_chunked_file_stats(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + stats = manager._get_chunked_file_stats() + + for file_path, file_stats in stats.items(): + self.assertEqual(file_stats["size"], TEST_FILE_SIZE) + expected_last_access_time = 0 + for file in os.listdir(file_path): + if os.path.isfile(os.path.join(file_path, file)): + expected_last_access_time = max( + expected_last_access_time, + os.path.getatime(os.path.join(file_path, file)), + ) + self.assertEqual(file_stats["last_access_time"], expected_last_access_time) + + def test_evict_files_exact_file_size_sum(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual(TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3)) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [], + ) + + def test_evict_files_more_than_file_size_sum(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual( + TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3 + 12) + ) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [], + ) + + def test_evict_files_exact_file_size(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE)) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_evict_files_less_than_file_size(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE - 12)) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_evict_files_more_than_file_size(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual(TEST_FILE_SIZE * 2, manager.evict_files(TEST_FILE_SIZE + 12)) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_evict_files_more_than_twice_file_size(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual( + TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 2 + 12) + ) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [], + ) + + def test_evict_files_zero_bytes(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + self.assertEqual(0, manager.evict_files(0)) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) From 2f1f14c8a4252ec9716f1d04535dd7c48a8c65b1 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 14:36:17 -0700 Subject: [PATCH 004/180] Tweak storage calc logic to prevent double counting incomplete downloads. --- kolibri/core/content/utils/content_request.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kolibri/core/content/utils/content_request.py b/kolibri/core/content/utils/content_request.py index 37fdb75a2a6..22504bc5305 100644 --- a/kolibri/core/content/utils/content_request.py +++ b/kolibri/core/content/utils/content_request.py @@ -970,12 +970,13 @@ def __init__(self, incomplete_downloads_queryset): total_size=_total_size_annotation(available=True), ) self.free_space = 0 + self.incomplete_downloads_size = 0 def _calculate_space_available(self): + self.incomplete_downloads_size = _total_size(self.incomplete_downloads) free_space = get_free_space_for_downloads( completed_size=_total_size(completed_downloads_queryset()) ) - free_space -= _total_size(self.incomplete_downloads) free_space += _total_size(self.incomplete_sync_removals) free_space += _total_size(self.incomplete_user_removals) free_space += _total_size(self.complete_user_downloads) @@ -984,4 +985,4 @@ def _calculate_space_available(self): def is_space_sufficient(self): self._calculate_space_available() - return self.free_space > _total_size(self.incomplete_downloads) + return self.free_space > self.incomplete_downloads_size From 44f8613af168215c58682bc34890e26e10536cb4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 14:37:08 -0700 Subject: [PATCH 005/180] Use streamed file cache clear as a last resort when there's insufficient storage. --- kolibri/core/content/utils/content_request.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/kolibri/core/content/utils/content_request.py b/kolibri/core/content/utils/content_request.py index 22504bc5305..c479e7f4795 100644 --- a/kolibri/core/content/utils/content_request.py +++ b/kolibri/core/content/utils/content_request.py @@ -43,7 +43,9 @@ from kolibri.core.discovery.utils.network.connections import capture_connection_state from kolibri.core.discovery.utils.network.errors import NetworkLocationResponseFailure from kolibri.core.utils.urls import reverse_path +from kolibri.utils.conf import OPTIONS from kolibri.utils.data import bytes_for_humans +from kolibri.utils.file_transfer import ChunkedFileDirectoryManager logger = logging.getLogger(__name__) @@ -663,6 +665,7 @@ def _process_content_requests(incomplete_downloads): has_processed_sync_removals = False has_processed_user_removals = False has_processed_user_downloads = False + has_freed_space_in_stream_cache = False qs = incomplete_downloads_with_metadata.all() # loop while we have pending downloads @@ -710,6 +713,16 @@ def _process_content_requests(incomplete_downloads): has_processed_user_downloads = True process_user_downloads_for_removal() continue + if not has_freed_space_in_stream_cache: + # try to clear space, then repeat + has_freed_space_in_stream_cache = True + chunked_file_manager = ChunkedFileDirectoryManager( + OPTIONS["Paths"]["CONTENT_DIR"] + ) + chunked_file_manager.evict_files( + calc.get_additional_free_space_needed() + ) + continue raise InsufficientStorage( "Content download requests need {} of free space".format( bytes_for_humans(_total_size(incomplete_downloads_with_metadata)) @@ -986,3 +999,7 @@ def _calculate_space_available(self): def is_space_sufficient(self): self._calculate_space_available() return self.free_space > self.incomplete_downloads_size + + def get_additional_free_space_needed(self): + self._calculate_space_available() + return self.incomplete_downloads_size - self.free_space From 2eb8f41f856fc591916fc9ba8057ca7746043d2d Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 15:47:11 -0700 Subject: [PATCH 006/180] Add method to limit_files to a specific total number of bytes. --- kolibri/utils/file_transfer.py | 32 ++++++++++++++++------ kolibri/utils/tests/test_chunked_file.py | 35 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/kolibri/utils/file_transfer.py b/kolibri/utils/file_transfer.py index 612d93e169d..c19931cdc2a 100644 --- a/kolibri/utils/file_transfer.py +++ b/kolibri/utils/file_transfer.py @@ -137,20 +137,14 @@ def _get_chunked_file_stats(self): stats[chunked_file_dir] = file_stats return stats - def evict_files(self, file_size): - """ - Attempt to clean up file_size bytes of space in the chunked file directory. - Iterate through all chunked file directories, and delete the oldest chunked files - until the target file size is reached. - """ - chunked_file_stats = self._get_chunked_file_stats() + def _do_file_eviction(self, chunked_file_stats, file_size): chunked_file_dirs = sorted( chunked_file_stats.keys(), key=lambda x: chunked_file_stats[x]["last_access_time"], ) evicted_file_size = 0 for chunked_file_dir in chunked_file_dirs: - # Do the check here to catch the edge case where file_size is 0 + # Do the check here to catch the edge case where file_size is <= 0 if file_size <= evicted_file_size: break file_stats = chunked_file_stats[chunked_file_dir] @@ -158,6 +152,28 @@ def evict_files(self, file_size): shutil.rmtree(chunked_file_dir) return evicted_file_size + def evict_files(self, file_size): + """ + Attempt to clean up file_size bytes of space in the chunked file directory. + Iterate through all chunked file directories, and delete the oldest chunked files + until the target file size is reached. + """ + chunked_file_stats = self._get_chunked_file_stats() + return self._do_file_eviction(chunked_file_stats, file_size) + + def limit_files(self, max_size): + """ + Limits the total size used to a certain number of bytes. + If the total size of all chunked files exceeds max_size, the oldest files are evicted. + """ + chunked_file_stats = self._get_chunked_file_stats() + + total_size = sum( + file_stats["size"] for file_stats in chunked_file_stats.values() + ) + + return self._do_file_eviction(chunked_file_stats, total_size - max_size) + class ChunkedFile(BufferedIOBase): # Set chunk size to 128KB diff --git a/kolibri/utils/tests/test_chunked_file.py b/kolibri/utils/tests/test_chunked_file.py index 2823fd1e32f..9c801a90d97 100644 --- a/kolibri/utils/tests/test_chunked_file.py +++ b/kolibri/utils/tests/test_chunked_file.py @@ -422,3 +422,38 @@ def test_evict_files_zero_bytes(self): ), ], ) + + def test_limit_files_no_eviction_needed(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + manager.limit_files(TEST_FILE_SIZE * 3) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_limit_files_some_eviction_needed(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + manager.limit_files(TEST_FILE_SIZE * 2) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [ + os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX + ), + ], + ) + + def test_limit_files_all_evicted(self): + manager = ChunkedFileDirectoryManager(self.base_dir) + manager.limit_files(TEST_FILE_SIZE - 12) + self.assertEqual( + list(manager._get_chunked_file_dirs()), + [], + ) From b24bb37b58cbd03c4c4d511ad39665348918f157 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 16:10:16 -0700 Subject: [PATCH 007/180] Separate out default task scheduling into separate plugin, and add to base process bus. --- kolibri/utils/server.py | 19 ++++++++---- kolibri/utils/tests/test_server.py | 49 +++++++++++++++--------------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/kolibri/utils/server.py b/kolibri/utils/server.py index dcc7b9b1139..ad7c4cff8ab 100644 --- a/kolibri/utils/server.py +++ b/kolibri/utils/server.py @@ -248,13 +248,8 @@ def START(self): START.priority = 75 -class ServicesPlugin(SimplePlugin): - def __init__(self, bus): - self.bus = bus - self.worker = None - +class DefaultScheduledTasksPlugin(SimplePlugin): def START(self): - from kolibri.core.tasks.main import initialize_workers from kolibri.core.analytics.tasks import schedule_ping from kolibri.core.deviceadmin.tasks import schedule_vacuum @@ -264,6 +259,15 @@ def START(self): # schedule the vacuum job if not already scheduled schedule_vacuum() + +class ServicesPlugin(SimplePlugin): + def __init__(self, bus): + self.bus = bus + self.worker = None + + def START(self): + from kolibri.core.tasks.main import initialize_workers + # Initialize the iceqube engine to handle queued tasks self.worker = initialize_workers() @@ -734,6 +738,9 @@ def __init__( reload_plugin = ProcessControlPlugin(self) reload_plugin.subscribe() + default_scheduled_tasks_plugin = DefaultScheduledTasksPlugin(self) + default_scheduled_tasks_plugin.subscribe() + def run(self): self.graceful() self.block() diff --git a/kolibri/utils/tests/test_server.py b/kolibri/utils/tests/test_server.py index d327512f1ef..d733ec961dd 100755 --- a/kolibri/utils/tests/test_server.py +++ b/kolibri/utils/tests/test_server.py @@ -113,12 +113,30 @@ def test_required_services_initiate_on_start( mock_kolibri_broadcast.assert_not_called() - @mock.patch("kolibri.core.tasks.main.initialize_workers") + def test_services_shutdown_on_stop(self): + + # Initialize and ready services plugin for testing + services_plugin = server.ServicesPlugin(mock.MagicMock(name="bus")) + + from kolibri.core.tasks.worker import Worker + + services_plugin.worker = mock.MagicMock(name="worker", spec_set=Worker) + + # Now, let us stop services plugin + services_plugin.STOP() + + # Do we shutdown workers correctly? + assert services_plugin.worker.shutdown.call_count == 1 + assert services_plugin.worker.mock_calls == [ + mock.call.shutdown(wait=True), + ] + + +class TestServerDefaultScheduledTasks(object): @mock.patch("kolibri.core.discovery.utils.network.broadcast.KolibriBroadcast") def test_scheduled_jobs_persist_on_restart( self, mock_kolibri_broadcast, - initialize_workers, job_storage, ): with mock.patch("kolibri.core.tasks.registry.job_storage", wraps=job_storage): @@ -132,8 +150,10 @@ def test_scheduled_jobs_persist_on_restart( test2 = job_storage.schedule(schedule_time, Job(id)) # Now, start services plugin - service_plugin = server.ServicesPlugin(mock.MagicMock(name="bus")) - service_plugin.START() + default_scheduled_tasks_plugin = server.DefaultScheduledTasksPlugin( + mock.MagicMock(name="bus") + ) + default_scheduled_tasks_plugin.START() # Currently, we must have exactly four scheduled jobs # two userdefined and two server defined (pingback and vacuum) @@ -147,8 +167,7 @@ def test_scheduled_jobs_persist_on_restart( assert job_storage.get_job(SCH_VACUUM_JOB_ID) is not None # Restart services - service_plugin.STOP() - service_plugin.START() + default_scheduled_tasks_plugin.START() # Make sure all scheduled jobs persist after restart assert len(job_storage) == 4 @@ -157,24 +176,6 @@ def test_scheduled_jobs_persist_on_restart( assert job_storage.get_job(DEFAULT_PING_JOB_ID) is not None assert job_storage.get_job(SCH_VACUUM_JOB_ID) is not None - def test_services_shutdown_on_stop(self): - - # Initialize and ready services plugin for testing - services_plugin = server.ServicesPlugin(mock.MagicMock(name="bus")) - - from kolibri.core.tasks.worker import Worker - - services_plugin.worker = mock.MagicMock(name="worker", spec_set=Worker) - - # Now, let us stop services plugin - services_plugin.STOP() - - # Do we shutdown workers correctly? - assert services_plugin.worker.shutdown.call_count == 1 - assert services_plugin.worker.mock_calls == [ - mock.call.shutdown(wait=True), - ] - class TestZeroConfPlugin(object): @mock.patch("kolibri.core.discovery.utils.network.search.NetworkLocationListener") From ea0772ffbdad8bfb3ebc27d69bff255d83604fc5 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 26 Sep 2023 16:16:02 -0700 Subject: [PATCH 008/180] Add scheduled task for cleaning up the streamed file cache. --- kolibri/core/deviceadmin/tasks.py | 27 +++++++++++++++++++++++++++ kolibri/utils/options.py | 10 ++++++++++ kolibri/utils/server.py | 4 ++++ kolibri/utils/tests/test_server.py | 7 +++++-- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/kolibri/core/deviceadmin/tasks.py b/kolibri/core/deviceadmin/tasks.py index c2618e321cd..861067b1958 100644 --- a/kolibri/core/deviceadmin/tasks.py +++ b/kolibri/core/deviceadmin/tasks.py @@ -7,6 +7,8 @@ from kolibri.core.tasks.decorators import register_task from kolibri.core.tasks.exceptions import JobRunning from kolibri.core.utils.lock import db_lock +from kolibri.utils.conf import OPTIONS +from kolibri.utils.file_transfer import ChunkedFileDirectoryManager from kolibri.utils.time_utils import local_now @@ -69,3 +71,28 @@ def schedule_vacuum(): perform_vacuum.enqueue_at(vacuum_time, repeat=None, interval=24 * 60 * 60) except JobRunning: pass + + +# Constant job id for streamed cache cleanup task +STREAMED_CACHE_CLEANUP_JOB_ID = "streamed_cache_cleanup" + + +@register_task(job_id=STREAMED_CACHE_CLEANUP_JOB_ID) +def streamed_cache_cleanup(): + manager = ChunkedFileDirectoryManager(OPTIONS["Paths"]["CONTENT_DIR"]) + manager.limit_files(OPTIONS["Cache"]["STREAMED_FILE_CACHE_SIZE"]) + + +def schedule_streamed_cache_cleanup(): + current_dt = local_now() + cleanup_time = current_dt.replace(hour=1, minute=0, second=0, microsecond=0) + if cleanup_time < current_dt: + # If it is past 1AM, change the day to tomorrow. + cleanup_time = cleanup_time + timedelta(days=1) + # Repeat indefinitely + try: + streamed_cache_cleanup.enqueue_at( + cleanup_time, repeat=None, interval=24 * 60 * 60 + ) + except JobRunning: + pass diff --git a/kolibri/utils/options.py b/kolibri/utils/options.py index 2b906f1c708..8a3005e196c 100644 --- a/kolibri/utils/options.py +++ b/kolibri/utils/options.py @@ -404,6 +404,16 @@ def lazy_import_callback_list(value): "default": "", "description": "Eviction policy to use when using Redis for caching, Redis only.", }, + "STREAMED_FILE_CACHE_SIZE": { + "type": "bytes", + "default": "500MB", + "description": """ + Disk space to be used for caching streamed files. This is used for caching files that are + being streamed from remote libraries, if these files are later imported, these should be cleaned up, + and will no longer count to this cache size. + Value can either be a number suffixed with a unit (e.g. MB, GB, TB) or an integer number of bytes. + """, + }, }, "Database": { "DATABASE_ENGINE": { diff --git a/kolibri/utils/server.py b/kolibri/utils/server.py index ad7c4cff8ab..78036ad0a84 100644 --- a/kolibri/utils/server.py +++ b/kolibri/utils/server.py @@ -252,6 +252,7 @@ class DefaultScheduledTasksPlugin(SimplePlugin): def START(self): from kolibri.core.analytics.tasks import schedule_ping from kolibri.core.deviceadmin.tasks import schedule_vacuum + from kolibri.core.deviceadmin.tasks import schedule_streamed_cache_cleanup # schedule the pingback job if not already scheduled schedule_ping() @@ -259,6 +260,9 @@ def START(self): # schedule the vacuum job if not already scheduled schedule_vacuum() + # schedule the streamed cache cleanup job if not already scheduled + schedule_streamed_cache_cleanup() + class ServicesPlugin(SimplePlugin): def __init__(self, bus): diff --git a/kolibri/utils/tests/test_server.py b/kolibri/utils/tests/test_server.py index d733ec961dd..e9b88dc32fe 100755 --- a/kolibri/utils/tests/test_server.py +++ b/kolibri/utils/tests/test_server.py @@ -159,22 +159,25 @@ def test_scheduled_jobs_persist_on_restart( # two userdefined and two server defined (pingback and vacuum) from kolibri.core.analytics.tasks import DEFAULT_PING_JOB_ID from kolibri.core.deviceadmin.tasks import SCH_VACUUM_JOB_ID + from kolibri.core.deviceadmin.tasks import STREAMED_CACHE_CLEANUP_JOB_ID - assert len(job_storage) == 4 + assert len(job_storage) == 5 assert job_storage.get_job(test1) is not None assert job_storage.get_job(test2) is not None assert job_storage.get_job(DEFAULT_PING_JOB_ID) is not None assert job_storage.get_job(SCH_VACUUM_JOB_ID) is not None + assert job_storage.get_job(STREAMED_CACHE_CLEANUP_JOB_ID) is not None # Restart services default_scheduled_tasks_plugin.START() # Make sure all scheduled jobs persist after restart - assert len(job_storage) == 4 + assert len(job_storage) == 5 assert job_storage.get_job(test1) is not None assert job_storage.get_job(test2) is not None assert job_storage.get_job(DEFAULT_PING_JOB_ID) is not None assert job_storage.get_job(SCH_VACUUM_JOB_ID) is not None + assert job_storage.get_job(STREAMED_CACHE_CLEANUP_JOB_ID) is not None class TestZeroConfPlugin(object): From 8ca431d8442ffbef5cac83b733cc1fd832ca540d Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 27 Sep 2023 11:40:47 -0700 Subject: [PATCH 009/180] Sort file names to avoid inconsistency of os.walk on different file systems. --- kolibri/utils/tests/test_chunked_file.py | 165 +++++++++++++++-------- 1 file changed, 106 insertions(+), 59 deletions(-) diff --git a/kolibri/utils/tests/test_chunked_file.py b/kolibri/utils/tests/test_chunked_file.py index 9c801a90d97..a37761084c7 100644 --- a/kolibri/utils/tests/test_chunked_file.py +++ b/kolibri/utils/tests/test_chunked_file.py @@ -318,14 +318,21 @@ def tearDown(self): def test_listing_chunked_files(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_get_chunked_file_stats(self): @@ -347,8 +354,8 @@ def test_evict_files_exact_file_size_sum(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual(TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3)) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [], + sorted(list(manager._get_chunked_file_dirs())), + sorted([]), ) def test_evict_files_more_than_file_size_sum(self): @@ -357,46 +364,65 @@ def test_evict_files_more_than_file_size_sum(self): TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3 + 12) ) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [], + sorted(list(manager._get_chunked_file_dirs())), + sorted([]), ) def test_evict_files_exact_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE)) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_evict_files_less_than_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE - 12)) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_evict_files_more_than_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual(TEST_FILE_SIZE * 2, manager.evict_files(TEST_FILE_SIZE + 12)) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_evict_files_more_than_twice_file_size(self): @@ -405,55 +431,76 @@ def test_evict_files_more_than_twice_file_size(self): TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 2 + 12) ) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [], + sorted(list(manager._get_chunked_file_dirs())), + sorted([]), ) def test_evict_files_zero_bytes(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual(0, manager.evict_files(0)) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_limit_files_no_eviction_needed(self): manager = ChunkedFileDirectoryManager(self.base_dir) manager.limit_files(TEST_FILE_SIZE * 3) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join(self.base_dir, "file1.txt" + CHUNK_SUFFIX), + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_limit_files_some_eviction_needed(self): manager = ChunkedFileDirectoryManager(self.base_dir) manager.limit_files(TEST_FILE_SIZE * 2) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [ - os.path.join(self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX), - os.path.join( - self.base_dir, "nested", "nested_twice", "file3.txt" + CHUNK_SUFFIX - ), - ], + sorted(list(manager._get_chunked_file_dirs())), + sorted( + [ + os.path.join( + self.base_dir, "nested_once", "file2.txt" + CHUNK_SUFFIX + ), + os.path.join( + self.base_dir, + "nested", + "nested_twice", + "file3.txt" + CHUNK_SUFFIX, + ), + ] + ), ) def test_limit_files_all_evicted(self): manager = ChunkedFileDirectoryManager(self.base_dir) manager.limit_files(TEST_FILE_SIZE - 12) self.assertEqual( - list(manager._get_chunked_file_dirs()), - [], + sorted(list(manager._get_chunked_file_dirs())), + sorted([]), ) From 2b35350940214e804c850d58bbde319432406fae Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 27 Sep 2023 17:52:20 -0700 Subject: [PATCH 010/180] Optimize os.walk usage. Include diskcache directory in file size counts. --- kolibri/utils/file_transfer.py | 8 +++-- kolibri/utils/tests/test_chunked_file.py | 46 +++++++++++++++++------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/kolibri/utils/file_transfer.py b/kolibri/utils/file_transfer.py index c19931cdc2a..12a2c1cd955 100644 --- a/kolibri/utils/file_transfer.py +++ b/kolibri/utils/file_transfer.py @@ -122,14 +122,16 @@ def _get_chunked_file_dirs(self): for dir in dirs: if dir.endswith(CHUNK_SUFFIX): yield os.path.join(root, dir) + # Don't continue to walk down the directory tree + dirs.remove(dir) def _get_chunked_file_stats(self): stats = {} for chunked_file_dir in self._get_chunked_file_dirs(): file_stats = {"last_access_time": 0, "size": 0} - for file in os.listdir(chunked_file_dir): - file_path = os.path.join(chunked_file_dir, file) - if os.path.isfile(file_path): + for dirpath, _, filenames in os.walk(chunked_file_dir): + for file in filenames: + file_path = os.path.join(dirpath, file) file_stats["last_access_time"] = max( file_stats["last_access_time"], os.path.getatime(file_path) ) diff --git a/kolibri/utils/tests/test_chunked_file.py b/kolibri/utils/tests/test_chunked_file.py index a37761084c7..3a900b05277 100644 --- a/kolibri/utils/tests/test_chunked_file.py +++ b/kolibri/utils/tests/test_chunked_file.py @@ -299,6 +299,14 @@ def test_file_finalized_by_parallel_process_after_opening_locking(self): pass +# The expected number of bytes taken up by files used in the diskcache +# for chunked files. +EXPECTED_DISKCACHE_SIZE = 32768 + + +TOTAL_CHUNKED_FILE_SIZE = TEST_FILE_SIZE + EXPECTED_DISKCACHE_SIZE + + class TestChunkedFileDirectoryManager(unittest.TestCase): def setUp(self): self.base_dir = tempfile.mkdtemp() @@ -340,19 +348,22 @@ def test_get_chunked_file_stats(self): stats = manager._get_chunked_file_stats() for file_path, file_stats in stats.items(): - self.assertEqual(file_stats["size"], TEST_FILE_SIZE) + self.assertEqual(file_stats["size"], TOTAL_CHUNKED_FILE_SIZE) expected_last_access_time = 0 - for file in os.listdir(file_path): - if os.path.isfile(os.path.join(file_path, file)): + for dirpath, _, filenames in os.walk(file_path): + for filename in filenames: expected_last_access_time = max( expected_last_access_time, - os.path.getatime(os.path.join(file_path, file)), + os.path.getatime(os.path.join(dirpath, filename)), ) self.assertEqual(file_stats["last_access_time"], expected_last_access_time) def test_evict_files_exact_file_size_sum(self): manager = ChunkedFileDirectoryManager(self.base_dir) - self.assertEqual(TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3)) + self.assertEqual( + TOTAL_CHUNKED_FILE_SIZE * 3, + manager.evict_files(TOTAL_CHUNKED_FILE_SIZE * 3), + ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted([]), @@ -361,7 +372,8 @@ def test_evict_files_exact_file_size_sum(self): def test_evict_files_more_than_file_size_sum(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual( - TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 3 + 12) + TOTAL_CHUNKED_FILE_SIZE * 3, + manager.evict_files(TOTAL_CHUNKED_FILE_SIZE * 3 + 12), ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), @@ -370,7 +382,9 @@ def test_evict_files_more_than_file_size_sum(self): def test_evict_files_exact_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) - self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE)) + self.assertEqual( + TOTAL_CHUNKED_FILE_SIZE, manager.evict_files(TOTAL_CHUNKED_FILE_SIZE) + ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted( @@ -390,7 +404,9 @@ def test_evict_files_exact_file_size(self): def test_evict_files_less_than_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) - self.assertEqual(TEST_FILE_SIZE, manager.evict_files(TEST_FILE_SIZE - 12)) + self.assertEqual( + TOTAL_CHUNKED_FILE_SIZE, manager.evict_files(TOTAL_CHUNKED_FILE_SIZE - 12) + ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted( @@ -410,7 +426,10 @@ def test_evict_files_less_than_file_size(self): def test_evict_files_more_than_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) - self.assertEqual(TEST_FILE_SIZE * 2, manager.evict_files(TEST_FILE_SIZE + 12)) + self.assertEqual( + TOTAL_CHUNKED_FILE_SIZE * 2, + manager.evict_files(TOTAL_CHUNKED_FILE_SIZE + 12), + ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted( @@ -428,7 +447,8 @@ def test_evict_files_more_than_file_size(self): def test_evict_files_more_than_twice_file_size(self): manager = ChunkedFileDirectoryManager(self.base_dir) self.assertEqual( - TEST_FILE_SIZE * 3, manager.evict_files(TEST_FILE_SIZE * 2 + 12) + TOTAL_CHUNKED_FILE_SIZE * 3, + manager.evict_files(TOTAL_CHUNKED_FILE_SIZE * 2 + 12), ) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), @@ -458,7 +478,7 @@ def test_evict_files_zero_bytes(self): def test_limit_files_no_eviction_needed(self): manager = ChunkedFileDirectoryManager(self.base_dir) - manager.limit_files(TEST_FILE_SIZE * 3) + manager.limit_files(TOTAL_CHUNKED_FILE_SIZE * 3) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted( @@ -479,7 +499,7 @@ def test_limit_files_no_eviction_needed(self): def test_limit_files_some_eviction_needed(self): manager = ChunkedFileDirectoryManager(self.base_dir) - manager.limit_files(TEST_FILE_SIZE * 2) + manager.limit_files(TOTAL_CHUNKED_FILE_SIZE * 2) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted( @@ -499,7 +519,7 @@ def test_limit_files_some_eviction_needed(self): def test_limit_files_all_evicted(self): manager = ChunkedFileDirectoryManager(self.base_dir) - manager.limit_files(TEST_FILE_SIZE - 12) + manager.limit_files(TOTAL_CHUNKED_FILE_SIZE - 12) self.assertEqual( sorted(list(manager._get_chunked_file_dirs())), sorted([]), From cd42526a25d37d1a5df5f0f8fd53bc761c7684c2 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 29 Sep 2023 16:54:26 -0700 Subject: [PATCH 011/180] Some minor linting fixes that became apparent after linting updates, backported here. --- .../assets/src/views/sortable/DragContainer.vue | 7 ++----- .../src/views/home/HomePage/OverviewBlock.vue | 2 +- .../LessonContentCard/BookmarkIcon.vue | 2 +- .../assets/src/views/FacilitiesPage/index.vue | 5 ++--- .../plugins/learn/assets/src/views/CardList.vue | 2 +- .../media_player/assets/src/utils/settings.js | 14 +++++++------- 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/kolibri/core/assets/src/views/sortable/DragContainer.vue b/kolibri/core/assets/src/views/sortable/DragContainer.vue index f577418e7ea..7fb1e54585c 100644 --- a/kolibri/core/assets/src/views/sortable/DragContainer.vue +++ b/kolibri/core/assets/src/views/sortable/DragContainer.vue @@ -115,12 +115,9 @@ } @keyframes bounce-in { - from { - animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); - } - 0% { transform: scale3d(1.05, 1.05, 1.05); + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 50% { @@ -128,7 +125,7 @@ animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } - to { + 100% { transform: scale3d(1, 1, 1); animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } diff --git a/kolibri/plugins/coach/assets/src/views/home/HomePage/OverviewBlock.vue b/kolibri/plugins/coach/assets/src/views/home/HomePage/OverviewBlock.vue index dcd4a3c0492..de6374c8506 100644 --- a/kolibri/plugins/coach/assets/src/views/home/HomePage/OverviewBlock.vue +++ b/kolibri/plugins/coach/assets/src/views/home/HomePage/OverviewBlock.vue @@ -43,7 +43,7 @@ :text="$tr('viewLearners')" appearance="basic-link" :to="classLearnersListRoute" - style="margin-left: 24 px;" + style="margin-left: 24px;" /> diff --git a/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/LessonContentCard/BookmarkIcon.vue b/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/LessonContentCard/BookmarkIcon.vue index 9059cbe0845..8876153d1ed 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/LessonContentCard/BookmarkIcon.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/LessonContentCard/BookmarkIcon.vue @@ -2,7 +2,7 @@ diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue index 6e6b94407fe..7c894beb031 100644 --- a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue @@ -40,7 +40,7 @@