From eb170cc7e582bd64453ea87d5e399aaf6dd43543 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 22 Aug 2024 10:14:32 -0500 Subject: [PATCH] feat: Add Households to Mealie (#3970) --- ...2108_added_shopping_list_label_settings.py | 22 +- ...6_dded3119c1fe_added_unique_constraints.py | 27 +- ...12-16.16.29_feecc8ffb956_add_households.py | 321 +++++++++ .../gen_py_pytest_data_paths.py | 2 +- dev/code-generation/utils/template.py | 6 + dev/scripts/all_recipes_stress_test.py | 2 +- .../community-guide/home-assistant.md | 4 +- .../documentation/getting-started/features.md | 8 +- docs/docs/overrides/api.html | 2 +- .../Domain/Group/GroupPreferencesEditor.vue | 65 +- .../GroupMealPlanDayContextMenu.vue | 2 +- .../GroupMealPlanRuleForm.vue | 0 .../GroupWebhookEditor.vue | 2 +- .../Household/HouseholdPreferencesEditor.vue | 99 +++ .../Domain/Recipe/RecipeCardSection.vue | 2 +- .../Domain/Recipe/RecipeContextMenu.vue | 8 +- .../Domain/Recipe/RecipeDataTable.vue | 19 +- .../Recipe/RecipeDialogAddToShoppingList.vue | 27 +- .../Domain/Recipe/RecipeDialogShare.vue | 6 +- .../Domain/Recipe/RecipeExplorerPage.vue | 2 +- .../Recipe/RecipeIngredientListItem.vue | 2 +- .../Domain/Recipe/RecipeLastMade.vue | 6 +- .../components/Domain/Recipe/RecipeList.vue | 2 +- .../ShoppingList/MultiPurposeLabelSection.vue | 2 +- .../Domain/ShoppingList/ShoppingListItem.vue | 4 +- .../ShoppingList/ShoppingListItemEditor.vue | 2 +- frontend/components/Layout/DefaultLayout.vue | 2 +- frontend/components/global/AppToolbar.vue | 2 +- frontend/components/global/AutoForm.vue | 2 + .../partials/use-actions-factory.ts | 2 +- .../composables/recipe-page/shared-state.ts | 3 + .../recipes/use-recipe-ingredients.ts | 6 +- frontend/composables/store/use-tag-store.ts | 2 +- frontend/composables/use-group-cookbooks.ts | 4 +- .../composables/use-group-recipe-actions.ts | 4 +- frontend/composables/use-group-webhooks.ts | 2 +- frontend/composables/use-groups.ts | 4 +- frontend/composables/use-households.ts | 117 +++ .../use-shopping-list-item-actions.ts | 4 +- frontend/lang/messages/en-US.json | 40 +- frontend/layouts/admin.vue | 6 + frontend/layouts/error.vue | 18 +- frontend/lib/api/client-user.ts | 3 + frontend/lib/api/public/explore/cookbooks.ts | 5 +- frontend/lib/api/public/explore/foods.ts | 5 +- frontend/lib/api/public/explore/organizers.ts | 13 +- frontend/lib/api/public/explore/recipes.ts | 5 +- frontend/lib/api/types/admin.ts | 87 ++- frontend/lib/api/types/cookbook.ts | 55 +- frontend/lib/api/types/group.ts | 577 +-------------- frontend/lib/api/types/household.ts | 664 ++++++++++++++++++ frontend/lib/api/types/meal-plan.ts | 94 +-- frontend/lib/api/types/openai.ts | 65 ++ frontend/lib/api/types/recipe.ts | 253 +++---- frontend/lib/api/types/response.ts | 14 +- frontend/lib/api/types/user.ts | 133 ++-- frontend/lib/api/user/email.ts | 4 +- frontend/lib/api/user/group-cookbooks.ts | 4 +- frontend/lib/api/user/group-event-notifier.ts | 6 +- frontend/lib/api/user/group-mealplan-rules.ts | 4 +- frontend/lib/api/user/group-mealplan.ts | 6 +- frontend/lib/api/user/group-recipe-actions.ts | 6 +- frontend/lib/api/user/group-shopping-lists.ts | 18 +- frontend/lib/api/user/group-webhooks.ts | 8 +- frontend/lib/api/user/groups.ts | 46 +- frontend/lib/api/user/households.ts | 64 ++ frontend/lib/api/user/users.ts | 8 - frontend/lib/icons/icons.ts | 2 + frontend/nuxt.config.js | 2 +- frontend/pages/admin/backups.vue | 2 +- frontend/pages/admin/manage/groups/index.vue | 21 +- .../pages/admin/manage/households/_id.vue | 117 +++ .../pages/admin/manage/households/index.vue | 167 +++++ frontend/pages/admin/manage/users/_id.vue | 22 +- frontend/pages/admin/manage/users/create.vue | 38 +- frontend/pages/admin/manage/users/index.vue | 3 +- frontend/pages/admin/setup.vue | 28 +- frontend/pages/admin/site-settings.vue | 5 + .../pages/g/_groupSlug/recipes/timeline.vue | 11 +- frontend/pages/group/data/recipe-actions.vue | 2 +- frontend/pages/group/index.vue | 119 +--- frontend/pages/household/index.vue | 178 +++++ .../{group => household}/mealplan/planner.vue | 16 +- .../mealplan/planner/edit.vue | 8 +- .../mealplan/planner/types.ts | 0 .../mealplan/planner/view.vue | 2 +- .../mealplan/settings.vue | 2 +- .../pages/{group => household}/members.vue | 4 +- .../pages/{group => household}/notifiers.vue | 2 +- .../pages/{group => household}/webhooks.vue | 2 +- frontend/pages/shopping-lists/_id.vue | 16 +- frontend/pages/user/profile/index.vue | 112 +-- mealie/core/dependencies/dependencies.py | 6 +- .../core/security/providers/auth_provider.py | 2 +- .../providers/credentials_provider.py | 2 +- .../core/security/providers/ldap_provider.py | 2 +- .../security/providers/openid_provider.py | 2 +- mealie/core/settings/settings.py | 1 + mealie/db/db_setup.py | 2 +- mealie/db/fixes/fix_migration_data.py | 2 +- mealie/db/init_db.py | 35 +- mealie/db/models/_model_base.py | 6 +- mealie/db/models/group/__init__.py | 7 - mealie/db/models/group/group.py | 39 +- mealie/db/models/group/preferences.py | 4 +- mealie/db/models/household/__init__.py | 35 + .../models/{group => household}/cookbook.py | 11 +- .../db/models/{group => household}/events.py | 7 +- mealie/db/models/household/household.py | 80 +++ .../{group => household}/invite_tokens.py | 5 +- .../models/{group => household}/mealplan.py | 11 +- mealie/db/models/household/preferences.py | 37 + .../{group => household}/recipe_action.py | 5 +- .../{group => household}/shopping_list.py | 31 +- .../models/{group => household}/webhooks.py | 9 +- mealie/db/models/recipe/comment.py | 4 + mealie/db/models/recipe/recipe.py | 14 +- mealie/db/models/recipe/recipe_timeline.py | 4 + mealie/db/models/users/user_to_recipe.py | 12 +- mealie/db/models/users/users.py | 64 +- mealie/pkgs/safehttp/transport.py | 2 +- mealie/repos/_utils.py | 6 + mealie/repos/all_repositories.py | 8 +- mealie/repos/repository_factory.py | 268 +++++-- mealie/repos/repository_foods.py | 7 +- mealie/repos/repository_generic.py | 76 +- mealie/repos/repository_group.py | 22 +- mealie/repos/repository_household.py | 103 +++ mealie/repos/repository_meal_plan_rules.py | 16 +- mealie/repos/repository_meals.py | 17 +- mealie/repos/repository_recipes.py | 105 +-- mealie/repos/repository_shopping_list.py | 8 +- mealie/repos/repository_units.py | 7 +- mealie/repos/repository_users.py | 9 +- mealie/repos/seed/_abstract_seeder.py | 5 +- mealie/repos/seed/init_users.py | 5 + mealie/repos/seed/seeders.py | 8 +- mealie/routes/__init__.py | 2 + mealie/routes/_base/base_controllers.py | 81 ++- mealie/routes/_base/routers.py | 2 +- mealie/routes/admin/__init__.py | 4 +- mealie/routes/admin/admin_about.py | 2 + mealie/routes/admin/admin_analytics.py | 20 - .../routes/admin/admin_management_groups.py | 5 +- .../admin/admin_management_households.py | 91 +++ mealie/routes/admin/admin_management_users.py | 2 +- mealie/routes/app/app_about.py | 17 +- mealie/routes/explore/__init__.py | 17 +- .../explore/controller_public_cookbooks.py | 46 +- .../routes/explore/controller_public_foods.py | 14 +- .../explore/controller_public_organizers.py | 36 +- .../explore/controller_public_recipes.py | 43 +- mealie/routes/groups/__init__.py | 19 - .../routes/groups/controller_group_reports.py | 2 +- .../groups/controller_group_self_service.py | 50 +- mealie/routes/groups/controller_labels.py | 4 +- .../groups/controller_mealplan_config.py | 26 - mealie/routes/groups/controller_migrations.py | 3 +- mealie/routes/groups/controller_seeder.py | 2 +- mealie/routes/households/__init__.py | 28 + .../controller_cookbooks.py | 39 +- .../controller_group_notifications.py | 10 +- .../controller_group_recipe_actions.py | 8 +- .../controller_household_self_service.py | 69 ++ .../controller_invitations.py | 11 +- .../controller_mealplan.py | 20 +- .../controller_mealplan_rules.py | 6 +- .../controller_shopping_lists.py | 33 +- .../controller_webhooks.py | 10 +- mealie/routes/media/media_recipe.py | 2 +- mealie/routes/media/media_user.py | 2 +- .../organizers/controller_categories.py | 14 +- mealie/routes/organizers/controller_tags.py | 8 +- mealie/routes/organizers/controller_tools.py | 2 +- mealie/routes/recipe/__init__.py | 3 +- mealie/routes/recipe/all_recipe_routes.py | 20 - mealie/routes/recipe/comments.py | 2 +- mealie/routes/recipe/recipe_crud_routes.py | 32 +- mealie/routes/recipe/shared_routes.py | 2 +- mealie/routes/recipe/timeline_events.py | 10 +- mealie/routes/shared/__init__.py | 2 +- mealie/routes/spa/__init__.py | 11 +- mealie/routes/unit_and_foods/foods.py | 2 +- mealie/routes/unit_and_foods/units.py | 2 +- mealie/routes/users/crud.py | 14 +- mealie/routes/users/ratings.py | 2 +- mealie/routes/users/registration.py | 3 +- mealie/routes/validators/validators.py | 18 +- mealie/schema/_mealie/__init__.py | 6 +- mealie/schema/_mealie/mealie_model.py | 24 +- mealie/schema/admin/__init__.py | 28 +- mealie/schema/admin/about.py | 3 + mealie/schema/cookbook/cookbook.py | 5 +- mealie/schema/group/__init__.py | 143 +--- mealie/schema/group/group_preferences.py | 9 - mealie/schema/group/group_statistics.py | 8 - mealie/schema/household/__init__.py | 126 ++++ .../{group => household}/group_events.py | 4 +- .../group_recipe_action.py | 1 + .../group_shopping_list.py | 14 +- mealie/schema/household/household.py | 75 ++ .../household_permissions.py} | 0 .../schema/household/household_preferences.py | 28 + .../schema/household/household_statistics.py | 9 + .../{group => household}/invite_token.py | 2 + mealie/schema/{group => household}/webhook.py | 4 +- mealie/schema/meal_plan/__init__.py | 22 +- mealie/schema/meal_plan/meal.py | 45 -- mealie/schema/meal_plan/new_meal.py | 4 +- mealie/schema/meal_plan/plan_rules.py | 3 +- mealie/schema/openai/__init__.py | 7 + mealie/schema/recipe/__init__.py | 92 +-- mealie/schema/recipe/recipe.py | 10 +- mealie/schema/recipe/recipe_comments.py | 9 +- mealie/schema/recipe/recipe_ingredient.py | 5 +- .../schema/recipe/recipe_timeline_events.py | 18 +- mealie/schema/response/__init__.py | 2 +- mealie/schema/response/query_filter.py | 24 +- mealie/schema/user/__init__.py | 20 +- mealie/schema/user/registration.py | 1 + mealie/schema/user/user.py | 40 +- mealie/schema/user/user_passwords.py | 1 + .../event_bus_service/event_bus_listeners.py | 35 +- .../event_bus_service/event_bus_service.py | 49 +- .../services/group_services/group_service.py | 39 +- .../services/group_services/labels_service.py | 20 +- .../services/household_services/__init__.py | 0 .../household_services/household_service.py | 50 ++ .../shopping_lists.py | 20 +- mealie/services/migrations/_migration_base.py | 29 +- .../migrations/utils/database_helpers.py | 22 +- mealie/services/parser_services/_base.py | 12 +- mealie/services/recipe/recipe_data_service.py | 3 +- mealie/services/recipe/recipe_service.py | 55 +- .../scheduler/tasks/create_timeline_events.py | 191 ++--- .../delete_old_checked_shopping_list_items.py | 56 +- .../services/scheduler/tasks/post_webhooks.py | 32 +- .../scheduler/tasks/purge_registration.py | 2 +- mealie/services/scraper/scraped_extras.py | 7 +- mealie/services/seeder/seeder_service.py | 11 +- .../user_services/password_reset_service.py | 2 +- .../user_services/registration_service.py | 42 +- poetry.lock | 12 +- pyproject.toml | 2 +- tests/data/__init__.py | 14 +- ....zip => backup-version-09aba125b57a-1.zip} | Bin ....zip => backup-version-44e8d670719d-1.zip} | Bin ....zip => backup-version-44e8d670719d-2.zip} | Bin ....zip => backup-version-44e8d670719d-3.zip} | Bin ....zip => backup-version-44e8d670719d-4.zip} | Bin ....zip => backup-version-ba1e4a6cfe99-1.zip} | Bin ....zip => backup-version-bcfdad6b7355-1.zip} | Bin tests/fixtures/fixture_admin.py | 26 +- tests/fixtures/fixture_database.py | 19 +- tests/fixtures/fixture_multitenant.py | 7 +- tests/fixtures/fixture_recipe.py | 10 +- tests/fixtures/fixture_shopping_lists.py | 30 +- tests/fixtures/fixture_users.py | 149 +++- .../admin_tests/test_admin_about.py | 8 +- .../admin_tests/test_admin_group_actions.py | 51 +- .../test_admin_household_actions.py | 76 ++ .../admin_tests/test_admin_user_actions.py | 6 +- .../test_public_cookbooks.py | 164 +++-- .../test_public_foods.py | 41 +- .../test_public_organizers.py | 87 +-- .../test_public_recipes.py | 291 ++++++-- .../test_repository_factory.py | 364 ++++++++++ tests/integration_tests/test_validators.py | 9 +- .../test_group_preferences.py | 12 +- .../user_group_tests/test_group_seeder.py | 23 +- .../test_group_self_service.py | 62 ++ .../test_group_cookbooks.py | 32 +- .../test_group_invitation.py | 11 +- .../test_group_mealplan.py | 28 +- .../test_group_mealplan_rules.py | 78 +- .../test_group_notifications.py | 22 +- .../test_group_recipe_actions.py | 23 +- .../test_group_shopping_list_items.py | 96 ++- .../test_group_shopping_lists.py | 216 +++--- .../test_group_webhooks.py | 16 +- .../test_household_perferences.py | 47 ++ .../test_household_permissions.py} | 52 +- .../test_household_self_service.py | 20 + .../test_shopping_list_labels.py | 77 +- .../test_recipe_bulk_action.py | 16 +- .../user_recipe_tests/test_recipe_comments.py | 24 +- .../user_recipe_tests/test_recipe_crud.py | 72 +- .../user_recipe_tests/test_recipe_owner.py | 12 +- .../user_recipe_tests/test_recipe_ratings.py | 28 +- .../test_recipe_share_tokens.py | 48 +- .../user_recipe_tests/test_recipe_steps.py | 4 +- .../test_recipe_timeline_events.py | 16 +- .../user_tests/test_user_crud.py | 61 +- .../user_tests/test_user_login.py | 4 +- .../test_multitenant_cases.py | 8 +- .../test_recipe_data_storage.py | 9 - .../repository_tests/test_food_repository.py | 12 +- .../repository_tests/test_group_repository.py | 18 +- .../repository_tests/test_pagination.py | 100 +-- .../test_recipe_repository.py | 144 ++-- .../repository_tests/test_search.py | 32 +- .../repository_tests/test_unit_repository.py | 13 +- .../repository_tests/test_user_repository.py | 4 +- .../test_shopping_list_ingredient.py | 10 +- .../backup_v2_tests/test_backup_v2.py | 2 +- .../tasks/test_create_timeline_events.py | 10 +- ..._delete_old_checked_shopping_list_items.py | 29 +- .../scheduler/tasks/test_post_webhook.py | 16 +- .../tasks/test_purge_group_exports.py | 15 +- .../user_services/test_user_service.py | 14 +- tests/unit_tests/test_ingredient_parser.py | 21 +- .../test_registration_validators.py | 13 +- tests/utils/api_routes/__init__.py | 265 +++---- tests/utils/factories.py | 1 + tests/utils/fixture_schemas.py | 7 + 315 files changed, 6975 insertions(+), 3577 deletions(-) create mode 100644 alembic/versions/2024-07-12-16.16.29_feecc8ffb956_add_households.py rename frontend/components/Domain/{Group => Household}/GroupMealPlanDayContextMenu.vue (98%) rename frontend/components/Domain/{Group => Household}/GroupMealPlanRuleForm.vue (100%) rename frontend/components/Domain/{Group => Household}/GroupWebhookEditor.vue (97%) create mode 100644 frontend/components/Domain/Household/HouseholdPreferencesEditor.vue create mode 100644 frontend/composables/use-households.ts create mode 100644 frontend/lib/api/types/household.ts create mode 100644 frontend/lib/api/types/openai.ts create mode 100644 frontend/lib/api/user/households.ts create mode 100644 frontend/pages/admin/manage/households/_id.vue create mode 100644 frontend/pages/admin/manage/households/index.vue create mode 100644 frontend/pages/household/index.vue rename frontend/pages/{group => household}/mealplan/planner.vue (88%) rename frontend/pages/{group => household}/mealplan/planner/edit.vue (98%) rename frontend/pages/{group => household}/mealplan/planner/types.ts (100%) rename frontend/pages/{group => household}/mealplan/planner/view.vue (97%) rename frontend/pages/{group => household}/mealplan/settings.vue (98%) rename frontend/pages/{group => household}/members.vue (97%) rename frontend/pages/{group => household}/notifiers.vue (99%) rename frontend/pages/{group => household}/webhooks.vue (96%) create mode 100644 mealie/db/models/household/__init__.py rename mealie/db/models/{group => household}/cookbook.py (78%) rename mealie/db/models/{group => household}/events.py (91%) create mode 100644 mealie/db/models/household/household.py rename mealie/db/models/{group => household}/invite_tokens.py (74%) rename mealie/db/models/{group => household}/mealplan.py (81%) create mode 100644 mealie/db/models/household/preferences.py rename mealie/db/models/{group => household}/recipe_action.py (77%) rename mealie/db/models/{group => household}/shopping_list.py (84%) rename mealie/db/models/{group => household}/webhooks.py (78%) create mode 100644 mealie/repos/_utils.py create mode 100644 mealie/repos/repository_household.py delete mode 100644 mealie/routes/admin/admin_analytics.py create mode 100644 mealie/routes/admin/admin_management_households.py delete mode 100644 mealie/routes/groups/controller_mealplan_config.py create mode 100644 mealie/routes/households/__init__.py rename mealie/routes/{groups => households}/controller_cookbooks.py (75%) rename mealie/routes/{groups => households}/controller_group_notifications.py (91%) rename mealie/routes/{groups => households}/controller_group_recipe_actions.py (88%) create mode 100644 mealie/routes/households/controller_household_self_service.py rename mealie/routes/{groups => households}/controller_invitations.py (77%) rename mealie/routes/{groups => households}/controller_mealplan.py (89%) rename mealie/routes/{groups => households}/controller_mealplan_rules.py (90%) rename mealie/routes/{groups => households}/controller_shopping_lists.py (86%) rename mealie/routes/{groups => households}/controller_webhooks.py (88%) delete mode 100644 mealie/routes/recipe/all_recipe_routes.py create mode 100644 mealie/schema/household/__init__.py rename mealie/schema/{group => household}/group_events.py (95%) rename mealie/schema/{group => household}/group_recipe_action.py (96%) rename mealie/schema/{group => household}/group_shopping_list.py (95%) create mode 100644 mealie/schema/household/household.py rename mealie/schema/{group/group_permissions.py => household/household_permissions.py} (100%) create mode 100644 mealie/schema/household/household_preferences.py create mode 100644 mealie/schema/household/household_statistics.py rename mealie/schema/{group => household}/invite_token.py (92%) rename mealie/schema/{group => household}/webhook.py (97%) delete mode 100644 mealie/schema/meal_plan/meal.py create mode 100644 mealie/services/household_services/__init__.py create mode 100644 mealie/services/household_services/household_service.py rename mealie/services/{group_services => household_services}/shopping_lists.py (96%) rename tests/data/backups/{backup_version_09aba125b57a_1.zip => backup-version-09aba125b57a-1.zip} (100%) rename tests/data/backups/{backup_version_44e8d670719d_1.zip => backup-version-44e8d670719d-1.zip} (100%) rename tests/data/backups/{backup_version_44e8d670719d_2.zip => backup-version-44e8d670719d-2.zip} (100%) rename tests/data/backups/{backup_version_44e8d670719d_3.zip => backup-version-44e8d670719d-3.zip} (100%) rename tests/data/backups/{backup_version_44e8d670719d_4.zip => backup-version-44e8d670719d-4.zip} (100%) rename tests/data/backups/{backup_version_ba1e4a6cfe99_1.zip => backup-version-ba1e4a6cfe99-1.zip} (100%) rename tests/data/backups/{backup_version_bcfdad6b7355_1.zip => backup-version-bcfdad6b7355-1.zip} (100%) create mode 100644 tests/integration_tests/admin_tests/test_admin_household_actions.py create mode 100644 tests/integration_tests/test_repository_factory.py create mode 100644 tests/integration_tests/user_group_tests/test_group_self_service.py rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_cookbooks.py (70%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_invitation.py (82%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_mealplan.py (79%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_mealplan_rules.py (56%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_notifications.py (81%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_recipe_actions.py (80%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_shopping_list_items.py (86%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_shopping_lists.py (77%) rename tests/integration_tests/{user_group_tests => user_household_tests}/test_group_webhooks.py (81%) create mode 100644 tests/integration_tests/user_household_tests/test_household_perferences.py rename tests/integration_tests/{user_group_tests/test_group_permissions.py => user_household_tests/test_household_permissions.py} (51%) create mode 100644 tests/integration_tests/user_household_tests/test_household_self_service.py rename tests/integration_tests/{user_group_tests => user_household_tests}/test_shopping_list_labels.py (65%) delete mode 100644 tests/multitenant_tests/test_recipe_data_storage.py diff --git a/alembic/versions/2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings.py b/alembic/versions/2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings.py index fdc7046ca396..cdb5ca86a650 100644 --- a/alembic/versions/2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings.py +++ b/alembic/versions/2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings.py @@ -13,8 +13,7 @@ from sqlalchemy import orm import mealie.db.migration_types from alembic import op -from mealie.db.models.group.shopping_list import ShoppingList -from mealie.db.models.labels import MultiPurposeLabel +from mealie.db.models._model_utils.guid import GUID # revision identifiers, used by Alembic. revision = "b04a08da2108" @@ -23,6 +22,25 @@ branch_labels = None depends_on = None +# Intermediate table definitions +class SqlAlchemyBase(orm.DeclarativeBase): + pass + + +class ShoppingList(SqlAlchemyBase): + __tablename__ = "shopping_lists" + + id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) + group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) + + +class MultiPurposeLabel(SqlAlchemyBase): + __tablename__ = "multi_purpose_labels" + + id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) + group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) + + def populate_shopping_lists_multi_purpose_labels( shopping_lists_multi_purpose_labels_table: sa.Table, session: orm.Session ): diff --git a/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py b/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py index 57a59a077125..5fc44eee66ce 100644 --- a/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py +++ b/alembic/versions/2023-10-04-14.29.26_dded3119c1fe_added_unique_constraints.py @@ -12,11 +12,11 @@ from typing import Any import sqlalchemy as sa from pydantic import UUID4 +from sqlalchemy import orm from sqlalchemy.orm import Session, load_only from alembic import op -from mealie.db.models._model_base import SqlAlchemyBase -from mealie.db.models.group.shopping_list import ShoppingListItem +from mealie.db.models._model_utils.guid import GUID from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel @@ -27,6 +27,27 @@ branch_labels = None depends_on = None +# Intermediate table definitions +class SqlAlchemyBase(orm.DeclarativeBase): + pass + + +class ShoppingList(SqlAlchemyBase): + __tablename__ = "shopping_lists" + + id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) + group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) + + +class ShoppingListItem(SqlAlchemyBase): + __tablename__ = "shopping_list_items" + + id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate) + food_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_foods.id")) + unit_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_units.id")) + label_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("multi_purpose_labels.id")) + + @dataclass class TableMeta: tablename: str @@ -42,7 +63,7 @@ def _is_postgres(): return op.get_context().dialect.name == "postgresql" -def _get_duplicates(session: Session, model: SqlAlchemyBase) -> defaultdict[str, list]: +def _get_duplicates(session: Session, model: orm.DeclarativeBase) -> defaultdict[str, list]: duplicate_map: defaultdict[str, list] = defaultdict(list) query = session.execute(sa.text(f"SELECT id, group_id, name FROM {model.__tablename__}")) diff --git a/alembic/versions/2024-07-12-16.16.29_feecc8ffb956_add_households.py b/alembic/versions/2024-07-12-16.16.29_feecc8ffb956_add_households.py new file mode 100644 index 000000000000..9436f7301721 --- /dev/null +++ b/alembic/versions/2024-07-12-16.16.29_feecc8ffb956_add_households.py @@ -0,0 +1,321 @@ +"""add households + +Revision ID: feecc8ffb956 +Revises: 32d69327997b +Create Date: 2024-07-12 16:16:29.973929 + +""" + +from datetime import datetime, timezone +from textwrap import dedent +from typing import Any +from uuid import uuid4 + +import sqlalchemy as sa +from slugify import slugify +from sqlalchemy import orm + +import mealie.db.migration_types +from alembic import op +from mealie.core.config import get_app_settings + +# revision identifiers, used by Alembic. +revision = "feecc8ffb956" +down_revision = "32d69327997b" +branch_labels = None # type: ignore +depends_on = None # type: ignore + +settings = get_app_settings() + + +def is_postgres(): + return op.get_context().dialect.name == "postgresql" + + +def generate_id() -> str: + """See GUID.convert_value_to_guid""" + val = uuid4() + if is_postgres(): + return str(val) + else: + return f"{val.int:032x}" + + +def dedupe_cookbook_slugs(): + bind = op.get_bind() + session = orm.Session(bind=bind) + with session: + sql = sa.text( + dedent( + """ + SELECT slug, group_id, COUNT(*) + FROM cookbooks + GROUP BY slug, group_id + HAVING COUNT(*) > 1 + """ + ) + ) + rows = session.execute(sql).fetchall() + + for slug, group_id, _ in rows: + sql = sa.text( + dedent( + """ + SELECT id + FROM cookbooks + WHERE slug = :slug AND group_id = :group_id + ORDER BY id + """ + ) + ) + cookbook_ids = session.execute(sql, {"slug": slug, "group_id": group_id}).fetchall() + + for i, (cookbook_id,) in enumerate(cookbook_ids): + if i == 0: + continue + + sql = sa.text( + dedent( + """ + UPDATE cookbooks + SET slug = :slug || '-' || :i + WHERE id = :id + """ + ) + ) + session.execute(sql, {"slug": slug, "i": i, "id": cookbook_id}) + + +def create_household(session: orm.Session, group_id: str) -> str: + # create/insert household + household_id = generate_id() + timestamp = datetime.now(timezone.utc).isoformat() + household_data = { + "id": household_id, + "name": settings.DEFAULT_HOUSEHOLD, + "slug": slugify(settings.DEFAULT_HOUSEHOLD), + "group_id": group_id, + "created_at": timestamp, + "update_at": timestamp, + } + columns = ", ".join(household_data.keys()) + placeholders = ", ".join(f":{key}" for key in household_data.keys()) + sql_statement = f"INSERT INTO households ({columns}) VALUES ({placeholders})" + + session.execute(sa.text(sql_statement), household_data) + + # fetch group preferences so we can copy them over to household preferences + migrated_field_defaults = { + "private_group": True, # this is renamed later + "first_day_of_week": 0, + "recipe_public": True, + "recipe_show_nutrition": False, + "recipe_show_assets": False, + "recipe_landscape_view": False, + "recipe_disable_comments": False, + "recipe_disable_amount": True, + } + sql_statement = ( + f"SELECT {', '.join(migrated_field_defaults.keys())} FROM group_preferences WHERE group_id = :group_id" + ) + group_preferences = session.execute(sa.text(sql_statement), {"group_id": group_id}).fetchone() + + # build preferences data + if group_preferences: + preferences_data: dict[str, Any] = {} + for i, (field, default_value) in enumerate(migrated_field_defaults.items()): + value = group_preferences[i] + preferences_data[field] = value if value is not None else default_value + else: + preferences_data = migrated_field_defaults + + preferences_data["id"] = generate_id() + preferences_data["household_id"] = household_id + preferences_data["created_at"] = timestamp + preferences_data["update_at"] = timestamp + preferences_data["private_household"] = preferences_data.pop("private_group") + + # insert preferences data + columns = ", ".join(preferences_data.keys()) + placeholders = ", ".join(f":{key}" for key in preferences_data.keys()) + sql_statement = f"INSERT INTO household_preferences ({columns}) VALUES ({placeholders})" + + session.execute(sa.text(sql_statement), preferences_data) + + return household_id + + +def create_households_for_groups() -> dict[str, str]: + bind = op.get_bind() + session = orm.Session(bind=bind) + group_id_household_id_map: dict[str, str] = {} + with session: + rows = session.execute(sa.text("SELECT id FROM groups")).fetchall() + for row in rows: + group_id = row[0] + group_id_household_id_map[group_id] = create_household(session, group_id) + + return group_id_household_id_map + + +def _do_assignment(session: orm.Session, table: str, group_id: str, household_id: str): + sql = sa.text( + dedent( + f""" + UPDATE {table} + SET household_id = :household_id + WHERE group_id = :group_id + """, + ) + ) + session.execute(sql, {"group_id": group_id, "household_id": household_id}) + + +def assign_households(group_id_household_id_map: dict[str, str]): + tables = [ + "cookbooks", + "group_events_notifiers", + "group_meal_plan_rules", + "invite_tokens", + "recipe_actions", + "users", + "webhook_urls", + ] + + bind = op.get_bind() + session = orm.Session(bind=bind) + with session: + for table in tables: + for group_id, household_id in group_id_household_id_map.items(): + _do_assignment(session, table, group_id, household_id) + + +def populate_household_data(): + group_id_household_id_map = create_households_for_groups() + assign_households(group_id_household_id_map) + + +def upgrade(): + dedupe_cookbook_slugs() + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "households", + sa.Column("id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("update_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["group_id"], + ["groups.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("group_id", "name", name="household_name_group_id_key"), + sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"), + ) + op.create_index(op.f("ix_households_created_at"), "households", ["created_at"], unique=False) + op.create_index(op.f("ix_households_group_id"), "households", ["group_id"], unique=False) + op.create_index(op.f("ix_households_name"), "households", ["name"], unique=False) + op.create_index(op.f("ix_households_slug"), "households", ["slug"], unique=False) + op.create_table( + "household_preferences", + sa.Column("id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("private_household", sa.Boolean(), nullable=True), + sa.Column("first_day_of_week", sa.Integer(), nullable=True), + sa.Column("recipe_public", sa.Boolean(), nullable=True), + sa.Column("recipe_show_nutrition", sa.Boolean(), nullable=True), + sa.Column("recipe_show_assets", sa.Boolean(), nullable=True), + sa.Column("recipe_landscape_view", sa.Boolean(), nullable=True), + sa.Column("recipe_disable_comments", sa.Boolean(), nullable=True), + sa.Column("recipe_disable_amount", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("update_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["household_id"], + ["households.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_household_preferences_created_at"), "household_preferences", ["created_at"], unique=False) + op.create_index( + op.f("ix_household_preferences_household_id"), "household_preferences", ["household_id"], unique=False + ) + + with op.batch_alter_table("cookbooks") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_cookbooks_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_cookbooks_household_id", "households", ["household_id"], ["id"]) + + # not directly related to households, but important for frontend routes + batch_op.create_unique_constraint("cookbook_slug_group_id_key", ["slug", "group_id"]) + + with op.batch_alter_table("group_events_notifiers") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_group_events_notifiers_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_group_events_notifiers_household_id", "households", ["household_id"], ["id"]) + + with op.batch_alter_table("group_meal_plan_rules") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_group_meal_plan_rules_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_group_meal_plan_rules_household_id", "households", ["household_id"], ["id"]) + + with op.batch_alter_table("invite_tokens") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_invite_tokens_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_invite_tokens_household_id", "households", ["household_id"], ["id"]) + + with op.batch_alter_table("recipe_actions") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_recipe_actions_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_recipe_actions_household_id", "households", ["household_id"], ["id"]) + + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_users_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_users_household_id", "households", ["household_id"], ["id"]) + + with op.batch_alter_table("webhook_urls") as batch_op: + batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(op.f("ix_webhook_urls_household_id"), ["household_id"], unique=False) + batch_op.create_foreign_key("fk_webhook_urls_household_id", "households", ["household_id"], ["id"]) + # ### end Alembic commands ### + + populate_household_data() + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "webhook_urls", type_="foreignkey") + op.drop_index(op.f("ix_webhook_urls_household_id"), table_name="webhook_urls") + op.drop_column("webhook_urls", "household_id") + op.drop_constraint(None, "users", type_="foreignkey") + op.drop_index(op.f("ix_users_household_id"), table_name="users") + op.drop_column("users", "household_id") + op.drop_constraint(None, "recipe_actions", type_="foreignkey") + op.drop_index(op.f("ix_recipe_actions_household_id"), table_name="recipe_actions") + op.drop_column("recipe_actions", "household_id") + op.drop_constraint(None, "invite_tokens", type_="foreignkey") + op.drop_index(op.f("ix_invite_tokens_household_id"), table_name="invite_tokens") + op.drop_column("invite_tokens", "household_id") + op.drop_constraint(None, "group_meal_plan_rules", type_="foreignkey") + op.drop_index(op.f("ix_group_meal_plan_rules_household_id"), table_name="group_meal_plan_rules") + op.drop_column("group_meal_plan_rules", "household_id") + op.drop_constraint(None, "group_events_notifiers", type_="foreignkey") + op.drop_index(op.f("ix_group_events_notifiers_household_id"), table_name="group_events_notifiers") + op.drop_column("group_events_notifiers", "household_id") + op.drop_constraint(None, "cookbooks", type_="foreignkey") + op.drop_index(op.f("ix_cookbooks_household_id"), table_name="cookbooks") + op.drop_column("cookbooks", "household_id") + op.drop_constraint("cookbook_slug_group_id_key", "cookbooks", type_="unique") + op.drop_index(op.f("ix_household_preferences_household_id"), table_name="household_preferences") + op.drop_index(op.f("ix_household_preferences_created_at"), table_name="household_preferences") + op.drop_table("household_preferences") + op.drop_index(op.f("ix_households_slug"), table_name="households") + op.drop_index(op.f("ix_households_name"), table_name="households") + op.drop_index(op.f("ix_households_group_id"), table_name="households") + op.drop_index(op.f("ix_households_created_at"), table_name="households") + op.drop_table("households") + # ### end Alembic commands ### diff --git a/dev/code-generation/gen_py_pytest_data_paths.py b/dev/code-generation/gen_py_pytest_data_paths.py index 51cdd854ef28..02313d819dff 100644 --- a/dev/code-generation/gen_py_pytest_data_paths.py +++ b/dev/code-generation/gen_py_pytest_data_paths.py @@ -67,7 +67,7 @@ def rename_non_compliant_paths(): kabab case. """ - ignore_files = ["DS_Store", ".gitkeep"] + ignore_files = ["DS_Store", ".gitkeep", "af-ZA.json", "en-US.json"] ignore_extensions = [".pyc", ".pyo", ".py"] diff --git a/dev/code-generation/utils/template.py b/dev/code-generation/utils/template.py index eb4962a639e0..6312426e296a 100644 --- a/dev/code-generation/utils/template.py +++ b/dev/code-generation/utils/template.py @@ -1,5 +1,6 @@ import logging import re +import subprocess from dataclasses import dataclass from pathlib import Path @@ -23,6 +24,11 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict): dest.write_text(text) + # lint/format file with Ruff + log.info(f"Formatting {dest}") + subprocess.run(["poetry", "run", "ruff", "check", str(dest), "--fix"]) + subprocess.run(["poetry", "run", "ruff", "format", str(dest)]) + @dataclass class CodeSlicer: diff --git a/dev/scripts/all_recipes_stress_test.py b/dev/scripts/all_recipes_stress_test.py index 510e7b231290..0ce27cb13726 100644 --- a/dev/scripts/all_recipes_stress_test.py +++ b/dev/scripts/all_recipes_stress_test.py @@ -173,7 +173,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic "dateAdded": "2022-09-03", "dateUpdated": "2022-09-10T15:18:19.866085", "createdAt": "2022-09-03T18:31:17.488118", - "updateAt": "2022-09-10T15:18:19.869630", + "updatedAt": "2022-09-10T15:18:19.869630", "recipeInstructions": [ { "id": "60ae53a3-b3ff-40ee-bae3-89fea0b1c637", diff --git a/docs/docs/documentation/community-guide/home-assistant.md b/docs/docs/documentation/community-guide/home-assistant.md index 79a926555cf6..5f35d80afe5e 100644 --- a/docs/docs/documentation/community-guide/home-assistant.md +++ b/docs/docs/documentation/community-guide/home-assistant.md @@ -24,7 +24,7 @@ Make sure the url and port (`http://mealie:9000` ) matches your installation's a ```yaml - platform: rest - resource: "http://mealie:9000/api/groups/mealplans/today" + resource: "http://mealie:9000/api/households/mealplans/today" method: GET name: Mealie todays meal headers: @@ -36,7 +36,7 @@ Make sure the url and port (`http://mealie:9000` ) matches your installation's a ```yaml - platform: rest - resource: "http://mealie:9000/api/groups/mealplans/today" + resource: "http://mealie:9000/api/households/mealplans/today" method: GET name: Mealie todays meal ID headers: diff --git a/docs/docs/documentation/getting-started/features.md b/docs/docs/documentation/getting-started/features.md index 9d0d58533d71..b9ee0cebd675 100644 --- a/docs/docs/documentation/getting-started/features.md +++ b/docs/docs/documentation/getting-started/features.md @@ -73,13 +73,13 @@ Mealie uses a calendar like view to help you plan your meals. It shows you the p !!! tip You can also add a "Note" type entry to your meal-plan when you want to include something that might not have a specific recipes. This is great for leftovers, or for ordering out. -[Mealplanner Demo](https://demo.mealie.io/group/mealplan/planner/view){ .md-button .md-button--primary } +[Mealplanner Demo](https://demo.mealie.io/household/mealplan/planner/view){ .md-button .md-button--primary } ### Planner Rules The meal planner has the concept of plan rules. These offer a flexible way to use your organizers to customize how a random recipe is inserted into your meal plan. You can set rules to restrict the pool of recipes based on the Tags and/or Categories of a recipe. Additionally, since meal plans have a Breakfast, Lunch, Dinner, and Snack labels, you can specifically set a rule to be active for a **specific meal type** or even a **specific day of the week.** -[Planner Settings Demo](https://demo.mealie.io/group/mealplan/settings){ .md-button .md-button--primary } +[Planner Settings Demo](https://demo.mealie.io/household/mealplan/settings){ .md-button .md-button--primary } ## Shopping Lists @@ -105,13 +105,13 @@ Notifiers use the [Apprise library](https://github.com/caronc/apprise/wiki), whi - `json` and `jsons` - `xml` and `xmls` -[Notifiers Demo](https://demo.mealie.io/group/notifiers){ .md-button .md-button--primary } +[Notifiers Demo](https://demo.mealie.io/household/notifiers){ .md-button .md-button--primary } ### Webhooks Unlike notifiers, which are event-driven notifications, Webhooks allow you to send scheduled notifications to your desired endpoint. Webhooks are sent on the day of a scheduled mealplan, at the specified time, and contain the mealplan data in the request. -[Webhooks Demo](https://demo.mealie.io/group/webhooks){ .md-button .md-button--primary } +[Webhooks Demo](https://demo.mealie.io/household/webhooks){ .md-button .md-button--primary } ### Recipe Actions diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html index 334025e5eea6..c1b7fc8ada20 100644 --- a/docs/docs/overrides/api.html +++ b/docs/docs/overrides/api.html @@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Group/GroupPreferencesEditor.vue b/frontend/components/Domain/Group/GroupPreferencesEditor.vue index 557f67ef273f..4fab77b6796d 100644 --- a/frontend/components/Domain/Group/GroupPreferencesEditor.vue +++ b/frontend/components/Domain/Group/GroupPreferencesEditor.vue @@ -2,30 +2,11 @@{{ $t("user.user-id-with-value", {id: user.id} ) }}