diff --git a/.env.example b/.env.example index d483f39..9de1943 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,12 @@ DB_ENGINE=mysql # If using SQLite, DB_NAME is the path to the database file DB_NAME_SQLITE=db.sqlite3 +# Test database +# By default, `manage.py test` uses an isolated SQLite test database. +# Set this to true to run tests against the configured DB_ENGINE instead. +USE_CONFIGURED_TEST_DATABASE=false +TEST_DB_NAME=:memory: + # MySQL specific DB_NAME_MYSQL=your_db_name DB_USER=root diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..f0040c7 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,70 @@ +name: Tests & Coverage + +on: + push: + branches: + - development + - master + pull_request: + branches: + - development + - master + +permissions: + contents: read + +jobs: + coverage: + runs-on: ubuntu-latest + + env: + ENVIRONMENT: dev + DB_ENGINE: sqlite + DB_NAME: ":memory:" + SECRET_KEY: ci-test-secret-key-for-coverage-checks-not-for-production + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y default-libmysqlclient-dev pkg-config + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12.4" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Install project dependencies + run: uv sync --dev + + - name: Run tests with coverage + run: uv run coverage run manage.py test --keepdb --no-progress --no-color + + - name: Print coverage summary + run: uv run coverage report --fail-under=80 + + - name: Write coverage XML + run: uv run coverage xml -o coverage.xml + + - name: Check Codecov token + run: | + if [ -z "${{ secrets.CODECOV_TOKEN }}" ]; then + echo "CODECOV_TOKEN is required for coverage uploads." + echo "Add it under GitHub repository Settings > Secrets and variables > Actions." + exit 1 + fi + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + flags: django + name: mealstack-django + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index e864ac3..652ad5b 100644 --- a/.gitignore +++ b/.gitignore @@ -109,12 +109,14 @@ pip-selfcheck.json # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff -.idea/ .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf +.idea/MypyPlugin.xml +.idea/git_toolbox_prj.xml +.idea/misc.xml # AWS User-specific .idea/**/aws.xml @@ -142,8 +144,8 @@ pip-selfcheck.json # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml +.idea/modules.xml +.idea/*.iml # .idea/modules # *.iml # *.ipr @@ -184,8 +186,5 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser -# idea folder, uncomment if you don't need it -.idea - # Ignore uv lock file uv.lock diff --git a/.idea/MealStack.iml b/.idea/MealStack.iml deleted file mode 100644 index f46c513..0000000 --- a/.idea/MealStack.iml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cf69a94..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1cd70f6..02ab843 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,11 +42,11 @@ RUN /root/.local/bin/uv sync # Compile localization files # This will generate .po files if not present and compile .mo files -RUN uv run python manage.py makemessages -a || true # -a: all languages, ignore if no changes -RUN uv run python manage.py compilemessages --ignore "*/site-packages/*" +RUN SECRET_KEY=build-time-only uv run python manage.py makemessages -a || true # -a: all languages, ignore if no changes +RUN SECRET_KEY=build-time-only uv run python manage.py compilemessages --ignore "*/site-packages/*" EXPOSE 8000 # Default command is to run Django, but can override ENTRYPOINT ["uv", "run", "python", "manage.py"] -CMD ["runserver", "0.0.0.0:8000"] \ No newline at end of file +CMD ["runserver", "0.0.0.0:8000"] diff --git a/MealStack/locale/de/LC_MESSAGES/django.po b/MealStack/locale/de/LC_MESSAGES/django.po index d65d311..80c6912 100644 --- a/MealStack/locale/de/LC_MESSAGES/django.po +++ b/MealStack/locale/de/LC_MESSAGES/django.po @@ -3,12 +3,11 @@ # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-23 10:33+0200\n" +"POT-Creation-Date: 2026-08-01 19:30+0200\n" "PO-Revision-Date: 2026-02-06 00:30+0100\n" "Last-Translator: YOUR NAME \n" "Language-Team: German \n" @@ -18,14 +17,24 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: .\MealStack\settings\base.py:128 +#: .\MealStack\settings\base.py:133 msgid "English" msgstr "Englisch" -#: .\MealStack\settings\base.py:129 +#: .\MealStack\settings\base.py:134 msgid "German" msgstr "Deutsch" +#: .\MealStack\templates\admin\ingredient_importer.html:8 +#: .\apps\recipes\models\ingredient.py:7 +msgid "EAN" +msgstr "EAN" + +#: .\MealStack\templates\admin\ingredient_importer.html:10 +#: .\MealStack\templates\admin\recipe_importer.html:10 +msgid "Import" +msgstr "Importieren" + #: .\MealStack\templates\admin\login.html:10 msgid "Login with OAuth" msgstr "Mit OAuth anmelden" @@ -34,38 +43,57 @@ msgstr "Mit OAuth anmelden" msgid "Use internal authentication" msgstr "Interne Anmeldung verwenden" -#: .\MealStack\templates\components\header.html:16 -msgid "Search recipes..." -msgstr "Rezepte suchen..." +#: .\MealStack\templates\admin\recipe_importer.html:8 +#: .\apps\recipes\services\pdf.py:709 +msgid "Recipe URL" +msgstr "Rezept-URL" -#: .\apps\recipes\admin\recipe.py:160 -msgid "Mark selected recipes as published" -msgstr "Ausgewählte Rezepte als veröffentlicht markieren" +#: .\MealStack\templates\base.html:17 +msgid "Skip to content" +msgstr "Zum Inhalt springen" -#: .\apps\recipes\admin\recipe.py:163 -msgid "Published %(count)d recipe(s)." -msgstr "%(count)d Rezept(e) veröffentlicht." +#: .\MealStack\templates\components\footer.html:12 +#: .\MealStack\templates\components\footer.html:22 +msgid "Language selection" +msgstr "Sprachauswahl" -#: .\apps\recipes\admin\recipe.py:165 -msgid "Mark selected recipes as draft" -msgstr "Ausgewählte Rezepte als Entwurf markieren" +#: .\MealStack\templates\components\header.html:10 +msgid "Recipe library" +msgstr "Rezeptbibliothek" -#: .\apps\recipes\admin\recipe.py:168 -msgid "Moved %(count)d recipe(s) to draft." -msgstr "%(count)d Rezept(e) in den Entwurf verschoben." +#: .\MealStack\templates\components\header.html:17 +msgid "Open menu" +msgstr "Menü öffnen" -msgid "Recipes" -msgstr "Rezepte" +#: .\MealStack\templates\components\header.html:37 +#: .\MealStack\templates\components\header.html:47 +#: .\MealStack\templates\components\header.html:55 +msgid "Search recipes..." +msgstr "Rezepte suchen..." -#: .\MealStack\templates\components\header.html:24 +#: .\MealStack\templates\components\header.html:67 +msgid "Search suggestions" +msgstr "Suchvorschläge" + +#: .\MealStack\templates\components\header.html:72 +msgid "Header actions" +msgstr "Header-Aktionen" + +#: .\MealStack\templates\components\header.html:75 +msgid "Admin" +msgstr "Admin" + +#: .\MealStack\templates\components\header.html:77 msgid "Login" msgstr "Anmelden" -#: .\MealStack\templates\components\header.html:25 -msgid "Sign Up" -msgstr "Registrieren" +#: .\MealStack\templates\components\header.html:83 +#: .\MealStack\templates\components\header.html:84 +msgid "Toggle theme" +msgstr "Design wechseln" #: .\MealStack\templates\recipes\components\carousel.html:12 +#: .\apps\recipes\models\recipe_image.py:35 msgid "Image" msgstr "Bild" @@ -77,69 +105,48 @@ msgstr "Vorheriges Bild" msgid "Next image" msgstr "Nächstes Bild" -#: .\MealStack\templates\recipes\recipe_detail.html:25 -msgid "Recipe sections" -msgstr "Rezeptabschnitte" - -#: .\MealStack\templates\recipes\recipe_detail.html:25 +#: .\MealStack\templates\recipes\recipe_detail.html:26 msgid "At a glance" msgstr "Auf einen Blick" -#: .\MealStack\templates\recipes\recipe_detail.html:52 -msgid "Recipe actions" -msgstr "Rezeptaktionen" - -#: .\MealStack\templates\recipes\recipe_detail.html:57 -msgid "Cook mode" -msgstr "Kochmodus" - -#: .\MealStack\templates\recipes\recipe_detail.html:57 -msgid "Exit cook mode" -msgstr "Kochmodus beenden" - -#: .\MealStack\templates\recipes\recipe_detail.html:57 -msgid "Decrease servings" -msgstr "Portionen verringern" - -#: .\MealStack\templates\recipes\recipe_detail.html:57 -msgid "Increase servings" -msgstr "Portionen erhöhen" - -#: .\MealStack\templates\recipes\recipe_detail.html:63 -msgid "Print PDF" -msgstr "PDF drucken" - -#: .\MealStack\templates\recipes\recipe_detail.html:63 -msgid "Share" -msgstr "Teilen" - -#: .\MealStack\templates\recipes\recipe_detail.html:63 -msgid "Link copied" -msgstr "Link kopiert" - -#: .\MealStack\templates\recipes\recipe_detail.html:30 +#: .\MealStack\templates\recipes\recipe_detail.html:31 #: .\MealStack\templates\recipes\recipe_detail.html:75 +#: .\MealStack\templates\recipes\recipe_detail.html:158 +#: .\MealStack\templates\recipes\recipe_detail.html:164 +#: .\MealStack\templates\recipes\recipe_detail.html:176 +#: .\apps\recipes\models\recipe.py:26 .\apps\recipes\services\pdf.py:435 msgid "Servings" msgstr "Portionen" -#: .\MealStack\templates\recipes\recipe_detail.html:34 -#: .\MealStack\templates\recipes\recipe_detail.html:136 +#: .\MealStack\templates\recipes\recipe_detail.html:35 +#: .\MealStack\templates\recipes\recipe_detail.html:212 +#: .\MealStack\templates\recipes\recipe_detail.html:222 +#: .\apps\recipes\models\ingredient.py:31 .\apps\recipes\services\pdf.py:576 +#: .\apps\recipes\services\pdf.py:584 msgid "Ingredients" msgstr "Zutaten" -#: .\MealStack\templates\recipes\recipe_detail.html:38 +#: .\MealStack\templates\recipes\recipe_detail.html:39 msgid "Steps" msgstr "Schritte" -#: .\MealStack\templates\recipes\recipe_detail.html:42 +#: .\MealStack\templates\recipes\recipe_detail.html:43 +#: .\MealStack\templates\recipes\recipe_detail.html:158 msgid "Total time" msgstr "Gesamtzeit" -#: .\MealStack\templates\recipes\recipe_detail.html:47 +#: .\MealStack\templates\recipes\recipe_detail.html:48 msgid "Jump to ingredients" msgstr "Zu den Zutaten springen" -#: .\MealStack\templates\recipes\recipe_detail.html:53 +#: .\MealStack\templates\recipes\recipe_detail.html:54 +#: .\apps\recipes\admin\recipe.py:109 .\apps\recipes\models\recipe.py:20 +#: .\apps\recipes\models\recipe_image.py:30 +#: .\apps\recipes\models\recipe_ingredient_group.py:5 +#: .\apps\recipes\models\recipe_note.py:8 +#: .\apps\recipes\models\recipe_step_group.py:5 +#: .\apps\recipes\models\recipe_tag.py:5 +#: .\apps\recipes\views\recipes_list_view.py:77 msgid "Recipe" msgstr "Rezept" @@ -151,6 +158,7 @@ msgstr "Von %(author)s" #: .\MealStack\templates\recipes\recipe_detail.html:64 #: .\MealStack\templates\recipes\recipe_detail.html:68 +#: .\apps\recipes\models\recipe.py:40 msgid "Source" msgstr "Quelle" @@ -159,10 +167,12 @@ msgid "Recipe facts" msgstr "Rezeptdetails" #: .\MealStack\templates\recipes\recipe_detail.html:79 +#: .\apps\recipes\services\pdf.py:436 msgid "Prep" msgstr "Vorbereitung" #: .\MealStack\templates\recipes\recipe_detail.html:83 +#: .\apps\recipes\services\pdf.py:437 msgid "Cook" msgstr "Kochen" @@ -171,116 +181,274 @@ msgid "Rest" msgstr "Ruhezeit" #: .\MealStack\templates\recipes\recipe_detail.html:91 +#: .\apps\recipes\services\pdf.py:438 .\apps\recipes\services\pdf.py:522 msgid "Total" msgstr "Gesamt" #: .\MealStack\templates\recipes\recipe_detail.html:107 -msgid "Per Serving" -msgstr "Pro Portion" +#: .\MealStack\templates\recipes\recipe_detail.html:215 +#: .\MealStack\templates\recipes\recipe_detail.html:269 +#: .\apps\recipes\admin\recipe.py:58 .\apps\recipes\services\pdf.py:517 +msgid "Nutrition" +msgstr "Nährwerte" -#: .\MealStack\templates\recipes\recipe_detail.html:108 +#: .\MealStack\templates\recipes\recipe_detail.html:110 +#: .\MealStack\templates\recipes\recipe_detail.html:272 msgid "Selected servings" msgstr "Gewählte Portionen" -#: .\MealStack\templates\recipes\recipe_detail.html:111 -#: .\MealStack\templates\recipes\recipe_detail.html:180 +#: .\MealStack\templates\recipes\recipe_detail.html:114 +#: .\MealStack\templates\recipes\recipe_detail.html:277 +#: .\apps\recipes\services\pdf.py:525 msgid "Calories" msgstr "Kalorien" -#: .\MealStack\templates\recipes\recipe_detail.html:115 -#: .\MealStack\templates\recipes\recipe_detail.html:181 +#: .\MealStack\templates\recipes\recipe_detail.html:123 +#: .\MealStack\templates\recipes\recipe_detail.html:286 +#: .\apps\recipes\models\ingredient.py:19 .\apps\recipes\services\pdf.py:530 msgid "Protein" msgstr "Eiweiß" -#: .\MealStack\templates\recipes\recipe_detail.html:119 -#: .\MealStack\templates\recipes\recipe_detail.html:183 +#: .\MealStack\templates\recipes\recipe_detail.html:132 +#: .\MealStack\templates\recipes\recipe_detail.html:304 +#: .\apps\recipes\models\ingredient.py:17 .\apps\recipes\services\pdf.py:540 msgid "Carbs" msgstr "Kohlenhydrate" -#: .\MealStack\templates\recipes\recipe_detail.html:123 -#: .\MealStack\templates\recipes\recipe_detail.html:182 +#: .\MealStack\templates\recipes\recipe_detail.html:141 +#: .\MealStack\templates\recipes\recipe_detail.html:295 +#: .\apps\recipes\models\ingredient.py:15 .\apps\recipes\services\pdf.py:535 msgid "Fat" msgstr "Fett" -#: .\MealStack\templates\recipes\recipe_detail.html:150 -#: .\MealStack\templates\recipes\recipe_detail.html:154 -msgid "No ingredients added yet." -msgstr "Noch keine Zutaten hinzugefügt." +#: .\MealStack\templates\recipes\recipe_detail.html:162 +msgid "Recipe actions" +msgstr "Rezeptaktionen" -#: .\MealStack\templates\recipes\recipe_detail.html:161 -msgid "Notes" -msgstr "Notizen" +#: .\MealStack\templates\recipes\recipe_detail.html:166 +msgid "Decrease servings" +msgstr "Portionen verringern" -#: .\MealStack\templates\recipes\recipe_detail.html:176 -msgid "Nutrition" -msgstr "Nährwerte" +#: .\MealStack\templates\recipes\recipe_detail.html:178 +msgid "Increase servings" +msgstr "Portionen erhöhen" -#: .\MealStack\templates\recipes\recipe_detail.html:184 -msgid "Sugar" -msgstr "Zucker" +#: .\MealStack\templates\recipes\recipe_detail.html:186 +msgid "Exit cook mode" +msgstr "Kochmodus beenden" -#: .\apps\recipes\views\admin_importers_view.py:16 -msgid "Ingredient '%(ingredient_name)s' imported successfully!" -msgstr "Zutat '%(ingredient_name)s' erfolgreich importiert!" +#: .\MealStack\templates\recipes\recipe_detail.html:187 +#: .\MealStack\templates\recipes\recipe_detail.html:189 +msgid "Cook mode" +msgstr "Kochmodus" -#: .\apps\recipes\views\admin_importers_view.py:19 -msgid "EAN %(ean)s not found in OpenFoodFacts." -msgstr "EAN %(ean)s wurde in OpenFoodFacts nicht gefunden." +#: .\MealStack\templates\recipes\recipe_detail.html:198 +msgid "Print PDF" +msgstr "PDF drucken" -#: .\apps\recipes\views\admin_importers_view.py:21 -msgid "Error: %(error)s" -msgstr "Fehler: %(error)s" +#: .\MealStack\templates\recipes\recipe_detail.html:204 +#: .\MealStack\templates\recipes\recipe_detail.html:207 +msgid "Share" +msgstr "Teilen" -#: .\apps\recipes\views\admin_importers_view.py:23 -msgid "Please provide a valid EAN." -msgstr "Bitte eine gültige EAN angeben." +#: .\MealStack\templates\recipes\recipe_detail.html:205 +msgid "Link copied" +msgstr "Link kopiert" -#: .\apps\recipes\views\admin_importers_view.py:32 -msgid "Invalid URL for %(site_name)s. Please use a matching recipe URL." -msgstr "Ungültige URL für %(site_name)s. Bitte eine passende Rezept-URL verwenden." +#: .\MealStack\templates\recipes\recipe_detail.html:211 +msgid "Recipe sections" +msgstr "Rezeptabschnitte" -#: .\apps\recipes\views\admin_importers_view.py:38 -msgid "Recipe '%(recipe_title)s' imported successfully!" -msgstr "Rezept '%(recipe_title)s' erfolgreich importiert!" +#: .\MealStack\templates\recipes\recipe_detail.html:213 +#: .\MealStack\templates\recipes\recipe_detail.html:339 +#: .\apps\recipes\services\pdf.py:625 .\apps\recipes\services\pdf.py:633 +msgid "Method" +msgstr "Anleitung" -#: .\apps\recipes\views\admin_importers_view.py:42 -msgid "Error importing recipe: %(error)s" -msgstr "Fehler beim Importieren des Rezepts: %(error)s" +#: .\MealStack\templates\recipes\recipe_detail.html:214 +#: .\MealStack\templates\recipes\recipe_detail.html:254 +#: .\apps\recipes\services\pdf.py:675 +msgid "Notes" +msgstr "Notizen" -#: .\apps\recipes\views\admin_importers_view.py:44 -msgid "Please provide a valid recipe URL." -msgstr "Bitte eine gültige Rezept-URL angeben." +#: .\MealStack\templates\recipes\recipe_detail.html:243 +#: .\MealStack\templates\recipes\recipe_detail.html:247 +#: .\apps\recipes\services\pdf.py:622 +msgid "No ingredients added yet." +msgstr "Noch keine Zutaten hinzugefügt." + +#: .\MealStack\templates\recipes\recipe_detail.html:313 +#: .\apps\recipes\models\ingredient.py:18 .\apps\recipes\services\pdf.py:545 +msgid "Sugar" +msgstr "Zucker" -#: .\MealStack\templates\recipes\recipe_detail.html:185 +#: .\MealStack\templates\recipes\recipe_detail.html:322 +#: .\apps\recipes\models\ingredient.py:20 .\apps\recipes\services\pdf.py:550 msgid "Salt" msgstr "Salz" -#: .\MealStack\templates\recipes\recipe_detail.html:195 -msgid "Method" -msgstr "Anleitung" - -#: .\MealStack\templates\recipes\recipe_detail.html:209 -#: .\MealStack\templates\recipes\recipe_detail.html:213 +#: .\MealStack\templates\recipes\recipe_detail.html:353 +#: .\MealStack\templates\recipes\recipe_detail.html:357 +#: .\apps\recipes\services\pdf.py:670 msgid "No steps added yet." msgstr "Noch keine Schritte hinzugefügt." #: .\MealStack\templates\recipes\recipe_list.html:4 -#: .\MealStack\templates\recipes\recipe_list.html:13 +#: .\MealStack\templates\recipes\recipe_list.html:17 msgid "All Recipes" msgstr "Alle Rezepte" -#: .\MealStack\templates\recipes\recipe_list.html:27 +#: .\MealStack\templates\recipes\recipe_list.html:15 +msgid "Search results" +msgstr "Suchergebnisse" + +#: .\MealStack\templates\recipes\recipe_list.html:20 +#, python-format +msgid "Showing %(recipe_count)s search result for \"%(query)s\"" +msgid_plural "Showing %(recipe_count)s search results for \"%(query)s\"" +msgstr[0] "Es wird %(recipe_count)s Suchergebnis für \"%(query)s\" angezeigt" +msgstr[1] "" +"Es werden %(recipe_count)s Suchergebnisse für \"%(query)s\" angezeigt" + +#: .\MealStack\templates\recipes\recipe_list.html:29 +msgid "Clear search" +msgstr "Suche löschen" + +#: .\MealStack\templates\recipes\recipe_list.html:44 msgid "Servings:" msgstr "Portionen:" -#: .\MealStack\templates\recipes\recipe_list.html:28 +#: .\MealStack\templates\recipes\recipe_list.html:45 msgid "Total time:" msgstr "Gesamtzeit:" -#: .\MealStack\templates\recipes\recipe_list.html:33 +#: .\MealStack\templates\recipes\recipe_list.html:52 +msgid "No recipes matched your search." +msgstr "Keine Rezepte entsprechen deiner Suche." + +#: .\MealStack\templates\recipes\recipe_list.html:54 msgid "No recipes available." msgstr "Keine Rezepte verfügbar." +#: .\apps\recipes\admin\admin_site.py:13 +msgid "Ingredient Importers" +msgstr "Zutat-Importer" + +#: .\apps\recipes\admin\admin_site.py:27 +msgid "Recipe Scrapers" +msgstr "Rezept-Scraper" + +#: .\apps\recipes\admin\recipe.py:123 +msgid "Timing" +msgstr "Zeitangaben" + +#: .\apps\recipes\admin\recipe.py:145 +msgid "Primary Image" +msgstr "Primärbild" + +#: .\apps\recipes\admin\recipe.py:160 +msgid "Mark selected recipes as published" +msgstr "Ausgewählte Rezepte als veröffentlicht markieren" + +#: .\apps\recipes\admin\recipe.py:163 +#, python-format +msgid "Published %(count)d recipe(s)." +msgstr "%(count)d Rezept(e) veröffentlicht." + +#: .\apps\recipes\admin\recipe.py:165 +msgid "Mark selected recipes as draft" +msgstr "Ausgewählte Rezepte als Entwurf markieren" + +#: .\apps\recipes\admin\recipe.py:168 +#, python-format +msgid "Moved %(count)d recipe(s) to draft." +msgstr "%(count)d Rezept(e) in den Entwurf verschoben." + +#: .\apps\recipes\apps.py:7 .\apps\recipes\models\recipe.py:21 +msgid "Recipes" +msgstr "Rezepte" + +#: .\apps\recipes\migrations\0005_alter_unit_type.py:29 +#: .\apps\recipes\models\unit.py:9 +msgid "Weight" +msgstr "Gewicht" + +#: .\apps\recipes\migrations\0005_alter_unit_type.py:30 +#: .\apps\recipes\models\unit.py:10 +msgid "Volume" +msgstr "Volumen" + +#: .\apps\recipes\migrations\0005_alter_unit_type.py:31 +#: .\apps\recipes\models\unit.py:11 +msgid "Count" +msgstr "Anzahl" + +#: .\apps\recipes\migrations\0005_alter_unit_type.py:34 +#: .\apps\recipes\models\unit.py:15 +msgid "Type" +msgstr "Typ" + +#: .\apps\recipes\models\cuisine.py:7 .\apps\recipes\models\ingredient.py:8 +#: .\apps\recipes\models\recipe_ingredient_group.py:6 +#: .\apps\recipes\models\recipe_step_group.py:6 .\apps\recipes\models\tag.py:6 +#: .\apps\recipes\models\unit.py:14 +msgid "Name" +msgstr "Name" + +#: .\apps\recipes\models\cuisine.py:8 .\apps\recipes\models\ingredient.py:11 +#: .\apps\recipes\models\recipe.py:24 .\apps\recipes\models\tag.py:7 +msgid "Slug" +msgstr "Slug" + +#: .\apps\recipes\models\cuisine.py:11 .\apps\recipes\models\recipe.py:37 +#: .\apps\recipes\views\recipes_list_view.py:79 +msgid "Cuisine" +msgstr "Küche" + +#: .\apps\recipes\models\cuisine.py:12 +msgid "Cuisines" +msgstr "Küchen" + +#: .\apps\recipes\models\ingredient.py:9 +msgid "Generic name" +msgstr "Allgemeiner Name" + +#: .\apps\recipes\models\ingredient.py:10 +msgid "Brand" +msgstr "Marke" + +#: .\apps\recipes\models\ingredient.py:14 +msgid "kcal" +msgstr "kcal" + +#: .\apps\recipes\models\ingredient.py:16 .\apps\recipes\services\pdf.py:558 +msgid "Saturates" +msgstr "Gesättigte Fettsäuren" + +#: .\apps\recipes\models\ingredient.py:23 +msgid "Density" +msgstr "Dichte" + +#: .\apps\recipes\models\ingredient.py:26 .\apps\recipes\models\recipe.py:43 +#: .\apps\recipes\models\recipe_note.py:15 +#, fuzzy +#| msgid "Exported at" +msgid "Created at" +msgstr "Erstellt am" + +#: .\apps\recipes\models\ingredient.py:27 .\apps\recipes\models\recipe.py:44 +#: .\apps\recipes\models\recipe_note.py:16 +#, fuzzy +#| msgid "Exported at" +msgid "Updated at" +msgstr "Aktualisiert am" + +#: .\apps\recipes\models\ingredient.py:30 +#: .\apps\recipes\models\recipe_ingredient.py:5 +#: .\apps\recipes\views\recipes_list_view.py:80 +msgid "Ingredient" +msgstr "Zutat" + #: .\apps\recipes\models\recipe.py:14 msgid "Draft" msgstr "Entwurf" @@ -288,76 +456,278 @@ msgstr "Entwurf" #: .\apps\recipes\models\recipe.py:15 msgid "Published" msgstr "Veröffentlicht" -#: .\apps\recipes\services\pdf.py:120 -msgid "Exported at" -msgstr "Exportiert um" -#: .\apps\recipes\services\pdf.py:121 -msgid "Recipe URL" -msgstr "Rezept-URL" +#: .\apps\recipes\models\recipe.py:23 +msgid "Title" +msgstr "Titel" -#: .\apps\recipes\services\pdf.py:133 -msgid "Tags" -msgstr "Tags" +#: .\apps\recipes\models\recipe.py:28 +msgid "Preparation time" +msgstr "Vorbereitungszeit" -msgid "Recipe library" -msgstr "Rezeptbibliothek" +#: .\apps\recipes\models\recipe.py:29 +msgid "Cooking time" +msgstr "Kochzeit" -msgid "Header actions" -msgstr "Header-Aktionen" +#: .\apps\recipes\models\recipe.py:30 +msgid "Resting time" +msgstr "Ruhezeit" -msgid "Open menu" -msgstr "Menü öffnen" +#: .\apps\recipes\models\recipe.py:41 +msgid "Author" +msgstr "Autor" + +#: .\apps\recipes\models\recipe.py:42 +msgid "Status" +msgstr "Status" + +#: .\apps\recipes\models\recipe_image.py:38 +msgid "Caption" +msgstr "Bildunterschrift" + +#: .\apps\recipes\models\recipe_image.py:39 +msgid "Is primary" +msgstr "Ist primär" + +#: .\apps\recipes\models\recipe_image.py:40 +#: .\apps\recipes\models\recipe_note.py:13 +msgid "Ordering" +msgstr "Sortierung" + +#: .\apps\recipes\models\recipe_image.py:43 +msgid "Recipe Image" +msgstr "Rezeptbild" + +#: .\apps\recipes\models\recipe_image.py:44 +msgid "Recipe Images" +msgstr "Rezeptbilder" + +#: .\apps\recipes\models\recipe_image.py:51 +msgid "Animated recipe images are not supported." +msgstr "Animierte Rezeptbilder werden nicht unterstützt." + +#: .\apps\recipes\models\recipe_ingredient.py:6 +msgid "Quantity" +msgstr "Menge" + +#: .\apps\recipes\models\recipe_ingredient.py:7 +#: .\apps\recipes\models\unit.py:21 +msgid "Unit" +msgstr "Einheit" + +#: .\apps\recipes\models\recipe_ingredient.py:9 +#: .\apps\recipes\models\recipe_step.py:8 +msgid "Group" +msgstr "Gruppe" + +#: .\apps\recipes\models\recipe_ingredient.py:11 +#: .\apps\recipes\models\recipe_ingredient_group.py:8 +#: .\apps\recipes\models\recipe_step.py:5 +#: .\apps\recipes\models\recipe_step_group.py:8 +msgid "Order" +msgstr "Reihenfolge" + +#: .\apps\recipes\models\recipe_ingredient.py:15 +msgid "Recipe Ingredient" +msgstr "Rezeptzutat" + +#: .\apps\recipes\models\recipe_ingredient.py:16 +msgid "Recipe Ingredients" +msgstr "Rezeptzutaten" + +#: .\apps\recipes\models\recipe_ingredient_group.py:12 +msgid "Recipe Ingredient Group" +msgstr "Rezeptzutaten-Gruppe" + +#: .\apps\recipes\models\recipe_ingredient_group.py:13 +msgid "Recipe Ingredient Groups" +msgstr "Rezeptzutaten-Gruppen" + +#: .\apps\recipes\models\recipe_note.py:11 +msgid "Content" +msgstr "Inhalt" + +#: .\apps\recipes\models\recipe_note.py:20 +msgid "Recipe Note" +msgstr "Rezeptnotiz" + +#: .\apps\recipes\models\recipe_note.py:21 +msgid "Recipe Notes" +msgstr "Rezeptnotizen" + +#: .\apps\recipes\models\recipe_nutrition.py:9 +msgid "Total kcal" +msgstr "Gesamt kcal" + +#: .\apps\recipes\models\recipe_nutrition.py:10 +msgid "Total fat" +msgstr "Gesamt Fett" + +#: .\apps\recipes\models\recipe_nutrition.py:11 +msgid "Total saturates" +msgstr "Gesamt gesättigte Fettsäuren" + +#: .\apps\recipes\models\recipe_nutrition.py:12 +msgid "Total carbs" +msgstr "Gesamt Kohlenhydrate" + +#: .\apps\recipes\models\recipe_nutrition.py:13 +msgid "Total sugar" +msgstr "Gesamt Zucker" + +#: .\apps\recipes\models\recipe_nutrition.py:14 +#, fuzzy +#| msgid "Total time" +msgid "Total protein" +msgstr "Gesamt Eiweiß" -msgid "Toggle theme" -msgstr "Design wechseln" +#: .\apps\recipes\models\recipe_nutrition.py:15 +#, fuzzy +#| msgid "Total time" +msgid "Total salt" +msgstr "Gesamt Salz" -msgid "Skip to content" -msgstr "Zum Inhalt springen" +#: .\apps\recipes\models\recipe_nutrition.py:18 +msgid "Per serving kcal" +msgstr "Pro Portion kcal" -msgid "Admin" -msgstr "Admin" +#: .\apps\recipes\models\recipe_nutrition.py:19 +msgid "Per serving fat" +msgstr "Pro Portion Fett" -msgid "Language selection" -msgstr "Sprachauswahl" +#: .\apps\recipes\models\recipe_nutrition.py:20 +msgid "Per serving saturates" +msgstr "Pro Portion gesättigte Fettsäuren" -msgid "Search results" -msgstr "Suchergebnisse" +#: .\apps\recipes\models\recipe_nutrition.py:21 +msgid "Per serving carbs" +msgstr "Pro Portion Kohlenhydrate" -msgid "Search suggestions" -msgstr "Suchvorschläge" +#: .\apps\recipes\models\recipe_nutrition.py:22 +msgid "Per serving sugar" +msgstr "Pro Portion Zucker" + +#: .\apps\recipes\models\recipe_nutrition.py:23 +msgid "Per serving protein" +msgstr "Pro Portion Eiweiß" + +#: .\apps\recipes\models\recipe_nutrition.py:24 +msgid "Per serving salt" +msgstr "Pro Portion Salz" + +#: .\apps\recipes\models\recipe_nutrition.py:27 +msgid "Recipe Nutrition" +msgstr "Rezept-Nährwerte" +#: .\apps\recipes\models\recipe_nutrition.py:28 +msgid "Recipe Nutritions" +msgstr "Rezept-Nährwerte" + +#: .\apps\recipes\models\recipe_step.py:6 +msgid "Description" +msgstr "Beschreibung" + +#: .\apps\recipes\models\recipe_step.py:12 +msgid "Recipe Step" +msgstr "Rezeptschritt" + +#: .\apps\recipes\models\recipe_step.py:13 +msgid "Recipe Steps" +msgstr "Rezeptschritte" + +#: .\apps\recipes\models\recipe_step_group.py:12 +msgid "Recipe Step Group" +msgstr "Rezeptschritt-Gruppe" + +#: .\apps\recipes\models\recipe_step_group.py:13 +msgid "Recipe Step Groups" +msgstr "Rezeptschritt-Gruppen" + +#: .\apps\recipes\models\recipe_tag.py:6 .\apps\recipes\models\tag.py:10 +#: .\apps\recipes\views\recipes_list_view.py:78 msgid "Tag" msgstr "Tag" -msgid "Cuisine" -msgstr "Küche" +#: .\apps\recipes\models\recipe_tag.py:10 +msgid "Recipe Tag" +msgstr "Rezept-Tag" -msgid "Ingredient" -msgstr "Zutat" +#: .\apps\recipes\models\recipe_tag.py:11 +msgid "Recipe Tags" +msgstr "Rezept-Tags" -msgid "Clear search" -msgstr "Suche löschen" +#: .\apps\recipes\models\tag.py:11 +msgid "Tags" +msgstr "Tags" -msgid "No recipes matched your search." -msgstr "Keine Rezepte entsprechen deiner Suche." +#: .\apps\recipes\models\unit.py:17 +msgid "Grams per unit" +msgstr "Gramm pro Einheit" -#: .\MealStack\templates\recipes\recipe_list.html:20 -#, python-format -msgid "Showing %(recipe_count)s search result for \"%(query)s\"" -msgid_plural "Showing %(recipe_count)s search results for \"%(query)s\"" -msgstr[0] "Es wird %(recipe_count)s Suchergebnis für \"%(query)s\" angezeigt" -msgstr[1] "Es werden %(recipe_count)s Suchergebnisse für \"%(query)s\" angezeigt" +#: .\apps\recipes\models\unit.py:18 +msgid "ML per unit" +msgstr "ML pro Einheit" +#: .\apps\recipes\models\unit.py:22 +msgid "Units" +msgstr "Einheiten" + +#: .\apps\recipes\services\pdf.py:520 msgid "Metric" msgstr "Wert" +#: .\apps\recipes\services\pdf.py:521 msgid "Per serving" msgstr "Pro Portion" -msgid "Saturates" -msgstr "Gesättigte Fettsäuren" +#: .\apps\recipes\services\pdf.py:710 +msgid "Exported at" +msgstr "Exportiert um" + +#: .\apps\recipes\views\admin_importers_view.py:17 +#, python-format +msgid "Ingredient '%(ingredient_name)s' imported successfully!" +msgstr "Zutat '%(ingredient_name)s' erfolgreich importiert!" + +#: .\apps\recipes\views\admin_importers_view.py:21 +#, python-format +msgid "EAN %(ean)s not found in OpenFoodFacts." +msgstr "EAN %(ean)s wurde in OpenFoodFacts nicht gefunden." + +#: .\apps\recipes\views\admin_importers_view.py:23 +#, python-format +msgid "Error: %(error)s" +msgstr "Fehler: %(error)s" + +#: .\apps\recipes\views\admin_importers_view.py:25 +msgid "Please provide a valid EAN." +msgstr "Bitte eine gültige EAN angeben." + +#: .\apps\recipes\views\admin_importers_view.py:35 +#, python-format +msgid "Invalid URL for %(site_name)s. Please use a matching recipe URL." +msgstr "" +"Ungültige URL für %(site_name)s. Bitte eine passende Rezept-URL verwenden." + +#: .\apps\recipes\views\admin_importers_view.py:42 +#, python-format +msgid "Recipe '%(recipe_title)s' imported successfully!" +msgstr "Rezept '%(recipe_title)s' erfolgreich importiert!" + +#: .\apps\recipes\views\admin_importers_view.py:46 +#, python-format +msgid "Error importing recipe: %(error)s" +msgstr "Fehler beim Importieren des Rezepts: %(error)s" + +#: .\apps\recipes\views\admin_importers_view.py:48 +msgid "Please provide a valid recipe URL." +msgstr "Bitte eine gültige Rezept-URL angeben." + +#~ msgid "Sign Up" +#~ msgstr "Registrieren" + +#~ msgid "Per Serving" +#~ msgstr "Pro Portion" #~ msgid "Nutrition (per serving)" #~ msgstr "Nährwerte (pro Portion)" - diff --git a/MealStack/settings/base.py b/MealStack/settings/base.py index 8e3d4b4..5ca216f 100644 --- a/MealStack/settings/base.py +++ b/MealStack/settings/base.py @@ -10,7 +10,9 @@ https://docs.djangoproject.com/en/6.0/ref/settings/ """ +import os from pathlib import Path +from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,8 +22,15 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-71khm7gr7ei44b6_#(+@)b)zr)mk8+$nhb%t@l8*)icm1))y(y' +# SECURITY WARNING: keep the secret key used in production secret. +# Set SECRET_KEY in the environment or in the project .env file. +SECRET_KEY = os.getenv("SECRET_KEY") +if not SECRET_KEY: + raise ImproperlyConfigured( + "SECRET_KEY must be set. Generate one with " + "`python -c \"from django.core.management.utils import get_random_secret_key; " + "print(get_random_secret_key())\"` and set it in your .env file." + ) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -73,6 +82,7 @@ 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.request', + 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], @@ -84,6 +94,8 @@ LOGIN_REDIRECT_URL = "/admin/" +TEST_RUNNER = "apps.common.test_runner.StyledProgressTestRunner" + # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases diff --git a/MealStack/settings/prod.py b/MealStack/settings/prod.py index 1846161..2229352 100644 --- a/MealStack/settings/prod.py +++ b/MealStack/settings/prod.py @@ -1,19 +1,35 @@ +import os + +from django.core.exceptions import ImproperlyConfigured + from .base import * + +def required_env(name): + value = os.getenv(name) + if not value: + raise ImproperlyConfigured(f"{name} must be set in production.") + return value + DEBUG = False -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'mealstack', - 'USER': 'user', - 'PASSWORD': 'pass', - 'HOST': 'host', - 'PORT': '3306', - "OPTIONS": { - "init_command": "SET sql_mode='STRICT_TRANS_TABLES'", - }, +SECRET_KEY = required_env("SECRET_KEY") + +if os.getenv("DB_ENGINE") == "sqlite": + DATABASES = {} +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': required_env("DB_NAME_MYSQL"), + 'USER': required_env("DB_USER"), + 'PASSWORD': required_env("DB_PASSWORD"), + 'HOST': required_env("DB_HOST"), + 'PORT': os.getenv("DB_PORT", "3306"), + "OPTIONS": { + "init_command": "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } } -} -ALLOWED_HOSTS = [] \ No newline at end of file +ALLOWED_HOSTS = [] diff --git a/MealStack/settings/profile_selector.py b/MealStack/settings/profile_selector.py index 17757b7..0c81251 100644 --- a/MealStack/settings/profile_selector.py +++ b/MealStack/settings/profile_selector.py @@ -1,4 +1,5 @@ import os +import sys from dotenv import load_dotenv # Load .env from project root @@ -19,6 +20,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", SETTINGS_MODULE) +def env_flag(name): + return os.getenv(name, "").lower() in ("true", "1", "yes") + + +def is_plain_test_command(): + return len(sys.argv) > 1 and sys.argv[1] == "test" + + def apply_env_overrides(): """ Apply environment variable overrides on top of the loaded profile settings. @@ -47,7 +56,17 @@ def apply_env_overrides(): settings.CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in csrf_trusted_origins_env.split(",") if origin.strip()] # DATABASE overrides - db_engine_env = os.getenv("DB_ENGINE") + if is_plain_test_command() and not env_flag("USE_CONFIGURED_TEST_DATABASE"): + settings.DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.getenv("TEST_DB_NAME", ":memory:"), + } + } + db_engine_env = None + else: + db_engine_env = os.getenv("DB_ENGINE") + if db_engine_env: if db_engine_env == "sqlite": settings.DATABASES = { @@ -138,4 +157,4 @@ def apply_env_overrides(): # Call it immediately to apply overrides -apply_env_overrides() \ No newline at end of file +apply_env_overrides() diff --git a/MealStack/templates/admin/ingredient_importer.html b/MealStack/templates/admin/ingredient_importer.html index 1dd8e36..66335c4 100644 --- a/MealStack/templates/admin/ingredient_importer.html +++ b/MealStack/templates/admin/ingredient_importer.html @@ -1,11 +1,12 @@ {% extends "admin/base_site.html" %} +{% load i18n %} {% block content %}

OpenFoodFacts

{% if message %}

{{ message }}

{% endif %}
{% csrf_token %} - + - +
{% endblock %} diff --git a/MealStack/templates/admin/recipe_importer.html b/MealStack/templates/admin/recipe_importer.html index 02f3272..01abde5 100644 --- a/MealStack/templates/admin/recipe_importer.html +++ b/MealStack/templates/admin/recipe_importer.html @@ -1,11 +1,12 @@ {% extends "admin/base_site.html" %} +{% load i18n %} {% block content %}

{{ site_name }}

{% if message %}

{{ message }}

{% endif %}
{% csrf_token %} - + - +
{% endblock %} diff --git a/MealStack/templates/recipes/recipe_detail.html b/MealStack/templates/recipes/recipe_detail.html index a1c88c5..acb9055 100644 --- a/MealStack/templates/recipes/recipe_detail.html +++ b/MealStack/templates/recipes/recipe_detail.html @@ -52,7 +52,6 @@

{% trans "At a glance" %}

{% if recipe.cuisine %}{{ recipe.cuisine.name }}{% endif %} {% trans "Recipe" %} - {% if recipe.status %}{{ recipe.get_status_display }}{% endif %}

{{ recipe.title }}

diff --git a/README.md b/README.md index f9ac069..de27154 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ![Meal Stack stable](https://img.shields.io/docker/v/trickshotmlg/mealstack/latest?label=stable&sort=semver) ![Meal Stack unstable](https://img.shields.io/docker/v/trickshotmlg/mealstack/dev?label=unstable&sort=semver) ![Python](https://img.shields.io/badge/python-3.12.4-blue) +[![Coverage stable](https://img.shields.io/codecov/c/github/TrickShotMLG02/MealStack/master?label=coverage%20stable)](https://codecov.io/gh/TrickShotMLG02/MealStack/tree/master) +[![Coverage unstable](https://img.shields.io/codecov/c/github/TrickShotMLG02/MealStack/development?label=coverage%20unstable)](https://codecov.io/gh/TrickShotMLG02/MealStack/tree/development) ## 📖 Description @@ -97,6 +99,69 @@ Default seed login: - User: `admin` - Password: `admin` +## Tests and coverage + +Most of the time this is enough: + +```bash +uv run python manage.py test --keepdb +``` + +The test runner uses a local SQLite test database by default, even if your `.env` points at MySQL. +That keeps local test runs fast and avoids needing extra database permissions for `test_*` +databases. It also shows a progress bar and a short summary at the end. + +For coverage reports, install the dev dependencies first: + +```bash +uv sync --dev +``` + +Then run: + +```bash +uv run python manage.py test_coverage --keepdb +``` + +This runs the suite once and reports app-only coverage for `apps/`. Tests, migrations, and test +helpers are not counted. + +For a more detailed report, add `--per-test`: + +```bash +uv run python manage.py test_coverage --keepdb --per-test +``` + +This is slower, but useful when checking what a single test actually covers. Each test is measured +against the app files it touched, not against the whole project. + +Common flags: + +| Option | Description | +| --- | --- | +| `--keepdb` | Reuse the test database between runs. Usually worth using locally. | +| `--per-test` | Add the slower per-test touched-file coverage table. | +| `--no-progress` | Hide progress bars. With `manage.py test`, this falls back to Django's dot output. With `test_coverage --per-test`, this falls back to one line per measured test. | +| `--no-color` | Disable colored output. | +| `--configured-database` | For `test_coverage`, use the database from the active Django settings instead of isolated SQLite. | +| `--pattern "test*.py"` | Use a different test discovery pattern. | + +You can limit coverage runs with normal Django test labels: + +```bash +uv run python manage.py test_coverage apps.recipes.tests.test_recipe_list_search --keepdb +uv run python manage.py test_coverage apps.recipes.tests.test_unit_model.UnitModelTests --keepdb --per-test +``` + +Failed and skipped tests are shown in the summary. In `--per-test` mode, one failed test does not +stop the remaining tests from being measured. + +If you really want `manage.py test` to use the database from `.env`, set: + +```bash +USE_CONFIGURED_TEST_DATABASE=true +``` + ## 🐳 Docker Compose The repository includes `docker-compose.yml` for local stack usage. diff --git a/apps/common/backend/auth_backends.py b/apps/common/backend/auth_backends.py index b6003f6..bb5a47c 100644 --- a/apps/common/backend/auth_backends.py +++ b/apps/common/backend/auth_backends.py @@ -9,17 +9,16 @@ def create_user(self, claims): # Use email as username if available user.username = claims.get("email", user.username) - # Give admin access - # TODO: sync roles/permissions from OIDC - user.is_staff = True + # TODO: sync roles/permissions from OIDC claims user.is_superuser = False + user.is_staff = False user.email = claims.get("email", "") user.save() return user def update_user(self, user, claims): - # Update email if it changes user.email = claims.get("email", user.email) + # TODO: sync roles/permissions from OIDC claims user.save() - return user \ No newline at end of file + return user diff --git a/apps/common/test_runner.py b/apps/common/test_runner.py new file mode 100644 index 0000000..3fbbbb3 --- /dev/null +++ b/apps/common/test_runner.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +import os + +from django.core.management.color import color_style, no_style +from django.test.runner import DiscoverRunner + + +class StyledProgressTestRunner(DiscoverRunner): + @classmethod + def add_arguments(cls, parser): + super().add_arguments(parser) + parser.add_argument( + "--no-progress", + action="store_true", + help="Disable the test progress bar.", + ) + + def __init__(self, *args, no_progress=False, no_color=False, **kwargs): + super().__init__(*args, **kwargs) + self.no_progress = no_progress + self.no_color = no_color + self.style = no_style() if no_color else color_style(force_color=True) + self.progress_bar = None + self.emit_progress_events = os.getenv("MEALSTACK_TEST_PROGRESS_EVENTS") == "1" + + def get_resultclass(self): + base_result_class = super().get_resultclass() or unittest.TextTestResult + progress_bar = self.progress_bar + emit_progress_events = self.emit_progress_events + + class ProgressTextTestResult(base_result_class): + def stopTest(self, test): + super().stopTest(test) + if progress_bar is not None: + progress_bar.update(1) + elif emit_progress_events: + print("MEALSTACK_TEST_PROGRESS 1", flush=True) + + return ProgressTextTestResult + + def get_test_runner_kwargs(self): + kwargs = super().get_test_runner_kwargs() + if self._use_progress_bar(): + kwargs["verbosity"] = 0 + return kwargs + + def run_tests(self, test_labels, **kwargs): + self.setup_test_environment() + suite = self.build_suite(test_labels) + databases = self.get_databases(suite) + suite.serialized_aliases = { + alias for alias, serialize in databases.items() if serialize + } + suite.used_aliases = set(databases) + + with self.time_keeper.timed("Total database setup"): + old_config = self.setup_databases( + aliases=databases, + serialized_aliases=suite.serialized_aliases, + ) + + run_failed = False + result = None + captured_stdout = StringIO() + captured_stderr = StringIO() + try: + self.run_checks(databases) + self.progress_bar = self._create_progress_bar(suite) + if self.progress_bar is None: + result = self.run_suite(suite) + else: + with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr): + result = self.run_suite(suite) + if self.progress_bar is not None: + self.progress_bar.close() + except Exception: + run_failed = True + raise + finally: + if self.progress_bar is not None: + self.progress_bar.close() + self.progress_bar = None + try: + with self.time_keeper.timed("Total database teardown"): + self.teardown_databases(old_config) + self.teardown_test_environment() + except Exception: + if not run_failed: + raise + + self.time_keeper.print_results() + if result is not None and self.suite_result(suite, result): + self._write_captured_output(captured_stdout, captured_stderr) + self._print_summary(suite, result) + return self.suite_result(suite, result) + + def _use_progress_bar(self): + return not self.emit_progress_events and not self.no_progress and self.parallel == 0 + + def _create_progress_bar(self, suite): + if not self._use_progress_bar(): + return None + + try: + from tqdm import tqdm + except ImportError: + return None + + return tqdm( + total=suite.countTestCases(), + desc="Running tests", + unit="test", + file=sys.stderr, + leave=False, + ncols=100, + colour=None if self.no_color else "cyan", + ) + + def _print_summary(self, suite, result): + tests_run = result.testsRun + failed = len(result.failures) + len(result.unexpectedSuccesses) + errors = len(result.errors) + skipped = len(result.skipped) + succeeded = max( + tests_run - failed - errors - skipped - len(result.expectedFailures), + 0, + ) + + print(self.style.MIGRATE_HEADING("Test summary"), file=sys.stderr) + print(f"Discovered: {suite.countTestCases()}", file=sys.stderr) + print(f"Executed: {tests_run}", file=sys.stderr) + print(f"Succeeded: {self.style.SUCCESS(str(succeeded))}", file=sys.stderr) + print(f"Failed: {self._style_count(failed, bad=True)}", file=sys.stderr) + print(f"Errors: {self._style_count(errors, bad=True)}", file=sys.stderr) + print(f"Skipped: {self._style_count(skipped, warning=True)}", file=sys.stderr) + + def _write_captured_output(self, captured_stdout, captured_stderr): + output = captured_stdout.getvalue() + captured_stderr.getvalue() + if output: + print(output, file=sys.stderr, end="") + + def _style_count(self, count, *, bad=False, warning=False): + count_text = str(count) + if count == 0: + return self.style.SUCCESS(count_text) + if bad: + return self.style.ERROR(count_text) + if warning: + return self.style.WARNING(count_text) + return count_text diff --git a/apps/common/text_formatting.py b/apps/common/text_formatting.py index 551442e..7c84a17 100644 --- a/apps/common/text_formatting.py +++ b/apps/common/text_formatting.py @@ -2,6 +2,7 @@ import django.utils.text + GERMAN_CHAR_MAP = { "ä": "ae", "ö": "oe", @@ -21,13 +22,12 @@ def normalize_german(text: str) -> str: def slugify(text: str) -> str: """ - Uses slugify to convert text to slug, while applying normalizations + Uses slugify to convert text to an ASCII URL slug. :param text: The text to slugify - :return: The slug with applied normalizations + :return: The generated slug """ - normalized_text = normalize_german(text) + return django.utils.text.slugify(normalize_german(text)) - return django.utils.text.slugify(normalized_text) def time_formatting(delta: timedelta) -> str: """ diff --git a/apps/recipes/admin/admin_site.py b/apps/recipes/admin/admin_site.py index 334de83..76dcc6d 100644 --- a/apps/recipes/admin/admin_site.py +++ b/apps/recipes/admin/admin_site.py @@ -1,5 +1,6 @@ from django.contrib import admin from django.urls import path +from django.utils.translation import gettext_lazy as _ from apps.recipes.views import admin_importers_view @@ -9,7 +10,7 @@ def get_app_list(self, request, context=None): app_list = super().get_app_list(request, context) ingredient_importers_section = { - "name": "Ingredient Importers", + "name": _("Ingredient Importers"), "app_label": "ingredient_importers", "url_path": "/admin/importers/ingredient/", "models": [ @@ -23,7 +24,7 @@ def get_app_list(self, request, context=None): ], } recipe_importers_section = { - "name": "Recipe Scrapers", + "name": _("Recipe Scrapers"), "app_label": "recipe_scrapers", "url_path": "/admin/importers/recipe/", "models": [ @@ -56,7 +57,7 @@ def get_urls(self): ) ] - return custom_importer_urls + urls + custom_urls + return custom_importer_urls + custom_urls + urls my_admin_site = MyAdminSite(name="myadmin") diff --git a/apps/recipes/admin/cuisine.py b/apps/recipes/admin/cuisine.py index 0d5b856..8453ec3 100644 --- a/apps/recipes/admin/cuisine.py +++ b/apps/recipes/admin/cuisine.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from apps.recipes.models import Cuisine diff --git a/apps/recipes/admin/ingredient.py b/apps/recipes/admin/ingredient.py index 5d9272f..d08501f 100644 --- a/apps/recipes/admin/ingredient.py +++ b/apps/recipes/admin/ingredient.py @@ -1,7 +1,8 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from apps.recipes.models import Ingredient @admin.register(Ingredient) class IngredientAdmin(admin.ModelAdmin): - list_display = ['name', 'kcal', 'protein', 'fat', 'carbs', 'salt', 'density'] + list_display = ['name', 'kcal', 'protein', 'fat', 'saturates', 'carbs', 'sugar', 'salt', 'density'] search_fields = ['name'] \ No newline at end of file diff --git a/apps/recipes/admin/recipe.py b/apps/recipes/admin/recipe.py index e6d4f76..e21b196 100644 --- a/apps/recipes/admin/recipe.py +++ b/apps/recipes/admin/recipe.py @@ -50,12 +50,12 @@ class RecipeNutritionInline(NestedTabularInline): model = RecipeNutrition can_delete = False readonly_fields = [ - 'total_kcal', 'total_protein', 'total_fat', 'total_carbs', 'total_salt', - 'per_serving_kcal', 'per_serving_protein', 'per_serving_fat', 'per_serving_carbs', 'per_serving_salt' + 'total_kcal', 'total_protein', 'total_fat', 'total_carbs', 'total_saturates', 'total_sugar', 'total_salt', + 'per_serving_kcal', 'per_serving_protein', 'per_serving_fat', 'per_serving_carbs', 'per_serving_saturates', 'per_serving_sugar', 'per_serving_salt' ] max_num = 1 extra = 0 - verbose_name_plural = "Nutrition" + verbose_name_plural = _("Nutrition") classes = ("collapse",) class RecipeTagsInline(NestedTabularInline): @@ -106,7 +106,7 @@ class RecipeAdmin(NestedModelAdmin): search_fields = ['title', 'source', 'author'] fieldsets = ( ( - "Recipe", + _("Recipe"), { "fields": ( "title", @@ -120,7 +120,7 @@ class RecipeAdmin(NestedModelAdmin): }, ), ( - "Timing", + _("Timing"), { "fields": ( ("preparation_time", "cooking_time", "resting_time"), @@ -142,7 +142,7 @@ def primary_image_preview(self, obj): ) return "-" - primary_image_preview.short_description = "Primary Image" + primary_image_preview.short_description = _("Primary Image") inlines = [ RecipeImageInline, diff --git a/apps/recipes/admin/tag.py b/apps/recipes/admin/tag.py index 657ef0a..108caec 100644 --- a/apps/recipes/admin/tag.py +++ b/apps/recipes/admin/tag.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from apps.recipes.models import Tag @admin.register(Tag) diff --git a/apps/recipes/admin/unit.py b/apps/recipes/admin/unit.py index ce01287..15e159a 100644 --- a/apps/recipes/admin/unit.py +++ b/apps/recipes/admin/unit.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from apps.recipes.models import Unit @admin.register(Unit) diff --git a/apps/recipes/apps.py b/apps/recipes/apps.py index 57cb06c..e4e8df9 100644 --- a/apps/recipes/apps.py +++ b/apps/recipes/apps.py @@ -1,8 +1,10 @@ from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class RecipesConfig(AppConfig): name = "apps.recipes" + verbose_name = _("Recipes") def ready(self): import apps.recipes.signals \ No newline at end of file diff --git a/apps/recipes/importers/ingredients/openfoodfacts.py b/apps/recipes/importers/ingredients/openfoodfacts.py index 7a1d0a9..32aa2c2 100644 --- a/apps/recipes/importers/ingredients/openfoodfacts.py +++ b/apps/recipes/importers/ingredients/openfoodfacts.py @@ -40,7 +40,7 @@ def import_ingredient(self, ean: str = "") -> Ingredient: fat = nutriments.get('fat_100g', 0) saturated_fat = nutriments.get('saturated-fat_100g', 0) carbs = nutriments.get('carbohydrates_100g', 0) - sugar = nutriments.get('sugar_100g', 0) + sugar = nutriments.get('sugars_100g', 0) proteins = nutriments.get('proteins_100g', 0) salt = nutriments.get('salt_100g', 0) diff --git a/apps/recipes/importers/recipes/base.py b/apps/recipes/importers/recipes/base.py index 9fcdc0b..2aefb87 100644 --- a/apps/recipes/importers/recipes/base.py +++ b/apps/recipes/importers/recipes/base.py @@ -1,6 +1,7 @@ import re from abc import ABC, abstractmethod from datetime import timedelta +from urllib.parse import urlparse import requests from django.core.files.base import ContentFile @@ -28,6 +29,31 @@ ) from apps.recipes.services.nutrition import update_recipe_nutrition + +class RecipeImageImportError(ValueError): + pass + + +def _hostname_matches_base_domain(hostname: str | None, base_domain: str | None) -> bool: + if not base_domain: + return True + if not hostname: + return False + + normalized_hostname = hostname.rstrip(".").casefold() + normalized_base_domain = base_domain.rstrip(".").casefold() + return normalized_hostname == normalized_base_domain or normalized_hostname.endswith(f".{normalized_base_domain}") + + +def _validate_image_url(url: str, base_domain: str | None) -> None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise RecipeImageImportError("Recipe image URL must use HTTP or HTTPS.") + + if not _hostname_matches_base_domain(parsed.hostname, base_domain): + raise RecipeImageImportError("Recipe image URL is outside the importer domain.") + + class BaseRecipeImporter(ABC): """ Abstract base class for all recipe importers. @@ -93,9 +119,15 @@ def to_json(self) -> str: pass @staticmethod - def attach_image(recipe, image_url, *, primary=False, ordering=0): + def attach_image(recipe, image_url, *, primary=False, ordering=0, base_domain=None): + _validate_image_url(image_url, base_domain) response = requests.get(image_url, timeout=10) response.raise_for_status() + _validate_image_url(response.url, base_domain) + + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().casefold() + if not content_type.startswith("image/"): + raise RecipeImageImportError("Recipe image response must have an image content type.") filename = image_url.split("/")[-1].split("?")[0] @@ -112,6 +144,7 @@ class BaseRecipeScraperImporter(BaseRecipeImporter, ABC): default_ingredient_group_name = "Main" default_step_group_name = "Method" url_placeholder = "https://example.com/recipe" + base_domain = "" def __init__(self, url: str): self.url = url @@ -235,7 +268,13 @@ def _attach_image(self, recipe: Recipe) -> None: if image_url: try: - BaseRecipeImporter.attach_image(recipe, image_url, primary=True, ordering=0) + BaseRecipeImporter.attach_image( + recipe, + image_url, + primary=True, + ordering=0, + base_domain=self.base_domain, + ) except Exception: pass @@ -245,7 +284,7 @@ def import_recipe(self) -> Recipe: source=self.url, defaults={ "title": title, - "servings": self._parse_servings(self.scraper.yields(), fallback=1), + "servings": self._parse_servings(self._safe_text("yields"), fallback=1), "preparation_time": timedelta(minutes=self._safe_minutes("prep_time")), "cooking_time": timedelta(minutes=self._safe_minutes("cook_time")), "resting_time": timedelta(0), diff --git a/apps/recipes/importers/recipes/bbcgoodfood.py b/apps/recipes/importers/recipes/bbcgoodfood.py index 8879eb5..2bce4a5 100644 --- a/apps/recipes/importers/recipes/bbcgoodfood.py +++ b/apps/recipes/importers/recipes/bbcgoodfood.py @@ -4,6 +4,7 @@ @register_recipe_importer( url_path="recipe/bbc-good-food/", + base_domain="bbcgoodfood.com", url_patterns=( r"^https?://(?:www\.)?bbcgoodfood\.com/recipes/[^/?#]+/?(?:[?#].*)?$", ), diff --git a/apps/recipes/importers/recipes/chefkoch.py b/apps/recipes/importers/recipes/chefkoch.py index ad50233..ab603bb 100644 --- a/apps/recipes/importers/recipes/chefkoch.py +++ b/apps/recipes/importers/recipes/chefkoch.py @@ -6,6 +6,7 @@ @register_recipe_importer( url_path="recipe/chefkoch/", + base_domain="chefkoch.de", url_patterns=( r"^https?://(?:www\.)?chefkoch\.de/rezepte/\d+/.+$", ), diff --git a/apps/recipes/importers/recipes/epicurious.py b/apps/recipes/importers/recipes/epicurious.py index a821981..a660bea 100644 --- a/apps/recipes/importers/recipes/epicurious.py +++ b/apps/recipes/importers/recipes/epicurious.py @@ -4,6 +4,7 @@ @register_recipe_importer( url_path="recipe/epicurious/", + base_domain="epicurious.com", url_patterns=( r"^https?://(?:www\.)?epicurious\.com/recipes/food/views/[^/?#]+/?(?:[?#].*)?$", ), diff --git a/apps/recipes/importers/recipes/registry.py b/apps/recipes/importers/recipes/registry.py index 5589d75..be24232 100644 --- a/apps/recipes/importers/recipes/registry.py +++ b/apps/recipes/importers/recipes/registry.py @@ -14,6 +14,7 @@ class RecipeImporterSpec: importer_cls: type url_placeholder: str url_patterns: tuple[str, ...] + base_domain: str def matches_url(self, url: str) -> bool: normalized_url = (url or "").strip() @@ -27,12 +28,14 @@ def register_recipe_importer( *, url_path: str, url_patterns: tuple[str, ...], + base_domain: str, name: str | None = None, url_placeholder: str | None = None, ): def decorator(importer_cls: type): importer_name = name or getattr(importer_cls, "site_name", importer_cls.__name__.removesuffix("Importer")) importer_placeholder = url_placeholder or getattr(importer_cls, "url_placeholder", "") + importer_cls.base_domain = base_domain _RECIPE_IMPORTERS.append( RecipeImporterSpec( name=importer_name, @@ -40,6 +43,7 @@ def decorator(importer_cls: type): importer_cls=importer_cls, url_placeholder=importer_placeholder, url_patterns=url_patterns, + base_domain=base_domain, ) ) return importer_cls diff --git a/apps/recipes/management/commands/seed_tags.py b/apps/recipes/management/commands/seed_tags.py index 8a47b12..63675e0 100644 --- a/apps/recipes/management/commands/seed_tags.py +++ b/apps/recipes/management/commands/seed_tags.py @@ -11,7 +11,6 @@ def get_seed_name(self): return "Tags" def seed(self, *args, **kwargs): - Tag.objects.all().delete() for name in TAG_CATALOG: Tag.objects.update_or_create( name=name, diff --git a/apps/recipes/management/commands/strip_upload_metadata.py b/apps/recipes/management/commands/strip_upload_metadata.py new file mode 100644 index 0000000..9817873 --- /dev/null +++ b/apps/recipes/management/commands/strip_upload_metadata.py @@ -0,0 +1,78 @@ +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from PIL import UnidentifiedImageError + +from apps.recipes.models import RecipeImage +from apps.recipes.services.image_metadata import AnimatedImageError, strip_image_metadata + + +class Command(BaseCommand): + help = "Strip metadata from existing uploaded recipe images." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Report affected files without rewriting them.", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + checked = 0 + stripped_count = 0 + unchanged = 0 + missing = 0 + skipped = 0 + + for recipe_image in RecipeImage.objects.exclude(image="").iterator(): + checked += 1 + image_field = recipe_image.image + storage = image_field.storage + name = image_field.name + + if not storage.exists(name): + missing += 1 + self.stderr.write(f"Missing file: {name}") + continue + + try: + with storage.open(name, "rb") as image_file: + result = strip_image_metadata(image_file, name) + except AnimatedImageError: + skipped += 1 + self.stderr.write(f"Skipped animated image: {name}") + continue + except UnidentifiedImageError: + skipped += 1 + self.stderr.write(f"Skipped unsupported image: {name}") + continue + + if not result.had_metadata: + unchanged += 1 + continue + + stripped_count += 1 + if dry_run: + self.stdout.write(f"Would strip metadata: {name}") + continue + + result.content.seek(0) + try: + storage_path = storage.path(name) + except NotImplementedError: + storage.delete(name) + storage.save(name, ContentFile(result.content.read())) + else: + with open(storage_path, "wb") as target: + target.write(result.content.read()) + + self.stdout.write(f"Stripped metadata: {name}") + + summary = ( + f"Checked {checked} image(s): {stripped_count} stripped, " + f"{unchanged} unchanged, {missing} missing, {skipped} skipped." + ) + if dry_run: + summary = f"Dry run complete. {summary}" + + self.stdout.write(self.style.SUCCESS(summary)) diff --git a/apps/recipes/management/commands/test_coverage.py b/apps/recipes/management/commands/test_coverage.py new file mode 100644 index 0000000..2505707 --- /dev/null +++ b/apps/recipes/management/commands/test_coverage.py @@ -0,0 +1,635 @@ +from __future__ import annotations + +import os +import re +import subprocess +import sys +import threading +import unittest +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass +from fnmatch import fnmatch +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory + +from django.conf import settings +from django.core.management.color import color_style +from django.core.management.base import BaseCommand, CommandError +from django.db import connections +from django.test.runner import DiscoverRunner + + +APP_SOURCE = ["apps"] +APP_OMIT = [ + "*/tests/*", + "*/migrations/*", + "*/test_runner.py", + "*/management/commands/test_coverage.py", +] + + +@dataclass(frozen=True) +class CoverageResult: + label: str + status: str + scope: str + statements: int + missing: int + covered: int + percent: float + executed: int + succeeded: int + failed: int + errors: int + skipped: int + + +@dataclass(frozen=True) +class TestSummary: + discovered: int + executed: int + succeeded: int + failed: int + errors: int + skipped: int + + +class ProgressDiscoverRunner(DiscoverRunner): + def __init__(self, *args, progress_bar=None, **kwargs): + self.progress_bar = progress_bar + super().__init__(*args, **kwargs) + + def get_resultclass(self): + base_result_class = super().get_resultclass() or unittest.TextTestResult + progress_bar = self.progress_bar + + class ProgressTextTestResult(base_result_class): + def stopTest(self, test): + super().stopTest(test) + if progress_bar is not None: + progress_bar.update(1) + + return ProgressTextTestResult + + +class Command(BaseCommand): + help = ( + "Run the Django test suite and report app-only coverage overall and " + "for each discovered test." + ) + + def add_arguments(self, parser): + parser.add_argument( + "test_labels", + nargs="*", + help="Optional test labels. Defaults to all discovered tests.", + ) + parser.add_argument( + "--pattern", + default="test*.py", + help="Test discovery pattern. Defaults to test*.py.", + ) + parser.add_argument( + "--keepdb", + action="store_true", + help="Preserve the test database between runs.", + ) + parser.add_argument( + "--per-test", + action="store_true", + help="Also run each discovered test individually for touched-file coverage.", + ) + parser.add_argument( + "--configured-database", + action="store_true", + help=( + "Use the database configured by the active settings instead " + "of the default temporary SQLite test database." + ), + ) + parser.add_argument( + "--no-progress", + action="store_true", + help="Disable progress bars and print line-based progress instead.", + ) + + def handle(self, *args, **options): + self.no_color_output = options["no_color"] + if not options["no_color"]: + self.style = color_style(force_color=True) + + try: + import coverage + except ImportError as exc: + raise CommandError( + "The coverage package is required. Install dev dependencies " + "or run `uv sync --dev`." + ) from exc + + labels = options["test_labels"] + pattern = options["pattern"] + keepdb = options["keepdb"] + include_per_test = options["per_test"] + use_progress = not options["no_progress"] + tqdm = self._get_tqdm(use_progress) + + if not options["configured_database"]: + self._use_sqlite_test_database() + + discovered_tests = self._discover_test_ids(labels, pattern) + if not discovered_tests: + raise CommandError("No tests were discovered.") + + self.stdout.write(f"Discovered {len(discovered_tests)} test(s).") + + with TemporaryDirectory(prefix="mealstack-coverage-") as coverage_dir: + overall_progress = self._progress_bar( + tqdm, + total=len(discovered_tests), + desc="Overall coverage", + use_progress=use_progress, + ) + overall = self._run_with_coverage( + coverage, + labels, + "overall", + coverage_dir, + coverage_scope="app", + pattern=pattern, + keepdb=keepdb, + progress_bar=overall_progress, + configured_database=options["configured_database"], + ) + if overall_progress is not None: + overall_progress.close() + + per_test_results = [] + if include_per_test: + per_test_progress = self._progress_bar( + tqdm, + total=len(discovered_tests), + desc="Per-test coverage", + use_progress=use_progress, + ) + for index, test_label in enumerate(discovered_tests, start=1): + if not use_progress: + self.stdout.write( + f"[{index}/{len(discovered_tests)}] Measuring {test_label}" + ) + result = self._run_with_coverage( + coverage, + [test_label], + test_label, + coverage_dir, + coverage_scope="touched-files", + pattern=pattern, + keepdb=keepdb, + progress_bar=None, + ) + per_test_results.append(result) + if per_test_progress is not None: + per_test_progress.update(1) + if per_test_progress is not None: + per_test_progress.close() + + if include_per_test: + self._print_results("Per-test touched-file coverage", per_test_results) + self.stdout.write("") + + self._print_results("Overall app coverage", [overall]) + self.stdout.write("") + if include_per_test: + self._print_summary( + "Per-test execution summary", + self._summarize_results(discovered_tests, per_test_results), + ) + else: + self._print_summary( + "Execution summary", + self._summarize_results(discovered_tests, [overall]), + ) + + def _discover_test_ids(self, labels, pattern): + runner = DiscoverRunner( + verbosity=0, + interactive=False, + pattern=pattern, + ) + suite = runner.build_suite(test_labels=labels) + return sorted(test.id() for test in self._iter_test_cases(suite)) + + def _use_sqlite_test_database(self): + databases = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + } + settings.DATABASES = connections.configure_settings(databases) + connections.close_all() + connections.settings = settings.DATABASES + connections._settings = settings.DATABASES + if hasattr(connections._connections, "default"): + delattr(connections._connections, "default") + + def _iter_test_cases(self, suite): + for item in suite: + if isinstance(item, unittest.TestSuite): + yield from self._iter_test_cases(item) + else: + yield item + + def _get_tqdm(self, use_progress): + if not use_progress: + return None + try: + from tqdm import tqdm + except ImportError as exc: + raise CommandError( + "The tqdm package is required for progress bars. Install dev " + "dependencies with `uv sync --dev`, or pass `--no-progress`." + ) from exc + return tqdm + + def _progress_bar(self, tqdm, *, total, desc, use_progress): + if not use_progress: + return None + return tqdm( + total=total, + desc=desc, + unit="test", + file=sys.stderr, + leave=False, + ncols=100, + colour=None if self.no_color_output else "cyan", + ) + + def _run_with_coverage( + self, + coverage_module, + test_labels, + result_label, + coverage_dir, + *, + coverage_scope, + pattern, + keepdb, + progress_bar, + configured_database=False, + ): + if coverage_scope == "app": + return self._run_overall_with_coverage_subprocess( + coverage_module, + test_labels, + result_label, + coverage_dir, + pattern=pattern, + keepdb=keepdb, + configured_database=configured_database, + progress_bar=progress_bar, + ) + + data_file = os.path.join(coverage_dir, ".coverage") + cov = coverage_module.Coverage( + data_file=data_file, + config_file=False, + source=APP_SOURCE, + omit=APP_OMIT, + ) + cov.set_option("run:disable_warnings", ["no-data-collected"]) + + runner = ProgressDiscoverRunner( + verbosity=0, + interactive=False, + pattern=pattern, + keepdb=keepdb, + progress_bar=progress_bar, + ) + + captured_stdout = StringIO() + captured_stderr = StringIO() + coverage_started = False + result = None + try: + cov.start() + coverage_started = True + with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr): + result = self._run_suite(runner, test_labels) + cov.stop() + coverage_started = False + except SystemExit as exc: + self._write_captured_output(captured_stdout, captured_stderr) + raise CommandError( + f"{result_label} exited during test setup or execution " + f"with code {exc.code}." + ) from exc + finally: + if coverage_started: + cov.stop() + cov.save() + + statements, missing = self._coverage_counts(cov, coverage_scope) + + errors = len(result.errors) + failed = len(result.failures) + len(result.unexpectedSuccesses) + skipped = len(result.skipped) + succeeded = ( + result.testsRun + - failed + - errors + - skipped + - len(result.expectedFailures) + ) + status = "passed" + if errors: + status = "error" + elif failed: + status = "failed" + elif skipped == result.testsRun: + status = "skipped" + + covered = statements - missing + percent = (covered / statements * 100) if statements else 100.0 + return CoverageResult( + result_label, + status, + coverage_scope, + statements, + missing, + covered, + percent, + result.testsRun, + max(succeeded, 0), + failed, + errors, + skipped, + ) + + def _run_overall_with_coverage_subprocess( + self, + coverage_module, + test_labels, + result_label, + coverage_dir, + *, + pattern, + keepdb, + configured_database, + progress_bar, + ): + data_file = os.path.join(coverage_dir, ".coverage-overall") + command = [ + sys.executable, + "-m", + "coverage", + "run", + "--data-file", + data_file, + "manage.py", + "test", + "--no-color", + "--pattern", + pattern, + ] + if progress_bar is None: + command.append("--no-progress") + if keepdb: + command.append("--keepdb") + command.extend(test_labels) + + env = os.environ.copy() + if configured_database: + env["USE_CONFIGURED_TEST_DATABASE"] = "true" + else: + env.pop("USE_CONFIGURED_TEST_DATABASE", None) + if progress_bar is not None: + env["MEALSTACK_TEST_PROGRESS_EVENTS"] = "1" + else: + env.pop("MEALSTACK_TEST_PROGRESS_EVENTS", None) + + completed_returncode, output = self._run_coverage_subprocess(command, env, progress_bar) + if completed_returncode: + self.stderr.write(output) + + cov = coverage_module.Coverage( + data_file=data_file, + config_file=False, + source=APP_SOURCE, + omit=APP_OMIT, + ) + cov.load() + statements, missing = self._coverage_counts(cov, "app") + + executed = self._parse_tests_run(output) + skipped = self._parse_skipped(output) + failed = 1 if "FAILED" in output else 0 + errors = 0 + status = "failed" if completed_returncode else "passed" + succeeded = max(executed - failed - errors - skipped, 0) + covered = statements - missing + percent = (covered / statements * 100) if statements else 100.0 + + return CoverageResult( + result_label, + status, + "app", + statements, + missing, + covered, + percent, + executed, + succeeded, + failed, + errors, + skipped, + ) + + def _run_coverage_subprocess(self, command, env, progress_bar): + process = subprocess.Popen( + command, + cwd=Path.cwd(), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + ) + + output_parts = [] + assert process.stdout is not None + assert process.stderr is not None + + def read_stdout(): + for line in process.stdout: + if line.startswith("MEALSTACK_TEST_PROGRESS"): + if progress_bar is not None: + progress_bar.update(1) + else: + output_parts.append(line) + + def read_stderr(): + for line in process.stderr: + output_parts.append(line) + + stdout_thread = threading.Thread(target=read_stdout) + stderr_thread = threading.Thread(target=read_stderr) + stdout_thread.start() + stderr_thread.start() + + returncode = process.wait() + stdout_thread.join() + stderr_thread.join() + if progress_bar is not None and progress_bar.n < progress_bar.total: + progress_bar.update(progress_bar.total - progress_bar.n) + return returncode, "".join(output_parts) + + def _parse_tests_run(self, output): + match = re.search(r"Ran (\d+) tests?", output) + return int(match.group(1)) if match else 0 + + def _parse_skipped(self, output): + match = re.search(r"skipped=(\d+)", output) + return int(match.group(1)) if match else 0 + + def _coverage_counts(self, cov, coverage_scope): + statements = 0 + missing = 0 + for filename in self._coverage_files(cov, coverage_scope): + analysis = cov.analysis2(filename) + executable_statements = analysis[1] + missing_statements = analysis[3] + executed_statements = set(executable_statements) - set(missing_statements) + if coverage_scope == "touched-files" and not executed_statements: + continue + statements += len(executable_statements) + missing += len(missing_statements) + return statements, missing + + def _coverage_files(self, cov, coverage_scope): + if coverage_scope == "touched-files": + return cov.get_data().measured_files() + + project_root = Path.cwd() + files = [] + for source in APP_SOURCE: + source_root = project_root / source + for path in source_root.rglob("*.py"): + normalized = path.as_posix() + if any(fnmatch(normalized, pattern) for pattern in APP_OMIT): + continue + files.append(str(path)) + return files + + def _run_suite(self, runner, test_labels): + runner.setup_test_environment() + suite = runner.build_suite(test_labels) + databases = runner.get_databases(suite) + suite.serialized_aliases = { + alias for alias, serialize in databases.items() if serialize + } + suite.used_aliases = set(databases) + old_config = runner.setup_databases( + aliases=databases, + serialized_aliases=suite.serialized_aliases, + ) + run_failed = False + try: + runner.run_checks(databases) + return runner.run_suite(suite) + except Exception: + run_failed = True + raise + finally: + try: + runner.teardown_databases(old_config) + runner.teardown_test_environment() + except Exception: + if not run_failed: + raise + + def _write_captured_output(self, captured_stdout, captured_stderr): + output = captured_stdout.getvalue() + captured_stderr.getvalue() + if output: + self.stderr.write(output) + + def _print_results(self, title, results): + label_width = max(len("Test"), *(len(result.label) for result in results)) + status_width = max(len("Status"), *(len(result.status) for result in results)) + self.stdout.write(self.style.MIGRATE_HEADING(title)) + self.stdout.write( + f"{'Test'.ljust(label_width)} " + f"{'Status'.ljust(status_width)} " + f"Scope Executed/Statements Coverage" + ) + self.stdout.write( + f"{'-' * label_width} " + f"{'-' * status_width} " + f"------------- ------------------- --------" + ) + for result in results: + executed_statements = ( + f"{result.covered}/{result.statements}" + if result.status == "passed" and result.statements + else "n/a" + ) + coverage = ( + f"{result.percent:.1f}%" + if result.status == "passed" and result.statements + else "n/a" + ) + self.stdout.write( + f"{result.label.ljust(label_width)} " + f"{self._style_status(result.status.ljust(status_width))} " + f"{result.scope.ljust(13)} " + f"{executed_statements.ljust(19)} " + f"{self._style_coverage(coverage, result.percent)}" + ) + + def _summarize_results(self, discovered_tests, results): + return TestSummary( + discovered=len(discovered_tests), + executed=sum(result.executed for result in results), + succeeded=sum(result.succeeded for result in results), + failed=sum(result.failed for result in results), + errors=sum(result.errors for result in results), + skipped=sum(result.skipped for result in results), + ) + + def _print_summary(self, title, summary): + self.stdout.write(self.style.MIGRATE_HEADING(title)) + self.stdout.write(f"Discovered: {summary.discovered}") + self.stdout.write(f"Executed: {summary.executed}") + self.stdout.write(f"Succeeded: {self.style.SUCCESS(str(summary.succeeded))}") + self.stdout.write(f"Failed: {self._style_count(summary.failed, bad=True)}") + self.stdout.write(f"Errors: {self._style_count(summary.errors, bad=True)}") + self.stdout.write(f"Skipped: {self._style_count(summary.skipped, warning=True)}") + + def _style_status(self, status): + stripped_status = status.strip() + if stripped_status == "passed": + return self.style.SUCCESS(status) + if stripped_status == "skipped": + return self.style.WARNING(status) + if stripped_status in {"failed", "error"}: + return self.style.ERROR(status) + return status + + def _style_coverage(self, coverage, percent): + if coverage == "n/a": + return self.style.WARNING(coverage) + if percent >= 80: + return self.style.SUCCESS(coverage) + if percent >= 50: + return self.style.WARNING(coverage) + return self.style.ERROR(coverage) + + def _style_count(self, count, *, bad=False, warning=False): + count_text = str(count) + if count == 0: + return self.style.SUCCESS(count_text) + if bad: + return self.style.ERROR(count_text) + if warning: + return self.style.WARNING(count_text) + return count_text diff --git a/apps/recipes/migrations/0005_alter_unit_type.py b/apps/recipes/migrations/0005_alter_unit_type.py new file mode 100644 index 0000000..34cf3bb --- /dev/null +++ b/apps/recipes/migrations/0005_alter_unit_type.py @@ -0,0 +1,37 @@ +from django.db import migrations, models +from django.utils.translation import gettext_lazy as _ + + +def normalize_unit_type_values(apps, schema_editor): + Unit = apps.get_model("recipes", "Unit") + replacements = { + "UnitType.WEIGHT": "weight", + "UnitType.VOLUME": "volume", + "UnitType.COUNT": "count", + } + for old_value, new_value in replacements.items(): + Unit.objects.filter(type=old_value).update(type=new_value) + + +class Migration(migrations.Migration): + + dependencies = [ + ("recipes", "0004_ingredient_generic_name"), + ] + + operations = [ + migrations.RunPython(normalize_unit_type_values, migrations.RunPython.noop), + migrations.AlterField( + model_name="unit", + name="type", + field=models.CharField( + choices=[ + ("weight", _("Weight")), + ("volume", _("Volume")), + ("count", _("Count")), + ], + max_length=10, + verbose_name=_("Type"), + ), + ), + ] diff --git a/apps/recipes/migrations/0006_alter_cuisine_options_alter_ingredient_options_and_more.py b/apps/recipes/migrations/0006_alter_cuisine_options_alter_ingredient_options_and_more.py new file mode 100644 index 0000000..d6dad51 --- /dev/null +++ b/apps/recipes/migrations/0006_alter_cuisine_options_alter_ingredient_options_and_more.py @@ -0,0 +1,437 @@ +# Generated by Django 6.0.1 on 2026-08-01 17:33 + +import apps.recipes.models.recipe_image +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipes', '0005_alter_unit_type'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cuisine', + options={'ordering': ['name'], 'verbose_name': 'Cuisine', 'verbose_name_plural': 'Cuisines'}, + ), + migrations.AlterModelOptions( + name='ingredient', + options={'verbose_name': 'Ingredient', 'verbose_name_plural': 'Ingredients'}, + ), + migrations.AlterModelOptions( + name='recipe', + options={'verbose_name': 'Recipe', 'verbose_name_plural': 'Recipes'}, + ), + migrations.AlterModelOptions( + name='recipeimage', + options={'verbose_name': 'Recipe Image', 'verbose_name_plural': 'Recipe Images'}, + ), + migrations.AlterModelOptions( + name='recipeingredient', + options={'ordering': ['group__order', 'order'], 'verbose_name': 'Recipe Ingredient', 'verbose_name_plural': 'Recipe Ingredients'}, + ), + migrations.AlterModelOptions( + name='recipeingredientgroup', + options={'ordering': ['order'], 'verbose_name': 'Recipe Ingredient Group', 'verbose_name_plural': 'Recipe Ingredient Groups'}, + ), + migrations.AlterModelOptions( + name='recipenote', + options={'ordering': ['ordering', 'id'], 'verbose_name': 'Recipe Note', 'verbose_name_plural': 'Recipe Notes'}, + ), + migrations.AlterModelOptions( + name='recipenutrition', + options={'verbose_name': 'Recipe Nutrition', 'verbose_name_plural': 'Recipe Nutritions'}, + ), + migrations.AlterModelOptions( + name='recipestep', + options={'ordering': ['order'], 'verbose_name': 'Recipe Step', 'verbose_name_plural': 'Recipe Steps'}, + ), + migrations.AlterModelOptions( + name='recipestepgroup', + options={'ordering': ['order'], 'verbose_name': 'Recipe Step Group', 'verbose_name_plural': 'Recipe Step Groups'}, + ), + migrations.AlterModelOptions( + name='recipetag', + options={'verbose_name': 'Recipe Tag', 'verbose_name_plural': 'Recipe Tags'}, + ), + migrations.AlterModelOptions( + name='tag', + options={'verbose_name': 'Tag', 'verbose_name_plural': 'Tags'}, + ), + migrations.AlterModelOptions( + name='unit', + options={'verbose_name': 'Unit', 'verbose_name_plural': 'Units'}, + ), + migrations.AlterField( + model_name='cuisine', + name='name', + field=models.CharField(max_length=100, unique=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='cuisine', + name='slug', + field=models.SlugField(blank=True, max_length=120, unique=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='ingredient', + name='brand', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Brand'), + ), + migrations.AlterField( + model_name='ingredient', + name='carbs', + field=models.FloatField(default=0, verbose_name='Carbs'), + ), + migrations.AlterField( + model_name='ingredient', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='ingredient', + name='density', + field=models.FloatField(blank=True, null=True, verbose_name='Density'), + ), + migrations.AlterField( + model_name='ingredient', + name='ean', + field=models.CharField(blank=True, max_length=13, null=True, unique=True, verbose_name='EAN'), + ), + migrations.AlterField( + model_name='ingredient', + name='fat', + field=models.FloatField(default=0, verbose_name='Fat'), + ), + migrations.AlterField( + model_name='ingredient', + name='generic_name', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Generic name'), + ), + migrations.AlterField( + model_name='ingredient', + name='kcal', + field=models.FloatField(default=0, verbose_name='kcal'), + ), + migrations.AlterField( + model_name='ingredient', + name='name', + field=models.CharField(max_length=200, verbose_name='Name'), + ), + migrations.AlterField( + model_name='ingredient', + name='protein', + field=models.FloatField(default=0, verbose_name='Protein'), + ), + migrations.AlterField( + model_name='ingredient', + name='salt', + field=models.FloatField(default=0, verbose_name='Salt'), + ), + migrations.AlterField( + model_name='ingredient', + name='saturates', + field=models.FloatField(default=0, verbose_name='Saturates'), + ), + migrations.AlterField( + model_name='ingredient', + name='slug', + field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='ingredient', + name='sugar', + field=models.FloatField(default=0, verbose_name='Sugar'), + ), + migrations.AlterField( + model_name='ingredient', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + migrations.AlterField( + model_name='recipe', + name='author', + field=models.CharField(blank=True, max_length=40, null=True, verbose_name='Author'), + ), + migrations.AlterField( + model_name='recipe', + name='cooking_time', + field=models.DurationField(blank=True, null=True, verbose_name='Cooking time'), + ), + migrations.AlterField( + model_name='recipe', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='recipe', + name='cuisine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recipes.cuisine', verbose_name='Cuisine'), + ), + migrations.AlterField( + model_name='recipe', + name='preparation_time', + field=models.DurationField(blank=True, null=True, verbose_name='Preparation time'), + ), + migrations.AlterField( + model_name='recipe', + name='resting_time', + field=models.DurationField(blank=True, null=True, verbose_name='Resting time'), + ), + migrations.AlterField( + model_name='recipe', + name='servings', + field=models.PositiveIntegerField(default=1, verbose_name='Servings'), + ), + migrations.AlterField( + model_name='recipe', + name='slug', + field=models.SlugField(blank=True, max_length=250, unique=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='recipe', + name='source', + field=models.URLField(blank=True, null=True, verbose_name='Source'), + ), + migrations.AlterField( + model_name='recipe', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20, verbose_name='Status'), + ), + migrations.AlterField( + model_name='recipe', + name='title', + field=models.CharField(max_length=250, verbose_name='Title'), + ), + migrations.AlterField( + model_name='recipe', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + migrations.AlterField( + model_name='recipeimage', + name='caption', + field=models.CharField(blank=True, max_length=255, verbose_name='Caption'), + ), + migrations.AlterField( + model_name='recipeimage', + name='image', + field=models.ImageField(upload_to=apps.recipes.models.recipe_image.recipe_image_upload_to, verbose_name='Image'), + ), + migrations.AlterField( + model_name='recipeimage', + name='is_primary', + field=models.BooleanField(default=False, verbose_name='Is primary'), + ), + migrations.AlterField( + model_name='recipeimage', + name='ordering', + field=models.PositiveIntegerField(default=0, verbose_name='Ordering'), + ), + migrations.AlterField( + model_name='recipeimage', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe', verbose_name='Recipe'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='recipes.recipeingredientgroup', verbose_name='Group'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='ingredient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.ingredient', verbose_name='Ingredient'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Order'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='quantity', + field=models.FloatField(verbose_name='Quantity'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='unit', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='recipes.unit', verbose_name='Unit'), + ), + migrations.AlterField( + model_name='recipeingredientgroup', + name='name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='recipeingredientgroup', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Order'), + ), + migrations.AlterField( + model_name='recipeingredientgroup', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe', verbose_name='Recipe'), + ), + migrations.AlterField( + model_name='recipenote', + name='content', + field=models.TextField(blank=True, null=True, verbose_name='Content'), + ), + migrations.AlterField( + model_name='recipenote', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='recipenote', + name='ordering', + field=models.PositiveIntegerField(default=0, verbose_name='Ordering'), + ), + migrations.AlterField( + model_name='recipenote', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe', verbose_name='Recipe'), + ), + migrations.AlterField( + model_name='recipenote', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_carbs', + field=models.FloatField(default=0, verbose_name='Per serving carbs'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_fat', + field=models.FloatField(default=0, verbose_name='Per serving fat'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_kcal', + field=models.FloatField(default=0, verbose_name='Per serving kcal'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_protein', + field=models.FloatField(default=0, verbose_name='Per serving protein'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_salt', + field=models.FloatField(default=0, verbose_name='Per serving salt'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_saturates', + field=models.FloatField(default=0, verbose_name='Per serving saturates'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='per_serving_sugar', + field=models.FloatField(default=0, verbose_name='Per serving sugar'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_carbs', + field=models.FloatField(default=0, verbose_name='Total carbs'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_fat', + field=models.FloatField(default=0, verbose_name='Total fat'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_kcal', + field=models.FloatField(default=0, verbose_name='Total kcal'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_protein', + field=models.FloatField(default=0, verbose_name='Total protein'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_salt', + field=models.FloatField(default=0, verbose_name='Total salt'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_saturates', + field=models.FloatField(default=0, verbose_name='Total saturates'), + ), + migrations.AlterField( + model_name='recipenutrition', + name='total_sugar', + field=models.FloatField(default=0, verbose_name='Total sugar'), + ), + migrations.AlterField( + model_name='recipestep', + name='description', + field=models.TextField(verbose_name='Description'), + ), + migrations.AlterField( + model_name='recipestep', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipestepgroup', verbose_name='Group'), + ), + migrations.AlterField( + model_name='recipestep', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Order'), + ), + migrations.AlterField( + model_name='recipestepgroup', + name='name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='recipestepgroup', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Order'), + ), + migrations.AlterField( + model_name='recipestepgroup', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe', verbose_name='Recipe'), + ), + migrations.AlterField( + model_name='recipetag', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe', verbose_name='Recipe'), + ), + migrations.AlterField( + model_name='recipetag', + name='tag', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.tag', verbose_name='Tag'), + ), + migrations.AlterField( + model_name='tag', + name='name', + field=models.CharField(max_length=50, unique=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='tag', + name='slug', + field=models.SlugField(blank=True, unique=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='unit', + name='grams_per_unit', + field=models.FloatField(blank=True, null=True, verbose_name='Grams per unit'), + ), + migrations.AlterField( + model_name='unit', + name='ml_per_unit', + field=models.FloatField(blank=True, null=True, verbose_name='ML per unit'), + ), + migrations.AlterField( + model_name='unit', + name='name', + field=models.CharField(max_length=50, unique=True, verbose_name='Name'), + ), + ] diff --git a/apps/recipes/models/cuisine.py b/apps/recipes/models/cuisine.py index 3facd1d..fbc0891 100644 --- a/apps/recipes/models/cuisine.py +++ b/apps/recipes/models/cuisine.py @@ -1,13 +1,15 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.common.text_formatting import slugify class Cuisine(models.Model): - name = models.CharField(max_length=100, unique=True) - slug = models.SlugField(max_length=120, unique=True, blank=True) + name = models.CharField(max_length=100, unique=True, verbose_name=_("Name")) + slug = models.SlugField(max_length=120, unique=True, blank=True, verbose_name=_("Slug")) class Meta: - verbose_name_plural = "Cuisines" + verbose_name = _("Cuisine") + verbose_name_plural = _("Cuisines") ordering = ["name"] def save(self, *args, **kwargs): @@ -16,4 +18,4 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self): - return self.name \ No newline at end of file + return self.name diff --git a/apps/recipes/models/ingredient.py b/apps/recipes/models/ingredient.py index e915f5e..78fad74 100644 --- a/apps/recipes/models/ingredient.py +++ b/apps/recipes/models/ingredient.py @@ -1,30 +1,34 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.common.text_formatting import slugify class Ingredient(models.Model): - ean = models.CharField(max_length=13, unique=True, null=True, blank=True) - name = models.CharField(max_length=200) - generic_name = models.CharField(max_length=200, null=True, blank=True) - slug = models.SlugField(max_length=200, null=True, blank=True) - brand = models.CharField(max_length=200, null=True, blank=True) - slug = models.SlugField(max_length=200, unique=True, blank=True) + ean = models.CharField(max_length=13, unique=True, null=True, blank=True, verbose_name=_("EAN")) + name = models.CharField(max_length=200, verbose_name=_("Name")) + generic_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Generic name")) + brand = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Brand")) + slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name=_("Slug")) # nutrition - kcal = models.FloatField(default=0) - fat = models.FloatField(default=0) - saturates = models.FloatField(default=0) - carbs = models.FloatField(default=0) - sugar = models.FloatField(default=0) - protein = models.FloatField(default=0) - salt = models.FloatField(default=0) + kcal = models.FloatField(default=0, verbose_name=_("kcal")) + fat = models.FloatField(default=0, verbose_name=_("Fat")) + saturates = models.FloatField(default=0, verbose_name=_("Saturates")) + carbs = models.FloatField(default=0, verbose_name=_("Carbs")) + sugar = models.FloatField(default=0, verbose_name=_("Sugar")) + protein = models.FloatField(default=0, verbose_name=_("Protein")) + salt = models.FloatField(default=0, verbose_name=_("Salt")) # grams per ml - density = models.FloatField(null=True, blank=True) + density = models.FloatField(null=True, blank=True, verbose_name=_("Density")) # metadata - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) + + class Meta: + verbose_name = _("Ingredient") + verbose_name_plural = _("Ingredients") def save(self, *args, **kwargs): if not self.slug: @@ -34,4 +38,4 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self): - return self.name \ No newline at end of file + return self.name diff --git a/apps/recipes/models/recipe.py b/apps/recipes/models/recipe.py index 83db001..fe0027b 100644 --- a/apps/recipes/models/recipe.py +++ b/apps/recipes/models/recipe.py @@ -20,27 +20,28 @@ class Meta: verbose_name = _("Recipe") verbose_name_plural = _("Recipes") - title = models.CharField(max_length=250) - slug = models.SlugField(max_length=250, unique=True, blank=True) + title = models.CharField(max_length=250, verbose_name=_("Title")) + slug = models.SlugField(max_length=250, unique=True, blank=True, verbose_name=_("Slug")) - servings = models.PositiveIntegerField(default=1) + servings = models.PositiveIntegerField(default=1, verbose_name=_("Servings")) - preparation_time = models.DurationField(null=True, blank=True) - cooking_time = models.DurationField(null=True, blank=True) - resting_time = models.DurationField(null=True, blank=True) + preparation_time = models.DurationField(null=True, blank=True, verbose_name=_("Preparation time")) + cooking_time = models.DurationField(null=True, blank=True, verbose_name=_("Cooking time")) + resting_time = models.DurationField(null=True, blank=True, verbose_name=_("Resting time")) cuisine = models.ForeignKey( 'Cuisine', on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_("Cuisine"), ) - source = models.URLField(blank=True, null=True) - author = models.CharField(max_length=40, blank=True, null=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + source = models.URLField(blank=True, null=True, verbose_name=_("Source")) + author = models.CharField(max_length=40, blank=True, null=True, verbose_name=_("Author")) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name=_("Status")) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) """ ingredients = models.ManyToManyField( diff --git a/apps/recipes/models/recipe_image.py b/apps/recipes/models/recipe_image.py index 857b91c..e44773b 100644 --- a/apps/recipes/models/recipe_image.py +++ b/apps/recipes/models/recipe_image.py @@ -2,7 +2,12 @@ import uuid from django.db import models +from django.core.exceptions import ValidationError from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from PIL import UnidentifiedImageError + +from apps.recipes.services.image_metadata import AnimatedImageError, strip_image_metadata def recipe_image_upload_to(instance, filename): @@ -22,13 +27,31 @@ class RecipeImage(models.Model): recipe = models.ForeignKey( "Recipe", on_delete=models.CASCADE, + verbose_name=_("Recipe"), ) image = models.ImageField( - #upload_to="recipes/%Y/%m/", upload_to=recipe_image_upload_to, + verbose_name=_("Image"), ) - caption = models.CharField(max_length=255, blank=True) - is_primary = models.BooleanField(default=False) - ordering = models.PositiveIntegerField(default=0) \ No newline at end of file + caption = models.CharField(max_length=255, blank=True, verbose_name=_("Caption")) + is_primary = models.BooleanField(default=False, verbose_name=_("Is primary")) + ordering = models.PositiveIntegerField(default=0, verbose_name=_("Ordering")) + + class Meta: + verbose_name = _("Recipe Image") + verbose_name_plural = _("Recipe Images") + + def save(self, *args, **kwargs): + if self.image and not self.image._committed: + try: + stripped = strip_image_metadata(self.image.file, self.image.name) + except AnimatedImageError as exc: + raise ValidationError(_("Animated recipe images are not supported.")) from exc + except UnidentifiedImageError: + pass + else: + self.image.save(self.image.name, stripped.content, save=False) + + super().save(*args, **kwargs) diff --git a/apps/recipes/models/recipe_ingredient.py b/apps/recipes/models/recipe_ingredient.py index 9995e76..32a532f 100644 --- a/apps/recipes/models/recipe_ingredient.py +++ b/apps/recipes/models/recipe_ingredient.py @@ -1,18 +1,19 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeIngredient(models.Model): - #recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE) - ingredient = models.ForeignKey('Ingredient', on_delete=models.CASCADE) - quantity = models.FloatField() - unit = models.ForeignKey('Unit', on_delete=models.PROTECT) + ingredient = models.ForeignKey('Ingredient', on_delete=models.CASCADE, verbose_name=_("Ingredient")) + quantity = models.FloatField(verbose_name=_("Quantity")) + unit = models.ForeignKey('Unit', on_delete=models.PROTECT, verbose_name=_("Unit")) - group = models.ForeignKey('RecipeIngredientGroup', on_delete=models.CASCADE, null=True, blank=True) + group = models.ForeignKey('RecipeIngredientGroup', on_delete=models.CASCADE, null=True, blank=True, verbose_name=_("Group")) - order = models.PositiveIntegerField(default=0) # optional for display ordering + order = models.PositiveIntegerField(default=0, verbose_name=_("Order")) class Meta: - #unique_together = ('ingredient', 'unit', 'group',) #'recipe') - ordering = ['group__order', 'order'] # order by group first, then item + ordering = ['group__order', 'order'] + verbose_name = _("Recipe Ingredient") + verbose_name_plural = _("Recipe Ingredients") def __str__(self): group_name = f" ({self.group.name})" if self.group else "" diff --git a/apps/recipes/models/recipe_ingredient_group.py b/apps/recipes/models/recipe_ingredient_group.py index 34d8c4b..f66795d 100644 --- a/apps/recipes/models/recipe_ingredient_group.py +++ b/apps/recipes/models/recipe_ingredient_group.py @@ -1,13 +1,16 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeIngredientGroup(models.Model): - recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE) - name = models.CharField(max_length=255, blank=True, null=True) # e.g., "Salad", "Sauce" + recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE, verbose_name=_("Recipe")) + name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Name")) - order = models.PositiveIntegerField(default=0) + order = models.PositiveIntegerField(default=0, verbose_name=_("Order")) class Meta: ordering = ['order'] + verbose_name = _("Recipe Ingredient Group") + verbose_name_plural = _("Recipe Ingredient Groups") def save(self, *args, **kwargs): if not self.name: diff --git a/apps/recipes/models/recipe_note.py b/apps/recipes/models/recipe_note.py index f7fba46..5c85a41 100644 --- a/apps/recipes/models/recipe_note.py +++ b/apps/recipes/models/recipe_note.py @@ -1,20 +1,24 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeNote(models.Model): recipe = models.ForeignKey( "Recipe", on_delete=models.CASCADE, + verbose_name=_("Recipe"), ) - content = models.TextField(blank=True, null=True) + content = models.TextField(blank=True, null=True, verbose_name=_("Content")) - ordering = models.PositiveIntegerField(default=0) + ordering = models.PositiveIntegerField(default=0, verbose_name=_("Ordering")) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) class Meta: ordering = ["ordering", "id"] + verbose_name = _("Recipe Note") + verbose_name_plural = _("Recipe Notes") indexes = [ models.Index(fields=["recipe"]), ] diff --git a/apps/recipes/models/recipe_nutrition.py b/apps/recipes/models/recipe_nutrition.py index bc9feb0..13276e0 100644 --- a/apps/recipes/models/recipe_nutrition.py +++ b/apps/recipes/models/recipe_nutrition.py @@ -1,26 +1,31 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeNutrition(models.Model): recipe = models.OneToOneField('Recipe', on_delete=models.CASCADE, related_name='recipe_nutrition') # total values - total_kcal = models.FloatField(default=0) - total_fat = models.FloatField(default=0) - total_saturates = models.FloatField(default=0) - total_carbs = models.FloatField(default=0) - total_sugar = models.FloatField(default=0) - total_protein = models.FloatField(default=0) - total_salt = models.FloatField(default=0) + total_kcal = models.FloatField(default=0, verbose_name=_("Total kcal")) + total_fat = models.FloatField(default=0, verbose_name=_("Total fat")) + total_saturates = models.FloatField(default=0, verbose_name=_("Total saturates")) + total_carbs = models.FloatField(default=0, verbose_name=_("Total carbs")) + total_sugar = models.FloatField(default=0, verbose_name=_("Total sugar")) + total_protein = models.FloatField(default=0, verbose_name=_("Total protein")) + total_salt = models.FloatField(default=0, verbose_name=_("Total salt")) # per-serving - per_serving_kcal = models.FloatField(default=0) - per_serving_fat = models.FloatField(default=0) - per_serving_saturates = models.FloatField(default=0) - per_serving_carbs = models.FloatField(default=0) - per_serving_sugar = models.FloatField(default=0) - per_serving_protein = models.FloatField(default=0) - per_serving_salt = models.FloatField(default=0) + per_serving_kcal = models.FloatField(default=0, verbose_name=_("Per serving kcal")) + per_serving_fat = models.FloatField(default=0, verbose_name=_("Per serving fat")) + per_serving_saturates = models.FloatField(default=0, verbose_name=_("Per serving saturates")) + per_serving_carbs = models.FloatField(default=0, verbose_name=_("Per serving carbs")) + per_serving_sugar = models.FloatField(default=0, verbose_name=_("Per serving sugar")) + per_serving_protein = models.FloatField(default=0, verbose_name=_("Per serving protein")) + per_serving_salt = models.FloatField(default=0, verbose_name=_("Per serving salt")) + + class Meta: + verbose_name = _("Recipe Nutrition") + verbose_name_plural = _("Recipe Nutritions") def __str__(self): return f"Nutrition for {self.recipe.title}" \ No newline at end of file diff --git a/apps/recipes/models/recipe_step.py b/apps/recipes/models/recipe_step.py index d70e825..cab74f4 100644 --- a/apps/recipes/models/recipe_step.py +++ b/apps/recipes/models/recipe_step.py @@ -1,14 +1,16 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeStep(models.Model): - #recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE, related_name='steps') - order = models.PositiveIntegerField(default=0) - description = models.TextField() + order = models.PositiveIntegerField(default=0, verbose_name=_("Order")) + description = models.TextField(verbose_name=_("Description")) - group = models.ForeignKey('RecipeStepGroup', on_delete=models.CASCADE) + group = models.ForeignKey('RecipeStepGroup', on_delete=models.CASCADE, verbose_name=_("Group")) class Meta: ordering = ['order'] + verbose_name = _("Recipe Step") + verbose_name_plural = _("Recipe Steps") def __str__(self): group_name = f" ({self.group.name})" if self.group else "" diff --git a/apps/recipes/models/recipe_step_group.py b/apps/recipes/models/recipe_step_group.py index 092bcd8..d1bafb5 100644 --- a/apps/recipes/models/recipe_step_group.py +++ b/apps/recipes/models/recipe_step_group.py @@ -1,13 +1,16 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeStepGroup(models.Model): - recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE) - name = models.CharField(max_length=255, blank=True, null=True) # e.g., "Salad", "Sauce" + recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE, verbose_name=_("Recipe")) + name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Name")) - order = models.PositiveIntegerField(default=0) + order = models.PositiveIntegerField(default=0, verbose_name=_("Order")) class Meta: ordering = ['order'] + verbose_name = _("Recipe Step Group") + verbose_name_plural = _("Recipe Step Groups") def save(self, *args, **kwargs): if not self.name: diff --git a/apps/recipes/models/recipe_tag.py b/apps/recipes/models/recipe_tag.py index 087596c..439e6da 100644 --- a/apps/recipes/models/recipe_tag.py +++ b/apps/recipes/models/recipe_tag.py @@ -1,11 +1,14 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class RecipeTag(models.Model): - recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE) - tag = models.ForeignKey('Tag', on_delete=models.CASCADE) + recipe = models.ForeignKey('Recipe', on_delete=models.CASCADE, verbose_name=_("Recipe")) + tag = models.ForeignKey('Tag', on_delete=models.CASCADE, verbose_name=_("Tag")) class Meta: unique_together = ('recipe', 'tag') + verbose_name = _("Recipe Tag") + verbose_name_plural = _("Recipe Tags") def __str__(self): return f"{self.recipe} - {self.tag}" \ No newline at end of file diff --git a/apps/recipes/models/tag.py b/apps/recipes/models/tag.py index c9ada8e..3487f89 100644 --- a/apps/recipes/models/tag.py +++ b/apps/recipes/models/tag.py @@ -1,9 +1,14 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.common.text_formatting import slugify class Tag(models.Model): - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(max_length=50, unique=True, blank=True) + name = models.CharField(max_length=50, unique=True, verbose_name=_("Name")) + slug = models.SlugField(max_length=50, unique=True, blank=True, verbose_name=_("Slug")) + + class Meta: + verbose_name = _("Tag") + verbose_name_plural = _("Tags") def save(self, *args, **kwargs): if not self.slug: diff --git a/apps/recipes/models/unit.py b/apps/recipes/models/unit.py index 335974e..a60eacd 100644 --- a/apps/recipes/models/unit.py +++ b/apps/recipes/models/unit.py @@ -1,20 +1,25 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.recipes import UnitType class Unit(models.Model): TYPE_CHOICES = [ - (UnitType.WEIGHT, 'Weight'), - (UnitType.VOLUME, 'Volume'), - (UnitType.COUNT, 'Count'), + (UnitType.WEIGHT.value, _('Weight')), + (UnitType.VOLUME.value, _('Volume')), + (UnitType.COUNT.value, _('Count')), ] - name = models.CharField(max_length=50, unique=True) - type = models.CharField(max_length=10, choices=TYPE_CHOICES) + name = models.CharField(max_length=50, unique=True, verbose_name=_("Name")) + type = models.CharField(max_length=10, choices=TYPE_CHOICES, verbose_name=_("Type")) - grams_per_unit = models.FloatField(null=True, blank=True) # for weight/count - ml_per_unit = models.FloatField(null=True, blank=True) # for volume + grams_per_unit = models.FloatField(null=True, blank=True, verbose_name=_("Grams per unit")) # for weight/count + ml_per_unit = models.FloatField(null=True, blank=True, verbose_name=_("ML per unit")) # for volume + + class Meta: + verbose_name = _("Unit") + verbose_name_plural = _("Units") def to_grams(self, quantity: float, ingredient=None) -> float: """ @@ -31,4 +36,4 @@ def to_grams(self, quantity: float, ingredient=None) -> float: raise ValueError(f"Unknown unit type: {self.type}") def __str__(self): - return self.name \ No newline at end of file + return self.name diff --git a/apps/recipes/services/image_metadata.py b/apps/recipes/services/image_metadata.py new file mode 100644 index 0000000..ffa1174 --- /dev/null +++ b/apps/recipes/services/image_metadata.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from django.core.files.base import ContentFile +from PIL import Image, ImageOps, UnidentifiedImageError + + +METADATA_KEYS = { + "exif", + "icc_profile", + "xmp", + "XML:com.adobe.xmp", + "photoshop", +} + + +@dataclass(frozen=True) +class ImageMetadataStripResult: + content: ContentFile + had_metadata: bool + format: str + + +class AnimatedImageError(ValueError): + pass + + +def _save_format(filename: str, image_format: str | None) -> str: + if image_format: + return image_format + + extension = Path(filename).suffix.lower() + if extension in {".jpg", ".jpeg"}: + return "JPEG" + if extension == ".png": + return "PNG" + if extension == ".webp": + return "WEBP" + if extension == ".gif": + return "GIF" + return "PNG" + + +def image_has_metadata(image: Image.Image) -> bool: + if image.getexif(): + return True + + return any(key in image.info for key in METADATA_KEYS) + + +def strip_image_metadata(file_obj, filename: str) -> ImageMetadataStripResult: + position = file_obj.tell() if hasattr(file_obj, "tell") else None + try: + file_obj.seek(0) + except (AttributeError, OSError): + pass + + try: + with Image.open(file_obj) as image: + if getattr(image, "is_animated", False) or getattr(image, "n_frames", 1) > 1: + raise AnimatedImageError("Animated image uploads are not supported.") + + had_metadata = image_has_metadata(image) + image_format = _save_format(filename, image.format) + stripped = ImageOps.exif_transpose(image) + + if image_format == "JPEG" and stripped.mode not in {"RGB", "L"}: + stripped = stripped.convert("RGB") + + output = BytesIO() + save_kwargs = {} + if image_format == "JPEG": + save_kwargs.update({"quality": 95, "optimize": True}) + elif image_format == "PNG": + save_kwargs.update({"optimize": True}) + + stripped.save(output, format=image_format, **save_kwargs) + output.seek(0) + return ImageMetadataStripResult( + content=ContentFile(output.read(), name=Path(filename).name), + had_metadata=had_metadata, + format=image_format, + ) + except UnidentifiedImageError: + raise + finally: + if position is not None: + try: + file_obj.seek(position) + except (AttributeError, OSError): + pass diff --git a/apps/recipes/services/nutrition.py b/apps/recipes/services/nutrition.py index 90ceefb..f93719d 100644 --- a/apps/recipes/services/nutrition.py +++ b/apps/recipes/services/nutrition.py @@ -3,11 +3,11 @@ def update_recipe_nutrition(recipe: Recipe) -> RecipeNutrition: """ Calculate total and per-serving nutrition for a recipe. - Assumes each ingredient has kcal, protein, fat, carbs, salt per 100g + Assumes each ingredient has kcal, protein, fat, saturates, carbs, sugar, salt per 100g and units have grams_per_unit or density for volume conversions. Supports multiple IngredientGroups per recipe. """ - total_kcal = total_protein = total_fat = total_carbs = total_salt = 0 + total_kcal = total_protein = total_fat = total_saturates = total_carbs = total_sugar = total_salt = 0 # Get all RecipeIngredient objects via groups ingredients = RecipeIngredient.objects.filter( @@ -34,7 +34,9 @@ def update_recipe_nutrition(recipe: Recipe) -> RecipeNutrition: total_kcal += ingredient.kcal * factor total_protein += ingredient.protein * factor total_fat += ingredient.fat * factor + total_saturates += ingredient.saturates * factor total_carbs += ingredient.carbs * factor + total_sugar += ingredient.sugar * factor total_salt += ingredient.salt * factor # Get or create RecipeNutrition @@ -43,20 +45,26 @@ def update_recipe_nutrition(recipe: Recipe) -> RecipeNutrition: nutrition.total_kcal = total_kcal nutrition.total_protein = total_protein nutrition.total_fat = total_fat + nutrition.total_saturates = total_saturates nutrition.total_carbs = total_carbs + nutrition.total_sugar = total_sugar nutrition.total_salt = total_salt if recipe.servings > 0: nutrition.per_serving_kcal = total_kcal / recipe.servings nutrition.per_serving_protein = total_protein / recipe.servings nutrition.per_serving_fat = total_fat / recipe.servings + nutrition.per_serving_saturates = total_saturates / recipe.servings nutrition.per_serving_carbs = total_carbs / recipe.servings + nutrition.per_serving_sugar = total_sugar / recipe.servings nutrition.per_serving_salt = total_salt / recipe.servings else: nutrition.per_serving_kcal = 0 nutrition.per_serving_protein = 0 nutrition.per_serving_fat = 0 + nutrition.per_serving_saturates = 0 nutrition.per_serving_carbs = 0 + nutrition.per_serving_sugar = 0 nutrition.per_serving_salt = 0 nutrition.save() diff --git a/apps/recipes/tests/__init__.py b/apps/recipes/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/recipes/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/recipes/tests/test_admin_importer_views.py b/apps/recipes/tests/test_admin_importer_views.py new file mode 100644 index 0000000..53372fd --- /dev/null +++ b/apps/recipes/tests/test_admin_importer_views.py @@ -0,0 +1,142 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import RequestFactory, SimpleTestCase + +from apps.recipes.importers.ingredients.base import EANNotFound +from apps.recipes.views import admin_importers_view + + +class AdminImporterViewTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + + def _render_context(self, view, request): + with patch("apps.recipes.views.admin_importers_view.render") as render: + render.side_effect = lambda _request, template, context: SimpleNamespace( + template=template, + context=context, + ) + return view(request) + + def test_ingredient_importer_get_renders_without_message(self): + response = self._render_context( + admin_importers_view.ingredient_importer, + self.factory.get("/admin/importers/ingredient/openfoodfacts/"), + ) + + self.assertEqual(response.template, "admin/ingredient_importer.html") + self.assertIsNone(response.context["message"]) + + def test_ingredient_importer_requires_ean(self): + response = self._render_context( + admin_importers_view.ingredient_importer, + self.factory.post("/admin/importers/ingredient/openfoodfacts/", data={}), + ) + + self.assertEqual(response.context["message"], "Please provide a valid EAN.") + + @patch("apps.recipes.views.admin_importers_view.OpenFoodFactsImporter") + def test_ingredient_importer_success(self, importer_cls): + importer_cls.return_value.import_ingredient.return_value = SimpleNamespace(name="Milk") + + response = self._render_context( + admin_importers_view.ingredient_importer, + self.factory.post("/admin/importers/ingredient/openfoodfacts/", data={"ean": "123"}), + ) + + self.assertEqual(response.context["message"], "Ingredient 'Milk' imported successfully!") + + @patch("apps.recipes.views.admin_importers_view.OpenFoodFactsImporter") + def test_ingredient_importer_handles_missing_ean_and_generic_errors(self, importer_cls): + importer_cls.return_value.import_ingredient.side_effect = EANNotFound("123") + response = self._render_context( + admin_importers_view.ingredient_importer, + self.factory.post("/admin/importers/ingredient/openfoodfacts/", data={"ean": "123"}), + ) + self.assertEqual(response.context["message"], "EAN 123 not found in OpenFoodFacts.") + + importer_cls.return_value.import_ingredient.side_effect = RuntimeError("offline") + response = self._render_context( + admin_importers_view.ingredient_importer, + self.factory.post("/admin/importers/ingredient/openfoodfacts/", data={"ean": "123"}), + ) + self.assertEqual(response.context["message"], "Error: offline") + + def test_recipe_importer_view_get_and_validation_messages(self): + importer_spec = Mock() + importer_spec.name = "Fake" + importer_spec.url_placeholder = "https://example.com/recipe" + importer_spec.matches_url.return_value = False + + response = self._render_context( + admin_importers_view.make_recipe_importer_view(importer_spec), + self.factory.get("/admin/importers/recipe/fake/"), + ) + self.assertEqual(response.template, "admin/recipe_importer.html") + self.assertEqual(response.context["site_name"], "Fake") + self.assertIsNone(response.context["message"]) + + response = self._render_context( + admin_importers_view.make_recipe_importer_view(importer_spec), + self.factory.post("/admin/importers/recipe/fake/", data={}), + ) + self.assertEqual(response.context["message"], "Please provide a valid recipe URL.") + + response = self._render_context( + admin_importers_view.make_recipe_importer_view(importer_spec), + self.factory.post("/admin/importers/recipe/fake/", data={"url": "https://other.test/"}), + ) + self.assertEqual( + response.context["message"], + "Invalid URL for Fake. Please use a matching recipe URL.", + ) + + def test_recipe_importer_view_success_and_error(self): + importer_cls = Mock() + importer_cls.return_value.import_recipe.return_value = SimpleNamespace(title="Cake") + importer_spec = Mock() + importer_spec.name = "Fake" + importer_spec.url_placeholder = "https://example.com/recipe" + importer_spec.matches_url.return_value = True + importer_spec.importer_cls = importer_cls + view = admin_importers_view.make_recipe_importer_view(importer_spec) + + response = self._render_context( + view, + self.factory.post("/admin/importers/recipe/fake/", data={"url": "https://example.com/recipe"}), + ) + self.assertEqual(response.context["message"], "Recipe 'Cake' imported successfully!") + + importer_cls.return_value.import_recipe.side_effect = RuntimeError("offline") + response = self._render_context( + view, + self.factory.post("/admin/importers/recipe/fake/", data={"url": "https://example.com/recipe"}), + ) + self.assertEqual(response.context["message"], "Error importing recipe: offline") + + def test_importer_home_views_build_expected_context(self): + home = self._render_context( + admin_importers_view.importers_home, + self.factory.get("/admin/importers/"), + ) + self.assertEqual(home.context["section_title"], "Importers") + self.assertEqual(len(home.context["importers"]), 2) + + ingredients_home = self._render_context( + admin_importers_view.ingredient_importers_home, + self.factory.get("/admin/importers/ingredient/"), + ) + self.assertEqual(ingredients_home.context["importers"][0]["name"], "OpenFoodFacts") + + recipe_spec = Mock() + recipe_spec.name = "Fake" + recipe_spec.url_path = "recipe/fake/" + with patch("apps.recipes.views.admin_importers_view.get_recipe_importers", return_value=[recipe_spec]): + recipes_home = self._render_context( + admin_importers_view.recipe_importers_home, + self.factory.get("/admin/importers/recipe/"), + ) + + self.assertEqual(recipes_home.context["section_title"], "Recipe Scrapers") + self.assertEqual(recipes_home.context["importers"][0]["url"], "/admin/importers/recipe/fake/") diff --git a/apps/recipes/tests/test_image_metadata.py b/apps/recipes/tests/test_image_metadata.py new file mode 100644 index 0000000..689ad68 --- /dev/null +++ b/apps/recipes/tests/test_image_metadata.py @@ -0,0 +1,126 @@ +from io import BytesIO +from io import StringIO +from pathlib import Path +import shutil +import tempfile + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings +from PIL import Image + +from apps.recipes.models import Recipe, RecipeImage + + +def jpeg_with_exif() -> bytes: + image = Image.new("RGB", (12, 12), color="red") + exif = Image.Exif() + exif[0x010E] = "private description" + + output = BytesIO() + image.save(output, format="JPEG", exif=exif) + return output.getvalue() + + +def animated_gif() -> bytes: + first = Image.new("RGB", (12, 12), color="red") + second = Image.new("RGB", (12, 12), color="blue") + + output = BytesIO() + first.save( + output, + format="GIF", + save_all=True, + append_images=[second], + duration=100, + loop=0, + ) + return output.getvalue() + + +def plain_png() -> bytes: + image = Image.new("RGB", (12, 12), color="green") + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +class RecipeImageMetadataTests(TestCase): + def setUp(self): + self.media_root = tempfile.mkdtemp() + self.settings_override = override_settings(MEDIA_ROOT=self.media_root) + self.settings_override.enable() + + def tearDown(self): + self.settings_override.disable() + shutil.rmtree(self.media_root, ignore_errors=True) + + def test_recipe_image_upload_strips_exif_metadata(self): + recipe = Recipe.objects.create(title="Cake") + upload = SimpleUploadedFile( + "cake.jpg", + jpeg_with_exif(), + content_type="image/jpeg", + ) + + recipe_image = RecipeImage.objects.create(recipe=recipe, image=upload) + + with Image.open(recipe_image.image.path) as saved_image: + self.assertFalse(saved_image.getexif()) + + def test_strip_upload_metadata_command_strips_existing_files(self): + recipe = Recipe.objects.create(title="Cake") + image_name = "recipes/2026/8/cake.jpg" + image_path = Path(self.media_root) / image_name + image_path.parent.mkdir(parents=True, exist_ok=True) + image_path.write_bytes(jpeg_with_exif()) + RecipeImage.objects.create(recipe=recipe, image=image_name) + + call_command("strip_upload_metadata", stdout=StringIO(), stderr=StringIO(), verbosity=0) + + with Image.open(image_path) as saved_image: + self.assertFalse(saved_image.getexif()) + + def test_recipe_image_upload_rejects_animated_images(self): + recipe = Recipe.objects.create(title="Cake") + upload = SimpleUploadedFile( + "cake.gif", + animated_gif(), + content_type="image/gif", + ) + + with self.assertRaises(ValidationError): + RecipeImage.objects.create(recipe=recipe, image=upload) + + def test_recipe_image_upload_allows_plain_png(self): + recipe = Recipe.objects.create(title="Cake") + upload = SimpleUploadedFile( + "cake.png", + plain_png(), + content_type="image/png", + ) + + recipe_image = RecipeImage.objects.create(recipe=recipe, image=upload) + + with Image.open(recipe_image.image.path) as saved_image: + self.assertEqual(saved_image.format, "PNG") + + def test_strip_upload_metadata_command_handles_missing_and_invalid_files(self): + recipe = Recipe.objects.create(title="Cake") + RecipeImage.objects.create(recipe=recipe, image="recipes/missing.jpg") + + invalid_name = "recipes/invalid.jpg" + invalid_path = Path(self.media_root) / invalid_name + invalid_path.parent.mkdir(parents=True, exist_ok=True) + invalid_path.write_bytes(b"not an image") + RecipeImage.objects.create(recipe=recipe, image=invalid_name) + + output = StringIO() + errors = StringIO() + + call_command("strip_upload_metadata", stdout=output, stderr=errors, verbosity=0) + + self.assertEqual(RecipeImage.objects.count(), 2) + self.assertIn("Missing file: recipes/missing.jpg", errors.getvalue()) + self.assertIn("Skipped unsupported image: recipes/invalid.jpg", errors.getvalue()) diff --git a/apps/recipes/tests/test_importers.py b/apps/recipes/tests/test_importers.py deleted file mode 100644 index 6f474f5..0000000 --- a/apps/recipes/tests/test_importers.py +++ /dev/null @@ -1,48 +0,0 @@ - - -#if __name__ == '__main__': - #from recipe_scrapers import SCRAPERS - - # supported websites - #print(SCRAPERS.keys()) - - #from recipe_scrapers import scrape_me - - #scraper = scrape_me("https://www.chefkoch.de/rezepte/3520631525089100/Tomaten-Kaesesauce-mit-Fleischwurst.html") - #print(scraper.title()) - #print(scraper.instructions()) - #print(scraper.to_json()) - # for a complete list of methods: - #help(scraper) - -import os -import django - -# Set the settings module -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "MealStack.settings.dev") # or prod - -# Initialize Django -django.setup() - -# Now you can import models and importers safely -from apps.recipes.importers.recipes.chefkoch import ChefkochImporter - -def main(): - url = "https://www.chefkoch.de/rezepte/3520631525089100/Tomaten-Kaesesauce-mit-Fleischwurst.html?portionen=4" - #url = "https://www.chefkoch.de/rezepte/2651801416564525/Karamellisierte-Balsamicozwiebeln.html?portionen=2" - #url = "https://www.chefkoch.de/rezepte/1120311218283565/Apfel-Quark-Kuchen-mit-Zucker-und-Zimt.html" - - importer = ChefkochImporter(url) - - print(importer.to_json(indent=2)) - - recipe = importer.import_recipe() - print(recipe.title) - - for rig in recipe.recipeingredientgroup_set.all(): - print(rig) - for ri in rig.recipeingredient_set.all(): - print(f"{ri.quantity} {ri.unit.name} {ri.ingredient.name}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/apps/recipes/tests/test_ingredient_importers.py b/apps/recipes/tests/test_ingredient_importers.py index abf98f2..cc321c8 100644 --- a/apps/recipes/tests/test_ingredient_importers.py +++ b/apps/recipes/tests/test_ingredient_importers.py @@ -1,9 +1,75 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase + +from apps.recipes.importers.ingredients.base import EANNotFound, IngredientImporterError from apps.recipes.importers.ingredients.openfoodfacts import OpenFoodFactsImporter +from apps.recipes.models import Ingredient + + +class IngredientImporterTests(TestCase): + def test_openfoodfacts_importer_creates_ingredient_from_api_response(self): + api = Mock() + api.product.get.return_value = { + "code": "1234567890123", + "brands": "Acme, Other", + "product_name": "Milk", + "product_name_de": "Milch", + "generic_name": "Milk generic", + "generic_name_de": "Milch generisch", + "nutriments": { + "energy-kcal": 60, + "fat_100g": 3.3, + "saturated-fat_100g": 2.1, + "carbohydrates_100g": 5, + "sugars_100g": 4.8, + "proteins_100g": 3.2, + "salt_100g": 0.1, + }, + } + + with patch("apps.recipes.importers.ingredients.openfoodfacts.openfoodfacts.API", return_value=api): + ingredient = OpenFoodFactsImporter(ean="1234567890123").import_ingredient() + + self.assertEqual(ingredient.ean, "1234567890123") + self.assertEqual(ingredient.name, "Milch") + self.assertEqual(ingredient.generic_name, "Milch generisch") + self.assertEqual(ingredient.brand, "Acme") + self.assertEqual(ingredient.kcal, 60) + self.assertEqual(Ingredient.objects.count(), 1) + + def test_openfoodfacts_importer_can_use_ean_argument(self): + api = Mock() + api.product.get.return_value = { + "code": "999", + "brands": "Brand", + "product_name": "Sugar", + "product_name_de": "", + "generic_name": "Sweetener", + "generic_name_de": "", + "nutriments": {}, + } + + with patch("apps.recipes.importers.ingredients.openfoodfacts.openfoodfacts.API", return_value=api): + ingredient = OpenFoodFactsImporter().import_ingredient("999") + + api.product.get.assert_called_once() + self.assertEqual(ingredient.name, "Sugar") + self.assertEqual(ingredient.kcal, 0) + + def test_openfoodfacts_importer_raises_for_missing_product(self): + api = Mock() + api.product.get.return_value = None + + with patch("apps.recipes.importers.ingredients.openfoodfacts.openfoodfacts.API", return_value=api): + with self.assertRaises(EANNotFound) as context: + OpenFoodFactsImporter(ean="missing").import_ingredient() -def main(): - importer = OpenFoodFactsImporter() - importer.import_ingredient("1103086260005") + self.assertEqual(context.exception.value, "missing") + self.assertEqual(str(context.exception), "Product not found for EAN: missing") + def test_ingredient_importer_error_uses_default_message(self): + error = IngredientImporterError(value="x") -if __name__ == '__main__': - main() \ No newline at end of file + self.assertEqual(error.value, "x") + self.assertEqual(str(error), "Ingredient Importer error occurred") diff --git a/apps/recipes/tests/test_management_commands.py b/apps/recipes/tests/test_management_commands.py new file mode 100644 index 0000000..0c39ce4 --- /dev/null +++ b/apps/recipes/tests/test_management_commands.py @@ -0,0 +1,162 @@ +from io import StringIO +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from apps.recipes.importers.ingredients.base import EANNotFound +from apps.recipes.management.commands.SeedCommand import SeedCommand +from apps.recipes.management.commands import ingredient_importer, recipe_importer, seed_all +from apps.recipes.management.commands.seed_admin_user import Command as SeedAdminUserCommand +from apps.recipes.management.commands.seed_ingredients import Command as SeedIngredientsCommand +from apps.recipes.management.commands.seed_units import Command as SeedUnitsCommand +from apps.recipes.models import Ingredient, Recipe, Unit + + +class ImporterCommandTests(TestCase): + def test_ingredient_importer_requires_ean(self): + with self.assertRaises(CommandError): + call_command("ingredient_importer") + + def test_ingredient_importer_rejects_unknown_importer(self): + with self.assertRaises(CommandError): + call_command("ingredient_importer", "123", importer_name="missing") + + def test_ingredient_importer_reports_missing_ean(self): + importer_cls = Mock() + importer_cls.return_value.import_ingredient.side_effect = EANNotFound("123") + + with patch.dict(ingredient_importer.Command.SUPPORTED_IMPORTERS, {"fake": importer_cls}): + with self.assertRaises(CommandError) as context: + call_command("ingredient_importer", "123", importer_name="fake") + + self.assertIn("Product not found for EAN: 123", str(context.exception)) + + def test_ingredient_importer_success(self): + ingredient = Ingredient.objects.create(ean="123", name="Milk") + importer_cls = Mock() + importer_cls.return_value.import_ingredient.return_value = ingredient + output = StringIO() + + with patch.dict(ingredient_importer.Command.SUPPORTED_IMPORTERS, {"fake": importer_cls}): + call_command("ingredient_importer", "123", importer_name="fake", stdout=output) + + importer_cls.assert_called_once_with(ean="123") + self.assertIn("Imported ingredient 'Milk'", output.getvalue()) + + def test_recipe_importer_requires_url(self): + with self.assertRaises(CommandError): + call_command("recipe_importer") + + def test_recipe_importer_rejects_unmatched_url(self): + with patch("apps.recipes.management.commands.recipe_importer.get_recipe_importers", return_value=[]): + with self.assertRaises(CommandError): + call_command("recipe_importer", "https://example.com/recipe") + + def test_recipe_importer_rejects_unknown_named_importer(self): + with self.assertRaises(CommandError): + call_command("recipe_importer", "https://example.com/recipe", importer_name="missing") + + def test_recipe_importer_rejects_url_that_does_not_match_named_importer(self): + spec = Mock() + spec.name = "Fake" + spec.matches_url.return_value = False + + with patch("apps.recipes.management.commands.recipe_importer.get_recipe_importers", return_value=[spec]): + with self.assertRaises(CommandError): + call_command("recipe_importer", "https://example.com/recipe", importer_name="Fake") + + def test_recipe_importer_rejects_ambiguous_auto_detection(self): + first = Mock() + first.name = "First" + first.matches_url.return_value = True + second = Mock() + second.name = "Second" + second.matches_url.return_value = True + + with patch("apps.recipes.management.commands.recipe_importer.get_recipe_importers", return_value=[first, second]): + with self.assertRaises(CommandError): + call_command("recipe_importer", "https://example.com/recipe") + + def test_recipe_importer_success_with_auto_detection(self): + recipe = Recipe.objects.create(title="Cake") + importer_cls = Mock() + importer_cls.return_value.import_recipe.return_value = recipe + spec = Mock() + spec.name = "Fake" + spec.matches_url.return_value = True + spec.importer_cls = importer_cls + output = StringIO() + + with patch("apps.recipes.management.commands.recipe_importer.get_recipe_importers", return_value=[spec]): + call_command("recipe_importer", "https://example.com/recipe", stdout=output) + + importer_cls.assert_called_once_with(url="https://example.com/recipe") + self.assertIn("Imported recipe 'Cake' via Fake.", output.getvalue()) + + +class SeedCommandTests(TestCase): + def test_seed_command_reports_success_and_handles_errors(self): + class SuccessfulSeed(SeedCommand): + def get_seed_name(self): + return "Successful" + + def seed(self, *args, **kwargs): + return None + + class FailingSeed(SeedCommand): + def get_seed_name(self): + return "Failing" + + def seed(self, *args, **kwargs): + raise RuntimeError("boom") + + success = SuccessfulSeed() + success.stdout = StringIO() + success.handle() + self.assertIn("Successfully seeded Successful.", success.stdout.getvalue()) + + failing = FailingSeed() + failing.stdout = StringIO() + failing.handle() + self.assertIn("Failed to seed Failing: boom", failing.stdout.getvalue()) + with self.assertRaises(RuntimeError): + failing.handle(raise_on_error=True) + + def test_seed_admin_user_creates_or_updates_admin(self): + SeedAdminUserCommand().seed() + user = get_user_model().objects.get(username="admin") + + self.assertTrue(user.is_staff) + self.assertTrue(user.is_superuser) + self.assertTrue(user.check_password("admin")) + + def test_seed_units_creates_catalog_units(self): + SeedUnitsCommand().seed() + + self.assertTrue(Unit.objects.filter(name="gram", type="weight").exists()) + self.assertTrue(Unit.objects.filter(name="cup", type="volume").exists()) + + def test_seed_ingredients_creates_catalog_ingredients(self): + SeedIngredientsCommand().seed() + + self.assertTrue(Ingredient.objects.filter(name="milk", density=1.03).exists()) + self.assertTrue(Ingredient.objects.filter(name="flour", kcal=364).exists()) + + def test_seed_all_reports_failed_seed_commands(self): + success_command = Mock() + success_command.return_value.handle.return_value = None + failing_command = Mock() + failing_command.__module__ = "apps.recipes.management.commands.failing_seed" + failing_command.return_value.handle.side_effect = RuntimeError("boom") + success_command.__module__ = "apps.recipes.management.commands.success_seed" + output = StringIO() + + with patch.object(seed_all.Command, "SEED_COMMANDS", [success_command, failing_command]): + call_command("seed_all", stdout=output) + + self.assertIn("OK success_seed succeeded", output.getvalue()) + self.assertIn("FAIL failing_seed failed: boom", output.getvalue()) + self.assertIn("WARN 1/2 seeds failed: failing_seed", output.getvalue()) diff --git a/apps/recipes/tests/test_models_and_utils.py b/apps/recipes/tests/test_models_and_utils.py new file mode 100644 index 0000000..09e275e --- /dev/null +++ b/apps/recipes/tests/test_models_and_utils.py @@ -0,0 +1,114 @@ +from datetime import timedelta +from pathlib import Path +import shutil +import tempfile + +from django.core.files.base import ContentFile +from django.test import TestCase, override_settings + +from apps.common.text_formatting import normalize_german, slugify +from apps.common.time import format_timedelta +from apps.recipes.models import ( + Ingredient, + Recipe, + RecipeImage, + RecipeIngredient, + RecipeIngredientGroup, + RecipeStep, + RecipeStepGroup, + Unit, +) + + +class CommonUtilityTests(TestCase): + def test_format_timedelta_handles_empty_hours_and_minutes(self): + self.assertEqual(format_timedelta(None), "—") + self.assertEqual(format_timedelta(timedelta(minutes=45)), "45m") + self.assertEqual(format_timedelta(timedelta(hours=2, minutes=5)), "2h 5m") + + def test_slugify_normalizes_german_characters_for_urls(self): + self.assertEqual(normalize_german("Äpfel Öl süß"), "Aepfel Oel suess") + self.assertEqual(slugify("Äpfel Öl süß"), "aepfel-oel-suess") + + +class ModelBehaviorTests(TestCase): + def setUp(self): + self.media_root = tempfile.mkdtemp() + self.settings_override = override_settings(MEDIA_ROOT=self.media_root) + self.settings_override.enable() + + def tearDown(self): + self.settings_override.disable() + shutil.rmtree(self.media_root, ignore_errors=True) + + def test_recipe_time_defaults_display_and_string(self): + recipe = Recipe.objects.create(title="Cake", preparation_time=timedelta(minutes=10)) + + self.assertEqual(str(recipe), "Cake") + self.assertEqual(recipe.total_time, timedelta(minutes=10)) + self.assertEqual(recipe.total_time_display(), "10m") + self.assertEqual(recipe.cooking_time, timedelta()) + self.assertEqual(recipe.resting_time, timedelta()) + + def test_recipe_image_placeholders_and_existing_images(self): + recipe = Recipe.objects.create(title="Cake") + + self.assertIn("placeholder", recipe.primary_or_placeholder) + self.assertIn("placeholder", recipe.images_or_placeholder[0].image.url) + + image_name = "recipes/cake.jpg" + image_path = Path(self.media_root) / image_name + image_path.parent.mkdir(parents=True, exist_ok=True) + image_path.write_bytes(b"not-real-image") + RecipeImage.objects.create( + recipe=recipe, + image=ContentFile(b"not-real-image", name=image_name), + is_primary=True, + ) + + self.assertIn("/media/recipes/", recipe.primary_or_placeholder) + self.assertIn("/media/recipes/", recipe.images_or_placeholder[0].image.url) + + def test_recipe_images_missing_files_use_placeholder(self): + recipe = Recipe.objects.create(title="Cake") + RecipeImage.objects.create(recipe=recipe, image="recipes/missing.jpg", is_primary=True) + + self.assertIn("placeholder", recipe.primary_or_placeholder) + self.assertIn("placeholder", recipe.images_or_placeholder[0].image.url) + + def test_unit_conversions_and_errors(self): + flour = Ingredient.objects.create(name="Flour", density=0.6) + gram = Unit.objects.create(name="gram", type="weight", grams_per_unit=1) + cup = Unit.objects.create(name="cup", type="volume", ml_per_unit=240) + piece = Unit.objects.create(name="piece", type="count", grams_per_unit=50) + unknown = Unit.objects.create(name="mystery", type="mystery") + + self.assertEqual(str(gram), "gram") + self.assertEqual(gram.to_grams(100), 100) + self.assertEqual(cup.to_grams(2, flour), 288) + self.assertEqual(piece.to_grams(3), 150) + with self.assertRaises(ValueError): + cup.to_grams(1) + with self.assertRaises(ValueError): + unknown.to_grams(1) + + def test_model_string_methods(self): + recipe = Recipe.objects.create(title="Cake") + ingredient = Ingredient.objects.create(name="Sugar", brand="SweetCo") + unit = Unit.objects.create(name="gram", type="weight") + ingredient_group = RecipeIngredientGroup.objects.create(recipe=recipe, name="Main", order=1) + step_group = RecipeStepGroup.objects.create(recipe=recipe, name="Method", order=1) + recipe_ingredient = RecipeIngredient.objects.create( + group=ingredient_group, + ingredient=ingredient, + unit=unit, + quantity=2, + order=1, + ) + step = RecipeStep.objects.create(group=step_group, description="Mix.", order=1) + + self.assertEqual(str(ingredient), "Sugar") + self.assertIn("Main", str(ingredient_group)) + self.assertIn("Sugar", str(recipe_ingredient)) + self.assertIn("Method", str(step_group)) + self.assertEqual(str(step), "Step 1 (Method)") diff --git a/apps/recipes/tests/test_recipe_importer_security.py b/apps/recipes/tests/test_recipe_importer_security.py new file mode 100644 index 0000000..67ecd1e --- /dev/null +++ b/apps/recipes/tests/test_recipe_importer_security.py @@ -0,0 +1,86 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import SimpleTestCase + +from apps.recipes.importers.recipes.base import BaseRecipeImporter, RecipeImageImportError +from apps.recipes.importers.recipes.registry import get_recipe_importers + + +class RecipeImporterSecurityTests(SimpleTestCase): + def test_registered_recipe_importers_define_base_domains(self): + importer_domains = {importer.name: importer.base_domain for importer in get_recipe_importers()} + + self.assertEqual(importer_domains["BBC Good Food"], "bbcgoodfood.com") + self.assertEqual(importer_domains["Chefkoch"], "chefkoch.de") + self.assertEqual(importer_domains["Epicurious"], "epicurious.com") + + def test_attach_image_rejects_url_outside_importer_domain(self): + with self.assertRaises(RecipeImageImportError): + BaseRecipeImporter.attach_image( + recipe=object(), + image_url="https://example.com/image.jpg", + base_domain="bbcgoodfood.com", + ) + + def test_attach_image_rejects_non_http_urls_and_missing_hosts(self): + for image_url in ["file:///tmp/image.jpg", "data:image/png;base64,abc", "https:///image.jpg"]: + with self.subTest(image_url=image_url): + with self.assertRaises(RecipeImageImportError): + BaseRecipeImporter.attach_image( + recipe=object(), + image_url=image_url, + base_domain="bbcgoodfood.com", + ) + + def test_attach_image_rejects_non_image_content_type(self): + response = SimpleNamespace( + url="https://www.bbcgoodfood.com/image.jpg", + headers={"Content-Type": "text/html; charset=utf-8"}, + content=b"", + raise_for_status=lambda: None, + ) + + with patch("apps.recipes.importers.recipes.base.requests.get", return_value=response): + with self.assertRaises(RecipeImageImportError): + BaseRecipeImporter.attach_image( + recipe=object(), + image_url="https://www.bbcgoodfood.com/image.jpg", + base_domain="bbcgoodfood.com", + ) + + def test_attach_image_rejects_redirect_outside_importer_domain(self): + response = SimpleNamespace( + url="https://example.com/image.jpg", + headers={"Content-Type": "image/jpeg"}, + content=b"image", + raise_for_status=lambda: None, + ) + + with patch("apps.recipes.importers.recipes.base.requests.get", return_value=response): + with self.assertRaises(RecipeImageImportError): + BaseRecipeImporter.attach_image( + recipe=object(), + image_url="https://www.bbcgoodfood.com/image.jpg", + base_domain="bbcgoodfood.com", + ) + + def test_attach_image_accepts_subdomain_redirect_inside_importer_domain(self): + response = SimpleNamespace( + url="https://images.bbcgoodfood.com/image.jpg", + headers={"Content-Type": "image/jpeg; charset=binary"}, + content=b"image", + raise_for_status=lambda: None, + ) + + with ( + patch("apps.recipes.importers.recipes.base.requests.get", return_value=response), + patch("apps.recipes.importers.recipes.base.RecipeImage.objects.create") as create_image, + ): + BaseRecipeImporter.attach_image( + recipe=object(), + image_url="https://www.bbcgoodfood.com/image.jpg?width=100", + base_domain="bbcgoodfood.com", + ) + + self.assertEqual(create_image.call_args.kwargs["image"].name, "image.jpg") diff --git a/apps/recipes/tests/test_recipe_importers.py b/apps/recipes/tests/test_recipe_importers.py new file mode 100644 index 0000000..ff0f784 --- /dev/null +++ b/apps/recipes/tests/test_recipe_importers.py @@ -0,0 +1,271 @@ +from dataclasses import dataclass +from unittest.mock import Mock, patch + +from django.test import TestCase + +from apps.recipes.importers.recipes.base import ( + BaseRecipeImporter, + BaseRecipeScraperImporter, + RecipeImageImportError, + _hostname_matches_base_domain, +) +from apps.recipes.models import Cuisine, Recipe, RecipeIngredient, RecipeStep, Tag + + +@dataclass +class FakeIngredientGroup: + purpose: str + ingredients: list[str] + + +class FakeScraper: + def __init__( + self, + *, + title=" Imported Cake ", + yields="Serves 4", + prep_time=15, + cook_time=30, + author="Long Author", + ingredients=None, + ingredient_groups=None, + instructions=None, + keywords=None, + image="", + cuisine="Italian", + ): + self._title = title + self._yields = yields + self._prep_time = prep_time + self._cook_time = cook_time + self._author = author + self._ingredients = ingredients or ["100g flour", "2 eggs"] + self._ingredient_groups = ingredient_groups + self._instructions = instructions or ["Method", "mix ingredients well", "bake until set"] + self._keywords = keywords or ["Dessert", " Cake ", ""] + self._image = image + self._cuisine = cuisine + + def title(self): + return self._title + + def yields(self): + return self._yields + + def prep_time(self): + return self._prep_time + + def cook_time(self): + return self._cook_time + + def author(self): + return self._author + + def ingredients(self): + return self._ingredients + + def ingredient_groups(self): + if self._ingredient_groups is None: + raise AttributeError("no grouped ingredients") + return self._ingredient_groups + + def instructions_list(self): + return self._instructions + + def keywords(self): + return self._keywords + + def image(self): + return self._image + + def cuisine(self): + return self._cuisine + + def to_json(self, **kwargs): + return {"title": self._title, **kwargs} + + +class RaisingScraper(FakeScraper): + def title(self): + raise RuntimeError("title unavailable") + + def yields(self): + raise RuntimeError("yield unavailable") + + def prep_time(self): + raise RuntimeError("prep unavailable") + + def cook_time(self): + raise RuntimeError("cook unavailable") + + def author(self): + raise RuntimeError("author unavailable") + + def image(self): + raise RuntimeError("image unavailable") + + def cuisine(self): + raise RuntimeError("cuisine unavailable") + + +class TestScraperImporter(BaseRecipeScraperImporter): + site_name = "Test Site" + default_ingredient_group_name = "Ingredients" + default_step_group_name = "Steps" + base_domain = "example.com" + + +class RecipeImporterTests(TestCase): + def test_hostname_matches_base_domain_and_subdomains(self): + self.assertTrue(_hostname_matches_base_domain("example.com", "example.com")) + self.assertTrue(_hostname_matches_base_domain("cdn.example.com", "example.com")) + self.assertTrue(_hostname_matches_base_domain("EXAMPLE.COM.", "example.com")) + self.assertFalse(_hostname_matches_base_domain("evil-example.com", "example.com")) + self.assertFalse(_hostname_matches_base_domain(None, "example.com")) + + def test_base_importer_helpers_parse_simple_ingredients(self): + self.assertEqual(BaseRecipeImporter.normalize_ingredient_name("Egg(s)"), "Egg") + self.assertEqual(BaseRecipeImporter.normalize_ingredient("2 cup flour"), (2.0, "cup", "flour")) + self.assertEqual(BaseRecipeImporter.normalize_ingredient("salt"), (1, "", "salt")) + + with self.assertRaises(NotImplementedError): + BaseRecipeImporter.normalize_quantity(1, "cup") + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_creates_recipe_payload(self, scrape_me, update_nutrition): + scrape_me.return_value = FakeScraper() + + recipe = TestScraperImporter("https://example.com/recipe").import_recipe() + + self.assertEqual(recipe.title, "Imported Cake") + self.assertEqual(recipe.servings, 4) + self.assertEqual(recipe.preparation_time.total_seconds(), 15 * 60) + self.assertEqual(recipe.cooking_time.total_seconds(), 30 * 60) + self.assertEqual(recipe.author, "Long Author") + self.assertEqual(recipe.status, "published") + self.assertEqual(recipe.cuisine.name, "Italian") + self.assertEqual(Tag.objects.filter(name="Dessert").count(), 1) + self.assertEqual(recipe.recipeingredientgroup_set.get().name, "Ingredients") + self.assertEqual(RecipeIngredient.objects.count(), 2) + self.assertEqual(RecipeStep.objects.count(), 2) + update_nutrition.assert_called_once_with(recipe) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_handles_grouped_ingredients_and_step_groups(self, scrape_me, update_nutrition): + scrape_me.return_value = FakeScraper( + ingredient_groups=[ + FakeIngredientGroup("", ["1 cup milk"]), + FakeIngredientGroup("Topping", ["2 tbsp sugar"]), + ], + instructions=["Prep", "whisk until smooth", "Bake", "bake until set"], + cuisine="", + ) + + recipe = TestScraperImporter("https://example.com/grouped").import_recipe() + + self.assertEqual( + list(recipe.recipeingredientgroup_set.order_by("order").values_list("name", flat=True)), + ["Ingredients 1", "Topping"], + ) + self.assertEqual( + list(recipe.recipestepgroup_set.order_by("order").values_list("name", flat=True)), + ["Prep", "Bake"], + ) + self.assertFalse(Cuisine.objects.exists()) + update_nutrition.assert_called_once_with(recipe) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_clears_existing_payload_on_reimport(self, scrape_me, update_nutrition): + scrape_me.side_effect = [ + FakeScraper(ingredients=["100g flour"], keywords=["Old"]), + FakeScraper(title="Updated", ingredients=["200g sugar"], keywords=["New"]), + ] + + importer = TestScraperImporter("https://example.com/reimport") + first_recipe = importer.import_recipe() + second_recipe = TestScraperImporter("https://example.com/reimport").import_recipe() + + self.assertEqual(first_recipe.pk, second_recipe.pk) + second_recipe.refresh_from_db() + self.assertEqual(second_recipe.title, "Updated") + self.assertEqual(RecipeIngredient.objects.count(), 1) + self.assertTrue(second_recipe.tags.filter(name="New").exists()) + self.assertFalse(second_recipe.tags.filter(name="Old").exists()) + self.assertEqual(update_nutrition.call_count, 2) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_safe_fallbacks(self, scrape_me, update_nutrition): + scraper = FakeScraper( + title=None, + yields="many", + prep_time="soon", + cook_time=None, + author=123, + ingredients=[], + instructions=[], + keywords=None, + cuisine=None, + ) + scrape_me.return_value = scraper + + recipe = TestScraperImporter("https://example.com/fallback").import_recipe() + + self.assertEqual(recipe.title, "Test Site") + self.assertEqual(recipe.servings, 1) + self.assertEqual(recipe.preparation_time.total_seconds(), 0) + self.assertEqual(recipe.cooking_time.total_seconds(), 0) + self.assertEqual(recipe.author, "123") + update_nutrition.assert_called_once_with(recipe) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_handles_scraper_method_exceptions(self, scrape_me, update_nutrition): + scrape_me.return_value = RaisingScraper(ingredients=["1 cup milk"], instructions=["mix"]) + + recipe = TestScraperImporter("https://example.com/raising").import_recipe() + + self.assertEqual(recipe.title, "Test Site") + self.assertEqual(recipe.servings, 1) + self.assertEqual(recipe.author, "") + self.assertEqual(RecipeIngredient.objects.count(), 1) + update_nutrition.assert_called_once_with(recipe) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.BaseRecipeImporter.attach_image") + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_attempts_image_attachment(self, scrape_me, attach_image, update_nutrition): + scrape_me.return_value = FakeScraper(image="https://example.com/image.jpg") + + recipe = TestScraperImporter("https://example.com/image").import_recipe() + + attach_image.assert_called_once_with( + recipe, + "https://example.com/image.jpg", + primary=True, + ordering=0, + base_domain="example.com", + ) + + @patch("apps.recipes.importers.recipes.base.update_recipe_nutrition") + @patch("apps.recipes.importers.recipes.base.BaseRecipeImporter.attach_image", side_effect=RecipeImageImportError("bad")) + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_ignores_image_import_errors(self, scrape_me, attach_image, update_nutrition): + scrape_me.return_value = FakeScraper(image="https://example.com/bad.jpg") + + recipe = TestScraperImporter("https://example.com/bad-image").import_recipe() + + self.assertEqual(recipe.title, "Imported Cake") + attach_image.assert_called_once() + + @patch("apps.recipes.importers.recipes.base.scrape_me") + def test_scraper_importer_to_json_delegates_to_scraper(self, scrape_me): + scrape_me.return_value = FakeScraper() + + self.assertEqual( + TestScraperImporter("https://example.com/json").to_json(indent=2), + {"title": " Imported Cake ", "indent": 2}, + ) diff --git a/apps/recipes/tests/test_release_fixes.py b/apps/recipes/tests/test_release_fixes.py new file mode 100644 index 0000000..7b5fca1 --- /dev/null +++ b/apps/recipes/tests/test_release_fixes.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import SimpleTestCase, TestCase +from django.urls import resolve + +from apps.common.backend.auth_backends import OIDCAuthBackend +from apps.common.text_formatting import slugify +from apps.recipes.models import Tag + + +class ReleaseConfigurationTests(SimpleTestCase): + def test_admin_importer_overview_urls_resolve_to_custom_views(self): + self.assertEqual(resolve("/admin/importers/").url_name, "importers_home") + self.assertEqual(resolve("/admin/importers/ingredient/").url_name, "ingredient_importers_home") + self.assertEqual(resolve("/admin/importers/recipe/").url_name, "recipe_importers_home") + + def test_i18n_context_processor_is_enabled(self): + context_processors = settings.TEMPLATES[0]["OPTIONS"]["context_processors"] + self.assertIn("django.template.context_processors.i18n", context_processors) + + def test_slug_generation_normalizes_german_characters_for_urls(self): + self.assertEqual(slugify("K\u00e4se \u00d6l s\u00fc\u00df"), "kaese-oel-suess") + + +class SeedCommandTests(TestCase): + def test_seed_tags_does_not_delete_existing_tags(self): + custom_tag = Tag.objects.create(name="Private", slug="private") + + call_command("seed_tags", verbosity=0) + + self.assertTrue(Tag.objects.filter(pk=custom_tag.pk, name="Private").exists()) + + +class OIDCBackendTests(TestCase): + def test_create_user_does_not_grant_staff_or_superuser(self): + user = get_user_model().objects.create_user( + username="person@example.com", + email="person@example.com", + is_staff=True, + is_superuser=True, + ) + backend = OIDCAuthBackend.__new__(OIDCAuthBackend) + backend.create_user = OIDCAuthBackend.create_user.__get__(backend, OIDCAuthBackend) + parent_create_user = OIDCAuthBackend.__mro__[1].create_user + + try: + OIDCAuthBackend.__mro__[1].create_user = lambda _backend, _claims: user + created_user = backend.create_user({"email": "person@example.com"}) + finally: + OIDCAuthBackend.__mro__[1].create_user = parent_create_user + + self.assertFalse(created_user.is_superuser) + self.assertFalse(created_user.is_staff) + + def test_update_user_preserves_existing_superuser_and_staff_flags(self): + user = get_user_model().objects.create_user( + username="admin", + email="old@example.com", + password="unused", + is_superuser=True, + is_staff=False, + ) + + backend = OIDCAuthBackend.__new__(OIDCAuthBackend) + updated_user = backend.update_user(user, {"email": "admin@example.com"}) + + self.assertTrue(updated_user.is_superuser) + self.assertFalse(updated_user.is_staff) + self.assertEqual(updated_user.email, "admin@example.com") + + def test_update_user_preserves_existing_staff_flag(self): + user = get_user_model().objects.create_user( + username="person", + email="old@example.com", + password="unused", + is_superuser=False, + is_staff=True, + ) + + backend = OIDCAuthBackend.__new__(OIDCAuthBackend) + updated_user = backend.update_user(user, {"email": "person@example.com"}) + + self.assertFalse(updated_user.is_superuser) + self.assertTrue(updated_user.is_staff) diff --git a/apps/recipes/tests/test_seed_catalog.py b/apps/recipes/tests/test_seed_catalog.py new file mode 100644 index 0000000..ec1d20f --- /dev/null +++ b/apps/recipes/tests/test_seed_catalog.py @@ -0,0 +1,70 @@ +from django.test import SimpleTestCase + +from apps.recipes.management.commands.seed_catalog import ( + canonicalize_ingredient_name, + ingredient_defaults, + normalize_text, + parse_ingredient_line, + parse_quantity, + should_start_new_step_group, + unit_defaults, +) + + +class SeedCatalogParsingTests(SimpleTestCase): + def test_normalize_text_replaces_unicode_fractions_and_spacing(self): + self.assertEqual(normalize_text(" 1\u00bd\xa0cups \u2014 flour "), "1 1/2 cups - flour") + + def test_parse_quantity_supports_fractions_and_mixed_numbers(self): + self.assertEqual(parse_quantity("1/2"), 0.5) + self.assertEqual(parse_quantity("1 1/2"), 1.5) + self.assertEqual(parse_quantity("1 0.5"), 1.5) + self.assertEqual(parse_quantity("\u00be"), 0.75) + + def test_canonicalize_ingredient_name_uses_aliases_and_cleanup(self): + cases = { + "Large eggs": "egg", + "self-raising flour": "flour", + "sunflower or vegetable oil": "vegetable oil", + "garlic cloves, chopped": "garlic", + "tomatoes": "tomatoes", + "lemon wedges to serve": "lemon", + "parmesan cheese / breadcrumbs": "parmesan", + "eggs (large)": "egg", + "unknown things": "unknown things", + } + + for raw_name, expected in cases.items(): + with self.subTest(raw_name=raw_name): + self.assertEqual(canonicalize_ingredient_name(raw_name), expected) + + def test_parse_ingredient_line_supports_common_scraped_formats(self): + cases = [ + ("100g flour", (100.0, "gram", "flour")), + ("2 cups milk", (2.0, "cup", "milk")), + ("1 1/2 tbsp olive oil", (1.5, "tbsp", "olive oil")), + ("N/A salt", (1.0, "pcs", "salt")), + ("1-oz can crushed tomatoes", (1.0, "oz", "tomatoes")), + ("2 pcs 14 oz diced tomatoes", (14.0, "oz", "tomatoes")), + ("large egg", (1.0, "pcs", "egg")), + ("3kg chicken breasts, diced", (3.0, "kilogram", "chicken")), + ("4 lb. chicken thighs plus sauce", (4.0, "lb", "chicken")), + ("1 container ricotta cheese", (1.0, "pcs", "ricotta cheese")), + ] + + for raw_line, expected in cases: + with self.subTest(raw_line=raw_line): + self.assertEqual(parse_ingredient_line(raw_line), expected) + + def test_step_group_detection_uses_short_heading_like_steps(self): + self.assertTrue(should_start_new_step_group("For the sauce")) + self.assertFalse(should_start_new_step_group("")) + self.assertFalse(should_start_new_step_group("Mix everything together until smooth.")) + self.assertFalse(should_start_new_step_group("this is lowercase")) + self.assertFalse(should_start_new_step_group("Bake!")) + + def test_catalog_default_helpers_return_known_values_and_empty_fallbacks(self): + self.assertEqual(unit_defaults("gram")["type"], "weight") + self.assertEqual(unit_defaults("missing"), {}) + self.assertEqual(ingredient_defaults("milk")["density"], 1.03) + self.assertEqual(ingredient_defaults("missing"), {}) diff --git a/apps/recipes/tests/test_unit_model.py b/apps/recipes/tests/test_unit_model.py new file mode 100644 index 0000000..4bf84ca --- /dev/null +++ b/apps/recipes/tests/test_unit_model.py @@ -0,0 +1,12 @@ +from django.forms import modelform_factory +from django.test import TestCase + +from apps.recipes.models import Unit + + +class UnitModelTests(TestCase): + def test_unit_type_choices_accept_plain_values(self): + form_class = modelform_factory(Unit, fields=["name", "type"]) + form = form_class(data={"name": "pcs", "type": "count"}) + + self.assertTrue(form.is_valid(), form.errors) diff --git a/pyproject.toml b/pyproject.toml index de4bbb2..b1722e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,32 @@ version = "0.1.0" description = "Web App for managing recipes" requires-python = "==3.12.4" dependencies = [ - "django==6.0.1", - "python-dotenv==1.2.1", + "django==6.0.7", + "python-dotenv==1.2.2", "recipe-scrapers==15.11.0", "django-nested-admin==4.1.6", "django-cleanup==9.0.0", - "pillow==12.1.0", + "pillow==12.3.0", "mysqlclient==2.2.7", "openfoodfacts>=3.3.0", "mozilla-django-oidc>=5.0.2", "reportlab>=5.0.0", ] + +[dependency-groups] +dev = [ + "coverage>=7.15.2", + "tqdm>=4.67.1", +] + +[tool.coverage.run] +source = ["apps"] +omit = [ + "*/tests/*", + "*/migrations/*", + "*/test_runner.py", + "*/management/commands/test_coverage.py", +] + +[tool.coverage.report] +show_missing = true