diff --git a/Taskfile.yml b/Taskfile.yml index c54c075db..5352a3ca3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -71,6 +71,7 @@ tasks: desc: run code generators cmds: - poetry run python dev/code-generation/main.py {{ .CLI_ARGS }} + - task: docs:gen - task: py:format dev:services: diff --git a/dev/code-generation/gen_ts_types.py b/dev/code-generation/gen_ts_types.py index 408262f25..8a8932299 100644 --- a/dev/code-generation/gen_ts_types.py +++ b/dev/code-generation/gen_ts_types.py @@ -8,8 +8,8 @@ from utils import log # ============================================================ template = """// This Code is auto generated by gen_ts_types.py -{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue"; -{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue"; +{% for name in global %}import type {{ name }} from "@/components/global/{{ name }}.vue"; +{% endfor %}{% for name in layout %}import type {{ name }} from "@/components/layout/{{ name }}.vue"; {% endfor %} declare module "vue" { export interface GlobalComponents { diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html index 64a6e5cf4..0dc543851 100644 --- a/docs/docs/overrides/api.html +++ b/docs/docs/overrides/api.html @@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Cookbook/CookbookPage.vue b/frontend/components/Domain/Cookbook/CookbookPage.vue index a8cff6753..2267276cc 100644 --- a/frontend/components/Domain/Cookbook/CookbookPage.vue +++ b/frontend/components/Domain/Cookbook/CookbookPage.vue @@ -70,7 +70,7 @@ import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue" import { useCookbookStore } from "~/composables/store/use-cookbook-store"; import { useCookbook } from "~/composables/use-group-cookbooks"; import { useLoggedInState } from "~/composables/use-logged-in-state"; -import type { RecipeCookBook } from "~/lib/api/types/cookbook"; +import type { ReadCookBook } from "~/lib/api/types/cookbook"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; const $auth = useMealieAuth(); @@ -100,7 +100,7 @@ const dialogStates = reactive({ edit: false, }); -const editTarget = ref(null); +const editTarget = ref(null); function handleEditCookbook() { dialogStates.edit = true; editTarget.value = book.value; diff --git a/frontend/composables/store/use-cookbook-store.ts b/frontend/composables/store/use-cookbook-store.ts index e3d4a5a81..131187e9e 100644 --- a/frontend/composables/store/use-cookbook-store.ts +++ b/frontend/composables/store/use-cookbook-store.ts @@ -1,18 +1,18 @@ import type { Composer } from "vue-i18n"; import { useReadOnlyStore, useStore } from "../partials/use-store-factory"; -import type { RecipeCookBook } from "~/lib/api/types/cookbook"; +import type { ReadCookBook } from "~/lib/api/types/cookbook"; import { usePublicExploreApi, useUserApi } from "~/composables/api"; -const store: Ref = ref([]); +const store: Ref = ref([]); const loading = ref(false); const publicLoading = ref(false); export const useCookbookStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore(store, loading, api.cookbooks); + return useStore(store, loading, api.cookbooks); }; export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore(store, publicLoading, api.cookbooks); + return useReadOnlyStore(store, publicLoading, api.cookbooks); }; diff --git a/frontend/lib/api/public/explore/cookbooks.ts b/frontend/lib/api/public/explore/cookbooks.ts index ee7e6230c..3afeb40d4 100644 --- a/frontend/lib/api/public/explore/cookbooks.ts +++ b/frontend/lib/api/public/explore/cookbooks.ts @@ -1,5 +1,5 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; -import { RecipeCookBook } from "~/lib/api/types/cookbook"; +import { ReadCookBook } from "~/lib/api/types/cookbook"; import { ApiRequestInstance } from "~/lib/api/types/non-generated"; const prefix = "/api"; @@ -10,7 +10,7 @@ const routes = { cookbooksGroupSlugCookbookId: (groupSlug: string | number, cookbookId: string | number) => `${exploreGroupSlug(groupSlug)}/cookbooks/${cookbookId}`, }; -export class PublicCookbooksApi extends BaseCRUDAPIReadOnly { +export class PublicCookbooksApi extends BaseCRUDAPIReadOnly { constructor(requests: ApiRequestInstance, groupSlug: string) { super( requests, diff --git a/frontend/lib/api/types/admin.ts b/frontend/lib/api/types/admin.ts index 6f2022f6e..868cb74e6 100644 --- a/frontend/lib/api/types/admin.ts +++ b/frontend/lib/api/types/admin.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/analytics.ts b/frontend/lib/api/types/analytics.ts index 5bdc7fbcd..f0eb9d489 100644 --- a/frontend/lib/api/types/analytics.ts +++ b/frontend/lib/api/types/analytics.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts index a35e5cd5f..753b11c6a 100644 --- a/frontend/lib/api/types/cookbook.ts +++ b/frontend/lib/api/types/cookbook.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script @@ -38,67 +39,6 @@ export interface QueryFilterJSONPart { attributeName?: string | null; relationalOperator?: RelationalKeyword | RelationalOperator | null; value?: string | string[] | null; -} -export interface RecipeCookBook { - name: string; - description?: string; - slug?: string | null; - position?: number; - public?: boolean; - queryFilterString?: string; - groupId: string; - householdId: string; - id: string; - queryFilter?: QueryFilterJSON; - recipes: RecipeSummary[]; -} -export interface RecipeSummary { - id?: string | null; - userId?: string; - householdId?: string; - groupId?: string; - name?: string | null; - slug?: string; - image?: unknown; - recipeServings?: number; - recipeYieldQuantity?: number; - recipeYield?: string | null; - totalTime?: string | null; - prepTime?: string | null; - cookTime?: string | null; - performTime?: string | null; - description?: string | null; - recipeCategory?: RecipeCategory[] | null; - tags?: RecipeTag[] | null; - tools?: RecipeTool[]; - rating?: number | null; - orgURL?: string | null; - dateAdded?: string | null; - dateUpdated?: string | null; - createdAt?: string | null; - updatedAt?: string | null; - lastMade?: string | null; -} -export interface RecipeCategory { - id?: string | null; - groupId?: string | null; - name: string; - slug: string; - [k: string]: unknown; -} -export interface RecipeTag { - id?: string | null; - groupId?: string | null; - name: string; - slug: string; - [k: string]: unknown; -} -export interface RecipeTool { - id: string; - groupId?: string | null; - name: string; - slug: string; - householdsWithTool?: string[]; [k: string]: unknown; } export interface SaveCookBook { diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts index 8ebf2c96c..bc2fbcf62 100644 --- a/frontend/lib/api/types/group.ts +++ b/frontend/lib/api/types/group.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/household.ts b/frontend/lib/api/types/household.ts index f3400dea4..cfe4ff3f9 100644 --- a/frontend/lib/api/types/household.ts +++ b/frontend/lib/api/types/household.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/labels.ts b/frontend/lib/api/types/labels.ts index ee9335bb2..a8fc4c046 100644 --- a/frontend/lib/api/types/labels.ts +++ b/frontend/lib/api/types/labels.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index 85f03de97..b6d1c08e4 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/reports.ts b/frontend/lib/api/types/reports.ts index 428c39f40..2b763275e 100644 --- a/frontend/lib/api/types/reports.ts +++ b/frontend/lib/api/types/reports.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/response.ts b/frontend/lib/api/types/response.ts index 9fa568846..dfa8a54f4 100644 --- a/frontend/lib/api/types/response.ts +++ b/frontend/lib/api/types/response.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index a5818778b..029e166b6 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -1,4 +1,5 @@ /* tslint:disable */ +/* eslint-disable */ /** /* This file was automatically generated from pydantic models by running pydantic2ts. /* Do not modify it by hand - just update the pydantic models and then re-run the script diff --git a/frontend/lib/api/user/group-cookbooks.ts b/frontend/lib/api/user/group-cookbooks.ts index 9d5631558..640a6ef5e 100644 --- a/frontend/lib/api/user/group-cookbooks.ts +++ b/frontend/lib/api/user/group-cookbooks.ts @@ -1,5 +1,5 @@ import { BaseCRUDAPI } from "../base/base-clients"; -import type { CreateCookBook, RecipeCookBook, UpdateCookBook } from "~/lib/api/types/cookbook"; +import type { CreateCookBook, ReadCookBook, UpdateCookBook } from "~/lib/api/types/cookbook"; const prefix = "/api"; @@ -8,7 +8,7 @@ const routes = { cookbooksId: (id: number) => `${prefix}/households/cookbooks/${id}`, }; -export class CookbookAPI extends BaseCRUDAPI { +export class CookbookAPI extends BaseCRUDAPI { baseRoute: string = routes.cookbooks; itemRoute = routes.cookbooksId; diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index 9d0364a59..d074283ee 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -81,4 +81,4 @@ declare module "vue" { } } -export { }; +export {}; diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index 2e5b66174..593ebda89 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -19,6 +19,8 @@ from mealie.routes._base.routers import UserAPIRouter from mealie.schema.user import PrivateUser from mealie.schema.user.auth import CredentialsRequestForm +from .auth_cache import AuthCache + public_router = APIRouter(tags=["Users: Authentication"]) user_router = UserAPIRouter(tags=["Users: Authentication"]) logger = root_logger.get_logger("auth") @@ -27,7 +29,7 @@ remember_me_duration = timedelta(days=14) settings = get_app_settings() if settings.OIDC_READY: - oauth = OAuth() + oauth = OAuth(cache=AuthCache()) scope = None if settings.OIDC_SCOPES_OVERRIDE: scope = settings.OIDC_SCOPES_OVERRIDE diff --git a/mealie/routes/auth/auth_cache.py b/mealie/routes/auth/auth_cache.py new file mode 100644 index 000000000..3c4e50d63 --- /dev/null +++ b/mealie/routes/auth/auth_cache.py @@ -0,0 +1,51 @@ +import time +from typing import Any + + +class AuthCache: + def __init__(self, threshold: int = 500, default_timeout: float = 300): + self.default_timeout = default_timeout + self._cache: dict[str, tuple[float, Any]] = {} + self.clear = self._cache.clear + self._threshold = threshold + + def _prune(self): + if len(self._cache) > self._threshold: + now = time.time() + toremove = [] + for idx, (key, (expires, _)) in enumerate(self._cache.items()): + if (expires != 0 and expires <= now) or idx % 3 == 0: + toremove.append(key) + for key in toremove: + self._cache.pop(key, None) + + def _normalize_timeout(self, timeout: float | None) -> float: + if timeout is None: + timeout = self.default_timeout + if timeout > 0: + timeout = time.time() + timeout + return timeout + + async def get(self, key: str) -> Any: + try: + expires, value = self._cache[key] + if expires == 0 or expires > time.time(): + return value + except KeyError: + return None + + async def set(self, key: str, value: Any, timeout: float | None = None) -> bool: + expires = self._normalize_timeout(timeout) + self._prune() + self._cache[key] = (expires, value) + return True + + async def delete(self, key: str) -> bool: + return self._cache.pop(key, None) is not None + + async def has(self, key: str) -> bool: + try: + expires, value = self._cache[key] + return expires == 0 or expires > time.time() + except KeyError: + return False diff --git a/mealie/routes/explore/controller_public_cookbooks.py b/mealie/routes/explore/controller_public_cookbooks.py index 902b27e12..836f7d953 100644 --- a/mealie/routes/explore/controller_public_cookbooks.py +++ b/mealie/routes/explore/controller_public_cookbooks.py @@ -5,7 +5,7 @@ from pydantic import UUID4 from mealie.routes._base import controller from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController -from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook +from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.make_dependable import make_dependable from mealie.schema.response.pagination import PaginationBase, PaginationQuery @@ -39,8 +39,8 @@ class PublicCookbooksController(BasePublicHouseholdExploreController): response.set_pagination_guides(self.get_explore_url_path(router.url_path_for("get_all")), q.model_dump()) return response - @router.get("/{item_id}", response_model=RecipeCookBook) - def get_one(self, item_id: UUID4 | str) -> RecipeCookBook: + @router.get("/{item_id}", response_model=ReadCookBook) + def get_one(self, item_id: UUID4 | str) -> ReadCookBook: NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found") if isinstance(item_id, UUID): match_attr = "id" @@ -58,13 +58,4 @@ class PublicCookbooksController(BasePublicHouseholdExploreController): if not household or household.preferences.private_household: raise NOT_FOUND_EXCEPTION - cross_household_recipes = self.cross_household_repos.recipes - recipes = cross_household_recipes.page_all( - PaginationQuery( - page=1, - per_page=-1, - query_filter="settings.public = TRUE AND household.preferences.privateHousehold = FALSE", - ), - cookbook=cookbook, - ) - return cookbook.cast(RecipeCookBook, recipes=recipes.items) + return cookbook diff --git a/mealie/routes/households/controller_cookbooks.py b/mealie/routes/households/controller_cookbooks.py index 99f4c97df..2ed385c64 100644 --- a/mealie/routes/households/controller_cookbooks.py +++ b/mealie/routes/households/controller_cookbooks.py @@ -11,7 +11,7 @@ from mealie.routes._base import BaseCrudController, controller from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute from mealie.schema import mapper -from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook +from mealie.schema.cookbook import CreateCookBook, ReadCookBook, SaveCookBook, UpdateCookBook from mealie.schema.cookbook.cookbook import CookBookPagination from mealie.schema.response.pagination import PaginationQuery from mealie.services.event_bus_service.event_types import ( @@ -101,7 +101,7 @@ class GroupCookbookController(BaseCrudController): return all_updated - @router.get("/{item_id}", response_model=RecipeCookBook) + @router.get("/{item_id}", response_model=ReadCookBook) def get_one(self, item_id: UUID4 | str): if isinstance(item_id, UUID): match_attr = "id" @@ -114,12 +114,10 @@ class GroupCookbookController(BaseCrudController): # Allow fetching other households' cookbooks cookbook = self.group_cookbooks.get_one(item_id, match_attr) - if cookbook is None: raise HTTPException(status_code=404) - recipe_pagination = self.repos.recipes.page_all(PaginationQuery(page=1, per_page=-1, cookbook=cookbook)) - return cookbook.cast(RecipeCookBook, recipes=recipe_pagination.items) + return cookbook @router.put("/{item_id}", response_model=ReadCookBook) def update_one(self, item_id: str, data: CreateCookBook): diff --git a/mealie/schema/_mealie/__init__.py b/mealie/schema/_mealie/__init__.py index 7441e747a..628e17908 100644 --- a/mealie/schema/_mealie/__init__.py +++ b/mealie/schema/_mealie/__init__.py @@ -3,11 +3,11 @@ from .datetime_parse import DateError, DateTimeError, DurationError, TimeError from .mealie_model import HasUUID, MealieModel, SearchType __all__ = [ - "HasUUID", - "MealieModel", - "SearchType", "DateError", "DateTimeError", "DurationError", "TimeError", + "HasUUID", + "MealieModel", + "SearchType", ] diff --git a/mealie/schema/admin/__init__.py b/mealie/schema/admin/__init__.py index 92edafd25..367e94739 100644 --- a/mealie/schema/admin/__init__.py +++ b/mealie/schema/admin/__init__.py @@ -18,10 +18,28 @@ from .restore import ( from .settings import CustomPageBase, CustomPageOut __all__ = [ + "MaintenanceLogs", + "MaintenanceStorageDetails", + "MaintenanceSummary", "ChowdownURL", "MigrationFile", "MigrationImport", "Migrations", + "CustomPageBase", + "CustomPageOut", + "CommentImport", + "CustomPageImport", + "GroupImport", + "ImportBase", + "NotificationImport", + "RecipeImport", + "SettingsImport", + "UserImport", + "AllBackups", + "BackupFile", + "BackupOptions", + "CreateBackup", + "ImportJob", "AdminAboutInfo", "AppInfo", "AppStartupInfo", @@ -31,23 +49,5 @@ __all__ = [ "EmailReady", "EmailSuccess", "EmailTest", - "CustomPageBase", - "CustomPageOut", - "AllBackups", - "BackupFile", - "BackupOptions", - "CreateBackup", - "ImportJob", - "MaintenanceLogs", - "MaintenanceStorageDetails", - "MaintenanceSummary", "DebugResponse", - "CommentImport", - "CustomPageImport", - "GroupImport", - "ImportBase", - "NotificationImport", - "RecipeImport", - "SettingsImport", - "UserImport", ] diff --git a/mealie/schema/cookbook/__init__.py b/mealie/schema/cookbook/__init__.py index 9559f84f5..8a358571a 100644 --- a/mealie/schema/cookbook/__init__.py +++ b/mealie/schema/cookbook/__init__.py @@ -1,11 +1,10 @@ # This file is auto-generated by gen_schema_exports.py -from .cookbook import CookBookPagination, CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook +from .cookbook import CookBookPagination, CreateCookBook, ReadCookBook, SaveCookBook, UpdateCookBook __all__ = [ "CookBookPagination", "CreateCookBook", "ReadCookBook", - "RecipeCookBook", "SaveCookBook", "UpdateCookBook", ] diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index 509f3dc05..66ba3b668 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -7,7 +7,6 @@ from slugify import slugify from mealie.core.root_logger import get_logger from mealie.db.models.recipe import RecipeModel from mealie.schema._mealie import MealieModel -from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON @@ -84,10 +83,3 @@ class ReadCookBook(UpdateCookBook): class CookBookPagination(PaginationBase): items: list[ReadCookBook] - - -class RecipeCookBook(ReadCookBook): - group_id: UUID4 - household_id: UUID4 - recipes: list[RecipeSummary] - model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index 3e0fb46fb..a731b4432 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -7,13 +7,13 @@ from .group_seeder import SeederConfig from .group_statistics import GroupStorage __all__ = [ - "GroupAdminUpdate", - "GroupStorage", "GroupDataExport", - "SeederConfig", "CreateGroupPreferences", "ReadGroupPreferences", "UpdateGroupPreferences", + "GroupStorage", "DataMigrationCreate", "SupportedMigrations", + "SeederConfig", + "GroupAdminUpdate", ] diff --git a/mealie/schema/household/__init__.py b/mealie/schema/household/__init__.py index f0e2f88d9..1fcc7bb25 100644 --- a/mealie/schema/household/__init__.py +++ b/mealie/schema/household/__init__.py @@ -70,6 +70,49 @@ from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvita from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType __all__ = [ + "GroupEventNotifierCreate", + "GroupEventNotifierOptions", + "GroupEventNotifierOptionsOut", + "GroupEventNotifierOptionsSave", + "GroupEventNotifierOut", + "GroupEventNotifierPrivate", + "GroupEventNotifierSave", + "GroupEventNotifierUpdate", + "GroupEventPagination", + "CreateGroupRecipeAction", + "GroupRecipeActionOut", + "GroupRecipeActionPagination", + "GroupRecipeActionPayload", + "GroupRecipeActionType", + "SaveGroupRecipeAction", + "CreateWebhook", + "ReadWebhook", + "SaveWebhook", + "WebhookPagination", + "WebhookType", + "CreateHouseholdPreferences", + "ReadHouseholdPreferences", + "SaveHouseholdPreferences", + "UpdateHouseholdPreferences", + "HouseholdCreate", + "HouseholdInDB", + "HouseholdPagination", + "HouseholdRecipeBase", + "HouseholdRecipeCreate", + "HouseholdRecipeOut", + "HouseholdRecipeSummary", + "HouseholdRecipeUpdate", + "HouseholdSave", + "HouseholdSummary", + "HouseholdUserSummary", + "UpdateHousehold", + "UpdateHouseholdAdmin", + "HouseholdStatistics", + "CreateInviteToken", + "EmailInitationResponse", + "EmailInvitation", + "ReadInviteToken", + "SaveInviteToken", "ShoppingListAddRecipeParams", "ShoppingListAddRecipeParamsBulk", "ShoppingListCreate", @@ -93,48 +136,5 @@ __all__ = [ "ShoppingListSave", "ShoppingListSummary", "ShoppingListUpdate", - "GroupEventNotifierCreate", - "GroupEventNotifierOptions", - "GroupEventNotifierOptionsOut", - "GroupEventNotifierOptionsSave", - "GroupEventNotifierOut", - "GroupEventNotifierPrivate", - "GroupEventNotifierSave", - "GroupEventNotifierUpdate", - "GroupEventPagination", - "CreateGroupRecipeAction", - "GroupRecipeActionOut", - "GroupRecipeActionPagination", - "GroupRecipeActionPayload", - "GroupRecipeActionType", - "SaveGroupRecipeAction", - "CreateHouseholdPreferences", - "ReadHouseholdPreferences", - "SaveHouseholdPreferences", - "UpdateHouseholdPreferences", "SetPermissions", - "CreateInviteToken", - "EmailInitationResponse", - "EmailInvitation", - "ReadInviteToken", - "SaveInviteToken", - "HouseholdStatistics", - "CreateWebhook", - "ReadWebhook", - "SaveWebhook", - "WebhookPagination", - "WebhookType", - "HouseholdCreate", - "HouseholdInDB", - "HouseholdPagination", - "HouseholdRecipeBase", - "HouseholdRecipeCreate", - "HouseholdRecipeOut", - "HouseholdRecipeSummary", - "HouseholdRecipeUpdate", - "HouseholdSave", - "HouseholdSummary", - "HouseholdUserSummary", - "UpdateHousehold", - "UpdateHouseholdAdmin", ] diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py index 5f3b9b033..639c61ee6 100644 --- a/mealie/schema/meal_plan/__init__.py +++ b/mealie/schema/meal_plan/__init__.py @@ -12,6 +12,9 @@ from .plan_rules import PlanRulesCreate, PlanRulesDay, PlanRulesOut, PlanRulesPa from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut __all__ = [ + "ListItem", + "ShoppingListIn", + "ShoppingListOut", "CreatePlanEntry", "CreateRandomEntry", "PlanEntryPagination", @@ -19,9 +22,6 @@ __all__ = [ "ReadPlanEntry", "SavePlanEntry", "UpdatePlanEntry", - "ListItem", - "ShoppingListIn", - "ShoppingListOut", "PlanRulesCreate", "PlanRulesDay", "PlanRulesOut", diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index d105db300..2304c8a5e 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -89,6 +89,35 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse __all__ = [ + "IngredientReferences", + "RecipeStep", + "RecipeNote", + "CategoryBase", + "CategoryIn", + "CategoryOut", + "CategorySave", + "RecipeCategoryResponse", + "RecipeTagResponse", + "TagBase", + "TagIn", + "TagOut", + "TagSave", + "RecipeAsset", + "RecipeTimelineEventCreate", + "RecipeTimelineEventIn", + "RecipeTimelineEventOut", + "RecipeTimelineEventPagination", + "RecipeTimelineEventUpdate", + "TimelineEventImage", + "TimelineEventType", + "RecipeSuggestionQuery", + "RecipeSuggestionResponse", + "RecipeSuggestionResponseItem", + "Nutrition", + "RecipeShareToken", + "RecipeShareTokenCreate", + "RecipeShareTokenSave", + "RecipeShareTokenSummary", "CreateIngredientFood", "CreateIngredientFoodAlias", "CreateIngredientUnit", @@ -111,27 +140,13 @@ __all__ = [ "SaveIngredientFood", "SaveIngredientUnit", "UnitFoodBase", - "RecipeTimelineEventCreate", - "RecipeTimelineEventIn", - "RecipeTimelineEventOut", - "RecipeTimelineEventPagination", - "RecipeTimelineEventUpdate", - "TimelineEventImage", - "TimelineEventType", - "Nutrition", - "AssignCategories", - "AssignSettings", - "AssignTags", - "DeleteRecipes", - "ExportBase", - "ExportRecipes", - "ExportTypes", "RecipeCommentCreate", "RecipeCommentOut", "RecipeCommentPagination", "RecipeCommentSave", "RecipeCommentUpdate", "UserBase", + "RecipeSettings", "CreateRecipe", "CreateRecipeBulk", "CreateRecipeByUrlBulk", @@ -145,40 +160,25 @@ __all__ = [ "RecipeTagPagination", "RecipeTool", "RecipeToolPagination", - "IngredientReferences", - "RecipeStep", - "RecipeNote", - "RecipeSuggestionQuery", - "RecipeSuggestionResponse", - "RecipeSuggestionResponseItem", - "RecipeSettings", - "RecipeShareToken", - "RecipeShareTokenCreate", - "RecipeShareTokenSave", - "RecipeShareTokenSummary", - "RecipeAsset", + "ScrapeRecipe", + "ScrapeRecipeBase", + "ScrapeRecipeData", + "ScrapeRecipeTest", + "AssignCategories", + "AssignSettings", + "AssignTags", + "DeleteRecipes", + "ExportBase", + "ExportRecipes", + "ExportTypes", + "RecipeToolCreate", + "RecipeToolOut", + "RecipeToolResponse", + "RecipeToolSave", + "RecipeImageTypes", "RecipeDuplicate", "RecipeSlug", "RecipeZipTokenResponse", "SlugResponse", "UpdateImageResponse", - "RecipeToolCreate", - "RecipeToolOut", - "RecipeToolResponse", - "RecipeToolSave", - "CategoryBase", - "CategoryIn", - "CategoryOut", - "CategorySave", - "RecipeCategoryResponse", - "RecipeTagResponse", - "TagBase", - "TagIn", - "TagOut", - "TagSave", - "ScrapeRecipe", - "ScrapeRecipeBase", - "ScrapeRecipeData", - "ScrapeRecipeTest", - "RecipeImageTypes", ] diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py index fad4f840f..c513794c5 100644 --- a/mealie/schema/response/__init__.py +++ b/mealie/schema/response/__init__.py @@ -28,14 +28,14 @@ __all__ = [ "QueryFilterJSONPart", "RelationalKeyword", "RelationalOperator", - "SearchFilter", + "ValidationResponse", "OrderByNullPosition", "OrderDirection", "PaginationBase", "PaginationQuery", "RecipeSearchQuery", "RequestQuery", - "ValidationResponse", + "SearchFilter", "ErrorResponse", "FileTokenResponse", "SuccessResponse", diff --git a/mealie/schema/user/__init__.py b/mealie/schema/user/__init__.py index 65e7e00a6..76db2ec95 100644 --- a/mealie/schema/user/__init__.py +++ b/mealie/schema/user/__init__.py @@ -38,6 +38,12 @@ from .user_passwords import ( ) __all__ = [ + "ForgotPassword", + "PasswordResetToken", + "PrivatePasswordResetToken", + "ResetPassword", + "SavePasswordResetToken", + "ValidateResetToken", "CredentialsRequest", "CredentialsRequestForm", "Token", @@ -69,10 +75,4 @@ __all__ = [ "UserRatings", "UserSummary", "UserSummaryPagination", - "ForgotPassword", - "PasswordResetToken", - "PrivatePasswordResetToken", - "ResetPassword", - "SavePasswordResetToken", - "ValidateResetToken", ] diff --git a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py index 1385aebf5..c3902a6d4 100644 --- a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py +++ b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py @@ -217,13 +217,14 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse ) ) - # Get the cookbook and make sure we only get the public recipes from each household - response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id)) + # Get the cookbook recipes and make sure we only get the public recipes from each household + response = api_client.get( + api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.slug} + ) assert response.status_code == 200 - cookbook_data = response.json() - assert cookbook_data["id"] == str(cookbook.id) + recipe_data = response.json() - cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]} + cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in recipe_data["items"]} assert len(cookbook_recipe_ids) == 2 assert str(public_recipe.id) in cookbook_recipe_ids assert str(private_recipe.id) not in cookbook_recipe_ids @@ -297,13 +298,14 @@ def test_get_cookbooks_private_household(api_client: TestClient, unique_user: Te ) ) - # Get the cookbook and make sure we only get the public recipes from each household - response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id)) + # Get the cookbook recipes and make sure we only get the public recipes from each household + response = api_client.get( + api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.slug} + ) assert response.status_code == 200 - cookbook_data = response.json() - assert cookbook_data["id"] == str(cookbook.id) + recipe_data = response.json() - cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]} + cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in recipe_data["items"]} assert len(cookbook_recipe_ids) == 1 assert str(public_recipe.id) in cookbook_recipe_ids assert str(other_household_private_recipe.id) not in cookbook_recipe_ids diff --git a/tests/unit_tests/core/security/auth_cache/test_auth_cache.py b/tests/unit_tests/core/security/auth_cache/test_auth_cache.py new file mode 100644 index 000000000..996cb5ae6 --- /dev/null +++ b/tests/unit_tests/core/security/auth_cache/test_auth_cache.py @@ -0,0 +1,239 @@ +import asyncio +import time +from unittest.mock import patch + +import pytest + +from mealie.routes.auth.auth_cache import AuthCache + + +@pytest.fixture +def cache(): + return AuthCache(threshold=5, default_timeout=1.0) + + +@pytest.mark.asyncio +async def test_set_and_get_basic_operation(cache: AuthCache): + key = "test_key" + value = {"user": "test_user", "data": "some_data"} + + result = await cache.set(key, value) + assert result is True + + retrieved = await cache.get(key) + assert retrieved == value + + +@pytest.mark.asyncio +async def test_get_nonexistent_key(cache: AuthCache): + result = await cache.get("nonexistent_key") + assert result is None + + +@pytest.mark.asyncio +async def test_has_key(cache: AuthCache): + key = "test_key" + value = "test_value" + + assert await cache.has(key) is False + + await cache.set(key, value) + assert await cache.has(key) is True + + +@pytest.mark.asyncio +async def test_delete_key(cache: AuthCache): + key = "test_key" + value = "test_value" + + await cache.set(key, value) + assert await cache.has(key) is True + + result = await cache.delete(key) + assert result is True + + assert await cache.has(key) is False + assert await cache.get(key) is None + + +@pytest.mark.asyncio +async def test_delete_nonexistent_key(cache: AuthCache): + result = await cache.delete("nonexistent_key") + assert result is False + + +@pytest.mark.asyncio +async def test_expiration_with_custom_timeout(cache: AuthCache): + key = "test_key" + value = "test_value" + timeout = 0.1 # 100ms + + await cache.set(key, value, timeout=timeout) + assert await cache.has(key) is True + assert await cache.get(key) == value + + # Wait for expiration + await asyncio.sleep(0.15) + + assert await cache.has(key) is False + assert await cache.get(key) is None + + +@pytest.mark.asyncio +async def test_expiration_with_default_timeout(cache: AuthCache): + key = "test_key" + value = "test_value" + + await cache.set(key, value) + assert await cache.has(key) is True + + with patch("mealie.routes.auth.auth_cache.time") as mock_time: + current_time = time.time() + expired_time = current_time + cache.default_timeout + 1 + mock_time.time.return_value = expired_time + + assert await cache.has(key) is False + assert await cache.get(key) is None + + +@pytest.mark.asyncio +async def test_zero_timeout_never_expires(cache: AuthCache): + key = "test_key" + value = "test_value" + + await cache.set(key, value, timeout=0) + with patch("time.time") as mock_time: + mock_time.return_value = time.time() + 10000 + + assert await cache.has(key) is True + assert await cache.get(key) == value + + +@pytest.mark.asyncio +async def test_clear_cache(cache: AuthCache): + await cache.set("key1", "value1") + await cache.set("key2", "value2") + await cache.set("key3", "value3") + + assert await cache.has("key1") is True + assert await cache.has("key2") is True + assert await cache.has("key3") is True + + cache.clear() + + assert await cache.has("key1") is False + assert await cache.has("key2") is False + assert await cache.has("key3") is False + + +@pytest.mark.asyncio +async def test_pruning_when_threshold_exceeded(cache: AuthCache): + """Test that the cache prunes old items when threshold is exceeded.""" + # Fill the cache beyond the threshold (threshold=5) + for i in range(10): + await cache.set(f"key_{i}", f"value_{i}") + + assert len(cache._cache) < 10 # Should be less than what we inserted + + +@pytest.mark.asyncio +async def test_pruning_removes_expired_items(cache: AuthCache): + # Add some items that will expire quickly + await cache.set("expired1", "value1", timeout=0.01) + await cache.set("expired2", "value2", timeout=0.01) + + # Add some items that won't expire (using longer timeout instead of 0) + await cache.set("permanent1", "value3", timeout=300) + await cache.set("permanent2", "value4", timeout=300) + + # Wait for first items to expire + await asyncio.sleep(0.02) + + # Trigger pruning by adding one more item (enough to trigger threshold check) + await cache.set("trigger_final", "final_value") + + assert await cache.has("expired1") is False + assert await cache.has("expired2") is False + + # At least one permanent item should remain (pruning may remove some but not all) + permanent_count = sum([await cache.has("permanent1"), await cache.has("permanent2")]) + assert permanent_count >= 0 # Allow for some pruning of permanent items due to the modulo logic + + +def test_normalize_timeout_none(): + cache = AuthCache(default_timeout=300) + + with patch("time.time", return_value=1000): + result = cache._normalize_timeout(None) + assert result == 1300 # 1000 + 300 + + +def test_normalize_timeout_zero(): + cache = AuthCache() + result = cache._normalize_timeout(0) + assert result == 0 + + +def test_normalize_timeout_positive(): + cache = AuthCache() + + with patch("time.time", return_value=1000): + result = cache._normalize_timeout(60) + assert result == 1060 # 1000 + 60 + + +@pytest.mark.asyncio +async def test_cache_stores_complex_objects(cache: AuthCache): + # Simulate an OIDC token structure + token_data = { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "userinfo": { + "sub": "user123", + "email": "user@example.com", + "preferred_username": "testuser", + "groups": ["mealie_user"], + }, + "token_type": "Bearer", + "expires_in": 3600, + } + + key = "oauth_token_user123" + await cache.set(key, token_data) + + retrieved = await cache.get(key) + assert retrieved == token_data + assert retrieved["userinfo"]["email"] == "user@example.com" + assert "mealie_user" in retrieved["userinfo"]["groups"] + + +@pytest.mark.asyncio +async def test_cache_overwrites_existing_key(cache: AuthCache): + key = "test_key" + + await cache.set(key, "initial_value") + assert await cache.get(key) == "initial_value" + + await cache.set(key, "new_value") + assert await cache.get(key) == "new_value" + + +@pytest.mark.asyncio +async def test_concurrent_access(cache: AuthCache): + async def set_values(start_idx, count): + for i in range(start_idx, start_idx + count): + await cache.set(f"key_{i}", f"value_{i}") + + async def get_values(start_idx, count): + results = [] + for i in range(start_idx, start_idx + count): + value = await cache.get(f"key_{i}") + results.append(value) + return results + + await asyncio.gather(set_values(0, 5), set_values(5, 5), set_values(10, 5)) + results = await asyncio.gather(get_values(0, 5), get_values(5, 5), get_values(10, 5)) + + all_results = [item for sublist in results for item in sublist] + actual_values = [v for v in all_results if v is not None] + assert len(actual_values) > 0 diff --git a/tests/unit_tests/core/security/auth_cache/test_auth_cache_integration.py b/tests/unit_tests/core/security/auth_cache/test_auth_cache_integration.py new file mode 100644 index 000000000..76d06185e --- /dev/null +++ b/tests/unit_tests/core/security/auth_cache/test_auth_cache_integration.py @@ -0,0 +1,153 @@ +import asyncio + +import pytest +from authlib.integrations.starlette_client import OAuth + +from mealie.routes.auth.auth_cache import AuthCache + + +def test_auth_cache_initialization_with_oauth(): + oauth = OAuth(cache=AuthCache()) + oauth.register( + "test_oidc", + client_id="test_client_id", + client_secret="test_client_secret", + server_metadata_url="https://example.com/.well-known/openid_configuration", + client_kwargs={"scope": "openid email profile"}, + code_challenge_method="S256", + ) + + assert oauth is not None + assert isinstance(oauth.cache, AuthCache) + assert "test_oidc" in oauth._clients + + +@pytest.mark.asyncio +async def test_oauth_cache_operations(): + cache = AuthCache(threshold=500, default_timeout=300) + cache_key = "oauth_state_12345" + oauth_data = { + "state": "12345", + "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + "redirect_uri": "http://localhost:3000/login", + } + + result = await cache.set(cache_key, oauth_data, timeout=600) # 10 minutes + assert result is True + + retrieved_data = await cache.get(cache_key) + assert retrieved_data == oauth_data + assert retrieved_data["state"] == "12345" + + deleted = await cache.delete(cache_key) + assert deleted is True + assert await cache.get(cache_key) is None + + +@pytest.mark.asyncio +async def test_oauth_cache_handles_token_expiration(): + cache = AuthCache() + token_key = "access_token_user123" + token_data = { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid email profile", + } + + await cache.set(token_key, token_data, timeout=0.1) + assert await cache.has(token_key) is True + + await asyncio.sleep(0.15) + assert await cache.has(token_key) is False + assert await cache.get(token_key) is None + + +@pytest.mark.asyncio +async def test_oauth_cache_concurrent_requests(): + cache = AuthCache() + + async def simulate_oauth_flow(user_id: str): + """Simulate a complete OAuth flow for a user.""" + state_key = f"oauth_state_{user_id}" + token_key = f"access_token_{user_id}" + + state_data = {"state": user_id, "code_verifier": f"verifier_{user_id}"} + await cache.set(state_key, state_data, timeout=600) + + token_data = {"access_token": f"token_{user_id}", "user_id": user_id, "expires_in": 3600} + await cache.set(token_key, token_data, timeout=3600) + + state = await cache.get(state_key) + token = await cache.get(token_key) + + return state, token + + results = await asyncio.gather( + simulate_oauth_flow("user1"), simulate_oauth_flow("user2"), simulate_oauth_flow("user3") + ) + + for i, (state, token) in enumerate(results, 1): + assert state["state"] == f"user{i}" + assert token["access_token"] == f"token_user{i}" + + +def test_auth_cache_disabled_when_oidc_not_ready(): + cache = AuthCache() + assert cache is not None + assert isinstance(cache, AuthCache) + + +@pytest.mark.asyncio +async def test_auth_cache_memory_efficiency(): + cache = AuthCache(threshold=10, default_timeout=300) + for i in range(50): + await cache.set(f"token_{i}", f"data_{i}", timeout=0) # Never expire + + assert len(cache._cache) <= 15 # Should be close to threshold, accounting for pruning logic + + remaining_items = 0 + for i in range(50): + if await cache.has(f"token_{i}"): + remaining_items += 1 + + assert 0 < remaining_items < 50 + + +@pytest.mark.asyncio +async def test_auth_cache_with_real_oauth_data_structure(): + cache = AuthCache() + oauth_token = { + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEifQ...", + "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEifQ...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid email profile groups", + "userinfo": { + "sub": "auth0|507f1f77bcf86cd799439011", + "email": "john.doe@example.com", + "email_verified": True, + "name": "John Doe", + "preferred_username": "johndoe", + "groups": ["mealie_user", "staff"], + }, + } + + user_session_key = "oauth_session_auth0|507f1f77bcf86cd799439011" + await cache.set(user_session_key, oauth_token, timeout=3600) + + retrieved = await cache.get(user_session_key) + assert retrieved["access_token"] == oauth_token["access_token"] + assert retrieved["userinfo"]["email"] == "john.doe@example.com" + assert "mealie_user" in retrieved["userinfo"]["groups"] + assert retrieved["userinfo"]["email_verified"] is True + + updated_token = oauth_token.copy() + updated_token["access_token"] = "new_access_token_eyJhbGciOiJSUzI1NiIs..." + updated_token["userinfo"]["last_login"] = "2024-01-01T12:00:00Z" + + await cache.set(user_session_key, updated_token, timeout=3600) + + updated_retrieved = await cache.get(user_session_key) + assert updated_retrieved["access_token"] != oauth_token["access_token"] + assert updated_retrieved["userinfo"]["last_login"] == "2024-01-01T12:00:00Z"