From c617251f4cc3bc7bc1fcd5c829010ecb981d6651 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 13 Feb 2022 12:23:42 -0900 Subject: [PATCH] feature: proper multi-tenant-support (#969)(WIP) * update naming * refactor tests to use shared structure * shorten names * add tools test case * refactor to support multi-tenant * set group_id on creation * initial refactor for multitenant tags/cats * spelling * additional test case for same valued resources * fix recipe update tests * apply indexes to foreign keys * fix performance regressions * handle unknown exception * utility decorator for function debugging * migrate recipe_id to UUID * GUID for recipes * remove unused import * move image functions into package * move utilities to packages dir * update import * linter * image image and asset routes * update assets and images to use UUIDs * fix migration base * image asset test coverage * use ids for categories and tag crud functions * refactor recipe organizer test suite to reduce duplication * add uuid serlization utility * organizer base router * slug routes testing and fixes * fix postgres error * adopt UUIDs * move tags, categories, and tools under "organizers" umbrella * update composite label * generate ts types * fix import error * update frontend types * fix type errors * fix postgres errors * fix #978 * add null check for title validation * add note in docs on multi-tenancy --- docs/docs/changelog/v1.0.0.md | 1 + frontend/api/class-interfaces/categories.ts | 47 ----- .../api/class-interfaces/group-cookbooks.ts | 4 +- .../api/class-interfaces/group-mealplan.ts | 2 +- .../class-interfaces/group-shopping-lists.ts | 6 +- .../class-interfaces/organizer-categories.ts | 20 ++ .../api/class-interfaces/organizer-tags.ts | 20 ++ .../{tools.ts => organizer-tools.ts} | 6 +- frontend/api/class-interfaces/recipe-foods.ts | 12 +- .../class-interfaces/recipes/recipe-share.ts | 4 +- .../api/class-interfaces/recipes/types.ts | 9 +- frontend/api/class-interfaces/tags.ts | 47 ----- frontend/api/config.ts | 5 + frontend/api/index.ts | 6 +- .../Domain/Recipe/RecipeActionMenu.vue | 25 +-- .../components/Domain/Recipe/RecipeAssets.vue | 6 +- .../components/Domain/Recipe/RecipeCard.vue | 11 +- .../Domain/Recipe/RecipeCardImage.vue | 27 ++- .../Domain/Recipe/RecipeCardMobile.vue | 13 +- .../Recipe/RecipeCategoryTagSelector.vue | 21 +- .../Domain/Recipe/RecipeComments.vue | 4 +- .../Domain/Recipe/RecipeContextMenu.vue | 2 +- .../Domain/Recipe/RecipeDialogShare.vue | 2 +- .../Domain/Recipe/RecipeIngredientEditor.vue | 7 +- .../Domain/Recipe/RecipeIngredients.vue | 2 +- frontend/composables/api/static-routes.ts | 16 +- .../composables/recipes/use-recipe-foods.ts | 12 +- .../composables/recipes/use-recipe-meta.ts | 2 - .../composables/recipes/use-recipe-tools.ts | 7 +- .../recipes/use-tags-categories.ts | 14 +- frontend/pages/group/mealplan/planner.vue | 1 + frontend/pages/index.vue | 5 +- frontend/pages/recipe/_slug/index.vue | 10 +- frontend/pages/recipes/categories/_slug.vue | 4 +- frontend/pages/recipes/tags/_slug.vue | 4 +- frontend/pages/recipes/tools/_slug.vue | 2 +- frontend/pages/search.vue | 4 +- frontend/pages/shared/recipes/_id.vue | 2 +- frontend/pages/shopping-lists/_id.vue | 5 +- frontend/types/api-types/admin.ts | 80 +------ frontend/types/api-types/cookbook.ts | 82 +------- frontend/types/api-types/group.ts | 30 +-- frontend/types/api-types/meal-plan.ts | 24 +-- frontend/types/api-types/recipe.ts | 149 ++++++++----- frontend/types/api-types/user.ts | 14 +- makefile | 1 - mealie/core/root_logger.py | 5 +- mealie/db/db_setup.py | 7 +- mealie/db/models/group/group.py | 3 + mealie/db/models/group/mealplan.py | 3 +- mealie/db/models/group/preferences.py | 2 +- mealie/db/models/group/report.py | 2 +- mealie/db/models/group/shopping_list.py | 10 +- mealie/db/models/labels.py | 2 +- mealie/db/models/recipe/api_extras.py | 3 +- mealie/db/models/recipe/assets.py | 3 +- mealie/db/models/recipe/category.py | 32 +-- mealie/db/models/recipe/comment.py | 4 +- mealie/db/models/recipe/ingredient.py | 10 +- mealie/db/models/recipe/instruction.py | 2 +- mealie/db/models/recipe/note.py | 3 +- mealie/db/models/recipe/nutrition.py | 3 +- mealie/db/models/recipe/recipe.py | 13 +- mealie/db/models/recipe/settings.py | 3 +- mealie/db/models/recipe/shared.py | 4 +- mealie/db/models/recipe/tag.py | 25 ++- mealie/db/models/recipe/tool.py | 14 +- mealie/db/models/server/task.py | 2 +- mealie/db/models/users/user_to_favorite.py | 4 +- mealie/db/models/users/users.py | 6 +- mealie/{services/image => pkgs}/__init__.py | 0 mealie/pkgs/cache/__init__.py | 1 + mealie/{utils => pkgs/cache}/cache_key.py | 3 +- mealie/pkgs/dev/__init__.py | 7 + .../{utils => pkgs/dev}/lifespan_tracker.py | 0 mealie/pkgs/dev/timer.py | 12 ++ mealie/pkgs/img/__init__.py | 7 + mealie/pkgs/img/minify.py | 130 ++++++++++++ mealie/pkgs/stats/__init__.py | 1 + mealie/{utils => pkgs/stats}/fs_stats.py | 0 mealie/repos/repository_factory.py | 11 +- mealie/repos/repository_recipes.py | 12 +- mealie/routes/__init__.py | 6 +- mealie/routes/backup_routes.py | 2 +- mealie/routes/categories/__init__.py | 6 - mealie/routes/categories/categories.py | 69 ------ .../groups/controller_shopping_lists.py | 4 +- mealie/routes/media/media_recipe.py | 19 +- mealie/routes/organizers/__init__.py | 8 + .../organizers/controller_categories.py | 87 ++++++++ mealie/routes/organizers/controller_tags.py | 66 ++++++ .../controller_tools.py} | 17 +- mealie/routes/recipe/__init__.py | 3 +- mealie/routes/recipe/image_and_assets.py | 68 ------ mealie/routes/recipe/recipe_crud_routes.py | 69 +++++- mealie/routes/shared/__init__.py | 2 +- mealie/routes/tags/__init__.py | 51 ----- mealie/routes/unit_and_foods/foods.py | 7 +- mealie/routes/unit_and_foods/units.py | 7 +- mealie/routes/users/images.py | 7 +- mealie/run.sh | 1 - mealie/schema/group/group_shopping_list.py | 8 +- mealie/schema/meal_plan/new_meal.py | 2 +- mealie/schema/meal_plan/plan_rules.py | 2 +- mealie/schema/recipe/recipe.py | 19 +- mealie/schema/recipe/recipe_category.py | 33 ++- mealie/schema/recipe/recipe_comments.py | 4 +- mealie/schema/recipe/recipe_ingredient.py | 6 +- mealie/schema/recipe/recipe_share_token.py | 2 +- mealie/schema/recipe/recipe_tool.py | 7 +- mealie/services/backups/imports.py | 3 - mealie/services/exporter/exporter.py | 2 +- .../services/group_services/shopping_lists.py | 4 +- mealie/services/image/image.py | 81 ------- mealie/services/image/minify.py | 149 ------------- mealie/services/migrations/_migration_base.py | 27 +-- mealie/services/migrations/chowdown.py | 4 +- mealie/services/migrations/mealie_alpha.py | 9 +- mealie/services/migrations/nextcloud.py | 4 +- mealie/services/migrations/paprika.py | 4 +- .../migrations/utils/database_helpers.py | 25 ++- .../migrations/utils/migration_helpers.py | 11 +- mealie/services/recipe/recipe_data_service.py | 108 ++++++++++ mealie/services/recipe/recipe_service.py | 5 +- mealie/services/scraper/cleaner.py | 3 + mealie/services/scraper/scraper.py | 30 ++- mealie/utils/__init__.py | 1 - mealie/utils/post_webhooks.py | 7 - tests/fixtures/fixture_recipe.py | 23 +- .../category_tag_tool_tests/test_category.py | 107 ---------- .../test_organizers_common.py | 198 ++++++++++++++++++ .../category_tag_tool_tests/test_tags.py | 106 ---------- .../category_tag_tool_tests/test_tools.py | 110 ---------- .../user_group_tests/test_group_mealplan.py | 1 + .../test_group_mealplan_preferences.py | 38 ---- .../test_group_mealplan_rules.py | 10 +- .../test_group_shopping_lists.py | 4 +- .../test_recipe_bulk_action.py | 17 +- .../user_recipe_tests/test_recipe_comments.py | 11 +- .../user_recipe_tests/test_recipe_crud.py | 29 +-- .../test_recipe_image_assets.py | 64 ++++++ .../test_recipe_share_tokens.py | 4 +- tests/multitenant_tests/case_abc.py | 35 ++++ tests/multitenant_tests/case_categories.py | 53 +++++ tests/multitenant_tests/case_foods.py | 52 +++++ tests/multitenant_tests/case_tags.py | 53 +++++ tests/multitenant_tests/case_tools.py | 53 +++++ tests/multitenant_tests/case_units.py | 52 +++++ .../multitenant_tests/test_ingredient_food.py | 79 ------- .../test_multitenant_cases.py | 95 +++++++++ .../test_recipe_data_storage.py | 9 + .../test_recipe_repository.py | 13 +- tests/unit_tests/test_utils.py | 2 +- .../validator_tests/test_create_plan_entry.py | 6 +- tests/utils/__init__.py | 1 + tests/utils/jsonify.py | 6 + tests/utils/routes.py | 28 ++- 157 files changed, 1866 insertions(+), 1578 deletions(-) delete mode 100644 frontend/api/class-interfaces/categories.ts create mode 100644 frontend/api/class-interfaces/organizer-categories.ts create mode 100644 frontend/api/class-interfaces/organizer-tags.ts rename frontend/api/class-interfaces/{tools.ts => organizer-tools.ts} (81%) delete mode 100644 frontend/api/class-interfaces/tags.ts create mode 100644 frontend/api/config.ts rename mealie/{services/image => pkgs}/__init__.py (100%) create mode 100644 mealie/pkgs/cache/__init__.py rename mealie/{utils => pkgs/cache}/cache_key.py (85%) create mode 100644 mealie/pkgs/dev/__init__.py rename mealie/{utils => pkgs/dev}/lifespan_tracker.py (100%) create mode 100644 mealie/pkgs/dev/timer.py create mode 100644 mealie/pkgs/img/__init__.py create mode 100644 mealie/pkgs/img/minify.py create mode 100644 mealie/pkgs/stats/__init__.py rename mealie/{utils => pkgs/stats}/fs_stats.py (100%) delete mode 100644 mealie/routes/categories/__init__.py delete mode 100644 mealie/routes/categories/categories.py create mode 100644 mealie/routes/organizers/__init__.py create mode 100644 mealie/routes/organizers/controller_categories.py create mode 100644 mealie/routes/organizers/controller_tags.py rename mealie/routes/{tools/__init__.py => organizers/controller_tools.py} (75%) delete mode 100644 mealie/routes/recipe/image_and_assets.py delete mode 100644 mealie/routes/tags/__init__.py delete mode 100644 mealie/services/image/image.py delete mode 100644 mealie/services/image/minify.py create mode 100644 mealie/services/recipe/recipe_data_service.py delete mode 100644 mealie/utils/__init__.py delete mode 100644 mealie/utils/post_webhooks.py delete mode 100644 tests/integration_tests/category_tag_tool_tests/test_category.py create mode 100644 tests/integration_tests/category_tag_tool_tests/test_organizers_common.py delete mode 100644 tests/integration_tests/category_tag_tool_tests/test_tags.py delete mode 100644 tests/integration_tests/category_tag_tool_tests/test_tools.py delete mode 100644 tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py create mode 100644 tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py create mode 100644 tests/multitenant_tests/case_abc.py create mode 100644 tests/multitenant_tests/case_categories.py create mode 100644 tests/multitenant_tests/case_foods.py create mode 100644 tests/multitenant_tests/case_tags.py create mode 100644 tests/multitenant_tests/case_tools.py create mode 100644 tests/multitenant_tests/case_units.py delete mode 100644 tests/multitenant_tests/test_ingredient_food.py create mode 100644 tests/multitenant_tests/test_multitenant_cases.py create mode 100644 tests/multitenant_tests/test_recipe_data_storage.py create mode 100644 tests/utils/jsonify.py diff --git a/docs/docs/changelog/v1.0.0.md b/docs/docs/changelog/v1.0.0.md index 8e6a8f952e89..3b755f64760f 100644 --- a/docs/docs/changelog/v1.0.0.md +++ b/docs/docs/changelog/v1.0.0.md @@ -14,6 +14,7 @@ - User/Group settings are now completely separated from the Administration page. - All settings and configurations pages now have some sort of self-documenting help text. Additional text or descriptions are welcome from PRs - New experimental banner for the site to give users a sense of what features are still "in development" and provide a link to a github issue that provides additional context. +- Groups now offer full multi-tenant support so you can all groups have their own set of data. #### ⚙️ Site Settings Page diff --git a/frontend/api/class-interfaces/categories.ts b/frontend/api/class-interfaces/categories.ts deleted file mode 100644 index b3425d1cad2f..000000000000 --- a/frontend/api/class-interfaces/categories.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BaseCRUDAPI } from "../_base"; -import { Recipe } from "~/types/api-types/recipe"; - -const prefix = "/api"; - -export interface Category { - name: string; - id: number; - slug: string; - recipes?: Recipe[]; -} - -export interface CreateCategory { - name: string; -} - -const routes = { - categories: `${prefix}/categories`, - categoriesEmpty: `${prefix}/categories/empty`, - - categoriesCategory: (category: string) => `${prefix}/categories/${category}`, -}; - -export class CategoriesAPI extends BaseCRUDAPI { - baseRoute: string = routes.categories; - itemRoute = routes.categoriesCategory; - - /** Returns a list of categories that do not contain any recipes - */ - async getEmptyCategories() { - return await this.requests.get(routes.categoriesEmpty); - } - - /** Returns a list of recipes associated with the provided category. - */ - async getAllRecipesByCategory(category: string) { - return await this.requests.get(routes.categoriesCategory(category)); - } - - /** Removes a recipe category from the database. Deleting a - * category does not impact a recipe. The category will be removed - * from any recipes that contain it - */ - async deleteRecipeCategory(category: string) { - return await this.requests.delete(routes.categoriesCategory(category)); - } -} diff --git a/frontend/api/class-interfaces/group-cookbooks.ts b/frontend/api/class-interfaces/group-cookbooks.ts index 25de42c8aa2d..fcaa85aecc01 100644 --- a/frontend/api/class-interfaces/group-cookbooks.ts +++ b/frontend/api/class-interfaces/group-cookbooks.ts @@ -1,6 +1,6 @@ import { BaseCRUDAPI } from "../_base"; -import { Category } from "./categories"; import { CategoryBase } from "~/types/api-types/recipe"; +import { RecipeCategory } from "~/types/api-types/user"; const prefix = "/api"; @@ -14,7 +14,7 @@ export interface CookBook extends CreateCookBook { description: string; position: number; group_id: number; - categories: Category[] | CategoryBase[]; + categories: RecipeCategory[] | CategoryBase[]; } const routes = { diff --git a/frontend/api/class-interfaces/group-mealplan.ts b/frontend/api/class-interfaces/group-mealplan.ts index 852577600c9f..e1543605f62b 100644 --- a/frontend/api/class-interfaces/group-mealplan.ts +++ b/frontend/api/class-interfaces/group-mealplan.ts @@ -16,7 +16,7 @@ export interface CreateMealPlan { entryType: PlanEntryType; title: string; text: string; - recipeId?: number; + recipeId?: string; } export interface UpdateMealPlan extends CreateMealPlan { diff --git a/frontend/api/class-interfaces/group-shopping-lists.ts b/frontend/api/class-interfaces/group-shopping-lists.ts index f84df5f90c73..96be394bbb76 100644 --- a/frontend/api/class-interfaces/group-shopping-lists.ts +++ b/frontend/api/class-interfaces/group-shopping-lists.ts @@ -12,7 +12,7 @@ const prefix = "/api"; const routes = { shoppingLists: `${prefix}/groups/shopping/lists`, shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`, - shoppingListIdAddRecipe: (id: string, recipeId: number) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`, + shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`, shoppingListItems: `${prefix}/groups/shopping/items`, shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`, @@ -22,11 +22,11 @@ export class ShoppingListsApi extends BaseCRUDAPI `${prefix}/categories/${category}`, + categoriesSlug: (category: string) => `${prefix}/categories/slug/${category}`, +}; + +export class CategoriesAPI extends BaseCRUDAPI { + baseRoute: string = routes.categories; + itemRoute = routes.categoriesId; + + async bySlug(slug: string) { + return await this.requests.get(routes.categoriesSlug(slug)); + } +} diff --git a/frontend/api/class-interfaces/organizer-tags.ts b/frontend/api/class-interfaces/organizer-tags.ts new file mode 100644 index 000000000000..0433a194983b --- /dev/null +++ b/frontend/api/class-interfaces/organizer-tags.ts @@ -0,0 +1,20 @@ +import { BaseCRUDAPI } from "../_base"; +import { RecipeTagResponse, TagIn } from "~/types/api-types/recipe"; +import { config } from "~/api/config"; + +const prefix = config.PREFIX + "/organizers"; + +const routes = { + tags: `${prefix}/tags`, + tagsId: (tag: string) => `${prefix}/tags/${tag}`, + tagsSlug: (tag: string) => `${prefix}/tags/slug/${tag}`, +}; + +export class TagsAPI extends BaseCRUDAPI { + baseRoute: string = routes.tags; + itemRoute = routes.tagsId; + + async bySlug(slug: string) { + return await this.requests.get(routes.tagsSlug(slug)); + } +} diff --git a/frontend/api/class-interfaces/tools.ts b/frontend/api/class-interfaces/organizer-tools.ts similarity index 81% rename from frontend/api/class-interfaces/tools.ts rename to frontend/api/class-interfaces/organizer-tools.ts index f0b8e92e0938..fbf29fca9836 100644 --- a/frontend/api/class-interfaces/tools.ts +++ b/frontend/api/class-interfaces/organizer-tools.ts @@ -1,7 +1,9 @@ import { BaseCRUDAPI } from "../_base"; import { RecipeTool, RecipeToolCreate, RecipeToolResponse } from "~/types/api-types/recipe"; -const prefix = "/api"; +import { config } from "~/api/config"; + +const prefix = config.PREFIX + "/organizers"; const routes = { tools: `${prefix}/tools`, @@ -13,7 +15,7 @@ export class ToolsApi extends BaseCRUDAPI { baseRoute: string = routes.tools; itemRoute = routes.toolsId; - async byslug(slug: string) { + async bySlug(slug: string) { return await this.requests.get(routes.toolsSlug(slug)); } } diff --git a/frontend/api/class-interfaces/recipe-foods.ts b/frontend/api/class-interfaces/recipe-foods.ts index a04ad7a76120..293815c5b55e 100644 --- a/frontend/api/class-interfaces/recipe-foods.ts +++ b/frontend/api/class-interfaces/recipe-foods.ts @@ -1,22 +1,14 @@ import { BaseCRUDAPI } from "../_base"; +import { CreateIngredientFood, IngredientFood } from "~/types/api-types/recipe"; const prefix = "/api"; -export interface CreateFood { - name: string; - description: string; -} - -export interface Food extends CreateFood { - id: number; -} - const routes = { food: `${prefix}/foods`, foodsFood: (tag: string) => `${prefix}/foods/${tag}`, }; -export class FoodAPI extends BaseCRUDAPI { +export class FoodAPI extends BaseCRUDAPI { baseRoute: string = routes.food; itemRoute = routes.foodsFood; } diff --git a/frontend/api/class-interfaces/recipes/recipe-share.ts b/frontend/api/class-interfaces/recipes/recipe-share.ts index be00e53443c1..9ec0f166bf96 100644 --- a/frontend/api/class-interfaces/recipes/recipe-share.ts +++ b/frontend/api/class-interfaces/recipes/recipe-share.ts @@ -8,12 +8,12 @@ const routes = { }; export interface RecipeShareTokenCreate { - recipeId: number; + recipeId: string; expiresAt?: Date; } export interface RecipeShareToken { - recipeId: number; + recipeId: string; id: string; groupId: number; expiresAt: string; diff --git a/frontend/api/class-interfaces/recipes/types.ts b/frontend/api/class-interfaces/recipes/types.ts index a70997192501..77342e22f834 100644 --- a/frontend/api/class-interfaces/recipes/types.ts +++ b/frontend/api/class-interfaces/recipes/types.ts @@ -1,6 +1,5 @@ -import { Category } from "../categories"; -import { Tag } from "../tags"; import { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit } from "~/types/api-types/recipe"; +import { RecipeCategory, RecipeTag } from "~/types/api-types/user"; export type Parser = "nlp" | "brute"; @@ -30,8 +29,8 @@ export interface ParsedIngredient { export interface BulkCreateRecipe { url: string; - categories: Category[]; - tags: Tag[]; + categories: RecipeCategory[]; + tags: RecipeTag[]; } export interface BulkCreatePayload { @@ -50,7 +49,7 @@ export interface CreateAsset { } export interface RecipeCommentCreate { - recipeId: number; + recipeId: string; text: string; } diff --git a/frontend/api/class-interfaces/tags.ts b/frontend/api/class-interfaces/tags.ts deleted file mode 100644 index 17dd1fa6ccaf..000000000000 --- a/frontend/api/class-interfaces/tags.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BaseCRUDAPI } from "../_base"; -import { Recipe } from "~/types/api-types/admin"; - -const prefix = "/api"; - -export interface Tag { - name: string; - id: number; - slug: string; - recipes?: Recipe[]; -} - -export interface CreateTag { - name: string; -} - -const routes = { - tags: `${prefix}/tags`, - tagsEmpty: `${prefix}/tags/empty`, - - tagsTag: (tag: string) => `${prefix}/tags/${tag}`, -}; - -export class TagsAPI extends BaseCRUDAPI { - baseRoute: string = routes.tags; - itemRoute = routes.tagsTag; - - /** Returns a list of categories that do not contain any recipes - */ - async getEmptyCategories() { - return await this.requests.get(routes.tagsEmpty); - } - - /** Returns a list of recipes associated with the provided category. - */ - async getAllRecipesByCategory(category: string) { - return await this.requests.get(routes.tagsTag(category)); - } - - /** Removes a recipe category from the database. Deleting a - * category does not impact a recipe. The category will be removed - * from any recipes that contain it - */ - async deleteRecipeCategory(category: string) { - return await this.requests.delete(routes.tagsTag(category)); - } -} diff --git a/frontend/api/config.ts b/frontend/api/config.ts new file mode 100644 index 000000000000..23b1b7b80cb1 --- /dev/null +++ b/frontend/api/config.ts @@ -0,0 +1,5 @@ +const PREFIX = "/api"; + +export const config = { + PREFIX, +}; diff --git a/frontend/api/index.ts b/frontend/api/index.ts index 8dfc7ad8e511..7f9e19018280 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -4,8 +4,8 @@ import { GroupAPI } from "./class-interfaces/groups"; import { EventsAPI } from "./class-interfaces/events"; import { BackupAPI } from "./class-interfaces/backups"; import { UploadFile } from "./class-interfaces/upload"; -import { CategoriesAPI } from "./class-interfaces/categories"; -import { TagsAPI } from "./class-interfaces/tags"; +import { CategoriesAPI } from "./class-interfaces/organizer-categories"; +import { TagsAPI } from "./class-interfaces/organizer-tags"; import { UtilsAPI } from "./class-interfaces/utils"; import { FoodAPI } from "./class-interfaces/recipe-foods"; import { UnitAPI } from "./class-interfaces/recipe-units"; @@ -17,7 +17,7 @@ import { EmailAPI } from "./class-interfaces/email"; import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions"; import { GroupServerTaskAPI } from "./class-interfaces/group-tasks"; import { AdminAPI } from "./admin-api"; -import { ToolsApi } from "./class-interfaces/tools"; +import { ToolsApi } from "./class-interfaces/organizer-tools"; import { GroupMigrationApi } from "./class-interfaces/group-migrations"; import { GroupReportsApi } from "./class-interfaces/group-reports"; import { ShoppingApi } from "./class-interfaces/group-shopping-lists"; diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index 4ef8d50826aa..d5844e7c72d0 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -24,15 +24,7 @@ @@ -40,14 +32,7 @@ @@ -93,7 +78,7 @@ diff --git a/frontend/components/Domain/Recipe/RecipeAssets.vue b/frontend/components/Domain/Recipe/RecipeAssets.vue index 585504fc7727..7118d47b61e2 100644 --- a/frontend/components/Domain/Recipe/RecipeAssets.vue +++ b/frontend/components/Domain/Recipe/RecipeAssets.vue @@ -86,6 +86,10 @@ export default defineComponent({ type: String, required: true, }, + recipeId: { + type: String, + required: true, + }, value: { type: Array, required: true, @@ -143,7 +147,7 @@ export default defineComponent({ const { recipeAssetPath } = useStaticRoutes(); function assetURL(assetName: string) { - return recipeAssetPath(props.slug, assetName); + return recipeAssetPath(props.recipeId, assetName); } function assetEmbed(name: string) { diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index 474b9fbfb68d..a7d7997f0fde 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -8,7 +8,14 @@ :min-height="imageHeight + 75" @click="$emit('click')" > - +
@@ -95,7 +102,7 @@ export default defineComponent({ }, recipeId: { required: true, - type: Number, + type: String, }, imageHeight: { type: Number, diff --git a/frontend/components/Domain/Recipe/RecipeCardImage.vue b/frontend/components/Domain/Recipe/RecipeCardImage.vue index 99ec8fa35299..2b405f55e0be 100644 --- a/frontend/components/Domain/Recipe/RecipeCardImage.vue +++ b/frontend/components/Domain/Recipe/RecipeCardImage.vue @@ -2,7 +2,7 @@ diff --git a/frontend/components/Domain/Recipe/RecipeCategoryTagSelector.vue b/frontend/components/Domain/Recipe/RecipeCategoryTagSelector.vue index 6b0ac13b1fe8..40f2e9e03ce7 100644 --- a/frontend/components/Domain/Recipe/RecipeCategoryTagSelector.vue +++ b/frontend/components/Domain/Recipe/RecipeCategoryTagSelector.vue @@ -46,8 +46,7 @@ import { computed, defineComponent, onMounted, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api"; import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue"; import { useTags, useCategories } from "~/composables/recipes"; -import { Category } from "~/api/class-interfaces/categories"; -import { Tag } from "~/api/class-interfaces/tags"; +import { RecipeCategory, RecipeTag } from "~/types/api-types/user"; const MOUNTED_EVENT = "mounted"; @@ -57,7 +56,7 @@ export default defineComponent({ }, props: { value: { - type: Array as () => (Category | Tag | string)[], + type: Array as () => (RecipeTag | RecipeCategory | string)[], required: true, }, solo: { @@ -103,9 +102,12 @@ export default defineComponent({ const state = reactive({ selected: props.value, }); - watch(() => props.value, (val) => { - state.selected = val; - }); + watch( + () => props.value, + (val) => { + state.selected = val; + } + ); const { i18n } = useContext(); const inputLabel = computed(() => { @@ -114,14 +116,14 @@ export default defineComponent({ }); const activeItems = computed(() => { - let itemObjects: Tag[] | Category[] | null; + let itemObjects: RecipeTag[] | RecipeCategory[] | null; if (props.tagSelector) itemObjects = allTags.value; else { itemObjects = allCategories.value; } if (props.returnObject) return itemObjects; else { - return itemObjects?.map((x: Tag | Category) => x.name); + return itemObjects?.map((x: RecipeTag | RecipeCategory) => x.name); } }); @@ -145,7 +147,7 @@ export default defineComponent({ state.selected.splice(index, 1); } - function pushToItem(createdItem: Tag | Category) { + function pushToItem(createdItem: RecipeTag | RecipeCategory) { // TODO: Remove excessive get calls getAllCategories(); getAllTags(); @@ -164,4 +166,3 @@ export default defineComponent({ }, }); - diff --git a/frontend/components/Domain/Recipe/RecipeComments.vue b/frontend/components/Domain/Recipe/RecipeComments.vue index 58711ccccb5d..56a13e0c5d4f 100644 --- a/frontend/components/Domain/Recipe/RecipeComments.vue +++ b/frontend/components/Domain/Recipe/RecipeComments.vue @@ -69,7 +69,7 @@ export default defineComponent({ required: true, }, recipeId: { - type: Number, + type: String, required: true, }, }, @@ -114,4 +114,4 @@ export default defineComponent({ return { api, comments, ...toRefs(state), submitComment, deleteComment }; }, }); - \ No newline at end of file + diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index abc28c851d16..4509479aa719 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -168,7 +168,7 @@ export default defineComponent({ }, recipeId: { required: true, - type: Number, + type: String, }, }, setup(props, context) { diff --git a/frontend/components/Domain/Recipe/RecipeDialogShare.vue b/frontend/components/Domain/Recipe/RecipeDialogShare.vue index 8b979b5cc19e..f74fb8a571d7 100644 --- a/frontend/components/Domain/Recipe/RecipeDialogShare.vue +++ b/frontend/components/Domain/Recipe/RecipeDialogShare.vue @@ -70,7 +70,7 @@ export default defineComponent({ default: false, }, recipeId: { - type: Number, + type: String, required: true, }, name: { diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue index 5eb7806cd7af..14b2fa422abf 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue @@ -158,14 +158,15 @@ export default defineComponent({ } function handleUnitEnter() { - if (value.unit === undefined || !value.unit.name.includes(unitSearch.value)) { + if (value.unit === undefined || value.unit === null || !value.unit.name.includes(unitSearch.value)) { console.log("Creating"); createAssignUnit(); } } function handleFoodEnter() { - if (value.food === undefined || !value.food.name.includes(foodSearch.value)) { + console.log(value.food); + if (value.food === undefined || value.food === null || !value.food.name.includes(foodSearch.value)) { console.log("Creating"); createAssignFood(); } @@ -190,7 +191,7 @@ export default defineComponent({ }); - - diff --git a/frontend/types/api-types/admin.ts b/frontend/types/api-types/admin.ts index e00d11d26a8d..bba69b0d3a5b 100644 --- a/frontend/types/api-types/admin.ts +++ b/frontend/types/api-types/admin.ts @@ -72,12 +72,12 @@ export interface CustomPageBase { } export interface RecipeCategoryResponse { name: string; - id: number; + id: string; slug: string; - recipes?: Recipe[]; + recipes?: RecipeSummary[]; } -export interface Recipe { - id?: number; +export interface RecipeSummary { + id?: string; userId?: string; groupId?: string; name?: string; @@ -97,28 +97,19 @@ export interface Recipe { recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; - recipeInstructions?: RecipeStep[]; - nutrition?: Nutrition; - settings?: RecipeSettings; - assets?: RecipeAsset[]; - notes?: RecipeNote[]; - extras?: { - [k: string]: unknown; - }; - comments?: RecipeCommentOut[]; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -137,7 +128,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface CreateIngredientUnit { name: string; @@ -149,7 +140,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { @@ -163,59 +154,6 @@ export interface CreateIngredientFood { description?: string; labelId?: string; } -export interface RecipeStep { - id?: string; - title?: string; - text: string; - ingredientReferences?: IngredientReferences[]; -} -/** - * A list of ingredient references. - */ -export interface IngredientReferences { - referenceId?: string; -} -export interface Nutrition { - calories?: string; - fatContent?: string; - proteinContent?: string; - carbohydrateContent?: string; - fiberContent?: string; - sodiumContent?: string; - sugarContent?: string; -} -export interface RecipeSettings { - public?: boolean; - showNutrition?: boolean; - showAssets?: boolean; - landscapeView?: boolean; - disableComments?: boolean; - disableAmount?: boolean; - locked?: boolean; -} -export interface RecipeAsset { - name: string; - icon: string; - fileName?: string; -} -export interface RecipeNote { - title: string; - text: string; -} -export interface RecipeCommentOut { - recipeId: number; - text: string; - id: string; - createdAt: string; - updateAt: string; - userId: string; - user: UserBase; -} -export interface UserBase { - id: number; - username?: string; - admin: boolean; -} export interface CustomPageImport { name: string; status: boolean; diff --git a/frontend/types/api-types/cookbook.ts b/frontend/types/api-types/cookbook.ts index bb05c2b482b2..0e2493d5bb77 100644 --- a/frontend/types/api-types/cookbook.ts +++ b/frontend/types/api-types/cookbook.ts @@ -7,7 +7,7 @@ export interface CategoryBase { name: string; - id: number; + id: string; slug: string; } export interface CreateCookBook { @@ -28,12 +28,12 @@ export interface ReadCookBook { } export interface RecipeCategoryResponse { name: string; - id: number; + id: string; slug: string; - recipes?: Recipe[]; + recipes?: RecipeSummary[]; } -export interface Recipe { - id?: number; +export interface RecipeSummary { + id?: string; userId?: string; groupId?: string; name?: string; @@ -53,28 +53,19 @@ export interface Recipe { recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; - recipeInstructions?: RecipeStep[]; - nutrition?: Nutrition; - settings?: RecipeSettings; - assets?: RecipeAsset[]; - notes?: RecipeNote[]; - extras?: { - [k: string]: unknown; - }; - comments?: RecipeCommentOut[]; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -93,7 +84,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface CreateIngredientUnit { name: string; @@ -105,7 +96,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { @@ -119,59 +110,6 @@ export interface CreateIngredientFood { description?: string; labelId?: string; } -export interface RecipeStep { - id?: string; - title?: string; - text: string; - ingredientReferences?: IngredientReferences[]; -} -/** - * A list of ingredient references. - */ -export interface IngredientReferences { - referenceId?: string; -} -export interface Nutrition { - calories?: string; - fatContent?: string; - proteinContent?: string; - carbohydrateContent?: string; - fiberContent?: string; - sodiumContent?: string; - sugarContent?: string; -} -export interface RecipeSettings { - public?: boolean; - showNutrition?: boolean; - showAssets?: boolean; - landscapeView?: boolean; - disableComments?: boolean; - disableAmount?: boolean; - locked?: boolean; -} -export interface RecipeAsset { - name: string; - icon: string; - fileName?: string; -} -export interface RecipeNote { - title: string; - text: string; -} -export interface RecipeCommentOut { - recipeId: number; - text: string; - id: string; - createdAt: string; - updateAt: string; - userId: string; - user: UserBase; -} -export interface UserBase { - id: number; - username?: string; - admin: boolean; -} export interface RecipeCookBook { name: string; description?: string; diff --git a/frontend/types/api-types/group.ts b/frontend/types/api-types/group.ts index a2cf12e6b621..26a3a512021e 100644 --- a/frontend/types/api-types/group.ts +++ b/frontend/types/api-types/group.ts @@ -180,7 +180,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { @@ -194,7 +194,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface ReadGroupPreferences { privateGroup?: boolean; @@ -222,7 +222,7 @@ export interface ReadWebhook { id: number; } export interface RecipeSummary { - id?: number; + id?: string; userId?: string; groupId?: string; name?: string; @@ -244,17 +244,17 @@ export interface RecipeSummary { dateUpdated?: string; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -307,15 +307,15 @@ export interface ShoppingListItemCreate { isFood?: boolean; note?: string; quantity?: number; - unitId?: number; + unitId?: string; unit?: IngredientUnit; - foodId?: number; + foodId?: string; food?: IngredientFood; labelId?: string; recipeReferences?: ShoppingListItemRecipeRef[]; } export interface ShoppingListItemRecipeRef { - recipeId: number; + recipeId: string; recipeQuantity: number; } export interface ShoppingListItemOut { @@ -325,9 +325,9 @@ export interface ShoppingListItemOut { isFood?: boolean; note?: string; quantity?: number; - unitId?: number; + unitId?: string; unit?: IngredientUnit; - foodId?: number; + foodId?: string; food?: IngredientFood; labelId?: string; recipeReferences?: ShoppingListItemRecipeRefOut[]; @@ -335,7 +335,7 @@ export interface ShoppingListItemOut { label?: MultiPurposeLabelSummary; } export interface ShoppingListItemRecipeRefOut { - recipeId: number; + recipeId: string; recipeQuantity: number; id: string; shoppingListItemId: string; @@ -347,9 +347,9 @@ export interface ShoppingListItemUpdate { isFood?: boolean; note?: string; quantity?: number; - unitId?: number; + unitId?: string; unit?: IngredientUnit; - foodId?: number; + foodId?: string; food?: IngredientFood; labelId?: string; recipeReferences?: ShoppingListItemRecipeRef[]; @@ -365,7 +365,7 @@ export interface ShoppingListOut { export interface ShoppingListRecipeRefOut { id: string; shoppingListId: string; - recipeId: number; + recipeId: string; recipeQuantity: number; recipe: RecipeSummary; } diff --git a/frontend/types/api-types/meal-plan.ts b/frontend/types/api-types/meal-plan.ts index 6537707cf883..3949914676e6 100644 --- a/frontend/types/api-types/meal-plan.ts +++ b/frontend/types/api-types/meal-plan.ts @@ -10,7 +10,7 @@ export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "fr export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "unset"; export interface Category { - id: number; + id: string; name: string; slug: string; } @@ -23,7 +23,7 @@ export interface CreatePlanEntry { entryType?: PlanEntryType & string; title?: string; text?: string; - recipeId?: number; + recipeId?: string; } export interface ListItem { title?: string; @@ -66,7 +66,7 @@ export interface PlanRulesCreate { tags?: Tag[]; } export interface Tag { - id: number; + id: string; name: string; slug: string; } @@ -90,13 +90,13 @@ export interface ReadPlanEntry { entryType?: PlanEntryType & string; title?: string; text?: string; - recipeId?: number; + recipeId?: string; id: number; groupId: string; recipe?: RecipeSummary; } export interface RecipeSummary { - id?: number; + id?: string; userId?: string; groupId?: string; name?: string; @@ -118,17 +118,17 @@ export interface RecipeSummary { dateUpdated?: string; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -147,7 +147,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface CreateIngredientUnit { name: string; @@ -159,7 +159,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { @@ -178,7 +178,7 @@ export interface SavePlanEntry { entryType?: PlanEntryType & string; title?: string; text?: string; - recipeId?: number; + recipeId?: string; groupId: string; } export interface ShoppingListIn { @@ -197,7 +197,7 @@ export interface UpdatePlanEntry { entryType?: PlanEntryType & string; title?: string; text?: string; - recipeId?: number; + recipeId?: string; id: number; groupId: string; } diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index f384a78e7ec8..d2981e291d2e 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -14,7 +14,7 @@ export interface AssignCategories { } export interface CategoryBase { name: string; - id: number; + id: string; slug: string; } export interface AssignTags { @@ -23,7 +23,7 @@ export interface AssignTags { } export interface TagBase { name: string; - id: number; + id: string; slug: string; } export interface BulkActionError { @@ -38,6 +38,15 @@ export interface BulkActionsResponse { export interface CategoryIn { name: string; } +export interface CategoryOut { + name: string; + id: string; + slug: string; +} +export interface CategorySave { + name: string; + groupId: string; +} export interface CreateIngredientFood { name: string; description?: string; @@ -58,12 +67,12 @@ export interface CreateRecipeBulk { tags?: RecipeTag[]; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } @@ -95,7 +104,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { @@ -119,7 +128,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface IngredientsRequest { parser?: RegisteredParser & string; @@ -149,7 +158,7 @@ export interface RecipeIngredient { referenceId?: string; } export interface Recipe { - id?: number; + id?: string; userId?: string; groupId?: string; name?: string; @@ -180,7 +189,7 @@ export interface Recipe { comments?: RecipeCommentOut[]; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -210,7 +219,7 @@ export interface RecipeNote { text: string; } export interface RecipeCommentOut { - recipeId: number; + recipeId: string; text: string; id: string; createdAt: string; @@ -225,52 +234,12 @@ export interface UserBase { } export interface RecipeCategoryResponse { name: string; - id: number; - slug: string; - recipes?: Recipe[]; -} -export interface RecipeCommentCreate { - recipeId: number; - text: string; -} -export interface RecipeCommentSave { - recipeId: number; - text: string; - userId: string; -} -export interface RecipeCommentUpdate { - id: string; - text: string; -} -export interface RecipeShareToken { - recipeId: number; - expiresAt?: string; - groupId: string; - id: string; - createdAt: string; - recipe: Recipe; -} -export interface RecipeShareTokenCreate { - recipeId: number; - expiresAt?: string; -} -export interface RecipeShareTokenSave { - recipeId: number; - expiresAt?: string; - groupId: string; -} -export interface RecipeShareTokenSummary { - recipeId: number; - expiresAt?: string; - groupId: string; - id: string; - createdAt: string; -} -export interface RecipeSlug { + id: string; slug: string; + recipes?: RecipeSummary[]; } export interface RecipeSummary { - id?: number; + id?: string; userId?: string; groupId?: string; name?: string; @@ -291,16 +260,56 @@ export interface RecipeSummary { dateAdded?: string; dateUpdated?: string; } +export interface RecipeCommentCreate { + recipeId: string; + text: string; +} +export interface RecipeCommentSave { + recipeId: string; + text: string; + userId: string; +} +export interface RecipeCommentUpdate { + id: string; + text: string; +} +export interface RecipeShareToken { + recipeId: string; + expiresAt?: string; + groupId: string; + id: string; + createdAt: string; + recipe: Recipe; +} +export interface RecipeShareTokenCreate { + recipeId: string; + expiresAt?: string; +} +export interface RecipeShareTokenSave { + recipeId: string; + expiresAt?: string; + groupId: string; +} +export interface RecipeShareTokenSummary { + recipeId: string; + expiresAt?: string; + groupId: string; + id: string; + createdAt: string; +} +export interface RecipeSlug { + slug: string; +} export interface RecipeTagResponse { name: string; - id: number; + id: string; slug: string; - recipes?: Recipe[]; + recipes?: RecipeSummary[]; } export interface RecipeTool1 { name: string; onHand?: boolean; - id: number; + id: string; slug: string; } export interface RecipeToolCreate { @@ -310,14 +319,42 @@ export interface RecipeToolCreate { export interface RecipeToolResponse { name: string; onHand?: boolean; - id: number; + id: string; slug: string; recipes?: Recipe[]; } +export interface RecipeToolSave { + name: string; + onHand?: boolean; + groupId: string; +} +export interface SaveIngredientFood { + name: string; + description?: string; + labelId?: string; + groupId: string; +} +export interface SaveIngredientUnit { + name: string; + description?: string; + fraction?: boolean; + abbreviation?: string; + groupId: string; +} export interface SlugResponse {} export interface TagIn { name: string; } +export interface TagOut { + name: string; + groupId: string; + id: string; + slug: string; +} +export interface TagSave { + name: string; + groupId: string; +} export interface UnitFoodBase { name: string; description?: string; diff --git a/frontend/types/api-types/user.ts b/frontend/types/api-types/user.ts index d02d7f191067..1ede27805f74 100644 --- a/frontend/types/api-types/user.ts +++ b/frontend/types/api-types/user.ts @@ -7,7 +7,7 @@ export interface CategoryBase { name: string; - id: number; + id: string; slug: string; } export interface ChangePassword { @@ -109,7 +109,7 @@ export interface PrivatePasswordResetToken { user: PrivateUser; } export interface RecipeSummary { - id?: number; + id?: string; userId?: string; groupId?: string; name?: string; @@ -131,17 +131,17 @@ export interface RecipeSummary { dateUpdated?: string; } export interface RecipeCategory { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTag { - id?: number; + id: string; name: string; slug: string; } export interface RecipeTool { - id?: number; + id: string; name: string; slug: string; onHand?: boolean; @@ -160,7 +160,7 @@ export interface IngredientUnit { description?: string; fraction?: boolean; abbreviation?: string; - id: number; + id: string; } export interface CreateIngredientUnit { name: string; @@ -172,7 +172,7 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - id: number; + id: string; label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { diff --git a/makefile b/makefile index 1580042eecfd..cd09f2605c0f 100644 --- a/makefile +++ b/makefile @@ -82,7 +82,6 @@ setup-model: ## 🤖 Get the latest NLP CRF++ Model backend: ## 🎬 Start Mealie Backend Development Server poetry run python mealie/db/init_db.py && \ - poetry run python mealie/services/image/minify.py && \ poetry run python mealie/app.py .PHONY: frontend diff --git a/mealie/core/root_logger.py b/mealie/core/root_logger.py index 277572d7f10a..5d2adea0909b 100644 --- a/mealie/core/root_logger.py +++ b/mealie/core/root_logger.py @@ -9,8 +9,6 @@ DATA_DIR = determine_data_dir() from .config import get_app_settings -settings = get_app_settings() - LOGGER_FILE = DATA_DIR.joinpath("mealie.log") DATE_FORMAT = "%d-%b-%y %H:%M:%S" LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s" @@ -27,6 +25,8 @@ class LoggerConfig: @lru_cache def get_logger_config(): + settings = get_app_settings() + if not settings.PRODUCTION: from rich.logging import RichHandler @@ -69,7 +69,6 @@ def logger_init() -> logging.Logger: root_logger = logger_init() -root_logger.info("Testing Root Logger") def get_logger(module=None) -> logging.Logger: diff --git a/mealie/db/db_setup.py b/mealie/db/db_setup.py index 2339aaa13fff..448ea115e421 100644 --- a/mealie/db/db_setup.py +++ b/mealie/db/db_setup.py @@ -12,7 +12,12 @@ def sql_global_init(db_url: str): if "sqlite" in db_url: connect_args["check_same_thread"] = False - engine = sa.create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True) + engine = sa.create_engine( + db_url, + echo=False, + connect_args=connect_args, + pool_pre_ping=True, + ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index d2fc83975ad6..2cf9669933f4 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -58,6 +58,9 @@ class Group(SqlAlchemyBase, BaseMixins): # Owned Models ingredient_units = orm.relationship("IngredientUnitModel", **common_args) ingredient_foods = orm.relationship("IngredientFoodModel", **common_args) + tools = orm.relationship("Tool", **common_args) + tags = orm.relationship("Tag", **common_args) + categories = orm.relationship("Category", **common_args) class Config: exclude = { diff --git a/mealie/db/models/group/mealplan.py b/mealie/db/models/group/mealplan.py index 4e49b5ba9b52..05c0aec83f03 100644 --- a/mealie/db/models/group/mealplan.py +++ b/mealie/db/models/group/mealplan.py @@ -1,5 +1,4 @@ from sqlalchemy import Column, Date, ForeignKey, String, orm -from sqlalchemy.sql.sqltypes import Integer from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags @@ -36,7 +35,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins): group_id = Column(GUID, ForeignKey("groups.id"), index=True) group = orm.relationship("Group", back_populates="mealplans") - recipe_id = Column(Integer, ForeignKey("recipes.id")) + recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe = orm.relationship("RecipeModel", back_populates="meal_entries", uselist=False) @auto_init() diff --git a/mealie/db/models/group/preferences.py b/mealie/db/models/group/preferences.py index e7e16f893dc7..4d8009457aaa 100644 --- a/mealie/db/models/group/preferences.py +++ b/mealie/db/models/group/preferences.py @@ -9,7 +9,7 @@ from .._model_utils import auto_init class GroupPreferencesModel(SqlAlchemyBase, BaseMixins): __tablename__ = "group_preferences" - group_id = sa.Column(GUID, sa.ForeignKey("groups.id")) + group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="preferences") private_group: bool = sa.Column(sa.Boolean, default=True) diff --git a/mealie/db/models/group/report.py b/mealie/db/models/group/report.py index 2805dffc5de6..7f06bdf6f0d9 100644 --- a/mealie/db/models/group/report.py +++ b/mealie/db/models/group/report.py @@ -38,7 +38,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins): entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan") # Relationships - group_id = Column(GUID, ForeignKey("groups.id")) + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="group_reports", single_parent=True) class Config: diff --git a/mealie/db/models/group/shopping_list.py b/mealie/db/models/group/shopping_list.py index b226c065ca0a..5263e7c8db7a 100644 --- a/mealie/db/models/group/shopping_list.py +++ b/mealie/db/models/group/shopping_list.py @@ -13,7 +13,7 @@ class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): id = Column(GUID, primary_key=True, default=GUID.generate) shopping_list_item_id = Column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True) - recipe_id = Column(Integer, ForeignKey("recipes.id")) + recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs") recipe_quantity = Column(Float, nullable=False) @@ -40,10 +40,10 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins): is_food = Column(Boolean, default=False) # Scaling Items - unit_id = Column(Integer, ForeignKey("ingredient_units.id")) + unit_id = Column(GUID, ForeignKey("ingredient_units.id")) unit = orm.relationship(IngredientUnitModel, uselist=False) - food_id = Column(Integer, ForeignKey("ingredient_foods.id")) + food_id = Column(GUID, ForeignKey("ingredient_foods.id")) food = orm.relationship(IngredientFoodModel, uselist=False) label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) @@ -66,7 +66,7 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) - recipe_id = Column(Integer, ForeignKey("recipes.id")) + recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs") recipe_quantity = Column(Float, nullable=False) @@ -83,7 +83,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins): __tablename__ = "shopping_lists" id = Column(GUID, primary_key=True, default=GUID.generate) - group_id = Column(GUID, ForeignKey("groups.id")) + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="shopping_lists") name = Column(String) diff --git a/mealie/db/models/labels.py b/mealie/db/models/labels.py index a6b791a3a4af..dae6ac4479a1 100644 --- a/mealie/db/models/labels.py +++ b/mealie/db/models/labels.py @@ -12,7 +12,7 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): name = Column(String(255), nullable=False) color = Column(String(10), nullable=False, default="") - group_id = Column(GUID, ForeignKey("groups.id")) + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="labels") shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label") diff --git a/mealie/db/models/recipe/api_extras.py b/mealie/db/models/recipe/api_extras.py index a5da029926f1..496add1af035 100644 --- a/mealie/db/models/recipe/api_extras.py +++ b/mealie/db/models/recipe/api_extras.py @@ -1,12 +1,13 @@ import sqlalchemy as sa from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.guid import GUID class ApiExtras(SqlAlchemyBase): __tablename__ = "api_extras" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) + recipee_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) key_name = sa.Column(sa.String) value = sa.Column(sa.String) diff --git a/mealie/db/models/recipe/assets.py b/mealie/db/models/recipe/assets.py index 8f18a2d4dc45..530976e82edd 100644 --- a/mealie/db/models/recipe/assets.py +++ b/mealie/db/models/recipe/assets.py @@ -1,12 +1,13 @@ import sqlalchemy as sa from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.guid import GUID class RecipeAsset(SqlAlchemyBase): __tablename__ = "recipe_assets" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) + recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) name = sa.Column(sa.String) icon = sa.Column(sa.String) file_name = sa.Column(sa.String) diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index 84dec5b90e30..1711bf6fa8b5 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -15,47 +15,51 @@ group2categories = sa.Table( "group2categories", SqlAlchemyBase.metadata, sa.Column("group_id", GUID, sa.ForeignKey("groups.id")), - sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), + sa.Column("category_id", GUID, sa.ForeignKey("categories.id")), ) plan_rules_to_categories = sa.Table( "plan_rules_to_categories", SqlAlchemyBase.metadata, sa.Column("group_plan_rule_id", GUID, sa.ForeignKey("group_meal_plan_rules.id")), - sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), + sa.Column("category_id", GUID, sa.ForeignKey("categories.id")), ) -recipes2categories = sa.Table( - "recipes2categories", +recipes_to_categories = sa.Table( + "recipes_to_categories", SqlAlchemyBase.metadata, - sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), - sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), + sa.Column("recipe_id", GUID, sa.ForeignKey("recipes.id")), + sa.Column("category_id", GUID, sa.ForeignKey("categories.id")), ) cookbooks_to_categories = sa.Table( "cookbooks_to_categories", SqlAlchemyBase.metadata, sa.Column("cookbook_id", sa.Integer, sa.ForeignKey("cookbooks.id")), - sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), + sa.Column("category_id", GUID, sa.ForeignKey("categories.id")), ) class Category(SqlAlchemyBase, BaseMixins): __tablename__ = "categories" - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.String, index=True, nullable=False) - slug = sa.Column(sa.String, index=True, unique=True, nullable=False) - recipes = orm.relationship("RecipeModel", secondary=recipes2categories, back_populates="recipe_category") + __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),) - class Config: - get_attr = "slug" + # ID Relationships + group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) + group = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id]) + + id = sa.Column(GUID, primary_key=True, default=GUID.generate) + name = sa.Column(sa.String, index=True, nullable=False) + slug = sa.Column(sa.String, index=True, nullable=False) + recipes = orm.relationship("RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category") @validates("name") def validate_name(self, key, name): assert name != "" return name - def __init__(self, name, **_) -> None: + def __init__(self, name, group_id, **_) -> None: + self.group_id = group_id self.name = name.strip() self.slug = slugify(name) diff --git a/mealie/db/models/recipe/comment.py b/mealie/db/models/recipe/comment.py index 7c84aa4f0eb1..1a8ca7541f63 100644 --- a/mealie/db/models/recipe/comment.py +++ b/mealie/db/models/recipe/comment.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, Integer, String, orm +from sqlalchemy import Column, ForeignKey, String, orm from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_utils import auto_init @@ -11,7 +11,7 @@ class RecipeComment(SqlAlchemyBase, BaseMixins): text = Column(String) # Recipe Link - recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False) + recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False) recipe = orm.relationship("RecipeModel", back_populates="comments") # User Link diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index cf3cc7dacea0..084832eda21c 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -9,12 +9,12 @@ from .._model_utils.guid import GUID class IngredientUnitModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_units" + id = Column(GUID, primary_key=True, default=GUID.generate) # ID Relationships group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id]) - id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) abbreviation = Column(String) @@ -28,12 +28,12 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientFoodModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_foods" + id = Column(GUID, primary_key=True, default=GUID.generate) # ID Relationships group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) - id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) ingredients = orm.relationship("RecipeIngredient", back_populates="food") @@ -50,16 +50,16 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins): __tablename__ = "recipes_ingredients" id = Column(Integer, primary_key=True) position = Column(Integer) - parent_id = Column(Integer, ForeignKey("recipes.id")) + recipe_id = Column(GUID, ForeignKey("recipes.id")) title = Column(String) # Section Header - Shows if Present note = Column(String) # Force Show Text - Overrides Concat # Scaling Items - unit_id = Column(Integer, ForeignKey("ingredient_units.id")) + unit_id = Column(GUID, ForeignKey("ingredient_units.id")) unit = orm.relationship(IngredientUnitModel, uselist=False) - food_id = Column(Integer, ForeignKey("ingredient_foods.id")) + food_id = Column(GUID, ForeignKey("ingredient_foods.id")) food = orm.relationship(IngredientFoodModel, uselist=False) quantity = Column(Integer) diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index ae95304d0123..d9ef7d2d8218 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -18,7 +18,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins): class RecipeInstruction(SqlAlchemyBase): __tablename__ = "recipe_instructions" id = Column(GUID, primary_key=True, default=GUID.generate) - parent_id = Column(Integer, ForeignKey("recipes.id")) + recipe_id = Column(GUID, ForeignKey("recipes.id")) position = Column(Integer) type = Column(String, default="") title = Column(String) diff --git a/mealie/db/models/recipe/note.py b/mealie/db/models/recipe/note.py index 3e2a5ce5bc4b..c50458ae5c2f 100644 --- a/mealie/db/models/recipe/note.py +++ b/mealie/db/models/recipe/note.py @@ -1,12 +1,13 @@ import sqlalchemy as sa from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.guid import GUID class Note(SqlAlchemyBase): __tablename__ = "notes" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) + recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) title = sa.Column(sa.String) text = sa.Column(sa.String) diff --git a/mealie/db/models/recipe/nutrition.py b/mealie/db/models/recipe/nutrition.py index cec6dcaba87b..28d8fa99d6fd 100644 --- a/mealie/db/models/recipe/nutrition.py +++ b/mealie/db/models/recipe/nutrition.py @@ -1,12 +1,13 @@ import sqlalchemy as sa from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.guid import GUID class Nutrition(SqlAlchemyBase): __tablename__ = "recipe_nutrition" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) + recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) calories = sa.Column(sa.String) fat_content = sa.Column(sa.String) fiber_content = sa.Column(sa.String) diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index a22707527ff8..6d114618341b 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -13,14 +13,14 @@ from .._model_utils import auto_init from ..users import users_to_favorites from .api_extras import ApiExtras from .assets import RecipeAsset -from .category import recipes2categories +from .category import recipes_to_categories from .ingredient import RecipeIngredient from .instruction import RecipeInstruction from .note import Note from .nutrition import Nutrition from .settings import RecipeSettings from .shared import RecipeShareTokenModel -from .tag import Tag, recipes2tags +from .tag import Tag, recipes_to_tags from .tool import recipes_to_tools @@ -43,13 +43,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): __tablename__ = "recipes" __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),) + id = sa.Column(GUID, primary_key=True, default=GUID.generate) slug = sa.Column(sa.String, index=True) # ID Relationships - group_id = sa.Column(GUID, sa.ForeignKey("groups.id")) + group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) - user_id = sa.Column(GUID, sa.ForeignKey("users.id")) + user_id = sa.Column(GUID, sa.ForeignKey("users.id"), index=True) user = orm.relationship("User", uselist=False, foreign_keys=[user_id]) meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan") @@ -72,7 +73,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan") nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") - recipe_category = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes") + recipe_category = orm.relationship("Category", secondary=recipes_to_categories, back_populates="recipes") tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes") recipe_ingredient: list[RecipeIngredient] = orm.relationship( @@ -96,7 +97,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Mealie Specific settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan") - tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes") + tags: list[Tag] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan") rating = sa.Column(sa.Integer) org_url = sa.Column(sa.String) diff --git a/mealie/db/models/recipe/settings.py b/mealie/db/models/recipe/settings.py index a3954e061113..7faf685de4e9 100644 --- a/mealie/db/models/recipe/settings.py +++ b/mealie/db/models/recipe/settings.py @@ -1,12 +1,13 @@ import sqlalchemy as sa from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.guid import GUID class RecipeSettings(SqlAlchemyBase): __tablename__ = "recipe_settings" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) + recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) public = sa.Column(sa.Boolean) show_nutrition = sa.Column(sa.Boolean) show_assets = sa.Column(sa.Boolean) diff --git a/mealie/db/models/recipe/shared.py b/mealie/db/models/recipe/shared.py index 688ab61c4d67..7b8b205760d7 100644 --- a/mealie/db/models/recipe/shared.py +++ b/mealie/db/models/recipe/shared.py @@ -15,9 +15,9 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins): __tablename__ = "recipe_share_tokens" id = sa.Column(GUID, primary_key=True, default=uuid4) - group_id = sa.Column(GUID, sa.ForeignKey("groups.id")) + group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) - recipe_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"), nullable=False) + recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), nullable=False) recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False) expires_at = sa.Column(sa.DateTime, nullable=False) diff --git a/mealie/db/models/recipe/tag.py b/mealie/db/models/recipe/tag.py index b94f84918894..f5e715afe57a 100644 --- a/mealie/db/models/recipe/tag.py +++ b/mealie/db/models/recipe/tag.py @@ -9,27 +9,33 @@ from mealie.db.models._model_utils import guid logger = root_logger.get_logger() -recipes2tags = sa.Table( - "recipes2tags", +recipes_to_tags = sa.Table( + "recipes_to_tags", SqlAlchemyBase.metadata, - sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), - sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")), + sa.Column("recipe_id", guid.GUID, sa.ForeignKey("recipes.id")), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")), ) plan_rules_to_tags = sa.Table( "plan_rules_to_tags", SqlAlchemyBase.metadata, sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id")), - sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")), ) class Tag(SqlAlchemyBase, BaseMixins): __tablename__ = "tags" - id = sa.Column(sa.Integer, primary_key=True) + __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),) + + # ID Relationships + group_id = sa.Column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) + group = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id]) + + id = sa.Column(guid.GUID, primary_key=True, default=guid.GUID.generate) name = sa.Column(sa.String, index=True, nullable=False) - slug = sa.Column(sa.String, index=True, unique=True, nullable=False) - recipes = orm.relationship("RecipeModel", secondary=recipes2tags, back_populates="tags") + slug = sa.Column(sa.String, index=True, nullable=False) + recipes = orm.relationship("RecipeModel", secondary=recipes_to_tags, back_populates="tags") class Config: get_attr = "slug" @@ -39,7 +45,8 @@ class Tag(SqlAlchemyBase, BaseMixins): assert name != "" return name - def __init__(self, name, **_) -> None: + def __init__(self, name, group_id, **_) -> None: + self.group_id = group_id self.name = name.strip() self.slug = slugify(self.name) diff --git a/mealie/db/models/recipe/tool.py b/mealie/db/models/recipe/tool.py index eb243b95b622..fda2b0297074 100644 --- a/mealie/db/models/recipe/tool.py +++ b/mealie/db/models/recipe/tool.py @@ -1,19 +1,27 @@ from slugify import slugify -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm +from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_utils import auto_init +from mealie.db.models._model_utils.guid import GUID recipes_to_tools = Table( "recipes_to_tools", SqlAlchemyBase.metadata, - Column("recipe_id", Integer, ForeignKey("recipes.id")), - Column("tool_id", Integer, ForeignKey("tools.id")), + Column("recipe_id", GUID, ForeignKey("recipes.id")), + Column("tool_id", GUID, ForeignKey("tools.id")), ) class Tool(SqlAlchemyBase, BaseMixins): __tablename__ = "tools" + id = Column(GUID, primary_key=True, default=GUID.generate) + __table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),) + + # ID Relationships + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) + group = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id]) + name = Column(String, index=True, unique=True, nullable=False) slug = Column(String, index=True, unique=True, nullable=False) on_hand = Column(Boolean, default=False) diff --git a/mealie/db/models/server/task.py b/mealie/db/models/server/task.py index c153492e6a9b..06c686ec5a94 100644 --- a/mealie/db/models/server/task.py +++ b/mealie/db/models/server/task.py @@ -13,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins): status = Column(String, nullable=False) log = Column(String, nullable=True) - group_id = Column(GUID, ForeignKey("groups.id")) + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="server_tasks") @auto_init() diff --git a/mealie/db/models/users/user_to_favorite.py b/mealie/db/models/users/user_to_favorite.py index f42bf068332c..1ab0c6f2b3ac 100644 --- a/mealie/db/models/users/user_to_favorite.py +++ b/mealie/db/models/users/user_to_favorite.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, Integer, Table +from sqlalchemy import Column, ForeignKey, Table from .._model_base import SqlAlchemyBase from .._model_utils import GUID @@ -7,5 +7,5 @@ users_to_favorites = Table( "users_to_favorites", SqlAlchemyBase.metadata, Column("user_id", GUID, ForeignKey("users.id")), - Column("recipe_id", Integer, ForeignKey("recipes.id")), + Column("recipe_id", GUID, ForeignKey("recipes.id")), ) diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 8f7dad818459..66494921813d 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm +from sqlalchemy import Boolean, Column, ForeignKey, String, orm from mealie.core.config import get_app_settings from mealie.db.models._model_utils.guid import GUID @@ -33,7 +33,7 @@ class User(SqlAlchemyBase, BaseMixins): admin = Column(Boolean, default=False) advanced = Column(Boolean, default=False) - group_id = Column(GUID, ForeignKey("groups.id")) + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group = orm.relationship("Group", back_populates="users") cache_key = Column(String, default="1234") @@ -53,7 +53,7 @@ class User(SqlAlchemyBase, BaseMixins): comments = orm.relationship("RecipeComment", **sp_args) password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args) - owned_recipes_id = Column(Integer, ForeignKey("recipes.id")) + owned_recipes_id = Column(GUID, ForeignKey("recipes.id")) owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]) favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by") diff --git a/mealie/services/image/__init__.py b/mealie/pkgs/__init__.py similarity index 100% rename from mealie/services/image/__init__.py rename to mealie/pkgs/__init__.py diff --git a/mealie/pkgs/cache/__init__.py b/mealie/pkgs/cache/__init__.py new file mode 100644 index 000000000000..dcebcc5f47ef --- /dev/null +++ b/mealie/pkgs/cache/__init__.py @@ -0,0 +1 @@ +from .cache_key import * diff --git a/mealie/utils/cache_key.py b/mealie/pkgs/cache/cache_key.py similarity index 85% rename from mealie/utils/cache_key.py rename to mealie/pkgs/cache/cache_key.py index d17ba2321ed0..16dbc8f4dcc4 100644 --- a/mealie/utils/cache_key.py +++ b/mealie/pkgs/cache/cache_key.py @@ -2,8 +2,7 @@ import random import string -def new_cache_key(length=4) -> str: +def new_key(length=4) -> str: """returns a 4 character string to be used as a cache key for frontend data""" options = string.ascii_letters + string.digits - return "".join(random.choices(options, k=length)) diff --git a/mealie/pkgs/dev/__init__.py b/mealie/pkgs/dev/__init__.py new file mode 100644 index 000000000000..16550066db62 --- /dev/null +++ b/mealie/pkgs/dev/__init__.py @@ -0,0 +1,7 @@ +""" +This package containers helpful development tools to be used for development and testing. It shouldn't be used for or imported +in production +""" + +from .lifespan_tracker import * +from .timer import * diff --git a/mealie/utils/lifespan_tracker.py b/mealie/pkgs/dev/lifespan_tracker.py similarity index 100% rename from mealie/utils/lifespan_tracker.py rename to mealie/pkgs/dev/lifespan_tracker.py diff --git a/mealie/pkgs/dev/timer.py b/mealie/pkgs/dev/timer.py new file mode 100644 index 000000000000..42b97994ed0d --- /dev/null +++ b/mealie/pkgs/dev/timer.py @@ -0,0 +1,12 @@ +import time + + +def timer(func): + def wrapper(*args, **kwargs): + start = time.time() + result = func(*args, **kwargs) + end = time.time() + print(f"{func.__name__} took {end - start} seconds") # noqa: T001 + return result + + return wrapper diff --git a/mealie/pkgs/img/__init__.py b/mealie/pkgs/img/__init__.py new file mode 100644 index 000000000000..997641dad965 --- /dev/null +++ b/mealie/pkgs/img/__init__.py @@ -0,0 +1,7 @@ +""" +The img package is a collection of utilities for working with images. While it offers some Mealie specific functionality, libraries +within the img package should not be tightly coupled to Mealie. +""" + + +from .minify import * diff --git a/mealie/pkgs/img/minify.py b/mealie/pkgs/img/minify.py new file mode 100644 index 000000000000..6484db57d4ef --- /dev/null +++ b/mealie/pkgs/img/minify.py @@ -0,0 +1,130 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from logging import Logger +from pathlib import Path + +from PIL import Image + +WEBP = ".webp" +FORMAT = "WEBP" + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + + +def get_format(image: Path) -> str: + img = Image.open(image) + return img.format + + +def sizeof_fmt(file_path: Path, decimal_places=2): + if not file_path.exists(): + return "(File Not Found)" + size = file_path.stat().st_size + for unit in ["B", "kB", "MB", "GB", "TB", "PB"]: + if size < 1024.0 or unit == "PiB": + break + size /= 1024.0 + return f"{size:.{decimal_places}f} {unit}" + + +@dataclass +class MinifierOptions: + original: bool = True + minature: bool = True + tiny: bool = True + + +class ABCMinifier(ABC): + def __init__(self, purge=False, opts: MinifierOptions = None, logger: Logger = None): + self._purge = purge + self._opts = opts or MinifierOptions() + self._logger = logger or Logger("Minifier") + + def get_image_sizes(self, org_img: Path, min_img: Path, tiny_img: Path): + self._logger.info( + f"{org_img.name} Minified: {sizeof_fmt(org_img)} -> {sizeof_fmt(min_img)} -> {sizeof_fmt(tiny_img)}" + ) + + @abstractmethod + def minify(self, image: Path, force=True): + ... + + def purge(self, image: Path): + if not self._purge: + return + + for file in image.parent.glob("*.*"): + if file.suffix != WEBP: + file.unlink() + + +class PillowMinifier(ABCMinifier): + @staticmethod + def to_webp(image_file: Path, dest: Path = None, quality: int = 100) -> Path: + """ + Converts an image to the webp format in-place. The original image is not + removed By default, the quality is set to 100. + """ + if image_file.suffix == WEBP: + return image_file + + img = Image.open(image_file) + + dest = dest or image_file.with_suffix(WEBP) + img.save(dest, FORMAT, quality=quality) + + return dest + + @staticmethod + def crop_center(pil_img: Image, crop_width=300, crop_height=300): + img_width, img_height = pil_img.size + return pil_img.crop( + ( + (img_width - crop_width) // 2, + (img_height - crop_height) // 2, + (img_width + crop_width) // 2, + (img_height + crop_height) // 2, + ) + ) + + def minify(self, image_file: Path, force=True): + if not image_file.exists(): + raise FileNotFoundError(f"{image_file.name} does not exist") + + org_dest = image_file.parent.joinpath("original.webp") + min_dest = image_file.parent.joinpath("min-original.webp") + tiny_dest = image_file.parent.joinpath("tiny-original.webp") + + if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists(): + self._logger.info(f"{image_file.name} already minified") + return + + success = False + + if self._opts.original: + if not force and org_dest.exists(): + self._logger.info(f"{image_file.name} already minified") + else: + PillowMinifier.to_webp(image_file, org_dest, quality=70) + success = True + + if self._opts.minature: + if not force and min_dest.exists(): + self._logger.info(f"{image_file.name} already minified") + else: + PillowMinifier.to_webp(image_file, min_dest, quality=70) + self._logger.info(f"{image_file.name} minified") + success = True + + if self._opts.tiny: + if not force and tiny_dest.exists(): + self._logger.info(f"{image_file.name} already minified") + else: + img = Image.open(image_file) + tiny_image = PillowMinifier.crop_center(img) + tiny_image.save(tiny_dest, FORMAT, quality=70) + self._logger.info("Tiny image saved") + success = True + + if self._purge and success: + self.purge(image_file) diff --git a/mealie/pkgs/stats/__init__.py b/mealie/pkgs/stats/__init__.py new file mode 100644 index 000000000000..48df509b157c --- /dev/null +++ b/mealie/pkgs/stats/__init__.py @@ -0,0 +1 @@ +from .fs_stats import * diff --git a/mealie/utils/fs_stats.py b/mealie/pkgs/stats/fs_stats.py similarity index 100% rename from mealie/utils/fs_stats.py rename to mealie/pkgs/stats/fs_stats.py diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index 8124a98eabe3..3363736ea14b 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -46,7 +46,8 @@ from mealie.schema.group.webhook import ReadWebhook from mealie.schema.labels import MultiPurposeLabelOut from mealie.schema.meal_plan.new_meal import ReadPlanEntry from mealie.schema.meal_plan.plan_rules import PlanRulesOut -from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool +from mealie.schema.recipe import Recipe, RecipeCommentOut, RecipeTool +from mealie.schema.recipe.recipe_category import CategoryOut, TagOut from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_share_token import RecipeShareToken from mealie.schema.reports.reports import ReportEntryOut, ReportOut @@ -67,12 +68,12 @@ PK_TOKEN = "token" PK_GROUP_ID = "group_id" -class RepositoryCategories(RepositoryGeneric): +class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]): def get_empty(self): return self.session.query(Category).filter(~Category.recipes.any()).all() -class RepositoryTags(RepositoryGeneric): +class RepositoryTags(RepositoryGeneric[TagOut, Tag]): def get_empty(self): return self.session.query(Tag).filter(~Tag.recipes.any()).all() @@ -114,11 +115,11 @@ class AllRepositories: @cached_property def categories(self) -> RepositoryCategories: # TODO: Fix Typing for Category Repository - return RepositoryCategories(self.session, PK_SLUG, Category, RecipeCategoryResponse) + return RepositoryCategories(self.session, PK_ID, Category, CategoryOut) @cached_property def tags(self) -> RepositoryTags: - return RepositoryTags(self.session, PK_SLUG, Tag, RecipeTagResponse) + return RepositoryTags(self.session, PK_ID, Tag, TagOut) @cached_property def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]: diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 75f905ca3304..cbc8cc160ce0 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -11,7 +11,7 @@ from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.tag import Tag from mealie.schema.recipe import Recipe -from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag +from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag from .repository_generic import RepositoryGeneric @@ -89,7 +89,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .all() ) - def get_by_categories(self, categories: list[RecipeCategory]) -> list[Recipe]: + def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSummary]: """ get_by_categories returns all the Recipes that contain every category provided in the list """ @@ -97,7 +97,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): ids = [x.id for x in categories] return [ - self.schema.from_orm(x) + RecipeSummary.from_orm(x) for x in self.session.query(RecipeModel) .join(RecipeModel.recipe_category) .filter(RecipeModel.recipe_category.any(Category.id.in_(ids))) @@ -120,13 +120,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): if categories: cat_ids = [x.id for x in categories] - for cat_id in cat_ids: - filters.append(RecipeModel.recipe_category.any(Category.id.is_(cat_id))) + filters.extend(RecipeModel.recipe_category.any(Category.id.is_(cat_id)) for cat_id in cat_ids) if tags: tag_ids = [x.id for x in tags] - for tag_id in tag_ids: - filters.append(RecipeModel.tags.any(Tag.id.is_(tag_id))) + filters.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids) return [ self.schema.from_orm(x) diff --git a/mealie/routes/__init__.py b/mealie/routes/__init__.py index 4cc75159962f..dd5e3ba5a275 100644 --- a/mealie/routes/__init__.py +++ b/mealie/routes/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users +from . import admin, app, auth, comments, groups, organizers, parser, recipe, shared, unit_and_foods, users router = APIRouter(prefix="/api") @@ -9,11 +9,9 @@ router.include_router(auth.router) router.include_router(users.router) router.include_router(groups.router) router.include_router(recipe.router) +router.include_router(organizers.router) router.include_router(shared.router) router.include_router(comments.router) router.include_router(parser.router) router.include_router(unit_and_foods.router) -router.include_router(tools.router) -router.include_router(categories.router) -router.include_router(tags.router) router.include_router(admin.router) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 0667f34ba838..b14a1d834db6 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -10,13 +10,13 @@ from mealie.core.dependencies import get_current_user from mealie.core.root_logger import get_logger from mealie.core.security import create_file_token from mealie.db.db_setup import generate_session +from mealie.pkgs.stats.fs_stats import pretty_size from mealie.routes._base.routers import AdminAPIRouter from mealie.schema.admin import AllBackups, BackupFile, CreateBackup, ImportJob from mealie.schema.user.user import PrivateUser from mealie.services.backups import imports from mealie.services.backups.exports import backup_all from mealie.services.events import create_backup_event -from mealie.utils.fs_stats import pretty_size router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"]) logger = get_logger() diff --git a/mealie/routes/categories/__init__.py b/mealie/routes/categories/__init__.py deleted file mode 100644 index 523fb16bffca..000000000000 --- a/mealie/routes/categories/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastapi import APIRouter - -from . import categories - -router = APIRouter() -router.include_router(categories.router) diff --git a/mealie/routes/categories/categories.py b/mealie/routes/categories/categories.py deleted file mode 100644 index 3d33d2a9dbb8..000000000000 --- a/mealie/routes/categories/categories.py +++ /dev/null @@ -1,69 +0,0 @@ -from functools import cached_property - -from fastapi import APIRouter -from pydantic import BaseModel - -from mealie.routes._base import BaseUserController, controller -from mealie.routes._base.mixins import CrudMixins -from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse -from mealie.schema.recipe.recipe_category import CategoryBase - -router = APIRouter(prefix="/categories", tags=["Categories: CRUD"]) - - -class CategorySummary(BaseModel): - id: int - slug: str - name: str - - class Config: - orm_mode = True - - -@controller(router) -class RecipeCategoryController(BaseUserController): - # ========================================================================= - # CRUD Operations - - @cached_property - def mixins(self): - return CrudMixins(self.repos.categories, self.deps.logger) - - @router.get("", response_model=list[CategorySummary]) - def get_all(self): - """Returns a list of available categories in the database""" - return self.repos.categories.get_all_limit_columns(fields=["slug", "name"]) - - @router.post("", status_code=201) - def create_one(self, category: CategoryIn): - """Creates a Category in the database""" - return self.mixins.create_one(category) - - @router.get("/{slug}", response_model=RecipeCategoryResponse) - def get_all_recipes_by_category(self, slug: str): - """Returns a list of recipes associated with the provided category.""" - category_obj = self.repos.categories.get(slug) - category_obj = RecipeCategoryResponse.from_orm(category_obj) - return category_obj - - @router.put("/{slug}", response_model=RecipeCategoryResponse) - def update_one(self, slug: str, update_data: CategoryIn): - """Updates an existing Tag in the database""" - return self.mixins.update_one(update_data, slug) - - @router.delete("/{slug}") - def delete_one(self, slug: str): - """ - Removes a recipe category from the database. Deleting a - category does not impact a recipe. The category will be removed - from any recipes that contain it - """ - self.mixins.delete_one(slug) - - # ========================================================================= - # Read All Operations - - @router.get("/empty", response_model=list[CategoryBase]) - def get_all_empty(self): - """Returns a list of categories that do not contain any recipes""" - return self.repos.categories.get_empty() diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index 6398be52c24b..d1cac0a35849 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -143,9 +143,9 @@ class ShoppingListController(BaseUserController): # Other Operations @router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut) - def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: int): + def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: UUID4): return self.service.add_recipe_ingredients_to_list(item_id, recipe_id) @router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut) - def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: int): + def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: UUID4): return self.service.remove_recipe_ingredients_from_list(item_id, recipe_id) diff --git a/mealie/routes/media/media_recipe.py b/mealie/routes/media/media_recipe.py index 9c9ad2d58682..ddd6f001ede9 100644 --- a/mealie/routes/media/media_recipe.py +++ b/mealie/routes/media/media_recipe.py @@ -1,6 +1,7 @@ from enum import Enum from fastapi import APIRouter, HTTPException, status +from pydantic import UUID4 from starlette.responses import FileResponse from mealie.schema.recipe import Recipe @@ -19,11 +20,13 @@ class ImageType(str, Enum): tiny = "tiny-original.webp" -@router.get("/{slug}/images/{file_name}") -async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original): - """Takes in a recipe slug, returns the static image. This route is proxied in the docker image - and should not hit the API in production""" - recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value) +@router.get("/{recipe_id}/images/{file_name}") +async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original): + """ + Takes in a recipe recipe_id, returns the static image. This route is proxied in the docker image + and should not hit the API in production + """ + recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value) if recipe_image.exists(): return FileResponse(recipe_image) @@ -31,10 +34,10 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original): raise HTTPException(status.HTTP_404_NOT_FOUND) -@router.get("/{slug}/assets/{file_name}") -async def get_recipe_asset(slug: str, file_name: str): +@router.get("/{recipe_id}/assets/{file_name}") +async def get_recipe_asset(recipe_id: UUID4, file_name: str): """Returns a recipe asset""" - file = Recipe(slug=slug).asset_dir.joinpath(file_name) + file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name) try: return FileResponse(file) diff --git a/mealie/routes/organizers/__init__.py b/mealie/routes/organizers/__init__.py new file mode 100644 index 000000000000..76bd9e957146 --- /dev/null +++ b/mealie/routes/organizers/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from . import controller_categories, controller_tags, controller_tools + +router = APIRouter(prefix="/organizers") +router.include_router(controller_categories.router) +router.include_router(controller_tags.router) +router.include_router(controller_tools.router) diff --git a/mealie/routes/organizers/controller_categories.py b/mealie/routes/organizers/controller_categories.py new file mode 100644 index 000000000000..344de96a1019 --- /dev/null +++ b/mealie/routes/organizers/controller_categories.py @@ -0,0 +1,87 @@ +from functools import cached_property + +from fastapi import APIRouter +from pydantic import UUID4, BaseModel + +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper +from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse +from mealie.schema.recipe.recipe import RecipeCategory +from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave + +router = APIRouter(prefix="/categories", tags=["Organizer: Categories"]) + + +class CategorySummary(BaseModel): + id: UUID4 + slug: str + name: str + + class Config: + orm_mode = True + + +@controller(router) +class RecipeCategoryController(BaseUserController): + # ========================================================================= + # CRUD Operations + @cached_property + def repo(self): + return self.repos.categories.by_group(self.group_id) + + @cached_property + def mixins(self): + return CrudMixins(self.repo, self.deps.logger) + + @router.get("", response_model=list[CategorySummary]) + def get_all(self): + """Returns a list of available categories in the database""" + return self.repo.get_all(override_schema=CategorySummary) + + @router.post("", status_code=201) + def create_one(self, category: CategoryIn): + """Creates a Category in the database""" + save_data = mapper.cast(category, CategorySave, group_id=self.group_id) + return self.mixins.create_one(save_data) + + @router.get("/{item_id}", response_model=CategorySummary) + def get_one(self, item_id: UUID4): + """Returns a list of recipes associated with the provided category.""" + category_obj = self.mixins.get_one(item_id) + category_obj = CategorySummary.from_orm(category_obj) + return category_obj + + @router.put("/{item_id}", response_model=CategorySummary) + def update_one(self, item_id: UUID4, update_data: CategoryIn): + """Updates an existing Tag in the database""" + save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id) + return self.mixins.update_one(save_data, item_id) + + @router.delete("/{item_id}") + def delete_one(self, item_id: UUID4): + """ + Removes a recipe category from the database. Deleting a + category does not impact a recipe. The category will be removed + from any recipes that contain it + """ + self.mixins.delete_one(item_id) + + # ========================================================================= + # Read All Operations + + @router.get("/empty", response_model=list[CategoryBase]) + def get_all_empty(self): + """Returns a list of categories that do not contain any recipes""" + return self.repos.categories.get_empty() + + @router.get("/slug/{category_slug}") + def get_one_by_slug(self, category_slug: str): + """Returns a category object with the associated recieps relating to the category""" + category: RecipeCategory = self.mixins.get_one(category_slug, "slug") + return RecipeCategoryResponse.construct( + id=category.id, + slug=category.slug, + name=category.name, + recipes=self.repos.recipes.by_group(self.group_id).get_by_categories([category]), + ) diff --git a/mealie/routes/organizers/controller_tags.py b/mealie/routes/organizers/controller_tags.py new file mode 100644 index 000000000000..48456c713f5b --- /dev/null +++ b/mealie/routes/organizers/controller_tags.py @@ -0,0 +1,66 @@ +from functools import cached_property + +from fastapi import APIRouter, HTTPException, status +from pydantic import UUID4 + +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper +from mealie.schema.recipe import RecipeTagResponse, TagIn +from mealie.schema.recipe.recipe import RecipeTag +from mealie.schema.recipe.recipe_category import TagSave + +router = APIRouter(prefix="/tags", tags=["Organizer: Tags"]) + + +@controller(router) +class TagController(BaseUserController): + @cached_property + def repo(self): + return self.repos.tags.by_group(self.group_id) + + @cached_property + def mixins(self): + return CrudMixins(self.repo, self.deps.logger) + + @router.get("") + async def get_all(self): + """Returns a list of available tags in the database""" + return self.repo.get_all(override_schema=RecipeTag) + + @router.get("/empty") + def get_empty_tags(self): + """Returns a list of tags that do not contain any recipes""" + return self.repo.get_empty() + + @router.get("/{item_id}", response_model=RecipeTagResponse) + def get_one(self, item_id: UUID4): + """Returns a list of recipes associated with the provided tag.""" + return self.mixins.get_one(item_id) + + @router.post("", status_code=201) + def create_one(self, tag: TagIn): + """Creates a Tag in the database""" + save_data = mapper.cast(tag, TagSave, group_id=self.group_id) + return self.repo.create(save_data) + + @router.put("/{item_id}", response_model=RecipeTagResponse) + def update_one(self, item_id: UUID4, new_tag: TagIn): + """Updates an existing Tag in the database""" + save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id) + return self.repo.update(item_id, save_data) + + @router.delete("/{item_id}") + def delete_recipe_tag(self, item_id: UUID4): + """Removes a recipe tag from the database. Deleting a + tag does not impact a recipe. The tag will be removed + from any recipes that contain it""" + + try: + self.repo.delete(item_id) + except Exception as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST) from e + + @router.get("/slug/{tag_slug}", response_model=RecipeTagResponse) + async def get_one_by_slug(self, tag_slug: str): + return self.repo.get_one(tag_slug, "slug", override_schema=RecipeTagResponse) diff --git a/mealie/routes/tools/__init__.py b/mealie/routes/organizers/controller_tools.py similarity index 75% rename from mealie/routes/tools/__init__.py rename to mealie/routes/organizers/controller_tools.py index 212669206e54..bebfcb16f7c2 100644 --- a/mealie/routes/tools/__init__.py +++ b/mealie/routes/organizers/controller_tools.py @@ -1,22 +1,24 @@ from functools import cached_property from fastapi import APIRouter, Depends +from pydantic import UUID4 from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper from mealie.schema.query import GetAll from mealie.schema.recipe.recipe import RecipeTool -from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse +from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave -router = APIRouter(prefix="/tools", tags=["Recipes: Tools"]) +router = APIRouter(prefix="/tools", tags=["Organizer: Tools"]) @controller(router) class RecipeToolController(BaseUserController): @cached_property def repo(self): - return self.repos.tools + return self.repos.tools.by_group(self.group_id) @property def mixins(self) -> CrudMixins: @@ -28,18 +30,19 @@ class RecipeToolController(BaseUserController): @router.post("", response_model=RecipeTool, status_code=201) def create_one(self, data: RecipeToolCreate): - return self.mixins.create_one(data) + save_data = mapper.cast(data, RecipeToolSave, group_id=self.group_id) + return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=RecipeTool) - def get_one(self, item_id: int): + def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) @router.put("/{item_id}", response_model=RecipeTool) - def update_one(self, item_id: int, data: RecipeToolCreate): + def update_one(self, item_id: UUID4, data: RecipeToolCreate): return self.mixins.update_one(data, item_id) @router.delete("/{item_id}", response_model=RecipeTool) - def delete_one(self, item_id: int): + def delete_one(self, item_id: UUID4): return self.mixins.delete_one(item_id) # type: ignore @router.get("/slug/{tool_slug}", response_model=RecipeToolResponse) diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index a5db7943e269..dca1ba949bef 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, shared_routes +from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes prefix = "/recipes" @@ -9,7 +9,6 @@ router = APIRouter() router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"]) router.include_router(recipe_crud_routes.router_exports) router.include_router(recipe_crud_routes.router) -router.include_router(image_and_assets.router, prefix=prefix, tags=["Recipe: Images and Assets"]) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) router.include_router(bulk_actions.router, prefix=prefix) router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"]) diff --git a/mealie/routes/recipe/image_and_assets.py b/mealie/routes/recipe/image_and_assets.py deleted file mode 100644 index 47e8741105b9..000000000000 --- a/mealie/routes/recipe/image_and_assets.py +++ /dev/null @@ -1,68 +0,0 @@ -from shutil import copyfileobj - -from fastapi import Depends, File, Form, HTTPException, status -from fastapi.datastructures import UploadFile -from pydantic import BaseModel -from slugify import slugify -from sqlalchemy.orm.session import Session - -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes._base.routers import UserAPIRouter -from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset -from mealie.services.image.image import scrape_image, write_image - -router = UserAPIRouter() - - -class UpdateImageResponse(BaseModel): - image: str - - -@router.post("/{slug}/image") -def scrape_image_url(slug: str, url: CreateRecipeByUrl): - """Removes an existing image and replaces it with the incoming file.""" - scrape_image(url.url, slug) - - -@router.put("/{slug}/image", response_model=UpdateImageResponse) -def update_recipe_image( - slug: str, - image: bytes = File(...), - extension: str = Form(...), - session: Session = Depends(generate_session), -): - """Removes an existing image and replaces it with the incoming file.""" - db = get_repositories(session) - write_image(slug, image, extension) - new_version = db.recipes.update_image(slug, extension) - - return UpdateImageResponse(image=new_version) - - -@router.post("/{slug}/assets", response_model=RecipeAsset) -def upload_recipe_asset( - slug: str, - name: str = Form(...), - icon: str = Form(...), - extension: str = Form(...), - file: UploadFile = File(...), - session: Session = Depends(generate_session), -): - """Upload a file to store as a recipe asset""" - file_name = slugify(name) + "." + extension - asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) - dest = Recipe(slug=slug).asset_dir.joinpath(file_name) - - with dest.open("wb") as buffer: - copyfileobj(file.file, buffer) - - if not dest.is_file(): - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) - - db = get_repositories(session) - - recipe: Recipe = db.recipes.get(slug) - recipe.assets.append(asset_in) - db.recipes.update(slug, recipe.dict()) - return asset_in diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index b705bbdca572..459aafd6391e 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,12 +1,14 @@ from functools import cached_property +from shutil import copyfileobj from zipfile import ZipFile import sqlalchemy -from fastapi import BackgroundTasks, Depends, File, HTTPException +from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, status from fastapi.datastructures import UploadFile from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from slugify import slugify from sqlalchemy.orm.session import Session from starlette.responses import FileResponse @@ -22,8 +24,10 @@ from mealie.routes._base.routers import UserAPIRouter from mealie.schema.query import GetAll from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary +from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.response.responses import ErrorResponse from mealie.schema.server.tasks import ServerTaskNames +from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_service import RecipeService from mealie.services.recipe.template_service import TemplateService from mealie.services.scraper.scraper import create_from_url @@ -49,6 +53,10 @@ class RecipeGetAll(GetAll): load_food: bool = False +class UpdateImageResponse(BaseModel): + image: str + + class FormatResponse(BaseModel): jjson: list[str] = Field(..., alias="json") zip: list[str] @@ -158,10 +166,9 @@ class RecipeController(BaseRecipeController): @router.post("/test-scrape-url") def test_parse_recipe_url(self, url: CreateRecipeByUrl): # Debugger should produce the same result as the scraper sees before cleaning - scraped_data = RecipeScraperPackage(url.url).scrape_url() - - if scraped_data: + if scraped_data := RecipeScraperPackage(url.url).scrape_url(): return scraped_data.schema.data + return "recipe_scrapers was unable to scrape this URL" @router.post("/create-from-zip", status_code=201) @@ -217,6 +224,12 @@ class RecipeController(BaseRecipeController): self.deps.logger.error("SQL Integrity Error on recipe controller action") raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists")) + case _: + self.deps.logger.error("Unknown Error on recipe controller action") + raise HTTPException( + status_code=500, detail=ErrorResponse.respond(message="Unknown Error", exception=ex) + ) + @router.put("/{slug}") def update_one(self, slug: str, data: Recipe): """Updates a recipe by existing slug and data.""" @@ -243,3 +256,51 @@ class RecipeController(BaseRecipeController): return self.service.delete_one(slug) except Exception as e: self.handle_exceptions(e) + + # ================================================================================================================== + # Image and Assets + + @router.post("/{slug}/image", tags=["Recipe: Images and Assets"]) + def scrape_image_url(self, slug: str, url: CreateRecipeByUrl) -> str: + recipe = self.mixins.get_one(slug) + data_service = RecipeDataService(recipe.id) + data_service.scrape_image(url.url) + + @router.put("/{slug}/image", response_model=UpdateImageResponse, tags=["Recipe: Images and Assets"]) + def update_recipe_image(self, slug: str, image: bytes = File(...), extension: str = Form(...)): + recipe = self.mixins.get_one(slug) + data_service = RecipeDataService(recipe.id) + data_service.write_image(image, extension) + + new_version = self.repo.update_image(slug, extension) + return UpdateImageResponse(image=new_version) + + @router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"]) + def upload_recipe_asset( + self, + slug: str, + name: str = Form(...), + icon: str = Form(...), + extension: str = Form(...), + file: UploadFile = File(...), + ): + """Upload a file to store as a recipe asset""" + file_name = slugify(name) + "." + extension + asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) + + recipe = self.mixins.get_one(slug) + + dest = recipe.asset_dir / file_name + + with dest.open("wb") as buffer: + copyfileobj(file.file, buffer) + + if not dest.is_file(): + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + recipe: Recipe = self.mixins.get_one(slug) + recipe.assets.append(asset_in) + + self.mixins.update_one(recipe, slug) + + return asset_in diff --git a/mealie/routes/shared/__init__.py b/mealie/routes/shared/__init__.py index af0d197c5b05..ba4ff79cbc6b 100644 --- a/mealie/routes/shared/__init__.py +++ b/mealie/routes/shared/__init__.py @@ -22,7 +22,7 @@ class RecipeSharedController(BaseUserController): return CrudMixins[RecipeShareTokenSave, RecipeShareToken, RecipeShareTokenCreate](self.repo, self.deps.logger) @router.get("", response_model=list[RecipeShareTokenSummary]) - def get_all(self, recipe_id: int = None): + def get_all(self, recipe_id: UUID4 = None): if recipe_id: return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary) else: diff --git a/mealie/routes/tags/__init__.py b/mealie/routes/tags/__init__.py deleted file mode 100644 index b405931b9a6b..000000000000 --- a/mealie/routes/tags/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from functools import cached_property - -from fastapi import APIRouter, HTTPException, status - -from mealie.routes._base import BaseUserController, controller -from mealie.schema.recipe import RecipeTagResponse, TagIn - -router = APIRouter(prefix="/tags", tags=["Tags: CRUD"]) - - -@controller(router) -class TagController(BaseUserController): - @cached_property - def repo(self): - return self.repos.tags - - @router.get("") - async def get_all_recipe_tags(self): - """Returns a list of available tags in the database""" - return self.repo.get_all_limit_columns(["slug", "name"]) - - @router.get("/empty") - def get_empty_tags(self): - """Returns a list of tags that do not contain any recipes""" - return self.repo.get_empty() - - @router.get("/{tag_slug}", response_model=RecipeTagResponse) - def get_all_recipes_by_tag(self, tag_slug: str): - """Returns a list of recipes associated with the provided tag.""" - return self.repo.get_one(tag_slug, override_schema=RecipeTagResponse) - - @router.post("", status_code=201) - def create_recipe_tag(self, tag: TagIn): - """Creates a Tag in the database""" - return self.repo.create(tag) - - @router.put("/{tag_slug}", response_model=RecipeTagResponse) - def update_recipe_tag(self, tag_slug: str, new_tag: TagIn): - """Updates an existing Tag in the database""" - return self.repo.update(tag_slug, new_tag) - - @router.delete("/{tag_slug}") - def delete_recipe_tag(self, tag_slug: str): - """Removes a recipe tag from the database. Deleting a - tag does not impact a recipe. The tag will be removed - from any recipes that contain it""" - - try: - self.repo.delete(tag_slug) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py index 50fb28134d2d..aea1438ffdbe 100644 --- a/mealie/routes/unit_and_foods/foods.py +++ b/mealie/routes/unit_and_foods/foods.py @@ -1,6 +1,7 @@ from functools import cached_property from fastapi import APIRouter, Depends +from pydantic import UUID4 from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller @@ -36,13 +37,13 @@ class IngredientFoodsController(BaseUserController): return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=IngredientFood) - def get_one(self, item_id: int): + def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) @router.put("/{item_id}", response_model=IngredientFood) - def update_one(self, item_id: int, data: CreateIngredientFood): + def update_one(self, item_id: UUID4, data: CreateIngredientFood): return self.mixins.update_one(data, item_id) @router.delete("/{item_id}", response_model=IngredientFood) - def delete_one(self, item_id: int): + def delete_one(self, item_id: UUID4): return self.mixins.delete_one(item_id) diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py index 47eaaaf28bbe..de4e3db81043 100644 --- a/mealie/routes/unit_and_foods/units.py +++ b/mealie/routes/unit_and_foods/units.py @@ -1,6 +1,7 @@ from functools import cached_property from fastapi import APIRouter, Depends +from pydantic import UUID4 from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller @@ -36,13 +37,13 @@ class IngredientUnitsController(BaseUserController): return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=IngredientUnit) - def get_one(self, item_id: int): + def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) @router.put("/{item_id}", response_model=IngredientUnit) - def update_one(self, item_id: int, data: CreateIngredientUnit): + def update_one(self, item_id: UUID4, data: CreateIngredientUnit): return self.mixins.update_one(data, item_id) @router.delete("/{item_id}", response_model=IngredientUnit) - def delete_one(self, item_id: int): + def delete_one(self, item_id: UUID4): return self.mixins.delete_one(item_id) # type: ignore diff --git a/mealie/routes/users/images.py b/mealie/routes/users/images.py index e033e3d8018b..6be6496aa153 100644 --- a/mealie/routes/users/images.py +++ b/mealie/routes/users/images.py @@ -4,13 +4,12 @@ from pathlib import Path from fastapi import Depends, File, HTTPException, UploadFile, status from pydantic import UUID4 -from mealie import utils from mealie.core.dependencies.dependencies import temporary_dir +from mealie.pkgs import cache, img from mealie.routes._base import BaseUserController, controller from mealie.routes._base.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed from mealie.schema.user import PrivateUser -from mealie.services.image import minify router = UserAPIRouter(prefix="", tags=["Users: Images"]) @@ -31,12 +30,12 @@ class UserImageController(BaseUserController): with temp_img.open("wb") as buffer: shutil.copyfileobj(profile.file, buffer) - image = minify.to_webp(temp_img) + image = img.PillowMinifier.to_webp(temp_img) dest = PrivateUser.get_directory(id) / "profile.webp" shutil.copyfile(image, dest) - self.repos.users.patch(id, {"cache_key": utils.new_cache_key()}) + self.repos.users.patch(id, {"cache_key": cache.new_key()}) if not dest.is_file: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/mealie/run.sh b/mealie/run.sh index 935c38369cf4..398f10951105 100755 --- a/mealie/run.sh +++ b/mealie/run.sh @@ -28,7 +28,6 @@ init() { # Initialize Database Prerun poetry run python /app/mealie/db/init_db.py - poetry run python /app/mealie/services/image/minify.py } # Migrations diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index 1cebf0dbfac7..eec7cbddc1d5 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -9,7 +9,7 @@ from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUni class ShoppingListItemRecipeRef(CamelModel): - recipe_id: int + recipe_id: UUID4 recipe_quantity: float @@ -30,9 +30,9 @@ class ShoppingListItemCreate(CamelModel): note: Optional[str] = "" quantity: float = 1 - unit_id: int = None + unit_id: UUID4 = None unit: Optional[IngredientUnit] - food_id: int = None + food_id: UUID4 = None food: Optional[IngredientFood] label_id: Optional[UUID4] = None @@ -58,7 +58,7 @@ class ShoppingListCreate(CamelModel): class ShoppingListRecipeRefOut(CamelModel): id: UUID4 shopping_list_id: UUID4 - recipe_id: int + recipe_id: UUID4 recipe_quantity: float recipe: RecipeSummary diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index 396ac3498851..853d04cb38b0 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -26,7 +26,7 @@ class CreatePlanEntry(CamelModel): entry_type: PlanEntryType = PlanEntryType.breakfast title: str = "" text: str = "" - recipe_id: Optional[int] + recipe_id: Optional[UUID] @validator("recipe_id", always=True) @classmethod diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py index 5d8528d07e60..394575b326da 100644 --- a/mealie/schema/meal_plan/plan_rules.py +++ b/mealie/schema/meal_plan/plan_rules.py @@ -6,7 +6,7 @@ from pydantic import UUID4 class Category(CamelModel): - id: int + id: UUID4 name: str slug: str diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index cd9957ebd5ad..45beb9c604eb 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -24,7 +24,7 @@ app_dirs = get_app_dirs() class RecipeTag(CamelModel): - id: int = 0 + id: UUID4 = None name: str slug: str @@ -37,7 +37,7 @@ class RecipeCategory(RecipeTag): class RecipeTool(RecipeTag): - id: int = 0 + id: UUID4 on_hand: bool = False @@ -63,7 +63,7 @@ class CreateRecipe(CamelModel): class RecipeSummary(CamelModel): - id: Optional[int] + id: Optional[UUID4] user_id: UUID4 = Field(default_factory=uuid4) group_id: UUID4 = Field(default_factory=uuid4) @@ -96,13 +96,13 @@ class RecipeSummary(CamelModel): @validator("tags", always=True, pre=True, allow_reuse=True) def validate_tags(cats: list[Any]): # type: ignore if isinstance(cats, list) and cats and isinstance(cats[0], str): - return [RecipeTag(name=c, slug=slugify(c)) for c in cats] + return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats] return cats @validator("recipe_category", always=True, pre=True, allow_reuse=True) def validate_categories(cats: list[Any]): # type: ignore if isinstance(cats, list) and cats and isinstance(cats[0], str): - return [RecipeCategory(name=c, slug=slugify(c)) for c in cats] + return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats] return cats @validator("group_id", always=True, pre=True, allow_reuse=True) @@ -132,12 +132,15 @@ class Recipe(RecipeSummary): comments: Optional[list[RecipeCommentOut]] = [] @staticmethod - def directory_from_slug(slug) -> Path: - return app_dirs.RECIPE_DATA_DIR.joinpath(slug) + def directory_from_id(recipe_id: UUID4 | str) -> Path: + return app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id)) @property def directory(self) -> Path: - dir = app_dirs.RECIPE_DATA_DIR.joinpath(self.slug) + if not self.id: + raise ValueError("Recipe has no ID") + + dir = app_dirs.RECIPE_DATA_DIR.joinpath(str(self.id)) dir.mkdir(exist_ok=True, parents=True) return dir diff --git a/mealie/schema/recipe/recipe_category.py b/mealie/schema/recipe/recipe_category.py index 1e239c44e238..22a07462ecbf 100644 --- a/mealie/schema/recipe/recipe_category.py +++ b/mealie/schema/recipe/recipe_category.py @@ -1,4 +1,5 @@ from fastapi_camelcase import CamelModel +from pydantic import UUID4 from pydantic.utils import GetterDict @@ -6,8 +7,12 @@ class CategoryIn(CamelModel): name: str +class CategorySave(CategoryIn): + group_id: UUID4 + + class CategoryBase(CategoryIn): - id: int + id: UUID4 slug: str class Config: @@ -20,27 +25,45 @@ class CategoryBase(CategoryIn): } -class RecipeCategoryResponse(CategoryBase): - recipes: "list[Recipe]" = [] +class CategoryOut(CategoryBase): + slug: str + + class Config: + orm_mode = True + + +class RecipeCategoryResponse(CategoryBase): + recipes: "list[RecipeSummary]" = [] class Config: orm_mode = True - schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}} class TagIn(CategoryIn): pass +class TagSave(TagIn): + group_id: UUID4 + + class TagBase(CategoryBase): pass +class TagOut(TagSave): + id: UUID4 + slug: str + + class Config: + orm_mode = True + + class RecipeTagResponse(RecipeCategoryResponse): pass -from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe import RecipeSummary RecipeCategoryResponse.update_forward_refs() RecipeTagResponse.update_forward_refs() diff --git a/mealie/schema/recipe/recipe_comments.py b/mealie/schema/recipe/recipe_comments.py index 6ab709997835..646bb9ede486 100644 --- a/mealie/schema/recipe/recipe_comments.py +++ b/mealie/schema/recipe/recipe_comments.py @@ -16,7 +16,7 @@ class UserBase(CamelModel): class RecipeCommentCreate(CamelModel): - recipe_id: int + recipe_id: UUID4 text: str @@ -31,7 +31,7 @@ class RecipeCommentUpdate(CamelModel): class RecipeCommentOut(RecipeCommentCreate): id: UUID - recipe_id: int + recipe_id: UUID4 created_at: datetime update_at: datetime user_id: UUID4 diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 6d18fb9882eb..03febdedbe58 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -14,7 +14,7 @@ class UnitFoodBase(CamelModel): class CreateIngredientFood(UnitFoodBase): - label_id: UUID4 = None + label_id: Optional[UUID4] = None class SaveIngredientFood(CreateIngredientFood): @@ -22,7 +22,7 @@ class SaveIngredientFood(CreateIngredientFood): class IngredientFood(CreateIngredientFood): - id: int + id: UUID4 label: MultiPurposeLabelSummary = None class Config: @@ -39,7 +39,7 @@ class SaveIngredientUnit(CreateIngredientUnit): class IngredientUnit(CreateIngredientUnit): - id: int + id: UUID4 class Config: orm_mode = True diff --git a/mealie/schema/recipe/recipe_share_token.py b/mealie/schema/recipe/recipe_share_token.py index c129aa18dd20..229286449ef8 100644 --- a/mealie/schema/recipe/recipe_share_token.py +++ b/mealie/schema/recipe/recipe_share_token.py @@ -11,7 +11,7 @@ def defaut_expires_at_time() -> datetime: class RecipeShareTokenCreate(CamelModel): - recipe_id: int + recipe_id: UUID4 expires_at: datetime = Field(default_factory=defaut_expires_at_time) diff --git a/mealie/schema/recipe/recipe_tool.py b/mealie/schema/recipe/recipe_tool.py index 41cd486baabd..4be7b5df1f51 100644 --- a/mealie/schema/recipe/recipe_tool.py +++ b/mealie/schema/recipe/recipe_tool.py @@ -1,6 +1,7 @@ from typing import List from fastapi_camelcase import CamelModel +from pydantic import UUID4 class RecipeToolCreate(CamelModel): @@ -8,8 +9,12 @@ class RecipeToolCreate(CamelModel): on_hand: bool = False +class RecipeToolSave(RecipeToolCreate): + group_id: UUID4 + + class RecipeTool(RecipeToolCreate): - id: int + id: UUID4 slug: str class Config: diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index 0d09a239304c..18a420e97576 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -12,7 +12,6 @@ from mealie.repos.all_repositories import get_repositories from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport from mealie.schema.recipe import Recipe, RecipeCommentOut from mealie.schema.user import PrivateUser, UpdateGroup -from mealie.services.image import minify app_dirs = get_app_dirs() @@ -156,8 +155,6 @@ class ImportDatabase: recipe_dir = self.import_dir.joinpath("recipes") shutil.copytree(recipe_dir, app_dirs.RECIPE_DATA_DIR, dirs_exist_ok=True) - minify.migrate_images() - def import_settings(self): return [] diff --git a/mealie/services/exporter/exporter.py b/mealie/services/exporter/exporter.py index 4a5200e7884f..0899493186e8 100644 --- a/mealie/services/exporter/exporter.py +++ b/mealie/services/exporter/exporter.py @@ -4,10 +4,10 @@ import zipfile from pathlib import Path from uuid import UUID, uuid4 +from mealie.pkgs.stats.fs_stats import pretty_size from mealie.repos.all_repositories import AllRepositories from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.user import GroupInDB -from mealie.utils.fs_stats import pretty_size from .._base_service import BaseService from ._abc_exporter import ABCExporter diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py index ceffce0151fb..e2628019b059 100644 --- a/mealie/services/group_services/shopping_lists.py +++ b/mealie/services/group_services/shopping_lists.py @@ -100,7 +100,7 @@ class ShoppingListService: # ======================================================================= # Methods - def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: + def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: UUID4) -> ShoppingListOut: recipe = self.repos.recipes.get_one(recipe_id, "id") to_create = [] @@ -161,7 +161,7 @@ class ShoppingListService: return updated_list - def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: + def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: UUID4) -> ShoppingListOut: shopping_list = self.shopping_lists.get_one(list_id) for item in shopping_list.list_items: diff --git a/mealie/services/image/image.py b/mealie/services/image/image.py deleted file mode 100644 index e1653aa5d28e..000000000000 --- a/mealie/services/image/image.py +++ /dev/null @@ -1,81 +0,0 @@ -import shutil -from pathlib import Path - -import requests - -from mealie.core import root_logger -from mealie.schema.recipe import Recipe -from mealie.services.image import minify - - -def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: - image_dir = Recipe(slug=recipe_slug).image_dir - extension = extension.replace(".", "") - image_path = image_dir.joinpath(f"original.{extension}") - image_path.unlink(missing_ok=True) - - if isinstance(file_data, Path): - shutil.copy2(file_data, image_path) - elif isinstance(file_data, bytes): - with open(image_path, "ab") as f: - f.write(file_data) - else: - with open(image_path, "ab") as f: - shutil.copyfileobj(file_data, f) - - minify.minify_image(image_path, force=True) - - return image_path - - -def scrape_image(image_url: str, slug: str) -> Path: - logger = root_logger.get_logger() - logger.info(f"Image URL: {image_url}") - _FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" - - if isinstance(image_url, str): # Handles String Types - pass - - if isinstance(image_url, list): # Handles List Types - # Multiple images have been defined in the schema - usually different resolutions - # Typically would be in smallest->biggest order, but can't be certain so test each. - # 'Google will pick the best image to display in Search results based on the aspect ratio and resolution.' - - all_image_requests = [] - for url in image_url: - if isinstance(url, dict): - url = url.get("url", "") - try: - r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA}) - except Exception: - logger.exception("Image {url} could not be requested") - continue - if r.status_code == 200: - all_image_requests.append((url, r)) - - image_url, _ = max(all_image_requests, key=lambda url_r: len(url_r[1].content), default=("", 0)) - - if isinstance(image_url, dict): # Handles Dictionary Types - for key in image_url: - if key == "url": - image_url = image_url.get("url") - - filename = slug + "." + image_url.split(".")[-1] - filename = Recipe(slug=slug).image_dir.joinpath(filename) - - try: - r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA}) - except Exception: - logger.exception("Fatal Image Request Exception") - return None - - if r.status_code == 200: - r.raw.decode_content = True - logger.info(f"File Name Suffix {filename.suffix}") - write_image(slug, r.raw, filename.suffix) - - filename.unlink(missing_ok=True) - - return Path(slug) - - return None diff --git a/mealie/services/image/minify.py b/mealie/services/image/minify.py deleted file mode 100644 index 4f73d82927d2..000000000000 --- a/mealie/services/image/minify.py +++ /dev/null @@ -1,149 +0,0 @@ -import shutil -from dataclasses import dataclass -from pathlib import Path - -from PIL import Image - -from mealie.core import root_logger -from mealie.core.config import get_app_dirs -from mealie.schema.recipe import Recipe - -logger = root_logger.get_logger() -app_dirs = get_app_dirs() - - -@dataclass -class ImageSizes: - org: str - min: str - tiny: str - - -def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes: - return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img)) - - -def to_webp(image_file: Path, quality: int = 100) -> Path: - """ - Converts an image to the webp format in-place. The original image is not - removed By default, the quality is set to 100. - """ - if image_file.suffix == ".webp": - return image_file - - img = Image.open(image_file) - - dest = image_file.with_suffix(".webp") - img.save(dest, "WEBP", quality=quality) - - return dest - - -def minify_image(image_file: Path, force=False) -> ImageSizes: - """Minifies an image in it's original file format. Quality is lost - - Args: - my_path (Path): Source Files - min_dest (Path): FULL Destination File Path - tiny_dest (Path): FULL Destination File Path - """ - - def cleanup(dir: Path) -> None: - for file in dir.glob("*.*"): - if file.suffix != ".webp": - file.unlink() - - org_dest = image_file.parent.joinpath("original.webp") - min_dest = image_file.parent.joinpath("min-original.webp") - tiny_dest = image_file.parent.joinpath("tiny-original.webp") - - cleanup_images = False - - if min_dest.exists() and tiny_dest.exists() and org_dest.exists() and not force: - return - try: - img = Image.open(image_file) - - img.save(org_dest, "WEBP") - basewidth = 720 - wpercent = basewidth / float(img.size[0]) - hsize = int((float(img.size[1]) * float(wpercent))) - img = img.resize((basewidth, hsize), Image.ANTIALIAS) - img.save(min_dest, "WEBP", quality=70) - - tiny_image = crop_center(img) - tiny_image.save(tiny_dest, "WEBP", quality=70) - - cleanup_images = True - - except Exception as e: - logger.error(e) - shutil.copy(image_file, min_dest) - shutil.copy(image_file, tiny_dest) - - image_sizes = get_image_sizes(image_file, min_dest, tiny_dest) - - logger.info(f"{image_file.name} Minified: {image_sizes.org} -> {image_sizes.min} -> {image_sizes.tiny}") - - if cleanup_images: - cleanup(image_file.parent) - - return image_sizes - - -def crop_center(pil_img, crop_width=300, crop_height=300): - img_width, img_height = pil_img.size - return pil_img.crop( - ( - (img_width - crop_width) // 2, - (img_height - crop_height) // 2, - (img_width + crop_width) // 2, - (img_height + crop_height) // 2, - ) - ) - - -def sizeof_fmt(file_path: Path, decimal_places=2): - if not file_path.exists(): - return "(File Not Found)" - size = file_path.stat().st_size - for unit in ["B", "kB", "MB", "GB", "TB", "PB"]: - if size < 1024.0 or unit == "PiB": - break - size /= 1024.0 - return f"{size:.{decimal_places}f} {unit}" - - -def move_all_images(): - if not app_dirs.IMG_DIR.exists(): - return - - for image_file in app_dirs.IMG_DIR.iterdir(): - if image_file.is_file(): - if image_file.name == ".DS_Store": - continue - new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem) - new_folder.mkdir(parents=True, exist_ok=True) - new_file = new_folder.joinpath(f"original{image_file.suffix}") - if new_file.is_file(): - new_file.unlink() - image_file.rename(new_file) - if image_file.is_dir(): - slug = image_file.name - image_file.rename(Recipe(slug=slug).image_dir) - - -def migrate_images(): - logger.info("Checking for Images to Minify...") - - move_all_images() - - for image in app_dirs.RECIPE_DATA_DIR.glob("**/original.*"): - - minify_image(image) - - logger.info("Finished Minification Check") - - -if __name__ == "__main__": - migrate_images() diff --git a/mealie/services/migrations/_migration_base.py b/mealie/services/migrations/_migration_base.py index 86e418bcc5a4..b4609bf34e2d 100644 --- a/mealie/services/migrations/_migration_base.py +++ b/mealie/services/migrations/_migration_base.py @@ -29,6 +29,8 @@ class BaseMigrator(BaseService): report_id: int report: ReportOut + helpers: DatabaseMigrationHelpers + def __init__( self, archive: Path, db: AllRepositories, session, user_id: UUID4, group_id: UUID, add_migration_tag: bool ): @@ -94,7 +96,7 @@ class BaseMigrator(BaseService): self._save_all_entries() return self.db.group_reports.get(self.report_id) - def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> list[Tuple[str, bool]]: + def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> list[Tuple[str, UUID4, bool]]: """ Used as a single access point to process a list of Recipe objects into the database in a predictable way. If an error occurs the session is rolled back @@ -114,13 +116,19 @@ class BaseMigrator(BaseService): recipe.user_id = self.user_id recipe.group_id = self.group_id + if recipe.tags: + recipe.tags = self.helpers.get_or_set_tags(x.name for x in recipe.tags) + + if recipe.recipe_category: + recipe.recipe_category = self.helpers.get_or_set_category(x.name for x in recipe.recipe_category) + if self.add_migration_tag: recipe.tags.append(migration_tag) exception = "" status = False try: - self.db.recipes.create(recipe) + recipe = self.db.recipes.create(recipe) status = True except Exception as inst: @@ -133,7 +141,7 @@ class BaseMigrator(BaseService): else: message = f"Failed to import {recipe.name}" - return_vars.append((recipe.slug, status)) + return_vars.append((recipe.slug, recipe.id, status)) self.report_entries.append( ReportEntryCreate( @@ -181,16 +189,11 @@ class BaseMigrator(BaseService): """ recipe_dict = self.rewrite_alias(recipe_dict) - # Temporary hold out of recipe_dict - # temp_categories = recipe_dict["recipeCategory"] - # temp_tools = recipe_dict["tools"] - # temp_tasg = recipe_dict["tags"] + try: + del recipe_dict["id"] + except KeyError: + pass recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None)) - # Reassign after cleaning - # recipe_dict["recipeCategory"] = temp_categories - # recipe_dict["tools"] = temp_tools - # recipe_dict["tags"] = temp_tasg - return Recipe(**recipe_dict) diff --git a/mealie/services/migrations/chowdown.py b/mealie/services/migrations/chowdown.py index 82100e4ac0a3..5a075d5165e0 100644 --- a/mealie/services/migrations/chowdown.py +++ b/mealie/services/migrations/chowdown.py @@ -39,7 +39,7 @@ class ChowdownMigrator(BaseMigrator): recipe_lookup = {r.slug: r for r in recipes} - for slug, status in results: + for slug, recipe_id, status in results: if status: try: original_image = recipe_lookup.get(slug).image @@ -47,4 +47,4 @@ class ChowdownMigrator(BaseMigrator): except StopIteration: continue if cd_image: - import_image(cd_image, slug) + import_image(cd_image, recipe_id) diff --git a/mealie/services/migrations/mealie_alpha.py b/mealie/services/migrations/mealie_alpha.py index 632a31a1428c..3d3980734f43 100644 --- a/mealie/services/migrations/mealie_alpha.py +++ b/mealie/services/migrations/mealie_alpha.py @@ -32,6 +32,7 @@ class MealieAlphaMigrator(BaseMigrator): del recipe["date_added"] except Exception: pass + # Migration from list to Object Type Data try: if "" in recipe["tags"]: @@ -42,7 +43,6 @@ class MealieAlphaMigrator(BaseMigrator): try: if "" in recipe["categories"]: recipe["categories"] = [cat for cat in recipe["categories"] if cat != ""] - except Exception: pass @@ -76,14 +76,11 @@ class MealieAlphaMigrator(BaseMigrator): results = self.import_recipes_to_database(recipes) - recipe_model_lookup = {x.slug: x for x in recipes} - - for slug, status in results: + for slug, recipe_id, status in results: if not status: continue - model = recipe_model_lookup.get(slug) - dest_dir = model.directory + dest_dir = Recipe.directory_from_id(recipe_id) source_dir = recipe_lookup.get(slug) if dest_dir.exists(): diff --git a/mealie/services/migrations/nextcloud.py b/mealie/services/migrations/nextcloud.py index 08ca2ab6951c..739f5b7b2115 100644 --- a/mealie/services/migrations/nextcloud.py +++ b/mealie/services/migrations/nextcloud.py @@ -65,8 +65,8 @@ class NextcloudMigrator(BaseMigrator): all_statuses = self.import_recipes_to_database(all_recipes) - for slug, status in all_statuses: + for slug, recipe_id, status in all_statuses: if status: nc_dir: NextcloudDir = nextcloud_dirs[slug] if nc_dir.image: - import_image(nc_dir.image, nc_dir.slug) + import_image(nc_dir.image, recipe_id) diff --git a/mealie/services/migrations/paprika.py b/mealie/services/migrations/paprika.py index 449abeb69b08..231d886478c9 100644 --- a/mealie/services/migrations/paprika.py +++ b/mealie/services/migrations/paprika.py @@ -78,7 +78,7 @@ class PaprikaMigrator(BaseMigrator): results = self.import_recipes_to_database(recipes) - for slug, status in results: + for slug, recipe_id, status in results: if not status: continue @@ -88,6 +88,6 @@ class PaprikaMigrator(BaseMigrator): with tempfile.NamedTemporaryFile(suffix=".jpeg") as temp_file: temp_file.write(image.read()) path = Path(temp_file.name) - import_image(path, slug) + import_image(path, recipe_id) except Exception as e: self.logger.error(f"Failed to download image for {slug}: {e}") diff --git a/mealie/services/migrations/utils/database_helpers.py b/mealie/services/migrations/utils/database_helpers.py index e2a9b650506b..883688912979 100644 --- a/mealie/services/migrations/utils/database_helpers.py +++ b/mealie/services/migrations/utils/database_helpers.py @@ -8,6 +8,7 @@ from mealie.repos.all_repositories import AllRepositories from mealie.repos.repository_factory import RepositoryGeneric from mealie.schema.recipe import RecipeCategory from mealie.schema.recipe.recipe import RecipeTag +from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave T = TypeVar("T", bound=BaseModel) @@ -19,7 +20,9 @@ class DatabaseMigrationHelpers: self.session = session self.db = db - def _get_or_set_generic(self, accessor: RepositoryGeneric, items: list[str], out_model: T) -> list[T]: + def _get_or_set_generic( + self, accessor: RepositoryGeneric, items: list[str], create_model: T, out_model: T + ) -> list[T]: """ Utility model for getting or setting categories or tags. This will only work for those two cases. @@ -30,22 +33,32 @@ class DatabaseMigrationHelpers: for item_name in items: slug_lookup = slugify(item_name) - item_model = accessor.get_one(slug_lookup, "slug", override_schema=out_model) + item_model = accessor.get_one(value=slug_lookup, key="slug", override_schema=out_model) if not item_model: item_model = accessor.create( - out_model( + create_model( + group_id=self.group_id, name=item_name, slug=slug_lookup, ) ) items_out.append(item_model.dict()) - return items_out def get_or_set_category(self, categories: list[str]) -> list[RecipeCategory]: - return self._get_or_set_generic(self.db.categories, categories, RecipeCategory) + return self._get_or_set_generic( + self.db.categories.by_group(self.group_id), + categories, + CategorySave, + CategoryOut, + ) def get_or_set_tags(self, tags: list[str]) -> list[RecipeTag]: - return self._get_or_set_generic(self.db.tags, tags, RecipeTag) + return self._get_or_set_generic( + self.db.tags.by_group(self.group_id), + tags, + TagSave, + TagOut, + ) diff --git a/mealie/services/migrations/utils/migration_helpers.py b/mealie/services/migrations/utils/migration_helpers.py index 526a796f55fa..a25c2b7edf2b 100644 --- a/mealie/services/migrations/utils/migration_helpers.py +++ b/mealie/services/migrations/utils/migration_helpers.py @@ -2,8 +2,9 @@ import json from pathlib import Path import yaml +from pydantic import UUID4 -from mealie.services.image import image +from mealie.services.recipe.recipe_data_service import RecipeDataService class MigrationReaders: @@ -26,8 +27,7 @@ class MigrationReaders: with open(yaml_file, "r") as f: contents = f.read().split("---") recipe_data = {} - for _, document in enumerate(contents): - + for document in contents: # Check if None or Empty String if document is None or document == "": continue @@ -81,9 +81,10 @@ def glob_walker(directory: Path, glob_str: str, return_parent=True) -> list[Path return matches -def import_image(src: Path, dest_slug: str): +def import_image(src: Path, recipe_id: UUID4): """Read the successful migrations attribute and for each import the image appropriately into the image directory. Minification is done in mass after the migration occurs. """ - image.write_image(dest_slug, src, extension=src.suffix) + data_service = RecipeDataService(recipe_id=recipe_id) + data_service.write_image(src, src.suffix) diff --git a/mealie/services/recipe/recipe_data_service.py b/mealie/services/recipe/recipe_data_service.py new file mode 100644 index 000000000000..1fb53a926921 --- /dev/null +++ b/mealie/services/recipe/recipe_data_service.py @@ -0,0 +1,108 @@ +import shutil +from pathlib import Path + +import requests +from pydantic import UUID4 + +from mealie.pkgs import img +from mealie.schema.recipe.recipe import Recipe +from mealie.services._base_service import BaseService + +_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" + + +class RecipeDataService(BaseService): + minifier: img.ABCMinifier + + def __init__(self, recipe_id: UUID4, group_id: UUID4 = None) -> None: + """ + RecipeDataService is a service that consolidates the reading/writing actions related + to assets, and images for a recipe. + """ + super().__init__() + + self.recipe_id = recipe_id + self.slug = group_id + self.minifier = img.PillowMinifier(purge=True, logger=self.logger) + + self.dir_data = Recipe.directory_from_id(self.recipe_id) + self.dir_image = self.dir_data.joinpath("images") + self.dir_assets = self.dir_data.joinpath("assets") + + self.dir_image.mkdir(parents=True, exist_ok=True) + self.dir_assets.mkdir(parents=True, exist_ok=True) + + def delete_all_data(self) -> None: + try: + shutil.rmtree(self.dir_data) + except Exception as e: + self.logger.exception(f"Failed to delete recipe data: {e}") + + def write_image(self, file_data: bytes, extension: str) -> Path: + extension = extension.replace(".", "") + image_path = self.dir_image.joinpath(f"original.{extension}") + image_path.unlink(missing_ok=True) + + if isinstance(file_data, Path): + shutil.copy2(file_data, image_path) + elif isinstance(file_data, bytes): + with open(image_path, "ab") as f: + f.write(file_data) + else: + with open(image_path, "ab") as f: + shutil.copyfileobj(file_data, f) + + self.minifier.minify(image_path) + + return image_path + + def scrape_image(self, image_url) -> None: + self.logger.info(f"Image URL: {image_url}") + + if isinstance(image_url, str): # Handles String Types + pass + + elif isinstance(image_url, list): # Handles List Types + # Multiple images have been defined in the schema - usually different resolutions + # Typically would be in smallest->biggest order, but can't be certain so test each. + # 'Google will pick the best image to display in Search results based on the aspect ratio and resolution.' + + all_image_requests = [] + for url in image_url: + if isinstance(url, dict): + url = url.get("url", "") + try: + r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA}) + except Exception: + self.logger.exception("Image {url} could not be requested") + continue + if r.status_code == 200: + all_image_requests.append((url, r)) + + image_url, _ = max(all_image_requests, key=lambda url_r: len(url_r[1].content), default=("", 0)) + + elif isinstance(image_url, dict): # Handles Dictionary Types + for key in image_url: + if key == "url": + image_url = image_url.get("url") + + ext = image_url.split(".")[-1] + + if ext not in img.IMAGE_EXTENSIONS: + ext = "jpg" # Guess the extension + + filename = str(self.recipe_id) + "." + ext + filename = Recipe.directory_from_id(self.recipe_id).joinpath("images", filename) + + try: + r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA}) + except Exception: + self.logger.exception("Fatal Image Request Exception") + return None + + if r.status_code == 200: + r.raw.decode_content = True + self.logger.info(f"File Name Suffix {filename.suffix}") + self.write_image(r.raw, filename.suffix) + + filename.unlink(missing_ok=True) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 43e875987141..4858f25b9560 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -15,7 +15,7 @@ from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.user.user import GroupInDB, PrivateUser from mealie.services._base_service import BaseService -from mealie.services.image.image import write_image +from mealie.services.recipe.recipe_data_service import RecipeDataService from .template_service import TemplateService @@ -142,7 +142,8 @@ class RecipeService(BaseService): recipe = self.create_one(Recipe(**recipe_dict)) if recipe: - write_image(recipe.slug, recipe_image, "webp") + data_service = RecipeDataService(recipe.id) + data_service.write_image(recipe_image, "webp") return recipe diff --git a/mealie/services/scraper/cleaner.py b/mealie/services/scraper/cleaner.py index 8ab8e93f6d53..0f65b40cc084 100644 --- a/mealie/services/scraper/cleaner.py +++ b/mealie/services/scraper/cleaner.py @@ -43,6 +43,9 @@ def clean_string(text: str) -> str: if isinstance(text, list): text = text[0] + if isinstance(text, int): + text = str(text) + if text == "" or text is None: return "" diff --git a/mealie/services/scraper/scraper.py b/mealie/services/scraper/scraper.py index a99f60ca1070..dd2b33929469 100644 --- a/mealie/services/scraper/scraper.py +++ b/mealie/services/scraper/scraper.py @@ -5,8 +5,9 @@ from fastapi import HTTPException, status from slugify import slugify from mealie.core.root_logger import get_logger +from mealie.pkgs import cache from mealie.schema.recipe import Recipe -from mealie.services.image.image import scrape_image +from mealie.services.recipe.recipe_data_service import RecipeDataService from .recipe_scraper import RecipeScraper @@ -29,29 +30,26 @@ def create_from_url(url: str) -> Recipe: """ scraper = RecipeScraper() new_recipe = scraper.scrape(url) + new_recipe.id = uuid4() if not new_recipe: raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.BAD_RECIPE_DATA.value}) logger = get_logger() logger.info(f"Image {new_recipe.image}") - new_recipe.image = download_image_for_recipe(new_recipe.slug, new_recipe.image) + + recipe_data_service = RecipeDataService(new_recipe.id) + + try: + recipe_data_service.scrape_image(new_recipe.image) + new_recipe.name = slugify(new_recipe.name) + new_recipe.image = cache.new_key(4) + except Exception as e: + recipe_data_service.logger.exception(f"Error Scraping Image: {e}") + new_recipe.image = "no image" if new_recipe.name is None or new_recipe.name == "": - new_recipe.name = "No Recipe Found - " + uuid4().hex + new_recipe.name = "No Recipe Name Found - " + str(uuid4()) new_recipe.slug = slugify(new_recipe.name) return new_recipe - - -def download_image_for_recipe(slug, image_url) -> str | None: - img_name = None - try: - img_path = scrape_image(image_url, slug) - img_name = img_path.name - except Exception as e: - logger = get_logger() - logger.error(f"Error Scraping Image: {e}") - img_name = None - - return img_name or "no image" diff --git a/mealie/utils/__init__.py b/mealie/utils/__init__.py deleted file mode 100644 index 07bbe450d4d9..000000000000 --- a/mealie/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cache_key import new_cache_key diff --git a/mealie/utils/post_webhooks.py b/mealie/utils/post_webhooks.py deleted file mode 100644 index 8bda34be0fcb..000000000000 --- a/mealie/utils/post_webhooks.py +++ /dev/null @@ -1,7 +0,0 @@ -from asyncio.log import logger - -from sqlalchemy.orm.session import Session - - -def post_webhooks(group: int, session: Session = None, force=True): - logger.error("post_webhooks is depreciated") diff --git a/tests/fixtures/fixture_recipe.py b/tests/fixtures/fixture_recipe.py index 6223a5d0e5b8..cfbd4c70328d 100644 --- a/tests/fixtures/fixture_recipe.py +++ b/tests/fixtures/fixture_recipe.py @@ -2,7 +2,8 @@ import sqlalchemy from pytest import fixture from mealie.repos.repository_factory import AllRepositories -from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe import Recipe, RecipeCategory +from mealie.schema.recipe.recipe_category import CategorySave from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -49,3 +50,23 @@ def recipe_ingredient_only(database: AllRepositories, unique_user: TestUser): database.recipes.delete(model.slug) except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test pass + + +@fixture(scope="function") +def recipe_categories(database: AllRepositories, unique_user: TestUser) -> list[RecipeCategory]: + models: list[RecipeCategory] = [] + for _ in range(3): + category = CategorySave( + group_id=unique_user.group_id, + name=random_string(10), + ) + model = database.categories.create(category) + models.append(model) + + yield models + + for model in models: + try: + database.categories.delete(model.id) + except sqlalchemy.exc.NoResultFound: + pass diff --git a/tests/integration_tests/category_tag_tool_tests/test_category.py b/tests/integration_tests/category_tag_tool_tests/test_category.py deleted file mode 100644 index 24890667e361..000000000000 --- a/tests/integration_tests/category_tag_tool_tests/test_category.py +++ /dev/null @@ -1,107 +0,0 @@ -from dataclasses import dataclass - -import pytest -from fastapi.testclient import TestClient - -from mealie.schema.static import recipe_keys -from tests.utils.factories import random_string -from tests.utils.fixture_schemas import TestUser - - -class Routes: - base = "/api/categories" - recipes = "/api/recipes" - - def item(item_id: int) -> str: - return f"{Routes.base}/{item_id}" - - def recipe(recipe_id: int) -> str: - return f"{Routes.recipes}/{recipe_id}" - - -@dataclass -class TestRecipeCategory: - id: int - name: str - slug: str - recipes: list - - -@pytest.fixture(scope="function") -def category(api_client: TestClient, unique_user: TestUser) -> TestRecipeCategory: - data = {"name": random_string(10)} - - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - - assert response.status_code == 201 - - as_json = response.json() - - yield TestRecipeCategory( - id=as_json["id"], - name=data["name"], - slug=as_json["slug"], - recipes=[], - ) - - try: - response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token) - except Exception: - pass - - -def test_create_category(api_client: TestClient, unique_user: TestUser): - data = {"name": random_string(10)} - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - assert response.status_code == 201 - - -def test_read_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): - response = api_client.get(Routes.item(category.slug), headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == category.id - assert as_json["name"] == category.name - - -def test_update_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): - update_data = { - "id": category.id, - "name": random_string(10), - "slug": category.slug, - } - - response = api_client.put(Routes.item(category.slug), json=update_data, headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == category.id - assert as_json["name"] == update_data["name"] - - -def test_delete_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): - response = api_client.delete(Routes.item(category.slug), headers=unique_user.token) - assert response.status_code == 200 - - -def test_recipe_category_association(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): - # Setup Recipe - recipe_data = {"name": random_string(10)} - response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) - slug = response.json() - assert response.status_code == 201 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - as_json[recipe_keys.recipe_category] = [{"id": category.id, "name": category.name, "slug": category.slug}] - - # Update Recipe - response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) - assert response.status_code == 200 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - assert as_json[recipe_keys.recipe_category][0]["slug"] == category.slug diff --git a/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py b/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py new file mode 100644 index 000000000000..475dab3a84f1 --- /dev/null +++ b/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py @@ -0,0 +1,198 @@ +import pytest +from fastapi.testclient import TestClient + +from mealie.schema.static import recipe_keys +from tests.utils import routes +from tests.utils.factories import random_bool, random_string +from tests.utils.fixture_schemas import TestUser + +# Test IDs to be used to identify the test cases - order matters! +test_ids = [ + "category", + "tags", + "tools", +] + +organizer_routes = [ + (routes.RoutesCategory), + (routes.RoutesTags), + (routes.RoutesTools), +] + + +@pytest.mark.parametrize("route", organizer_routes, ids=test_ids) +def test_organizers_create_read(api_client: TestClient, unique_user: TestUser, route: routes.RoutesBase): + data = {"name": random_string(10)} + + response = api_client.post(route.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + + item_id = response.json()["id"] + + response = api_client.get(route.item(item_id), headers=unique_user.token) + assert response.status_code == 200 + + item = response.json() + + assert item["id"] == item_id + assert item["name"] == data["name"] + assert item["slug"] == data["name"] + + response = api_client.delete(route.item(item_id), headers=unique_user.token) + assert response.status_code == 200 + + +update_data = [ + (routes.RoutesCategory, {"name": random_string(10)}), + (routes.RoutesTags, {"name": random_string(10)}), + (routes.RoutesTools, {"name": random_string(10), "onHand": random_bool()}), +] + + +@pytest.mark.parametrize("route, update_data", update_data, ids=test_ids) +def test_organizer_update( + api_client: TestClient, + unique_user: TestUser, + route: routes.RoutesBase, + update_data: dict, +): + data = {"name": random_string(10)} + + response = api_client.post(route.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + item = response.json() + item_id = item["id"] + + # Update the item if the key is presetn in the update_data + for key in update_data: + if key in item: + item[key] = update_data[key] + + response = api_client.put(route.item(item_id), json=item, headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.get(route.item(item_id), headers=unique_user.token) + + item = response.json() + + for key, value in update_data.items(): + if key in item: + assert item[key] == value + + +@pytest.mark.parametrize("route", organizer_routes, ids=test_ids) +def test_organizer_delete( + api_client: TestClient, + unique_user: TestUser, + route: routes.RoutesBase, +): + data = {"name": random_string(10)} + + response = api_client.post(route.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + item = response.json() + item_id = item["id"] + + response = api_client.delete(route.item(item_id), headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.get(route.item(item_id), headers=unique_user.token) + assert response.status_code == 404 + + +association_data = [ + (routes.RoutesCategory, recipe_keys.recipe_category), + (routes.RoutesTags, "tags"), + (routes.RoutesTools, "tools"), +] + + +@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids) +def test_organizer_association( + api_client: TestClient, + unique_user: TestUser, + route: routes.RoutesBase, + recipe_key: str, +): + data = {"name": random_string(10)} + + # Setup Organizer + response = api_client.post(route.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + item = response.json() + + # Setup Recipe + recipe_data = {"name": random_string(10)} + response = api_client.post(routes.RoutesRecipe.base, json=recipe_data, headers=unique_user.token) + slug = response.json() + assert response.status_code == 201 + + # Get Recipe Data + response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token) + as_json = response.json() + as_json[recipe_key] = [{"id": item["id"], "name": item["name"], "slug": item["slug"]}] + + # Update Recipe + response = api_client.put(routes.RoutesRecipe.item(slug), json=as_json, headers=unique_user.token) + assert response.status_code == 200 + + # Get Recipe Data + response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token) + as_json = response.json() + assert as_json[recipe_key][0]["slug"] == item["slug"] + + # Cleanup + response = api_client.delete(routes.RoutesRecipe.item(slug), headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.delete(route.item(item["id"]), headers=unique_user.token) + assert response.status_code == 200 + + +@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids) +def test_organizer_get_by_slug( + api_client: TestClient, + unique_user: TestUser, + route: routes.RoutesOrganizerBase, + recipe_key: str, +): + # Create Organizer + data = {"name": random_string(10)} + response = api_client.post(route.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + item = response.json() + + # Create 10 Recipes + recipe_slugs = [] + + for _ in range(10): + # Setup Recipe + recipe_data = {"name": random_string(10)} + response = api_client.post(routes.RoutesRecipe.base, json=recipe_data, headers=unique_user.token) + assert response.status_code == 201 + slug = response.json() + recipe_slugs.append(slug) + + # Associate 10 Recipes to Organizer + for slug in recipe_slugs: + response = api_client.get(routes.RoutesRecipe.item(slug), headers=unique_user.token) + as_json = response.json() + as_json[recipe_key] = [{"id": item["id"], "name": item["name"], "slug": item["slug"]}] + + response = api_client.put(routes.RoutesRecipe.item(slug), json=as_json, headers=unique_user.token) + assert response.status_code == 200 + + # Get Organizer by Slug + response = api_client.get(route.slug(item["slug"]), headers=unique_user.token) + assert response.status_code == 200 + + as_json = response.json() + assert as_json["slug"] == item["slug"] + + recipes = as_json["recipes"] + + # Check if Organizer is returned with 10 RecipeSummary + assert len(recipes) == len(recipe_slugs) + + for recipe in recipes: + assert recipe["slug"] in recipe_slugs diff --git a/tests/integration_tests/category_tag_tool_tests/test_tags.py b/tests/integration_tests/category_tag_tool_tests/test_tags.py deleted file mode 100644 index 6aa46d9cbe6a..000000000000 --- a/tests/integration_tests/category_tag_tool_tests/test_tags.py +++ /dev/null @@ -1,106 +0,0 @@ -from dataclasses import dataclass - -import pytest -from fastapi.testclient import TestClient - -from tests.utils.factories import random_string -from tests.utils.fixture_schemas import TestUser - - -class Routes: - base = "/api/tags" - recipes = "/api/recipes" - - def item(item_id: int) -> str: - return f"{Routes.base}/{item_id}" - - def recipe(recipe_id: int) -> str: - return f"{Routes.recipes}/{recipe_id}" - - -@dataclass -class TestRecipeTag: - id: int - name: str - slug: str - recipes: list - - -@pytest.fixture(scope="function") -def tag(api_client: TestClient, unique_user: TestUser) -> TestRecipeTag: - data = {"name": random_string(10)} - - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - - assert response.status_code == 201 - - as_json = response.json() - - yield TestRecipeTag( - id=as_json["id"], - name=data["name"], - slug=as_json["slug"], - recipes=[], - ) - - try: - response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token) - except Exception: - pass - - -def test_create_tag(api_client: TestClient, unique_user: TestUser): - data = {"name": random_string(10)} - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - assert response.status_code == 201 - - -def test_read_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): - response = api_client.get(Routes.item(tag.slug), headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == tag.id - assert as_json["name"] == tag.name - - -def test_update_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): - update_data = { - "id": tag.id, - "name": random_string(10), - "slug": tag.slug, - } - - response = api_client.put(Routes.item(tag.slug), json=update_data, headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == tag.id - assert as_json["name"] == update_data["name"] - - -def test_delete_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): - response = api_client.delete(Routes.item(tag.slug), headers=unique_user.token) - assert response.status_code == 200 - - -def test_recipe_tag_association(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): - # Setup Recipe - recipe_data = {"name": random_string(10)} - response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) - slug = response.json() - assert response.status_code == 201 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - as_json["tags"] = [{"id": tag.id, "name": tag.name, "slug": tag.slug}] - - # Update Recipe - response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) - assert response.status_code == 200 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - assert as_json["tags"][0]["slug"] == tag.slug diff --git a/tests/integration_tests/category_tag_tool_tests/test_tools.py b/tests/integration_tests/category_tag_tool_tests/test_tools.py deleted file mode 100644 index 77636de03271..000000000000 --- a/tests/integration_tests/category_tag_tool_tests/test_tools.py +++ /dev/null @@ -1,110 +0,0 @@ -from dataclasses import dataclass - -import pytest -from fastapi.testclient import TestClient - -from tests.utils.factories import random_string -from tests.utils.fixture_schemas import TestUser - - -class Routes: - base = "/api/tools" - recipes = "/api/recipes" - - def item(item_id: int) -> str: - return f"{Routes.base}/{item_id}" - - def recipe(recipe_id: int) -> str: - return f"{Routes.recipes}/{recipe_id}" - - -@dataclass -class TestRecipeTool: - id: int - name: str - slug: str - on_hand: bool - recipes: list - - -@pytest.fixture(scope="function") -def tool(api_client: TestClient, unique_user: TestUser) -> TestRecipeTool: - data = {"name": random_string(10)} - - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - - assert response.status_code == 201 - - as_json = response.json() - - yield TestRecipeTool( - id=as_json["id"], - name=data["name"], - slug=as_json["slug"], - on_hand=as_json["onHand"], - recipes=[], - ) - - try: - response = api_client.delete(Routes.item(response.json()["id"]), headers=unique_user.token) - except Exception: - pass - - -def test_create_tool(api_client: TestClient, unique_user: TestUser): - data = {"name": random_string(10)} - - response = api_client.post(Routes.base, json=data, headers=unique_user.token) - assert response.status_code == 201 - - -def test_read_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): - response = api_client.get(Routes.item(tool.id), headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == tool.id - assert as_json["name"] == tool.name - - -def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): - update_data = { - "id": tool.id, - "name": random_string(10), - "slug": tool.slug, - "on_hand": True, - } - - response = api_client.put(Routes.item(tool.id), json=update_data, headers=unique_user.token) - assert response.status_code == 200 - - as_json = response.json() - assert as_json["id"] == tool.id - assert as_json["name"] == update_data["name"] - - -def test_delete_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): - response = api_client.delete(Routes.item(tool.id), headers=unique_user.token) - assert response.status_code == 200 - - -def test_recipe_tool_association(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): - # Setup Recipe - recipe_data = {"name": random_string(10)} - response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) - slug = response.json() - assert response.status_code == 201 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - as_json["tools"] = [{"id": tool.id, "name": tool.name, "slug": tool.slug}] - - # Update Recipe - response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) - assert response.status_code == 200 - - # Get Recipe Data - response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - assert as_json["tools"][0]["id"] == tool.id diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan.py b/tests/integration_tests/user_group_tests/test_group_mealplan.py index cc4037f5212c..2d153a68c6b2 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan.py @@ -48,6 +48,7 @@ def test_create_mealplan_with_recipe(api_client: TestClient, unique_user: TestUs new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True) new_plan["date"] = date.today().strftime("%Y-%m-%d") + new_plan["recipeId"] = str(recipe_id) response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token) response_json = response.json() diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py b/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py deleted file mode 100644 index bb20aed8af5d..000000000000 --- a/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py +++ /dev/null @@ -1,38 +0,0 @@ -from fastapi.testclient import TestClient - -from mealie.repos.all_repositories import AllRepositories -from tests.utils.assertion_helpers import assert_ignore_keys -from tests.utils.fixture_schemas import TestUser - - -class Routes: - base = "/api/groups/categories" - - @staticmethod - def item(item_id: int | str) -> str: - return f"{Routes.base}/{item_id}" - - -def test_group_mealplan_set_preferences(api_client: TestClient, unique_user: TestUser, database: AllRepositories): - # Create Categories - categories = [{"name": x} for x in ["Breakfast", "Lunch", "Dinner"]] - - created = [] - for category in categories: - create = database.categories.create(category) - created.append(create.dict()) - - # Set Category Preferences - response = api_client.put(Routes.base, json=created, headers=unique_user.token) - assert response.status_code == 200 - - # Get Category Preferences - response = api_client.get(Routes.base, headers=unique_user.token) - assert response.status_code == 200 - - as_dict = response.json() - - assert len(as_dict) == len(categories) - - for api_data, expected in zip(as_dict, created): - assert_ignore_keys(api_data, expected, ["id", "recipes"]) diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py index 74cda06ff8eb..6b8ebd2bfd04 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py @@ -7,6 +7,7 @@ from pydantic import UUID4 from mealie.repos.all_repositories import AllRepositories from mealie.schema.meal_plan.plan_rules import PlanRulesOut, PlanRulesSave from mealie.schema.recipe.recipe import RecipeCategory +from mealie.schema.recipe.recipe_category import CategorySave from tests import utils from tests.utils.fixture_schemas import TestUser @@ -20,9 +21,12 @@ class Routes: @pytest.fixture(scope="function") -def category(database: AllRepositories): +def category( + database: AllRepositories, + unique_user: TestUser, +): slug = utils.random_string(length=10) - model = database.categories.create(RecipeCategory(slug=slug, name=slug)) + model = database.categories.create(CategorySave(group_id=unique_user.group_id, slug=slug, name=slug)) yield model @@ -61,7 +65,7 @@ def test_group_mealplan_rules_create( "categories": [category.dict()], } - response = api_client.post(Routes.base, json=payload, headers=unique_user.token) + response = api_client.post(Routes.base, json=utils.jsonify(payload), headers=unique_user.token) assert response.status_code == 201 # Validate the response data diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py index 888972a15662..a45c336ad66d 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -121,7 +121,7 @@ def test_shopping_lists_add_recipe( assert len(refs) == 1 - assert refs[0]["recipeId"] == recipe.id + assert refs[0]["recipeId"] == str(recipe.id) def test_shopping_lists_remove_recipe( @@ -198,4 +198,4 @@ def test_shopping_lists_remove_recipe_multiple_quantity( refs = as_json["recipeReferences"] assert len(refs) == 1 - assert refs[0]["recipeId"] == recipe.id + assert refs[0]["recipeId"] == str(recipe.id) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py index 25cc15fe006b..b091589ab58b 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py @@ -7,7 +7,8 @@ from fastapi.testclient import TestClient from mealie.core.dependencies.dependencies import validate_file_token from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe_bulk_actions import ExportTypes -from mealie.schema.recipe.recipe_category import TagIn +from mealie.schema.recipe.recipe_category import CategorySave, TagSave +from tests import utils from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -20,8 +21,8 @@ class Routes: bulk_delete = "api/recipes/bulk-actions/delete" bulk_export = "api/recipes/bulk-actions/export" - bulk_export_download = bulk_export + "/download" - bulk_export_purge = bulk_export + "/purge" + bulk_export_download = f"{bulk_export}/download" + bulk_export_purge = f"{bulk_export}/purge" @pytest.fixture(scope="function") @@ -53,12 +54,12 @@ def test_bulk_tag_recipes( tags = [] for _ in range(3): tag_name = random_string() - tag = database.tags.create(TagIn(name=tag_name)) + tag = database.tags.create(TagSave(group_id=unique_user.group_id, name=tag_name)) tags.append(tag.dict()) payload = {"recipes": ten_slugs, "tags": tags} - response = api_client.post(Routes.bulk_tag, json=payload, headers=unique_user.token) + response = api_client.post(Routes.bulk_tag, json=utils.jsonify(payload), headers=unique_user.token) assert response.status_code == 200 # Validate Recipes are Tagged @@ -79,12 +80,12 @@ def test_bulk_categorize_recipes( categories = [] for _ in range(3): cat_name = random_string() - cat = database.tags.create(TagIn(name=cat_name)) + cat = database.categories.create(CategorySave(group_id=unique_user.group_id, name=cat_name)) categories.append(cat.dict()) payload = {"recipes": ten_slugs, "categories": categories} - response = api_client.post(Routes.bulk_categorize, json=payload, headers=unique_user.token) + response = api_client.post(Routes.bulk_categorize, json=utils.jsonify(payload), headers=unique_user.token) assert response.status_code == 200 # Validate Recipes are Categorized @@ -140,7 +141,7 @@ def test_bulk_export_recipes(api_client: TestClient, unique_user: TestUser, ten_ assert validate_file_token(response_data["fileToken"]) == Path(export_path) # Use Export Token to donwload export - response = api_client.get("/api/utils/download?token=" + response_data["fileToken"]) + response = api_client.get(f'/api/utils/download?token={response_data["fileToken"]}') assert response.status_code == 200 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_comments.py b/tests/integration_tests/user_recipe_tests/test_recipe_comments.py index 81090d66dd31..d8704421aef2 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_comments.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_comments.py @@ -1,5 +1,6 @@ import pytest from fastapi.testclient import TestClient +from pydantic import UUID4 from mealie.schema.recipe.recipe import Recipe from tests.utils.factories import random_string @@ -32,11 +33,11 @@ def unique_recipe(api_client: TestClient, unique_user: TestUser): return Recipe(**recipe_response.json()) -def random_comment(recipe_id: int) -> dict: +def random_comment(recipe_id: UUID4) -> dict: if recipe_id is None: raise ValueError("recipe_id is required") return { - "recipeId": recipe_id, + "recipeId": str(recipe_id), "text": random_string(length=50), } @@ -49,7 +50,7 @@ def test_create_comment(api_client: TestClient, unique_recipe: Recipe, unique_us response_data = response.json() - assert response_data["recipeId"] == unique_recipe.id + assert response_data["recipeId"] == str(unique_recipe.id) assert response_data["text"] == create_data["text"] assert response_data["userId"] == unique_user.user_id @@ -60,7 +61,7 @@ def test_create_comment(api_client: TestClient, unique_recipe: Recipe, unique_us response_data = response.json() assert len(response_data) == 1 - assert response_data[0]["recipeId"] == unique_recipe.id + assert response_data[0]["recipeId"] == str(unique_recipe.id) assert response_data[0]["text"] == create_data["text"] assert response_data[0]["userId"] == unique_user.user_id @@ -83,7 +84,7 @@ def test_update_comment(api_client: TestClient, unique_recipe: Recipe, unique_us response_data = response.json() - assert response_data["recipeId"] == unique_recipe.id + assert response_data["recipeId"] == str(unique_recipe.id) assert response_data["text"] == update_data["text"] assert response_data["userId"] == unique_user.user_id diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 58c6914537fa..fef2818c4ab5 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -10,8 +10,10 @@ from recipe_scrapers._abstract import AbstractScraper from recipe_scrapers._schemaorg import SchemaOrg from slugify import slugify -from mealie.services.scraper import scraper +from mealie.schema.recipe.recipe import RecipeCategory +from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph +from tests import utils from tests.utils.app_routes import AppRoutes from tests.utils.fixture_schemas import TestUser from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases @@ -73,8 +75,8 @@ def test_create_by_url( ) # Skip image downloader monkeypatch.setattr( - scraper, - "download_image_for_recipe", + RecipeDataService, + "scrape_image", lambda *_: "TEST_IMAGE", ) @@ -88,7 +90,11 @@ def test_create_by_url( @pytest.mark.parametrize("recipe_data", recipe_test_data) def test_read_update( - api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser + api_client: TestClient, + api_routes: AppRoutes, + recipe_data: RecipeSiteTestCase, + unique_user: TestUser, + recipe_categories: list[RecipeCategory], ): recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug) response = api_client.get(recipe_url, headers=unique_user.token) @@ -103,14 +109,9 @@ def test_read_update( recipe["notes"] = test_notes - test_categories = [ - {"name": "one", "slug": "one"}, - {"name": "two", "slug": "two"}, - {"name": "three", "slug": "three"}, - ] - recipe["recipeCategory"] = test_categories + recipe["recipeCategory"] = [x.dict() for x in recipe_categories] - response = api_client.put(recipe_url, json=recipe, headers=unique_user.token) + response = api_client.put(recipe_url, json=utils.jsonify(recipe), headers=unique_user.token) assert response.status_code == 200 assert json.loads(response.text).get("slug") == recipe_data.expected_slug @@ -121,10 +122,10 @@ def test_read_update( assert recipe["notes"] == test_notes - assert len(recipe["recipeCategory"]) == len(test_categories) + assert len(recipe["recipeCategory"]) == len(recipe_categories) - test_name = [x["name"] for x in test_categories] - for cats in zip(recipe["recipeCategory"], test_categories): + test_name = [x.name for x in recipe_categories] + for cats in zip(recipe["recipeCategory"], recipe_categories): assert cats[0]["name"] in test_name diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py b/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py new file mode 100644 index 000000000000..b269a248f0a9 --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py @@ -0,0 +1,64 @@ +import filecmp + +from fastapi.testclient import TestClient +from slugify import slugify + +from mealie.schema.recipe.recipe import Recipe +from tests import data +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +def test_recipe_assets_create(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe): + recipe = recipe_ingredient_only + payload = { + "slug": recipe.slug, + "name": random_string(10), + "icon": random_string(10), + "extension": "jpg", + } + + file_payload = { + "file": data.images_test_image_1.read_bytes(), + } + + response = api_client.post( + f"/api/recipes/{recipe.slug}/assets", + data=payload, + files=file_payload, + headers=unique_user.token, + ) + assert response.status_code == 200 + + # Ensure asset was created + asset_path = recipe.asset_dir / str(slugify(payload["name"]) + "." + payload["extension"]) + + assert asset_path.exists() + assert filecmp.cmp(asset_path, data.images_test_image_1) + + # Ensure asset data is included in recipe + response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token) + recipe_respons = response.json() + + assert recipe_respons["assets"][0]["name"] == payload["name"] + + +def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe): + data_payload = {"extension": "jpg"} + file_payload = {"image": data.images_test_image_1.read_bytes()} + + response = api_client.put( + f"/api/recipes/{recipe_ingredient_only.slug}/image", + data=data_payload, + files=file_payload, + headers=unique_user.token, + ) + + assert response.status_code == 200 + + image_version = response.json()["image"] + + # Get Recipe check for version + response = api_client.get(f"/api/recipes/{recipe_ingredient_only.slug}", headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] == image_version diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py b/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py index 2927b4488c52..8edcf5a3a18e 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py @@ -89,7 +89,7 @@ def test_recipe_share_tokens_create_and_get_one( recipe = database.recipes.get_one(slug) payload = { - "recipe_id": recipe.id, + "recipeId": str(recipe.id), } response = api_client.post(Routes.base, json=payload, headers=unique_user.token) @@ -99,7 +99,7 @@ def test_recipe_share_tokens_create_and_get_one( assert response.status_code == 200 response_data = response.json() - assert response_data["recipe"]["id"] == recipe.id + assert response_data["recipe"]["id"] == str(recipe.id) def test_recipe_share_tokens_delete_one( diff --git a/tests/multitenant_tests/case_abc.py b/tests/multitenant_tests/case_abc.py new file mode 100644 index 000000000000..a4f235599072 --- /dev/null +++ b/tests/multitenant_tests/case_abc.py @@ -0,0 +1,35 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from fastapi import Response +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories + + +class ABCMultiTenantTestCase(ABC): + def __init__(self, database: AllRepositories, client: TestClient) -> None: + self.database = database + self.client = client + self.items = [] + + @abstractmethod + def seed_action(repos: AllRepositories, group_id: str) -> set[int] | set[str]: + ... + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[int], set[int]]: + pass + + @abstractmethod + def get_all(token: str) -> Response: + ... + + @abstractmethod + def cleanup(self) -> None: + ... + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() diff --git a/tests/multitenant_tests/case_categories.py b/tests/multitenant_tests/case_categories.py new file mode 100644 index 000000000000..769c6ed9082f --- /dev/null +++ b/tests/multitenant_tests/case_categories.py @@ -0,0 +1,53 @@ +from typing import Tuple + +from requests import Response + +from mealie.schema.recipe.recipe import RecipeCategory +from mealie.schema.recipe.recipe_category import CategorySave +from tests import utils +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.utils import routes + + +class CategoryTestCase(ABCMultiTenantTestCase): + items: list[RecipeCategory] + + def seed_action(self, group_id: str) -> set[int]: + category_ids: set[int] = set() + for _ in range(10): + category = self.database.categories.create( + CategorySave( + group_id=group_id, + name=utils.random_string(10), + ) + ) + + self.items.append(category) + category_ids.add(str(category.id)) + + return category_ids + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]: + g1_item_ids = set() + g2_item_ids = set() + + for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]: + for _ in range(10): + name = utils.random_string(10) + category = self.database.categories.create( + CategorySave( + group_id=group_id, + name=name, + ) + ) + item_ids.add(str(category.id)) + self.items.append(category) + + return g1_item_ids, g2_item_ids + + def get_all(self, token: str) -> Response: + return self.client.get(routes.RoutesCategory.base, headers=token) + + def cleanup(self) -> None: + for item in self.items: + self.database.categories.delete(item.id) diff --git a/tests/multitenant_tests/case_foods.py b/tests/multitenant_tests/case_foods.py new file mode 100644 index 000000000000..e28206f5f663 --- /dev/null +++ b/tests/multitenant_tests/case_foods.py @@ -0,0 +1,52 @@ +from typing import Tuple + +from requests import Response + +from mealie.schema.recipe.recipe_ingredient import IngredientFood, SaveIngredientFood +from tests import utils +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.utils import routes + + +class FoodsTestCase(ABCMultiTenantTestCase): + items: list[IngredientFood] + + def seed_action(self, group_id: str) -> set[int]: + food_ids: set[int] = set() + for _ in range(10): + food = self.database.ingredient_foods.create( + SaveIngredientFood( + group_id=group_id, + name=utils.random_string(10), + ) + ) + + food_ids.add(str(food.id)) + self.items.append(food) + + return food_ids + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]: + g1_item_ids = set() + g2_item_ids = set() + + for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]: + for _ in range(10): + name = utils.random_string(10) + food = self.database.ingredient_foods.create( + SaveIngredientFood( + group_id=group_id, + name=name, + ) + ) + item_ids.add(str(food.id)) + self.items.append(food) + + return g1_item_ids, g2_item_ids + + def get_all(self, token: str) -> Response: + return self.client.get(routes.RoutesFoods.base, headers=token) + + def cleanup(self) -> None: + for item in self.items: + self.database.ingredient_foods.delete(item.id) diff --git a/tests/multitenant_tests/case_tags.py b/tests/multitenant_tests/case_tags.py new file mode 100644 index 000000000000..3b2f4aab4285 --- /dev/null +++ b/tests/multitenant_tests/case_tags.py @@ -0,0 +1,53 @@ +from typing import Tuple + +from requests import Response + +from mealie.schema.recipe.recipe import RecipeTag +from mealie.schema.recipe.recipe_category import TagSave +from tests import utils +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.utils import routes + + +class TagsTestCase(ABCMultiTenantTestCase): + items: list[RecipeTag] + + def seed_action(self, group_id: str) -> set[int]: + tag_ids: set[int] = set() + for _ in range(10): + tag = self.database.tags.create( + TagSave( + group_id=group_id, + name=utils.random_string(10), + ) + ) + + tag_ids.add(str(tag.id)) + self.items.append(tag) + + return tag_ids + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]: + g1_item_ids = set() + g2_item_ids = set() + + for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]: + for _ in range(10): + name = utils.random_string(10) + category = self.database.tags.create( + TagSave( + group_id=group_id, + name=name, + ) + ) + item_ids.add(str(category.id)) + self.items.append(category) + + return g1_item_ids, g2_item_ids + + def get_all(self, token: str) -> Response: + return self.client.get(routes.RoutesTags.base, headers=token) + + def cleanup(self) -> None: + for item in self.items: + self.database.tags.delete(item.id) diff --git a/tests/multitenant_tests/case_tools.py b/tests/multitenant_tests/case_tools.py new file mode 100644 index 000000000000..f1939cc79bbb --- /dev/null +++ b/tests/multitenant_tests/case_tools.py @@ -0,0 +1,53 @@ +from typing import Tuple + +from requests import Response + +from mealie.schema.recipe.recipe import RecipeTool +from mealie.schema.recipe.recipe_tool import RecipeToolSave +from tests import utils +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.utils import routes + + +class ToolsTestCase(ABCMultiTenantTestCase): + items: list[RecipeTool] + + def seed_action(self, group_id: str) -> set[int]: + tool_ids: set[int] = set() + for _ in range(10): + tool = self.database.tools.create( + RecipeToolSave( + group_id=group_id, + name=utils.random_string(10), + ) + ) + + tool_ids.add(str(tool.id)) + self.items.append(tool) + + return tool_ids + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[int], set[int]]: + g1_item_ids = set() + g2_item_ids = set() + + for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]: + for _ in range(10): + name = utils.random_string(10) + tool = self.database.tools.create( + RecipeToolSave( + group_id=group_id, + name=name, + ) + ) + item_ids.add(str(tool.id)) + self.items.append(tool) + + return g1_item_ids, g2_item_ids + + def get_all(self, token: str) -> Response: + return self.client.get(routes.RoutesTools.base, headers=token) + + def cleanup(self) -> None: + for item in self.items: + self.database.tools.delete(item.id) diff --git a/tests/multitenant_tests/case_units.py b/tests/multitenant_tests/case_units.py new file mode 100644 index 000000000000..4a0235cd7b1f --- /dev/null +++ b/tests/multitenant_tests/case_units.py @@ -0,0 +1,52 @@ +from typing import Tuple + +from requests import Response + +from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit +from tests import utils +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.utils import routes + + +class UnitsTestCase(ABCMultiTenantTestCase): + items: list[IngredientUnit] + + def seed_action(self, group_id: str) -> set[int]: + unit_ids: set[int] = set() + for _ in range(10): + unit = self.database.ingredient_units.create( + SaveIngredientUnit( + group_id=group_id, + name=utils.random_string(10), + ) + ) + + unit_ids.add(str(unit.id)) + self.items.append(unit) + + return unit_ids + + def seed_multi(self, group1_id: str, group2_id: str) -> Tuple[set[str], set[str]]: + g1_item_ids = set() + g2_item_ids = set() + + for group_id, item_ids in [(group1_id, g1_item_ids), (group2_id, g2_item_ids)]: + for _ in range(10): + name = utils.random_string(10) + food = self.database.ingredient_units.create( + SaveIngredientUnit( + group_id=group_id, + name=name, + ) + ) + item_ids.add(str(food.id)) + self.items.append(food) + + return g1_item_ids, g2_item_ids + + def get_all(self, token: str) -> Response: + return self.client.get(routes.RoutesUnits.base, headers=token) + + def cleanup(self) -> None: + for item in self.items: + self.database.ingredient_units.delete(item.id) diff --git a/tests/multitenant_tests/test_ingredient_food.py b/tests/multitenant_tests/test_ingredient_food.py deleted file mode 100644 index 600477cf9146..000000000000 --- a/tests/multitenant_tests/test_ingredient_food.py +++ /dev/null @@ -1,79 +0,0 @@ -from fastapi.testclient import TestClient - -from mealie.repos.repository_factory import AllRepositories -from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit -from tests import utils -from tests.fixtures.fixture_multitenant import MultiTenant -from tests.utils import routes - - -def test_foods_are_private_by_group( - api_client: TestClient, multitenants: MultiTenant, database: AllRepositories -) -> None: - user1 = multitenants.user_one - user2 = multitenants.user_two - - # Bootstrap foods for user1 - food_ids: set[int] = set() - for _ in range(10): - food = database.ingredient_foods.create( - SaveIngredientFood( - group_id=user1.group_id, - name=utils.random_string(10), - ) - ) - - food_ids.add(food.id) - - expected_results = [ - (user1.token, food_ids), - (user2.token, []), - ] - - for token, expected_food_ids in expected_results: - response = api_client.get(routes.RoutesFoods.base, headers=token) - assert response.status_code == 200 - - data = response.json() - - assert len(data) == len(expected_food_ids) - - if len(data) > 0: - for food in data: - assert food["id"] in expected_food_ids - - -def test_units_are_private_by_group( - api_client: TestClient, multitenants: MultiTenant, database: AllRepositories -) -> None: - user1 = multitenants.user_one - user2 = multitenants.user_two - - # Bootstrap foods for user1 - unit_ids: set[int] = set() - for _ in range(10): - food = database.ingredient_units.create( - SaveIngredientUnit( - group_id=user1.group_id, - name=utils.random_string(10), - ) - ) - - unit_ids.add(food.id) - - expected_results = [ - (user1.token, unit_ids), - (user2.token, []), - ] - - for token, expected_unit_ids in expected_results: - response = api_client.get(routes.RoutesUnits.base, headers=token) - assert response.status_code == 200 - - data = response.json() - - assert len(data) == len(expected_unit_ids) - - if len(data) > 0: - for food in data: - assert food["id"] in expected_unit_ids diff --git a/tests/multitenant_tests/test_multitenant_cases.py b/tests/multitenant_tests/test_multitenant_cases.py new file mode 100644 index 000000000000..a4bbc3a4a1c4 --- /dev/null +++ b/tests/multitenant_tests/test_multitenant_cases.py @@ -0,0 +1,95 @@ +from typing import Type + +import pytest +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories +from tests.fixtures.fixture_multitenant import MultiTenant +from tests.multitenant_tests.case_abc import ABCMultiTenantTestCase +from tests.multitenant_tests.case_categories import CategoryTestCase +from tests.multitenant_tests.case_foods import FoodsTestCase +from tests.multitenant_tests.case_tags import TagsTestCase +from tests.multitenant_tests.case_tools import ToolsTestCase +from tests.multitenant_tests.case_units import UnitsTestCase + +all_cases = [ + UnitsTestCase, + FoodsTestCase, + ToolsTestCase, + TagsTestCase, + CategoryTestCase, +] + + +@pytest.mark.parametrize("test_case", all_cases) +def test_multitenant_cases_get_all( + api_client: TestClient, + multitenants: MultiTenant, + database: AllRepositories, + test_case: Type[ABCMultiTenantTestCase], +): + """ + This test will run all the multitenant test cases and validate that they return only the data for their group. + When requesting all resources. + """ + + user1 = multitenants.user_one + user2 = multitenants.user_two + + test_case = test_case(database, api_client) + + with test_case: + expected_ids = test_case.seed_action(user1.group_id) + expected_results = [ + (user1.token, expected_ids), + (user2.token, []), + ] + + for token, item_ids in expected_results: + response = test_case.get_all(token) + assert response.status_code == 200 + + data = response.json() + + assert len(data) == len(item_ids) + + if len(data) > 0: + for item in data: + assert item["id"] in item_ids + + +@pytest.mark.parametrize("test_case", all_cases) +def test_multitenant_cases_same_named_resources( + api_client: TestClient, + multitenants: MultiTenant, + database: AllRepositories, + test_case: Type[ABCMultiTenantTestCase], +): + """ + This test is used to ensure that the same resource can be created with the same values in different tenants. + i.e. the same category can exist in multiple groups. This is important to validate that the compound unique constraints + are operating in SQLAlchemy correctly. + """ + user1 = multitenants.user_one + user2 = multitenants.user_two + + test_case = test_case(database, api_client) + + with test_case: + expected_ids, expected_ids2 = test_case.seed_multi(user1.group_id, user2.group_id) + expected_results = [ + (user1.token, expected_ids), + (user2.token, expected_ids2), + ] + + for token, item_ids in expected_results: + response = test_case.get_all(token) + assert response.status_code == 200 + + data = response.json() + + assert len(data) == len(item_ids) + + if len(data) > 0: + for item in data: + assert item["id"] in item_ids diff --git a/tests/multitenant_tests/test_recipe_data_storage.py b/tests/multitenant_tests/test_recipe_data_storage.py new file mode 100644 index 000000000000..008979b5c442 --- /dev/null +++ b/tests/multitenant_tests/test_recipe_data_storage.py @@ -0,0 +1,9 @@ +from mealie.repos.repository_factory import AllRepositories +from tests.fixtures.fixture_multitenant import MultiTenant + + +def test_multitenant_recipe_data_storage( + multitenants: MultiTenant, + database: AllRepositories, +): + pass diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index daae1958819a..1434ccbc4b6b 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -1,6 +1,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_recipes import RepositoryRecipes -from mealie.schema.recipe.recipe import Recipe, RecipeCategory +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_category import CategorySave from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -10,9 +11,9 @@ def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_u slug1, slug2, slug3 = [random_string(10) for _ in range(3)] categories = [ - RecipeCategory(name=slug1, slug=slug1), - RecipeCategory(name=slug2, slug=slug2), - RecipeCategory(name=slug3, slug=slug3), + CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1), + CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2), + CategorySave(group_id=unique_user.group_id, name=slug3, slug=slug3), ] created_categories = [] @@ -67,8 +68,8 @@ def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_u slug1, slug2 = [random_string(10) for _ in range(2)] categories = [ - RecipeCategory(name=slug1, slug=slug1), - RecipeCategory(name=slug2, slug=slug2), + CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1), + CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2), ] created_categories = [] diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py index cb2d490b26e4..b3b7a3246d6f 100644 --- a/tests/unit_tests/test_utils.py +++ b/tests/unit_tests/test_utils.py @@ -1,6 +1,6 @@ import pytest -from mealie.utils.fs_stats import pretty_size +from mealie.pkgs.stats.fs_stats import pretty_size @pytest.mark.parametrize( diff --git a/tests/unit_tests/validator_tests/test_create_plan_entry.py b/tests/unit_tests/validator_tests/test_create_plan_entry.py index ad502c4261e2..12f4e1289f84 100644 --- a/tests/unit_tests/validator_tests/test_create_plan_entry.py +++ b/tests/unit_tests/validator_tests/test_create_plan_entry.py @@ -1,4 +1,5 @@ from datetime import date +from uuid import uuid4 import pytest @@ -13,9 +14,10 @@ def test_create_plan_with_title(): def test_create_plan_with_slug(): - entry = CreatePlanEntry(date=date.today(), recipe_id=123) + uuid = uuid4() + entry = CreatePlanEntry(date=date.today(), recipe_id=uuid) - assert entry.recipe_id == 123 + assert entry.recipe_id == uuid assert entry.title == "" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 8fe114632278..17dbfd14cbc7 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -2,4 +2,5 @@ from .app_routes import * from .assertion_helpers import * from .factories import * from .fixture_schemas import * +from .jsonify import * from .user_login import * diff --git a/tests/utils/jsonify.py b/tests/utils/jsonify.py new file mode 100644 index 000000000000..32afbaba3a29 --- /dev/null +++ b/tests/utils/jsonify.py @@ -0,0 +1,6 @@ +from fastapi.encoders import jsonable_encoder + + +def jsonify(data): + + return jsonable_encoder(data) diff --git a/tests/utils/routes.py b/tests/utils/routes.py index e474b803ff00..963b836fafb9 100644 --- a/tests/utils/routes.py +++ b/tests/utils/routes.py @@ -1,7 +1,7 @@ from pydantic import UUID4 -class _RoutesBase: +class RoutesBase: prefix = "/api" base = f"{prefix}/" @@ -13,9 +13,31 @@ class _RoutesBase: return f"{cls.base}/{item_id}" -class RoutesFoods(_RoutesBase): +class RoutesFoods(RoutesBase): base = "/api/foods" -class RoutesUnits(_RoutesBase): +class RoutesUnits(RoutesBase): base = "/api/units" + + +class RoutesOrganizerBase(RoutesBase): + @classmethod + def slug(cls, slug: str) -> str: + return f"{cls.base}/slug/{slug}" + + +class RoutesTools(RoutesOrganizerBase): + base = "/api/organizers/tools" + + +class RoutesTags(RoutesOrganizerBase): + base = "/api/organizers/tags" + + +class RoutesCategory(RoutesOrganizerBase): + base = "/api/organizers/categories" + + +class RoutesRecipe(RoutesBase): + base = "/api/recipes"