Merge branch 'mealie-next' into fix-recipe-timeline-nuxt-3

This commit is contained in:
Michael Genson 2025-06-30 10:15:48 -05:00 committed by GitHub
commit 5fbf102a03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 118 additions and 144 deletions

View file

@ -1,101 +1,104 @@
<template> <template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<v-lazy> <v-lazy>
<v-hover <div>
v-slot="{ isHovering, props }" <v-hover
:open-delay="50" v-slot="{ isHovering, props }"
> :open-delay="50"
<v-card
v-bind="props"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
:to="recipeRoute"
:min-height="imageHeight + 75"
@click.self="$emit('click')"
> >
<RecipeCardImage <v-card
:icon-size="imageHeight" v-bind="props"
:height="imageHeight" :class="{ 'on-hover': isHovering }"
:slug="slug" :style="{ cursor }"
:recipe-id="recipeId" :elevation="isHovering ? 12 : 2"
size="small" :to="recipeRoute"
:image-version="image" :min-height="imageHeight + 75"
@click.self="$emit('click')"
> >
<v-expand-transition v-if="description"> <RecipeCardImage
<div :icon-size="imageHeight"
v-if="isHovering" :height="imageHeight"
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal" :slug="slug"
style="height: 100%" :recipe-id="recipeId"
> size="small"
<v-card-text class="v-card--text-show white--text"> :image-version="image"
<div class="descriptionWrapper">
<SafeMarkdown :source="description" />
</div>
</v-card-text>
</div>
</v-expand-transition>
</RecipeCardImage>
<v-card-title class="mb-n3 px-4">
<div class="headerClass">
{{ name }}
</div>
</v-card-title>
<slot name="actions">
<v-card-actions
v-if="showRecipeContent"
class="px-1"
> >
<RecipeFavoriteBadge <v-expand-transition v-if="description">
v-if="isOwnGroup" <div
class="absolute" v-if="isHovering"
:recipe-id="recipeId" class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
show-always style="height: 100%"
/> >
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent --> <v-card-text class="v-card--text-show white--text">
<div class="descriptionWrapper">
<SafeMarkdown :source="description" />
</div>
</v-card-text>
</div>
</v-expand-transition>
</RecipeCardImage>
<v-card-title class="mb-n3 px-4">
<div class="headerClass">
{{ name }}
</div>
</v-card-title>
<RecipeRating <slot name="actions">
class="ml-n2" <v-card-actions
:value="rating" v-if="showRecipeContent"
:recipe-id="recipeId" class="px-1"
:slug="slug" >
small <RecipeFavoriteBadge
/> v-if="isOwnGroup"
<v-spacer /> class="absolute"
<RecipeChips :recipe-id="recipeId"
:truncate="true" show-always
:items="tags" />
:title="false" <div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
<!-- If we're not logged-in, no items display, so we hide this menu --> <RecipeRating
<RecipeContextMenu class="ml-n2"
v-if="isOwnGroup" :value="rating"
color="grey-darken-2" :recipe-id="recipeId"
:slug="slug" :slug="slug"
:name="name" small
:recipe-id="recipeId" />
:use-items="{ <v-spacer />
delete: false, <RecipeChips
edit: false, :truncate="true"
download: true, :items="tags"
mealplanner: true, :title="false"
shoppingList: true, :limit="2"
print: false, small
printPreferences: false, url-prefix="tags"
share: true, v-bind="$attrs"
}" />
@delete="$emit('delete', slug)"
/> <!-- If we're not logged-in, no items display, so we hide this menu -->
</v-card-actions> <RecipeContextMenu
</slot> v-if="isOwnGroup"
<slot /> color="grey-darken-2"
</v-card> :slug="slug"
</v-hover> :name="name"
:recipe-id="recipeId"
:use-items="{
delete: false,
edit: false,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
printPreferences: false,
share: true,
}"
@delete="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
<slot />
</v-card>
</v-hover>
</div>
</v-lazy> </v-lazy>
</template> </template>

View file

@ -1,4 +1,4 @@
import { useUserApi } from "~/composables/api"; import { useAdminApi } from "~/composables/api";
import type { UserIn, UserOut } from "~/lib/api/types/user"; import type { UserIn, UserOut } from "~/lib/api/types/user";
/* /*
@ -8,7 +8,7 @@ to control whether the object is substantiated... but some of the others rely on
*/ */
export const useAllUsers = function () { export const useAllUsers = function () {
const api = useUserApi(); const api = useAdminApi();
const asyncKey = String(Date.now()); const asyncKey = String(Date.now());
const { data: users, refresh: refreshAllUsers } = useLazyAsyncData(asyncKey, async () => { const { data: users, refresh: refreshAllUsers } = useLazyAsyncData(asyncKey, async () => {
const { data } = await api.users.getAll(); const { data } = await api.users.getAll();
@ -24,7 +24,7 @@ export const useAllUsers = function () {
}; };
export const useUser = function (refreshFunc: CallableFunction | null = null) { export const useUser = function (refreshFunc: CallableFunction | null = null) {
const api = useUserApi(); const api = useAdminApi();
const loading = ref(false); const loading = ref(false);
function getUser(id: string) { function getUser(id: string) {

View file

@ -1,6 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import UUID4 from pydantic import UUID4
from mealie.core import security from mealie.core import security
@ -42,6 +42,11 @@ class AdminUserManagementRoutes(BaseAdminController):
@router.post("", response_model=UserOut, status_code=201) @router.post("", response_model=UserOut, status_code=201)
def create_one(self, data: UserIn): def create_one(self, data: UserIn):
if self.repos.users.get_by_username(data.username):
raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t("exceptions.username-conflict-error")})
elif self.repos.users.get_one(data.email, "email"):
raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t("exceptions.email-conflict-error")})
data.password = security.hash_password(data.password) data.password = security.hash_password(data.password)
return self.mixins.create_one(data) return self.mixins.create_one(data)

View file

@ -9,7 +9,6 @@ router = APIRouter()
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"]) router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
router.include_router(crud.user_router) router.include_router(crud.user_router)
router.include_router(crud.admin_router)
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"]) router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"]) router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(api_tokens.router) router.include_router(api_tokens.router)

View file

@ -1,52 +1,17 @@
from fastapi import Depends, HTTPException, status from fastapi import HTTPException, status
from pydantic import UUID4 from pydantic import UUID4
from mealie.core.security import hash_password from mealie.core.security import hash_password
from mealie.core.security.providers.credentials_provider import CredentialsProvider from mealie.core.security.providers.credentials_provider import CredentialsProvider
from mealie.db.models.users.users import AuthMethod from mealie.db.models.users.users import AuthMethod
from mealie.routes._base import BaseAdminController, BaseUserController, controller from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import UserAPIRouter
from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.response import ErrorResponse, SuccessResponse from mealie.schema.response import ErrorResponse, SuccessResponse
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.user import ChangePassword, UserBase, UserOut
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut from mealie.schema.user.user import UserRatings, UserRatingSummary
from mealie.schema.user.user import UserPagination, UserRatings, UserRatingSummary
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@controller(admin_router)
class AdminUserController(BaseAdminController):
@property
def mixins(self) -> HttpRepo:
return HttpRepo[UserIn, UserOut, UserBase](self.repos.users, self.logger)
@admin_router.get("", response_model=UserPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns all users from all groups"""
response = self.repos.users.page_all(
pagination=q,
override=UserOut,
)
response.set_pagination_guides(admin_router.url_path_for("get_all"), q.model_dump())
return response
@admin_router.post("", response_model=UserOut, status_code=201)
def create_user(self, new_user: UserIn):
new_user.password = hash_password(new_user.password)
return self.mixins.create_one(new_user)
@admin_router.get("/{item_id}", response_model=UserOut)
def get_user(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@admin_router.delete("/{item_id}")
def delete_user(self, item_id: UUID4):
self.mixins.delete_one(item_id)
@controller(user_router) @controller(user_router)

View file

@ -76,7 +76,7 @@ def h2_user(session: Session, admin_token, api_client: TestClient, unique_user:
"admin": False, "admin": False,
"tokens": [], "tokens": [],
} }
response = api_client.post(api_routes.users, json=user_data, headers=admin_token) response = api_client.post(api_routes.admin_users, json=user_data, headers=admin_token)
assert response.status_code == 201 assert response.status_code == 201
# Log in as this user # Log in as this user
@ -135,7 +135,7 @@ def g2_user(session: Session, admin_token, api_client: TestClient):
} }
api_client.post(api_routes.admin_groups, json={"name": group}, headers=admin_token) api_client.post(api_routes.admin_groups, json={"name": group}, headers=admin_token)
response = api_client.post(api_routes.users, json=create_data, headers=admin_token) response = api_client.post(api_routes.admin_users, json=create_data, headers=admin_token)
assert response.status_code == 201 assert response.status_code == 201
@ -258,7 +258,7 @@ def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generat
users_out = [] users_out = []
for usr in [create_data_1, create_data_2]: for usr in [create_data_1, create_data_2]:
response = api_client.post(api_routes.users, json=usr, headers=admin_token) response = api_client.post(api_routes.admin_users, json=usr, headers=admin_token)
assert response.status_code == 201 assert response.status_code == 201
# Log in as this user # Log in as this user
@ -312,7 +312,7 @@ def user_token(admin_token, api_client: TestClient):
"tokens": [], "tokens": [],
} }
response = api_client.post(api_routes.users, json=create_data, headers=admin_token) response = api_client.post(api_routes.admin_users, json=create_data, headers=admin_token)
assert response.status_code == 201 assert response.status_code == 201

View file

@ -24,7 +24,7 @@ def test_api_token_private(api_client: TestClient, admin_token):
response = api_client.post(api_routes.users_api_tokens, json={"name": "Test API Token"}, headers=admin_token) response = api_client.post(api_routes.users_api_tokens, json={"name": "Test API Token"}, headers=admin_token)
assert response.status_code == 201 assert response.status_code == 201
response = api_client.get(api_routes.users, headers=admin_token, params={"perPage": -1}) response = api_client.get(api_routes.admin_users, headers=admin_token, params={"perPage": -1})
assert response.status_code == 200 assert response.status_code == 200
for user in response.json()["items"]: for user in response.json()["items"]:
for user_token in user["tokens"] or []: for user_token in user["tokens"] or []:
@ -39,7 +39,7 @@ def test_api_token_private(api_client: TestClient, admin_token):
def test_use_token(api_client: TestClient, long_live_token): def test_use_token(api_client: TestClient, long_live_token):
response = api_client.get(api_routes.users, headers=long_live_token) response = api_client.get(api_routes.admin_users, headers=long_live_token)
assert response.status_code == 200 assert response.status_code == 200

View file

@ -88,9 +88,11 @@ def test_user_update(api_client: TestClient, unique_user: TestUser, admin_user:
def test_admin_updates(api_client: TestClient, admin_user: TestUser, unique_user: TestUser): 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) response = api_client.get(api_routes.admin_users_item_id(unique_user.user_id), headers=admin_user.token)
assert response.status_code == 200
user = response.json() user = response.json()
response = api_client.get(api_routes.users_item_id(admin_user.user_id), headers=admin_user.token)
response = api_client.get(api_routes.admin_users_item_id(admin_user.user_id), headers=admin_user.token)
admin = response.json() admin = response.json()
# admin updating themselves # admin updating themselves