From 29a937c44d602d764f9d14a7b2194b1e55de653b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 00:04:56 +0000 Subject: [PATCH 01/34] Bump django-cors-headers from 4.2.0 to 4.3.1 Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 4.2.0 to 4.3.1. - [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst) - [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.2.0...4.3.1) --- updated-dependencies: - dependency-name: django-cors-headers dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 095132069f..8dea2916a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ pytest==8.0.0 pytest-asyncio==0.23.5 pytest-django==4.8.0 django-treebeard==4.7 -django-cors-headers==4.2.0 +django-cors-headers==4.3.1 django-storages==1.14.2 boto3==1.28.75 django-prometheus==2.2.0 From cf61de0dba415512bd2e96f82acf07d35bae729d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 00:05:19 +0000 Subject: [PATCH 02/34] Bump gunicorn from 20.1.0 to 21.2.0 Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 20.1.0 to 21.2.0. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/20.1.0...21.2.0) --- updated-dependencies: - dependency-name: gunicorn dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 095132069f..3891439011 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ drf-writable-nested==0.7.0 django-oauth-toolkit==2.3.0 django-debug-toolbar==4.2.0 bleach==6.0.0 -gunicorn==20.1.0 +gunicorn==21.2.0 lxml==5.1.0 Markdown==3.5.1 Pillow==10.2.0 From a9dcc7261c88815534abd537f9c79da49e454674 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:04:03 +0000 Subject: [PATCH 03/34] Bump cryptography from 42.0.4 to 42.0.5 Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.4 to 42.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/42.0.4...42.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 40f0d626a3..95470c6ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==4.2.11 -cryptography===42.0.4 +cryptography===42.0.5 django-annoying==0.10.6 django-cleanup==8.0.0 django-crispy-forms==2.0 From 149cf93618c3e27756beec613097ae887d8a952e Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 08:56:21 -0500 Subject: [PATCH 04/34] removed unused CustomTestRunner --- cookbook/helper/CustomTestRunner.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 cookbook/helper/CustomTestRunner.py diff --git a/cookbook/helper/CustomTestRunner.py b/cookbook/helper/CustomTestRunner.py deleted file mode 100644 index b70430515b..0000000000 --- a/cookbook/helper/CustomTestRunner.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.test.runner import DiscoverRunner -from django_scopes import scopes_disabled - - -class CustomTestRunner(DiscoverRunner): - def run_tests(self, *args, **kwargs): - with scopes_disabled(): - return super().run_tests(*args, **kwargs) From a047613edb45989c38d0d9ca6252810153d56416 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 08:56:45 -0500 Subject: [PATCH 05/34] enabled entire test_recipe_full_text_search unit tests --- .vscode/launch.json | 5 +- .../other/test_recipe_full_text_search.py | 60 +++++++++---------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index cc144c06e9..a824f6dcca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,7 +4,6 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "Python Debugger: Django", "type": "debugpy", @@ -24,8 +23,8 @@ ], "console": "integratedTerminal", "env": { - "//comment": "coverage and pytest can't both be running at the same time", - "PYTEST_ADDOPTS": "--no-cov -n 2" + // coverage and pytest can't both be running at the same time + "PYTEST_ADDOPTS": "--no-cov" }, "django": true, "justMyCode": true diff --git a/cookbook/tests/other/test_recipe_full_text_search.py b/cookbook/tests/other/test_recipe_full_text_search.py index b6b7a858fc..9118c64ddd 100644 --- a/cookbook/tests/other/test_recipe_full_text_search.py +++ b/cookbook/tests/other/test_recipe_full_text_search.py @@ -297,36 +297,36 @@ def test_fuzzy_lookup(found_recipe, recipes, param_type, user1, space_1): # commenting this out for general use - it is really slow # it should be run on occasion to ensure everything still works -# @pytest.mark.skipif(sqlite and True, reason="requires PostgreSQL") -# @pytest.mark.parametrize("user1", itertools.product( -# [ -# ('fuzzy_search', True), ('fuzzy_search', False), -# ('fulltext', True), ('fulltext', False), -# ('icontains', True), ('icontains', False), -# ('istartswith', True), ('istartswith', False), -# ], -# [('unaccent', True), ('unaccent', False)] -# ), indirect=['user1']) -# @pytest.mark.parametrize("found_recipe", [ -# ({'name': True}), -# ({'description': True}), -# ({'instruction': True}), -# ({'keyword': True}), -# ({'food': True}), -# ], indirect=['found_recipe']) -# # user array contains: user client, expected count of search, expected count of mispelled search, search string, mispelled search string, user search preferences -# def test_search_string(found_recipe, recipes, user1, space_1): -# with scope(space=space_1): -# param1 = f"query={user1[3]}" -# param2 = f"query={user1[4]}" - -# r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param1}').content) -# assert len([x['id'] for x in r['results'] if x['id'] in [ -# found_recipe[0].id, found_recipe[1].id]]) == user1[1] - -# r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param2}').content) -# assert len([x['id'] for x in r['results'] if x['id'] in [ -# found_recipe[0].id, found_recipe[1].id]]) == user1[2] +@pytest.mark.skipif(sqlite and True, reason="requires PostgreSQL") +@pytest.mark.parametrize("user1", itertools.product( + [ + ('fuzzy_search', True), ('fuzzy_search', False), + ('fulltext', True), ('fulltext', False), + ('icontains', True), ('icontains', False), + ('istartswith', True), ('istartswith', False), + ], + [('unaccent', True), ('unaccent', False)] +), indirect=['user1']) +@pytest.mark.parametrize("found_recipe", [ + ({'name': True}), + ({'description': True}), + ({'instruction': True}), + ({'keyword': True}), + ({'food': True}), +], indirect=['found_recipe']) +# user array contains: user client, expected count of search, expected count of mispelled search, search string, mispelled search string, user search preferences +def test_search_string(found_recipe, recipes, user1, space_1): + with scope(space=space_1): + param1 = f"query={user1[3]}" + param2 = f"query={user1[4]}" + + r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param1}').content) + assert len([x['id'] for x in r['results'] if x['id'] in [ + found_recipe[0].id, found_recipe[1].id]]) == user1[1] + + r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param2}').content) + assert len([x['id'] for x in r['results'] if x['id'] in [ + found_recipe[0].id, found_recipe[1].id]]) == user1[2] @pytest.mark.parametrize("found_recipe, param_type, result", [ From 33b23b299d15c5cff6228840552e1f8f976eb5ce Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 08:58:32 -0500 Subject: [PATCH 06/34] removed unused docs/coverage/ reports --- docs/coverage/.gitignore | 2 - docs/coverage/coverage-badge.svg | 1 - docs/coverage/coverage.xml | 9118 ----------------- docs/coverage/coverage_html.js | 624 -- .../d_0b5495cf37ee6c4f_dropbox_py.html | 249 - .../coverage/d_0b5495cf37ee6c4f_local_py.html | 155 - .../d_0b5495cf37ee6c4f_nextcloud_py.html | 246 - .../d_0b5495cf37ee6c4f_provider_py.html | 121 - .../d_1d409d097a8b76e7_custom_tags_py.html | 308 - .../d_1d409d097a8b76e7_theming_tags_py.html | 150 - .../d_37812bb4c19c71da_cheftap_py.html | 156 - .../d_37812bb4c19c71da_chowdown_py.html | 219 - .../d_37812bb4c19c71da_cookbookapp_py.html | 170 - .../d_37812bb4c19c71da_cookmate_py.html | 180 - .../d_37812bb4c19c71da_copymethat_py.html | 227 - .../d_37812bb4c19c71da_default_py.html | 171 - .../d_37812bb4c19c71da_domestica_py.html | 154 - .../d_37812bb4c19c71da_integration_py.html | 393 - .../d_37812bb4c19c71da_mealie_py.html | 196 - .../d_37812bb4c19c71da_mealmaster_py.html | 176 - .../d_37812bb4c19c71da_melarecipes_py.html | 180 - ...7812bb4c19c71da_nextcloud_cookbook_py.html | 289 - .../d_37812bb4c19c71da_openeats_py.html | 226 - .../d_37812bb4c19c71da_paprika_py.html | 194 - .../d_37812bb4c19c71da_pdfexport_py.html | 153 - .../d_37812bb4c19c71da_pepperplate_py.html | 152 - .../d_37812bb4c19c71da_plantoeat_py.html | 198 - .../d_37812bb4c19c71da_recettetek_py.html | 237 - .../d_37812bb4c19c71da_recipekeeper_py.html | 184 - .../d_37812bb4c19c71da_recipesage_py.html | 205 - .../d_37812bb4c19c71da_rezeptsuitede_py.html | 172 - .../d_37812bb4c19c71da_rezkonv_py.html | 181 - .../d_37812bb4c19c71da_saffron_py.html | 196 - .../coverage/d_a167ab5b5108d61e_admin_py.html | 496 - .../coverage/d_a167ab5b5108d61e_forms_py.html | 617 -- .../d_a167ab5b5108d61e_managers_py.html | 144 - .../d_a167ab5b5108d61e_models_py.html | 1484 --- .../d_a167ab5b5108d61e_schemas_py.html | 166 - .../d_a167ab5b5108d61e_serializer_py.html | 1530 --- .../d_a167ab5b5108d61e_signals_py.html | 260 - .../d_a167ab5b5108d61e_tables_py.html | 197 - .../d_a167ab5b5108d61e_version_info_py.html | 100 - .../d_b7ebbfe037735c69_middleware_py.html | 169 - ..._cc5b0727f68102d6_cooksillustrated_py.html | 165 - .../d_cc5b0727f68102d6_scrapers_py.html | 140 - docs/coverage/d_dd189b0e5315428c_api_py.html | 1832 ---- docs/coverage/d_dd189b0e5315428c_data_py.html | 233 - .../d_dd189b0e5315428c_delete_py.html | 302 - docs/coverage/d_dd189b0e5315428c_edit_py.html | 300 - .../d_dd189b0e5315428c_import_export_py.html | 248 - .../coverage/d_dd189b0e5315428c_lists_py.html | 356 - docs/coverage/d_dd189b0e5315428c_new_py.html | 206 - .../d_dd189b0e5315428c_telegram_py.html | 160 - .../coverage/d_dd189b0e5315428c_views_py.html | 651 -- ...d9a78c43a323f_AllAuthCustomAdapter_py.html | 140 - ...8cd9a78c43a323f_CustomStorageClass_py.html | 116 - ..._f8cd9a78c43a323f_CustomTestRunner_py.html | 105 - ...d_f8cd9a78c43a323f_HelperFunctions_py.html | 110 - ...f8cd9a78c43a323f_automation_helper_py.html | 324 - .../d_f8cd9a78c43a323f_cache_helper_py.html | 108 - ...8cd9a78c43a323f_context_processors_py.html | 111 - docs/coverage/d_f8cd9a78c43a323f_dal_py.html | 131 - .../d_f8cd9a78c43a323f_fdc_helper_py.html | 116 - ..._f8cd9a78c43a323f_image_processing_py.html | 150 - ...f8cd9a78c43a323f_ingredient_parser_py.html | 376 - .../d_f8cd9a78c43a323f_mdx_attributes_py.html | 125 - .../d_f8cd9a78c43a323f_mdx_urlize_py.html | 186 - ...8cd9a78c43a323f_open_data_importer_py.html | 307 - ...f8cd9a78c43a323f_permission_config_py.html | 106 - ...f8cd9a78c43a323f_permission_helper_py.html | 539 - ...d_f8cd9a78c43a323f_property_helper_py.html | 171 - .../d_f8cd9a78c43a323f_recipe_search_py.html | 678 -- ...f8cd9a78c43a323f_recipe_url_import_py.html | 600 -- ..._f8cd9a78c43a323f_scope_middleware_py.html | 164 - ...d_f8cd9a78c43a323f_shopping_helper_py.html | 297 - ...d_f8cd9a78c43a323f_template_helper_py.html | 191 - ...a78c43a323f_unit_conversion_helper_py.html | 238 - docs/coverage/favicon_32.png | Bin 1732 -> 0 bytes docs/coverage/index.html | 613 -- docs/coverage/keybd_closed.png | Bin 9004 -> 0 bytes docs/coverage/keybd_open.png | Bin 9003 -> 0 bytes docs/coverage/status.json | 1 - docs/coverage/style.css | 309 - docs/coverage/version_py.html | 173 - 84 files changed, 31824 deletions(-) delete mode 100644 docs/coverage/.gitignore delete mode 100644 docs/coverage/coverage-badge.svg delete mode 100644 docs/coverage/coverage.xml delete mode 100644 docs/coverage/coverage_html.js delete mode 100644 docs/coverage/d_0b5495cf37ee6c4f_dropbox_py.html delete mode 100644 docs/coverage/d_0b5495cf37ee6c4f_local_py.html delete mode 100644 docs/coverage/d_0b5495cf37ee6c4f_nextcloud_py.html delete mode 100644 docs/coverage/d_0b5495cf37ee6c4f_provider_py.html delete mode 100644 docs/coverage/d_1d409d097a8b76e7_custom_tags_py.html delete mode 100644 docs/coverage/d_1d409d097a8b76e7_theming_tags_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_cheftap_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_chowdown_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_cookbookapp_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_cookmate_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_copymethat_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_default_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_domestica_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_integration_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_mealie_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_mealmaster_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_melarecipes_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_nextcloud_cookbook_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_openeats_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_paprika_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_pdfexport_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_pepperplate_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_plantoeat_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_recettetek_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_recipekeeper_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_recipesage_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_rezeptsuitede_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_rezkonv_py.html delete mode 100644 docs/coverage/d_37812bb4c19c71da_saffron_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_admin_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_forms_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_managers_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_models_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_schemas_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_serializer_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_signals_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_tables_py.html delete mode 100644 docs/coverage/d_a167ab5b5108d61e_version_info_py.html delete mode 100644 docs/coverage/d_b7ebbfe037735c69_middleware_py.html delete mode 100644 docs/coverage/d_cc5b0727f68102d6_cooksillustrated_py.html delete mode 100644 docs/coverage/d_cc5b0727f68102d6_scrapers_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_api_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_data_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_delete_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_edit_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_import_export_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_lists_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_new_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_telegram_py.html delete mode 100644 docs/coverage/d_dd189b0e5315428c_views_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_AllAuthCustomAdapter_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_CustomStorageClass_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_CustomTestRunner_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_HelperFunctions_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_automation_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_cache_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_context_processors_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_dal_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_fdc_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_image_processing_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_ingredient_parser_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_mdx_attributes_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_mdx_urlize_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_open_data_importer_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_permission_config_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_permission_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_property_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_recipe_search_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_recipe_url_import_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_scope_middleware_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_shopping_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_template_helper_py.html delete mode 100644 docs/coverage/d_f8cd9a78c43a323f_unit_conversion_helper_py.html delete mode 100644 docs/coverage/favicon_32.png delete mode 100644 docs/coverage/index.html delete mode 100644 docs/coverage/keybd_closed.png delete mode 100644 docs/coverage/keybd_open.png delete mode 100644 docs/coverage/status.json delete mode 100644 docs/coverage/style.css delete mode 100644 docs/coverage/version_py.html diff --git a/docs/coverage/.gitignore b/docs/coverage/.gitignore deleted file mode 100644 index ccccf14235..0000000000 --- a/docs/coverage/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Created by coverage.py -* diff --git a/docs/coverage/coverage-badge.svg b/docs/coverage/coverage-badge.svg deleted file mode 100644 index 26508af3a2..0000000000 --- a/docs/coverage/coverage-badge.svg +++ /dev/null @@ -1 +0,0 @@ -coverage: 58.61%coverage58.61% \ No newline at end of file diff --git a/docs/coverage/coverage.xml b/docs/coverage/coverage.xml deleted file mode 100644 index e58233b8e8..0000000000 --- a/docs/coverage/coverage.xml +++ /dev/null @@ -1,9118 +0,0 @@ - - - - - - /home/runner/work/recipes/recipes - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/coverage/coverage_html.js b/docs/coverage/coverage_html.js deleted file mode 100644 index 5934882860..0000000000 --- a/docs/coverage/coverage_html.js +++ /dev/null @@ -1,624 +0,0 @@ -// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt - -// Coverage.py HTML report browser code. -/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ -/*global coverage: true, document, window, $ */ - -coverage = {}; - -// General helpers -function debounce(callback, wait) { - let timeoutId = null; - return function(...args) { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - callback.apply(this, args); - }, wait); - }; -}; - -function checkVisible(element) { - const rect = element.getBoundingClientRect(); - const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); - const viewTop = 30; - return !(rect.bottom < viewTop || rect.top >= viewBottom); -} - -function on_click(sel, fn) { - const elt = document.querySelector(sel); - if (elt) { - elt.addEventListener("click", fn); - } -} - -// Helpers for table sorting -function getCellValue(row, column = 0) { - const cell = row.cells[column] // nosemgrep: eslint.detect-object-injection - if (cell.childElementCount == 1) { - const child = cell.firstElementChild - if (child instanceof HTMLTimeElement && child.dateTime) { - return child.dateTime - } else if (child instanceof HTMLDataElement && child.value) { - return child.value - } - } - return cell.innerText || cell.textContent; -} - -function rowComparator(rowA, rowB, column = 0) { - let valueA = getCellValue(rowA, column); - let valueB = getCellValue(rowB, column); - if (!isNaN(valueA) && !isNaN(valueB)) { - return valueA - valueB - } - return valueA.localeCompare(valueB, undefined, {numeric: true}); -} - -function sortColumn(th) { - // Get the current sorting direction of the selected header, - // clear state on other headers and then set the new sorting direction - const currentSortOrder = th.getAttribute("aria-sort"); - [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); - if (currentSortOrder === "none") { - th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); - } else { - th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); - } - - const column = [...th.parentElement.cells].indexOf(th) - - // Sort all rows and afterwards append them in order to move them in the DOM - Array.from(th.closest("table").querySelectorAll("tbody tr")) - .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) - .forEach(tr => tr.parentElement.appendChild(tr) ); -} - -// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. -coverage.assign_shortkeys = function () { - document.querySelectorAll("[data-shortcut]").forEach(element => { - document.addEventListener("keypress", event => { - if (event.target.tagName.toLowerCase() === "input") { - return; // ignore keypress from search filter - } - if (event.key === element.dataset.shortcut) { - element.click(); - } - }); - }); -}; - -// Create the events for the filter box. -coverage.wire_up_filter = function () { - // Cache elements. - const table = document.querySelector("table.index"); - const table_body_rows = table.querySelectorAll("tbody tr"); - const no_rows = document.getElementById("no_rows"); - - // Observe filter keyevents. - document.getElementById("filter").addEventListener("input", debounce(event => { - // Keep running total of each metric, first index contains number of shown rows - const totals = new Array(table.rows[0].cells.length).fill(0); - // Accumulate the percentage as fraction - totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection - - // Hide / show elements. - table_body_rows.forEach(row => { - if (!row.cells[0].textContent.includes(event.target.value)) { - // hide - row.classList.add("hidden"); - return; - } - - // show - row.classList.remove("hidden"); - totals[0]++; - - for (let column = 1; column < totals.length; column++) { - // Accumulate dynamic totals - cell = row.cells[column] // nosemgrep: eslint.detect-object-injection - if (column === totals.length - 1) { - // Last column contains percentage - const [numer, denom] = cell.dataset.ratio.split(" "); - totals[column]["numer"] += parseInt(numer, 10); // nosemgrep: eslint.detect-object-injection - totals[column]["denom"] += parseInt(denom, 10); // nosemgrep: eslint.detect-object-injection - } else { - totals[column] += parseInt(cell.textContent, 10); // nosemgrep: eslint.detect-object-injection - } - } - }); - - // Show placeholder if no rows will be displayed. - if (!totals[0]) { - // Show placeholder, hide table. - no_rows.style.display = "block"; - table.style.display = "none"; - return; - } - - // Hide placeholder, show table. - no_rows.style.display = null; - table.style.display = null; - - const footer = table.tFoot.rows[0]; - // Calculate new dynamic sum values based on visible rows. - for (let column = 1; column < totals.length; column++) { - // Get footer cell element. - const cell = footer.cells[column]; // nosemgrep: eslint.detect-object-injection - - // Set value into dynamic footer cell element. - if (column === totals.length - 1) { - // Percentage column uses the numerator and denominator, - // and adapts to the number of decimal places. - const match = /\.([0-9]+)/.exec(cell.textContent); - const places = match ? match[1].length : 0; - const { numer, denom } = totals[column]; // nosemgrep: eslint.detect-object-injection - cell.dataset.ratio = `${numer} ${denom}`; - // Check denom to prevent NaN if filtered files contain no statements - cell.textContent = denom - ? `${(numer * 100 / denom).toFixed(places)}%` - : `${(100).toFixed(places)}%`; - } else { - cell.textContent = totals[column]; // nosemgrep: eslint.detect-object-injection - } - } - })); - - // Trigger change event on setup, to force filter on page refresh - // (filter value may still be present). - document.getElementById("filter").dispatchEvent(new Event("input")); -}; - -coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; - -// Loaded on index.html -coverage.index_ready = function () { - coverage.assign_shortkeys(); - coverage.wire_up_filter(); - document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( - th => th.addEventListener("click", e => sortColumn(e.target)) - ); - - // Look for a localStorage item containing previous sort settings: - const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); - - if (stored_list) { - const {column, direction} = JSON.parse(stored_list); - const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; // nosemgrep: eslint.detect-object-injection - th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); - th.click() - } - - // Watch for page unload events so we can save the final sort settings: - window.addEventListener("unload", function () { - const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); - if (!th) { - return; - } - localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ - column: [...th.parentElement.cells].indexOf(th), - direction: th.getAttribute("aria-sort"), - })); - }); - - on_click(".button_prev_file", coverage.to_prev_file); - on_click(".button_next_file", coverage.to_next_file); - - on_click(".button_show_hide_help", coverage.show_hide_help); -}; - -// -- pyfile stuff -- - -coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; - -coverage.pyfile_ready = function () { - // If we're directed to a particular line number, highlight the line. - var frag = location.hash; - if (frag.length > 2 && frag[1] === "t") { - document.querySelector(frag).closest(".n").classList.add("highlight"); - coverage.set_sel(parseInt(frag.substr(2), 10)); - } else { - coverage.set_sel(0); - } - - on_click(".button_toggle_run", coverage.toggle_lines); - on_click(".button_toggle_mis", coverage.toggle_lines); - on_click(".button_toggle_exc", coverage.toggle_lines); - on_click(".button_toggle_par", coverage.toggle_lines); - - on_click(".button_next_chunk", coverage.to_next_chunk_nicely); - on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); - on_click(".button_top_of_page", coverage.to_top); - on_click(".button_first_chunk", coverage.to_first_chunk); - - on_click(".button_prev_file", coverage.to_prev_file); - on_click(".button_next_file", coverage.to_next_file); - on_click(".button_to_index", coverage.to_index); - - on_click(".button_show_hide_help", coverage.show_hide_help); - - coverage.filters = undefined; - try { - coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); - } catch(err) {} - - if (coverage.filters) { - coverage.filters = JSON.parse(coverage.filters); - } - else { - coverage.filters = {run: false, exc: true, mis: true, par: true}; - } - - for (cls in coverage.filters) { - coverage.set_line_visibilty(cls, coverage.filters[cls]); // nosemgrep: eslint.detect-object-injection - } - - coverage.assign_shortkeys(); - coverage.init_scroll_markers(); - coverage.wire_up_sticky_header(); - - document.querySelectorAll("[id^=ctxs]").forEach( - cbox => cbox.addEventListener("click", coverage.expand_contexts) - ); - - // Rebuild scroll markers when the window height changes. - window.addEventListener("resize", coverage.build_scroll_markers); -}; - -coverage.toggle_lines = function (event) { - const btn = event.target.closest("button"); - const category = btn.value - const show = !btn.classList.contains("show_" + category); - coverage.set_line_visibilty(category, show); - coverage.build_scroll_markers(); - coverage.filters[category] = show; - try { - localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); - } catch(err) {} -}; - -coverage.set_line_visibilty = function (category, should_show) { - const cls = "show_" + category; - const btn = document.querySelector(".button_toggle_" + category); - if (btn) { - if (should_show) { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); - btn.classList.add(cls); - } - else { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); - btn.classList.remove(cls); - } - } -}; - -// Return the nth line div. -coverage.line_elt = function (n) { - return document.getElementById("t" + n)?.closest("p"); -}; - -// Set the selection. b and e are line numbers. -coverage.set_sel = function (b, e) { - // The first line selected. - coverage.sel_begin = b; - // The next line not selected. - coverage.sel_end = (e === undefined) ? b+1 : e; -}; - -coverage.to_top = function () { - coverage.set_sel(0, 1); - coverage.scroll_window(0); -}; - -coverage.to_first_chunk = function () { - coverage.set_sel(0, 1); - coverage.to_next_chunk(); -}; - -coverage.to_prev_file = function () { - window.location = document.getElementById("prevFileLink").href; -} - -coverage.to_next_file = function () { - window.location = document.getElementById("nextFileLink").href; -} - -coverage.to_index = function () { - location.href = document.getElementById("indexLink").href; -} - -coverage.show_hide_help = function () { - const helpCheck = document.getElementById("help_panel_state") - helpCheck.checked = !helpCheck.checked; -} - -// Return a string indicating what kind of chunk this line belongs to, -// or null if not a chunk. -coverage.chunk_indicator = function (line_elt) { - const classes = line_elt?.className; - if (!classes) { - return null; - } - const match = classes.match(/\bshow_\w+\b/); - if (!match) { - return null; - } - return match[0]; -}; - -coverage.to_next_chunk = function () { - const c = coverage; - - // Find the start of the next colored chunk. - var probe = c.sel_end; - var chunk_indicator, probe_line; - while (true) { - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - if (chunk_indicator) { - break; - } - probe++; - } - - // There's a next chunk, `probe` points to it. - var begin = probe; - - // Find the end of this chunk. - var next_indicator = chunk_indicator; - while (next_indicator === chunk_indicator) { - probe++; - probe_line = c.line_elt(probe); - next_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(begin, probe); - c.show_selection(); -}; - -coverage.to_prev_chunk = function () { - const c = coverage; - - // Find the end of the prev colored chunk. - var probe = c.sel_begin-1; - var probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - var chunk_indicator = c.chunk_indicator(probe_line); - while (probe > 1 && !chunk_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - } - - // There's a prev chunk, `probe` points to its last line. - var end = probe+1; - - // Find the beginning of this chunk. - var prev_indicator = chunk_indicator; - while (prev_indicator === chunk_indicator) { - probe--; - if (probe <= 0) { - return; - } - probe_line = c.line_elt(probe); - prev_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(probe+1, end); - c.show_selection(); -}; - -// Returns 0, 1, or 2: how many of the two ends of the selection are on -// the screen right now? -coverage.selection_ends_on_screen = function () { - if (coverage.sel_begin === 0) { - return 0; - } - - const begin = coverage.line_elt(coverage.sel_begin); - const end = coverage.line_elt(coverage.sel_end-1); - - return ( - (checkVisible(begin) ? 1 : 0) - + (checkVisible(end) ? 1 : 0) - ); -}; - -coverage.to_next_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the top line on the screen as selection. - - // This will select the top-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(0, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(1); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_next_chunk(); -}; - -coverage.to_prev_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the lowest line on the screen as selection. - - // This will select the bottom-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(coverage.lines_len); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_prev_chunk(); -}; - -// Select line number lineno, or if it is in a colored chunk, select the -// entire chunk -coverage.select_line_or_chunk = function (lineno) { - var c = coverage; - var probe_line = c.line_elt(lineno); - if (!probe_line) { - return; - } - var the_indicator = c.chunk_indicator(probe_line); - if (the_indicator) { - // The line is in a highlighted chunk. - // Search backward for the first line. - var probe = lineno; - var indicator = the_indicator; - while (probe > 0 && indicator === the_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - break; - } - indicator = c.chunk_indicator(probe_line); - } - var begin = probe + 1; - - // Search forward for the last line. - probe = lineno; - indicator = the_indicator; - while (indicator === the_indicator) { - probe++; - probe_line = c.line_elt(probe); - indicator = c.chunk_indicator(probe_line); - } - - coverage.set_sel(begin, probe); - } - else { - coverage.set_sel(lineno); - } -}; - -coverage.show_selection = function () { - // Highlight the lines in the chunk - document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); - for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { - coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); - } - - coverage.scroll_to_selection(); -}; - -coverage.scroll_to_selection = function () { - // Scroll the page if the chunk isn't fully visible. - if (coverage.selection_ends_on_screen() < 2) { - const element = coverage.line_elt(coverage.sel_begin); - coverage.scroll_window(element.offsetTop - 60); - } -}; - -coverage.scroll_window = function (to_pos) { - window.scroll({top: to_pos, behavior: "smooth"}); -}; - -coverage.init_scroll_markers = function () { - // Init some variables - coverage.lines_len = document.querySelectorAll("#source > p").length; - - // Build html - coverage.build_scroll_markers(); -}; - -coverage.build_scroll_markers = function () { - const temp_scroll_marker = document.getElementById("scroll_marker") - if (temp_scroll_marker) temp_scroll_marker.remove(); - // Don't build markers if the window has no scroll bar. - if (document.body.scrollHeight <= window.innerHeight) { - return; - } - - const marker_scale = window.innerHeight / document.body.scrollHeight; - const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); - - let previous_line = -99, last_mark, last_top; - - const scroll_marker = document.createElement("div"); - scroll_marker.id = "scroll_marker"; - document.getElementById("source").querySelectorAll( - "p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par" - ).forEach(element => { - const line_top = Math.floor(element.offsetTop * marker_scale); - const line_number = parseInt(element.querySelector(".n a").id.substr(1)); - - if (line_number === previous_line + 1) { - // If this solid missed block just make previous mark higher. - last_mark.style.height = `${line_top + line_height - last_top}px`; - } else { - // Add colored line in scroll_marker block. - last_mark = document.createElement("div"); - last_mark.id = `m${line_number}`; - last_mark.classList.add("marker"); - last_mark.style.height = `${line_height}px`; - last_mark.style.top = `${line_top}px`; - scroll_marker.append(last_mark); - last_top = line_top; - } - - previous_line = line_number; - }); - - // Append last to prevent layout calculation - document.body.append(scroll_marker); -}; - -coverage.wire_up_sticky_header = function () { - const header = document.querySelector("header"); - const header_bottom = ( - header.querySelector(".content h2").getBoundingClientRect().top - - header.getBoundingClientRect().top - ); - - function updateHeader() { - if (window.scrollY > header_bottom) { - header.classList.add("sticky"); - } else { - header.classList.remove("sticky"); - } - } - - window.addEventListener("scroll", updateHeader); - updateHeader(); -}; - -coverage.expand_contexts = function (e) { - var ctxs = e.target.parentNode.querySelector(".ctxs"); - - if (!ctxs.classList.contains("expanded")) { - var ctxs_text = ctxs.textContent; - var width = Number(ctxs_text[0]); - ctxs.textContent = ""; - for (var i = 1; i < ctxs_text.length; i += width) { - key = ctxs_text.substring(i, i + width).trim(); - ctxs.appendChild(document.createTextNode(contexts[key])); - ctxs.appendChild(document.createElement("br")); - } - ctxs.classList.add("expanded"); - } -}; - -document.addEventListener("DOMContentLoaded", () => { - if (document.body.classList.contains("indexfile")) { - coverage.index_ready(); - } else { - coverage.pyfile_ready(); - } -}); diff --git a/docs/coverage/d_0b5495cf37ee6c4f_dropbox_py.html b/docs/coverage/d_0b5495cf37ee6c4f_dropbox_py.html deleted file mode 100644 index edefe3f662..0000000000 --- a/docs/coverage/d_0b5495cf37ee6c4f_dropbox_py.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - Coverage for cookbook/provider/dropbox.py: 28% - - - - - -
-
-

- Coverage for cookbook/provider/dropbox.py: - 28% -

- -

- 75 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import io 

-

2import json 

-

3import os 

-

4from datetime import datetime 

-

5 

-

6import requests 

-

7import validators 

-

8 

-

9from cookbook.models import Recipe, RecipeImport, SyncLog 

-

10from cookbook.provider.provider import Provider 

-

11 

-

12 

-

13class Dropbox(Provider): 

-

14 

-

15 @staticmethod 

-

16 def import_all(monitor): 

-

17 url = "https://api.dropboxapi.com/2/files/list_folder" 

-

18 

-

19 headers = { 

-

20 "Authorization": "Bearer " + monitor.storage.token, 

-

21 "Content-Type": "application/json" 

-

22 } 

-

23 

-

24 data = { 

-

25 "path": monitor.path 

-

26 } 

-

27 

-

28 r = requests.post(url, headers=headers, data=json.dumps(data)) 

-

29 try: 

-

30 recipes = r.json() 

-

31 except ValueError: 

-

32 log_entry = SyncLog(status='ERROR', msg=str(r), sync=monitor) 

-

33 log_entry.save() 

-

34 return r 

-

35 

-

36 import_count = 0 

-

37 # TODO check if has_more is set and import that as well 

-

38 for recipe in recipes['entries']: 

-

39 path = recipe['path_lower'] 

-

40 if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists(): 

-

41 name = os.path.splitext(recipe['name'])[0] 

-

42 new_recipe = RecipeImport( 

-

43 name=name, 

-

44 file_path=path, 

-

45 storage=monitor.storage, 

-

46 file_uid=recipe['id'], 

-

47 space=monitor.space, 

-

48 ) 

-

49 new_recipe.save() 

-

50 import_count += 1 

-

51 

-

52 log_entry = SyncLog( 

-

53 status='SUCCESS', 

-

54 msg='Imported ' + str(import_count) + ' recipes', 

-

55 sync=monitor, 

-

56 ) 

-

57 log_entry.save() 

-

58 

-

59 monitor.last_checked = datetime.now() 

-

60 monitor.save() 

-

61 

-

62 return True 

-

63 

-

64 @staticmethod 

-

65 def create_share_link(recipe): 

-

66 url = "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings" # noqa: E501 

-

67 

-

68 headers = { 

-

69 "Authorization": "Bearer " + recipe.storage.token, 

-

70 "Content-Type": "application/json" 

-

71 } 

-

72 

-

73 data = { 

-

74 "path": recipe.file_uid 

-

75 } 

-

76 

-

77 r = requests.post(url, headers=headers, data=json.dumps(data)) 

-

78 

-

79 return r.json() 

-

80 

-

81 @staticmethod 

-

82 def get_share_link(recipe): 

-

83 url = "https://api.dropboxapi.com/2/sharing/list_shared_links" 

-

84 

-

85 headers = { 

-

86 "Authorization": "Bearer " + recipe.storage.token, 

-

87 "Content-Type": "application/json" 

-

88 } 

-

89 

-

90 data = { 

-

91 "path": recipe.file_path, 

-

92 } 

-

93 

-

94 r = requests.post(url, headers=headers, data=json.dumps(data)) 

-

95 p = r.json() 

-

96 

-

97 for link in p['links']: 

-

98 return link['url'] 

-

99 

-

100 response = Dropbox.create_share_link(recipe) 

-

101 return response['url'] 

-

102 

-

103 @staticmethod 

-

104 def get_file(recipe): 

-

105 if not recipe.link: 

-

106 recipe.link = Dropbox.get_share_link(recipe) 

-

107 recipe.save() 

-

108 

-

109 url = recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.') 

-

110 if validators.url(url, public=True): 

-

111 response = requests.get(url) 

-

112 

-

113 return io.BytesIO(response.content) 

-

114 

-

115 @staticmethod 

-

116 def rename_file(recipe, new_name): 

-

117 url = "https://api.dropboxapi.com/2/files/move_v2" 

-

118 

-

119 headers = { 

-

120 "Authorization": "Bearer " + recipe.storage.token, 

-

121 "Content-Type": "application/json" 

-

122 } 

-

123 

-

124 data = { 

-

125 "from_path": recipe.file_path, 

-

126 "to_path": "%s/%s%s" % ( 

-

127 os.path.dirname(recipe.file_path), 

-

128 new_name, 

-

129 os.path.splitext(recipe.file_path)[1] 

-

130 ) 

-

131 } 

-

132 

-

133 r = requests.post(url, headers=headers, data=json.dumps(data)) 

-

134 

-

135 return r.json() 

-

136 

-

137 @staticmethod 

-

138 def delete_file(recipe): 

-

139 url = "https://api.dropboxapi.com/2/files/delete_v2" 

-

140 

-

141 headers = { 

-

142 "Authorization": "Bearer " + recipe.storage.token, 

-

143 "Content-Type": "application/json" 

-

144 } 

-

145 

-

146 data = { 

-

147 "path": recipe.file_path 

-

148 } 

-

149 

-

150 r = requests.post(url, headers=headers, data=json.dumps(data)) 

-

151 

-

152 return r.json() 

-
- - - diff --git a/docs/coverage/d_0b5495cf37ee6c4f_local_py.html b/docs/coverage/d_0b5495cf37ee6c4f_local_py.html deleted file mode 100644 index e39fec3666..0000000000 --- a/docs/coverage/d_0b5495cf37ee6c4f_local_py.html +++ /dev/null @@ -1,155 +0,0 @@ - - - - - Coverage for cookbook/provider/local.py: 44% - - - - - -
-
-

- Coverage for cookbook/provider/local.py: - 44% -

- -

- 36 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import io 

-

2import os 

-

3from datetime import datetime 

-

4from os import listdir 

-

5from os.path import isfile, join 

-

6 

-

7from cookbook.models import Recipe, RecipeImport, SyncLog 

-

8from cookbook.provider.provider import Provider 

-

9 

-

10 

-

11class Local(Provider): 

-

12 

-

13 @staticmethod 

-

14 def import_all(monitor): 

-

15 files = [f for f in listdir(monitor.path) if isfile(join(monitor.path, f))] 

-

16 

-

17 import_count = 0 

-

18 for file in files: 

-

19 path = monitor.path + '/' + file 

-

20 if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists(): 

-

21 name = os.path.splitext(file)[0] 

-

22 new_recipe = RecipeImport( 

-

23 name=name, 

-

24 file_path=path, 

-

25 storage=monitor.storage, 

-

26 space=monitor.space, 

-

27 ) 

-

28 new_recipe.save() 

-

29 import_count += 1 

-

30 

-

31 log_entry = SyncLog( 

-

32 status='SUCCESS', 

-

33 msg='Imported ' + str(import_count) + ' recipes', 

-

34 sync=monitor, 

-

35 ) 

-

36 log_entry.save() 

-

37 

-

38 monitor.last_checked = datetime.now() 

-

39 monitor.save() 

-

40 

-

41 return True 

-

42 

-

43 @staticmethod 

-

44 def get_file(recipe): 

-

45 file = io.BytesIO(open(recipe.file_path, 'rb').read()) 

-

46 

-

47 return file 

-

48 

-

49 @staticmethod 

-

50 def rename_file(recipe, new_name): 

-

51 os.rename(recipe.file_path, os.path.join(os.path.dirname(recipe.file_path), (new_name + os.path.splitext(recipe.file_path)[1]))) 

-

52 

-

53 return True 

-

54 

-

55 @staticmethod 

-

56 def delete_file(recipe): 

-

57 os.remove(recipe.file_path) 

-

58 return True 

-
- - - diff --git a/docs/coverage/d_0b5495cf37ee6c4f_nextcloud_py.html b/docs/coverage/d_0b5495cf37ee6c4f_nextcloud_py.html deleted file mode 100644 index d1fe1a5fb9..0000000000 --- a/docs/coverage/d_0b5495cf37ee6c4f_nextcloud_py.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - Coverage for cookbook/provider/nextcloud.py: 33% - - - - - -
-
-

- Coverage for cookbook/provider/nextcloud.py: - 33% -

- -

- 78 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import io 

-

2import os 

-

3import tempfile 

-

4from datetime import datetime 

-

5 

-

6import requests 

-

7import validators 

-

8import webdav3.client as wc 

-

9from cookbook.models import Recipe, RecipeImport, SyncLog 

-

10from cookbook.provider.provider import Provider 

-

11from requests.auth import HTTPBasicAuth 

-

12 

-

13from recipes.settings import DEBUG 

-

14 

-

15 

-

16class Nextcloud(Provider): 

-

17 

-

18 @staticmethod 

-

19 def get_client(storage): 

-

20 options = { 

-

21 'webdav_hostname': storage.url, 

-

22 'webdav_login': storage.username, 

-

23 'webdav_password': storage.password, 

-

24 'webdav_root': '/remote.php/dav/files/' + storage.username 

-

25 } 

-

26 if storage.path != '': 

-

27 options['webdav_root'] = storage.path 

-

28 return wc.Client(options) 

-

29 

-

30 @staticmethod 

-

31 def import_all(monitor): 

-

32 client = Nextcloud.get_client(monitor.storage) 

-

33 

-

34 if DEBUG: 

-

35 print(f'TANDOOR_PROVIDER_DEBUG checking path {monitor.path} with client {client}') 

-

36 

-

37 files = client.list(monitor.path) 

-

38 

-

39 if DEBUG: 

-

40 print(f'TANDOOR_PROVIDER_DEBUG file list {files}') 

-

41 

-

42 import_count = 0 

-

43 for file in files: 

-

44 if DEBUG: 

-

45 print(f'TANDOOR_PROVIDER_DEBUG importing file {file}') 

-

46 path = monitor.path + '/' + file 

-

47 if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists(): 

-

48 name = os.path.splitext(file)[0] 

-

49 new_recipe = RecipeImport( 

-

50 name=name, 

-

51 file_path=path, 

-

52 storage=monitor.storage, 

-

53 space=monitor.space, 

-

54 ) 

-

55 new_recipe.save() 

-

56 import_count += 1 

-

57 

-

58 log_entry = SyncLog( 

-

59 status='SUCCESS', 

-

60 msg='Imported ' + str(import_count) + ' recipes', 

-

61 sync=monitor, 

-

62 ) 

-

63 log_entry.save() 

-

64 

-

65 monitor.last_checked = datetime.now() 

-

66 monitor.save() 

-

67 

-

68 return True 

-

69 

-

70 @staticmethod 

-

71 def create_share_link(recipe): 

-

72 url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json' # noqa: E501 

-

73 

-

74 headers = { 

-

75 "OCS-APIRequest": "true", 

-

76 "Content-Type": "application/x-www-form-urlencoded" 

-

77 } 

-

78 

-

79 data = {'path': recipe.file_path, 'shareType': 3} 

-

80 

-

81 r = requests.post(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password), data=data) 

-

82 

-

83 response_json = r.json() 

-

84 

-

85 return response_json['ocs']['data']['url'] 

-

86 

-

87 @staticmethod 

-

88 def get_share_link(recipe): 

-

89 url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json&path=' + recipe.file_path # noqa: E501 

-

90 

-

91 headers = { 

-

92 "OCS-APIRequest": "true", 

-

93 "Content-Type": "application/json" 

-

94 } 

-

95 

-

96 if validators.url(url, public=True): 

-

97 r = requests.get( 

-

98 url, 

-

99 headers=headers, 

-

100 auth=HTTPBasicAuth( 

-

101 recipe.storage.username, recipe.storage.password 

-

102 ) 

-

103 ) 

-

104 

-

105 response_json = r.json() 

-

106 for element in response_json['ocs']['data']: 

-

107 if element['share_type'] == '3': 

-

108 return element['url'] 

-

109 

-

110 return Nextcloud.create_share_link(recipe) 

-

111 

-

112 @staticmethod 

-

113 def get_file(recipe): 

-

114 client = Nextcloud.get_client(recipe.storage) 

-

115 

-

116 tmp_file_path = tempfile.gettempdir() + '/' + recipe.name + '.pdf' 

-

117 

-

118 client.download_file( 

-

119 remote_path=recipe.file_path, 

-

120 local_path=tmp_file_path 

-

121 ) 

-

122 

-

123 file = io.BytesIO(open(tmp_file_path, 'rb').read()) 

-

124 os.remove(tmp_file_path) 

-

125 

-

126 return file 

-

127 

-

128 @staticmethod 

-

129 def rename_file(recipe, new_name): 

-

130 client = Nextcloud.get_client(recipe.storage) 

-

131 

-

132 client.move( 

-

133 recipe.file_path, 

-

134 "%s/%s%s" % ( 

-

135 os.path.dirname(recipe.file_path), 

-

136 new_name, 

-

137 os.path.splitext(recipe.file_path)[1] 

-

138 ) 

-

139 ) 

-

140 

-

141 return True 

-

142 

-

143 @staticmethod 

-

144 def delete_file(recipe): 

-

145 client = Nextcloud.get_client(recipe.storage) 

-

146 

-

147 client.clean(recipe.file_path) 

-

148 

-

149 return True 

-
- - - diff --git a/docs/coverage/d_0b5495cf37ee6c4f_provider_py.html b/docs/coverage/d_0b5495cf37ee6c4f_provider_py.html deleted file mode 100644 index 0e1befc08d..0000000000 --- a/docs/coverage/d_0b5495cf37ee6c4f_provider_py.html +++ /dev/null @@ -1,121 +0,0 @@ - - - - - Coverage for cookbook/provider/provider.py: 68% - - - - - -
-
-

- Coverage for cookbook/provider/provider.py: - 68% -

- -

- 19 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1class Provider: 

-

2 @staticmethod 

-

3 def import_all(monitor): 

-

4 raise Exception('Method not implemented in storage provider') 

-

5 

-

6 @staticmethod 

-

7 def create_share_link(recipe): 

-

8 raise Exception('Method not implemented in storage provider') 

-

9 

-

10 @staticmethod 

-

11 def get_share_link(recipe): 

-

12 raise Exception('Method not implemented in storage provider') 

-

13 

-

14 @staticmethod 

-

15 def get_file(recipe): 

-

16 raise Exception('Method not implemented in storage provider') 

-

17 

-

18 @staticmethod 

-

19 def rename_file(recipe, new_name): 

-

20 raise Exception('Method not implemented in storage provider') 

-

21 

-

22 @staticmethod 

-

23 def delete_file(recipe): 

-

24 raise Exception('Method not implemented in storage provider') 

-
- - - diff --git a/docs/coverage/d_1d409d097a8b76e7_custom_tags_py.html b/docs/coverage/d_1d409d097a8b76e7_custom_tags_py.html deleted file mode 100644 index 10e4f836ca..0000000000 --- a/docs/coverage/d_1d409d097a8b76e7_custom_tags_py.html +++ /dev/null @@ -1,308 +0,0 @@ - - - - - Coverage for cookbook/templatetags/custom_tags.py: 73% - - - - - -
-
-

- Coverage for cookbook/templatetags/custom_tags.py: - 73% -

- -

- 124 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2from gettext import gettext as _ 

-

3 

-

4import bleach 

-

5import markdown as md 

-

6from django import template 

-

7from django.db.models import Avg 

-

8from django.templatetags.static import static 

-

9from django.urls import NoReverseMatch, reverse 

-

10from django_scopes import ScopeError 

-

11from markdown.extensions.tables import TableExtension 

-

12from rest_framework.authtoken.models import Token 

-

13 

-

14from cookbook.helper.mdx_attributes import MarkdownFormatExtension 

-

15from cookbook.helper.mdx_urlize import UrlizeExtension 

-

16from cookbook.models import get_model_name 

-

17from recipes import settings 

-

18from recipes.settings import PLUGINS, STATIC_URL 

-

19 

-

20register = template.Library() 

-

21 

-

22 

-

23@register.filter() 

-

24def get_class_name(value): 

-

25 return value.__class__.__name__ 

-

26 

-

27 

-

28@register.filter() 

-

29def get_class(value): 

-

30 return value.__class__ 

-

31 

-

32 

-

33@register.simple_tag 

-

34def class_name(value): 

-

35 return value.__class__.__name__ 

-

36 

-

37 

-

38@register.simple_tag 

-

39def delete_url(model, pk): 

-

40 try: 

-

41 return reverse(f'delete_{get_model_name(model)}', args=[pk]) 

-

42 except NoReverseMatch: 

-

43 return None 

-

44 

-

45 

-

46@register.filter() 

-

47def markdown(value): 

-

48 tags = { 

-

49 "h1", "h2", "h3", "h4", "h5", "h6", 

-

50 "b", "i", "strong", "em", "tt", 

-

51 "p", "br", 

-

52 "span", "div", "blockquote", "code", "pre", "hr", 

-

53 "ul", "ol", "li", "dd", "dt", 

-

54 "img", 

-

55 "a", 

-

56 "sub", "sup", 

-

57 'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead' 

-

58 } 

-

59 parsed_md = md.markdown( 

-

60 value, 

-

61 extensions=[ 

-

62 'markdown.extensions.fenced_code', TableExtension(), 

-

63 UrlizeExtension(), MarkdownFormatExtension() 

-

64 ] 

-

65 ) 

-

66 markdown_attrs = { 

-

67 "*": ["id", "class"], 

-

68 "img": ["src", "alt", "title"], 

-

69 "a": ["href", "alt", "title"], 

-

70 } 

-

71 

-

72 parsed_md = parsed_md[3:] # remove outer paragraph 

-

73 parsed_md = parsed_md[:len(parsed_md) - 4] 

-

74 return bleach.clean(parsed_md, tags, markdown_attrs) 

-

75 

-

76 

-

77@register.simple_tag 

-

78def recipe_rating(recipe, user): 

-

79 if not user.is_authenticated: 

-

80 return '' 

-

81 rating = recipe.cooklog_set \ 

-

82 .filter(created_by=user, rating__gt=0) \ 

-

83 .aggregate(Avg('rating')) 

-

84 if rating['rating__avg']: 

-

85 

-

86 rating_stars = '<span style="display: inline-block;">' 

-

87 for i in range(int(rating['rating__avg'])): 

-

88 rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>' 

-

89 

-

90 if rating['rating__avg'] % 1 >= 0.5: 

-

91 rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>' 

-

92 

-

93 rating_stars += '</span>' 

-

94 

-

95 return rating_stars 

-

96 else: 

-

97 return '' 

-

98 

-

99 

-

100@register.simple_tag 

-

101def recipe_last(recipe, user): 

-

102 if not user.is_authenticated: 

-

103 return '' 

-

104 last = recipe.cooklog_set.filter(created_by=user).last() 

-

105 if last: 

-

106 return last.created_at 

-

107 else: 

-

108 return '' 

-

109 

-

110 

-

111@register.simple_tag 

-

112def page_help(page_name): 

-

113 help_pages = { 

-

114 'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/', 

-

115 'view_shopping': 'https://docs.tandoor.dev/features/shopping/', 

-

116 'view_import': 'https://docs.tandoor.dev/features/import_export/', 

-

117 'view_export': 'https://docs.tandoor.dev/features/import_export/', 

-

118 'list_automation': 'https://docs.tandoor.dev/features/automation/', 

-

119 } 

-

120 

-

121 link = help_pages.get(page_name, '') 

-

122 

-

123 if link != '': 

-

124 return f'<li class="nav-item"><a class="nav-link" target="_blank" rel="nofollow noreferrer" href="{link}"><i class="far fa-question-circle"></i>&zwnj;<span class="d-lg-none"> {_("Help")}</span></a></li>' 

-

125 else: 

-

126 return None 

-

127 

-

128 

-

129@register.simple_tag 

-

130def message_of_the_day(request): 

-

131 try: 

-

132 if request.space.message: 

-

133 return request.space.message 

-

134 except (AttributeError, KeyError, ValueError): 

-

135 pass 

-

136 

-

137 

-

138@register.simple_tag 

-

139def is_debug(): 

-

140 return settings.DEBUG 

-

141 

-

142 

-

143@register.simple_tag() 

-

144def markdown_link(): 

-

145 return f"{_('You can use markdown to format this field. See the ')}<a target='_blank' href='{reverse('docs_markdown')}'>{_('docs here')}</a>" 

-

146 

-

147 

-

148@register.simple_tag 

-

149def plugin_dropdown_nav_templates(): 

-

150 templates = [] 

-

151 for p in PLUGINS: 

-

152 if p['nav_dropdown']: 

-

153 templates.append(p['nav_dropdown']) 

-

154 return templates 

-

155 

-

156 

-

157@register.simple_tag 

-

158def plugin_main_nav_templates(): 

-

159 templates = [] 

-

160 for p in PLUGINS: 

-

161 if p['nav_main']: 

-

162 templates.append(p['nav_main']) 

-

163 return templates 

-

164 

-

165 

-

166@register.simple_tag 

-

167def bookmarklet(request): 

-

168 if request.is_secure(): 

-

169 protocol = "https://" 

-

170 else: 

-

171 protocol = "http://" 

-

172 server = protocol + request.get_host() 

-

173 prefix = settings.JS_REVERSE_SCRIPT_PREFIX 

-

174 # TODO is it safe to store the token in clear text in a bookmark? 

-

175 if (api_token := Token.objects.filter(user=request.user).first()) is None: 

-

176 api_token = Token.objects.create(user=request.user) 

-

177 

-

178 bookmark = "<a href='javascript: \ 

-

179 (function(){ \ 

-

180 if(window.bookmarkletTandoor!==undefined){ \ 

-

181 bookmarkletTandoor(); \ 

-

182 } else { \ 

-

183 localStorage.setItem('importURL', '" + server + reverse('api:bookmarkletimport-list') + "'); \ 

-

184 localStorage.setItem('redirectURL', '" + server + reverse('data_import_url') + "'); \ 

-

185 localStorage.setItem('token', '" + api_token.__str__() + "'); \ 

-

186 document.body.appendChild(document.createElement(\'script\')).src=\'" \ 

-

187 + server + prefix + static('js/bookmarklet_v3.js') + "? \ 

-

188 r=\'+Math.floor(Math.random()*999999999);}})();'>Test</a>" 

-

189 return re.sub(r"[\n\t]*", "", bookmark) 

-

190 

-

191 

-

192@register.simple_tag 

-

193def base_path(request, path_type): 

-

194 if path_type == 'base': 

-

195 return request._current_scheme_host + request.META.get('HTTP_X_SCRIPT_NAME', '') 

-

196 elif path_type == 'script': 

-

197 return request.META.get('HTTP_X_SCRIPT_NAME', '') 

-

198 elif path_type == 'static_base': 

-

199 return STATIC_URL 

-

200 

-

201 

-

202@register.simple_tag 

-

203def user_prefs(request): 

-

204 from cookbook.serializer import \ 

-

205 UserPreferenceSerializer # putting it with imports caused circular execution 

-

206 try: 

-

207 return UserPreferenceSerializer(request.user.userpreference, context={'request': request}).data 

-

208 except AttributeError: 

-

209 pass 

-

210 except ScopeError: # there are pages without an active space that still need to load but don't require prefs 

-

211 pass 

-
- - - diff --git a/docs/coverage/d_1d409d097a8b76e7_theming_tags_py.html b/docs/coverage/d_1d409d097a8b76e7_theming_tags_py.html deleted file mode 100644 index bee22ee2ef..0000000000 --- a/docs/coverage/d_1d409d097a8b76e7_theming_tags_py.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - Coverage for cookbook/templatetags/theming_tags.py: 87% - - - - - -
-
-

- Coverage for cookbook/templatetags/theming_tags.py: - 87% -

- -

- 30 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django import template 

-

2from django.templatetags.static import static 

-

3 

-

4from cookbook.models import UserPreference 

-

5from recipes.settings import STICKY_NAV_PREF_DEFAULT 

-

6 

-

7register = template.Library() 

-

8 

-

9 

-

10@register.simple_tag 

-

11def theme_url(request): 

-

12 if not request.user.is_authenticated: 

-

13 return static('themes/tandoor.min.css') 

-

14 themes = { 

-

15 UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css', 

-

16 UserPreference.FLATLY: 'themes/flatly.min.css', 

-

17 UserPreference.DARKLY: 'themes/darkly.min.css', 

-

18 UserPreference.SUPERHERO: 'themes/superhero.min.css', 

-

19 UserPreference.TANDOOR: 'themes/tandoor.min.css', 

-

20 UserPreference.TANDOOR_DARK: 'themes/tandoor_dark.min.css', 

-

21 } 

-

22 if request.user.userpreference.theme in themes: 

-

23 return static(themes[request.user.userpreference.theme]) 

-

24 else: 

-

25 raise AttributeError 

-

26 

-

27 

-

28@register.simple_tag 

-

29def logo_url(request): 

-

30 if request.user.is_authenticated and getattr(getattr(request, "space", {}), 'image', None): 

-

31 return request.space.image.file.url 

-

32 else: 

-

33 return static('assets/brand_logo.png') 

-

34 

-

35 

-

36@register.simple_tag 

-

37def nav_color(request): 

-

38 if not request.user.is_authenticated: 

-

39 return 'navbar-light bg-primary' 

-

40 

-

41 if request.user.userpreference.nav_color.lower() in ['light', 'warning', 'info', 'success']: 

-

42 return f'navbar-light bg-{request.user.userpreference.nav_color.lower()}' 

-

43 else: 

-

44 return f'navbar-dark bg-{request.user.userpreference.nav_color.lower()}' 

-

45 

-

46 

-

47@register.simple_tag 

-

48def sticky_nav(request): 

-

49 if (not request.user.is_authenticated and STICKY_NAV_PREF_DEFAULT) or \ 

-

50 (request.user.is_authenticated and request.user.userpreference.sticky_navbar): # noqa: E501 

-

51 return 'position: sticky; top: 0; left: 0; z-index: 1000;' 

-

52 else: 

-

53 return '' 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_cheftap_py.html b/docs/coverage/d_37812bb4c19c71da_cheftap_py.html deleted file mode 100644 index ec243061e0..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_cheftap_py.html +++ /dev/null @@ -1,156 +0,0 @@ - - - - - Coverage for cookbook/integration/cheftap.py: 20% - - - - - -
-
-

- Coverage for cookbook/integration/cheftap.py: - 20% -

- -

- 41 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2 

-

3from cookbook.helper.ingredient_parser import IngredientParser 

-

4from cookbook.integration.integration import Integration 

-

5from cookbook.models import Ingredient, Recipe, Step 

-

6 

-

7 

-

8class ChefTap(Integration): 

-

9 

-

10 def import_file_name_filter(self, zip_info_object): 

-

11 print("testing", zip_info_object.filename) 

-

12 return re.match(r'^cheftap_export/([A-Za-z\d\s\-_()\[\]\u00C0-\u017F])+.txt$', zip_info_object.filename) or re.match(r'^([A-Za-z\d\s\-_()\[\]\u00C0-\u017F])+.txt$', zip_info_object.filename) 

-

13 

-

14 def get_recipe_from_file(self, file): 

-

15 source_url = '' 

-

16 

-

17 ingredient_mode = 0 

-

18 

-

19 ingredients = [] 

-

20 directions = [] 

-

21 for i, fl in enumerate(file.readlines(), start=0): 

-

22 line = fl.decode("utf-8") 

-

23 if i == 0: 

-

24 title = line.strip() 

-

25 else: 

-

26 if line.startswith('https:') or line.startswith('http:'): 

-

27 source_url = line.strip() 

-

28 else: 

-

29 if ingredient_mode == 1 and len(line.strip()) == 0: 

-

30 ingredient_mode = 2 

-

31 if re.match(r'^([0-9])[^.](.)*$', line) and ingredient_mode < 2: 

-

32 ingredient_mode = 1 

-

33 ingredients.append(line.strip()) 

-

34 else: 

-

35 directions.append(line.strip()) 

-

36 

-

37 recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, ) 

-

38 

-

39 step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,) 

-

40 

-

41 if source_url != '': 

-

42 step.instruction += '\n' + source_url 

-

43 step.save() 

-

44 

-

45 ingredient_parser = IngredientParser(self.request, True) 

-

46 for ingredient in ingredients: 

-

47 if len(ingredient.strip()) > 0: 

-

48 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

49 f = ingredient_parser.get_food(food) 

-

50 u = ingredient_parser.get_unit(unit) 

-

51 step.ingredients.add(Ingredient.objects.create( 

-

52 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

53 )) 

-

54 recipe.steps.add(step) 

-

55 

-

56 return recipe 

-

57 

-

58 def get_file_from_recipe(self, recipe): 

-

59 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_chowdown_py.html b/docs/coverage/d_37812bb4c19c71da_chowdown_py.html deleted file mode 100644 index c4f9177599..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_chowdown_py.html +++ /dev/null @@ -1,219 +0,0 @@ - - - - - Coverage for cookbook/integration/chowdown.py: 12% - - - - - -
-
-

- Coverage for cookbook/integration/chowdown.py: - 12% -

- -

- 96 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2from io import BytesIO 

-

3from zipfile import ZipFile 

-

4 

-

5from cookbook.helper.image_processing import get_filetype 

-

6from cookbook.helper.ingredient_parser import IngredientParser 

-

7from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time 

-

8from cookbook.integration.integration import Integration 

-

9from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

10 

-

11 

-

12class Chowdown(Integration): 

-

13 

-

14 def import_file_name_filter(self, zip_info_object): 

-

15 print("testing", zip_info_object.filename) 

-

16 return re.match(r'^(_)*recipes/([A-Za-z\d\s\-_()\[\]\u00C0-\u017F])+.md$', zip_info_object.filename) 

-

17 

-

18 def get_recipe_from_file(self, file): 

-

19 ingredient_mode = False 

-

20 direction_mode = False 

-

21 description_mode = False 

-

22 

-

23 description = None 

-

24 prep_time = None 

-

25 serving = None 

-

26 

-

27 ingredients = [] 

-

28 directions = [] 

-

29 descriptions = [] 

-

30 for fl in file.readlines(): 

-

31 line = fl.decode("utf-8") 

-

32 if 'title:' in line: 

-

33 title = line.replace('title:', '').replace('"', '').strip() 

-

34 if 'description:' in line: 

-

35 description = line.replace('description:', '').replace('"', '').strip() 

-

36 if 'prep_time:' in line: 

-

37 prep_time = line.replace('prep_time:', '').replace('"', '').strip() 

-

38 if 'yield:' in line: 

-

39 serving = line.replace('yield:', '').replace('"', '').strip() 

-

40 if 'image:' in line: 

-

41 image = line.replace('image:', '').strip() 

-

42 if 'tags:' in line: 

-

43 tags = line.replace('tags:', '').strip() 

-

44 if ingredient_mode: 

-

45 if len(line) > 2 and 'directions:' not in line: 

-

46 ingredients.append(line[2:]) 

-

47 if '---' in line and direction_mode: 

-

48 direction_mode = False 

-

49 description_mode = True 

-

50 if direction_mode: 

-

51 if len(line) > 2: 

-

52 directions.append(line[2:]) 

-

53 if 'ingredients:' in line: 

-

54 ingredient_mode = True 

-

55 if 'directions:' in line: 

-

56 ingredient_mode = False 

-

57 direction_mode = True 

-

58 if description_mode and len(line) > 3 and '---' not in line: 

-

59 descriptions.append(line) 

-

60 

-

61 recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space) 

-

62 if description: 

-

63 recipe.description = description 

-

64 

-

65 for k in tags.split(','): 

-

66 keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space) 

-

67 recipe.keywords.add(keyword) 

-

68 

-

69 ingredients_added = False 

-

70 for direction in directions: 

-

71 if len(direction.strip()) > 0: 

-

72 step = Step.objects.create( 

-

73 instruction=direction, name='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

74 ) 

-

75 else: 

-

76 step = Step.objects.create( 

-

77 instruction=direction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

78 ) 

-

79 if not ingredients_added: 

-

80 ingredients_added = True 

-

81 

-

82 ingredient_parser = IngredientParser(self.request, True) 

-

83 for ingredient in ingredients: 

-

84 if len(ingredient.strip()) > 0: 

-

85 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

86 f = ingredient_parser.get_food(food) 

-

87 u = ingredient_parser.get_unit(unit) 

-

88 step.ingredients.add(Ingredient.objects.create( 

-

89 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

90 )) 

-

91 recipe.steps.add(step) 

-

92 

-

93 if serving: 

-

94 recipe.servings = parse_servings(serving) 

-

95 recipe.servings_text = 'servings' 

-

96 

-

97 if prep_time: 

-

98 recipe.working_time = parse_time(prep_time) 

-

99 

-

100 ingredient_parser = IngredientParser(self.request, True) 

-

101 for ingredient in ingredients: 

-

102 if len(ingredient.strip()) > 0: 

-

103 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

104 f = ingredient_parser.get_food(food) 

-

105 u = ingredient_parser.get_unit(unit) 

-

106 step.ingredients.add(Ingredient.objects.create( 

-

107 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

108 )) 

-

109 recipe.steps.add(step) 

-

110 

-

111 for f in self.files: 

-

112 if '.zip' in f['name']: 

-

113 import_zip = ZipFile(f['file']) 

-

114 for z in import_zip.filelist: 

-

115 if re.match(f'^images/{image}$', z.filename): 

-

116 self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename)) 

-

117 

-

118 recipe.save() 

-

119 return recipe 

-

120 

-

121 def get_file_from_recipe(self, recipe): 

-

122 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_cookbookapp_py.html b/docs/coverage/d_37812bb4c19c71da_cookbookapp_py.html deleted file mode 100644 index 736f5a2766..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_cookbookapp_py.html +++ /dev/null @@ -1,170 +0,0 @@ - - - - - Coverage for cookbook/integration/cookbookapp.py: 24% - - - - - -
-
-

- Coverage for cookbook/integration/cookbookapp.py: - 24% -

- -

- 49 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2from io import BytesIO 

-

3 

-

4import requests 

-

5import validators 

-

6 

-

7from cookbook.helper.ingredient_parser import IngredientParser 

-

8from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup, 

-

9 iso_duration_to_minutes) 

-

10from cookbook.helper.scrapers.scrapers import text_scraper 

-

11from cookbook.integration.integration import Integration 

-

12from cookbook.models import Ingredient, Recipe, Step 

-

13 

-

14 

-

15class CookBookApp(Integration): 

-

16 

-

17 def import_file_name_filter(self, zip_info_object): 

-

18 return zip_info_object.filename.endswith('.html') 

-

19 

-

20 def get_recipe_from_file(self, file): 

-

21 recipe_html = file.getvalue().decode("utf-8") 

-

22 

-

23 scrape = text_scraper(text=recipe_html) 

-

24 recipe_json = get_from_scraper(scrape, self.request) 

-

25 images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None))) 

-

26 

-

27 recipe = Recipe.objects.create( 

-

28 name=recipe_json['name'].strip(), 

-

29 created_by=self.request.user, internal=True, 

-

30 space=self.request.space) 

-

31 

-

32 try: 

-

33 recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0] 

-

34 except Exception: 

-

35 pass 

-

36 

-

37 try: 

-

38 recipe.working_time = iso_duration_to_minutes(recipe_json['prepTime']) 

-

39 recipe.waiting_time = iso_duration_to_minutes(recipe_json['cookTime']) 

-

40 except Exception: 

-

41 pass 

-

42 

-

43 # assuming import files only contain single step 

-

44 step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space, 

-

45 show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) 

-

46 

-

47 if 'nutrition' in recipe_json: 

-

48 step.instruction = step.instruction + '\n\n' + recipe_json['nutrition'] 

-

49 

-

50 step.save() 

-

51 recipe.steps.add(step) 

-

52 

-

53 ingredient_parser = IngredientParser(self.request, True) 

-

54 for ingredient in recipe_json['steps'][0]['ingredients']: 

-

55 f = ingredient_parser.get_food(ingredient['food']['name']) 

-

56 u = None 

-

57 if unit := ingredient.get('unit', None): 

-

58 u = ingredient_parser.get_unit(unit.get('name', None)) 

-

59 step.ingredients.add(Ingredient.objects.create( 

-

60 food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space, 

-

61 )) 

-

62 

-

63 if len(images) > 0: 

-

64 try: 

-

65 url = images[0] 

-

66 if validators.url(url, public=True): 

-

67 response = requests.get(url) 

-

68 self.import_recipe_image(recipe, BytesIO(response.content)) 

-

69 except Exception as e: 

-

70 print('failed to import image ', str(e)) 

-

71 

-

72 recipe.save() 

-

73 return recipe 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_cookmate_py.html b/docs/coverage/d_37812bb4c19c71da_cookmate_py.html deleted file mode 100644 index 5589573d93..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_cookmate_py.html +++ /dev/null @@ -1,180 +0,0 @@ - - - - - Coverage for cookbook/integration/cookmate.py: 21% - - - - - -
-
-

- Coverage for cookbook/integration/cookmate.py: - 21% -

- -

- 56 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from io import BytesIO 

-

2 

-

3import requests 

-

4import validators 

-

5 

-

6from cookbook.helper.ingredient_parser import IngredientParser 

-

7from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time 

-

8from cookbook.integration.integration import Integration 

-

9from cookbook.models import Ingredient, Recipe, Step 

-

10 

-

11 

-

12class Cookmate(Integration): 

-

13 

-

14 def import_file_name_filter(self, zip_info_object): 

-

15 return zip_info_object.filename.endswith('.xml') 

-

16 

-

17 def get_files_from_recipes(self, recipes, el, cookie): 

-

18 raise NotImplementedError('Method not implemented in storage integration') 

-

19 

-

20 def get_recipe_from_file(self, file): 

-

21 recipe_xml = file 

-

22 

-

23 recipe = Recipe.objects.create( 

-

24 name=recipe_xml.find('title').text.strip(), 

-

25 created_by=self.request.user, internal=True, space=self.request.space) 

-

26 

-

27 if recipe_xml.find('preptime') is not None and recipe_xml.find('preptime').text is not None: 

-

28 recipe.working_time = parse_time(recipe_xml.find('preptime').text.strip()) 

-

29 

-

30 if recipe_xml.find('cooktime') is not None and recipe_xml.find('cooktime').text is not None: 

-

31 recipe.waiting_time = parse_time(recipe_xml.find('cooktime').text.strip()) 

-

32 

-

33 if recipe_xml.find('quantity') is not None and recipe_xml.find('quantity').text is not None: 

-

34 recipe.servings = parse_servings(recipe_xml.find('quantity').text.strip()) 

-

35 recipe.servings_text = parse_servings_text(recipe_xml.find('quantity').text.strip()) 

-

36 

-

37 if recipe_xml.find('url') is not None and recipe_xml.find('url').text is not None: 

-

38 recipe.source_url = recipe_xml.find('url').text.strip() 

-

39 

-

40 if recipe_xml.find('description') is not None: # description is a list of <li>'s with text 

-

41 if len(recipe_xml.find('description')) > 0: 

-

42 recipe.description = recipe_xml.find('description')[0].text[:512] 

-

43 

-

44 if recipe_text := recipe_xml.find('recipetext'): 

-

45 for step in recipe_text.getchildren(): 

-

46 if step.text: 

-

47 step = Step.objects.create( 

-

48 instruction=step.text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

49 ) 

-

50 recipe.steps.add(step) 

-

51 

-

52 ingredient_parser = IngredientParser(self.request, True) 

-

53 

-

54 if recipe_ingredients := recipe_xml.find('ingredient'): 

-

55 ingredient_step = recipe.steps.first() 

-

56 if ingredient_step is None: 

-

57 ingredient_step = Step.objects.create(space=self.request.space, instruction='') 

-

58 

-

59 for ingredient in recipe_ingredients.getchildren(): 

-

60 if ingredient.text: 

-

61 if ingredient.text.strip() != '': 

-

62 amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip()) 

-

63 f = ingredient_parser.get_food(food) 

-

64 u = ingredient_parser.get_unit(unit) 

-

65 ingredient_step.ingredients.add(Ingredient.objects.create( 

-

66 food=f, unit=u, amount=amount, note=note, original_text=ingredient.text.strip(), space=self.request.space, 

-

67 )) 

-

68 

-

69 if recipe_xml.find('imageurl') is not None: 

-

70 try: 

-

71 url = recipe_xml.find('imageurl').text.strip() 

-

72 if validators.url(url, public=True): 

-

73 response = requests.get(url) 

-

74 self.import_recipe_image(recipe, BytesIO(response.content)) 

-

75 except Exception as e: 

-

76 print('failed to import image ', str(e)) 

-

77 

-

78 recipe.save() 

-

79 

-

80 return recipe 

-

81 

-

82 def get_file_from_recipe(self, recipe): 

-

83 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_copymethat_py.html b/docs/coverage/d_37812bb4c19c71da_copymethat_py.html deleted file mode 100644 index dee9bc5751..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_copymethat_py.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - Coverage for cookbook/integration/copymethat.py: 14% - - - - - -
-
-

- Coverage for cookbook/integration/copymethat.py: - 14% -

- -

- 94 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from io import BytesIO 

-

2from zipfile import ZipFile 

-

3 

-

4from bs4 import BeautifulSoup, Tag 

-

5from django.utils.translation import gettext as _ 

-

6 

-

7from cookbook.helper.ingredient_parser import IngredientParser 

-

8from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings 

-

9from cookbook.integration.integration import Integration 

-

10from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

11from recipes.settings import DEBUG 

-

12 

-

13 

-

14class CopyMeThat(Integration): 

-

15 

-

16 def import_file_name_filter(self, zip_info_object): 

-

17 if DEBUG: 

-

18 print("testing", zip_info_object.filename, zip_info_object.filename == 'recipes.html') 

-

19 return zip_info_object.filename == 'recipes.html' 

-

20 

-

21 def get_recipe_from_file(self, file): 

-

22 # 'file' comes is as a beautifulsoup object 

-

23 try: 

-

24 source = file.find("a", {"id": "original_link"}).text 

-

25 except AttributeError: 

-

26 source = None 

-

27 

-

28 recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip( 

-

29 )[:128], source_url=source, created_by=self.request.user, internal=True, space=self.request.space, ) 

-

30 

-

31 for category in file.find_all("span", {"class": "recipeCategory"}): 

-

32 keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space) 

-

33 recipe.keywords.add(keyword) 

-

34 

-

35 try: 

-

36 recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip()) 

-

37 recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip()) 

-

38 recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip()) 

-

39 except AttributeError: 

-

40 pass 

-

41 

-

42 try: 

-

43 if len(file.find("span", {"id": "starred"}).text.strip()) > 0: 

-

44 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0]) 

-

45 except AttributeError: 

-

46 pass 

-

47 

-

48 try: 

-

49 if len(file.find("span", {"id": "made_this"}).text.strip()) > 0: 

-

50 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('I made this'))[0]) 

-

51 except AttributeError: 

-

52 pass 

-

53 

-

54 step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) 

-

55 

-

56 ingredient_parser = IngredientParser(self.request, True) 

-

57 

-

58 ingredients = file.find("ul", {"id": "recipeIngredients"}) 

-

59 if isinstance(ingredients, Tag): 

-

60 for ingredient in ingredients.children: 

-

61 if not isinstance(ingredient, Tag) or not ingredient.text.strip() or "recipeIngredient_spacer" in ingredient['class']: 

-

62 continue 

-

63 if any(x in ingredient['class'] for x in ["recipeIngredient_subheader", "recipeIngredient_note"]): 

-

64 step.ingredients.add( 

-

65 Ingredient.objects.create( 

-

66 is_header=True, 

-

67 note=ingredient.text.strip()[ 

-

68 :256], 

-

69 original_text=ingredient.text.strip(), 

-

70 space=self.request.space, 

-

71 )) 

-

72 else: 

-

73 amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip()) 

-

74 f = ingredient_parser.get_food(food) 

-

75 u = ingredient_parser.get_unit(unit) 

-

76 step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, note=note, original_text=ingredient.text.strip(), space=self.request.space, )) 

-

77 

-

78 instructions = file.find("ol", {"id": "recipeInstructions"}) 

-

79 if isinstance(instructions, Tag): 

-

80 for instruction in instructions.children: 

-

81 if not isinstance(instruction, Tag) or instruction.text == "": 

-

82 continue 

-

83 if "instruction_subheader" in instruction['class']: 

-

84 if step.instruction: 

-

85 step.save() 

-

86 recipe.steps.add(step) 

-

87 step = Step.objects.create(instruction='', space=self.request.space, ) 

-

88 

-

89 step.name = instruction.text.strip()[:128] 

-

90 else: 

-

91 step.instruction += instruction.text.strip() + ' \n\n' 

-

92 

-

93 notes = file.find_all("li", {"class": "recipeNote"}) 

-

94 if notes: 

-

95 step.instruction += '*Notes:* \n\n' 

-

96 

-

97 for n in notes: 

-

98 if n.text == "": 

-

99 continue 

-

100 step.instruction += '*' + n.text.strip() + '* \n\n' 

-

101 

-

102 description = '' 

-

103 try: 

-

104 description = file.find("div", {"id": "description"}).text.strip() 

-

105 except AttributeError: 

-

106 pass 

-

107 if len(description) <= 512: 

-

108 recipe.description = description 

-

109 else: 

-

110 recipe.description = description[:480] + ' ... (full description below)' 

-

111 step.instruction += '*Description:* \n\n*' + description + '* \n\n' 

-

112 

-

113 step.save() 

-

114 recipe.steps.add(step) 

-

115 

-

116 # import the Primary recipe image that is stored in the Zip 

-

117 try: 

-

118 for f in self.files: 

-

119 if '.zip' in f['name']: 

-

120 import_zip = ZipFile(f['file']) 

-

121 self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipeImage").get("src"))), filetype='.jpeg') 

-

122 except Exception as e: 

-

123 print(recipe.name, ': failed to import image ', str(e)) 

-

124 

-

125 recipe.save() 

-

126 return recipe 

-

127 

-

128 def split_recipe_file(self, file): 

-

129 soup = BeautifulSoup(file, "html.parser") 

-

130 return soup.find_all("div", {"class": "recipe"}) 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_default_py.html b/docs/coverage/d_37812bb4c19c71da_default_py.html deleted file mode 100644 index 68d33c18bd..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_default_py.html +++ /dev/null @@ -1,171 +0,0 @@ - - - - - Coverage for cookbook/integration/default.py: 26% - - - - - -
-
-

- Coverage for cookbook/integration/default.py: - 26% -

- -

- 54 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2import traceback 

-

3from io import BytesIO, StringIO 

-

4from re import match 

-

5from zipfile import ZipFile 

-

6 

-

7from rest_framework.renderers import JSONRenderer 

-

8 

-

9from cookbook.helper.image_processing import get_filetype 

-

10from cookbook.integration.integration import Integration 

-

11from cookbook.serializer import RecipeExportSerializer 

-

12 

-

13 

-

14class Default(Integration): 

-

15 

-

16 def get_recipe_from_file(self, file): 

-

17 recipe_zip = ZipFile(file) 

-

18 

-

19 recipe_string = recipe_zip.read('recipe.json').decode("utf-8") 

-

20 recipe = self.decode_recipe(recipe_string) 

-

21 images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist())) 

-

22 if images: 

-

23 try: 

-

24 self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0])) 

-

25 except AttributeError: 

-

26 traceback.print_exc() 

-

27 return recipe 

-

28 

-

29 def decode_recipe(self, string): 

-

30 data = json.loads(string) 

-

31 serialized_recipe = RecipeExportSerializer(data=data, context={'request': self.request}) 

-

32 if serialized_recipe.is_valid(): 

-

33 recipe = serialized_recipe.save() 

-

34 return recipe 

-

35 

-

36 return None 

-

37 

-

38 def get_file_from_recipe(self, recipe): 

-

39 

-

40 export = RecipeExportSerializer(recipe).data 

-

41 

-

42 return 'recipe.json', JSONRenderer().render(export).decode("utf-8") 

-

43 

-

44 def get_files_from_recipes(self, recipes, el, cookie): 

-

45 export_zip_stream = BytesIO() 

-

46 export_zip_obj = ZipFile(export_zip_stream, 'w') 

-

47 

-

48 for r in recipes: 

-

49 if r.internal and r.space == self.request.space: 

-

50 recipe_zip_stream = BytesIO() 

-

51 recipe_zip_obj = ZipFile(recipe_zip_stream, 'w') 

-

52 

-

53 recipe_stream = StringIO() 

-

54 filename, data = self.get_file_from_recipe(r) 

-

55 recipe_stream.write(data) 

-

56 recipe_zip_obj.writestr(filename, recipe_stream.getvalue()) 

-

57 recipe_stream.close() 

-

58 

-

59 try: 

-

60 recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read()) 

-

61 except (ValueError, FileNotFoundError): 

-

62 pass 

-

63 

-

64 recipe_zip_obj.close() 

-

65 

-

66 export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue()) 

-

67 

-

68 el.exported_recipes += 1 

-

69 el.msg += self.get_recipe_processed_msg(r) 

-

70 el.save() 

-

71 

-

72 export_zip_obj.close() 

-

73 

-

74 return [[self.get_export_file_name(), export_zip_stream.getvalue()]] 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_domestica_py.html b/docs/coverage/d_37812bb4c19c71da_domestica_py.html deleted file mode 100644 index 2b8ba28641..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_domestica_py.html +++ /dev/null @@ -1,154 +0,0 @@ - - - - - Coverage for cookbook/integration/domestica.py: 29% - - - - - -
-
-

- Coverage for cookbook/integration/domestica.py: - 29% -

- -

- 34 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import base64 

-

2import json 

-

3from io import BytesIO 

-

4 

-

5from cookbook.helper.ingredient_parser import IngredientParser 

-

6from cookbook.integration.integration import Integration 

-

7from cookbook.models import Ingredient, Recipe, Step 

-

8 

-

9 

-

10class Domestica(Integration): 

-

11 

-

12 def get_recipe_from_file(self, file): 

-

13 

-

14 recipe = Recipe.objects.create( 

-

15 name=file['name'].strip(), 

-

16 created_by=self.request.user, internal=True, 

-

17 space=self.request.space) 

-

18 

-

19 if file['servings'] != '': 

-

20 recipe.servings = file['servings'] 

-

21 

-

22 if file['timeCook'] != '': 

-

23 recipe.waiting_time = file['timeCook'] 

-

24 

-

25 if file['timePrep'] != '': 

-

26 recipe.working_time = file['timePrep'] 

-

27 

-

28 recipe.save() 

-

29 

-

30 step = Step.objects.create( 

-

31 instruction=file['directions'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

32 ) 

-

33 

-

34 if file['source'] != '': 

-

35 step.instruction += '\n' + file['source'] 

-

36 

-

37 ingredient_parser = IngredientParser(self.request, True) 

-

38 for ingredient in file['ingredients'].split('\n'): 

-

39 if len(ingredient.strip()) > 0: 

-

40 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

41 f = ingredient_parser.get_food(food) 

-

42 u = ingredient_parser.get_unit(unit) 

-

43 step.ingredients.add(Ingredient.objects.create( 

-

44 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

45 )) 

-

46 recipe.steps.add(step) 

-

47 

-

48 if file['image'] != '': 

-

49 self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))), filetype='.jpeg') 

-

50 

-

51 return recipe 

-

52 

-

53 def get_file_from_recipe(self, recipe): 

-

54 raise NotImplementedError('Method not implemented in storage integration') 

-

55 

-

56 def split_recipe_file(self, file): 

-

57 return json.loads(file.read().decode("utf-8")) 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_integration_py.html b/docs/coverage/d_37812bb4c19c71da_integration_py.html deleted file mode 100644 index 0ebb2c14f0..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_integration_py.html +++ /dev/null @@ -1,393 +0,0 @@ - - - - - Coverage for cookbook/integration/integration.py: 20% - - - - - -
-
-

- Coverage for cookbook/integration/integration.py: - 20% -

- -

- 189 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import datetime 

-

2import traceback 

-

3import uuid 

-

4from io import BytesIO 

-

5from zipfile import BadZipFile, ZipFile 

-

6 

-

7from bs4 import Tag 

-

8from django.core.cache import cache 

-

9from django.core.exceptions import ObjectDoesNotExist 

-

10from django.core.files import File 

-

11from django.db import IntegrityError 

-

12from django.http import HttpResponse 

-

13from django.utils.formats import date_format 

-

14from django.utils.translation import gettext as _ 

-

15from django_scopes import scope 

-

16from lxml import etree 

-

17 

-

18from cookbook.helper.image_processing import handle_image 

-

19from cookbook.models import Keyword, Recipe 

-

20from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION 

-

21 

-

22 

-

23class Integration: 

-

24 request = None 

-

25 keyword = None 

-

26 files = None 

-

27 export_type = None 

-

28 ignored_recipes = [] 

-

29 

-

30 def __init__(self, request, export_type): 

-

31 """ 

-

32 Integration for importing and exporting recipes 

-

33 :param request: request context of import session (used to link user to created objects) 

-

34 """ 

-

35 self.request = request 

-

36 self.export_type = export_type 

-

37 self.ignored_recipes = [] 

-

38 

-

39 description = f'Imported by {request.user.get_user_display_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}' 

-

40 

-

41 try: 

-

42 last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at') 

-

43 name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}' 

-

44 except (ObjectDoesNotExist, ValueError): 

-

45 name = 'Import 1' 

-

46 

-

47 parent, created = Keyword.objects.get_or_create(name='Import', space=request.space) 

-

48 try: 

-

49 self.keyword = parent.add_child( 

-

50 name=name, 

-

51 description=description, 

-

52 space=request.space 

-

53 ) 

-

54 except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now. 

-

55 self.keyword = parent.add_child( 

-

56 name=f'{name} {str(uuid.uuid4())[0:8]}', 

-

57 description=description, 

-

58 space=request.space 

-

59 ) 

-

60 

-

61 def do_export(self, recipes, el): 

-

62 

-

63 with scope(space=self.request.space): 

-

64 el.total_recipes = len(recipes) 

-

65 el.cache_duration = EXPORT_FILE_CACHE_DURATION 

-

66 el.save() 

-

67 

-

68 files = self.get_files_from_recipes(recipes, el, self.request.COOKIES) 

-

69 

-

70 if len(files) == 1: 

-

71 filename, file = files[0] 

-

72 export_filename = filename 

-

73 export_file = file 

-

74 

-

75 else: 

-

76 # zip the files if there is more then one file 

-

77 export_filename = self.get_export_file_name() 

-

78 export_stream = BytesIO() 

-

79 export_obj = ZipFile(export_stream, 'w') 

-

80 

-

81 for filename, file in files: 

-

82 export_obj.writestr(filename, file) 

-

83 

-

84 export_obj.close() 

-

85 export_file = export_stream.getvalue() 

-

86 

-

87 cache.set('export_file_' + str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION) 

-

88 el.running = False 

-

89 el.save() 

-

90 

-

91 response = HttpResponse(export_file, content_type='application/force-download') 

-

92 response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"' 

-

93 return response 

-

94 

-

95 def import_file_name_filter(self, zip_info_object): 

-

96 """ 

-

97 Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files 

-

98 If false is returned the file will be ignored 

-

99 By default all files are included 

-

100 :param zip_info_object: ZipInfo object 

-

101 :return: Boolean if object should be included 

-

102 """ 

-

103 return True 

-

104 

-

105 def do_import(self, files, il, import_duplicates): 

-

106 """ 

-

107 Imports given files 

-

108 :param import_duplicates: if true duplicates are imported as well 

-

109 :param files: List of in memory files 

-

110 :param il: Import Log object to refresh while running 

-

111 :return: HttpResponseRedirect to the recipe search showing all imported recipes 

-

112 """ 

-

113 with scope(space=self.request.space): 

-

114 

-

115 try: 

-

116 self.files = files 

-

117 for f in files: 

-

118 if 'RecipeKeeper' in f['name']: 

-

119 import_zip = ZipFile(f['file']) 

-

120 file_list = [] 

-

121 for z in import_zip.filelist: 

-

122 if self.import_file_name_filter(z): 

-

123 file_list.append(z) 

-

124 il.total_recipes += len(file_list) 

-

125 

-

126 for z in file_list: 

-

127 data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8')) 

-

128 for d in data_list: 

-

129 recipe = self.get_recipe_from_file(d) 

-

130 recipe.keywords.add(self.keyword) 

-

131 il.msg += self.get_recipe_processed_msg(recipe) 

-

132 self.handle_duplicates(recipe, import_duplicates) 

-

133 il.imported_recipes += 1 

-

134 il.save() 

-

135 import_zip.close() 

-

136 elif '.zip' in f['name'] or '.paprikarecipes' in f['name'] or '.mcb' in f['name']: 

-

137 import_zip = ZipFile(f['file']) 

-

138 file_list = [] 

-

139 for z in import_zip.filelist: 

-

140 if self.import_file_name_filter(z): 

-

141 file_list.append(z) 

-

142 il.total_recipes += len(file_list) 

-

143 

-

144 import cookbook 

-

145 if isinstance(self, cookbook.integration.copymethat.CopyMeThat): 

-

146 file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html'))) 

-

147 il.total_recipes += len(file_list) 

-

148 

-

149 if isinstance(self, cookbook.integration.cookmate.Cookmate): 

-

150 new_file_list = [] 

-

151 for file in file_list: 

-

152 new_file_list += etree.parse(BytesIO(import_zip.read(file.filename))).getroot().getchildren() 

-

153 il.total_recipes = len(new_file_list) 

-

154 file_list = new_file_list 

-

155 

-

156 for z in file_list: 

-

157 try: 

-

158 if not hasattr(z, 'filename') or isinstance(z, Tag): 

-

159 recipe = self.get_recipe_from_file(z) 

-

160 else: 

-

161 recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename))) 

-

162 recipe.keywords.add(self.keyword) 

-

163 il.msg += self.get_recipe_processed_msg(recipe) 

-

164 self.handle_duplicates(recipe, import_duplicates) 

-

165 il.imported_recipes += 1 

-

166 il.save() 

-

167 except Exception as e: 

-

168 traceback.print_exc() 

-

169 self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n') 

-

170 import_zip.close() 

-

171 elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']: 

-

172 data_list = self.split_recipe_file(f['file']) 

-

173 il.total_recipes += len(data_list) 

-

174 for d in data_list: 

-

175 try: 

-

176 recipe = self.get_recipe_from_file(d) 

-

177 recipe.keywords.add(self.keyword) 

-

178 il.msg += self.get_recipe_processed_msg(recipe) 

-

179 self.handle_duplicates(recipe, import_duplicates) 

-

180 il.imported_recipes += 1 

-

181 il.save() 

-

182 except Exception as e: 

-

183 self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n') 

-

184 elif '.rtk' in f['name']: 

-

185 import_zip = ZipFile(f['file']) 

-

186 for z in import_zip.filelist: 

-

187 if self.import_file_name_filter(z): 

-

188 data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8')) 

-

189 il.total_recipes += len(data_list) 

-

190 

-

191 for d in data_list: 

-

192 try: 

-

193 recipe = self.get_recipe_from_file(d) 

-

194 recipe.keywords.add(self.keyword) 

-

195 il.msg += self.get_recipe_processed_msg(recipe) 

-

196 self.handle_duplicates(recipe, import_duplicates) 

-

197 il.imported_recipes += 1 

-

198 il.save() 

-

199 except Exception as e: 

-

200 self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n') 

-

201 import_zip.close() 

-

202 else: 

-

203 recipe = self.get_recipe_from_file(f['file']) 

-

204 recipe.keywords.add(self.keyword) 

-

205 il.msg += self.get_recipe_processed_msg(recipe) 

-

206 self.handle_duplicates(recipe, import_duplicates) 

-

207 except BadZipFile: 

-

208 il.msg += 'ERROR ' + _( 

-

209 'Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n' 

-

210 except Exception as e: 

-

211 msg = 'ERROR ' + _( 

-

212 'An unexpected error occurred during the import. Please make sure you have uploaded a valid file.') + '\n' 

-

213 self.handle_exception(e, log=il, message=msg) 

-

214 

-

215 if len(self.ignored_recipes) > 0: 

-

216 il.msg += '\n' + _( 

-

217 'The following recipes were ignored because they already existed:') + ' ' + ', '.join( 

-

218 self.ignored_recipes) + '\n\n' 

-

219 

-

220 il.keyword = self.keyword 

-

221 il.msg += (_('Imported %s recipes.') % Recipe.objects.filter(keywords=self.keyword).count()) + '\n' 

-

222 il.running = False 

-

223 il.save() 

-

224 

-

225 def handle_duplicates(self, recipe, import_duplicates): 

-

226 """ 

-

227 Checks if a recipe is already present, if so deletes it 

-

228 :param recipe: Recipe object 

-

229 :param import_duplicates: if duplicates should be imported 

-

230 """ 

-

231 if Recipe.objects.filter(space=self.request.space, name=recipe.name).count() > 1 and not import_duplicates: 

-

232 self.ignored_recipes.append(recipe.name) 

-

233 recipe.delete() 

-

234 

-

235 def import_recipe_image(self, recipe, image_file, filetype='.jpeg'): 

-

236 """ 

-

237 Adds an image to a recipe naming it correctly 

-

238 :param recipe: Recipe object 

-

239 :param image_file: ByteIO stream containing the image 

-

240 :param filetype: type of file to write bytes to, default to .jpeg if unknown 

-

241 """ 

-

242 recipe.image = File(handle_image(self.request, File(image_file, name='image'), filetype=filetype), name=f'{uuid.uuid4()}_{recipe.pk}{filetype}') 

-

243 recipe.save() 

-

244 

-

245 def get_recipe_from_file(self, file): 

-

246 """ 

-

247 Takes any file like object and converts it into a recipe 

-

248 :param file: ByteIO or any file like object, depends on provider 

-

249 :return: Recipe object 

-

250 """ 

-

251 raise NotImplementedError('Method not implemented in integration') 

-

252 

-

253 def split_recipe_file(self, file): 

-

254 """ 

-

255 Takes a file that contains multiple recipes and splits it into a list of strings of various formats (e.g. json, text, ..) 

-

256 :param file: ByteIO or any file like object, depends on provider 

-

257 :return: list of strings 

-

258 """ 

-

259 raise NotImplementedError('Method not implemented in integration') 

-

260 

-

261 def get_file_from_recipe(self, recipe): 

-

262 """ 

-

263 Takes a recipe object and converts it to a string (depending on the format) 

-

264 returns both the filename of the exported file and the file contents 

-

265 :param recipe: Recipe object that should be converted 

-

266 :returns: 

-

267 - name - file name in export 

-

268 - data - string content for file to get created in export zip 

-

269 """ 

-

270 raise NotImplementedError('Method not implemented in integration') 

-

271 

-

272 def get_files_from_recipes(self, recipes, el, cookie): 

-

273 """ 

-

274 Takes a list of recipe object and converts it to a array containing each file. 

-

275 Each file is represented as an array [filename, data] where data is a string of the content of the file. 

-

276 :param recipe: Recipe object that should be converted 

-

277 :returns: 

-

278 [[filename, data], ...] 

-

279 """ 

-

280 raise NotImplementedError('Method not implemented in integration') 

-

281 

-

282 @staticmethod 

-

283 def handle_exception(exception, log=None, message=''): 

-

284 if log: 

-

285 if message: 

-

286 log.msg += message 

-

287 else: 

-

288 log.msg += exception.msg 

-

289 if DEBUG: 

-

290 traceback.print_exc() 

-

291 

-

292 def get_export_file_name(self, format='zip'): 

-

293 return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format) 

-

294 

-

295 def get_recipe_processed_msg(self, recipe): 

-

296 return f'{recipe.pk} - {recipe.name} \n' 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_mealie_py.html b/docs/coverage/d_37812bb4c19c71da_mealie_py.html deleted file mode 100644 index e0a1f58a9d..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_mealie_py.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - Coverage for cookbook/integration/mealie.py: 19% - - - - - -
-
-

- Coverage for cookbook/integration/mealie.py: - 19% -

- -

- 70 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2import re 

-

3from io import BytesIO 

-

4from zipfile import ZipFile 

-

5 

-

6from cookbook.helper.image_processing import get_filetype 

-

7from cookbook.helper.ingredient_parser import IngredientParser 

-

8from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time 

-

9from cookbook.integration.integration import Integration 

-

10from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

11 

-

12 

-

13class Mealie(Integration): 

-

14 

-

15 def import_file_name_filter(self, zip_info_object): 

-

16 return re.match(r'^recipes/([A-Za-z\d\s\-_()\[\]\u00C0-\u017F])+/([A-Za-z\d\s\-_()\[\]\u00C0-\u017F])+.json$', zip_info_object.filename) 

-

17 

-

18 def get_recipe_from_file(self, file): 

-

19 recipe_json = json.loads(file.getvalue().decode("utf-8")) 

-

20 

-

21 description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip() 

-

22 

-

23 recipe = Recipe.objects.create( 

-

24 name=recipe_json['name'].strip(), description=description, 

-

25 created_by=self.request.user, internal=True, space=self.request.space) 

-

26 

-

27 for s in recipe_json['recipe_instructions']: 

-

28 step = Step.objects.create(instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) 

-

29 recipe.steps.add(step) 

-

30 

-

31 step = recipe.steps.first() 

-

32 if not step: # if there is no step in the exported data 

-

33 step = Step.objects.create(instruction='', space=self.request.space, ) 

-

34 recipe.steps.add(step) 

-

35 

-

36 if len(recipe_json['description'].strip()) > 500: 

-

37 step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction 

-

38 

-

39 ingredient_parser = IngredientParser(self.request, True) 

-

40 for ingredient in recipe_json['recipe_ingredient']: 

-

41 try: 

-

42 if ingredient['food']: 

-

43 f = ingredient_parser.get_food(ingredient['food']) 

-

44 u = ingredient_parser.get_unit(ingredient['unit']) 

-

45 amount = ingredient['quantity'] 

-

46 note = ingredient['note'] 

-

47 original_text = None 

-

48 else: 

-

49 amount, unit, food, note = ingredient_parser.parse(ingredient['note']) 

-

50 f = ingredient_parser.get_food(food) 

-

51 u = ingredient_parser.get_unit(unit) 

-

52 original_text = ingredient['note'] 

-

53 step.ingredients.add(Ingredient.objects.create( 

-

54 food=f, unit=u, amount=amount, note=note, original_text=original_text, space=self.request.space, 

-

55 )) 

-

56 except Exception: 

-

57 pass 

-

58 

-

59 if 'tags' in recipe_json and len(recipe_json['tags']) > 0: 

-

60 for k in recipe_json['tags']: 

-

61 if 'name' in k: 

-

62 keyword, created = Keyword.objects.get_or_create(name=k['name'].strip(), space=self.request.space) 

-

63 recipe.keywords.add(keyword) 

-

64 

-

65 if 'notes' in recipe_json and len(recipe_json['notes']) > 0: 

-

66 notes_text = "#### Notes \n\n" 

-

67 for n in recipe_json['notes']: 

-

68 notes_text += f'{n["text"]} \n' 

-

69 

-

70 step = Step.objects.create( 

-

71 instruction=notes_text, space=self.request.space, 

-

72 ) 

-

73 recipe.steps.add(step) 

-

74 

-

75 if 'recipe_yield' in recipe_json: 

-

76 recipe.servings = parse_servings(recipe_json['recipe_yield']) 

-

77 recipe.servings_text = parse_servings_text(recipe_json['recipe_yield']) 

-

78 

-

79 if 'total_time' in recipe_json and recipe_json['total_time'] is not None: 

-

80 recipe.working_time = parse_time(recipe_json['total_time']) 

-

81 

-

82 if 'org_url' in recipe_json: 

-

83 recipe.source_url = recipe_json['org_url'] 

-

84 

-

85 recipe.save() 

-

86 

-

87 for f in self.files: 

-

88 if '.zip' in f['name']: 

-

89 import_zip = ZipFile(f['file']) 

-

90 try: 

-

91 self.import_recipe_image(recipe, BytesIO(import_zip.read(f'recipes/{recipe_json["slug"]}/images/min-original.webp')), 

-

92 filetype=get_filetype(f'recipes/{recipe_json["slug"]}/images/original')) 

-

93 except Exception: 

-

94 pass 

-

95 

-

96 return recipe 

-

97 

-

98 def get_file_from_recipe(self, recipe): 

-

99 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_mealmaster_py.html b/docs/coverage/d_37812bb4c19c71da_mealmaster_py.html deleted file mode 100644 index dc705f62e0..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_mealmaster_py.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - Coverage for cookbook/integration/mealmaster.py: 15% - - - - - -
-
-

- Coverage for cookbook/integration/mealmaster.py: - 15% -

- -

- 54 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2 

-

3from cookbook.helper.ingredient_parser import IngredientParser 

-

4from cookbook.integration.integration import Integration 

-

5from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

6 

-

7 

-

8class MealMaster(Integration): 

-

9 

-

10 def get_recipe_from_file(self, file): 

-

11 servings = 1 

-

12 ingredients = [] 

-

13 directions = [] 

-

14 for line in file.replace('\r', '').split('\n'): 

-

15 if not line.startswith('MMMMM') and line.strip != '': 

-

16 if 'Title:' in line: 

-

17 title = line.replace('Title:', '').strip() 

-

18 else: 

-

19 if 'Categories:' in line: 

-

20 tags = line.replace('Categories:', '').strip() 

-

21 else: 

-

22 if 'Yield:' in line: 

-

23 servings_text = line.replace('Yield:', '').strip() 

-

24 else: 

-

25 if re.match('\s{2,}([0-9])+', line): 

-

26 ingredients.append(line.strip()) 

-

27 else: 

-

28 directions.append(line.strip()) 

-

29 

-

30 try: 

-

31 servings = re.findall('([0-9])+', servings_text)[0] 

-

32 except Exception as e: 

-

33 print('failed parsing servings ', e) 

-

34 

-

35 recipe = Recipe.objects.create(name=title, servings=servings, created_by=self.request.user, internal=True, space=self.request.space) 

-

36 

-

37 for k in tags.split(','): 

-

38 keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space) 

-

39 recipe.keywords.add(keyword) 

-

40 

-

41 step = Step.objects.create( 

-

42 instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

43 ) 

-

44 

-

45 ingredient_parser = IngredientParser(self.request, True) 

-

46 for ingredient in ingredients: 

-

47 if len(ingredient.strip()) > 0: 

-

48 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

49 f = ingredient_parser.get_food(ingredient) 

-

50 u = ingredient_parser.get_unit(unit) 

-

51 step.ingredients.add(Ingredient.objects.create( 

-

52 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

53 )) 

-

54 recipe.steps.add(step) 

-

55 

-

56 return recipe 

-

57 

-

58 def get_file_from_recipe(self, recipe): 

-

59 raise NotImplementedError('Method not implemented in storage integration') 

-

60 

-

61 def split_recipe_file(self, file): 

-

62 recipe_list = [] 

-

63 current_recipe = '' 

-

64 

-

65 for fl in file.readlines(): 

-

66 line = fl.decode("windows-1250") 

-

67 if (line.startswith('MMMMM') or line.startswith('-----')) and 'meal-master' in line.lower(): 

-

68 if current_recipe != '': 

-

69 recipe_list.append(current_recipe) 

-

70 current_recipe = '' 

-

71 else: 

-

72 current_recipe = '' 

-

73 else: 

-

74 current_recipe += line + '\n' 

-

75 

-

76 if current_recipe != '': 

-

77 recipe_list.append(current_recipe) 

-

78 

-

79 return recipe_list 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_melarecipes_py.html b/docs/coverage/d_37812bb4c19c71da_melarecipes_py.html deleted file mode 100644 index 3b286e3416..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_melarecipes_py.html +++ /dev/null @@ -1,180 +0,0 @@ - - - - - Coverage for cookbook/integration/melarecipes.py: 23% - - - - - -
-
-

- Coverage for cookbook/integration/melarecipes.py: - 23% -

- -

- 56 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import base64 

-

2import json 

-

3from io import BytesIO 

-

4 

-

5from gettext import gettext as _ 

-

6from cookbook.helper.ingredient_parser import IngredientParser 

-

7from cookbook.helper.recipe_url_import import parse_servings, parse_time 

-

8from cookbook.integration.integration import Integration 

-

9from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

10 

-

11 

-

12class MelaRecipes(Integration): 

-

13 

-

14 def split_recipe_file(self, file): 

-

15 return [json.loads(file.getvalue().decode("utf-8"))] 

-

16 

-

17 def get_files_from_recipes(self, recipes, el, cookie): 

-

18 raise NotImplementedError('Method not implemented in storage integration') 

-

19 

-

20 def get_recipe_from_file(self, file): 

-

21 recipe_json = file 

-

22 

-

23 recipe = Recipe.objects.create( 

-

24 name=recipe_json['title'].strip(), 

-

25 created_by=self.request.user, internal=True, space=self.request.space) 

-

26 

-

27 if 'yield' in recipe_json: 

-

28 recipe.servings = parse_servings(recipe_json['yield']) 

-

29 

-

30 if 'cookTime' in recipe_json: 

-

31 recipe.waiting_time = parse_time(recipe_json['cookTime']) 

-

32 

-

33 if 'prepTime' in recipe_json: 

-

34 recipe.working_time = parse_time(recipe_json['prepTime']) 

-

35 

-

36 if 'favorite' in recipe_json and recipe_json['favorite']: 

-

37 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0]) 

-

38 

-

39 if 'categories' in recipe_json: 

-

40 try: 

-

41 for x in recipe_json['categories']: 

-

42 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0]) 

-

43 except Exception: 

-

44 pass 

-

45 

-

46 instruction = '' 

-

47 if 'text' in recipe_json: 

-

48 instruction += f'*{recipe_json["text"].strip()}* \n' 

-

49 

-

50 if 'instructions' in recipe_json: 

-

51 instruction += recipe_json["instructions"].strip() + ' \n' 

-

52 

-

53 if 'notes' in recipe_json: 

-

54 instruction += recipe_json["notes"].strip() + ' \n' 

-

55 

-

56 if 'link' in recipe_json: 

-

57 recipe.source_url = recipe_json['link'] 

-

58 

-

59 step = Step.objects.create( 

-

60 instruction=instruction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients 

-

61 ) 

-

62 

-

63 ingredient_parser = IngredientParser(self.request, True) 

-

64 for ingredient in recipe_json['ingredients'].split('\n'): 

-

65 if ingredient.strip() != '': 

-

66 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

67 f = ingredient_parser.get_food(food) 

-

68 u = ingredient_parser.get_unit(unit) 

-

69 step.ingredients.add(Ingredient.objects.create( 

-

70 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

71 )) 

-

72 recipe.steps.add(step) 

-

73 

-

74 if recipe_json.get("images", None): 

-

75 try: 

-

76 self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['images'][0])), filetype='.jpeg') 

-

77 except Exception: 

-

78 pass 

-

79 

-

80 return recipe 

-

81 

-

82 def get_file_from_recipe(self, recipe): 

-

83 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_nextcloud_cookbook_py.html b/docs/coverage/d_37812bb4c19c71da_nextcloud_cookbook_py.html deleted file mode 100644 index cd3b13485d..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_nextcloud_cookbook_py.html +++ /dev/null @@ -1,289 +0,0 @@ - - - - - Coverage for cookbook/integration/nextcloud_cookbook.py: 13% - - - - - -
-
-

- Coverage for cookbook/integration/nextcloud_cookbook.py: - 13% -

- -

- 141 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2import re 

-

3from io import BytesIO, StringIO 

-

4from zipfile import ZipFile 

-

5 

-

6from PIL import Image 

-

7 

-

8from cookbook.helper.image_processing import get_filetype 

-

9from cookbook.helper.ingredient_parser import IngredientParser 

-

10from cookbook.helper.recipe_url_import import iso_duration_to_minutes 

-

11from cookbook.integration.integration import Integration 

-

12from cookbook.models import Ingredient, Keyword, NutritionInformation, Recipe, Step 

-

13 

-

14 

-

15class NextcloudCookbook(Integration): 

-

16 

-

17 def import_file_name_filter(self, zip_info_object): 

-

18 return zip_info_object.filename.endswith('.json') 

-

19 

-

20 def get_recipe_from_file(self, file): 

-

21 recipe_json = json.loads(file.getvalue().decode("utf-8")) 

-

22 

-

23 description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip() 

-

24 

-

25 recipe = Recipe.objects.create( 

-

26 name=recipe_json['name'].strip(), description=description, 

-

27 created_by=self.request.user, internal=True, 

-

28 servings=recipe_json['recipeYield'], space=self.request.space) 

-

29 

-

30 try: 

-

31 recipe.working_time = iso_duration_to_minutes(recipe_json['prepTime']) 

-

32 recipe.waiting_time = iso_duration_to_minutes(recipe_json['cookTime']) 

-

33 except Exception: 

-

34 pass 

-

35 

-

36 if 'url' in recipe_json: 

-

37 recipe.source_url = recipe_json['url'].strip() 

-

38 

-

39 if 'recipeCategory' in recipe_json: 

-

40 try: 

-

41 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=recipe_json['recipeCategory'])[0]) 

-

42 except Exception: 

-

43 pass 

-

44 

-

45 if 'keywords' in recipe_json: 

-

46 try: 

-

47 for x in recipe_json['keywords'].split(','): 

-

48 if x.strip() != '': 

-

49 recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0]) 

-

50 except Exception: 

-

51 pass 

-

52 

-

53 ingredients_added = False 

-

54 for s in recipe_json['recipeInstructions']: 

-

55 if 'text' in s: 

-

56 step = Step.objects.create( 

-

57 instruction=s['text'], name=s['name'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

58 ) 

-

59 else: 

-

60 step = Step.objects.create( 

-

61 instruction=s, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

62 ) 

-

63 if not ingredients_added: 

-

64 if len(recipe_json['description'].strip()) > 500: 

-

65 step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction 

-

66 

-

67 ingredients_added = True 

-

68 

-

69 ingredient_parser = IngredientParser(self.request, True) 

-

70 for ingredient in recipe_json['recipeIngredient']: 

-

71 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

72 f = ingredient_parser.get_food(food) 

-

73 u = ingredient_parser.get_unit(unit) 

-

74 step.ingredients.add(Ingredient.objects.create( 

-

75 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

76 )) 

-

77 recipe.steps.add(step) 

-

78 

-

79 if 'nutrition' in recipe_json: 

-

80 nutrition = {} 

-

81 try: 

-

82 if 'calories' in recipe_json['nutrition']: 

-

83 nutrition['calories'] = int(re.search(r'\d+', recipe_json['nutrition']['calories']).group()) 

-

84 if 'proteinContent' in recipe_json['nutrition']: 

-

85 nutrition['proteins'] = int(re.search(r'\d+', recipe_json['nutrition']['proteinContent']).group()) 

-

86 if 'fatContent' in recipe_json['nutrition']: 

-

87 nutrition['fats'] = int(re.search(r'\d+', recipe_json['nutrition']['fatContent']).group()) 

-

88 if 'carbohydrateContent' in recipe_json['nutrition']: 

-

89 nutrition['carbohydrates'] = int(re.search(r'\d+', recipe_json['nutrition']['carbohydrateContent']).group()) 

-

90 

-

91 if nutrition != {}: 

-

92 recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space) 

-

93 recipe.save() 

-

94 except Exception: 

-

95 pass 

-

96 

-

97 for f in self.files: 

-

98 if '.zip' in f['name']: 

-

99 import_zip = ZipFile(f['file']) 

-

100 for z in import_zip.filelist: 

-

101 if re.match(f'^(.)+{recipe.name}/full.jpg$', z.filename): 

-

102 self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename)) 

-

103 

-

104 return recipe 

-

105 

-

106 def formatTime(self, min): 

-

107 h = min // 60 

-

108 m = min % 60 

-

109 return f'PT{h}H{m}M0S' 

-

110 

-

111 def get_file_from_recipe(self, recipe): 

-

112 

-

113 export = {} 

-

114 export['name'] = recipe.name 

-

115 export['description'] = recipe.description 

-

116 export['url'] = recipe.source_url 

-

117 export['prepTime'] = self.formatTime(recipe.working_time) 

-

118 export['cookTime'] = self.formatTime(recipe.waiting_time) 

-

119 export['totalTime'] = self.formatTime(recipe.working_time + recipe.waiting_time) 

-

120 export['recipeYield'] = recipe.servings 

-

121 export['image'] = f'/Recipes/{recipe.name}/full.jpg' 

-

122 export['imageUrl'] = f'/Recipes/{recipe.name}/full.jpg' 

-

123 

-

124 recipeKeyword = [] 

-

125 for k in recipe.keywords.all(): 

-

126 recipeKeyword.append(k.name) 

-

127 

-

128 export['keywords'] = recipeKeyword 

-

129 

-

130 recipeInstructions = [] 

-

131 recipeIngredient = [] 

-

132 for s in recipe.steps.all(): 

-

133 recipeInstructions.append(s.instruction) 

-

134 

-

135 for i in s.ingredients.all(): 

-

136 recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}') 

-

137 

-

138 export['recipeIngredient'] = recipeIngredient 

-

139 export['recipeInstructions'] = recipeInstructions 

-

140 

-

141 return "recipe.json", json.dumps(export) 

-

142 

-

143 def get_files_from_recipes(self, recipes, el, cookie): 

-

144 export_zip_stream = BytesIO() 

-

145 export_zip_obj = ZipFile(export_zip_stream, 'w') 

-

146 

-

147 for recipe in recipes: 

-

148 if recipe.internal and recipe.space == self.request.space: 

-

149 

-

150 recipe_stream = StringIO() 

-

151 filename, data = self.get_file_from_recipe(recipe) 

-

152 recipe_stream.write(data) 

-

153 export_zip_obj.writestr(f'{recipe.name}/{filename}', recipe_stream.getvalue()) 

-

154 recipe_stream.close() 

-

155 

-

156 try: 

-

157 imageByte = recipe.image.file.read() 

-

158 export_zip_obj.writestr(f'{recipe.name}/full.jpg', self.getJPEG(imageByte)) 

-

159 export_zip_obj.writestr(f'{recipe.name}/thumb.jpg', self.getThumb(171, imageByte)) 

-

160 export_zip_obj.writestr(f'{recipe.name}/thumb16.jpg', self.getThumb(16, imageByte)) 

-

161 except ValueError: 

-

162 pass 

-

163 

-

164 el.exported_recipes += 1 

-

165 el.msg += self.get_recipe_processed_msg(recipe) 

-

166 el.save() 

-

167 

-

168 export_zip_obj.close() 

-

169 

-

170 return [[self.get_export_file_name(), export_zip_stream.getvalue()]] 

-

171 

-

172 def getJPEG(self, imageByte): 

-

173 image = Image.open(BytesIO(imageByte)) 

-

174 image = image.convert('RGB') 

-

175 

-

176 bytes = BytesIO() 

-

177 image.save(bytes, "JPEG") 

-

178 return bytes.getvalue() 

-

179 

-

180 def getThumb(self, size, imageByte): 

-

181 image = Image.open(BytesIO(imageByte)) 

-

182 

-

183 w, h = image.size 

-

184 m = min(w, h) 

-

185 

-

186 image = image.crop(((w - m) // 2, (h - m) // 2, (w + m) // 2, (h + m) // 2)) 

-

187 image = image.resize([size, size], Image.Resampling.LANCZOS) 

-

188 image = image.convert('RGB') 

-

189 

-

190 bytes = BytesIO() 

-

191 image.save(bytes, "JPEG") 

-

192 return bytes.getvalue() 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_openeats_py.html b/docs/coverage/d_37812bb4c19c71da_openeats_py.html deleted file mode 100644 index dd9421c644..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_openeats_py.html +++ /dev/null @@ -1,226 +0,0 @@ - - - - - Coverage for cookbook/integration/openeats.py: 11% - - - - - -
-
-

- Coverage for cookbook/integration/openeats.py: - 11% -

- -

- 81 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2 

-

3from django.utils.translation import gettext as _ 

-

4 

-

5from cookbook.helper.ingredient_parser import IngredientParser 

-

6from cookbook.integration.integration import Integration 

-

7from cookbook.models import Comment, CookLog, Ingredient, Keyword, Recipe, Step 

-

8 

-

9 

-

10class OpenEats(Integration): 

-

11 

-

12 def get_recipe_from_file(self, file): 

-

13 

-

14 description = file['info'] 

-

15 description_max_length = Recipe._meta.get_field('description').max_length 

-

16 if len(description) > description_max_length: 

-

17 description = description[0:description_max_length] 

-

18 

-

19 recipe = Recipe.objects.create(name=file['name'].strip(), description=description, created_by=self.request.user, internal=True, 

-

20 servings=file['servings'], space=self.request.space, waiting_time=file['cook_time'], working_time=file['prep_time']) 

-

21 

-

22 instructions = '' 

-

23 

-

24 if file["directions"] != '': 

-

25 instructions += file["directions"] 

-

26 

-

27 if file["source"] != '': 

-

28 instructions += '\n' + _('Recipe source:') + f'[{file["source"]}]({file["source"]})' 

-

29 

-

30 cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space) 

-

31 if file["cuisine"] != '': 

-

32 keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space) 

-

33 if created: 

-

34 keyword.move(cuisine_keyword, pos="last-child") 

-

35 recipe.keywords.add(keyword) 

-

36 

-

37 course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space) 

-

38 if file["course"] != '': 

-

39 keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space) 

-

40 if created: 

-

41 keyword.move(course_keyword, pos="last-child") 

-

42 recipe.keywords.add(keyword) 

-

43 

-

44 for tag in file["tags"]: 

-

45 keyword, created = Keyword.objects.get_or_create(name=tag.strip(), space=self.request.space) 

-

46 recipe.keywords.add(keyword) 

-

47 

-

48 for comment in file['comments']: 

-

49 Comment.objects.create(recipe=recipe, text=comment['text'], created_by=self.request.user) 

-

50 CookLog.objects.create(recipe=recipe, rating=comment['rating'], created_by=self.request.user, space=self.request.space) 

-

51 

-

52 if file["photo"] != '': 

-

53 recipe.image = f'recipes/openeats-import/{file["photo"]}' 

-

54 recipe.save() 

-

55 

-

56 step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,) 

-

57 

-

58 ingredient_parser = IngredientParser(self.request, True) 

-

59 for ingredient in file['ingredients']: 

-

60 f = ingredient_parser.get_food(ingredient['food']) 

-

61 u = ingredient_parser.get_unit(ingredient['unit']) 

-

62 step.ingredients.add(Ingredient.objects.create( 

-

63 food=f, unit=u, amount=ingredient['amount'], space=self.request.space, 

-

64 )) 

-

65 recipe.steps.add(step) 

-

66 

-

67 return recipe 

-

68 

-

69 def split_recipe_file(self, file): 

-

70 recipe_json = json.loads(file.read()) 

-

71 recipe_dict = {} 

-

72 ingredient_group_dict = {} 

-

73 cuisine_group_dict = {} 

-

74 course_group_dict = {} 

-

75 tag_group_dict = {} 

-

76 

-

77 for o in recipe_json: 

-

78 if o['model'] == 'recipe.recipe': 

-

79 recipe_dict[o['pk']] = { 

-

80 'name': o['fields']['title'], 

-

81 'info': o['fields']['info'], 

-

82 'directions': o['fields']['directions'], 

-

83 'source': o['fields']['source'], 

-

84 'prep_time': o['fields']['prep_time'], 

-

85 'cook_time': o['fields']['cook_time'], 

-

86 'servings': o['fields']['servings'], 

-

87 'ingredients': [], 

-

88 'photo': o['fields']['photo'], 

-

89 'cuisine': o['fields']['cuisine'], 

-

90 'course': o['fields']['course'], 

-

91 'tags': o['fields']['tags'], 

-

92 'comments': [], 

-

93 } 

-

94 if o['model'] == 'ingredient.ingredientgroup': 

-

95 ingredient_group_dict[o['pk']] = o['fields']['recipe'] 

-

96 if o['model'] == 'recipe_groups.cuisine': 

-

97 cuisine_group_dict[o['pk']] = o['fields']['title'] 

-

98 if o['model'] == 'recipe_groups.course': 

-

99 course_group_dict[o['pk']] = o['fields']['title'] 

-

100 if o['model'] == 'recipe_groups.tag': 

-

101 tag_group_dict[o['pk']] = o['fields']['title'] 

-

102 

-

103 for o in recipe_json: 

-

104 if o['model'] == 'rating.rating': 

-

105 recipe_dict[o['fields']['recipe']]["comments"].append({ 

-

106 "text": o['fields']['comment'], 

-

107 "rating": o['fields']['rating'] 

-

108 }) 

-

109 if o['model'] == 'ingredient.ingredient': 

-

110 ingredient = { 

-

111 'food': o['fields']['title'], 

-

112 'unit': o['fields']['measurement'], 

-

113 'amount': round(o['fields']['numerator'] / o['fields']['denominator'], 2), 

-

114 } 

-

115 recipe_dict[ingredient_group_dict[o['fields']['ingredient_group']]]['ingredients'].append(ingredient) 

-

116 

-

117 for k, r in recipe_dict.items(): 

-

118 if r["cuisine"] in cuisine_group_dict: 

-

119 r["cuisine"] = cuisine_group_dict[r["cuisine"]] 

-

120 if r["course"] in course_group_dict: 

-

121 r["course"] = course_group_dict[r["course"]] 

-

122 for index in range(len(r["tags"])): 

-

123 if r["tags"][index] in tag_group_dict: 

-

124 r["tags"][index] = tag_group_dict[r["tags"][index]] 

-

125 

-

126 return list(recipe_dict.values()) 

-

127 

-

128 def get_file_from_recipe(self, recipe): 

-

129 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_paprika_py.html b/docs/coverage/d_37812bb4c19c71da_paprika_py.html deleted file mode 100644 index e33c7187b5..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_paprika_py.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - Coverage for cookbook/integration/paprika.py: 21% - - - - - -
-
-

- Coverage for cookbook/integration/paprika.py: - 21% -

- -

- 70 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import base64 

-

2import gzip 

-

3import json 

-

4import re 

-

5from gettext import gettext as _ 

-

6from io import BytesIO 

-

7 

-

8import requests 

-

9import validators 

-

10 

-

11from cookbook.helper.ingredient_parser import IngredientParser 

-

12from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text 

-

13from cookbook.integration.integration import Integration 

-

14from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

15 

-

16 

-

17class Paprika(Integration): 

-

18 

-

19 def get_file_from_recipe(self, recipe): 

-

20 raise NotImplementedError('Method not implemented in storage integration') 

-

21 

-

22 def get_recipe_from_file(self, file): 

-

23 with gzip.open(file, 'r') as recipe_zip: 

-

24 recipe_json = json.loads(recipe_zip.read().decode("utf-8")) 

-

25 

-

26 recipe = Recipe.objects.create( 

-

27 name=recipe_json['name'].strip(), created_by=self.request.user, internal=True, space=self.request.space) 

-

28 

-

29 if 'description' in recipe_json: 

-

30 recipe.description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip() 

-

31 

-

32 try: 

-

33 if 'servings' in recipe_json: 

-

34 recipe.servings = parse_servings(recipe_json['servings']) 

-

35 recipe.servings_text = parse_servings_text(recipe_json['servings']) 

-

36 

-

37 if len(recipe_json['cook_time'].strip()) > 0: 

-

38 recipe.waiting_time = re.findall(r'\d+', recipe_json['cook_time'])[0] 

-

39 

-

40 if len(recipe_json['prep_time'].strip()) > 0: 

-

41 recipe.working_time = re.findall(r'\d+', recipe_json['prep_time'])[0] 

-

42 except Exception: 

-

43 pass 

-

44 

-

45 recipe.save() 

-

46 

-

47 instructions = recipe_json['directions'] 

-

48 if recipe_json['notes'] and len(recipe_json['notes'].strip()) > 0: 

-

49 instructions += '\n\n### ' + _('Notes') + ' \n' + recipe_json['notes'] 

-

50 

-

51 if recipe_json['nutritional_info'] and len(recipe_json['nutritional_info'].strip()) > 0: 

-

52 instructions += '\n\n### ' + _('Nutritional Information') + ' \n' + recipe_json['nutritional_info'] 

-

53 

-

54 try: 

-

55 if len(recipe_json['source'].strip()) > 0 or len(recipe_json['source_url'].strip()) > 0: 

-

56 instructions += '\n\n### ' + _('Source') + ' \n' + recipe_json['source'].strip() + ' \n' + recipe_json['source_url'].strip() 

-

57 except AttributeError: 

-

58 pass 

-

59 

-

60 step = Step.objects.create( 

-

61 instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

62 ) 

-

63 

-

64 if 'description' in recipe_json and len(recipe_json['description'].strip()) > 500: 

-

65 step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction 

-

66 

-

67 if 'categories' in recipe_json: 

-

68 for c in recipe_json['categories']: 

-

69 keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space) 

-

70 recipe.keywords.add(keyword) 

-

71 

-

72 ingredient_parser = IngredientParser(self.request, True) 

-

73 try: 

-

74 for ingredient in recipe_json['ingredients'].split('\n'): 

-

75 if len(ingredient.strip()) > 0: 

-

76 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

77 f = ingredient_parser.get_food(food) 

-

78 u = ingredient_parser.get_unit(unit) 

-

79 step.ingredients.add(Ingredient.objects.create( 

-

80 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

81 )) 

-

82 except AttributeError: 

-

83 pass 

-

84 

-

85 recipe.steps.add(step) 

-

86 

-

87 try: 

-

88 if recipe_json.get("image_url", None): 

-

89 url = recipe_json.get("image_url", None) 

-

90 if validators.url(url, public=True): 

-

91 response = requests.get(url) 

-

92 self.import_recipe_image(recipe, BytesIO(response.content)) 

-

93 except Exception: 

-

94 if recipe_json.get("photo_data", None): 

-

95 self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg') 

-

96 

-

97 return recipe 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_pdfexport_py.html b/docs/coverage/d_37812bb4c19c71da_pdfexport_py.html deleted file mode 100644 index 7284e3f2fc..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_pdfexport_py.html +++ /dev/null @@ -1,153 +0,0 @@ - - - - - Coverage for cookbook/integration/pdfexport.py: 31% - - - - - -
-
-

- Coverage for cookbook/integration/pdfexport.py: - 31% -

- -

- 29 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import asyncio 

-

2 

-

3import django.core.management.commands.runserver as runserver 

-

4from asgiref.sync import sync_to_async 

-

5from pyppeteer import launch 

-

6 

-

7from cookbook.integration.integration import Integration 

-

8 

-

9 

-

10class PDFexport(Integration): 

-

11 

-

12 def get_recipe_from_file(self, file): 

-

13 raise NotImplementedError('Method not implemented in storage integration') 

-

14 

-

15 async def get_files_from_recipes_async(self, recipes, el, cookie): 

-

16 cmd = runserver.Command() 

-

17 

-

18 browser = await launch( 

-

19 handleSIGINT=False, 

-

20 handleSIGTERM=False, 

-

21 handleSIGHUP=False, 

-

22 ignoreHTTPSErrors=True, 

-

23 ) 

-

24 

-

25 cookies = {'domain': cmd.default_addr, 'name': 'sessionid', 'value': cookie['sessionid'], } 

-

26 options = {'format': 'letter', 

-

27 'margin': { 

-

28 'top': '0.75in', 

-

29 'bottom': '0.75in', 

-

30 'left': '0.75in', 

-

31 'right': '0.75in', 

-

32 } 

-

33 } 

-

34 

-

35 files = [] 

-

36 for recipe in recipes: 

-

37 

-

38 page = await browser.newPage() 

-

39 await page.emulateMedia('print') 

-

40 await page.setCookie(cookies) 

-

41 

-

42 await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'domcontentloaded'}) 

-

43 await page.waitForSelector('#printReady') 

-

44 

-

45 files.append([recipe.name + '.pdf', await page.pdf(options)]) 

-

46 await page.close() 

-

47 

-

48 el.exported_recipes += 1 

-

49 el.msg += self.get_recipe_processed_msg(recipe) 

-

50 await sync_to_async(el.save, thread_sensitive=True)() 

-

51 

-

52 await browser.close() 

-

53 return files 

-

54 

-

55 def get_files_from_recipes(self, recipes, el, cookie): 

-

56 return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie)) 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_pepperplate_py.html b/docs/coverage/d_37812bb4c19c71da_pepperplate_py.html deleted file mode 100644 index 31b430e99e..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_pepperplate_py.html +++ /dev/null @@ -1,152 +0,0 @@ - - - - - Coverage for cookbook/integration/pepperplate.py: 14% - - - - - -
-
-

- Coverage for cookbook/integration/pepperplate.py: - 14% -

- -

- 42 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from cookbook.helper.ingredient_parser import IngredientParser 

-

2from cookbook.integration.integration import Integration 

-

3from cookbook.models import Ingredient, Recipe, Step 

-

4 

-

5 

-

6class Pepperplate(Integration): 

-

7 

-

8 def get_recipe_from_file(self, file): 

-

9 ingredient_mode = False 

-

10 direction_mode = False 

-

11 

-

12 ingredients = [] 

-

13 directions = [] 

-

14 for fl in file.readlines(): 

-

15 line = fl.decode("utf-8") 

-

16 if 'Title:' in line: 

-

17 title = line.replace('Title:', '').replace('"', '').strip() 

-

18 if 'Description:' in line: 

-

19 description = line.replace('Description:', '').strip() 

-

20 if 'Original URL:' in line or 'Source:' in line or 'Yield:' in line or 'Total:' in line: 

-

21 if len(line.strip().split(':')[1]) > 0: 

-

22 directions.append(line.strip() + '\n') 

-

23 if ingredient_mode: 

-

24 if len(line) > 2 and 'Instructions:' not in line: 

-

25 ingredients.append(line.strip()) 

-

26 if direction_mode: 

-

27 if len(line) > 2: 

-

28 directions.append(line.strip() + '\n') 

-

29 if 'Ingredients:' in line: 

-

30 ingredient_mode = True 

-

31 if 'Instructions:' in line: 

-

32 ingredient_mode = False 

-

33 direction_mode = True 

-

34 

-

35 recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space) 

-

36 

-

37 step = Step.objects.create( 

-

38 instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

39 ) 

-

40 

-

41 ingredient_parser = IngredientParser(self.request, True) 

-

42 for ingredient in ingredients: 

-

43 if len(ingredient.strip()) > 0: 

-

44 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

45 f = ingredient_parser.get_food(food) 

-

46 u = ingredient_parser.get_unit(unit) 

-

47 step.ingredients.add(Ingredient.objects.create( 

-

48 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

49 )) 

-

50 recipe.steps.add(step) 

-

51 

-

52 return recipe 

-

53 

-

54 def get_file_from_recipe(self, recipe): 

-

55 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_plantoeat_py.html b/docs/coverage/d_37812bb4c19c71da_plantoeat_py.html deleted file mode 100644 index 01041d685f..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_plantoeat_py.html +++ /dev/null @@ -1,198 +0,0 @@ - - - - - Coverage for cookbook/integration/plantoeat.py: 12% - - - - - -
-
-

- Coverage for cookbook/integration/plantoeat.py: - 12% -

- -

- 78 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from io import BytesIO 

-

2 

-

3import requests 

-

4import validators 

-

5 

-

6from cookbook.helper.ingredient_parser import IngredientParser 

-

7from cookbook.integration.integration import Integration 

-

8from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

9 

-

10 

-

11class Plantoeat(Integration): 

-

12 

-

13 def get_recipe_from_file(self, file): 

-

14 ingredient_mode = False 

-

15 direction_mode = False 

-

16 

-

17 image_url = None 

-

18 tags = None 

-

19 ingredients = [] 

-

20 directions = [] 

-

21 description = '' 

-

22 for line in file.replace('\r', '').split('\n'): 

-

23 if line.strip() != '': 

-

24 if 'Title:' in line: 

-

25 title = line.replace('Title:', '').replace('"', '').strip() 

-

26 if 'Description:' in line: 

-

27 description = line.replace('Description:', '').strip() 

-

28 if 'Source:' in line or 'Serves:' in line or 'Prep Time:' in line or 'Cook Time:' in line: 

-

29 directions.append(line.strip() + '\n') 

-

30 if 'Photo Url:' in line: 

-

31 image_url = line.replace('Photo Url:', '').strip() 

-

32 if 'Tags:' in line: 

-

33 tags = line.replace('Tags:', '').strip() 

-

34 if ingredient_mode: 

-

35 if len(line) > 2 and 'Instructions:' not in line: 

-

36 ingredients.append(line.strip()) 

-

37 if direction_mode: 

-

38 if len(line) > 2: 

-

39 directions.append(line.strip() + '\n') 

-

40 if 'Ingredients:' in line: 

-

41 ingredient_mode = True 

-

42 if 'Directions:' in line: 

-

43 ingredient_mode = False 

-

44 direction_mode = True 

-

45 

-

46 recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space) 

-

47 

-

48 step = Step.objects.create( 

-

49 instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

50 ) 

-

51 

-

52 if tags: 

-

53 tags = tags.replace('^',',') 

-

54 for k in tags.split(','): 

-

55 keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space) 

-

56 recipe.keywords.add(keyword) 

-

57 

-

58 ingredient_parser = IngredientParser(self.request, True) 

-

59 for ingredient in ingredients: 

-

60 if len(ingredient.strip()) > 0: 

-

61 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

62 f = ingredient_parser.get_food(food) 

-

63 u = ingredient_parser.get_unit(unit) 

-

64 step.ingredients.add(Ingredient.objects.create( 

-

65 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

66 )) 

-

67 recipe.steps.add(step) 

-

68 

-

69 if image_url: 

-

70 try: 

-

71 if validators.url(image_url, public=True): 

-

72 response = requests.get(image_url) 

-

73 self.import_recipe_image(recipe, BytesIO(response.content)) 

-

74 except Exception as e: 

-

75 print('failed to import image ', str(e)) 

-

76 

-

77 return recipe 

-

78 

-

79 def split_recipe_file(self, file): 

-

80 recipe_list = [] 

-

81 current_recipe = '' 

-

82 

-

83 for fl in file.readlines(): 

-

84 try: 

-

85 line = fl.decode("utf-8") 

-

86 except UnicodeDecodeError: 

-

87 line = fl.decode("windows-1250") 

-

88 

-

89 if line.startswith('--------------'): 

-

90 if current_recipe != '': 

-

91 recipe_list.append(current_recipe) 

-

92 current_recipe = '' 

-

93 else: 

-

94 current_recipe = '' 

-

95 else: 

-

96 current_recipe += line + '\n' 

-

97 

-

98 if current_recipe != '': 

-

99 recipe_list.append(current_recipe) 

-

100 

-

101 return recipe_list 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_recettetek_py.html b/docs/coverage/d_37812bb4c19c71da_recettetek_py.html deleted file mode 100644 index 30781231bf..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_recettetek_py.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - Coverage for cookbook/integration/recettetek.py: 17% - - - - - -
-
-

- Coverage for cookbook/integration/recettetek.py: - 17% -

- -

- 100 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import imghdr 

-

2import json 

-

3import re 

-

4from io import BytesIO 

-

5from zipfile import ZipFile 

-

6 

-

7import requests 

-

8import validators 

-

9 

-

10from django.utils.translation import gettext as _ 

-

11from cookbook.helper.image_processing import get_filetype 

-

12from cookbook.helper.ingredient_parser import IngredientParser 

-

13from cookbook.integration.integration import Integration 

-

14from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

15 

-

16 

-

17class RecetteTek(Integration): 

-

18 

-

19 def import_file_name_filter(self, zip_info_object): 

-

20 print("testing", zip_info_object.filename) 

-

21 return re.match(r'^recipes_0.json$', zip_info_object.filename) or re.match(r'^recipes.json$', zip_info_object.filename) 

-

22 

-

23 def split_recipe_file(self, file): 

-

24 

-

25 recipe_json = json.loads(file) 

-

26 

-

27 recipe_list = [r for r in recipe_json] 

-

28 

-

29 return recipe_list 

-

30 

-

31 def get_recipe_from_file(self, file): 

-

32 

-

33 # Create initial recipe with just a title and a description 

-

34 recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, ) 

-

35 

-

36 # set the description as an empty string for later use for the source URL, in case there is no description text. 

-

37 recipe.description = '' 

-

38 

-

39 try: 

-

40 if file['description'] != '': 

-

41 recipe.description = file['description'].strip() 

-

42 except Exception as e: 

-

43 print(recipe.name, ': failed to parse recipe description ', str(e)) 

-

44 

-

45 instructions = file['instructions'] 

-

46 if not instructions: 

-

47 instructions = '' 

-

48 

-

49 step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,) 

-

50 

-

51 # Append the original import url to the step (if it exists) 

-

52 try: 

-

53 if file['url'] != '': 

-

54 step.instruction += '\n\n' + _('Imported from') + ': ' + file['url'] 

-

55 step.save() 

-

56 except Exception as e: 

-

57 print(recipe.name, ': failed to import source url ', str(e)) 

-

58 

-

59 try: 

-

60 # Process the ingredients. Assumes 1 ingredient per line. 

-

61 ingredient_parser = IngredientParser(self.request, True) 

-

62 for ingredient in file['ingredients'].split('\n'): 

-

63 if len(ingredient.strip()) > 0: 

-

64 amount, unit, food, note = ingredient_parser.parse(ingredient.strip()) 

-

65 f = ingredient_parser.get_food(ingredient) 

-

66 u = ingredient_parser.get_unit(unit) 

-

67 step.ingredients.add(Ingredient.objects.create( 

-

68 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

69 )) 

-

70 except Exception as e: 

-

71 print(recipe.name, ': failed to parse recipe ingredients ', str(e)) 

-

72 recipe.steps.add(step) 

-

73 

-

74 # Attempt to import prep/cooking times 

-

75 # quick hack, this assumes only one number in the quantity field. 

-

76 try: 

-

77 if file['quantity'] != '': 

-

78 for item in file['quantity'].split(' '): 

-

79 if item.isdigit(): 

-

80 recipe.servings = int(item) 

-

81 break 

-

82 except Exception as e: 

-

83 print(recipe.name, ': failed to parse quantity ', str(e)) 

-

84 

-

85 try: 

-

86 if file['totalTime'] != '': 

-

87 recipe.waiting_time = int(file['totalTime']) 

-

88 except Exception as e: 

-

89 print(recipe.name, ': failed to parse total times ', str(e)) 

-

90 

-

91 try: 

-

92 if file['preparationTime'] != '': 

-

93 recipe.working_time = int(file['preparationTime']) 

-

94 except Exception as e: 

-

95 print(recipe.name, ': failed to parse prep time ', str(e)) 

-

96 

-

97 try: 

-

98 if file['cookingTime'] != '': 

-

99 recipe.waiting_time = int(file['cookingTime']) 

-

100 except Exception as e: 

-

101 print(recipe.name, ': failed to parse cooking time ', str(e)) 

-

102 

-

103 recipe.save() 

-

104 

-

105 # Import the recipe keywords 

-

106 try: 

-

107 if file['keywords'] != '': 

-

108 for keyword in file['keywords'].split(';'): 

-

109 k, created = Keyword.objects.get_or_create(name=keyword.strip(), space=self.request.space) 

-

110 recipe.keywords.add(k) 

-

111 recipe.save() 

-

112 except Exception as e: 

-

113 print(recipe.name, ': failed to parse keywords ', str(e)) 

-

114 

-

115 # TODO: Parse Nutritional Information 

-

116 

-

117 # Import the original image from the zip file, if we cannot do that, attempt to download it again. 

-

118 try: 

-

119 if file['pictures'][0] != '': 

-

120 image_file_name = file['pictures'][0].split('/')[-1] 

-

121 for f in self.files: 

-

122 if '.rtk' in f['name']: 

-

123 import_zip = ZipFile(f['file']) 

-

124 self.import_recipe_image(recipe, BytesIO(import_zip.read(image_file_name)), filetype=get_filetype(image_file_name)) 

-

125 else: 

-

126 if file['originalPicture'] != '': 

-

127 url = file['originalPicture'] 

-

128 if validators.url(url, public=True): 

-

129 response = requests.get(url) 

-

130 if imghdr.what(BytesIO(response.content)) is not None: 

-

131 self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture'])) 

-

132 else: 

-

133 raise Exception("Original image failed to download.") 

-

134 except Exception as e: 

-

135 print(recipe.name, ': failed to import image ', str(e)) 

-

136 

-

137 return recipe 

-

138 

-

139 def get_file_from_recipe(self, recipe): 

-

140 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_recipekeeper_py.html b/docs/coverage/d_37812bb4c19c71da_recipekeeper_py.html deleted file mode 100644 index 433d0df523..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_recipekeeper_py.html +++ /dev/null @@ -1,184 +0,0 @@ - - - - - Coverage for cookbook/integration/recipekeeper.py: 22% - - - - - -
-
-

- Coverage for cookbook/integration/recipekeeper.py: - 22% -

- -

- 63 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2from io import BytesIO 

-

3from zipfile import ZipFile 

-

4 

-

5from bs4 import BeautifulSoup 

-

6 

-

7from django.utils.translation import gettext as _ 

-

8from cookbook.helper.ingredient_parser import IngredientParser 

-

9from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings 

-

10from cookbook.integration.integration import Integration 

-

11from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

12 

-

13 

-

14class RecipeKeeper(Integration): 

-

15 

-

16 def import_file_name_filter(self, zip_info_object): 

-

17 return re.match(r'^recipes.html$', zip_info_object.filename) 

-

18 

-

19 def split_recipe_file(self, file): 

-

20 recipe_html = BeautifulSoup(file, 'html.parser') 

-

21 return recipe_html.find_all('div', class_='recipe-details') 

-

22 

-

23 def get_recipe_from_file(self, file): 

-

24 # 'file' comes is as a beautifulsoup object 

-

25 recipe = Recipe.objects.create(name=file.find("h2", {"itemprop": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, ) 

-

26 

-

27 # add 'Courses' and 'Categories' as keywords 

-

28 for course in file.find_all("span", {"itemprop": "recipeCourse"}): 

-

29 keyword, created = Keyword.objects.get_or_create(name=course.text, space=self.request.space) 

-

30 recipe.keywords.add(keyword) 

-

31 

-

32 for category in file.find_all("meta", {"itemprop": "recipeCategory"}): 

-

33 keyword, created = Keyword.objects.get_or_create(name=category.get("content"), space=self.request.space) 

-

34 recipe.keywords.add(keyword) 

-

35 

-

36 try: 

-

37 recipe.servings = parse_servings(file.find("span", {"itemprop": "recipeYield"}).text.strip()) 

-

38 recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip()) 

-

39 recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip()) 

-

40 recipe.save() 

-

41 except AttributeError: 

-

42 pass 

-

43 

-

44 step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) 

-

45 

-

46 ingredient_parser = IngredientParser(self.request, True) 

-

47 for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"): 

-

48 if ingredient.text == "": 

-

49 continue 

-

50 amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip()) 

-

51 f = ingredient_parser.get_food(food) 

-

52 u = ingredient_parser.get_unit(unit) 

-

53 step.ingredients.add(Ingredient.objects.create( 

-

54 food=f, unit=u, amount=amount, note=note, original_text=str(ingredient).replace('<p>', '').replace('</p>', ''), space=self.request.space, 

-

55 )) 

-

56 

-

57 for s in file.find("div", {"itemprop": "recipeDirections"}).find_all("p"): 

-

58 if s.text == "": 

-

59 continue 

-

60 step.instruction += s.text + ' \n' 

-

61 step.save() 

-

62 

-

63 for s in file.find("div", {"itemprop": "recipeNotes"}).find_all("p"): 

-

64 if s.text == "": 

-

65 continue 

-

66 step.instruction += s.text + ' \n' 

-

67 step.save() 

-

68 

-

69 if file.find("span", {"itemprop": "recipeSource"}).text != '': 

-

70 step.instruction += "\n\n" + _("Imported from") + ": " + file.find("span", {"itemprop": "recipeSource"}).text 

-

71 step.save() 

-

72 

-

73 recipe.steps.add(step) 

-

74 

-

75 # import the Primary recipe image that is stored in the Zip 

-

76 try: 

-

77 for f in self.files: 

-

78 if '.zip' in f['name']: 

-

79 import_zip = ZipFile(f['file']) 

-

80 self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipe-photo").get("src"))), filetype='.jpeg') 

-

81 except Exception as e: 

-

82 print(recipe.name, ': failed to import image ', str(e)) 

-

83 

-

84 return recipe 

-

85 

-

86 def get_file_from_recipe(self, recipe): 

-

87 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_recipesage_py.html b/docs/coverage/d_37812bb4c19c71da_recipesage_py.html deleted file mode 100644 index 07bcedef19..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_recipesage_py.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - - Coverage for cookbook/integration/recipesage.py: 21% - - - - - -
-
-

- Coverage for cookbook/integration/recipesage.py: - 21% -

- -

- 61 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2from io import BytesIO 

-

3 

-

4import requests 

-

5import validators 

-

6 

-

7from cookbook.helper.ingredient_parser import IngredientParser 

-

8from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time 

-

9from cookbook.integration.integration import Integration 

-

10from cookbook.models import Ingredient, Recipe, Step 

-

11 

-

12 

-

13class RecipeSage(Integration): 

-

14 

-

15 def get_recipe_from_file(self, file): 

-

16 

-

17 recipe = Recipe.objects.create( 

-

18 name=file['name'].strip(), 

-

19 created_by=self.request.user, internal=True, 

-

20 space=self.request.space) 

-

21 

-

22 if file['recipeYield'] != '': 

-

23 recipe.servings = parse_servings(file['recipeYield']) 

-

24 recipe.servings_text = parse_servings_text(file['recipeYield']) 

-

25 

-

26 try: 

-

27 if 'totalTime' in file and file['totalTime'] != '': 

-

28 recipe.working_time = parse_time(file['totalTime']) 

-

29 

-

30 if 'timePrep' in file and file['prepTime'] != '': 

-

31 recipe.working_time = parse_time(file['timePrep']) 

-

32 recipe.waiting_time = parse_time(file['totalTime']) - parse_time(file['timePrep']) 

-

33 except Exception as e: 

-

34 print('failed to parse time ', str(e)) 

-

35 

-

36 recipe.save() 

-

37 

-

38 ingredient_parser = IngredientParser(self.request, True) 

-

39 ingredients_added = False 

-

40 for s in file['recipeInstructions']: 

-

41 step = Step.objects.create( 

-

42 instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

43 ) 

-

44 if not ingredients_added: 

-

45 ingredients_added = True 

-

46 

-

47 for ingredient in file['recipeIngredient']: 

-

48 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

49 f = ingredient_parser.get_food(food) 

-

50 u = ingredient_parser.get_unit(unit) 

-

51 step.ingredients.add(Ingredient.objects.create( 

-

52 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

53 )) 

-

54 recipe.steps.add(step) 

-

55 

-

56 if len(file['image']) > 0: 

-

57 try: 

-

58 url = file['image'][0] 

-

59 if validators.url(url, public=True): 

-

60 response = requests.get(url) 

-

61 self.import_recipe_image(recipe, BytesIO(response.content)) 

-

62 except Exception as e: 

-

63 print('failed to import image ', str(e)) 

-

64 

-

65 return recipe 

-

66 

-

67 def get_file_from_recipe(self, recipe): 

-

68 data = { 

-

69 '@context': 'http://schema.org', 

-

70 '@type': 'Recipe', 

-

71 'creditText': '', 

-

72 'isBasedOn': '', 

-

73 'name': recipe.name, 

-

74 'description': recipe.description, 

-

75 'prepTime': str(recipe.working_time), 

-

76 'totalTime': str(recipe.waiting_time + recipe.working_time), 

-

77 'recipeYield': str(recipe.servings), 

-

78 'image': [], 

-

79 'recipeCategory': [], 

-

80 'comment': [], 

-

81 'recipeIngredient': [], 

-

82 'recipeInstructions': [], 

-

83 } 

-

84 

-

85 for s in recipe.steps.all(): 

-

86 data['recipeInstructions'].append({ 

-

87 '@type': 'HowToStep', 

-

88 'text': s.instruction 

-

89 }) 

-

90 

-

91 for i in s.ingredients.all(): 

-

92 data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}') 

-

93 

-

94 return data 

-

95 

-

96 def get_files_from_recipes(self, recipes, el, cookie): 

-

97 json_list = [] 

-

98 for r in recipes: 

-

99 json_list.append(self.get_file_from_recipe(r)) 

-

100 

-

101 el.exported_recipes += 1 

-

102 el.msg += self.get_recipe_processed_msg(r) 

-

103 el.save() 

-

104 

-

105 return [[self.get_export_file_name('json'), json.dumps(json_list)]] 

-

106 

-

107 def split_recipe_file(self, file): 

-

108 return json.loads(file.read().decode("utf-8")) 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_rezeptsuitede_py.html b/docs/coverage/d_37812bb4c19c71da_rezeptsuitede_py.html deleted file mode 100644 index 2803a01cee..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_rezeptsuitede_py.html +++ /dev/null @@ -1,172 +0,0 @@ - - - - - Coverage for cookbook/integration/rezeptsuitede.py: 20% - - - - - -
-
-

- Coverage for cookbook/integration/rezeptsuitede.py: - 20% -

- -

- 54 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import base64 

-

2from io import BytesIO 

-

3from xml import etree 

-

4 

-

5from cookbook.helper.ingredient_parser import IngredientParser 

-

6from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text 

-

7from cookbook.integration.integration import Integration 

-

8from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

9 

-

10 

-

11class Rezeptsuitede(Integration): 

-

12 

-

13 def split_recipe_file(self, file): 

-

14 return etree.parse(file).getroot().getchildren() 

-

15 

-

16 def get_recipe_from_file(self, file): 

-

17 recipe_xml = file 

-

18 

-

19 recipe = Recipe.objects.create( 

-

20 name=recipe_xml.find('head').attrib['title'].strip(), 

-

21 created_by=self.request.user, internal=True, space=self.request.space) 

-

22 

-

23 try: 

-

24 if recipe_xml.find('head').attrib['servingtype']: 

-

25 recipe.servings = parse_servings(recipe_xml.find('head').attrib['servingtype'].strip()) 

-

26 recipe.servings_text = parse_servings_text(recipe_xml.find('head').attrib['servingtype'].strip()) 

-

27 except KeyError: 

-

28 pass 

-

29 

-

30 if recipe_xml.find('remark') is not None: # description is a list of <li>'s with text 

-

31 if recipe_xml.find('remark').find('line') is not None: 

-

32 recipe.description = recipe_xml.find('remark').find('line').text[:512] 

-

33 

-

34 for prep in recipe_xml.findall('preparation'): 

-

35 try: 

-

36 if prep.find('step').text: 

-

37 step = Step.objects.create( 

-

38 instruction=prep.find('step').text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

39 ) 

-

40 recipe.steps.add(step) 

-

41 except Exception: 

-

42 pass 

-

43 

-

44 ingredient_parser = IngredientParser(self.request, True) 

-

45 

-

46 if recipe_xml.find('part').find('ingredient') is not None: 

-

47 ingredient_step = recipe.steps.first() 

-

48 if ingredient_step is None: 

-

49 ingredient_step = Step.objects.create(space=self.request.space, instruction='') 

-

50 

-

51 for ingredient in recipe_xml.find('part').findall('ingredient'): 

-

52 f = ingredient_parser.get_food(ingredient.attrib['item']) 

-

53 u = ingredient_parser.get_unit(ingredient.attrib['unit']) 

-

54 amount = 0 

-

55 if ingredient.attrib['qty'].strip() != '': 

-

56 amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty']) 

-

57 ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, )) 

-

58 

-

59 try: 

-

60 k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space) 

-

61 recipe.keywords.add(k) 

-

62 except Exception: 

-

63 pass 

-

64 

-

65 recipe.save() 

-

66 

-

67 try: 

-

68 self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg') 

-

69 except BaseException: 

-

70 pass 

-

71 

-

72 return recipe 

-

73 

-

74 def get_file_from_recipe(self, recipe): 

-

75 raise NotImplementedError('Method not implemented in storage integration') 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_rezkonv_py.html b/docs/coverage/d_37812bb4c19c71da_rezkonv_py.html deleted file mode 100644 index 52bc45dbcd..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_rezkonv_py.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - Coverage for cookbook/integration/rezkonv.py: 12% - - - - - -
-
-

- Coverage for cookbook/integration/rezkonv.py: - 12% -

- -

- 60 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from cookbook.helper.ingredient_parser import IngredientParser 

-

2from cookbook.integration.integration import Integration 

-

3from cookbook.models import Ingredient, Keyword, Recipe, Step 

-

4 

-

5 

-

6class RezKonv(Integration): 

-

7 

-

8 def get_recipe_from_file(self, file): 

-

9 

-

10 ingredient_mode = False 

-

11 direction_mode = False 

-

12 

-

13 ingredients = [] 

-

14 directions = [] 

-

15 for line in file.replace('\r', '').replace('\n\n', '\n').split('\n'): 

-

16 if 'Titel:' in line: 

-

17 title = line.replace('Titel:', '').strip() 

-

18 if 'Kategorien:' in line: 

-

19 tags = line.replace('Kategorien:', '').strip() 

-

20 if ingredient_mode and ( 

-

21 'quelle' in line.lower() or 'source' in line.lower() or (line == '' and len(ingredients) > 0)): 

-

22 ingredient_mode = False 

-

23 direction_mode = True 

-

24 if ingredient_mode: 

-

25 if line != '' and '===' not in line and 'Zubereitung' not in line: 

-

26 ingredients.append(line.strip()) 

-

27 if direction_mode: 

-

28 if line.strip() != '' and line.strip() != '=====': 

-

29 directions.append(line.strip()) 

-

30 if 'Zutaten:' in line or 'Ingredients' in line or 'Menge:' in line: 

-

31 ingredient_mode = True 

-

32 

-

33 recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, 

-

34 space=self.request.space) 

-

35 

-

36 for k in tags.split(','): 

-

37 keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space) 

-

38 recipe.keywords.add(keyword) 

-

39 

-

40 step = Step.objects.create( 

-

41 instruction=' \n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, 

-

42 ) 

-

43 

-

44 ingredient_parser = IngredientParser(self.request, True) 

-

45 for ingredient in ingredients: 

-

46 if len(ingredient.strip()) > 0: 

-

47 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

48 f = ingredient_parser.get_food(food) 

-

49 u = ingredient_parser.get_unit(unit) 

-

50 step.ingredients.add(Ingredient.objects.create( 

-

51 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

52 )) 

-

53 recipe.steps.add(step) 

-

54 

-

55 return recipe 

-

56 

-

57 def get_file_from_recipe(self, recipe): 

-

58 raise NotImplementedError('Method not implemented in storage integration') 

-

59 

-

60 def split_recipe_file(self, file): 

-

61 recipe_list = [] 

-

62 current_recipe = '' 

-

63 # TODO build algorithm to try trough encodings and fail if none work, use for all importers 

-

64 # encoding_list = ['windows-1250', 'latin-1'] 

-

65 encoding = 'windows-1250' 

-

66 for fl in file.readlines(): 

-

67 try: 

-

68 line = fl.decode(encoding) 

-

69 except UnicodeDecodeError: 

-

70 encoding = 'latin-1' 

-

71 line = fl.decode(encoding) 

-

72 if line.startswith('=====') and 'rezkonv' in line.lower(): 

-

73 if current_recipe != '': 

-

74 recipe_list.append(current_recipe) 

-

75 current_recipe = '' 

-

76 else: 

-

77 current_recipe = '' 

-

78 else: 

-

79 current_recipe += line + '\n' 

-

80 

-

81 if current_recipe != '': 

-

82 recipe_list.append(current_recipe) 

-

83 

-

84 return recipe_list 

-
- - - diff --git a/docs/coverage/d_37812bb4c19c71da_saffron_py.html b/docs/coverage/d_37812bb4c19c71da_saffron_py.html deleted file mode 100644 index 94fa36c63c..0000000000 --- a/docs/coverage/d_37812bb4c19c71da_saffron_py.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - Coverage for cookbook/integration/saffron.py: 10% - - - - - -
-
-

- Coverage for cookbook/integration/saffron.py: - 10% -

- -

- 78 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.utils.translation import gettext as _ 

-

2 

-

3from cookbook.helper.ingredient_parser import IngredientParser 

-

4from cookbook.integration.integration import Integration 

-

5from cookbook.models import Ingredient, Recipe, Step 

-

6 

-

7 

-

8class Saffron(Integration): 

-

9 

-

10 def get_recipe_from_file(self, file): 

-

11 ingredient_mode = False 

-

12 direction_mode = False 

-

13 

-

14 ingredients = [] 

-

15 directions = [] 

-

16 for fl in file.readlines(): 

-

17 line = fl.decode("utf-8") 

-

18 if 'Title:' in line: 

-

19 title = line.replace('Title:', '').strip() 

-

20 if 'Description:' in line: 

-

21 description = line.replace('Description:', '').strip() 

-

22 if 'Yield:' in line: 

-

23 directions.append(_('Servings') + ' ' + line.replace('Yield:', '').strip() + '\n') 

-

24 if 'Cook:' in line: 

-

25 directions.append(_('Waiting time') + ' ' + line.replace('Cook:', '').strip() + '\n') 

-

26 if 'Prep:' in line: 

-

27 directions.append(_('Preparation Time') + ' ' + line.replace('Prep:', '').strip() + '\n') 

-

28 if 'Cookbook:' in line: 

-

29 directions.append(_('Cookbook') + ' ' + line.replace('Cookbook:', '').strip() + '\n') 

-

30 if 'Section:' in line: 

-

31 directions.append(_('Section') + ' ' + line.replace('Section:', '').strip() + '\n') 

-

32 if ingredient_mode: 

-

33 if len(line) > 2 and 'Instructions:' not in line: 

-

34 ingredients.append(line.strip()) 

-

35 if direction_mode: 

-

36 if len(line) > 2: 

-

37 directions.append(line.strip()) 

-

38 if 'Ingredients:' in line: 

-

39 ingredient_mode = True 

-

40 if 'Instructions:' in line: 

-

41 ingredient_mode = False 

-

42 direction_mode = True 

-

43 

-

44 recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, ) 

-

45 

-

46 step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) 

-

47 

-

48 ingredient_parser = IngredientParser(self.request, True) 

-

49 for ingredient in ingredients: 

-

50 amount, unit, food, note = ingredient_parser.parse(ingredient) 

-

51 f = ingredient_parser.get_food(food) 

-

52 u = ingredient_parser.get_unit(unit) 

-

53 step.ingredients.add(Ingredient.objects.create( 

-

54 food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, 

-

55 )) 

-

56 recipe.steps.add(step) 

-

57 

-

58 return recipe 

-

59 

-

60 def get_file_from_recipe(self, recipe): 

-

61 

-

62 data = "Title: " + recipe.name if recipe.name else "" + "\n" 

-

63 data += "Description: " + recipe.description if recipe.description else "" + "\n" 

-

64 data += "Source: \n" 

-

65 data += "Original URL: \n" 

-

66 data += "Yield: " + str(recipe.servings) + "\n" 

-

67 data += "Cookbook: \n" 

-

68 data += "Section: \n" 

-

69 data += "Image: \n" 

-

70 

-

71 recipeInstructions = [] 

-

72 recipeIngredient = [] 

-

73 for s in recipe.steps.all(): 

-

74 recipeInstructions.append(s.instruction) 

-

75 

-

76 for i in s.ingredients.all(): 

-

77 recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}') 

-

78 

-

79 data += "Ingredients: \n" 

-

80 for ingredient in recipeIngredient: 

-

81 data += ingredient + "\n" 

-

82 

-

83 data += "Instructions: \n" 

-

84 for instruction in recipeInstructions: 

-

85 data += instruction + "\n" 

-

86 

-

87 return recipe.name + '.txt', data 

-

88 

-

89 def get_files_from_recipes(self, recipes, el, cookie): 

-

90 files = [] 

-

91 for r in recipes: 

-

92 filename, data = self.get_file_from_recipe(r) 

-

93 files.append([filename, data]) 

-

94 

-

95 el.exported_recipes += 1 

-

96 el.msg += self.get_recipe_processed_msg(r) 

-

97 el.save() 

-

98 

-

99 return files 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_admin_py.html b/docs/coverage/d_a167ab5b5108d61e_admin_py.html deleted file mode 100644 index af98b9f3de..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_admin_py.html +++ /dev/null @@ -1,496 +0,0 @@ - - - - - Coverage for cookbook/admin.py: 86% - - - - - -
-
-

- Coverage for cookbook/admin.py: - 86% -

- -

- 216 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.conf import settings 

-

2from django.contrib import admin 

-

3from django.contrib.auth.admin import UserAdmin 

-

4from django.contrib.auth.models import Group, User 

-

5from django.contrib.postgres.search import SearchVector 

-

6from django.utils import translation 

-

7from django_scopes import scopes_disabled 

-

8from treebeard.admin import TreeAdmin 

-

9from treebeard.forms import movenodeform_factory 

-

10 

-

11from cookbook.managers import DICTIONARY 

-

12 

-

13from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink, 

-

14 Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType, 

-

15 Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, 

-

16 ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, 

-

17 Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, 

-

18 TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace, 

-

19 ViewLog) 

-

20 

-

21 

-

22class CustomUserAdmin(UserAdmin): 

-

23 def has_add_permission(self, request, obj=None): 

-

24 return False 

-

25 

-

26 

-

27admin.site.unregister(User) 

-

28admin.site.register(User, CustomUserAdmin) 

-

29 

-

30admin.site.unregister(Group) 

-

31 

-

32 

-

33@admin.action(description='Delete all data from a space') 

-

34def delete_space_action(modeladmin, request, queryset): 

-

35 for space in queryset: 

-

36 space.safe_delete() 

-

37 

-

38 

-

39class SpaceAdmin(admin.ModelAdmin): 

-

40 list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') 

-

41 search_fields = ('name', 'created_by__username') 

-

42 autocomplete_fields = ('created_by',) 

-

43 filter_horizontal = ('food_inherit',) 

-

44 list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') 

-

45 date_hierarchy = 'created_at' 

-

46 actions = [delete_space_action] 

-

47 

-

48 

-

49admin.site.register(Space, SpaceAdmin) 

-

50 

-

51 

-

52class UserSpaceAdmin(admin.ModelAdmin): 

-

53 list_display = ('user', 'space',) 

-

54 search_fields = ('user__username', 'space__name',) 

-

55 filter_horizontal = ('groups',) 

-

56 autocomplete_fields = ('user', 'space',) 

-

57 

-

58 

-

59admin.site.register(UserSpace, UserSpaceAdmin) 

-

60 

-

61 

-

62class UserPreferenceAdmin(admin.ModelAdmin): 

-

63 list_display = ('name', 'theme', 'nav_color', 'default_page',) 

-

64 search_fields = ('user__username',) 

-

65 list_filter = ('theme', 'nav_color', 'default_page',) 

-

66 date_hierarchy = 'created_at' 

-

67 filter_horizontal = ('plan_share', 'shopping_share',) 

-

68 

-

69 @staticmethod 

-

70 def name(obj): 

-

71 return obj.user.get_user_display_name() 

-

72 

-

73 

-

74admin.site.register(UserPreference, UserPreferenceAdmin) 

-

75 

-

76 

-

77class SearchPreferenceAdmin(admin.ModelAdmin): 

-

78 list_display = ('name', 'search', 'trigram_threshold',) 

-

79 search_fields = ('user__username',) 

-

80 list_filter = ('search',) 

-

81 

-

82 @staticmethod 

-

83 def name(obj): 

-

84 return obj.user.get_user_display_name() 

-

85 

-

86 

-

87admin.site.register(SearchPreference, SearchPreferenceAdmin) 

-

88 

-

89 

-

90class StorageAdmin(admin.ModelAdmin): 

-

91 list_display = ('name', 'method') 

-

92 search_fields = ('name',) 

-

93 

-

94 

-

95admin.site.register(Storage, StorageAdmin) 

-

96 

-

97 

-

98class SyncAdmin(admin.ModelAdmin): 

-

99 list_display = ('storage', 'path', 'active', 'last_checked') 

-

100 search_fields = ('storage__name', 'path') 

-

101 

-

102 

-

103admin.site.register(Sync, SyncAdmin) 

-

104 

-

105 

-

106class SupermarketCategoryInline(admin.TabularInline): 

-

107 model = SupermarketCategoryRelation 

-

108 

-

109 

-

110class SupermarketAdmin(admin.ModelAdmin): 

-

111 inlines = (SupermarketCategoryInline,) 

-

112 

-

113 

-

114admin.site.register(Supermarket, SupermarketAdmin) 

-

115admin.site.register(SupermarketCategory) 

-

116 

-

117 

-

118class SyncLogAdmin(admin.ModelAdmin): 

-

119 list_display = ('sync', 'status', 'msg', 'created_at') 

-

120 

-

121 

-

122admin.site.register(SyncLog, SyncLogAdmin) 

-

123 

-

124 

-

125@admin.action(description='Temporarily ENABLE sorting on Foods and Keywords.') 

-

126def enable_tree_sorting(modeladmin, request, queryset): 

-

127 Food.node_order_by = ['name'] 

-

128 Keyword.node_order_by = ['name'] 

-

129 with scopes_disabled(): 

-

130 Food.fix_tree(fix_paths=True) 

-

131 Keyword.fix_tree(fix_paths=True) 

-

132 

-

133 

-

134@admin.action(description='Temporarily DISABLE sorting on Foods and Keywords.') 

-

135def disable_tree_sorting(modeladmin, request, queryset): 

-

136 Food.node_order_by = [] 

-

137 Keyword.node_order_by = [] 

-

138 

-

139 

-

140@admin.action(description='Fix problems and sort tree by name') 

-

141def sort_tree(modeladmin, request, queryset): 

-

142 orginal_value = modeladmin.model.node_order_by[:] 

-

143 modeladmin.model.node_order_by = ['name'] 

-

144 with scopes_disabled(): 

-

145 modeladmin.model.fix_tree(fix_paths=True) 

-

146 modeladmin.model.node_order_by = orginal_value 

-

147 

-

148 

-

149class KeywordAdmin(TreeAdmin): 

-

150 form = movenodeform_factory(Keyword) 

-

151 ordering = ('space', 'path',) 

-

152 search_fields = ('name',) 

-

153 actions = [sort_tree, enable_tree_sorting, disable_tree_sorting] 

-

154 

-

155 

-

156admin.site.register(Keyword, KeywordAdmin) 

-

157 

-

158 

-

159@admin.action(description='Delete Steps not part of a Recipe.') 

-

160def delete_unattached_steps(modeladmin, request, queryset): 

-

161 with scopes_disabled(): 

-

162 Step.objects.filter(recipe=None).delete() 

-

163 

-

164 

-

165class StepAdmin(admin.ModelAdmin): 

-

166 list_display = ('name', 'order',) 

-

167 search_fields = ('name',) 

-

168 actions = [delete_unattached_steps] 

-

169 

-

170 

-

171admin.site.register(Step, StepAdmin) 

-

172 

-

173 

-

174@admin.action(description='Rebuild index for selected recipes') 

-

175def rebuild_index(modeladmin, request, queryset): 

-

176 language = DICTIONARY.get(translation.get_language(), 'simple') 

-

177 with scopes_disabled(): 

-

178 Recipe.objects.all().update( 

-

179 name_search_vector=SearchVector('name__unaccent', weight='A', config=language), 

-

180 desc_search_vector=SearchVector('description__unaccent', weight='B', config=language) 

-

181 ) 

-

182 Step.objects.all().update(search_vector=SearchVector('instruction__unaccent', weight='B', config=language)) 

-

183 

-

184 

-

185class RecipeAdmin(admin.ModelAdmin): 

-

186 list_display = ('name', 'internal', 'created_by', 'storage') 

-

187 search_fields = ('name', 'created_by__username') 

-

188 list_filter = ('internal',) 

-

189 date_hierarchy = 'created_at' 

-

190 

-

191 @staticmethod 

-

192 def created_by(obj): 

-

193 return obj.created_by.get_user_display_name() 

-

194 

-

195 if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': 

-

196 actions = [rebuild_index] 

-

197 

-

198 

-

199admin.site.register(Recipe, RecipeAdmin) 

-

200 

-

201admin.site.register(Unit) 

-

202 

-

203 

-

204# admin.site.register(FoodInheritField) 

-

205 

-

206 

-

207class FoodAdmin(TreeAdmin): 

-

208 form = movenodeform_factory(Keyword) 

-

209 ordering = ('space', 'path',) 

-

210 search_fields = ('name',) 

-

211 actions = [sort_tree, enable_tree_sorting, disable_tree_sorting] 

-

212 

-

213 

-

214admin.site.register(Food, FoodAdmin) 

-

215 

-

216 

-

217class UnitConversionAdmin(admin.ModelAdmin): 

-

218 list_display = ('base_amount', 'base_unit', 'food', 'converted_amount', 'converted_unit') 

-

219 search_fields = ('food__name', 'unit__name') 

-

220 

-

221 

-

222admin.site.register(UnitConversion, UnitConversionAdmin) 

-

223 

-

224 

-

225@admin.action(description='Delete Ingredients not part of a Recipe.') 

-

226def delete_unattached_ingredients(modeladmin, request, queryset): 

-

227 with scopes_disabled(): 

-

228 Ingredient.objects.filter(step__recipe=None).delete() 

-

229 

-

230 

-

231class IngredientAdmin(admin.ModelAdmin): 

-

232 list_display = ('food', 'amount', 'unit') 

-

233 search_fields = ('food__name', 'unit__name') 

-

234 actions = [delete_unattached_ingredients] 

-

235 

-

236 

-

237admin.site.register(Ingredient, IngredientAdmin) 

-

238 

-

239 

-

240class CommentAdmin(admin.ModelAdmin): 

-

241 list_display = ('recipe', 'name', 'created_at') 

-

242 search_fields = ('text', 'created_by__username') 

-

243 date_hierarchy = 'created_at' 

-

244 

-

245 @staticmethod 

-

246 def name(obj): 

-

247 return obj.created_by.get_user_display_name() 

-

248 

-

249 

-

250admin.site.register(Comment, CommentAdmin) 

-

251 

-

252 

-

253class RecipeImportAdmin(admin.ModelAdmin): 

-

254 list_display = ('name', 'storage', 'file_path') 

-

255 

-

256 

-

257admin.site.register(RecipeImport, RecipeImportAdmin) 

-

258 

-

259 

-

260class RecipeBookAdmin(admin.ModelAdmin): 

-

261 list_display = ('name', 'user_name') 

-

262 search_fields = ('name', 'created_by__username') 

-

263 

-

264 @staticmethod 

-

265 def user_name(obj): 

-

266 return obj.created_by.get_user_display_name() 

-

267 

-

268 

-

269admin.site.register(RecipeBook, RecipeBookAdmin) 

-

270 

-

271 

-

272class RecipeBookEntryAdmin(admin.ModelAdmin): 

-

273 list_display = ('book', 'recipe') 

-

274 

-

275 

-

276admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin) 

-

277 

-

278 

-

279class MealPlanAdmin(admin.ModelAdmin): 

-

280 list_display = ('user', 'recipe', 'meal_type', 'from_date', 'to_date') 

-

281 

-

282 @staticmethod 

-

283 def user(obj): 

-

284 return obj.created_by.get_user_display_name() 

-

285 

-

286 

-

287admin.site.register(MealPlan, MealPlanAdmin) 

-

288 

-

289 

-

290class MealTypeAdmin(admin.ModelAdmin): 

-

291 list_display = ('name', 'created_by', 'order') 

-

292 search_fields = ('name', 'created_by__username') 

-

293 

-

294 

-

295admin.site.register(MealType, MealTypeAdmin) 

-

296 

-

297 

-

298class ViewLogAdmin(admin.ModelAdmin): 

-

299 list_display = ('recipe', 'created_by', 'created_at') 

-

300 

-

301 

-

302admin.site.register(ViewLog, ViewLogAdmin) 

-

303 

-

304 

-

305class InviteLinkAdmin(admin.ModelAdmin): 

-

306 list_display = ( 

-

307 'group', 'valid_until', 'space', 

-

308 'created_by', 'created_at', 'used_by' 

-

309 ) 

-

310 

-

311 

-

312admin.site.register(InviteLink, InviteLinkAdmin) 

-

313 

-

314 

-

315class CookLogAdmin(admin.ModelAdmin): 

-

316 list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings') 

-

317 search_fields = ('recipe__name', 'space__name',) 

-

318 

-

319 

-

320admin.site.register(CookLog, CookLogAdmin) 

-

321 

-

322 

-

323class ShoppingListRecipeAdmin(admin.ModelAdmin): 

-

324 list_display = ('id', 'recipe', 'servings') 

-

325 

-

326 

-

327admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin) 

-

328 

-

329 

-

330class ShoppingListEntryAdmin(admin.ModelAdmin): 

-

331 list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked') 

-

332 

-

333 

-

334admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin) 

-

335 

-

336 

-

337class ShoppingListAdmin(admin.ModelAdmin): 

-

338 list_display = ('id', 'created_by', 'created_at') 

-

339 

-

340 

-

341admin.site.register(ShoppingList, ShoppingListAdmin) 

-

342 

-

343 

-

344class ShareLinkAdmin(admin.ModelAdmin): 

-

345 list_display = ('recipe', 'created_by', 'uuid', 'created_at',) 

-

346 

-

347 

-

348admin.site.register(ShareLink, ShareLinkAdmin) 

-

349 

-

350 

-

351class PropertyTypeAdmin(admin.ModelAdmin): 

-

352 search_fields = ('space',) 

-

353 

-

354 list_display = ('id', 'space', 'name', 'fdc_id') 

-

355 

-

356 

-

357admin.site.register(PropertyType, PropertyTypeAdmin) 

-

358 

-

359 

-

360class PropertyAdmin(admin.ModelAdmin): 

-

361 list_display = ('property_amount', 'property_type') 

-

362 

-

363 

-

364admin.site.register(Property, PropertyAdmin) 

-

365 

-

366 

-

367class NutritionInformationAdmin(admin.ModelAdmin): 

-

368 list_display = ('id',) 

-

369 

-

370 

-

371admin.site.register(NutritionInformation, NutritionInformationAdmin) 

-

372 

-

373 

-

374class ImportLogAdmin(admin.ModelAdmin): 

-

375 list_display = ('id', 'type', 'running', 'created_by', 'created_at',) 

-

376 

-

377 

-

378admin.site.register(ImportLog, ImportLogAdmin) 

-

379 

-

380 

-

381class TelegramBotAdmin(admin.ModelAdmin): 

-

382 list_display = ('id', 'name', 'created_by',) 

-

383 

-

384 

-

385admin.site.register(TelegramBot, TelegramBotAdmin) 

-

386 

-

387 

-

388class BookmarkletImportAdmin(admin.ModelAdmin): 

-

389 list_display = ('id', 'url', 'created_by', 'created_at',) 

-

390 

-

391 

-

392admin.site.register(BookmarkletImport, BookmarkletImportAdmin) 

-

393 

-

394 

-

395class UserFileAdmin(admin.ModelAdmin): 

-

396 list_display = ('id', 'name', 'file_size_kb', 'created_at',) 

-

397 

-

398 

-

399admin.site.register(UserFile, UserFileAdmin) 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_forms_py.html b/docs/coverage/d_a167ab5b5108d61e_forms_py.html deleted file mode 100644 index 252ebe901d..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_forms_py.html +++ /dev/null @@ -1,617 +0,0 @@ - - - - - Coverage for cookbook/forms.py: 79% - - - - - -
-
-

- Coverage for cookbook/forms.py: - 79% -

- -

- 228 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from datetime import datetime 

-

2 

-

3from django import forms 

-

4from django.conf import settings 

-

5from django.core.exceptions import ValidationError 

-

6from django.forms import NumberInput, widgets 

-

7from django.utils.translation import gettext_lazy as _ 

-

8from django_scopes import scopes_disabled 

-

9from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField 

-

10from hcaptcha.fields import hCaptchaField 

-

11 

-

12from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry, 

-

13 SearchPreference, Space, Storage, Sync, User, UserPreference) 

-

14 

-

15 

-

16class SelectWidget(widgets.Select): 

-

17 class Media: 

-

18 js = ('custom/js/form_select.js',) 

-

19 

-

20 

-

21class MultiSelectWidget(widgets.SelectMultiple): 

-

22 class Media: 

-

23 js = ('custom/js/form_multiselect.js',) 

-

24 

-

25 

-

26# Yes there are some stupid browsers that still dont support this but 

-

27# I dont support people using these browsers. 

-

28class DateWidget(forms.DateInput): 

-

29 input_type = 'date' 

-

30 

-

31 def __init__(self, **kwargs): 

-

32 kwargs["format"] = "%Y-%m-%d" 

-

33 super().__init__(**kwargs) 

-

34 

-

35 

-

36class UserPreferenceForm(forms.ModelForm): 

-

37 prefix = 'preference' 

-

38 

-

39 def __init__(self, *args, **kwargs): 

-

40 space = kwargs.pop('space') 

-

41 super().__init__(*args, **kwargs) 

-

42 self.fields['plan_share'].queryset = User.objects.filter(userspace__space=space).all() 

-

43 

-

44 class Meta: 

-

45 model = UserPreference 

-

46 fields = ( 

-

47 'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color', 

-

48 'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', 'show_step_ingredients', 

-

49 ) 

-

50 

-

51 labels = { 

-

52 'default_unit': _('Default unit'), 

-

53 'use_fractions': _('Use fractions'), 

-

54 'use_kj': _('Use KJ'), 

-

55 'theme': _('Theme'), 

-

56 'nav_color': _('Navbar color'), 

-

57 'sticky_navbar': _('Sticky navbar'), 

-

58 'default_page': _('Default page'), 

-

59 'plan_share': _('Plan sharing'), 

-

60 'ingredient_decimals': _('Ingredient decimal places'), 

-

61 'shopping_auto_sync': _('Shopping list auto sync period'), 

-

62 'comments': _('Comments'), 

-

63 'left_handed': _('Left-handed mode'), 

-

64 'show_step_ingredients': _('Show step ingredients table') 

-

65 } 

-

66 

-

67 help_texts = { 

-

68 'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), 

-

69 'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), 

-

70 'use_fractions': _( 

-

71 'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), 

-

72 'use_kj': _('Display nutritional energy amounts in joules instead of calories'), 

-

73 'plan_share': _('Users with whom newly created meal plans should be shared by default.'), 

-

74 'shopping_share': _('Users with whom to share shopping lists.'), 

-

75 'ingredient_decimals': _('Number of decimals to round ingredients.'), 

-

76 'comments': _('If you want to be able to create and see comments underneath recipes.'), 

-

77 'shopping_auto_sync': _( 

-

78 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 

-

79 'of mobile data. If lower than instance limit it is reset when saving.' 

-

80 ), 

-

81 'sticky_navbar': _('Makes the navbar stick to the top of the page.'), 

-

82 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 

-

83 'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'), 

-

84 'left_handed': _('Will optimize the UI for use with your left hand.'), 

-

85 'show_step_ingredients': _('Add ingredients table next to recipe steps. Applies at creation time for manually created and URL imported recipes. Individual steps can be overridden in the edit recipe view.') 

-

86 } 

-

87 

-

88 widgets = { 

-

89 'plan_share': MultiSelectWidget, 

-

90 'shopping_share': MultiSelectWidget, 

-

91 } 

-

92 

-

93 

-

94class UserNameForm(forms.ModelForm): 

-

95 prefix = 'name' 

-

96 

-

97 class Meta: 

-

98 model = User 

-

99 fields = ('first_name', 'last_name') 

-

100 

-

101 help_texts = { 

-

102 'first_name': _('Both fields are optional. If none are given the username will be displayed instead') 

-

103 } 

-

104 

-

105 

-

106class ExternalRecipeForm(forms.ModelForm): 

-

107 file_path = forms.CharField(disabled=True, required=False) 

-

108 file_uid = forms.CharField(disabled=True, required=False) 

-

109 

-

110 def __init__(self, *args, **kwargs): 

-

111 space = kwargs.pop('space') 

-

112 super().__init__(*args, **kwargs) 

-

113 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all() 

-

114 

-

115 class Meta: 

-

116 model = Recipe 

-

117 fields = ( 

-

118 'name', 'description', 'servings', 'working_time', 'waiting_time', 

-

119 'file_path', 'file_uid', 'keywords' 

-

120 ) 

-

121 

-

122 labels = { 

-

123 'name': _('Name'), 

-

124 'keywords': _('Keywords'), 

-

125 'working_time': _('Preparation time in minutes'), 

-

126 'waiting_time': _('Waiting time (cooking/baking) in minutes'), 

-

127 'file_path': _('Path'), 

-

128 'file_uid': _('Storage UID'), 

-

129 } 

-

130 widgets = {'keywords': MultiSelectWidget} 

-

131 field_classes = { 

-

132 'keywords': SafeModelMultipleChoiceField, 

-

133 } 

-

134 

-

135 

-

136class ImportExportBase(forms.Form): 

-

137 DEFAULT = 'DEFAULT' 

-

138 PAPRIKA = 'PAPRIKA' 

-

139 NEXTCLOUD = 'NEXTCLOUD' 

-

140 MEALIE = 'MEALIE' 

-

141 CHOWDOWN = 'CHOWDOWN' 

-

142 SAFFRON = 'SAFFRON' 

-

143 CHEFTAP = 'CHEFTAP' 

-

144 PEPPERPLATE = 'PEPPERPLATE' 

-

145 RECIPEKEEPER = 'RECIPEKEEPER' 

-

146 RECETTETEK = 'RECETTETEK' 

-

147 RECIPESAGE = 'RECIPESAGE' 

-

148 DOMESTICA = 'DOMESTICA' 

-

149 MEALMASTER = 'MEALMASTER' 

-

150 MELARECIPES = 'MELARECIPES' 

-

151 REZKONV = 'REZKONV' 

-

152 OPENEATS = 'OPENEATS' 

-

153 PLANTOEAT = 'PLANTOEAT' 

-

154 COOKBOOKAPP = 'COOKBOOKAPP' 

-

155 COPYMETHAT = 'COPYMETHAT' 

-

156 COOKMATE = 'COOKMATE' 

-

157 REZEPTSUITEDE = 'REZEPTSUITEDE' 

-

158 PDF = 'PDF' 

-

159 

-

160 type = forms.ChoiceField(choices=( 

-

161 (DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), 

-

162 (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), 

-

163 (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'), 

-

164 (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), 

-

165 (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'), 

-

166 (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de') 

-

167 )) 

-

168 

-

169 

-

170class MultipleFileInput(forms.ClearableFileInput): 

-

171 allow_multiple_selected = True 

-

172 

-

173 

-

174class MultipleFileField(forms.FileField): 

-

175 def __init__(self, *args, **kwargs): 

-

176 kwargs.setdefault("widget", MultipleFileInput()) 

-

177 super().__init__(*args, **kwargs) 

-

178 

-

179 def clean(self, data, initial=None): 

-

180 single_file_clean = super().clean 

-

181 if isinstance(data, (list, tuple)): 

-

182 result = [single_file_clean(d, initial) for d in data] 

-

183 else: 

-

184 result = single_file_clean(data, initial) 

-

185 return result 

-

186 

-

187 

-

188class ImportForm(ImportExportBase): 

-

189 files = MultipleFileField(required=True) 

-

190 duplicates = forms.BooleanField(help_text=_( 

-

191 'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), 

-

192 required=False) 

-

193 

-

194 

-

195class ExportForm(ImportExportBase): 

-

196 recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False) 

-

197 all = forms.BooleanField(required=False) 

-

198 custom_filter = forms.IntegerField(required=False) 

-

199 

-

200 def __init__(self, *args, **kwargs): 

-

201 space = kwargs.pop('space') 

-

202 super().__init__(*args, **kwargs) 

-

203 self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all() 

-

204 

-

205 

-

206class CommentForm(forms.ModelForm): 

-

207 prefix = 'comment' 

-

208 

-

209 class Meta: 

-

210 model = Comment 

-

211 fields = ('text',) 

-

212 

-

213 labels = { 

-

214 'text': _('Add your comment: '), 

-

215 } 

-

216 widgets = { 

-

217 'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), 

-

218 } 

-

219 

-

220 

-

221class StorageForm(forms.ModelForm): 

-

222 username = forms.CharField( 

-

223 widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), 

-

224 required=False 

-

225 ) 

-

226 password = forms.CharField( 

-

227 widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), 

-

228 required=False, 

-

229 help_text=_('Leave empty for dropbox and enter app password for nextcloud.') 

-

230 ) 

-

231 token = forms.CharField( 

-

232 widget=forms.TextInput( 

-

233 attrs={'autocomplete': 'new-password', 'type': 'password'} 

-

234 ), 

-

235 required=False, 

-

236 help_text=_('Leave empty for nextcloud and enter api token for dropbox.') 

-

237 ) 

-

238 

-

239 class Meta: 

-

240 model = Storage 

-

241 fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path') 

-

242 

-

243 help_texts = { 

-

244 'url': _( 

-

245 'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), 

-

246 } 

-

247 

-

248 

-

249# TODO: Deprecate 

-

250class RecipeBookEntryForm(forms.ModelForm): 

-

251 prefix = 'bookmark' 

-

252 

-

253 def __init__(self, *args, **kwargs): 

-

254 space = kwargs.pop('space') 

-

255 super().__init__(*args, **kwargs) 

-

256 self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all() 

-

257 

-

258 class Meta: 

-

259 model = RecipeBookEntry 

-

260 fields = ('book',) 

-

261 

-

262 field_classes = { 

-

263 'book': SafeModelChoiceField, 

-

264 } 

-

265 

-

266 

-

267class SyncForm(forms.ModelForm): 

-

268 

-

269 def __init__(self, *args, **kwargs): 

-

270 space = kwargs.pop('space') 

-

271 super().__init__(*args, **kwargs) 

-

272 self.fields['storage'].queryset = Storage.objects.filter(space=space).all() 

-

273 

-

274 class Meta: 

-

275 model = Sync 

-

276 fields = ('storage', 'path', 'active') 

-

277 

-

278 field_classes = { 

-

279 'storage': SafeModelChoiceField, 

-

280 } 

-

281 

-

282 labels = { 

-

283 'storage': _('Storage'), 

-

284 'path': _('Path'), 

-

285 'active': _('Active') 

-

286 } 

-

287 

-

288 

-

289# TODO deprecate 

-

290class BatchEditForm(forms.Form): 

-

291 search = forms.CharField(label=_('Search String')) 

-

292 keywords = forms.ModelMultipleChoiceField( 

-

293 queryset=Keyword.objects.none(), 

-

294 required=False, 

-

295 widget=MultiSelectWidget 

-

296 ) 

-

297 

-

298 def __init__(self, *args, **kwargs): 

-

299 space = kwargs.pop('space') 

-

300 super().__init__(*args, **kwargs) 

-

301 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all().order_by('id') 

-

302 

-

303 

-

304class ImportRecipeForm(forms.ModelForm): 

-

305 def __init__(self, *args, **kwargs): 

-

306 space = kwargs.pop('space') 

-

307 super().__init__(*args, **kwargs) 

-

308 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all() 

-

309 

-

310 class Meta: 

-

311 model = Recipe 

-

312 fields = ('name', 'keywords', 'file_path', 'file_uid') 

-

313 

-

314 labels = { 

-

315 'name': _('Name'), 

-

316 'keywords': _('Keywords'), 

-

317 'file_path': _('Path'), 

-

318 'file_uid': _('File ID'), 

-

319 } 

-

320 widgets = {'keywords': MultiSelectWidget} 

-

321 field_classes = { 

-

322 'keywords': SafeModelChoiceField, 

-

323 } 

-

324 

-

325 

-

326class InviteLinkForm(forms.ModelForm): 

-

327 def __init__(self, *args, **kwargs): 

-

328 user = kwargs.pop('user') 

-

329 super().__init__(*args, **kwargs) 

-

330 self.fields['space'].queryset = Space.objects.filter(created_by=user).all() 

-

331 

-

332 def clean(self): 

-

333 space = self.cleaned_data['space'] 

-

334 if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + 

-

335 InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users: 

-

336 raise ValidationError(_('Maximum number of users for this space reached.')) 

-

337 

-

338 def clean_email(self): 

-

339 email = self.cleaned_data['email'] 

-

340 with scopes_disabled(): 

-

341 if email != '' and User.objects.filter(email=email).exists(): 

-

342 raise ValidationError(_('Email address already taken!')) 

-

343 

-

344 return email 

-

345 

-

346 class Meta: 

-

347 model = InviteLink 

-

348 fields = ('email', 'group', 'valid_until', 'space') 

-

349 help_texts = { 

-

350 'email': _('An email address is not required but if present the invite link will be sent to the user.'), 

-

351 } 

-

352 field_classes = { 

-

353 'space': SafeModelChoiceField, 

-

354 } 

-

355 

-

356 

-

357class SpaceCreateForm(forms.Form): 

-

358 prefix = 'create' 

-

359 name = forms.CharField() 

-

360 

-

361 def clean_name(self): 

-

362 name = self.cleaned_data['name'] 

-

363 with scopes_disabled(): 

-

364 if Space.objects.filter(name=name).exists(): 

-

365 raise ValidationError(_('Name already taken.')) 

-

366 return name 

-

367 

-

368 

-

369class SpaceJoinForm(forms.Form): 

-

370 prefix = 'join' 

-

371 token = forms.CharField() 

-

372 

-

373 

-

374class AllAuthSignupForm(forms.Form): 

-

375 captcha = hCaptchaField() 

-

376 terms = forms.BooleanField(label=_('Accept Terms and Privacy')) 

-

377 

-

378 def __init__(self, **kwargs): 

-

379 super(AllAuthSignupForm, self).__init__(**kwargs) 

-

380 if settings.PRIVACY_URL == '' and settings.TERMS_URL == '': 

-

381 self.fields.pop('terms') 

-

382 if settings.HCAPTCHA_SECRET == '': 

-

383 self.fields.pop('captcha') 

-

384 

-

385 def signup(self, request, user): 

-

386 pass 

-

387 

-

388 

-

389class UserCreateForm(forms.Form): 

-

390 name = forms.CharField(label='Username') 

-

391 password = forms.CharField( 

-

392 widget=forms.TextInput( 

-

393 attrs={'autocomplete': 'new-password', 'type': 'password'} 

-

394 ) 

-

395 ) 

-

396 password_confirm = forms.CharField( 

-

397 widget=forms.TextInput( 

-

398 attrs={'autocomplete': 'new-password', 'type': 'password'} 

-

399 ) 

-

400 ) 

-

401 

-

402 

-

403class SearchPreferenceForm(forms.ModelForm): 

-

404 prefix = 'search' 

-

405 trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, 

-

406 widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), 

-

407 help_text=_( 

-

408 'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) 

-

409 preset = forms.CharField(widget=forms.HiddenInput(), required=False) 

-

410 

-

411 class Meta: 

-

412 model = SearchPreference 

-

413 fields = ( 

-

414 'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold') 

-

415 

-

416 help_texts = { 

-

417 'search': _( 

-

418 'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'), 

-

419 'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 

-

420 'unaccent': _( 

-

421 'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 

-

422 'icontains': _( 

-

423 "Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 

-

424 'istartswith': _( 

-

425 "Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 

-

426 'trigram': _( 

-

427 "Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 

-

428 'fulltext': _( 

-

429 "Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."), 

-

430 } 

-

431 

-

432 labels = { 

-

433 'search': _('Search Method'), 

-

434 'lookup': _('Fuzzy Lookups'), 

-

435 'unaccent': _('Ignore Accent'), 

-

436 'icontains': _("Partial Match"), 

-

437 'istartswith': _("Starts With"), 

-

438 'trigram': _("Fuzzy Search"), 

-

439 'fulltext': _("Full Text") 

-

440 } 

-

441 

-

442 widgets = { 

-

443 'search': SelectWidget, 

-

444 'unaccent': MultiSelectWidget, 

-

445 'icontains': MultiSelectWidget, 

-

446 'istartswith': MultiSelectWidget, 

-

447 'trigram': MultiSelectWidget, 

-

448 'fulltext': MultiSelectWidget, 

-

449 } 

-

450 

-

451 

-

452class ShoppingPreferenceForm(forms.ModelForm): 

-

453 prefix = 'shopping' 

-

454 

-

455 class Meta: 

-

456 model = UserPreference 

-

457 

-

458 fields = ( 

-

459 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', 

-

460 'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' 

-

461 ) 

-

462 

-

463 help_texts = { 

-

464 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), 

-

465 'shopping_auto_sync': _( 

-

466 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 

-

467 'of mobile data. If lower than instance limit it is reset when saving.' 

-

468 ), 

-

469 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 

-

470 'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'), 

-

471 'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'), 

-

472 'default_delay': _('Default number of hours to delay a shopping list entry.'), 

-

473 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), 

-

474 'shopping_recent_days': _('Days of recent shopping list entries to display.'), 

-

475 'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."), 

-

476 'csv_delim': _('Delimiter to use for CSV exports.'), 

-

477 'csv_prefix': _('Prefix to add when copying list to the clipboard.'), 

-

478 

-

479 } 

-

480 labels = { 

-

481 'shopping_share': _('Share Shopping List'), 

-

482 'shopping_auto_sync': _('Autosync'), 

-

483 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), 

-

484 'mealplan_autoexclude_onhand': _('Exclude On Hand'), 

-

485 'mealplan_autoinclude_related': _('Include Related'), 

-

486 'default_delay': _('Default Delay Hours'), 

-

487 'filter_to_supermarket': _('Filter to Supermarket'), 

-

488 'shopping_recent_days': _('Recent Days'), 

-

489 'csv_delim': _('CSV Delimiter'), 

-

490 "csv_prefix_label": _("List Prefix"), 

-

491 'shopping_add_onhand': _("Auto On Hand"), 

-

492 } 

-

493 

-

494 widgets = { 

-

495 'shopping_share': MultiSelectWidget 

-

496 } 

-

497 

-

498 

-

499class SpacePreferenceForm(forms.ModelForm): 

-

500 prefix = 'space' 

-

501 reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, 

-

502 help_text=_("Reset all food to inherit the fields configured.")) 

-

503 

-

504 def __init__(self, *args, **kwargs): 

-

505 super().__init__(*args, **kwargs) # populates the post 

-

506 self.fields['food_inherit'].queryset = Food.inheritable_fields 

-

507 

-

508 class Meta: 

-

509 model = Space 

-

510 

-

511 fields = ('food_inherit', 'reset_food_inherit', 'use_plural') 

-

512 

-

513 help_texts = { 

-

514 'food_inherit': _('Fields on food that should be inherited by default.'), 

-

515 'use_plural': _('Use the plural form for units and food inside this space.'), 

-

516 } 

-

517 

-

518 widgets = { 

-

519 'food_inherit': MultiSelectWidget 

-

520 } 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_managers_py.html b/docs/coverage/d_a167ab5b5108d61e_managers_py.html deleted file mode 100644 index ee24bebc41..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_managers_py.html +++ /dev/null @@ -1,144 +0,0 @@ - - - - - Coverage for cookbook/managers.py: 62% - - - - - -
-
-

- Coverage for cookbook/managers.py: - 62% -

- -

- 13 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.contrib.postgres.aggregates import StringAgg 

-

2from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector 

-

3from django.db import models 

-

4from django.db.models import Q 

-

5from django.utils import translation 

-

6 

-

7DICTIONARY = { 

-

8 # TODO find custom dictionaries - maybe from here https://www.postgresql.org/message-id/CAF4Au4x6X_wSXFwsQYE8q5o0aQZANrvYjZJ8uOnsiHDnOVPPEg%40mail.gmail.com 

-

9 # 'hy': 'Armenian', 

-

10 # 'ca': 'Catalan', 

-

11 # 'cs': 'Czech', 

-

12 'nl': 'dutch', 

-

13 'en': 'english', 

-

14 'fr': 'french', 

-

15 'de': 'german', 

-

16 'it': 'italian', 

-

17 # 'lv': 'Latvian', 

-

18 'es': 'spanish', 

-

19 'sv': 'swedish', 

-

20} 

-

21 

-

22 

-

23# TODO add schedule index rebuild 

-

24class RecipeSearchManager(models.Manager): 

-

25 def search(self, search_text, space): 

-

26 language = DICTIONARY.get(translation.get_language(), 'simple') 

-

27 search_query = SearchQuery( 

-

28 search_text, 

-

29 config=language, 

-

30 search_type="websearch" 

-

31 ) 

-

32 search_vectors = ( 

-

33 SearchVector('search_vector') 

-

34 + SearchVector(StringAgg('steps__ingredients__food__name__unaccent', delimiter=' '), weight='B', config=language) 

-

35 + SearchVector(StringAgg('keywords__name__unaccent', delimiter=' '), weight='B', config=language)) 

-

36 search_rank = SearchRank(search_vectors, search_query) 

-

37 

-

38 return ( 

-

39 self.get_queryset() 

-

40 .annotate( 

-

41 search=search_vectors, 

-

42 rank=search_rank, 

-

43 ) 

-

44 .filter( 

-

45 Q(search=search_query) 

-

46 ) 

-

47 .order_by('-rank')) 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_models_py.html b/docs/coverage/d_a167ab5b5108d61e_models_py.html deleted file mode 100644 index 0de20a0b22..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_models_py.html +++ /dev/null @@ -1,1484 +0,0 @@ - - - - - Coverage for cookbook/models.py: 86% - - - - - -
-
-

- Coverage for cookbook/models.py: - 86% -

- -

- 900 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import operator 

-

2import pathlib 

-

3import re 

-

4import uuid 

-

5from datetime import date, timedelta 

-

6 

-

7import oauth2_provider.models 

-

8from annoying.fields import AutoOneToOneField 

-

9from django.contrib import auth 

-

10from django.contrib.auth.models import Group, User 

-

11from django.contrib.postgres.indexes import GinIndex 

-

12from django.contrib.postgres.search import SearchVectorField 

-

13from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile 

-

14from django.core.validators import MinLengthValidator 

-

15from django.db import IntegrityError, models 

-

16from django.db.models import Avg, Index, Max, ProtectedError, Q 

-

17from django.db.models.fields.related import ManyToManyField 

-

18from django.db.models.functions import Substr 

-

19from django.utils import timezone 

-

20from django.utils.translation import gettext as _ 

-

21from django_prometheus.models import ExportModelOperationsMixin 

-

22from django_scopes import ScopedManager, scopes_disabled 

-

23from PIL import Image 

-

24from treebeard.mp_tree import MP_Node, MP_NodeManager 

-

25 

-

26from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT, 

-

27 SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT) 

-

28 

-

29 

-

30def get_user_display_name(self): 

-

31 if not (name := f"{self.first_name} {self.last_name}") == " ": 

-

32 return name 

-

33 else: 

-

34 return self.username 

-

35 

-

36 

-

37def get_active_space(self): 

-

38 """ 

-

39 Returns the active space of a user or in case no space is actives raises an *** exception 

-

40 CAREFUL: cannot be used in django scopes with scope() function because passing None as a scope context means no space checking is enforced (at least I think)!! 

-

41 :param self: user 

-

42 :return: space currently active for user 

-

43 """ 

-

44 try: 

-

45 return self.userspace_set.filter(active=True).first().space 

-

46 except AttributeError: 

-

47 return None 

-

48 

-

49 

-

50def get_shopping_share(self): 

-

51 # get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required 

-

52 return User.objects.raw(' '.join([ 

-

53 'SELECT auth_user.id FROM auth_user', 

-

54 'INNER JOIN cookbook_userpreference', 

-

55 'ON (auth_user.id = cookbook_userpreference.user_id)', 

-

56 'INNER JOIN cookbook_userpreference_shopping_share', 

-

57 'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)', 

-

58 'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id) 

-

59 ])) 

-

60 

-

61 

-

62auth.models.User.add_to_class('get_user_display_name', get_user_display_name) 

-

63auth.models.User.add_to_class('get_shopping_share', get_shopping_share) 

-

64auth.models.User.add_to_class('get_active_space', get_active_space) 

-

65 

-

66 

-

67def oauth_token_get_owner(self): 

-

68 return self.user 

-

69 

-

70 

-

71oauth2_provider.models.AccessToken.add_to_class('get_owner', oauth_token_get_owner) 

-

72 

-

73 

-

74def get_model_name(model): 

-

75 return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower() 

-

76 

-

77 

-

78class TreeManager(MP_NodeManager): 

-

79 def create(self, *args, **kwargs): 

-

80 return self.get_or_create(*args, **kwargs)[0] 

-

81 

-

82 # model.Manager get_or_create() is not compatible with MP_Tree 

-

83 def get_or_create(self, *args, **kwargs): 

-

84 kwargs['name'] = kwargs['name'].strip() 

-

85 if hasattr(self, 'space'): 

-

86 if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first(): 

-

87 return obj, False 

-

88 else: 

-

89 if obj := self.filter(name__iexact=kwargs['name']).first(): 

-

90 return obj, False 

-

91 

-

92 with scopes_disabled(): 

-

93 try: 

-

94 defaults = kwargs.pop('defaults', None) 

-

95 if defaults: 

-

96 kwargs = {**kwargs, **defaults} 

-

97 # ManyToMany fields can't be set this way, so pop them out to save for later 

-

98 fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)] 

-

99 many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields} 

-

100 obj = self.model.add_root(**kwargs) 

-

101 for field in many_to_many: 

-

102 field_model = getattr(obj, field).model 

-

103 for related_obj in many_to_many[field]: 

-

104 if isinstance(related_obj, User): 

-

105 getattr(obj, field).add(field_model.objects.get(id=related_obj.id)) 

-

106 else: 

-

107 getattr(obj, field).add(field_model.objects.get(**dict(related_obj))) 

-

108 return obj, True 

-

109 except IntegrityError as e: 

-

110 if 'Key (path)' in e.args[0]: 

-

111 self.model.fix_tree(fix_paths=True) 

-

112 return self.model.add_root(**kwargs), True 

-

113 

-

114 

-

115class TreeModel(MP_Node): 

-

116 _full_name_separator = ' > ' 

-

117 

-

118 def __str__(self): 

-

119 return f"{self.name}" 

-

120 

-

121 @property 

-

122 def parent(self): 

-

123 parent = self.get_parent() 

-

124 if parent: 

-

125 return self.get_parent().id 

-

126 return None 

-

127 

-

128 @property 

-

129 def full_name(self): 

-

130 """ 

-

131 Returns a string representation of a tree node and it's ancestors, 

-

132 e.g. 'Cuisine > Asian > Chinese > Catonese'. 

-

133 """ 

-

134 names = [node.name for node in self.get_ancestors_and_self()] 

-

135 return self._full_name_separator.join(names) 

-

136 

-

137 def get_ancestors_and_self(self): 

-

138 """ 

-

139 Gets ancestors and includes itself. Use treebeard's get_ancestors 

-

140 if you don't want to include the node itself. It's a separate 

-

141 function as it's commonly used in templates. 

-

142 """ 

-

143 if self.is_root(): 

-

144 return [self] 

-

145 return list(self.get_ancestors()) + [self] 

-

146 

-

147 def get_descendants_and_self(self): 

-

148 """ 

-

149 Gets descendants and includes itself. Use treebeard's get_descendants 

-

150 if you don't want to include the node itself. It's a separate 

-

151 function as it's commonly used in templates. 

-

152 """ 

-

153 return self.get_tree(self) 

-

154 

-

155 def has_children(self): 

-

156 return self.get_num_children() > 0 

-

157 

-

158 def get_num_children(self): 

-

159 return self.get_children().count() 

-

160 

-

161 # use self.objects.get_or_create() instead 

-

162 @classmethod 

-

163 def add_root(self, **kwargs): 

-

164 with scopes_disabled(): 

-

165 return super().add_root(**kwargs) 

-

166 

-

167 # i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet 

-

168 @staticmethod 

-

169 def include_descendants(queryset=None, filter=None): 

-

170 """ 

-

171 :param queryset: Model Queryset to add descendants 

-

172 :param filter: Filter (exclude) the descendants nodes with the provided Q filter 

-

173 """ 

-

174 descendants = Q() 

-

175 # TODO filter the queryset nodes to exclude descendants of objects in the queryset 

-

176 nodes = queryset.values('path', 'depth') 

-

177 for node in nodes: 

-

178 descendants |= Q(path__startswith=node['path'], depth__gt=node['depth']) 

-

179 

-

180 return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants) 

-

181 

-

182 def exclude_descendants(queryset=None, filter=None): 

-

183 """ 

-

184 :param queryset: Model Queryset to add descendants 

-

185 :param filter: Filter (include) the descendants nodes with the provided Q filter 

-

186 """ 

-

187 descendants = Q() 

-

188 nodes = queryset.values('path', 'depth') 

-

189 for node in nodes: 

-

190 descendants |= Q(path__startswith=node['path'], depth__gt=node['depth']) 

-

191 

-

192 return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants) 

-

193 

-

194 def include_ancestors(queryset=None): 

-

195 """ 

-

196 :param queryset: Model Queryset to add ancestors 

-

197 :param filter: Filter (include) the ancestors nodes with the provided Q filter 

-

198 """ 

-

199 

-

200 queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen)) 

-

201 nodes = list(set(queryset.values_list('root', 'depth'))) 

-

202 

-

203 ancestors = Q() 

-

204 for node in nodes: 

-

205 ancestors |= Q(path__startswith=node[0], depth__lt=node[1]) 

-

206 return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors) 

-

207 

-

208 class Meta: 

-

209 abstract = True 

-

210 

-

211 

-

212class PermissionModelMixin: 

-

213 @staticmethod 

-

214 def get_space_key(): 

-

215 return ('space',) 

-

216 

-

217 def get_space_kwarg(self): 

-

218 return '__'.join(self.get_space_key()) 

-

219 

-

220 def get_owner(self): 

-

221 if getattr(self, 'created_by', None): 

-

222 return self.created_by 

-

223 if getattr(self, 'user', None): 

-

224 return self.user 

-

225 return None 

-

226 

-

227 def get_shared(self): 

-

228 if getattr(self, 'shared', None): 

-

229 return self.shared.all() 

-

230 return [] 

-

231 

-

232 def get_space(self): 

-

233 p = '.'.join(self.get_space_key()) 

-

234 try: 

-

235 if space := operator.attrgetter(p)(self): 

-

236 return space 

-

237 except AttributeError: 

-

238 raise NotImplementedError('get space for method not implemented and standard fields not available') 

-

239 

-

240 

-

241class FoodInheritField(models.Model, PermissionModelMixin): 

-

242 field = models.CharField(max_length=32, unique=True) 

-

243 name = models.CharField(max_length=64, unique=True) 

-

244 

-

245 def __str__(self): 

-

246 return _(self.name) 

-

247 

-

248 @staticmethod 

-

249 def get_name(self): 

-

250 return _(self.name) 

-

251 

-

252 

-

253class Space(ExportModelOperationsMixin('space'), models.Model): 

-

254 name = models.CharField(max_length=128, default='Default') 

-

255 image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image') 

-

256 created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True) 

-

257 created_at = models.DateTimeField(auto_now_add=True) 

-

258 message = models.CharField(max_length=512, default='', blank=True) 

-

259 max_recipes = models.IntegerField(default=0) 

-

260 max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.')) 

-

261 max_users = models.IntegerField(default=0) 

-

262 use_plural = models.BooleanField(default=True) 

-

263 allow_sharing = models.BooleanField(default=True) 

-

264 no_sharing_limit = models.BooleanField(default=False) 

-

265 demo = models.BooleanField(default=False) 

-

266 food_inherit = models.ManyToManyField(FoodInheritField, blank=True) 

-

267 

-

268 internal_note = models.TextField(blank=True, null=True) 

-

269 

-

270 def safe_delete(self): 

-

271 """ 

-

272 Safely deletes a space by deleting all objects belonging to the space first and then deleting the space itself 

-

273 """ 

-

274 CookLog.objects.filter(space=self).delete() 

-

275 ViewLog.objects.filter(space=self).delete() 

-

276 ImportLog.objects.filter(space=self).delete() 

-

277 BookmarkletImport.objects.filter(space=self).delete() 

-

278 CustomFilter.objects.filter(space=self).delete() 

-

279 

-

280 Comment.objects.filter(recipe__space=self).delete() 

-

281 Keyword.objects.filter(space=self).delete() 

-

282 Ingredient.objects.filter(space=self).delete() 

-

283 Food.objects.filter(space=self).delete() 

-

284 Unit.objects.filter(space=self).delete() 

-

285 Step.objects.filter(space=self).delete() 

-

286 NutritionInformation.objects.filter(space=self).delete() 

-

287 RecipeBookEntry.objects.filter(book__space=self).delete() 

-

288 RecipeBook.objects.filter(space=self).delete() 

-

289 MealType.objects.filter(space=self).delete() 

-

290 MealPlan.objects.filter(space=self).delete() 

-

291 ShareLink.objects.filter(space=self).delete() 

-

292 Recipe.objects.filter(space=self).delete() 

-

293 

-

294 RecipeImport.objects.filter(space=self).delete() 

-

295 SyncLog.objects.filter(sync__space=self).delete() 

-

296 Sync.objects.filter(space=self).delete() 

-

297 Storage.objects.filter(space=self).delete() 

-

298 

-

299 ShoppingListEntry.objects.filter(shoppinglist__space=self).delete() 

-

300 ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete() 

-

301 ShoppingList.objects.filter(space=self).delete() 

-

302 

-

303 SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete() 

-

304 SupermarketCategory.objects.filter(space=self).delete() 

-

305 Supermarket.objects.filter(space=self).delete() 

-

306 

-

307 InviteLink.objects.filter(space=self).delete() 

-

308 UserFile.objects.filter(space=self).delete() 

-

309 Automation.objects.filter(space=self).delete() 

-

310 self.delete() 

-

311 

-

312 def get_owner(self): 

-

313 return self.created_by 

-

314 

-

315 def get_space(self): 

-

316 return self 

-

317 

-

318 def __str__(self): 

-

319 return self.name 

-

320 

-

321 

-

322class UserPreference(models.Model, PermissionModelMixin): 

-

323 # Themes 

-

324 BOOTSTRAP = 'BOOTSTRAP' 

-

325 DARKLY = 'DARKLY' 

-

326 FLATLY = 'FLATLY' 

-

327 SUPERHERO = 'SUPERHERO' 

-

328 TANDOOR = 'TANDOOR' 

-

329 TANDOOR_DARK = 'TANDOOR_DARK' 

-

330 

-

331 THEMES = ( 

-

332 (TANDOOR, 'Tandoor'), 

-

333 (BOOTSTRAP, 'Bootstrap'), 

-

334 (DARKLY, 'Darkly'), 

-

335 (FLATLY, 'Flatly'), 

-

336 (SUPERHERO, 'Superhero'), 

-

337 (TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'), 

-

338 ) 

-

339 

-

340 # Nav colors 

-

341 PRIMARY = 'PRIMARY' 

-

342 SECONDARY = 'SECONDARY' 

-

343 SUCCESS = 'SUCCESS' 

-

344 INFO = 'INFO' 

-

345 WARNING = 'WARNING' 

-

346 DANGER = 'DANGER' 

-

347 LIGHT = 'LIGHT' 

-

348 DARK = 'DARK' 

-

349 

-

350 COLORS = ( 

-

351 (PRIMARY, 'Primary'), 

-

352 (SECONDARY, 'Secondary'), 

-

353 (SUCCESS, 'Success'), 

-

354 (INFO, 'Info'), 

-

355 (WARNING, 'Warning'), 

-

356 (DANGER, 'Danger'), 

-

357 (LIGHT, 'Light'), 

-

358 (DARK, 'Dark') 

-

359 ) 

-

360 

-

361 # Default Page 

-

362 SEARCH = 'SEARCH' 

-

363 PLAN = 'PLAN' 

-

364 BOOKS = 'BOOKS' 

-

365 

-

366 PAGES = ( 

-

367 (SEARCH, _('Search')), 

-

368 (PLAN, _('Meal-Plan')), 

-

369 (BOOKS, _('Books')), 

-

370 ) 

-

371 

-

372 user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) 

-

373 image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image') 

-

374 theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR) 

-

375 nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY) 

-

376 default_unit = models.CharField(max_length=32, default='g') 

-

377 use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT) 

-

378 use_kj = models.BooleanField(default=KJ_PREF_DEFAULT) 

-

379 default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH) 

-

380 plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default') 

-

381 shopping_share = models.ManyToManyField(User, blank=True, related_name='shopping_share') 

-

382 ingredient_decimals = models.IntegerField(default=2) 

-

383 comments = models.BooleanField(default=COMMENT_PREF_DEFAULT) 

-

384 shopping_auto_sync = models.IntegerField(default=5) 

-

385 sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT) 

-

386 mealplan_autoadd_shopping = models.BooleanField(default=False) 

-

387 mealplan_autoexclude_onhand = models.BooleanField(default=True) 

-

388 mealplan_autoinclude_related = models.BooleanField(default=True) 

-

389 shopping_add_onhand = models.BooleanField(default=False) 

-

390 filter_to_supermarket = models.BooleanField(default=False) 

-

391 left_handed = models.BooleanField(default=False) 

-

392 show_step_ingredients = models.BooleanField(default=True) 

-

393 default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4) 

-

394 shopping_recent_days = models.PositiveIntegerField(default=7) 

-

395 csv_delim = models.CharField(max_length=2, default=",") 

-

396 csv_prefix = models.CharField(max_length=10, blank=True, ) 

-

397 

-

398 created_at = models.DateTimeField(auto_now_add=True) 

-

399 objects = ScopedManager(space='space') 

-

400 

-

401 def __str__(self): 

-

402 return str(self.user) 

-

403 

-

404 

-

405class UserSpace(models.Model, PermissionModelMixin): 

-

406 user = models.ForeignKey(User, on_delete=models.CASCADE) 

-

407 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

408 groups = models.ManyToManyField(Group) 

-

409 

-

410 # there should always only be one active space although permission methods are written in such a way 

-

411 # that having more than one active space should just break certain parts of the application and not leak any data 

-

412 active = models.BooleanField(default=False) 

-

413 

-

414 invite_link = models.ForeignKey("InviteLink", on_delete=models.PROTECT, null=True, blank=True) 

-

415 internal_note = models.TextField(blank=True, null=True) 

-

416 

-

417 created_at = models.DateTimeField(auto_now_add=True) 

-

418 updated_at = models.DateTimeField(auto_now=True) 

-

419 

-

420 

-

421class Storage(models.Model, PermissionModelMixin): 

-

422 DROPBOX = 'DB' 

-

423 NEXTCLOUD = 'NEXTCLOUD' 

-

424 LOCAL = 'LOCAL' 

-

425 STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local')) 

-

426 

-

427 name = models.CharField(max_length=128) 

-

428 method = models.CharField( 

-

429 choices=STORAGE_TYPES, max_length=128, default=DROPBOX 

-

430 ) 

-

431 username = models.CharField(max_length=128, blank=True, null=True) 

-

432 password = models.CharField(max_length=128, blank=True, null=True) 

-

433 token = models.CharField(max_length=512, blank=True, null=True) 

-

434 url = models.URLField(blank=True, null=True) 

-

435 path = models.CharField(blank=True, default='', max_length=256) 

-

436 created_by = models.ForeignKey(User, on_delete=models.PROTECT) 

-

437 

-

438 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

439 objects = ScopedManager(space='space') 

-

440 

-

441 def __str__(self): 

-

442 return self.name 

-

443 

-

444 

-

445class Sync(models.Model, PermissionModelMixin): 

-

446 storage = models.ForeignKey(Storage, on_delete=models.PROTECT) 

-

447 path = models.CharField(max_length=512, default="") 

-

448 active = models.BooleanField(default=True) 

-

449 last_checked = models.DateTimeField(null=True) 

-

450 created_at = models.DateTimeField(auto_now_add=True) 

-

451 updated_at = models.DateTimeField(auto_now=True) 

-

452 

-

453 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

454 objects = ScopedManager(space='space') 

-

455 

-

456 def __str__(self): 

-

457 return self.path 

-

458 

-

459 

-

460class SupermarketCategory(models.Model, PermissionModelMixin): 

-

461 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) 

-

462 description = models.TextField(blank=True, null=True) 

-

463 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

464 

-

465 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

466 objects = ScopedManager(space='space') 

-

467 

-

468 def __str__(self): 

-

469 return self.name 

-

470 

-

471 class Meta: 

-

472 constraints = [ 

-

473 models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'), 

-

474 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space') 

-

475 ] 

-

476 

-

477 

-

478class Supermarket(models.Model, PermissionModelMixin): 

-

479 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) 

-

480 description = models.TextField(blank=True, null=True) 

-

481 categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation') 

-

482 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

483 

-

484 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

485 objects = ScopedManager(space='space') 

-

486 

-

487 def __str__(self): 

-

488 return self.name 

-

489 

-

490 class Meta: 

-

491 constraints = [ 

-

492 models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'), 

-

493 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space') 

-

494 ] 

-

495 

-

496 

-

497class SupermarketCategoryRelation(models.Model, PermissionModelMixin): 

-

498 supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket') 

-

499 category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket') 

-

500 order = models.IntegerField(default=0) 

-

501 

-

502 objects = ScopedManager(space='supermarket__space') 

-

503 

-

504 @staticmethod 

-

505 def get_space_key(): 

-

506 return 'supermarket', 'space' 

-

507 

-

508 class Meta: 

-

509 constraints = [ 

-

510 models.UniqueConstraint(fields=['supermarket', 'category'], name='unique_sm_category_relation') 

-

511 ] 

-

512 ordering = ('order',) 

-

513 

-

514 

-

515class SyncLog(models.Model, PermissionModelMixin): 

-

516 sync = models.ForeignKey(Sync, on_delete=models.CASCADE) 

-

517 status = models.CharField(max_length=32) 

-

518 msg = models.TextField(default="") 

-

519 created_at = models.DateTimeField(auto_now_add=True) 

-

520 

-

521 objects = ScopedManager(space='sync__space') 

-

522 

-

523 def __str__(self): 

-

524 return f"{self.created_at}:{self.sync} - {self.status}" 

-

525 

-

526 

-

527class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin): 

-

528 if SORT_TREE_BY_NAME: 

-

529 node_order_by = ['name'] 

-

530 name = models.CharField(max_length=64) 

-

531 description = models.TextField(default="", blank=True) 

-

532 created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate 

-

533 updated_at = models.DateTimeField(auto_now=True) # TODO deprecate 

-

534 

-

535 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

536 objects = ScopedManager(space='space', _manager_class=TreeManager) 

-

537 

-

538 class Meta: 

-

539 constraints = [ 

-

540 models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space') 

-

541 ] 

-

542 indexes = (Index(fields=['id', 'name']),) 

-

543 

-

544 

-

545class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin): 

-

546 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) 

-

547 plural_name = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

548 description = models.TextField(blank=True, null=True) 

-

549 base_unit = models.TextField(max_length=256, null=True, blank=True, default=None) 

-

550 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

551 

-

552 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

553 objects = ScopedManager(space='space') 

-

554 

-

555 def __str__(self): 

-

556 return self.name 

-

557 

-

558 class Meta: 

-

559 constraints = [ 

-

560 models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'), 

-

561 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space') 

-

562 ] 

-

563 

-

564 

-

565class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): 

-

566 # TODO when savings a food as substitute children - assume children and descednants are also substitutes for siblings 

-

567 # exclude fields not implemented yet 

-

568 inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', ]) 

-

569 # TODO add inherit children_inherit, parent_inherit, Do Not Inherit 

-

570 

-

571 # WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals 

-

572 if SORT_TREE_BY_NAME: 

-

573 node_order_by = ['name'] 

-

574 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) 

-

575 plural_name = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

576 recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) 

-

577 url = models.CharField(max_length=1024, blank=True, null=True, default='') 

-

578 supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field 

-

579 ignore_shopping = models.BooleanField(default=False) # inherited field 

-

580 onhand_users = models.ManyToManyField(User, blank=True) 

-

581 description = models.TextField(default='', blank=True) 

-

582 inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) 

-

583 substitute = models.ManyToManyField("self", blank=True) 

-

584 substitute_siblings = models.BooleanField(default=False) 

-

585 substitute_children = models.BooleanField(default=False) 

-

586 child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit') 

-

587 

-

588 properties = models.ManyToManyField("Property", blank=True, through='FoodProperty') 

-

589 properties_food_amount = models.DecimalField(default=100, max_digits=16, decimal_places=2, blank=True) 

-

590 properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True) 

-

591 

-

592 preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit') 

-

593 preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit') 

-

594 fdc_id = models.IntegerField(null=True, default=None, blank=True) 

-

595 

-

596 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

597 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

598 objects = ScopedManager(space='space', _manager_class=TreeManager) 

-

599 

-

600 def __str__(self): 

-

601 return self.name 

-

602 

-

603 def delete(self): 

-

604 if self.ingredient_set.all().exclude(step=None).count() > 0: 

-

605 raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None)) 

-

606 else: 

-

607 return super().delete() 

-

608 

-

609 # MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal 

-

610 

-

611 def move(self, *args, **kwargs): 

-

612 super().move(*args, **kwargs) 

-

613 # treebeard bypasses ORM, need to explicity save to trigger post save signals retrieve the object again to avoid writing previous state back to disk 

-

614 obj = self.__class__.objects.get(id=self.id) 

-

615 if parent := obj.get_parent(): 

-

616 # child should inherit what the parent defines it should inherit 

-

617 fields = list(parent.child_inherit_fields.all() or parent.inherit_fields.all()) 

-

618 if len(fields) > 0: 

-

619 obj.inherit_fields.set(fields) 

-

620 obj.save() 

-

621 

-

622 @staticmethod 

-

623 def reset_inheritance(space=None, food=None): 

-

624 # resets inherited fields to the space defaults and updates all inherited fields to root object values 

-

625 if food: 

-

626 # if child inherit fields is preset children should be set to that, otherwise inherit this foods inherited fields 

-

627 inherit = list((food.child_inherit_fields.all() or food.inherit_fields.all()).values('id', 'field')) 

-

628 tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth + 1) 

-

629 else: 

-

630 inherit = list(space.food_inherit.all().values('id', 'field')) 

-

631 tree_filter = Q(space=space) 

-

632 

-

633 # remove all inherited fields from food 

-

634 trough = Food.inherit_fields.through 

-

635 trough.objects.all().delete() 

-

636 

-

637 # food is going to inherit attributes 

-

638 if len(inherit) > 0: 

-

639 # ManyToMany cannot be updated through an UPDATE operation 

-

640 for i in inherit: 

-

641 trough.objects.bulk_create([ 

-

642 trough(food_id=x, foodinheritfield_id=i['id']) 

-

643 for x in Food.objects.filter(tree_filter).values_list('id', flat=True) 

-

644 ]) 

-

645 

-

646 inherit = [x['field'] for x in inherit] 

-

647 for field in ['ignore_shopping', 'substitute_children', 'substitute_siblings']: 

-

648 if field in inherit: 

-

649 if food and getattr(food, field, None): 

-

650 food.get_descendants().update(**{f"{field}": True}) 

-

651 elif food and not getattr(food, field, True): 

-

652 food.get_descendants().update(**{f"{field}": False}) 

-

653 else: 

-

654 # get food at root that have children that need updated 

-

655 Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": True}, space=space)).update(**{f"{field}": True}) 

-

656 Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": False}, space=space)).update(**{f"{field}": False}) 

-

657 

-

658 if 'supermarket_category' in inherit: 

-

659 # when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants 

-

660 if food and food.supermarket_category: 

-

661 food.get_descendants().update(supermarket_category=food.supermarket_category) 

-

662 elif food is None: 

-

663 # find top node that has category set 

-

664 category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space)) 

-

665 for root in category_roots: 

-

666 root.get_descendants().update(supermarket_category=root.supermarket_category) 

-

667 

-

668 class Meta: 

-

669 constraints = [ 

-

670 models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space'), 

-

671 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='food_unique_open_data_slug_per_space') 

-

672 ] 

-

673 indexes = ( 

-

674 Index(fields=['id']), 

-

675 Index(fields=['name']), 

-

676 ) 

-

677 

-

678 

-

679class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin): 

-

680 base_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

681 base_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_base_relation') 

-

682 converted_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

683 converted_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_converted_relation') 

-

684 

-

685 food = models.ForeignKey('Food', on_delete=models.CASCADE, null=True, blank=True) 

-

686 

-

687 created_by = models.ForeignKey(User, on_delete=models.PROTECT) 

-

688 created_at = models.DateTimeField(auto_now_add=True) 

-

689 updated_at = models.DateTimeField(auto_now=True) 

-

690 

-

691 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

692 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

693 objects = ScopedManager(space='space') 

-

694 

-

695 def __str__(self): 

-

696 return f'{self.base_amount} {self.base_unit} -> {self.converted_amount} {self.converted_unit} {self.food}' 

-

697 

-

698 class Meta: 

-

699 constraints = [ 

-

700 models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'), 

-

701 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space') 

-

702 ] 

-

703 

-

704 

-

705class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin): 

-

706 # delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete 

-

707 food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True) 

-

708 unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True) 

-

709 amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

710 note = models.CharField(max_length=256, null=True, blank=True) 

-

711 is_header = models.BooleanField(default=False) 

-

712 no_amount = models.BooleanField(default=False) 

-

713 always_use_plural_unit = models.BooleanField(default=False) 

-

714 always_use_plural_food = models.BooleanField(default=False) 

-

715 order = models.IntegerField(default=0) 

-

716 original_text = models.CharField(max_length=512, null=True, blank=True, default=None) 

-

717 

-

718 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

719 objects = ScopedManager(space='space') 

-

720 

-

721 class Meta: 

-

722 ordering = ['order', 'pk'] 

-

723 indexes = ( 

-

724 Index(fields=['id']), 

-

725 ) 

-

726 

-

727 

-

728class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin): 

-

729 name = models.CharField(max_length=128, default='', blank=True) 

-

730 instruction = models.TextField(blank=True) 

-

731 ingredients = models.ManyToManyField(Ingredient, blank=True) 

-

732 time = models.IntegerField(default=0, blank=True) 

-

733 order = models.IntegerField(default=0) 

-

734 file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True) 

-

735 show_as_header = models.BooleanField(default=True) 

-

736 show_ingredients_table = models.BooleanField(default=True) 

-

737 search_vector = SearchVectorField(null=True) 

-

738 step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT) 

-

739 

-

740 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

741 objects = ScopedManager(space='space') 

-

742 

-

743 def get_instruction_render(self): 

-

744 from cookbook.helper.template_helper import render_instructions 

-

745 return render_instructions(self) 

-

746 

-

747 def __str__(self): 

-

748 return f'{self.pk} {self.name}' 

-

749 

-

750 class Meta: 

-

751 ordering = ['order', 'pk'] 

-

752 indexes = (GinIndex(fields=["search_vector"]),) 

-

753 

-

754 

-

755class PropertyType(models.Model, PermissionModelMixin): 

-

756 NUTRITION = 'NUTRITION' 

-

757 ALLERGEN = 'ALLERGEN' 

-

758 PRICE = 'PRICE' 

-

759 GOAL = 'GOAL' 

-

760 OTHER = 'OTHER' 

-

761 

-

762 name = models.CharField(max_length=128) 

-

763 unit = models.CharField(max_length=64, blank=True, null=True) 

-

764 order = models.IntegerField(default=0) 

-

765 description = models.CharField(max_length=512, blank=True, null=True) 

-

766 category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), 

-

767 (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True) 

-

768 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) 

-

769 

-

770 fdc_id = models.IntegerField(null=True, default=None, blank=True) 

-

771 # TODO show if empty property? 

-

772 # TODO formatting property? 

-

773 

-

774 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

775 objects = ScopedManager(space='space') 

-

776 

-

777 def __str__(self): 

-

778 return f'{self.name}' 

-

779 

-

780 class Meta: 

-

781 constraints = [ 

-

782 models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'), 

-

783 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space') 

-

784 ] 

-

785 ordering = ('order',) 

-

786 

-

787 

-

788class Property(models.Model, PermissionModelMixin): 

-

789 property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32) 

-

790 property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT) 

-

791 

-

792 import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project 

-

793 

-

794 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

795 objects = ScopedManager(space='space') 

-

796 

-

797 def __str__(self): 

-

798 return f'{self.property_amount} {self.property_type.unit} {self.property_type.name}' 

-

799 

-

800 class Meta: 

-

801 constraints = [ 

-

802 models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space') 

-

803 ] 

-

804 

-

805 

-

806class FoodProperty(models.Model): 

-

807 food = models.ForeignKey(Food, on_delete=models.CASCADE) 

-

808 property = models.ForeignKey(Property, on_delete=models.CASCADE) 

-

809 

-

810 class Meta: 

-

811 constraints = [ 

-

812 models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'), 

-

813 ] 

-

814 

-

815 

-

816class NutritionInformation(models.Model, PermissionModelMixin): 

-

817 fats = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

818 carbohydrates = models.DecimalField( 

-

819 default=0, decimal_places=16, max_digits=32 

-

820 ) 

-

821 proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

822 calories = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

823 source = models.CharField(max_length=512, default="", null=True, blank=True) 

-

824 

-

825 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

826 objects = ScopedManager(space='space') 

-

827 

-

828 def __str__(self): 

-

829 return f'Nutrition {self.pk}' 

-

830 

-

831 

-

832class RecipeManager(models.Manager.from_queryset(models.QuerySet)): 

-

833 def get_queryset(self): 

-

834 return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at')) 

-

835 

-

836 

-

837class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin): 

-

838 name = models.CharField(max_length=128) 

-

839 description = models.CharField(max_length=512, blank=True, null=True) 

-

840 servings = models.IntegerField(default=1) 

-

841 servings_text = models.CharField(default='', blank=True, max_length=32) 

-

842 image = models.ImageField(upload_to='recipes/', blank=True, null=True) 

-

843 storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True) 

-

844 file_uid = models.CharField(max_length=256, default="", blank=True) 

-

845 file_path = models.CharField(max_length=512, default="", blank=True) 

-

846 link = models.CharField(max_length=512, null=True, blank=True) 

-

847 cors_link = models.CharField(max_length=1024, null=True, blank=True) 

-

848 keywords = models.ManyToManyField(Keyword, blank=True) 

-

849 steps = models.ManyToManyField(Step, blank=True) 

-

850 working_time = models.IntegerField(default=0) 

-

851 waiting_time = models.IntegerField(default=0) 

-

852 internal = models.BooleanField(default=False) 

-

853 nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE) 

-

854 properties = models.ManyToManyField(Property, blank=True) 

-

855 show_ingredient_overview = models.BooleanField(default=True) 

-

856 private = models.BooleanField(default=False) 

-

857 shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with') 

-

858 

-

859 source_url = models.CharField(max_length=1024, default=None, blank=True, null=True) 

-

860 created_by = models.ForeignKey(User, on_delete=models.PROTECT) 

-

861 created_at = models.DateTimeField(auto_now_add=True) 

-

862 updated_at = models.DateTimeField(auto_now=True) 

-

863 

-

864 name_search_vector = SearchVectorField(null=True) 

-

865 desc_search_vector = SearchVectorField(null=True) 

-

866 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

867 

-

868 objects = ScopedManager(space='space', _manager_class=RecipeManager) 

-

869 

-

870 def __str__(self): 

-

871 return self.name 

-

872 

-

873 def get_related_recipes(self, levels=1): 

-

874 # recipes for step recipe 

-

875 step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe')) 

-

876 # recipes for foods 

-

877 food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe')) 

-

878 related_recipes = Recipe.objects.filter(step_recipes | food_recipes) 

-

879 if levels == 1: 

-

880 return related_recipes 

-

881 

-

882 # this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?) 

-

883 # for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios 

-

884 sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe')) 

-

885 sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe')) 

-

886 return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes) 

-

887 

-

888 class Meta(): 

-

889 indexes = ( 

-

890 GinIndex(fields=["name_search_vector"]), 

-

891 GinIndex(fields=["desc_search_vector"]), 

-

892 Index(fields=['id']), 

-

893 Index(fields=['name']), 

-

894 ) 

-

895 

-

896 

-

897class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin): 

-

898 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) 

-

899 text = models.TextField() 

-

900 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

901 created_at = models.DateTimeField(auto_now_add=True) 

-

902 updated_at = models.DateTimeField(auto_now=True) 

-

903 

-

904 objects = ScopedManager(space='recipe__space') 

-

905 

-

906 @staticmethod 

-

907 def get_space_key(): 

-

908 return 'recipe', 'space' 

-

909 

-

910 def get_space(self): 

-

911 return self.recipe.space 

-

912 

-

913 def __str__(self): 

-

914 return self.text 

-

915 

-

916 

-

917class RecipeImport(models.Model, PermissionModelMixin): 

-

918 name = models.CharField(max_length=128) 

-

919 storage = models.ForeignKey(Storage, on_delete=models.PROTECT) 

-

920 file_uid = models.CharField(max_length=256, default="") 

-

921 file_path = models.CharField(max_length=512, default="") 

-

922 created_at = models.DateTimeField(auto_now_add=True) 

-

923 

-

924 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

925 objects = ScopedManager(space='space') 

-

926 

-

927 def __str__(self): 

-

928 return self.name 

-

929 

-

930 

-

931class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin): 

-

932 name = models.CharField(max_length=128) 

-

933 description = models.TextField(blank=True) 

-

934 shared = models.ManyToManyField(User, blank=True, related_name='shared_with') 

-

935 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

936 filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL) 

-

937 

-

938 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

939 objects = ScopedManager(space='space') 

-

940 

-

941 def __str__(self): 

-

942 return self.name 

-

943 

-

944 class Meta(): 

-

945 indexes = (Index(fields=['name']),) 

-

946 

-

947 

-

948class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin): 

-

949 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) 

-

950 book = models.ForeignKey(RecipeBook, on_delete=models.CASCADE) 

-

951 

-

952 objects = ScopedManager(space='book__space') 

-

953 

-

954 @staticmethod 

-

955 def get_space_key(): 

-

956 return 'book', 'space' 

-

957 

-

958 def __str__(self): 

-

959 return self.recipe.name 

-

960 

-

961 def get_owner(self): 

-

962 try: 

-

963 return self.book.created_by 

-

964 except AttributeError: 

-

965 return None 

-

966 

-

967 class Meta: 

-

968 constraints = [ 

-

969 models.UniqueConstraint(fields=['recipe', 'book'], name='rbe_unique_name_per_space') 

-

970 ] 

-

971 

-

972 

-

973class MealType(models.Model, PermissionModelMixin): 

-

974 name = models.CharField(max_length=128) 

-

975 order = models.IntegerField(default=0) 

-

976 color = models.CharField(max_length=7, blank=True, null=True) 

-

977 default = models.BooleanField(default=False, blank=True) 

-

978 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

979 

-

980 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

981 objects = ScopedManager(space='space') 

-

982 

-

983 def __str__(self): 

-

984 return self.name 

-

985 

-

986 class Meta: 

-

987 constraints = [ 

-

988 models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'), 

-

989 ] 

-

990 

-

991 

-

992class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin): 

-

993 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True) 

-

994 servings = models.DecimalField(default=1, max_digits=8, decimal_places=4) 

-

995 title = models.CharField(max_length=64, blank=True, default='') 

-

996 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

997 shared = models.ManyToManyField(User, blank=True, related_name='plan_share') 

-

998 meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE) 

-

999 note = models.TextField(blank=True) 

-

1000 from_date = models.DateField() 

-

1001 to_date = models.DateField() 

-

1002 

-

1003 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1004 objects = ScopedManager(space='space') 

-

1005 

-

1006 def get_label(self): 

-

1007 if self.title: 

-

1008 return self.title 

-

1009 return str(self.recipe) 

-

1010 

-

1011 def get_meal_name(self): 

-

1012 return self.meal_type.name 

-

1013 

-

1014 def __str__(self): 

-

1015 return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}' 

-

1016 

-

1017 

-

1018class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin): 

-

1019 name = models.CharField(max_length=32, blank=True, default='') 

-

1020 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated 

-

1021 servings = models.DecimalField(default=1, max_digits=8, decimal_places=4) 

-

1022 mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True) 

-

1023 

-

1024 objects = ScopedManager(space='recipe__space') 

-

1025 

-

1026 @staticmethod 

-

1027 def get_space_key(): 

-

1028 return 'recipe', 'space' 

-

1029 

-

1030 def get_space(self): 

-

1031 return self.recipe.space 

-

1032 

-

1033 def __str__(self): 

-

1034 return f'Shopping list recipe {self.id} - {self.recipe}' 

-

1035 

-

1036 def get_owner(self): 

-

1037 try: 

-

1038 return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None) 

-

1039 except AttributeError: 

-

1040 return None 

-

1041 

-

1042 

-

1043class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): 

-

1044 list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') 

-

1045 food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries') 

-

1046 unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True) 

-

1047 ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True) 

-

1048 amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) 

-

1049 order = models.IntegerField(default=0) 

-

1050 checked = models.BooleanField(default=False) 

-

1051 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1052 created_at = models.DateTimeField(auto_now_add=True) 

-

1053 completed_at = models.DateTimeField(null=True, blank=True) 

-

1054 delay_until = models.DateTimeField(null=True, blank=True) 

-

1055 

-

1056 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1057 objects = ScopedManager(space='space') 

-

1058 

-

1059 @staticmethod 

-

1060 def get_space_key(): 

-

1061 return 'shoppinglist', 'space' 

-

1062 

-

1063 def get_space(self): 

-

1064 return self.shoppinglist_set.first().space 

-

1065 

-

1066 def __str__(self): 

-

1067 return f'Shopping list entry {self.id}' 

-

1068 

-

1069 def get_shared(self): 

-

1070 try: 

-

1071 return self.shoppinglist_set.first().shared.all() 

-

1072 except AttributeError: 

-

1073 return self.created_by.userpreference.shopping_share.all() 

-

1074 

-

1075 def get_owner(self): 

-

1076 try: 

-

1077 return self.created_by or self.shoppinglist_set.first().created_by 

-

1078 except AttributeError: 

-

1079 return None 

-

1080 

-

1081 

-

1082class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin): 

-

1083 uuid = models.UUIDField(default=uuid.uuid4) 

-

1084 note = models.TextField(blank=True, null=True) 

-

1085 recipes = models.ManyToManyField(ShoppingListRecipe, blank=True) 

-

1086 entries = models.ManyToManyField(ShoppingListEntry, blank=True) 

-

1087 shared = models.ManyToManyField(User, blank=True, related_name='list_share') 

-

1088 supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL) 

-

1089 finished = models.BooleanField(default=False) 

-

1090 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1091 created_at = models.DateTimeField(auto_now_add=True) 

-

1092 

-

1093 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1094 objects = ScopedManager(space='space') 

-

1095 

-

1096 def __str__(self): 

-

1097 return f'Shopping list {self.id}' 

-

1098 

-

1099 def get_shared(self): 

-

1100 try: 

-

1101 return self.shared.all() or self.created_by.userpreference.shopping_share.all() 

-

1102 except AttributeError: 

-

1103 return [] 

-

1104 

-

1105 

-

1106class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin): 

-

1107 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) 

-

1108 uuid = models.UUIDField(default=uuid.uuid4) 

-

1109 request_count = models.IntegerField(default=0) 

-

1110 abuse_blocked = models.BooleanField(default=False) 

-

1111 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1112 created_at = models.DateTimeField(auto_now_add=True) 

-

1113 

-

1114 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1115 objects = ScopedManager(space='space') 

-

1116 

-

1117 def __str__(self): 

-

1118 return f'{self.recipe} - {self.uuid}' 

-

1119 

-

1120 

-

1121def default_valid_until(): 

-

1122 return date.today() + timedelta(days=14) 

-

1123 

-

1124 

-

1125class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin): 

-

1126 uuid = models.UUIDField(default=uuid.uuid4) 

-

1127 email = models.EmailField(blank=True) 

-

1128 group = models.ForeignKey(Group, on_delete=models.CASCADE) 

-

1129 valid_until = models.DateField(default=default_valid_until) 

-

1130 used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by') 

-

1131 reusable = models.BooleanField(default=False) 

-

1132 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1133 created_at = models.DateTimeField(auto_now_add=True) 

-

1134 

-

1135 internal_note = models.TextField(blank=True, null=True) 

-

1136 

-

1137 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1138 objects = ScopedManager(space='space') 

-

1139 

-

1140 def __str__(self): 

-

1141 return f'{self.uuid}' 

-

1142 

-

1143 

-

1144class TelegramBot(models.Model, PermissionModelMixin): 

-

1145 token = models.CharField(max_length=256) 

-

1146 name = models.CharField(max_length=128, default='', blank=True) 

-

1147 chat_id = models.CharField(max_length=128, default='', blank=True) 

-

1148 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1149 webhook_token = models.UUIDField(default=uuid.uuid4) 

-

1150 

-

1151 objects = ScopedManager(space='space') 

-

1152 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1153 

-

1154 def __str__(self): 

-

1155 return f"{self.name}" 

-

1156 

-

1157 

-

1158class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin): 

-

1159 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) 

-

1160 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1161 created_at = models.DateTimeField(default=timezone.now) 

-

1162 rating = models.IntegerField(null=True) 

-

1163 servings = models.IntegerField(default=0) 

-

1164 

-

1165 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1166 objects = ScopedManager(space='space') 

-

1167 

-

1168 def __str__(self): 

-

1169 return self.recipe.name 

-

1170 

-

1171 class Meta(): 

-

1172 indexes = ( 

-

1173 Index(fields=['id']), 

-

1174 Index(fields=['recipe']), 

-

1175 Index(fields=['-created_at']), 

-

1176 Index(fields=['rating']), 

-

1177 Index(fields=['created_by']), 

-

1178 Index(fields=['created_by', 'rating']), 

-

1179 ) 

-

1180 

-

1181 

-

1182class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin): 

-

1183 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) 

-

1184 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1185 created_at = models.DateTimeField(auto_now_add=True) 

-

1186 

-

1187 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1188 objects = ScopedManager(space='space') 

-

1189 

-

1190 def __str__(self): 

-

1191 return self.recipe.name 

-

1192 

-

1193 class Meta(): 

-

1194 indexes = ( 

-

1195 Index(fields=['recipe']), 

-

1196 Index(fields=['-created_at']), 

-

1197 Index(fields=['created_by']), 

-

1198 Index(fields=['recipe', '-created_at', 'created_by']), 

-

1199 ) 

-

1200 

-

1201 

-

1202class ImportLog(models.Model, PermissionModelMixin): 

-

1203 type = models.CharField(max_length=32) 

-

1204 running = models.BooleanField(default=True) 

-

1205 msg = models.TextField(default="") 

-

1206 keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL) 

-

1207 

-

1208 total_recipes = models.IntegerField(default=0) 

-

1209 imported_recipes = models.IntegerField(default=0) 

-

1210 

-

1211 created_at = models.DateTimeField(auto_now_add=True) 

-

1212 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1213 

-

1214 objects = ScopedManager(space='space') 

-

1215 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1216 

-

1217 def __str__(self): 

-

1218 return f"{self.created_at}:{self.type}" 

-

1219 

-

1220 

-

1221class ExportLog(models.Model, PermissionModelMixin): 

-

1222 type = models.CharField(max_length=32) 

-

1223 running = models.BooleanField(default=True) 

-

1224 msg = models.TextField(default="") 

-

1225 

-

1226 total_recipes = models.IntegerField(default=0) 

-

1227 exported_recipes = models.IntegerField(default=0) 

-

1228 cache_duration = models.IntegerField(default=0) 

-

1229 possibly_not_expired = models.BooleanField(default=True) 

-

1230 

-

1231 created_at = models.DateTimeField(auto_now_add=True) 

-

1232 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1233 

-

1234 objects = ScopedManager(space='space') 

-

1235 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1236 

-

1237 def __str__(self): 

-

1238 return f"{self.created_at}:{self.type}" 

-

1239 

-

1240 

-

1241class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin): 

-

1242 html = models.TextField() 

-

1243 url = models.CharField(max_length=256, null=True, blank=True) 

-

1244 created_at = models.DateTimeField(auto_now_add=True) 

-

1245 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1246 

-

1247 objects = ScopedManager(space='space') 

-

1248 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1249 

-

1250 

-

1251# field names used to configure search behavior - all data populated during data migration 

-

1252# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield 

-

1253class SearchFields(models.Model, PermissionModelMixin): 

-

1254 name = models.CharField(max_length=32, unique=True) 

-

1255 field = models.CharField(max_length=64, unique=True) 

-

1256 

-

1257 def __str__(self): 

-

1258 return _(self.name) 

-

1259 

-

1260 @staticmethod 

-

1261 def get_name(self): 

-

1262 return _(self.name) 

-

1263 

-

1264 

-

1265class SearchPreference(models.Model, PermissionModelMixin): 

-

1266 # Search Style (validation parsleyjs.org) 

-

1267 # phrase or plain or raw (websearch and trigrams are mutually exclusive) 

-

1268 SIMPLE = 'plain' 

-

1269 PHRASE = 'phrase' 

-

1270 WEB = 'websearch' 

-

1271 RAW = 'raw' 

-

1272 SEARCH_STYLE = ( 

-

1273 (SIMPLE, _('Simple')), 

-

1274 (PHRASE, _('Phrase')), 

-

1275 (WEB, _('Web')), 

-

1276 (RAW, _('Raw')) 

-

1277 ) 

-

1278 

-

1279 user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) 

-

1280 search = models.CharField(choices=SEARCH_STYLE, max_length=32, default=SIMPLE) 

-

1281 

-

1282 lookup = models.BooleanField(default=False) 

-

1283 unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True) 

-

1284 icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True) 

-

1285 istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True) 

-

1286 trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True) 

-

1287 fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True) 

-

1288 trigram_threshold = models.DecimalField(default=0.2, decimal_places=2, max_digits=3) 

-

1289 

-

1290 

-

1291class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin): 

-

1292 name = models.CharField(max_length=128) 

-

1293 file = models.FileField(upload_to='files/') 

-

1294 file_size_kb = models.IntegerField(default=0, blank=True) 

-

1295 created_at = models.DateTimeField(auto_now_add=True) 

-

1296 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1297 

-

1298 objects = ScopedManager(space='space') 

-

1299 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1300 

-

1301 def is_image(self): 

-

1302 try: 

-

1303 Image.open(self.file.file.file) 

-

1304 return True 

-

1305 except Exception: 

-

1306 return False 

-

1307 

-

1308 def save(self, *args, **kwargs): 

-

1309 if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile): 

-

1310 self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix 

-

1311 self.file_size_kb = round(self.file.size / 1000) 

-

1312 super(UserFile, self).save(*args, **kwargs) 

-

1313 

-

1314 

-

1315class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin): 

-

1316 FOOD_ALIAS = 'FOOD_ALIAS' 

-

1317 UNIT_ALIAS = 'UNIT_ALIAS' 

-

1318 KEYWORD_ALIAS = 'KEYWORD_ALIAS' 

-

1319 DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE' 

-

1320 INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE' 

-

1321 NEVER_UNIT = 'NEVER_UNIT' 

-

1322 TRANSPOSE_WORDS = 'TRANSPOSE_WORDS' 

-

1323 FOOD_REPLACE = 'FOOD_REPLACE' 

-

1324 UNIT_REPLACE = 'UNIT_REPLACE' 

-

1325 NAME_REPLACE = 'NAME_REPLACE' 

-

1326 

-

1327 type = models.CharField(max_length=128, 

-

1328 choices=( 

-

1329 (FOOD_ALIAS, _('Food Alias')), 

-

1330 (UNIT_ALIAS, _('Unit Alias')), 

-

1331 (KEYWORD_ALIAS, _('Keyword Alias')), 

-

1332 (DESCRIPTION_REPLACE, _('Description Replace')), 

-

1333 (INSTRUCTION_REPLACE, _('Instruction Replace')), 

-

1334 (NEVER_UNIT, _('Never Unit')), 

-

1335 (TRANSPOSE_WORDS, _('Transpose Words')), 

-

1336 (FOOD_REPLACE, _('Food Replace')), 

-

1337 (UNIT_REPLACE, _('Unit Replace')), 

-

1338 (NAME_REPLACE, _('Name Replace')), 

-

1339 )) 

-

1340 name = models.CharField(max_length=128, default='') 

-

1341 description = models.TextField(blank=True, null=True) 

-

1342 

-

1343 param_1 = models.CharField(max_length=128, blank=True, null=True) 

-

1344 param_2 = models.CharField(max_length=128, blank=True, null=True) 

-

1345 param_3 = models.CharField(max_length=128, blank=True, null=True) 

-

1346 

-

1347 order = models.IntegerField(default=1000) 

-

1348 

-

1349 disabled = models.BooleanField(default=False) 

-

1350 

-

1351 updated_at = models.DateTimeField(auto_now=True) 

-

1352 created_at = models.DateTimeField(auto_now_add=True) 

-

1353 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1354 

-

1355 objects = ScopedManager(space='space') 

-

1356 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1357 

-

1358 

-

1359class CustomFilter(models.Model, PermissionModelMixin): 

-

1360 RECIPE = 'RECIPE' 

-

1361 FOOD = 'FOOD' 

-

1362 KEYWORD = 'KEYWORD' 

-

1363 

-

1364 MODELS = ( 

-

1365 (RECIPE, _('Recipe')), 

-

1366 (FOOD, _('Food')), 

-

1367 (KEYWORD, _('Keyword')), 

-

1368 ) 

-

1369 

-

1370 name = models.CharField(max_length=128, null=False, blank=False) 

-

1371 type = models.CharField(max_length=128, choices=(MODELS), default=MODELS[0]) 

-

1372 # could use JSONField, but requires installing extension on SQLite, don't need to search the objects, so seems unecessary 

-

1373 search = models.TextField(blank=False, null=False) 

-

1374 created_at = models.DateTimeField(auto_now_add=True) 

-

1375 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

-

1376 shared = models.ManyToManyField(User, blank=True, related_name='f_shared_with') 

-

1377 

-

1378 objects = ScopedManager(space='space') 

-

1379 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

-

1380 

-

1381 def __str__(self): 

-

1382 return self.name 

-

1383 

-

1384 class Meta: 

-

1385 constraints = [ 

-

1386 models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space') 

-

1387 ] 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_schemas_py.html b/docs/coverage/d_a167ab5b5108d61e_schemas_py.html deleted file mode 100644 index 080de5b14e..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_schemas_py.html +++ /dev/null @@ -1,166 +0,0 @@ - - - - - Coverage for cookbook/schemas.py: 42% - - - - - -
-
-

- Coverage for cookbook/schemas.py: - 42% -

- -

- 36 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from rest_framework.schemas.openapi import AutoSchema 

-

2from rest_framework.schemas.utils import is_list_view 

-

3 

-

4 

-

5class QueryParam(object): 

-

6 def __init__(self, name, description=None, qtype='string', required=False): 

-

7 self.name = name 

-

8 self.description = description 

-

9 self.qtype = qtype 

-

10 self.required = required 

-

11 

-

12 def __str__(self): 

-

13 return f'{self.name}, {self.qtype}, {self.description}' 

-

14 

-

15 

-

16class QueryParamAutoSchema(AutoSchema): 

-

17 def get_path_parameters(self, path, method): 

-

18 if not is_list_view(path, method, self.view): 

-

19 return super().get_path_parameters(path, method) 

-

20 parameters = super().get_path_parameters(path, method) 

-

21 for q in self.view.query_params: 

-

22 parameters.append({ 

-

23 "name": q.name, "in": "query", "required": q.required, 

-

24 "description": q.description, 

-

25 'schema': {'type': q.qtype, }, 

-

26 }) 

-

27 

-

28 return parameters 

-

29 

-

30 

-

31class TreeSchema(AutoSchema): 

-

32 def get_path_parameters(self, path, method): 

-

33 if not is_list_view(path, method, self.view): 

-

34 return super(TreeSchema, self).get_path_parameters(path, method) 

-

35 

-

36 api_name = path.split('/')[2] 

-

37 parameters = super().get_path_parameters(path, method) 

-

38 parameters.append({ 

-

39 "name": 'query', "in": "query", "required": False, 

-

40 "description": 'Query string matched against {} name.'.format(api_name), 

-

41 'schema': {'type': 'string', }, 

-

42 }) 

-

43 parameters.append({ 

-

44 "name": 'root', "in": "query", "required": False, 

-

45 "description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format( 

-

46 obj=api_name), 

-

47 'schema': {'type': 'int', }, 

-

48 }) 

-

49 parameters.append({ 

-

50 "name": 'tree', "in": "query", "required": False, 

-

51 "description": 'Return all self and children of {} with ID [int].'.format(api_name), 

-

52 'schema': {'type': 'int', }, 

-

53 }) 

-

54 return parameters 

-

55 

-

56 

-

57class FilterSchema(AutoSchema): 

-

58 def get_path_parameters(self, path, method): 

-

59 if not is_list_view(path, method, self.view): 

-

60 return super(FilterSchema, self).get_path_parameters(path, method) 

-

61 

-

62 api_name = path.split('/')[2] 

-

63 parameters = super().get_path_parameters(path, method) 

-

64 parameters.append({ 

-

65 "name": 'query', "in": "query", "required": False, 

-

66 "description": 'Query string matched against {} name.'.format(api_name), 

-

67 'schema': {'type': 'string', }, 

-

68 }) 

-

69 return parameters 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_serializer_py.html b/docs/coverage/d_a167ab5b5108d61e_serializer_py.html deleted file mode 100644 index 72c203d82e..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_serializer_py.html +++ /dev/null @@ -1,1530 +0,0 @@ - - - - - Coverage for cookbook/serializer.py: 85% - - - - - -
-
-

- Coverage for cookbook/serializer.py: - 85% -

- -

- 939 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import traceback 

-

2import uuid 

-

3from datetime import datetime, timedelta 

-

4from decimal import Decimal 

-

5from gettext import gettext as _ 

-

6from html import escape 

-

7from smtplib import SMTPException 

-

8 

-

9from django.contrib.auth.models import AnonymousUser, Group, User 

-

10from django.core.cache import caches 

-

11from django.core.mail import send_mail 

-

12from django.db.models import Q, QuerySet, Sum 

-

13from django.http import BadHeaderError 

-

14from django.urls import reverse 

-

15from django.utils import timezone 

-

16from django_scopes import scopes_disabled 

-

17from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer 

-

18from oauth2_provider.models import AccessToken 

-

19from PIL import Image 

-

20from rest_framework import serializers 

-

21from rest_framework.exceptions import NotFound, ValidationError 

-

22from rest_framework.fields import IntegerField 

-

23 

-

24from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage 

-

25from cookbook.helper.HelperFunctions import str2bool 

-

26from cookbook.helper.permission_helper import above_space_limit 

-

27from cookbook.helper.property_helper import FoodPropertyHelper 

-

28from cookbook.helper.shopping_helper import RecipeShoppingEditor 

-

29from cookbook.helper.unit_conversion_helper import UnitConversionHelper 

-

30from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter, 

-

31 ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink, 

-

32 Keyword, MealPlan, MealType, NutritionInformation, Property, 

-

33 PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport, 

-

34 ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, 

-

35 Step, Storage, Supermarket, SupermarketCategory, 

-

36 SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, 

-

37 UserFile, UserPreference, UserSpace, ViewLog) 

-

38from cookbook.templatetags.custom_tags import markdown 

-

39from recipes.settings import AWS_ENABLED, MEDIA_URL 

-

40 

-

41 

-

42class ExtendedRecipeMixin(serializers.ModelSerializer): 

-

43 # adds image and recipe count to serializer when query param extended=1 

-

44 # ORM path to this object from Recipe 

-

45 recipe_filter = None 

-

46 # list of ORM paths to any image 

-

47 images = None 

-

48 

-

49 image = serializers.SerializerMethodField('get_image') 

-

50 numrecipe = serializers.ReadOnlyField(source='recipe_count') 

-

51 

-

52 def get_fields(self, *args, **kwargs): 

-

53 fields = super().get_fields(*args, **kwargs) 

-

54 try: 

-

55 api_serializer = self.context['view'].serializer_class 

-

56 except KeyError: 

-

57 api_serializer = None 

-

58 # extended values are computationally expensive and not needed in normal circumstances 

-

59 try: 

-

60 if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: 

-

61 return fields 

-

62 except (AttributeError, KeyError): 

-

63 pass 

-

64 try: 

-

65 del fields['image'] 

-

66 del fields['numrecipe'] 

-

67 except KeyError: 

-

68 pass 

-

69 return fields 

-

70 

-

71 def get_image(self, obj): 

-

72 if obj.recipe_image: 

-

73 if AWS_ENABLED: 

-

74 storage = CachedS3Boto3Storage() 

-

75 path = storage.url(obj.recipe_image) 

-

76 else: 

-

77 path = MEDIA_URL + obj.recipe_image 

-

78 return path 

-

79 

-

80 

-

81class OpenDataModelMixin(serializers.ModelSerializer): 

-

82 

-

83 def create(self, validated_data): 

-

84 if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': 

-

85 validated_data['open_data_slug'] = None 

-

86 return super().create(validated_data) 

-

87 

-

88 def update(self, instance, validated_data): 

-

89 if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': 

-

90 validated_data['open_data_slug'] = None 

-

91 return super().update(instance, validated_data) 

-

92 

-

93 

-

94class CustomDecimalField(serializers.Field): 

-

95 """ 

-

96 Custom decimal field to normalize useless decimal places 

-

97 and allow commas as decimal separators 

-

98 """ 

-

99 

-

100 def to_representation(self, value): 

-

101 if not isinstance(value, Decimal): 

-

102 value = Decimal(value) 

-

103 return round(value, 2).normalize() 

-

104 

-

105 def to_internal_value(self, data): 

-

106 if isinstance(data, int) or isinstance(data, float): 

-

107 return data 

-

108 elif isinstance(data, str): 

-

109 if data == '': 

-

110 return 0 

-

111 try: 

-

112 return float(data.replace(',', '.')) 

-

113 except ValueError: 

-

114 raise ValidationError('A valid number is required') 

-

115 

-

116 

-

117class CustomOnHandField(serializers.Field): 

-

118 def get_attribute(self, instance): 

-

119 return instance 

-

120 

-

121 def to_representation(self, obj): 

-

122 if not self.context["request"].user.is_authenticated: 

-

123 return [] 

-

124 shared_users = [] 

-

125 if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): 

-

126 shared_users = c 

-

127 else: 

-

128 try: 

-

129 shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ 

-

130 self.context['request'].user.id] 

-

131 caches['default'].set( 

-

132 f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', 

-

133 shared_users, timeout=5 * 60) 

-

134 # TODO ugly hack that improves API performance significantly, should be done properly 

-

135 except AttributeError: # Anonymous users (using share links) don't have shared users 

-

136 pass 

-

137 return obj.onhand_users.filter(id__in=shared_users).exists() 

-

138 

-

139 def to_internal_value(self, data): 

-

140 return data 

-

141 

-

142 

-

143class SpaceFilterSerializer(serializers.ListSerializer): 

-

144 

-

145 def to_representation(self, data): 

-

146 if self.context.get('request', None) is None: 

-

147 return 

-

148 if (isinstance(data, QuerySet) and data.query.is_sliced): 

-

149 # if query is sliced it came from api request not nested serializer 

-

150 return super().to_representation(data) 

-

151 if self.child.Meta.model == User: 

-

152 if isinstance(self.context['request'].user, AnonymousUser): 

-

153 data = [] 

-

154 else: 

-

155 data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all() 

-

156 else: 

-

157 data = data.filter(**{'__'.join(data.model.get_space_key()): self.context['request'].space}) 

-

158 return super().to_representation(data) 

-

159 

-

160 

-

161class UserSerializer(WritableNestedModelSerializer): 

-

162 display_name = serializers.SerializerMethodField('get_user_label') 

-

163 

-

164 def get_user_label(self, obj): 

-

165 return obj.get_user_display_name() 

-

166 

-

167 class Meta: 

-

168 list_serializer_class = SpaceFilterSerializer 

-

169 model = User 

-

170 fields = ('id', 'username', 'first_name', 'last_name', 'display_name') 

-

171 read_only_fields = ('username',) 

-

172 

-

173 

-

174class GroupSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): 

-

175 def create(self, validated_data): 

-

176 raise ValidationError('Cannot create using this endpoint') 

-

177 

-

178 def update(self, instance, validated_data): 

-

179 return instance # cannot update group 

-

180 

-

181 class Meta: 

-

182 model = Group 

-

183 fields = ('id', 'name') 

-

184 

-

185 

-

186class FoodInheritFieldSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): 

-

187 name = serializers.CharField(allow_null=True, allow_blank=True, required=False) 

-

188 field = serializers.CharField(allow_null=True, allow_blank=True, required=False) 

-

189 

-

190 def create(self, validated_data): 

-

191 raise ValidationError('Cannot create using this endpoint') 

-

192 

-

193 def update(self, instance, validated_data): 

-

194 return instance 

-

195 

-

196 class Meta: 

-

197 model = FoodInheritField 

-

198 fields = ('id', 'name', 'field',) 

-

199 read_only_fields = ['id'] 

-

200 

-

201 

-

202class UserFileSerializer(serializers.ModelSerializer): 

-

203 file = serializers.FileField(write_only=True) 

-

204 file_download = serializers.SerializerMethodField('get_download_link') 

-

205 preview = serializers.SerializerMethodField('get_preview_link') 

-

206 

-

207 def get_download_link(self, obj): 

-

208 return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk})) 

-

209 

-

210 def get_preview_link(self, obj): 

-

211 try: 

-

212 Image.open(obj.file.file.file) 

-

213 return self.context['request'].build_absolute_uri(obj.file.url) 

-

214 except Exception: 

-

215 traceback.print_exc() 

-

216 return "" 

-

217 

-

218 def check_file_limit(self, validated_data): 

-

219 if 'file' in validated_data: 

-

220 if self.context['request'].space.max_file_storage_mb == -1: 

-

221 raise ValidationError(_('File uploads are not enabled for this Space.')) 

-

222 

-

223 try: 

-

224 current_file_size_mb = \ 

-

225 UserFile.objects.filter(space=self.context['request'].space).aggregate(Sum('file_size_kb'))[ 

-

226 'file_size_kb__sum'] / 1000 

-

227 except TypeError: 

-

228 current_file_size_mb = 0 

-

229 

-

230 if ((validated_data['file'].size / 1000 / 1000 + current_file_size_mb - 5) 

-

231 > self.context['request'].space.max_file_storage_mb != 0): 

-

232 raise ValidationError(_('You have reached your file upload limit.')) 

-

233 

-

234 def create(self, validated_data): 

-

235 self.check_file_limit(validated_data) 

-

236 validated_data['created_by'] = self.context['request'].user 

-

237 validated_data['space'] = self.context['request'].space 

-

238 return super().create(validated_data) 

-

239 

-

240 def update(self, instance, validated_data): 

-

241 self.check_file_limit(validated_data) 

-

242 return super().update(instance, validated_data) 

-

243 

-

244 class Meta: 

-

245 model = UserFile 

-

246 fields = ('id', 'name', 'file', 'file_download', 'preview', 'file_size_kb') 

-

247 read_only_fields = ('id', 'file_size_kb') 

-

248 extra_kwargs = {"file": {"required": False, }} 

-

249 

-

250 

-

251class UserFileViewSerializer(serializers.ModelSerializer): 

-

252 file_download = serializers.SerializerMethodField('get_download_link') 

-

253 preview = serializers.SerializerMethodField('get_preview_link') 

-

254 

-

255 def get_download_link(self, obj): 

-

256 return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk})) 

-

257 

-

258 def get_preview_link(self, obj): 

-

259 try: 

-

260 Image.open(obj.file.file.file) 

-

261 return self.context['request'].build_absolute_uri(obj.file.url) 

-

262 except Exception: 

-

263 traceback.print_exc() 

-

264 return "" 

-

265 

-

266 def create(self, validated_data): 

-

267 raise ValidationError('Cannot create File over this view') 

-

268 

-

269 def update(self, instance, validated_data): 

-

270 return instance 

-

271 

-

272 class Meta: 

-

273 model = UserFile 

-

274 fields = ('id', 'name', 'file_download', 'preview') 

-

275 read_only_fields = ('id', 'file') 

-

276 

-

277 

-

278class SpaceSerializer(WritableNestedModelSerializer): 

-

279 user_count = serializers.SerializerMethodField('get_user_count') 

-

280 recipe_count = serializers.SerializerMethodField('get_recipe_count') 

-

281 file_size_mb = serializers.SerializerMethodField('get_file_size_mb') 

-

282 food_inherit = FoodInheritFieldSerializer(many=True) 

-

283 image = UserFileViewSerializer(required=False, many=False, allow_null=True) 

-

284 

-

285 def get_user_count(self, obj): 

-

286 return UserSpace.objects.filter(space=obj).count() 

-

287 

-

288 def get_recipe_count(self, obj): 

-

289 return Recipe.objects.filter(space=obj).count() 

-

290 

-

291 def get_file_size_mb(self, obj): 

-

292 try: 

-

293 return UserFile.objects.filter(space=obj).aggregate(Sum('file_size_kb'))['file_size_kb__sum'] / 1000 

-

294 except TypeError: 

-

295 return 0 

-

296 

-

297 def create(self, validated_data): 

-

298 raise ValidationError('Cannot create using this endpoint') 

-

299 

-

300 class Meta: 

-

301 model = Space 

-

302 fields = ( 

-

303 'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users', 

-

304 'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb', 

-

305 'image', 'use_plural',) 

-

306 read_only_fields = ( 

-

307 'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 

-

308 'demo',) 

-

309 

-

310 

-

311class UserSpaceSerializer(WritableNestedModelSerializer): 

-

312 user = UserSerializer(read_only=True) 

-

313 groups = GroupSerializer(many=True) 

-

314 

-

315 def validate(self, data): 

-

316 if self.instance.user == self.context['request'].space.created_by: # can't change space owner permission 

-

317 raise serializers.ValidationError(_('Cannot modify Space owner permission.')) 

-

318 return super().validate(data) 

-

319 

-

320 def create(self, validated_data): 

-

321 raise ValidationError('Cannot create using this endpoint') 

-

322 

-

323 class Meta: 

-

324 model = UserSpace 

-

325 fields = ('id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',) 

-

326 read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space') 

-

327 

-

328 

-

329class SpacedModelSerializer(serializers.ModelSerializer): 

-

330 def create(self, validated_data): 

-

331 validated_data['space'] = self.context['request'].space 

-

332 return super().create(validated_data) 

-

333 

-

334 

-

335class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 

-

336 

-

337 def create(self, validated_data): 

-

338 validated_data['name'] = validated_data['name'].strip() 

-

339 space = validated_data.pop('space', self.context['request'].space) 

-

340 validated_data['created_by'] = self.context['request'].user 

-

341 obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) 

-

342 return obj 

-

343 

-

344 class Meta: 

-

345 list_serializer_class = SpaceFilterSerializer 

-

346 model = MealType 

-

347 fields = ('id', 'name', 'order', 'color', 'default', 'created_by') 

-

348 read_only_fields = ('created_by',) 

-

349 

-

350 

-

351class UserPreferenceSerializer(WritableNestedModelSerializer): 

-

352 food_inherit_default = serializers.SerializerMethodField('get_food_inherit_defaults') 

-

353 plan_share = UserSerializer(many=True, allow_null=True, required=False) 

-

354 shopping_share = UserSerializer(many=True, allow_null=True, required=False) 

-

355 food_children_exist = serializers.SerializerMethodField('get_food_children_exist') 

-

356 image = UserFileViewSerializer(required=False, allow_null=True, many=False) 

-

357 

-

358 def get_food_inherit_defaults(self, obj): 

-

359 return FoodInheritFieldSerializer(obj.user.get_active_space().food_inherit.all(), many=True).data 

-

360 

-

361 def get_food_children_exist(self, obj): 

-

362 space = getattr(self.context.get('request', None), 'space', None) 

-

363 return Food.objects.filter(depth__gt=0, space=space).exists() 

-

364 

-

365 def update(self, instance, validated_data): 

-

366 with scopes_disabled(): 

-

367 return super().update(instance, validated_data) 

-

368 

-

369 def create(self, validated_data): 

-

370 raise ValidationError('Cannot create using this endpoint') 

-

371 

-

372 class Meta: 

-

373 model = UserPreference 

-

374 fields = ( 

-

375 'user', 'image', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 

-

376 'plan_share', 'sticky_navbar', 

-

377 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 

-

378 'food_inherit_default', 'default_delay', 

-

379 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 

-

380 'csv_delim', 'csv_prefix', 

-

381 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist' 

-

382 ) 

-

383 

-

384 

-

385class StorageSerializer(SpacedModelSerializer): 

-

386 

-

387 def create(self, validated_data): 

-

388 validated_data['created_by'] = self.context['request'].user 

-

389 return super().create(validated_data) 

-

390 

-

391 class Meta: 

-

392 model = Storage 

-

393 fields = ( 

-

394 'id', 'name', 'method', 'username', 'password', 

-

395 'token', 'created_by' 

-

396 ) 

-

397 

-

398 read_only_fields = ('created_by',) 

-

399 

-

400 extra_kwargs = { 

-

401 'password': {'write_only': True}, 

-

402 'token': {'write_only': True}, 

-

403 } 

-

404 

-

405 

-

406class SyncSerializer(SpacedModelSerializer): 

-

407 class Meta: 

-

408 model = Sync 

-

409 fields = ( 

-

410 'id', 'storage', 'path', 'active', 'last_checked', 

-

411 'created_at', 'updated_at' 

-

412 ) 

-

413 

-

414 

-

415class SyncLogSerializer(SpacedModelSerializer): 

-

416 class Meta: 

-

417 model = SyncLog 

-

418 fields = ('id', 'sync', 'status', 'msg', 'created_at') 

-

419 

-

420 

-

421class KeywordLabelSerializer(serializers.ModelSerializer): 

-

422 label = serializers.SerializerMethodField('get_label') 

-

423 

-

424 def get_label(self, obj): 

-

425 return str(obj) 

-

426 

-

427 class Meta: 

-

428 list_serializer_class = SpaceFilterSerializer 

-

429 model = Keyword 

-

430 fields = ( 

-

431 'id', 'label', 

-

432 ) 

-

433 read_only_fields = ('id', 'label') 

-

434 

-

435 

-

436class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): 

-

437 label = serializers.SerializerMethodField('get_label') 

-

438 recipe_filter = 'keywords' 

-

439 

-

440 def get_label(self, obj): 

-

441 return str(obj) 

-

442 

-

443 def create(self, validated_data): 

-

444 # since multi select tags dont have id's 

-

445 # duplicate names might be routed to create 

-

446 name = validated_data.pop('name').strip() 

-

447 space = validated_data.pop('space', self.context['request'].space) 

-

448 obj, created = Keyword.objects.get_or_create(name=name, space=space, defaults=validated_data) 

-

449 return obj 

-

450 

-

451 class Meta: 

-

452 model = Keyword 

-

453 fields = ( 

-

454 'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', 

-

455 'updated_at', 'full_name') 

-

456 read_only_fields = ('id', 'label', 'numchild', 'parent', 'image') 

-

457 

-

458 

-

459class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin): 

-

460 recipe_filter = 'steps__ingredients__unit' 

-

461 

-

462 def create(self, validated_data): 

-

463 # get_or_create drops any field that contains '__' when creating so values must be included in validated data 

-

464 space = validated_data.pop('space', self.context['request'].space) 

-

465 if x := validated_data.get('name', None): 

-

466 validated_data['name'] = x.strip() 

-

467 if x := validated_data.get('name', None): 

-

468 validated_data['plural_name'] = x.strip() 

-

469 

-

470 if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first(): 

-

471 return unit 

-

472 

-

473 obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) 

-

474 return obj 

-

475 

-

476 def update(self, instance, validated_data): 

-

477 validated_data['name'] = validated_data['name'].strip() 

-

478 if plural_name := validated_data.get('plural_name', None): 

-

479 validated_data['plural_name'] = plural_name.strip() 

-

480 return super(UnitSerializer, self).update(instance, validated_data) 

-

481 

-

482 class Meta: 

-

483 model = Unit 

-

484 fields = ('id', 'name', 'plural_name', 'description', 'base_unit', 'numrecipe', 'image', 'open_data_slug') 

-

485 read_only_fields = ('id', 'numrecipe', 'image') 

-

486 

-

487 

-

488class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer, OpenDataModelMixin): 

-

489 

-

490 def create(self, validated_data): 

-

491 validated_data['name'] = validated_data['name'].strip() 

-

492 space = validated_data.pop('space', self.context['request'].space) 

-

493 obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) 

-

494 return obj 

-

495 

-

496 def update(self, instance, validated_data): 

-

497 return super(SupermarketCategorySerializer, self).update(instance, validated_data) 

-

498 

-

499 class Meta: 

-

500 model = SupermarketCategory 

-

501 fields = ('id', 'name', 'description') 

-

502 

-

503 

-

504class SupermarketCategoryRelationSerializer(WritableNestedModelSerializer): 

-

505 category = SupermarketCategorySerializer() 

-

506 

-

507 class Meta: 

-

508 model = SupermarketCategoryRelation 

-

509 fields = ('id', 'category', 'supermarket', 'order') 

-

510 

-

511 

-

512class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataModelMixin): 

-

513 category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True) 

-

514 

-

515 def create(self, validated_data): 

-

516 validated_data['name'] = validated_data['name'].strip() 

-

517 space = validated_data.pop('space', self.context['request'].space) 

-

518 obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) 

-

519 return obj 

-

520 

-

521 class Meta: 

-

522 model = Supermarket 

-

523 fields = ('id', 'name', 'description', 'category_to_supermarket', 'open_data_slug') 

-

524 

-

525 

-

526class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin): 

-

527 id = serializers.IntegerField(required=False) 

-

528 order = IntegerField(default=0, required=False) 

-

529 

-

530 def create(self, validated_data): 

-

531 validated_data['name'] = validated_data['name'].strip() 

-

532 space = validated_data.pop('space', self.context['request'].space) 

-

533 obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) 

-

534 return obj 

-

535 

-

536 class Meta: 

-

537 model = PropertyType 

-

538 fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug', 'fdc_id',) 

-

539 

-

540 

-

541class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer): 

-

542 property_type = PropertyTypeSerializer() 

-

543 property_amount = CustomDecimalField() 

-

544 

-

545 def create(self, validated_data): 

-

546 validated_data['space'] = self.context['request'].space 

-

547 return super().create(validated_data) 

-

548 

-

549 class Meta: 

-

550 model = Property 

-

551 fields = ('id', 'property_amount', 'property_type') 

-

552 

-

553 

-

554class RecipeSimpleSerializer(WritableNestedModelSerializer): 

-

555 url = serializers.SerializerMethodField('get_url') 

-

556 

-

557 def get_url(self, obj): 

-

558 return reverse('view_recipe', args=[obj.id]) 

-

559 

-

560 def create(self, validated_data): 

-

561 # don't allow writing to Recipe via this API 

-

562 return Recipe.objects.get(**validated_data) 

-

563 

-

564 def update(self, instance, validated_data): 

-

565 # don't allow writing to Recipe via this API 

-

566 return Recipe.objects.get(**validated_data) 

-

567 

-

568 class Meta: 

-

569 model = Recipe 

-

570 fields = ('id', 'name', 'url') 

-

571 

-

572 

-

573class FoodSimpleSerializer(serializers.ModelSerializer): 

-

574 class Meta: 

-

575 model = Food 

-

576 fields = ('id', 'name', 'plural_name') 

-

577 

-

578 

-

579class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin, OpenDataModelMixin): 

-

580 supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) 

-

581 recipe = RecipeSimpleSerializer(allow_null=True, required=False) 

-

582 shopping = serializers.ReadOnlyField(source='shopping_status') 

-

583 inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) 

-

584 child_inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) 

-

585 food_onhand = CustomOnHandField(required=False, allow_null=True) 

-

586 substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand') 

-

587 substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False) 

-

588 

-

589 properties = PropertySerializer(many=True, allow_null=True, required=False) 

-

590 properties_food_unit = UnitSerializer(allow_null=True, required=False) 

-

591 properties_food_amount = CustomDecimalField(required=False) 

-

592 

-

593 recipe_filter = 'steps__ingredients__food' 

-

594 images = ['recipe__image'] 

-

595 

-

596 def get_substitute_onhand(self, obj): 

-

597 if not self.context["request"].user.is_authenticated: 

-

598 return [] 

-

599 shared_users = [] 

-

600 if c := caches['default'].get( 

-

601 f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): 

-

602 shared_users = c 

-

603 else: 

-

604 try: 

-

605 shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ 

-

606 self.context['request'].user.id] 

-

607 caches['default'].set( 

-

608 f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', 

-

609 shared_users, timeout=5 * 60) 

-

610 # TODO ugly hack that improves API performance significantly, should be done properly 

-

611 except AttributeError: # Anonymous users (using share links) don't have shared users 

-

612 pass 

-

613 filter = Q(id__in=obj.substitute.all()) 

-

614 if obj.substitute_siblings: 

-

615 filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth) 

-

616 if obj.substitute_children: 

-

617 filter |= Q(path__startswith=obj.path, depth__gt=obj.depth) 

-

618 return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists() 

-

619 

-

620 def create(self, validated_data): 

-

621 name = validated_data['name'].strip() 

-

622 

-

623 if plural_name := validated_data.pop('plural_name', None): 

-

624 plural_name = plural_name.strip() 

-

625 

-

626 if food := Food.objects.filter(Q(name=name) | Q(plural_name=name)).first(): 

-

627 return food 

-

628 

-

629 space = validated_data.pop('space', self.context['request'].space) 

-

630 # supermarket category needs to be handled manually as food.get or create does not create nested serializers unlike a super.create of serializer 

-

631 if 'supermarket_category' in validated_data and validated_data['supermarket_category']: 

-

632 sm_category = validated_data['supermarket_category'] 

-

633 sc_name = sm_category.pop('name', None) 

-

634 validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create( 

-

635 name=sc_name, 

-

636 space=space, defaults=sm_category) 

-

637 onhand = validated_data.pop('food_onhand', None) 

-

638 if recipe := validated_data.get('recipe', None): 

-

639 validated_data['recipe'] = Recipe.objects.get(**recipe) 

-

640 

-

641 # assuming if on hand for user also onhand for shopping_share users 

-

642 if onhand is not None: 

-

643 shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) 

-

644 if self.instance: 

-

645 onhand_users = self.instance.onhand_users.all() 

-

646 else: 

-

647 onhand_users = [] 

-

648 if onhand: 

-

649 validated_data['onhand_users'] = list(onhand_users) + shared_users 

-

650 else: 

-

651 validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users)) 

-

652 

-

653 if properties_food_unit := validated_data.pop('properties_food_unit', None): 

-

654 properties_food_unit = Unit.objects.filter(name=properties_food_unit['name']).first() 

-

655 

-

656 properties = validated_data.pop('properties', None) 

-

657 

-

658 obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit, 

-

659 defaults=validated_data) 

-

660 

-

661 if properties and len(properties) > 0: 

-

662 for p in properties: 

-

663 obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space)) 

-

664 

-

665 return obj 

-

666 

-

667 def update(self, instance, validated_data): 

-

668 if name := validated_data.get('name', None): 

-

669 validated_data['name'] = name.strip() 

-

670 if plural_name := validated_data.get('plural_name', None): 

-

671 validated_data['plural_name'] = plural_name.strip() 

-

672 # assuming if on hand for user also onhand for shopping_share users 

-

673 onhand = validated_data.get('food_onhand', None) 

-

674 reset_inherit = self.initial_data.get('reset_inherit', False) 

-

675 if onhand is not None: 

-

676 shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) 

-

677 if onhand: 

-

678 validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users 

-

679 else: 

-

680 validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users)) 

-

681 

-

682 # update before resetting inheritance 

-

683 saved_instance = super(FoodSerializer, self).update(instance, validated_data) 

-

684 if reset_inherit and (r := self.context.get('request', None)): 

-

685 Food.reset_inheritance(food=saved_instance, space=r.space) 

-

686 return saved_instance 

-

687 

-

688 class Meta: 

-

689 model = Food 

-

690 fields = ( 

-

691 'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'url', 

-

692 'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id', 

-

693 'food_onhand', 'supermarket_category', 

-

694 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', 

-

695 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug', 

-

696 ) 

-

697 read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') 

-

698 

-

699 

-

700class IngredientSimpleSerializer(WritableNestedModelSerializer): 

-

701 food = FoodSimpleSerializer(allow_null=True) 

-

702 unit = UnitSerializer(allow_null=True) 

-

703 used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes') 

-

704 amount = CustomDecimalField() 

-

705 conversions = serializers.SerializerMethodField('get_conversions') 

-

706 

-

707 def get_used_in_recipes(self, obj): 

-

708 used_in = [] 

-

709 for s in obj.step_set.all(): 

-

710 for r in s.recipe_set.all(): 

-

711 used_in.append({'id': r.id, 'name': r.name}) 

-

712 return used_in 

-

713 

-

714 def get_conversions(self, obj): 

-

715 if obj.unit and obj.food: 

-

716 uch = UnitConversionHelper(self.context['request'].space) 

-

717 conversions = [] 

-

718 for c in uch.get_conversions(obj): 

-

719 conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper 

-

720 return conversions 

-

721 else: 

-

722 return [] 

-

723 

-

724 def create(self, validated_data): 

-

725 validated_data['space'] = self.context['request'].space 

-

726 return super().create(validated_data) 

-

727 

-

728 def update(self, instance, validated_data): 

-

729 validated_data.pop('original_text', None) 

-

730 return super().update(instance, validated_data) 

-

731 

-

732 class Meta: 

-

733 model = Ingredient 

-

734 fields = ( 

-

735 'id', 'food', 'unit', 'amount', 'conversions', 'note', 'order', 

-

736 'is_header', 'no_amount', 'original_text', 'used_in_recipes', 

-

737 'always_use_plural_unit', 'always_use_plural_food', 

-

738 ) 

-

739 read_only_fields = ['conversions', ] 

-

740 

-

741 

-

742class IngredientSerializer(IngredientSimpleSerializer): 

-

743 food = FoodSerializer(allow_null=True) 

-

744 

-

745 

-

746class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin): 

-

747 ingredients = IngredientSerializer(many=True) 

-

748 ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown') 

-

749 ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue') 

-

750 file = UserFileViewSerializer(allow_null=True, required=False) 

-

751 step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data') 

-

752 recipe_filter = 'steps' 

-

753 

-

754 def create(self, validated_data): 

-

755 validated_data['space'] = self.context['request'].space 

-

756 return super().create(validated_data) 

-

757 

-

758 def get_ingredients_vue(self, obj): 

-

759 return obj.get_instruction_render() 

-

760 

-

761 def get_ingredients_markdown(self, obj): 

-

762 return obj.get_instruction_render() 

-

763 

-

764 def get_step_recipes(self, obj): 

-

765 return list(obj.recipe_set.values_list('id', flat=True).all()) 

-

766 

-

767 def get_step_recipe_data(self, obj): 

-

768 # check if root type is recipe to prevent infinite recursion 

-

769 # can be improved later to allow multi level embedding 

-

770 if obj.step_recipe and isinstance(self.parent.root, RecipeSerializer): 

-

771 return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data 

-

772 

-

773 class Meta: 

-

774 model = Step 

-

775 fields = ( 

-

776 'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown', 

-

777 'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 

-

778 'step_recipe_data', 'numrecipe', 'show_ingredients_table' 

-

779 ) 

-

780 

-

781 

-

782class StepRecipeSerializer(WritableNestedModelSerializer): 

-

783 steps = StepSerializer(many=True) 

-

784 

-

785 class Meta: 

-

786 model = Recipe 

-

787 fields = ( 

-

788 'id', 'name', 'steps', 

-

789 ) 

-

790 

-

791 

-

792class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin): 

-

793 name = serializers.SerializerMethodField('get_conversion_name') 

-

794 base_unit = UnitSerializer() 

-

795 converted_unit = UnitSerializer() 

-

796 food = FoodSerializer(allow_null=True, required=False) 

-

797 base_amount = CustomDecimalField() 

-

798 converted_amount = CustomDecimalField() 

-

799 

-

800 def get_conversion_name(self, obj): 

-

801 text = f'{round(obj.base_amount)} {obj.base_unit} ' 

-

802 if obj.food: 

-

803 text += f' {obj.food}' 

-

804 return text + f' = {round(obj.converted_amount)} {obj.converted_unit}' 

-

805 

-

806 def create(self, validated_data): 

-

807 validated_data['space'] = validated_data.pop('space', self.context['request'].space) 

-

808 try: 

-

809 return UnitConversion.objects.get( 

-

810 food__name__iexact=validated_data.get('food', {}).get('name', None), 

-

811 base_unit__name__iexact=validated_data.get('base_unit', {}).get('name', None), 

-

812 converted_unit__name__iexact=validated_data.get('converted_unit', {}).get('name', None), 

-

813 space=validated_data['space'] 

-

814 ) 

-

815 except UnitConversion.DoesNotExist: 

-

816 validated_data['created_by'] = self.context['request'].user 

-

817 return super().create(validated_data) 

-

818 

-

819 class Meta: 

-

820 model = UnitConversion 

-

821 fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug') 

-

822 

-

823 

-

824class NutritionInformationSerializer(serializers.ModelSerializer): 

-

825 carbohydrates = CustomDecimalField() 

-

826 fats = CustomDecimalField() 

-

827 proteins = CustomDecimalField() 

-

828 calories = CustomDecimalField() 

-

829 

-

830 def create(self, validated_data): 

-

831 validated_data['space'] = self.context['request'].space 

-

832 return super().create(validated_data) 

-

833 

-

834 class Meta: 

-

835 model = NutritionInformation 

-

836 fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source') 

-

837 

-

838 

-

839class RecipeBaseSerializer(WritableNestedModelSerializer): 

-

840 # TODO make days of new recipe a setting 

-

841 def is_recipe_new(self, obj): 

-

842 if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)): 

-

843 return True 

-

844 else: 

-

845 return False 

-

846 

-

847 

-

848class RecipeOverviewSerializer(RecipeBaseSerializer): 

-

849 keywords = KeywordLabelSerializer(many=True) 

-

850 new = serializers.SerializerMethodField('is_recipe_new') 

-

851 recent = serializers.ReadOnlyField() 

-

852 

-

853 rating = CustomDecimalField(required=False, allow_null=True) 

-

854 last_cooked = serializers.DateTimeField(required=False, allow_null=True) 

-

855 

-

856 def create(self, validated_data): 

-

857 pass 

-

858 

-

859 def update(self, instance, validated_data): 

-

860 return instance 

-

861 

-

862 class Meta: 

-

863 model = Recipe 

-

864 fields = ( 

-

865 'id', 'name', 'description', 'image', 'keywords', 'working_time', 

-

866 'waiting_time', 'created_by', 'created_at', 'updated_at', 

-

867 'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent' 

-

868 ) 

-

869 read_only_fields = ['image', 'created_by', 'created_at'] 

-

870 

-

871 

-

872class RecipeSerializer(RecipeBaseSerializer): 

-

873 nutrition = NutritionInformationSerializer(allow_null=True, required=False) 

-

874 properties = PropertySerializer(many=True, required=False) 

-

875 steps = StepSerializer(many=True) 

-

876 keywords = KeywordSerializer(many=True) 

-

877 shared = UserSerializer(many=True, required=False) 

-

878 rating = CustomDecimalField(required=False, allow_null=True, read_only=True) 

-

879 last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True) 

-

880 food_properties = serializers.SerializerMethodField('get_food_properties') 

-

881 

-

882 def get_food_properties(self, obj): 

-

883 fph = FoodPropertyHelper(obj.space) # initialize with object space since recipes might be viewed anonymously 

-

884 return fph.calculate_recipe_properties(obj) 

-

885 

-

886 class Meta: 

-

887 model = Recipe 

-

888 fields = ( 

-

889 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 

-

890 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', 

-

891 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating', 

-

892 'last_cooked', 

-

893 'private', 'shared', 

-

894 ) 

-

895 read_only_fields = ['image', 'created_by', 'created_at', 'food_properties'] 

-

896 

-

897 def validate(self, data): 

-

898 above_limit, msg = above_space_limit(self.context['request'].space) 

-

899 if above_limit: 

-

900 raise serializers.ValidationError(msg) 

-

901 return super().validate(data) 

-

902 

-

903 def create(self, validated_data): 

-

904 validated_data['created_by'] = self.context['request'].user 

-

905 validated_data['space'] = self.context['request'].space 

-

906 return super().create(validated_data) 

-

907 

-

908 

-

909class RecipeImageSerializer(WritableNestedModelSerializer): 

-

910 image = serializers.ImageField(required=False, allow_null=True) 

-

911 image_url = serializers.CharField(max_length=4096, required=False, allow_null=True) 

-

912 

-

913 class Meta: 

-

914 model = Recipe 

-

915 fields = ['image', 'image_url', ] 

-

916 

-

917 

-

918class RecipeImportSerializer(SpacedModelSerializer): 

-

919 class Meta: 

-

920 model = RecipeImport 

-

921 fields = '__all__' 

-

922 

-

923 

-

924class CommentSerializer(serializers.ModelSerializer): 

-

925 class Meta: 

-

926 model = Comment 

-

927 fields = '__all__' 

-

928 

-

929 

-

930class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 

-

931 shared = UserSerializer(many=True, required=False) 

-

932 

-

933 def create(self, validated_data): 

-

934 validated_data['created_by'] = self.context['request'].user 

-

935 return super().create(validated_data) 

-

936 

-

937 class Meta: 

-

938 model = CustomFilter 

-

939 fields = ('id', 'name', 'search', 'shared', 'created_by') 

-

940 read_only_fields = ('created_by',) 

-

941 

-

942 

-

943class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 

-

944 shared = UserSerializer(many=True) 

-

945 filter = CustomFilterSerializer(allow_null=True, required=False) 

-

946 

-

947 def create(self, validated_data): 

-

948 validated_data['created_by'] = self.context['request'].user 

-

949 return super().create(validated_data) 

-

950 

-

951 class Meta: 

-

952 model = RecipeBook 

-

953 fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter') 

-

954 read_only_fields = ('created_by',) 

-

955 

-

956 

-

957class RecipeBookEntrySerializer(serializers.ModelSerializer): 

-

958 book_content = serializers.SerializerMethodField(method_name='get_book_content', read_only=True) 

-

959 recipe_content = serializers.SerializerMethodField(method_name='get_recipe_content', read_only=True) 

-

960 

-

961 def get_book_content(self, obj): 

-

962 return RecipeBookSerializer(context={'request': self.context['request']}).to_representation(obj.book) 

-

963 

-

964 def get_recipe_content(self, obj): 

-

965 return RecipeOverviewSerializer(context={'request': self.context['request']}).to_representation(obj.recipe) 

-

966 

-

967 def create(self, validated_data): 

-

968 book = validated_data['book'] 

-

969 recipe = validated_data['recipe'] 

-

970 if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared(): 

-

971 raise NotFound(detail=None, code=None) 

-

972 obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) 

-

973 return obj 

-

974 

-

975 class Meta: 

-

976 model = RecipeBookEntry 

-

977 fields = ('id', 'book', 'book_content', 'recipe', 'recipe_content',) 

-

978 

-

979 

-

980class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 

-

981 recipe = RecipeOverviewSerializer(required=False, allow_null=True) 

-

982 recipe_name = serializers.ReadOnlyField(source='recipe.name') 

-

983 meal_type = MealTypeSerializer() 

-

984 meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed 

-

985 note_markdown = serializers.SerializerMethodField('get_note_markdown') 

-

986 servings = CustomDecimalField() 

-

987 shared = UserSerializer(many=True, required=False, allow_null=True) 

-

988 shopping = serializers.SerializerMethodField('in_shopping') 

-

989 

-

990 to_date = serializers.DateField(required=False) 

-

991 

-

992 def get_note_markdown(self, obj): 

-

993 return markdown(obj.note) 

-

994 

-

995 def in_shopping(self, obj): 

-

996 return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists() 

-

997 

-

998 def create(self, validated_data): 

-

999 validated_data['created_by'] = self.context['request'].user 

-

1000 

-

1001 if 'to_date' not in validated_data or validated_data['to_date'] is None: 

-

1002 validated_data['to_date'] = validated_data['from_date'] 

-

1003 

-

1004 mealplan = super().create(validated_data) 

-

1005 if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None): 

-

1006 SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space']) 

-

1007 SLR.create(mealplan=mealplan, servings=validated_data['servings']) 

-

1008 return mealplan 

-

1009 

-

1010 class Meta: 

-

1011 model = MealPlan 

-

1012 fields = ( 

-

1013 'id', 'title', 'recipe', 'servings', 'note', 'note_markdown', 

-

1014 'from_date', 'to_date', 'meal_type', 'created_by', 'shared', 'recipe_name', 

-

1015 'meal_type_name', 'shopping' 

-

1016 ) 

-

1017 read_only_fields = ('created_by',) 

-

1018 

-

1019 

-

1020class AutoMealPlanSerializer(serializers.Serializer): 

-

1021 start_date = serializers.DateField() 

-

1022 end_date = serializers.DateField() 

-

1023 meal_type_id = serializers.IntegerField() 

-

1024 keywords = KeywordSerializer(many=True) 

-

1025 servings = CustomDecimalField() 

-

1026 shared = UserSerializer(many=True, required=False, allow_null=True) 

-

1027 addshopping = serializers.BooleanField() 

-

1028 

-

1029 

-

1030class ShoppingListRecipeSerializer(serializers.ModelSerializer): 

-

1031 name = serializers.SerializerMethodField('get_name') # should this be done at the front end? 

-

1032 recipe_name = serializers.ReadOnlyField(source='recipe.name') 

-

1033 mealplan_note = serializers.ReadOnlyField(source='mealplan.note') 

-

1034 servings = CustomDecimalField() 

-

1035 

-

1036 def get_name(self, obj): 

-

1037 if not isinstance(value := obj.servings, Decimal): 

-

1038 value = Decimal(value) 

-

1039 value = value.quantize( 

-

1040 Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero 

-

1041 return ( 

-

1042 obj.name 

-

1043 or getattr(obj.mealplan, 'title', None) 

-

1044 or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)]) 

-

1045 or obj.recipe.name 

-

1046 ) + f' ({value:.2g})' 

-

1047 

-

1048 def update(self, instance, validated_data): 

-

1049 # TODO remove once old shopping list 

-

1050 if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet': 

-

1051 SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space) 

-

1052 SLR.edit_servings(servings=validated_data['servings'], id=instance.id) 

-

1053 return super().update(instance, validated_data) 

-

1054 

-

1055 class Meta: 

-

1056 model = ShoppingListRecipe 

-

1057 fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') 

-

1058 read_only_fields = ('id',) 

-

1059 

-

1060 

-

1061class ShoppingListEntrySerializer(WritableNestedModelSerializer): 

-

1062 food = FoodSerializer(allow_null=True) 

-

1063 unit = UnitSerializer(allow_null=True, required=False) 

-

1064 ingredient_note = serializers.ReadOnlyField(source='ingredient.note') 

-

1065 recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) 

-

1066 amount = CustomDecimalField() 

-

1067 created_by = UserSerializer(read_only=True) 

-

1068 completed_at = serializers.DateTimeField(allow_null=True, required=False) 

-

1069 

-

1070 def get_fields(self, *args, **kwargs): 

-

1071 fields = super().get_fields(*args, **kwargs) 

-

1072 

-

1073 # autosync values are only needed for frequent 'checked' value updating 

-

1074 if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))): 

-

1075 for f in list(set(fields) - set(['id', 'checked'])): 

-

1076 del fields[f] 

-

1077 return fields 

-

1078 

-

1079 def run_validation(self, data): 

-

1080 if self.root.instance.__class__.__name__ == 'ShoppingListEntry': 

-

1081 if ( 

-

1082 data.get('checked', False) 

-

1083 and self.root.instance 

-

1084 and not self.root.instance.checked 

-

1085 ): 

-

1086 # if checked flips from false to true set completed datetime 

-

1087 data['completed_at'] = timezone.now() 

-

1088 

-

1089 elif not data.get('checked', False): 

-

1090 # if not checked set completed to None 

-

1091 data['completed_at'] = None 

-

1092 else: 

-

1093 # otherwise don't write anything 

-

1094 if 'completed_at' in data: 

-

1095 del data['completed_at'] 

-

1096 

-

1097 return super().run_validation(data) 

-

1098 

-

1099 def create(self, validated_data): 

-

1100 validated_data['space'] = self.context['request'].space 

-

1101 validated_data['created_by'] = self.context['request'].user 

-

1102 return super().create(validated_data) 

-

1103 

-

1104 def update(self, instance, validated_data): 

-

1105 user = self.context['request'].user 

-

1106 # update the onhand for food if shopping_add_onhand is True 

-

1107 if user.userpreference.shopping_add_onhand: 

-

1108 if checked := validated_data.get('checked', None): 

-

1109 instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user) 

-

1110 elif checked == False: 

-

1111 instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user) 

-

1112 return super().update(instance, validated_data) 

-

1113 

-

1114 class Meta: 

-

1115 model = ShoppingListEntry 

-

1116 fields = ( 

-

1117 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 

-

1118 'recipe_mealplan', 

-

1119 'created_by', 'created_at', 'completed_at', 'delay_until' 

-

1120 ) 

-

1121 read_only_fields = ('id', 'created_by', 'created_at',) 

-

1122 

-

1123 

-

1124# TODO deprecate 

-

1125class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer): 

-

1126 class Meta: 

-

1127 model = ShoppingListEntry 

-

1128 fields = ('id', 'checked') 

-

1129 

-

1130 

-

1131# TODO deprecate 

-

1132class ShoppingListSerializer(WritableNestedModelSerializer): 

-

1133 recipes = ShoppingListRecipeSerializer(many=True, allow_null=True) 

-

1134 entries = ShoppingListEntrySerializer(many=True, allow_null=True) 

-

1135 shared = UserSerializer(many=True) 

-

1136 supermarket = SupermarketSerializer(allow_null=True) 

-

1137 

-

1138 def create(self, validated_data): 

-

1139 validated_data['space'] = self.context['request'].space 

-

1140 validated_data['created_by'] = self.context['request'].user 

-

1141 return super().create(validated_data) 

-

1142 

-

1143 class Meta: 

-

1144 model = ShoppingList 

-

1145 fields = ( 

-

1146 'id', 'uuid', 'note', 'recipes', 'entries', 

-

1147 'shared', 'finished', 'supermarket', 'created_by', 'created_at' 

-

1148 ) 

-

1149 read_only_fields = ('id', 'created_by',) 

-

1150 

-

1151 

-

1152# TODO deprecate 

-

1153class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer): 

-

1154 entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True) 

-

1155 

-

1156 class Meta: 

-

1157 model = ShoppingList 

-

1158 fields = ('id', 'entries',) 

-

1159 read_only_fields = ('id',) 

-

1160 

-

1161 

-

1162class ShareLinkSerializer(SpacedModelSerializer): 

-

1163 class Meta: 

-

1164 model = ShareLink 

-

1165 fields = '__all__' 

-

1166 

-

1167 

-

1168class CookLogSerializer(serializers.ModelSerializer): 

-

1169 def create(self, validated_data): 

-

1170 validated_data['created_by'] = self.context['request'].user 

-

1171 validated_data['space'] = self.context['request'].space 

-

1172 return super().create(validated_data) 

-

1173 

-

1174 class Meta: 

-

1175 model = CookLog 

-

1176 fields = ('id', 'recipe', 'servings', 'rating', 'created_by', 'created_at') 

-

1177 read_only_fields = ('id', 'created_by') 

-

1178 

-

1179 

-

1180class ViewLogSerializer(serializers.ModelSerializer): 

-

1181 def create(self, validated_data): 

-

1182 validated_data['created_by'] = self.context['request'].user 

-

1183 validated_data['space'] = self.context['request'].space 

-

1184 return super().create(validated_data) 

-

1185 

-

1186 class Meta: 

-

1187 model = ViewLog 

-

1188 fields = ('id', 'recipe', 'created_by', 'created_at') 

-

1189 read_only_fields = ('created_by',) 

-

1190 

-

1191 

-

1192class ImportLogSerializer(serializers.ModelSerializer): 

-

1193 keyword = KeywordSerializer(read_only=True) 

-

1194 

-

1195 def create(self, validated_data): 

-

1196 validated_data['created_by'] = self.context['request'].user 

-

1197 validated_data['space'] = self.context['request'].space 

-

1198 return super().create(validated_data) 

-

1199 

-

1200 class Meta: 

-

1201 model = ImportLog 

-

1202 fields = ( 

-

1203 'id', 'type', 'msg', 'running', 'keyword', 'total_recipes', 'imported_recipes', 'created_by', 'created_at') 

-

1204 read_only_fields = ('created_by',) 

-

1205 

-

1206 

-

1207class ExportLogSerializer(serializers.ModelSerializer): 

-

1208 

-

1209 def create(self, validated_data): 

-

1210 validated_data['created_by'] = self.context['request'].user 

-

1211 validated_data['space'] = self.context['request'].space 

-

1212 return super().create(validated_data) 

-

1213 

-

1214 class Meta: 

-

1215 model = ExportLog 

-

1216 fields = ( 

-

1217 'id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 

-

1218 'possibly_not_expired', 

-

1219 'created_by', 'created_at') 

-

1220 read_only_fields = ('created_by',) 

-

1221 

-

1222 

-

1223class AutomationSerializer(serializers.ModelSerializer): 

-

1224 

-

1225 def create(self, validated_data): 

-

1226 validated_data['created_by'] = self.context['request'].user 

-

1227 validated_data['space'] = self.context['request'].space 

-

1228 return super().create(validated_data) 

-

1229 

-

1230 class Meta: 

-

1231 model = Automation 

-

1232 fields = ( 

-

1233 'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'order', 'disabled', 'created_by',) 

-

1234 read_only_fields = ('created_by',) 

-

1235 

-

1236 

-

1237class InviteLinkSerializer(WritableNestedModelSerializer): 

-

1238 group = GroupSerializer() 

-

1239 

-

1240 def create(self, validated_data): 

-

1241 validated_data['created_by'] = self.context['request'].user 

-

1242 validated_data['space'] = self.context['request'].space 

-

1243 obj = super().create(validated_data) 

-

1244 

-

1245 if obj.email: 

-

1246 try: 

-

1247 if InviteLink.objects.filter(space=self.context['request'].space, 

-

1248 created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20: 

-

1249 message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape( 

-

1250 self.context['request'].user.get_user_display_name()) 

-

1251 message += _(' to join their Tandoor Recipes space ') + escape( 

-

1252 self.context['request'].space.name) + '.\n\n' 

-

1253 message += _('Click the following link to activate your account: ') + self.context[ 

-

1254 'request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n' 

-

1255 message += _('If the link does not work use the following code to manually join the space: ') + str( 

-

1256 obj.uuid) + '\n\n' 

-

1257 message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n' 

-

1258 message += _( 

-

1259 'Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/' 

-

1260 

-

1261 send_mail( 

-

1262 _('Tandoor Recipes Invite'), 

-

1263 message, 

-

1264 None, 

-

1265 [obj.email], 

-

1266 fail_silently=True, 

-

1267 ) 

-

1268 except (SMTPException, BadHeaderError, TimeoutError): 

-

1269 pass 

-

1270 

-

1271 return obj 

-

1272 

-

1273 class Meta: 

-

1274 model = InviteLink 

-

1275 fields = ( 

-

1276 'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',) 

-

1277 read_only_fields = ('id', 'uuid', 'created_by', 'created_at',) 

-

1278 

-

1279 

-

1280# CORS, REST and Scopes aren't currently working 

-

1281# Scopes are evaluating before REST has authenticated the user assigning a None space 

-

1282# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix 

-

1283class BookmarkletImportListSerializer(serializers.ModelSerializer): 

-

1284 def create(self, validated_data): 

-

1285 validated_data['created_by'] = self.context['request'].user 

-

1286 validated_data['space'] = self.context['request'].space 

-

1287 return super().create(validated_data) 

-

1288 

-

1289 class Meta: 

-

1290 model = BookmarkletImport 

-

1291 fields = ('id', 'url', 'created_by', 'created_at') 

-

1292 read_only_fields = ('created_by', 'space') 

-

1293 

-

1294 

-

1295class BookmarkletImportSerializer(BookmarkletImportListSerializer): 

-

1296 class Meta: 

-

1297 model = BookmarkletImport 

-

1298 fields = ('id', 'url', 'html', 'created_by', 'created_at') 

-

1299 read_only_fields = ('created_by', 'space') 

-

1300 

-

1301 

-

1302# OAuth / Auth Token related Serializers 

-

1303 

-

1304class AccessTokenSerializer(serializers.ModelSerializer): 

-

1305 token = serializers.SerializerMethodField('get_token') 

-

1306 

-

1307 def create(self, validated_data): 

-

1308 validated_data['token'] = f'tda_{str(uuid.uuid4()).replace("-", "_")}' 

-

1309 validated_data['user'] = self.context['request'].user 

-

1310 return super().create(validated_data) 

-

1311 

-

1312 def get_token(self, obj): 

-

1313 if (timezone.now() - obj.created).seconds < 15: 

-

1314 return obj.token 

-

1315 return f'tda_************_******_***********{obj.token[len(obj.token) - 4:]}' 

-

1316 

-

1317 class Meta: 

-

1318 model = AccessToken 

-

1319 fields = ('id', 'token', 'expires', 'scope', 'created', 'updated') 

-

1320 read_only_fields = ('id', 'token',) 

-

1321 

-

1322 

-

1323# Export/Import Serializers 

-

1324 

-

1325class KeywordExportSerializer(KeywordSerializer): 

-

1326 class Meta: 

-

1327 model = Keyword 

-

1328 fields = ('name', 'description', 'created_at', 'updated_at') 

-

1329 

-

1330 

-

1331class NutritionInformationExportSerializer(NutritionInformationSerializer): 

-

1332 class Meta: 

-

1333 model = NutritionInformation 

-

1334 fields = ('carbohydrates', 'fats', 'proteins', 'calories', 'source') 

-

1335 

-

1336 

-

1337class SupermarketCategoryExportSerializer(SupermarketCategorySerializer): 

-

1338 class Meta: 

-

1339 model = SupermarketCategory 

-

1340 fields = ('name',) 

-

1341 

-

1342 

-

1343class UnitExportSerializer(UnitSerializer): 

-

1344 class Meta: 

-

1345 model = Unit 

-

1346 fields = ('name', 'plural_name', 'description') 

-

1347 

-

1348 

-

1349class FoodExportSerializer(FoodSerializer): 

-

1350 supermarket_category = SupermarketCategoryExportSerializer(allow_null=True, required=False) 

-

1351 

-

1352 class Meta: 

-

1353 model = Food 

-

1354 fields = ('name', 'plural_name', 'ignore_shopping', 'supermarket_category',) 

-

1355 

-

1356 

-

1357class IngredientExportSerializer(WritableNestedModelSerializer): 

-

1358 food = FoodExportSerializer(allow_null=True) 

-

1359 unit = UnitExportSerializer(allow_null=True) 

-

1360 amount = CustomDecimalField() 

-

1361 

-

1362 def create(self, validated_data): 

-

1363 validated_data['space'] = self.context['request'].space 

-

1364 return super().create(validated_data) 

-

1365 

-

1366 class Meta: 

-

1367 model = Ingredient 

-

1368 fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit', 

-

1369 'always_use_plural_food') 

-

1370 

-

1371 

-

1372class StepExportSerializer(WritableNestedModelSerializer): 

-

1373 ingredients = IngredientExportSerializer(many=True) 

-

1374 

-

1375 def create(self, validated_data): 

-

1376 validated_data['space'] = self.context['request'].space 

-

1377 return super().create(validated_data) 

-

1378 

-

1379 class Meta: 

-

1380 model = Step 

-

1381 fields = ('name', 'instruction', 'ingredients', 'time', 'order', 'show_as_header', 'show_ingredients_table') 

-

1382 

-

1383 

-

1384class RecipeExportSerializer(WritableNestedModelSerializer): 

-

1385 nutrition = NutritionInformationSerializer(allow_null=True, required=False) 

-

1386 steps = StepExportSerializer(many=True) 

-

1387 keywords = KeywordExportSerializer(many=True) 

-

1388 

-

1389 class Meta: 

-

1390 model = Recipe 

-

1391 fields = ( 

-

1392 'name', 'description', 'keywords', 'steps', 'working_time', 

-

1393 'waiting_time', 'internal', 'nutrition', 'servings', 'servings_text', 'source_url', 

-

1394 ) 

-

1395 

-

1396 def create(self, validated_data): 

-

1397 validated_data['created_by'] = self.context['request'].user 

-

1398 validated_data['space'] = self.context['request'].space 

-

1399 return super().create(validated_data) 

-

1400 

-

1401 

-

1402class RecipeShoppingUpdateSerializer(serializers.ModelSerializer): 

-

1403 list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, 

-

1404 help_text=_("Existing shopping list to update")) 

-

1405 ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_( 

-

1406 "List of ingredient IDs from the recipe to add, if not provided all ingredients will be added.")) 

-

1407 servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_( 

-

1408 "Providing a list_recipe ID and servings of 0 will delete that shopping list.")) 

-

1409 

-

1410 class Meta: 

-

1411 model = Recipe 

-

1412 fields = ['id', 'list_recipe', 'ingredients', 'servings', ] 

-

1413 

-

1414 

-

1415class FoodShoppingUpdateSerializer(serializers.ModelSerializer): 

-

1416 amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, 

-

1417 help_text=_("Amount of food to add to the shopping list")) 

-

1418 unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, 

-

1419 help_text=_("ID of unit to use for the shopping list")) 

-

1420 delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, 

-

1421 help_text=_("When set to true will delete all food from active shopping lists.")) 

-

1422 

-

1423 class Meta: 

-

1424 model = Recipe 

-

1425 fields = ['id', 'amount', 'unit', 'delete', ] 

-

1426 

-

1427 

-

1428# non model serializers 

-

1429 

-

1430class RecipeFromSourceSerializer(serializers.Serializer): 

-

1431 url = serializers.CharField(max_length=4096, required=False, allow_null=True, allow_blank=True) 

-

1432 data = serializers.CharField(required=False, allow_null=True, allow_blank=True) 

-

1433 bookmarklet = serializers.IntegerField(required=False, allow_null=True, ) 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_signals_py.html b/docs/coverage/d_a167ab5b5108d61e_signals_py.html deleted file mode 100644 index 83dcbe9281..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_signals_py.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - Coverage for cookbook/signals.py: 82% - - - - - -
-
-

- Coverage for cookbook/signals.py: - 82% -

- -

- 120 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from functools import wraps 

-

2 

-

3from django.conf import settings 

-

4from django.contrib.auth.models import User 

-

5from django.contrib.postgres.search import SearchVector 

-

6from django.core.cache import caches 

-

7from django.db.models.signals import post_save 

-

8from django.dispatch import receiver 

-

9from django.utils import translation 

-

10from django_scopes import scope, scopes_disabled 

-

11 

-

12from cookbook.helper.cache_helper import CacheHelper 

-

13from cookbook.helper.shopping_helper import RecipeShoppingEditor 

-

14from cookbook.managers import DICTIONARY 

-

15from cookbook.models import (Food, MealPlan, PropertyType, Recipe, SearchFields, SearchPreference, 

-

16 Step, Unit, UserPreference) 

-

17 

-

18SQLITE = True 

-

19if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': 

-

20 SQLITE = False 

-

21 

-

22 

-

23# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals 

-

24def skip_signal(signal_func): 

-

25 @wraps(signal_func) 

-

26 def _decorator(sender, instance, **kwargs): 

-

27 if not instance: 

-

28 return None 

-

29 if hasattr(instance, 'skip_signal'): 

-

30 return None 

-

31 return signal_func(sender, instance, **kwargs) 

-

32 

-

33 return _decorator 

-

34 

-

35 

-

36@receiver(post_save, sender=User) 

-

37def create_user_preference(sender, instance=None, created=False, **kwargs): 

-

38 if created: 

-

39 with scopes_disabled(): 

-

40 UserPreference.objects.get_or_create(user=instance) 

-

41 

-

42 

-

43@receiver(post_save, sender=SearchPreference) 

-

44def create_search_preference(sender, instance=None, created=False, **kwargs): 

-

45 if created: 

-

46 with scopes_disabled(): 

-

47 instance.unaccent.add(SearchFields.objects.get(name='Name')) 

-

48 instance.icontains.add(SearchFields.objects.get(name='Name')) 

-

49 instance.trigram.add(SearchFields.objects.get(name='Name')) 

-

50 

-

51 

-

52@receiver(post_save, sender=Recipe) 

-

53@skip_signal 

-

54def update_recipe_search_vector(sender, instance=None, created=False, **kwargs): 

-

55 if SQLITE: 

-

56 return 

-

57 language = DICTIONARY.get(translation.get_language(), 'simple') 

-

58 # these indexed fields are space wide, reading user preferences would lead to inconsistent behavior 

-

59 instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language) 

-

60 instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language) 

-

61 try: 

-

62 instance.skip_signal = True 

-

63 instance.save() 

-

64 finally: 

-

65 del instance.skip_signal 

-

66 

-

67 

-

68@receiver(post_save, sender=Step) 

-

69@skip_signal 

-

70def update_step_search_vector(sender, instance=None, created=False, **kwargs): 

-

71 if SQLITE: 

-

72 return 

-

73 language = DICTIONARY.get(translation.get_language(), 'simple') 

-

74 instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language) 

-

75 try: 

-

76 instance.skip_signal = True 

-

77 instance.save() 

-

78 finally: 

-

79 del instance.skip_signal 

-

80 

-

81 

-

82@receiver(post_save, sender=Food) 

-

83@skip_signal 

-

84def update_food_inheritance(sender, instance=None, created=False, **kwargs): 

-

85 if not instance: 

-

86 return 

-

87 

-

88 inherit = instance.inherit_fields.all() 

-

89 # nothing to apply from parent and nothing to apply to children 

-

90 if (not instance.parent or inherit.count() == 0) and instance.numchild == 0: 

-

91 return 

-

92 

-

93 inherit = inherit.values_list('field', flat=True) 

-

94 # apply changes from parent to instance for each inherited field 

-

95 if instance.parent and inherit.count() > 0: 

-

96 parent = instance.get_parent() 

-

97 for field in ['ignore_shopping', 'substitute_children', 'substitute_siblings']: 

-

98 if field in inherit: 

-

99 setattr(instance, field, getattr(parent, field, None)) 

-

100 # if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change 

-

101 if 'supermarket_category' in inherit and parent.supermarket_category: 

-

102 instance.supermarket_category = parent.supermarket_category 

-

103 try: 

-

104 instance.skip_signal = True 

-

105 instance.save() 

-

106 finally: 

-

107 del instance.skip_signal 

-

108 

-

109 # apply changes to direct children - depend on save signals for those objects to cascade inheritance down 

-

110 for child in instance.get_children().filter(inherit_fields__in=Food.inheritable_fields): 

-

111 # set inherited field values 

-

112 for field in (inherit_fields := ['ignore_shopping', 'substitute_children', 'substitute_siblings']): 

-

113 if field in instance.inherit_fields.values_list('field', flat=True): 

-

114 setattr(child, field, getattr(instance, field, None)) 

-

115 

-

116 # don't cascade empty supermarket category 

-

117 if instance.supermarket_category and 'supermarket_category' in inherit_fields: 

-

118 setattr(child, 'supermarket_category', getattr(instance, 'supermarket_category', None)) 

-

119 

-

120 child.save() 

-

121 

-

122 

-

123@receiver(post_save, sender=MealPlan) 

-

124def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs): 

-

125 print("MEAL_AUTO_ADD Signal trying to auto add to shopping") 

-

126 if not instance: 

-

127 print("MEAL_AUTO_ADD Instance is none") 

-

128 return 

-

129 

-

130 try: 

-

131 space = instance.get_space() 

-

132 user = instance.get_owner() 

-

133 with scope(space=space): 

-

134 slr_exists = instance.shoppinglistrecipe_set.exists() 

-

135 

-

136 if not created and slr_exists: 

-

137 for x in instance.shoppinglistrecipe_set.all(): 

-

138 # assuming that permissions checks for the MealPlan have happened upstream 

-

139 if instance.servings != x.servings: 

-

140 SLR = RecipeShoppingEditor(id=x.id, user=user, space=instance.space) 

-

141 SLR.edit_servings(servings=instance.servings) 

-

142 elif not user.userpreference.mealplan_autoadd_shopping or not instance.recipe: 

-

143 print("MEAL_AUTO_ADD No recipe or no setting") 

-

144 return 

-

145 

-

146 if created: 

-

147 SLR = RecipeShoppingEditor(user=user, space=space) 

-

148 SLR.create(mealplan=instance, servings=instance.servings) 

-

149 print("MEAL_AUTO_ADD Created SLR") 

-

150 except AttributeError: 

-

151 pass 

-

152 

-

153 

-

154@receiver(post_save, sender=Unit) 

-

155def clear_unit_cache(sender, instance=None, created=False, **kwargs): 

-

156 if instance: 

-

157 caches['default'].delete(CacheHelper(instance.space).BASE_UNITS_CACHE_KEY) 

-

158 

-

159 

-

160@receiver(post_save, sender=PropertyType) 

-

161def clear_property_type_cache(sender, instance=None, created=False, **kwargs): 

-

162 if instance: 

-

163 caches['default'].delete(CacheHelper(instance.space).PROPERTY_TYPE_CACHE_KEY) 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_tables_py.html b/docs/coverage/d_a167ab5b5108d61e_tables_py.html deleted file mode 100644 index f25204f82e..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_tables_py.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - Coverage for cookbook/tables.py: 92% - - - - - -
-
-

- Coverage for cookbook/tables.py: - 92% -

- -

- 60 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import django_tables2 as tables 

-

2from django.utils.html import format_html 

-

3from django.utils.translation import gettext as _ 

-

4from django_tables2.utils import A 

-

5 

-

6from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog 

-

7 

-

8 

-

9class StorageTable(tables.Table): 

-

10 id = tables.LinkColumn('edit_storage', args=[A('id')]) 

-

11 

-

12 class Meta: 

-

13 model = Storage 

-

14 template_name = 'generic/table_template.html' 

-

15 fields = ('id', 'name', 'method') 

-

16 

-

17 

-

18class ImportLogTable(tables.Table): 

-

19 sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')]) 

-

20 

-

21 @staticmethod 

-

22 def render_status(value): 

-

23 if value == 'SUCCESS': 

-

24 return format_html( 

-

25 '<span class="badge badge-success">%s</span>' % value 

-

26 ) 

-

27 else: 

-

28 return format_html( 

-

29 '<span class="badge badge-danger">%s</span>' % value 

-

30 ) 

-

31 

-

32 class Meta: 

-

33 model = SyncLog 

-

34 template_name = 'generic/table_template.html' 

-

35 fields = ('status', 'msg', 'sync_id', 'created_at') 

-

36 

-

37 

-

38class SyncTable(tables.Table): 

-

39 id = tables.LinkColumn('edit_sync', args=[A('id')]) 

-

40 

-

41 @staticmethod 

-

42 def render_path(value): 

-

43 return format_html('<code>%s</code>' % value) 

-

44 

-

45 @staticmethod 

-

46 def render_storage(value): 

-

47 return format_html( 

-

48 '<span class="badge badge-success">%s</span>' % value 

-

49 ) 

-

50 

-

51 class Meta: 

-

52 model = Sync 

-

53 template_name = 'generic/table_template.html' 

-

54 fields = ('id', 'path', 'storage', 'last_checked') 

-

55 

-

56 

-

57class RecipeImportTable(tables.Table): 

-

58 id = tables.LinkColumn('new_recipe_import', args=[A('id')]) 

-

59 delete = tables.TemplateColumn( 

-

60 "<a href='{% url 'delete_recipe_import' record.id %}' >" + _('Delete') + "</a>" # noqa: E501 

-

61 ) 

-

62 

-

63 class Meta: 

-

64 model = RecipeImport 

-

65 template_name = 'generic/table_template.html' 

-

66 fields = ('id', 'name', 'file_path') 

-

67 

-

68 

-

69class InviteLinkTable(tables.Table): 

-

70 link = tables.TemplateColumn( 

-

71 "<input value='{{ request.scheme }}://{{ request.get_host }}{% url 'view_invite' record.uuid %}' class='form-control' />" 

-

72 ) 

-

73 delete_link = tables.TemplateColumn( 

-

74 "<a href='{% url 'delete_invite_link' record.pk %}' >" + _('Delete') + "</a>", verbose_name=_('Delete') 

-

75 ) 

-

76 

-

77 class Meta: 

-

78 model = InviteLink 

-

79 template_name = 'generic/table_template.html' 

-

80 fields = ( 

-

81 'username', 'group', 'valid_until', 

-

82 ) 

-

83 

-

84 

-

85class ViewLogTable(tables.Table): 

-

86 recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')]) 

-

87 

-

88 class Meta: 

-

89 model = ViewLog 

-

90 template_name = 'generic/table_template.html' 

-

91 fields = ('recipe', 'created_at') 

-

92 

-

93 

-

94class CookLogTable(tables.Table): 

-

95 recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')]) 

-

96 

-

97 class Meta: 

-

98 model = CookLog 

-

99 template_name = 'generic/table_template.html' 

-

100 fields = ('recipe', 'rating', 'serving', 'created_at') 

-
- - - diff --git a/docs/coverage/d_a167ab5b5108d61e_version_info_py.html b/docs/coverage/d_a167ab5b5108d61e_version_info_py.html deleted file mode 100644 index e655e4aecf..0000000000 --- a/docs/coverage/d_a167ab5b5108d61e_version_info_py.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - Coverage for cookbook/version_info.py: 100% - - - - - -
-
-

- Coverage for cookbook/version_info.py: - 100% -

- -

- 3 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1TANDOOR_VERSION = "" 

-

2TANDOOR_REF = "" 

-

3VERSION_INFO = [] 

-
- - - diff --git a/docs/coverage/d_b7ebbfe037735c69_middleware_py.html b/docs/coverage/d_b7ebbfe037735c69_middleware_py.html deleted file mode 100644 index 7b2e2484a5..0000000000 --- a/docs/coverage/d_b7ebbfe037735c69_middleware_py.html +++ /dev/null @@ -1,169 +0,0 @@ - - - - - Coverage for recipes/middleware.py: 0% - - - - - -
-
-

- Coverage for recipes/middleware.py: - 0% -

- -

- 46 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from os import getenv 

-

2 

-

3from django.conf import settings 

-

4from django.contrib.auth.middleware import RemoteUserMiddleware 

-

5from django.db import connection 

-

6 

-

7 

-

8class CustomRemoteUser(RemoteUserMiddleware): 

-

9 header = getenv('PROXY_HEADER', 'HTTP_REMOTE_USER') 

-

10 

-

11 

-

12""" 

-

13Gist code by vstoykov, you can check his original gist at: 

-

14https://gist.github.com/vstoykov/1390853/5d2e8fac3ca2b2ada8c7de2fb70c021e50927375 

-

15Changes: 

-

16Ignoring static file requests and a certain useless admin request from triggering the logger. 

-

17Updated statements to make it Python 3 friendly. 

-

18""" 

-

19 

-

20 

-

21def terminal_width(): 

-

22 """ 

-

23 Function to compute the terminal width. 

-

24 """ 

-

25 width = 0 

-

26 try: 

-

27 import fcntl 

-

28 import struct 

-

29 import termios 

-

30 s = struct.pack('HHHH', 0, 0, 0, 0) 

-

31 x = fcntl.ioctl(1, termios.TIOCGWINSZ, s) 

-

32 width = struct.unpack('HHHH', x)[1] 

-

33 except Exception: 

-

34 pass 

-

35 if width <= 0: 

-

36 try: 

-

37 width = int(getenv['COLUMNS']) 

-

38 except Exception: 

-

39 pass 

-

40 if width <= 0: 

-

41 width = 80 

-

42 return width 

-

43 

-

44 

-

45def SqlPrintingMiddleware(get_response): 

-

46 def middleware(request): 

-

47 response = get_response(request) 

-

48 if ( 

-

49 not settings.DEBUG 

-

50 or len(connection.queries) == 0 

-

51 or request.path_info.startswith(settings.MEDIA_URL) 

-

52 or '/admin/jsi18n/' in request.path_info 

-

53 ): 

-

54 return response 

-

55 

-

56 indentation = 2 

-

57 print("\n\n%s\033[1;35m[SQL Queries for]\033[1;34m %s\033[0m\n" % (" " * indentation, request.path_info)) 

-

58 width = terminal_width() 

-

59 total_time = 0.0 

-

60 for query in connection.queries: 

-

61 nice_sql = query['sql'].replace('"', '').replace(',', ', ') 

-

62 sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql) 

-

63 total_time = total_time + float(query['time']) 

-

64 while len(sql) > width - indentation: 

-

65 # print("%s%s" % (" " * indentation, sql[:width - indentation])) 

-

66 sql = sql[width - indentation:] 

-

67 # print("%s%s\n" % (" " * indentation, sql)) 

-

68 replace_tuple = (" " * indentation, str(total_time)) 

-

69 print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple) 

-

70 print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries))) 

-

71 return response 

-

72 return middleware 

-
- - - diff --git a/docs/coverage/d_cc5b0727f68102d6_cooksillustrated_py.html b/docs/coverage/d_cc5b0727f68102d6_cooksillustrated_py.html deleted file mode 100644 index ee4077d13c..0000000000 --- a/docs/coverage/d_cc5b0727f68102d6_cooksillustrated_py.html +++ /dev/null @@ -1,165 +0,0 @@ - - - - - Coverage for cookbook/helper/scrapers/cooksillustrated.py: 82% - - - - - -
-
-

- Coverage for cookbook/helper/scrapers/cooksillustrated.py: - 82% -

- -

- 38 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2from recipe_scrapers._abstract import AbstractScraper 

-

3 

-

4 

-

5class CooksIllustrated(AbstractScraper): 

-

6 @classmethod 

-

7 def host(cls, site='cooksillustrated'): 

-

8 return { 

-

9 'cooksillustrated': f"{site}.com", 

-

10 'americastestkitchen': f"{site}.com", 

-

11 'cookscountry': f"{site}.com", 

-

12 }.get(site) 

-

13 

-

14 def title(self): 

-

15 return self.schema.title() 

-

16 

-

17 def image(self): 

-

18 return self.schema.image() 

-

19 

-

20 def total_time(self): 

-

21 if not self.recipe: 

-

22 self.get_recipe() 

-

23 return self.recipe['recipeTimeNote'] 

-

24 

-

25 def yields(self): 

-

26 if not self.recipe: 

-

27 self.get_recipe() 

-

28 return self.recipe['yields'] 

-

29 

-

30 def ingredients(self): 

-

31 if not self.recipe: 

-

32 self.get_recipe() 

-

33 ingredients = [] 

-

34 for group in self.recipe['ingredientGroups']: 

-

35 ingredients += group['fields']['recipeIngredientItems'] 

-

36 return [ 

-

37 "{} {} {}{}".format( 

-

38 i['fields']['qty'] or '', 

-

39 i['fields']['measurement'] or '', 

-

40 i['fields']['ingredient']['fields']['title'] or '', 

-

41 i['fields']['postText'] or '' 

-

42 ) 

-

43 for i in ingredients 

-

44 ] 

-

45 

-

46 def instructions(self): 

-

47 if not self.recipe: 

-

48 self.get_recipe() 

-

49 if self.recipe.get('headnote', False): 

-

50 i = ['Note: ' + self.recipe.get('headnote', '')] 

-

51 else: 

-

52 i = [] 

-

53 return "\n".join( 

-

54 i 

-

55 + [self.recipe.get('whyThisWorks', '')] 

-

56 + [ 

-

57 instruction['fields']['content'] 

-

58 for instruction in self.recipe['instructions'] 

-

59 ] 

-

60 ) 

-

61 

-

62 def nutrients(self): 

-

63 raise NotImplementedError("This should be implemented.") 

-

64 

-

65 def get_recipe(self): 

-

66 j = json.loads(self.soup.find(type='application/json').string) 

-

67 name = list(j['props']['initialState']['content']['documents'])[0] 

-

68 self.recipe = j['props']['initialState']['content']['documents'][name] 

-
- - - diff --git a/docs/coverage/d_cc5b0727f68102d6_scrapers_py.html b/docs/coverage/d_cc5b0727f68102d6_scrapers_py.html deleted file mode 100644 index 1f98830c50..0000000000 --- a/docs/coverage/d_cc5b0727f68102d6_scrapers_py.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - Coverage for cookbook/helper/scrapers/scrapers.py: 93% - - - - - -
-
-

- Coverage for cookbook/helper/scrapers/scrapers.py: - 93% -

- -

- 27 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from json import JSONDecodeError 

-

2 

-

3from bs4 import BeautifulSoup 

-

4from recipe_scrapers import SCRAPERS, get_host_name 

-

5from recipe_scrapers._factory import SchemaScraperFactory 

-

6from recipe_scrapers._schemaorg import SchemaOrg 

-

7 

-

8from .cooksillustrated import CooksIllustrated 

-

9 

-

10CUSTOM_SCRAPERS = { 

-

11 CooksIllustrated.host(site="cooksillustrated"): CooksIllustrated, 

-

12 CooksIllustrated.host(site="americastestkitchen"): CooksIllustrated, 

-

13 CooksIllustrated.host(site="cookscountry"): CooksIllustrated, 

-

14} 

-

15SCRAPERS.update(CUSTOM_SCRAPERS) 

-

16 

-

17 

-

18def text_scraper(text, url=None): 

-

19 domain = None 

-

20 if url: 

-

21 domain = get_host_name(url) 

-

22 if domain in SCRAPERS: 

-

23 scraper_class = SCRAPERS[domain] 

-

24 else: 

-

25 scraper_class = SchemaScraperFactory.SchemaScraper 

-

26 

-

27 class TextScraper(scraper_class): 

-

28 def __init__( 

-

29 self, 

-

30 html=None, 

-

31 url=None, 

-

32 ): 

-

33 self.wild_mode = False 

-

34 self.meta_http_equiv = False 

-

35 self.soup = BeautifulSoup(html, "html.parser") 

-

36 self.url = url 

-

37 self.recipe = None 

-

38 try: 

-

39 self.schema = SchemaOrg(html) 

-

40 except (JSONDecodeError, AttributeError): 

-

41 pass 

-

42 

-

43 return TextScraper(url=url, html=text) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_api_py.html b/docs/coverage/d_dd189b0e5315428c_api_py.html deleted file mode 100644 index 019f442619..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_api_py.html +++ /dev/null @@ -1,1832 +0,0 @@ - - - - - Coverage for cookbook/views/api.py: 65% - - - - - -
-
-

- Coverage for cookbook/views/api.py: - 65% -

- -

- 1058 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import datetime 

-

2import io 

-

3import json 

-

4import mimetypes 

-

5import pathlib 

-

6import re 

-

7import threading 

-

8import traceback 

-

9import uuid 

-

10from collections import OrderedDict 

-

11from json import JSONDecodeError 

-

12from urllib.parse import unquote 

-

13from zipfile import ZipFile 

-

14 

-

15import requests 

-

16import validators 

-

17from annoying.decorators import ajax_request 

-

18from annoying.functions import get_object_or_None 

-

19from django.contrib import messages 

-

20from django.contrib.auth.models import Group, User 

-

21from django.contrib.postgres.search import TrigramSimilarity 

-

22from django.core.cache import caches 

-

23from django.core.exceptions import FieldError, ValidationError 

-

24from django.core.files import File 

-

25from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When 

-

26from django.db.models.fields.related import ForeignObjectRel 

-

27from django.db.models.functions import Coalesce, Lower 

-

28from django.db.models.signals import post_save 

-

29from django.http import FileResponse, HttpResponse, JsonResponse 

-

30from django.shortcuts import get_object_or_404, redirect 

-

31from django.urls import reverse 

-

32from django.utils import timezone 

-

33from django.utils.translation import gettext as _ 

-

34from django_scopes import scopes_disabled 

-

35from icalendar import Calendar, Event 

-

36from oauth2_provider.models import AccessToken 

-

37from PIL import UnidentifiedImageError 

-

38from recipe_scrapers import scrape_me 

-

39from recipe_scrapers._exceptions import NoSchemaFoundInWildMode 

-

40from requests.exceptions import MissingSchema 

-

41from rest_framework import decorators, status, viewsets 

-

42from rest_framework.authtoken.views import ObtainAuthToken 

-

43from rest_framework.decorators import api_view, permission_classes 

-

44from rest_framework.exceptions import APIException, PermissionDenied 

-

45from rest_framework.pagination import PageNumberPagination 

-

46from rest_framework.parsers import MultiPartParser 

-

47from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer 

-

48from rest_framework.response import Response 

-

49from rest_framework.throttling import AnonRateThrottle, UserRateThrottle 

-

50from rest_framework.views import APIView 

-

51from rest_framework.viewsets import ViewSetMixin 

-

52from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow 

-

53 

-

54from cookbook.forms import ImportForm 

-

55from cookbook.helper import recipe_url_import as helper 

-

56from cookbook.helper.HelperFunctions import str2bool 

-

57from cookbook.helper.image_processing import handle_image 

-

58from cookbook.helper.ingredient_parser import IngredientParser 

-

59from cookbook.helper.open_data_importer import OpenDataImporter 

-

60from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, 

-

61 CustomIsShared, CustomIsSpaceOwner, CustomIsUser, 

-

62 CustomRecipePermission, CustomTokenHasReadWriteScope, 

-

63 CustomTokenHasScope, CustomUserPermission, 

-

64 IsReadOnlyDRF, above_space_limit, group_required, 

-

65 has_group_permission, is_space_owner, 

-

66 switch_user_active_space) 

-

67from cookbook.helper.recipe_search import RecipeSearch 

-

68from cookbook.helper.recipe_url_import import (clean_dict, get_from_youtube_scraper, 

-

69 get_images_from_soup) 

-

70from cookbook.helper.scrapers.scrapers import text_scraper 

-

71from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper 

-

72from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food, 

-

73 FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink, 

-

74 Keyword, MealPlan, MealType, Property, PropertyType, Recipe, 

-

75 RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, 

-

76 ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, 

-

77 Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, 

-

78 SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace, 

-

79 ViewLog) 

-

80from cookbook.provider.dropbox import Dropbox 

-

81from cookbook.provider.local import Local 

-

82from cookbook.provider.nextcloud import Nextcloud 

-

83from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema 

-

84from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, 

-

85 AutoMealPlanSerializer, BookmarkletImportListSerializer, 

-

86 BookmarkletImportSerializer, CookLogSerializer, 

-

87 CustomFilterSerializer, ExportLogSerializer, 

-

88 FoodInheritFieldSerializer, FoodSerializer, 

-

89 FoodShoppingUpdateSerializer, FoodSimpleSerializer, 

-

90 GroupSerializer, ImportLogSerializer, IngredientSerializer, 

-

91 IngredientSimpleSerializer, InviteLinkSerializer, 

-

92 KeywordSerializer, MealPlanSerializer, MealTypeSerializer, 

-

93 PropertySerializer, PropertyTypeSerializer, 

-

94 RecipeBookEntrySerializer, RecipeBookSerializer, 

-

95 RecipeExportSerializer, RecipeFromSourceSerializer, 

-

96 RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer, 

-

97 RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, 

-

98 ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer, 

-

99 ShoppingListRecipeSerializer, ShoppingListSerializer, 

-

100 SpaceSerializer, StepSerializer, StorageSerializer, 

-

101 SupermarketCategoryRelationSerializer, 

-

102 SupermarketCategorySerializer, SupermarketSerializer, 

-

103 SyncLogSerializer, SyncSerializer, UnitConversionSerializer, 

-

104 UnitSerializer, UserFileSerializer, UserPreferenceSerializer, 

-

105 UserSerializer, UserSpaceSerializer, ViewLogSerializer) 

-

106from cookbook.views.import_export import get_integration 

-

107from recipes import settings 

-

108from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT 

-

109 

-

110 

-

111class StandardFilterMixin(ViewSetMixin): 

-

112 def get_queryset(self): 

-

113 queryset = self.queryset 

-

114 query = self.request.query_params.get('query', None) 

-

115 if query is not None: 

-

116 queryset = queryset.filter(name__icontains=query) 

-

117 

-

118 updated_at = self.request.query_params.get('updated_at', None) 

-

119 if updated_at is not None: 

-

120 try: 

-

121 queryset = queryset.filter(updated_at__gte=updated_at) 

-

122 except FieldError: 

-

123 pass 

-

124 except ValidationError: 

-

125 raise APIException(_('Parameter updated_at incorrectly formatted')) 

-

126 

-

127 limit = self.request.query_params.get('limit', None) 

-

128 random = self.request.query_params.get('random', False) 

-

129 if limit is not None: 

-

130 if random: 

-

131 queryset = queryset.order_by("?")[:int(limit)] 

-

132 else: 

-

133 queryset = queryset[:int(limit)] 

-

134 return queryset 

-

135 

-

136 

-

137class DefaultPagination(PageNumberPagination): 

-

138 page_size = 50 

-

139 page_size_query_param = 'page_size' 

-

140 max_page_size = 200 

-

141 

-

142 

-

143class ExtendedRecipeMixin(): 

-

144 ''' 

-

145 ExtendedRecipe annotates a queryset with recipe_image and recipe_count values 

-

146 ''' 

-

147 

-

148 @classmethod 

-

149 def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False): 

-

150 extended = str2bool(request.query_params.get('extended', None)) 

-

151 if extended: 

-

152 recipe_filter = serializer.recipe_filter 

-

153 images = serializer.images 

-

154 space = request.space 

-

155 

-

156 # add a recipe count annotation to the query 

-

157 # explanation on construction https://stackoverflow.com/a/43771738/15762829 

-

158 recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk', distinct=True)).values('count') 

-

159 queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0)) 

-

160 

-

161 # add a recipe image annotation to the query 

-

162 image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude( 

-

163 image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] 

-

164 if tree: 

-

165 image_children_subquery = Recipe.objects.filter( 

-

166 **{f"{recipe_filter}__path__startswith": OuterRef('path')}, 

-

167 space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] 

-

168 else: 

-

169 image_children_subquery = None 

-

170 if images: 

-

171 queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery)) 

-

172 else: 

-

173 queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery)) 

-

174 return queryset 

-

175 

-

176 

-

177class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): 

-

178 schema = FilterSchema() 

-

179 

-

180 def get_queryset(self): 

-

181 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) 

-

182 query = self.request.query_params.get('query', None) 

-

183 if self.request.user.is_authenticated: 

-

184 fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in 

-

185 self.request.user.searchpreference.trigram.values_list( 

-

186 'field', flat=True)]) 

-

187 else: 

-

188 fuzzy = True 

-

189 

-

190 if query is not None and query not in ["''", '']: 

-

191 if fuzzy and (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'): 

-

192 if self.request.user.is_authenticated and any( 

-

193 [self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): 

-

194 self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query)) 

-

195 else: 

-

196 self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)) 

-

197 self.queryset = self.queryset.order_by('-trigram') 

-

198 else: 

-

199 # TODO have this check unaccent search settings or other search preferences? 

-

200 filter = Q(name__icontains=query) 

-

201 if self.request.user.is_authenticated: 

-

202 if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): 

-

203 filter |= Q(name__unaccent__icontains=query) 

-

204 

-

205 self.queryset = ( 

-

206 self.queryset.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), 

-

207 default=Value(0))) # put exact matches at the top of the result set 

-

208 .filter(filter).order_by('-starts', Lower('name').asc()) 

-

209 ) 

-

210 

-

211 updated_at = self.request.query_params.get('updated_at', None) 

-

212 if updated_at is not None: 

-

213 try: 

-

214 self.queryset = self.queryset.filter(updated_at__gte=updated_at) 

-

215 except FieldError: 

-

216 pass 

-

217 except ValidationError: 

-

218 raise APIException(_('Parameter updated_at incorrectly formatted')) 

-

219 

-

220 limit = self.request.query_params.get('limit', None) 

-

221 random = self.request.query_params.get('random', False) 

-

222 if random: 

-

223 self.queryset = self.queryset.order_by("?") 

-

224 if limit is not None: 

-

225 self.queryset = self.queryset[:int(limit)] 

-

226 return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class) 

-

227 

-

228 

-

229class MergeMixin(ViewSetMixin): 

-

230 @decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], ) 

-

231 @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) 

-

232 def merge(self, request, pk, target): 

-

233 self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]." 

-

234 

-

235 try: 

-

236 source = self.model.objects.get(pk=pk, space=self.request.space) 

-

237 except (self.model.DoesNotExist): 

-

238 content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')} 

-

239 return Response(content, status=status.HTTP_404_NOT_FOUND) 

-

240 

-

241 if int(target) == source.id: 

-

242 content = {'error': True, 'msg': _('Cannot merge with the same object!')} 

-

243 return Response(content, status=status.HTTP_403_FORBIDDEN) 

-

244 

-

245 else: 

-

246 try: 

-

247 target = self.model.objects.get(pk=target, space=self.request.space) 

-

248 except (self.model.DoesNotExist): 

-

249 content = {'error': True, 'msg': _(f'No {self.basename} with id {target} exists')} 

-

250 return Response(content, status=status.HTTP_404_NOT_FOUND) 

-

251 

-

252 try: 

-

253 if target in source.get_descendants_and_self(): 

-

254 content = {'error': True, 'msg': _('Cannot merge with child object!')} 

-

255 return Response(content, status=status.HTTP_403_FORBIDDEN) 

-

256 isTree = True 

-

257 except AttributeError: 

-

258 # AttributeError probably means its not a tree, so can safely ignore 

-

259 isTree = False 

-

260 

-

261 try: 

-

262 if isinstance(source, Food): 

-

263 source.properties.remove() 

-

264 

-

265 for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]: 

-

266 linkManager = getattr(source, link.get_accessor_name()) 

-

267 related = linkManager.all() 

-

268 # link to foreign relationship could be OneToMany or ManyToMany 

-

269 if link.one_to_many: 

-

270 for r in related: 

-

271 setattr(r, link.field.name, target) 

-

272 r.save() 

-

273 elif link.many_to_many: 

-

274 for r in related: 

-

275 getattr(r, link.field.name).add(target) 

-

276 getattr(r, link.field.name).remove(source) 

-

277 r.save() 

-

278 else: 

-

279 # a new scenario exists and needs to be handled 

-

280 raise NotImplementedError 

-

281 if isTree: 

-

282 if self.model.node_order_by: 

-

283 node_location = 'sorted-child' 

-

284 else: 

-

285 node_location = 'last-child' 

-

286 

-

287 children = source.get_children().exclude(id=target.id) 

-

288 for c in children: 

-

289 c.move(target, node_location) 

-

290 content = {'msg': _(f'{source.name} was merged successfully with {target.name}')} 

-

291 source.delete() 

-

292 return Response(content, status=status.HTTP_200_OK) 

-

293 except Exception: 

-

294 traceback.print_exc() 

-

295 content = {'error': True, 

-

296 'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')} 

-

297 return Response(content, status=status.HTTP_400_BAD_REQUEST) 

-

298 

-

299 

-

300class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin): 

-

301 schema = TreeSchema() 

-

302 model = None 

-

303 

-

304 def get_queryset(self): 

-

305 root = self.request.query_params.get('root', None) 

-

306 tree = self.request.query_params.get('tree', None) 

-

307 

-

308 if root: 

-

309 if root.isnumeric(): 

-

310 try: 

-

311 root = int(root) 

-

312 except ValueError: 

-

313 self.queryset = self.model.objects.none() 

-

314 

-

315 if root == 0: 

-

316 self.queryset = self.model.get_root_nodes() 

-

317 else: 

-

318 self.queryset = self.model.objects.get(id=root).get_children() 

-

319 elif tree: 

-

320 if tree.isnumeric(): 

-

321 try: 

-

322 self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self() 

-

323 except self.model.DoesNotExist: 

-

324 self.queryset = self.model.objects.none() 

-

325 else: 

-

326 return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True) 

-

327 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) 

-

328 

-

329 return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, 

-

330 tree=True) 

-

331 

-

332 @decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], ) 

-

333 @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) 

-

334 def move(self, request, pk, parent): 

-

335 self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root." 

-

336 if self.model.node_order_by: 

-

337 node_location = 'sorted' 

-

338 else: 

-

339 node_location = 'last' 

-

340 

-

341 try: 

-

342 child = self.model.objects.get(pk=pk, space=self.request.space) 

-

343 except (self.model.DoesNotExist): 

-

344 content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')} 

-

345 return Response(content, status=status.HTTP_404_NOT_FOUND) 

-

346 

-

347 parent = int(parent) 

-

348 # parent 0 is root of the tree 

-

349 if parent == 0: 

-

350 try: 

-

351 with scopes_disabled(): 

-

352 child.move(self.model.get_first_root_node(), f'{node_location}-sibling') 

-

353 content = {'msg': _(f'{child.name} was moved successfully to the root.')} 

-

354 return Response(content, status=status.HTTP_200_OK) 

-

355 except (PathOverflow, InvalidMoveToDescendant, InvalidPosition): 

-

356 content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name} 

-

357 return Response(content, status=status.HTTP_400_BAD_REQUEST) 

-

358 elif parent == child.id: 

-

359 content = {'error': True, 'msg': _('Cannot move an object to itself!')} 

-

360 return Response(content, status=status.HTTP_403_FORBIDDEN) 

-

361 

-

362 try: 

-

363 parent = self.model.objects.get(pk=parent, space=self.request.space) 

-

364 except (self.model.DoesNotExist): 

-

365 content = {'error': True, 'msg': _(f'No {self.basename} with id {parent} exists')} 

-

366 return Response(content, status=status.HTTP_404_NOT_FOUND) 

-

367 

-

368 try: 

-

369 with scopes_disabled(): 

-

370 child.move(parent, f'{node_location}-child') 

-

371 content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')} 

-

372 return Response(content, status=status.HTTP_200_OK) 

-

373 except (PathOverflow, InvalidMoveToDescendant, InvalidPosition): 

-

374 content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name} 

-

375 return Response(content, status=status.HTTP_400_BAD_REQUEST) 

-

376 

-

377 

-

378class UserViewSet(viewsets.ModelViewSet): 

-

379 """ 

-

380 list: 

-

381 optional parameters 

-

382 

-

383 - **filter_list**: array of user id's to get names for 

-

384 """ 

-

385 queryset = User.objects 

-

386 serializer_class = UserSerializer 

-

387 permission_classes = [CustomUserPermission & CustomTokenHasReadWriteScope] 

-

388 http_method_names = ['get', 'patch'] 

-

389 

-

390 def get_queryset(self): 

-

391 queryset = self.queryset.filter(userspace__space=self.request.space) 

-

392 try: 

-

393 filter_list = self.request.query_params.get('filter_list', None) 

-

394 if filter_list is not None: 

-

395 queryset = queryset.filter(pk__in=json.loads(filter_list)) 

-

396 except ValueError: 

-

397 raise APIException('Parameter filter_list incorrectly formatted') 

-

398 

-

399 return queryset 

-

400 

-

401 

-

402class GroupViewSet(viewsets.ModelViewSet): 

-

403 queryset = Group.objects.all() 

-

404 serializer_class = GroupSerializer 

-

405 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

406 http_method_names = ['get', ] 

-

407 

-

408 

-

409class SpaceViewSet(viewsets.ModelViewSet): 

-

410 queryset = Space.objects 

-

411 serializer_class = SpaceSerializer 

-

412 permission_classes = [IsReadOnlyDRF & CustomIsUser | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

413 http_method_names = ['get', 'patch'] 

-

414 

-

415 def get_queryset(self): 

-

416 return self.queryset.filter(id=self.request.space.id) 

-

417 

-

418 

-

419class UserSpaceViewSet(viewsets.ModelViewSet): 

-

420 queryset = UserSpace.objects 

-

421 serializer_class = UserSpaceSerializer 

-

422 permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope] 

-

423 http_method_names = ['get', 'patch', 'delete'] 

-

424 pagination_class = DefaultPagination 

-

425 

-

426 def destroy(self, request, *args, **kwargs): 

-

427 if request.space.created_by == UserSpace.objects.get(pk=kwargs['pk']).user: 

-

428 raise APIException('Cannot delete Space owner permission.') 

-

429 return super().destroy(request, *args, **kwargs) 

-

430 

-

431 def get_queryset(self): 

-

432 internal_note = self.request.query_params.get('internal_note', None) 

-

433 if internal_note is not None: 

-

434 self.queryset = self.queryset.filter(internal_note=internal_note) 

-

435 

-

436 if is_space_owner(self.request.user, self.request.space): 

-

437 return self.queryset.filter(space=self.request.space) 

-

438 else: 

-

439 return self.queryset.filter(user=self.request.user, space=self.request.space) 

-

440 

-

441 

-

442class UserPreferenceViewSet(viewsets.ModelViewSet): 

-

443 queryset = UserPreference.objects 

-

444 serializer_class = UserPreferenceSerializer 

-

445 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

446 http_method_names = ['get', 'patch', ] 

-

447 

-

448 def get_queryset(self): 

-

449 with scopes_disabled(): # need to disable scopes as user preference is no longer a spaced method 

-

450 return self.queryset.filter(user=self.request.user) 

-

451 

-

452 

-

453class StorageViewSet(viewsets.ModelViewSet): 

-

454 # TODO handle delete protect error and adjust test 

-

455 queryset = Storage.objects 

-

456 serializer_class = StorageSerializer 

-

457 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

458 

-

459 def get_queryset(self): 

-

460 return self.queryset.filter(space=self.request.space) 

-

461 

-

462 

-

463class SyncViewSet(viewsets.ModelViewSet): 

-

464 queryset = Sync.objects 

-

465 serializer_class = SyncSerializer 

-

466 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

467 

-

468 def get_queryset(self): 

-

469 return self.queryset.filter(space=self.request.space) 

-

470 

-

471 

-

472class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): 

-

473 queryset = SyncLog.objects 

-

474 serializer_class = SyncLogSerializer 

-

475 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

476 pagination_class = DefaultPagination 

-

477 

-

478 def get_queryset(self): 

-

479 return self.queryset.filter(sync__space=self.request.space) 

-

480 

-

481 

-

482class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

483 queryset = Supermarket.objects 

-

484 serializer_class = SupermarketSerializer 

-

485 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

486 

-

487 def get_queryset(self): 

-

488 self.queryset = self.queryset.filter(space=self.request.space) 

-

489 return super().get_queryset() 

-

490 

-

491 

-

492class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): 

-

493 queryset = SupermarketCategory.objects 

-

494 model = SupermarketCategory 

-

495 serializer_class = SupermarketCategorySerializer 

-

496 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

497 

-

498 def get_queryset(self): 

-

499 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) 

-

500 return super().get_queryset() 

-

501 

-

502 

-

503class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

504 queryset = SupermarketCategoryRelation.objects 

-

505 serializer_class = SupermarketCategoryRelationSerializer 

-

506 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

507 pagination_class = DefaultPagination 

-

508 

-

509 def get_queryset(self): 

-

510 self.queryset = self.queryset.filter(supermarket__space=self.request.space).order_by('order') 

-

511 return super().get_queryset() 

-

512 

-

513 

-

514class KeywordViewSet(viewsets.ModelViewSet, TreeMixin): 

-

515 queryset = Keyword.objects 

-

516 model = Keyword 

-

517 serializer_class = KeywordSerializer 

-

518 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

519 pagination_class = DefaultPagination 

-

520 

-

521 

-

522class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): 

-

523 queryset = Unit.objects 

-

524 model = Unit 

-

525 serializer_class = UnitSerializer 

-

526 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

527 pagination_class = DefaultPagination 

-

528 

-

529 

-

530class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): 

-

531 queryset = FoodInheritField.objects 

-

532 serializer_class = FoodInheritFieldSerializer 

-

533 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

534 

-

535 def get_queryset(self): 

-

536 # exclude fields not yet implemented 

-

537 self.queryset = Food.inheritable_fields 

-

538 return super().get_queryset() 

-

539 

-

540 

-

541class FoodViewSet(viewsets.ModelViewSet, TreeMixin): 

-

542 queryset = Food.objects 

-

543 model = Food 

-

544 serializer_class = FoodSerializer 

-

545 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

546 pagination_class = DefaultPagination 

-

547 

-

548 def get_queryset(self): 

-

549 shared_users = [] 

-

550 if c := caches['default'].get( 

-

551 f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None): 

-

552 shared_users = c 

-

553 else: 

-

554 try: 

-

555 shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [ 

-

556 self.request.user.id] 

-

557 caches['default'].set( 

-

558 f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', 

-

559 shared_users, timeout=5 * 60) 

-

560 # TODO ugly hack that improves API performance significantly, should be done properly 

-

561 except AttributeError: # Anonymous users (using share links) don't have shared users 

-

562 pass 

-

563 

-

564 self.queryset = super().get_queryset() 

-

565 shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), 

-

566 checked=False).values('id') 

-

567 # onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users])) 

-

568 return self.queryset \ 

-

569 .annotate(shopping_status=Exists(shopping_status)) \ 

-

570 .prefetch_related('onhand_users', 'inherit_fields', 'child_inherit_fields', 'substitute') \ 

-

571 .select_related('recipe', 'supermarket_category') 

-

572 

-

573 def get_serializer_class(self): 

-

574 if self.request and self.request.query_params.get('simple', False): 

-

575 return FoodSimpleSerializer 

-

576 return self.serializer_class 

-

577 

-

578 @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer, ) 

-

579 # TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably 

-

580 def shopping(self, request, pk): 

-

581 if self.request.space.demo: 

-

582 raise PermissionDenied(detail='Not available in demo', code=None) 

-

583 obj = self.get_object() 

-

584 shared_users = list(self.request.user.get_shopping_share()) 

-

585 shared_users.append(request.user) 

-

586 if request.data.get('_delete', False) == 'true': 

-

587 ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, 

-

588 created_by__in=shared_users).delete() 

-

589 content = {'msg': _(f'{obj.name} was removed from the shopping list.')} 

-

590 return Response(content, status=status.HTTP_204_NO_CONTENT) 

-

591 

-

592 amount = request.data.get('amount', 1) 

-

593 unit = request.data.get('unit', None) 

-

594 content = {'msg': _(f'{obj.name} was added to the shopping list.')} 

-

595 

-

596 ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, 

-

597 created_by=request.user) 

-

598 return Response(content, status=status.HTTP_204_NO_CONTENT) 

-

599 

-

600 @decorators.action(detail=True, methods=['POST'], ) 

-

601 def fdc(self, request, pk): 

-

602 """ 

-

603 updates the food with all possible data from the FDC Api 

-

604 if properties with a fdc_id already exist they will be overridden, if existing properties don't have a fdc_id they won't be changed 

-

605 """ 

-

606 food = self.get_object() 

-

607 

-

608 response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}') 

-

609 if response.status_code == 429: 

-

610 return JsonResponse({'msg', 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429, 

-

611 json_dumps_params={'indent': 4}) 

-

612 

-

613 try: 

-

614 data = json.loads(response.content) 

-

615 

-

616 food_property_list = [] 

-

617 

-

618 # delete all properties where the property type has a fdc_id as these should be overridden 

-

619 for fp in food.properties.all(): 

-

620 if fp.property_type.fdc_id: 

-

621 fp.delete() 

-

622 

-

623 for pt in PropertyType.objects.filter(space=request.space, fdc_id__gte=0).all(): 

-

624 if pt.fdc_id: 

-

625 for fn in data['foodNutrients']: 

-

626 if fn['nutrient']['id'] == pt.fdc_id: 

-

627 food_property_list.append(Property( 

-

628 property_type_id=pt.id, 

-

629 property_amount=round(fn['amount'], 2), 

-

630 import_food_id=food.id, 

-

631 space=self.request.space, 

-

632 )) 

-

633 

-

634 Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',)) 

-

635 

-

636 property_food_relation_list = [] 

-

637 for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ): 

-

638 property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1])) 

-

639 

-

640 FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',)) 

-

641 Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None) 

-

642 

-

643 return self.retrieve(request, pk) 

-

644 except Exception: 

-

645 traceback.print_exc() 

-

646 return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'}, status=500, json_dumps_params={'indent': 4}) 

-

647 

-

648 def destroy(self, *args, **kwargs): 

-

649 try: 

-

650 return (super().destroy(self, *args, **kwargs)) 

-

651 except ProtectedError as e: 

-

652 content = {'error': True, 'msg': e.args[0]} 

-

653 return Response(content, status=status.HTTP_403_FORBIDDEN) 

-

654 

-

655 

-

656class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

657 queryset = RecipeBook.objects 

-

658 serializer_class = RecipeBookSerializer 

-

659 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

660 

-

661 def get_queryset(self): 

-

662 self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( 

-

663 space=self.request.space).distinct() 

-

664 return super().get_queryset() 

-

665 

-

666 

-

667class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet): 

-

668 """ 

-

669 list: 

-

670 optional parameters 

-

671 

-

672 - **recipe**: id of recipe - only return books for that recipe 

-

673 - **book**: id of book - only return recipes in that book 

-

674 

-

675 """ 

-

676 queryset = RecipeBookEntry.objects 

-

677 serializer_class = RecipeBookEntrySerializer 

-

678 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

679 

-

680 def get_queryset(self): 

-

681 queryset = self.queryset.filter( 

-

682 Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter( 

-

683 book__space=self.request.space).distinct() 

-

684 

-

685 recipe_id = self.request.query_params.get('recipe', None) 

-

686 if recipe_id is not None: 

-

687 queryset = queryset.filter(recipe__pk=recipe_id) 

-

688 

-

689 book_id = self.request.query_params.get('book', None) 

-

690 if book_id is not None: 

-

691 queryset = queryset.filter(book__pk=book_id) 

-

692 return queryset 

-

693 

-

694 

-

695class MealPlanViewSet(viewsets.ModelViewSet): 

-

696 """ 

-

697 list: 

-

698 optional parameters 

-

699 

-

700 - **from_date**: filter from (inclusive) a certain date onward 

-

701 - **to_date**: filter upward to (inclusive) certain date 

-

702 - **meal_type**: filter meal plans based on meal_type ID 

-

703 

-

704 """ 

-

705 queryset = MealPlan.objects 

-

706 serializer_class = MealPlanSerializer 

-

707 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

708 query_params = [ 

-

709 QueryParam(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'), 

-

710 QueryParam(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'), 

-

711 QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='int'), 

-

712 ] 

-

713 schema = QueryParamAutoSchema() 

-

714 

-

715 def get_queryset(self): 

-

716 queryset = self.queryset.filter( 

-

717 Q(created_by=self.request.user) 

-

718 | Q(shared=self.request.user) 

-

719 ).filter(space=self.request.space).distinct().all() 

-

720 

-

721 from_date = self.request.query_params.get('from_date', None) 

-

722 if from_date is not None: 

-

723 queryset = queryset.filter(to_date__gte=from_date) 

-

724 

-

725 to_date = self.request.query_params.get('to_date', None) 

-

726 if to_date is not None: 

-

727 queryset = queryset.filter(to_date__lte=to_date) 

-

728 

-

729 meal_type = self.request.query_params.getlist('meal_type', []) 

-

730 if meal_type: 

-

731 queryset = queryset.filter(meal_type__in=meal_type) 

-

732 

-

733 return queryset 

-

734 

-

735 

-

736class AutoPlanViewSet(viewsets.ViewSet): 

-

737 def create(self, request): 

-

738 serializer = AutoMealPlanSerializer(data=request.data) 

-

739 

-

740 if serializer.is_valid(): 

-

741 keywords = serializer.validated_data['keywords'] 

-

742 start_date = serializer.validated_data['start_date'] 

-

743 end_date = serializer.validated_data['end_date'] 

-

744 servings = serializer.validated_data['servings'] 

-

745 shared = serializer.get_initial().get('shared', None) 

-

746 shared_pks = list() 

-

747 if shared is not None: 

-

748 for i in range(len(shared)): 

-

749 shared_pks.append(shared[i]['id']) 

-

750 

-

751 days = min((end_date - start_date).days + 1, 14) 

-

752 

-

753 recipes = Recipe.objects.values('id', 'name') 

-

754 meal_plans = list() 

-

755 

-

756 for keyword in keywords: 

-

757 recipes = recipes.filter(keywords__name=keyword['name']) 

-

758 

-

759 if len(recipes) == 0: 

-

760 return Response(serializer.data) 

-

761 recipes = list(recipes.order_by('?')[:days]) 

-

762 

-

763 for i in range(0, days): 

-

764 day = start_date + datetime.timedelta(i) 

-

765 recipe = recipes[i % len(recipes)] 

-

766 args = {'recipe_id': recipe['id'], 'servings': servings, 

-

767 'created_by': request.user, 

-

768 'meal_type_id': serializer.validated_data['meal_type_id'], 

-

769 'note': '', 'from_date': day, 'to_date': day, 'space': request.space} 

-

770 

-

771 m = MealPlan(**args) 

-

772 meal_plans.append(m) 

-

773 

-

774 MealPlan.objects.bulk_create(meal_plans) 

-

775 

-

776 for m in meal_plans: 

-

777 m.shared.set(shared_pks) 

-

778 

-

779 if request.data.get('addshopping', False): 

-

780 SLR = RecipeShoppingEditor(user=request.user, space=request.space) 

-

781 SLR.create(mealplan=m, servings=servings) 

-

782 

-

783 else: 

-

784 post_save.send( 

-

785 sender=m.__class__, 

-

786 instance=m, 

-

787 created=True, 

-

788 update_fields=None, 

-

789 ) 

-

790 

-

791 return Response(serializer.data) 

-

792 

-

793 return Response(serializer.errors, 400) 

-

794 

-

795 

-

796class MealTypeViewSet(viewsets.ModelViewSet): 

-

797 """ 

-

798 returns list of meal types created by the 

-

799 requesting user ordered by the order field. 

-

800 """ 

-

801 queryset = MealType.objects 

-

802 serializer_class = MealTypeSerializer 

-

803 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

804 

-

805 def get_queryset(self): 

-

806 queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter( 

-

807 space=self.request.space).all() 

-

808 return queryset 

-

809 

-

810 

-

811class IngredientViewSet(viewsets.ModelViewSet): 

-

812 queryset = Ingredient.objects 

-

813 serializer_class = IngredientSerializer 

-

814 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

815 pagination_class = DefaultPagination 

-

816 

-

817 def get_serializer_class(self): 

-

818 if self.request and self.request.query_params.get('simple', False): 

-

819 return IngredientSimpleSerializer 

-

820 return self.serializer_class 

-

821 

-

822 def get_queryset(self): 

-

823 queryset = self.queryset.filter(step__recipe__space=self.request.space) 

-

824 food = self.request.query_params.get('food', None) 

-

825 if food and re.match(r'^(\d)+$', food): 

-

826 queryset = queryset.filter(food_id=food) 

-

827 

-

828 unit = self.request.query_params.get('unit', None) 

-

829 if unit and re.match(r'^(\d)+$', unit): 

-

830 queryset = queryset.filter(unit_id=unit) 

-

831 

-

832 return queryset 

-

833 

-

834 

-

835class StepViewSet(viewsets.ModelViewSet): 

-

836 queryset = Step.objects 

-

837 serializer_class = StepSerializer 

-

838 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

839 pagination_class = DefaultPagination 

-

840 query_params = [ 

-

841 QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'), 

-

842 QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'), 

-

843 ] 

-

844 schema = QueryParamAutoSchema() 

-

845 

-

846 def get_queryset(self): 

-

847 recipes = self.request.query_params.getlist('recipe', []) 

-

848 query = self.request.query_params.get('query', None) 

-

849 if len(recipes) > 0: 

-

850 self.queryset = self.queryset.filter(recipe__in=recipes) 

-

851 if query is not None: 

-

852 self.queryset = self.queryset.filter(Q(name__icontains=query) | Q(recipe__name__icontains=query)) 

-

853 return self.queryset.filter(recipe__space=self.request.space) 

-

854 

-

855 

-

856class RecipePagination(PageNumberPagination): 

-

857 page_size = 25 

-

858 page_size_query_param = 'page_size' 

-

859 max_page_size = 100 

-

860 

-

861 def paginate_queryset(self, queryset, request, view=None): 

-

862 if queryset is None: 

-

863 raise Exception 

-

864 return super().paginate_queryset(queryset, request, view) 

-

865 

-

866 def get_paginated_response(self, data): 

-

867 return Response(OrderedDict([ 

-

868 ('count', self.page.paginator.count), 

-

869 ('next', self.get_next_link()), 

-

870 ('previous', self.get_previous_link()), 

-

871 ('results', data), 

-

872 ])) 

-

873 

-

874 

-

875class RecipeViewSet(viewsets.ModelViewSet): 

-

876 queryset = Recipe.objects 

-

877 serializer_class = RecipeSerializer 

-

878 # TODO split read and write permission for meal plan guest 

-

879 permission_classes = [CustomRecipePermission & CustomTokenHasReadWriteScope] 

-

880 pagination_class = RecipePagination 

-

881 

-

882 query_params = [ 

-

883 QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), 

-

884 QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'), 

-

885 QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'), 

-

886 QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'), 

-

887 QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'), 

-

888 QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'), 

-

889 QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'), 

-

890 QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'), 

-

891 QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'), 

-

892 QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'), 

-

893 QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'), 

-

894 QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'), 

-

895 QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'), 

-

896 QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')), 

-

897 QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'), 

-

898 QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'), 

-

899 QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'), 

-

900 QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'), 

-

901 QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')), 

-

902 QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')), 

-

903 QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')), 

-

904 QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), 

-

905 QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), 

-

906 QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), 

-

907 QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), 

-

908 QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), 

-

909 QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')), 

-

910 ] 

-

911 schema = QueryParamAutoSchema() 

-

912 

-

913 def get_queryset(self): 

-

914 share = self.request.query_params.get('share', None) 

-

915 

-

916 if self.detail: # if detail request and not list, private condition is verified by permission class 

-

917 if not share: # filter for space only if not shared 

-

918 self.queryset = self.queryset.filter(space=self.request.space).prefetch_related( 

-

919 'keywords', 

-

920 'shared', 

-

921 'properties', 

-

922 'properties__property_type', 

-

923 'steps', 

-

924 'steps__ingredients', 

-

925 'steps__ingredients__step_set', 

-

926 'steps__ingredients__step_set__recipe_set', 

-

927 'steps__ingredients__food', 

-

928 'steps__ingredients__food__properties', 

-

929 'steps__ingredients__food__properties__property_type', 

-

930 'steps__ingredients__food__inherit_fields', 

-

931 'steps__ingredients__food__supermarket_category', 

-

932 'steps__ingredients__food__onhand_users', 

-

933 'steps__ingredients__food__substitute', 

-

934 'steps__ingredients__food__child_inherit_fields', 

-

935 

-

936 'steps__ingredients__unit', 

-

937 'steps__ingredients__unit__unit_conversion_base_relation', 

-

938 'steps__ingredients__unit__unit_conversion_base_relation__base_unit', 

-

939 'steps__ingredients__unit__unit_conversion_converted_relation', 

-

940 'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit', 

-

941 'cooklog_set', 

-

942 ).select_related('nutrition') 

-

943 

-

944 return super().get_queryset() 

-

945 

-

946 self.queryset = self.queryset.filter(space=self.request.space).filter( 

-

947 Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user))) 

-

948 ) 

-

949 

-

950 params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x 

-

951 in list(self.request.GET)} 

-

952 search = RecipeSearch(self.request, **params) 

-

953 self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set') 

-

954 return self.queryset 

-

955 

-

956 def list(self, request, *args, **kwargs): 

-

957 if self.request.GET.get('debug', False): 

-

958 return JsonResponse({ 

-

959 'new': str(self.get_queryset().query), 

-

960 }) 

-

961 return super().list(request, *args, **kwargs) 

-

962 

-

963 def get_serializer_class(self): 

-

964 if self.action == 'list': 

-

965 return RecipeOverviewSerializer 

-

966 return self.serializer_class 

-

967 

-

968 @decorators.action( 

-

969 detail=True, 

-

970 methods=['PUT'], 

-

971 serializer_class=RecipeImageSerializer, 

-

972 parser_classes=[MultiPartParser], 

-

973 ) 

-

974 def image(self, request, pk): 

-

975 obj = self.get_object() 

-

976 

-

977 if obj.get_space() != request.space: 

-

978 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) 

-

979 

-

980 serializer = self.serializer_class(obj, data=request.data, partial=True) 

-

981 

-

982 if serializer.is_valid(): 

-

983 serializer.save() 

-

984 image = None 

-

985 filetype = ".jpeg" # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways 

-

986 

-

987 if 'image' in serializer.validated_data: 

-

988 image = obj.image 

-

989 filetype = mimetypes.guess_extension(serializer.validated_data['image'].content_type) or filetype 

-

990 elif 'image_url' in serializer.validated_data: 

-

991 try: 

-

992 url = serializer.validated_data['image_url'] 

-

993 if validators.url(url, public=True): 

-

994 response = requests.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"}) 

-

995 image = File(io.BytesIO(response.content)) 

-

996 filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype 

-

997 except UnidentifiedImageError as e: 

-

998 print(e) 

-

999 pass 

-

1000 except MissingSchema as e: 

-

1001 print(e) 

-

1002 pass 

-

1003 except Exception as e: 

-

1004 print(e) 

-

1005 pass 

-

1006 

-

1007 if image is not None: 

-

1008 img = handle_image(request, image, filetype) 

-

1009 obj.image.save(f'{uuid.uuid4()}_{obj.pk}{filetype}', img) 

-

1010 obj.save() 

-

1011 return Response(serializer.data) 

-

1012 else: 

-

1013 obj.image = None 

-

1014 obj.save() 

-

1015 return Response(serializer.data) 

-

1016 

-

1017 return Response(serializer.errors, 400) 

-

1018 

-

1019 # TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating 

-

1020 # DRF only allows one action in a decorator action without overriding get_operation_id_base() 

-

1021 @decorators.action( 

-

1022 detail=True, 

-

1023 methods=['PUT'], 

-

1024 serializer_class=RecipeShoppingUpdateSerializer, 

-

1025 ) 

-

1026 def shopping(self, request, pk): 

-

1027 if self.request.space.demo: 

-

1028 raise PermissionDenied(detail='Not available in demo', code=None) 

-

1029 obj = self.get_object() 

-

1030 ingredients = request.data.get('ingredients', None) 

-

1031 

-

1032 servings = request.data.get('servings', None) 

-

1033 list_recipe = request.data.get('list_recipe', None) 

-

1034 mealplan = request.data.get('mealplan', None) 

-

1035 SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan, servings=servings) 

-

1036 

-

1037 content = {'msg': _(f'{obj.name} was added to the shopping list.')} 

-

1038 http_status = status.HTTP_204_NO_CONTENT 

-

1039 if servings and servings <= 0: 

-

1040 result = SLR.delete() 

-

1041 elif list_recipe: 

-

1042 result = SLR.edit(servings=servings, ingredients=ingredients) 

-

1043 else: 

-

1044 result = SLR.create(servings=servings, ingredients=ingredients) 

-

1045 

-

1046 if not result: 

-

1047 content = {'msg': ('An error occurred')} 

-

1048 http_status = status.HTTP_500_INTERNAL_SERVER_ERROR 

-

1049 else: 

-

1050 content = {'msg': _(f'{obj.name} was added to the shopping list.')} 

-

1051 http_status = status.HTTP_204_NO_CONTENT 

-

1052 

-

1053 return Response(content, status=http_status) 

-

1054 

-

1055 @decorators.action( 

-

1056 detail=True, 

-

1057 methods=['GET'], 

-

1058 serializer_class=RecipeSimpleSerializer 

-

1059 ) 

-

1060 def related(self, request, pk): 

-

1061 obj = self.get_object() 

-

1062 if obj.get_space() != request.space: 

-

1063 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) 

-

1064 try: 

-

1065 levels = int(request.query_params.get('levels', 1)) 

-

1066 except (ValueError, TypeError): 

-

1067 levels = 1 

-

1068 qs = obj.get_related_recipes( 

-

1069 levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend? 

-

1070 return Response(self.serializer_class(qs, many=True).data) 

-

1071 

-

1072 

-

1073class UnitConversionViewSet(viewsets.ModelViewSet): 

-

1074 queryset = UnitConversion.objects 

-

1075 serializer_class = UnitConversionSerializer 

-

1076 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1077 query_params = [ 

-

1078 QueryParam(name='food_id', description='ID of food to filter for', qtype='int'), 

-

1079 ] 

-

1080 schema = QueryParamAutoSchema() 

-

1081 

-

1082 def get_queryset(self): 

-

1083 food_id = self.request.query_params.get('food_id', None) 

-

1084 if food_id is not None: 

-

1085 self.queryset = self.queryset.filter(food_id=food_id) 

-

1086 

-

1087 return self.queryset.filter(space=self.request.space) 

-

1088 

-

1089 

-

1090class PropertyTypeViewSet(viewsets.ModelViewSet): 

-

1091 queryset = PropertyType.objects 

-

1092 serializer_class = PropertyTypeSerializer 

-

1093 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1094 

-

1095 def get_queryset(self): 

-

1096 return self.queryset.filter(space=self.request.space) 

-

1097 

-

1098 

-

1099class PropertyViewSet(viewsets.ModelViewSet): 

-

1100 queryset = Property.objects 

-

1101 serializer_class = PropertySerializer 

-

1102 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1103 

-

1104 def get_queryset(self): 

-

1105 return self.queryset.filter(space=self.request.space) 

-

1106 

-

1107 

-

1108class ShoppingListRecipeViewSet(viewsets.ModelViewSet): 

-

1109 queryset = ShoppingListRecipe.objects 

-

1110 serializer_class = ShoppingListRecipeSerializer 

-

1111 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

1112 

-

1113 def get_queryset(self): 

-

1114 self.queryset = self.queryset.filter( 

-

1115 Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space)) 

-

1116 return self.queryset.filter( 

-

1117 Q(shoppinglist__created_by=self.request.user) 

-

1118 | Q(shoppinglist__shared=self.request.user) 

-

1119 | Q(entries__created_by=self.request.user) 

-

1120 | Q(entries__created_by__in=list(self.request.user.get_shopping_share())) 

-

1121 ).distinct().all() 

-

1122 

-

1123 

-

1124class ShoppingListEntryViewSet(viewsets.ModelViewSet): 

-

1125 queryset = ShoppingListEntry.objects 

-

1126 serializer_class = ShoppingListEntrySerializer 

-

1127 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

1128 query_params = [ 

-

1129 QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'), 

-

1130 QueryParam(name='checked', description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.') 

-

1131 ), 

-

1132 QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'), 

-

1133 ] 

-

1134 schema = QueryParamAutoSchema() 

-

1135 

-

1136 def get_queryset(self): 

-

1137 self.queryset = self.queryset.filter(space=self.request.space) 

-

1138 

-

1139 self.queryset = self.queryset.filter( 

-

1140 Q(created_by=self.request.user) 

-

1141 | Q(shoppinglist__shared=self.request.user) 

-

1142 | Q(created_by__in=list(self.request.user.get_shopping_share())) 

-

1143 ).prefetch_related( 

-

1144 'created_by', 

-

1145 'food', 

-

1146 'food__properties', 

-

1147 'food__properties__property_type', 

-

1148 'food__inherit_fields', 

-

1149 'food__supermarket_category', 

-

1150 'food__onhand_users', 

-

1151 'food__substitute', 

-

1152 'food__child_inherit_fields', 

-

1153 

-

1154 'unit', 

-

1155 'list_recipe', 

-

1156 'list_recipe__mealplan', 

-

1157 'list_recipe__mealplan__recipe', 

-

1158 ).distinct().all() 

-

1159 

-

1160 if pk := self.request.query_params.getlist('id', []): 

-

1161 self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) 

-

1162 

-

1163 if 'checked' in self.request.query_params or 'recent' in self.request.query_params: 

-

1164 return shopping_helper(self.queryset, self.request) 

-

1165 

-

1166 # TODO once old shopping list is removed this needs updated to sharing users in preferences 

-

1167 return self.queryset 

-

1168 

-

1169 

-

1170# TODO deprecate 

-

1171class ShoppingListViewSet(viewsets.ModelViewSet): 

-

1172 queryset = ShoppingList.objects 

-

1173 serializer_class = ShoppingListSerializer 

-

1174 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

-

1175 

-

1176 def get_queryset(self): 

-

1177 return self.queryset.filter( 

-

1178 Q(created_by=self.request.user) 

-

1179 | Q(shared=self.request.user) 

-

1180 | Q(created_by__in=list(self.request.user.get_shopping_share())) 

-

1181 ).filter(space=self.request.space).distinct() 

-

1182 

-

1183 def get_serializer_class(self): 

-

1184 try: 

-

1185 autosync = self.request.query_params.get('autosync', False) 

-

1186 if autosync: 

-

1187 return ShoppingListAutoSyncSerializer 

-

1188 except AttributeError: # Needed for the openapi schema to determine a serializer without a request 

-

1189 pass 

-

1190 return self.serializer_class 

-

1191 

-

1192 

-

1193class ViewLogViewSet(viewsets.ModelViewSet): 

-

1194 queryset = ViewLog.objects 

-

1195 serializer_class = ViewLogSerializer 

-

1196 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

1197 pagination_class = DefaultPagination 

-

1198 

-

1199 def get_queryset(self): 

-

1200 # working backwards from the test - this is supposed to be limited to user view logs only?? 

-

1201 return self.queryset.filter(created_by=self.request.user).filter(space=self.request.space) 

-

1202 

-

1203 

-

1204class CookLogViewSet(viewsets.ModelViewSet): 

-

1205 queryset = CookLog.objects 

-

1206 serializer_class = CookLogSerializer 

-

1207 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

1208 pagination_class = DefaultPagination 

-

1209 

-

1210 def get_queryset(self): 

-

1211 return self.queryset.filter(space=self.request.space) 

-

1212 

-

1213 

-

1214class ImportLogViewSet(viewsets.ModelViewSet): 

-

1215 queryset = ImportLog.objects 

-

1216 serializer_class = ImportLogSerializer 

-

1217 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1218 pagination_class = DefaultPagination 

-

1219 

-

1220 def get_queryset(self): 

-

1221 return self.queryset.filter(space=self.request.space) 

-

1222 

-

1223 

-

1224class ExportLogViewSet(viewsets.ModelViewSet): 

-

1225 queryset = ExportLog.objects 

-

1226 serializer_class = ExportLogSerializer 

-

1227 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1228 pagination_class = DefaultPagination 

-

1229 

-

1230 def get_queryset(self): 

-

1231 return self.queryset.filter(space=self.request.space) 

-

1232 

-

1233 

-

1234class BookmarkletImportViewSet(viewsets.ModelViewSet): 

-

1235 queryset = BookmarkletImport.objects 

-

1236 serializer_class = BookmarkletImportSerializer 

-

1237 permission_classes = [CustomIsUser & CustomTokenHasScope] 

-

1238 required_scopes = ['bookmarklet'] 

-

1239 

-

1240 def get_serializer_class(self): 

-

1241 if self.action == 'list': 

-

1242 return BookmarkletImportListSerializer 

-

1243 return self.serializer_class 

-

1244 

-

1245 def get_queryset(self): 

-

1246 return self.queryset.filter(space=self.request.space).all() 

-

1247 

-

1248 

-

1249class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

1250 queryset = UserFile.objects 

-

1251 serializer_class = UserFileSerializer 

-

1252 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1253 parser_classes = [MultiPartParser] 

-

1254 

-

1255 def get_queryset(self): 

-

1256 self.queryset = self.queryset.filter(space=self.request.space).all() 

-

1257 return super().get_queryset() 

-

1258 

-

1259 

-

1260class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

1261 queryset = Automation.objects 

-

1262 serializer_class = AutomationSerializer 

-

1263 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1264 

-

1265 def get_queryset(self): 

-

1266 self.queryset = self.queryset.filter(space=self.request.space).all() 

-

1267 return super().get_queryset() 

-

1268 

-

1269 

-

1270class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

1271 queryset = InviteLink.objects 

-

1272 serializer_class = InviteLinkSerializer 

-

1273 permission_classes = [CustomIsSpaceOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

1274 

-

1275 def get_queryset(self): 

-

1276 

-

1277 internal_note = self.request.query_params.get('internal_note', None) 

-

1278 if internal_note is not None: 

-

1279 self.queryset = self.queryset.filter(internal_note=internal_note) 

-

1280 

-

1281 if is_space_owner(self.request.user, self.request.space): 

-

1282 self.queryset = self.queryset.filter(space=self.request.space).all() 

-

1283 return super().get_queryset() 

-

1284 else: 

-

1285 return None 

-

1286 

-

1287 

-

1288class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

-

1289 queryset = CustomFilter.objects 

-

1290 serializer_class = CustomFilterSerializer 

-

1291 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

1292 

-

1293 def get_queryset(self): 

-

1294 self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( 

-

1295 space=self.request.space).distinct() 

-

1296 return super().get_queryset() 

-

1297 

-

1298 

-

1299class AccessTokenViewSet(viewsets.ModelViewSet): 

-

1300 queryset = AccessToken.objects 

-

1301 serializer_class = AccessTokenSerializer 

-

1302 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

-

1303 

-

1304 def get_queryset(self): 

-

1305 return self.queryset.filter(user=self.request.user) 

-

1306 

-

1307 

-

1308# -------------- DRF custom views -------------------- 

-

1309 

-

1310class AuthTokenThrottle(AnonRateThrottle): 

-

1311 rate = '10/day' 

-

1312 

-

1313 

-

1314class RecipeImportThrottle(UserRateThrottle): 

-

1315 rate = DRF_THROTTLE_RECIPE_URL_IMPORT 

-

1316 

-

1317 

-

1318class CustomAuthToken(ObtainAuthToken): 

-

1319 throttle_classes = [AuthTokenThrottle] 

-

1320 

-

1321 def post(self, request, *args, **kwargs): 

-

1322 serializer = self.serializer_class(data=request.data, 

-

1323 context={'request': request}) 

-

1324 serializer.is_valid(raise_exception=True) 

-

1325 user = serializer.validated_data['user'] 

-

1326 if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter( 

-

1327 scope__contains='write').first(): 

-

1328 access_token = token 

-

1329 else: 

-

1330 access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}', 

-

1331 expires=(timezone.now() + timezone.timedelta(days=365 * 5)), 

-

1332 scope='read write app') 

-

1333 return Response({ 

-

1334 'id': access_token.id, 

-

1335 'token': access_token.token, 

-

1336 'scope': access_token.scope, 

-

1337 'expires': access_token.expires, 

-

1338 'user_id': access_token.user.pk, 

-

1339 'test': user.pk 

-

1340 }) 

-

1341 

-

1342 

-

1343class RecipeUrlImportView(ObtainAuthToken): 

-

1344 throttle_classes = [RecipeImportThrottle] 

-

1345 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

-

1346 

-

1347 def post(self, request, *args, **kwargs): 

-

1348 """ 

-

1349 function to retrieve a recipe from a given url or source string 

-

1350 :param request: standard request with additional post parameters 

-

1351 - url: url to use for importing recipe 

-

1352 - data: if no url is given recipe is imported from provided source data 

-

1353 - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes 

-

1354 :return: JsonResponse containing the parsed json and images 

-

1355 """ 

-

1356 scrape = None 

-

1357 serializer = RecipeFromSourceSerializer(data=request.data) 

-

1358 if serializer.is_valid(): 

-

1359 

-

1360 if (b_pk := serializer.validated_data.get('bookmarklet', None)) and ( 

-

1361 bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()): 

-

1362 serializer.validated_data['url'] = bookmarklet.url 

-

1363 serializer.validated_data['data'] = bookmarklet.html 

-

1364 bookmarklet.delete() 

-

1365 

-

1366 url = serializer.validated_data.get('url', None) 

-

1367 data = unquote(serializer.validated_data.get('data', None)) 

-

1368 if not url and not data: 

-

1369 return Response({ 

-

1370 'error': True, 

-

1371 'msg': _('Nothing to do.') 

-

1372 }, status=status.HTTP_400_BAD_REQUEST) 

-

1373 

-

1374 elif url and not data: 

-

1375 if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url): 

-

1376 if validators.url(url, public=True): 

-

1377 return Response({ 

-

1378 'recipe_json': get_from_youtube_scraper(url, request), 

-

1379 'recipe_images': [], 

-

1380 }, status=status.HTTP_200_OK) 

-

1381 if re.match( 

-

1382 '^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', 

-

1383 url): 

-

1384 recipe_json = requests.get( 

-

1385 url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1], 

-

1386 '') + '?share=' + 

-

1387 re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json() 

-

1388 recipe_json = clean_dict(recipe_json, 'id') 

-

1389 serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request}) 

-

1390 if serialized_recipe.is_valid(): 

-

1391 recipe = serialized_recipe.save() 

-

1392 if validators.url(recipe_json['image'], public=True): 

-

1393 recipe.image = File(handle_image(request, 

-

1394 File(io.BytesIO(requests.get(recipe_json['image']).content), 

-

1395 name='image'), 

-

1396 filetype=pathlib.Path(recipe_json['image']).suffix), 

-

1397 name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}') 

-

1398 recipe.save() 

-

1399 return Response({ 

-

1400 'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk})) 

-

1401 }, status=status.HTTP_201_CREATED) 

-

1402 else: 

-

1403 try: 

-

1404 if validators.url(url, public=True): 

-

1405 scrape = scrape_me(url_path=url, wild_mode=True) 

-

1406 

-

1407 else: 

-

1408 return Response({ 

-

1409 'error': True, 

-

1410 'msg': _('Invalid Url') 

-

1411 }, status=status.HTTP_400_BAD_REQUEST) 

-

1412 except NoSchemaFoundInWildMode: 

-

1413 pass 

-

1414 except requests.exceptions.ConnectionError: 

-

1415 return Response({ 

-

1416 'error': True, 

-

1417 'msg': _('Connection Refused.') 

-

1418 }, status=status.HTTP_400_BAD_REQUEST) 

-

1419 except requests.exceptions.MissingSchema: 

-

1420 return Response({ 

-

1421 'error': True, 

-

1422 'msg': _('Bad URL Schema.') 

-

1423 }, status=status.HTTP_400_BAD_REQUEST) 

-

1424 else: 

-

1425 try: 

-

1426 data_json = json.loads(data) 

-

1427 if '@context' not in data_json: 

-

1428 data_json['@context'] = 'https://schema.org' 

-

1429 if '@type' not in data_json: 

-

1430 data_json['@type'] = 'Recipe' 

-

1431 data = "<script type='application/ld+json'>" + json.dumps(data_json) + "</script>" 

-

1432 except JSONDecodeError: 

-

1433 pass 

-

1434 scrape = text_scraper(text=data, url=url) 

-

1435 if not url and (found_url := scrape.schema.data.get('url', None)): 

-

1436 scrape = text_scraper(text=data, url=found_url) 

-

1437 

-

1438 if scrape: 

-

1439 return Response({ 

-

1440 'recipe_json': helper.get_from_scraper(scrape, request), 

-

1441 'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))), 

-

1442 }, status=status.HTTP_200_OK) 

-

1443 

-

1444 else: 

-

1445 return Response({ 

-

1446 'error': True, 

-

1447 'msg': _('No usable data could be found.') 

-

1448 }, status=status.HTTP_400_BAD_REQUEST) 

-

1449 else: 

-

1450 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 

-

1451 

-

1452 

-

1453@api_view(['GET']) 

-

1454# @schema(AutoSchema()) #TODO add proper schema 

-

1455@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope]) 

-

1456# TODO add rate limiting 

-

1457def reset_food_inheritance(request): 

-

1458 """ 

-

1459 function to reset inheritance from api, see food method for docs 

-

1460 """ 

-

1461 try: 

-

1462 Food.reset_inheritance(space=request.space) 

-

1463 return Response({'message': 'success', }, status=status.HTTP_200_OK) 

-

1464 except Exception: 

-

1465 traceback.print_exc() 

-

1466 return Response({}, status=status.HTTP_400_BAD_REQUEST) 

-

1467 

-

1468 

-

1469@api_view(['GET']) 

-

1470# @schema(AutoSchema()) #TODO add proper schema 

-

1471@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope]) 

-

1472# TODO add rate limiting 

-

1473def switch_active_space(request, space_id): 

-

1474 """ 

-

1475 api endpoint to switch space function 

-

1476 """ 

-

1477 try: 

-

1478 space = get_object_or_404(Space, id=space_id) 

-

1479 user_space = switch_user_active_space(request.user, space) 

-

1480 if user_space: 

-

1481 return Response(UserSpaceSerializer().to_representation(instance=user_space), status=status.HTTP_200_OK) 

-

1482 else: 

-

1483 return Response("not found", status=status.HTTP_404_NOT_FOUND) 

-

1484 except Exception: 

-

1485 traceback.print_exc() 

-

1486 return Response({}, status=status.HTTP_400_BAD_REQUEST) 

-

1487 

-

1488 

-

1489@api_view(['GET']) 

-

1490# @schema(AutoSchema()) #TODO add proper schema 

-

1491@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) 

-

1492def download_file(request, file_id): 

-

1493 """ 

-

1494 function to download a user file securely (wrapping as zip to prevent any context based XSS problems) 

-

1495 temporary solution until a real file manager is implemented 

-

1496 """ 

-

1497 try: 

-

1498 uf = UserFile.objects.get(space=request.space, pk=file_id) 

-

1499 

-

1500 in_memory = io.BytesIO() 

-

1501 zf = ZipFile(in_memory, mode="w") 

-

1502 zf.writestr(uf.file.name, uf.file.file.read()) 

-

1503 zf.close() 

-

1504 

-

1505 response = HttpResponse(in_memory.getvalue(), content_type='application/force-download') 

-

1506 response['Content-Disposition'] = 'attachment; filename="' + uf.name + '.zip"' 

-

1507 return response 

-

1508 

-

1509 except Exception: 

-

1510 traceback.print_exc() 

-

1511 return Response({}, status=status.HTTP_400_BAD_REQUEST) 

-

1512 

-

1513 

-

1514@api_view(['POST']) 

-

1515# @schema(AutoSchema()) #TODO add proper schema 

-

1516@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) 

-

1517def import_files(request): 

-

1518 """ 

-

1519 function to handle files passed by application importer 

-

1520 """ 

-

1521 limit, msg = above_space_limit(request.space) 

-

1522 if limit: 

-

1523 return Response({'error': True, 'msg': _('File is above space limit')}, status=status.HTTP_400_BAD_REQUEST) 

-

1524 

-

1525 form = ImportForm(request.POST, request.FILES) 

-

1526 if form.is_valid() and request.FILES != {}: 

-

1527 try: 

-

1528 integration = get_integration(request, form.cleaned_data['type']) 

-

1529 

-

1530 il = ImportLog.objects.create(type=form.cleaned_data['type'], created_by=request.user, space=request.space) 

-

1531 files = [] 

-

1532 for f in request.FILES.getlist('files'): 

-

1533 files.append({'file': io.BytesIO(f.read()), 'name': f.name}) 

-

1534 t = threading.Thread(target=integration.do_import, args=[files, il, form.cleaned_data['duplicates']]) 

-

1535 t.setDaemon(True) 

-

1536 t.start() 

-

1537 

-

1538 return Response({'import_id': il.pk}, status=status.HTTP_200_OK) 

-

1539 except NotImplementedError: 

-

1540 return Response({'error': True, 'msg': _('Importing is not implemented for this provider')}, 

-

1541 status=status.HTTP_400_BAD_REQUEST) 

-

1542 else: 

-

1543 return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST) 

-

1544 

-

1545 

-

1546class ImportOpenData(APIView): 

-

1547 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

-

1548 

-

1549 def get(self, request, format=None): 

-

1550 response = requests.get('https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json') 

-

1551 metadata = json.loads(response.content) 

-

1552 return Response(metadata) 

-

1553 

-

1554 def post(self, request, *args, **kwargs): 

-

1555 # TODO validate data 

-

1556 print(request.data) 

-

1557 selected_version = request.data['selected_version'] 

-

1558 update_existing = str2bool(request.data['update_existing']) 

-

1559 use_metric = str2bool(request.data['use_metric']) 

-

1560 

-

1561 response = requests.get(f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ... 

-

1562 data = json.loads(response.content) 

-

1563 

-

1564 response_obj = {} 

-

1565 

-

1566 data_importer = OpenDataImporter(request, data, update_existing=update_existing, use_metric=use_metric) 

-

1567 response_obj['unit'] = len(data_importer.import_units()) 

-

1568 response_obj['category'] = len(data_importer.import_category()) 

-

1569 response_obj['property'] = len(data_importer.import_property()) 

-

1570 response_obj['store'] = len(data_importer.import_supermarket()) 

-

1571 response_obj['food'] = len(data_importer.import_food()) 

-

1572 response_obj['conversion'] = len(data_importer.import_conversion()) 

-

1573 

-

1574 return Response(response_obj) 

-

1575 

-

1576 

-

1577def get_recipe_provider(recipe): 

-

1578 if recipe.storage.method == Storage.DROPBOX: 

-

1579 return Dropbox 

-

1580 elif recipe.storage.method == Storage.NEXTCLOUD: 

-

1581 return Nextcloud 

-

1582 elif recipe.storage.method == Storage.LOCAL: 

-

1583 return Local 

-

1584 else: 

-

1585 raise Exception('Provider not implemented') 

-

1586 

-

1587 

-

1588def update_recipe_links(recipe): 

-

1589 if not recipe.link: 

-

1590 # TODO response validation in apis 

-

1591 recipe.link = get_recipe_provider(recipe).get_share_link(recipe) 

-

1592 

-

1593 recipe.save() 

-

1594 

-

1595 

-

1596@group_required('user') 

-

1597def get_external_file_link(request, recipe_id): 

-

1598 recipe = get_object_or_404(Recipe, pk=recipe_id, space=request.space) 

-

1599 if not recipe.link: 

-

1600 update_recipe_links(recipe) 

-

1601 

-

1602 return HttpResponse(recipe.link) 

-

1603 

-

1604 

-

1605@group_required('guest') 

-

1606def get_recipe_file(request, recipe_id): 

-

1607 recipe = get_object_or_404(Recipe, pk=recipe_id, space=request.space) 

-

1608 if recipe.storage: 

-

1609 return FileResponse(get_recipe_provider(recipe).get_file(recipe)) 

-

1610 else: 

-

1611 return FileResponse() 

-

1612 

-

1613 

-

1614@group_required('user') 

-

1615def sync_all(request): 

-

1616 if request.space.demo or settings.HOSTED: 

-

1617 messages.add_message(request, messages.ERROR, 

-

1618 _('This feature is not yet available in the hosted version of tandoor!')) 

-

1619 return redirect('index') 

-

1620 

-

1621 monitors = Sync.objects.filter(active=True).filter(space=request.user.userspace_set.filter(active=1).first().space) 

-

1622 

-

1623 error = False 

-

1624 for monitor in monitors: 

-

1625 if monitor.storage.method == Storage.DROPBOX: 

-

1626 ret = Dropbox.import_all(monitor) 

-

1627 if not ret: 

-

1628 error = True 

-

1629 if monitor.storage.method == Storage.NEXTCLOUD: 

-

1630 ret = Nextcloud.import_all(monitor) 

-

1631 if not ret: 

-

1632 error = True 

-

1633 if monitor.storage.method == Storage.LOCAL: 

-

1634 ret = Local.import_all(monitor) 

-

1635 if not ret: 

-

1636 error = True 

-

1637 

-

1638 if not error: 

-

1639 messages.add_message( 

-

1640 request, messages.SUCCESS, _('Sync successful!') 

-

1641 ) 

-

1642 return redirect('list_recipe_import') 

-

1643 else: 

-

1644 messages.add_message( 

-

1645 request, messages.ERROR, _('Error synchronizing with Storage') 

-

1646 ) 

-

1647 return redirect('list_recipe_import') 

-

1648 

-

1649 

-

1650@api_view(['GET']) 

-

1651# @schema(AutoSchema()) #TODO add proper schema 

-

1652@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) 

-

1653def share_link(request, pk): 

-

1654 if request.space.allow_sharing and has_group_permission(request.user, ('user',)): 

-

1655 recipe = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

1656 link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space) 

-

1657 return JsonResponse({'pk': pk, 'share': link.uuid, 

-

1658 'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))}) 

-

1659 else: 

-

1660 return JsonResponse({'error': 'sharing_disabled'}, status=403) 

-

1661 

-

1662 

-

1663@group_required('user') 

-

1664@ajax_request 

-

1665def log_cooking(request, recipe_id): 

-

1666 recipe = get_object_or_None(Recipe, id=recipe_id) 

-

1667 if recipe: 

-

1668 log = CookLog.objects.create(created_by=request.user, recipe=recipe, space=request.space) 

-

1669 servings = request.GET['s'] if 's' in request.GET else None 

-

1670 if servings and re.match(r'^([1-9])+$', servings): 

-

1671 log.servings = int(servings) 

-

1672 

-

1673 rating = request.GET['r'] if 'r' in request.GET else None 

-

1674 if rating and re.match(r'^([1-9])+$', rating): 

-

1675 log.rating = int(rating) 

-

1676 log.save() 

-

1677 return {'msg': 'updated successfully'} 

-

1678 

-

1679 return {'error': 'recipe does not exist'} 

-

1680 

-

1681 

-

1682@group_required('user') 

-

1683def get_plan_ical(request, from_date, to_date): 

-

1684 queryset = MealPlan.objects.filter( 

-

1685 Q(created_by=request.user) | Q(shared=request.user) 

-

1686 ).filter(space=request.user.userspace_set.filter(active=1).first().space).distinct().all() 

-

1687 

-

1688 if from_date is not None: 

-

1689 queryset = queryset.filter(date__gte=from_date) 

-

1690 

-

1691 if to_date is not None: 

-

1692 queryset = queryset.filter(date__lte=to_date) 

-

1693 

-

1694 cal = Calendar() 

-

1695 

-

1696 for p in queryset: 

-

1697 event = Event() 

-

1698 event['uid'] = p.id 

-

1699 event.add('dtstart', p.from_date) 

-

1700 if p.to_date: 

-

1701 event.add('dtend', p.to_date) 

-

1702 else: 

-

1703 event.add('dtend', p.from_date) 

-

1704 event['summary'] = f'{p.meal_type.name}: {p.get_label()}' 

-

1705 event['description'] = p.note 

-

1706 cal.add_component(event) 

-

1707 

-

1708 response = FileResponse(io.BytesIO(cal.to_ical())) 

-

1709 response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' # noqa: E501 

-

1710 

-

1711 return response 

-

1712 

-

1713 

-

1714@group_required('admin') 

-

1715def get_backup(request): 

-

1716 if not request.user.is_superuser: 

-

1717 return HttpResponse('', status=403) 

-

1718 

-

1719 

-

1720@group_required('user') 

-

1721def ingredient_from_string(request): 

-

1722 text = request.POST['text'] 

-

1723 

-

1724 ingredient_parser = IngredientParser(request, False) 

-

1725 amount, unit, food, note = ingredient_parser.parse(text) 

-

1726 

-

1727 return JsonResponse( 

-

1728 { 

-

1729 'amount': amount, 

-

1730 'unit': unit, 

-

1731 'food': food, 

-

1732 'note': note 

-

1733 }, 

-

1734 status=200 

-

1735 ) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_data_py.html b/docs/coverage/d_dd189b0e5315428c_data_py.html deleted file mode 100644 index b16ec9f8f1..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_data_py.html +++ /dev/null @@ -1,233 +0,0 @@ - - - - - Coverage for cookbook/views/data.py: 30% - - - - - -
-
-

- Coverage for cookbook/views/data.py: - 30% -

- -

- 88 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import uuid 

-

2from datetime import datetime 

-

3 

-

4from django.contrib import messages 

-

5from django.http import HttpResponseRedirect 

-

6from django.shortcuts import redirect, render 

-

7from django.urls import reverse 

-

8from django.utils import timezone 

-

9from django.utils.translation import gettext as _ 

-

10from django.utils.translation import ngettext 

-

11from django_tables2 import RequestConfig 

-

12from oauth2_provider.models import AccessToken 

-

13 

-

14from cookbook.forms import BatchEditForm, SyncForm 

-

15from cookbook.helper.permission_helper import (above_space_limit, group_required, 

-

16 has_group_permission) 

-

17from cookbook.models import BookmarkletImport, Recipe, RecipeImport, Sync 

-

18from cookbook.tables import SyncTable 

-

19from recipes import settings 

-

20 

-

21 

-

22@group_required('user') 

-

23def sync(request): 

-

24 limit, msg = above_space_limit(request.space) 

-

25 if limit: 

-

26 messages.add_message(request, messages.WARNING, msg) 

-

27 return HttpResponseRedirect(reverse('index')) 

-

28 

-

29 if request.space.demo or settings.HOSTED: 

-

30 messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!')) 

-

31 return redirect('index') 

-

32 

-

33 if request.method == "POST": 

-

34 if not has_group_permission(request.user, ['admin']): 

-

35 messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) 

-

36 return HttpResponseRedirect(reverse('data_sync')) 

-

37 form = SyncForm(request.POST, space=request.space) 

-

38 if form.is_valid(): 

-

39 new_path = Sync() 

-

40 new_path.path = form.cleaned_data['path'] 

-

41 new_path.storage = form.cleaned_data['storage'] 

-

42 new_path.last_checked = datetime.now() 

-

43 new_path.space = request.space 

-

44 new_path.save() 

-

45 return redirect('data_sync') 

-

46 else: 

-

47 form = SyncForm(space=request.space) 

-

48 

-

49 monitored_paths = SyncTable(Sync.objects.filter(space=request.space).all()) 

-

50 RequestConfig(request, paginate={'per_page': 25}).configure(monitored_paths) 

-

51 

-

52 return render(request, 'batch/monitor.html', {'form': form, 'monitored_paths': monitored_paths}) 

-

53 

-

54 

-

55@group_required('user') 

-

56def sync_wait(request): 

-

57 return render(request, 'batch/waiting.html') 

-

58 

-

59 

-

60@group_required('user') 

-

61def batch_import(request): 

-

62 imports = RecipeImport.objects.filter(space=request.space).all() 

-

63 for new_recipe in imports: 

-

64 recipe = Recipe( 

-

65 name=new_recipe.name, 

-

66 file_path=new_recipe.file_path, 

-

67 storage=new_recipe.storage, 

-

68 file_uid=new_recipe.file_uid, 

-

69 created_by=request.user, 

-

70 space=request.space 

-

71 ) 

-

72 recipe.save() 

-

73 new_recipe.delete() 

-

74 

-

75 return redirect('list_recipe_import') 

-

76 

-

77 

-

78@group_required('user') 

-

79def batch_edit(request): 

-

80 if request.method == "POST": 

-

81 form = BatchEditForm(request.POST, space=request.space) 

-

82 if form.is_valid(): 

-

83 word = form.cleaned_data['search'] 

-

84 keywords = form.cleaned_data['keywords'] 

-

85 

-

86 recipes = Recipe.objects.filter(name__icontains=word, space=request.space) 

-

87 count = 0 

-

88 for recipe in recipes: 

-

89 edit = False 

-

90 if keywords.__sizeof__() > 0: 

-

91 recipe.keywords.add(*list(keywords)) 

-

92 edit = True 

-

93 if edit: 

-

94 count = count + 1 

-

95 

-

96 recipe.save() 

-

97 

-

98 msg = ngettext( 

-

99 'Batch edit done. %(count)d recipe was updated.', 

-

100 'Batch edit done. %(count)d Recipes where updated.', 

-

101 count) % { 

-

102 'count': count, 

-

103 } 

-

104 messages.add_message(request, messages.SUCCESS, msg) 

-

105 

-

106 return redirect('data_batch_edit') 

-

107 else: 

-

108 form = BatchEditForm(space=request.space) 

-

109 

-

110 return render(request, 'batch/edit.html', {'form': form}) 

-

111 

-

112 

-

113@group_required('user') 

-

114def import_url(request): 

-

115 limit, msg = above_space_limit(request.space) 

-

116 if limit: 

-

117 messages.add_message(request, messages.WARNING, msg) 

-

118 return HttpResponseRedirect(reverse('index')) 

-

119 

-

120 if (api_token := AccessToken.objects.filter(user=request.user, scope='bookmarklet').first()) is None: 

-

121 api_token = AccessToken.objects.create( 

-

122 user=request.user, 

-

123 scope='bookmarklet', 

-

124 expires=( 

-

125 timezone.now() + 

-

126 timezone.timedelta( 

-

127 days=365 * 

-

128 10)), 

-

129 token=f'tda_{str(uuid.uuid4()).replace("-","_")}') 

-

130 

-

131 bookmarklet_import_id = -1 

-

132 if 'id' in request.GET: 

-

133 if bookmarklet_import := BookmarkletImport.objects.filter(id=request.GET['id']).first(): 

-

134 bookmarklet_import_id = bookmarklet_import.pk 

-

135 

-

136 return render(request, 'url_import.html', {'api_token': api_token, 'bookmarklet_import_id': bookmarklet_import_id}) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_delete_py.html b/docs/coverage/d_dd189b0e5315428c_delete_py.html deleted file mode 100644 index 435c602a14..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_delete_py.html +++ /dev/null @@ -1,302 +0,0 @@ - - - - - Coverage for cookbook/views/delete.py: 51% - - - - - -
-
-

- Coverage for cookbook/views/delete.py: - 51% -

- -

- 152 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.contrib import messages 

-

2from django.db import models 

-

3from django.db.models import ProtectedError 

-

4from django.http import HttpResponseRedirect 

-

5from django.shortcuts import get_object_or_404, render 

-

6from django.urls import reverse, reverse_lazy 

-

7from django.utils.translation import gettext as _ 

-

8from django.views.generic import DeleteView 

-

9 

-

10from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required 

-

11from cookbook.models import (Comment, InviteLink, MealPlan, Recipe, RecipeBook, RecipeBookEntry, 

-

12 RecipeImport, Space, Storage, Sync, UserSpace) 

-

13from cookbook.provider.dropbox import Dropbox 

-

14from cookbook.provider.local import Local 

-

15from cookbook.provider.nextcloud import Nextcloud 

-

16 

-

17 

-

18class RecipeDelete(GroupRequiredMixin, DeleteView): 

-

19 groups_required = ['user'] 

-

20 template_name = "generic/delete_template.html" 

-

21 model = Recipe 

-

22 success_url = reverse_lazy('index') 

-

23 

-

24 def delete(self, request, *args, **kwargs): 

-

25 self.object = self.get_object() 

-

26 # TODO make this more generic so that all delete functions benefit from this 

-

27 if self.get_context_data()['protected_objects']: 

-

28 return render(request, template_name=self.template_name, context=self.get_context_data()) 

-

29 

-

30 success_url = self.get_success_url() 

-

31 self.object.delete() 

-

32 return HttpResponseRedirect(success_url) 

-

33 

-

34 def get_context_data(self, **kwargs): 

-

35 context = super(RecipeDelete, self).get_context_data(**kwargs) 

-

36 context['title'] = _("Recipe") 

-

37 

-

38 # TODO make this more generic so that all delete functions benefit from this 

-

39 self.object = self.get_object() 

-

40 context['protected_objects'] = [] 

-

41 context['cascading_objects'] = [] 

-

42 context['set_null_objects'] = [] 

-

43 for x in self.object._meta.get_fields(): 

-

44 try: 

-

45 related = x.related_model.objects.filter(**{x.field.name: self.object}) 

-

46 if related.exists() and x.on_delete == models.PROTECT: 

-

47 context['protected_objects'].append(related) 

-

48 if related.exists() and x.on_delete == models.CASCADE: 

-

49 context['cascading_objects'].append(related) 

-

50 if related.exists() and x.on_delete == models.SET_NULL: 

-

51 context['set_null_objects'].append(related) 

-

52 except AttributeError: 

-

53 pass 

-

54 

-

55 return context 

-

56 

-

57 

-

58@group_required('user') 

-

59def delete_recipe_source(request, pk): 

-

60 recipe = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

61 

-

62 if recipe.storage.method == Storage.DROPBOX: 

-

63 # TODO central location to handle storage type switches 

-

64 Dropbox.delete_file(recipe) 

-

65 if recipe.storage.method == Storage.NEXTCLOUD: 

-

66 Nextcloud.delete_file(recipe) 

-

67 if recipe.storage.method == Storage.LOCAL: 

-

68 Local.delete_file(recipe) 

-

69 

-

70 recipe.storage = None 

-

71 recipe.file_path = '' 

-

72 recipe.file_uid = '' 

-

73 recipe.save() 

-

74 

-

75 return HttpResponseRedirect(reverse('edit_recipe', args=[recipe.pk])) 

-

76 

-

77 

-

78class RecipeImportDelete(GroupRequiredMixin, DeleteView): 

-

79 groups_required = ['user'] 

-

80 template_name = "generic/delete_template.html" 

-

81 model = RecipeImport 

-

82 success_url = reverse_lazy('list_recipe_import') 

-

83 

-

84 def get_context_data(self, **kwargs): 

-

85 context = super(RecipeImportDelete, self).get_context_data(**kwargs) 

-

86 context['title'] = _("Import") 

-

87 return context 

-

88 

-

89 

-

90class SyncDelete(GroupRequiredMixin, DeleteView): 

-

91 groups_required = ['admin'] 

-

92 template_name = "generic/delete_template.html" 

-

93 model = Sync 

-

94 success_url = reverse_lazy('data_sync') 

-

95 

-

96 def get_context_data(self, **kwargs): 

-

97 context = super(SyncDelete, self).get_context_data(**kwargs) 

-

98 context['title'] = _("Monitor") 

-

99 return context 

-

100 

-

101 

-

102class StorageDelete(GroupRequiredMixin, DeleteView): 

-

103 groups_required = ['admin'] 

-

104 template_name = "generic/delete_template.html" 

-

105 model = Storage 

-

106 success_url = reverse_lazy('list_storage') 

-

107 

-

108 def get_context_data(self, **kwargs): 

-

109 context = super(StorageDelete, self).get_context_data(**kwargs) 

-

110 context['title'] = _("Storage Backend") 

-

111 return context 

-

112 

-

113 def post(self, request, *args, **kwargs): 

-

114 try: 

-

115 return self.delete(request, *args, **kwargs) 

-

116 except ProtectedError: 

-

117 messages.add_message( 

-

118 request, 

-

119 messages.WARNING, 

-

120 _('Could not delete this storage backend as it is used in at least one monitor.') # noqa: E501 

-

121 ) 

-

122 return HttpResponseRedirect(reverse('list_storage')) 

-

123 

-

124 

-

125class CommentDelete(OwnerRequiredMixin, DeleteView): 

-

126 template_name = "generic/delete_template.html" 

-

127 model = Comment 

-

128 success_url = reverse_lazy('index') 

-

129 

-

130 def get_context_data(self, **kwargs): 

-

131 context = super(CommentDelete, self).get_context_data(**kwargs) 

-

132 context['title'] = _("Comment") 

-

133 return context 

-

134 

-

135 

-

136class RecipeBookDelete(OwnerRequiredMixin, DeleteView): 

-

137 template_name = "generic/delete_template.html" 

-

138 model = RecipeBook 

-

139 success_url = reverse_lazy('view_books') 

-

140 

-

141 def get_context_data(self, **kwargs): 

-

142 context = super(RecipeBookDelete, self).get_context_data(**kwargs) 

-

143 context['title'] = _("Recipe Book") 

-

144 return context 

-

145 

-

146 

-

147class RecipeBookEntryDelete(OwnerRequiredMixin, DeleteView): 

-

148 groups_required = ['user'] 

-

149 template_name = "generic/delete_template.html" 

-

150 model = RecipeBookEntry 

-

151 success_url = reverse_lazy('view_books') 

-

152 

-

153 def get_context_data(self, **kwargs): 

-

154 context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs) 

-

155 context['title'] = _("Bookmarks") 

-

156 return context 

-

157 

-

158 

-

159class MealPlanDelete(OwnerRequiredMixin, DeleteView): 

-

160 template_name = "generic/delete_template.html" 

-

161 model = MealPlan 

-

162 success_url = reverse_lazy('view_plan') 

-

163 

-

164 def get_context_data(self, **kwargs): 

-

165 context = super(MealPlanDelete, self).get_context_data(**kwargs) 

-

166 context['title'] = _("Meal-Plan") 

-

167 return context 

-

168 

-

169 

-

170class InviteLinkDelete(OwnerRequiredMixin, DeleteView): 

-

171 template_name = "generic/delete_template.html" 

-

172 model = InviteLink 

-

173 success_url = reverse_lazy('list_invite_link') 

-

174 

-

175 def get_context_data(self, **kwargs): 

-

176 context = super(InviteLinkDelete, self).get_context_data(**kwargs) 

-

177 context['title'] = _("Invite Link") 

-

178 return context 

-

179 

-

180 

-

181class UserSpaceDelete(OwnerRequiredMixin, DeleteView): 

-

182 template_name = "generic/delete_template.html" 

-

183 model = UserSpace 

-

184 success_url = reverse_lazy('view_space_overview') 

-

185 

-

186 def get_context_data(self, **kwargs): 

-

187 context = super(UserSpaceDelete, self).get_context_data(**kwargs) 

-

188 context['title'] = _("Space Membership") 

-

189 return context 

-

190 

-

191 

-

192class SpaceDelete(OwnerRequiredMixin, DeleteView): 

-

193 template_name = "generic/delete_template.html" 

-

194 model = Space 

-

195 success_url = reverse_lazy('view_space_overview') 

-

196 

-

197 def delete(self, request, *args, **kwargs): 

-

198 self.object = self.get_object() 

-

199 self.object.safe_delete() 

-

200 return HttpResponseRedirect(self.get_success_url()) 

-

201 

-

202 def get_context_data(self, **kwargs): 

-

203 context = super(SpaceDelete, self).get_context_data(**kwargs) 

-

204 context['title'] = _("Space") 

-

205 return context 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_edit_py.html b/docs/coverage/d_dd189b0e5315428c_edit_py.html deleted file mode 100644 index f488189af0..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_edit_py.html +++ /dev/null @@ -1,300 +0,0 @@ - - - - - Coverage for cookbook/views/edit.py: 78% - - - - - -
-
-

- Coverage for cookbook/views/edit.py: - 78% -

- -

- 132 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import os 

-

2 

-

3from django.contrib import messages 

-

4from django.http import HttpResponseRedirect 

-

5from django.shortcuts import get_object_or_404, redirect, render 

-

6from django.urls import reverse 

-

7from django.utils.translation import gettext as _ 

-

8from django.views.generic import UpdateView 

-

9from django.views.generic.edit import FormMixin 

-

10 

-

11from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm 

-

12from cookbook.helper.permission_helper import (GroupRequiredMixin, OwnerRequiredMixin, 

-

13 above_space_limit, group_required) 

-

14from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync 

-

15from cookbook.provider.dropbox import Dropbox 

-

16from cookbook.provider.local import Local 

-

17from cookbook.provider.nextcloud import Nextcloud 

-

18from recipes import settings 

-

19 

-

20 

-

21@group_required('guest') 

-

22def switch_recipe(request, pk): 

-

23 recipe = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

24 if recipe.internal: 

-

25 return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk])) 

-

26 else: 

-

27 return HttpResponseRedirect(reverse('edit_external_recipe', args=[pk])) 

-

28 

-

29 

-

30@group_required('user') 

-

31def convert_recipe(request, pk): 

-

32 recipe = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

33 if not recipe.internal: 

-

34 recipe.internal = True 

-

35 recipe.save() 

-

36 

-

37 return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk])) 

-

38 

-

39 

-

40@group_required('user') 

-

41def internal_recipe_update(request, pk): 

-

42 limit, msg = above_space_limit(request.space) 

-

43 if limit: 

-

44 messages.add_message(request, messages.WARNING, msg) 

-

45 return HttpResponseRedirect(reverse('view_recipe', args=[pk])) 

-

46 

-

47 recipe_instance = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

48 

-

49 return render(request, 'forms/edit_internal_recipe.html', {'recipe': recipe_instance}) 

-

50 

-

51 

-

52class SpaceFormMixing(FormMixin): 

-

53 

-

54 def get_form_kwargs(self): 

-

55 kwargs = super().get_form_kwargs() 

-

56 kwargs.update({'space': self.request.space}) 

-

57 return kwargs 

-

58 

-

59 

-

60class SyncUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing): 

-

61 groups_required = ['admin'] 

-

62 template_name = "generic/edit_template.html" 

-

63 model = Sync 

-

64 form_class = SyncForm 

-

65 

-

66 # TODO add msg box 

-

67 

-

68 def get_success_url(self): 

-

69 return reverse('edit_sync', kwargs={'pk': self.object.pk}) 

-

70 

-

71 def get_context_data(self, **kwargs): 

-

72 context = super().get_context_data(**kwargs) 

-

73 context['title'] = _("Sync") 

-

74 return context 

-

75 

-

76 

-

77@group_required('admin') 

-

78def edit_storage(request, pk): 

-

79 instance = get_object_or_404(Storage, pk=pk, space=request.space) 

-

80 

-

81 if not (instance.created_by == request.user or request.user.is_superuser): 

-

82 messages.add_message(request, messages.ERROR, _('You cannot edit this storage!')) 

-

83 return HttpResponseRedirect(reverse('list_storage')) 

-

84 

-

85 if request.space.demo or settings.HOSTED: 

-

86 messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!')) 

-

87 return redirect('index') 

-

88 

-

89 if request.method == "POST": 

-

90 form = StorageForm(request.POST, instance=instance) 

-

91 if form.is_valid(): 

-

92 instance.name = form.cleaned_data['name'] 

-

93 instance.method = form.cleaned_data['method'] 

-

94 instance.username = form.cleaned_data['username'] 

-

95 instance.url = form.cleaned_data['url'] 

-

96 

-

97 if form.cleaned_data['password'] != '__NO__CHANGE__': 

-

98 instance.password = form.cleaned_data['password'] 

-

99 

-

100 if form.cleaned_data['token'] != '__NO__CHANGE__': 

-

101 instance.token = form.cleaned_data['token'] 

-

102 

-

103 instance.save() 

-

104 

-

105 messages.add_message( 

-

106 request, messages.SUCCESS, _('Storage saved!') 

-

107 ) 

-

108 else: 

-

109 messages.add_message( 

-

110 request, 

-

111 messages.ERROR, 

-

112 _('There was an error updating this storage backend!') 

-

113 ) 

-

114 else: 

-

115 pseudo_instance = instance 

-

116 pseudo_instance.password = '__NO__CHANGE__' 

-

117 pseudo_instance.token = '__NO__CHANGE__' 

-

118 form = StorageForm(instance=pseudo_instance) 

-

119 

-

120 return render( 

-

121 request, 

-

122 'generic/edit_template.html', 

-

123 {'form': form, 'title': _('Storage')} 

-

124 ) 

-

125 

-

126 

-

127class CommentUpdate(OwnerRequiredMixin, UpdateView): 

-

128 template_name = "generic/edit_template.html" 

-

129 model = Comment 

-

130 form_class = CommentForm 

-

131 

-

132 def get_success_url(self): 

-

133 return reverse('edit_comment', kwargs={'pk': self.object.pk}) 

-

134 

-

135 def get_context_data(self, **kwargs): 

-

136 context = super(CommentUpdate, self).get_context_data(**kwargs) 

-

137 context['title'] = _("Comment") 

-

138 context['view_url'] = reverse( 

-

139 'view_recipe', args=[self.object.recipe.pk] 

-

140 ) 

-

141 return context 

-

142 

-

143 

-

144class ImportUpdate(GroupRequiredMixin, UpdateView): 

-

145 groups_required = ['user'] 

-

146 template_name = "generic/edit_template.html" 

-

147 model = RecipeImport 

-

148 fields = ['name', 'path'] 

-

149 

-

150 # TODO add msg box 

-

151 

-

152 def get_success_url(self): 

-

153 return reverse('edit_import', kwargs={'pk': self.object.pk}) 

-

154 

-

155 def get_context_data(self, **kwargs): 

-

156 context = super(ImportUpdate, self).get_context_data(**kwargs) 

-

157 context['title'] = _("Import") 

-

158 return context 

-

159 

-

160 

-

161class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing): 

-

162 groups_required = ['user'] 

-

163 model = Recipe 

-

164 form_class = ExternalRecipeForm 

-

165 template_name = "generic/edit_template.html" 

-

166 

-

167 def form_valid(self, form): 

-

168 self.object = form.save(commit=False) 

-

169 old_recipe = Recipe.objects.get(pk=self.object.pk, space=self.request.space) 

-

170 if not old_recipe.name == self.object.name: 

-

171 # TODO central location to handle storage type switches 

-

172 if self.object.storage.method == Storage.DROPBOX: 

-

173 Dropbox.rename_file(old_recipe, self.object.name) 

-

174 if self.object.storage.method == Storage.NEXTCLOUD: 

-

175 Nextcloud.rename_file(old_recipe, self.object.name) 

-

176 if self.object.storage.method == Storage.LOCAL: 

-

177 Local.rename_file(old_recipe, self.object.name) 

-

178 

-

179 self.object.file_path = "%s/%s%s" % ( 

-

180 os.path.dirname(self.object.file_path), 

-

181 self.object.name, 

-

182 os.path.splitext(self.object.file_path)[1] 

-

183 ) 

-

184 

-

185 messages.add_message(self.request, messages.SUCCESS, _('Changes saved!')) 

-

186 return super(ExternalRecipeUpdate, self).form_valid(form) 

-

187 

-

188 def form_invalid(self, form): 

-

189 messages.add_message(self.request, messages.ERROR, _('Error saving changes!')) 

-

190 return super(ExternalRecipeUpdate, self).form_valid(form) 

-

191 

-

192 def get_success_url(self): 

-

193 return reverse('edit_recipe', kwargs={'pk': self.object.pk}) 

-

194 

-

195 def get_context_data(self, **kwargs): 

-

196 context = super().get_context_data(**kwargs) 

-

197 context['title'] = _("Recipe") 

-

198 context['view_url'] = reverse('view_recipe', args=[self.object.pk]) 

-

199 if self.object.storage: 

-

200 context['delete_external_url'] = reverse( 

-

201 'delete_recipe_source', args=[self.object.pk] 

-

202 ) 

-

203 return context 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_import_export_py.html b/docs/coverage/d_dd189b0e5315428c_import_export_py.html deleted file mode 100644 index 1399ec5ae6..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_import_export_py.html +++ /dev/null @@ -1,248 +0,0 @@ - - - - - Coverage for cookbook/views/import_export.py: 39% - - - - - -
-
-

- Coverage for cookbook/views/import_export.py: - 39% -

- -

- 123 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2import threading 

-

3 

-

4from django.core.cache import cache 

-

5from django.http import HttpResponse, JsonResponse 

-

6from django.shortcuts import get_object_or_404, render 

-

7from django.utils.translation import gettext as _ 

-

8 

-

9from cookbook.forms import ExportForm, ImportExportBase 

-

10from cookbook.helper.permission_helper import group_required 

-

11from cookbook.helper.recipe_search import RecipeSearch 

-

12from cookbook.integration.cheftap import ChefTap 

-

13from cookbook.integration.chowdown import Chowdown 

-

14from cookbook.integration.cookbookapp import CookBookApp 

-

15from cookbook.integration.cookmate import Cookmate 

-

16from cookbook.integration.copymethat import CopyMeThat 

-

17from cookbook.integration.default import Default 

-

18from cookbook.integration.domestica import Domestica 

-

19from cookbook.integration.mealie import Mealie 

-

20from cookbook.integration.mealmaster import MealMaster 

-

21from cookbook.integration.melarecipes import MelaRecipes 

-

22from cookbook.integration.nextcloud_cookbook import NextcloudCookbook 

-

23from cookbook.integration.openeats import OpenEats 

-

24from cookbook.integration.paprika import Paprika 

-

25from cookbook.integration.pdfexport import PDFexport 

-

26from cookbook.integration.pepperplate import Pepperplate 

-

27from cookbook.integration.plantoeat import Plantoeat 

-

28from cookbook.integration.recettetek import RecetteTek 

-

29from cookbook.integration.recipekeeper import RecipeKeeper 

-

30from cookbook.integration.recipesage import RecipeSage 

-

31from cookbook.integration.rezeptsuitede import Rezeptsuitede 

-

32from cookbook.integration.rezkonv import RezKonv 

-

33from cookbook.integration.saffron import Saffron 

-

34from cookbook.models import ExportLog, Recipe 

-

35from recipes import settings 

-

36 

-

37 

-

38def get_integration(request, export_type): 

-

39 if export_type == ImportExportBase.DEFAULT: 

-

40 return Default(request, export_type) 

-

41 if export_type == ImportExportBase.PAPRIKA: 

-

42 return Paprika(request, export_type) 

-

43 if export_type == ImportExportBase.NEXTCLOUD: 

-

44 return NextcloudCookbook(request, export_type) 

-

45 if export_type == ImportExportBase.MEALIE: 

-

46 return Mealie(request, export_type) 

-

47 if export_type == ImportExportBase.CHOWDOWN: 

-

48 return Chowdown(request, export_type) 

-

49 if export_type == ImportExportBase.SAFFRON: 

-

50 return Saffron(request, export_type) 

-

51 if export_type == ImportExportBase.CHEFTAP: 

-

52 return ChefTap(request, export_type) 

-

53 if export_type == ImportExportBase.PEPPERPLATE: 

-

54 return Pepperplate(request, export_type) 

-

55 if export_type == ImportExportBase.DOMESTICA: 

-

56 return Domestica(request, export_type) 

-

57 if export_type == ImportExportBase.RECIPEKEEPER: 

-

58 return RecipeKeeper(request, export_type) 

-

59 if export_type == ImportExportBase.RECETTETEK: 

-

60 return RecetteTek(request, export_type) 

-

61 if export_type == ImportExportBase.RECIPESAGE: 

-

62 return RecipeSage(request, export_type) 

-

63 if export_type == ImportExportBase.REZKONV: 

-

64 return RezKonv(request, export_type) 

-

65 if export_type == ImportExportBase.MEALMASTER: 

-

66 return MealMaster(request, export_type) 

-

67 if export_type == ImportExportBase.OPENEATS: 

-

68 return OpenEats(request, export_type) 

-

69 if export_type == ImportExportBase.PLANTOEAT: 

-

70 return Plantoeat(request, export_type) 

-

71 if export_type == ImportExportBase.COOKBOOKAPP: 

-

72 return CookBookApp(request, export_type) 

-

73 if export_type == ImportExportBase.COPYMETHAT: 

-

74 return CopyMeThat(request, export_type) 

-

75 if export_type == ImportExportBase.PDF: 

-

76 return PDFexport(request, export_type) 

-

77 if export_type == ImportExportBase.MELARECIPES: 

-

78 return MelaRecipes(request, export_type) 

-

79 if export_type == ImportExportBase.COOKMATE: 

-

80 return Cookmate(request, export_type) 

-

81 if export_type == ImportExportBase.REZEPTSUITEDE: 

-

82 return Rezeptsuitede(request, export_type) 

-

83 

-

84 

-

85@group_required('user') 

-

86def export_recipe(request): 

-

87 if request.method == "POST": 

-

88 form = ExportForm(request.POST, space=request.space) 

-

89 if form.is_valid(): 

-

90 try: 

-

91 recipes = form.cleaned_data['recipes'] 

-

92 if form.cleaned_data['all']: 

-

93 recipes = Recipe.objects.filter(space=request.space, internal=True).all() 

-

94 elif custom_filter := form.cleaned_data['custom_filter']: 

-

95 search = RecipeSearch(request, filter=custom_filter) 

-

96 recipes = search.get_queryset(Recipe.objects.filter(space=request.space, internal=True)) 

-

97 

-

98 integration = get_integration(request, form.cleaned_data['type']) 

-

99 

-

100 if form.cleaned_data['type'] == ImportExportBase.PDF and not settings.ENABLE_PDF_EXPORT: 

-

101 return JsonResponse({'error': _('The PDF Exporter is not enabled on this instance as it is still in an experimental state.')}) 

-

102 

-

103 el = ExportLog.objects.create(type=form.cleaned_data['type'], created_by=request.user, space=request.space) 

-

104 

-

105 t = threading.Thread(target=integration.do_export, args=[recipes, el]) 

-

106 t.setDaemon(True) 

-

107 t.start() 

-

108 

-

109 return JsonResponse({'export_id': el.pk}) 

-

110 except NotImplementedError: 

-

111 return JsonResponse( 

-

112 { 

-

113 'error': True, 

-

114 'msg': _('Importing is not implemented for this provider') 

-

115 }, 

-

116 status=400 

-

117 ) 

-

118 else: 

-

119 pk = '' 

-

120 recipe = request.GET.get('r') 

-

121 if recipe: 

-

122 if re.match(r'^([0-9])+$', recipe): 

-

123 pk = Recipe.objects.filter(pk=int(recipe), space=request.space).first().pk 

-

124 

-

125 return render(request, 'export.html', {'pk': pk}) 

-

126 

-

127 

-

128@group_required('user') 

-

129def import_response(request, pk): 

-

130 return render(request, 'import_response.html', {'pk': pk}) 

-

131 

-

132 

-

133@group_required('user') 

-

134def export_response(request, pk): 

-

135 return render(request, 'export_response.html', {'pk': pk}) 

-

136 

-

137 

-

138@group_required('user') 

-

139def export_file(request, pk): 

-

140 el = get_object_or_404(ExportLog, pk=pk, space=request.space) 

-

141 

-

142 cacheData = cache.get(f'export_file_{el.pk}') 

-

143 

-

144 if cacheData is None: 

-

145 el.possibly_not_expired = False 

-

146 el.save() 

-

147 return render(request, 'export_response.html', {'pk': pk}) 

-

148 

-

149 response = HttpResponse(cacheData['file'], content_type='application/force-download') 

-

150 response['Content-Disposition'] = 'attachment; filename="' + cacheData['filename'] + '"' 

-

151 return response 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_lists_py.html b/docs/coverage/d_dd189b0e5315428c_lists_py.html deleted file mode 100644 index ac1b27fbda..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_lists_py.html +++ /dev/null @@ -1,356 +0,0 @@ - - - - - Coverage for cookbook/views/lists.py: 60% - - - - - -
-
-

- Coverage for cookbook/views/lists.py: - 60% -

- -

- 68 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from datetime import datetime 

-

2 

-

3from django.db.models import Sum 

-

4from django.shortcuts import render 

-

5from django.utils.translation import gettext as _ 

-

6from django_tables2 import RequestConfig 

-

7 

-

8from cookbook.helper.permission_helper import group_required 

-

9from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile 

-

10from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable 

-

11 

-

12 

-

13@group_required('admin') 

-

14def sync_log(request): 

-

15 table = ImportLogTable( 

-

16 SyncLog.objects.filter(sync__space=request.space).all().order_by('-created_at') 

-

17 ) 

-

18 RequestConfig(request, paginate={'per_page': 25}).configure(table) 

-

19 

-

20 return render( 

-

21 request, 

-

22 'generic/list_template.html', 

-

23 {'title': _("Import Log"), 'table': table} 

-

24 ) 

-

25 

-

26 

-

27@group_required('user') 

-

28def recipe_import(request): 

-

29 table = RecipeImportTable(RecipeImport.objects.filter(space=request.space).all()) 

-

30 

-

31 RequestConfig(request, paginate={'per_page': 25}).configure(table) 

-

32 

-

33 return render( 

-

34 request, 

-

35 'generic/list_template.html', 

-

36 {'title': _("Discovery"), 'table': table, 'import_btn': True} 

-

37 ) 

-

38 

-

39 

-

40@group_required('user') 

-

41def shopping_list(request): 

-

42 return render( 

-

43 request, 

-

44 'shoppinglist_template.html', 

-

45 { 

-

46 "title": _("Shopping List"), 

-

47 

-

48 } 

-

49 ) 

-

50 

-

51 

-

52@group_required('admin') 

-

53def storage(request): 

-

54 table = StorageTable(Storage.objects.filter(space=request.space).all()) 

-

55 RequestConfig(request, paginate={'per_page': 25}).configure(table) 

-

56 

-

57 return render( 

-

58 request, 

-

59 'generic/list_template.html', 

-

60 { 

-

61 'title': _("Storage Backend"), 

-

62 'table': table, 

-

63 'create_url': 'new_storage' 

-

64 } 

-

65 ) 

-

66 

-

67 

-

68@group_required('admin') 

-

69def invite_link(request): 

-

70 table = InviteLinkTable( 

-

71 InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all()) 

-

72 RequestConfig(request, paginate={'per_page': 25}).configure(table) 

-

73 

-

74 return render(request, 'generic/list_template.html', { 

-

75 'title': _("Invite Links"), 

-

76 'table': table, 

-

77 'create_url': 'new_invite_link' 

-

78 }) 

-

79 

-

80 

-

81@group_required('user') 

-

82def keyword(request): 

-

83 return render( 

-

84 request, 

-

85 'generic/model_template.html', 

-

86 { 

-

87 "title": _("Keywords"), 

-

88 "config": { 

-

89 'model': "KEYWORD", 

-

90 'recipe_param': 'keywords' 

-

91 } 

-

92 } 

-

93 ) 

-

94 

-

95 

-

96@group_required('user') 

-

97def food(request): 

-

98 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

99 # model-name is the models.js name of the model, probably ALL-CAPS 

-

100 return render( 

-

101 request, 

-

102 'generic/model_template.html', 

-

103 { 

-

104 "title": _("Foods"), 

-

105 "config": { 

-

106 'model': "FOOD", # *REQUIRED* name of the model in models.js 

-

107 'recipe_param': 'foods' # *OPTIONAL* name of the listRecipes parameter if filtering on this attribute 

-

108 } 

-

109 } 

-

110 ) 

-

111 

-

112 

-

113@group_required('user') 

-

114def unit(request): 

-

115 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

116 # model-name is the models.js name of the model, probably ALL-CAPS 

-

117 return render( 

-

118 request, 

-

119 'generic/model_template.html', 

-

120 { 

-

121 "title": _("Units"), 

-

122 "config": { 

-

123 'model': "UNIT", # *REQUIRED* name of the model in models.js 

-

124 'recipe_param': 'units', # *OPTIONAL* name of the listRecipes parameter if filtering on this attribute 

-

125 } 

-

126 } 

-

127 ) 

-

128 

-

129 

-

130@group_required('user') 

-

131def supermarket(request): 

-

132 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

133 # model-name is the models.js name of the model, probably ALL-CAPS 

-

134 return render( 

-

135 request, 

-

136 'generic/model_template.html', 

-

137 { 

-

138 "title": _("Supermarkets"), 

-

139 "config": { 

-

140 'model': "SUPERMARKET", # *REQUIRED* name of the model in models.js 

-

141 } 

-

142 } 

-

143 ) 

-

144 

-

145 

-

146@group_required('user') 

-

147def supermarket_category(request): 

-

148 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

149 # model-name is the models.js name of the model, probably ALL-CAPS 

-

150 return render( 

-

151 request, 

-

152 'generic/model_template.html', 

-

153 { 

-

154 "title": _("Shopping Categories"), 

-

155 "config": { 

-

156 'model': "SHOPPING_CATEGORY", # *REQUIRED* name of the model in models.js 

-

157 } 

-

158 } 

-

159 ) 

-

160 

-

161 

-

162@group_required('user') 

-

163def automation(request): 

-

164 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

165 # model-name is the models.js name of the model, probably ALL-CAPS 

-

166 return render( 

-

167 request, 

-

168 'generic/model_template.html', 

-

169 { 

-

170 "title": _("Automations"), 

-

171 "config": { 

-

172 'model': "AUTOMATION", # *REQUIRED* name of the model in models.js 

-

173 } 

-

174 } 

-

175 ) 

-

176 

-

177 

-

178@group_required('user') 

-

179def custom_filter(request): 

-

180 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

181 # model-name is the models.js name of the model, probably ALL-CAPS 

-

182 return render( 

-

183 request, 

-

184 'generic/model_template.html', 

-

185 { 

-

186 "title": _("Custom Filters"), 

-

187 "config": { 

-

188 'model': "CUSTOM_FILTER", # *REQUIRED* name of the model in models.js 

-

189 } 

-

190 } 

-

191 ) 

-

192 

-

193 

-

194@group_required('user') 

-

195def user_file(request): 

-

196 try: 

-

197 current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[ 

-

198 'file_size_kb__sum'] / 1000 

-

199 except TypeError: 

-

200 current_file_size_mb = 0 

-

201 

-

202 return render( 

-

203 request, 

-

204 'generic/model_template.html', 

-

205 { 

-

206 "title": _("Files"), 

-

207 "config": { 

-

208 'model': "USERFILE", # *REQUIRED* name of the model in models.js 

-

209 }, 

-

210 'current_file_size_mb': current_file_size_mb, 'max_file_size_mb': request.space.max_file_storage_mb 

-

211 } 

-

212 ) 

-

213 

-

214 

-

215@group_required('user') 

-

216def step(request): 

-

217 # recipe-param is the name of the parameters used when filtering recipes by this attribute 

-

218 # model-name is the models.js name of the model, probably ALL-CAPS 

-

219 return render( 

-

220 request, 

-

221 'generic/model_template.html', 

-

222 { 

-

223 "title": _("Steps"), 

-

224 "config": { 

-

225 'model': "STEP", # *REQUIRED* name of the model in models.js 

-

226 'recipe_param': 'steps', 

-

227 } 

-

228 } 

-

229 ) 

-

230 

-

231 

-

232@group_required('user') 

-

233def unit_conversion(request): 

-

234 # model-name is the models.js name of the model, probably ALL-CAPS 

-

235 return render( 

-

236 request, 

-

237 'generic/model_template.html', 

-

238 { 

-

239 "title": _("Unit Conversions"), 

-

240 "config": { 

-

241 'model': "UNIT_CONVERSION", # *REQUIRED* name of the model in models.js 

-

242 } 

-

243 } 

-

244 ) 

-

245 

-

246 

-

247@group_required('user') 

-

248def property_type(request): 

-

249 # model-name is the models.js name of the model, probably ALL-CAPS 

-

250 return render( 

-

251 request, 

-

252 'generic/model_template.html', 

-

253 { 

-

254 "title": _("Property Types"), 

-

255 "config": { 

-

256 'model': "PROPERTY_TYPE", # *REQUIRED* name of the model in models.js 

-

257 } 

-

258 } 

-

259 ) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_new_py.html b/docs/coverage/d_dd189b0e5315428c_new_py.html deleted file mode 100644 index 979b9ec58c..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_new_py.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - Coverage for cookbook/views/new.py: 41% - - - - - -
-
-

- Coverage for cookbook/views/new.py: - 41% -

- -

- 80 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1 

-

2from django.contrib import messages 

-

3from django.http import HttpResponseRedirect 

-

4from django.shortcuts import get_object_or_404, redirect, render 

-

5from django.urls import reverse, reverse_lazy 

-

6from django.utils.translation import gettext as _ 

-

7from django.views.generic import CreateView 

-

8 

-

9from cookbook.forms import ImportRecipeForm, Storage, StorageForm 

-

10from cookbook.helper.permission_helper import GroupRequiredMixin, above_space_limit, group_required 

-

11from cookbook.models import Recipe, RecipeImport, ShareLink, Step 

-

12from recipes import settings 

-

13 

-

14 

-

15class RecipeCreate(GroupRequiredMixin, CreateView): 

-

16 groups_required = ['user'] 

-

17 template_name = "generic/new_template.html" 

-

18 model = Recipe 

-

19 fields = ('name',) 

-

20 

-

21 def form_valid(self, form): 

-

22 limit, msg = above_space_limit(self.request.space) 

-

23 if limit: 

-

24 messages.add_message(self.request, messages.WARNING, msg) 

-

25 return HttpResponseRedirect(reverse('index')) 

-

26 

-

27 obj = form.save(commit=False) 

-

28 obj.created_by = self.request.user 

-

29 obj.space = self.request.space 

-

30 obj.internal = True 

-

31 obj.save() 

-

32 obj.steps.add(Step.objects.create(space=self.request.space, show_as_header=False, show_ingredients_table=self.request.user.userpreference.show_step_ingredients)) 

-

33 return HttpResponseRedirect(reverse('edit_recipe', kwargs={'pk': obj.pk})) 

-

34 

-

35 def get_success_url(self): 

-

36 return reverse('edit_recipe', kwargs={'pk': self.object.pk}) 

-

37 

-

38 def get_context_data(self, **kwargs): 

-

39 context = super(RecipeCreate, self).get_context_data(**kwargs) 

-

40 context['title'] = _("Recipe") 

-

41 return context 

-

42 

-

43 

-

44@group_required('user') 

-

45def share_link(request, pk): 

-

46 recipe = get_object_or_404(Recipe, pk=pk, space=request.space) 

-

47 link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space) 

-

48 return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': pk, 'share': link.uuid})) 

-

49 

-

50 

-

51class StorageCreate(GroupRequiredMixin, CreateView): 

-

52 groups_required = ['admin'] 

-

53 template_name = "generic/new_template.html" 

-

54 model = Storage 

-

55 form_class = StorageForm 

-

56 success_url = reverse_lazy('list_storage') 

-

57 

-

58 def form_valid(self, form): 

-

59 obj = form.save(commit=False) 

-

60 obj.created_by = self.request.user 

-

61 obj.space = self.request.space 

-

62 obj.save() 

-

63 if self.request.space.demo or settings.HOSTED: 

-

64 messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!')) 

-

65 return redirect('index') 

-

66 return HttpResponseRedirect(reverse('edit_storage', kwargs={'pk': obj.pk})) 

-

67 

-

68 def get_context_data(self, **kwargs): 

-

69 context = super(StorageCreate, self).get_context_data(**kwargs) 

-

70 context['title'] = _("Storage Backend") 

-

71 return context 

-

72 

-

73 

-

74@group_required('user') 

-

75def create_new_external_recipe(request, import_id): 

-

76 if request.method == "POST": 

-

77 form = ImportRecipeForm(request.POST, space=request.space) 

-

78 if form.is_valid(): 

-

79 new_recipe = get_object_or_404(RecipeImport, pk=import_id, space=request.space) 

-

80 recipe = Recipe() 

-

81 recipe.space = request.space 

-

82 recipe.storage = new_recipe.storage 

-

83 recipe.name = form.cleaned_data['name'] 

-

84 recipe.file_path = form.cleaned_data['file_path'] 

-

85 recipe.file_uid = form.cleaned_data['file_uid'] 

-

86 recipe.created_by = request.user 

-

87 

-

88 recipe.save() 

-

89 

-

90 if form.cleaned_data['keywords']: 

-

91 recipe.keywords.set(form.cleaned_data['keywords']) 

-

92 

-

93 new_recipe.delete() 

-

94 

-

95 messages.add_message(request, messages.SUCCESS, _('Imported new recipe!')) 

-

96 return redirect('list_recipe_import') 

-

97 else: 

-

98 messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!')) 

-

99 else: 

-

100 new_recipe = get_object_or_404(RecipeImport, pk=import_id, space=request.space) 

-

101 form = ImportRecipeForm( 

-

102 initial={ 

-

103 'file_path': new_recipe.file_path, 

-

104 'name': new_recipe.name, 

-

105 'file_uid': new_recipe.file_uid 

-

106 }, space=request.space 

-

107 ) 

-

108 

-

109 return render(request, 'forms/edit_import_recipe.html', {'form': form}) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_telegram_py.html b/docs/coverage/d_dd189b0e5315428c_telegram_py.html deleted file mode 100644 index 378d0d8725..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_telegram_py.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - Coverage for cookbook/views/telegram.py: 36% - - - - - -
-
-

- Coverage for cookbook/views/telegram.py: - 36% -

- -

- 42 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2import traceback 

-

3 

-

4import requests 

-

5from django.http import JsonResponse 

-

6from django.shortcuts import get_object_or_404 

-

7from django.views.decorators.csrf import csrf_exempt 

-

8 

-

9from cookbook.helper.ingredient_parser import IngredientParser 

-

10from cookbook.helper.permission_helper import group_required 

-

11from cookbook.models import ShoppingListEntry, TelegramBot 

-

12 

-

13 

-

14@group_required('user') 

-

15def setup_bot(request, pk): 

-

16 bot = get_object_or_404(TelegramBot, pk=pk, space=request.space) 

-

17 

-

18 hook_url = f'{request.build_absolute_uri("/")}telegram/hook/{bot.webhook_token}/' 

-

19 

-

20 create_response = requests.get(f'https://api.telegram.org/bot{bot.token}/setWebhook?url={hook_url}') 

-

21 info_response = requests.get(f'https://api.telegram.org/bot{bot.token}/getWebhookInfo') 

-

22 

-

23 return JsonResponse({'hook_url': hook_url, 'create_response': json.loads(create_response.content.decode()), 

-

24 'info_response': json.loads(info_response.content.decode())}, json_dumps_params={'indent': 4}) 

-

25 

-

26 

-

27@group_required('user') 

-

28def remove_bot(request, pk): 

-

29 bot = get_object_or_404(TelegramBot, pk=pk, space=request.space) 

-

30 

-

31 remove_response = requests.get(f'https://api.telegram.org/bot{bot.token}/deleteWebhook') 

-

32 info_response = requests.get(f'https://api.telegram.org/bot{bot.token}/getWebhookInfo') 

-

33 

-

34 return JsonResponse({'remove_response': json.loads(remove_response.content.decode()), 

-

35 'info_response': json.loads(info_response.content.decode())}, json_dumps_params={'indent': 4}) 

-

36 

-

37 

-

38@csrf_exempt 

-

39def hook(request, token): 

-

40 try: 

-

41 tb = get_object_or_404(TelegramBot, webhook_token=token) 

-

42 

-

43 data = json.loads(request.body.decode()) 

-

44 

-

45 if tb.chat_id == '': 

-

46 tb.chat_id = data['message']['chat']['id'] 

-

47 tb.save() 

-

48 

-

49 if tb.chat_id == str(data['message']['chat']['id']): 

-

50 request.space = tb.space # TODO this is likely a bad idea. Verify and test 

-

51 request.user = tb.created_by 

-

52 ingredient_parser = IngredientParser(request, False) 

-

53 amount, unit, food, note = ingredient_parser.parse(data['message']['text']) 

-

54 f = ingredient_parser.get_food(food) 

-

55 u = ingredient_parser.get_unit(unit) 

-

56 

-

57 ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space) 

-

58 

-

59 return JsonResponse({'data': data['message']['text']}) 

-

60 except Exception: 

-

61 traceback.print_exc() 

-

62 

-

63 return JsonResponse({}) 

-
- - - diff --git a/docs/coverage/d_dd189b0e5315428c_views_py.html b/docs/coverage/d_dd189b0e5315428c_views_py.html deleted file mode 100644 index 0b270eda6c..0000000000 --- a/docs/coverage/d_dd189b0e5315428c_views_py.html +++ /dev/null @@ -1,651 +0,0 @@ - - - - - Coverage for cookbook/views/views.py: 27% - - - - - -
-
-

- Coverage for cookbook/views/views.py: - 27% -

- -

- 363 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import os 

-

2import re 

-

3from datetime import datetime 

-

4from io import StringIO 

-

5from uuid import UUID 

-

6import subprocess 

-

7 

-

8from django.apps import apps 

-

9from django.conf import settings 

-

10from django.contrib import messages 

-

11from django.contrib.auth.decorators import login_required 

-

12from django.contrib.auth.models import Group 

-

13from django.contrib.auth.password_validation import validate_password 

-

14from django.core.exceptions import ValidationError 

-

15from django.core.management import call_command 

-

16from django.db import models 

-

17from django.http import HttpResponseRedirect 

-

18from django.shortcuts import get_object_or_404, redirect, render 

-

19from django.urls import reverse, reverse_lazy 

-

20from django.utils import timezone 

-

21from django.utils.translation import gettext as _ 

-

22from django_scopes import scopes_disabled 

-

23 

-

24from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, 

-

25 SpaceJoinForm, User, UserCreateForm, UserPreference) 

-

26from cookbook.helper.HelperFunctions import str2bool 

-

27from cookbook.helper.permission_helper import (group_required, has_group_permission, 

-

28 share_link_valid, switch_user_active_space) 

-

29from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, 

-

30 ShareLink, Space, UserSpace, ViewLog) 

-

31from cookbook.tables import CookLogTable, ViewLogTable 

-

32from cookbook.version_info import VERSION_INFO 

-

33from recipes.settings import PLUGINS, BASE_DIR 

-

34 

-

35 

-

36def index(request): 

-

37 with scopes_disabled(): 

-

38 if not request.user.is_authenticated: 

-

39 if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS: 

-

40 return HttpResponseRedirect(reverse_lazy('view_setup')) 

-

41 return HttpResponseRedirect(reverse_lazy('view_search')) 

-

42 

-

43 try: 

-

44 page_map = { 

-

45 UserPreference.SEARCH: reverse_lazy('view_search'), 

-

46 UserPreference.PLAN: reverse_lazy('view_plan'), 

-

47 UserPreference.BOOKS: reverse_lazy('view_books'), 

-

48 } 

-

49 

-

50 return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page)) 

-

51 except UserPreference.DoesNotExist: 

-

52 return HttpResponseRedirect(reverse('view_search')) 

-

53 

-

54 

-

55# TODO need to deprecate 

-

56def search(request): 

-

57 if has_group_permission(request.user, ('guest',)): 

-

58 return render(request, 'search.html', {}) 

-

59 else: 

-

60 if request.user.is_authenticated: 

-

61 return HttpResponseRedirect(reverse('view_no_group')) 

-

62 else: 

-

63 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path) 

-

64 

-

65 

-

66def no_groups(request): 

-

67 return render(request, 'no_groups_info.html') 

-

68 

-

69 

-

70@login_required 

-

71def space_overview(request): 

-

72 if request.POST: 

-

73 create_form = SpaceCreateForm(request.POST, prefix='create') 

-

74 join_form = SpaceJoinForm(request.POST, prefix='join') 

-

75 if settings.HOSTED and request.user.username == 'demo': 

-

76 messages.add_message(request, messages.WARNING, _('This feature is not available in the demo version!')) 

-

77 else: 

-

78 if create_form.is_valid(): 

-

79 created_space = Space.objects.create( 

-

80 name=create_form.cleaned_data['name'], 

-

81 created_by=request.user, 

-

82 max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES, 

-

83 max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES, 

-

84 max_users=settings.SPACE_DEFAULT_MAX_USERS, 

-

85 allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING, 

-

86 ) 

-

87 

-

88 user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False) 

-

89 user_space.groups.add(Group.objects.filter(name='admin').get()) 

-

90 

-

91 messages.add_message(request, messages.SUCCESS, 

-

92 _('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.')) 

-

93 return HttpResponseRedirect(reverse('view_switch_space', args=[user_space.space.pk])) 

-

94 

-

95 if join_form.is_valid(): 

-

96 return HttpResponseRedirect(reverse('view_invite', args=[join_form.cleaned_data['token']])) 

-

97 else: 

-

98 if settings.SOCIAL_DEFAULT_ACCESS and len(request.user.userspace_set.all()) == 0: 

-

99 user_space = UserSpace.objects.create(space=Space.objects.first(), user=request.user, active=False) 

-

100 user_space.groups.add(Group.objects.filter(name=settings.SOCIAL_DEFAULT_GROUP).get()) 

-

101 return HttpResponseRedirect(reverse('index')) 

-

102 if 'signup_token' in request.session: 

-

103 return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')])) 

-

104 

-

105 create_form = SpaceCreateForm(initial={'name': f'{request.user.get_user_display_name()}\'s Space'}) 

-

106 join_form = SpaceJoinForm() 

-

107 

-

108 return render(request, 'space_overview.html', {'create_form': create_form, 'join_form': join_form}) 

-

109 

-

110 

-

111@login_required 

-

112def switch_space(request, space_id): 

-

113 space = get_object_or_404(Space, id=space_id) 

-

114 switch_user_active_space(request.user, space) 

-

115 return HttpResponseRedirect(reverse('index')) 

-

116 

-

117 

-

118def no_perm(request): 

-

119 if not request.user.is_authenticated: 

-

120 messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!')) 

-

121 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.GET.get('next', '/search/')) 

-

122 return render(request, 'no_perm_info.html') 

-

123 

-

124 

-

125def recipe_view(request, pk, share=None): 

-

126 with scopes_disabled(): 

-

127 recipe = get_object_or_404(Recipe, pk=pk) 

-

128 

-

129 if not request.user.is_authenticated and not share_link_valid(recipe, share): 

-

130 messages.add_message(request, messages.ERROR, 

-

131 _('You do not have the required permissions to view this page!')) 

-

132 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path) 

-

133 

-

134 if not (has_group_permission(request.user, 

-

135 ('guest',)) and recipe.space == request.space) and not share_link_valid(recipe, 

-

136 share): 

-

137 messages.add_message(request, messages.ERROR, 

-

138 _('You do not have the required permissions to view this page!')) 

-

139 return HttpResponseRedirect(reverse('index')) 

-

140 

-

141 comments = Comment.objects.filter(recipe__space=request.space, recipe=recipe) 

-

142 

-

143 if request.method == "POST": 

-

144 if not request.user.is_authenticated: 

-

145 messages.add_message(request, messages.ERROR, 

-

146 _('You do not have the required permissions to perform this action!')) 

-

147 return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share})) 

-

148 

-

149 comment_form = CommentForm(request.POST, prefix='comment') 

-

150 if comment_form.is_valid(): 

-

151 comment = Comment() 

-

152 comment.recipe = recipe 

-

153 comment.text = comment_form.cleaned_data['text'] 

-

154 comment.created_by = request.user 

-

155 comment.save() 

-

156 

-

157 messages.add_message(request, messages.SUCCESS, _('Comment saved!')) 

-

158 

-

159 comment_form = CommentForm() 

-

160 

-

161 if request.user.is_authenticated: 

-

162 if not ViewLog.objects.filter(recipe=recipe, created_by=request.user, 

-

163 created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)), 

-

164 space=request.space).exists(): 

-

165 ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space) 

-

166 

-

167 return render(request, 'recipe_view.html', 

-

168 {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, }) 

-

169 

-

170 

-

171@group_required('user') 

-

172def books(request): 

-

173 return render(request, 'books.html', {}) 

-

174 

-

175 

-

176@group_required('user') 

-

177def meal_plan(request): 

-

178 return render(request, 'meal_plan.html', {}) 

-

179 

-

180 

-

181@group_required('user') 

-

182def supermarket(request): 

-

183 return render(request, 'supermarket.html', {}) 

-

184 

-

185 

-

186@group_required('user') 

-

187def view_profile(request, user_id): 

-

188 return render(request, 'profile.html', {}) 

-

189 

-

190 

-

191@group_required('guest') 

-

192def user_settings(request): 

-

193 if request.space.demo: 

-

194 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!')) 

-

195 return redirect('index') 

-

196 

-

197 return render(request, 'user_settings.html', {}) 

-

198 

-

199 

-

200@group_required('user') 

-

201def ingredient_editor(request): 

-

202 template_vars = {'food_id': -1, 'unit_id': -1} 

-

203 food_id = request.GET.get('food_id', None) 

-

204 if food_id and re.match(r'^(\d)+$', food_id): 

-

205 template_vars['food_id'] = food_id 

-

206 

-

207 unit_id = request.GET.get('unit_id', None) 

-

208 if unit_id and re.match(r'^(\d)+$', unit_id): 

-

209 template_vars['unit_id'] = unit_id 

-

210 return render(request, 'ingredient_editor.html', template_vars) 

-

211 

-

212 

-

213@group_required('user') 

-

214def property_editor(request, pk): 

-

215 return render(request, 'property_editor.html', {'recipe_id': pk}) 

-

216 

-

217 

-

218@group_required('guest') 

-

219def shopping_settings(request): 

-

220 if request.space.demo: 

-

221 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!')) 

-

222 return redirect('index') 

-

223 

-

224 sp = request.user.searchpreference 

-

225 search_error = False 

-

226 

-

227 if request.method == "POST": 

-

228 if 'search_form' in request.POST: 

-

229 search_form = SearchPreferenceForm(request.POST, prefix='search') 

-

230 if search_form.is_valid(): 

-

231 if not sp: 

-

232 sp = SearchPreferenceForm(user=request.user) 

-

233 fields_searched = ( 

-

234 len(search_form.cleaned_data['icontains']) 

-

235 + len(search_form.cleaned_data['istartswith']) 

-

236 + len(search_form.cleaned_data['trigram']) 

-

237 + len(search_form.cleaned_data['fulltext']) 

-

238 ) 

-

239 if search_form.cleaned_data['preset'] == 'fuzzy': 

-

240 sp.search = SearchPreference.SIMPLE 

-

241 sp.lookup = True 

-

242 sp.unaccent.set([SearchFields.objects.get(name='Name')]) 

-

243 sp.icontains.set([SearchFields.objects.get(name='Name')]) 

-

244 sp.istartswith.clear() 

-

245 sp.trigram.set([SearchFields.objects.get(name='Name')]) 

-

246 sp.fulltext.clear() 

-

247 sp.trigram_threshold = 0.2 

-

248 sp.save() 

-

249 elif search_form.cleaned_data['preset'] == 'precise': 

-

250 sp.search = SearchPreference.WEB 

-

251 sp.lookup = True 

-

252 sp.unaccent.set(SearchFields.objects.all()) 

-

253 # full text on food is very slow, add search_vector field and index it (including Admin functions and postsave signal to rebuild index) 

-

254 sp.icontains.set([SearchFields.objects.get(name='Name')]) 

-

255 sp.istartswith.set([SearchFields.objects.get(name='Name')]) 

-

256 sp.trigram.clear() 

-

257 sp.fulltext.set(SearchFields.objects.filter(name__in=['Ingredients'])) 

-

258 sp.trigram_threshold = 0.2 

-

259 sp.save() 

-

260 elif fields_searched == 0: 

-

261 search_form.add_error(None, _('You must select at least one field to search!')) 

-

262 search_error = True 

-

263 elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len( 

-

264 search_form.cleaned_data['fulltext']) == 0: 

-

265 search_form.add_error('search', 

-

266 _('To use this search method you must select at least one full text search field!')) 

-

267 search_error = True 

-

268 elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len( 

-

269 search_form.cleaned_data['trigram']) > 0: 

-

270 search_form.add_error(None, _('Fuzzy search is not compatible with this search method!')) 

-

271 search_error = True 

-

272 else: 

-

273 sp.search = search_form.cleaned_data['search'] 

-

274 sp.lookup = search_form.cleaned_data['lookup'] 

-

275 sp.unaccent.set(search_form.cleaned_data['unaccent']) 

-

276 sp.icontains.set(search_form.cleaned_data['icontains']) 

-

277 sp.istartswith.set(search_form.cleaned_data['istartswith']) 

-

278 sp.trigram.set(search_form.cleaned_data['trigram']) 

-

279 sp.fulltext.set(search_form.cleaned_data['fulltext']) 

-

280 sp.trigram_threshold = search_form.cleaned_data['trigram_threshold'] 

-

281 sp.save() 

-

282 else: 

-

283 search_error = True 

-

284 

-

285 fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len( 

-

286 sp.fulltext.all()) 

-

287 if sp and not search_error and fields_searched > 0: 

-

288 search_form = SearchPreferenceForm(instance=sp) 

-

289 elif not search_error: 

-

290 search_form = SearchPreferenceForm() 

-

291 

-

292 # these fields require postgresql - just disable them if postgresql isn't available 

-

293 if not settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': 

-

294 sp.search = SearchPreference.SIMPLE 

-

295 sp.trigram.clear() 

-

296 sp.fulltext.clear() 

-

297 sp.save() 

-

298 

-

299 return render(request, 'settings.html', { 

-

300 'search_form': search_form, 

-

301 }) 

-

302 

-

303 

-

304@group_required('guest') 

-

305def history(request): 

-

306 view_log = ViewLogTable( 

-

307 ViewLog.objects.filter( 

-

308 created_by=request.user, space=request.space 

-

309 ).order_by('-created_at').all() 

-

310 ) 

-

311 cook_log = CookLogTable( 

-

312 CookLog.objects.filter( 

-

313 created_by=request.user 

-

314 ).order_by('-created_at').all() 

-

315 ) 

-

316 return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log}) 

-

317 

-

318 

-

319def system(request): 

-

320 if not request.user.is_superuser: 

-

321 return HttpResponseRedirect(reverse('index')) 

-

322 

-

323 postgres_ver = None 

-

324 postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql' 

-

325 

-

326 if postgres: 

-

327 postgres_current = 16 # will need to be updated as PostgreSQL releases new major versions 

-

328 from decimal import Decimal 

-

329 

-

330 from django.db import connection 

-

331 

-

332 postgres_ver = Decimal(str(connection.pg_version).replace('00', '.')) 

-

333 if postgres_ver >= postgres_current: 

-

334 database_status = 'success' 

-

335 database_message = _('Everything is fine!') 

-

336 elif postgres_ver < postgres_current - 2: 

-

337 database_status = 'danger' 

-

338 database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver} 

-

339 else: 

-

340 database_status = 'info' 

-

341 database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current} 

-

342 else: 

-

343 database_status = 'info' 

-

344 database_message = _('This application is not running with a Postgres database backend. This is ok but not recommended as some features only work with postgres databases.') 

-

345 

-

346 secret_key = False if os.getenv('SECRET_KEY') else True 

-

347 

-

348 if request.method == "POST": 

-

349 del_orphans = request.POST.get('delete_orphans') 

-

350 orphans = get_orphan_files(delete_orphans=str2bool(del_orphans)) 

-

351 else: 

-

352 orphans = get_orphan_files() 

-

353 

-

354 out = StringIO() 

-

355 call_command('showmigrations', stdout=out) 

-

356 missing_migration = False 

-

357 migration_info = {} 

-

358 current_app = None 

-

359 for row in out.getvalue().splitlines(): 

-

360 if '[ ]' in row and current_app: 

-

361 migration_info[current_app]['unapplied_migrations'].append(row.replace('[ ]', '')) 

-

362 missing_migration = True 

-

363 elif '[X]' in row and current_app: 

-

364 migration_info[current_app]['applied_migrations'].append(row.replace('[x]', '')) 

-

365 elif '(no migrations)' in row and current_app: 

-

366 pass 

-

367 else: 

-

368 current_app = row 

-

369 migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [], 'total': 0} 

-

370 

-

371 for key in migration_info.keys(): 

-

372 migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(migration_info[key]['applied_migrations']) 

-

373 

-

374 return render(request, 'system.html', { 

-

375 'gunicorn_media': settings.GUNICORN_MEDIA, 

-

376 'debug': settings.DEBUG, 

-

377 'postgres': postgres, 

-

378 'postgres_version': postgres_ver, 

-

379 'postgres_status': database_status, 

-

380 'postgres_message': database_message, 

-

381 'version_info': VERSION_INFO, 

-

382 'plugins': PLUGINS, 

-

383 'secret_key': secret_key, 

-

384 'orphans': orphans, 

-

385 'migration_info': migration_info, 

-

386 'missing_migration': missing_migration, 

-

387 }) 

-

388 

-

389 

-

390def setup(request): 

-

391 with scopes_disabled(): 

-

392 if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS: 

-

393 messages.add_message(request, messages.ERROR, 

-

394 _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.')) 

-

395 return HttpResponseRedirect(reverse('account_login')) 

-

396 

-

397 if request.method == 'POST': 

-

398 form = UserCreateForm(request.POST) 

-

399 if form.is_valid(): 

-

400 if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: 

-

401 form.add_error('password', _('Passwords dont match!')) 

-

402 else: 

-

403 user = User(username=form.cleaned_data['name'], is_superuser=True, is_staff=True) 

-

404 try: 

-

405 validate_password(form.cleaned_data['password'], user=user) 

-

406 user.set_password(form.cleaned_data['password']) 

-

407 user.save() 

-

408 

-

409 messages.add_message(request, messages.SUCCESS, _('User has been created, please login!')) 

-

410 return HttpResponseRedirect(reverse('account_login')) 

-

411 except ValidationError as e: 

-

412 for m in e: 

-

413 form.add_error('password', m) 

-

414 else: 

-

415 form = UserCreateForm() 

-

416 

-

417 return render(request, 'setup.html', {'form': form}) 

-

418 

-

419 

-

420def invite_link(request, token): 

-

421 with scopes_disabled(): 

-

422 try: 

-

423 token = UUID(token, version=4) 

-

424 except ValueError: 

-

425 messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!')) 

-

426 return HttpResponseRedirect(reverse('index')) 

-

427 

-

428 if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first(): 

-

429 if request.user.is_authenticated and not request.user.userspace_set.filter(space=link.space).exists(): 

-

430 if not link.reusable: 

-

431 link.used_by = request.user 

-

432 link.save() 

-

433 

-

434 user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False) 

-

435 

-

436 if request.user.userspace_set.count() == 1: 

-

437 user_space.active = True 

-

438 user_space.save() 

-

439 

-

440 user_space.groups.add(link.group) 

-

441 

-

442 messages.add_message(request, messages.SUCCESS, _('Successfully joined space.')) 

-

443 return HttpResponseRedirect(reverse('view_space_overview')) 

-

444 else: 

-

445 request.session['signup_token'] = str(token) 

-

446 return HttpResponseRedirect(reverse('account_signup')) 

-

447 

-

448 messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!')) 

-

449 return HttpResponseRedirect(reverse('view_space_overview')) 

-

450 

-

451 

-

452@group_required('admin') 

-

453def space_manage(request, space_id): 

-

454 if request.space.demo: 

-

455 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!')) 

-

456 return redirect('index') 

-

457 space = get_object_or_404(Space, id=space_id) 

-

458 switch_user_active_space(request.user, space) 

-

459 return render(request, 'space_manage.html', {}) 

-

460 

-

461 

-

462def report_share_abuse(request, token): 

-

463 if not settings.SHARING_ABUSE: 

-

464 messages.add_message(request, messages.WARNING, 

-

465 _('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.')) 

-

466 else: 

-

467 if link := ShareLink.objects.filter(uuid=token).first(): 

-

468 link.abuse_blocked = True 

-

469 link.save() 

-

470 messages.add_message(request, messages.WARNING, 

-

471 _('Recipe sharing link has been disabled! For additional information please contact the page administrator.')) 

-

472 return HttpResponseRedirect(reverse('index')) 

-

473 

-

474 

-

475def markdown_info(request): 

-

476 return render(request, 'markdown_info.html', {}) 

-

477 

-

478 

-

479def search_info(request): 

-

480 return render(request, 'search_info.html', {}) 

-

481 

-

482 

-

483@group_required('guest') 

-

484def api_info(request): 

-

485 return render(request, 'api_info.html', {}) 

-

486 

-

487 

-

488def offline(request): 

-

489 return render(request, 'offline.html', {}) 

-

490 

-

491 

-

492def test(request): 

-

493 if not settings.DEBUG: 

-

494 return HttpResponseRedirect(reverse('index')) 

-

495 

-

496 from cookbook.helper.ingredient_parser import IngredientParser 

-

497 parser = IngredientParser(request, False) 

-

498 

-

499 data = { 

-

500 'original': '90g golden syrup' 

-

501 } 

-

502 data['parsed'] = parser.parse(data['original']) 

-

503 

-

504 return render(request, 'test.html', {'data': data}) 

-

505 

-

506 

-

507def test2(request): 

-

508 if not settings.DEBUG: 

-

509 return HttpResponseRedirect(reverse('index')) 

-

510 

-

511 

-

512def get_orphan_files(delete_orphans=False): 

-

513 # Get list of all image files in media folder 

-

514 media_dir = settings.MEDIA_ROOT 

-

515 

-

516 def find_orphans(): 

-

517 image_files = [] 

-

518 for root, dirs, files in os.walk(media_dir): 

-

519 for file in files: 

-

520 

-

521 if not file.lower().endswith(('.db')) and not root.lower().endswith(('@eadir')): 

-

522 full_path = os.path.join(root, file) 

-

523 relative_path = os.path.relpath(full_path, media_dir) 

-

524 image_files.append((relative_path, full_path)) 

-

525 

-

526 # Get list of all image fields in models 

-

527 image_fields = [] 

-

528 for model in apps.get_models(): 

-

529 for field in model._meta.get_fields(): 

-

530 if isinstance(field, models.ImageField) or isinstance(field, models.FileField): 

-

531 image_fields.append((model, field.name)) 

-

532 

-

533 # get all images in the database 

-

534 # TODO I don't know why, but this completely bypasses scope limitations 

-

535 image_paths = [] 

-

536 for model, field in image_fields: 

-

537 image_field_paths = model.objects.values_list(field, flat=True) 

-

538 image_paths.extend(image_field_paths) 

-

539 

-

540 # Check each image file against model image fields 

-

541 return [img for img in image_files if img[0] not in image_paths] 

-

542 

-

543 orphans = find_orphans() 

-

544 if delete_orphans: 

-

545 for f in [img[1] for img in orphans]: 

-

546 try: 

-

547 os.remove(f) 

-

548 except FileNotFoundError: 

-

549 print(f"File not found: {f}") 

-

550 except Exception as e: 

-

551 print(f"Error deleting file {f}: {e}") 

-

552 orphans = find_orphans() 

-

553 

-

554 return [img[1] for img in orphans] 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_AllAuthCustomAdapter_py.html b/docs/coverage/d_f8cd9a78c43a323f_AllAuthCustomAdapter_py.html deleted file mode 100644 index e3c6c46de6..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_AllAuthCustomAdapter_py.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - Coverage for cookbook/helper/AllAuthCustomAdapter.py: 36% - - - - - -
-
-

- Coverage for cookbook/helper/AllAuthCustomAdapter.py: - 36% -

- -

- 28 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import datetime 

-

2from gettext import gettext as _ 

-

3 

-

4from allauth.account.adapter import DefaultAccountAdapter 

-

5from django.conf import settings 

-

6from django.contrib import messages 

-

7from django.core.cache import caches 

-

8 

-

9from cookbook.models import InviteLink 

-

10 

-

11 

-

12class AllAuthCustomAdapter(DefaultAccountAdapter): 

-

13 

-

14 def is_open_for_signup(self, request): 

-

15 """ 

-

16 Whether to allow sign-ups. 

-

17 """ 

-

18 signup_token = False 

-

19 if 'signup_token' in request.session and InviteLink.objects.filter( 

-

20 valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists(): 

-

21 signup_token = True 

-

22 

-

23 if request.resolver_match.view_name == 'account_signup' and not settings.ENABLE_SIGNUP and not signup_token: 

-

24 return False 

-

25 elif request.resolver_match.view_name == 'socialaccount_signup' and len(settings.SOCIAL_PROVIDERS) < 1: 

-

26 return False 

-

27 else: 

-

28 return super(AllAuthCustomAdapter, self).is_open_for_signup(request) 

-

29 

-

30 # disable password reset for now 

-

31 def send_mail(self, template_prefix, email, context): 

-

32 if settings.EMAIL_HOST != '': 

-

33 default = datetime.datetime.now() 

-

34 c = caches['default'].get_or_set(email, default, timeout=360) 

-

35 if c == default: 

-

36 try: 

-

37 super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context) 

-

38 except Exception: # dont fail signup just because confirmation mail could not be send 

-

39 pass 

-

40 else: 

-

41 messages.add_message(self.request, messages.ERROR, _('In order to prevent spam, the requested email was not send. Please wait a few minutes and try again.')) 

-

42 else: 

-

43 pass 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_CustomStorageClass_py.html b/docs/coverage/d_f8cd9a78c43a323f_CustomStorageClass_py.html deleted file mode 100644 index ffd759d2e5..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_CustomStorageClass_py.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - Coverage for cookbook/helper/CustomStorageClass.py: 46% - - - - - -
-
-

- Coverage for cookbook/helper/CustomStorageClass.py: - 46% -

- -

- 13 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import hashlib 

-

2 

-

3from django.conf import settings 

-

4from django.core.cache import cache 

-

5from storages.backends.s3boto3 import S3Boto3Storage 

-

6 

-

7 

-

8class CachedS3Boto3Storage(S3Boto3Storage): 

-

9 def url(self, name, **kwargs): 

-

10 key = hashlib.md5(f'recipes_media_urls_{name}'.encode('utf-8')).hexdigest() 

-

11 if result := cache.get(key): 

-

12 return result 

-

13 

-

14 result = super(CachedS3Boto3Storage, self).url(name, **kwargs) 

-

15 

-

16 timeout = int(settings.AWS_QUERYSTRING_EXPIRE * .95) 

-

17 cache.set(key, result, timeout) 

-

18 

-

19 return result 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_CustomTestRunner_py.html b/docs/coverage/d_f8cd9a78c43a323f_CustomTestRunner_py.html deleted file mode 100644 index b31066ca4b..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_CustomTestRunner_py.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - Coverage for cookbook/helper/CustomTestRunner.py: 0% - - - - - -
-
-

- Coverage for cookbook/helper/CustomTestRunner.py: - 0% -

- -

- 6 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.test.runner import DiscoverRunner 

-

2from django_scopes import scopes_disabled 

-

3 

-

4 

-

5class CustomTestRunner(DiscoverRunner): 

-

6 def run_tests(self, *args, **kwargs): 

-

7 with scopes_disabled(): 

-

8 return super().run_tests(*args, **kwargs) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_HelperFunctions_py.html b/docs/coverage/d_f8cd9a78c43a323f_HelperFunctions_py.html deleted file mode 100644 index 86982680a2..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_HelperFunctions_py.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - Coverage for cookbook/helper/HelperFunctions.py: 88% - - - - - -
-
-

- Coverage for cookbook/helper/HelperFunctions.py: - 88% -

- -

- 8 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.db.models import Func 

-

2 

-

3 

-

4class Round(Func): 

-

5 function = 'ROUND' 

-

6 template = '%(function)s(%(expressions)s, 0)' 

-

7 

-

8 

-

9def str2bool(v): 

-

10 if isinstance(v, bool) or v is None: 

-

11 return v 

-

12 else: 

-

13 return v.lower() in ("yes", "true", "1") 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_automation_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_automation_helper_py.html deleted file mode 100644 index 2738f5d871..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_automation_helper_py.html +++ /dev/null @@ -1,324 +0,0 @@ - - - - - Coverage for cookbook/helper/automation_helper.py: 67% - - - - - -
-
-

- Coverage for cookbook/helper/automation_helper.py: - 67% -

- -

- 149 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2 

-

3from django.core.cache import caches 

-

4from django.db.models.functions import Lower 

-

5 

-

6from cookbook.models import Automation 

-

7 

-

8 

-

9class AutomationEngine: 

-

10 request = None 

-

11 source = None 

-

12 use_cache = None 

-

13 food_aliases = None 

-

14 keyword_aliases = None 

-

15 unit_aliases = None 

-

16 never_unit = None 

-

17 transpose_words = None 

-

18 regex_replace = { 

-

19 Automation.DESCRIPTION_REPLACE: None, 

-

20 Automation.INSTRUCTION_REPLACE: None, 

-

21 Automation.FOOD_REPLACE: None, 

-

22 Automation.UNIT_REPLACE: None, 

-

23 Automation.NAME_REPLACE: None, 

-

24 } 

-

25 

-

26 def __init__(self, request, use_cache=True, source=None): 

-

27 self.request = request 

-

28 self.use_cache = use_cache 

-

29 if not source: 

-

30 self.source = "default_string_to_avoid_false_regex_match" 

-

31 else: 

-

32 self.source = source 

-

33 

-

34 def apply_keyword_automation(self, keyword): 

-

35 keyword = keyword.strip() 

-

36 if self.use_cache and self.keyword_aliases is None: 

-

37 self.keyword_aliases = {} 

-

38 KEYWORD_CACHE_KEY = f'automation_keyword_alias_{self.request.space.pk}' 

-

39 if c := caches['default'].get(KEYWORD_CACHE_KEY, None): 

-

40 self.keyword_aliases = c 

-

41 caches['default'].touch(KEYWORD_CACHE_KEY, 30) 

-

42 else: 

-

43 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all(): 

-

44 self.keyword_aliases[a.param_1.lower()] = a.param_2 

-

45 caches['default'].set(KEYWORD_CACHE_KEY, self.keyword_aliases, 30) 

-

46 else: 

-

47 self.keyword_aliases = {} 

-

48 if self.keyword_aliases: 

-

49 try: 

-

50 keyword = self.keyword_aliases[keyword.lower()] 

-

51 except KeyError: 

-

52 pass 

-

53 else: 

-

54 if automation := Automation.objects.filter(space=self.request.space, type=Automation.KEYWORD_ALIAS, param_1__iexact=keyword, disabled=False).order_by('order').first(): 

-

55 return automation.param_2 

-

56 return keyword 

-

57 

-

58 def apply_unit_automation(self, unit): 

-

59 unit = unit.strip() 

-

60 if self.use_cache and self.unit_aliases is None: 

-

61 self.unit_aliases = {} 

-

62 UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}' 

-

63 if c := caches['default'].get(UNIT_CACHE_KEY, None): 

-

64 self.unit_aliases = c 

-

65 caches['default'].touch(UNIT_CACHE_KEY, 30) 

-

66 else: 

-

67 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all(): 

-

68 self.unit_aliases[a.param_1.lower()] = a.param_2 

-

69 caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30) 

-

70 else: 

-

71 self.unit_aliases = {} 

-

72 if self.unit_aliases: 

-

73 try: 

-

74 unit = self.unit_aliases[unit.lower()] 

-

75 except KeyError: 

-

76 pass 

-

77 else: 

-

78 if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__iexact=unit, disabled=False).order_by('order').first(): 

-

79 return automation.param_2 

-

80 return self.apply_regex_replace_automation(unit, Automation.UNIT_REPLACE) 

-

81 

-

82 def apply_food_automation(self, food): 

-

83 food = food.strip() 

-

84 if self.use_cache and self.food_aliases is None: 

-

85 self.food_aliases = {} 

-

86 FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}' 

-

87 if c := caches['default'].get(FOOD_CACHE_KEY, None): 

-

88 self.food_aliases = c 

-

89 caches['default'].touch(FOOD_CACHE_KEY, 30) 

-

90 else: 

-

91 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all(): 

-

92 self.food_aliases[a.param_1.lower()] = a.param_2 

-

93 caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30) 

-

94 else: 

-

95 self.food_aliases = {} 

-

96 

-

97 if self.food_aliases: 

-

98 try: 

-

99 return self.food_aliases[food.lower()] 

-

100 except KeyError: 

-

101 return food 

-

102 else: 

-

103 if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first(): 

-

104 return automation.param_2 

-

105 return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE) 

-

106 

-

107 def apply_never_unit_automation(self, tokens): 

-

108 """ 

-

109 Moves a string that should never be treated as a unit to next token and optionally replaced with default unit 

-

110 e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white'] 

-

111 or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk'] 

-

112 :param1 string: string that should never be considered a unit, will be moved to token[2] 

-

113 :param2 (optional) unit as string: will insert unit string into token[1] 

-

114 :return: unit as string (possibly changed by automation) 

-

115 """ 

-

116 

-

117 if self.use_cache and self.never_unit is None: 

-

118 self.never_unit = {} 

-

119 NEVER_UNIT_CACHE_KEY = f'automation_never_unit_{self.request.space.pk}' 

-

120 if c := caches['default'].get(NEVER_UNIT_CACHE_KEY, None): 

-

121 self.never_unit = c 

-

122 caches['default'].touch(NEVER_UNIT_CACHE_KEY, 30) 

-

123 else: 

-

124 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.NEVER_UNIT).only('param_1', 'param_2').order_by('order').all(): 

-

125 self.never_unit[a.param_1.lower()] = a.param_2 

-

126 caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30) 

-

127 else: 

-

128 self.never_unit = {} 

-

129 

-

130 new_unit = None 

-

131 alt_unit = self.apply_unit_automation(tokens[1]) 

-

132 never_unit = False 

-

133 if self.never_unit: 

-

134 try: 

-

135 new_unit = self.never_unit[tokens[1].lower()] 

-

136 never_unit = True 

-

137 except KeyError: 

-

138 return tokens 

-

139 else: 

-

140 if a := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[ 

-

141 tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first(): 

-

142 new_unit = a.param_2 

-

143 never_unit = True 

-

144 

-

145 if never_unit: 

-

146 tokens.insert(1, new_unit) 

-

147 return tokens 

-

148 

-

149 def apply_transpose_automation(self, string): 

-

150 """ 

-

151 If two words (param_1 & param_2) are detected in sequence, swap their position in the ingredient string 

-

152 :param 1: first word to detect 

-

153 :param 2: second word to detect 

-

154 return: new ingredient string 

-

155 """ 

-

156 if self.use_cache and self.transpose_words is None: 

-

157 self.transpose_words = {} 

-

158 TRANSPOSE_WORDS_CACHE_KEY = f'automation_transpose_words_{self.request.space.pk}' 

-

159 if c := caches['default'].get(TRANSPOSE_WORDS_CACHE_KEY, None): 

-

160 self.transpose_words = c 

-

161 caches['default'].touch(TRANSPOSE_WORDS_CACHE_KEY, 30) 

-

162 else: 

-

163 i = 0 

-

164 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.TRANSPOSE_WORDS).only( 

-

165 'param_1', 'param_2').order_by('order').all()[:512]: 

-

166 self.transpose_words[i] = [a.param_1.lower(), a.param_2.lower()] 

-

167 i += 1 

-

168 caches['default'].set(TRANSPOSE_WORDS_CACHE_KEY, self.transpose_words, 30) 

-

169 else: 

-

170 self.transpose_words = {} 

-

171 

-

172 tokens = [x.lower() for x in string.replace(',', ' ').split()] 

-

173 if self.transpose_words: 

-

174 for key, value in self.transpose_words.items(): 

-

175 if value[0] in tokens and value[1] in tokens: 

-

176 string = re.sub(rf"\b({value[0]})\W*({value[1]})\b", r"\2 \1", string, flags=re.IGNORECASE) 

-

177 else: 

-

178 for rule in Automation.objects.filter(space=self.request.space, type=Automation.TRANSPOSE_WORDS, disabled=False) \ 

-

179 .annotate(param_1_lower=Lower('param_1'), param_2_lower=Lower('param_2')) \ 

-

180 .filter(param_1_lower__in=tokens, param_2_lower__in=tokens).order_by('order')[:512]: 

-

181 if rule.param_1 in tokens and rule.param_2 in tokens: 

-

182 string = re.sub(rf"\b({rule.param_1})\W*({rule.param_2})\b", r"\2 \1", string, flags=re.IGNORECASE) 

-

183 return string 

-

184 

-

185 def apply_regex_replace_automation(self, string, automation_type): 

-

186 # TODO add warning - maybe on SPACE page? when a max of 512 automations of a specific type is exceeded (ALIAS types excluded?) 

-

187 """ 

-

188 Replaces strings in a recipe field that are from a matched source 

-

189 field_type are Automation.type that apply regex replacements 

-

190 Automation.DESCRIPTION_REPLACE 

-

191 Automation.INSTRUCTION_REPLACE 

-

192 Automation.FOOD_REPLACE 

-

193 Automation.UNIT_REPLACE 

-

194 Automation.NAME_REPLACE 

-

195 

-

196 regex replacment utilized the following fields from the Automation model 

-

197 :param 1: source that should apply the automation in regex format ('.*' for all) 

-

198 :param 2: regex pattern to match () 

-

199 :param 3: replacement string (leave blank to delete) 

-

200 return: new string 

-

201 """ 

-

202 if self.use_cache and self.regex_replace[automation_type] is None: 

-

203 self.regex_replace[automation_type] = {} 

-

204 REGEX_REPLACE_CACHE_KEY = f'automation_regex_replace_{self.request.space.pk}' 

-

205 if c := caches['default'].get(REGEX_REPLACE_CACHE_KEY, None): 

-

206 self.regex_replace[automation_type] = c[automation_type] 

-

207 caches['default'].touch(REGEX_REPLACE_CACHE_KEY, 30) 

-

208 else: 

-

209 i = 0 

-

210 for a in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only( 

-

211 'param_1', 'param_2', 'param_3').order_by('order').all()[:512]: 

-

212 self.regex_replace[automation_type][i] = [a.param_1, a.param_2, a.param_3] 

-

213 i += 1 

-

214 caches['default'].set(REGEX_REPLACE_CACHE_KEY, self.regex_replace, 30) 

-

215 else: 

-

216 self.regex_replace[automation_type] = {} 

-

217 

-

218 if self.regex_replace[automation_type]: 

-

219 for rule in self.regex_replace[automation_type].values(): 

-

220 if re.match(rule[0], (self.source)[:512]): 

-

221 string = re.sub(rule[1], rule[2], string, flags=re.IGNORECASE) 

-

222 else: 

-

223 for rule in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only( 

-

224 'param_1', 'param_2', 'param_3').order_by('order').all()[:512]: 

-

225 if re.match(rule.param_1, (self.source)[:512]): 

-

226 string = re.sub(rule.param_2, rule.param_3, string, flags=re.IGNORECASE) 

-

227 return string 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_cache_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_cache_helper_py.html deleted file mode 100644 index bb21aee50c..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_cache_helper_py.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - Coverage for cookbook/helper/cache_helper.py: 100% - - - - - -
-
-

- Coverage for cookbook/helper/cache_helper.py: - 100% -

- -

- 8 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1class CacheHelper: 

-

2 space = None 

-

3 

-

4 BASE_UNITS_CACHE_KEY = None 

-

5 PROPERTY_TYPE_CACHE_KEY = None 

-

6 

-

7 def __init__(self, space): 

-

8 self.space = space 

-

9 

-

10 self.BASE_UNITS_CACHE_KEY = f'SPACE_{space.id}_BASE_UNITS' 

-

11 self.PROPERTY_TYPE_CACHE_KEY = f'SPACE_{space.id}_PROPERTY_TYPES' 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_context_processors_py.html b/docs/coverage/d_f8cd9a78c43a323f_context_processors_py.html deleted file mode 100644 index 4a97cce9bf..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_context_processors_py.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - Coverage for cookbook/helper/context_processors.py: 100% - - - - - -
-
-

- Coverage for cookbook/helper/context_processors.py: - 100% -

- -

- 3 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.conf import settings 

-

2 

-

3 

-

4def context_settings(request): 

-

5 return { 

-

6 'EMAIL_ENABLED': settings.EMAIL_HOST != '', 

-

7 'SIGNUP_ENABLED': settings.ENABLE_SIGNUP, 

-

8 'CAPTCHA_ENABLED': settings.HCAPTCHA_SITEKEY != '', 

-

9 'HOSTED': settings.HOSTED, 

-

10 'TERMS_URL': settings.TERMS_URL, 

-

11 'PRIVACY_URL': settings.PRIVACY_URL, 

-

12 'IMPRINT_URL': settings.IMPRINT_URL, 

-

13 'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL, 

-

14 } 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_dal_py.html b/docs/coverage/d_f8cd9a78c43a323f_dal_py.html deleted file mode 100644 index d7bf8d1c3c..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_dal_py.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - Coverage for cookbook/helper/dal.py: 68% - - - - - -
-
-

- Coverage for cookbook/helper/dal.py: - 68% -

- -

- 19 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from cookbook.models import Food, Keyword, Recipe, Unit 

-

2 

-

3from dal import autocomplete 

-

4 

-

5 

-

6class BaseAutocomplete(autocomplete.Select2QuerySetView): 

-

7 model = None 

-

8 

-

9 def get_queryset(self): 

-

10 if not self.request.user.is_authenticated: 

-

11 return self.model.objects.none() 

-

12 

-

13 qs = self.model.objects.filter(space=self.request.space).all() 

-

14 

-

15 if self.q: 

-

16 qs = qs.filter(name__icontains=self.q) 

-

17 

-

18 return qs 

-

19 

-

20 

-

21class KeywordAutocomplete(BaseAutocomplete): 

-

22 model = Keyword 

-

23 

-

24 

-

25class IngredientsAutocomplete(BaseAutocomplete): 

-

26 model = Food 

-

27 

-

28 

-

29class RecipeAutocomplete(BaseAutocomplete): 

-

30 model = Recipe 

-

31 

-

32 

-

33class UnitAutocomplete(BaseAutocomplete): 

-

34 model = Unit 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_fdc_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_fdc_helper_py.html deleted file mode 100644 index 682dda2ca9..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_fdc_helper_py.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - Coverage for cookbook/helper/fdc_helper.py: 0% - - - - - -
-
-

- Coverage for cookbook/helper/fdc_helper.py: - 0% -

- -

- 13 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2 

-

3 

-

4def get_all_nutrient_types(): 

-

5 f = open('') # <--- download the foundation food or any other dataset and retrieve all nutrition ID's from it https://fdc.nal.usda.gov/download-datasets.html 

-

6 json_data = json.loads(f.read()) 

-

7 

-

8 nutrients = {} 

-

9 for food in json_data['FoundationFoods']: 

-

10 for entry in food['foodNutrients']: 

-

11 nutrients[entry['nutrient']['id']] = {'name': entry['nutrient']['name'], 'unit': entry['nutrient']['unitName']} 

-

12 

-

13 nutrient_ids = list(nutrients.keys()) 

-

14 nutrient_ids.sort() 

-

15 for nid in nutrient_ids: 

-

16 print('{', f'value: {nid}, text: "{nutrients[nid]["name"]} [{nutrients[nid]["unit"]}] ({nid})"', '},') 

-

17 

-

18 

-

19get_all_nutrient_types() 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_image_processing_py.html b/docs/coverage/d_f8cd9a78c43a323f_image_processing_py.html deleted file mode 100644 index 63484549d5..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_image_processing_py.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - Coverage for cookbook/helper/image_processing.py: 19% - - - - - -
-
-

- Coverage for cookbook/helper/image_processing.py: - 19% -

- -

- 36 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import os 

-

2from io import BytesIO 

-

3 

-

4from PIL import Image 

-

5 

-

6 

-

7def rescale_image_jpeg(image_object, base_width=1020): 

-

8 img = Image.open(image_object) 

-

9 icc_profile = img.info.get('icc_profile') # remember color profile to not mess up colors 

-

10 width_percent = (base_width / float(img.size[0])) 

-

11 height = int((float(img.size[1]) * float(width_percent))) 

-

12 

-

13 img = img.resize((base_width, height), Image.LANCZOS) 

-

14 img_bytes = BytesIO() 

-

15 img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile) 

-

16 

-

17 return img_bytes 

-

18 

-

19 

-

20def rescale_image_png(image_object, base_width=1020): 

-

21 image_object = Image.open(image_object) 

-

22 wpercent = (base_width / float(image_object.size[0])) 

-

23 hsize = int((float(image_object.size[1]) * float(wpercent))) 

-

24 img = image_object.resize((base_width, hsize), Image.LANCZOS) 

-

25 

-

26 im_io = BytesIO() 

-

27 img.save(im_io, 'PNG', quality=90) 

-

28 return im_io 

-

29 

-

30 

-

31def get_filetype(name): 

-

32 try: 

-

33 return os.path.splitext(name)[1] 

-

34 except Exception: 

-

35 return '.jpeg' 

-

36 

-

37 

-

38# TODO this whole file needs proper documentation, refactoring, and testing 

-

39# TODO also add env variable to define which images sizes should be compressed 

-

40# filetype argument can not be optional, otherwise this function will treat all images as if they were a jpeg 

-

41# Because it's no longer optional, no reason to return it 

-

42def handle_image(request, image_object, filetype): 

-

43 try: 

-

44 Image.open(image_object).verify() 

-

45 except Exception: 

-

46 return None 

-

47 

-

48 if (image_object.size / 1000) > 500: # if larger than 500 kb compress 

-

49 if filetype == '.jpeg' or filetype == '.jpg': 

-

50 return rescale_image_jpeg(image_object) 

-

51 if filetype == '.png': 

-

52 return rescale_image_png(image_object) 

-

53 return image_object 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_ingredient_parser_py.html b/docs/coverage/d_f8cd9a78c43a323f_ingredient_parser_py.html deleted file mode 100644 index 9fadb7c893..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_ingredient_parser_py.html +++ /dev/null @@ -1,376 +0,0 @@ - - - - - Coverage for cookbook/helper/ingredient_parser.py: 85% - - - - - -
-
-

- Coverage for cookbook/helper/ingredient_parser.py: - 85% -

- -

- 175 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2import string 

-

3import unicodedata 

-

4 

-

5from cookbook.helper.automation_helper import AutomationEngine 

-

6from cookbook.models import Food, Ingredient, Unit 

-

7 

-

8 

-

9class IngredientParser: 

-

10 request = None 

-

11 ignore_rules = False 

-

12 automation = None 

-

13 

-

14 def __init__(self, request, cache_mode=True, ignore_automations=False): 

-

15 """ 

-

16 Initialize ingredient parser 

-

17 :param request: request context (to control caching, rule ownership, etc.) 

-

18 :param cache_mode: defines if all rules should be loaded on initialization (good when parser is used many times) or if they should be retrieved every time (good when parser is not used many times in a row) 

-

19 :param ignore_automations: ignore automation rules, allows to use ingredient parser without database access/request (request can be None) 

-

20 """ 

-

21 self.request = request 

-

22 self.ignore_rules = ignore_automations 

-

23 if not self.ignore_rules: 

-

24 self.automation = AutomationEngine(self.request, use_cache=cache_mode) 

-

25 

-

26 def get_unit(self, unit): 

-

27 """ 

-

28 Get or create a unit for given space respecting possible automations 

-

29 :param unit: string unit 

-

30 :return: None if unit passed is invalid, Unit object otherwise 

-

31 """ 

-

32 if not unit: 

-

33 return None 

-

34 if len(unit) > 0: 

-

35 if self.ignore_rules: 

-

36 u, created = Unit.objects.get_or_create(name=unit.strip(), space=self.request.space) 

-

37 else: 

-

38 u, created = Unit.objects.get_or_create(name=self.automation.apply_unit_automation(unit), space=self.request.space) 

-

39 return u 

-

40 return None 

-

41 

-

42 def get_food(self, food): 

-

43 """ 

-

44 Get or create a food for given space respecting possible automations 

-

45 :param food: string food 

-

46 :return: None if food passed is invalid, Food object otherwise 

-

47 """ 

-

48 if not food: 

-

49 return None 

-

50 if len(food) > 0: 

-

51 if self.ignore_rules: 

-

52 f, created = Food.objects.get_or_create(name=food.strip(), space=self.request.space) 

-

53 else: 

-

54 f, created = Food.objects.get_or_create(name=self.automation.apply_food_automation(food), space=self.request.space) 

-

55 return f 

-

56 return None 

-

57 

-

58 def parse_fraction(self, x): 

-

59 if len(x) == 1 and 'fraction' in unicodedata.decomposition(x): 

-

60 frac_split = unicodedata.decomposition(x[-1:]).split() 

-

61 return (float((frac_split[1]).replace('003', '')) 

-

62 / float((frac_split[3]).replace('003', ''))) 

-

63 else: 

-

64 frac_split = x.split('/') 

-

65 if not len(frac_split) == 2: 

-

66 raise ValueError 

-

67 try: 

-

68 return int(frac_split[0]) / int(frac_split[1]) 

-

69 except ZeroDivisionError: 

-

70 raise ValueError 

-

71 

-

72 def parse_amount(self, x): 

-

73 amount = 0 

-

74 unit = None 

-

75 note = '' 

-

76 if x.strip() == '': 

-

77 return amount, unit, note 

-

78 

-

79 did_check_frac = False 

-

80 end = 0 

-

81 while (end < len(x) and (x[end] in string.digits 

-

82 or ( 

-

83 (x[end] == '.' or x[end] == ',' or x[end] == '/') 

-

84 and end + 1 < len(x) 

-

85 and x[end + 1] in string.digits 

-

86 ))): 

-

87 end += 1 

-

88 if end > 0: 

-

89 if "/" in x[:end]: 

-

90 amount = self.parse_fraction(x[:end]) 

-

91 else: 

-

92 amount = float(x[:end].replace(',', '.')) 

-

93 else: 

-

94 amount = self.parse_fraction(x[0]) 

-

95 end += 1 

-

96 did_check_frac = True 

-

97 if end < len(x): 

-

98 if did_check_frac: 

-

99 unit = x[end:] 

-

100 else: 

-

101 try: 

-

102 amount += self.parse_fraction(x[end]) 

-

103 unit = x[end + 1:] 

-

104 except ValueError: 

-

105 unit = x[end:] 

-

106 

-

107 if unit is not None and unit.strip() == '': 

-

108 unit = None 

-

109 

-

110 if unit is not None and (unit.startswith('(') or unit.startswith( 

-

111 '-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3 

-

112 unit = None 

-

113 note = x 

-

114 return amount, unit, note 

-

115 

-

116 def parse_food_with_comma(self, tokens): 

-

117 food = '' 

-

118 note = '' 

-

119 start = 0 

-

120 # search for first occurrence of an argument ending in a comma 

-

121 while start < len(tokens) and not tokens[start].endswith(','): 

-

122 start += 1 

-

123 if start == len(tokens): 

-

124 # no token ending in a comma found -> use everything as food 

-

125 food = ' '.join(tokens) 

-

126 else: 

-

127 food = ' '.join(tokens[:start + 1])[:-1] 

-

128 note = ' '.join(tokens[start + 1:]) 

-

129 return food, note 

-

130 

-

131 def parse_food(self, tokens): 

-

132 food = '' 

-

133 note = '' 

-

134 if tokens[-1].endswith(')'): 

-

135 # Check if the matching opening bracket is in the same token 

-

136 if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]): 

-

137 return self.parse_food_with_comma(tokens) 

-

138 # last argument ends with closing bracket -> look for opening bracket 

-

139 start = len(tokens) - 1 

-

140 while not tokens[start].startswith('(') and not start == 0: 

-

141 start -= 1 

-

142 if start == 0: 

-

143 # the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501 

-

144 raise ValueError 

-

145 elif start < 0: 

-

146 # no opening bracket anywhere -> just ignore the last bracket 

-

147 food, note = self.parse_food_with_comma(tokens) 

-

148 else: 

-

149 # opening bracket found -> split in food and note, remove brackets from note # noqa: E501 

-

150 note = ' '.join(tokens[start:])[1:-1] 

-

151 food = ' '.join(tokens[:start]) 

-

152 else: 

-

153 food, note = self.parse_food_with_comma(tokens) 

-

154 return food, note 

-

155 

-

156 def parse(self, ingredient): 

-

157 """ 

-

158 Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ... 

-

159 :param ingredient: string ingredient 

-

160 :return: amount, unit (can be None), food, note (can be empty) 

-

161 """ 

-

162 # initialize default values 

-

163 amount = 0 

-

164 unit = None 

-

165 food = '' 

-

166 note = '' 

-

167 unit_note = '' 

-

168 

-

169 if len(ingredient) == 0: 

-

170 raise ValueError('string to parse cannot be empty') 

-

171 

-

172 if len(ingredient) > 512: 

-

173 raise ValueError('cannot parse ingredients with more than 512 characters') 

-

174 

-

175 # some people/languages put amount and unit at the end of the ingredient string 

-

176 # if something like this is detected move it to the beginning so the parser can handle it 

-

177 if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient): 

-

178 match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient) 

-

179 print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}') 

-

180 ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '') 

-

181 

-

182 # if the string contains parenthesis early on remove it and place it at the end 

-

183 # because its likely some kind of note 

-

184 if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient): 

-

185 match = re.search('\\((.[^\\(])+\\)', ingredient) 

-

186 ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()] 

-

187 

-

188 # leading spaces before commas result in extra tokens, clean them out 

-

189 ingredient = ingredient.replace(' ,', ',') 

-

190 

-

191 # handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description 

-

192 # "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)" 

-

193 ingredient = re.sub("^(\\d+|\\d+[\\.,]\\d+) - (\\d+|\\d+[\\.,]\\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient) 

-

194 

-

195 # if amount and unit are connected add space in between 

-

196 if re.match('([0-9])+([A-z])+\\s', ingredient): 

-

197 ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient) 

-

198 

-

199 if not self.ignore_rules: 

-

200 ingredient = self.automation.apply_transpose_automation(ingredient) 

-

201 

-

202 tokens = ingredient.split() # split at each space into tokens 

-

203 if len(tokens) == 1: 

-

204 # there only is one argument, that must be the food 

-

205 food = tokens[0] 

-

206 else: 

-

207 try: 

-

208 # try to parse first argument as amount 

-

209 amount, unit, unit_note = self.parse_amount(tokens[0]) 

-

210 # only try to parse second argument as amount if there are at least 

-

211 # three arguments if it already has a unit there can't be 

-

212 # a fraction for the amount 

-

213 if len(tokens) > 2: 

-

214 if not self.ignore_rules: 

-

215 tokens = self.automation.apply_never_unit_automation(tokens) 

-

216 try: 

-

217 if unit is not None: 

-

218 # a unit is already found, no need to try the second argument for a fraction 

-

219 # probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except 

-

220 raise ValueError 

-

221 # try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' 

-

222 amount += self.parse_fraction(tokens[1]) 

-

223 # assume that units can't end with a comma 

-

224 if len(tokens) > 3 and not tokens[2].endswith(','): 

-

225 # try to use third argument as unit and everything else as food, use everything as food if it fails 

-

226 try: 

-

227 food, note = self.parse_food(tokens[3:]) 

-

228 unit = tokens[2] 

-

229 except ValueError: 

-

230 food, note = self.parse_food(tokens[2:]) 

-

231 else: 

-

232 food, note = self.parse_food(tokens[2:]) 

-

233 except ValueError: 

-

234 # assume that units can't end with a comma 

-

235 if not tokens[1].endswith(','): 

-

236 # try to use second argument as unit and everything else as food, use everything as food if it fails 

-

237 try: 

-

238 food, note = self.parse_food(tokens[2:]) 

-

239 if unit is None: 

-

240 unit = tokens[1] 

-

241 else: 

-

242 note = tokens[1] 

-

243 except ValueError: 

-

244 food, note = self.parse_food(tokens[1:]) 

-

245 else: 

-

246 food, note = self.parse_food(tokens[1:]) 

-

247 else: 

-

248 # only two arguments, first one is the amount 

-

249 # which means this is the food 

-

250 food = tokens[1] 

-

251 except ValueError: 

-

252 try: 

-

253 # can't parse first argument as amount 

-

254 # -> no unit -> parse everything as food 

-

255 food, note = self.parse_food(tokens) 

-

256 except ValueError: 

-

257 food = ' '.join(tokens[1:]) 

-

258 

-

259 if unit_note not in note: 

-

260 note += ' ' + unit_note 

-

261 

-

262 if unit and not self.ignore_rules: 

-

263 unit = self.automation.apply_unit_automation(unit) 

-

264 

-

265 if food and not self.ignore_rules: 

-

266 food = self.automation.apply_food_automation(food) 

-

267 if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long 

-

268 # try splitting it at a space and taking only the first arg 

-

269 if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length: 

-

270 note = ' '.join(food.split()[1:]) + ' ' + note 

-

271 food = food.split()[0] 

-

272 else: 

-

273 note = food + ' ' + note 

-

274 food = food[:Food._meta.get_field('name').max_length] 

-

275 

-

276 if len(food.strip()) == 0: 

-

277 raise ValueError(f'Error parsing string {ingredient}, food cannot be empty') 

-

278 

-

279 return amount, unit, food, note[:Ingredient._meta.get_field('note').max_length].strip() 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_mdx_attributes_py.html b/docs/coverage/d_f8cd9a78c43a323f_mdx_attributes_py.html deleted file mode 100644 index 3580c2f4c8..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_mdx_attributes_py.html +++ /dev/null @@ -1,125 +0,0 @@ - - - - - Coverage for cookbook/helper/mdx_attributes.py: 88% - - - - - -
-
-

- Coverage for cookbook/helper/mdx_attributes.py: - 88% -

- -

- 17 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import markdown 

-

2from markdown.treeprocessors import Treeprocessor 

-

3 

-

4 

-

5class StyleTreeprocessor(Treeprocessor): 

-

6 

-

7 def run_processor(self, node): 

-

8 for child in node: 

-

9 if child.tag == "table": 

-

10 child.set("class", "table table-bordered") 

-

11 if child.tag == "img": 

-

12 child.set("class", "img-fluid") 

-

13 self.run_processor(child) 

-

14 return node 

-

15 

-

16 def run(self, root): 

-

17 self.run_processor(root) 

-

18 return root 

-

19 

-

20 

-

21class MarkdownFormatExtension(markdown.Extension): 

-

22 # md_ globals deprecated - see here: 

-

23 def extendMarkdown(self, md): 

-

24 md.treeprocessors.register( 

-

25 StyleTreeprocessor(), 

-

26 'StyleTreeprocessor', 

-

27 10 

-

28 ) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_mdx_urlize_py.html b/docs/coverage/d_f8cd9a78c43a323f_mdx_urlize_py.html deleted file mode 100644 index a259bb642e..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_mdx_urlize_py.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - Coverage for cookbook/helper/mdx_urlize.py: 40% - - - - - -
-
-

- Coverage for cookbook/helper/mdx_urlize.py: - 40% -

- -

- 25 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1""" 

-

2A more liberal autolinker 

-

3 

-

4Inspired by Django's urlize function. 

-

5 

-

6Positive examples: 

-

7 

-

8>>> import markdown 

-

9>>> md = markdown.Markdown(extensions=['urlize']) 

-

10 

-

11>>> md.convert('http://example.com/') 

-

12u'<p><a href="http://example.com/">http://example.com/</a></p>' 

-

13 

-

14>>> md.convert('go to http://example.com') 

-

15u'<p>go to <a href="http://example.com">http://example.com</a></p>' 

-

16 

-

17>>> md.convert('example.com') 

-

18u'<p><a href="http://example.com">example.com</a></p>' 

-

19 

-

20>>> md.convert('example.net') 

-

21u'<p><a href="http://example.net">example.net</a></p>' 

-

22 

-

23>>> md.convert('www.example.us') 

-

24u'<p><a href="http://www.example.us">www.example.us</a></p>' 

-

25 

-

26>>> md.convert('(www.example.us/path/?name=val)') 

-

27u'<p>(<a href="http://www.example.us/path/?name=val">www.example.us/path/?name=val</a>)</p>' 

-

28 

-

29>>> md.convert('go to <http://example.com> now!') 

-

30u'<p>go to <a href="http://example.com">http://example.com</a> now!</p>' 

-

31 

-

32Negative examples: 

-

33 

-

34>>> md.convert('del.icio.us') 

-

35u'<p>del.icio.us</p>' 

-

36 

-

37""" 

-

38from xml.etree.ElementTree import Element 

-

39 

-

40import markdown 

-

41 

-

42# Global Vars 

-

43URLIZE_RE = '(%s)' % '|'.join([ 

-

44 r'<(?:f|ht)tps?://[^>]*>', 

-

45 r'\b(?:f|ht)tps?://[^)<>\s]+[^.,)<>\s]', 

-

46 r'\bwww\.[^)<>\s]+[^.,)<>\s]', 

-

47 r'[^(<\s]+\.(?:com|net|org)\b', 

-

48]) 

-

49 

-

50 

-

51class UrlizePattern(markdown.inlinepatterns.Pattern): 

-

52 """ Return a link Element given an autolink (`http://example/com`). """ 

-

53 

-

54 def handleMatch(self, m): 

-

55 url = m.group(2) 

-

56 

-

57 if url.startswith('<'): 

-

58 url = url[1:-1] 

-

59 

-

60 text = url 

-

61 

-

62 if not url.split('://')[0] in ('http', 'https', 'ftp'): 

-

63 if '@' in url and '/' not in url: 

-

64 url = 'mailto:' + url 

-

65 else: 

-

66 url = 'http://' + url 

-

67 

-

68 el = Element("a") 

-

69 el.set('href', url) 

-

70 el.text = markdown.util.AtomicString(text) 

-

71 return el 

-

72 

-

73 

-

74class UrlizeExtension(markdown.Extension): 

-

75 """ Urlize Extension for Python-Markdown. """ 

-

76 

-

77 def extendMarkdown(self, md): 

-

78 """ Replace autolink with UrlizePattern """ 

-

79 md.inlinePatterns.register(UrlizePattern(URLIZE_RE, md), 'autolink', 120) 

-

80 

-

81 

-

82def makeExtension(*args, **kwargs): 

-

83 return UrlizeExtension(*args, **kwargs) 

-

84 

-

85 

-

86if __name__ == "__main__": 

-

87 import doctest 

-

88 

-

89 doctest.testmod() 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_open_data_importer_py.html b/docs/coverage/d_f8cd9a78c43a323f_open_data_importer_py.html deleted file mode 100644 index 07e808b581..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_open_data_importer_py.html +++ /dev/null @@ -1,307 +0,0 @@ - - - - - Coverage for cookbook/helper/open_data_importer.py: 14% - - - - - -
-
-

- Coverage for cookbook/helper/open_data_importer.py: - 14% -

- -

- 110 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket, 

-

2 SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion) 

-

3 

-

4 

-

5class OpenDataImporter: 

-

6 request = None 

-

7 data = {} 

-

8 slug_id_cache = {} 

-

9 update_existing = False 

-

10 use_metric = True 

-

11 

-

12 def __init__(self, request, data, update_existing=False, use_metric=True): 

-

13 self.request = request 

-

14 self.data = data 

-

15 self.update_existing = update_existing 

-

16 self.use_metric = use_metric 

-

17 

-

18 def _update_slug_cache(self, object_class, datatype): 

-

19 self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', )) 

-

20 

-

21 def import_units(self): 

-

22 datatype = 'unit' 

-

23 

-

24 insert_list = [] 

-

25 for u in list(self.data[datatype].keys()): 

-

26 insert_list.append(Unit( 

-

27 name=self.data[datatype][u]['name'], 

-

28 plural_name=self.data[datatype][u]['plural_name'], 

-

29 base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None, 

-

30 open_data_slug=u, 

-

31 space=self.request.space 

-

32 )) 

-

33 

-

34 if self.update_existing: 

-

35 return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=( 

-

36 'name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',)) 

-

37 else: 

-

38 return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) 

-

39 

-

40 def import_category(self): 

-

41 datatype = 'category' 

-

42 

-

43 insert_list = [] 

-

44 for k in list(self.data[datatype].keys()): 

-

45 insert_list.append(SupermarketCategory( 

-

46 name=self.data[datatype][k]['name'], 

-

47 open_data_slug=k, 

-

48 space=self.request.space 

-

49 )) 

-

50 

-

51 return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) 

-

52 

-

53 def import_property(self): 

-

54 datatype = 'property' 

-

55 

-

56 insert_list = [] 

-

57 for k in list(self.data[datatype].keys()): 

-

58 insert_list.append(PropertyType( 

-

59 name=self.data[datatype][k]['name'], 

-

60 unit=self.data[datatype][k]['unit'], 

-

61 open_data_slug=k, 

-

62 space=self.request.space 

-

63 )) 

-

64 

-

65 return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) 

-

66 

-

67 def import_supermarket(self): 

-

68 datatype = 'store' 

-

69 

-

70 self._update_slug_cache(SupermarketCategory, 'category') 

-

71 insert_list = [] 

-

72 for k in list(self.data[datatype].keys()): 

-

73 insert_list.append(Supermarket( 

-

74 name=self.data[datatype][k]['name'], 

-

75 open_data_slug=k, 

-

76 space=self.request.space 

-

77 )) 

-

78 

-

79 # always add open data slug if matching supermarket is found, otherwise relation might fail 

-

80 supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',)) 

-

81 self._update_slug_cache(Supermarket, 'store') 

-

82 

-

83 insert_list = [] 

-

84 for k in list(self.data[datatype].keys()): 

-

85 relations = [] 

-

86 order = 0 

-

87 for c in self.data[datatype][k]['categories']: 

-

88 relations.append( 

-

89 SupermarketCategoryRelation( 

-

90 supermarket_id=self.slug_id_cache[datatype][k], 

-

91 category_id=self.slug_id_cache['category'][c], 

-

92 order=order, 

-

93 ) 

-

94 ) 

-

95 order += 1 

-

96 

-

97 SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',)) 

-

98 

-

99 return supermarkets 

-

100 

-

101 def import_food(self): 

-

102 identifier_list = [] 

-

103 datatype = 'food' 

-

104 for k in list(self.data[datatype].keys()): 

-

105 identifier_list.append(self.data[datatype][k]['name']) 

-

106 identifier_list.append(self.data[datatype][k]['plural_name']) 

-

107 

-

108 existing_objects_flat = [] 

-

109 existing_objects = {} 

-

110 for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'): 

-

111 existing_objects_flat.append(f[1]) 

-

112 existing_objects_flat.append(f[2]) 

-

113 existing_objects[f[1]] = f 

-

114 existing_objects[f[2]] = f 

-

115 

-

116 self._update_slug_cache(Unit, 'unit') 

-

117 self._update_slug_cache(PropertyType, 'property') 

-

118 

-

119 insert_list = [] 

-

120 insert_list_flat = [] 

-

121 update_list = [] 

-

122 update_field_list = [] 

-

123 for k in list(self.data[datatype].keys()): 

-

124 if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat): 

-

125 if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_flat): 

-

126 insert_list.append({'data': { 

-

127 'name': self.data[datatype][k]['name'], 

-

128 'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, 

-

129 'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']], 

-

130 'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None, 

-

131 'open_data_slug': k, 

-

132 'space': self.request.space.id, 

-

133 }}) 

-

134 # build a fake second flat array to prevent duplicate foods from being inserted. 

-

135 # trying to insert a duplicate would throw a db error :( 

-

136 insert_list_flat.append(self.data[datatype][k]['name']) 

-

137 insert_list_flat.append(self.data[datatype][k]['plural_name']) 

-

138 else: 

-

139 if self.data[datatype][k]['name'] in existing_objects: 

-

140 existing_food_id = existing_objects[self.data[datatype][k]['name']][0] 

-

141 else: 

-

142 existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0] 

-

143 

-

144 if self.update_existing: 

-

145 update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ] 

-

146 update_list.append(Food( 

-

147 id=existing_food_id, 

-

148 name=self.data[datatype][k]['name'], 

-

149 plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, 

-

150 supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']], 

-

151 fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None, 

-

152 open_data_slug=k, 

-

153 )) 

-

154 else: 

-

155 update_field_list = ['open_data_slug', ] 

-

156 update_list.append(Food(id=existing_food_id, open_data_slug=k, )) 

-

157 

-

158 Food.load_bulk(insert_list, None) 

-

159 if len(update_list) > 0: 

-

160 Food.objects.bulk_update(update_list, update_field_list) 

-

161 

-

162 self._update_slug_cache(Food, 'food') 

-

163 

-

164 food_property_list = [] 

-

165 # alias_list = [] 

-

166 

-

167 for k in list(self.data[datatype].keys()): 

-

168 for fp in self.data[datatype][k]['properties']['type_values']: 

-

169 # try catch here because somettimes key "k" is not set for he food cache 

-

170 try: 

-

171 food_property_list.append(Property( 

-

172 property_type_id=self.slug_id_cache['property'][fp['property_type']], 

-

173 property_amount=fp['property_value'], 

-

174 import_food_id=self.slug_id_cache['food'][k], 

-

175 space=self.request.space, 

-

176 )) 

-

177 except KeyError: 

-

178 print(str(k) + ' is not in self.slug_id_cache["food"]') 

-

179 

-

180 Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',)) 

-

181 

-

182 property_food_relation_list = [] 

-

183 for p in Property.objects.filter(space=self.request.space, import_food_id__isnull=False).values_list('import_food_id', 'id', ): 

-

184 property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1])) 

-

185 

-

186 FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',)) 

-

187 

-

188 return insert_list + update_list 

-

189 

-

190 def import_conversion(self): 

-

191 datatype = 'conversion' 

-

192 

-

193 insert_list = [] 

-

194 for k in list(self.data[datatype].keys()): 

-

195 # try catch here because sometimes key "k" is not set for he food cache 

-

196 try: 

-

197 insert_list.append(UnitConversion( 

-

198 base_amount=self.data[datatype][k]['base_amount'], 

-

199 base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']], 

-

200 converted_amount=self.data[datatype][k]['converted_amount'], 

-

201 converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']], 

-

202 food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']], 

-

203 open_data_slug=k, 

-

204 space=self.request.space, 

-

205 created_by=self.request.user, 

-

206 )) 

-

207 except KeyError: 

-

208 print(str(k) + ' is not in self.slug_id_cache["food"]') 

-

209 

-

210 return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug')) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_permission_config_py.html b/docs/coverage/d_f8cd9a78c43a323f_permission_config_py.html deleted file mode 100644 index 62f584013c..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_permission_config_py.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - Coverage for cookbook/helper/permission_config.py: 0% - - - - - -
-
-

- Coverage for cookbook/helper/permission_config.py: - 0% -

- -

- 3 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from cookbook.helper.permission_helper import CustomIsUser 

-

2 

-

3 

-

4class PermissionConfig: 

-

5 BOOKS = { 

-

6 'owner': True, 

-

7 'groups': ['user'], 

-

8 'drf': [CustomIsUser], 

-

9 } 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_permission_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_permission_helper_py.html deleted file mode 100644 index b788272725..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_permission_helper_py.html +++ /dev/null @@ -1,539 +0,0 @@ - - - - - Coverage for cookbook/helper/permission_helper.py: 79% - - - - - -
-
-

- Coverage for cookbook/helper/permission_helper.py: - 79% -

- -

- 216 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import inspect 

-

2 

-

3from django.conf import settings 

-

4from django.contrib import messages 

-

5from django.contrib.auth.decorators import user_passes_test 

-

6from django.core.cache import cache 

-

7from django.core.exceptions import ObjectDoesNotExist, ValidationError 

-

8from django.http import HttpResponseRedirect 

-

9from django.urls import reverse, reverse_lazy 

-

10from django.utils.translation import gettext as _ 

-

11from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope 

-

12from oauth2_provider.models import AccessToken 

-

13from rest_framework import permissions 

-

14from rest_framework.permissions import SAFE_METHODS 

-

15 

-

16from cookbook.models import Recipe, ShareLink, UserSpace 

-

17 

-

18 

-

19def get_allowed_groups(groups_required): 

-

20 """ 

-

21 Builds a list of all groups equal or higher to the provided groups 

-

22 This means checking for guest will also allow admins to access 

-

23 :param groups_required: list or tuple of groups 

-

24 :return: tuple of groups 

-

25 """ 

-

26 groups_allowed = tuple(groups_required) 

-

27 if 'guest' in groups_required: 

-

28 groups_allowed = groups_allowed + ('user', 'admin') 

-

29 if 'user' in groups_required: 

-

30 groups_allowed = groups_allowed + ('admin',) 

-

31 return groups_allowed 

-

32 

-

33 

-

34def has_group_permission(user, groups, no_cache=False): 

-

35 """ 

-

36 Tests if a given user is member of a certain group (or any higher group) 

-

37 Superusers always bypass permission checks. 

-

38 Unauthenticated users can't be member of any group thus always return false. 

-

39 :param no_cache: (optional) do not return cached results, always check agains DB 

-

40 :param user: django auth user object 

-

41 :param groups: list or tuple of groups the user should be checked for 

-

42 :return: True if user is in allowed groups, false otherwise 

-

43 """ 

-

44 if not user.is_authenticated: 

-

45 return False 

-

46 groups_allowed = get_allowed_groups(groups) 

-

47 

-

48 CACHE_KEY = hash((inspect.stack()[0][3], (user.pk, user.username, user.email), groups_allowed)) 

-

49 if not no_cache: 

-

50 cached_result = cache.get(CACHE_KEY, default=None) 

-

51 if cached_result is not None: 

-

52 return cached_result 

-

53 

-

54 result = False 

-

55 if user.is_authenticated: 

-

56 if user_space := user.userspace_set.filter(active=True): 

-

57 if len(user_space) != 1: 

-

58 result = False # do not allow any group permission if more than one space is active, needs to be changed when simultaneous multi-space-tenancy is added 

-

59 elif bool(user_space.first().groups.filter(name__in=groups_allowed)): 

-

60 result = True 

-

61 

-

62 cache.set(CACHE_KEY, result, timeout=10) 

-

63 return result 

-

64 

-

65 

-

66def is_object_owner(user, obj): 

-

67 """ 

-

68 Tests if a given user is the owner of a given object 

-

69 test performed by checking user against the objects user 

-

70 and create_by field (if exists) 

-

71 :param user django auth user object 

-

72 :param obj any object that should be tested 

-

73 :return: true if user is owner of object, false otherwise 

-

74 """ 

-

75 if not user.is_authenticated: 

-

76 return False 

-

77 try: 

-

78 return obj.get_owner() == user 

-

79 except Exception: 

-

80 return False 

-

81 

-

82 

-

83def is_space_owner(user, obj): 

-

84 """ 

-

85 Tests if a given user is the owner the space of a given object 

-

86 :param user django auth user object 

-

87 :param obj any object that should be tested 

-

88 :return: true if user is owner of the objects space, false otherwise 

-

89 """ 

-

90 if not user.is_authenticated: 

-

91 return False 

-

92 try: 

-

93 return obj.get_space().get_owner() == user 

-

94 except Exception: 

-

95 return False 

-

96 

-

97 

-

98def is_object_shared(user, obj): 

-

99 """ 

-

100 Tests if a given user is shared for a given object 

-

101 test performed by checking user against the objects shared table 

-

102 :param user django auth user object 

-

103 :param obj any object that should be tested 

-

104 :return: true if user is shared for object, false otherwise 

-

105 """ 

-

106 # TODO this could be improved/cleaned up by adding 

-

107 # share checks for relevant objects 

-

108 if not user.is_authenticated: 

-

109 return False 

-

110 return user in obj.get_shared() 

-

111 

-

112 

-

113def share_link_valid(recipe, share): 

-

114 """ 

-

115 Verifies the validity of a share uuid 

-

116 :param recipe: recipe object 

-

117 :param share: share uuid 

-

118 :return: true if a share link with the given recipe and uuid exists 

-

119 """ 

-

120 try: 

-

121 CACHE_KEY = f'recipe_share_{recipe.pk}_{share}' 

-

122 if c := cache.get(CACHE_KEY, False): 

-

123 return c 

-

124 

-

125 if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first(): 

-

126 if 0 < settings.SHARING_LIMIT < link.request_count and not link.space.no_sharing_limit: 

-

127 return False 

-

128 link.request_count += 1 

-

129 link.save() 

-

130 cache.set(CACHE_KEY, True, timeout=3) 

-

131 return True 

-

132 return False 

-

133 except ValidationError: 

-

134 return False 

-

135 

-

136 

-

137# Django Views 

-

138 

-

139def group_required(*groups_required): 

-

140 """ 

-

141 Decorator that tests the requesting user to be member 

-

142 of at least one of the provided groups or higher level groups 

-

143 :param groups_required: list of required groups 

-

144 :return: true if member of group, false otherwise 

-

145 """ 

-

146 

-

147 def in_groups(u): 

-

148 return has_group_permission(u, groups_required) 

-

149 

-

150 return user_passes_test(in_groups, login_url='view_no_perm') 

-

151 

-

152 

-

153class GroupRequiredMixin(object): 

-

154 """ 

-

155 groups_required - list of strings, required param 

-

156 """ 

-

157 

-

158 groups_required = None 

-

159 

-

160 def dispatch(self, request, *args, **kwargs): 

-

161 if not has_group_permission(request.user, self.groups_required): 

-

162 if not request.user.is_authenticated: 

-

163 messages.add_message(request, messages.ERROR, 

-

164 _('You are not logged in and therefore cannot view this page!')) 

-

165 return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path) 

-

166 else: 

-

167 messages.add_message(request, messages.ERROR, 

-

168 _('You do not have the required permissions to view this page!')) 

-

169 return HttpResponseRedirect(reverse_lazy('index')) 

-

170 try: 

-

171 obj = self.get_object() 

-

172 if obj.get_space() != request.space: 

-

173 messages.add_message(request, messages.ERROR, 

-

174 _('You do not have the required permissions to view this page!')) 

-

175 return HttpResponseRedirect(reverse_lazy('index')) 

-

176 except AttributeError: 

-

177 pass 

-

178 

-

179 return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs) 

-

180 

-

181 

-

182class OwnerRequiredMixin(object): 

-

183 

-

184 def dispatch(self, request, *args, **kwargs): 

-

185 if not request.user.is_authenticated: 

-

186 messages.add_message(request, messages.ERROR, 

-

187 _('You are not logged in and therefore cannot view this page!')) 

-

188 return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path) 

-

189 else: 

-

190 if not is_object_owner(request.user, self.get_object()): 

-

191 messages.add_message(request, messages.ERROR, 

-

192 _('You cannot interact with this object as it is not owned by you!')) 

-

193 return HttpResponseRedirect(reverse('index')) 

-

194 

-

195 try: 

-

196 obj = self.get_object() 

-

197 if not request.user.userspace.filter(space=obj.get_space()).exists(): 

-

198 messages.add_message(request, messages.ERROR, 

-

199 _('You do not have the required permissions to view this page!')) 

-

200 return HttpResponseRedirect(reverse_lazy('index')) 

-

201 except AttributeError: 

-

202 pass 

-

203 

-

204 return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs) 

-

205 

-

206 

-

207# Django Rest Framework Permission classes 

-

208 

-

209class CustomIsOwner(permissions.BasePermission): 

-

210 """ 

-

211 Custom permission class for django rest framework views 

-

212 verifies user has ownership over object 

-

213 (either user or created_by or user is request user) 

-

214 """ 

-

215 message = _('You cannot interact with this object as it is not owned by you!') 

-

216 

-

217 def has_permission(self, request, view): 

-

218 return request.user.is_authenticated 

-

219 

-

220 def has_object_permission(self, request, view, obj): 

-

221 return is_object_owner(request.user, obj) 

-

222 

-

223 

-

224class CustomIsOwnerReadOnly(CustomIsOwner): 

-

225 def has_permission(self, request, view): 

-

226 return super().has_permission(request, view) and request.method in SAFE_METHODS 

-

227 

-

228 def has_object_permission(self, request, view, obj): 

-

229 return super().has_object_permission(request, view) and request.method in SAFE_METHODS 

-

230 

-

231 

-

232class CustomIsSpaceOwner(permissions.BasePermission): 

-

233 """ 

-

234 Custom permission class for django rest framework views 

-

235 verifies if the user is the owner of the space the object belongs to 

-

236 """ 

-

237 message = _('You cannot interact with this object as it is not owned by you!') 

-

238 

-

239 def has_permission(self, request, view): 

-

240 return request.user.is_authenticated and request.space.created_by == request.user 

-

241 

-

242 def has_object_permission(self, request, view, obj): 

-

243 return is_space_owner(request.user, obj) 

-

244 

-

245 

-

246# TODO function duplicate/too similar name 

-

247class CustomIsShared(permissions.BasePermission): 

-

248 """ 

-

249 Custom permission class for django rest framework views 

-

250 verifies user is shared for the object he is trying to access 

-

251 """ 

-

252 message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501 

-

253 

-

254 def has_permission(self, request, view): 

-

255 return request.user.is_authenticated 

-

256 

-

257 def has_object_permission(self, request, view, obj): 

-

258 return is_object_shared(request.user, obj) 

-

259 

-

260 

-

261class CustomIsGuest(permissions.BasePermission): 

-

262 """ 

-

263 Custom permission class for django rest framework views 

-

264 verifies the user is member of at least the group: guest 

-

265 """ 

-

266 message = _('You do not have the required permissions to view this page!') 

-

267 

-

268 def has_permission(self, request, view): 

-

269 return has_group_permission(request.user, ['guest']) 

-

270 

-

271 def has_object_permission(self, request, view, obj): 

-

272 return has_group_permission(request.user, ['guest']) 

-

273 

-

274 

-

275class CustomIsUser(permissions.BasePermission): 

-

276 """ 

-

277 Custom permission class for django rest framework views 

-

278 verifies the user is member of at least the group: user 

-

279 """ 

-

280 message = _('You do not have the required permissions to view this page!') 

-

281 

-

282 def has_permission(self, request, view): 

-

283 return has_group_permission(request.user, ['user']) 

-

284 

-

285 

-

286class CustomIsAdmin(permissions.BasePermission): 

-

287 """ 

-

288 Custom permission class for django rest framework views 

-

289 verifies the user is member of at least the group: admin 

-

290 """ 

-

291 message = _('You do not have the required permissions to view this page!') 

-

292 

-

293 def has_permission(self, request, view): 

-

294 return has_group_permission(request.user, ['admin']) 

-

295 

-

296 

-

297class CustomIsShare(permissions.BasePermission): 

-

298 """ 

-

299 Custom permission class for django rest framework views 

-

300 verifies the requesting user provided a valid share link 

-

301 """ 

-

302 message = _('You do not have the required permissions to view this page!') 

-

303 

-

304 def has_permission(self, request, view): 

-

305 return request.method in SAFE_METHODS and 'pk' in view.kwargs 

-

306 

-

307 def has_object_permission(self, request, view, obj): 

-

308 share = request.query_params.get('share', None) 

-

309 if share: 

-

310 return share_link_valid(obj, share) 

-

311 return False 

-

312 

-

313 

-

314class CustomRecipePermission(permissions.BasePermission): 

-

315 """ 

-

316 Custom permission class for recipe api endpoint 

-

317 """ 

-

318 message = _('You do not have the required permissions to view this page!') 

-

319 

-

320 def has_permission(self, request, view): # user is either at least a guest or a share link is given and the request is safe 

-

321 share = request.query_params.get('share', None) 

-

322 return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS) or has_group_permission( 

-

323 request.user, ['user'])) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs) 

-

324 

-

325 def has_object_permission(self, request, view, obj): 

-

326 share = request.query_params.get('share', None) 

-

327 if share: 

-

328 return share_link_valid(obj, share) 

-

329 else: 

-

330 if obj.private: 

-

331 return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space 

-

332 else: 

-

333 return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS) 

-

334 or has_group_permission(request.user, ['user'])) and obj.space == request.space 

-

335 

-

336 

-

337class CustomUserPermission(permissions.BasePermission): 

-

338 """ 

-

339 Custom permission class for user api endpoint 

-

340 """ 

-

341 message = _('You do not have the required permissions to view this page!') 

-

342 

-

343 def has_permission(self, request, view): # a space filtered user list is visible for everyone 

-

344 return has_group_permission(request.user, ['guest']) 

-

345 

-

346 def has_object_permission(self, request, view, obj): # object write permissions are only available for user 

-

347 if request.method in SAFE_METHODS and 'pk' in view.kwargs and has_group_permission(request.user, ['guest']) and request.space in obj.userspace_set.all(): 

-

348 return True 

-

349 elif request.user == obj: 

-

350 return True 

-

351 else: 

-

352 return False 

-

353 

-

354 

-

355class CustomTokenHasScope(TokenHasScope): 

-

356 """ 

-

357 Custom implementation of Django OAuth Toolkit TokenHasScope class 

-

358 Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored 

-

359 IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes 

-

360 """ 

-

361 

-

362 def has_permission(self, request, view): 

-

363 if isinstance(request.auth, AccessToken): 

-

364 return super().has_permission(request, view) 

-

365 else: 

-

366 return request.user.is_authenticated 

-

367 

-

368 

-

369class CustomTokenHasReadWriteScope(TokenHasReadWriteScope): 

-

370 """ 

-

371 Custom implementation of Django OAuth Toolkit TokenHasReadWriteScope class 

-

372 Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored 

-

373 IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes 

-

374 """ 

-

375 

-

376 def has_permission(self, request, view): 

-

377 if isinstance(request.auth, AccessToken): 

-

378 return super().has_permission(request, view) 

-

379 else: 

-

380 return True 

-

381 

-

382 

-

383def above_space_limit(space): # TODO add file storage limit 

-

384 """ 

-

385 Test if the space has reached any limit (e.g. max recipes, users, ..) 

-

386 :param space: Space to test for limits 

-

387 :return: Tuple (True if above or equal any limit else false, message) 

-

388 """ 

-

389 r_limit, r_msg = above_space_recipe_limit(space) 

-

390 u_limit, u_msg = above_space_user_limit(space) 

-

391 return r_limit or u_limit, (r_msg + ' ' + u_msg).strip() 

-

392 

-

393 

-

394def above_space_recipe_limit(space): 

-

395 """ 

-

396 Test if a space has reached its recipe limit 

-

397 :param space: Space to test for limits 

-

398 :return: Tuple (True if above or equal limit else false, message) 

-

399 """ 

-

400 limit = space.max_recipes != 0 and Recipe.objects.filter(space=space).count() >= space.max_recipes 

-

401 if limit: 

-

402 return True, _('You have reached the maximum number of recipes for your space.') 

-

403 return False, '' 

-

404 

-

405 

-

406def above_space_user_limit(space): 

-

407 """ 

-

408 Test if a space has reached its user limit 

-

409 :param space: Space to test for limits 

-

410 :return: Tuple (True if above or equal limit else false, message) 

-

411 """ 

-

412 limit = space.max_users != 0 and UserSpace.objects.filter(space=space).count() > space.max_users 

-

413 if limit: 

-

414 return True, _('You have more users than allowed in your space.') 

-

415 return False, '' 

-

416 

-

417 

-

418def switch_user_active_space(user, space): 

-

419 """ 

-

420 Switch the currently active space of a user by setting all spaces to inactive and activating the one passed 

-

421 :param user: user to change active space for 

-

422 :param space: space to activate user for 

-

423 :return user space object or none if not found/no permission 

-

424 """ 

-

425 try: 

-

426 us = UserSpace.objects.get(space=space, user=user) 

-

427 if not us.active: 

-

428 UserSpace.objects.filter(user=user).update(active=False) 

-

429 us.active = True 

-

430 us.save() 

-

431 return us 

-

432 else: 

-

433 return us 

-

434 except ObjectDoesNotExist: 

-

435 return None 

-

436 

-

437 

-

438class IsReadOnlyDRF(permissions.BasePermission): 

-

439 message = 'You cannot interact with this object as it is not owned by you!' 

-

440 

-

441 def has_permission(self, request, view): 

-

442 return request.method in SAFE_METHODS 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_property_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_property_helper_py.html deleted file mode 100644 index 6a09cc4211..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_property_helper_py.html +++ /dev/null @@ -1,171 +0,0 @@ - - - - - Coverage for cookbook/helper/property_helper.py: 100% - - - - - -
-
-

- Coverage for cookbook/helper/property_helper.py: - 100% -

- -

- 45 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.core.cache import caches 

-

2 

-

3from cookbook.helper.cache_helper import CacheHelper 

-

4from cookbook.helper.unit_conversion_helper import UnitConversionHelper 

-

5from cookbook.models import PropertyType 

-

6 

-

7 

-

8class FoodPropertyHelper: 

-

9 space = None 

-

10 

-

11 def __init__(self, space): 

-

12 """ 

-

13 Helper to perform food property calculations 

-

14 :param space: space to limit scope to 

-

15 """ 

-

16 self.space = space 

-

17 

-

18 def calculate_recipe_properties(self, recipe): 

-

19 """ 

-

20 Calculate all food properties for a given recipe. 

-

21 :param recipe: recipe to calculate properties for 

-

22 :return: dict of with property keys and total/food values for each property available 

-

23 """ 

-

24 ingredients = [] 

-

25 computed_properties = {} 

-

26 

-

27 for s in recipe.steps.all(): 

-

28 ingredients += s.ingredients.all() 

-

29 

-

30 property_types = caches['default'].get(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, None) 

-

31 

-

32 if not property_types: 

-

33 property_types = PropertyType.objects.filter(space=self.space).all() 

-

34 # cache is cleared on property type save signal so long duration is fine 

-

35 caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) 

-

36 

-

37 for fpt in property_types: 

-

38 computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'description': fpt.description, 

-

39 'unit': fpt.unit, 'order': fpt.order, 'food_values': {}, 'total_value': 0, 'missing_value': False} 

-

40 

-

41 uch = UnitConversionHelper(self.space) 

-

42 

-

43 for i in ingredients: 

-

44 if i.food is not None: 

-

45 conversions = uch.get_conversions(i) 

-

46 for pt in property_types: 

-

47 found_property = False 

-

48 if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None: 

-

49 computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0} 

-

50 computed_properties[pt.id]['missing_value'] = i.food.properties_food_unit is None 

-

51 else: 

-

52 for p in i.food.properties.all(): 

-

53 if p.property_type == pt: 

-

54 for c in conversions: 

-

55 if c.unit == i.food.properties_food_unit: 

-

56 found_property = True 

-

57 computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount 

-

58 computed_properties[pt.id]['food_values'] = self.add_or_create( 

-

59 computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food) 

-

60 if not found_property: 

-

61 computed_properties[pt.id]['missing_value'] = True 

-

62 computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0} 

-

63 

-

64 return computed_properties 

-

65 

-

66 # small dict helper to add to existing key or create new, probably a better way of doing this 

-

67 # TODO move to central helper ? 

-

68 @staticmethod 

-

69 def add_or_create(d, key, value, food): 

-

70 if key in d: 

-

71 d[key]['value'] += value 

-

72 else: 

-

73 d[key] = {'id': food.id, 'food': food.name, 'value': value} 

-

74 return d 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_recipe_search_py.html b/docs/coverage/d_f8cd9a78c43a323f_recipe_search_py.html deleted file mode 100644 index 9959bdad9a..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_recipe_search_py.html +++ /dev/null @@ -1,678 +0,0 @@ - - - - - Coverage for cookbook/helper/recipe_search.py: 70% - - - - - -
-
-

- Coverage for cookbook/helper/recipe_search.py: - 70% -

- -

- 397 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import json 

-

2from datetime import date, timedelta 

-

3 

-

4from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity 

-

5from django.core.cache import cache 

-

6from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When 

-

7from django.db.models.functions import Coalesce, Lower, Substr 

-

8from django.utils import timezone, translation 

-

9 

-

10from cookbook.helper.HelperFunctions import Round, str2bool 

-

11from cookbook.managers import DICTIONARY 

-

12from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, SearchFields, 

-

13 SearchPreference, ViewLog) 

-

14from recipes import settings 

-

15 

-

16 

-

17# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering 

-

18class RecipeSearch(): 

-

19 _postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql' 

-

20 

-

21 def __init__(self, request, **params): 

-

22 self._request = request 

-

23 self._queryset = None 

-

24 if f := params.get('filter', None): 

-

25 custom_filter = ( 

-

26 CustomFilter.objects.filter(id=f, space=self._request.space) 

-

27 .filter(Q(created_by=self._request.user) | Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)) 

-

28 .first() 

-

29 ) 

-

30 if custom_filter: 

-

31 self._params = {**json.loads(custom_filter.search)} 

-

32 self._original_params = {**(params or {})} 

-

33 # json.loads casts rating as an integer, expecting string 

-

34 if isinstance(self._params.get('rating', None), int): 

-

35 self._params['rating'] = str(self._params['rating']) 

-

36 else: 

-

37 self._params = {**(params or {})} 

-

38 else: 

-

39 self._params = {**(params or {})} 

-

40 if self._request.user.is_authenticated: 

-

41 CACHE_KEY = f'search_pref_{request.user.id}' 

-

42 cached_result = cache.get(CACHE_KEY, default=None) 

-

43 if cached_result is not None: 

-

44 self._search_prefs = cached_result 

-

45 else: 

-

46 self._search_prefs = request.user.searchpreference 

-

47 cache.set(CACHE_KEY, self._search_prefs, timeout=10) 

-

48 else: 

-

49 self._search_prefs = SearchPreference() 

-

50 self._string = self._params.get('query').strip( 

-

51 ) if self._params.get('query', None) else None 

-

52 self._rating = self._params.get('rating', None) 

-

53 self._keywords = { 

-

54 'or': self._params.get('keywords_or', None) or self._params.get('keywords', None), 

-

55 'and': self._params.get('keywords_and', None), 

-

56 'or_not': self._params.get('keywords_or_not', None), 

-

57 'and_not': self._params.get('keywords_and_not', None) 

-

58 } 

-

59 self._foods = { 

-

60 'or': self._params.get('foods_or', None) or self._params.get('foods', None), 

-

61 'and': self._params.get('foods_and', None), 

-

62 'or_not': self._params.get('foods_or_not', None), 

-

63 'and_not': self._params.get('foods_and_not', None) 

-

64 } 

-

65 self._books = { 

-

66 'or': self._params.get('books_or', None) or self._params.get('books', None), 

-

67 'and': self._params.get('books_and', None), 

-

68 'or_not': self._params.get('books_or_not', None), 

-

69 'and_not': self._params.get('books_and_not', None) 

-

70 } 

-

71 self._steps = self._params.get('steps', None) 

-

72 self._units = self._params.get('units', None) 

-

73 # TODO add created by 

-

74 # TODO image exists 

-

75 self._sort_order = self._params.get('sort_order', None) 

-

76 self._internal = str2bool(self._params.get('internal', None)) 

-

77 self._random = str2bool(self._params.get('random', False)) 

-

78 self._new = str2bool(self._params.get('new', False)) 

-

79 self._num_recent = int(self._params.get('num_recent', 0)) 

-

80 self._include_children = str2bool( 

-

81 self._params.get('include_children', None)) 

-

82 self._timescooked = self._params.get('timescooked', None) 

-

83 self._cookedon = self._params.get('cookedon', None) 

-

84 self._createdon = self._params.get('createdon', None) 

-

85 self._updatedon = self._params.get('updatedon', None) 

-

86 self._viewedon = self._params.get('viewedon', None) 

-

87 self._makenow = self._params.get('makenow', None) 

-

88 # this supports hidden feature to find recipes missing X ingredients 

-

89 if isinstance(self._makenow, bool) and self._makenow == True: 

-

90 self._makenow = 0 

-

91 elif isinstance(self._makenow, str) and self._makenow in ["yes", "true"]: 

-

92 self._makenow = 0 

-

93 else: 

-

94 try: 

-

95 self._makenow = int(self._makenow) 

-

96 except (ValueError, TypeError): 

-

97 self._makenow = None 

-

98 

-

99 self._search_type = self._search_prefs.search or 'plain' 

-

100 if self._string: 

-

101 if self._postgres: 

-

102 self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True) 

-

103 else: 

-

104 self._unaccent_include = [] 

-

105 self._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)] 

-

106 self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)] 

-

107 self._trigram_include = None 

-

108 self._fulltext_include = None 

-

109 self._trigram = False 

-

110 if self._postgres and self._string: 

-

111 self._language = DICTIONARY.get(translation.get_language(), 'simple') 

-

112 self._trigram_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)] 

-

113 self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None 

-

114 

-

115 if self._search_type not in ['websearch', 'raw'] and self._trigram_include: 

-

116 self._trigram = True 

-

117 self.search_query = SearchQuery( 

-

118 self._string, 

-

119 search_type=self._search_type, 

-

120 config=self._language, 

-

121 ) 

-

122 self.search_rank = None 

-

123 self.orderby = [] 

-

124 self._filters = None 

-

125 self._fuzzy_match = None 

-

126 

-

127 def get_queryset(self, queryset): 

-

128 self._queryset = queryset 

-

129 self._queryset = self._queryset.prefetch_related('keywords') 

-

130 

-

131 self._build_sort_order() 

-

132 self._recently_viewed(num_recent=self._num_recent) 

-

133 self._cooked_on_filter(cooked_date=self._cookedon) 

-

134 self._created_on_filter(created_date=self._createdon) 

-

135 self._updated_on_filter(updated_date=self._updatedon) 

-

136 self._viewed_on_filter(viewed_date=self._viewedon) 

-

137 self._favorite_recipes(times_cooked=self._timescooked) 

-

138 self._new_recipes() 

-

139 self.keyword_filters(**self._keywords) 

-

140 self.food_filters(**self._foods) 

-

141 self.book_filters(**self._books) 

-

142 self.rating_filter(rating=self._rating) 

-

143 self.internal_filter(internal=self._internal) 

-

144 self.step_filters(steps=self._steps) 

-

145 self.unit_filters(units=self._units) 

-

146 self._makenow_filter(missing=self._makenow) 

-

147 self.string_filters(string=self._string) 

-

148 return self._queryset.filter(space=self._request.space).order_by(*self.orderby) 

-

149 

-

150 def _sort_includes(self, *args): 

-

151 for x in args: 

-

152 if x in self.orderby: 

-

153 return True 

-

154 elif '-' + x in self.orderby: 

-

155 return True 

-

156 return False 

-

157 

-

158 def _build_sort_order(self): 

-

159 if self._random: 

-

160 self.orderby = ['?'] 

-

161 else: 

-

162 order = [] 

-

163 # TODO add userpreference for default sort order and replace '-favorite' 

-

164 default_order = ['name'] 

-

165 # recent and new_recipe are always first; they float a few recipes to the top 

-

166 if self._num_recent: 

-

167 order += ['-recent'] 

-

168 if self._new: 

-

169 order += ['-new_recipe'] 

-

170 

-

171 # if a sort order is provided by user - use that order 

-

172 if self._sort_order: 

-

173 if not isinstance(self._sort_order, list): 

-

174 order += [self._sort_order] 

-

175 else: 

-

176 order += self._sort_order 

-

177 if not self._postgres or not self._string: 

-

178 if 'score' in order: 

-

179 order.remove('score') 

-

180 if '-score' in order: 

-

181 order.remove('-score') 

-

182 # if no sort order provided prioritize text search, followed by the default search 

-

183 elif self._postgres and self._string and (self._trigram or self._fulltext_include): 

-

184 order += ['-score', *default_order] 

-

185 # otherwise sort by the remaining order_by attributes or favorite by default 

-

186 else: 

-

187 order += default_order 

-

188 order[:] = [Lower('name').asc() if x == 

-

189 'name' else x for x in order] 

-

190 order[:] = [Lower('name').desc() if x == 

-

191 '-name' else x for x in order] 

-

192 self.orderby = order 

-

193 

-

194 def string_filters(self, string=None): 

-

195 if not string: 

-

196 return 

-

197 

-

198 self.build_text_filters(self._string) 

-

199 if self._postgres: 

-

200 self.build_fulltext_filters(self._string) 

-

201 self.build_trigram(self._string) 

-

202 

-

203 query_filter = Q() 

-

204 if self._filters: 

-

205 for f in self._filters: 

-

206 query_filter |= f 

-

207 

-

208 # this creates duplicate records which can screw up other aggregates, see makenow for workaround 

-

209 self._queryset = self._queryset.filter(query_filter).distinct() 

-

210 if self._fulltext_include: 

-

211 if self._fuzzy_match is None: 

-

212 self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0)) 

-

213 else: 

-

214 self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0)) 

-

215 

-

216 if self._fuzzy_match is not None: 

-

217 simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity') 

-

218 if not self._fulltext_include: 

-

219 self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0)) 

-

220 else: 

-

221 self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0)) 

-

222 if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None: 

-

223 self._queryset = self._queryset.annotate(score=F('rank') + F('simularity')) 

-

224 else: 

-

225 query_filter = Q() 

-

226 for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]: 

-

227 query_filter |= Q(**{"%s" % f: self._string}) 

-

228 self._queryset = self._queryset.filter(query_filter).distinct() 

-

229 

-

230 def _cooked_on_filter(self, cooked_date=None): 

-

231 if self._sort_includes('lastcooked') or cooked_date: 

-

232 lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1] 

-

233 if lessthan: 

-

234 default = timezone.now() - timedelta(days=100000) 

-

235 else: 

-

236 default = timezone.now() 

-

237 self._queryset = self._queryset.annotate( 

-

238 lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default)) 

-

239 ) 

-

240 if cooked_date is None: 

-

241 return 

-

242 

-

243 cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != '']) 

-

244 

-

245 if lessthan: 

-

246 self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default) 

-

247 else: 

-

248 self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default) 

-

249 

-

250 def _created_on_filter(self, created_date=None): 

-

251 if created_date is None: 

-

252 return 

-

253 lessthan = '-' in created_date[:1] 

-

254 created_date = date(*[int(x) for x in created_date.split('-') if x != '']) 

-

255 if lessthan: 

-

256 self._queryset = self._queryset.filter(created_at__date__lte=created_date) 

-

257 else: 

-

258 self._queryset = self._queryset.filter(created_at__date__gte=created_date) 

-

259 

-

260 def _updated_on_filter(self, updated_date=None): 

-

261 if updated_date is None: 

-

262 return 

-

263 lessthan = '-' in updated_date[:1] 

-

264 updated_date = date(*[int(x)for x in updated_date.split('-') if x != '']) 

-

265 if lessthan: 

-

266 self._queryset = self._queryset.filter(updated_at__date__lte=updated_date) 

-

267 else: 

-

268 self._queryset = self._queryset.filter(updated_at__date__gte=updated_date) 

-

269 

-

270 def _viewed_on_filter(self, viewed_date=None): 

-

271 if self._sort_includes('lastviewed') or viewed_date: 

-

272 longTimeAgo = timezone.now() - timedelta(days=100000) 

-

273 self._queryset = self._queryset.annotate( 

-

274 lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)) 

-

275 ) 

-

276 if viewed_date is None: 

-

277 return 

-

278 lessthan = '-' in viewed_date[:1] 

-

279 viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != '']) 

-

280 

-

281 if lessthan: 

-

282 self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo) 

-

283 else: 

-

284 self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo) 

-

285 

-

286 def _new_recipes(self, new_days=7): 

-

287 # TODO make new days a user-setting 

-

288 if not self._new: 

-

289 return 

-

290 self._queryset = self._queryset.annotate( 

-

291 new_recipe=Case( 

-

292 When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), 

-

293 default=Value(0), 

-

294 ) 

-

295 ) 

-

296 

-

297 def _recently_viewed(self, num_recent=None): 

-

298 if not num_recent: 

-

299 if self._sort_includes('lastviewed'): 

-

300 self._queryset = self._queryset.annotate(lastviewed=Coalesce( 

-

301 Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0))) 

-

302 return 

-

303 

-

304 num_recent_recipes = ( 

-

305 ViewLog.objects.filter(created_by=self._request.user, space=self._request.space) 

-

306 .values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent] 

-

307 ) 

-

308 self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0))) 

-

309 

-

310 def _favorite_recipes(self, times_cooked=None): 

-

311 if self._sort_includes('favorite') or times_cooked: 

-

312 less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite') 

-

313 if less_than: 

-

314 default = 1000 

-

315 else: 

-

316 default = 0 

-

317 favorite_recipes = ( 

-

318 CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')) 

-

319 .values('recipe') 

-

320 .annotate(count=Count('pk', distinct=True)) 

-

321 .values('count') 

-

322 ) 

-

323 self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default)) 

-

324 if times_cooked is None: 

-

325 return 

-

326 

-

327 if times_cooked == '0': 

-

328 self._queryset = self._queryset.filter(favorite=0) 

-

329 elif less_than: 

-

330 self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0) 

-

331 else: 

-

332 self._queryset = self._queryset.filter(favorite__gte=int(times_cooked)) 

-

333 

-

334 def keyword_filters(self, **kwargs): 

-

335 if all([kwargs[x] is None for x in kwargs]): 

-

336 return 

-

337 for kw_filter in kwargs: 

-

338 if not kwargs[kw_filter]: 

-

339 continue 

-

340 if not isinstance(kwargs[kw_filter], list): 

-

341 kwargs[kw_filter] = [kwargs[kw_filter]] 

-

342 

-

343 keywords = Keyword.objects.filter(pk__in=kwargs[kw_filter]) 

-

344 if 'or' in kw_filter: 

-

345 if self._include_children: 

-

346 f_or = Q(keywords__in=Keyword.include_descendants(keywords)) 

-

347 else: 

-

348 f_or = Q(keywords__in=keywords) 

-

349 if 'not' in kw_filter: 

-

350 self._queryset = self._queryset.exclude(f_or) 

-

351 else: 

-

352 self._queryset = self._queryset.filter(f_or) 

-

353 elif 'and' in kw_filter: 

-

354 recipes = Recipe.objects.all() 

-

355 for kw in keywords: 

-

356 if self._include_children: 

-

357 f_and = Q(keywords__in=kw.get_descendants_and_self()) 

-

358 else: 

-

359 f_and = Q(keywords=kw) 

-

360 if 'not' in kw_filter: 

-

361 recipes = recipes.filter(f_and) 

-

362 else: 

-

363 self._queryset = self._queryset.filter(f_and) 

-

364 if 'not' in kw_filter: 

-

365 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

-

366 

-

367 def food_filters(self, **kwargs): 

-

368 if all([kwargs[x] is None for x in kwargs]): 

-

369 return 

-

370 for fd_filter in kwargs: 

-

371 if not kwargs[fd_filter]: 

-

372 continue 

-

373 if not isinstance(kwargs[fd_filter], list): 

-

374 kwargs[fd_filter] = [kwargs[fd_filter]] 

-

375 

-

376 foods = Food.objects.filter(pk__in=kwargs[fd_filter]) 

-

377 if 'or' in fd_filter: 

-

378 if self._include_children: 

-

379 f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods)) 

-

380 else: 

-

381 f_or = Q(steps__ingredients__food__in=foods) 

-

382 

-

383 if 'not' in fd_filter: 

-

384 self._queryset = self._queryset.exclude(f_or) 

-

385 else: 

-

386 self._queryset = self._queryset.filter(f_or) 

-

387 elif 'and' in fd_filter: 

-

388 recipes = Recipe.objects.all() 

-

389 for food in foods: 

-

390 if self._include_children: 

-

391 f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self()) 

-

392 else: 

-

393 f_and = Q(steps__ingredients__food=food) 

-

394 if 'not' in fd_filter: 

-

395 recipes = recipes.filter(f_and) 

-

396 else: 

-

397 self._queryset = self._queryset.filter(f_and) 

-

398 if 'not' in fd_filter: 

-

399 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

-

400 

-

401 def unit_filters(self, units=None, operator=True): 

-

402 if operator != True: 

-

403 raise NotImplementedError 

-

404 if not units: 

-

405 return 

-

406 if not isinstance(units, list): 

-

407 units = [units] 

-

408 self._queryset = self._queryset.filter(steps__ingredients__unit__in=units) 

-

409 

-

410 def rating_filter(self, rating=None): 

-

411 if rating or self._sort_includes('rating'): 

-

412 lessthan = '-' in (rating or []) 

-

413 reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or []) 

-

414 if lessthan or reverse: 

-

415 default = 100 

-

416 else: 

-

417 default = 0 

-

418 # TODO make ratings a settings user-only vs all-users 

-

419 self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default)))) 

-

420 if rating is None: 

-

421 return 

-

422 

-

423 if rating == '0': 

-

424 self._queryset = self._queryset.filter(rating=0) 

-

425 elif lessthan: 

-

426 self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0) 

-

427 else: 

-

428 self._queryset = self._queryset.filter(rating__gte=int(rating)) 

-

429 

-

430 def internal_filter(self, internal=None): 

-

431 if not internal: 

-

432 return 

-

433 self._queryset = self._queryset.filter(internal=internal) 

-

434 

-

435 def book_filters(self, **kwargs): 

-

436 if all([kwargs[x] is None for x in kwargs]): 

-

437 return 

-

438 for bk_filter in kwargs: 

-

439 if not kwargs[bk_filter]: 

-

440 continue 

-

441 if not isinstance(kwargs[bk_filter], list): 

-

442 kwargs[bk_filter] = [kwargs[bk_filter]] 

-

443 

-

444 if 'or' in bk_filter: 

-

445 f = Q(recipebookentry__book__id__in=kwargs[bk_filter]) 

-

446 if 'not' in bk_filter: 

-

447 self._queryset = self._queryset.exclude(f) 

-

448 else: 

-

449 self._queryset = self._queryset.filter(f) 

-

450 elif 'and' in bk_filter: 

-

451 recipes = Recipe.objects.all() 

-

452 for book in kwargs[bk_filter]: 

-

453 if 'not' in bk_filter: 

-

454 recipes = recipes.filter(recipebookentry__book__id=book) 

-

455 else: 

-

456 self._queryset = self._queryset.filter(recipebookentry__book__id=book) 

-

457 if 'not' in bk_filter: 

-

458 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

-

459 

-

460 def step_filters(self, steps=None, operator=True): 

-

461 if operator != True: 

-

462 raise NotImplementedError 

-

463 if not steps: 

-

464 return 

-

465 if not isinstance(steps, list): 

-

466 steps = [steps] 

-

467 self._queryset = self._queryset.filter(steps__id__in=steps) 

-

468 

-

469 def build_fulltext_filters(self, string=None): 

-

470 if not string: 

-

471 return 

-

472 if self._fulltext_include: 

-

473 vectors = [] 

-

474 rank = [] 

-

475 if 'name' in self._fulltext_include: 

-

476 vectors.append('name_search_vector') 

-

477 rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True)) 

-

478 if 'description' in self._fulltext_include: 

-

479 vectors.append('desc_search_vector') 

-

480 rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True)) 

-

481 if 'steps__instruction' in self._fulltext_include: 

-

482 vectors.append('steps__search_vector') 

-

483 rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True)) 

-

484 if 'keywords__name' in self._fulltext_include: 

-

485 # explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields 

-

486 vectors.append('keywords__name__unaccent') 

-

487 rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True)) 

-

488 if 'steps__ingredients__food__name' in self._fulltext_include: 

-

489 vectors.append('steps__ingredients__food__name__unaccent') 

-

490 rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True)) 

-

491 

-

492 for r in rank: 

-

493 if self.search_rank is None: 

-

494 self.search_rank = r 

-

495 else: 

-

496 self.search_rank += r 

-

497 # modifying queryset will annotation creates duplicate results 

-

498 self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query)))) 

-

499 

-

500 def build_text_filters(self, string=None): 

-

501 if not string: 

-

502 return 

-

503 

-

504 if not self._filters: 

-

505 self._filters = [] 

-

506 # dynamically build array of filters that will be applied 

-

507 for f in self._icontains_include: 

-

508 self._filters += [Q(**{"%s__icontains" % f: self._string})] 

-

509 

-

510 for f in self._istartswith_include: 

-

511 self._filters += [Q(**{"%s__istartswith" % f: self._string})] 

-

512 

-

513 def build_trigram(self, string=None): 

-

514 if not string: 

-

515 return 

-

516 if self._trigram: 

-

517 trigram = None 

-

518 for f in self._trigram_include: 

-

519 if trigram: 

-

520 trigram += TrigramSimilarity(f, self._string) 

-

521 else: 

-

522 trigram = TrigramSimilarity(f, self._string) 

-

523 self._fuzzy_match = ( 

-

524 Recipe.objects.annotate(trigram=trigram) 

-

525 .distinct() 

-

526 .annotate(simularity=Max('trigram')) 

-

527 .values('id', 'simularity') 

-

528 .filter(simularity__gt=self._search_prefs.trigram_threshold) 

-

529 ) 

-

530 self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))] 

-

531 

-

532 def _makenow_filter(self, missing=None): 

-

533 if missing is None or (isinstance(missing, bool) and missing == False): 

-

534 return 

-

535 shopping_users = [*self._request.user.get_shopping_share(), self._request.user] 

-

536 

-

537 onhand_filter = ( 

-

538 Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand 

-

539 # or substitute food onhand 

-

540 | Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) 

-

541 | Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users)) 

-

542 | Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users)) 

-

543 ) 

-

544 makenow_recipes = Recipe.objects.annotate( 

-

545 count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True), 

-

546 count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True), 

-

547 count_ignore_shopping=Count( 

-

548 'steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, steps__ingredients__food__recipe__isnull=True), distinct=True 

-

549 ), 

-

550 has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)), 

-

551 has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0)) 

-

552 ).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood__lte=missing) 

-

553 self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id')) 

-

554 

-

555 @staticmethod 

-

556 def __children_substitute_filter(shopping_users=None): 

-

557 children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users) 

-

558 return ( 

-

559 Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes 

-

560 Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users) 

-

561 ) 

-

562 .exclude(depth=1, numchild=0) 

-

563 .filter(substitute_children=True) 

-

564 .annotate(child_onhand_count=Exists(children_onhand_subquery)) 

-

565 .filter(child_onhand_count=True) 

-

566 ) 

-

567 

-

568 @staticmethod 

-

569 def __sibling_substitute_filter(shopping_users=None): 

-

570 sibling_onhand_subquery = Food.objects.filter( 

-

571 path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users 

-

572 ) 

-

573 return ( 

-

574 Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes 

-

575 Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users) 

-

576 ) 

-

577 .exclude(depth=1, numchild=0) 

-

578 .filter(substitute_siblings=True) 

-

579 .annotate(sibling_onhand=Exists(sibling_onhand_subquery)) 

-

580 .filter(sibling_onhand=True) 

-

581 ) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_recipe_url_import_py.html b/docs/coverage/d_f8cd9a78c43a323f_recipe_url_import_py.html deleted file mode 100644 index 34269d9cd3..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_recipe_url_import_py.html +++ /dev/null @@ -1,600 +0,0 @@ - - - - - Coverage for cookbook/helper/recipe_url_import.py: 64% - - - - - -
-
-

- Coverage for cookbook/helper/recipe_url_import.py: - 64% -

- -

- 331 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1import re 

-

2import traceback 

-

3from html import unescape 

-

4 

-

5from django.utils.dateparse import parse_duration 

-

6from django.utils.translation import gettext as _ 

-

7from isodate import parse_duration as iso_parse_duration 

-

8from isodate.isoerror import ISO8601Error 

-

9from pytube import YouTube 

-

10from recipe_scrapers._utils import get_host_name, get_minutes 

-

11 

-

12from cookbook.helper.automation_helper import AutomationEngine 

-

13from cookbook.helper.ingredient_parser import IngredientParser 

-

14from cookbook.models import Automation, Keyword, PropertyType 

-

15 

-

16 

-

17def get_from_scraper(scrape, request): 

-

18 # converting the scrape_me object to the existing json format based on ld+json 

-

19 

-

20 recipe_json = { 

-

21 'steps': [], 

-

22 'internal': True 

-

23 } 

-

24 keywords = [] 

-

25 

-

26 # assign source URL 

-

27 try: 

-

28 source_url = scrape.canonical_url() 

-

29 except Exception: 

-

30 try: 

-

31 source_url = scrape.url 

-

32 except Exception: 

-

33 pass 

-

34 if source_url: 

-

35 recipe_json['source_url'] = source_url 

-

36 try: 

-

37 keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0]) 

-

38 except Exception: 

-

39 recipe_json['source_url'] = '' 

-

40 

-

41 automation_engine = AutomationEngine(request, source=recipe_json.get('source_url')) 

-

42 # assign recipe name 

-

43 try: 

-

44 recipe_json['name'] = parse_name(scrape.title()[:128] or None) 

-

45 except Exception: 

-

46 recipe_json['name'] = None 

-

47 if not recipe_json['name']: 

-

48 try: 

-

49 recipe_json['name'] = scrape.schema.data.get('name') or '' 

-

50 except Exception: 

-

51 recipe_json['name'] = '' 

-

52 

-

53 if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0: 

-

54 recipe_json['name'] = recipe_json['name'][0] 

-

55 

-

56 recipe_json['name'] = automation_engine.apply_regex_replace_automation(recipe_json['name'], Automation.NAME_REPLACE) 

-

57 

-

58 # assign recipe description 

-

59 # TODO notify user about limit if reached - >256 description will be truncated 

-

60 try: 

-

61 description = scrape.description() or None 

-

62 except Exception: 

-

63 description = None 

-

64 if not description: 

-

65 try: 

-

66 description = scrape.schema.data.get("description") or '' 

-

67 except Exception: 

-

68 description = '' 

-

69 

-

70 recipe_json['description'] = parse_description(description) 

-

71 recipe_json['description'] = automation_engine.apply_regex_replace_automation(recipe_json['description'], Automation.DESCRIPTION_REPLACE) 

-

72 

-

73 # assign servings attributes 

-

74 try: 

-

75 # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly 

-

76 servings = scrape.schema.data.get('recipeYield') or 1 

-

77 except Exception: 

-

78 servings = 1 

-

79 

-

80 recipe_json['servings'] = parse_servings(servings) 

-

81 recipe_json['servings_text'] = parse_servings_text(servings) 

-

82 

-

83 # assign time attributes 

-

84 try: 

-

85 recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0 

-

86 except Exception: 

-

87 try: 

-

88 recipe_json['working_time'] = get_minutes(scrape.schema.data.get("prepTime")) or 0 

-

89 except Exception: 

-

90 recipe_json['working_time'] = 0 

-

91 try: 

-

92 recipe_json['waiting_time'] = get_minutes(scrape.cook_time()) or 0 

-

93 except Exception: 

-

94 try: 

-

95 recipe_json['waiting_time'] = get_minutes(scrape.schema.data.get("cookTime")) or 0 

-

96 except Exception: 

-

97 recipe_json['waiting_time'] = 0 

-

98 

-

99 if recipe_json['working_time'] + recipe_json['waiting_time'] == 0: 

-

100 try: 

-

101 recipe_json['working_time'] = get_minutes(scrape.total_time()) or 0 

-

102 except Exception: 

-

103 try: 

-

104 recipe_json['working_time'] = get_minutes(scrape.schema.data.get("totalTime")) or 0 

-

105 except Exception: 

-

106 pass 

-

107 

-

108 # assign image 

-

109 try: 

-

110 recipe_json['image'] = parse_image(scrape.image()) or None 

-

111 except Exception: 

-

112 recipe_json['image'] = None 

-

113 if not recipe_json['image']: 

-

114 try: 

-

115 recipe_json['image'] = parse_image(scrape.schema.data.get('image')) or '' 

-

116 except Exception: 

-

117 recipe_json['image'] = '' 

-

118 

-

119 # assign keywords 

-

120 try: 

-

121 if scrape.schema.data.get("keywords"): 

-

122 keywords += listify_keywords(scrape.schema.data.get("keywords")) 

-

123 except Exception: 

-

124 pass 

-

125 try: 

-

126 if scrape.category(): 

-

127 keywords += listify_keywords(scrape.category()) 

-

128 except Exception: 

-

129 try: 

-

130 if scrape.schema.data.get('recipeCategory'): 

-

131 keywords += listify_keywords(scrape.schema.data.get("recipeCategory")) 

-

132 except Exception: 

-

133 pass 

-

134 try: 

-

135 if scrape.cuisine(): 

-

136 keywords += listify_keywords(scrape.cuisine()) 

-

137 except Exception: 

-

138 try: 

-

139 if scrape.schema.data.get('recipeCuisine'): 

-

140 keywords += listify_keywords(scrape.schema.data.get("recipeCuisine")) 

-

141 except Exception: 

-

142 pass 

-

143 

-

144 try: 

-

145 if scrape.author(): 

-

146 keywords.append(scrape.author()) 

-

147 except Exception: 

-

148 pass 

-

149 

-

150 try: 

-

151 recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request) 

-

152 except AttributeError: 

-

153 recipe_json['keywords'] = keywords 

-

154 

-

155 ingredient_parser = IngredientParser(request, True) 

-

156 

-

157 # assign steps 

-

158 try: 

-

159 for i in parse_instructions(scrape.instructions()): 

-

160 recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, }) 

-

161 except Exception: 

-

162 pass 

-

163 if len(recipe_json['steps']) == 0: 

-

164 recipe_json['steps'].append({'instruction': '', 'ingredients': [], }) 

-

165 

-

166 recipe_json['description'] = recipe_json['description'][:512] 

-

167 if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards 

-

168 recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction'] 

-

169 

-

170 try: 

-

171 for x in scrape.ingredients(): 

-

172 if x.strip() != '': 

-

173 try: 

-

174 amount, unit, ingredient, note = ingredient_parser.parse(x) 

-

175 ingredient = { 

-

176 'amount': amount, 

-

177 'food': { 

-

178 'name': ingredient, 

-

179 }, 

-

180 'unit': None, 

-

181 'note': note, 

-

182 'original_text': x 

-

183 } 

-

184 if unit: 

-

185 ingredient['unit'] = {'name': unit, } 

-

186 recipe_json['steps'][0]['ingredients'].append(ingredient) 

-

187 except Exception: 

-

188 recipe_json['steps'][0]['ingredients'].append( 

-

189 { 

-

190 'amount': 0, 

-

191 'unit': None, 

-

192 'food': { 

-

193 'name': x, 

-

194 }, 

-

195 'note': '', 

-

196 'original_text': x 

-

197 } 

-

198 ) 

-

199 except Exception: 

-

200 pass 

-

201 

-

202 try: 

-

203 recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients()) 

-

204 print(recipe_json['properties']) 

-

205 except Exception: 

-

206 traceback.print_exc() 

-

207 pass 

-

208 

-

209 for s in recipe_json['steps']: 

-

210 s['instruction'] = automation_engine.apply_regex_replace_automation(s['instruction'], Automation.INSTRUCTION_REPLACE) 

-

211 # re.sub(a.param_2, a.param_3, s['instruction']) 

-

212 

-

213 return recipe_json 

-

214 

-

215 

-

216def get_recipe_properties(space, property_data): 

-

217 # {'servingSize': '1', 'calories': '302 kcal', 'proteinContent': '7,66g', 'fatContent': '11,56g', 'carbohydrateContent': '41,33g'} 

-

218 properties = { 

-

219 "property-calories": "calories", 

-

220 "property-carbohydrates": "carbohydrateContent", 

-

221 "property-proteins": "proteinContent", 

-

222 "property-fats": "fatContent", 

-

223 } 

-

224 recipe_properties = [] 

-

225 for pt in PropertyType.objects.filter(space=space, open_data_slug__in=list(properties.keys())).all(): 

-

226 for p in list(properties.keys()): 

-

227 if pt.open_data_slug == p: 

-

228 if properties[p] in property_data: 

-

229 recipe_properties.append({ 

-

230 'property_type': { 

-

231 'id': pt.id, 

-

232 'name': pt.name, 

-

233 }, 

-

234 'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']), 

-

235 }) 

-

236 

-

237 return recipe_properties 

-

238 

-

239 

-

240def get_from_youtube_scraper(url, request): 

-

241 """A YouTube Information Scraper.""" 

-

242 kw, created = Keyword.objects.get_or_create(name='YouTube', space=request.space) 

-

243 default_recipe_json = { 

-

244 'name': '', 

-

245 'internal': True, 

-

246 'description': '', 

-

247 'servings': 1, 

-

248 'working_time': 0, 

-

249 'waiting_time': 0, 

-

250 'image': "", 

-

251 'keywords': [{'name': kw.name, 'label': kw.name, 'id': kw.pk}], 

-

252 'source_url': url, 

-

253 'steps': [ 

-

254 { 

-

255 'ingredients': [], 

-

256 'instruction': '' 

-

257 } 

-

258 ] 

-

259 } 

-

260 

-

261 try: 

-

262 automation_engine = AutomationEngine(request, source=url) 

-

263 video = YouTube(url) 

-

264 video.streams.first() # this is required to execute some kind of generator/web request that fetches the description 

-

265 default_recipe_json['name'] = automation_engine.apply_regex_replace_automation(video.title, Automation.NAME_REPLACE) 

-

266 default_recipe_json['image'] = video.thumbnail_url 

-

267 if video.description: 

-

268 default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE) 

-

269 

-

270 except Exception: 

-

271 pass 

-

272 

-

273 return default_recipe_json 

-

274 

-

275 

-

276def parse_name(name): 

-

277 if isinstance(name, list): 

-

278 try: 

-

279 name = name[0] 

-

280 except Exception: 

-

281 name = 'ERROR' 

-

282 return normalize_string(name) 

-

283 

-

284 

-

285def parse_description(description): 

-

286 return normalize_string(description) 

-

287 

-

288 

-

289def clean_instruction_string(instruction): 

-

290 # handle HTML tags that can be converted to markup 

-

291 normalized_string = instruction \ 

-

292 .replace("<nobr>", "**") \ 

-

293 .replace("</nobr>", "**") \ 

-

294 .replace("<strong>", "**") \ 

-

295 .replace("</strong>", "**") 

-

296 normalized_string = normalize_string(normalized_string) 

-

297 normalized_string = normalized_string.replace('\n', ' \n') 

-

298 normalized_string = normalized_string.replace(' \n \n', '\n\n') 

-

299 

-

300 # handle unsupported, special UTF8 character in Thermomix-specific instructions, 

-

301 # that happen in nearly every recipe on Cookidoo, Zaubertopf Club, Rezeptwelt 

-

302 # and in Thermomix-specific recipes on many other sites 

-

303 return normalized_string \ 

-

304 .replace("", _('reverse rotation')) \ 

-

305 .replace("", _('careful rotation')) \ 

-

306 .replace("", _('knead')) \ 

-

307 .replace("Andicken ", _('thicken')) \ 

-

308 .replace("Erwärmen ", _('warm up')) \ 

-

309 .replace("Fermentieren ", _('ferment')) \ 

-

310 .replace("Sous-vide ", _("sous-vide")) 

-

311 

-

312 

-

313def parse_instructions(instructions): 

-

314 """ 

-

315 Convert arbitrary instructions object from website import and turn it into a flat list of strings 

-

316 :param instructions: any instructions object from import 

-

317 :return: list of strings (from one to many elements depending on website) 

-

318 """ 

-

319 instruction_list = [] 

-

320 

-

321 if isinstance(instructions, list): 

-

322 for i in instructions: 

-

323 if isinstance(i, str): 

-

324 instruction_list.append(clean_instruction_string(i)) 

-

325 else: 

-

326 if 'text' in i: 

-

327 instruction_list.append(clean_instruction_string(i['text'])) 

-

328 elif 'itemListElement' in i: 

-

329 for ile in i['itemListElement']: 

-

330 if isinstance(ile, str): 

-

331 instruction_list.append(clean_instruction_string(ile)) 

-

332 elif 'text' in ile: 

-

333 instruction_list.append(clean_instruction_string(ile['text'])) 

-

334 else: 

-

335 instruction_list.append(clean_instruction_string(str(i))) 

-

336 else: 

-

337 instruction_list.append(clean_instruction_string(instructions)) 

-

338 

-

339 return instruction_list 

-

340 

-

341 

-

342def parse_image(image): 

-

343 # check if list of images is returned, take first if so 

-

344 if not image: 

-

345 return None 

-

346 if isinstance(image, list): 

-

347 for pic in image: 

-

348 if (isinstance(pic, str)) and (pic[:4] == 'http'): 

-

349 image = pic 

-

350 elif 'url' in pic: 

-

351 image = pic['url'] 

-

352 elif isinstance(image, dict): 

-

353 if 'url' in image: 

-

354 image = image['url'] 

-

355 

-

356 # ignore relative image paths 

-

357 if image[:4] != 'http': 

-

358 image = '' 

-

359 return image 

-

360 

-

361 

-

362def parse_servings(servings): 

-

363 if isinstance(servings, str): 

-

364 try: 

-

365 servings = int(re.search(r'\d+', servings).group()) 

-

366 except AttributeError: 

-

367 servings = 1 

-

368 elif isinstance(servings, list): 

-

369 try: 

-

370 servings = int(re.findall(r'\b\d+\b', servings[0])[0]) 

-

371 except KeyError: 

-

372 servings = 1 

-

373 return servings 

-

374 

-

375 

-

376def parse_servings_text(servings): 

-

377 if isinstance(servings, str): 

-

378 try: 

-

379 servings = re.sub("\\d+", '', servings).strip() 

-

380 except Exception: 

-

381 servings = '' 

-

382 if isinstance(servings, list): 

-

383 try: 

-

384 servings = parse_servings_text(servings[1]) 

-

385 except Exception: 

-

386 pass 

-

387 return str(servings)[:32] 

-

388 

-

389 

-

390def parse_time(recipe_time): 

-

391 if type(recipe_time) not in [int, float]: 

-

392 try: 

-

393 recipe_time = float(re.search(r'\d+', recipe_time).group()) 

-

394 except (ValueError, AttributeError): 

-

395 try: 

-

396 recipe_time = round(iso_parse_duration(recipe_time).seconds / 60) 

-

397 except ISO8601Error: 

-

398 try: 

-

399 if (isinstance(recipe_time, list) and len(recipe_time) > 0): 

-

400 recipe_time = recipe_time[0] 

-

401 recipe_time = round(parse_duration(recipe_time).seconds / 60) 

-

402 except AttributeError: 

-

403 recipe_time = 0 

-

404 

-

405 return recipe_time 

-

406 

-

407 

-

408def parse_keywords(keyword_json, request): 

-

409 keywords = [] 

-

410 automation_engine = AutomationEngine(request) 

-

411 

-

412 # keywords as list 

-

413 for kw in keyword_json: 

-

414 kw = normalize_string(kw) 

-

415 # if alias exists use that instead 

-

416 

-

417 if len(kw) != 0: 

-

418 kw = automation_engine.apply_keyword_automation(kw) 

-

419 if k := Keyword.objects.filter(name__iexact=kw, space=request.space).first(): 

-

420 keywords.append({'label': str(k), 'name': k.name, 'id': k.id}) 

-

421 else: 

-

422 keywords.append({'label': kw, 'name': kw}) 

-

423 

-

424 return keywords 

-

425 

-

426 

-

427def listify_keywords(keyword_list): 

-

428 # keywords as string 

-

429 try: 

-

430 if isinstance(keyword_list[0], dict): 

-

431 return keyword_list 

-

432 except (KeyError, IndexError): 

-

433 pass 

-

434 if isinstance(keyword_list, str): 

-

435 keyword_list = keyword_list.split(',') 

-

436 

-

437 # keywords as string in list 

-

438 if (isinstance(keyword_list, list) and len(keyword_list) == 1 and ',' in keyword_list[0]): 

-

439 keyword_list = keyword_list[0].split(',') 

-

440 return [x.strip() for x in keyword_list] 

-

441 

-

442 

-

443def normalize_string(string): 

-

444 # Convert all named and numeric character references (e.g. &gt;, &#62;) 

-

445 unescaped_string = unescape(string) 

-

446 unescaped_string = re.sub('<[^<]+?>', '', unescaped_string) 

-

447 unescaped_string = re.sub(' +', ' ', unescaped_string) 

-

448 unescaped_string = re.sub('</p>', '\n', unescaped_string) 

-

449 unescaped_string = re.sub(r'\n\s*\n', '\n\n', unescaped_string) 

-

450 unescaped_string = unescaped_string.replace("\xa0", " ").replace("\t", " ").strip() 

-

451 return unescaped_string 

-

452 

-

453 

-

454def iso_duration_to_minutes(string): 

-

455 match = re.match( 

-

456 r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?', 

-

457 string 

-

458 ).groupdict() 

-

459 return int(match['days'] or 0) * 24 * 60 + int(match['hours'] or 0) * 60 + int(match['minutes'] or 0) 

-

460 

-

461 

-

462def get_images_from_soup(soup, url): 

-

463 sources = ['src', 'srcset', 'data-src'] 

-

464 images = [] 

-

465 img_tags = soup.find_all('img') 

-

466 if url: 

-

467 site = get_host_name(url) 

-

468 prot = url.split(':')[0] 

-

469 

-

470 urls = [] 

-

471 for img in img_tags: 

-

472 for src in sources: 

-

473 try: 

-

474 urls.append(img[src]) 

-

475 except KeyError: 

-

476 pass 

-

477 

-

478 for u in urls: 

-

479 u = u.split('?')[0] 

-

480 filename = re.search(r'/([\w_-]+[.](jpg|jpeg|gif|png))$', u) 

-

481 if filename: 

-

482 if (('http' not in u) and (url)): 

-

483 # sometimes an image source can be relative 

-

484 # if it is provide the base url 

-

485 u = '{}://{}{}'.format(prot, site, u) 

-

486 if 'http' in u: 

-

487 images.append(u) 

-

488 return images 

-

489 

-

490 

-

491def clean_dict(input_dict, key): 

-

492 if isinstance(input_dict, dict): 

-

493 for x in list(input_dict): 

-

494 if x == key: 

-

495 del input_dict[x] 

-

496 elif isinstance(input_dict[x], dict): 

-

497 input_dict[x] = clean_dict(input_dict[x], key) 

-

498 elif isinstance(input_dict[x], list): 

-

499 temp_list = [] 

-

500 for e in input_dict[x]: 

-

501 temp_list.append(clean_dict(e, key)) 

-

502 

-

503 return input_dict 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_scope_middleware_py.html b/docs/coverage/d_f8cd9a78c43a323f_scope_middleware_py.html deleted file mode 100644 index 11906256ef..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_scope_middleware_py.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - Coverage for cookbook/helper/scope_middleware.py: 69% - - - - - -
-
-

- Coverage for cookbook/helper/scope_middleware.py: - 69% -

- -

- 48 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.urls import reverse 

-

2from django_scopes import scope, scopes_disabled 

-

3from oauth2_provider.contrib.rest_framework import OAuth2Authentication 

-

4from rest_framework.exceptions import AuthenticationFailed 

-

5 

-

6from cookbook.views import views 

-

7from recipes import settings 

-

8 

-

9 

-

10class ScopeMiddleware: 

-

11 def __init__(self, get_response): 

-

12 self.get_response = get_response 

-

13 

-

14 def __call__(self, request): 

-

15 prefix = settings.JS_REVERSE_SCRIPT_PREFIX or '' 

-

16 

-

17 # need to disable scopes for writing requests into userpref and enable for loading ? 

-

18 if request.path.startswith(prefix + '/api/user-preference/'): 

-

19 with scopes_disabled(): 

-

20 return self.get_response(request) 

-

21 

-

22 if request.user.is_authenticated: 

-

23 

-

24 if request.path.startswith(prefix + '/admin/'): 

-

25 with scopes_disabled(): 

-

26 return self.get_response(request) 

-

27 

-

28 if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'): 

-

29 return self.get_response(request) 

-

30 

-

31 if request.path.startswith(prefix + '/accounts/'): 

-

32 return self.get_response(request) 

-

33 

-

34 if request.path.startswith(prefix + '/switch-space/'): 

-

35 return self.get_response(request) 

-

36 

-

37 with scopes_disabled(): 

-

38 if request.user.userspace_set.count() == 0 and not reverse('account_logout') in request.path: 

-

39 return views.space_overview(request) 

-

40 

-

41 # get active user space, if for some reason more than one space is active select first (group permission checks will fail, this is not intended at this point) 

-

42 user_space = request.user.userspace_set.filter(active=True).first() 

-

43 

-

44 if not user_space: 

-

45 return views.space_overview(request) 

-

46 

-

47 if user_space.groups.count() == 0 and not reverse('account_logout') in request.path: 

-

48 return views.no_groups(request) 

-

49 

-

50 request.space = user_space.space 

-

51 with scope(space=request.space): 

-

52 return self.get_response(request) 

-

53 else: 

-

54 if request.path.startswith(prefix + '/api/'): 

-

55 try: 

-

56 if auth := OAuth2Authentication().authenticate(request): 

-

57 user_space = auth[0].userspace_set.filter(active=True).first() 

-

58 if user_space: 

-

59 request.space = user_space.space 

-

60 with scope(space=request.space): 

-

61 return self.get_response(request) 

-

62 except AuthenticationFailed: 

-

63 pass 

-

64 

-

65 with scopes_disabled(): 

-

66 request.space = None 

-

67 return self.get_response(request) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_shopping_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_shopping_helper_py.html deleted file mode 100644 index 4874769aa4..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_shopping_helper_py.html +++ /dev/null @@ -1,297 +0,0 @@ - - - - - Coverage for cookbook/helper/shopping_helper.py: 87% - - - - - -
-
-

- Coverage for cookbook/helper/shopping_helper.py: - 87% -

- -

- 139 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from datetime import timedelta 

-

2from decimal import Decimal 

-

3 

-

4from django.db.models import F, OuterRef, Q, Subquery, Value 

-

5from django.db.models.functions import Coalesce 

-

6from django.utils import timezone 

-

7from django.utils.translation import gettext as _ 

-

8 

-

9from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe, 

-

10 SupermarketCategoryRelation) 

-

11 

-

12 

-

13def shopping_helper(qs, request): 

-

14 supermarket = request.query_params.get('supermarket', None) 

-

15 checked = request.query_params.get('checked', 'recent') 

-

16 user = request.user 

-

17 supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name'] 

-

18 

-

19 # TODO created either scheduled task or startup task to delete very old shopping list entries 

-

20 # TODO create user preference to define 'very old' 

-

21 if supermarket: 

-

22 supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category')) 

-

23 qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999))) 

-

24 supermarket_order = ['supermarket_order'] + supermarket_order 

-

25 if checked in ['false', 0, '0']: 

-

26 qs = qs.filter(checked=False) 

-

27 elif checked in ['true', 1, '1']: 

-

28 qs = qs.filter(checked=True) 

-

29 elif checked in ['recent']: 

-

30 today_start = timezone.now().replace(hour=0, minute=0, second=0) 

-

31 week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days) 

-

32 qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) 

-

33 supermarket_order = ['checked'] + supermarket_order 

-

34 

-

35 return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') 

-

36 

-

37 

-

38class RecipeShoppingEditor(): 

-

39 def __init__(self, user, space, **kwargs): 

-

40 self.created_by = user 

-

41 self.space = space 

-

42 self._kwargs = {**kwargs} 

-

43 

-

44 self.mealplan = self._kwargs.get('mealplan', None) 

-

45 if type(self.mealplan) in [int, float]: 

-

46 self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space) 

-

47 if isinstance(self.mealplan, dict): 

-

48 self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first() 

-

49 self.id = self._kwargs.get('id', None) 

-

50 

-

51 self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) 

-

52 

-

53 if self._shopping_list_recipe: 

-

54 # created_by needs to be sticky to original creator as it is 'their' shopping list 

-

55 # changing shopping list created_by can shift some items to new owner which may not share in the other direction 

-

56 self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by) 

-

57 

-

58 self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None) 

-

59 if type(self.recipe) in [int, float]: 

-

60 self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space) 

-

61 

-

62 try: 

-

63 self.servings = float(self._kwargs.get('servings', None)) 

-

64 except (ValueError, TypeError): 

-

65 self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None) 

-

66 

-

67 @property 

-

68 def _recipe_servings(self): 

-

69 return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings', 

-

70 None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None) 

-

71 

-

72 @property 

-

73 def _servings_factor(self): 

-

74 return Decimal(self.servings) / Decimal(self._recipe_servings) 

-

75 

-

76 @property 

-

77 def _shared_users(self): 

-

78 return [*list(self.created_by.get_shopping_share()), self.created_by] 

-

79 

-

80 @staticmethod 

-

81 def get_shopping_list_recipe(id, user, space): 

-

82 return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter( 

-

83 Q(shoppinglist__created_by=user) 

-

84 | Q(shoppinglist__shared=user) 

-

85 | Q(entries__created_by=user) 

-

86 | Q(entries__created_by__in=list(user.get_shopping_share())) 

-

87 ).prefetch_related('entries').first() 

-

88 

-

89 def get_recipe_ingredients(self, id, exclude_onhand=False): 

-

90 if exclude_onhand: 

-

91 return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude( 

-

92 food__onhand_users__id__in=[x.id for x in self._shared_users]) 

-

93 else: 

-

94 return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space) 

-

95 

-

96 @property 

-

97 def _include_related(self): 

-

98 return self.created_by.userpreference.mealplan_autoinclude_related 

-

99 

-

100 @property 

-

101 def _exclude_onhand(self): 

-

102 return self.created_by.userpreference.mealplan_autoexclude_onhand 

-

103 

-

104 def create(self, **kwargs): 

-

105 ingredients = kwargs.get('ingredients', None) 

-

106 exclude_onhand = not ingredients and self._exclude_onhand 

-

107 if servings := kwargs.get('servings', None): 

-

108 self.servings = float(servings) 

-

109 

-

110 if mealplan := kwargs.get('mealplan', None): 

-

111 if isinstance(mealplan, dict): 

-

112 self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first() 

-

113 else: 

-

114 self.mealplan = mealplan 

-

115 self.recipe = mealplan.recipe 

-

116 elif recipe := kwargs.get('recipe', None): 

-

117 self.recipe = recipe 

-

118 

-

119 if not self.servings: 

-

120 self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0) 

-

121 

-

122 self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings) 

-

123 

-

124 if ingredients: 

-

125 self._add_ingredients(ingredients=ingredients) 

-

126 else: 

-

127 if self._include_related: 

-

128 related = self.recipe.get_related_recipes() 

-

129 self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related)) 

-

130 for r in related: 

-

131 self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related)) 

-

132 else: 

-

133 self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand)) 

-

134 

-

135 return True 

-

136 

-

137 def add(self, **kwargs): 

-

138 return 

-

139 

-

140 def edit(self, servings=None, ingredients=None, **kwargs): 

-

141 if servings: 

-

142 self.servings = servings 

-

143 

-

144 self._delete_ingredients(ingredients=ingredients) 

-

145 if self.servings != self._shopping_list_recipe.servings: 

-

146 self.edit_servings() 

-

147 self._add_ingredients(ingredients=ingredients) 

-

148 return True 

-

149 

-

150 def edit_servings(self, servings=None, **kwargs): 

-

151 if servings: 

-

152 self.servings = servings 

-

153 if id := kwargs.get('id', None): 

-

154 self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space) 

-

155 if not self.servings: 

-

156 raise ValueError(_("You must supply a servings size")) 

-

157 

-

158 if self._shopping_list_recipe.servings == self.servings: 

-

159 return True 

-

160 

-

161 for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe): 

-

162 sle.amount = sle.ingredient.amount * Decimal(self._servings_factor) 

-

163 sle.save() 

-

164 self._shopping_list_recipe.servings = self.servings 

-

165 self._shopping_list_recipe.save() 

-

166 return True 

-

167 

-

168 def delete(self, **kwargs): 

-

169 try: 

-

170 self._shopping_list_recipe.delete() 

-

171 return True 

-

172 except BaseException: 

-

173 return False 

-

174 

-

175 def _add_ingredients(self, ingredients=None): 

-

176 if not ingredients: 

-

177 return 

-

178 elif isinstance(ingredients, list): 

-

179 ingredients = Ingredient.objects.filter(id__in=ingredients, food__ignore_shopping=False) 

-

180 existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True) 

-

181 add_ingredients = ingredients.exclude(id__in=existing) 

-

182 

-

183 for i in [x for x in add_ingredients if x.food]: 

-

184 ShoppingListEntry.objects.create( 

-

185 list_recipe=self._shopping_list_recipe, 

-

186 food=i.food, 

-

187 unit=i.unit, 

-

188 ingredient=i, 

-

189 amount=i.amount * Decimal(self._servings_factor), 

-

190 created_by=self.created_by, 

-

191 space=self.space, 

-

192 ) 

-

193 

-

194 # deletes shopping list entries not in ingredients list 

-

195 def _delete_ingredients(self, ingredients=None): 

-

196 if not ingredients: 

-

197 return 

-

198 to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients) 

-

199 ShoppingListEntry.objects.filter(id__in=to_delete).delete() 

-

200 self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_template_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_template_helper_py.html deleted file mode 100644 index e835a27c34..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_template_helper_py.html +++ /dev/null @@ -1,191 +0,0 @@ - - - - - Coverage for cookbook/helper/template_helper.py: 74% - - - - - -
-
-

- Coverage for cookbook/helper/template_helper.py: - 74% -

- -

- 53 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from gettext import gettext as _ 

-

2 

-

3import bleach 

-

4import markdown as md 

-

5from jinja2 import Template, TemplateSyntaxError, UndefinedError 

-

6from markdown.extensions.tables import TableExtension 

-

7 

-

8from cookbook.helper.mdx_attributes import MarkdownFormatExtension 

-

9from cookbook.helper.mdx_urlize import UrlizeExtension 

-

10 

-

11 

-

12class IngredientObject(object): 

-

13 amount = "" 

-

14 unit = "" 

-

15 food = "" 

-

16 note = "" 

-

17 

-

18 def __init__(self, ingredient): 

-

19 if ingredient.no_amount: 

-

20 self.amount = "" 

-

21 else: 

-

22 self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' v-bind:factor='ingredient_factor'></scalable-number>" 

-

23 if ingredient.unit: 

-

24 if ingredient.unit.plural_name in (None, ""): 

-

25 self.unit = bleach.clean(str(ingredient.unit)) 

-

26 else: 

-

27 if ingredient.always_use_plural_unit or ingredient.amount > 1 and not ingredient.no_amount: 

-

28 self.unit = bleach.clean(ingredient.unit.plural_name) 

-

29 else: 

-

30 self.unit = bleach.clean(str(ingredient.unit)) 

-

31 else: 

-

32 self.unit = "" 

-

33 if ingredient.food: 

-

34 if ingredient.food.plural_name in (None, ""): 

-

35 self.food = bleach.clean(str(ingredient.food)) 

-

36 else: 

-

37 if ingredient.always_use_plural_food or ingredient.amount > 1 and not ingredient.no_amount: 

-

38 self.food = bleach.clean(str(ingredient.food.plural_name)) 

-

39 else: 

-

40 self.food = bleach.clean(str(ingredient.food)) 

-

41 else: 

-

42 self.food = "" 

-

43 self.note = bleach.clean(str(ingredient.note)) 

-

44 

-

45 def __str__(self): 

-

46 ingredient = self.amount 

-

47 if self.unit != "": 

-

48 ingredient += f' {self.unit}' 

-

49 return f'{ingredient} {self.food}' 

-

50 

-

51 

-

52def render_instructions(step): # TODO deduplicate markdown cleanup code 

-

53 instructions = step.instruction 

-

54 

-

55 tags = { 

-

56 "h1", "h2", "h3", "h4", "h5", "h6", 

-

57 "b", "i", "strong", "em", "tt", 

-

58 "p", "br", 

-

59 "span", "div", "blockquote", "code", "pre", "hr", 

-

60 "ul", "ol", "li", "dd", "dt", 

-

61 "img", 

-

62 "a", 

-

63 "sub", "sup", 

-

64 'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead' 

-

65 } 

-

66 parsed_md = md.markdown( 

-

67 instructions, 

-

68 extensions=[ 

-

69 'markdown.extensions.fenced_code', TableExtension(), 

-

70 UrlizeExtension(), MarkdownFormatExtension() 

-

71 ] 

-

72 ) 

-

73 markdown_attrs = { 

-

74 "*": ["id", "class", 'width', 'height'], 

-

75 "img": ["src", "alt", "title"], 

-

76 "a": ["href", "alt", "title"], 

-

77 } 

-

78 

-

79 instructions = bleach.clean(parsed_md, tags, markdown_attrs) 

-

80 

-

81 ingredients = [] 

-

82 

-

83 for i in step.ingredients.all(): 

-

84 ingredients.append(IngredientObject(i)) 

-

85 

-

86 try: 

-

87 template = Template(instructions) 

-

88 instructions = template.render(ingredients=ingredients) 

-

89 except TemplateSyntaxError: 

-

90 return _('Could not parse template code.') + ' Error: Template Syntax broken' 

-

91 except UndefinedError: 

-

92 return _('Could not parse template code.') + ' Error: Undefined Error' 

-

93 

-

94 return instructions 

-
- - - diff --git a/docs/coverage/d_f8cd9a78c43a323f_unit_conversion_helper_py.html b/docs/coverage/d_f8cd9a78c43a323f_unit_conversion_helper_py.html deleted file mode 100644 index c6ca00932f..0000000000 --- a/docs/coverage/d_f8cd9a78c43a323f_unit_conversion_helper_py.html +++ /dev/null @@ -1,238 +0,0 @@ - - - - - Coverage for cookbook/helper/unit_conversion_helper.py: 100% - - - - - -
-
-

- Coverage for cookbook/helper/unit_conversion_helper.py: - 100% -

- -

- 64 statements   - - - -

-

- « prev     - ^ index     - » next -       - coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

- -
-
-
-

1from django.core.cache import caches 

-

2from decimal import Decimal 

-

3 

-

4from cookbook.helper.cache_helper import CacheHelper 

-

5from cookbook.models import Ingredient, Unit 

-

6 

-

7CONVERSION_TABLE = { 

-

8 'weight': { 

-

9 'g': 1000, 

-

10 'kg': 1, 

-

11 'ounce': 35.274, 

-

12 'pound': 2.20462 

-

13 }, 

-

14 'volume': { 

-

15 'ml': 1000, 

-

16 'l': 1, 

-

17 'fluid_ounce': 33.814, 

-

18 'pint': 2.11338, 

-

19 'quart': 1.05669, 

-

20 'gallon': 0.264172, 

-

21 'tbsp': 67.628, 

-

22 'tsp': 202.884, 

-

23 'imperial_fluid_ounce': 35.1951, 

-

24 'imperial_pint': 1.75975, 

-

25 'imperial_quart': 0.879877, 

-

26 'imperial_gallon': 0.219969, 

-

27 'imperial_tbsp': 56.3121, 

-

28 'imperial_tsp': 168.936, 

-

29 }, 

-

30} 

-

31 

-

32BASE_UNITS_WEIGHT = list(CONVERSION_TABLE['weight'].keys()) 

-

33BASE_UNITS_VOLUME = list(CONVERSION_TABLE['volume'].keys()) 

-

34 

-

35 

-

36class ConversionException(Exception): 

-

37 pass 

-

38 

-

39 

-

40class UnitConversionHelper: 

-

41 space = None 

-

42 

-

43 def __init__(self, space): 

-

44 """ 

-

45 Initializes unit conversion helper 

-

46 :param space: space to perform conversions on 

-

47 """ 

-

48 self.space = space 

-

49 

-

50 @staticmethod 

-

51 def convert_from_to(from_unit, to_unit, amount): 

-

52 """ 

-

53 Convert from one base unit to another. Throws ConversionException if trying to convert between different systems (weight/volume) or if units are not supported. 

-

54 :param from_unit: str unit to convert from 

-

55 :param to_unit: str unit to convert to 

-

56 :param amount: amount to convert 

-

57 :return: Decimal converted amount 

-

58 """ 

-

59 system = None 

-

60 if from_unit in BASE_UNITS_WEIGHT and to_unit in BASE_UNITS_WEIGHT: 

-

61 system = 'weight' 

-

62 if from_unit in BASE_UNITS_VOLUME and to_unit in BASE_UNITS_VOLUME: 

-

63 system = 'volume' 

-

64 

-

65 if not system: 

-

66 raise ConversionException('Trying to convert units not existing or not in one unit system (weight/volume)') 

-

67 

-

68 return Decimal(amount / Decimal(CONVERSION_TABLE[system][from_unit] / CONVERSION_TABLE[system][to_unit])) 

-

69 

-

70 def base_conversions(self, ingredient_list): 

-

71 """ 

-

72 Calculates all possible base unit conversions for each ingredient give. 

-

73 Converts to all common base units IF they exist in the unit database of the space. 

-

74 For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required. 

-

75 :param ingredient_list: list of ingredients to convert 

-

76 :return: ingredient list with appended conversions 

-

77 """ 

-

78 base_conversion_ingredient_list = ingredient_list.copy() 

-

79 for i in ingredient_list: 

-

80 try: 

-

81 conversion_unit = i.unit.name 

-

82 if i.unit.base_unit: 

-

83 conversion_unit = i.unit.base_unit 

-

84 

-

85 # TODO allow setting which units to convert to? possibly only once conversions become visible 

-

86 units = caches['default'].get(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, None) 

-

87 if not units: 

-

88 units = Unit.objects.filter(space=self.space, base_unit__in=(BASE_UNITS_VOLUME + BASE_UNITS_WEIGHT)).all() 

-

89 caches['default'].set(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, units, 60 * 60) # cache is cleared on unit save signal so long duration is fine 

-

90 

-

91 for u in units: 

-

92 try: 

-

93 ingredient = Ingredient(amount=self.convert_from_to(conversion_unit, u.base_unit, i.amount), unit=u, food=ingredient_list[0].food, ) 

-

94 if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in base_conversion_ingredient_list): 

-

95 base_conversion_ingredient_list.append(ingredient) 

-

96 except ConversionException: 

-

97 pass 

-

98 except Exception: 

-

99 pass 

-

100 

-

101 return base_conversion_ingredient_list 

-

102 

-

103 def get_conversions(self, ingredient): 

-

104 """ 

-

105 Converts an ingredient to all possible conversions based on the custom unit conversion database. 

-

106 After that passes conversion to UnitConversionHelper.base_conversions() to get all base conversions possible. 

-

107 :param ingredient: Ingredient object 

-

108 :return: list of ingredients with all possible custom and base conversions 

-

109 """ 

-

110 conversions = [ingredient] 

-

111 if ingredient.unit: 

-

112 for c in ingredient.unit.unit_conversion_base_relation.all(): 

-

113 if c.space == self.space: 

-

114 r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) 

-

115 if r and r not in conversions: 

-

116 conversions.append(r) 

-

117 for c in ingredient.unit.unit_conversion_converted_relation.all(): 

-

118 if c.space == self.space: 

-

119 r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) 

-

120 if r and r not in conversions: 

-

121 conversions.append(r) 

-

122 

-

123 conversions = self.base_conversions(conversions) 

-

124 

-

125 return conversions 

-

126 

-

127 def _uc_convert(self, uc, amount, unit, food): 

-

128 """ 

-

129 Helper to calculate values for custom unit conversions. 

-

130 Converts given base values using the passed UnitConversion object into a converted Ingredient 

-

131 :param uc: UnitConversion object 

-

132 :param amount: base amount 

-

133 :param unit: base unit 

-

134 :param food: base food 

-

135 :return: converted ingredient object from base amount/unit/food 

-

136 """ 

-

137 if uc.food is None or uc.food == food: 

-

138 if unit == uc.base_unit: 

-

139 return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space) 

-

140 else: 

-

141 return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space) 

-
- - - diff --git a/docs/coverage/favicon_32.png b/docs/coverage/favicon_32.png deleted file mode 100644 index 8649f0475d8d20793b2ec431fe25a186a414cf10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1732 zcmV;#20QtQP)K2KOkBOVxIZChq#W-v7@TU%U6P(wycKT1hUJUToW3ke1U1ONa4 z000000000000000bb)GRa9mqwR9|UWHy;^RUrt?IT__Y0JUcxmBP0(51q1>E00030 z|NrOz)aw7%8sJzM<5^g%z7^qE`}_Ot|JUUG(NUkWzR|7K?Zo%@_v-8G-1N%N=D$;; zw;keH4dGY$`1t4M=HK_s*zm^0#KgqfwWhe3qO_HtvXYvtjgX>;-~C$L`&k>^R)9)7 zdPh2TL^pCnHC#0+_4D)M`p?qp!pq{jO_{8;$fbaflbx`Tn52n|n}8VFRTA1&ugOP< zPd{uvFjz7t*Vot1&d$l-xWCk}s;sQL&#O(Bskh6gqNJv>#iB=ypG1e3K!K4yc7!~M zfj4S*g^zZ7eP$+_Sl07Z646l;%urinP#D8a6TwRtnLIRcI!r4f@bK~9-`~;E(N?Lv zSEst7s;rcxsi~}{Nsytfz@MtUoR*iFc8!#vvx}Umhm4blk(_~MdVD-@dW&>!Nn~ro z_E~-ESVQAj6Wmn;(olz(O&_{U2*pZBc1aYjMh>Dq3z|6`jW`RDHV=t3I6yRKJ~LOX zz_z!!vbVXPqob#=pj3^VMT?x6t(irRmSKsMo1~LLkB&=#j!=M%NP35mfqim$drWb9 zYIb>no_LUwc!r^NkDzs4YHu@=ZHRzrafWDZd1EhEVq=tGX?tK$pIa)DTh#bkvh!J- z?^%@YS!U*0E8$q$_*aOTQ&)Ra64g>ep;BdcQgvlg8qQHrP*E$;P{-m=A*@axn@$bO zO-Y4JzS&EAi%YG}N?cn?YFS7ivPY=EMV6~YH;+Xxu|tefLS|Aza)Cg6us#)=JW!uH zQa?H>d^j+YHCtyjL^LulF*05|F$RG!AX_OHVI&MtA~_@=5_lU|0000rbW%=J06GH4 z^5LD8b8apw8vNh1ua1mF{{Hy)_U`NA;Nacc+sCpuHXa-V{r&yz?c(9#+}oX+NmiRW z+W-IqK1oDDR5;6GfCDCOP5}iL5fK(cB~ET81`MFgF2kGa9AjhSIk~-E-4&*tPPKdiilQJ11k_J082ZS z>@TvivP!5ZFG?t@{t+GpR3XR&@*hA_VE1|Lo8@L@)l*h(Z@=?c-NS$Fk&&61IzUU9 z*nPqBM=OBZ-6ka1SJgGAS-Us5EN)r#dUX%>wQZLa2ytPCtMKp)Ob z*xcu38Z&d5<-NBS)@jRD+*!W*cf-m_wmxDEqBf?czI%3U0J$Xik;lA`jg}VH?(S(V zE!M3;X2B8w0TnnW&6(8;_Uc)WD;Ms6PKP+s(sFgO!}B!^ES~GDt4qLPxwYB)^7)XA zZwo9zDy-B0B+jT6V=!=bo(zs_8{eBA78gT9GH$(DVhz;4VAYwz+bOIdZ-PNb|I&rl z^XG=vFLF)1{&nT2*0vMz#}7^9hXzzf&ZdKlEj{LihP;|;Ywqn35ajP?H?7t|i-Un% z&&kxee@9B{nwgv1+S-~0)E1{ob1^Wn`F2isurqThKK=3%&;`@{0{!D- z&CSj80t;uPu&FaJFtSXKH#ajgGj}=sEad7US6jP0|Db@0j)?(5@sf<7`~a9>s;wCa zm^)spe{uxGFmrJYI9cOh7s$>8Npkt-5EWB1UKc`{W{y5Ce$1+nM9Cr;);=Ju#N^62OSlJMn7omiUgP&ErsYzT~iGxcW aE(`!K@+CXylaC4j0000 - - - - Coverage report - - - - - -
-
-

Coverage report: - 59% -

- -
- -
-

- coverage.py v7.4.0, - created at 2023-12-28 15:03 +0100 -

-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Modulestatementsmissingexcludedcoverage
cookbook/admin.py21630086%
cookbook/forms.py22849079%
cookbook/helper/AllAuthCustomAdapter.py2818036%
cookbook/helper/CustomStorageClass.py137046%
cookbook/helper/CustomTestRunner.py6600%
cookbook/helper/HelperFunctions.py81088%
cookbook/helper/automation_helper.py14949067%
cookbook/helper/cache_helper.py800100%
cookbook/helper/context_processors.py300100%
cookbook/helper/dal.py196068%
cookbook/helper/fdc_helper.py131300%
cookbook/helper/image_processing.py3629019%
cookbook/helper/ingredient_parser.py17527085%
cookbook/helper/mdx_attributes.py172088%
cookbook/helper/mdx_urlize.py2515040%
cookbook/helper/open_data_importer.py11095014%
cookbook/helper/permission_config.py3300%
cookbook/helper/permission_helper.py21646079%
cookbook/helper/property_helper.py4500100%
cookbook/helper/recipe_search.py397118070%
cookbook/helper/recipe_url_import.py331119064%
cookbook/helper/scope_middleware.py4815069%
cookbook/helper/scrapers/cooksillustrated.py387082%
cookbook/helper/scrapers/scrapers.py272093%
cookbook/helper/shopping_helper.py13918087%
cookbook/helper/template_helper.py5314074%
cookbook/helper/unit_conversion_helper.py6400100%
cookbook/integration/cheftap.py4133020%
cookbook/integration/chowdown.py9684012%
cookbook/integration/cookbookapp.py4937024%
cookbook/integration/cookmate.py5644021%
cookbook/integration/copymethat.py9481014%
cookbook/integration/default.py5440026%
cookbook/integration/domestica.py3424029%
cookbook/integration/integration.py189151020%
cookbook/integration/mealie.py7057019%
cookbook/integration/mealmaster.py5446015%
cookbook/integration/melarecipes.py5643023%
cookbook/integration/nextcloud_cookbook.py141123013%
cookbook/integration/openeats.py8172011%
cookbook/integration/paprika.py7055021%
cookbook/integration/pdfexport.py2920031%
cookbook/integration/pepperplate.py4236014%
cookbook/integration/plantoeat.py7869012%
cookbook/integration/recettetek.py10083017%
cookbook/integration/recipekeeper.py6349022%
cookbook/integration/recipesage.py6148021%
cookbook/integration/rezeptsuitede.py5443020%
cookbook/integration/rezkonv.py6053012%
cookbook/integration/saffron.py7870010%
cookbook/managers.py135062%
cookbook/models.py900122086%
cookbook/provider/dropbox.py7554028%
cookbook/provider/local.py3620044%
cookbook/provider/nextcloud.py7852033%
cookbook/provider/provider.py196068%
cookbook/schemas.py3621042%
cookbook/serializer.py939137085%
cookbook/signals.py12021082%
cookbook/tables.py605092%
cookbook/templatetags/custom_tags.py12433073%
cookbook/templatetags/theming_tags.py304087%
cookbook/version_info.py300100%
cookbook/views/api.py1058367065%
cookbook/views/data.py8862030%
cookbook/views/delete.py15274051%
cookbook/views/edit.py13229078%
cookbook/views/import_export.py12375039%
cookbook/views/lists.py6827060%
cookbook/views/new.py8047041%
cookbook/views/telegram.py4227036%
cookbook/views/views.py363266027%
recipes/middleware.py464600%
version.py525200%
Total87023602059%
-

- No items found using the specified filter. -

-
- - - diff --git a/docs/coverage/keybd_closed.png b/docs/coverage/keybd_closed.png deleted file mode 100644 index ba119c47df81ed2bbd27a06988abf700139c4f99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9004 zcmeHLc{tSF+aIY=A^R4_poB4tZAN2XC;O7M(inrW3}(h&Q4}dl*&-65$i9^&vW6_# zcM4g`Qix=GhkBl;=lwnJ@Ap2}^}hc-b6vBXb3XUyzR%~}_c`-Dw+!?&>5p(90RRB> zXe~7($~PP3eT?=X<@3~Q1w84vX~IoSx~1#~02+TopXK(db;4v6!{+W`RHLkkHO zo;+s?)puc`+$yOwHv>I$5^8v^F3<|$44HA8AFnFB0cAP|C`p}aSMJK*-CUB{eQ!;K z-9Ju3OQ+xVPr3P#o4>_lNBT;M+1vgV&B~6!naOGHb-LFA9TkfHv1IFA1Y!Iz!Zl3) z%c#-^zNWPq7U_}6I7aHSmFWi125RZrNBKyvnV^?64)zviS;E!UD%LaGRl6@zn!3E{ zJ`B$5``cH_3a)t1#6I7d==JeB_IcSU%=I#DrRCBGm8GvCmA=+XHEvC2SIfsNa0(h9 z7P^C4U`W@@`9p>2f^zyb5B=lpc*RZMn-%%IqrxSWQF8{ec3i?-AB(_IVe z)XgT>Y^u41MwOMFvU=I4?!^#jaS-%bjnx@ zmL44yVEslR_ynm18F!u}Ru#moEn3EE?1=9@$B1Z5aLi5b8{&?V(IAYBzIar!SiY3< z`l0V)djHtrImy}(!7x-Pmq+njM)JFQ9mx*(C+9a3M)(_SW|lrN=gfxFhStu^zvynS zm@gl;>d8i8wpUkX42vS3BEzE3-yctH%t0#N%s+6-&_<*Fe7+h=`=FM?DOg1)eGL~~ zQvIFm$D*lqEh07XrXY=jb%hdyP4)`wyMCb$=-z9(lOme9=tirVkb)_GOl2MJn;=Ky z^0pV1owR7KP-BSxhI@@@+gG0roD-kXE1;!#R7KY1QiUbyDdTElm|ul7{mMdF1%UDJ z_vp=Vo!TCF?D*?u% zk~}4!xK2MSQd-QKC0${G=ZRv2x8%8ZqdfR!?Dv=5Mj^8WU)?iH;C?o6rSQy*^YwQb zf@5V)q=xah#a3UEIBC~N7on(p4jQd4K$|i7k`d8mw|M{Mxapl46Z^X^9U}JgqH#;T z`CTzafpMD+J-LjzF+3Xau>xM_sXisRj6m-287~i9g|%gHc}v77>n_+p7ZgmJszx!b zSmL4wV;&*5Z|zaCk`rOYFdOjZLLQr!WSV6AlaqYh_OE)>rYdtx`gk$yAMO=-E1b~J zIZY6gM*}1UWsJ)TW(pf1=h?lJy_0TFOr|nALGW>$IE1E7z+$`^2WJY+>$$nJo8Rs` z)xS>AH{N~X3+b=2+8Q_|n(1JoGv55r>TuwBV~MXE&9?3Zw>cIxnOPNs#gh~C4Zo=k z&!s;5)^6UG>!`?hh0Q|r|Qbm>}pgtOt23Vh!NSibozH$`#LSiYL)HR4bkfEJMa zBHwC3TaHx|BzD|MXAr>mm&FbZXeEX-=W}Ji&!pji4sO$#0Wk^Q7j%{8#bJPn$C=E% zPlB}0)@Ti^r_HMJrTMN?9~4LQbIiUiOKBVNm_QjABKY4;zC88yVjvB>ZETNzr%^(~ zI3U&Ont?P`r&4 z#Bp)jcVV_N_{c1_qW}_`dQm)D`NG?h{+S!YOaUgWna4i8SuoLcXAZ|#Jh&GNn7B}3 z?vZ8I{LpmCYT=@6)dLPd@|(;d<08ufov%+V?$mgUYQHYTrc%eA=CDUzK}v|G&9}yJ z)|g*=+RH1IQ>rvkY9UIam=fkxWDyGIKQ2RU{GqOQjD8nG#sl+$V=?wpzJdT=wlNWr z1%lw&+;kVs(z?e=YRWRA&jc75rQ~({*TS<( z8X!j>B}?Bxrrp%wEE7yBefQ?*nM20~+ZoQK(NO_wA`RNhsqVkXHy|sod@mqen=B#@ zmLi=x2*o9rWqTMWoB&qdZph$~qkJJTVNc*8^hU?gH_fY{GYPEBE8Q{j0Y$tvjMv%3 z)j#EyBf^7n)2d8IXDYX2O0S%ZTnGhg4Ss#sEIATKpE_E4TU=GimrD5F6K(%*+T-!o z?Se7^Vm`$ZKDwq+=~jf?w0qC$Kr&R-;IF#{iLF*8zKu8(=#chRO;>x zdM;h{i{RLpJgS!B-ueTFs8&4U4+D8|7nP~UZ@P`J;*0sj^#f_WqT#xpA?@qHonGB& zQ<^;OLtOG1w#)N~&@b0caUL7syAsAxV#R`n>-+eVL9aZwnlklzE>-6!1#!tVA`uNo z>Gv^P)sohc~g_1YMC;^f(N<{2y5C^;QCEXo;LQ^#$0 zr>jCrdoeXuff!dJ^`#=Wy2Gumo^Qt7BZrI~G+Pyl_kL>is3P0^JlE;Sjm-YfF~I>t z_KeNpK|5U&F4;v?WS&#l(jxUWDarfcIcl=-6!8>^S`57!M6;hZea5IFA@)2+*Rt85 zi-MBs_b^DU8LygXXQGkG+86N7<%M|baM(orG*ASffC`p!?@m{qd}IcYmZyi^d}#Q& zNjk-0@CajpUI-gPm20ERVDO!L8@p`tMJ69FD(ASIkdoLdiRV6h9TPKRz>2WK4upHd z6OZK33EP?`GoJkXh)S035}uLUO$;TlXwNdMg-WOhLB)7a`-%*a9lFmjf6n+4ZmIHN z-V@$ z8PXsoR4*`5RwXz=A8|5;aXKtSHFccj%dG7cO~UBJnt)61K>-uPX)`vu{7fcX6_>zZ zw_2V&Li+7mxbf!f7{Rk&VVyY!UtZywac%g!cH+xh#j$a`uf?XWl<``t`36W;p7=_* zO6uf~2{sAdkZn=Ts@p0>8N8rzw2ZLS@$ibV-c-QmG@%|3gUUrRxu=e*ekhTa+f?8q z3$JVGPr9w$VQG~QCq~Y=2ThLIH!T@(>{NihJ6nj*HA_C#Popv)CBa)+UI-bx8u8zfCT^*1|k z&N9oFYsZEijPn31Yx_yO5pFs>0tOAV=oRx~Wpy5ie&S_449m4R^{LWQMA~}vocV1O zIf#1ZV85E>tvZE4mz~zn{hs!pkIQM;EvZMimqiPAJu-9P@mId&nb$lsrICS=)zU3~ zn>a#9>}5*3N)9;PTMZ)$`5k} z?iG}Rwj$>Y*|(D3S3e&fxhaPHma8@vwu(cwdlaCjX+NIK6=$H4U`rfzcWQVOhp{fnzuZhgCCGpw|p zTi`>cv~xVzdx|^`C0vXdlMwPae3S?>3|7v$e*Bs6-5gS>>FMHk_r2M(ADOV{KV7+6 zA@5Q(mdx%7J}MY}K461iuQ}5GwDGI=Yc&g0MZHu)7gC3{5@QZj6SJl*o0MS2Cl_ia zyK?9QmC9tJ6yn{EA-erJ4wk$+!E#X(s~9h^HOmQ_|6V_s1)k;%9Q6Niw}SyT?jxl4 z;HYz2$Nj$8Q_*Xo`TWEUx^Q9b+ik@$o39`mlY&P}G8wnjdE+Dlj?uL;$aB$n;x zWoh-M_u>9}_Ok@d_uidMqz10zJc}RQijPW3Fs&~1am=j*+A$QWTvxf9)6n;n8zTQW z!Q_J1%apTsJzLF`#^P_#mRv2Ya_keUE7iMSP!ha-WQoo0vZZG?gyR;+4q8F6tL#u< zRj8Hu5f-p1$J;)4?WpGL{4@HmJ6&tF9A5Tc8Trp>;Y>{^s?Q1&bam}?OjsnKd?|Z82aix26wUOLxbEW~E)|CgJ#)MLf_me# zv4?F$o@A~Um)6>HlM0=3Bd-vc91EM}D+t6-@!}O%i*&Wl%@#C8X+?5+nv`oPu!!=5 znbL+Fk_#J_%8vOq^FIv~5N(nk03kyo1p@l|1c+rO^zCG3bk2?|%AF;*|4si1XM<`a z1NY0-8$wv?&129!(g_A1lXR!+pD*1*cF?T~e1d6*G1Fz)jcSaZoKpxtA%FNnKP2jo zLXn@OR#1z@6zuH%mMB98}-t zHJqClsZ!G5xMSgIs_=<8sBePXxfoXsuvy`|buON9BX%s-o>OVLA)k3W=wKnw1?so$ zEjm0aS=zu@Xu#;{A)QTjJ$a9_={++ACkRY*sk3jLk&Fu}RxR<-DXR<`5`$VNG*wJE zidM6VzaQ!M0gbQM98@x@;#0qUS8O)p6mrYwTk*;8J~!ovbY6jon^Ki}uggd3#J5G8 z>awvtF85Y<9yE{Iag}J7O7)1O=ylk^255@XmV5J06-{xaaSNASZoTKKp~$tSxdUI~ zU1RZ&UuW37Ro&_ryj^cSt$Jd&pt|+h!A&dwcr&`S=R5E`=6Tm`+(qGm@$YZ8(8@a$ zXfo@Rwtvm7N3RMmVCb7radAs-@QtCXx^CQ-<)V>QPLZy@jH{#dc4#(y zV)6Hp{ZMz!|NG8!>i01gZMy)G<8Hf2X7e&LH_gOaajW<<^Xi55@OnlY*|S|*TS8;u_nHbv7lgmmZ+Q<5 zi!*lLCJmdpyzl(L${$C?(pVo|oR%r~x_B_ocPePa_);27^=n4L=`toZ;xdBut9rSv z?wDQ7j2I3WQBdhz%X7`2YaG_y|wA!7|s?k;A&WNMLMTZEzCaE^d??E&u?f=ejQBR~|< z)=thyP2(p8r6mt?Ad}tXAP_GvF9|P630I;$1cpQ+Ay7C34hK^ZV3H4kjPV8&NP>G5 zKRDEIBrFl{M#j4mfP0)68&?mqJP1S?2mU0djAGTjDV;wZ?6vplNn~3Hn$nP>%!dMi zz@bnC7zzi&k&s{QDWkf&zgrVXKUJjY3Gv3bL0}S4h>OdgEJ$Q^&p-VAr3J}^a*+rz z!jW7(h*+GuCyqcC{MD(Ovj^!{pB^OKUe|uy&bD?CN>KZrf3?v>>l*xSvnQiH-o^ViN$%FRdm9url;%(*jf5H$*S)8;i0xWHdl>$p);nH9v0)YfW?Vz$! zNCeUbi9`NEg(i^57y=fzM@1o*z*Bf6?QCV>2p9}(BLlYsOCfMjFv1pw1mlo)Py{8v zppw{MDfEeWN+n>Ne~oI7%9cU}mz0r3!es2gNF0t5jkGipjIo2lz;-e)7}Ul_#!eDv zw;#>kI>;#-pyfeu3Fsd^2F@6=oh#8r9;A!G0`-mm7%{=S;Ec(bJ=I_`FodKGQVNEY zmXwr4{9*jpDl%4{ggQZ5Ac z%wYTdl*!1c5^)%^E78Q&)ma|27c6j(a=)g4sGrp$r{jv>>M2 z6y)E5|Aooe!PSfKzvKA>`a6pfK3=E8vL14ksP&f=>gOP?}rG6ye@9ZR3 zJF*vsh*P$w390i!FV~~_Hv6t2Zl<4VUi|rNja#boFt{%q~xGb z(2petq9A*_>~B*>?d?Olx^lmYg4)}sH2>G42RE; diff --git a/docs/coverage/keybd_open.png b/docs/coverage/keybd_open.png deleted file mode 100644 index a8bac6c9de256626c680f9e9e3f8ee81d9713ecd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9003 zcmeHLc{tST+n?-2i>)FxMv|DtSZA{DOOu_57&BjtZI~H*ma;_1l4LIxB9dJQ*|TO# zN!sjLvQy+8>YUSgf9L)E-g8~=``>Y0!#wx%xj*;)e4hJ$zP?Ym-Z>3679JK52*jqP zscJy|%SHXLGSN|g3$@6f1Az_(`xu?47+^iYt|X!@!3h9Uyj=k>;6<(w94t$&Tmv4vUI0Y(72z4p-=52qQm)ibdMG{Lq zK-QAXj0ngGo#r{-=KfvMuhjI#;F3ml_v?vI<2-B3E&Sb83IPcet8E#VcMLMbDBXp( zietxGS0^|mhdOuNU*! z>lxhuyJ~5HC9jEu^6wu9yggaJEILLJFELe{&yOk3uY^_mY(J*EdTA{CbDHru&S*s5 zFHGCrim@r19P**ASiJAew_7dD+e>cSOtls3Z#(>lZx1iINjrV7NNt%PDNcMkXlA*W z`Bs*%ezf4U5NxJm__K5P?GEB7`Q`04T`~MTc=Sf&%qHuFd;!rn3}>8+-@yEidsy4J zwgV$+ymZ>vxo%s!H&}(*({B{M0j#!`Lt5GDbvmkji<_pajk9^n5DO(1Q=&m;TJ!?& z?dIZM5vQ>Gv(&EdlJNx^(v{pFFPfSP@r^ zUhRTD7bv*AYH`?Gq11M%nz2r;gHNp42jVLD`5tDqtqX8m!12pRUB0&T%w5?UN8u2$ z{33ra^&{S8?zu^Udrw+}HTUH(`Hi#oxx_~8z^KjV88Ir*uZL|Sg~!j^L_s$=4bBRW zop?W3)Xm?LO6n3E9KHt6XpGZ_HN~5oyARM_FU(4I%qcBvz8@9K>nRPh&##*Eoh-~w z_nj&&SNa->_^2rmZKKZTTsb8qBi7eZ+<|^m6k%kJZMtc45f~Vd$|>90cV@0+305_? z$}Q=5?!3a*rg#60fWtWf!9(Na58NEPqWSacwBi#FiX9R?*v-C&eMqb0k&TM0y0Va% zz~=|oCLbfUU9)b69enmUFXBy2)12vO`bS&kb^YOC0g}4%8d0@NbMm6<9C^4VY$)DE z97dE-HVFOL-)`t{@mQPechUcK@>Nbm7VqtmzZyM5U<`U@;RjksVMF8R*E>VhuI zkJSj=K$J!b9wLT59DZFvicVNQpWLaC2991nDs(piR8YcRq>puA}_3int5bZCnSnDDDBIyC`&DN%_Rawgsxlzfrw!$YU zk697D5ny@b5%eg+G2F&np#M_QkwT<~o z=20^H-;eo=m3|I#91GRY0$TY@>nd$|*Y@6PiI*+2I$KO&NY?@M466>Gt%~Lgowk~^JM_8wk%ghs}g}t}vM}#g;++DAjY#7oR5>!9Zb&%tZ@Av?{`s6b=pUPf& z`Ej0w!tuWT?VOSJ(s^!$)o|_8JY0RAMH30nz=QERTWUx%i6hBP9(PAp{ZQXvk!u}#Vab<|7#n z{maX?O+c&it?=GMZ6-mCiq1b`jrvnH%AIwV(c=)Y+Ng zV<#loBasaSDG>p~!~6DW%DmIwBgLM5kIpGHr(+-C2oq1L_i5|QlNU`n4xG_p4P3X+ zRb3J0k2659ugVF3jbY3g*#hm^+qFWErnuOPd#1_kH{$GKT=$ySdOG<2GJTTZieX8- z?SgdRq&e6K0~#g8LaMO>bF{p3>QU`28P6mcPxd#h%a3HMTriHT*5N2RdHdrvo)Hl( z`U&a1G+qKp7@qqMO*C~Dy@6-;0(yrivn$>oJm|n&YNs2%lFk?#rUv7N=CbY!26_#` zOwy)}i?Rp4nN$r%&5zU9O^|X|`}0gh4dooTajuqYy@fN0lYu~6li4||>k%x%XO;xj z5hh>P?#m$1I$s2gk=e^$N7Mm%F()PB*mBjl8#GTm}V z$n>4H{Zn?>tRb54D4BSNiH}riISvV^~kJ4Oqi-Q}*uV!1arYe1u@i3%->Aj(r zIL(E2nn^nhc3)1$LG?M!Z0P!8{kc7jVZ|z31Z9vW;zWG03+NwSV4)_v?8U zWzJng#k|hYcWf&`>pXSb$1J+|*RC+y0H1PLZGt#e5IB@{-e@rJo$|6ec*b&%(FN6?k>rN1-Nr$ z4m|s8prjrxoFseZy3M8c%nY<;8djgwW?!ntbr_BuPh)z_r$EZ(kbFfHIe-m~a@%)q zLHUZt{_ImXka>hsv7(tXD6IvCnD*Y9=OgFxoLemASErKGmb*^Vr}f(jx0bPl+I)E& zdgR_RtTV3aL1y$Y0L5%R`aCZ_j3{hDnOKUvJ-^B&r*-n!H1{M-gxge|1@AvCd1;LQ z&gyHGB7uzB5-;A*PN28V&l6{zV&ytnvv49kQD;x-Jcw{TPutVpBdI*~r2kQt;9y9} zrm;uL{ueR+pCY~(GsbF5WOLs1yA+{d^Nmfm{aCu^(uKBHuPP3>NOHZQeGCtO_(B6)e%e38$iS+A2@EuwaM3TExzF}i&|u$ zKssx-vZFF{(!fLzv#fm`hUWZG5W_HwZrHcibZGYIaTr8bF#XA~Yf^ke%h&0u3Dx%! z^ibu!hA$rmFDYFLiIR1*I%r`O?aUXua(z?Y&59c);yYe5&auIz#2%m$bF*Hyeb18q z{s%|D-an(}lltLeI1PH%zkvDJwfC);yKU+wq>Y~}`Wh1~1YKy!?;AbZMc?c-xx!ID zGU@t4XMu&;EzIlDe3)0mJ*~+gZ-I|7lWVH7XtQ^*7s@OAG%rXhF&W2i7^~4ZIjANP z)iqZodK~wkV=H<3sb9XbJmqa^_fu6Md2TL+@V@LjyB!gdKL)fcuy|X!v>b{(24;h6 zJWY9Lv8*x1KY;xnwHPyvsDJ@ za=nD?=lf8HdL|ib^6{~*M~Z^@X6f4_vccD5U;FmpEMP#m#3a{Hv(qAR7jbY4j^jmY1_kGt2jCr9Hcns@ad#dkAiH(87OC%{OL&%A8E67dds4 zUUa(por`Wt!CH3Hh4y+T!9&*HuNopp&DuC!EBsu2>zv#{TDK;p*zGdw3Q}{Qa3l3P z;iD#9LF=sx7%v`;5kM(4uz1BHUXiwju?VgYWB8vDMa+TeebP^R`85D{{ zc$n4X&Z!+bAB>Phr{s{sU9$^T=t{2+HO8<@oNBifmQ0|Km;F^;iwj#gXkI1ur>(!Z zG@-if3==No%Idh?cck)-zRX2RqlFtoV`vrn=qyc?4xL}sirUxBJ4r!#F?aOvj)juB z%{tu=P8ttd5+4}c=Ud{6@wDYv&cB^kki63NIG@ATX%<^s?;CRDcEa1`cD0Wo0dd{Y z6qjdr3O;ft)T>4e(3iLm_u`QvGhKad%P9zU^Lh8<(*A{x4mEG2wo)t&m&#+lvgmgT zX=0eA>sxXaMJ9`9ydOiNS4<9P-1gH31Wp9bo%!tP$g@wsOnW*#!un#WK&N2z$F93% z)7XXFa=YT;W;+I0qF=FN_Dr$}{`Q67WG7Phqm*HvlkJb*IdK?p`G_u_U_TMccM}%Z z9o(j&Lzg2plsL#1uY|kR zlIJvxnYMIcl8WJUtLEWZ=Jc)J-!GUhx*adO`KdDYV3eE|sbm38a(2si#4)I#TQ{ zu?Gg4M4z6{uc>!WZ(Z|4?1_ml(CD!lWvQIf+81z4K0o}Pq{RyyL8J8^KU+axA#4qy zQ_Hf5_NC-tOOi9sMZFnv)U{y8i$_y>bVIjd zYdd_eZZ%qsKW*^;2wxh(DlFXEIM5O>17AA*?E6crapNmn`L!Jn>AqbENHS$!E&q-T zFo+4DLWSrzdaYa`rye_*o~K22kByy4JzG;|#gQ7C@QCI9JkMy#2(2Fr`Ks(a7O@xQ zvrGC5UmLAPFdMG#Z`W+kDtZAXOA0bEMIr=*Q!fa#N06YRqNk;z^4on3^%f>IEv8Vr zL60-Ew)rk(`mRiv3IpS4>4mi@^GxX`R5ew(n60W&Syt}_o>A)pgE5&E8 zx78ULi@iR42{_udvF!_&adC>f`(&?{`S`^G4hsg;xq4oViQ6kITte;T!WM@^_k;-B zLpb!avBKI!QgmoYY?o2a^F?+Z#*eEd9ik7<*Uqk8Z`^Mqt=+4+d1B;xTx-$WS;2+I zO|PLhqWk+I$Zt%YKlF@o9>2ARqq#A@Bb52^a#Z=0)&8LgZP% zvLw7M+CWwPCk1sR2eGG6T+wj2r>7^(lX?k3vV)7EP$)P82}dHKR0Ndl?LxtNL0!lK zI}|@SQ~@%ML~x}Lh%VqAPOJ^logxQ;Q0Kuv$*HqAH7~01XMmmYE64doj z0dOP&Ap=Dqp-2?`SAXg(2J^eO3;CytR6XHdSXa0h3;}m`{*wopqUP~Oyub7y8&U5O z;RXPi=uW}`Y94?KMc~(#>9W6^Y0Fj&pS3( z&1F|tv?>wjz7teSRSvR~FB(t85%B2UuQo^=N&+ci3&lwQc&G#dB?U#Ha9F4<9xr7h zBPD@Dps>GCX}ORoSQi|yLq#Qr5vV*UoEQ=zjTM7RN}ch1}Yr4mQkNTZ}}B%l(~;?mS?Yyqf^gft3@K-mCDtb{mq zUTl|YXCKf?dRlT2Bn~8 zNJ`0wBY$x>0Z3$OmG6*>Az;WKS>thNbt)y6T5SYptQ`P%b+Oy!-Psp3bv0CFu{+H{ zW!|+@7lT$I0ayx=WJDx7$w79K1@BPq_7qt5XSblw5^=kZyI=sn({MjqP8n+l-yO=r z{~h>Wm<;WSo-Y48oj67^y5TwBJ4^92JfB%Xe{oB{A8>LfZyE$s*XRVaQ0XiJAiuJ z{_M5i?1aClV_U2g4k1M?Txn@MwF0GZQcxRdF#w7}NFk8o(kPUK)Q?*Eot;dyrFddV zfRY`x2B`Z??XBH?2A}#-e!_oF#?v0ysVxLj42qC|iisN`#nA|Hw73Lyh(;hFKeik! z3*R|qe_OKb&N+m^pnnxbcITWzYwc8{p}VWA69FLoS*+iR=YPQc;{UTy|C9T#upizk zL|1QWC)-nWJzf57_`d-DU^q*_0WM_Xzf1jB$PZb5c^FZ1{$Zm&;FtHmOoy*0T=2& zf1cErYE6u!67_|g#zsd&6|{Xdx}%mlVs_OuBZEMDId(pKK*_0xsYXVM7DkP6jBXz- zEd)lyY5I@OKCuXih+u*QN7paQfUw6wG;XcaW~qWCo?T2*0>x(MuCfDKSAqe7lXsSc7qm4=p(o#F8`bgRO G%6|bpD&^7u diff --git a/docs/coverage/status.json b/docs/coverage/status.json deleted file mode 100644 index bf3b2a2714..0000000000 --- a/docs/coverage/status.json +++ /dev/null @@ -1 +0,0 @@ -{"format":2,"version":"7.4.0","globals":"08b9cd51f0169582f4ff27bc262c6103","files":{"d_a167ab5b5108d61e_admin_py":{"hash":"b32d87dd0b303796cc577b7371f81543","index":{"nums":[0,1,216,0,30,0,0,0],"html_filename":"d_a167ab5b5108d61e_admin_py.html","relative_filename":"cookbook/admin.py"}},"d_a167ab5b5108d61e_forms_py":{"hash":"fc384a918dee009a3c6ff31dd2b1759c","index":{"nums":[0,1,228,0,49,0,0,0],"html_filename":"d_a167ab5b5108d61e_forms_py.html","relative_filename":"cookbook/forms.py"}},"d_f8cd9a78c43a323f_AllAuthCustomAdapter_py":{"hash":"e5aa55dddf501b0d1cc5958e22cbf7d8","index":{"nums":[0,1,28,0,18,0,0,0],"html_filename":"d_f8cd9a78c43a323f_AllAuthCustomAdapter_py.html","relative_filename":"cookbook/helper/AllAuthCustomAdapter.py"}},"d_f8cd9a78c43a323f_CustomStorageClass_py":{"hash":"f03c0c2e3a014ab2970dc675fa05f3f9","index":{"nums":[0,1,13,0,7,0,0,0],"html_filename":"d_f8cd9a78c43a323f_CustomStorageClass_py.html","relative_filename":"cookbook/helper/CustomStorageClass.py"}},"d_f8cd9a78c43a323f_CustomTestRunner_py":{"hash":"15bc906287327070e8bd16047adce486","index":{"nums":[0,1,6,0,6,0,0,0],"html_filename":"d_f8cd9a78c43a323f_CustomTestRunner_py.html","relative_filename":"cookbook/helper/CustomTestRunner.py"}},"d_f8cd9a78c43a323f_HelperFunctions_py":{"hash":"1eeaa7373a889a7abce9b3dd18a8e019","index":{"nums":[0,1,8,0,1,0,0,0],"html_filename":"d_f8cd9a78c43a323f_HelperFunctions_py.html","relative_filename":"cookbook/helper/HelperFunctions.py"}},"d_f8cd9a78c43a323f_automation_helper_py":{"hash":"5dba6fb723ee42143ef716167255f858","index":{"nums":[0,1,149,0,49,0,0,0],"html_filename":"d_f8cd9a78c43a323f_automation_helper_py.html","relative_filename":"cookbook/helper/automation_helper.py"}},"d_f8cd9a78c43a323f_cache_helper_py":{"hash":"f1e5a3024e5dde4eb1c429d95c7ff9ae","index":{"nums":[0,1,8,0,0,0,0,0],"html_filename":"d_f8cd9a78c43a323f_cache_helper_py.html","relative_filename":"cookbook/helper/cache_helper.py"}},"d_f8cd9a78c43a323f_context_processors_py":{"hash":"0b68abb45124cad6527ba201a5117321","index":{"nums":[0,1,3,0,0,0,0,0],"html_filename":"d_f8cd9a78c43a323f_context_processors_py.html","relative_filename":"cookbook/helper/context_processors.py"}},"d_f8cd9a78c43a323f_dal_py":{"hash":"20cc51b675d0bc1b7f9d63d7bf72de7d","index":{"nums":[0,1,19,0,6,0,0,0],"html_filename":"d_f8cd9a78c43a323f_dal_py.html","relative_filename":"cookbook/helper/dal.py"}},"d_f8cd9a78c43a323f_fdc_helper_py":{"hash":"f6aca060778985102f2d22cff0991c5e","index":{"nums":[0,1,13,0,13,0,0,0],"html_filename":"d_f8cd9a78c43a323f_fdc_helper_py.html","relative_filename":"cookbook/helper/fdc_helper.py"}},"d_f8cd9a78c43a323f_image_processing_py":{"hash":"ca012232f4646281e6de07b5d6452dc6","index":{"nums":[0,1,36,0,29,0,0,0],"html_filename":"d_f8cd9a78c43a323f_image_processing_py.html","relative_filename":"cookbook/helper/image_processing.py"}},"d_f8cd9a78c43a323f_ingredient_parser_py":{"hash":"3a3a6d818b6abf02e8f749b90faffb6d","index":{"nums":[0,1,175,0,27,0,0,0],"html_filename":"d_f8cd9a78c43a323f_ingredient_parser_py.html","relative_filename":"cookbook/helper/ingredient_parser.py"}},"d_f8cd9a78c43a323f_mdx_attributes_py":{"hash":"dafe1888d96fc60160ebde1f0a5822f2","index":{"nums":[0,1,17,0,2,0,0,0],"html_filename":"d_f8cd9a78c43a323f_mdx_attributes_py.html","relative_filename":"cookbook/helper/mdx_attributes.py"}},"d_f8cd9a78c43a323f_mdx_urlize_py":{"hash":"bd2c2f5ac5b4e9c27c98e5b6021e4bf3","index":{"nums":[0,1,25,0,15,0,0,0],"html_filename":"d_f8cd9a78c43a323f_mdx_urlize_py.html","relative_filename":"cookbook/helper/mdx_urlize.py"}},"d_f8cd9a78c43a323f_open_data_importer_py":{"hash":"412d0fb1f79507bc32acfde4ec384548","index":{"nums":[0,1,110,0,95,0,0,0],"html_filename":"d_f8cd9a78c43a323f_open_data_importer_py.html","relative_filename":"cookbook/helper/open_data_importer.py"}},"d_f8cd9a78c43a323f_permission_config_py":{"hash":"1f66855cdc46a8a84ad4254de81bf883","index":{"nums":[0,1,3,0,3,0,0,0],"html_filename":"d_f8cd9a78c43a323f_permission_config_py.html","relative_filename":"cookbook/helper/permission_config.py"}},"d_f8cd9a78c43a323f_permission_helper_py":{"hash":"aa88b89dadcc7ff5876f12f2138def55","index":{"nums":[0,1,216,0,46,0,0,0],"html_filename":"d_f8cd9a78c43a323f_permission_helper_py.html","relative_filename":"cookbook/helper/permission_helper.py"}},"d_f8cd9a78c43a323f_property_helper_py":{"hash":"1e31a7c0ace13122c11c613481eff5f4","index":{"nums":[0,1,45,0,0,0,0,0],"html_filename":"d_f8cd9a78c43a323f_property_helper_py.html","relative_filename":"cookbook/helper/property_helper.py"}},"d_f8cd9a78c43a323f_recipe_search_py":{"hash":"7663f3c2c6f4bd1b6eda9dc815d74dd2","index":{"nums":[0,1,397,0,118,0,0,0],"html_filename":"d_f8cd9a78c43a323f_recipe_search_py.html","relative_filename":"cookbook/helper/recipe_search.py"}},"d_f8cd9a78c43a323f_recipe_url_import_py":{"hash":"cccfc40547c37042a3af3726a914889d","index":{"nums":[0,1,331,0,119,0,0,0],"html_filename":"d_f8cd9a78c43a323f_recipe_url_import_py.html","relative_filename":"cookbook/helper/recipe_url_import.py"}},"d_f8cd9a78c43a323f_scope_middleware_py":{"hash":"e03c3d2ba7ae0fd9ed1059356d22f89e","index":{"nums":[0,1,48,0,15,0,0,0],"html_filename":"d_f8cd9a78c43a323f_scope_middleware_py.html","relative_filename":"cookbook/helper/scope_middleware.py"}},"d_cc5b0727f68102d6_cooksillustrated_py":{"hash":"8f0b9598960289e9f6243c3615e89425","index":{"nums":[0,1,38,0,7,0,0,0],"html_filename":"d_cc5b0727f68102d6_cooksillustrated_py.html","relative_filename":"cookbook/helper/scrapers/cooksillustrated.py"}},"d_cc5b0727f68102d6_scrapers_py":{"hash":"6e0af14a2e074f86d67985863d39a23c","index":{"nums":[0,1,27,0,2,0,0,0],"html_filename":"d_cc5b0727f68102d6_scrapers_py.html","relative_filename":"cookbook/helper/scrapers/scrapers.py"}},"d_f8cd9a78c43a323f_shopping_helper_py":{"hash":"52ff84a00acfb072cb840c702375a8f4","index":{"nums":[0,1,139,0,18,0,0,0],"html_filename":"d_f8cd9a78c43a323f_shopping_helper_py.html","relative_filename":"cookbook/helper/shopping_helper.py"}},"d_f8cd9a78c43a323f_template_helper_py":{"hash":"f9d5c094cbbc09a981d685aba667d743","index":{"nums":[0,1,53,0,14,0,0,0],"html_filename":"d_f8cd9a78c43a323f_template_helper_py.html","relative_filename":"cookbook/helper/template_helper.py"}},"d_f8cd9a78c43a323f_unit_conversion_helper_py":{"hash":"ad16fcf8b6ed5e2521da3b9471d4e0c7","index":{"nums":[0,1,64,0,0,0,0,0],"html_filename":"d_f8cd9a78c43a323f_unit_conversion_helper_py.html","relative_filename":"cookbook/helper/unit_conversion_helper.py"}},"d_37812bb4c19c71da_cheftap_py":{"hash":"9c06f1d5207117ca3cb1d88abb62f25f","index":{"nums":[0,1,41,0,33,0,0,0],"html_filename":"d_37812bb4c19c71da_cheftap_py.html","relative_filename":"cookbook/integration/cheftap.py"}},"d_37812bb4c19c71da_chowdown_py":{"hash":"545e97edd6553ac48c0f2d1215e864af","index":{"nums":[0,1,96,0,84,0,0,0],"html_filename":"d_37812bb4c19c71da_chowdown_py.html","relative_filename":"cookbook/integration/chowdown.py"}},"d_37812bb4c19c71da_cookbookapp_py":{"hash":"b995cd0f5ed29e2be1a7d2b28f2254a5","index":{"nums":[0,1,49,0,37,0,0,0],"html_filename":"d_37812bb4c19c71da_cookbookapp_py.html","relative_filename":"cookbook/integration/cookbookapp.py"}},"d_37812bb4c19c71da_cookmate_py":{"hash":"23f09b2569045ca2c9d48cd6ff6e35f4","index":{"nums":[0,1,56,0,44,0,0,0],"html_filename":"d_37812bb4c19c71da_cookmate_py.html","relative_filename":"cookbook/integration/cookmate.py"}},"d_37812bb4c19c71da_copymethat_py":{"hash":"c1afbf842a66d8f366f90493853d5cd3","index":{"nums":[0,1,94,0,81,0,0,0],"html_filename":"d_37812bb4c19c71da_copymethat_py.html","relative_filename":"cookbook/integration/copymethat.py"}},"d_37812bb4c19c71da_default_py":{"hash":"220b8260f81b5f31f36ca40f17be6829","index":{"nums":[0,1,54,0,40,0,0,0],"html_filename":"d_37812bb4c19c71da_default_py.html","relative_filename":"cookbook/integration/default.py"}},"d_37812bb4c19c71da_domestica_py":{"hash":"ca4c471fe75cb178cc3f80577165431f","index":{"nums":[0,1,34,0,24,0,0,0],"html_filename":"d_37812bb4c19c71da_domestica_py.html","relative_filename":"cookbook/integration/domestica.py"}},"d_37812bb4c19c71da_integration_py":{"hash":"ce7a70973121494e3386adbbc76b0148","index":{"nums":[0,1,189,0,151,0,0,0],"html_filename":"d_37812bb4c19c71da_integration_py.html","relative_filename":"cookbook/integration/integration.py"}},"d_37812bb4c19c71da_mealie_py":{"hash":"46efa9d13459202c1739c74d0e1c137b","index":{"nums":[0,1,70,0,57,0,0,0],"html_filename":"d_37812bb4c19c71da_mealie_py.html","relative_filename":"cookbook/integration/mealie.py"}},"d_37812bb4c19c71da_mealmaster_py":{"hash":"c44fd305ae32185bf2996e305145f686","index":{"nums":[0,1,54,0,46,0,0,0],"html_filename":"d_37812bb4c19c71da_mealmaster_py.html","relative_filename":"cookbook/integration/mealmaster.py"}},"d_37812bb4c19c71da_melarecipes_py":{"hash":"091383e7425c7e7dd987f7a3e1130250","index":{"nums":[0,1,56,0,43,0,0,0],"html_filename":"d_37812bb4c19c71da_melarecipes_py.html","relative_filename":"cookbook/integration/melarecipes.py"}},"d_37812bb4c19c71da_nextcloud_cookbook_py":{"hash":"f2663395a62aa6241160f1df1932aa89","index":{"nums":[0,1,141,0,123,0,0,0],"html_filename":"d_37812bb4c19c71da_nextcloud_cookbook_py.html","relative_filename":"cookbook/integration/nextcloud_cookbook.py"}},"d_37812bb4c19c71da_openeats_py":{"hash":"28ff52b1ea9a94c1bfa1fba2cc226e90","index":{"nums":[0,1,81,0,72,0,0,0],"html_filename":"d_37812bb4c19c71da_openeats_py.html","relative_filename":"cookbook/integration/openeats.py"}},"d_37812bb4c19c71da_paprika_py":{"hash":"c8d863ecd1cbdb71080c191b8af0ba04","index":{"nums":[0,1,70,0,55,0,0,0],"html_filename":"d_37812bb4c19c71da_paprika_py.html","relative_filename":"cookbook/integration/paprika.py"}},"d_37812bb4c19c71da_pdfexport_py":{"hash":"79977fb60ce5a6a34483fd6507630a95","index":{"nums":[0,1,29,0,20,0,0,0],"html_filename":"d_37812bb4c19c71da_pdfexport_py.html","relative_filename":"cookbook/integration/pdfexport.py"}},"d_37812bb4c19c71da_pepperplate_py":{"hash":"2202019eba8b091e3dd90f2216683be0","index":{"nums":[0,1,42,0,36,0,0,0],"html_filename":"d_37812bb4c19c71da_pepperplate_py.html","relative_filename":"cookbook/integration/pepperplate.py"}},"d_37812bb4c19c71da_plantoeat_py":{"hash":"7eb0dc376ef7a23c83fd264024faf490","index":{"nums":[0,1,78,0,69,0,0,0],"html_filename":"d_37812bb4c19c71da_plantoeat_py.html","relative_filename":"cookbook/integration/plantoeat.py"}},"d_37812bb4c19c71da_recettetek_py":{"hash":"9343f3cfea26bb202a96f9430c42b85a","index":{"nums":[0,1,100,0,83,0,0,0],"html_filename":"d_37812bb4c19c71da_recettetek_py.html","relative_filename":"cookbook/integration/recettetek.py"}},"d_37812bb4c19c71da_recipekeeper_py":{"hash":"e62ba3990cd7e49dfb96b5793fd458f0","index":{"nums":[0,1,63,0,49,0,0,0],"html_filename":"d_37812bb4c19c71da_recipekeeper_py.html","relative_filename":"cookbook/integration/recipekeeper.py"}},"d_37812bb4c19c71da_recipesage_py":{"hash":"f16b7f3b2e7b4acc6f8eee41b99ccfcd","index":{"nums":[0,1,61,0,48,0,0,0],"html_filename":"d_37812bb4c19c71da_recipesage_py.html","relative_filename":"cookbook/integration/recipesage.py"}},"d_37812bb4c19c71da_rezeptsuitede_py":{"hash":"834f64c056b7c7732f692cbfc652dc70","index":{"nums":[0,1,54,0,43,0,0,0],"html_filename":"d_37812bb4c19c71da_rezeptsuitede_py.html","relative_filename":"cookbook/integration/rezeptsuitede.py"}},"d_37812bb4c19c71da_rezkonv_py":{"hash":"5c893da290ea3d56639f42bdd792cd08","index":{"nums":[0,1,60,0,53,0,0,0],"html_filename":"d_37812bb4c19c71da_rezkonv_py.html","relative_filename":"cookbook/integration/rezkonv.py"}},"d_37812bb4c19c71da_saffron_py":{"hash":"620cb81f6af463cb94d06c27b0203835","index":{"nums":[0,1,78,0,70,0,0,0],"html_filename":"d_37812bb4c19c71da_saffron_py.html","relative_filename":"cookbook/integration/saffron.py"}},"d_a167ab5b5108d61e_managers_py":{"hash":"343627466c96f15e51ac54ec5e3a3f91","index":{"nums":[0,1,13,0,5,0,0,0],"html_filename":"d_a167ab5b5108d61e_managers_py.html","relative_filename":"cookbook/managers.py"}},"d_a167ab5b5108d61e_models_py":{"hash":"1af0c0013dae3da8ad5ab1be111bbffa","index":{"nums":[0,1,900,0,122,0,0,0],"html_filename":"d_a167ab5b5108d61e_models_py.html","relative_filename":"cookbook/models.py"}},"d_0b5495cf37ee6c4f_dropbox_py":{"hash":"78c4855680734cd8c4b3f7f92b9c2d5e","index":{"nums":[0,1,75,0,54,0,0,0],"html_filename":"d_0b5495cf37ee6c4f_dropbox_py.html","relative_filename":"cookbook/provider/dropbox.py"}},"d_0b5495cf37ee6c4f_local_py":{"hash":"f2a9a5412dbfc09353896620bb915bc9","index":{"nums":[0,1,36,0,20,0,0,0],"html_filename":"d_0b5495cf37ee6c4f_local_py.html","relative_filename":"cookbook/provider/local.py"}},"d_0b5495cf37ee6c4f_nextcloud_py":{"hash":"6547e4c73fe56c517f9bc0c101a98d07","index":{"nums":[0,1,78,0,52,0,0,0],"html_filename":"d_0b5495cf37ee6c4f_nextcloud_py.html","relative_filename":"cookbook/provider/nextcloud.py"}},"d_0b5495cf37ee6c4f_provider_py":{"hash":"aa8f1073216d1dd39d42f2f9d17ba1f9","index":{"nums":[0,1,19,0,6,0,0,0],"html_filename":"d_0b5495cf37ee6c4f_provider_py.html","relative_filename":"cookbook/provider/provider.py"}},"d_a167ab5b5108d61e_schemas_py":{"hash":"9eba8c00668617c5ed60fc0936993168","index":{"nums":[0,1,36,0,21,0,0,0],"html_filename":"d_a167ab5b5108d61e_schemas_py.html","relative_filename":"cookbook/schemas.py"}},"d_a167ab5b5108d61e_serializer_py":{"hash":"8efff654da464e840ab4a1dd120674ff","index":{"nums":[0,1,939,0,137,0,0,0],"html_filename":"d_a167ab5b5108d61e_serializer_py.html","relative_filename":"cookbook/serializer.py"}},"d_a167ab5b5108d61e_signals_py":{"hash":"c15f41f07d64ede4c0d9556d4aa5038d","index":{"nums":[0,1,120,0,21,0,0,0],"html_filename":"d_a167ab5b5108d61e_signals_py.html","relative_filename":"cookbook/signals.py"}},"d_a167ab5b5108d61e_tables_py":{"hash":"60360e77791d8a0414ce66f8ae67b9a4","index":{"nums":[0,1,60,0,5,0,0,0],"html_filename":"d_a167ab5b5108d61e_tables_py.html","relative_filename":"cookbook/tables.py"}},"d_1d409d097a8b76e7_custom_tags_py":{"hash":"60f37c3492aaa0366d2e02bccbd009c3","index":{"nums":[0,1,124,0,33,0,0,0],"html_filename":"d_1d409d097a8b76e7_custom_tags_py.html","relative_filename":"cookbook/templatetags/custom_tags.py"}},"d_1d409d097a8b76e7_theming_tags_py":{"hash":"d7e884c3a1d97c5e985663d6b4a01f2c","index":{"nums":[0,1,30,0,4,0,0,0],"html_filename":"d_1d409d097a8b76e7_theming_tags_py.html","relative_filename":"cookbook/templatetags/theming_tags.py"}},"d_a167ab5b5108d61e_version_info_py":{"hash":"a23d4f9be38b76057447e0889694af04","index":{"nums":[0,1,3,0,0,0,0,0],"html_filename":"d_a167ab5b5108d61e_version_info_py.html","relative_filename":"cookbook/version_info.py"}},"d_dd189b0e5315428c_api_py":{"hash":"9eeff418aced580d7c52210f1f1d83cf","index":{"nums":[0,1,1058,0,367,0,0,0],"html_filename":"d_dd189b0e5315428c_api_py.html","relative_filename":"cookbook/views/api.py"}},"d_dd189b0e5315428c_data_py":{"hash":"11d086fe6cdb45a9385384f27a6dcd48","index":{"nums":[0,1,88,0,62,0,0,0],"html_filename":"d_dd189b0e5315428c_data_py.html","relative_filename":"cookbook/views/data.py"}},"d_dd189b0e5315428c_delete_py":{"hash":"f323c19faceb3d96af32415dcd9b7dc6","index":{"nums":[0,1,152,0,74,0,0,0],"html_filename":"d_dd189b0e5315428c_delete_py.html","relative_filename":"cookbook/views/delete.py"}},"d_dd189b0e5315428c_edit_py":{"hash":"7f2c66253dda4fcf5bc2f985b90dd54a","index":{"nums":[0,1,132,0,29,0,0,0],"html_filename":"d_dd189b0e5315428c_edit_py.html","relative_filename":"cookbook/views/edit.py"}},"d_dd189b0e5315428c_import_export_py":{"hash":"70e49ae991f07ea52930ed2bf9e85f6c","index":{"nums":[0,1,123,0,75,0,0,0],"html_filename":"d_dd189b0e5315428c_import_export_py.html","relative_filename":"cookbook/views/import_export.py"}},"d_dd189b0e5315428c_lists_py":{"hash":"bc763b328283d7542b6467e2e1dde520","index":{"nums":[0,1,68,0,27,0,0,0],"html_filename":"d_dd189b0e5315428c_lists_py.html","relative_filename":"cookbook/views/lists.py"}},"d_dd189b0e5315428c_new_py":{"hash":"a8d1e88df183e575c4bcc7354fd1f653","index":{"nums":[0,1,80,0,47,0,0,0],"html_filename":"d_dd189b0e5315428c_new_py.html","relative_filename":"cookbook/views/new.py"}},"d_dd189b0e5315428c_telegram_py":{"hash":"2caed44ea36e42d4deb393ef6fd4c5c1","index":{"nums":[0,1,42,0,27,0,0,0],"html_filename":"d_dd189b0e5315428c_telegram_py.html","relative_filename":"cookbook/views/telegram.py"}},"d_dd189b0e5315428c_views_py":{"hash":"25c5e401e88ccd31408b56bfd1042b09","index":{"nums":[0,1,363,0,266,0,0,0],"html_filename":"d_dd189b0e5315428c_views_py.html","relative_filename":"cookbook/views/views.py"}},"d_b7ebbfe037735c69_middleware_py":{"hash":"c5dc721e0aff801f71627361f494620d","index":{"nums":[0,1,46,0,46,0,0,0],"html_filename":"d_b7ebbfe037735c69_middleware_py.html","relative_filename":"recipes/middleware.py"}},"version_py":{"hash":"aefd656b49ec622d5a44058ce09fd4dd","index":{"nums":[0,1,52,0,52,0,0,0],"html_filename":"version_py.html","relative_filename":"version.py"}}}} \ No newline at end of file diff --git a/docs/coverage/style.css b/docs/coverage/style.css deleted file mode 100644 index 2555fdfee8..0000000000 --- a/docs/coverage/style.css +++ /dev/null @@ -1,309 +0,0 @@ -@charset "UTF-8"; -/* Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 */ -/* For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt */ -/* Don't edit this .css file. Edit the .scss file instead! */ -html, body, h1, h2, h3, p, table, td, th { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; } - -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 1em; background: #fff; color: #000; } - -@media (prefers-color-scheme: dark) { body { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { body { color: #eee; } } - -html > body { font-size: 16px; } - -a:active, a:focus { outline: 2px dashed #007acc; } - -p { font-size: .875em; line-height: 1.4em; } - -table { border-collapse: collapse; } - -td { vertical-align: top; } - -table tr.hidden { display: none !important; } - -p#no_rows { display: none; font-size: 1.2em; } - -a.nav { text-decoration: none; color: inherit; } - -a.nav:hover { text-decoration: underline; color: inherit; } - -.hidden { display: none; } - -header { background: #f8f8f8; width: 100%; z-index: 2; border-bottom: 1px solid #ccc; } - -@media (prefers-color-scheme: dark) { header { background: black; } } - -@media (prefers-color-scheme: dark) { header { border-color: #333; } } - -header .content { padding: 1rem 3.5rem; } - -header h2 { margin-top: .5em; font-size: 1em; } - -header p.text { margin: .5em 0 -.5em; color: #666; font-style: italic; } - -@media (prefers-color-scheme: dark) { header p.text { color: #aaa; } } - -header.sticky { position: fixed; left: 0; right: 0; height: 2.5em; } - -header.sticky .text { display: none; } - -header.sticky h1, header.sticky h2 { font-size: 1em; margin-top: 0; display: inline-block; } - -header.sticky .content { padding: 0.5rem 3.5rem; } - -header.sticky .content p { font-size: 1em; } - -header.sticky ~ #source { padding-top: 6.5em; } - -main { position: relative; z-index: 1; } - -footer { margin: 1rem 3.5rem; } - -footer .content { padding: 0; color: #666; font-style: italic; } - -@media (prefers-color-scheme: dark) { footer .content { color: #aaa; } } - -#index { margin: 1rem 0 0 3.5rem; } - -h1 { font-size: 1.25em; display: inline-block; } - -#filter_container { float: right; margin: 0 2em 0 0; } - -#filter_container input { width: 10em; padding: 0.2em 0.5em; border: 2px solid #ccc; background: #fff; color: #000; } - -@media (prefers-color-scheme: dark) { #filter_container input { border-color: #444; } } - -@media (prefers-color-scheme: dark) { #filter_container input { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { #filter_container input { color: #eee; } } - -#filter_container input:focus { border-color: #007acc; } - -header button { font-family: inherit; font-size: inherit; border: 1px solid; border-radius: .2em; color: inherit; padding: .1em .5em; margin: 1px calc(.1em + 1px); cursor: pointer; border-color: #ccc; } - -@media (prefers-color-scheme: dark) { header button { border-color: #444; } } - -header button:active, header button:focus { outline: 2px dashed #007acc; } - -header button.run { background: #eeffee; } - -@media (prefers-color-scheme: dark) { header button.run { background: #373d29; } } - -header button.run.show_run { background: #dfd; border: 2px solid #00dd00; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.run.show_run { background: #373d29; } } - -header button.mis { background: #ffeeee; } - -@media (prefers-color-scheme: dark) { header button.mis { background: #4b1818; } } - -header button.mis.show_mis { background: #fdd; border: 2px solid #ff0000; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.mis.show_mis { background: #4b1818; } } - -header button.exc { background: #f7f7f7; } - -@media (prefers-color-scheme: dark) { header button.exc { background: #333; } } - -header button.exc.show_exc { background: #eee; border: 2px solid #808080; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.exc.show_exc { background: #333; } } - -header button.par { background: #ffffd5; } - -@media (prefers-color-scheme: dark) { header button.par { background: #650; } } - -header button.par.show_par { background: #ffa; border: 2px solid #bbbb00; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.par.show_par { background: #650; } } - -#help_panel, #source p .annotate.long { display: none; position: absolute; z-index: 999; background: #ffffcc; border: 1px solid #888; border-radius: .2em; color: #333; padding: .25em .5em; } - -#source p .annotate.long { white-space: normal; float: right; top: 1.75em; right: 1em; height: auto; } - -#help_panel_wrapper { float: right; position: relative; } - -#keyboard_icon { margin: 5px; } - -#help_panel_state { display: none; } - -#help_panel { top: 25px; right: 0; padding: .75em; border: 1px solid #883; color: #333; } - -#help_panel .keyhelp p { margin-top: .75em; } - -#help_panel .legend { font-style: italic; margin-bottom: 1em; } - -.indexfile #help_panel { width: 25em; } - -.pyfile #help_panel { width: 18em; } - -#help_panel_state:checked ~ #help_panel { display: block; } - -kbd { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em .35em; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: bold; background: #eee; border-radius: 3px; } - -#source { padding: 1em 0 1em 3.5rem; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; } - -#source p { position: relative; white-space: pre; } - -#source p * { box-sizing: border-box; } - -#source p .n { float: left; text-align: right; width: 3.5rem; box-sizing: border-box; margin-left: -3.5rem; padding-right: 1em; color: #999; user-select: none; } - -@media (prefers-color-scheme: dark) { #source p .n { color: #777; } } - -#source p .n.highlight { background: #ffdd00; } - -#source p .n a { margin-top: -4em; padding-top: 4em; text-decoration: none; color: #999; } - -@media (prefers-color-scheme: dark) { #source p .n a { color: #777; } } - -#source p .n a:hover { text-decoration: underline; color: #999; } - -@media (prefers-color-scheme: dark) { #source p .n a:hover { color: #777; } } - -#source p .t { display: inline-block; width: 100%; box-sizing: border-box; margin-left: -.5em; padding-left: 0.3em; border-left: 0.2em solid #fff; } - -@media (prefers-color-scheme: dark) { #source p .t { border-color: #1e1e1e; } } - -#source p .t:hover { background: #f2f2f2; } - -@media (prefers-color-scheme: dark) { #source p .t:hover { background: #282828; } } - -#source p .t:hover ~ .r .annotate.long { display: block; } - -#source p .t .com { color: #008000; font-style: italic; line-height: 1px; } - -@media (prefers-color-scheme: dark) { #source p .t .com { color: #6a9955; } } - -#source p .t .key { font-weight: bold; line-height: 1px; } - -#source p .t .str { color: #0451a5; } - -@media (prefers-color-scheme: dark) { #source p .t .str { color: #9cdcfe; } } - -#source p.mis .t { border-left: 0.2em solid #ff0000; } - -#source p.mis.show_mis .t { background: #fdd; } - -@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t { background: #4b1818; } } - -#source p.mis.show_mis .t:hover { background: #f2d2d2; } - -@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t:hover { background: #532323; } } - -#source p.run .t { border-left: 0.2em solid #00dd00; } - -#source p.run.show_run .t { background: #dfd; } - -@media (prefers-color-scheme: dark) { #source p.run.show_run .t { background: #373d29; } } - -#source p.run.show_run .t:hover { background: #d2f2d2; } - -@media (prefers-color-scheme: dark) { #source p.run.show_run .t:hover { background: #404633; } } - -#source p.exc .t { border-left: 0.2em solid #808080; } - -#source p.exc.show_exc .t { background: #eee; } - -@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t { background: #333; } } - -#source p.exc.show_exc .t:hover { background: #e2e2e2; } - -@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t:hover { background: #3c3c3c; } } - -#source p.par .t { border-left: 0.2em solid #bbbb00; } - -#source p.par.show_par .t { background: #ffa; } - -@media (prefers-color-scheme: dark) { #source p.par.show_par .t { background: #650; } } - -#source p.par.show_par .t:hover { background: #f2f2a2; } - -@media (prefers-color-scheme: dark) { #source p.par.show_par .t:hover { background: #6d5d0c; } } - -#source p .r { position: absolute; top: 0; right: 2.5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } - -#source p .annotate { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; color: #666; padding-right: .5em; } - -@media (prefers-color-scheme: dark) { #source p .annotate { color: #ddd; } } - -#source p .annotate.short:hover ~ .long { display: block; } - -#source p .annotate.long { width: 30em; right: 2.5em; } - -#source p input { display: none; } - -#source p input ~ .r label.ctx { cursor: pointer; border-radius: .25em; } - -#source p input ~ .r label.ctx::before { content: "▶ "; } - -#source p input ~ .r label.ctx:hover { background: #e8f4ff; color: #666; } - -@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { background: #0f3a42; } } - -@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { color: #aaa; } } - -#source p input:checked ~ .r label.ctx { background: #d0e8ff; color: #666; border-radius: .75em .75em 0 0; padding: 0 .5em; margin: -.25em 0; } - -@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { background: #056; } } - -@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { color: #aaa; } } - -#source p input:checked ~ .r label.ctx::before { content: "▼ "; } - -#source p input:checked ~ .ctxs { padding: .25em .5em; overflow-y: scroll; max-height: 10.5em; } - -#source p label.ctx { color: #999; display: inline-block; padding: 0 .5em; font-size: .8333em; } - -@media (prefers-color-scheme: dark) { #source p label.ctx { color: #777; } } - -#source p .ctxs { display: block; max-height: 0; overflow-y: hidden; transition: all .2s; padding: 0 .5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; white-space: nowrap; background: #d0e8ff; border-radius: .25em; margin-right: 1.75em; text-align: right; } - -@media (prefers-color-scheme: dark) { #source p .ctxs { background: #056; } } - -#index { font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.875em; } - -#index table.index { margin-left: -.5em; } - -#index td, #index th { text-align: right; width: 5em; padding: .25em .5em; border-bottom: 1px solid #eee; } - -@media (prefers-color-scheme: dark) { #index td, #index th { border-color: #333; } } - -#index td.name, #index th.name { text-align: left; width: auto; } - -#index th { font-style: italic; color: #333; cursor: pointer; } - -@media (prefers-color-scheme: dark) { #index th { color: #ddd; } } - -#index th:hover { background: #eee; } - -@media (prefers-color-scheme: dark) { #index th:hover { background: #333; } } - -#index th[aria-sort="ascending"], #index th[aria-sort="descending"] { white-space: nowrap; background: #eee; padding-left: .5em; } - -@media (prefers-color-scheme: dark) { #index th[aria-sort="ascending"], #index th[aria-sort="descending"] { background: #333; } } - -#index th[aria-sort="ascending"]::after { font-family: sans-serif; content: " ↑"; } - -#index th[aria-sort="descending"]::after { font-family: sans-serif; content: " ↓"; } - -#index td.name a { text-decoration: none; color: inherit; } - -#index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-top: 1px solid #ccc; border-bottom: none; } - -#index tr.file:hover { background: #eee; } - -@media (prefers-color-scheme: dark) { #index tr.file:hover { background: #333; } } - -#index tr.file:hover td.name { text-decoration: underline; color: inherit; } - -#scroll_marker { position: fixed; z-index: 3; right: 0; top: 0; width: 16px; height: 100%; background: #fff; border-left: 1px solid #eee; will-change: transform; } - -@media (prefers-color-scheme: dark) { #scroll_marker { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { #scroll_marker { border-color: #333; } } - -#scroll_marker .marker { background: #ccc; position: absolute; min-height: 3px; width: 100%; } - -@media (prefers-color-scheme: dark) { #scroll_marker .marker { background: #444; } } diff --git a/docs/coverage/version_py.html b/docs/coverage/version_py.html deleted file mode 100644 index c1b19683bc..0000000000 --- a/docs/coverage/version_py.html +++ /dev/null @@ -1,173 +0,0 @@ - - - - - Coverage for version.py: 0% - - - - - -
- -
-
-

1import os 

-

2import re 

-

3import subprocess 

-

4import sys 

-

5import traceback 

-

6 

-

7BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 

-

8PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins') 

-

9 

-

10version_info = [] 

-

11tandoor_tag = '' 

-

12tandoor_hash = '' 

-

13try: 

-

14 print('getting tandoor version') 

-

15 r = subprocess.check_output(['git', 'show', '-s'], cwd=BASE_DIR).decode() 

-

16 tandoor_branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=BASE_DIR).decode().replace('\n', '') 

-

17 tandoor_hash = r.split('\n')[0].split(' ')[1] 

-

18 try: 

-

19 tandoor_tag = subprocess.check_output(['git', 'describe', '--exact-match', '--tags', tandoor_hash], cwd=BASE_DIR).decode().replace('\n', '') 

-

20 except BaseException: 

-

21 pass 

-

22 

-

23 version_info.append({ 

-

24 'name': 'Tandoor ', 

-

25 'version': re.sub(r'<.*>', '', r), 

-

26 'website': 'https://github.com/TandoorRecipes/recipes', 

-

27 'commit_link': 'https://github.com/TandoorRecipes/recipes/commit/' + r.split('\n')[0].split(' ')[1], 

-

28 'ref': tandoor_hash, 

-

29 'branch': tandoor_branch, 

-

30 'tag': tandoor_tag 

-

31 }) 

-

32 

-

33 if os.path.isdir(PLUGINS_DIRECTORY): 

-

34 for d in os.listdir(PLUGINS_DIRECTORY): 

-

35 if d != '__pycache__': 

-

36 try: 

-

37 apps_path = f'recipes.plugins.{d}.apps' 

-

38 __import__(apps_path) 

-

39 app_config_classname = dir(sys.modules[apps_path])[1] 

-

40 plugin_module = f'recipes.plugins.{d}.apps.{app_config_classname}' 

-

41 plugin_class = getattr(sys.modules[apps_path], app_config_classname) 

-

42 

-

43 print('getting plugin version for ', plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name) 

-

44 r = subprocess.check_output(['git', 'show', '-s'], cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d)).decode() 

-

45 branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d)).decode() 

-

46 commit_hash = r.split('\n')[0].split(' ')[1] 

-

47 try: 

-

48 print('running describe') 

-

49 tag = subprocess.check_output(['git', 'describe', '--exact-match', commit_hash], 

-

50 cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d)).decode().replace('\n', '') 

-

51 except BaseException: 

-

52 tag = '' 

-

53 

-

54 version_info.append({ 

-

55 'name': 'Plugin: ' + plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name, 

-

56 'version': re.sub(r'<.*>', '', r), 

-

57 'website': plugin_class.website if hasattr(plugin_class, 'website') else '', 

-

58 'commit_link': plugin_class.github if hasattr(plugin_class, 'github') else '' + '/commit/' + commit_hash, 

-

59 'ref': commit_hash, 

-

60 'branch': branch, 

-

61 'tag': tag 

-

62 }) 

-

63 except subprocess.CalledProcessError as e: 

-

64 print("command '{}' return with error (code {}): {}".format(e.cmd, e.returncode, e.output)) 

-

65 except Exception: 

-

66 traceback.print_exc() 

-

67except subprocess.CalledProcessError as e: 

-

68 print("command '{}' return with error (code {}): {}".format(e.cmd, e.returncode, e.output)) 

-

69except BaseException: 

-

70 traceback.print_exc() 

-

71 

-

72with open('cookbook/version_info.py', 'w+', encoding='UTF-8') as f: 

-

73 print(f"writing version info {version_info}") 

-

74 if not tandoor_tag: 

-

75 tandoor_tag = tandoor_hash 

-

76 f.write(f'TANDOOR_VERSION = "{tandoor_tag}"\nTANDOOR_REF = "{tandoor_hash}"\nVERSION_INFO = {version_info}') 

-
- - - From db310c407615acc65e2d446c7350a88caad17606 Mon Sep 17 00:00:00 2001 From: Johannes Jandke Date: Wed, 20 Mar 2024 13:37:36 +0000 Subject: [PATCH 07/34] Translated using Weblate (German) Currently translated at 100.0% (569 of 569 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/ --- vue/src/locales/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vue/src/locales/de.json b/vue/src/locales/de.json index 3ce1269ddf..fed1ffcb01 100644 --- a/vue/src/locales/de.json +++ b/vue/src/locales/de.json @@ -22,7 +22,7 @@ "Energy": "Energie", "Nutrition": "Nährwerte", "Keywords": "Stichwörter", - "Books": "Bücher", + "Books": "Kochbücher", "show_only_internal": "Nur interne Rezepte anzeigen", "Ingredients": "Zutaten", "min": "min", @@ -319,7 +319,7 @@ "fields": "Felder", "show_keywords": "Schlüsselwörter anzeigen", "show_foods": "Zutaten anzeigen", - "show_books": "Bücher anzeigen", + "show_books": "Kochbücher anzeigen", "show_rating": "Bewertungen anzeigen", "show_units": "Einheiten anzeigen", "show_filters": "Filter anzeigen", From 49539ef3baa835c18375063f048487511e79a15a Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 09:11:57 -0500 Subject: [PATCH 08/34] add TEST_DATABASE env parameters --- docs/system/configuration.md | 1 + docs/tests/pytest-badge.svg | 1 - recipes/settings.py | 124 ++++++++++++++++++----------------- recipes/test_settings.py | 30 +++++++++ 4 files changed, 94 insertions(+), 62 deletions(-) delete mode 100644 docs/tests/pytest-badge.svg create mode 100644 recipes/test_settings.py diff --git a/docs/system/configuration.md b/docs/system/configuration.md index feb99e7883..90f75963c4 100644 --- a/docs/system/configuration.md +++ b/docs/system/configuration.md @@ -30,6 +30,7 @@ SECRET_KEY_FILE=/path/to/file.txt ### Database Multiple parameters are required to configure the database. +*Note: You can setup parameters for a test database by defining all of the parameters preceded by `TEST_` e.g. TEST_DB_ENGINE=* | Var | Options | Description | |-------------------|--------------------------------------------------------------------|-------------------------------------------------------------------------| diff --git a/docs/tests/pytest-badge.svg b/docs/tests/pytest-badge.svg deleted file mode 100644 index 7a41b959f0..0000000000 --- a/docs/tests/pytest-badge.svg +++ /dev/null @@ -1 +0,0 @@ -tests: 785tests785 \ No newline at end of file diff --git a/recipes/settings.py b/recipes/settings.py index a624288182..3219c0be00 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -105,7 +105,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', - 'django.contrib.sites', 'django.contrib.staticfiles', 'django.contrib.postgres', 'oauth2_provider', 'django_prometheus', 'django_tables2', 'corsheaders', 'crispy_forms', + 'django.contrib.sites', 'django.contrib.staticfiles', 'django.contrib.postgres', 'oauth2_provider', 'django_tables2', 'corsheaders', 'crispy_forms', 'crispy_bootstrap4', 'rest_framework', 'rest_framework.authtoken', 'django_cleanup.apps.CleanupConfig', 'webpack_loader', 'django_js_reverse', 'hcaptcha', 'allauth', 'allauth.account', 'allauth.socialaccount', 'cookbook.apps.CookbookConfig', 'treebeard', ] @@ -195,6 +195,7 @@ if ENABLE_METRICS: MIDDLEWARE += 'django_prometheus.middleware.PrometheusAfterMiddleware', + INSTALLED_APPS += 'django_prometheus', # Auth related settings AUTHENTICATION_BACKENDS = [] @@ -293,67 +294,70 @@ # Database # Load settings from env files if os.getenv('DATABASE_URL'): - match = re.match(r'(?P\w+):\/\/(?:(?P[\w\d_-]+)(?::(?P[^@]+))?@)?(?P[^:/]+)(?::(?P\d+))?(?:/(?P[\w\d/._-]+))?', - os.getenv('DATABASE_URL')) - settings = match.groupdict() - schema = settings['schema'] - if schema.startswith('postgres'): - engine = 'django.db.backends.postgresql' - elif schema == 'sqlite': - if not os.path.exists(db_path := os.path.dirname(settings['database'])): - os.makedirs(db_path) - engine = 'django.db.backends.sqlite3' - else: - raise Exception("Unsupported database schema: '%s'" % schema) - - DATABASES = { - 'default': { - 'ENGINE': engine, - 'OPTIONS': ast.literal_eval(os.getenv('DB_OPTIONS')) if os.getenv('DB_OPTIONS') else {}, - 'HOST': settings['host'], - 'PORT': settings['port'], - 'USER': settings['user'], - 'PASSWORD': settings['password'], - 'NAME': settings['database'], - 'CONN_MAX_AGE': 600, +DATABASE_URL = os.getenv('DATABASE_URL') or None +DB_OPTIONS = os.getenv('DB_OPTIONS') or None +DB_ENGINE = os.getenv('DB_ENGINE') or None +POSTGRES_HOST = os.getenv('POSTGRES_HOST') or None +POSTGRES_PORT = os.getenv('POSTGRES_PORT') or None +POSTGRES_USER = os.getenv('POSTGRES_USER') or None +POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD') or None +POSTGRES_DB = os.getenv('POSTGRES_DB') or None + + +def setup_database(db_url=None, db_options=None, db_engine=None, pg_host=None, pg_port=None, pg_user=None, pg_password=None, pg_db=None): + global DATABASE_URL, DB_ENGINE, DB_OPTIONS, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB + + DATABASE_URL = db_url or DATABASE_URL + DB_OPTIONS = db_options or DB_OPTIONS + DB_ENGINE = db_engine or DB_ENGINE + POSTGRES_HOST = pg_host or POSTGRES_HOST + POSTGRES_PORT = pg_port or POSTGRES_PORT + POSTGRES_USER = pg_user or POSTGRES_USER + POSTGRES_PASSWORD = pg_password or POSTGRES_PASSWORD + POSTGRES_DB = pg_db or POSTGRES_DB + + if DATABASE_URL: + match = re.match(r'(?P\w+):\/\/(?:(?P[\w\d_-]+)(?::(?P[^@]+))?@)?(?P[^:/]+)(?::(?P\d+))?(?:/(?P[\w\d/._-]+))?', DATABASE_URL) + settings = match.groupdict() + schema = settings['schema'] + if schema.startswith('postgres'): + engine = 'django.db.backends.postgresql' + elif schema == 'sqlite': + if (db_path := os.path.dirname(settings['database'])) and not os.path.exists(db_path): + os.makedirs(db_path) + engine = 'django.db.backends.sqlite3' + else: + raise Exception("Unsupported database schema: '%s'" % schema) + + DATABASES = { + 'default': { + 'ENGINE': engine, + 'OPTIONS': ast.literal_eval(DB_OPTIONS) if DB_OPTIONS else {}, + 'HOST': settings['host'], + 'PORT': settings['port'], + 'USER': settings['user'], + 'PASSWORD': settings['password'], + 'NAME': settings['database'], + 'CONN_MAX_AGE': 600, + } } - } -else: - DATABASES = { - 'default': { - 'ENGINE': os.getenv('DB_ENGINE') if os.getenv('DB_ENGINE') else 'django.db.backends.sqlite3', - 'OPTIONS': ast.literal_eval(os.getenv('DB_OPTIONS')) if os.getenv('DB_OPTIONS') else {}, - 'HOST': os.getenv('POSTGRES_HOST'), - 'PORT': os.getenv('POSTGRES_PORT'), - 'USER': os.getenv('POSTGRES_USER'), - 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), - 'NAME': os.getenv('POSTGRES_DB') if os.getenv('POSTGRES_DB') else 'db.sqlite3', - 'CONN_MAX_AGE': 60, + else: + DATABASES = { + 'default': { + 'ENGINE': DB_ENGINE if DB_ENGINE else 'django.db.backends.sqlite3', + 'OPTIONS': ast.literal_eval(DB_OPTIONS) if DB_OPTIONS else {}, + 'HOST': POSTGRES_HOST, + 'PORT': POSTGRES_PORT, + 'USER': POSTGRES_USER, + 'PASSWORD': POSTGRES_PASSWORD, + 'NAME': POSTGRES_DB if POSTGRES_DB else 'db.sqlite3', + 'CONN_MAX_AGE': 60, + } } - } + return DATABASES + -# Local testing DB -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.postgresql', -# 'HOST': 'localhost', -# 'PORT': 5432, -# 'USER': 'postgres', -# 'PASSWORD': 'postgres', # set to local pw -# 'NAME': 'tandoor_app', -# 'CONN_MAX_AGE': 600, -# } -# } - -# SQLite testing DB -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.sqlite3', -# 'OPTIONS': ast.literal_eval(os.getenv('DB_OPTIONS')) if os.getenv('DB_OPTIONS') else {}, -# 'NAME': 'db.sqlite3', -# 'CONN_MAX_AGE': 600, -# } -# } +DATABASES = setup_database() CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'default', }} @@ -441,8 +445,6 @@ # Serve static files with gzip STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' -TEST_RUNNER = "cookbook.helper.CustomTestRunner.CustomTestRunner" - # settings for cross site origin (CORS) # all origins allowed to support bookmarklet # all of this may or may not work with nginx or other web servers diff --git a/recipes/test_settings.py b/recipes/test_settings.py new file mode 100644 index 0000000000..29d5dbd8c3 --- /dev/null +++ b/recipes/test_settings.py @@ -0,0 +1,30 @@ +from recipes.settings import * # noqa: F403 +import os + +DATABASES = setup_database( # noqa: F405 + db_url=os.getenv('TEST_DATABASE_URL'), + db_options=os.getenv('TEST_DB_OPTIONS'), + db_engine=os.getenv('TEST_DB_ENGINE'), + pg_host=os.getenv('TEST_POSTGRES_HOST'), + pg_port=os.getenv('TEST_POSTGRES_PORT'), + pg_user=os.getenv('TEST_POSTGRES_PORT'), + pg_password=os.getenv('TEST_POSTGRES_PASSWORD'), + pg_db=os.getenv('TEST_POSTGRES_DB') + ) + + +UNINSTALL_MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware' +] + +UNINSTALL_INSTALLED_APPS = [ + 'django.contrib.messages', 'django.contrib.sites', 'django.contrib.staticfiles', 'corsheaders', 'django_cleanup.apps.CleanupConfig', 'django_js_reverse', 'hcaptcha'] + +# disable extras not needed for testing +for x in UNINSTALL_MIDDLEWARE: + MIDDLEWARE.remove(x) # noqa: F405 + +for y in UNINSTALL_INSTALLED_APPS: + INSTALLED_APPS.remove(y) # noqa: F405 From b6a42e8e811581a03548e05911d999325ec9a088 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 09:13:11 -0500 Subject: [PATCH 09/34] enable parallel test execution --- pytest.ini | 7 +++++-- requirements.txt | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index c1b192b408..a3d26ec48f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,7 @@ [pytest] -DJANGO_SETTINGS_MODULE = recipes.settings +DJANGO_SETTINGS_MODULE = recipes.test_settings +testpaths = cookbook/tests python_files = tests.py test_*.py *_tests.py -addopts = --cov=. --cov-report=html:docs/reports/coverage --cov-report=xml:docs/reports/coverage/coverage.xml --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html +# uncomment to run coverage reports +addopts = -n auto --cov=. --cov-report=html:docs/reports/coverage --cov-report=xml:docs/reports/coverage/coverage.xml --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html +# addopts = -n auto --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 40f0d626a3..8d1263e00e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,3 +52,4 @@ pytest-cov===4.1.0 pytest-factoryboy==2.6.0 pytest-html==4.1.1 pytest-asyncio==0.23.5 +pytest-xdist==3.5.0 From 5e3a5eb8f52827b97feab515b87477ac6013f12b Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Mar 2024 09:18:38 -0500 Subject: [PATCH 10/34] fix errant IF statement --- recipes/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/recipes/settings.py b/recipes/settings.py index 3219c0be00..268c8f68b3 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -293,7 +293,6 @@ # Database # Load settings from env files -if os.getenv('DATABASE_URL'): DATABASE_URL = os.getenv('DATABASE_URL') or None DB_OPTIONS = os.getenv('DB_OPTIONS') or None DB_ENGINE = os.getenv('DB_ENGINE') or None From 1235cb8da54f98d5337092b7ca1bf9f85ef5ea97 Mon Sep 17 00:00:00 2001 From: tooboredtocode Date: Fri, 22 Mar 2024 00:31:56 +0100 Subject: [PATCH 11/34] feat: improve docker run speeds --- Dockerfile | 19 +++++++++++++------ boot.sh | 22 +++++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3f17d0f118..a398507252 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg li #Print all logs without buffering it. ENV PYTHONUNBUFFERED 1 +ENV DOCKER true + #This port will be used by gunicorn. EXPOSE 8080 @@ -23,18 +25,23 @@ RUN \ RUN sed -i '/# Development/,$d' requirements.txt RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \ echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \ - python -m venv venv && \ - /opt/recipes/venv/bin/python -m pip install --upgrade pip && \ - venv/bin/pip install wheel==0.42.0 && \ - venv/bin/pip install setuptools_rust==1.9.0 && \ - venv/bin/pip install -r requirements.txt --no-cache-dir &&\ + python -m pip install --upgrade pip && \ + pip install wheel==0.42.0 && \ + pip install setuptools_rust==1.9.0 && \ + pip install -r requirements.txt --no-cache-dir &&\ apk --purge del .build-deps #Copy project and execute it. COPY . ./ +# collect the static files +RUN python manage.py collectstatic_js_reverse +RUN python manage.py collectstatic --noinput +# copy the collected static files to a different location, so they can be moved into a potentially mounted volume +RUN mv /opt/recipes/staticfiles /opt/recipes/staticfiles-collect + # collect information from git repositories -RUN /opt/recipes/venv/bin/python version.py +RUN python version.py # delete git repositories to reduce image size RUN find . -type d -name ".git" | xargs rm -rf diff --git a/boot.sh b/boot.sh index ae3dbb51d8..1c3c09a764 100644 --- a/boot.sh +++ b/boot.sh @@ -1,5 +1,9 @@ #!/bin/sh -source venv/bin/activate + +# conditionally activate virtualenv, since the docker container does not need it +if [[ "${DOCKER}" != "true" ]]; then + source venv/bin/activate +fi TANDOOR_PORT="${TANDOOR_PORT:-8080}" GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}" @@ -67,12 +71,20 @@ echo "Migrating database" python manage.py migrate -echo "Generating static files" +if [[ "${DOCKER}" == "true" ]]; then + echo "Collecting static files" -python manage.py collectstatic_js_reverse -python manage.py collectstatic --noinput + mkdir -p /opt/recipes/staticfiles + mv /opt/recipes/staticfiles-collect/* /opt/recipes/staticfiles + rm -rf /opt/recipes/staticfiles-collect +else + echo "Generating static files" -echo "Done" + python manage.py collectstatic_js_reverse + python manage.py collectstatic --noinput + + echo "Done" +fi chmod -R 755 /opt/recipes/mediafiles From 5f211e420e2212dd71d1d7120a11e4272aa50643 Mon Sep 17 00:00:00 2001 From: tooboredtocode Date: Fri, 22 Mar 2024 02:50:18 +0100 Subject: [PATCH 12/34] fix: moving into exisiting files --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index a398507252..1de5178b4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,7 @@ COPY . ./ RUN python manage.py collectstatic_js_reverse RUN python manage.py collectstatic --noinput # copy the collected static files to a different location, so they can be moved into a potentially mounted volume +RUN rm -rf /opt/recipes/staticfiles/* RUN mv /opt/recipes/staticfiles /opt/recipes/staticfiles-collect # collect information from git repositories From ae37abf8b21d3e9f13773d6857efbaed8db501ae Mon Sep 17 00:00:00 2001 From: tooboredtocode Date: Fri, 22 Mar 2024 11:26:26 +0100 Subject: [PATCH 13/34] fix: fix modified the wrong file --- Dockerfile | 1 - boot.sh | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1de5178b4a..a398507252 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,6 @@ COPY . ./ RUN python manage.py collectstatic_js_reverse RUN python manage.py collectstatic --noinput # copy the collected static files to a different location, so they can be moved into a potentially mounted volume -RUN rm -rf /opt/recipes/staticfiles/* RUN mv /opt/recipes/staticfiles /opt/recipes/staticfiles-collect # collect information from git repositories diff --git a/boot.sh b/boot.sh index 1c3c09a764..286d6c983c 100644 --- a/boot.sh +++ b/boot.sh @@ -75,6 +75,7 @@ if [[ "${DOCKER}" == "true" ]]; then echo "Collecting static files" mkdir -p /opt/recipes/staticfiles + rm -rf /opt/recipes/staticfiles/* mv /opt/recipes/staticfiles-collect/* /opt/recipes/staticfiles rm -rf /opt/recipes/staticfiles-collect else From 7bfa23b953a2a9ee671f23af7d55fbc997461e10 Mon Sep 17 00:00:00 2001 From: tooboredtocode Date: Fri, 22 Mar 2024 11:32:30 +0100 Subject: [PATCH 14/34] feat: better messages --- boot.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/boot.sh b/boot.sh index 286d6c983c..7d6ec74424 100644 --- a/boot.sh +++ b/boot.sh @@ -72,14 +72,14 @@ echo "Migrating database" python manage.py migrate if [[ "${DOCKER}" == "true" ]]; then - echo "Collecting static files" + echo "Copying cached static files from docker build" mkdir -p /opt/recipes/staticfiles rm -rf /opt/recipes/staticfiles/* mv /opt/recipes/staticfiles-collect/* /opt/recipes/staticfiles rm -rf /opt/recipes/staticfiles-collect else - echo "Generating static files" + echo "Collecting static files, this may take a while..." python manage.py collectstatic_js_reverse python manage.py collectstatic --noinput From 20b812c2cce1db0101a3fb2a10abca90410e44a3 Mon Sep 17 00:00:00 2001 From: tooboredtocode Date: Fri, 22 Mar 2024 19:04:29 +0100 Subject: [PATCH 15/34] feat: re-add venv --- Dockerfile | 15 ++++++++------- boot.sh | 6 +----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index a398507252..c5856bcfa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,23 +25,24 @@ RUN \ RUN sed -i '/# Development/,$d' requirements.txt RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \ echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \ - python -m pip install --upgrade pip && \ - pip install wheel==0.42.0 && \ - pip install setuptools_rust==1.9.0 && \ - pip install -r requirements.txt --no-cache-dir &&\ + python -m venv venv && \ + /opt/recipes/venv/bin/python -m pip install --upgrade pip && \ + venv/bin/pip install wheel==0.42.0 && \ + venv/bin/pip install setuptools_rust==1.9.0 && \ + venv/bin/pip install -r requirements.txt --no-cache-dir &&\ apk --purge del .build-deps #Copy project and execute it. COPY . ./ # collect the static files -RUN python manage.py collectstatic_js_reverse -RUN python manage.py collectstatic --noinput +RUN /opt/recipes/venv/bin/python manage.py collectstatic_js_reverse +RUN /opt/recipes/venv/bin/python manage.py collectstatic --noinput # copy the collected static files to a different location, so they can be moved into a potentially mounted volume RUN mv /opt/recipes/staticfiles /opt/recipes/staticfiles-collect # collect information from git repositories -RUN python version.py +RUN /opt/recipes/venv/bin/python version.py # delete git repositories to reduce image size RUN find . -type d -name ".git" | xargs rm -rf diff --git a/boot.sh b/boot.sh index 7d6ec74424..ab5d7fdddd 100644 --- a/boot.sh +++ b/boot.sh @@ -1,9 +1,5 @@ #!/bin/sh - -# conditionally activate virtualenv, since the docker container does not need it -if [[ "${DOCKER}" != "true" ]]; then - source venv/bin/activate -fi +source venv/bin/activate TANDOOR_PORT="${TANDOOR_PORT:-8080}" GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}" From d99a157416bf23abd999fa060b747a57b81231f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Mar 2024 06:40:34 +0000 Subject: [PATCH 16/34] Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /vue Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4. - [Release notes](https://github.com/webpack/webpack-dev-middleware/releases) - [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4) --- updated-dependencies: - dependency-name: webpack-dev-middleware dependency-type: indirect ... Signed-off-by: dependabot[bot] --- vue/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vue/yarn.lock b/vue/yarn.lock index 818b3ebaf5..fbbdc4b870 100644 --- a/vue/yarn.lock +++ b/vue/yarn.lock @@ -11498,9 +11498,9 @@ webpack-chain@^6.5.1: javascript-stringify "^2.0.1" webpack-dev-middleware@^5.3.1: - version "5.3.3" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" - integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA== + version "5.3.4" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517" + integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q== dependencies: colorette "^2.0.10" memfs "^3.4.3" From 7957413ca08f741ae6a1a618a32ef40886758180 Mon Sep 17 00:00:00 2001 From: Rich Schumacher Date: Sat, 23 Mar 2024 15:02:53 -0400 Subject: [PATCH 17/34] bug: Fix TypeError at /system due to invalid PostgreSQL values [Closes #3053] The bug was a missing tuple index introduced in #3027. Added the index and also wrapped everything in a try/catch to prevent future issues. --- cookbook/views/views.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cookbook/views/views.py b/cookbook/views/views.py index fd4f8e4769..58ffdf736c 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -296,16 +296,21 @@ def system(request): from django.db import connection - postgres_ver = divmod(connection.pg_version, 10000) - if postgres_ver >= postgres_current: - database_status = 'success' - database_message = _('Everything is fine!') - elif postgres_ver < postgres_current - 2: + try: + postgres_ver = divmod(connection.pg_version, 10000)[0] + if postgres_ver >= postgres_current: + database_status = 'success' + database_message = _('Everything is fine!') + elif postgres_ver < postgres_current - 2: + database_status = 'danger' + database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver} + else: + database_status = 'info' + database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current} + except Exception as e: + print(f"Error determining PostgreSQL version: {e}") database_status = 'danger' - database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver} - else: - database_status = 'info' - database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current} + database_message = _('Unable to determine PostgreSQL version.') else: database_status = 'info' database_message = _( From 87327b0959e1072e5606335d2ec196ea91cf2b4e Mon Sep 17 00:00:00 2001 From: jorge Teixeira Date: Sat, 23 Mar 2024 15:45:34 +0000 Subject: [PATCH 18/34] Translated using Weblate (Portuguese (Brazil)) Currently translated at 84.5% (481 of 569 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt_BR/ --- vue/src/locales/pt_BR.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vue/src/locales/pt_BR.json b/vue/src/locales/pt_BR.json index f24ab1273b..bd34a7e87e 100644 --- a/vue/src/locales/pt_BR.json +++ b/vue/src/locales/pt_BR.json @@ -415,7 +415,7 @@ "plan_share_desc": "Novo Plano de Refeição será automaticamente compartilhado com os usuários selecionados.", "Create Recipe": "Criar Receita", "Import Recipe": "Importar Receita", - "App": "App", + "App": "Aplicação", "Default_Unit": "Unidade Padrão", "Use_Fractions": "Usar Frações", "Use_Fractions_Help": "Automaticamente converter decimais para frações quando visualizando uma receita.", @@ -501,5 +501,6 @@ "Alignment": "Alinhamento", "Auto_Sort_Help": "Mover todos os ingredientes para o passo mais indicado.", "Ingredient Overview": "Ingredientes - Visão Geral", - "Cosmetic": "Aparência" + "Cosmetic": "Aparência", + "err_importing_recipe": "Não existem erros ao importar a receita!" } From b67281bbc88458f74cd863a33ccc021a71c75eaf Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 25 Mar 2024 20:27:33 +0100 Subject: [PATCH 19/34] fixed property helper calculation --- cookbook/helper/property_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookbook/helper/property_helper.py b/cookbook/helper/property_helper.py index 3cc2f2ecf9..8ec0593522 100644 --- a/cookbook/helper/property_helper.py +++ b/cookbook/helper/property_helper.py @@ -71,7 +71,7 @@ def calculate_recipe_properties(self, recipe): # TODO move to central helper ? --> use defaultdict @staticmethod def add_or_create(d, key, value, food): - if key in d: + if key in d and d[key]['value']: d[key]['value'] += value else: d[key] = {'id': food.id, 'food': food.name, 'value': value} From 441c55936d895a31cd1d28697e0973fd21183090 Mon Sep 17 00:00:00 2001 From: Mikhail Epifanov Date: Tue, 26 Mar 2024 22:46:04 +0100 Subject: [PATCH 20/34] use normal async client for api calls --- cookbook/connectors/homeassistant.py | 62 ++++++++++++++++------------ requirements.txt | 2 +- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/cookbook/connectors/homeassistant.py b/cookbook/connectors/homeassistant.py index e653c3f3de..bdd52c8b10 100644 --- a/cookbook/connectors/homeassistant.py +++ b/cookbook/connectors/homeassistant.py @@ -1,26 +1,33 @@ import logging from logging import Logger +from typing import Dict +from urllib.parse import urljoin -from homeassistant_api import Client, HomeassistantAPIError, Domain +from aiohttp import ClientError, request from cookbook.connectors.connector import Connector from cookbook.models import ShoppingListEntry, ConnectorConfig, Space class HomeAssistant(Connector): - _domains_cache: dict[str, Domain] _config: ConnectorConfig _logger: Logger - _client: Client def __init__(self, config: ConnectorConfig): if not config.token or not config.url or not config.todo_entity: raise ValueError("config for HomeAssistantConnector in incomplete") - self._domains_cache = dict() self._config = config self._logger = logging.getLogger("connector.HomeAssistant") - self._client = Client(self._config.url, self._config.token, async_cache_session=False, use_async=True) + + async def send_api_call(self, method: str, path: str, data: Dict) -> str: + headers = { + "Authorization": f"Bearer {self._config.token}", + "Content-Type": "application/json" + } + async with request(method, urljoin(self._config.url, path), headers=headers, json=data) as response: + response.raise_for_status() + return await response.json() async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None: if not self._config.on_shopping_list_entry_created_enabled: @@ -28,15 +35,17 @@ async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry item, description = _format_shopping_list_entry(shopping_list_entry) - todo_domain = self._domains_cache.get('todo') - try: - if todo_domain is None: - todo_domain = await self._client.async_get_domain('todo') - self._domains_cache['todo'] = todo_domain + logging.debug(f"adding {item=} to {self._config.name}") + + data = { + "entity_id": self._config.todo_entity, + "item": item, + "description": description, + } - logging.debug(f"pushing {item} to {self._config.name}") - await todo_domain.add_item(entity_id=self._config.todo_entity, item=item) - except HomeassistantAPIError as err: + try: + await self.send_api_call("POST", "services/todo/add_item", data) + except ClientError as err: self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}") async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None: @@ -48,21 +57,22 @@ async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry if not self._config.on_shopping_list_entry_deleted_enabled: return - item, description = _format_shopping_list_entry(shopping_list_entry) + item, _ = _format_shopping_list_entry(shopping_list_entry) - todo_domain = self._domains_cache.get('todo') - try: - if todo_domain is None: - todo_domain = await self._client.async_get_domain('todo') - self._domains_cache['todo'] = todo_domain + logging.debug(f"removing {item=} from {self._config.name}") + + data = { + "entity_id": self._config.todo_entity, + "item": item, + } - logging.debug(f"deleting {item} from {self._config.name}") - await todo_domain.remove_item(entity_id=self._config.todo_entity, item=item) - except HomeassistantAPIError as err: + try: + await self.send_api_call("POST", "services/todo/remove_item", data) + except ClientError as err: self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}") async def close(self) -> None: - await self._client.async_cache_session.close() + pass def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry): @@ -76,10 +86,10 @@ def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry): else: item += ")" - description = "Imported by TandoorRecipes" + description = "From TandoorRecipes" if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0: - description += f", created by {shopping_list_entry.created_by.first_name}" + description += f", by {shopping_list_entry.created_by.first_name}" else: - description += f", created by {shopping_list_entry.created_by.username}" + description += f", by {shopping_list_entry.created_by.username}" return item, description diff --git a/requirements.txt b/requirements.txt index 1c49620761..3ef2dd2b62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ django-auth-ldap==4.6.0 pyppeteer==2.0.0 validators==0.20.0 pytube==15.0.0 -homeassistant-api==4.1.1.post2 +aiohttp==3.9.3 # Development pytest==8.0.0 From 41a448578aceb4244b1ca80b1e4cabc205a93856 Mon Sep 17 00:00:00 2001 From: Mikhail Epifanov Date: Tue, 26 Mar 2024 23:21:23 +0100 Subject: [PATCH 21/34] extra check if we arent accidently doing a query --- cookbook/connectors/homeassistant.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/cookbook/connectors/homeassistant.py b/cookbook/connectors/homeassistant.py index bdd52c8b10..f824a4262a 100644 --- a/cookbook/connectors/homeassistant.py +++ b/cookbook/connectors/homeassistant.py @@ -1,6 +1,6 @@ import logging from logging import Logger -from typing import Dict +from typing import Dict, Tuple from urllib.parse import urljoin from aiohttp import ClientError, request @@ -17,10 +17,12 @@ def __init__(self, config: ConnectorConfig): if not config.token or not config.url or not config.todo_entity: raise ValueError("config for HomeAssistantConnector in incomplete") + if config.url[-1] != "/": + config.url += "/" self._config = config self._logger = logging.getLogger("connector.HomeAssistant") - async def send_api_call(self, method: str, path: str, data: Dict) -> str: + async def homeassistant_api_call(self, method: str, path: str, data: Dict) -> str: headers = { "Authorization": f"Bearer {self._config.token}", "Content-Type": "application/json" @@ -44,7 +46,7 @@ async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry } try: - await self.send_api_call("POST", "services/todo/add_item", data) + await self.homeassistant_api_call("POST", "services/todo/add_item", data) except ClientError as err: self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}") @@ -57,6 +59,11 @@ async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry if not self._config.on_shopping_list_entry_deleted_enabled: return + if not hasattr(shopping_list_entry._state.fields_cache, "food"): + # Sometimes the food foreign key is not loaded, and we cant load it from an async process + self._logger.debug("required property was not present in ShoppingListEntry") + return + item, _ = _format_shopping_list_entry(shopping_list_entry) logging.debug(f"removing {item=} from {self._config.name}") @@ -67,15 +74,16 @@ async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry } try: - await self.send_api_call("POST", "services/todo/remove_item", data) + await self.homeassistant_api_call("POST", "services/todo/remove_item", data) except ClientError as err: - self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}") + # This error will always trigger if the item is not present/found + self._logger.debug(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}") async def close(self) -> None: pass -def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry): +def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry) -> Tuple[str, str]: item = shopping_list_entry.food.name if shopping_list_entry.amount > 0: item += f" ({shopping_list_entry.amount:.2f}".rstrip('0').rstrip('.') From be388b0d10ac6a3b067f0a9512c87725a7aa8e91 Mon Sep 17 00:00:00 2001 From: Axel Breiterman Date: Tue, 26 Mar 2024 18:14:36 +0000 Subject: [PATCH 22/34] Translated using Weblate (Spanish) Currently translated at 53.1% (258 of 485 strings) Translation: Tandoor/Recipes Backend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/es/ --- cookbook/locale/es/LC_MESSAGES/django.po | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/cookbook/locale/es/LC_MESSAGES/django.po b/cookbook/locale/es/LC_MESSAGES/django.po index c217d727c8..408afd2ddf 100644 --- a/cookbook/locale/es/LC_MESSAGES/django.po +++ b/cookbook/locale/es/LC_MESSAGES/django.po @@ -14,8 +14,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-03-21 14:39+0100\n" -"PO-Revision-Date: 2023-09-25 09:59+0000\n" -"Last-Translator: Matias Laporte \n" +"PO-Revision-Date: 2024-03-27 19:02+0000\n" +"Last-Translator: Axel Breiterman \n" "Language-Team: Spanish \n" "Language: es\n" @@ -23,7 +23,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.15\n" +"X-Generator: Weblate 5.4.2\n" #: .\cookbook\forms.py:45 msgid "" @@ -97,14 +97,16 @@ msgid "" "Long Lived Access Token for your HomeAssistant instance" msgstr "" +"Token de larga duraciónpara tu instancia de HomeAssistant" #: .\cookbook\forms.py:193 msgid "Something like http://homeassistant.local:8123/api" -msgstr "" +msgstr "Algo similar a http://homeassistant.local:8123/api" #: .\cookbook\forms.py:205 msgid "http://homeassistant.local:8123/api for example" -msgstr "" +msgstr "por ejemplo http://homeassistant.local:8123/api for example" #: .\cookbook\forms.py:222 .\cookbook\views\edit.py:117 msgid "Storage" @@ -279,7 +281,7 @@ msgstr "Ha alcanzado el número máximo de recetas para su espacio." #: .\cookbook\helper\permission_helper.py:414 msgid "You have more users than allowed in your space." -msgstr "" +msgstr "Tenés mas usuarios que los permitidos en tu espacio" #: .\cookbook\helper\recipe_url_import.py:304 #, fuzzy @@ -309,7 +311,7 @@ msgstr "fermentar" #: .\cookbook\helper\recipe_url_import.py:310 msgid "sous-vide" -msgstr "" +msgstr "sous-vide" #: .\cookbook\helper\shopping_helper.py:150 msgid "You must supply a servings size" @@ -318,7 +320,7 @@ msgstr "Debe proporcionar un tamaño de porción" #: .\cookbook\helper\template_helper.py:95 #: .\cookbook\helper\template_helper.py:97 msgid "Could not parse template code." -msgstr "" +msgstr "No se pudo parsear el código de la planitlla." #: .\cookbook\integration\copymethat.py:44 #: .\cookbook\integration\melarecipes.py:37 @@ -342,6 +344,8 @@ msgid "" "An unexpected error occurred during the import. Please make sure you have " "uploaded a valid file." msgstr "" +"Ocurrió un error inesperado al importar. Por favor asegurate de haber subido " +"un archivo válido." #: .\cookbook\integration\integration.py:217 msgid "The following recipes were ignored because they already existed:" @@ -457,7 +461,7 @@ msgstr "Calorías" #: .\cookbook\migrations\0190_auto_20230525_1506.py:20 msgid "kcal" -msgstr "" +msgstr "kcal" #: .\cookbook\models.py:325 msgid "" From ad8d8daf7986a917b46669ed4a10a860826f9ac9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Mar 2024 06:40:03 +0000 Subject: [PATCH 23/34] Bump express from 4.18.2 to 4.19.2 in /vue Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2) --- updated-dependencies: - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] --- vue/yarn.lock | 48 ++++++++++-------------------------------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/vue/yarn.lock b/vue/yarn.lock index fbbdc4b870..663c77a06a 100644 --- a/vue/yarn.lock +++ b/vue/yarn.lock @@ -3717,25 +3717,7 @@ bn.js@^5.0.0, bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -body-parser@^1.19.0: +body-parser@1.20.2, body-parser@^1.19.0: version "1.20.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== @@ -4511,10 +4493,10 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== copy-concurrently@^1.0.0: version "1.0.5" @@ -5652,16 +5634,16 @@ express-history-api-fallback@^2.2.1: integrity sha512-swxwm3aP8vrOOvlzOdZvHlSZtJGwHKaY94J6AkrAgCTmcbko3IRwbkhLv2wKV1WeZhjxX58aLMpP3atDBnKuZg== express@^4.17.1, express@^4.17.3: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.5.0" + cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -9351,16 +9333,6 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - raw-body@2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" From 959ad2a45cdd15feee775879f06ed2f1233f49f5 Mon Sep 17 00:00:00 2001 From: atom karinca Date: Sun, 31 Mar 2024 15:24:00 +0000 Subject: [PATCH 24/34] Translated using Weblate (Turkish) Currently translated at 33.7% (192 of 569 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/tr/ --- vue/src/locales/tr.json | 122 ++++++++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 43 deletions(-) diff --git a/vue/src/locales/tr.json b/vue/src/locales/tr.json index 05ea7a965e..4da139004c 100644 --- a/vue/src/locales/tr.json +++ b/vue/src/locales/tr.json @@ -4,38 +4,38 @@ "err_creating_resource": "Kaynak oluşturulurken bir hata oluştu!", "err_updating_resource": "Kaynak güncellenirken bir hata oluştu!", "err_deleting_resource": "Kaynak silinirken bir hata oluştu!", - "err_deleting_protected_resource": "", - "err_moving_resource": "", - "err_merging_resource": "", + "err_deleting_protected_resource": "Silmeye çalıştığınız nesne hala kullanılıyor ve silinemedi.", + "err_moving_resource": "Kaynak taşınırken bir hata oluştu!", + "err_merging_resource": "Kaynak birleştirilirken bir hata oluştu!", "success_fetching_resource": "Kaynak başarıyla getirildi!", "success_creating_resource": "Kaynak başarıyla oluşturuldu!", - "success_updating_resource": "", + "success_updating_resource": "Kaynak başarıyla güncellendi!", "success_deleting_resource": "Kaynak başarıyla silindi!", "success_moving_resource": "Kaynak başarıyla taşındı!", "success_merging_resource": "Kaynak başarıyla birleştirildi!", "file_upload_disabled": "Alanınız için dosya yükleme aktif değil.", - "warning_space_delete": "Tüm tarifler, alışveriş listeleri, yemek planları ve oluşturduğunuz her şey dahil olmak üzere silinecektir. Bu geri alınamaz! Bunu yapmak istediğinizden emin misiniz?", - "food_inherit_info": "", + "warning_space_delete": "Tüm tarifleri, alışveriş listelerini, yemek planlarını ve oluşturduğunuz diğer her şeyi içeren alanınızı silebilirsiniz. Bu geri alınamaz! Bunu yapmak istediğinizden emin misiniz?", + "food_inherit_info": "Yiyeceklerdeki öntanımlı olarak aktarılması gereken alanlar.", "step_time_minutes": "Dakika olarak adım süresi", - "confirm_delete": "", - "import_running": "", - "all_fields_optional": "", + "confirm_delete": "Bu {object}'yi silmek istediğinizden emin misiniz?", + "import_running": "İçeri aktarım devam ediyor, lütfen bekleyin!", + "all_fields_optional": "Bütün alanlar tercihe bağlıdır ve boş bırakılabilir.", "convert_internal": "Dahili tarif'e dönüştür", - "show_only_internal": "Sadece dahili tarifler", + "show_only_internal": "Sadece dahili tarifleri göster", "show_split_screen": "Bölünmüş Görünüm", - "Log_Recipe_Cooking": "", - "External_Recipe_Image": "", + "Log_Recipe_Cooking": "Günlük Tarif Pişirme", + "External_Recipe_Image": "Harici Tarif Resim", "Add_to_Shopping": "Alışverişe Ekle", - "Add_to_Plan": "", - "Step_start_time": "", + "Add_to_Plan": "Plana ekle", + "Step_start_time": "Adım başlangıç zamanı", "Sort_by_new": "Yeniye göre sırala", "Table_of_Contents": "İçindekiler Tablosu", "Recipes_per_page": "Sayfa Başına Tarif", - "Show_as_header": "Başlığı Göster", - "Hide_as_header": "Başlığı gizle", - "Add_nutrition_recipe": "", - "Remove_nutrition_recipe": "", - "Copy_template_reference": "", + "Show_as_header": "Başlık olarak göster", + "Hide_as_header": "Başlık olarak gizle", + "Add_nutrition_recipe": "Tarife besin değeri ekle", + "Remove_nutrition_recipe": "Tariften besin değeri sil", + "Copy_template_reference": "Şablon referansını kopyala", "Save_and_View": "Kaydet & Görüntüle", "Manage_Books": "Kitapları Yönet", "Meal_Plan": "Yemek Planı", @@ -44,12 +44,12 @@ "Recipe_Image": "Tarif Resmi", "Import_finished": "İçeriye Aktarma Bitti", "View_Recipes": "Tarifleri Görüntüle", - "Log_Cooking": "", + "Log_Cooking": "Günlük Pişirme", "New_Recipe": "Yeni Tarif", "Url_Import": "Url İçeri Aktar", "Reset_Search": "Aramayı Sıfırla", "Recently_Viewed": "Son Görüntülenen", - "Load_More": "Daha Fazla", + "Load_More": "Daha Fazla Yükle", "New_Keyword": "Yeni Anahtar Kelime", "Delete_Keyword": "Anahtar Kelimeyi Sil", "Edit_Keyword": "Anahtar Kelimeyi Düzenle", @@ -57,20 +57,20 @@ "Move_Keyword": "Anahtar Kelimeyi Taşı", "Merge_Keyword": "Anahtar Kelimeyi Birleştir", "Hide_Keywords": "Anahtar Kelimeyi Gizle", - "Hide_Recipes": "Tarifi Gizle", + "Hide_Recipes": "Tarifleri Gizle", "Move_Up": "Yukarı Taşı", "Move_Down": "Aşağıya Taşı", "Step_Name": "Adım Adı", "Step_Type": "Adım Tipi", - "Make_Header": "", - "Make_Ingredient": "", + "Make_Header": "Başlık Oluştur", + "Make_Ingredient": "Malzeme Oluştur", "Enable_Amount": "Tutarı Etkinleştir", "Disable_Amount": "Tutarı Devre Dışı Bırak", - "Ingredient Editor": "", + "Ingredient Editor": "Malzeme Düzenleyici", "Private_Recipe": "Özel Tarif", - "Private_Recipe_Help": "", - "reusable_help_text": "", - "Add_Step": "", + "Private_Recipe_Help": "Tarif yalnızca size ve paylaştığınız kişilere gösterilir.", + "reusable_help_text": "Davet bağlantısı birden fazla kullanıcı için kullanılabilir olsun mu.", + "Add_Step": "Adım Ekle", "Keywords": "Anahtar Kelimeler", "Books": "Kitaplar", "Proteins": "Proteinler", @@ -78,7 +78,7 @@ "Carbohydrates": "Karbonhidratlar", "Calories": "Kaloriler", "Energy": "Enerji", - "Nutrition": "Besin", + "Nutrition": "Besin Değeri", "Date": "Tarih", "Share": "Paylaş", "Automation": "Otomasyon", @@ -93,18 +93,18 @@ "New": "Yeni", "Note": "Not", "Success": "Başarılı", - "Failure": "Hata", + "Failure": "Başarısız", "Protected": "Korumalı", - "Ingredients": "Mazemeler", + "Ingredients": "Malzemeler", "Supermarket": "Market", "Categories": "Kategoriler", "Category": "Kategori", "Selected": "Seçilen", - "min": "", - "Servings": "", - "Waiting": "", - "Preparation": "", - "External": "", + "min": "min", + "Servings": "Servis Sayısı", + "Waiting": "Bekleniyor", + "Preparation": "Hazırlama", + "External": "Harici", "Size": "Boyut", "Files": "Dosyalar", "File": "Dosya", @@ -112,7 +112,7 @@ "Image": "Resim", "Delete": "Sil", "Open": "Aç", - "Ok": "Aç", + "Ok": "Tamam", "Save": "Kaydet", "Step": "Adım", "Search": "Ara", @@ -121,7 +121,7 @@ "Settings": "Ayarlar", "or": "veya", "and": "ve", - "Information": "bilgi", + "Information": "Bilgi", "Download": "İndir", "Create": "Oluştur", "Search Settings": "Arama Ayarları", @@ -129,10 +129,10 @@ "Recipes": "Tarifler", "Move": "Taşı", "Merge": "Birleştir", - "Parent": "", - "Copy Link": "", - "Copy Token": "", - "delete_confirmation": "", + "Parent": "Üst Öğe", + "Copy Link": "Bağlantıyı Kopyala", + "Copy Token": "Anahtarı Kopyala", + "delete_confirmation": "{source}'ı silmek istediğinizden emin misiniz?", "move_confirmation": "", "merge_confirmation": "", "create_rule": "", @@ -457,5 +457,41 @@ "New_Supermarket": "Yeni Market", "New_Supermarket_Category": "Yeni Market Kategorisi", "Are_You_Sure": "Emin misin?", - "Valid Until": "Geçerlilik Tarihi" + "Valid Until": "Geçerlilik Tarihi", + "err_importing_recipe": "Tarif içeri aktarılırken bir hata oluştu!", + "Data_Import_Info": "Tarif koleksiyonunuzu geliştirmek için topluluk tarafından oluşturulmuş yiyecek, birim ve daha fazlasını olduğu listeleri içeri aktararak Alanlarınızı genişletin.", + "open_data_help_text": "The Tandoor Open Data projesi Tandoor için topluluk tarafından oluşturulmuş verileri sağlar. Bu alan içeri aktarım sırasında otomatik olarak doldurulur ve gelecekte güncellenebilir.", + "Description_Replace": "Açıklama Değiştir", + "FDC_ID": "FDC Kimlik", + "FDC_Search": "FDC Arama", + "FDC_ID_help": "FDC veritabanı Kimlik", + "Auto_Sort": "Otomatik Sırala", + "Welcome": "Hoşgeldiniz", + "recipe_property_info": "Yiyeceklere ayrıca özellikler ekleyebilir ve tarifinize göre bunları otomatik olarak hesaplayabilirsiniz!", + "Amount": "Miktar", + "per_serving": "servis başına", + "Instruction_Replace": "Talimat Değiştir", + "Open_Data_Slug": "Açık Veri Tanım", + "Input": "Giriş", + "Undo": "Geri Al", + "NoMoreUndo": "Yapılacak değişiklik yok.", + "Delete_All": "Tümünü sil", + "Update_Existing_Data": "Mevcut Verileri Güncelleyin", + "Use_Metric": "Metrik Birimler Kullan", + "Learn_More": "Daha Fazla", + "converted_unit": "Dönüştürülmüş Birim", + "converted_amount": "Dönüştürülmüş Miktar", + "base_unit": "Temel Birim", + "Number of Objects": "Nesne Sayısı", + "Datatype": "Veri tipi", + "StartDate": "Başlangıç Tarihi", + "EndDate": "Bitiş Tarihi", + "Auto_Sort_Help": "Tüm malzemeleri en uygun adıma taşı.", + "Open_Data_Import": "Açık Veri İçeri Aktar", + "Properties_Food_Amount": "Özellikler Yiyecek Miktar", + "Properties_Food_Unit": "Özellikler Yiyecek Birim", + "base_amount": "Temel Miktar", + "Calculator": "Hesap Makinesi", + "property_type_fdc_hint": "Yalnızca FDC kimliği olan özellik tipleri FDC veritabanından veri çekebilir", + "Alignment": "Hizalama" } From 4e0780d5124f02dd999a16b75b7c477e434e577f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 00:53:06 +0000 Subject: [PATCH 25/34] Bump recipe-scrapers from 14.52.0 to 14.55.0 Bumps [recipe-scrapers](https://github.com/hhursev/recipe-scrapers) from 14.52.0 to 14.55.0. - [Release notes](https://github.com/hhursev/recipe-scrapers/releases) - [Commits](https://github.com/hhursev/recipe-scrapers/compare/14.52.0...14.55.0) --- updated-dependencies: - dependency-name: recipe-scrapers dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..6c6dfdf9be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ Jinja2==3.1.3 django-webpack-loader==3.0.1 git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82 django-allauth==0.61.1 -recipe-scrapers==14.52.0 +recipe-scrapers==14.55.0 django-scopes==2.0.0 django-treebeard==4.7 django-cors-headers==4.3.1 From 10373b6ac58cc93c9c845f91ce30d1cd37c3736e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 00:53:12 +0000 Subject: [PATCH 26/34] Bump pytest-cov from 4.1.0 to 5.0.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 4.1.0 to 5.0.0. - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v5.0.0) --- updated-dependencies: - dependency-name: pytest-cov dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..f6e17e20a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,7 +48,7 @@ aiohttp==3.9.3 # Development pytest==8.0.0 pytest-django==4.8.0 -pytest-cov===4.1.0 +pytest-cov===5.0.0 pytest-factoryboy==2.6.0 pytest-html==4.1.1 pytest-asyncio==0.23.5 From 768b678c9316a821a7b8f993572507038422903f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 00:53:19 +0000 Subject: [PATCH 27/34] Bump django-crispy-forms from 2.0 to 2.1 Bumps [django-crispy-forms](https://github.com/django-crispy-forms/django-crispy-forms) from 2.0 to 2.1. - [Release notes](https://github.com/django-crispy-forms/django-crispy-forms/releases) - [Changelog](https://github.com/django-crispy-forms/django-crispy-forms/blob/main/CHANGELOG.md) - [Commits](https://github.com/django-crispy-forms/django-crispy-forms/compare/2.0...2.1) --- updated-dependencies: - dependency-name: django-crispy-forms dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..de8f5dcfa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Django==4.2.11 cryptography===42.0.5 django-annoying==0.10.6 django-cleanup==8.0.0 -django-crispy-forms==2.0 +django-crispy-forms==2.1 crispy-bootstrap4==2022.1 django-tables2==2.7.0 djangorestframework==3.14.0 From 4f0f59a55c9dc6ad73e402f8130e8fa3d8da8f2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 00:53:25 +0000 Subject: [PATCH 28/34] Bump django-debug-toolbar from 4.2.0 to 4.3.0 Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/jazzband/django-debug-toolbar/releases) - [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst) - [Commits](https://github.com/jazzband/django-debug-toolbar/compare/4.2...4.3) --- updated-dependencies: - dependency-name: django-debug-toolbar dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..969972043a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ django-tables2==2.7.0 djangorestframework==3.14.0 drf-writable-nested==0.7.0 django-oauth-toolkit==2.3.0 -django-debug-toolbar==4.2.0 +django-debug-toolbar==4.3.0 bleach==6.0.0 gunicorn==21.2.0 lxml==5.1.0 From 5a0b9e14d2af02e6e3f2674f74f80a60af049971 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 00:53:28 +0000 Subject: [PATCH 29/34] Bump beautifulsoup4 from 4.12.2 to 4.12.3 Bumps [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/) from 4.12.2 to 4.12.3. --- updated-dependencies: - dependency-name: beautifulsoup4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..aed1558986 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ whitenoise==6.6.0 icalendar==5.0.11 pyyaml==6.0.1 uritemplate==4.1.1 -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 microdata==0.8.0 mock==5.1.0 Jinja2==3.1.3 From b38ea866b4df61de332a5facccc928b1ccc25628 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 01:02:35 +0000 Subject: [PATCH 30/34] Bump awalsh128/cache-apt-pkgs-action from 1.4.1 to 1.4.2 Bumps [awalsh128/cache-apt-pkgs-action](https://github.com/awalsh128/cache-apt-pkgs-action) from 1.4.1 to 1.4.2. - [Release notes](https://github.com/awalsh128/cache-apt-pkgs-action/releases) - [Commits](https://github.com/awalsh128/cache-apt-pkgs-action/compare/v1.4.1...v1.4.2) --- updated-dependencies: - dependency-name: awalsh128/cache-apt-pkgs-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 853545ceea..6e045b6d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: awalsh128/cache-apt-pkgs-action@v1.4.1 + - uses: awalsh128/cache-apt-pkgs-action@v1.4.2 with: packages: libsasl2-dev python3-dev libldap2-dev libssl-dev version: 1.0 From 30a2012e909835f9f129c53c40ab14d8ea785c54 Mon Sep 17 00:00:00 2001 From: atom karinca Date: Mon, 1 Apr 2024 17:32:39 +0000 Subject: [PATCH 31/34] Translated using Weblate (Turkish) Currently translated at 5.9% (29 of 485 strings) Translation: Tandoor/Recipes Backend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/tr/ --- cookbook/locale/tr/LC_MESSAGES/django.po | 45 ++++++++++++++++-------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/cookbook/locale/tr/LC_MESSAGES/django.po b/cookbook/locale/tr/LC_MESSAGES/django.po index 1dd15200b0..5fdea7ddc8 100644 --- a/cookbook/locale/tr/LC_MESSAGES/django.po +++ b/cookbook/locale/tr/LC_MESSAGES/django.po @@ -11,8 +11,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-03-21 14:39+0100\n" -"PO-Revision-Date: 2024-03-03 23:19+0000\n" -"Last-Translator: M Ugur \n" +"PO-Revision-Date: 2024-04-01 22:04+0000\n" +"Last-Translator: atom karinca \n" "Language-Team: Turkish \n" "Language: tr\n" @@ -20,7 +20,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Weblate 4.15\n" +"X-Generator: Weblate 5.4.2\n" #: .\cookbook\forms.py:45 msgid "" @@ -63,42 +63,48 @@ msgid "" "To prevent duplicates recipes with the same name as existing ones are " "ignored. Check this box to import everything." msgstr "" +"Varolan tariflerden benzer isimli olanlar mükerrerliği engellemek için " +"gözardı edilecektir. Tümünü içeri aktarmak için bu kutucuğu işaretleyin." #: .\cookbook\forms.py:143 msgid "Add your comment: " -msgstr "" +msgstr "Yorum ekleyin: " #: .\cookbook\forms.py:151 msgid "Leave empty for dropbox and enter app password for nextcloud." -msgstr "" +msgstr "Dropbox için boş bırakın ve Nextcloud için uygulama şifresini girin." #: .\cookbook\forms.py:154 msgid "Leave empty for nextcloud and enter api token for dropbox." -msgstr "" +msgstr "Nextcloud için boş bırakın ve Dropbox için API anahtarını girin." #: .\cookbook\forms.py:160 msgid "" "Leave empty for dropbox and enter only base url for nextcloud (/remote." "php/webdav/ is added automatically)" msgstr "" +"Dropbox için boş bırakın ve Nextcloud için yalnızca ana URL'yi " +"girin(/remote.php/webdav/ otomatik olarak eklenir)" #: .\cookbook\forms.py:188 msgid "" "Long Lived Access Token for your HomeAssistant instance" msgstr "" +"HomeAssistant uygulamanız için Uzun Süreli Erişim Anahtarı" #: .\cookbook\forms.py:193 msgid "Something like http://homeassistant.local:8123/api" -msgstr "" +msgstr "Örneğin http://homeassistant.local:8123/api" #: .\cookbook\forms.py:205 msgid "http://homeassistant.local:8123/api for example" -msgstr "" +msgstr "http://homeassistant.local:8123/api örneğin" #: .\cookbook\forms.py:222 .\cookbook\views\edit.py:117 msgid "Storage" -msgstr "" +msgstr "Depolama" #: .\cookbook\forms.py:222 msgid "Active" @@ -106,51 +112,60 @@ msgstr "Aktif" #: .\cookbook\forms.py:226 msgid "Search String" -msgstr "" +msgstr "Arama Sorgusu" #: .\cookbook\forms.py:246 msgid "File ID" -msgstr "" +msgstr "Dosya ID" #: .\cookbook\forms.py:262 msgid "Maximum number of users for this space reached." -msgstr "" +msgstr "Bu alan için maksimum kullanıcı sayısına ulaşıldı." #: .\cookbook\forms.py:268 msgid "Email address already taken!" -msgstr "" +msgstr "Email adresi zaten alınmış!" #: .\cookbook\forms.py:275 msgid "" "An email address is not required but if present the invite link will be sent " "to the user." msgstr "" +"Email adresi zorunlu değildir fakat verilmesi halinde davet linki " +"kullanıcıya gönderilecektir." #: .\cookbook\forms.py:287 msgid "Name already taken." -msgstr "" +msgstr "İsim zaten alınmış." #: .\cookbook\forms.py:298 msgid "Accept Terms and Privacy" -msgstr "" +msgstr "Koşulları ve Gizliliği Onayla" #: .\cookbook\forms.py:332 msgid "" "Determines how fuzzy a search is if it uses trigram similarity matching (e." "g. low values mean more typos are ignored)." msgstr "" +"Trigram benzerlik eşleşmesi kullanılması halinde aramanın ne kadar bulanık " +"olduğunu belirler (ör. düşük değerler daha fazla yazım hatasını gözardı " +"eder)." #: .\cookbook\forms.py:340 msgid "" "Select type method of search. Click here for " "full description of choices." msgstr "" +"Arama tipi metodunu seçin. Seçeneklerin tam açıklamasını görmek için buraya tıklayın." #: .\cookbook\forms.py:341 msgid "" "Use fuzzy matching on units, keywords and ingredients when editing and " "importing recipes." msgstr "" +"Tarifleri düzenlerken ve içeri aktarırken birimler, anahtar kelimeler ve " +"malzemelerde bulanık eşleştirme kullan." #: .\cookbook\forms.py:342 msgid "" From 47bc3cfbe713f5b7d776bf7e944eaf46c75af1d1 Mon Sep 17 00:00:00 2001 From: smilerz Date: Wed, 3 Apr 2024 09:07:09 -0500 Subject: [PATCH 32/34] Update backup.md update compose configuration for pgbackup to include .env file --- docs/system/backup.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/system/backup.md b/docs/system/backup.md index 39f4a302fb..480266a822 100644 --- a/docs/system/backup.md +++ b/docs/system/backup.md @@ -63,6 +63,8 @@ Modify the below to match your environment and add it to your `docker-compose.ym ``` yaml pgbackup: container_name: pgbackup + env_file: + - ./.env environment: BACKUP_KEEP_DAYS: "8" BACKUP_KEEP_MONTHS: "6" From 4390703c0cbd5331c35fa8fb3673db5c67834cb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:22:02 +0000 Subject: [PATCH 33/34] Bump pillow from 10.2.0 to 10.3.0 Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.2.0 to 10.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/10.2.0...10.3.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ef2dd2b62..59bc9da1f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ bleach==6.0.0 gunicorn==21.2.0 lxml==5.1.0 Markdown==3.5.1 -Pillow==10.2.0 +Pillow==10.3.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 requests==2.31.0 From 4293ec77c0b4ee46f4cc6cb3346795a87ceb9ccb Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Thu, 4 Apr 2024 20:49:48 +0200 Subject: [PATCH 34/34] fixed changing categories of shopping items --- vue/src/components/ShoppingLineItem.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue index b11f2ce72c..f95c974109 100644 --- a/vue/src/components/ShoppingLineItem.vue +++ b/vue/src/components/ShoppingLineItem.vue @@ -54,7 +54,7 @@ text-field="name" value-field="id" v-model="food.supermarket_category" - @change="detail_modal_visible = false; updateFoodCategory(food)" + @input="detail_modal_visible = false; updateFoodCategory(food)" >