From b8e62ab8dd3e36cba5f8e240635c2a522957927e Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:35:39 -0500 Subject: [PATCH] feat: Query Filter Builder for Cookbooks and Meal Plans (#4346) --- ..._added_query_filter_string_to_cookbook_.py | 188 ++++++ .../Domain/Cookbook/CookbookEditor.vue | 81 ++- .../Domain/Cookbook/CookbookPage.vue | 4 +- .../Household/GroupMealPlanRuleForm.vue | 100 +-- .../components/Domain/QueryFilterBuilder.vue | 622 ++++++++++++++++++ .../Domain/Recipe/RecipeOrganizerPage.vue | 4 +- .../Domain/Recipe/RecipeOrganizerSelector.vue | 95 ++- frontend/components/global/BaseDialog.vue | 6 +- .../partials/use-actions-factory.ts | 6 + frontend/composables/use-group-cookbooks.ts | 2 + .../composables/use-query-filter-builder.ts | 318 +++++++++ frontend/lang/messages/en-US.json | 33 +- frontend/lib/api/types/cookbook.ts | 76 +-- frontend/lib/api/types/meal-plan.ts | 44 +- frontend/lib/api/types/non-generated.ts | 9 +- frontend/lib/api/types/response.ts | 14 + frontend/lib/api/user/group-mealplan.ts | 1 - .../pages/g/_groupSlug/cookbooks/index.vue | 43 +- .../pages/household/mealplan/settings.vue | 25 +- mealie/db/models/household/cookbook.py | 2 + mealie/db/models/household/mealplan.py | 3 +- mealie/db/models/labels.py | 2 +- mealie/repos/repository_generic.py | 8 +- mealie/repos/repository_recipes.py | 46 +- .../explore/controller_public_cookbooks.py | 10 +- .../explore/controller_public_recipes.py | 9 +- .../routes/households/controller_cookbooks.py | 13 +- .../routes/households/controller_mealplan.py | 32 +- mealie/schema/admin/__init__.py | 9 +- mealie/schema/cookbook/cookbook.py | 55 +- mealie/schema/meal_plan/__init__.py | 17 +- mealie/schema/meal_plan/plan_rules.py | 81 ++- mealie/schema/response/__init__.py | 16 +- mealie/schema/response/query_filter.py | 117 +++- mealie/schema/user/__init__.py | 12 +- pyproject.toml | 1 + tests/conftest.py | 22 +- .../test_public_cookbooks.py | 106 ++- .../test_public_recipes.py | 28 +- .../test_group_cookbooks.py | 41 +- .../test_group_mealplan.py | 13 +- .../test_group_mealplan_rules.py | 58 +- .../test_recipe_cross_household.py | 6 +- .../user_recipe_tests/test_recipe_crud.py | 2 +- .../test_query_filter_builder.py | 62 ++ .../backup_v2_tests/test_backup_v2.py | 39 ++ tests/utils/api_routes/__init__.py | 2 - 47 files changed, 2043 insertions(+), 440 deletions(-) create mode 100644 alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py create mode 100644 frontend/components/Domain/QueryFilterBuilder.vue create mode 100644 frontend/composables/use-query-filter-builder.ts create mode 100644 tests/unit_tests/repository_tests/test_query_filter_builder.py diff --git a/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py b/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py new file mode 100644 index 000000000..3f5eb908c --- /dev/null +++ b/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py @@ -0,0 +1,188 @@ +"""added query_filter_string to cookbook and mealplan + +Revision ID: 86054b40fd06 +Revises: 602927e1013e +Create Date: 2024-10-08 21:17:31.601903 + +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from alembic import op +from mealie.db.models._model_utils import guid + +# revision identifiers, used by Alembic. +revision = "86054b40fd06" +down_revision: str | None = "602927e1013e" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +# Intermediate table definitions +class SqlAlchemyBase(orm.DeclarativeBase): + pass + + +class Category(SqlAlchemyBase): + __tablename__ = "categories" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Tag(SqlAlchemyBase): + __tablename__ = "tags" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Tool(SqlAlchemyBase): + __tablename__ = "tools" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Household(SqlAlchemyBase): + __tablename__ = "households" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +cookbooks_to_categories = sa.Table( + "cookbooks_to_categories", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True), +) + +cookbooks_to_tags = sa.Table( + "cookbooks_to_tags", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True), +) + +cookbooks_to_tools = sa.Table( + "cookbooks_to_tools", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("tool_id", guid.GUID, sa.ForeignKey("tools.id"), index=True), +) + +plan_rules_to_categories = sa.Table( + "plan_rules_to_categories", + SqlAlchemyBase.metadata, + sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True), + sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True), +) + +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"), index=True), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True), +) + +plan_rules_to_households = sa.Table( + "plan_rules_to_households", + SqlAlchemyBase.metadata, + sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True), + sa.Column("household_id", guid.GUID, sa.ForeignKey("households.id"), index=True), +) + + +class CookBook(SqlAlchemyBase): + __tablename__ = "cookbooks" + + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="") + + categories: orm.Mapped[list[Category]] = orm.relationship( + Category, secondary=cookbooks_to_categories, single_parent=True + ) + require_all_categories: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True) + require_all_tags: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + tools: orm.Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True) + require_all_tools: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + +class GroupMealPlanRules(SqlAlchemyBase): + __tablename__ = "group_meal_plan_rules" + + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="") + + categories: orm.Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories) + tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags) + households: orm.Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households) + + +def migrate_cookbooks(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + cookbooks = session.query(CookBook).all() + for cookbook in cookbooks: + parts = [] + if cookbook.categories: + relop = "CONTAINS ALL" if cookbook.require_all_categories else "IN" + vals = ",".join([f'"{cat.id}"' for cat in cookbook.categories]) + parts.append(f"recipe_category.id {relop} [{vals}]") + if cookbook.tags: + relop = "CONTAINS ALL" if cookbook.require_all_tags else "IN" + vals = ",".join([f'"{tag.id}"' for tag in cookbook.tags]) + parts.append(f"tags.id {relop} [{vals}]") + if cookbook.tools: + relop = "CONTAINS ALL" if cookbook.require_all_tools else "IN" + vals = ",".join([f'"{tool.id}"' for tool in cookbook.tools]) + parts.append(f"tools.id {relop} [{vals}]") + + cookbook.query_filter_string = " AND ".join(parts) + + session.commit() + + +def migrate_mealplan_rules(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + rules = session.query(GroupMealPlanRules).all() + for rule in rules: + parts = [] + if rule.categories: + vals = ",".join([f'"{cat.id}"' for cat in rule.categories]) + parts.append(f"recipe_category.id CONTAINS ALL [{vals}]") + if rule.tags: + vals = ",".join([f'"{tag.id}"' for tag in rule.tags]) + parts.append(f"tags.id CONTAINS ALL [{vals}]") + if rule.households: + vals = ",".join([f'"{household.id}"' for household in rule.households]) + parts.append(f"household_id IN [{vals}]") + + rule.query_filter_string = " AND ".join(parts) + + session.commit() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("cookbooks", schema=None) as batch_op: + batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default="")) + + with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op: + batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default="")) + + # ### end Alembic commands ### + + migrate_cookbooks() + migrate_mealplan_rules() + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op: + batch_op.drop_column("query_filter_string") + + with op.batch_alter_table("cookbooks", schema=None) as batch_op: + batch_op.drop_column("query_filter_string") + + # ### end Alembic commands ### diff --git a/frontend/components/Domain/Cookbook/CookbookEditor.vue b/frontend/components/Domain/Cookbook/CookbookEditor.vue index 6f733f485..56dbf525d 100644 --- a/frontend/components/Domain/Cookbook/CookbookEditor.vue +++ b/frontend/components/Domain/Cookbook/CookbookEditor.vue @@ -1,11 +1,13 @@ diff --git a/frontend/components/Domain/Cookbook/CookbookPage.vue b/frontend/components/Domain/Cookbook/CookbookPage.vue index 722d2b0fb..44486e70d 100644 --- a/frontend/components/Domain/Cookbook/CookbookPage.vue +++ b/frontend/components/Domain/Cookbook/CookbookPage.vue @@ -4,11 +4,13 @@ diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index 1f263eb36..06f03d5ee 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -6,12 +6,10 @@
- - -
@@ -25,14 +23,14 @@ + + diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue b/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue index 375d32e94..d00172b01 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue @@ -143,7 +143,9 @@ export default defineComponent({ const typeMap = { "categories": "category.category", "tags": "tag.tag", - "tools": "tool.tool" + "tools": "tool.tool", + "foods": "shopping-list.food", + "households": "household.household", }; return typeMap[props.itemType] || ""; }); diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue index 658f04357..26dd1a84d 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue @@ -8,13 +8,12 @@ deletable-chips item-text="name" multiple - :prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam : - selectorType === Organizer.Category ? $globals.icons.categories : - $globals.icons.tags" + :prepend-inner-icon="icon" return-object v-bind="inputAttrs" auto-select-first :search-input.sync="searchInput" + class="pa-0" @change="resetSearchInput" >