Compare commits

..

1 Commits

Author SHA1 Message Date
Hayden
8ff7f27fea fix: translate mealplan timeline event subjects by locale
Mealplan "made this" timeline events were always stored as hardcoded
English strings. This stores them instead as i18n key references in the
format `recipe.<key>|<user-name>`, and translates them at serve time
in the timeline events GET endpoints.

- Add backend i18n keys for all seven entry-type variants
  (breakfast, lunch, dinner, snack, drink, dessert, side).
- Store structured subjects in create_timeline_events.py — deduplication
  logic is unaffected since the stored string is still deterministic.
- Translate info-type events at serve time in timeline_events.py, with
  a fallback to en-US when a locale has not yet been translated.
- Old events with plain English subject strings are displayed as-is
  (backward-compatible).

Follows up on #7623 which applied the same pattern to the system-type
"Recipe Created" event.
2026-05-14 09:12:58 -05:00
37 changed files with 233 additions and 306 deletions

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.18.0`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.17.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container

View File

@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.17.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.17.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -38,7 +38,7 @@ export const LOCALES = [
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 75,
progress: 74,
dir: "ltr",
pluralFoodHandling: "always",
},
@@ -101,14 +101,14 @@ export const LOCALES = [
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 60,
progress: 59,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 98,
progress: 97,
dir: "ltr",
pluralFoodHandling: "always",
},

View File

@@ -51,7 +51,7 @@
"category": "Kategoria"
},
"events": {
"apprise-url": "Apprise-url",
"apprise-url": "Apprise URL",
"database": "Tietokanta",
"delete-event": "Poista tapahtuma",
"event-delete-confirmation": "Haluatko varmasti poistaa tämän tapahtuman?",
@@ -98,7 +98,7 @@
"dashboard": "Hallintanäkymä",
"delete": "Poista",
"disabled": "Poistettu käytöstä",
"done": "Valmis",
"done": "Done",
"download": "Lataa",
"duplicate": "Monista",
"edit": "Muokkaa",
@@ -169,7 +169,7 @@
"token": "Tunniste",
"tuesday": "Tiistai",
"type": "Tyyppi",
"undo": "Peru",
"undo": "Undo",
"update": "Päivitä",
"updated": "Päivitetty",
"upload": "Lähetä",
@@ -333,8 +333,8 @@
"any-household": "Mikä tahansa kotitalous",
"no-meal-plan-defined-yet": "Ateriasuunnitelmaa ei ole vielä määritelty",
"no-meal-planned-for-today": "Ei ateriasuunnitelmaa tälle päivälle",
"numberOfDaysPast-hint": "Menneisyydestä ladattujen päivien määrä",
"numberOfDaysPast-label": "Oletusarvo menneiden päivien lataukselle",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDays-hint": "Sivun latauspäivien lukumäärä",
"numberOfDays-label": "Oletuspäivät",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Vain näiden luokkien reseptejä käytetään ateriasuunnitelmissa",
@@ -392,7 +392,7 @@
"nextcloud": {
"description": "Tuo tiedot Nextcloudin Cookbookista",
"description-long": "Nextcloud reseptejä voidaan tuoda zip-tiedostosta, joka sisältää Nextcloudin tallennetut tiedot. Katso esimerkkikansiorakenne alla varmistaaksesi, että reseptisi voidaan tuoda.",
"title": "Nextcloud-keittokirja"
"title": "Nextcloud Cookbook"
},
"copymethat": {
"description-long": "Mealie voi tuoda reseptejä Copy Me That -sovelluksesta. Vie reseptisi HTML-muodossa ja lataa sitten zip-tiedosto.",
@@ -702,7 +702,7 @@
"confidence-score": "Varmuuspisteet",
"ingredient-parser-description": "Ainesosat on haettu onnistuneesti. Ole hyvä ja tarkista ainesosat joista emme ole varmoja.",
"ingredient-parser-final-review-description": "Kun kaikki ainesosat on tarkistettu, sinulla on vielä yksi mahdollisuus tarkistaa kaikki ainesosat ennen kuin muokkaat reseptiäsi.",
"add-text-as-alias-for-item": "Lisää \"{text}\" kohteen {item} aliakseksi",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"delete-item": "Poista kohde"
},
"reset-servings-count": "Palauta Annoksien Määrä",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` on API-palvelimen oletusarvo. Tämä aiheuttaa ongelmia ilmoitusten linkkien kanssa, jotka on luotu palvelimella sähköposteja varten jne.",
"server-side-base-url-success-text": "Palvelimen nettiosoite ei vastaa oletusta",
"ldap-ready": "LDAP Valmis",
"ldap-not-ready": "LDAP ei valmis",
"ldap-not-ready": "LDAP Not Ready",
"ldap-ready-error-text": "Kaikkia LDAP-arvoja ei ole määritetty. Tämä voidaan ohittaa, jos et käytä LDAP-todennusta.",
"ldap-ready-success-text": "Kaikki vaaditut LDAP-muuttujat on asetettu.",
"build": "Koonti",
"recipe-scraper-version": "Reseptikaappaimen versio",
"oidc-ready": "OIDC valmis",
"oidc-not-ready": "OIDC ei ole valmis",
"oidc-not-ready": "OIDC Not Ready",
"oidc-ready-error-text": "Kaikkia OIDC-arvoja ei ole määritelty. Jos et käytä OIDC-todennusta, voidaan asia jättää huomiotta.",
"oidc-ready-success-text": "Kaikki vaaditut OIDC-muuttujat asetettu.",
"openai-ready": "OpenAI valmis",
"openai-not-ready": "OpenAI ei ole valmis",
"openai-not-ready": "OpenAI Not Ready",
"openai-ready-error-text": "Kaikkia OpenAI:n arvoja ei ole määritelty. Tämä voidaan sivuuttaa, mikäli et käytä OpenAI:n toimintoja.",
"openai-ready-success-text": "Vaadittavat OpenAI-muuttujat ovat asetetut."
},
@@ -917,7 +917,7 @@
"quantity": "Määrä: {0}",
"shopping-list": "Ostoslista",
"shopping-lists": "Ostoslistat",
"add-item": "Lisää kohde",
"add-item": "Add item",
"food": "Elintarvikkeet",
"note": "Muistiinpano",
"label": "Tunnus",
@@ -962,7 +962,7 @@
"language": "Kieli",
"maintenance": "Ylläpito",
"background-tasks": "Taustatehtävät",
"parser": "Jäsentäjä",
"parser": "Parser",
"developer": "Kehittäjä",
"cookbook": "Keittokirja",
"create-cookbook": "Luo uusi keittokirja"
@@ -1351,7 +1351,7 @@
"ingredient-text": "Ainesosan Teksti",
"average-confident": "{0} Luottamus",
"try-an-example": "Kokeile esimerkkiä",
"parser": "Jäsentäjä",
"parser": "Parser",
"background-tasks": "Taustatehtävät",
"background-tasks-description": "Täältä voit tarkastella kaikkia käynnissä olevia taustatehtäviä ja niiden tilaa",
"no-logs-found": "Lokeja Ei Löytynyt",
@@ -1481,7 +1481,7 @@
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Näytä Mealien ilmoitukset",
"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"
}
}

View File

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?",
"are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?",
"no-shopping-lists-found": "Nem találhatók bevásárlólisták",
"item-checked-off": "{item} leellenőrzve"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Minden recept",

View File

@@ -333,8 +333,8 @@
"any-household": "Öll heimili",
"no-meal-plan-defined-yet": "Ekkert matarplan hefur verið skilgreint",
"no-meal-planned-for-today": "Ekkert matarplan skipulagt í dag",
"numberOfDaysPast-hint": "Fjöldi liðina daga við síðuhleðslu",
"numberOfDaysPast-label": "Sjálfgefnir liðnir dagar",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDays-hint": "Fjöldi daga við síðuhleðslu",
"numberOfDays-label": "Sjálfgefnir dagar",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Aðeins uppskriftir í þessum flokkum verða notaðir í matarplan",
@@ -640,8 +640,8 @@
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Stofnaðu uppskrift með því að gefa henni nafn, allar uppskriftir þurfa að hafa einstakt nafn.",
"new-recipe-names-must-be-unique": "Nöfn uppskrifta þurfa að vera einstök",
"scrape-recipe": "Vinna uppskrift",
"scrape-recipe-description": "Sækja uppskrift af vefslóð. Settu inn vefslóð fyrir síðuna þar sem þú vilt sækja uppskrift og Mealie mun reyna að vinna uppskriftina þaðan og bæta henni við safnið þitt.",
"scrape-recipe-description-transcription": "Þú getur einnig sett inn slóð á video og Mealie mun reyna að umrita það yfir í uppskrift.",
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.",
"scrape-recipe-description-transcription": "You can also provide the url to a video and Mealie will attempt to transcribe it into a recipe.",
"scrape-recipe-have-a-lot-of-recipes": "Ertu með margar uppskriftir sem þú villt setja inn í einu?",
"scrape-recipe-suggest-bulk-importer": "Prófaðu að setja inn margar uppskriftir í einu",
"scrape-recipe-have-raw-html-or-json-data": "Ertu með hrá HTML eða JSON gögn?",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "'BASE_URL' er enn sjálfgefið gildi á API netþjóns. Þetta getur valdið vandræðum með tilkynninga tengla sem netþjónninn býr til fyrir tölvupósta og annað.",
"server-side-base-url-success-text": "Slóð netþjóns samsvarar ekki sjálfgefnu gildi",
"ldap-ready": "LDAP klár",
"ldap-not-ready": "LDAP er ekki tilbúið",
"ldap-not-ready": "LDAP Not Ready",
"ldap-ready-error-text": "Ekki öll LDAP-gildi eru stillt. Þetta má hunsa ef þú notar ekki LDAP-auðkenningu.",
"ldap-ready-success-text": "Öll nauðsynleg LDAP-gildi eru stillt.",
"build": "Build",
"recipe-scraper-version": "Recipe Scraper útgáfa",
"oidc-ready": "OIDC klár",
"oidc-not-ready": "OIDC er ekki tilbúið",
"oidc-not-ready": "OIDC Not Ready",
"oidc-ready-error-text": "Ekki öll OIDC gildi eru stillt. Þetta má hunsa ef þú notar ekki OIDC-auðkenningu.",
"oidc-ready-success-text": "Öll nauðsynleg OIDC-gildi eru stillt.",
"openai-ready": "OpenAI klár",
"openai-not-ready": "OpenAI er ekki tilbúið",
"openai-not-ready": "OpenAI Not Ready",
"openai-ready-error-text": "Ekki öll OpenAI gildi eru stillt. Þetta má hunsa ef þú notar ekki OpenAI.",
"openai-ready-success-text": "Öll nauðsynleg OpenAI-gildi eru stillt."
},
@@ -917,7 +917,7 @@
"quantity": "Fjöldi: {0}",
"shopping-list": "Innkaupalisti",
"shopping-lists": "Innkaupalistar",
"add-item": "Bæta við vöru",
"add-item": "Add item",
"food": "Matvara",
"note": "Minnispunktur",
"label": "Merkimiði",

View File

@@ -51,7 +51,7 @@
"category": "Categorie"
},
"events": {
"apprise-url": "Kennisgevings-url",
"apprise-url": "Apprise URL",
"database": "Database",
"delete-event": "Gebeurtenis verwijderen",
"event-delete-confirmation": "Weet je zeker dat je deze gebeurtenis wilt verwijderen?",
@@ -98,7 +98,7 @@
"dashboard": "Dashboard",
"delete": "Verwijderen",
"disabled": "Uitgeschakeld",
"done": "Gereed",
"done": "Done",
"download": "Downloaden",
"duplicate": "Dupliceren",
"edit": "Bewerken",
@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "dinsdag",
"type": "Soort",
"undo": "Ongedaan maken",
"undo": "Undo",
"update": "Bijwerken",
"updated": "Bijgewerkt",
"upload": "Uploaden",
@@ -333,8 +333,8 @@
"any-household": "Elk huishouden",
"no-meal-plan-defined-yet": "Nog geen maaltijdplan opgesteld",
"no-meal-planned-for-today": "Geen maaltijd gepland voor vandaag",
"numberOfDaysPast-hint": "Aantal dagen in het verleden bij laden pagina",
"numberOfDaysPast-label": "Standaard dagen in het verleden",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDays-hint": "Aantal dagen bij laden van de pagina",
"numberOfDays-label": "Standaard aantal dagen",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Alleen recepten met deze categorieën zullen worden gebruikt in maaltijdplannen",
@@ -443,7 +443,7 @@
"error-details": "Alleen websites met ld+json of microdata kunnen worden geïmporteerd door Mealie. De meeste grote receptenwebsites ondersteunen deze gegevensstructuur. Als je site niet kan worden geïmporteerd, maar er zijn json-gegevens in de log, maak dan een github issue aan met de URL en gegevens.",
"error-title": "Het lijkt erop dat we niets konden vinden",
"from-url": "Recept importeren",
"github-issues": "GitHubproblemen",
"github-issues": "GitHub Issues",
"google-ld-json-info": "Google ld+json Info",
"must-be-a-valid-url": "Moet een geldige URL zijn",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak je receptgegevens. Elke regel wordt behandeld als een item in een lijst",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` is nog steeds de standaard waarde op de API Server. Dit geeft problemen met notificatielinks in e-mails etc.",
"server-side-base-url-success-text": "Server-side URL komt niet overeen met de standaard",
"ldap-ready": "LDAP klaar",
"ldap-not-ready": "LDAP niet gereed",
"ldap-not-ready": "LDAP Not Ready",
"ldap-ready-error-text": "Niet alle LDAP-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen LDAP-authenticatie gebruikt.",
"ldap-ready-success-text": "Vereiste LDAP variabelen zijn helemaal ingesteld.",
"build": "Build",
"recipe-scraper-version": "Versie van de receptenscraper",
"oidc-ready": "OIDC klaar",
"oidc-not-ready": "OIDC niet gereed",
"oidc-not-ready": "OIDC Not Ready",
"oidc-ready-error-text": "Niet alle OIDC-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen OIDC-authenticatie gebruikt.",
"oidc-ready-success-text": "Vereiste OIDC-variabelen zijn allemaal ingesteld.",
"openai-ready": "OpenAI staat klaar",
"openai-not-ready": "OpenAI niet gereed",
"openai-not-ready": "OpenAI Not Ready",
"openai-ready-error-text": "Niet alle tekstvakken voor OpenAI zijn ingevuld. Als je geen OpenAI gebruikt kun je dit leeg laten.",
"openai-ready-success-text": "Verplichte tekstvakken voor OpenAI zijn ingevuld."
},
@@ -917,7 +917,7 @@
"quantity": "Hoeveelheid: {0}",
"shopping-list": "Boodschappenlijst",
"shopping-lists": "Boodschappenlijsten",
"add-item": "Item toevoegen",
"add-item": "Add item",
"food": "Levensmiddelen",
"note": "Notitie",
"label": "Label",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Weet je zeker dat je alle items wilt deselecteren?",
"are-you-sure-you-want-to-delete-checked-items": "Weet je zeker dat je de geselecteerde items wilt verwijderen?",
"no-shopping-lists-found": "Geen boodschappenlijsten gevonden",
"item-checked-off": "Uitgevinkt {item}"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Alle Recepten",
@@ -1283,7 +1283,7 @@
"split-by-block": "Splits per tekstblok",
"flatten": "Plat maken ongeacht originele opmaak",
"help": {
"help": "Hulp",
"help": "Help",
"mouse-modes": "Muismodus",
"selection-mode": "Selectiemodus (standaard)",
"selection-mode-desc": "De selectiemodus is de hoofdmodus die gebruikt kan worden om gegevens in te voeren:",
@@ -1478,10 +1478,10 @@
"max-length": "Moet maximaal {max} tekens bevatten|Moet maximaal {max} tekens bevatten"
},
"announcements": {
"announcements": "Aankondigingen",
"all-announcements": "Alle aankondigingen",
"mark-all-as-read": "Alles markeren als gelezen",
"show-announcements-from-mealie": "Aankondigingen van Mealie weergeven",
"show-announcements-setting-description": "Of je gebruikers wel of niet meldingen van Mealie wilt laten zien. Wanneer ingeschakeld kunnen gebruikers nog steeds afzien van het bekijken van hen in hun gebruikersinstellingen"
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"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"
}
}

View File

@@ -98,7 +98,7 @@
"dashboard": "Kontrollpanel",
"delete": "Slett",
"disabled": "Deaktivert",
"done": "Ferdig",
"done": "Done",
"download": "Last ned",
"duplicate": "Dupliser",
"edit": "Rediger",
@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Tirsdag",
"type": "Type",
"undo": "Angre",
"undo": "Undo",
"update": "Oppdater",
"updated": "Oppdatert",
"upload": "Last opp",
@@ -334,7 +334,7 @@
"no-meal-plan-defined-yet": "Ingen måltidsplan er definert ennå",
"no-meal-planned-for-today": "Ingen måltid planlagt i dag",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Standard antall dager tilbake",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDays-hint": "Antall dager på sideinnlasting",
"numberOfDays-label": "Standard antall dager",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Kun oppskrifter med disse kategoriene vil bli brukt i måltidsplaner",
@@ -392,7 +392,7 @@
"nextcloud": {
"description": "Overfør data fra en Nextcloud Cookbook-instans",
"description-long": "Oppskrifter fra Nextcloud kan importeres fra en zip-fil som inneholder dataene lagret i Nextcloud. Se eksempelet på mappestrukture nedenfor for å sikre at oppskriftene kan importeres.",
"title": "Nextcloud kokebok"
"title": "Nextcloud Cookbook"
},
"copymethat": {
"description-long": "Mealie kan importere oppskrifter fra Copy Me That. Eksporter oppskrifter i HTML-format, last deretter opp .zip-filen under.",
@@ -917,7 +917,7 @@
"quantity": "Antall: {0}",
"shopping-list": "Handleliste",
"shopping-lists": "Handlelister",
"add-item": "Legg til produkt",
"add-item": "Add item",
"food": "Matvare",
"note": "Notat",
"label": "Etikett",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på at du vil fjerne valg av alle elementer?",
"are-you-sure-you-want-to-delete-checked-items": "Er du sikker på at du vil slette alle valgte elementer?",
"no-shopping-lists-found": "Ingen handlelister funnet",
"item-checked-off": "Avkrysset av {item}"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Alle oppskrifter",
@@ -1478,10 +1478,10 @@
"max-length": "Må være minst minst {max} tegn må bestå av maks {max} tegn"
},
"announcements": {
"announcements": "Kunngjøringer",
"all-announcements": "Alle kunngjøringer",
"mark-all-as-read": "Marker alle som lest",
"show-announcements-from-mealie": "Vis kunngjøringer fra Mealie",
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"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"
}
}

View File

@@ -51,7 +51,7 @@
"category": "Kategoria"
},
"events": {
"apprise-url": "URL Apprise",
"apprise-url": "Apprise URL",
"database": "Baza danych",
"delete-event": "Usuń wydarzenie",
"event-delete-confirmation": "Czy na pewno chcesz usunąć to zdarzenie?",
@@ -98,7 +98,7 @@
"dashboard": "Panel główny",
"delete": "Usuń",
"disabled": "Wyłączone",
"done": "Gotowe",
"done": "Done",
"download": "Pobierz",
"duplicate": "Duplikuj",
"edit": "Edytuj",
@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Wtorek",
"type": "Typ",
"undo": "Cofnij",
"undo": "Undo",
"update": "Zaktualizuj",
"updated": "Zaktualizowano",
"upload": "Prześlij",
@@ -917,7 +917,7 @@
"quantity": "Ilość: {0}",
"shopping-list": "Lista zakupów",
"shopping-lists": "Listy zakupów",
"add-item": "Dodaj element",
"add-item": "Add item",
"food": "Jedzenie",
"note": "Notatka",
"label": "Etykieta",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Czy na pewno chcesz odznaczyć wszystkie elementy?",
"are-you-sure-you-want-to-delete-checked-items": "Czy jesteś pewien, że chcesz usunąć wszystkie zaznaczone elementy?",
"no-shopping-lists-found": "Nie znaleziono list zakupów",
"item-checked-off": "Zaznaczono {item}"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Wszystkie",
@@ -1478,10 +1478,10 @@
"max-length": "Może zawierać co najwyżej {max} znak|Może zawierać co najwyżej {max} znaki|Może zawierać co najwyżej {max} znaków"
},
"announcements": {
"announcements": "Ogłoszenia",
"all-announcements": "Wszystkie ogłoszenia",
"mark-all-as-read": "Oznacz wszystkie jako przeczytane",
"show-announcements-from-mealie": "Pokazuj ogłoszenia z Mealie",
"show-announcements-setting-description": "Czy chcesz by użytkownicy widzieli ogłoszenia z Mealie? Użytkownicy będą w dalszym ciągu mogli wyłączyć ogłoszenia w swoich ustawieniach użytkownika"
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"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"
}
}

View File

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?",
"are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?",
"no-shopping-lists-found": "Ni nakupovalnih seznamov",
"item-checked-off": "Odkljukano {item}"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Vsi recepti",

View File

@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Tisdag",
"type": "Typ",
"undo": "Ångra",
"undo": "Undo",
"update": "Uppdatera",
"updated": "Uppdaterad",
"upload": "Ladda upp",
@@ -333,8 +333,8 @@
"any-household": "Valfritt hushåll",
"no-meal-plan-defined-yet": "Ingen måltidsplan definierad ännu",
"no-meal-planned-for-today": "Ingen måltidsplan för idag",
"numberOfDaysPast-hint": "Antal förflutna dagar vid sidhämtning",
"numberOfDaysPast-label": "Förvalda förflutna dagar",
"numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past",
"numberOfDays-hint": "Antal dagar vid sidhämtning",
"numberOfDays-label": "Förvalda dagar",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Endast recept med dessa kategorier kommer att användas i måltidsplaner",
@@ -812,7 +812,7 @@
"settings-updated": "Inställningar uppdaterade",
"site-settings": "Systeminställningar",
"theme": {
"accent": "Accentfärg",
"accent": "Accent",
"dark": "Mörkt",
"default-to-system": "Standard",
"error": "Fel",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` är fortfarande standardvärdet på API-servern. Detta kommer att orsaka problem med meddelanden som genereras på servern för e-postmeddelanden, etc.",
"server-side-base-url-success-text": "Serversidans URL matchar inte standard",
"ldap-ready": "LDAP Redo",
"ldap-not-ready": "LDAP ej tillgängligt",
"ldap-not-ready": "LDAP Not Ready",
"ldap-ready-error-text": "Alla LDAP-värden är inte konfigurerade. Detta kan ignoreras om du inte använder LDAP-autentisering.",
"ldap-ready-success-text": "Alla obligatoriska LDAP-variabler är satta.",
"build": "Bygge",
"recipe-scraper-version": "Version av Recept-scraper",
"oidc-ready": "OIDC Klar",
"oidc-not-ready": "OIDC ej tillgängligt",
"oidc-not-ready": "OIDC Not Ready",
"oidc-ready-error-text": "Alla OIDC-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OIDC-autentisering.",
"oidc-ready-success-text": "Alla obligatoriska OIDC-variabler är satta.",
"openai-ready": "OpenAI redo",
"openai-not-ready": "OpenAI ej tillgängligt",
"openai-not-ready": "OpenAI Not Ready",
"openai-ready-error-text": "Alla OpenAI-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OpenAI-funktioner.",
"openai-ready-success-text": "Alla obligatoriska OpenAI-variabler är satta."
},
@@ -917,7 +917,7 @@
"quantity": "Antal {0}",
"shopping-list": "Inköpslista",
"shopping-lists": "Inköpslistor",
"add-item": "Lägg till vara",
"add-item": "Add item",
"food": "Mat",
"note": "Anteckning",
"label": "Etikett",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Är du säker på att du vill avmarkera alla objekt?",
"are-you-sure-you-want-to-delete-checked-items": "Är du säker på att du vill ta bort alla markerade objekt?",
"no-shopping-lists-found": "Inga inköpslistor hittades",
"item-checked-off": "Kryssat av {item}"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "Recept",
@@ -1478,10 +1478,10 @@
"max-length": "Måste Vara Som Mest {max} Tecken|Måste Vara Som Mest {max} Tecken"
},
"announcements": {
"announcements": "Meddelanden",
"all-announcements": "Alla meddelanden",
"mark-all-as-read": "Markera alla som lästa",
"show-announcements-from-mealie": "Visa meddelanden från Mealie",
"show-announcements-setting-description": "Om du vill tillåta användare att se meddelanden från Mealie eller inte. När funktionen är aktiverad kan användarna fortfarande välja att inte se dem i sina användarinställningar"
"announcements": "Announcements",
"all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read",
"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"
}
}

View File

@@ -911,7 +911,7 @@
"all-lists": "Всі списки",
"create-shopping-list": "Сторити список покупок",
"from-recipe": "З рецепту",
"ingredient-of-recipe": "Інгредієнт з {recipe}",
"ingredient-of-recipe": "Ingredient of {recipe}",
"list-name": "Назва списку",
"new-list": "Новий список",
"quantity": "Кількість: {0}",

View File

@@ -1,6 +1,6 @@
{
"name": "mealie",
"version": "3.18.0",
"version": "3.17.0",
"private": true,
"scripts": {
"dev": "nuxt dev",

View File

@@ -35,7 +35,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
self._logger.debug("[OIDC] %s: %s", key, value)
if not self.required_claims.issubset(claims.keys()):
self._logger.debug(
self._logger.error(
"[OIDC] Required claims not present. Expected: %s Actual: %s",
self.required_claims,
claims.keys(),
@@ -45,7 +45,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
# Check for empty required claims
for claim in self.required_claims:
if not claims.get(claim):
self._logger.debug("[OIDC] Required claim '%s' is empty", claim)
self._logger.error("[OIDC] Required claim '%s' is empty", claim)
raise MissingClaimException()
repos = get_repositories(self.session, group_id=None, household_id=None)

View File

@@ -5,6 +5,13 @@
"recipe": {
"unique-name-error": "Recipe names must be unique",
"recipe-created": "Recipe Created",
"made-this-as-side": "{name} made this as a side",
"made-this-for-breakfast": "{name} made this for breakfast",
"made-this-for-lunch": "{name} made this for lunch",
"made-this-for-dinner": "{name} made this for dinner",
"made-this-for-snack": "{name} made this for a snack",
"made-this-for-drink": "{name} made this for a drink",
"made-this-for-dessert": "{name} made this for dessert",
"recipe-image-deleted": "Recipe image deleted",
"recipe-defaults": {
"ingredient-note": "1 Cup Flour",

View File

@@ -23,7 +23,7 @@
"create-progress": {
"creating-recipe-with-ai": "Recept maken met AI...",
"creating-recipe-from-transcript-with-ai": "Maak recept van een transcript met AI...",
"creating-recipe-from-webpage-data": "Creëren recept van webpaginagegevens...",
"creating-recipe-from-webpage-data": "Creëren recept van webpagina-gegevens...",
"downloading-image": "Afbeeldingen downloaden...",
"downloading-video": "Video downloaden...",
"extracting-recipe-data": "Receptgegevens ophalen...",

View File

@@ -340,7 +340,7 @@
"aliases": [],
"description": "",
"name": "acelga",
"plural_name": "acelga"
"plural_name": "chard"
},
"pimiento": {
"aliases": [],
@@ -979,8 +979,8 @@
"chestnut purée": {
"aliases": [],
"description": "",
"name": "puré de castañas",
"plural_name": "puré de castañas"
"name": "chestnut purée",
"plural_name": "chestnut purée"
},
"prickly pear": {
"aliases": [],
@@ -1295,7 +1295,7 @@
"black fungu": {
"aliases": [],
"description": "",
"name": "hongos negros",
"name": "black fungus",
"plural_name": "hongos negros"
},
"black truffle": {
@@ -1361,7 +1361,7 @@
"white fungu": {
"aliases": [],
"description": "",
"name": "hongos blancos",
"name": "white fungus",
"plural_name": "hongos blancos"
},
"pioppini": {
@@ -1373,7 +1373,7 @@
"snow fungu": {
"aliases": [],
"description": "",
"name": "hongos de nieve",
"name": "snow fungus",
"plural_name": "hongos de nieve"
},
"white beech mushroom": {

View File

@@ -571,8 +571,8 @@
"delicata squash": {
"aliases": [],
"description": "",
"name": "delikat squash",
"plural_name": "delikate squasher"
"name": "delicata squash",
"plural_name": "delicata squashes"
},
"Frisée": {
"aliases": [
@@ -980,7 +980,7 @@
"aliases": [],
"description": "",
"name": "kastanjepuré",
"plural_name": "kastanjepuré"
"plural_name": "chestnut purée"
},
"prickly pear": {
"aliases": [],
@@ -1045,7 +1045,7 @@
"sweet lime": {
"aliases": [],
"description": "",
"name": "søt lime",
"name": "sweet lime",
"plural_name": "sweet limes"
},
"custard-apple": {
@@ -1873,8 +1873,8 @@
"melon seed": {
"aliases": [],
"description": "",
"name": "melonfrø",
"plural_name": "melonfrø"
"name": "melon seed",
"plural_name": "melon seeds"
},
"lotus seed": {
"aliases": [],
@@ -2003,48 +2003,48 @@
"parmesan cheese": {
"aliases": [],
"description": "",
"name": "parmesanost",
"plural_name": "parmesanost"
"name": "parmesan cheese",
"plural_name": "parmesan cheese"
},
"cheddar cheese": {
"aliases": [
"cheddarost"
"cheddar cheese"
],
"description": "",
"name": "cheddarost",
"plural_name": "cheddarost"
"name": "cheddar cheese",
"plural_name": "cheddar cheese"
},
"cream cheese": {
"aliases": [],
"description": "",
"name": "kremost",
"plural_name": "kremost"
"name": "cream cheese",
"plural_name": "cream cheese"
},
"sharp cheddar cheese": {
"aliases": [
"skarp cheddarost"
"sharp cheddar"
],
"description": "",
"name": "skarp cheddarost",
"plural_name": "skarp cheddarost"
"name": "sharp cheddar cheese",
"plural_name": "sharp cheddar cheese"
},
"cheese": {
"aliases": [],
"description": "",
"name": "ost",
"plural_name": "ost"
"name": "cheese",
"plural_name": "cheese"
},
"mozzarella cheese": {
"aliases": [],
"description": "",
"name": "mozzarellaost",
"plural_name": "mozzarellaost"
"name": "mozzarella cheese",
"plural_name": "mozzarella cheese"
},
"feta cheese": {
"aliases": [],
"description": "",
"name": "fetaost",
"plural_name": "fetaost"
"name": "feta cheese",
"plural_name": "feta cheese"
},
"ricotta cheese": {
"aliases": [],
@@ -2073,14 +2073,14 @@
"goat cheese": {
"aliases": [],
"description": "",
"name": "geitost",
"plural_name": "geitost"
"name": "goat cheese",
"plural_name": "goat cheese"
},
"fresh mozzarella cheese": {
"aliases": [],
"description": "",
"name": "fersk mozzarellaost",
"plural_name": "fersk mozzarellaost"
"name": "fresh mozzarella cheese",
"plural_name": "fresh mozzarella cheese"
},
"swis cheese": {
"aliases": [],

View File

@@ -919,7 +919,7 @@
"jackfruit": {
"aliases": [],
"description": "",
"name": "owoc chlebowca",
"name": "jackfruit",
"plural_name": "jackfruity"
},
"dragon fruit": {

View File

@@ -134,7 +134,6 @@ async def oauth_callback(request: Request, session: Session = Depends(generate_s
auth_provider = OpenIDProvider(session, userinfo, use_default_groups=True)
auth = auth_provider.authenticate()
except MissingClaimException:
logger.error("[OIDC] Required claims not present in ID token or userinfo endpoint")
auth = None
if not auth:

View File

@@ -54,11 +54,7 @@ class MultiPurposeLabelsController(BaseCrudController):
@router.post("", response_model=MultiPurposeLabelOut)
def create_one(self, data: MultiPurposeLabelCreate):
try:
new_label = self.service.create_one(data)
except Exception as ex:
self.mixins.handle_exception(ex)
raise # handle_exception always raises; this satisfies static analysis
new_label = self.service.create_one(data)
self.publish_event(
event_type=EventTypes.label_created,
document_data=EventLabelData(operation=EventOperation.create, label_id=new_label.id),

View File

@@ -58,6 +58,6 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
if file.exists():
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
return FileResponse(file)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -52,7 +52,7 @@ class TagController(BaseCrudController):
def create_one(self, tag: TagIn):
"""Creates a Tag in the database"""
save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
new_tag = self.mixins.create_one(save_data)
new_tag = self.repo.create(save_data)
if new_tag:
self.publish_event(

View File

@@ -80,8 +80,6 @@ from mealie.services.scraper.scraper_strategies import (
from ._base import BaseRecipeController, JSONBytes
ASSET_ALLOWED_EXTENSIONS = {"pdf", "jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "txt", "md", "csv", "json"}
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
@@ -662,10 +660,6 @@ class RecipeController(BaseRecipeController):
if "." in extension:
extension = extension.split(".")[-1]
extension = extension.lower()
if extension not in ASSET_ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported file extension")
file_slug = slugify(name)
if not extension or not file_slug:
raise HTTPException(status_code=400, detail="Missing required fields")

View File

@@ -4,6 +4,7 @@ from functools import cached_property
from fastapi import Depends, File, Form, HTTPException
from pydantic import UUID4
from mealie.lang.providers import get_locale_provider
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
@@ -44,6 +45,21 @@ class RecipeTimelineEventsController(BaseCrudController):
self.registered_exceptions,
)
def _translate_event_subject(self, event: RecipeTimelineEventOut) -> None:
"""Translate auto-generated event subjects stored as i18n key references.
Subjects are stored as ``<i18n-key>|<name>`` (e.g. ``recipe.made-this-for-dinner|Alice``).
Falls back to en-US when the requested locale has not yet been translated.
"""
if event.event_type == TimelineEventType.info.value and "|" in event.subject:
key, _, name = event.subject.partition("|")
if key.startswith("recipe."):
translated = self.t(key, name=name)
if translated == key:
translated = get_locale_provider("en-US").t(key, name=name)
if translated != key:
event.subject = translated
@router.get("", response_model=RecipeTimelineEventPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all(
@@ -52,8 +68,7 @@ class RecipeTimelineEventsController(BaseCrudController):
)
for event in response.items:
if event.event_type == TimelineEventType.system.value:
event.subject = self.t(event.subject)
self._translate_event_subject(event)
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@@ -89,8 +104,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, item_id: UUID4):
event = self.mixins.get_one(item_id)
if event.event_type == TimelineEventType.system.value:
event.subject = self.t(event.subject)
self._translate_event_subject(event)
return event
@router.put("/{item_id}", response_model=RecipeTimelineEventOut)

View File

@@ -38,8 +38,6 @@ from mealie.services.scraper import cleaner
from .template_service import TemplateService
RECIPE_CREATED_EVENT_SUBJECT = "recipe.recipe-created"
class RecipeServiceBase(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, household: HouseholdInDB, translator: Translator):
@@ -71,19 +69,8 @@ class RecipeService(RecipeServiceBase):
def can_delete(self, recipe_slugs: list[str]) -> bool:
if self.user.admin:
return True
# Deletion requires ownership; collaborative editing rules (can_update) do not apply
model = self.group_recipes.model
owned_count = self.group_recipes.session.scalar(
sa.select(sa.func.count())
.select_from(model)
.where(
model.slug.in_(recipe_slugs),
model.group_id == self.user.group_id,
model.user_id == self.user.id,
)
)
return owned_count == len(recipe_slugs)
else:
return self.can_update(recipe_slugs)
def can_update(self, recipe_slugs: list[str]) -> bool:
sql = dedent(
@@ -237,7 +224,7 @@ class RecipeService(RecipeServiceBase):
timeline_event_data = RecipeTimelineEventCreate(
user_id=new_recipe.user_id,
recipe_id=new_recipe.id,
subject=RECIPE_CREATED_EVENT_SUBJECT,
subject=self.t("recipe.recipe-created"),
event_type=TimelineEventType.system,
timestamp=new_recipe.created_at or datetime.now(UTC),
)

View File

@@ -43,12 +43,10 @@ def _create_mealplan_timeline_events_for_household(
if not user:
continue
# TODO: make this translatable
if mealplan.entry_type == PlanEntryType.side:
event_subject = f"{user.full_name} made this as a side"
event_subject = f"recipe.made-this-as-side|{user.full_name}"
else:
event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}"
event_subject = f"recipe.made-this-for-{mealplan.entry_type.value}|{user.full_name}"
query_start_time = datetime.combine(datetime.now(UTC).date(), time.min)
query_end_time = query_start_time + timedelta(days=1)

View File

@@ -1,6 +1,6 @@
[project]
name = "mealie"
version = "3.18.0"
version = "3.17.0"
description = "A Recipe Manager"
authors = [{ name = "Hayden", email = "hay-kot@pm.me" }]
license = "AGPL-3.0-only"

View File

@@ -191,24 +191,6 @@ def test_organizer_association(
assert response.status_code == 200
@pytest.mark.parametrize("route", organizer_routes, ids=test_ids)
def test_organizer_create_duplicate_name_returns_400(
api_client: TestClient,
unique_user: TestUser,
route: RoutesBase,
):
# Regression test for #7582: POSTing a duplicate name to organizer endpoints
# leaked the sqlalchemy IntegrityError as an HTTP 500. The expected behavior,
# matching other organizer endpoints (foods, units, tools), is HTTP 400.
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 400
@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids)
def test_organizer_get_by_slug(
api_client: TestClient,

View File

@@ -20,21 +20,6 @@ def create_labels(api_client: TestClient, unique_user: TestUser, count: int = 10
return labels
def test_label_create_duplicate_name_returns_400(api_client: TestClient, unique_user_fn_scoped: TestUser):
# Regression test for #7582: POSTing a duplicate label name leaked the
# sqlalchemy IntegrityError as an HTTP 500. The expected behavior, matching
# the other organizer endpoints (foods, units, tools, tags, categories),
# is HTTP 400. The function-scoped fixture avoids leaking the created label
# into the module-scoped `unique_user` group state used by sibling tests.
payload = {"name": random_string(), "color": "#ff0000"}
response = api_client.post(api_routes.groups_labels, json=payload, headers=unique_user_fn_scoped.token)
assert response.status_code == 200
response = api_client.post(api_routes.groups_labels, json=payload, headers=unique_user_fn_scoped.token)
assert response.status_code == 400
def test_new_list_creates_list_labels(api_client: TestClient, unique_user: TestUser):
labels = create_labels(api_client, unique_user)
response = api_client.post(

View File

@@ -201,12 +201,19 @@ def test_delete_recipes_from_other_households(
assert recipe_json["id"] == h2_recipe_id
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
assert response.status_code == 403
if household_lock_recipe_edits:
assert response.status_code == 403
# confirm the recipe still exists
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["id"] == h2_recipe_id
# confirm the recipe still exists
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["id"] == h2_recipe_id
else:
assert response.status_code == 200
# confirm the recipe was deleted
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 404
@pytest.mark.parametrize("is_private_household", [True, False])

View File

@@ -87,23 +87,6 @@ def test_recipe_asset_exploit(api_client: TestClient, unique_user: TestUser, rec
assert not (recipe.asset_dir / "test.txt").exists()
def test_recipe_asset_dangerous_extension_blocked(
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
):
"""Ensure scriptable extensions are rejected to prevent stored XSS (GHSA-gfwc-pjx4-mg9p)."""
recipe = recipe_ingredient_only
for ext in ("html", "svg", "js", "htm", "xhtml"):
payload = {"name": random_string(10), "icon": "mdi-file", "extension": ext}
file_payload = {"file": b"<script>alert(1)</script>"}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()}

View File

@@ -160,24 +160,6 @@ def test_other_user_cant_delete_recipe(api_client: TestClient, user_tuple: list[
assert response.status_code == 403
def test_other_user_cant_delete_unlocked_recipe(api_client: TestClient, user_tuple: list[TestUser]):
"""Non-owner must not delete an unlocked recipe — BOLA regression (GHSA-x5v9-9jvh-7c7q)."""
slug = random_string(10)
unique_user, other_user = user_tuple
unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=slug,
settings=RecipeSettings(locked=False),
)
)
response = api_client.delete(api_routes.recipes_slug(slug), headers=other_user.token)
assert response.status_code == 403
def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUser]):
slug_locked = random_string(10)
slug_unlocked = random_string(10)
@@ -208,30 +190,6 @@ def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUse
assert response.status_code == 403
def test_other_user_cant_bulk_delete_unlocked_recipes(api_client: TestClient, user_tuple: list[TestUser]):
"""Non-owner must not bulk-delete unlocked recipes — BOLA regression (GHSA-x5v9-9jvh-7c7q)."""
slug_1 = random_string(10)
slug_2 = random_string(10)
unique_user, other_user = user_tuple
for slug in (slug_1, slug_2):
unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=slug,
settings=RecipeSettings(locked=False),
)
)
response = api_client.post(
api_routes.recipes_bulk_actions_delete,
json={"recipes": [slug_1, slug_2]},
headers=other_user.token,
)
assert response.status_code == 403
def test_admin_can_delete_locked_recipe_owned_by_another_user(
api_client: TestClient, unfiltered_database: AllRepositories, unique_user: TestUser, admin_user: TestUser
):

View File

@@ -3,24 +3,18 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from mealie.lang.providers import get_all_translations
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventOut,
RecipeTimelineEventPagination,
TimelineEventImage,
TimelineEventType,
)
from mealie.schema.recipe.request_helpers import UpdateImageResponse
from mealie.services.recipe.recipe_service import RECIPE_CREATED_EVENT_SUBJECT
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
PERSISTED_TRANSLATION_KEYS = [RECIPE_CREATED_EVENT_SUBJECT]
@pytest.fixture(scope="function")
def recipes(api_client: TestClient, unique_user: TestUser):
recipes = []
@@ -347,50 +341,6 @@ def test_create_recipe_with_timeline_event(
assert events_pagination.items
@pytest.mark.parametrize("translation_key", PERSISTED_TRANSLATION_KEYS)
def test_persisted_translation_keys_have_translations(translation_key: str):
translations = get_all_translations(translation_key)
missing_translations = [locale for locale, translation in translations.items() if translation == translation_key]
assert missing_translations == []
def test_recipe_created_system_event_is_translated(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
):
recipe = recipes[0]
params = {"queryFilter": f"recipe_id={recipe.id}"}
# fetch events in French — the system "recipe created" event should be translated
fr_headers = {**unique_user.token, "Accept-Language": "fr-FR"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=fr_headers)
assert events_response.status_code == 200
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
system_events = [e for e in events_pagination.items if e.event_type == TimelineEventType.system.value]
assert system_events, "expected at least one system event for a newly created recipe"
for event in system_events:
assert event.subject == "Recette créée", f"expected French translation, got: {event.subject!r}"
# also verify the individual GET endpoint translates correctly
single_response = api_client.get(api_routes.recipes_timeline_events_item_id(event.id), headers=fr_headers)
assert single_response.status_code == 200
single_event = RecipeTimelineEventOut.model_validate(single_response.json())
assert single_event.subject == "Recette créée"
# fetch the same events in English — subject should be the English string
en_headers = {**unique_user.token, "Accept-Language": "en-US"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=en_headers)
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
system_events = [e for e in events_pagination.items if e.event_type == TimelineEventType.system.value]
for event in system_events:
assert event.subject == "Recipe Created", f"expected English string, got: {event.subject!r}"
@pytest.mark.parametrize("use_other_household_user", [True, False])
def test_invalid_recipe_id(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, use_other_household_user: bool

View File

@@ -13,6 +13,49 @@ from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
def _create_recipe_and_mealplan(api_client: TestClient, user: TestUser, entry_type: str) -> tuple[RecipeSummary, int]:
recipe_name = random_string(length=25)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=user.token)
assert response.status_code == 201
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=user.token)
recipe = RecipeSummary.model_validate(response.json())
params = {"queryFilter": f"recipe_id={recipe.id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
initial_event_count = len(response.json()["items"])
new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type=entry_type, recipe_id=recipe.id).model_dump(
by_alias=True
)
new_plan["date"] = datetime.now(UTC).date().isoformat()
new_plan["recipeId"] = str(recipe.id)
response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=user.token)
assert response.status_code == 201
return recipe, initial_event_count
def _get_mealplan_event(
api_client: TestClient, user: TestUser, recipe: RecipeSummary, initial_count: int, extra_headers: dict
) -> dict:
create_mealplan_timeline_events()
params = {
"page": "1",
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe.id}",
}
response = api_client.get(
api_routes.recipes_timeline_events, headers={**user.token, **extra_headers}, params=params
)
items = response.json()["items"]
assert len(items) == initial_count + 1
return items[0]
def test_no_mealplans():
# make sure this task runs successfully even if it doesn't do anything
create_mealplan_timeline_events()
@@ -251,3 +294,27 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token)
household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made is None
def test_mealplan_event_subject_is_translated(api_client: TestClient, unique_user: TestUser):
"""Mealplan timeline event subjects are stored as i18n keys and translated at serve time."""
# --- dinner entry type ---
recipe, initial_count = _create_recipe_and_mealplan(api_client, unique_user, "dinner")
event = _get_mealplan_event(api_client, unique_user, recipe, initial_count, {"Accept-Language": "en-US"})
expected = f"{unique_user.full_name} made this for dinner"
assert event["subject"] == expected, f"expected {expected!r}, got {event['subject']!r}"
# --- side entry type uses a distinct phrase ---
recipe2, initial_count2 = _create_recipe_and_mealplan(api_client, unique_user, "side")
event2 = _get_mealplan_event(api_client, unique_user, recipe2, initial_count2, {"Accept-Language": "en-US"})
expected2 = f"{unique_user.full_name} made this as a side"
assert event2["subject"] == expected2, f"expected {expected2!r}, got {event2['subject']!r}"
# --- locale fallback: fr-FR doesn't have these keys yet, should fall back to en-US ---
recipe3, initial_count3 = _create_recipe_and_mealplan(api_client, unique_user, "lunch")
event3 = _get_mealplan_event(api_client, unique_user, recipe3, initial_count3, {"Accept-Language": "fr-FR"})
expected3 = f"{unique_user.full_name} made this for lunch"
assert event3["subject"] == expected3, f"expected en-US fallback {expected3!r}, got {event3['subject']!r}"

2
uv.lock generated
View File

@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "mealie"
version = "3.18.0"
version = "3.17.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },