\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 %}
{% 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 %}
{% 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 @@



+[](https://codecov.io/gh/TrickShotMLG02/MealStack/tree/master)
+[](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