mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-27 08:14:30 -04:00 
			
		
		
		
	feat: User Tooltip (#4319)
This commit is contained in:
		| @@ -37,6 +37,7 @@ These endpoints have moved, but are otherwise unchanged: | ||||
| - `/explore/recipes/{group_slug}` -> `/explore/groups/{group_slug}/recipes` | ||||
|  | ||||
| `/groups/members` previously returned a `UserOut` object, but now returns a `UserSummary`. Should you need the full user information (username, email, etc.), rather than just the summary, see `/households/members` instead for the household members. | ||||
| `/groups/members` previously returned a list of users, but now returns paginated users (similar to all other list endpoints). | ||||
|  | ||||
| These endpoints have been completely removed: | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,7 @@ | ||||
|     </template> | ||||
|     <template #item.userId="{ item }"> | ||||
|       <v-list-item class="justify-start"> | ||||
|         <UserAvatar :user-id="item.userId" size="40" /> | ||||
|         <UserAvatar :user-id="item.userId" :tooltip="false" size="40" /> | ||||
|         <v-list-item-content> | ||||
|           <v-list-item-title> | ||||
|             {{ getMember(item.userId) }} | ||||
| @@ -153,7 +153,7 @@ export default defineComponent({ | ||||
|     async function refreshMembers() { | ||||
|       const { data } = await api.groups.fetchMembers(); | ||||
|       if (data) { | ||||
|         members.value = data; | ||||
|         members.value = data.items; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|     <v-divider class="mx-2"></v-divider> | ||||
|     <div v-if="user.id" class="d-flex flex-column"> | ||||
|       <div class="d-flex mt-3" style="gap: 10px"> | ||||
|         <UserAvatar size="40" :user-id="user.id" /> | ||||
|         <UserAvatar :tooltip="false" size="40" :user-id="user.id" /> | ||||
|  | ||||
|         <v-textarea | ||||
|           v-model="comment" | ||||
| @@ -31,7 +31,7 @@ | ||||
|       </div> | ||||
|     </div> | ||||
|     <div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px"> | ||||
|       <UserAvatar size="40" :user-id="comment.userId" /> | ||||
|       <UserAvatar :tooltip="false" size="40" :user-id="comment.userId" /> | ||||
|       <v-card outlined class="flex-grow-1"> | ||||
|         <v-card-text class="pa-3 pb-0"> | ||||
|           <p class="">{{ comment.user.username }} • {{ $d(Date.parse(comment.createdAt), "medium") }}</p> | ||||
|   | ||||
| @@ -1,14 +1,26 @@ | ||||
| <template> | ||||
|   <v-list-item-avatar v-if="list && userId"> | ||||
|     <v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> | ||||
|   </v-list-item-avatar> | ||||
|   <v-avatar v-else-if="userId" :size="size"> | ||||
|     <v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> | ||||
|   </v-avatar> | ||||
|   <v-tooltip | ||||
|     v-if="userId" | ||||
|     :disabled="!user || !tooltip" | ||||
|     right | ||||
|   > | ||||
|     <template #activator="{ on, attrs }"> | ||||
|       <v-list-item-avatar v-if="list" v-bind="attrs" v-on="on"> | ||||
|         <v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> | ||||
|       </v-list-item-avatar> | ||||
|       <v-avatar v-else :size="size" v-bind="attrs" v-on="on"> | ||||
|         <v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> | ||||
|       </v-avatar> | ||||
|     </template> | ||||
|     <span v-if="user"> | ||||
|       {{ user.fullName }} | ||||
|     </span> | ||||
|   </v-tooltip> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, toRefs, reactive, useContext, computed } from "@nuxtjs/composition-api"; | ||||
| import { useUserStore } from "~/composables/store/use-user-store"; | ||||
| import { UserOut } from "~/lib/api/types/user"; | ||||
|  | ||||
| export default defineComponent({ | ||||
| @@ -25,6 +37,10 @@ export default defineComponent({ | ||||
|       type: String, | ||||
|       default: "42", | ||||
|     }, | ||||
|     tooltip: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     } | ||||
|   }, | ||||
|   setup(props) { | ||||
|     const state = reactive({ | ||||
| @@ -32,15 +48,20 @@ export default defineComponent({ | ||||
|     }); | ||||
|  | ||||
|     const { $auth } = useContext(); | ||||
|     const { store: users } = useUserStore(); | ||||
|     const user = computed(() => { | ||||
|       return users.value.find((user) => user.id === props.userId); | ||||
|     }) | ||||
|  | ||||
|     const imageURL = computed(() => { | ||||
|       // TODO Setup correct user type for $auth.user | ||||
|       const user = $auth.user as unknown as UserOut | null; | ||||
|       const key = user?.cacheKey ?? ""; | ||||
|       const authUser = $auth.user as unknown as UserOut | null; | ||||
|       const key = authUser?.cacheKey ?? ""; | ||||
|       return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`; | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|       user, | ||||
|       imageURL, | ||||
|       ...toRefs(state), | ||||
|     }; | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <!-- User Profile --> | ||||
|     <template v-if="loggedIn"> | ||||
|       <v-list-item two-line :to="userProfileLink" exact> | ||||
|         <UserAvatar list :user-id="$auth.user.id" /> | ||||
|         <UserAvatar list :user-id="$auth.user.id" :tooltip="false" /> | ||||
|  | ||||
|         <v-list-item-content> | ||||
|           <v-list-item-title class="pr-2"> {{ $auth.user.fullName }}</v-list-item-title> | ||||
|   | ||||
| @@ -57,35 +57,30 @@ function getRequests(axiosInstance: NuxtAxiosInstance): ApiRequestInstance { | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const useAdminApi = function (): AdminAPI { | ||||
| export const useRequests = function (): ApiRequestInstance { | ||||
|   const { $axios, i18n } = useContext(); | ||||
|  | ||||
|   $axios.setHeader("Accept-Language", i18n.locale); | ||||
|  | ||||
|   const requests = getRequests($axios); | ||||
|   return getRequests($axios); | ||||
| }; | ||||
|  | ||||
| export const useAdminApi = function (): AdminAPI { | ||||
|   const requests = useRequests(); | ||||
|   return new AdminAPI(requests); | ||||
| }; | ||||
|  | ||||
| export const useUserApi = function (): UserApi { | ||||
|   const { $axios, i18n } = useContext(); | ||||
|   $axios.setHeader("Accept-Language", i18n.locale); | ||||
|  | ||||
|   const requests = getRequests($axios); | ||||
|   const requests = useRequests(); | ||||
|   return new UserApi(requests); | ||||
| }; | ||||
|  | ||||
| export const usePublicApi = function (): PublicApi { | ||||
|   const { $axios, i18n } = useContext(); | ||||
|   $axios.setHeader("Accept-Language", i18n.locale); | ||||
|  | ||||
|   const requests = getRequests($axios); | ||||
|   const requests = useRequests(); | ||||
|   return new PublicApi(requests); | ||||
| }; | ||||
|  | ||||
| export const usePublicExploreApi = function (groupSlug: string): PublicExploreApi { | ||||
|   const { $axios, i18n } = useContext(); | ||||
|   $axios.setHeader("Accept-Language", i18n.locale); | ||||
|  | ||||
|   const requests = getRequests($axios); | ||||
|   const requests = useRequests(); | ||||
|   return new PublicExploreApi(requests, groupSlug); | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { QueryValue } from "~/lib/api/base/route"; | ||||
|  | ||||
| interface ReadOnlyStoreActions<T extends BoundT> { | ||||
|   getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>; | ||||
|   refresh(): Promise<void>; | ||||
|   refresh(page?: number, perPage?: number, params?: any): Promise<void>; | ||||
| } | ||||
|  | ||||
| interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> { | ||||
| @@ -50,9 +50,9 @@ export function useReadOnlyActions<T extends BoundT>( | ||||
|     return allItems; | ||||
|   } | ||||
|  | ||||
|   async function refresh() { | ||||
|   async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { | ||||
|     loading.value = true; | ||||
|     const { data } = await api.getAll(); | ||||
|     const { data } = await api.getAll(page, perPage, params); | ||||
|  | ||||
|     if (data && data.items && allRef) { | ||||
|       allRef.value = data.items; | ||||
| @@ -101,9 +101,9 @@ export function useStoreActions<T extends BoundT>( | ||||
|     return allItems; | ||||
|   } | ||||
|  | ||||
|   async function refresh() { | ||||
|   async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { | ||||
|     loading.value = true; | ||||
|     const { data } = await api.getAll(); | ||||
|     const { data } = await api.getAll(page, perPage, params); | ||||
|  | ||||
|     if (data && data.items && allRef) { | ||||
|       allRef.value = data.items; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { ref, reactive, Ref } from "@nuxtjs/composition-api"; | ||||
| import { useReadOnlyActions, useStoreActions } from "./use-actions-factory"; | ||||
| import { BoundT } from "./types"; | ||||
| import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; | ||||
| import { QueryValue } from "~/lib/api/base/route"; | ||||
|  | ||||
| export const useData = function<T extends BoundT>(defaultObject: T) { | ||||
|   const data = reactive({ ...defaultObject }); | ||||
| @@ -16,16 +17,21 @@ export const useReadOnlyStore = function<T extends BoundT>( | ||||
|   store: Ref<T[]>, | ||||
|   loading: Ref<boolean>, | ||||
|   api: BaseCRUDAPIReadOnly<T>, | ||||
|   params = {} as Record<string, QueryValue>, | ||||
| ) { | ||||
|   const storeActions = useReadOnlyActions(api, store, loading); | ||||
|   const actions = { | ||||
|     ...useReadOnlyActions(api, store, loading), | ||||
|     ...storeActions, | ||||
|     async refresh() { | ||||
|       return await storeActions.refresh(1, -1, params); | ||||
|     }, | ||||
|     flushStore() { | ||||
|       store.value = []; | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   if (!loading.value && (!store.value || store.value.length === 0)) { | ||||
|     const result = actions.getAll(); | ||||
|     const result = actions.getAll(1, -1, params); | ||||
|     store.value = result.value || []; | ||||
|   } | ||||
|  | ||||
| @@ -36,16 +42,21 @@ export const useStore = function<T extends BoundT>( | ||||
|   store: Ref<T[]>, | ||||
|   loading: Ref<boolean>, | ||||
|   api: BaseCRUDAPI<unknown, T, unknown>, | ||||
|   params = {} as Record<string, QueryValue>, | ||||
| ) { | ||||
|   const storeActions = useStoreActions(api, store, loading); | ||||
|   const actions = { | ||||
|     ...useStoreActions(api, store, loading), | ||||
|     ...storeActions, | ||||
|     async refresh() { | ||||
|       return await storeActions.refresh(1, -1, params); | ||||
|     }, | ||||
|     flushStore() { | ||||
|       store = ref([]); | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   if (!loading.value && (!store.value || store.value.length === 0)) { | ||||
|     const result = actions.getAll(); | ||||
|     const result = actions.getAll(1, -1, params); | ||||
|     store.value = result.value || []; | ||||
|   } | ||||
|  | ||||
|   | ||||
							
								
								
									
										20
									
								
								frontend/composables/store/use-user-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/composables/store/use-user-store.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import { ref, Ref } from "@nuxtjs/composition-api"; | ||||
| import { useReadOnlyStore } from "../partials/use-store-factory"; | ||||
| import { useRequests } from "../api/api-client"; | ||||
| import { UserSummary } from "~/lib/api/types/user"; | ||||
| import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; | ||||
|  | ||||
| const store: Ref<UserSummary[]> = ref([]); | ||||
| const loading = ref(false); | ||||
|  | ||||
| class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> { | ||||
|   baseRoute = "/api/groups/members"; | ||||
|   itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`; | ||||
| } | ||||
|  | ||||
| export const useUserStore = function () { | ||||
|   const requests = useRequests(); | ||||
|   const api = new GroupUserAPIReadOnly(requests); | ||||
|  | ||||
|   return useReadOnlyStore<UserSummary>(store, loading, api, {orderBy: "full_name"}); | ||||
| } | ||||
| @@ -77,6 +77,7 @@ export interface ReadWebhook { | ||||
| } | ||||
| export interface UserSummary { | ||||
|   id: string; | ||||
|   username: string; | ||||
|   fullName: string; | ||||
| } | ||||
| export interface ReadGroupPreferences { | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| import { BaseCRUDAPI } from "../base/base-clients"; | ||||
| import { PaginationData } from "../types/non-generated"; | ||||
| import { QueryValue } from "../base/route"; | ||||
| import { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user"; | ||||
| import { | ||||
|   GroupAdminUpdate, | ||||
| @@ -14,11 +16,7 @@ const routes = { | ||||
|   groupsSelf: `${prefix}/groups/self`, | ||||
|   preferences: `${prefix}/groups/preferences`, | ||||
|   storage: `${prefix}/groups/storage`, | ||||
|   membersHouseholdId: (householdId: string | number | null) => { | ||||
|     return householdId ? | ||||
|       `${prefix}/households/members?householdId=${householdId}` : | ||||
|       `${prefix}/groups/members`; | ||||
|   }, | ||||
|   members: `${prefix}/groups/members`, | ||||
|   groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`, | ||||
| }; | ||||
|  | ||||
| @@ -40,8 +38,8 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate | ||||
|     return await this.requests.put<ReadGroupPreferences, UpdateGroupPreferences>(routes.preferences, payload); | ||||
|   } | ||||
|  | ||||
|   async fetchMembers(householdId: string | number | null = null) { | ||||
|     return await this.requests.get<UserSummary[]>(routes.membersHouseholdId(householdId)); | ||||
|   async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { | ||||
|     return await this.requests.get<PaginationData<UserSummary>>(routes.members, { page, perPage, ...params }); | ||||
|   } | ||||
|  | ||||
|   async storage() { | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| import { BaseCRUDAPIReadOnly } from "../base/base-clients"; | ||||
| import { PaginationData } from "../types/non-generated"; | ||||
| import { QueryValue } from "../base/route"; | ||||
| import { UserOut } from "~/lib/api/types/user"; | ||||
| import { | ||||
|   HouseholdInDB, | ||||
| @@ -48,8 +50,8 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> { | ||||
|     return await this.requests.post<ReadInviteToken>(routes.invitation, payload); | ||||
|   } | ||||
|  | ||||
|   async fetchMembers() { | ||||
|     return await this.requests.get<UserOut[]>(routes.members); | ||||
|   async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { | ||||
|     return await this.requests.get<PaginationData<UserOut>>(routes.members, { page, perPage, ...params }); | ||||
|   } | ||||
|  | ||||
|   async setMemberPermissions(payload: SetPermissions) { | ||||
|   | ||||
| @@ -26,7 +26,7 @@ | ||||
|       disable-pagination | ||||
|     > | ||||
|       <template #item.avatar="{ item }"> | ||||
|         <UserAvatar :user-id="item.id" /> | ||||
|         <UserAvatar :tooltip="false" :user-id="item.id" /> | ||||
|       </template> | ||||
|       <template #item.admin="{ item }"> | ||||
|         {{ item.admin ? $t('user.admin') : $t('user.user') }} | ||||
| @@ -111,7 +111,7 @@ export default defineComponent({ | ||||
|     async function refreshMembers() { | ||||
|       const { data } = await api.households.fetchMembers(); | ||||
|       if (data) { | ||||
|         members.value = data; | ||||
|         members.value = data.items; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1025,7 +1025,7 @@ export default defineComponent({ | ||||
|       } | ||||
|  | ||||
|       // update current user | ||||
|       allUsers.value = data.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1)); | ||||
|       allUsers.value = data.items.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1)); | ||||
|       currentUserId.value = shoppingList.value?.userId; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <BasePageTitle divider> | ||||
|       <template #header> | ||||
|         <div class="d-flex flex-column align-center justify-center"> | ||||
|           <UserAvatar size="96" :user-id="$auth.user.id" /> | ||||
|           <UserAvatar :tooltip="false" size="96" :user-id="$auth.user.id" /> | ||||
|           <AppButtonUpload | ||||
|             class="my-1" | ||||
|             file-name="profile" | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <v-container v-if="user"> | ||||
|     <section class="d-flex flex-column align-center mt-4"> | ||||
|       <UserAvatar size="96" :user-id="user.id" /> | ||||
|       <UserAvatar :tooltip="false" size="96" :user-id="user.id" /> | ||||
|  | ||||
|       <h2 class="headline">{{ $t('profile.welcome-user', [user.fullName]) }}</h2> | ||||
|       <p class="subtitle-1 mb-0 text-center"> | ||||
|   | ||||
							
								
								
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -34,6 +34,7 @@ import ReportTable from "@/components/global/ReportTable.vue"; | ||||
| import SafeMarkdown from "@/components/global/SafeMarkdown.vue"; | ||||
| import StatsCards from "@/components/global/StatsCards.vue"; | ||||
| import ToggleState from "@/components/global/ToggleState.vue"; | ||||
| import WakelockSwitch from "@/components/global/WakelockSwitch.vue"; | ||||
| import DefaultLayout from "@/components/layout/DefaultLayout.vue"; | ||||
|  | ||||
| declare module "vue" { | ||||
| @@ -74,6 +75,7 @@ declare module "vue" { | ||||
|     SafeMarkdown: typeof SafeMarkdown; | ||||
|     StatsCards: typeof StatsCards; | ||||
|     ToggleState: typeof ToggleState; | ||||
|     WakelockSwitch: typeof WakelockSwitch; | ||||
|     // Layout Components | ||||
|     DefaultLayout: typeof DefaultLayout; | ||||
|   } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from functools import cached_property | ||||
| from uuid import UUID | ||||
|  | ||||
| from fastapi import Query | ||||
| from fastapi import Depends, HTTPException | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.routes._base.base_controllers import BaseUserController | ||||
| @@ -8,7 +9,7 @@ from mealie.routes._base.controller import controller | ||||
| from mealie.routes._base.routers import UserAPIRouter | ||||
| from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences | ||||
| from mealie.schema.group.group_statistics import GroupStorage | ||||
| from mealie.schema.response.pagination import PaginationQuery | ||||
| from mealie.schema.response.pagination import PaginationBase, PaginationQuery | ||||
| from mealie.schema.user.user import GroupSummary, UserSummary | ||||
| from mealie.services.group_services.group_service import GroupService | ||||
|  | ||||
| @@ -26,13 +27,29 @@ class GroupSelfServiceController(BaseUserController): | ||||
|         """Returns the Group Data for the Current User""" | ||||
|         return self.group.cast(GroupSummary) | ||||
|  | ||||
|     @router.get("/members", response_model=list[UserSummary]) | ||||
|     def get_group_members(self, household_id: UUID4 | None = Query(None, alias="householdId")): | ||||
|         """Returns all users belonging to the current group, optionally filtered by household_id""" | ||||
|     @router.get("/members", response_model=PaginationBase[UserSummary]) | ||||
|     def get_group_members(self, q: PaginationQuery = Depends()): | ||||
|         """Returns all users belonging to the current group""" | ||||
|  | ||||
|         query_filter = f"household_id={household_id}" if household_id else None | ||||
|         private_users = self.repos.users.page_all(PaginationQuery(page=1, per_page=-1, query_filter=query_filter)).items | ||||
|         return [user.cast(UserSummary) for user in private_users] | ||||
|         response = self.repos.users.page_all(q, override=UserSummary) | ||||
|         response.set_pagination_guides(router.url_path_for("get_group_members"), q.model_dump()) | ||||
|         return response | ||||
|  | ||||
|     @router.get("/members/{username_or_id}", response_model=UserSummary) | ||||
|     def get_group_member(self, username_or_id: str | UUID4): | ||||
|         """Returns a single user belonging to the current group""" | ||||
|  | ||||
|         try: | ||||
|             UUID(username_or_id) | ||||
|             key = "id" | ||||
|         except ValueError: | ||||
|             key = "username" | ||||
|  | ||||
|         private_user = self.repos.users.get_one(username_or_id, key) | ||||
|         if not private_user: | ||||
|             raise HTTPException(status_code=404, detail="User Not Found") | ||||
|  | ||||
|         return private_user.cast(UserSummary) | ||||
|  | ||||
|     @router.get("/preferences", response_model=ReadGroupPreferences) | ||||
|     def get_group_preferences(self): | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| from functools import cached_property | ||||
|  | ||||
| from fastapi import HTTPException, status | ||||
| from fastapi import Depends, HTTPException, status | ||||
|  | ||||
| from mealie.routes._base.base_controllers import BaseUserController | ||||
| from mealie.routes._base.controller import controller | ||||
| @@ -9,7 +9,7 @@ from mealie.schema.household.household import HouseholdInDB | ||||
| from mealie.schema.household.household_permissions import SetPermissions | ||||
| from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences | ||||
| from mealie.schema.household.household_statistics import HouseholdStatistics | ||||
| from mealie.schema.response.pagination import PaginationQuery | ||||
| from mealie.schema.response.pagination import PaginationBase, PaginationQuery | ||||
| from mealie.schema.user.user import UserOut | ||||
| from mealie.services.household_services.household_service import HouseholdService | ||||
|  | ||||
| @@ -27,13 +27,20 @@ class HouseholdSelfServiceController(BaseUserController): | ||||
|         """Returns the Household Data for the Current User""" | ||||
|         return self.household | ||||
|  | ||||
|     @router.get("/members", response_model=list[UserOut]) | ||||
|     def get_household_members(self): | ||||
|     @router.get("/members", response_model=PaginationBase[UserOut]) | ||||
|     def get_household_members(self, q: PaginationQuery = Depends()): | ||||
|         """Returns all users belonging to the current household""" | ||||
|         private_users = self.repos.users.page_all( | ||||
|             PaginationQuery(page=1, per_page=-1, query_filter=f"household_id={self.household_id}") | ||||
|         ).items | ||||
|         return [user.cast(UserOut) for user in private_users] | ||||
|  | ||||
|         qf_part = f"household_id={self.household_id}" | ||||
|         if q.query_filter: | ||||
|             q.query_filter = f"({q.query_filter}) AND {qf_part}" | ||||
|         else: | ||||
|             q.query_filter = qf_part | ||||
|  | ||||
|         response = self.repos.users.page_all(q, override=UserOut) | ||||
|  | ||||
|         response.set_pagination_guides(router.url_path_for("get_household_members"), q.model_dump()) | ||||
|         return response | ||||
|  | ||||
|     @router.get("/preferences", response_model=ReadHouseholdPreferences) | ||||
|     def get_household_preferences(self): | ||||
|   | ||||
| @@ -180,6 +180,7 @@ class UserOut(UserBase): | ||||
|  | ||||
| class UserSummary(MealieModel): | ||||
|     id: UUID4 | ||||
|     username: str | ||||
|     full_name: str | ||||
|     model_config = ConfigDict(from_attributes=True) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import random | ||||
|  | ||||
| import pytest | ||||
| from fastapi.testclient import TestClient | ||||
|  | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
| @@ -8,10 +9,10 @@ from tests.utils.fixture_schemas import TestUser | ||||
|  | ||||
|  | ||||
| def test_get_group_members(api_client: TestClient, unique_user: TestUser, h2_user: TestUser): | ||||
|     response = api_client.get(api_routes.groups_members, headers=unique_user.token) | ||||
|     response = api_client.get(api_routes.groups_members, params={"perPage": -1}, headers=unique_user.token) | ||||
|     assert response.status_code == 200 | ||||
|  | ||||
|     members = response.json() | ||||
|     members = response.json()["items"] | ||||
|     assert len(members) >= 2 | ||||
|  | ||||
|     all_ids = [x["id"] for x in members] | ||||
| @@ -20,19 +21,21 @@ def test_get_group_members(api_client: TestClient, unique_user: TestUser, h2_use | ||||
|     assert str(h2_user.user_id) in all_ids | ||||
|  | ||||
|  | ||||
| def test_get_group_members_filtered(api_client: TestClient, unique_user: TestUser, h2_user: TestUser): | ||||
|     response = api_client.get( | ||||
|         api_routes.groups_members, params={"householdId": h2_user.household_id}, headers=unique_user.token | ||||
|     ) | ||||
| @pytest.mark.parametrize("query", ["id", "username"]) | ||||
| def test_get_group_member(api_client: TestClient, unique_user: TestUser, h2_user: TestUser, query: str): | ||||
|     if query == "id": | ||||
|         param = str(h2_user.user_id) | ||||
|     else: | ||||
|         param = h2_user.username | ||||
|  | ||||
|     response = api_client.get(api_routes.groups_members_username_or_id(param), headers=unique_user.token) | ||||
|     assert response.status_code == 200 | ||||
|     assert response.json()["id"] == str(h2_user.user_id) | ||||
|  | ||||
|     members = response.json() | ||||
|     assert len(members) >= 1 | ||||
|  | ||||
|     all_ids = [x["id"] for x in members] | ||||
|  | ||||
|     assert str(unique_user.user_id) not in all_ids | ||||
|     assert str(h2_user.user_id) in all_ids | ||||
| def test_get_group_member_not_found(api_client: TestClient, unique_user: TestUser): | ||||
|     response = api_client.get(api_routes.groups_members_username_or_id(random_string()), headers=unique_user.token) | ||||
|     assert response.status_code == 404 | ||||
|  | ||||
|  | ||||
| def test_get_households(api_client: TestClient, unique_user: TestUser): | ||||
|   | ||||
| @@ -7,10 +7,10 @@ from tests.utils.fixture_schemas import TestUser | ||||
| def test_get_household_members(api_client: TestClient, user_tuple: list[TestUser], h2_user: TestUser): | ||||
|     usr_1, usr_2 = user_tuple | ||||
|  | ||||
|     response = api_client.get(api_routes.households_members, headers=usr_1.token) | ||||
|     response = api_client.get(api_routes.households_members, params={"perPage": -1}, headers=usr_1.token) | ||||
|     assert response.status_code == 200 | ||||
|  | ||||
|     members = response.json() | ||||
|     members = response.json()["items"] | ||||
|     assert len(members) >= 2 | ||||
|  | ||||
|     all_ids = [x["id"] for x in members] | ||||
|   | ||||
| @@ -320,6 +320,11 @@ def groups_labels_item_id(item_id): | ||||
|     return f"{prefix}/groups/labels/{item_id}" | ||||
|  | ||||
|  | ||||
| def groups_members_username_or_id(username_or_id): | ||||
|     """`/api/groups/members/{username_or_id}`""" | ||||
|     return f"{prefix}/groups/members/{username_or_id}" | ||||
|  | ||||
|  | ||||
| def groups_reports_item_id(item_id): | ||||
|     """`/api/groups/reports/{item_id}`""" | ||||
|     return f"{prefix}/groups/reports/{item_id}" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user