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,
icon: $globals.icons.fileImage,
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`,
restricted: true,
hide: !showImageImport.value,

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "إنشاء وصفة جديدة من الصفر.",
"create-recipes": "إنشاء الوصفات",
"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.",
"crop-and-rotate-the-image": "قم بقص الصورة وتدويرها بحيث يظهر النص فقط، ويكون في الاتجاه الصحيح.",
"create-from-images": "إنشاء عن طريق صور",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Създайте нова рецепта от чернова.",
"create-recipes": "Създайте рецепти",
"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.",
"crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.",
"create-from-images": "Създаване от изображения",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crea una nova recepta des de zero.",
"create-recipes": "Crea Receptes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Vytvořit nový recept od nuly.",
"create-recipes": "Vytvořit recepty",
"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.",
"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ů",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Opret ny opskrift fra bunden.",
"create-recipes": "Opret opskrift",
"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.",
"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",

View File

@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Dienstag",
"type": "Typ",
"undo": "Undo",
"undo": "Rückgängig",
"update": "Aktualisieren",
"updated": "Aktualisiert",
"upload": "Hochladen",
@@ -628,7 +628,7 @@
"create-recipe-description": "Erstelle ein neues Rezept von Grund auf.",
"create-recipes": "Rezepte erstellen",
"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.",
"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",
@@ -917,7 +917,7 @@
"quantity": "Menge: {0}",
"shopping-list": "Einkaufsliste",
"shopping-lists": "Einkaufslisten",
"add-item": "Add item",
"add-item": "Eintrag hinzufügen",
"food": "Lebensmittel",
"note": "Notiz",
"label": "Kategorie",
@@ -1478,10 +1478,10 @@
"max-length": "Muss maximal {max} Zeichen haben|Muss maximal {max} Zeichen haben"
},
"announcements": {
"announcements": "Announcements",
"all-announcements": "All announcements",
"announcements": "Ankündigungen",
"all-announcements": "Alle Ankündigungen",
"mark-all-as-read": "Alle als gelesen markieren",
"show-announcements-from-mealie": "Show announcements from Mealie",
"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-from-mealie": "Ankündigung von Mealie anzeigen",
"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-recipes": "Δημιουργία Συνταγών",
"import-with-zip": "Εισαγωγή μέσω .zip",
"create-recipe-from-an-image": "Δημιουργία συνταγής από μια εικόνα",
"create-recipe-from-images": "Δημιουργία συνταγής από εικόνες",
"create-recipe-from-an-image-description": "Δημιουργήστε μια συνταγή ανεβάζοντας μια εικόνα της. Το Mealie θα προσπαθήσει να εξάγει το κείμενο από την εικόνα χρησιμοποιώντας τεχνητή νοημοσύνη και να δημιουργήσει μια συνταγή από αυτό.",
"crop-and-rotate-the-image": "Περικοπή και περιστροφή της εικόνας, έτσι ώστε να είναι μόνο το κείμενο ορατό και να είναι στο σωστό προσανατολισμό.",
"create-from-images": "Δημιουργία από εικόνες",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crear nueva receta desde cero.",
"create-recipes": "Crear Recetas",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Loo uus retsept algusest",
"create-recipes": "Loo retseptid",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Luo resepti alusta.",
"create-recipes": "Luo reseptejä",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette de zéro.",
"create-recipes": "Créer des recettes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette à partir de zéro.",
"create-recipes": "Créer des recettes",
"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.",
"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",

View File

@@ -169,7 +169,7 @@
"token": "Jeton",
"tuesday": "Mardi",
"type": "Type",
"undo": "Undo",
"undo": "Annuler",
"update": "Mettre à jour",
"updated": "Mis à jour",
"upload": "Importer",
@@ -628,7 +628,7 @@
"create-recipe-description": "Créer une nouvelle recette de zéro.",
"create-recipes": "Créer des recettes",
"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.",
"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",
@@ -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-delete-checked-items": "Voulez-vous vraiment supprimer tous les éléments sélectionnés ?",
"no-shopping-lists-found": "Aucune liste de courses trouvée",
"item-checked-off": "Checked off {item}"
"item-checked-off": "{item} coché"
},
"sidebar": {
"all-recipes": "Recettes",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crear unha receita en branco.",
"create-recipes": "Crear Receitas",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "יצירת מתכון חדש מאפס.",
"create-recipes": "יצירת מתכונים",
"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 ותייצר ממנו מתכון.",
"crop-and-rotate-the-image": "נא לחתוך ולסובב את התמונה כך שרואים רק את הטקסט, והוא בכיוון הנכון.",
"create-from-images": "יצירה מתמונה",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Izradi novi recept od početka",
"create-recipes": "Kreiraj recept",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Adj hozzá egy új receptet a nulláról kezdve.",
"create-recipes": "Receptek létrehozása",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Stofna nýja uppskrift frá grunni.",
"create-recipes": "Stofna uppskriftir",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Crea una nuova ricetta da zero.",
"create-recipes": "Crea Ricette",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "新しいレシピを一から作成します。",
"create-recipes": "レシピを作成する",
"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を使用して画像からテキストを抽出し、そこからレシピを作成しようとします。",
"crop-and-rotate-the-image": "テキストのみが表示され、正しい方向になるように画像をトリミングして回転します。",
"create-from-images": "Create from Images",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "처음부터 새로운 레시피를 만드세요.",
"create-recipes": "레시피 생성",
"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를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.",
"crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.",
"create-from-images": "이미지에서 생성",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"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.",
"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ų",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Izveidojiet jaunu recepti no nulles.",
"create-recipes": "Izveidojiet receptes",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Maak een nieuw recept.",
"create-recipes": "Recepten aanmaken",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Opprett en ny oppskrift fra bunnen av.",
"create-recipes": "Opprett oppskrifter",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Utwórz nowy przepis od zera.",
"create-recipes": "Utwórz przepisy",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Criar uma receita do zero.",
"create-recipes": "Criar Receitas",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Criar uma receita em branco.",
"create-recipes": "Criar Receitas",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Creează o rețetă nouă de la zero.",
"create-recipes": "Crează rețetă",
"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.",
"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",

View File

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

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Vytvoriť nový recept od začiatku.",
"create-recipes": "Vytvoriť recept",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Ustvari nov recept.",
"create-recipes": "Ustvari recepte",
"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.",
"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",

View File

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

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Skapa nytt recept från grunden.",
"create-recipes": "Skapa recept",
"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.",
"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",

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Sıfırdan yeni bir tarif oluşturun.",
"create-recipes": "Tarif Oluştur",
"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.",
"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",

View File

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

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"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.",
"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",

View File

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

View File

@@ -628,7 +628,7 @@
"create-recipe-description": "從頭開始建立新食譜。",
"create-recipes": "建立食譜",
"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 從圖片中擷取文字並建立食譜。",
"crop-and-rotate-the-image": "請裁切並旋轉圖片,使其僅顯示文字且方向正確。",
"create-from-images": "從圖片建立",

View File

@@ -3,7 +3,7 @@
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
<div>
<v-card-title class="headline">
{{ $t("recipe.create-recipe-from-an-image") }}
{{ $t("recipe.create-recipe-from-images") }}
</v-card-title>
<v-card-text>
<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 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
# 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.group.group import Group
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.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import User

View File

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

View File

@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict
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.guid import GUID
from ..household.cookbook import CookBook
@@ -31,9 +31,9 @@ if TYPE_CHECKING:
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
households: Mapped[list["Household"]] = orm.relationship("Household", 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)

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
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.auto_init import auto_init
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"),
)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
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")
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")
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String, default="")
public: Mapped[str | None] = mapped_column(Boolean, default=False)
name: FilterableColumn[str] = mapped_column(String, nullable=False)
slug: FilterableColumn[str] = mapped_column(String, nullable=False, index=True)
description: FilterableColumn[str | None] = mapped_column(String, default="")
public: FilterableColumn[str | None] = mapped_column(Boolean, default=False)
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
# Old filters - deprecated in favor of query filter strings
categories: Mapped[list[Category]] = orm.relationship(
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)
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)
require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True)
require_all_tools: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
@auto_init()
def __init__(self, **_) -> None:

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict
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.guid import GUID
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"),
)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
@@ -48,7 +48,7 @@ class Household(SqlAlchemyBase, BaseMixins):
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")
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 .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..recipe.category import Category, plan_rules_to_categories
@@ -30,14 +30,14 @@ plan_rules_to_households = Table(
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules"
id: Mapped[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)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, 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"
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type: Mapped[str] = mapped_column(
entry_type: FilterableColumn[str] = mapped_column(
String, nullable=False, default=""
) # "breakfast", "lunch", "dinner", etc ...
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
@@ -55,19 +55,19 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans"
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False)
title: Mapped[str] = mapped_column(String, index=True, nullable=False)
text: Mapped[str] = mapped_column(String, nullable=False)
date: FilterableColumn[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
title: FilterableColumn[str] = mapped_column(String, index=True, 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")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
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")
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="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.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.guid import GUID
@@ -15,27 +15,29 @@ if TYPE_CHECKING:
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
__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")
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True)
private_household: FilterableColumn[bool | None] = 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)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
lock_recipe_edits_from_other_households: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: FilterableColumn[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_public: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# 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()
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.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.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.guid import GUID
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
@@ -25,18 +25,20 @@ if TYPE_CHECKING:
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__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(
"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_quantity: Mapped[float] = mapped_column(Float, nullable=False)
recipe_scale: Mapped[float] = mapped_column(Float, default=1)
recipe_note: Mapped[str | None] = mapped_column(String)
recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
recipe_scale: FilterableColumn[float] = mapped_column(Float, default=1)
recipe_note: FilterableColumn[str | None] = mapped_column(String)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
@@ -50,33 +52,33 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
# 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_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")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
# Meta
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: Mapped[bool | None] = mapped_column(Boolean, default=False)
is_ingredient: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
quantity: Mapped[float | None] = mapped_column(Float, default=1)
note: Mapped[str | None] = mapped_column(String)
quantity: FilterableColumn[float | None] = mapped_column(Float, default=1)
note: FilterableColumn[str | None] = mapped_column(String)
extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
"ShoppingListItemExtras", cascade="all, delete-orphan"
)
# 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)
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)
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(
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
)
@@ -98,19 +100,19 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__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_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")
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(
"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"})
@auto_init()
@@ -121,12 +123,12 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists_multi_purpose_labels"
__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")
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(
"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")
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"})
@auto_init()
@@ -144,16 +146,16 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
class ShoppingList(SqlAlchemyBase, BaseMixins):
__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")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
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")
name: Mapped[str | None] = mapped_column(String)
name: FilterableColumn[str | None] = mapped_column(String)
list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
ShoppingListItem,
cascade="all, delete, delete-orphan",

View File

@@ -1,7 +1,7 @@
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
@@ -28,9 +28,9 @@ class ExtrasGeneric:
This class is not an actual table, so it does not inherit from SqlAlchemyBase
"""
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
key_name: Mapped[str | None] = mapped_column(sa.String)
value: Mapped[str | None] = mapped_column(sa.String)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
key_name: FilterableColumn[str | None] = mapped_column(sa.String)
value: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, key, value) -> None:
self.key_name = key
@@ -40,21 +40,25 @@ class ExtrasGeneric:
# used specifically for recipe extras
class ApiExtras(ExtrasGeneric, SqlAlchemyBase):
__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):
__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):
__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):
__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
)

View File

@@ -1,17 +1,17 @@
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
class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: Mapped[str | None] = mapped_column(sa.String)
icon: Mapped[str | None] = mapped_column(sa.String)
file_name: Mapped[str | None] = mapped_column(sa.String)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: FilterableColumn[str | None] = mapped_column(sa.String)
icon: FilterableColumn[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:
self.name = name

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
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
if TYPE_CHECKING:
@@ -54,12 +54,12 @@ class Category(SqlAlchemyBase, BaseMixins):
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
# 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])
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: FilterableColumn[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(
"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.session import Session
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
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.guid import GUID
@@ -29,19 +29,19 @@ households_to_ingredient_foods = sa.Table(
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__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
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])
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
abbreviation: Mapped[str | None] = mapped_column(String)
plural_abbreviation: Mapped[str | None] = mapped_column(String)
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False)
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True)
name: FilterableColumn[str | None] = mapped_column(String)
plural_name: FilterableColumn[str | None] = mapped_column(String)
description: FilterableColumn[str | None] = mapped_column(String)
abbreviation: FilterableColumn[str | None] = mapped_column(String)
plural_abbreviation: FilterableColumn[str | None] = mapped_column(String)
use_abbreviation: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
fraction: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="unit"
@@ -53,14 +53,14 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
)
# Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String)
standard_quantity: FilterableColumn[float | None] = mapped_column(Float)
standard_unit: FilterableColumn[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init()
def __init__(
@@ -152,18 +152,18 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__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
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])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
)
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
name: FilterableColumn[str | None] = mapped_column(String)
plural_name: FilterableColumn[str | None] = mapped_column(String)
description: FilterableColumn[str | None] = mapped_column(String)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food"
@@ -175,12 +175,12 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
)
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")
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_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: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
exclude={
@@ -261,15 +261,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
__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")
name: Mapped[str] = mapped_column(String)
name: FilterableColumn[str] = mapped_column(String)
# 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()
def __init__(self, session: Session, name: str, **_) -> None:
@@ -302,15 +302,15 @@ class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
__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")
name: Mapped[str] = mapped_column(String)
name: FilterableColumn[str] = mapped_column(String)
# 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()
def __init__(self, session: Session, name: str, **_) -> None:
@@ -343,34 +343,34 @@ class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
position: Mapped[int | None] = mapped_column(Integer, index=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
id: FilterableColumn[int] = mapped_column(Integer, primary_key=True)
position: FilterableColumn[int | None] = mapped_column(Integer, index=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
title: FilterableColumn[str | None] = mapped_column(String) # Section Header - Shows if Present
note: FilterableColumn[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
# 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)
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)
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
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(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
)
# Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)
note_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
original_text_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init()
def __init__(

View File

@@ -3,26 +3,26 @@ from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, UniqueConstraint, orm
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.guid import GUID
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group.group import Group
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from .recipe import IngredientFoodModel
from ..group.group import Group
from ..household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from . import IngredientFoodModel
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels"
__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)
name: Mapped[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(10), nullable=False, default="")
id: FilterableColumn[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name: FilterableColumn[str] = mapped_column(String(255), nullable=False)
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")
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")

View File

@@ -1,22 +1,22 @@
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
class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
calories: Mapped[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
cholesterol_content: Mapped[str | None] = mapped_column(sa.String)
fat_content: Mapped[str | None] = mapped_column(sa.String)
fiber_content: Mapped[str | None] = mapped_column(sa.String)
protein_content: Mapped[str | None] = mapped_column(sa.String)
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
calories: FilterableColumn[str | None] = mapped_column(sa.String)
carbohydrate_content: FilterableColumn[str | None] = mapped_column(sa.String)
cholesterol_content: FilterableColumn[str | None] = mapped_column(sa.String)
fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
fiber_content: FilterableColumn[str | None] = mapped_column(sa.String)
protein_content: FilterableColumn[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
# 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)
sodium_content: Mapped[str | None] = mapped_column(sa.String)
sugar_content: Mapped[str | None] = mapped_column(sa.String)
trans_fat_content: Mapped[str | None] = mapped_column(sa.String)
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
sodium_content: FilterableColumn[str | None] = mapped_column(sa.String)
sugar_content: FilterableColumn[str | None] = mapped_column(sa.String)
trans_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
unsaturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(
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.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 ..users.user_to_recipe import UserToRecipe
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"),
)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
# 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])
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
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])
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(
"User",
secondary=UserToRecipe.__tablename__,
@@ -78,20 +78,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
# General Recipe Properties
name: Mapped[str] = mapped_column(sa.String, nullable=False)
description: Mapped[str | None] = mapped_column(sa.String)
name: FilterableColumn[str] = mapped_column(sa.String, nullable=False)
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
total_time: Mapped[str | None] = mapped_column(sa.String)
prep_time: Mapped[str | None] = mapped_column(sa.String)
perform_time: Mapped[str | None] = mapped_column(sa.String)
cook_time: Mapped[str | None] = mapped_column(sa.String)
total_time: FilterableColumn[str | None] = mapped_column(sa.String)
prep_time: FilterableColumn[str | None] = mapped_column(sa.String)
perform_time: FilterableColumn[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_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
recipe_yield: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield_quantity: FilterableColumn[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")
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")
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")
# Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
date_added: FilterableColumn[date | None] = mapped_column(sa.Date, default=get_utc_today)
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(
"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
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
name_normalized: FilterableColumn[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
get_attr="slug",
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 .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
@@ -18,29 +18,29 @@ if TYPE_CHECKING:
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
__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
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")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# 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", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
)
# General Properties
subject: Mapped[str] = mapped_column(String, nullable=False)
message: Mapped[str | None] = mapped_column(String)
event_type: Mapped[str | None] = mapped_column(String)
image: Mapped[str | None] = mapped_column(String)
subject: FilterableColumn[str] = mapped_column(String, nullable=False)
message: FilterableColumn[str | None] = mapped_column(String)
event_type: FilterableColumn[str | None] = mapped_column(String)
image: FilterableColumn[str | None] = mapped_column(String)
# Timestamps
timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True)
timestamp: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, index=True)
@auto_init()
def __init__(

View File

@@ -1,20 +1,20 @@
import sqlalchemy as sa
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
class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: Mapped[bool | None] = mapped_column(sa.Boolean)
show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean)
show_assets: Mapped[bool | None] = mapped_column(sa.Boolean)
landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean)
disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
locked: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated
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
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.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
@@ -22,12 +22,12 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens"
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: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
recipe_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
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()
def __init__(self, **_) -> None:

View File

@@ -6,7 +6,7 @@ from slugify import slugify
from sqlalchemy.orm import Mapped, mapped_column, validates
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
if TYPE_CHECKING:
@@ -44,14 +44,16 @@ cookbooks_to_tags = sa.Table(
class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags"
__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
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])
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
name: FilterableColumn[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(
"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.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.guid import GUID
@@ -42,14 +42,14 @@ cookbooks_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools"
__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
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])
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
name: FilterableColumn[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(
"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.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .user_to_recipe import UserToRecipe
if TYPE_CHECKING:
@@ -50,18 +50,21 @@ class AuthMethod(enum.Enum):
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name: Mapped[str | None] = mapped_column(String, index=True)
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
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)
password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: 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")
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")
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,
)
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.comment import RecipeComment
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_timeline import RecipeTimelineEvent
from mealie.db.models.recipe.shared import RecipeShareTokenModel

View File

@@ -25,7 +25,7 @@ from mealie.schema.response.pagination import (
RequestQuery,
)
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
@@ -467,6 +467,12 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
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:
raise HTTPException(
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_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
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 ..db.models._model_base import SqlAlchemyBase
@@ -92,6 +92,20 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
)
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
max_retries = 10
original_name: str = document.name # type: ignore

View File

@@ -22,6 +22,17 @@ from .keywords import PlaceholderKeyword, RelationalKeyword
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):
left_parenthesis: str | None = None
right_parenthesis: str | None = None
@@ -202,7 +213,7 @@ class QueryFilterBuilder:
@classmethod
def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
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
model and model attribute. Optionally provide a query to apply the necessary table joins.
@@ -222,7 +233,7 @@ class QueryFilterBuilder:
if not attribute_chain:
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):
try:
model_attr = getattr(current_model, attribute_link)
@@ -259,6 +270,9 @@ class QueryFilterBuilder:
if model_attr is None:
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
@classmethod
@@ -334,7 +348,7 @@ class QueryFilterBuilder:
column_aliases = column_aliases or {}
# join tables and build model chain
attr_model_map: dict[int, Any] = {}
attr_map: dict[int, tuple[type[SqlAlchemyBase], InstrumentedAttribute]] = {}
model_attr: InstrumentedAttribute
for i, component in enumerate(self.filter_components):
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(
component.attribute_name, model, query=query
)
attr_model_map[i] = nested_model
attr_map[i] = (nested_model, model_attr)
# build query filter
partial_group: list[sa.ColumnElement] = []
@@ -367,9 +381,9 @@ class QueryFilterBuilder:
else:
component = cast(QueryFilterBuilderComponent, component)
base_attribute_name = component.attribute_name.split(".")[-1]
model_attr = getattr(attr_model_map[i], base_attribute_name)
nested_model, model_attr = attr_map[i]
base_attribute_name = component.attribute_name.split(".")[-1]
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
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 (
LogicalOperator,
NonFilterableValueError,
QueryFilterBuilder,
QueryFilterJSON,
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]
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):
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.mealplan import GroupMealPlanRules
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.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe