Compare commits

..

6 Commits

Author SHA1 Message Date
Hayden
f644ee1879 fix: sort recipe names accent-folded for locale-aware ordering (#6853)
Recipe name sorting applied lower() to the raw name, so accented and
umlaut characters sorted by raw code point and landed after "Z"
(e.g. "Über" after "Zebra"). Sort by the existing unidecode-normalized
name column instead, so accented characters sort next to their base
letter. Works identically on SQLite and Postgres since the sort key is
a pre-computed ASCII column rather than a DB collation.
2026-05-24 11:43:49 -05:00
Hayden
8eb00c3dc0 chore(l10n): New Crowdin updates (#7649) 2026-05-22 11:06:43 -05:00
Michael Genson
642c826f2b fix: Protect sensitive data in query filter API (GHSA-8m57-7cv5-rjp8) (#7629)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2026-05-21 21:08:41 +00:00
Hayden
493154caa8 chore(l10n): New Crowdin updates (#7646) 2026-05-21 15:27:16 -05:00
Hayden
71e0d99a46 chore(l10n): New Crowdin updates (#7643) 2026-05-20 22:35:33 -05:00
Michael Genson
c52a4e10c9 fix: Inconsistent "from an image" vs "from images" translation (#7642) 2026-05-20 13:45:42 -05:00
75 changed files with 488 additions and 299 deletions

View File

@@ -205,7 +205,7 @@ const createLinks = computed(() => [
insertDivider: false, insertDivider: false,
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
title: i18n.t("recipe.create-from-images"), title: i18n.t("recipe.create-from-images"),
subtitle: i18n.t("recipe.create-recipe-from-an-image"), subtitle: i18n.t("recipe.create-recipe-from-images"),
to: `/g/${groupSlug.value}/r/create/image`, to: `/g/${groupSlug.value}/r/create/image`,
restricted: true, restricted: true,
hide: !showImageImport.value, hide: !showImageImport.value,

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Voer in met .zip", "import-with-zip": "Voer in met .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "إنشاء وصفة جديدة من الصفر.", "create-recipe-description": "إنشاء وصفة جديدة من الصفر.",
"create-recipes": "إنشاء الوصفات", "create-recipes": "إنشاء الوصفات",
"import-with-zip": "الاستيراد باستخدام zip.", "import-with-zip": "الاستيراد باستخدام zip.",
"create-recipe-from-an-image": "إنشاء وصفة عن طريق صورة", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "قم بقص الصورة وتدويرها بحيث يظهر النص فقط، ويكون في الاتجاه الصحيح.", "crop-and-rotate-the-image": "قم بقص الصورة وتدويرها بحيث يظهر النص فقط، ويكون في الاتجاه الصحيح.",
"create-from-images": "إنشاء عن طريق صور", "create-from-images": "إنشاء عن طريق صور",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Създайте нова рецепта от чернова.", "create-recipe-description": "Създайте нова рецепта от чернова.",
"create-recipes": "Създайте рецепти", "create-recipes": "Създайте рецепти",
"import-with-zip": "Импортирай от .zip", "import-with-zip": "Импортирай от .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.", "crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.",
"create-from-images": "Създаване от изображения", "create-from-images": "Създаване от изображения",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crea una nova recepta des de zero.", "create-recipe-description": "Crea una nova recepta des de zero.",
"create-recipes": "Crea Receptes", "create-recipes": "Crea Receptes",
"import-with-zip": "Importar amb un .zip", "import-with-zip": "Importar amb un .zip",
"create-recipe-from-an-image": "Crear una recepta a partir d'una imatge", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.", "create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.", "crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
"create-from-images": "Crear una recepta a partir d'una imatge", "create-from-images": "Crear una recepta a partir d'una imatge",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Vytvořit nový recept od nuly.", "create-recipe-description": "Vytvořit nový recept od nuly.",
"create-recipes": "Vytvořit recepty", "create-recipes": "Vytvořit recepty",
"import-with-zip": "Importovat pomocí .zip", "import-with-zip": "Importovat pomocí .zip",
"create-recipe-from-an-image": "Vytvořit recept z obrázku", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Vytvořte recept nahráním obrázku. Mealie se pokusí z obrázku extrahovat text pomocí AI a vytvořit z něj recept.", "create-recipe-from-an-image-description": "Vytvořte recept nahráním obrázku. Mealie se pokusí z obrázku extrahovat text pomocí AI a vytvořit z něj recept.",
"crop-and-rotate-the-image": "Oříznout a otočit obrázek tak, aby byl viditelný pouze text a aby byl ve správné orientaci.", "crop-and-rotate-the-image": "Oříznout a otočit obrázek tak, aby byl viditelný pouze text a aby byl ve správné orientaci.",
"create-from-images": "Vytvořit z obrázků", "create-from-images": "Vytvořit z obrázků",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Opret ny opskrift fra bunden.", "create-recipe-description": "Opret ny opskrift fra bunden.",
"create-recipes": "Opret opskrift", "create-recipes": "Opret opskrift",
"import-with-zip": "Importér fra ZIP-fil", "import-with-zip": "Importér fra ZIP-fil",
"create-recipe-from-an-image": "Opret opskrift fra et billede", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Opret en opskrift ved at overføre et billede af den. Mealie vil forsøge at udtrække teksten fra billedet med AI og oprette en opskrift fra det.", "create-recipe-from-an-image-description": "Opret en opskrift ved at overføre et billede af den. Mealie vil forsøge at udtrække teksten fra billedet med AI og oprette en opskrift fra det.",
"crop-and-rotate-the-image": "Beskær og roter billedet, så kun teksten er synlig, og det vises i den rigtige retning.", "crop-and-rotate-the-image": "Beskær og roter billedet, så kun teksten er synlig, og det vises i den rigtige retning.",
"create-from-images": "Opret fra billede", "create-from-images": "Opret fra billede",

View File

@@ -169,7 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "Dienstag", "tuesday": "Dienstag",
"type": "Typ", "type": "Typ",
"undo": "Undo", "undo": "Rückgängig",
"update": "Aktualisieren", "update": "Aktualisieren",
"updated": "Aktualisiert", "updated": "Aktualisiert",
"upload": "Hochladen", "upload": "Hochladen",
@@ -628,7 +628,7 @@
"create-recipe-description": "Erstelle ein neues Rezept von Grund auf.", "create-recipe-description": "Erstelle ein neues Rezept von Grund auf.",
"create-recipes": "Rezepte erstellen", "create-recipes": "Rezepte erstellen",
"import-with-zip": "Von .zip importieren", "import-with-zip": "Von .zip importieren",
"create-recipe-from-an-image": "Rezept von einem Bild erstellen", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Erstelle ein Rezept, indem du ein Bild hochlädst. Mealie wird versuchen, den Text aus dem Bild mit Hilfe von KI zu extrahieren und ein Rezept daraus zu erstellen.", "create-recipe-from-an-image-description": "Erstelle ein Rezept, indem du ein Bild hochlädst. Mealie wird versuchen, den Text aus dem Bild mit Hilfe von KI zu extrahieren und ein Rezept daraus zu erstellen.",
"crop-and-rotate-the-image": "Beschneide und drehe das Bild so, dass nur der Text zu sehen ist und die Ausrichtung stimmt.", "crop-and-rotate-the-image": "Beschneide und drehe das Bild so, dass nur der Text zu sehen ist und die Ausrichtung stimmt.",
"create-from-images": "Aus Bildern erstellen", "create-from-images": "Aus Bildern erstellen",
@@ -917,7 +917,7 @@
"quantity": "Menge: {0}", "quantity": "Menge: {0}",
"shopping-list": "Einkaufsliste", "shopping-list": "Einkaufsliste",
"shopping-lists": "Einkaufslisten", "shopping-lists": "Einkaufslisten",
"add-item": "Add item", "add-item": "Eintrag hinzufügen",
"food": "Lebensmittel", "food": "Lebensmittel",
"note": "Notiz", "note": "Notiz",
"label": "Kategorie", "label": "Kategorie",
@@ -1478,10 +1478,10 @@
"max-length": "Muss maximal {max} Zeichen haben|Muss maximal {max} Zeichen haben" "max-length": "Muss maximal {max} Zeichen haben|Muss maximal {max} Zeichen haben"
}, },
"announcements": { "announcements": {
"announcements": "Announcements", "announcements": "Ankündigungen",
"all-announcements": "All announcements", "all-announcements": "Alle Ankündigungen",
"mark-all-as-read": "Alle als gelesen markieren", "mark-all-as-read": "Alle als gelesen markieren",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Ankündigung von Mealie anzeigen",
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings" "show-announcements-setting-description": "Lege fest, ob Benutzer Ankündigungen von Mealie sehen dürfen. Wenn aktiviert, können Benutzer die Anzeige in ihren Benutzereinstellungen immer noch deaktivieren"
} }
} }

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Δημιουργήστε μια νέα συνταγή από το μηδέν.", "create-recipe-description": "Δημιουργήστε μια νέα συνταγή από το μηδέν.",
"create-recipes": "Δημιουργία Συνταγών", "create-recipes": "Δημιουργία Συνταγών",
"import-with-zip": "Εισαγωγή μέσω .zip", "import-with-zip": "Εισαγωγή μέσω .zip",
"create-recipe-from-an-image": "Δημιουργία συνταγής από μια εικόνα", "create-recipe-from-images": "Δημιουργία συνταγής από εικόνες",
"create-recipe-from-an-image-description": "Δημιουργήστε μια συνταγή ανεβάζοντας μια εικόνα της. Το Mealie θα προσπαθήσει να εξάγει το κείμενο από την εικόνα χρησιμοποιώντας τεχνητή νοημοσύνη και να δημιουργήσει μια συνταγή από αυτό.", "create-recipe-from-an-image-description": "Δημιουργήστε μια συνταγή ανεβάζοντας μια εικόνα της. Το Mealie θα προσπαθήσει να εξάγει το κείμενο από την εικόνα χρησιμοποιώντας τεχνητή νοημοσύνη και να δημιουργήσει μια συνταγή από αυτό.",
"crop-and-rotate-the-image": "Περικοπή και περιστροφή της εικόνας, έτσι ώστε να είναι μόνο το κείμενο ορατό και να είναι στο σωστό προσανατολισμό.", "crop-and-rotate-the-image": "Περικοπή και περιστροφή της εικόνας, έτσι ώστε να είναι μόνο το κείμενο ορατό και να είναι στο σωστό προσανατολισμό.",
"create-from-images": "Δημιουργία από εικόνες", "create-from-images": "Δημιουργία από εικόνες",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip", "import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip", "import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from Images", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crear nueva receta desde cero.", "create-recipe-description": "Crear nueva receta desde cero.",
"create-recipes": "Crear Recetas", "create-recipes": "Crear Recetas",
"import-with-zip": "Importar desde .zip", "import-with-zip": "Importar desde .zip",
"create-recipe-from-an-image": "Crear receta a partir de una imagen", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Crea una receta cargando una imagen de ella. Mealie intentará extraer el texto de la imagen usando IA y crear una receta de ella.", "create-recipe-from-an-image-description": "Crea una receta cargando una imagen de ella. Mealie intentará extraer el texto de la imagen usando IA y crear una receta de ella.",
"crop-and-rotate-the-image": "Recortar y rotar la imagen de manera que sólo el texto sea visible, y esté en la orientación correcta.", "crop-and-rotate-the-image": "Recortar y rotar la imagen de manera que sólo el texto sea visible, y esté en la orientación correcta.",
"create-from-images": "Crear a partir de imágenes", "create-from-images": "Crear a partir de imágenes",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Loo uus retsept algusest", "create-recipe-description": "Loo uus retsept algusest",
"create-recipes": "Loo retseptid", "create-recipes": "Loo retseptid",
"import-with-zip": "Impordi .zip failist", "import-with-zip": "Impordi .zip failist",
"create-recipe-from-an-image": "Retsepti loomine pildist", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Retsepti loomiseks lae üles selle pilt. Mealie üritab ekstraheerida pildil oleva teksti ning luua retsepti sellest kasutades AI-d.", "create-recipe-from-an-image-description": "Retsepti loomiseks lae üles selle pilt. Mealie üritab ekstraheerida pildil oleva teksti ning luua retsepti sellest kasutades AI-d.",
"crop-and-rotate-the-image": "Kärpige ja pöörake pilti nii, et ainult tekst oleks nähtaval ja see oleks suunatud ülespoole.", "crop-and-rotate-the-image": "Kärpige ja pöörake pilti nii, et ainult tekst oleks nähtaval ja see oleks suunatud ülespoole.",
"create-from-images": "Retsepti loomine pildist", "create-from-images": "Retsepti loomine pildist",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Luo resepti alusta.", "create-recipe-description": "Luo resepti alusta.",
"create-recipes": "Luo reseptejä", "create-recipes": "Luo reseptejä",
"import-with-zip": "Tuo .zip:llä", "import-with-zip": "Tuo .zip:llä",
"create-recipe-from-an-image": "Luo resepti kuvasta", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Luo resepti tuomalla siitä kuva. Mealie pyrkii poimimaan tekstin kuvasta tekoälyllä ja luomaan siitä reseptin.", "create-recipe-from-an-image-description": "Luo resepti tuomalla siitä kuva. Mealie pyrkii poimimaan tekstin kuvasta tekoälyllä ja luomaan siitä reseptin.",
"crop-and-rotate-the-image": "Rajaa ja kierrä kuvaa niin, että vain teksti näkyy, ja että se on oikein päin.", "crop-and-rotate-the-image": "Rajaa ja kierrä kuvaa niin, että vain teksti näkyy, ja että se on oikein päin.",
"create-from-images": "Luo resepti kuvasta", "create-from-images": "Luo resepti kuvasta",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette de zéro.", "create-recipe-description": "Créer une nouvelle recette de zéro.",
"create-recipes": "Créer des recettes", "create-recipes": "Créer des recettes",
"import-with-zip": "Importer un .zip", "import-with-zip": "Importer un .zip",
"create-recipe-from-an-image": "Créer une recette à partir dune image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.", "create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.",
"crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible, et quil soit dans la bonne orientation.", "crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible, et quil soit dans la bonne orientation.",
"create-from-images": "Créer à partir dune image", "create-from-images": "Créer à partir dune image",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette à partir de zéro.", "create-recipe-description": "Créer une nouvelle recette à partir de zéro.",
"create-recipes": "Créer des recettes", "create-recipes": "Créer des recettes",
"import-with-zip": "Importer un .zip", "import-with-zip": "Importer un .zip",
"create-recipe-from-an-image": "Créer une recette à partir dune image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Créez une recette en téléversant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.", "create-recipe-from-an-image-description": "Créez une recette en téléversant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.",
"crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible et quil soit dans la bonne orientation.", "crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible et quil soit dans la bonne orientation.",
"create-from-images": "Créer à partir dimages", "create-from-images": "Créer à partir dimages",

View File

@@ -169,7 +169,7 @@
"token": "Jeton", "token": "Jeton",
"tuesday": "Mardi", "tuesday": "Mardi",
"type": "Type", "type": "Type",
"undo": "Undo", "undo": "Annuler",
"update": "Mettre à jour", "update": "Mettre à jour",
"updated": "Mis à jour", "updated": "Mis à jour",
"upload": "Importer", "upload": "Importer",
@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette de zéro.", "create-recipe-description": "Créer une nouvelle recette de zéro.",
"create-recipes": "Créer des recettes", "create-recipes": "Créer des recettes",
"import-with-zip": "Importer un .zip", "import-with-zip": "Importer un .zip",
"create-recipe-from-an-image": "Créer une recette à partir dune image", "create-recipe-from-images": "Créer une recette depuis une image",
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.", "create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.",
"crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible, et quil soit dans la bonne orientation.", "crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible, et quil soit dans la bonne orientation.",
"create-from-images": "Créer à partir dimages", "create-from-images": "Créer à partir dimages",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Voulez-vous vraiment désélectionner tous les éléments ?", "are-you-sure-you-want-to-uncheck-all-items": "Voulez-vous vraiment désélectionner tous les éléments ?",
"are-you-sure-you-want-to-delete-checked-items": "Voulez-vous vraiment supprimer tous les éléments sélectionnés ?", "are-you-sure-you-want-to-delete-checked-items": "Voulez-vous vraiment supprimer tous les éléments sélectionnés ?",
"no-shopping-lists-found": "Aucune liste de courses trouvée", "no-shopping-lists-found": "Aucune liste de courses trouvée",
"item-checked-off": "Checked off {item}" "item-checked-off": "{item} coché"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Recettes", "all-recipes": "Recettes",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crear unha receita en branco.", "create-recipe-description": "Crear unha receita en branco.",
"create-recipes": "Crear Receitas", "create-recipes": "Crear Receitas",
"import-with-zip": "Importar con .zip", "import-with-zip": "Importar con .zip",
"create-recipe-from-an-image": "Crear receita a partir dunha imaxen", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Cree unha receita cargando unha imaxen da mesma. O Mealie tentará extrair o texto da imaxen utilizando IA e creará unha receita a partir da mesma.", "create-recipe-from-an-image-description": "Cree unha receita cargando unha imaxen da mesma. O Mealie tentará extrair o texto da imaxen utilizando IA e creará unha receita a partir da mesma.",
"crop-and-rotate-the-image": "Recorte e vire a imaxen de modo a que só o texto sexa visível e na orientación correta.", "crop-and-rotate-the-image": "Recorte e vire a imaxen de modo a que só o texto sexa visível e na orientación correta.",
"create-from-images": "Crear a partir de imaxens", "create-from-images": "Crear a partir de imaxens",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "יצירת מתכון חדש מאפס.", "create-recipe-description": "יצירת מתכון חדש מאפס.",
"create-recipes": "יצירת מתכונים", "create-recipes": "יצירת מתכונים",
"import-with-zip": "ייבא באמצעות zip", "import-with-zip": "ייבא באמצעות zip",
"create-recipe-from-an-image": "יצירת מתכון מתמונה", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "יצירת מתכון ע\"י העלאת תמונה שלו. Mealie תנסה לחלץ את הטקסט מהתמונה באמצעות AI ותייצר ממנו מתכון.", "create-recipe-from-an-image-description": "יצירת מתכון ע\"י העלאת תמונה שלו. Mealie תנסה לחלץ את הטקסט מהתמונה באמצעות AI ותייצר ממנו מתכון.",
"crop-and-rotate-the-image": "נא לחתוך ולסובב את התמונה כך שרואים רק את הטקסט, והוא בכיוון הנכון.", "crop-and-rotate-the-image": "נא לחתוך ולסובב את התמונה כך שרואים רק את הטקסט, והוא בכיוון הנכון.",
"create-from-images": "יצירה מתמונה", "create-from-images": "יצירה מתמונה",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Izradi novi recept od početka", "create-recipe-description": "Izradi novi recept od početka",
"create-recipes": "Kreiraj recept", "create-recipes": "Kreiraj recept",
"import-with-zip": "Učitaj pomoću .zip-a", "import-with-zip": "Učitaj pomoću .zip-a",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Obreži i rotiraj sliku tako da bude vidljiv samo tekst i da bude u ispravnoj orijentaciji.", "crop-and-rotate-the-image": "Obreži i rotiraj sliku tako da bude vidljiv samo tekst i da bude u ispravnoj orijentaciji.",
"create-from-images": "Izradi na temelju fotografije", "create-from-images": "Izradi na temelju fotografije",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Adj hozzá egy új receptet a nulláról kezdve.", "create-recipe-description": "Adj hozzá egy új receptet a nulláról kezdve.",
"create-recipes": "Receptek létrehozása", "create-recipes": "Receptek létrehozása",
"import-with-zip": "Importálás .zip formátummal", "import-with-zip": "Importálás .zip formátummal",
"create-recipe-from-an-image": "Recept készítése képről", "create-recipe-from-images": "Recept létrehozása képek alapján",
"create-recipe-from-an-image-description": "Hozzon létre egy receptet egy kép feltöltésével. A Mealie megpróbálja a kép szövegét mesterséges intelligencia segítségével kinyerni, és létrehozni belőle a receptet.", "create-recipe-from-an-image-description": "Hozzon létre egy receptet egy kép feltöltésével. A Mealie megpróbálja a kép szövegét mesterséges intelligencia segítségével kinyerni, és létrehozni belőle a receptet.",
"crop-and-rotate-the-image": "Vágja ki és forgassa el a képet úgy, hogy csak a szöveg legyen látható, és megfelelő tájolásban legyen.", "crop-and-rotate-the-image": "Vágja ki és forgassa el a képet úgy, hogy csak a szöveg legyen látható, és megfelelő tájolásban legyen.",
"create-from-images": "Létrehozás képekről", "create-from-images": "Létrehozás képekről",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Stofna nýja uppskrift frá grunni.", "create-recipe-description": "Stofna nýja uppskrift frá grunni.",
"create-recipes": "Stofna uppskriftir", "create-recipes": "Stofna uppskriftir",
"import-with-zip": "Hlaða inn með .zip", "import-with-zip": "Hlaða inn með .zip",
"create-recipe-from-an-image": "Stofna uppskrift út frá mynd", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Stofna uppskrift með því hlaða inn myndum af uppskriftartextanum. Mealie mun reyna að vinna texta úr myndunum með gervigreind og stofna nýja uppskrift út frá textanum.", "create-recipe-from-an-image-description": "Stofna uppskrift með því hlaða inn myndum af uppskriftartextanum. Mealie mun reyna að vinna texta úr myndunum með gervigreind og stofna nýja uppskrift út frá textanum.",
"crop-and-rotate-the-image": "Sníða og snúa mynd svo bara textinn sé sýnilegur og að myndin snúi rétt.", "crop-and-rotate-the-image": "Sníða og snúa mynd svo bara textinn sé sýnilegur og að myndin snúi rétt.",
"create-from-images": "Stofna uppskrift frá mynd", "create-from-images": "Stofna uppskrift frá mynd",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crea una nuova ricetta da zero.", "create-recipe-description": "Crea una nuova ricetta da zero.",
"create-recipes": "Crea Ricette", "create-recipes": "Crea Ricette",
"import-with-zip": "Importa da .zip", "import-with-zip": "Importa da .zip",
"create-recipe-from-an-image": "Crea ricetta da un'immagine", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Crea una ricetta caricando un'immagine di essa. Mealie tenterà di estrarre il testo dall'immagine usando l'IA e creare una ricetta da esso.", "create-recipe-from-an-image-description": "Crea una ricetta caricando un'immagine di essa. Mealie tenterà di estrarre il testo dall'immagine usando l'IA e creare una ricetta da esso.",
"crop-and-rotate-the-image": "Ritaglia e ruota l'immagine in modo che solo il testo sia visibile e che sia orientato correttamente.", "crop-and-rotate-the-image": "Ritaglia e ruota l'immagine in modo che solo il testo sia visibile e che sia orientato correttamente.",
"create-from-images": "Crea da immagini", "create-from-images": "Crea da immagini",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "新しいレシピを一から作成します。", "create-recipe-description": "新しいレシピを一から作成します。",
"create-recipes": "レシピを作成する", "create-recipes": "レシピを作成する",
"import-with-zip": ".zip でインポート", "import-with-zip": ".zip でインポート",
"create-recipe-from-an-image": "画像からレシピを作成", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "画像をアップロードしてレシピを作成します。 Mealieは、AIを使用して画像からテキストを抽出し、そこからレシピを作成しようとします。", "create-recipe-from-an-image-description": "画像をアップロードしてレシピを作成します。 Mealieは、AIを使用して画像からテキストを抽出し、そこからレシピを作成しようとします。",
"crop-and-rotate-the-image": "テキストのみが表示され、正しい方向になるように画像をトリミングして回転します。", "crop-and-rotate-the-image": "テキストのみが表示され、正しい方向になるように画像をトリミングして回転します。",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "처음부터 새로운 레시피를 만드세요.", "create-recipe-description": "처음부터 새로운 레시피를 만드세요.",
"create-recipes": "레시피 생성", "create-recipes": "레시피 생성",
"import-with-zip": ".zip 파일로 가져오기", "import-with-zip": ".zip 파일로 가져오기",
"create-recipe-from-an-image": "이미지에서 레시피 생성", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "레시피 텍스트 이미지를 업로드하여 레시피를 생성하세요. Mealie는 AI를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.", "create-recipe-from-an-image-description": "레시피 텍스트 이미지를 업로드하여 레시피를 생성하세요. Mealie는 AI를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.",
"crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.", "crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.",
"create-from-images": "이미지에서 생성", "create-from-images": "이미지에서 생성",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Įkelti naudojant .zip failus", "import-with-zip": "Įkelti naudojant .zip failus",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Kurti iš vaizdų", "create-from-images": "Kurti iš vaizdų",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Izveidojiet jaunu recepti no nulles.", "create-recipe-description": "Izveidojiet jaunu recepti no nulles.",
"create-recipes": "Izveidojiet receptes", "create-recipes": "Izveidojiet receptes",
"import-with-zip": "Importēt ar .zip", "import-with-zip": "Importēt ar .zip",
"create-recipe-from-an-image": "Izveidojiet recepti no attēla", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Izveidojiet recepti, augšupielādējot tās attēlu. Mealie mēģinās iegūt tekstu no attēla, izmantojot AI, un no tā izveidot recepti.", "create-recipe-from-an-image-description": "Izveidojiet recepti, augšupielādējot tās attēlu. Mealie mēģinās iegūt tekstu no attēla, izmantojot AI, un no tā izveidot recepti.",
"crop-and-rotate-the-image": "Apgrieziet un pagrieziet attēlu tā, lai būtu redzams tikai teksts un tas būtu pareizajā orientācijā.", "crop-and-rotate-the-image": "Apgrieziet un pagrieziet attēlu tā, lai būtu redzams tikai teksts un tas būtu pareizajā orientācijā.",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Maak een nieuw recept.", "create-recipe-description": "Maak een nieuw recept.",
"create-recipes": "Recepten aanmaken", "create-recipes": "Recepten aanmaken",
"import-with-zip": "Importeer met .zip", "import-with-zip": "Importeer met .zip",
"create-recipe-from-an-image": "Maak recept van de tekst op een afbeelding", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Maak een recept door een afbeelding ervan te uploaden. Mealie probeert de tekst met behulp van AI uit de afbeelding te halen en er een recept uit te maken.", "create-recipe-from-an-image-description": "Maak een recept door een afbeelding ervan te uploaden. Mealie probeert de tekst met behulp van AI uit de afbeelding te halen en er een recept uit te maken.",
"crop-and-rotate-the-image": "Snijd de afbeelding bij zodat alleen tekst zichtbaar is. En draai t plaatje zodat het leesbaar is.", "crop-and-rotate-the-image": "Snijd de afbeelding bij zodat alleen tekst zichtbaar is. En draai t plaatje zodat het leesbaar is.",
"create-from-images": "Maak recept van een afbeelding", "create-from-images": "Maak recept van een afbeelding",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Opprett en ny oppskrift fra bunnen av.", "create-recipe-description": "Opprett en ny oppskrift fra bunnen av.",
"create-recipes": "Opprett oppskrifter", "create-recipes": "Opprett oppskrifter",
"import-with-zip": "Importer fra .zip-fil", "import-with-zip": "Importer fra .zip-fil",
"create-recipe-from-an-image": "Opprett oppskrift fra et bilde", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Opprett en oppskrift ved å laste opp et bilde av den. Mealie vil forsøke å hente ut teksten fra bildet ved bruk av AI, og lage en ny oppskrift.", "create-recipe-from-an-image-description": "Opprett en oppskrift ved å laste opp et bilde av den. Mealie vil forsøke å hente ut teksten fra bildet ved bruk av AI, og lage en ny oppskrift.",
"crop-and-rotate-the-image": "Beskjær og roter bildet slik at bare teksten er synlig, og at det er i riktig retning.", "crop-and-rotate-the-image": "Beskjær og roter bildet slik at bare teksten er synlig, og at det er i riktig retning.",
"create-from-images": "Opprett fra bilde", "create-from-images": "Opprett fra bilde",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Utwórz nowy przepis od zera.", "create-recipe-description": "Utwórz nowy przepis od zera.",
"create-recipes": "Utwórz przepisy", "create-recipes": "Utwórz przepisy",
"import-with-zip": "Importuj z pliku .zip", "import-with-zip": "Importuj z pliku .zip",
"create-recipe-from-an-image": "Utwórz przepis z obrazów", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Utwórz przepis poprzez przesłanie obrazów tekstu przepisu. Mealie spróbuje wyodrębnić tekst z obrazów za pomocą AI i utworzyć z niego przepis.", "create-recipe-from-an-image-description": "Utwórz przepis poprzez przesłanie obrazów tekstu przepisu. Mealie spróbuje wyodrębnić tekst z obrazów za pomocą AI i utworzyć z niego przepis.",
"crop-and-rotate-the-image": "Przytnij i obróć obraz, tak aby był w odpowiedniej orientacji i był widoczny tylko tekst.", "crop-and-rotate-the-image": "Przytnij i obróć obraz, tak aby był w odpowiedniej orientacji i był widoczny tylko tekst.",
"create-from-images": "Utwórz przepis z obrazów", "create-from-images": "Utwórz przepis z obrazów",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Criar uma receita do zero.", "create-recipe-description": "Criar uma receita do zero.",
"create-recipes": "Criar Receitas", "create-recipes": "Criar Receitas",
"import-with-zip": "Importar a partir de .zip", "import-with-zip": "Importar a partir de .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Corte e gire a imagem para que apenas o texto esteja visível e esteja na posição correta.", "crop-and-rotate-the-image": "Corte e gire a imagem para que apenas o texto esteja visível e esteja na posição correta.",
"create-from-images": "Criar a partir de imagens", "create-from-images": "Criar a partir de imagens",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Criar uma receita em branco.", "create-recipe-description": "Criar uma receita em branco.",
"create-recipes": "Criar Receitas", "create-recipes": "Criar Receitas",
"import-with-zip": "Importar com .zip", "import-with-zip": "Importar com .zip",
"create-recipe-from-an-image": "Criar receita a partir de uma imagem", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Crie uma receita carregando uma imagem da mesma. O Mealie tentará extrair o texto da imagem utilizando IA e criará uma receita a partir da mesma.", "create-recipe-from-an-image-description": "Crie uma receita carregando uma imagem da mesma. O Mealie tentará extrair o texto da imagem utilizando IA e criará uma receita a partir da mesma.",
"crop-and-rotate-the-image": "Recorte e rode a imagem de modo a que apenas o texto seja visível e esteja na orientação correta.", "crop-and-rotate-the-image": "Recorte e rode a imagem de modo a que apenas o texto seja visível e esteja na orientação correta.",
"create-from-images": "Criar a partir de Imagens", "create-from-images": "Criar a partir de Imagens",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Creează o rețetă nouă de la zero.", "create-recipe-description": "Creează o rețetă nouă de la zero.",
"create-recipes": "Crează rețetă", "create-recipes": "Crează rețetă",
"import-with-zip": "Importă cu .zip", "import-with-zip": "Importă cu .zip",
"create-recipe-from-an-image": "Creează o rețetă dintr-o imagine", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Creează o rețetă prin încărcarea unei imagini a acesteia. Mealie va încerca să extragă textul din imagine folosind AI și să creeze o rețetă din el.", "create-recipe-from-an-image-description": "Creează o rețetă prin încărcarea unei imagini a acesteia. Mealie va încerca să extragă textul din imagine folosind AI și să creeze o rețetă din el.",
"crop-and-rotate-the-image": "Decupați și rotiți imaginea astfel încât numai textul să fie vizibil, iar orientarea să fie corectă.", "crop-and-rotate-the-image": "Decupați și rotiți imaginea astfel încât numai textul să fie vizibil, iar orientarea să fie corectă.",
"create-from-images": "Creează din Imagini", "create-from-images": "Creează din Imagini",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Создать новый рецепт с нуля.", "create-recipe-description": "Создать новый рецепт с нуля.",
"create-recipes": "Создать Рецепт", "create-recipes": "Создать Рецепт",
"import-with-zip": "Импорт из .zip", "import-with-zip": "Импорт из .zip",
"create-recipe-from-an-image": "Создать рецепт из изображения", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Создайте рецепт, загрузив изображение. Mealie попытается извлечь текст из изображения с помощью ИИ и создать рецепт из него.", "create-recipe-from-an-image-description": "Создайте рецепт, загрузив изображение. Mealie попытается извлечь текст из изображения с помощью ИИ и создать рецепт из него.",
"crop-and-rotate-the-image": "Обрежьте и поверните изображение так, чтобы виден был только текст в нужной ориентации.", "crop-and-rotate-the-image": "Обрежьте и поверните изображение так, чтобы виден был только текст в нужной ориентации.",
"create-from-images": "Создать из изображений", "create-from-images": "Создать из изображений",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Vytvoriť nový recept od začiatku.", "create-recipe-description": "Vytvoriť nový recept od začiatku.",
"create-recipes": "Vytvoriť recept", "create-recipes": "Vytvoriť recept",
"import-with-zip": "Importovať .zip súbor", "import-with-zip": "Importovať .zip súbor",
"create-recipe-from-an-image": "Vytvoriť recept z obrázka", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Vytvoriť recept nahraním fotografie jedla. Mealie sa pokúsi previesť obrázok na text pomocou AI a vytvorí k nemu recept.", "create-recipe-from-an-image-description": "Vytvoriť recept nahraním fotografie jedla. Mealie sa pokúsi previesť obrázok na text pomocou AI a vytvorí k nemu recept.",
"crop-and-rotate-the-image": "Orežte a otočte obrázok tak, aby bol viditeľný iba text a aby mal správnu orientáciu.", "crop-and-rotate-the-image": "Orežte a otočte obrázok tak, aby bol viditeľný iba text a aby mal správnu orientáciu.",
"create-from-images": "Vytvoriť z obrázka", "create-from-images": "Vytvoriť z obrázka",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Ustvari nov recept.", "create-recipe-description": "Ustvari nov recept.",
"create-recipes": "Ustvari recepte", "create-recipes": "Ustvari recepte",
"import-with-zip": "Uvozi z .zip", "import-with-zip": "Uvozi z .zip",
"create-recipe-from-an-image": "Ustvari recept iz slike", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Ustvarite recept tako, da naložite njegovo sliko. Mealie bo s pomočjo umetne inteligence poskušal izluščiti besedilo iz slike in iz njega ustvariti recept.", "create-recipe-from-an-image-description": "Ustvarite recept tako, da naložite njegovo sliko. Mealie bo s pomočjo umetne inteligence poskušal izluščiti besedilo iz slike in iz njega ustvariti recept.",
"crop-and-rotate-the-image": "Obrežite in zasukajte sliko, tako da bo vidno samo besedilo in da bo v pravilnem položaju.", "crop-and-rotate-the-image": "Obrežite in zasukajte sliko, tako da bo vidno samo besedilo in da bo v pravilnem položaju.",
"create-from-images": "Ustvari iz slik", "create-from-images": "Ustvari iz slik",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Креирајте нови рецепт од нуле.", "create-recipe-description": "Креирајте нови рецепт од нуле.",
"create-recipes": "Направите рецепте", "create-recipes": "Направите рецепте",
"import-with-zip": "Увези помоћу .zip архиве", "import-with-zip": "Увези помоћу .zip архиве",
"create-recipe-from-an-image": "Направи рецепт на основи слике", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Креирајте рецепт отпремањем његове слике. Mealie ће покушати да издвоји текст из слике користећи АИ и од њега направи рецепт.", "create-recipe-from-an-image-description": "Креирајте рецепт отпремањем његове слике. Mealie ће покушати да издвоји текст из слике користећи АИ и од њега направи рецепт.",
"crop-and-rotate-the-image": "Исеците и ротирајте слику тако да је видљив само текст и да је у исправној оријентацији.", "crop-and-rotate-the-image": "Исеците и ротирајте слику тако да је видљив само текст и да је у исправној оријентацији.",
"create-from-images": "Креирај из слика", "create-from-images": "Креирај из слика",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Skapa nytt recept från grunden.", "create-recipe-description": "Skapa nytt recept från grunden.",
"create-recipes": "Skapa recept", "create-recipes": "Skapa recept",
"import-with-zip": "Importera från .zip", "import-with-zip": "Importera från .zip",
"create-recipe-from-an-image": "Skapa recept från en bild", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Skapa ett recept genom att ladda upp en bild på det. Mealie kommer att försöka extrahera texten från bilden med hjälp av AI och skapa ett recept från det.", "create-recipe-from-an-image-description": "Skapa ett recept genom att ladda upp en bild på det. Mealie kommer att försöka extrahera texten från bilden med hjälp av AI och skapa ett recept från det.",
"crop-and-rotate-the-image": "Beskär och rotera bilden så att endast texten är synlig och den är åt rätt håll.", "crop-and-rotate-the-image": "Beskär och rotera bilden så att endast texten är synlig och den är åt rätt håll.",
"create-from-images": "Skapa från bild", "create-from-images": "Skapa från bild",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Sıfırdan yeni bir tarif oluşturun.", "create-recipe-description": "Sıfırdan yeni bir tarif oluşturun.",
"create-recipes": "Tarif Oluştur", "create-recipes": "Tarif Oluştur",
"import-with-zip": ".zip ile içe aktar", "import-with-zip": ".zip ile içe aktar",
"create-recipe-from-an-image": "Görüntüden yemek tarifi oluştur", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Bir görsel yükleyerek yemek tarifi oluşturun. Mealie, yapay zekâ kullanarak görseldeki bilgileri çekip yemek tarifini oluşturmaya çalışacaktır.", "create-recipe-from-an-image-description": "Bir görsel yükleyerek yemek tarifi oluşturun. Mealie, yapay zekâ kullanarak görseldeki bilgileri çekip yemek tarifini oluşturmaya çalışacaktır.",
"crop-and-rotate-the-image": "Resmi sadece yazılar gözükecek şekilde kesin ve döndürün, ayrıca doğru yönde durduğuna emin olun.", "crop-and-rotate-the-image": "Resmi sadece yazılar gözükecek şekilde kesin ve döndürün, ayrıca doğru yönde durduğuna emin olun.",
"create-from-images": "Resimden Oluştur", "create-from-images": "Resimden Oluştur",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Створити новий рецепт з нуля.", "create-recipe-description": "Створити новий рецепт з нуля.",
"create-recipes": "Створити рецепти", "create-recipes": "Створити рецепти",
"import-with-zip": "Імпорт з .zip", "import-with-zip": "Імпорт з .zip",
"create-recipe-from-an-image": "Створити рецепт з зображення", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Створити рецепт, завантаживши його зображення. Mealie спробує витягти текст з зображення за допомогою ШІ та створити рецепт з нього.", "create-recipe-from-an-image-description": "Створити рецепт, завантаживши його зображення. Mealie спробує витягти текст з зображення за допомогою ШІ та створити рецепт з нього.",
"crop-and-rotate-the-image": "Обріжте і поверніть зображення так, щоб було видно лише текст в правильній орієнтації.", "crop-and-rotate-the-image": "Обріжте і поверніть зображення так, щоб було видно лише текст в правильній орієнтації.",
"create-from-images": "Створити з зображень", "create-from-images": "Створити з зображень",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip", "import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Create from Images", "create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "从头创建一个新食谱。", "create-recipe-description": "从头创建一个新食谱。",
"create-recipes": "创建食谱", "create-recipes": "创建食谱",
"import-with-zip": "使用 .zip 导入", "import-with-zip": "使用 .zip 导入",
"create-recipe-from-an-image": "用图片创建食谱", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "通过上传食谱文本的图片来创建一个食谱。Mealie将尝试使用人工智能从图像中提取文本并从中创建一个新的食谱。", "create-recipe-from-an-image-description": "通过上传食谱文本的图片来创建一个食谱。Mealie将尝试使用人工智能从图像中提取文本并从中创建一个新的食谱。",
"crop-and-rotate-the-image": "裁剪并旋转图片,使仅文字可见且方向正确。", "crop-and-rotate-the-image": "裁剪并旋转图片,使仅文字可见且方向正确。",
"create-from-images": "从图片创建", "create-from-images": "从图片创建",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "從頭開始建立新食譜。", "create-recipe-description": "從頭開始建立新食譜。",
"create-recipes": "建立食譜", "create-recipes": "建立食譜",
"import-with-zip": "以 .zip 匯入", "import-with-zip": "以 .zip 匯入",
"create-recipe-from-an-image": "從圖片建立食譜", "create-recipe-from-images": "Create Recipe from Images",
"create-recipe-from-an-image-description": "上傳食譜圖片來建立食譜Mealie 將嘗試使用 AI 從圖片中擷取文字並建立食譜。", "create-recipe-from-an-image-description": "上傳食譜圖片來建立食譜Mealie 將嘗試使用 AI 從圖片中擷取文字並建立食譜。",
"crop-and-rotate-the-image": "請裁切並旋轉圖片,使其僅顯示文字且方向正確。", "crop-and-rotate-the-image": "請裁切並旋轉圖片,使其僅顯示文字且方向正確。",
"create-from-images": "從圖片建立", "create-from-images": "從圖片建立",

View File

@@ -3,7 +3,7 @@
<v-form ref="domUrlForm" @submit.prevent="createRecipe"> <v-form ref="domUrlForm" @submit.prevent="createRecipe">
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
{{ $t("recipe.create-recipe-from-an-image") }} {{ $t("recipe.create-recipe-from-images") }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p> <p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p>

View File

@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session, load_only
from alembic import op from alembic import op
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View File

@@ -8,8 +8,8 @@ from mealie.core import root_logger
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.group.group import Group from mealie.db.models.group.group import Group
from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import User from mealie.db.models.users.users import User

View File

@@ -1,5 +1,4 @@
from .group import * from .group import *
from .labels import *
from .recipe import * from .recipe import *
from .server import * from .server import *
from .users import * from .users import *

View File

@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
from sqlalchemy.orm import Mapped, mapped_column
class _FilterableColumn[T]:
"""
Drop-in replacement for `Mapped[]` that marks a column as filterable.
Filterable columns can be used in query filter expressions.
Only valid on scalar column fields. Using it on a relationship type (e.g. `list[Model]`).
"""
def __class_getitem__(cls, item: type) -> type:
return Mapped[Annotated[item, mapped_column(info={"filterable": True})]]
# SQLAlchemy doesn't play nice with mypy when overriding Mapped, so
# we use this awkward workaround to make mypy happy
if TYPE_CHECKING:
FilterableColumn = Mapped
else:
FilterableColumn = _FilterableColumn

View File

@@ -5,6 +5,7 @@ from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
from text_unidecode import unidecode from text_unidecode import unidecode
from ._filterable_column import FilterableColumn
from ._model_utils.datetime import NaiveDateTime, get_utc_now from ._model_utils.datetime import NaiveDateTime, get_utc_now
# Punctuation characters replaced with spaces during text normalization. # Punctuation characters replaced with spaces during text normalization.
@@ -16,8 +17,10 @@ _NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NO
class SqlAlchemyBase(DeclarativeBase): class SqlAlchemyBase(DeclarativeBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True) created_at: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now) update_at: FilterableColumn[datetime | None] = mapped_column(
NaiveDateTime, default=get_utc_now, onupdate=get_utc_now
)
@declared_attr @declared_attr
def updated_at(cls) -> Mapped[datetime | None]: def updated_at(cls) -> Mapped[datetime | None]:

View File

@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..household.cookbook import CookBook from ..household.cookbook import CookBook
@@ -31,9 +31,9 @@ if TYPE_CHECKING:
class Group(SqlAlchemyBase, BaseMixins): class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups" __tablename__ = "groups"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group") households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group") users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True) categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils import guid from .._model_utils import guid
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from ..recipe.category import Category, cookbooks_to_categories from ..recipe.category import Category, cookbooks_to_categories
@@ -21,31 +21,31 @@ class CookBook(SqlAlchemyBase, BaseMixins):
UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"), UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"),
) )
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=1)
group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True) group_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks")
household_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True) household_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks") household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks")
name: Mapped[str] = mapped_column(String, nullable=False) name: FilterableColumn[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, index=True) slug: FilterableColumn[str] = mapped_column(String, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String, default="") description: FilterableColumn[str | None] = mapped_column(String, default="")
public: Mapped[str | None] = mapped_column(Boolean, default=False) public: FilterableColumn[str | None] = mapped_column(Boolean, default=False)
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="") query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
# Old filters - deprecated in favor of query filter strings # Old filters - deprecated in favor of query filter strings
categories: Mapped[list[Category]] = orm.relationship( categories: Mapped[list[Category]] = orm.relationship(
Category, secondary=cookbooks_to_categories, single_parent=True Category, secondary=cookbooks_to_categories, single_parent=True
) )
require_all_categories: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_categories: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True) tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
require_all_tags: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_tags: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True) tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_tools: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import households_to_ingredient_foods from ..recipe.ingredient import households_to_ingredient_foods
@@ -33,9 +33,9 @@ class Household(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"), sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"),
) )
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str | None] = mapped_column(sa.String, index=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship( invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan" "GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
@@ -48,7 +48,7 @@ class Household(SqlAlchemyBase, BaseMixins):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="households") group: Mapped["Group"] = orm.relationship("Group", back_populates="households")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="household") users: Mapped[list["User"]] = orm.relationship("User", back_populates="household")

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.category import Category, plan_rules_to_categories from ..recipe.category import Category, plan_rules_to_categories
@@ -30,14 +30,14 @@ plan_rules_to_households = Table(
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules" __tablename__ = "group_meal_plan_rules"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True) household_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
day: Mapped[str] = mapped_column( day: FilterableColumn[str] = mapped_column(
String, nullable=False, default="unset" String, nullable=False, default="unset"
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc... ) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type: Mapped[str] = mapped_column( entry_type: FilterableColumn[str] = mapped_column(
String, nullable=False, default="" String, nullable=False, default=""
) # "breakfast", "lunch", "dinner", etc ... ) # "breakfast", "lunch", "dinner", etc ...
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="") query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
@@ -55,19 +55,19 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins): class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans" __tablename__ = "group_meal_plans"
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False) date: FilterableColumn[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False) entry_type: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
title: Mapped[str] = mapped_column(String, index=True, nullable=False) title: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
text: Mapped[str] = mapped_column(String, nullable=False) text: FilterableColumn[str] = mapped_column(String, nullable=False)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True) group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True) user_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans") user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship( recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", back_populates="meal_entries", uselist=False "RecipeModel", back_populates="meal_entries", uselist=False
) )

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -15,27 +15,29 @@ if TYPE_CHECKING:
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins): class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "household_preferences" __tablename__ = "household_preferences"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
household_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("households.id"), nullable=False, index=True) household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("households.id"), nullable=False, index=True
)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences") household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences")
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id") group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) private_household: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True) show_announcements: FilterableColumn[bool] = mapped_column(sa.Boolean, default=True)
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) lock_recipe_edits_from_other_households: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0) first_day_of_week: FilterableColumn[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults # Recipe Defaults
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) recipe_public: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated # Deprecated
recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) recipe_disable_amount: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -8,10 +8,10 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
@@ -25,18 +25,20 @@ if TYPE_CHECKING:
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference" __tablename__ = "shopping_list_item_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship( shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship(
"ShoppingListItem", back_populates="recipe_references" "ShoppingListItem", back_populates="recipe_references"
) )
shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True) shopping_list_item_id: FilterableColumn[GUID] = mapped_column(
GUID, ForeignKey("shopping_list_items.id"), primary_key=True
)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs") recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
recipe_scale: Mapped[float] = mapped_column(Float, default=1) recipe_scale: FilterableColumn[float] = mapped_column(Float, default=1)
recipe_note: Mapped[str | None] = mapped_column(String) recipe_note: FilterableColumn[str | None] = mapped_column(String)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
@@ -50,33 +52,33 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items" __tablename__ = "shopping_list_items"
# Id's # Id's
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items")
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True) shopping_list_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
# Meta # Meta
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True) is_ingredient: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: Mapped[bool | None] = mapped_column(Boolean, default=False) checked: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
quantity: Mapped[float | None] = mapped_column(Float, default=1) quantity: FilterableColumn[float | None] = mapped_column(Float, default=1)
note: Mapped[str | None] = mapped_column(String) note: FilterableColumn[str | None] = mapped_column(String)
extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship( extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
"ShoppingListItemExtras", cascade="all, delete-orphan" "ShoppingListItemExtras", cascade="all, delete-orphan"
) )
# Scaling Items # Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id")) unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id")) food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id")) label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label: Mapped[MultiPurposeLabel | None] = orm.relationship( label: Mapped[MultiPurposeLabel | None] = orm.relationship(
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items" MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
) )
@@ -98,19 +100,19 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference" __tablename__ = "shopping_list_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references")
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship( recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", uselist=False, back_populates="shopping_list_refs" "RecipeModel", uselist=False, back_populates="shopping_list_refs"
) )
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
model_config = ConfigDict(exclude={"id", "recipe"}) model_config = ConfigDict(exclude={"id", "recipe"})
@auto_init() @auto_init()
@@ -121,12 +123,12 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins): class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists_multi_purpose_labels" __tablename__ = "shopping_lists_multi_purpose_labels"
__table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),) __table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True) label_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label: Mapped["MultiPurposeLabel"] = orm.relationship( label: Mapped["MultiPurposeLabel"] = orm.relationship(
"MultiPurposeLabel", back_populates="shopping_lists_label_settings" "MultiPurposeLabel", back_populates="shopping_lists_label_settings"
) )
@@ -134,7 +136,7 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0)
model_config = ConfigDict(exclude={"label"}) model_config = ConfigDict(exclude={"label"})
@auto_init() @auto_init()
@@ -144,16 +146,16 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
class ShoppingList(SqlAlchemyBase, BaseMixins): class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists" __tablename__ = "shopping_lists"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists") group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True) user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists") user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists")
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
list_items: Mapped[list[ShoppingListItem]] = orm.relationship( list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
ShoppingListItem, ShoppingListItem,
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",

View File

@@ -1,7 +1,7 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -28,9 +28,9 @@ class ExtrasGeneric:
This class is not an actual table, so it does not inherit from SqlAlchemyBase This class is not an actual table, so it does not inherit from SqlAlchemyBase
""" """
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
key_name: Mapped[str | None] = mapped_column(sa.String) key_name: FilterableColumn[str | None] = mapped_column(sa.String)
value: Mapped[str | None] = mapped_column(sa.String) value: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, key, value) -> None: def __init__(self, key, value) -> None:
self.key_name = key self.key_name = key
@@ -40,21 +40,25 @@ class ExtrasGeneric:
# used specifically for recipe extras # used specifically for recipe extras
class ApiExtras(ExtrasGeneric, SqlAlchemyBase): class ApiExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "api_extras" __tablename__ = "api_extras"
recipee_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipee_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
class IngredientFoodExtras(ExtrasGeneric, SqlAlchemyBase): class IngredientFoodExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "ingredient_food_extras" __tablename__ = "ingredient_food_extras"
ingredient_food_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("ingredient_foods.id"), index=True) ingredient_food_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("ingredient_foods.id"), index=True
)
class ShoppingListExtras(ExtrasGeneric, SqlAlchemyBase): class ShoppingListExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_extras" __tablename__ = "shopping_list_extras"
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("shopping_lists.id"), index=True) shopping_list_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_lists.id"), index=True
)
class ShoppingListItemExtras(ExtrasGeneric, SqlAlchemyBase): class ShoppingListItemExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_item_extras" __tablename__ = "shopping_list_item_extras"
shopping_list_item_id: Mapped[GUID | None] = mapped_column( shopping_list_item_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_list_items.id"), index=True GUID, sa.ForeignKey("shopping_list_items.id"), index=True
) )

View File

@@ -1,17 +1,17 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class RecipeAsset(SqlAlchemyBase): class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets" __tablename__ = "recipe_assets"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: Mapped[str | None] = mapped_column(sa.String) name: FilterableColumn[str | None] = mapped_column(sa.String)
icon: Mapped[str | None] = mapped_column(sa.String) icon: FilterableColumn[str | None] = mapped_column(sa.String)
file_name: Mapped[str | None] = mapped_column(sa.String) file_name: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, name=None, icon=None, file_name=None) -> None: def __init__(self, name=None, icon=None, file_name=None) -> None:
self.name = name self.name = name

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -54,12 +54,12 @@ class Category(SqlAlchemyBase, BaseMixins):
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category" "RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category"
) )

View File

@@ -6,9 +6,9 @@ from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -29,19 +29,19 @@ households_to_ingredient_foods = sa.Table(
class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units" __tablename__ = "ingredient_units"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: FilterableColumn[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: FilterableColumn[str | None] = mapped_column(String)
abbreviation: Mapped[str | None] = mapped_column(String) abbreviation: FilterableColumn[str | None] = mapped_column(String)
plural_abbreviation: Mapped[str | None] = mapped_column(String) plural_abbreviation: FilterableColumn[str | None] = mapped_column(String)
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False) use_abbreviation: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True) fraction: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="unit" "RecipeIngredientModel", back_populates="unit"
@@ -53,14 +53,14 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
) )
# Standardization # Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float) standard_quantity: FilterableColumn[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String) standard_unit: FilterableColumn[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True) abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True) plural_abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init() @auto_init()
def __init__( def __init__(
@@ -152,18 +152,18 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins): class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods" __tablename__ = "ingredient_foods"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship( households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand" "Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
) )
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: FilterableColumn[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: FilterableColumn[str | None] = mapped_column(String)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food" "RecipeIngredientModel", back_populates="food"
@@ -175,12 +175,12 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
) )
extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan") extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan")
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True) label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True)
label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods") label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods")
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict( model_config = ConfigDict(
exclude={ exclude={
@@ -261,15 +261,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins): class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units_aliases" __tablename__ = "ingredient_units_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
unit_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True) unit_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True)
unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases") unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String) name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init() @auto_init()
def __init__(self, session: Session, name: str, **_) -> None: def __init__(self, session: Session, name: str, **_) -> None:
@@ -302,15 +302,15 @@ class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins): class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods_aliases" __tablename__ = "ingredient_foods_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
food_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True) food_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True)
food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases") food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String) name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init() @auto_init()
def __init__(self, session: Session, name: str, **_) -> None: def __init__(self, session: Session, name: str, **_) -> None:
@@ -343,34 +343,34 @@ class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients" __tablename__ = "recipes_ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(Integer, primary_key=True)
position: Mapped[int | None] = mapped_column(Integer, index=True) position: FilterableColumn[int | None] = mapped_column(Integer, index=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id")) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present title: FilterableColumn[str | None] = mapped_column(String) # Section Header - Shows if Present
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat note: FilterableColumn[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
# Scaling Items # Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True) unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True)
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True) food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True)
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
quantity: Mapped[float | None] = mapped_column(Float) quantity: FilterableColumn[float | None] = mapped_column(Float)
original_text: Mapped[str | None] = mapped_column(String) original_text: FilterableColumn[str | None] = mapped_column(String)
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links reference_id: FilterableColumn[GUID | None] = mapped_column(GUID) # Reference Links
# Recipe Reference # Recipe Reference
referenced_recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) referenced_recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe: Mapped["RecipeModel"] = orm.relationship( referenced_recipe: Mapped["RecipeModel"] = orm.relationship(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id] "RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
) )
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True) note_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True) original_text_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init() @auto_init()
def __init__( def __init__(

View File

@@ -3,26 +3,26 @@ from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, UniqueConstraint, orm from sqlalchemy import ForeignKey, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from ._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from .group.group import Group from ..group.group import Group
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel from ..household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from .recipe import IngredientFoodModel from . import IngredientFoodModel
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels" __tablename__ = "multi_purpose_labels"
__table_args__ = (UniqueConstraint("name", "group_id", name="multi_purpose_labels_name_group_id_key"),) __table_args__ = (UniqueConstraint("name", "group_id", name="multi_purpose_labels_name_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True) id: FilterableColumn[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: FilterableColumn[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(10), nullable=False, default="") color: FilterableColumn[str] = mapped_column(String(10), nullable=False, default="")
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="labels") group: Mapped["Group"] = orm.relationship("Group", back_populates="labels")
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label") shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")

View File

@@ -1,22 +1,22 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase): class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition" __tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
calories: Mapped[str | None] = mapped_column(sa.String) calories: FilterableColumn[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) carbohydrate_content: FilterableColumn[str | None] = mapped_column(sa.String)
cholesterol_content: Mapped[str | None] = mapped_column(sa.String) cholesterol_content: FilterableColumn[str | None] = mapped_column(sa.String)
fat_content: Mapped[str | None] = mapped_column(sa.String) fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
fiber_content: Mapped[str | None] = mapped_column(sa.String) fiber_content: FilterableColumn[str | None] = mapped_column(sa.String)
protein_content: Mapped[str | None] = mapped_column(sa.String) protein_content: FilterableColumn[str | None] = mapped_column(sa.String)
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String) saturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
# `serving_size` is not a scaling factor, but a per-serving volume or mass # `serving_size` is not a scaling factor, but a per-serving volume or mass
# according to schema.org. E.g., "2 L", "500 g", "5 cups", etc. # according to schema.org. E.g., "2 L", "500 g", "5 cups", etc.
@@ -28,10 +28,10 @@ class Nutrition(SqlAlchemyBase):
# #
# serving_size: Mapped[str | None] = mapped_column(sa.String) # serving_size: Mapped[str | None] = mapped_column(sa.String)
sodium_content: Mapped[str | None] = mapped_column(sa.String) sodium_content: FilterableColumn[str | None] = mapped_column(sa.String)
sugar_content: Mapped[str | None] = mapped_column(sa.String) sugar_content: FilterableColumn[str | None] = mapped_column(sa.String)
trans_fat_content: Mapped[str | None] = mapped_column(sa.String) trans_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String) unsaturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__( def __init__(
self, self,

View File

@@ -16,7 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.recipe.ingredient import RecipeIngredientModel from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras from .api_extras import ApiExtras, api_extras
@@ -45,20 +45,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"), sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),
) )
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: Mapped[str | None] = mapped_column(sa.String, index=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True) user_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id]) user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True) rating: FilterableColumn[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rated_by: Mapped[list["User"]] = orm.relationship( rated_by: Mapped[list["User"]] = orm.relationship(
"User", "User",
secondary=UserToRecipe.__tablename__, secondary=UserToRecipe.__tablename__,
@@ -78,20 +78,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
# General Recipe Properties # General Recipe Properties
name: Mapped[str] = mapped_column(sa.String, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, nullable=False)
description: Mapped[str | None] = mapped_column(sa.String) description: FilterableColumn[str | None] = mapped_column(sa.String)
image: Mapped[str | None] = mapped_column(sa.String) image: FilterableColumn[str | None] = mapped_column(sa.String)
# Time Related Properties # Time Related Properties
total_time: Mapped[str | None] = mapped_column(sa.String) total_time: FilterableColumn[str | None] = mapped_column(sa.String)
prep_time: Mapped[str | None] = mapped_column(sa.String) prep_time: FilterableColumn[str | None] = mapped_column(sa.String)
perform_time: Mapped[str | None] = mapped_column(sa.String) perform_time: FilterableColumn[str | None] = mapped_column(sa.String)
cook_time: Mapped[str | None] = mapped_column(sa.String) cook_time: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield: Mapped[str | None] = mapped_column(sa.String) recipe_yield: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0) recipe_yield_quantity: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0) recipe_servings: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan") assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
@@ -137,14 +137,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan") notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
org_url: Mapped[str | None] = mapped_column(sa.String) org_url: FilterableColumn[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan") extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
# Time Stamp Properties # Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) date_added: FilterableColumn[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) date_updated: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) last_made: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
made_by: Mapped[list["Household"]] = orm.relationship( made_by: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes" "Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
) )
@@ -162,8 +162,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) name_normalized: FilterableColumn[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) description_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict( model_config = ConfigDict(
get_attr="slug", get_attr="slug",
exclude={ exclude={

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -18,29 +18,29 @@ if TYPE_CHECKING:
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_timeline_events" __tablename__ = "recipe_timeline_events"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# Parent Recipe # Parent Recipe
recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True) recipe_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events") recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id") group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id") household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# Related User (Actor) # Related User (Actor)
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True) user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id] "User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
) )
# General Properties # General Properties
subject: Mapped[str] = mapped_column(String, nullable=False) subject: FilterableColumn[str] = mapped_column(String, nullable=False)
message: Mapped[str | None] = mapped_column(String) message: FilterableColumn[str | None] = mapped_column(String)
event_type: Mapped[str | None] = mapped_column(String) event_type: FilterableColumn[str | None] = mapped_column(String)
image: Mapped[str | None] = mapped_column(String) image: FilterableColumn[str | None] = mapped_column(String)
# Timestamps # Timestamps
timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True) timestamp: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, index=True)
@auto_init() @auto_init()
def __init__( def __init__(

View File

@@ -1,20 +1,20 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase): class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings" __tablename__ = "recipe_settings"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: Mapped[bool | None] = mapped_column(sa.Boolean) public: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean) show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_assets: Mapped[bool | None] = mapped_column(sa.Boolean) show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean) landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) locked: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated # Deprecated
disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)

View File

@@ -3,9 +3,9 @@ from typing import TYPE_CHECKING
from uuid import uuid4 from uuid import uuid4
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -22,12 +22,12 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens" __tablename__ = "recipe_share_tokens"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4)
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True) recipe_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False) recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False) expires_at: FilterableColumn[datetime] = mapped_column(NaiveDateTime, nullable=False)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -6,7 +6,7 @@ from slugify import slugify
from sqlalchemy.orm import Mapped, mapped_column, validates from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils import guid from mealie.db.models._model_utils import guid
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -44,14 +44,16 @@ cookbooks_to_tags = sa.Table(
class Tag(SqlAlchemyBase, BaseMixins): class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags" __tablename__ = "tags"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[guid.GUID] = mapped_column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[guid.GUID] = mapped_column(
guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True
)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tags, back_populates="tags" "RecipeModel", secondary=recipes_to_tags, back_populates="tags"
) )

View File

@@ -5,7 +5,7 @@ from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -42,14 +42,14 @@ cookbooks_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),) __table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
households_with_tool: Mapped[list["Household"]] = orm.relationship( households_with_tool: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_tools, back_populates="tools_on_hand" "Household", secondary=households_to_tools, back_populates="tools_on_hand"

View File

@@ -13,7 +13,7 @@ from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .user_to_recipe import UserToRecipe from .user_to_recipe import UserToRecipe
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -50,18 +50,21 @@ class AuthMethod(enum.Enum):
class User(SqlAlchemyBase, BaseMixins): class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name: Mapped[str | None] = mapped_column(String, index=True) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
username: Mapped[str | None] = mapped_column(String, index=True, unique=True) full_name: FilterableColumn[str | None] = mapped_column(String, index=True)
username: FilterableColumn[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True) email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String) password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE) auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False) admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False) advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="users") group: Mapped["Group"] = orm.relationship("Group", back_populates="users")
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), nullable=True, index=True) household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, ForeignKey("households.id"), nullable=True, index=True
)
household: Mapped["Household"] = orm.relationship("Household", back_populates="users") household: Mapped["Household"] = orm.relationship("Household", back_populates="users")
cache_key: Mapped[str | None] = mapped_column(String, default="1234") cache_key: Mapped[str | None] = mapped_column(String, default="1234")

View File

@@ -25,10 +25,10 @@ from mealie.db.models.household.shopping_list import (
ShoppingListRecipeReference, ShoppingListRecipeReference,
) )
from mealie.db.models.household.webhooks import GroupWebhooksModel from mealie.db.models.household.webhooks import GroupWebhooksModel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent
from mealie.db.models.recipe.shared import RecipeShareTokenModel from mealie.db.models.recipe.shared import RecipeShareTokenModel

View File

@@ -25,7 +25,7 @@ from mealie.schema.response.pagination import (
RequestQuery, RequestQuery,
) )
from mealie.schema.response.query_search import SearchFilter from mealie.schema.response.query_search import SearchFilter
from mealie.services.query_filter.builder import QueryFilterBuilder from mealie.services.query_filter.builder import NonFilterableValueError, QueryFilterBuilder
from ._utils import NOT_SET, NotSet from ._utils import NOT_SET, NotSet
@@ -467,6 +467,12 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
query, order_attr, order_dir, request_query.order_by_null_position query, order_attr, order_dir, request_query.order_by_null_position
) )
except NonFilterableValueError as e:
raise HTTPException(
status_code=400,
detail=f'Invalid order_by statement "{request_query.order_by}": {e.message}',
) from e
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View File

@@ -26,7 +26,7 @@ from mealie.schema.recipe.recipe import RecipePagination, RecipeSummary, create_
from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
from mealie.services.query_filter.builder import QueryFilterBuilder from mealie.services.query_filter.builder import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase from ..db.models._model_base import SqlAlchemyBase
@@ -92,6 +92,20 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
) )
return sa.cast(effective_rating, sa.Float) return sa.cast(effective_rating, sa.Float)
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
# Sort recipe names by their normalized (accent-folded, lowercased) form so that
# accented and umlaut characters sort next to their base letter (e.g. "Über" near
# "U") instead of after "Z" by raw code point. See GH #6853.
if order_attr is RecipeModel.name:
order_attr = RecipeModel.name_normalized
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
def create(self, document: Recipe) -> Recipe: # type: ignore def create(self, document: Recipe) -> Recipe: # type: ignore
max_retries = 10 max_retries = 10
original_name: str = document.name # type: ignore original_name: str = document.name # type: ignore

View File

@@ -22,6 +22,17 @@ from .keywords import PlaceholderKeyword, RelationalKeyword
from .operators import LogicalOperator, RelationalOperator from .operators import LogicalOperator, RelationalOperator
class NonFilterableValueError(ValueError):
"""Raised when trying to filter by an unfilterable field"""
def __init__(self, field: str):
self.message = f"Cannot filter on {field}"
super().__init__(self.message)
def __str__(self):
return f"{self.message}"
class QueryFilterJSONPart(MealieModel): class QueryFilterJSONPart(MealieModel):
left_parenthesis: str | None = None left_parenthesis: str | None = None
right_parenthesis: str | None = None right_parenthesis: str | None = None
@@ -202,7 +213,7 @@ class QueryFilterBuilder:
@classmethod @classmethod
def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase]( def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]: ) -> tuple[type[SqlAlchemyBase], InstrumentedAttribute, sa.Select | None]:
""" """
Take an attribute string and traverse a database model and its relationships to get the desired Take an attribute string and traverse a database model and its relationships to get the desired
model and model attribute. Optionally provide a query to apply the necessary table joins. model and model attribute. Optionally provide a query to apply the necessary table joins.
@@ -222,7 +233,7 @@ class QueryFilterBuilder:
if not attribute_chain: if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty") raise ValueError("invalid query string: attribute name cannot be empty")
current_model: SqlAlchemyBase = model # type: ignore current_model: type[SqlAlchemyBase] = model
for i, attribute_link in enumerate(attribute_chain): for i, attribute_link in enumerate(attribute_chain):
try: try:
model_attr = getattr(current_model, attribute_link) model_attr = getattr(current_model, attribute_link)
@@ -259,6 +270,9 @@ class QueryFilterBuilder:
if model_attr is None: if model_attr is None:
raise ValueError(f"invalid attribute string: '{attr_string}'") raise ValueError(f"invalid attribute string: '{attr_string}'")
if not getattr(model_attr, "info", {}).get("filterable"):
raise NonFilterableValueError(model_attr)
return current_model, model_attr, query return current_model, model_attr, query
@classmethod @classmethod
@@ -334,7 +348,7 @@ class QueryFilterBuilder:
column_aliases = column_aliases or {} column_aliases = column_aliases or {}
# join tables and build model chain # join tables and build model chain
attr_model_map: dict[int, Any] = {} attr_map: dict[int, tuple[type[SqlAlchemyBase], InstrumentedAttribute]] = {}
model_attr: InstrumentedAttribute model_attr: InstrumentedAttribute
for i, component in enumerate(self.filter_components): for i, component in enumerate(self.filter_components):
if not isinstance(component, QueryFilterBuilderComponent): if not isinstance(component, QueryFilterBuilderComponent):
@@ -343,7 +357,7 @@ class QueryFilterBuilder:
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string( nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
component.attribute_name, model, query=query component.attribute_name, model, query=query
) )
attr_model_map[i] = nested_model attr_map[i] = (nested_model, model_attr)
# build query filter # build query filter
partial_group: list[sa.ColumnElement] = [] partial_group: list[sa.ColumnElement] = []
@@ -367,9 +381,9 @@ class QueryFilterBuilder:
else: else:
component = cast(QueryFilterBuilderComponent, component) component = cast(QueryFilterBuilderComponent, component)
base_attribute_name = component.attribute_name.split(".")[-1] nested_model, model_attr = attr_map[i]
model_attr = getattr(attr_model_map[i], base_attribute_name)
base_attribute_name = component.attribute_name.split(".")[-1]
if (column_alias := column_aliases.get(base_attribute_name)) is not None: if (column_alias := column_aliases.get(base_attribute_name)) is not None:
model_attr = column_alias model_attr = column_alias

View File

@@ -1,5 +1,11 @@
import pytest
import sqlalchemy as sa
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import LongLiveToken, User
from mealie.services.query_filter.builder import ( from mealie.services.query_filter.builder import (
LogicalOperator, LogicalOperator,
NonFilterableValueError,
QueryFilterBuilder, QueryFilterBuilder,
QueryFilterJSON, QueryFilterJSON,
QueryFilterJSONPart, QueryFilterJSONPart,
@@ -74,3 +80,80 @@ def test_query_filter_builder_json_uses_raw_value():
), ),
] ]
) )
# ---------------------------------------------------------------------------
# FilterableColumn tests
# ---------------------------------------------------------------------------
def test_non_filterable_field_user_password_raises():
"""Filtering on User.password (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("password", User)
def test_non_filterable_field_user_email_raises():
"""Filtering on User.email (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("email", User)
def test_non_filterable_field_long_live_token_raises():
"""Filtering on LongLiveToken.token (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("token", LongLiveToken)
def test_filterable_field_does_not_raise():
"""Filtering on a FilterableColumn field should not raise."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("full_name", User)
assert model is User
assert attr is User.full_name
# ---------------------------------------------------------------------------
# Relationship traversal tests
# ---------------------------------------------------------------------------
def test_deep_traversal_to_filterable_field_works():
"""Traversing a relationship to a FilterableColumn field should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.full_name", RecipeModel)
assert model is User
assert attr is User.full_name
def test_deep_traversal_to_non_filterable_field_raises():
"""Traversing a relationship to a plain Mapped field should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.email", RecipeModel)
def test_deep_traversal_user_password_raises():
"""Traversing RecipeModel.user.password should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.password", RecipeModel)
def test_filter_query_user_email_raises():
"""filter_query on user.email should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.email = "test@example.com"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_filter_query_user_password_raises():
"""filter_query on user.password should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.password = "secret"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_association_proxy_resolving_to_filterable_field_works():
"""Single-hop association proxy (e.g. household_id) resolving to a FilterableColumn should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("household_id", RecipeModel)
assert model is User
assert attr is User.household_id

View File

@@ -647,6 +647,40 @@ def test_order_by_last_made(unique_user: TestUser, h2_user: TestUser):
assert [item.id for item in h2_query.items] == [recipe_2.id, recipe_1.id] assert [item.id for item in h2_query.items] == [recipe_2.id, recipe_1.id]
def test_order_by_name_is_accent_folded(unique_user: TestUser):
# Names chosen so that raw code-point sorting (the previous behavior) would place the
# umlaut/accented entries after "Zebra". Accent-folded sorting must instead place them
# next to their base letter: "ärtsoppa" near "a", "Über" near "u". See GH #6853.
names = ["Zebra", "ärtsoppa", "Apple", "Über"]
recipes = [
unique_user.repos.recipes.create(Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=name))
for name in names
]
recipe_ids = ", ".join(str(recipe.id) for recipe in recipes)
ascending = unique_user.repos.recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="name",
order_direction=OrderDirection.asc,
query_filter=f"id IN [{recipe_ids}]",
)
)
assert [item.name for item in ascending.items] == ["Apple", "ärtsoppa", "Über", "Zebra"]
descending = unique_user.repos.recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="name",
order_direction=OrderDirection.desc,
query_filter=f"id IN [{recipe_ids}]",
)
)
assert [item.name for item in descending.items] == ["Zebra", "Über", "ärtsoppa", "Apple"]
def test_coalesce_last_made(unique_user: TestUser): def test_coalesce_last_made(unique_user: TestUser):
dt = datetime.now(UTC) dt = datetime.now(UTC)

View File

@@ -13,8 +13,8 @@ from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.mealplan import GroupMealPlanRules from mealie.db.models.household.mealplan import GroupMealPlanRules
from mealie.db.models.household.shopping_list import ShoppingList from mealie.db.models.household.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe