Merge branch 'mealie-next' into patch-1

This commit is contained in:
Joschka Rick 2025-01-27 18:10:46 +01:00 committed by GitHub
commit 1768af25d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 442 additions and 291 deletions

View file

@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/ exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.9.2 rev: v0.9.3
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format

View file

@ -0,0 +1,8 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).
However, for this to work, your Mealie instance needs to be exposed to the open Internet so that the Bring servers can access its information. If you don't want your server to be publicly accessible for security reasons, you can use the [Mealie-Bring-API](https://github.com/felixschndr/mealie-bring-api) written by a community member. This integration is entirely local and does not require any service to be exposed to the Internet.
This is a small web server that runs locally next to your Mealie instance, and instead of Bring pulling the data from you, it pushes the data to Bring. [Check out the project](https://github.com/felixschndr/mealie-bring-api) for more information and installation instructions.

View file

@ -10,7 +10,7 @@
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including: Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/) - [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc) - [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
- [Okta](https://www.okta.com/openid-connect/) - [Okta](https://www.okta.com/openid-connect/)

View file

@ -139,6 +139,9 @@ Below is a list of all valid merge fields:
- ${id} - ${id}
- ${slug} - ${slug}
- ${url} - ${url}
- ${servings}
- ${yieldQuantity}
- ${yieldText}
To add, modify, or delete Recipe Actions, visit the Data Management page (more on that below). To add, modify, or delete Recipe Actions, visit the Data Management page (more on that below).

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: 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! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.4.2` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.5.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. 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 4. Restart the container

View file

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

View file

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

View file

@ -371,7 +371,7 @@ export default defineComponent({
const groupRecipeActionsStore = useGroupRecipeActions(); const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) { async function executeRecipeAction(action: GroupRecipeActionOut) {
const response = await groupRecipeActionsStore.execute(action, props.recipe); const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") { if (action.actionType === "post") {
if (!response?.error) { if (!response?.error) {

View file

@ -46,17 +46,23 @@ export const useGroupRecipeActions = function (
return groupRecipeActions.value; return groupRecipeActions.value;
}); });
function parseRecipeActionUrl(url: string, recipe: Recipe): string { function parseRecipeActionUrl(url: string, recipe: Recipe, recipeScale: number): string {
const recipeServings = (recipe.recipeServings || 1) * recipeScale;
const recipeYieldQuantity = (recipe.recipeYieldQuantity || 1) * recipeScale;
/* eslint-disable no-template-curly-in-string */ /* eslint-disable no-template-curly-in-string */
return url return url
.replace("${url}", window.location.href) .replace("${url}", window.location.href)
.replace("${id}", recipe.id || "") .replace("${id}", recipe.id || "")
.replace("${slug}", recipe.slug || "") .replace("${slug}", recipe.slug || "")
.replace("${servings}", recipeServings.toString())
.replace("${yieldQuantity}", recipeYieldQuantity.toString())
.replace("${yieldText}", recipe.recipeYield || "")
/* eslint-enable no-template-curly-in-string */ /* eslint-enable no-template-curly-in-string */
}; };
async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | RequestResponse<unknown>> { async function execute(action: GroupRecipeActionOut, recipe: Recipe, recipeScale: number): Promise<void | RequestResponse<unknown>> {
const url = parseRecipeActionUrl(action.url, recipe); const url = parseRecipeActionUrl(action.url, recipe, recipeScale);
switch (action.actionType) { switch (action.actionType) {
case "link": case "link":

View file

@ -349,7 +349,7 @@
"note-only": "Pouze poznámka", "note-only": "Pouze poznámka",
"random-meal": "Náhodné jídlo", "random-meal": "Náhodné jídlo",
"random-dinner": "Náhodná večeře", "random-dinner": "Náhodná večeře",
"random-side": "Random Side", "random-side": "Náhodná příloha",
"this-rule-will-apply": "Toto pravidlo se použije {dayCriteria} {mealTypeCriteria}.", "this-rule-will-apply": "Toto pravidlo se použije {dayCriteria} {mealTypeCriteria}.",
"to-all-days": "na všechny dny", "to-all-days": "na všechny dny",
"on-days": "on {0}s", "on-days": "on {0}s",
@ -1059,14 +1059,14 @@
"food-label": "Označení jídla", "food-label": "Označení jídla",
"edit-food": "Upravit jídlo", "edit-food": "Upravit jídlo",
"food-data": "Data jídla", "food-data": "Data jídla",
"example-food-singular": "ex: Onion", "example-food-singular": "např.: Brambora",
"example-food-plural": "ex: Onions", "example-food-plural": "např.: Brambory",
"label-overwrite-warning": "Toto přiřadí vybraný štítek všem vybraným jídlům a může přepsat stávající štítky.", "label-overwrite-warning": "Toto přiřadí vybraný štítek všem vybraným jídlům a může přepsat stávající štítky.",
"on-hand-checkbox-label": "Nastavením tohoto příznaku bude tato potravina při přidávání receptu do nákupního seznamu ve výchozím nastavení odškrtnuta." "on-hand-checkbox-label": "Nastavením tohoto příznaku bude tato potravina při přidávání receptu do nákupního seznamu ve výchozím nastavení odškrtnuta."
}, },
"units": { "units": {
"seed-dialog-text": "Naplnit databázi s běžnými jednotkami používanými ve vašem jazyce.", "seed-dialog-text": "Naplnit databázi s běžnými jednotkami používanými ve vašem jazyce.",
"combine-unit-description": "Combining the selected units will merge the Source Unit and Target Unit into a single unit. The {source-unit-will-be-deleted} and all of the references to the Source Unit will be updated to point to the Target Unit.", "combine-unit-description": "Zkombinování zvolených jednotek spojí zdrojovou a cílovou jednotku do jedné. {source-unit-will-be-deleted} a všechny odkazy na ni budou upraveny na cílovou jednotku.",
"combine-unit": "Kombinovaná jednotka", "combine-unit": "Kombinovaná jednotka",
"source-unit": "Zdrojová jednotka", "source-unit": "Zdrojová jednotka",
"target-unit": "Cílová jednotka", "target-unit": "Cílová jednotka",
@ -1081,10 +1081,10 @@
"unit-data": "Data jednotky", "unit-data": "Data jednotky",
"use-abbv": "Používat zkratky", "use-abbv": "Používat zkratky",
"fraction": "Zlomek", "fraction": "Zlomek",
"example-unit-singular": "ex: Tablespoon", "example-unit-singular": "např.: Čajová lžička",
"example-unit-plural": "ex: Tablespoons", "example-unit-plural": "např.: Čajové lžičky",
"example-unit-abbreviation-singular": "ex: Tbsp", "example-unit-abbreviation-singular": "např.: čl",
"example-unit-abbreviation-plural": "ex: Tbsps" "example-unit-abbreviation-plural": "např.: čl"
}, },
"labels": { "labels": {
"seed-dialog-text": "Naplnit databázi s běžnými popisky používanými ve vašem jazyce.", "seed-dialog-text": "Naplnit databázi s běžnými popisky používanými ve vašem jazyce.",
@ -1296,7 +1296,7 @@
"profile": { "profile": {
"welcome-user": "👋 Vítejte, {0}!", "welcome-user": "👋 Vítejte, {0}!",
"description": "Spravujte svůj profil, recepty a nastavení skupiny.", "description": "Spravujte svůj profil, recepty a nastavení skupiny.",
"invite-link": "Invite Link", "invite-link": "Odkaz pozvánky",
"get-invite-link": "Získat odkaz na pozvánku", "get-invite-link": "Získat odkaz na pozvánku",
"get-public-link": "Získat veřejný odkaz", "get-public-link": "Získat veřejný odkaz",
"account-summary": "Přehled účtu", "account-summary": "Přehled účtu",
@ -1346,7 +1346,7 @@
"cookbook": { "cookbook": {
"cookbooks": "Kuchařky", "cookbooks": "Kuchařky",
"description": "Kuchařky jsou dalším způsobem, jak uspořádat recepty vytvořením průřezů receptů, organizátorů a dalších filtrů. Vytvořením kuchařky se přidá položka na postranní panel a v kuchařce se zobrazí všechny recepty s vybranými filtry.", "description": "Kuchařky jsou dalším způsobem, jak uspořádat recepty vytvořením průřezů receptů, organizátorů a dalších filtrů. Vytvořením kuchařky se přidá položka na postranní panel a v kuchařce se zobrazí všechny recepty s vybranými filtry.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households", "hide-cookbooks-from-other-households": "Skrýt kuchařky ostatních domácností",
"hide-cookbooks-from-other-households-description": "Pokud je povoleno, objeví se na postranním panelu pouze kuchařské knihy z vaší domácnosti", "hide-cookbooks-from-other-households-description": "Pokud je povoleno, objeví se na postranním panelu pouze kuchařské knihy z vaší domácnosti",
"public-cookbook": "Veřejná kuchařka", "public-cookbook": "Veřejná kuchařka",
"public-cookbook-description": "Veřejné kuchařky mohou být sdíleny s neregistrovanými uživateli a budou zobrazeny na stránce vaší skupiny.", "public-cookbook-description": "Veřejné kuchařky mohou být sdíleny s neregistrovanými uživateli a budou zobrazeny na stránce vaší skupiny.",

View file

@ -174,7 +174,7 @@
"wednesday": "Τετάρτη", "wednesday": "Τετάρτη",
"yes": "Ναι", "yes": "Ναι",
"foods": "Τρόφιμα", "foods": "Τρόφιμα",
"units": "Μονάδες", "units": "Μονάδες μέτρησης",
"back": "Πίσω", "back": "Πίσω",
"next": "Επόμενο", "next": "Επόμενο",
"start": "Εναρξη", "start": "Εναρξη",

View file

@ -277,7 +277,7 @@
"admin-group-management-text": "Los cambios en este grupo se reflejarán inmediatamente.", "admin-group-management-text": "Los cambios en este grupo se reflejarán inmediatamente.",
"group-id-value": "Id del Grupo: {0}", "group-id-value": "Id del Grupo: {0}",
"total-households": "Total de Casas", "total-households": "Total de Casas",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household" "you-must-select-a-group-before-selecting-a-household": "Debe seleccionar un grupo antes de seleccionar un hogar"
}, },
"household": { "household": {
"household": "Casa", "household": "Casa",
@ -518,7 +518,7 @@
"save-recipe-before-use": "Guardar la receta antes de usar", "save-recipe-before-use": "Guardar la receta antes de usar",
"section-title": "Título de la sección", "section-title": "Título de la sección",
"servings": "Porciones", "servings": "Porciones",
"serves-amount": "Serves {amount}", "serves-amount": "Personas {amount}",
"share-recipe-message": "Quería compartir mi receta {0} contigo.", "share-recipe-message": "Quería compartir mi receta {0} contigo.",
"show-nutrition-values": "Mostrar valores nutricionales", "show-nutrition-values": "Mostrar valores nutricionales",
"sodium-content": "Sodio", "sodium-content": "Sodio",
@ -547,8 +547,8 @@
"failed-to-add-recipe-to-mealplan": "Error al añadir receta al menú", "failed-to-add-recipe-to-mealplan": "Error al añadir receta al menú",
"failed-to-add-to-list": "No se pudo agregar a la lista", "failed-to-add-to-list": "No se pudo agregar a la lista",
"yield": "Raciones", "yield": "Raciones",
"yields-amount-with-text": "Yields {amount} {text}", "yields-amount-with-text": "Raciones {amount} {text}",
"yield-text": "Yield Text", "yield-text": "Texto de raciones",
"quantity": "Cantidad", "quantity": "Cantidad",
"choose-unit": "Elija unidad", "choose-unit": "Elija unidad",
"press-enter-to-create": "Presione Intro para crear", "press-enter-to-create": "Presione Intro para crear",
@ -637,9 +637,9 @@
"recipe-debugger-use-openai-description": "Utilice OpenAI para analizar los resultados en lugar de depender de la biblioteca de analizadores. Cuando se crea una receta a través de la URL, esto se hace automáticamente si la biblioteca del analizador falla, pero puede probarla manualmente aquí.", "recipe-debugger-use-openai-description": "Utilice OpenAI para analizar los resultados en lugar de depender de la biblioteca de analizadores. Cuando se crea una receta a través de la URL, esto se hace automáticamente si la biblioteca del analizador falla, pero puede probarla manualmente aquí.",
"debug": "Depuración", "debug": "Depuración",
"tree-view": "Vista en árbol", "tree-view": "Vista en árbol",
"recipe-servings": "Recipe Servings", "recipe-servings": "Cantidad de personas",
"recipe-yield": "Porciones", "recipe-yield": "Porciones",
"recipe-yield-text": "Recipe Yield Text", "recipe-yield-text": "Texto de raciones totales",
"unit": "Unidades", "unit": "Unidades",
"upload-image": "Subir imagen", "upload-image": "Subir imagen",
"screen-awake": "Mantener la pantalla encendida", "screen-awake": "Mantener la pantalla encendida",
@ -662,24 +662,24 @@
"no-food": "Sin Comida" "no-food": "Sin Comida"
}, },
"reset-servings-count": "Restablecer contador de porciones", "reset-servings-count": "Restablecer contador de porciones",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Ingredientes adicionales"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Buscador de recetas",
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.", "recipe-finder-description": "Busca recetas basadas en los ingredientes que tengas disponibles. También puede filtrar por utensilios disponibles, y establecer un número máximo de ingredientes o herramientas que faltan.",
"selected-ingredients": "Ingredientes seleccionados", "selected-ingredients": "Ingredientes seleccionados",
"no-ingredients-selected": "Ningún ingrediente seleccionado", "no-ingredients-selected": "Ningún ingrediente seleccionado",
"missing": "Missing", "missing": "Faltan",
"no-recipes-found": "No se encontraron recetas", "no-recipes-found": "No se encontraron recetas",
"no-recipes-found-description": "Intenta añadir más ingredientes a tu búsqueda o ajustar tus filtros", "no-recipes-found-description": "Intenta añadir más ingredientes a tu búsqueda o ajustar tus filtros",
"include-ingredients-on-hand": "Incluye ingredientes a mano", "include-ingredients-on-hand": "Incluye ingredientes a mano",
"include-tools-on-hand": "Include Tools On Hand", "include-tools-on-hand": "Incluye utensilios disponibles",
"max-missing-ingredients": "Max Missing Ingredients", "max-missing-ingredients": "Máximo de ingredientes que faltan",
"max-missing-tools": "Max Missing Tools", "max-missing-tools": "Máximo de utensilios que faltan",
"selected-tools": "Selected Tools", "selected-tools": "Utensilios seleccionados",
"other-filters": "Other Filters", "other-filters": "Otros filtros",
"ready-to-make": "Ready to Make", "ready-to-make": "Listo para hacer",
"almost-ready-to-make": "Almost Ready to Make" "almost-ready-to-make": "Casi listo para hacer"
}, },
"search": { "search": {
"advanced-search": "Búsqueda avanzada", "advanced-search": "Búsqueda avanzada",
@ -884,7 +884,7 @@
"are-you-sure-you-want-to-check-all-items": "¿Seguro que quieres seleccionar todos los elementos?", "are-you-sure-you-want-to-check-all-items": "¿Seguro que quieres seleccionar todos los elementos?",
"are-you-sure-you-want-to-uncheck-all-items": "¿Seguro que quieres de-seleccionar todos los elementos?", "are-you-sure-you-want-to-uncheck-all-items": "¿Seguro que quieres de-seleccionar todos los elementos?",
"are-you-sure-you-want-to-delete-checked-items": "¿Está seguro que deseas eliminar los elementos seleccionados?", "are-you-sure-you-want-to-delete-checked-items": "¿Está seguro que deseas eliminar los elementos seleccionados?",
"no-shopping-lists-found": "No Shopping Lists Found" "no-shopping-lists-found": "No hay listas de la compra"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Recetas", "all-recipes": "Recetas",
@ -1296,7 +1296,7 @@
"profile": { "profile": {
"welcome-user": "👋 ¡Bienvenido, {0}!", "welcome-user": "👋 ¡Bienvenido, {0}!",
"description": "Administra tu perfil, recetas y ajustes de grupo.", "description": "Administra tu perfil, recetas y ajustes de grupo.",
"invite-link": "Invite Link", "invite-link": "Link de invitación",
"get-invite-link": "Obtener enlace de invitación", "get-invite-link": "Obtener enlace de invitación",
"get-public-link": "Obtener enlace público", "get-public-link": "Obtener enlace público",
"account-summary": "Información de la cuenta", "account-summary": "Información de la cuenta",
@ -1346,7 +1346,7 @@
"cookbook": { "cookbook": {
"cookbooks": "Recetarios", "cookbooks": "Recetarios",
"description": "Los recetarios son otra forma de organizar recetas creando secciones cruzadas de recetas y etiquetas. Crear un recetario añadirá una entrada a la barra lateral y todas las recetas con las etiquetas y categorías elegidas se mostrarán en el recetario.", "description": "Los recetarios son otra forma de organizar recetas creando secciones cruzadas de recetas y etiquetas. Crear un recetario añadirá una entrada a la barra lateral y todas las recetas con las etiquetas y categorías elegidas se mostrarán en el recetario.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households", "hide-cookbooks-from-other-households": "Ocultar libros de cocina de otros grupos/hogares",
"hide-cookbooks-from-other-households-description": "Cuando esté habilitado, sólo los libros de cocina de su hogar aparecerán en la barra lateral", "hide-cookbooks-from-other-households-description": "Cuando esté habilitado, sólo los libros de cocina de su hogar aparecerán en la barra lateral",
"public-cookbook": "Recetario público", "public-cookbook": "Recetario público",
"public-cookbook-description": "Los recetarios públicos se pueden compartir con usuarios externos y se mostrarán en su página de grupos.", "public-cookbook-description": "Los recetarios públicos se pueden compartir con usuarios externos y se mostrarán en su página de grupos.",

View file

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

View file

@ -17,11 +17,69 @@
<v-container> <v-container>
<v-row> <v-row>
<v-col cols="3" class="text-left"> <v-col cols="3" class="text-left">
<ButtonLink :to="`/shopping-lists?disableRedirect=true`" :text="$tc('general.back')" :icon="$globals.icons.backArrow" /> <ButtonLink :to="`/shopping-lists?disableRedirect=true`" :text="$tc('shopping-list.all-lists')" :icon="$globals.icons.backArrow" />
</v-col> </v-col>
<v-col cols="6" class="d-flex justify-center"> <v-col cols="6" class="d-none d-lg-flex justify-center">
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img> <v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
</v-col> </v-col>
<v-col class="d-flex justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.contentCopy,
text: '',
event: 'edit',
children: [
{
icon: $globals.icons.contentCopy,
text: $tc('shopping-list.copy-as-text'),
event: 'copy-plain',
},
{
icon: $globals.icons.contentCopy,
text: $tc('shopping-list.copy-as-markdown'),
event: 'copy-markdown',
},
],
},
{
icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'),
event: 'check',
},
{
icon: $globals.icons.dotsVertical,
text: '',
event: 'three-dot',
children: [
{
icon: $globals.icons.tags,
text: $tc('shopping-list.toggle-label-sort'),
event: 'sort-by-labels',
},
{
icon: $globals.icons.tags,
text: $tc('shopping-list.reorder-labels'),
event: 'reorder-labels',
},
{
icon: $globals.icons.tags,
text: $tc('shopping-list.manage-labels'),
event: 'manage-labels',
},
],
},
]"
@edit="edit = true"
@three-dot="threeDot = true"
@check="openCheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')"
@reorder-labels="toggleReorderLabelsDialog()"
@manage-labels="$router.push(`/group/data/labels`)"
/>
</v-col>
</v-row> </v-row>
</v-container> </v-container>
</template> </template>
@ -119,27 +177,6 @@
</v-card> </v-card>
</BaseDialog> </BaseDialog>
<!-- Settings -->
<BaseDialog
v-model="settingsDialog"
:icon="$globals.icons.cog"
:title="$t('general.settings')"
@confirm="updateSettings"
>
<v-container>
<v-form>
<v-select
v-model="currentUserId"
:items="allUsers"
item-text="fullName"
item-value="id"
:label="$t('general.owner')"
:prepend-icon="$globals.icons.user"
/>
</v-form>
</v-container>
</BaseDialog>
<!-- Create Item --> <!-- Create Item -->
<div v-if="createEditorOpen"> <div v-if="createEditorOpen">
<ShoppingListItemEditor <ShoppingListItemEditor
@ -154,78 +191,41 @@
/> />
</div> </div>
<div v-else class="mt-4 d-flex justify-end"> <div v-else class="mt-4 d-flex justify-end">
<BaseButton
v-if="preferences.viewByLabel" edit class="mr-2"
:disabled="$nuxt.isOffline"
@click="toggleReorderLabelsDialog">
<template #icon> {{ $globals.icons.tags }} </template>
{{ $t('shopping-list.reorder-labels') }}
</BaseButton>
<BaseButton create @click="createEditorOpen = true" > {{ $t('general.add') }} </BaseButton> <BaseButton create @click="createEditorOpen = true" > {{ $t('general.add') }} </BaseButton>
</div> </div>
<!-- Action Bar -->
<div class="d-flex justify-end mb-4 mt-2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.contentCopy,
text: '',
event: 'edit',
children: [
{
icon: $globals.icons.contentCopy,
text: $tc('shopping-list.copy-as-text'),
event: 'copy-plain',
},
{
icon: $globals.icons.contentCopy,
text: $tc('shopping-list.copy-as-markdown'),
event: 'copy-markdown',
},
],
},
{
icon: $globals.icons.delete,
text: $tc('shopping-list.delete-checked'),
event: 'delete',
},
{
icon: $globals.icons.tags,
text: $tc('shopping-list.toggle-label-sort'),
event: 'sort-by-labels',
},
{
icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'),
event: 'uncheck',
},
{
icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'),
event: 'check',
},
]"
@edit="edit = true"
@delete="openDeleteChecked"
@uncheck="openUncheckAll"
@check="openCheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')"
/>
</div>
<!-- Checked Items --> <!-- Checked Items -->
<div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6"> <div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6">
<button @click="toggleShowChecked()"> <div class="d-flex">
<span> <div class="flex-grow-1">
<v-icon> <button @click="toggleShowChecked()">
{{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }} <span>
</v-icon> <v-icon>
</span> {{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }}
{{ $tc('shopping-list.items-checked-count', listItems.checked ? listItems.checked.length : 0) }} </v-icon>
</button> </span>
{{ $tc('shopping-list.items-checked-count', listItems.checked ? listItems.checked.length : 0) }}
</button>
</div>
<div class="justify-end mt-n2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'),
event: 'uncheck',
},
{
icon: $globals.icons.delete,
text: $tc('shopping-list.delete-checked'),
event: 'delete',
},
]"
@uncheck="openUncheckAll"
@delete="openDeleteChecked"
/>
</div>
</div>
<v-divider class="my-4"></v-divider> <v-divider class="my-4"></v-divider>
<v-expand-transition> <v-expand-transition>
<div v-show="showChecked"> <div v-show="showChecked">
@ -277,29 +277,6 @@
</RecipeList> </RecipeList>
</section> </section>
</v-lazy> </v-lazy>
<v-lazy>
<div class="d-flex justify-end">
<BaseButton
edit
:disabled="$nuxt.isOffline"
@click="toggleSettingsDialog"
>
<template #icon> {{ $globals.icons.cog }} </template>
{{ $t('general.settings') }}
</BaseButton>
</div>
</v-lazy>
<v-lazy>
<div v-if="$nuxt.isOnline" class="d-flex justify-end mt-10">
<ButtonLink
:to="`/group/data/labels`"
:text="$tc('shopping-list.manage-labels')"
:icon="$globals.icons.tags"
/>
</div>
</v-lazy>
<WakelockSwitch/> <WakelockSwitch/>
</v-container> </v-container>
</template> </template>
@ -314,7 +291,6 @@ import { useUserApi } from "~/composables/api";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue" import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household"; import { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
import { UserOut } from "~/lib/api/types/user";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
@ -349,8 +325,8 @@ export default defineComponent({
const userApi = useUserApi(); const userApi = useUserApi();
const edit = ref(false); const edit = ref(false);
const threeDot = ref(false);
const reorderLabelsDialog = ref(false); const reorderLabelsDialog = ref(false);
const settingsDialog = ref(false);
const preserveItemOrder = ref(false); const preserveItemOrder = ref(false);
const route = useRoute(); const route = useRoute();
@ -678,13 +654,6 @@ export default defineComponent({
localLabels.value = shoppingList.value?.labelSettings localLabels.value = shoppingList.value?.labelSettings
} }
async function toggleSettingsDialog() {
if (!settingsDialog.value) {
await fetchAllUsers();
}
settingsDialog.value = !settingsDialog.value;
}
function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) { function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
if (!shoppingList.value) { if (!shoppingList.value) {
return; return;
@ -1064,39 +1033,6 @@ export default defineComponent({
refresh(); refresh();
} }
// ===============================================================
// Shopping List Settings
const allUsers = ref<UserOut[]>([]);
const currentUserId = ref<string | undefined>();
async function fetchAllUsers() {
const { data } = await userApi.households.fetchMembers();
if (!data) {
return;
}
// update current user
allUsers.value = data.items.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1));
currentUserId.value = shoppingList.value?.userId;
}
async function updateSettings() {
if (!shoppingList.value || !currentUserId.value) {
return;
}
loadingCounter.value += 1;
const { data } = await userApi.shopping.lists.updateOne(
shoppingList.value.id,
{...shoppingList.value, userId: currentUserId.value},
);
loadingCounter.value -= 1;
if (data) {
refresh();
}
}
return { return {
...toRefs(state), ...toRefs(state),
addRecipeReferenceToList, addRecipeReferenceToList,
@ -1112,6 +1048,7 @@ export default defineComponent({
openDeleteChecked, openDeleteChecked,
deleteListItem, deleteListItem,
edit, edit,
threeDot,
getLabelColor, getLabelColor,
groupSlug, groupSlug,
itemsByLabel, itemsByLabel,
@ -1123,8 +1060,6 @@ export default defineComponent({
removeRecipeReferenceToList, removeRecipeReferenceToList,
reorderLabelsDialog, reorderLabelsDialog,
toggleReorderLabelsDialog, toggleReorderLabelsDialog,
settingsDialog,
toggleSettingsDialog,
localLabels, localLabels,
updateLabelOrder, updateLabelOrder,
cancelLabelOrder, cancelLabelOrder,
@ -1144,9 +1079,6 @@ export default defineComponent({
updateIndexUncheckedByLabel, updateIndexUncheckedByLabel,
allUnits, allUnits,
allFoods, allFoods,
allUsers,
currentUserId,
updateSettings,
getTextColor, getTextColor,
}; };
}, },

View file

@ -6,6 +6,27 @@
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Settings -->
<BaseDialog
v-model="ownerDialog"
:icon="$globals.icons.admin"
:title="$t('user.edit-user')"
@confirm="updateOwner"
>
<v-container>
<v-form>
<v-select
v-model="updateUserId"
:items="allUsers"
item-text="fullName"
item-value="id"
:label="$t('general.owner')"
:prepend-icon="$globals.icons.user"
/>
</v-form>
</v-container>
</BaseDialog>
<BaseDialog v-model="deleteDialog" :title="$tc('general.confirm')" color="error" @confirm="deleteOne"> <BaseDialog v-model="deleteDialog" :title="$tc('general.confirm')" color="error" @confirm="deleteOne">
<v-card-text>{{ $t('shopping-list.are-you-sure-you-want-to-delete-this-item') }}</v-card-text> <v-card-text>{{ $t('shopping-list.are-you-sure-you-want-to-delete-this-item') }}</v-card-text>
</BaseDialog> </BaseDialog>
@ -38,26 +59,34 @@
<v-icon left> <v-icon left>
{{ $globals.icons.cartCheck }} {{ $globals.icons.cartCheck }}
</v-icon> </v-icon>
{{ list.name }} <div class="flex-grow-1">
<v-btn class="ml-auto" icon @click.prevent="openDelete(list.id)"> {{ list.name }}
<v-icon> </div>
{{ $globals.icons.delete }} <div class="d-flex justify-end">
</v-icon> <v-btn icon @click.prevent="toggleOwnerDialog(list)">
</v-btn> <v-icon>
{{ $globals.icons.user }}
</v-icon>
</v-btn>
<v-btn icon @click.prevent="openDelete(list.id)">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</div>
</v-card-title> </v-card-title>
</v-card> </v-card>
</section> </section>
<div class="d-flex justify-end mt-10">
<ButtonLink :to="`/group/data/labels`" :text="$tc('shopping-list.manage-labels')" :icon="$globals.icons.tags" />
</div>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useAsync, useContext, reactive, ref, toRefs, useRoute, useRouter, watch } from "@nuxtjs/composition-api"; import { computed, defineComponent, useAsync, useContext, reactive, ref, toRefs, useRoute, useRouter, watch } from "@nuxtjs/composition-api";
import { ShoppingListOut } from "~/lib/api/types/household";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils"; import { useAsyncKey } from "~/composables/use-utils";
import { useShoppingListPreferences } from "~/composables/use-users/preferences"; import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({ export default defineComponent({
middleware: "auth", middleware: "auth",
@ -77,6 +106,8 @@ export default defineComponent({
createDialog: false, createDialog: false,
deleteDialog: false, deleteDialog: false,
deleteTarget: "", deleteTarget: "",
ownerDialog: false,
ownerTarget: ref<ShoppingListOut | null>(null),
}); });
const shoppingLists = useAsync(async () => { const shoppingLists = useAsync(async () => {
@ -136,6 +167,53 @@ export default defineComponent({
} }
} }
async function toggleOwnerDialog(list: ShoppingListOut) {
if (!state.ownerDialog) {
state.ownerTarget = list;
await fetchAllUsers();
}
state.ownerDialog = !state.ownerDialog;
}
// ===============================================================
// Shopping List Edit User/Owner
const allUsers = ref<UserOut[]>([]);
const updateUserId = ref<string | undefined>();
async function fetchAllUsers() {
const { data } = await userApi.households.fetchMembers();
if (!data) {
return;
}
// update current user
allUsers.value = data.items.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1));
updateUserId.value = state.ownerTarget?.userId;
}
async function updateOwner() {
if (!state.ownerTarget || !updateUserId.value) {
return;
}
// user has not changed, so we should not update
if (state.ownerTarget.userId === updateUserId.value) {
return;
}
// get full list, so the move does not delete shopping list items
const { data: fullList } = await userApi.shopping.lists.getOne(state.ownerTarget.id);
if (!fullList) {
return;
}
const { data } = await userApi.shopping.lists.updateOne(
state.ownerTarget.id,
{...fullList, userId: updateUserId.value},
);
if (data) {
refresh();
}
}
function openDelete(id: string) { function openDelete(id: string) {
state.deleteDialog = true; state.deleteDialog = true;
state.deleteTarget = id; state.deleteTarget = id;
@ -155,6 +233,10 @@ export default defineComponent({
preferences, preferences,
shoppingListChoices, shoppingListChoices,
createOne, createOne,
toggleOwnerDialog,
allUsers,
updateUserId,
updateOwner,
deleteOne, deleteOne,
openDelete, openDelete,
}; };

View file

@ -42,7 +42,7 @@
"generic-updated": "{name} blev opdateret", "generic-updated": "{name} blev opdateret",
"generic-created-with-url": "{name} er oprettet, {url}", "generic-created-with-url": "{name} er oprettet, {url}",
"generic-updated-with-url": "{name} er blevet opdateret, {url}", "generic-updated-with-url": "{name} er blevet opdateret, {url}",
"generic-duplicated": "{name} er blevet dublikeret", "generic-duplicated": "{name} er blevet duplikeret",
"generic-deleted": "{name} er blevet slettet" "generic-deleted": "{name} er blevet slettet"
}, },
"datetime": { "datetime": {
@ -60,7 +60,7 @@
"header_text": "Glemt adgangskode", "header_text": "Glemt adgangskode",
"message_top": "Du har anmodet om at nulstille din adgangskode.", "message_top": "Du har anmodet om at nulstille din adgangskode.",
"message_bottom": "Klik på knappen ovenfor for at nulstille din adgangskode.", "message_bottom": "Klik på knappen ovenfor for at nulstille din adgangskode.",
"button_text": "Nulstil adgangskoden" "button_text": "Nulstil adgangskode"
}, },
"invitation": { "invitation": {
"subject": "Invitation til at deltage i Mealie", "subject": "Invitation til at deltage i Mealie",

View file

@ -4,18 +4,18 @@
}, },
"recipe": { "recipe": {
"unique-name-error": "El nombre de la receta debe ser único", "unique-name-error": "El nombre de la receta debe ser único",
"recipe-created": "Recipe Created", "recipe-created": "Receta creada",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Taza de harina", "ingredient-note": "1 Taza de harina",
"step-text": "Los pasos de receta, así como otros campos en la página de recetas, soportan sintaxis Markdown.\n\n**Agregar un enlace**\n\n[Mi Enlace](https://demo.mealie.io)\n" "step-text": "Los pasos de receta, así como otros campos en la página de recetas, soportan sintaxis Markdown.\n\n**Agregar un enlace**\n\n[Mi Enlace](https://demo.mealie.io)\n"
}, },
"servings-text": { "servings-text": {
"makes": "Makes", "makes": "Realizadas",
"serves": "Serves", "serves": "Personas",
"serving": "Serving", "serving": "Porción",
"servings": "Servings", "servings": "Porciones",
"yield": "Yield", "yield": "Ración",
"yields": "Yields" "yields": "Raciones"
} }
}, },
"mealplan": { "mealplan": {

View file

@ -48,7 +48,7 @@
}, },
"bell-peppers": { "bell-peppers": {
"name": "papriky", "name": "papriky",
"plural_name": "bell peppers" "plural_name": "papriky"
}, },
"blackberries": { "blackberries": {
"name": "ostružiny" "name": "ostružiny"
@ -260,8 +260,8 @@
"name": "kapradiny" "name": "kapradiny"
}, },
"fiddlehead-fern": { "fiddlehead-fern": {
"name": "fiddlehead fern", "name": "kapradina",
"plural_name": "fiddlehead ferns" "plural_name": "kapradiny"
}, },
"fish": { "fish": {
"name": "ryba" "name": "ryba"

View file

@ -9,7 +9,7 @@
"name": "ansjoser" "name": "ansjoser"
}, },
"apples": { "apples": {
"name": "æbler", "name": "æble",
"plural_name": "æbler" "plural_name": "æbler"
}, },
"artichoke": { "artichoke": {

View file

@ -15,7 +15,7 @@
"name": "šálek", "name": "šálek",
"plural_name": "šálků", "plural_name": "šálků",
"description": "", "description": "",
"abbreviation": "c" "abbreviation": "š"
}, },
"fluid-ounce": { "fluid-ounce": {
"name": "dutá unce", "name": "dutá unce",
@ -31,7 +31,7 @@
}, },
"quart": { "quart": {
"name": "čtvrtka", "name": "čtvrtka",
"plural_name": "quarts", "plural_name": "čtvrtky",
"description": "", "description": "",
"abbreviation": "čtvrtka" "abbreviation": "čtvrtka"
}, },
@ -92,7 +92,7 @@
}, },
"dash": { "dash": {
"name": "špetka", "name": "špetka",
"plural_name": "dashes", "plural_name": "špetky",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },
@ -127,8 +127,8 @@
"abbreviation": "" "abbreviation": ""
}, },
"pack": { "pack": {
"name": "pack", "name": "balení",
"plural_name": "packs", "plural_name": "balení",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""
}, },

View file

@ -73,8 +73,8 @@
"abbreviation": "g" "abbreviation": "g"
}, },
"kilogram": { "kilogram": {
"name": "kilogam", "name": "kilogram",
"plural_name": "kilogam", "plural_name": "kilogram",
"description": "", "description": "",
"abbreviation": "kg" "abbreviation": "kg"
}, },
@ -97,7 +97,7 @@
"abbreviation": "" "abbreviation": ""
}, },
"serving": { "serving": {
"name": "servering", "name": "portion",
"plural_name": "portioner", "plural_name": "portioner",
"description": "", "description": "",
"abbreviation": "" "abbreviation": ""

View file

@ -76,6 +76,9 @@ class HouseholdSelfServiceController(BaseUserController):
if target_user.household_id != self.household_id: if target_user.household_id != self.household_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this household") raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this household")
if target_user.id == self.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to change their own permissions")
target_user.can_invite = permissions.can_invite target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage target_user.can_manage = permissions.can_manage
target_user.can_manage_household = permissions.can_manage_household target_user.can_manage_household = permissions.can_manage_household

View file

@ -1,5 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.routes._base import BaseUserController, controller from mealie.routes._base import BaseUserController, controller
@ -30,6 +31,11 @@ class RecipeSharedController(BaseUserController):
@router.post("", response_model=RecipeShareToken, status_code=201) @router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
# check if recipe group id is the same as the user group id
recipe = self.repos.recipes.get_one(data.recipe_id, "id")
if recipe is None or recipe.group_id != self.group_id:
raise HTTPException(status_code=404, detail="Recipe not found in your group")
save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id) save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id)
return self.mixins.create_one(save_data) return self.mixins.create_one(save_data)

View file

@ -1,10 +1,49 @@
from fastapi import HTTPException, status from fastapi import HTTPException, status
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema.user.user import PrivateUser from mealie.schema.response.responses import ErrorResponse
from mealie.schema.user.user import PrivateUser, UserBase
permission_attrs = ["can_invite", "can_manage", "can_manage_household", "can_organize", "admin"]
def assert_user_change_allowed(id: UUID4, current_user: PrivateUser): def _assert_non_admin_user_change_allowed(user_id: UUID4, current_user: PrivateUser, new_data: UserBase):
if current_user.id != id and not current_user.admin: if current_user.id != user_id:
# only admins can edit other users # User is trying to edit another user
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") raise HTTPException(status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User cannot edit other users"))
if any(getattr(current_user, p) != getattr(new_data, p) for p in permission_attrs):
# User is trying to change their own permissions
raise HTTPException(
status.HTTP_403_FORBIDDEN,
ErrorResponse.respond("User cannot change their own permissions"),
)
if current_user.group != new_data.group:
# prevent a regular user from changing their group
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change their group")
)
if current_user.household != new_data.household:
# prevent a regular user from changing their household
raise HTTPException(
status.HTTP_403_FORBIDDEN,
ErrorResponse.respond("User doesn't have permission to change their household"),
)
def assert_user_change_allowed(user_id: UUID4, current_user: PrivateUser, new_data: UserBase):
if not current_user.admin:
_assert_non_admin_user_change_allowed(user_id, current_user, new_data)
return
if current_user.id != user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, ErrorResponse.respond("Use the Admin API to update other users"))
# Admin is trying to edit themselves
if any(getattr(current_user, p) != getattr(new_data, p) for p in permission_attrs):
# prevent an admin from excalating their own permissions
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("Admins can't change their own permissions")
)

View file

@ -46,13 +46,6 @@ class AdminUserController(BaseAdminController):
@admin_router.delete("/{item_id}") @admin_router.delete("/{item_id}")
def delete_user(self, item_id: UUID4): def delete_user(self, item_id: UUID4):
"""Removes a user from the database. Must be the current user or a super user"""
assert_user_change_allowed(item_id, self.user)
if item_id == 1: # TODO: identify super_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER")
self.mixins.delete_one(item_id) self.mixins.delete_one(item_id)
@ -106,19 +99,7 @@ class UserController(BaseUserController):
@user_router.put("/{item_id}") @user_router.put("/{item_id}")
def update_user(self, item_id: UUID4, new_data: UserBase): def update_user(self, item_id: UUID4, new_data: UserBase):
assert_user_change_allowed(item_id, self.user) assert_user_change_allowed(item_id, self.user, new_data)
if not self.user.admin and (new_data.admin or self.user.group != new_data.group):
# prevent a regular user from doing admin tasks on themself
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change group")
)
if self.user.id == item_id and self.user.admin and not new_data.admin:
# prevent an admin from demoting themself
raise HTTPException(
status.HTTP_403_FORBIDDEN, ErrorResponse.respond("User doesn't have permission to change group")
)
try: try:
self.repos.users.update(item_id, new_data.model_dump()) self.repos.users.update(item_id, new_data.model_dump())

View file

@ -23,7 +23,7 @@ class UserImageController(BaseUserController):
): ):
"""Updates a User Image""" """Updates a User Image"""
with get_temporary_path() as temp_path: with get_temporary_path() as temp_path:
assert_user_change_allowed(id, self.user) assert_user_change_allowed(id, self.user, self.user)
temp_img = temp_path.joinpath(profile.filename) temp_img = temp_path.joinpath(profile.filename)
with temp_img.open("wb") as buffer: with temp_img.open("wb") as buffer:

View file

@ -54,7 +54,7 @@ class UserRatingsController(BaseUserController):
@router.post("/{id}/ratings/{slug}") @router.post("/{id}/ratings/{slug}")
def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate):
"""Sets the user's rating for a recipe""" """Sets the user's rating for a recipe"""
assert_user_change_allowed(id, self.user) assert_user_change_allowed(id, self.user, self.user)
recipe = self.get_recipe_or_404(slug) recipe = self.get_recipe_or_404(slug)
user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id) user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id)

62
poetry.lock generated
View file

@ -607,23 +607,23 @@ cli = ["requests"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.115.6" version = "0.115.7"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"}, {file = "fastapi-0.115.7-py3-none-any.whl", hash = "sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e"},
{file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"}, {file = "fastapi-0.115.7.tar.gz", hash = "sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015"},
] ]
[package.dependencies] [package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.42.0" starlette = ">=0.40.0,<0.46.0"
typing-extensions = ">=4.8.0" typing-extensions = ">=4.8.0"
[package.extras] [package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]] [[package]]
name = "filelock" name = "filelock"
@ -1590,13 +1590,13 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]] [[package]]
name = "openai" name = "openai"
version = "1.59.9" version = "1.60.1"
description = "The official Python library for the openai API" description = "The official Python library for the openai API"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "openai-1.59.9-py3-none-any.whl", hash = "sha256:61a0608a1313c08ddf92fe793b6dbd1630675a1fe3866b2f96447ce30050c448"}, {file = "openai-1.60.1-py3-none-any.whl", hash = "sha256:714181ec1c452353d456f143c22db892de7b373e3165063d02a2b798ed575ba1"},
{file = "openai-1.59.9.tar.gz", hash = "sha256:ec1a20b0351b4c3e65c6292db71d8233515437c6065efd4fd50edeb55df5f5d2"}, {file = "openai-1.60.1.tar.gz", hash = "sha256:beb1541dfc38b002bd629ab68b0d6fe35b870c5f4311d9bc4404d85af3214d5e"},
] ]
[package.dependencies] [package.dependencies]
@ -1931,13 +1931,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "4.0.1" version = "4.1.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, {file = "pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b"},
{file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, {file = "pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4"},
] ]
[package.dependencies] [package.dependencies]
@ -2832,29 +2832,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.9.2" version = "0.9.3"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, {file = "ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624"},
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, {file = "ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c"},
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, {file = "ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, {file = "ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, {file = "ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, {file = "ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, {file = "ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, {file = "ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b"},
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, {file = "ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"},
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, {file = "ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4"},
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, {file = "ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b"},
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, {file = "ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a"},
] ]
[[package]] [[package]]

View file

@ -3,7 +3,7 @@ authors = ["Hayden <hay-kot@pm.me>"]
description = "A Recipe Manager" description = "A Recipe Manager"
license = "AGPL" license = "AGPL"
name = "mealie" name = "mealie"
version = "2.4.2" version = "2.5.0"
[tool.poetry.scripts] [tool.poetry.scripts]
start = "mealie.app:main" start = "mealie.app:main"

View file

@ -86,3 +86,16 @@ def test_set_member_permissions_no_user(
payload = get_permissions_payload(str(uuid4())) payload = get_permissions_payload(str(uuid4()))
response = api_client.put(api_routes.households_permissions, json=payload, headers=unique_user.token) response = api_client.put(api_routes.households_permissions, json=payload, headers=unique_user.token)
assert response.status_code == 404 assert response.status_code == 404
def test_set_own_permissions(api_client: TestClient, unique_user: TestUser):
database = unique_user.repos
user = database.users.get_one(unique_user.user_id)
assert user
user.can_manage = True
database.users.update(user.id, user)
form = {"user_id": str(unique_user.user_id), "canOrganize": not user.can_organize}
response = api_client.put(api_routes.households_permissions, json=form, headers=unique_user.token)
assert response.status_code == 403

View file

@ -110,3 +110,12 @@ def test_recipe_share_tokens_delete_one(api_client: TestClient, unique_user: Tes
token = database.recipe_share_tokens.get_one(token.id) token = database.recipe_share_tokens.get_one(token.id)
assert token is None assert token is None
def test_share_recipe_from_different_group(api_client: TestClient, unique_user: TestUser, g2_user: TestUser, slug: str):
database = unique_user.repos
recipe = database.recipes.get_one(slug)
assert recipe
response = api_client.post(api_routes.shared_recipes, json={"recipeId": str(recipe.id)}, headers=g2_user.token)
assert response.status_code == 404

View file

@ -1,8 +1,9 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils import TestUser, api_routes from tests.utils import api_routes
from tests.utils.factories import random_email, random_int, random_string from tests.utils.factories import random_email, random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("use_admin_user", [True, False]) @pytest.mark.parametrize("use_admin_user", [True, False])
@ -43,3 +44,71 @@ def test_get_all_users_admin(request: pytest.FixtureRequest, api_client: TestCli
response_user_ids = {user["id"] for user in response.json()["items"]} response_user_ids = {user["id"] for user in response.json()["items"]}
for user_id in user_ids: for user_id in user_ids:
assert user_id in response_user_ids assert user_id in response_user_ids
def test_user_update(api_client: TestClient, unique_user: TestUser, admin_user: TestUser):
response = api_client.get(api_routes.users_self, headers=unique_user.token)
user = response.json()
# valid request without updates
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=user, headers=unique_user.token)
assert response.status_code == 200
# valid request with updates
tmp_user = user.copy()
tmp_user["email"] = random_email()
tmp_user["full_name"] = random_string()
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=tmp_user, headers=unique_user.token)
assert response.status_code == 200
# test user attempting to update another user
form = {"email": admin_user.email, "full_name": admin_user.full_name}
response = api_client.put(api_routes.users_item_id(admin_user.user_id), json=form, headers=unique_user.token)
assert response.status_code == 403
# test user attempting permission changes
permissions = ["canInvite", "canManage", "canManageHousehold", "canOrganize", "advanced", "admin"]
for permission in permissions:
tmp_user = user.copy()
tmp_user[permission] = not user[permission]
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=form, headers=unique_user.token)
assert response.status_code == 403
# test user attempting to change group
tmp_user = user.copy()
tmp_user["group"] = random_string()
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=tmp_user, headers=unique_user.token)
assert response.status_code == 403
# test user attempting to change household
tmp_user = user.copy()
tmp_user["household"] = random_string()
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=tmp_user, headers=unique_user.token)
assert response.status_code == 403
def test_admin_updates(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
response = api_client.get(api_routes.users_item_id(unique_user.user_id), headers=admin_user.token)
user = response.json()
response = api_client.get(api_routes.users_item_id(admin_user.user_id), headers=admin_user.token)
admin = response.json()
# admin updating themselves
tmp_user = admin.copy()
tmp_user["fullName"] = random_string()
response = api_client.put(api_routes.users_item_id(admin_user.user_id), json=tmp_user, headers=admin_user.token)
assert response.status_code == 200
# admin updating another user via the normal user route
tmp_user = user.copy()
tmp_user["fullName"] = random_string()
response = api_client.put(api_routes.users_item_id(unique_user.user_id), json=tmp_user, headers=admin_user.token)
assert response.status_code == 403
# admin updating their own permissions
permissions = ["canInvite", "canManage", "canManageHousehold", "canOrganize", "admin"]
for permission in permissions:
tmp_user = admin.copy()
tmp_user[permission] = not admin[permission]
response = api_client.put(api_routes.users_item_id(admin_user.user_id), json=tmp_user, headers=admin_user.token)
assert response.status_code == 403