mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-19 13:19:41 +02:00
feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)
This commit is contained in:
parent
2a9a6fa5e6
commit
b8e62ab8dd
47 changed files with 2043 additions and 440 deletions
|
@ -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 ###
|
|
@ -1,11 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-card-text v-if="cookbook">
|
<v-card-text v-if="cookbook" class="px-1">
|
||||||
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
||||||
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||||
<RecipeOrganizerSelector v-model="cookbook.categories" selector-type="categories" />
|
<QueryFilterBuilder
|
||||||
<RecipeOrganizerSelector v-model="cookbook.tags" selector-type="tags" />
|
:field-defs="fieldDefs"
|
||||||
<RecipeOrganizerSelector v-model="cookbook.tools" selector-type="tools" />
|
:initial-query-filter="cookbook.queryFilter"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
<v-switch v-model="cookbook.public" hide-details single-line>
|
<v-switch v-model="cookbook.public" hide-details single-line>
|
||||||
<template #label>
|
<template #label>
|
||||||
{{ $t('cookbook.public-cookbook') }}
|
{{ $t('cookbook.public-cookbook') }}
|
||||||
|
@ -14,33 +16,19 @@
|
||||||
</HelpIcon>
|
</HelpIcon>
|
||||||
</template>
|
</template>
|
||||||
</v-switch>
|
</v-switch>
|
||||||
<div class="mt-4">
|
|
||||||
<h3 class="text-subtitle-1 d-flex align-center mb-0 pb-0">
|
|
||||||
{{ $t('cookbook.filter-options') }}
|
|
||||||
<HelpIcon right small class="ml-2">
|
|
||||||
{{ $t('cookbook.filter-options-description') }}
|
|
||||||
</HelpIcon>
|
|
||||||
</h3>
|
|
||||||
<v-switch v-model="cookbook.requireAllCategories" class="mt-0" hide-details single-line>
|
|
||||||
<template #label> {{ $t('cookbook.require-all-categories') }} </template>
|
|
||||||
</v-switch>
|
|
||||||
<v-switch v-model="cookbook.requireAllTags" hide-details single-line>
|
|
||||||
<template #label> {{ $t('cookbook.require-all-tags') }} </template>
|
|
||||||
</v-switch>
|
|
||||||
<v-switch v-model="cookbook.requireAllTools" hide-details single-line>
|
|
||||||
<template #label> {{ $t('cookbook.require-all-tools') }} </template>
|
|
||||||
</v-switch>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
|
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { RecipeOrganizerSelector },
|
components: { QueryFilterBuilder },
|
||||||
props: {
|
props: {
|
||||||
cookbook: {
|
cookbook: {
|
||||||
type: Object as () => ReadCookBook,
|
type: Object as () => ReadCookBook,
|
||||||
|
@ -51,5 +39,50 @@ export default defineComponent({
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
|
||||||
|
function handleInput(value: string | undefined) {
|
||||||
|
props.cookbook.queryFilterString = value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldDefs: FieldDefinition[] = [
|
||||||
|
{
|
||||||
|
name: "recipe_category.id",
|
||||||
|
label: i18n.tc("category.categories"),
|
||||||
|
type: Organizer.Category,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags.id",
|
||||||
|
label: i18n.tc("tag.tags"),
|
||||||
|
type: Organizer.Tag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tools.id",
|
||||||
|
label: i18n.tc("tool.tools"),
|
||||||
|
type: Organizer.Tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "household_id",
|
||||||
|
label: i18n.tc("household.households"),
|
||||||
|
type: Organizer.Household,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created_at",
|
||||||
|
label: i18n.tc("general.date-created"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updated_at",
|
||||||
|
label: i18n.tc("general.date-updated"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleInput,
|
||||||
|
fieldDefs,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,11 +4,13 @@
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-if="editTarget"
|
v-if="editTarget"
|
||||||
v-model="dialogStates.edit"
|
v-model="dialogStates.edit"
|
||||||
:width="650"
|
width="100%"
|
||||||
|
max-width="1100px"
|
||||||
:icon="$globals.icons.pages"
|
:icon="$globals.icons.pages"
|
||||||
:title="$t('general.edit')"
|
:title="$t('general.edit')"
|
||||||
:submit-icon="$globals.icons.save"
|
:submit-icon="$globals.icons.save"
|
||||||
:submit-text="$tc('general.save')"
|
:submit-text="$tc('general.save')"
|
||||||
|
:submit-disabled="!editTarget.queryFilterString"
|
||||||
@submit="editCookbook"
|
@submit="editCookbook"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
<QueryFilterBuilder
|
||||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
:field-defs="fieldDefs"
|
||||||
<GroupHouseholdSelector
|
:initial-query-filter="queryFilter"
|
||||||
v-model="inputHouseholds"
|
@input="handleQueryFilterInput"
|
||||||
multiselect
|
|
||||||
:description="$tc('meal-plan.mealplan-households-description')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -25,14 +23,14 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||||
import GroupHouseholdSelector from "~/components/Domain/Household/GroupHouseholdSelector.vue";
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
import { PlanCategory, PlanHousehold, PlanTag } from "~/lib/api/types/meal-plan";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
GroupHouseholdSelector,
|
QueryFilterBuilder,
|
||||||
RecipeOrganizerSelector,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
day: {
|
day: {
|
||||||
|
@ -43,17 +41,13 @@ export default defineComponent({
|
||||||
type: String,
|
type: String,
|
||||||
default: "unset",
|
default: "unset",
|
||||||
},
|
},
|
||||||
categories: {
|
queryFilterString: {
|
||||||
type: Array as () => PlanCategory[],
|
type: String,
|
||||||
default: () => [],
|
default: "",
|
||||||
},
|
},
|
||||||
tags: {
|
queryFilter: {
|
||||||
type: Array as () => PlanTag[],
|
type: Object as () => QueryFilterJSON,
|
||||||
default: () => [],
|
default: null,
|
||||||
},
|
|
||||||
households: {
|
|
||||||
type: Array as () => PlanHousehold[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
},
|
||||||
showHelp: {
|
showHelp: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
@ -100,41 +94,65 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputCategories = computed({
|
const inputQueryFilterString = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.categories;
|
return props.queryFilterString;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("update:categories", val);
|
context.emit("update:query-filter-string", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputTags = computed({
|
function handleQueryFilterInput(value: string | undefined) {
|
||||||
get: () => {
|
inputQueryFilterString.value = value || "";
|
||||||
return props.tags;
|
};
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:tags", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputHouseholds = computed({
|
const fieldDefs: FieldDefinition[] = [
|
||||||
get: () => {
|
{
|
||||||
return props.households;
|
name: "recipe_category.id",
|
||||||
|
label: i18n.tc("category.categories"),
|
||||||
|
type: Organizer.Category,
|
||||||
},
|
},
|
||||||
set: (val) => {
|
{
|
||||||
context.emit("update:households", val);
|
name: "tags.id",
|
||||||
|
label: i18n.tc("tag.tags"),
|
||||||
|
type: Organizer.Tag,
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
name: "tools.id",
|
||||||
|
label: i18n.tc("tool.tools"),
|
||||||
|
type: Organizer.Tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "household_id",
|
||||||
|
label: i18n.tc("household.households"),
|
||||||
|
type: Organizer.Household,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last_made",
|
||||||
|
label: i18n.tc("general.last-made"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created_at",
|
||||||
|
label: i18n.tc("general.date-created"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updated_at",
|
||||||
|
label: i18n.tc("general.date-updated"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
MEAL_TYPE_OPTIONS,
|
MEAL_TYPE_OPTIONS,
|
||||||
MEAL_DAY_OPTIONS,
|
MEAL_DAY_OPTIONS,
|
||||||
inputDay,
|
inputDay,
|
||||||
inputEntryType,
|
inputEntryType,
|
||||||
inputCategories,
|
inputQueryFilterString,
|
||||||
inputTags,
|
handleQueryFilterInput,
|
||||||
inputHouseholds,
|
fieldDefs,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
622
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
622
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
|
@ -0,0 +1,622 @@
|
||||||
|
<template>
|
||||||
|
<v-card class="ma-0" style="overflow-x: auto;">
|
||||||
|
<v-card-text class="ma-0 pa-0">
|
||||||
|
<v-container fluid class="ma-0 pa-0">
|
||||||
|
<draggable
|
||||||
|
:value="fields"
|
||||||
|
handle=".handle"
|
||||||
|
v-bind="{
|
||||||
|
animation: 200,
|
||||||
|
group: 'recipe-instructions',
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
}"
|
||||||
|
@start="drag = true"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<v-row
|
||||||
|
v-for="(field, index) in fields"
|
||||||
|
:key="index"
|
||||||
|
class="d-flex flex-nowrap"
|
||||||
|
style="max-width: 100%;"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.icon.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.icon.style"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
class="handle"
|
||||||
|
style="width: 100%; height: 100%;"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</v-icon>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.logicalOperator.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.logicalOperator.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-if="index"
|
||||||
|
v-model="field.logicalOperator"
|
||||||
|
:items="[logOps.AND, logOps.OR]"
|
||||||
|
item-text="label"
|
||||||
|
item-value="value"
|
||||||
|
@input="setLogicalOperatorValue(field, index, $event)"
|
||||||
|
>
|
||||||
|
<template #selection="{ item }">
|
||||||
|
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
v-if="showAdvanced"
|
||||||
|
:cols="attrs.fields.leftParens.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.leftParens.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-model="field.leftParenthesis"
|
||||||
|
:items="['', '(', '((', '(((']"
|
||||||
|
@input="setLeftParenthesisValue(field, index, $event)"
|
||||||
|
>
|
||||||
|
<template #selection="{ item }">
|
||||||
|
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.fieldName.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.fieldName.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-model="field.label"
|
||||||
|
:items="fieldDefs"
|
||||||
|
item-text="label"
|
||||||
|
@change="setField(index, $event)"
|
||||||
|
>
|
||||||
|
<template #selection="{ item }">
|
||||||
|
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.relationalOperator.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.relationalOperator.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-if="field.type !== 'boolean'"
|
||||||
|
v-model="field.relationalOperatorValue"
|
||||||
|
:items="field.relationalOperatorOptions"
|
||||||
|
item-text="label"
|
||||||
|
item-value="value"
|
||||||
|
@input="setRelationalOperatorValue(field, index, $event)"
|
||||||
|
>
|
||||||
|
<template #selection="{ item }">
|
||||||
|
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.fieldValue.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.fieldValue.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-if="field.fieldOptions"
|
||||||
|
v-model="field.values"
|
||||||
|
:items="field.fieldOptions"
|
||||||
|
item-text="label"
|
||||||
|
item-value="value"
|
||||||
|
multiple
|
||||||
|
@input="setFieldValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="field.type === 'string'"
|
||||||
|
v-model="field.value"
|
||||||
|
@input="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="field.value"
|
||||||
|
type="number"
|
||||||
|
@input="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-checkbox
|
||||||
|
v-else-if="field.type === 'boolean'"
|
||||||
|
v-model="field.value"
|
||||||
|
@change="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-menu
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
v-model="datePickers[index]"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
offset-y
|
||||||
|
max-width="290px"
|
||||||
|
min-width="auto"
|
||||||
|
>
|
||||||
|
<template #activator="{ on, attrs: menuAttrs }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="field.value"
|
||||||
|
persistent-hint
|
||||||
|
:prepend-icon="$globals.icons.calendar"
|
||||||
|
v-bind="menuAttrs"
|
||||||
|
readonly
|
||||||
|
v-on="on"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
v-model="field.value"
|
||||||
|
no-title
|
||||||
|
:first-day-of-week="firstDayOfWeek"
|
||||||
|
:local="$i18n.locale"
|
||||||
|
@input="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.Category"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.Category"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
@input="setOrganizerValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.Tag"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.Tag"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
@input="setOrganizerValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.Tool"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.Tool"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
@input="setOrganizerValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.Food"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.Food"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
@input="setOrganizerValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.Household"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.Household"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
@input="setOrganizerValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
v-if="showAdvanced"
|
||||||
|
:cols="attrs.fields.rightParens.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.rightParens.style"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-model="field.rightParenthesis"
|
||||||
|
:items="['', ')', '))', ')))']"
|
||||||
|
@input="setRightParenthesisValue(field, index, $event)"
|
||||||
|
>
|
||||||
|
<template #selection="{ item }">
|
||||||
|
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
:cols="attrs.fields.fieldActions.cols"
|
||||||
|
:class="attrs.col.class"
|
||||||
|
:style="attrs.fields.fieldActions.style"
|
||||||
|
>
|
||||||
|
<BaseButtonGroup
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
text: $tc('general.delete'),
|
||||||
|
event: 'delete',
|
||||||
|
disabled: fields.length === 1,
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
class="my-auto"
|
||||||
|
@delete="removeField(index)"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</draggable>
|
||||||
|
</v-container>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-container fluid class="d-flex justify-end pa-0">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="showAdvanced"
|
||||||
|
hide-details
|
||||||
|
:label="$tc('general.show-advanced')"
|
||||||
|
class="my-auto mr-4"
|
||||||
|
/>
|
||||||
|
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" />
|
||||||
|
</v-container>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
|
||||||
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
|
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||||
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
|
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
draggable,
|
||||||
|
RecipeOrganizerSelector,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
fieldDefs: {
|
||||||
|
type: Array as () => FieldDefinition[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
initialQueryFilter: {
|
||||||
|
type: Object as () => QueryFilterJSON | null,
|
||||||
|
default: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props, context) {
|
||||||
|
const { household } = useHouseholdSelf();
|
||||||
|
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
showAdvanced: false,
|
||||||
|
qfValid: false,
|
||||||
|
datePickers: [] as boolean[],
|
||||||
|
drag: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeMap = {
|
||||||
|
[Organizer.Category]: useCategoryStore(),
|
||||||
|
[Organizer.Tag]: useTagStore(),
|
||||||
|
[Organizer.Tool]: useToolStore(),
|
||||||
|
[Organizer.Food]: useFoodStore(),
|
||||||
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function onDragEnd(event: any) {
|
||||||
|
state.drag = false;
|
||||||
|
|
||||||
|
const oldIndex: number = event.oldIndex;
|
||||||
|
const newIndex: number = event.newIndex;
|
||||||
|
state.datePickers[oldIndex] = false;
|
||||||
|
state.datePickers[newIndex] = false;
|
||||||
|
|
||||||
|
const field = fields.value.splice(oldIndex, 1)[0];
|
||||||
|
fields.value.splice(newIndex, 0, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = ref<Field[]>([]);
|
||||||
|
|
||||||
|
function addField(field: FieldDefinition) {
|
||||||
|
fields.value.push(getFieldFromFieldDef(field));
|
||||||
|
state.datePickers.push(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
function setField(index: number, fieldLabel: string) {
|
||||||
|
state.datePickers[index] = false;
|
||||||
|
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel);
|
||||||
|
if (!fieldDef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||||
|
const updatedField = {...fields.value[index], ...fieldDef};
|
||||||
|
|
||||||
|
// we have to set this explicitly since it might be undefined
|
||||||
|
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||||
|
|
||||||
|
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLeftParenthesisValue(field: Field, index: number, value: string) {
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
leftParenthesis: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRightParenthesisValue(field: Field, index: number, value: string) {
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
rightParenthesis: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
value = logOps.value.AND.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
logicalOperator: value ? logOps.value[value] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
relationalOperatorValue: relOps.value[value],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValue(field: Field, index: number, value: FieldValue) {
|
||||||
|
state.datePickers[index] = false;
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValues(field: Field, index: number, values: FieldValue[]) {
|
||||||
|
fields.value.splice(index, 1, {
|
||||||
|
...field,
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) {
|
||||||
|
setFieldValues(field, index, values.map((value) => value.id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index: number) {
|
||||||
|
fields.value.splice(index, 1);
|
||||||
|
state.datePickers.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
// Toggling showAdvanced changes the builder logic without changing the field values,
|
||||||
|
// so we need to manually trigger reactivity to re-run the builder.
|
||||||
|
() => state.showAdvanced,
|
||||||
|
() => {
|
||||||
|
if (fields.value?.length) {
|
||||||
|
fields.value = [...fields.value];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => fields.value,
|
||||||
|
(newFields) => {
|
||||||
|
newFields.forEach((field, index) => {
|
||||||
|
const updatedField = getFieldFromFieldDef(field);
|
||||||
|
fields.value[index] = updatedField;
|
||||||
|
});
|
||||||
|
|
||||||
|
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||||
|
if (qf) {
|
||||||
|
console.debug(`Set query filter: ${qf}`);
|
||||||
|
}
|
||||||
|
state.qfValid = !!qf;
|
||||||
|
|
||||||
|
context.emit("input", qf || undefined);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function hydrateOrganizers(field: Field, index: number) {
|
||||||
|
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.organizers = [];
|
||||||
|
|
||||||
|
const { store, actions } = storeMap[field.type];
|
||||||
|
if (!store.value.length) {
|
||||||
|
await actions.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value));
|
||||||
|
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[];
|
||||||
|
setOrganizerValues(field, index, field.organizers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFieldsError(error = "") {
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.value = [];
|
||||||
|
if (props.fieldDefs.length) {
|
||||||
|
addField(props.fieldDefs[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFields() {
|
||||||
|
if (!props.initialQueryFilter?.parts?.length) {
|
||||||
|
return initFieldsError();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initFields: Field[] = [];
|
||||||
|
let error = false;
|
||||||
|
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
||||||
|
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName);
|
||||||
|
if (!fieldDef) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = getFieldFromFieldDef(fieldDef);
|
||||||
|
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||||
|
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||||
|
field.logicalOperator = part.logicalOperator ?
|
||||||
|
logOps.value[part.logicalOperator] : field.logicalOperator;
|
||||||
|
field.relationalOperatorValue = part.relationalOperator ?
|
||||||
|
relOps.value[part.relationalOperator] : field.relationalOperatorValue;
|
||||||
|
|
||||||
|
if (field.leftParenthesis || field.rightParenthesis) {
|
||||||
|
state.showAdvanced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
|
if (typeof part.value === "string") {
|
||||||
|
field.values = part.value ? [part.value] : [];
|
||||||
|
} else {
|
||||||
|
field.values = part.value || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOrganizerType(field.type)) {
|
||||||
|
hydrateOrganizers(field, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (field.type === "boolean") {
|
||||||
|
const boolString = part.value || "false";
|
||||||
|
field.value = (
|
||||||
|
boolString[0].toLowerCase() === "t" ||
|
||||||
|
boolString[0].toLowerCase() === "y" ||
|
||||||
|
boolString[0] === "1"
|
||||||
|
);
|
||||||
|
} else if (field.type === "number") {
|
||||||
|
field.value = Number(part.value as string || "0");
|
||||||
|
if (isNaN(field.value)) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||||
|
}
|
||||||
|
} else if (field.type === "date") {
|
||||||
|
field.value = part.value as string || "";
|
||||||
|
const date = new Date(field.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.value = part.value as string || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
initFields.push(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initFields.length && !error) {
|
||||||
|
fields.value = initFields;
|
||||||
|
} else {
|
||||||
|
initFieldsError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
initializeFields();
|
||||||
|
} catch (error) {
|
||||||
|
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const attrs = computed(() => {
|
||||||
|
const baseColMaxWidth = 55;
|
||||||
|
const attrs = {
|
||||||
|
col: {
|
||||||
|
class: "d-flex justify-center align-end field-col pa-1",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
textClass: "d-flex justify-center text-center",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
icon: {
|
||||||
|
cols: 1,
|
||||||
|
style: "width: fit-content;",
|
||||||
|
},
|
||||||
|
leftParens: {
|
||||||
|
cols: state.showAdvanced ? 1 : 0,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||||
|
},
|
||||||
|
logicalOperator: {
|
||||||
|
cols: 1,
|
||||||
|
style: `min-width: ${baseColMaxWidth}px;`,
|
||||||
|
},
|
||||||
|
fieldName: {
|
||||||
|
cols: state.showAdvanced ? 2 : 3,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||||
|
},
|
||||||
|
relationalOperator: {
|
||||||
|
cols: 2,
|
||||||
|
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||||
|
},
|
||||||
|
fieldValue: {
|
||||||
|
cols: state.showAdvanced ? 3 : 4,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||||
|
},
|
||||||
|
rightParens: {
|
||||||
|
cols: state.showAdvanced ? 1 : 0,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||||
|
},
|
||||||
|
fieldActions: {
|
||||||
|
cols: 1,
|
||||||
|
style: `min-width: ${baseColMaxWidth}px;`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs;
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
Organizer,
|
||||||
|
...toRefs(state),
|
||||||
|
logOps,
|
||||||
|
relOps,
|
||||||
|
attrs,
|
||||||
|
firstDayOfWeek,
|
||||||
|
onDragEnd,
|
||||||
|
// Fields
|
||||||
|
fields,
|
||||||
|
addField,
|
||||||
|
setField,
|
||||||
|
setLeftParenthesisValue,
|
||||||
|
setRightParenthesisValue,
|
||||||
|
setLogicalOperatorValue,
|
||||||
|
setRelationalOperatorValue,
|
||||||
|
setFieldValue,
|
||||||
|
setFieldValues,
|
||||||
|
setOrganizerValues,
|
||||||
|
removeField,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
* {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -143,7 +143,9 @@ export default defineComponent({
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
"categories": "category.category",
|
"categories": "category.category",
|
||||||
"tags": "tag.tag",
|
"tags": "tag.tag",
|
||||||
"tools": "tool.tool"
|
"tools": "tool.tool",
|
||||||
|
"foods": "shopping-list.food",
|
||||||
|
"households": "household.household",
|
||||||
};
|
};
|
||||||
return typeMap[props.itemType] || "";
|
return typeMap[props.itemType] || "";
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,13 +8,12 @@
|
||||||
deletable-chips
|
deletable-chips
|
||||||
item-text="name"
|
item-text="name"
|
||||||
multiple
|
multiple
|
||||||
:prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam :
|
:prepend-inner-icon="icon"
|
||||||
selectorType === Organizer.Category ? $globals.icons.categories :
|
|
||||||
$globals.icons.tags"
|
|
||||||
return-object
|
return-object
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
:search-input.sync="searchInput"
|
:search-input.sync="searchInput"
|
||||||
|
class="pa-0"
|
||||||
@change="resetSearchInput"
|
@change="resetSearchInput"
|
||||||
>
|
>
|
||||||
<template #selection="data">
|
<template #selection="data">
|
||||||
|
@ -46,11 +45,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||||
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||||
import { RecipeTool } from "~/lib/api/types/admin";
|
import { RecipeTool } from "~/lib/api/types/admin";
|
||||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
|
||||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
@ -58,7 +57,14 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool | string)[] | undefined,
|
type: Array as () => (
|
||||||
|
| HouseholdSummary
|
||||||
|
| RecipeTag
|
||||||
|
| RecipeCategory
|
||||||
|
| RecipeTool
|
||||||
|
| IngredientFood
|
||||||
|
| string
|
||||||
|
)[] | undefined,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
@ -80,6 +86,14 @@ export default defineComponent({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showLabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showIcon: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
|
@ -96,9 +110,13 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const { $globals, i18n } = useContext();
|
||||||
|
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
|
if (!props.showLabel) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
switch (props.selectorType) {
|
switch (props.selectorType) {
|
||||||
case Organizer.Tag:
|
case Organizer.Tag:
|
||||||
return i18n.t("tag.tags");
|
return i18n.t("tag.tags");
|
||||||
|
@ -106,30 +124,57 @@ export default defineComponent({
|
||||||
return i18n.t("category.categories");
|
return i18n.t("category.categories");
|
||||||
case Organizer.Tool:
|
case Organizer.Tool:
|
||||||
return i18n.t("tool.tools");
|
return i18n.t("tool.tools");
|
||||||
|
case Organizer.Food:
|
||||||
|
return i18n.t("general.foods");
|
||||||
|
case Organizer.Household:
|
||||||
|
return i18n.t("household.households");
|
||||||
default:
|
default:
|
||||||
return i18n.t("general.organizer");
|
return i18n.t("general.organizer");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (!props.showIcon) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.selectorType) {
|
||||||
|
case Organizer.Tag:
|
||||||
|
return $globals.icons.tags;
|
||||||
|
case Organizer.Category:
|
||||||
|
return $globals.icons.categories;
|
||||||
|
case Organizer.Tool:
|
||||||
|
return $globals.icons.tools;
|
||||||
|
case Organizer.Food:
|
||||||
|
return $globals.icons.foods;
|
||||||
|
case Organizer.Household:
|
||||||
|
return $globals.icons.household;
|
||||||
|
default:
|
||||||
|
return $globals.icons.tags;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Store & Items Setup
|
// Store & Items Setup
|
||||||
|
|
||||||
const store = (() => {
|
const storeMap = {
|
||||||
switch (props.selectorType) {
|
[Organizer.Category]: useCategoryStore(),
|
||||||
case Organizer.Tag:
|
[Organizer.Tag]: useTagStore(),
|
||||||
return useTagStore();
|
[Organizer.Tool]: useToolStore(),
|
||||||
case Organizer.Tool:
|
[Organizer.Food]: useFoodStore(),
|
||||||
return useToolStore();
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
default:
|
};
|
||||||
return useCategoryStore();
|
|
||||||
}
|
const store = computed(() => {
|
||||||
})();
|
const { store } = storeMap[props.selectorType];
|
||||||
|
return store.value;
|
||||||
|
})
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed(() => {
|
||||||
if (!props.returnObject) {
|
if (!props.returnObject) {
|
||||||
return store.store.value.map((item) => item.name);
|
return store.value.map((item) => item.name);
|
||||||
}
|
}
|
||||||
return store.store.value;
|
return store.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeByIndex(index: number) {
|
function removeByIndex(index: number) {
|
||||||
|
@ -140,7 +185,7 @@ export default defineComponent({
|
||||||
selected.value = [...newSelected];
|
selected.value = [...newSelected];
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
function appendCreated(item: any) {
|
||||||
if (selected.value === undefined) {
|
if (selected.value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -162,6 +207,7 @@ export default defineComponent({
|
||||||
dialog,
|
dialog,
|
||||||
storeItem: items,
|
storeItem: items,
|
||||||
label,
|
label,
|
||||||
|
icon,
|
||||||
selected,
|
selected,
|
||||||
removeByIndex,
|
removeByIndex,
|
||||||
searchInput,
|
searchInput,
|
||||||
|
@ -170,3 +216,10 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-autocomplete {
|
||||||
|
/* This aligns the input with other standard input fields */
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
{{ $t("general.confirm") }}
|
{{ $t("general.confirm") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<slot name="custom-card-action"></slot>
|
<slot name="custom-card-action"></slot>
|
||||||
<BaseButton v-if="$listeners.submit" type="submit" @click="submitEvent">
|
<BaseButton v-if="$listeners.submit" type="submit" :disabled="submitDisabled" @click="submitEvent">
|
||||||
{{ submitText }}
|
{{ submitText }}
|
||||||
<template v-if="submitIcon" #icon>
|
<template v-if="submitIcon" #icon>
|
||||||
{{ submitIcon }}
|
{{ submitIcon }}
|
||||||
|
@ -125,6 +125,10 @@ export default defineComponent({
|
||||||
return this.$t("general.create");
|
return this.$t("general.create");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
submitDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
keepOpen: {
|
keepOpen: {
|
||||||
default: false,
|
default: false,
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
|
@ -51,6 +51,9 @@ export function useReadOnlyActions<T extends BoundT>(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||||
|
params.orderBy ??= "name";
|
||||||
|
params.orderDirection ??= "asc";
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const { data } = await api.getAll(page, perPage, params);
|
const { data } = await api.getAll(page, perPage, params);
|
||||||
|
|
||||||
|
@ -102,6 +105,9 @@ export function useStoreActions<T extends BoundT>(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||||
|
params.orderBy ??= "name";
|
||||||
|
params.orderDirection ??= "asc";
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const { data } = await api.getAll(page, perPage, params);
|
const { data } = await api.getAll(page, perPage, params);
|
||||||
|
|
||||||
|
|
|
@ -103,6 +103,8 @@ export const useCookbooks = function () {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const { data } = await api.cookbooks.createOne({
|
const { data } = await api.cookbooks.createOne({
|
||||||
name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
|
name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
|
||||||
|
position: (cookbookStore?.value?.length ?? 0) + 1,
|
||||||
|
queryFilterString: "",
|
||||||
});
|
});
|
||||||
if (data && cookbookStore?.value) {
|
if (data && cookbookStore?.value) {
|
||||||
cookbookStore.value.push(data);
|
cookbookStore.value.push(data);
|
||||||
|
|
318
frontend/composables/use-query-filter-builder.ts
Normal file
318
frontend/composables/use-query-filter-builder.ts
Normal file
|
@ -0,0 +1,318 @@
|
||||||
|
import { computed, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
|
import { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||||
|
|
||||||
|
export interface FieldLogicalOperator {
|
||||||
|
label: string;
|
||||||
|
value: LogicalOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldRelationalOperator {
|
||||||
|
label: string;
|
||||||
|
value: RelationalKeyword | RelationalOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrganizerBase {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "date"
|
||||||
|
| RecipeOrganizer;
|
||||||
|
|
||||||
|
export type FieldValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| Date
|
||||||
|
| Organizer;
|
||||||
|
|
||||||
|
export interface SelectableItem {
|
||||||
|
label: string;
|
||||||
|
value: FieldValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FieldDefinition {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: FieldType;
|
||||||
|
|
||||||
|
// only for select/organizer fields
|
||||||
|
fieldOptions?: SelectableItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Field extends FieldDefinition {
|
||||||
|
leftParenthesis?: string;
|
||||||
|
logicalOperator?: FieldLogicalOperator;
|
||||||
|
value: FieldValue;
|
||||||
|
relationalOperatorValue: FieldRelationalOperator;
|
||||||
|
relationalOperatorOptions: FieldRelationalOperator[];
|
||||||
|
rightParenthesis?: string;
|
||||||
|
|
||||||
|
// only for select/organizer fields
|
||||||
|
values: FieldValue[];
|
||||||
|
organizers: OrganizerBase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueryFilterBuilder() {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
|
||||||
|
const logOps = computed<Record<LogicalOperator, FieldLogicalOperator>>(() => {
|
||||||
|
const AND = {
|
||||||
|
label: i18n.tc("query-filter.logical-operators.and"),
|
||||||
|
value: "AND",
|
||||||
|
} as FieldLogicalOperator;
|
||||||
|
|
||||||
|
const OR = {
|
||||||
|
label: i18n.tc("query-filter.logical-operators.or"),
|
||||||
|
value: "OR",
|
||||||
|
} as FieldLogicalOperator;
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND,
|
||||||
|
OR,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const relOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
|
||||||
|
const EQ = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.equals"),
|
||||||
|
value: "=",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const NOT_EQ = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.does-not-equal"),
|
||||||
|
value: "<>",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const GT = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.is-greater-than"),
|
||||||
|
value: ">",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const GTE = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.is-greater-than-or-equal-to"),
|
||||||
|
value: ">=",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const LT = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.is-less-than"),
|
||||||
|
value: "<",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const LTE = {
|
||||||
|
label: i18n.tc("query-filter.relational-operators.is-less-than-or-equal-to"),
|
||||||
|
value: "<=",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const IS = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is"),
|
||||||
|
value: "IS",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const IS_NOT = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is-not"),
|
||||||
|
value: "IS NOT",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const IN = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is-one-of"),
|
||||||
|
value: "IN",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const NOT_IN = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is-not-one-of"),
|
||||||
|
value: "NOT IN",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const CONTAINS_ALL = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.contains-all-of"),
|
||||||
|
value: "CONTAINS ALL",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const LIKE = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is-like"),
|
||||||
|
value: "LIKE",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
const NOT_LIKE = {
|
||||||
|
label: i18n.tc("query-filter.relational-keywords.is-not-like"),
|
||||||
|
value: "NOT LIKE",
|
||||||
|
} as FieldRelationalOperator;
|
||||||
|
|
||||||
|
/* eslint-disable object-shorthand */
|
||||||
|
return {
|
||||||
|
"=": EQ,
|
||||||
|
"<>": NOT_EQ,
|
||||||
|
">": GT,
|
||||||
|
">=": GTE,
|
||||||
|
"<": LT,
|
||||||
|
"<=": LTE,
|
||||||
|
"IS": IS,
|
||||||
|
"IS NOT": IS_NOT,
|
||||||
|
"IN": IN,
|
||||||
|
"NOT IN": NOT_IN,
|
||||||
|
"CONTAINS ALL": CONTAINS_ALL,
|
||||||
|
"LIKE": LIKE,
|
||||||
|
"NOT LIKE": NOT_LIKE,
|
||||||
|
};
|
||||||
|
/* eslint-enable object-shorthand */
|
||||||
|
});
|
||||||
|
|
||||||
|
function isOrganizerType(type: FieldType): type is Organizer {
|
||||||
|
return (
|
||||||
|
type === Organizer.Category ||
|
||||||
|
type === Organizer.Tag ||
|
||||||
|
type === Organizer.Tool ||
|
||||||
|
type === Organizer.Food ||
|
||||||
|
type === Organizer.Household
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
|
||||||
|
/* eslint-disable dot-notation */
|
||||||
|
const updatedField = {logicalOperator: logOps.value.AND, ...field} as Field;
|
||||||
|
let operatorOptions: FieldRelationalOperator[];
|
||||||
|
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
|
||||||
|
operatorOptions = [
|
||||||
|
relOps.value["IN"],
|
||||||
|
relOps.value["NOT IN"],
|
||||||
|
relOps.value["CONTAINS ALL"],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
switch (updatedField.type) {
|
||||||
|
case "string":
|
||||||
|
operatorOptions = [
|
||||||
|
relOps.value["="],
|
||||||
|
relOps.value["<>"],
|
||||||
|
relOps.value["LIKE"],
|
||||||
|
relOps.value["NOT LIKE"],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
operatorOptions = [
|
||||||
|
relOps.value["="],
|
||||||
|
relOps.value["<>"],
|
||||||
|
relOps.value[">"],
|
||||||
|
relOps.value[">="],
|
||||||
|
relOps.value["<"],
|
||||||
|
relOps.value["<="],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
operatorOptions = [relOps.value["="]];
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
operatorOptions = [
|
||||||
|
relOps.value["="],
|
||||||
|
relOps.value["<>"],
|
||||||
|
relOps.value[">"],
|
||||||
|
relOps.value[">="],
|
||||||
|
relOps.value["<"],
|
||||||
|
relOps.value["<="],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
operatorOptions = [relOps.value["="], relOps.value["<>"]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatedField.relationalOperatorOptions = operatorOptions;
|
||||||
|
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
|
||||||
|
updatedField.relationalOperatorValue = operatorOptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetValue) {
|
||||||
|
updatedField.value = "";
|
||||||
|
updatedField.values = [];
|
||||||
|
updatedField.organizers = [];
|
||||||
|
} else {
|
||||||
|
updatedField.value = updatedField.value || "";
|
||||||
|
updatedField.values = updatedField.values || [];
|
||||||
|
updatedField.organizers = updatedField.organizers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
|
/* eslint-enable dot-notation */
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildQueryFilterString(fields: Field[], useParenthesis: boolean): string {
|
||||||
|
let isValid = true;
|
||||||
|
let lParenCounter = 0;
|
||||||
|
let rParenCounter = 0;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
fields.forEach((field, index) => {
|
||||||
|
if (index) {
|
||||||
|
if (!field.logicalOperator) {
|
||||||
|
field.logicalOperator = logOps.value.AND;
|
||||||
|
}
|
||||||
|
parts.push(field.logicalOperator.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.leftParenthesis && useParenthesis) {
|
||||||
|
lParenCounter += field.leftParenthesis.length;
|
||||||
|
parts.push(field.leftParenthesis);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.label) {
|
||||||
|
parts.push(field.name);
|
||||||
|
} else {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.relationalOperatorValue) {
|
||||||
|
parts.push(field.relationalOperatorValue.value);
|
||||||
|
} else if (field.type !== "boolean") {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
|
if (field.values?.length) {
|
||||||
|
let val: string;
|
||||||
|
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
|
||||||
|
val = field.values.map((value) => `"${value.toString()}"`).join(",");
|
||||||
|
} else {
|
||||||
|
val = field.values.join(",");
|
||||||
|
}
|
||||||
|
parts.push(`[${val}]`);
|
||||||
|
} else {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
} else if (field.value) {
|
||||||
|
if (field.type === "string" || field.type === "date") {
|
||||||
|
parts.push(`"${field.value.toString()}"`);
|
||||||
|
} else {
|
||||||
|
parts.push(field.value.toString());
|
||||||
|
}
|
||||||
|
} else if (field.type === "boolean") {
|
||||||
|
parts.push("false");
|
||||||
|
} else {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.rightParenthesis && useParenthesis) {
|
||||||
|
rParenCounter += field.rightParenthesis.length;
|
||||||
|
parts.push(field.rightParenthesis);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lParenCounter !== rParenCounter) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid ? parts.join(" ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logOps,
|
||||||
|
relOps,
|
||||||
|
buildQueryFilterString,
|
||||||
|
getFieldFromFieldDef,
|
||||||
|
isOrganizerType,
|
||||||
|
};
|
||||||
|
}
|
|
@ -212,7 +212,11 @@
|
||||||
"clipboard-copy-failure": "Failed to copy to the clipboard.",
|
"clipboard-copy-failure": "Failed to copy to the clipboard.",
|
||||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||||
"organizers": "Organizers",
|
"organizers": "Organizers",
|
||||||
"caution": "Caution"
|
"caution": "Caution",
|
||||||
|
"show-advanced": "Show Advanced",
|
||||||
|
"add-field": "Add Field",
|
||||||
|
"date-created": "Date Created",
|
||||||
|
"date-updated": "Date Updated"
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
||||||
|
@ -351,7 +355,7 @@
|
||||||
"for-type-meal-types": "for {0} meal types",
|
"for-type-meal-types": "for {0} meal types",
|
||||||
"meal-plan-rules": "Meal Plan Rules",
|
"meal-plan-rules": "Meal Plan Rules",
|
||||||
"new-rule": "New Rule",
|
"new-rule": "New Rule",
|
||||||
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the categories of the rules will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
|
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
|
||||||
"new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.",
|
"new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.",
|
||||||
"recipe-rules": "Recipe Rules",
|
"recipe-rules": "Recipe Rules",
|
||||||
"applies-to-all-days": "Applies to all days",
|
"applies-to-all-days": "Applies to all days",
|
||||||
|
@ -1319,7 +1323,7 @@
|
||||||
},
|
},
|
||||||
"cookbook": {
|
"cookbook": {
|
||||||
"cookbooks": "Cookbooks",
|
"cookbooks": "Cookbooks",
|
||||||
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the cookbook.",
|
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
|
||||||
"public-cookbook": "Public Cookbook",
|
"public-cookbook": "Public Cookbook",
|
||||||
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
|
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
|
||||||
"filter-options": "Filter Options",
|
"filter-options": "Filter Options",
|
||||||
|
@ -1332,5 +1336,28 @@
|
||||||
"household-cookbook-name": "{0} Cookbook {1}",
|
"household-cookbook-name": "{0} Cookbook {1}",
|
||||||
"create-a-cookbook": "Create a Cookbook",
|
"create-a-cookbook": "Create a Cookbook",
|
||||||
"cookbook": "Cookbook"
|
"cookbook": "Cookbook"
|
||||||
|
},
|
||||||
|
"query-filter": {
|
||||||
|
"logical-operators": {
|
||||||
|
"and": "AND",
|
||||||
|
"or": "OR"
|
||||||
|
},
|
||||||
|
"relational-operators": {
|
||||||
|
"equals": "equals",
|
||||||
|
"does-not-equal": "does not equal",
|
||||||
|
"is-greater-than": "is greater than",
|
||||||
|
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||||
|
"is-less-than": "is less than",
|
||||||
|
"is-less-than-or-equal-to": "is less than or equal to"
|
||||||
|
},
|
||||||
|
"relational-keywords": {
|
||||||
|
"is": "is",
|
||||||
|
"is-not": "is not",
|
||||||
|
"is-one-of": "is one of",
|
||||||
|
"is-not-one-of": "is not one of",
|
||||||
|
"contains-all-of": "contains all of",
|
||||||
|
"is-like": "is like",
|
||||||
|
"is-not-like": "is not like"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,34 +5,17 @@
|
||||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type LogicalOperator = "AND" | "OR";
|
||||||
|
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||||
|
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||||
|
|
||||||
export interface CreateCookBook {
|
export interface CreateCookBook {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
categories?: CategoryBase[];
|
queryFilterString: string;
|
||||||
tags?: TagBase[];
|
|
||||||
tools?: RecipeTool[];
|
|
||||||
requireAllCategories?: boolean;
|
|
||||||
requireAllTags?: boolean;
|
|
||||||
requireAllTools?: boolean;
|
|
||||||
}
|
|
||||||
export interface CategoryBase {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
export interface TagBase {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
export interface RecipeTool {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
onHand?: boolean;
|
|
||||||
}
|
}
|
||||||
export interface ReadCookBook {
|
export interface ReadCookBook {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -40,15 +23,23 @@ export interface ReadCookBook {
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
categories?: CategoryBase[];
|
queryFilterString: string;
|
||||||
tags?: TagBase[];
|
|
||||||
tools?: RecipeTool[];
|
|
||||||
requireAllCategories?: boolean;
|
|
||||||
requireAllTags?: boolean;
|
|
||||||
requireAllTools?: boolean;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
queryFilter: QueryFilterJSON;
|
||||||
|
}
|
||||||
|
export interface QueryFilterJSON {
|
||||||
|
parts?: QueryFilterJSONPart[];
|
||||||
|
}
|
||||||
|
export interface QueryFilterJSONPart {
|
||||||
|
leftParenthesis?: string | null;
|
||||||
|
rightParenthesis?: string | null;
|
||||||
|
logicalOperator?: LogicalOperator | null;
|
||||||
|
attributeName?: string | null;
|
||||||
|
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||||
|
value?: string | string[] | null;
|
||||||
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
export interface RecipeCookBook {
|
export interface RecipeCookBook {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -56,15 +47,11 @@ export interface RecipeCookBook {
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
categories?: CategoryBase[];
|
queryFilterString: string;
|
||||||
tags?: TagBase[];
|
|
||||||
tools?: RecipeTool[];
|
|
||||||
requireAllCategories?: boolean;
|
|
||||||
requireAllTags?: boolean;
|
|
||||||
requireAllTools?: boolean;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
queryFilter: QueryFilterJSON;
|
||||||
recipes: RecipeSummary[];
|
recipes: RecipeSummary[];
|
||||||
}
|
}
|
||||||
export interface RecipeSummary {
|
export interface RecipeSummary {
|
||||||
|
@ -104,18 +91,20 @@ export interface RecipeTag {
|
||||||
slug: string;
|
slug: string;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
export interface RecipeTool {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
onHand?: boolean;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
export interface SaveCookBook {
|
export interface SaveCookBook {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
categories?: CategoryBase[];
|
queryFilterString: string;
|
||||||
tags?: TagBase[];
|
|
||||||
tools?: RecipeTool[];
|
|
||||||
requireAllCategories?: boolean;
|
|
||||||
requireAllTags?: boolean;
|
|
||||||
requireAllTools?: boolean;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
}
|
}
|
||||||
|
@ -125,12 +114,7 @@ export interface UpdateCookBook {
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
position?: number;
|
position?: number;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
categories?: CategoryBase[];
|
queryFilterString: string;
|
||||||
tags?: TagBase[];
|
|
||||||
tools?: RecipeTool[];
|
|
||||||
requireAllCategories?: boolean;
|
|
||||||
requireAllTags?: boolean;
|
|
||||||
requireAllTools?: boolean;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -8,6 +8,9 @@
|
||||||
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
|
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
|
||||||
export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset";
|
export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset";
|
||||||
export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "side" | "unset";
|
export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "side" | "unset";
|
||||||
|
export type LogicalOperator = "AND" | "OR";
|
||||||
|
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||||
|
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -31,44 +34,36 @@ export interface ListItem {
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
export interface PlanCategory {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
export interface PlanHousehold {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
export interface PlanRulesCreate {
|
export interface PlanRulesCreate {
|
||||||
day?: PlanRulesDay & string;
|
day?: PlanRulesDay & string;
|
||||||
entryType?: PlanRulesType & string;
|
entryType?: PlanRulesType & string;
|
||||||
categories?: PlanCategory[];
|
queryFilterString: string;
|
||||||
tags?: PlanTag[];
|
|
||||||
households?: PlanHousehold[];
|
|
||||||
}
|
|
||||||
export interface PlanTag {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
export interface PlanRulesOut {
|
export interface PlanRulesOut {
|
||||||
day?: PlanRulesDay & string;
|
day?: PlanRulesDay & string;
|
||||||
entryType?: PlanRulesType & string;
|
entryType?: PlanRulesType & string;
|
||||||
categories?: PlanCategory[];
|
queryFilterString: string;
|
||||||
tags?: PlanTag[];
|
|
||||||
households?: PlanHousehold[];
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
queryFilter: QueryFilterJSON;
|
||||||
|
}
|
||||||
|
export interface QueryFilterJSON {
|
||||||
|
parts?: QueryFilterJSONPart[];
|
||||||
|
}
|
||||||
|
export interface QueryFilterJSONPart {
|
||||||
|
leftParenthesis?: string | null;
|
||||||
|
rightParenthesis?: string | null;
|
||||||
|
logicalOperator?: LogicalOperator | null;
|
||||||
|
attributeName?: string | null;
|
||||||
|
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||||
|
value?: string | string[] | null;
|
||||||
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
export interface PlanRulesSave {
|
export interface PlanRulesSave {
|
||||||
day?: PlanRulesDay & string;
|
day?: PlanRulesDay & string;
|
||||||
entryType?: PlanRulesType & string;
|
entryType?: PlanRulesType & string;
|
||||||
categories?: PlanCategory[];
|
queryFilterString: string;
|
||||||
tags?: PlanTag[];
|
|
||||||
households?: PlanHousehold[];
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
}
|
}
|
||||||
|
@ -126,6 +121,7 @@ export interface RecipeTool {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
onHand?: boolean;
|
onHand?: boolean;
|
||||||
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
export interface SavePlanEntry {
|
export interface SavePlanEntry {
|
||||||
date: string;
|
date: string;
|
||||||
|
|
|
@ -24,10 +24,17 @@ export interface PaginationData<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecipeOrganizer = "categories" | "tags" | "tools";
|
export type RecipeOrganizer =
|
||||||
|
| "categories"
|
||||||
|
| "tags"
|
||||||
|
| "tools"
|
||||||
|
| "foods"
|
||||||
|
| "households";
|
||||||
|
|
||||||
export enum Organizer {
|
export enum Organizer {
|
||||||
Category = "categories",
|
Category = "categories",
|
||||||
Tag = "tags",
|
Tag = "tags",
|
||||||
Tool = "tools",
|
Tool = "tools",
|
||||||
|
Food = "foods",
|
||||||
|
Household = "households",
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
export type OrderByNullPosition = "first" | "last";
|
export type OrderByNullPosition = "first" | "last";
|
||||||
export type OrderDirection = "asc" | "desc";
|
export type OrderDirection = "asc" | "desc";
|
||||||
|
export type LogicalOperator = "AND" | "OR";
|
||||||
|
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||||
|
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
message: string;
|
message: string;
|
||||||
|
@ -25,6 +28,17 @@ export interface PaginationQuery {
|
||||||
queryFilter?: string | null;
|
queryFilter?: string | null;
|
||||||
paginationSeed?: string | null;
|
paginationSeed?: string | null;
|
||||||
}
|
}
|
||||||
|
export interface QueryFilterJSON {
|
||||||
|
parts?: QueryFilterJSONPart[];
|
||||||
|
}
|
||||||
|
export interface QueryFilterJSONPart {
|
||||||
|
leftParenthesis?: string | null;
|
||||||
|
rightParenthesis?: string | null;
|
||||||
|
logicalOperator?: LogicalOperator | null;
|
||||||
|
attributeName?: string | null;
|
||||||
|
relationalOperator?: RelationalKeyword | RelationalOperator | null;
|
||||||
|
value?: string | string[] | null;
|
||||||
|
}
|
||||||
export interface RecipeSearchQuery {
|
export interface RecipeSearchQuery {
|
||||||
cookbook?: string | null;
|
cookbook?: string | null;
|
||||||
requireAllCategories?: boolean;
|
requireAllCategories?: boolean;
|
||||||
|
|
|
@ -14,7 +14,6 @@ export class MealPlanAPI extends BaseCRUDAPI<CreatePlanEntry, ReadPlanEntry, Upd
|
||||||
itemRoute = routes.mealplanId;
|
itemRoute = routes.mealplanId;
|
||||||
|
|
||||||
async setRandom(payload: CreateRandomEntry) {
|
async setRandom(payload: CreateRandomEntry) {
|
||||||
console.log(payload);
|
|
||||||
return await this.requests.post<ReadPlanEntry>(routes.random, payload);
|
return await this.requests.post<ReadPlanEntry>(routes.random, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,16 +4,19 @@
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-if="createTarget"
|
v-if="createTarget"
|
||||||
v-model="dialogStates.create"
|
v-model="dialogStates.create"
|
||||||
:width="650"
|
width="100%"
|
||||||
|
max-width="1100px"
|
||||||
:icon="$globals.icons.pages"
|
:icon="$globals.icons.pages"
|
||||||
:title="$t('cookbook.create-a-cookbook')"
|
:title="$t('cookbook.create-a-cookbook')"
|
||||||
:submit-icon="$globals.icons.save"
|
:submit-icon="$globals.icons.save"
|
||||||
:submit-text="$tc('general.save')"
|
:submit-text="$tc('general.save')"
|
||||||
|
:submit-disabled="!createTarget.queryFilterString"
|
||||||
@submit="actions.updateOne(createTarget)"
|
@submit="actions.updateOne(createTarget)"
|
||||||
@cancel="actions.deleteOne(createTarget.id)"
|
@cancel="deleteCreateTarget()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<CookbookEditor
|
<CookbookEditor
|
||||||
|
:key="createTargetKey"
|
||||||
:cookbook=createTarget
|
:cookbook=createTarget
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
/>
|
/>
|
||||||
|
@ -36,7 +39,7 @@
|
||||||
|
|
||||||
<!-- Cookbook Page -->
|
<!-- Cookbook Page -->
|
||||||
<!-- Page Title -->
|
<!-- Page Title -->
|
||||||
<v-container class="narrow-container">
|
<v-container class="px-12">
|
||||||
<BasePageTitle divider>
|
<BasePageTitle divider>
|
||||||
<template #header>
|
<template #header>
|
||||||
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
|
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
|
||||||
|
@ -51,7 +54,7 @@
|
||||||
<!-- Cookbook List -->
|
<!-- Cookbook List -->
|
||||||
<v-expansion-panels class="mt-2">
|
<v-expansion-panels class="mt-2">
|
||||||
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
|
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
|
||||||
<v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 left-border rounded">
|
<v-expansion-panel v-for="cookbook in cookbooks" :key="cookbook.id" class="my-2 left-border rounded">
|
||||||
<v-expansion-panel-header disable-icon-rotate class="headline">
|
<v-expansion-panel-header disable-icon-rotate class="headline">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-icon large left>
|
<v-icon large left>
|
||||||
|
@ -84,6 +87,7 @@
|
||||||
icon: $globals.icons.save,
|
icon: $globals.icons.save,
|
||||||
text: $tc('general.save'),
|
text: $tc('general.save'),
|
||||||
event: 'save',
|
event: 'save',
|
||||||
|
disabled: !cookbook.queryFilterString
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@delete="deleteEventHandler(cookbook)"
|
@delete="deleteEventHandler(cookbook)"
|
||||||
|
@ -99,7 +103,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import { defineComponent, reactive, ref } from "@nuxtjs/composition-api";
|
import { defineComponent, onBeforeUnmount, onMounted, reactive, ref } from "@nuxtjs/composition-api";
|
||||||
import draggable from "vuedraggable";
|
import draggable from "vuedraggable";
|
||||||
import { useCookbooks } from "@/composables/use-group-cookbooks";
|
import { useCookbooks } from "@/composables/use-group-cookbooks";
|
||||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||||
|
@ -116,10 +120,12 @@ export default defineComponent({
|
||||||
const { cookbooks, actions } = useCookbooks();
|
const { cookbooks, actions } = useCookbooks();
|
||||||
|
|
||||||
// create
|
// create
|
||||||
|
const createTargetKey = ref(0);
|
||||||
const createTarget = ref<ReadCookBook | null>(null);
|
const createTarget = ref<ReadCookBook | null>(null);
|
||||||
async function createCookbook() {
|
async function createCookbook() {
|
||||||
await actions.createOne().then((cookbook) => {
|
await actions.createOne().then((cookbook) => {
|
||||||
createTarget.value = cookbook as ReadCookBook;
|
createTarget.value = cookbook as ReadCookBook;
|
||||||
|
createTargetKey.value++;
|
||||||
});
|
});
|
||||||
dialogStates.create = true;
|
dialogStates.create = true;
|
||||||
}
|
}
|
||||||
|
@ -138,11 +144,37 @@ export default defineComponent({
|
||||||
dialogStates.delete = false;
|
dialogStates.delete = false;
|
||||||
deleteTarget.value = null;
|
deleteTarget.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteCreateTarget() {
|
||||||
|
if (!createTarget.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.deleteOne(createTarget.value.id);
|
||||||
|
dialogStates.create = false;
|
||||||
|
createTarget.value = null;
|
||||||
|
}
|
||||||
|
function handleUnmount() {
|
||||||
|
if(!createTarget.value?.id || createTarget.value.queryFilterString) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCreateTarget();
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("beforeunload", handleUnmount);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
handleUnmount();
|
||||||
|
window.removeEventListener("beforeunload", handleUnmount);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cookbooks,
|
cookbooks,
|
||||||
actions,
|
actions,
|
||||||
dialogStates,
|
dialogStates,
|
||||||
// create
|
// create
|
||||||
|
createTargetKey,
|
||||||
createTarget,
|
createTarget,
|
||||||
createCookbook,
|
createCookbook,
|
||||||
|
|
||||||
|
@ -150,6 +182,7 @@ export default defineComponent({
|
||||||
deleteTarget,
|
deleteTarget,
|
||||||
deleteEventHandler,
|
deleteEventHandler,
|
||||||
deleteCookbook,
|
deleteCookbook,
|
||||||
|
deleteCreateTarget,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head() {
|
head() {
|
||||||
|
|
|
@ -15,16 +15,15 @@
|
||||||
{{ $t('meal-plan.new-rule-description') }}
|
{{ $t('meal-plan.new-rule-description') }}
|
||||||
|
|
||||||
<GroupMealPlanRuleForm
|
<GroupMealPlanRuleForm
|
||||||
|
:key="createDataFormKey"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
:day.sync="createData.day"
|
:day.sync="createData.day"
|
||||||
:entry-type.sync="createData.entryType"
|
:entry-type.sync="createData.entryType"
|
||||||
:categories.sync="createData.categories"
|
:query-filter-string.sync="createData.queryFilterString"
|
||||||
:tags.sync="createData.tags"
|
|
||||||
:households.sync="createData.households"
|
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="justify-end">
|
<v-card-actions class="justify-end">
|
||||||
<BaseButton create @click="createRule" />
|
<BaseButton create :disabled="!createData.queryFilterString" @click="createRule" />
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
@ -117,12 +116,11 @@
|
||||||
<GroupMealPlanRuleForm
|
<GroupMealPlanRuleForm
|
||||||
:day.sync="allRules[idx].day"
|
:day.sync="allRules[idx].day"
|
||||||
:entry-type.sync="allRules[idx].entryType"
|
:entry-type.sync="allRules[idx].entryType"
|
||||||
:categories.sync="allRules[idx].categories"
|
:query-filter-string.sync="allRules[idx].queryFilterString"
|
||||||
:tags.sync="allRules[idx].tags"
|
:query-filter="allRules[idx].queryFilter"
|
||||||
:households.sync="allRules[idx].households"
|
|
||||||
/>
|
/>
|
||||||
<div class="d-flex justify-end">
|
<div class="d-flex justify-end">
|
||||||
<BaseButton update @click="updateRule(rule)" />
|
<BaseButton update :disabled="!allRules[idx].queryFilterString" @click="updateRule(rule)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
@ -181,12 +179,11 @@ export default defineComponent({
|
||||||
// ======================================================
|
// ======================================================
|
||||||
// Creating Rules
|
// Creating Rules
|
||||||
|
|
||||||
|
const createDataFormKey = ref(0);
|
||||||
const createData = ref<PlanRulesCreate>({
|
const createData = ref<PlanRulesCreate>({
|
||||||
entryType: "unset",
|
entryType: "unset",
|
||||||
day: "unset",
|
day: "unset",
|
||||||
categories: [],
|
queryFilterString: "",
|
||||||
tags: [],
|
|
||||||
households: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createRule() {
|
async function createRule() {
|
||||||
|
@ -196,10 +193,9 @@ export default defineComponent({
|
||||||
createData.value = {
|
createData.value = {
|
||||||
entryType: "unset",
|
entryType: "unset",
|
||||||
day: "unset",
|
day: "unset",
|
||||||
categories: [],
|
queryFilterString: "",
|
||||||
tags: [],
|
|
||||||
households: [],
|
|
||||||
};
|
};
|
||||||
|
createDataFormKey.value++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,6 +216,7 @@ export default defineComponent({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allRules,
|
allRules,
|
||||||
|
createDataFormKey,
|
||||||
createData,
|
createData,
|
||||||
createRule,
|
createRule,
|
||||||
deleteRule,
|
deleteRule,
|
||||||
|
|
|
@ -33,7 +33,9 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
||||||
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(String, default="")
|
description: Mapped[str | None] = mapped_column(String, default="")
|
||||||
public: Mapped[str | None] = mapped_column(Boolean, default=False)
|
public: Mapped[str | None] = mapped_column(Boolean, default=False)
|
||||||
|
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||||
|
|
||||||
|
# Old filters - deprecated in favor of query filter strings
|
||||||
categories: Mapped[list[Category]] = orm.relationship(
|
categories: Mapped[list[Category]] = orm.relationship(
|
||||||
Category, secondary=cookbooks_to_categories, single_parent=True
|
Category, secondary=cookbooks_to_categories, single_parent=True
|
||||||
)
|
)
|
||||||
|
|
|
@ -40,8 +40,9 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||||
entry_type: Mapped[str] = mapped_column(
|
entry_type: Mapped[str] = mapped_column(
|
||||||
String, nullable=False, default=""
|
String, nullable=False, default=""
|
||||||
) # "breakfast", "lunch", "dinner", "side"
|
) # "breakfast", "lunch", "dinner", "side"
|
||||||
|
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||||
|
|
||||||
# Filters
|
# Old filters - deprecated in favor of query filter strings
|
||||||
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
|
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
|
||||||
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
||||||
households: Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
|
households: Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
|
||||||
|
|
|
@ -10,7 +10,7 @@ from ._model_utils.guid import GUID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .group.group import Group
|
from .group.group import Group
|
||||||
from .group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||||
from .recipe import IngredientFoodModel
|
from .recipe import IngredientFoodModel
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ from mealie.core.root_logger import get_logger
|
||||||
from mealie.db.models._model_base import SqlAlchemyBase
|
from mealie.db.models._model_base import SqlAlchemyBase
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery
|
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery
|
||||||
from mealie.schema.response.query_filter import QueryFilter
|
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||||
from mealie.schema.response.query_search import SearchFilter
|
from mealie.schema.response.query_search import SearchFilter
|
||||||
|
|
||||||
from ._utils import NOT_SET, NotSet
|
from ._utils import NOT_SET, NotSet
|
||||||
|
@ -349,8 +349,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||||
|
|
||||||
if pagination.query_filter:
|
if pagination.query_filter:
|
||||||
try:
|
try:
|
||||||
query_filter = QueryFilter(pagination.query_filter)
|
query_filter_builder = QueryFilterBuilder(pagination.query_filter)
|
||||||
query = query_filter.filter_query(query, model=self.model)
|
query = query_filter_builder.filter_query(query, model=self.model)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self.logger.error(e)
|
self.logger.error(e)
|
||||||
|
@ -434,7 +434,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||||
order_by = order_by_val
|
order_by = order_by_val
|
||||||
order_dir = pagination.order_direction
|
order_dir = pagination.order_direction
|
||||||
|
|
||||||
_, order_attr, query = QueryFilter.get_model_and_model_attr_from_attr_string(
|
_, order_attr, query = QueryFilterBuilder.get_model_and_model_attr_from_attr_string(
|
||||||
order_by, self.model, query=query
|
order_by, self.model, query=query
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -19,13 +19,7 @@ from mealie.db.models.recipe.tool import Tool
|
||||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
from mealie.schema.recipe.recipe import (
|
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
|
||||||
RecipeCategory,
|
|
||||||
RecipePagination,
|
|
||||||
RecipeSummary,
|
|
||||||
RecipeTool,
|
|
||||||
)
|
|
||||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
|
||||||
from mealie.schema.response.pagination import (
|
from mealie.schema.response.pagination import (
|
||||||
OrderByNullPosition,
|
OrderByNullPosition,
|
||||||
OrderDirection,
|
OrderDirection,
|
||||||
|
@ -33,7 +27,6 @@ from mealie.schema.response.pagination import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..db.models._model_base import SqlAlchemyBase
|
from ..db.models._model_base import SqlAlchemyBase
|
||||||
from ..schema._mealie.mealie_model import extract_uuids
|
|
||||||
from .repository_generic import HouseholdRepositoryGeneric
|
from .repository_generic import HouseholdRepositoryGeneric
|
||||||
|
|
||||||
|
|
||||||
|
@ -173,17 +166,12 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||||
q = q.filter_by(**fltr)
|
q = q.filter_by(**fltr)
|
||||||
|
|
||||||
if cookbook:
|
if cookbook:
|
||||||
cb_filters = self._build_recipe_filter(
|
if pagination_result.query_filter and cookbook.query_filter_string:
|
||||||
households=[cookbook.household_id],
|
pagination_result.query_filter = (
|
||||||
categories=extract_uuids(cookbook.categories),
|
f"({pagination_result.query_filter}) AND ({cookbook.query_filter_string})"
|
||||||
tags=extract_uuids(cookbook.tags),
|
)
|
||||||
tools=extract_uuids(cookbook.tools),
|
else:
|
||||||
require_all_categories=cookbook.require_all_categories,
|
pagination_result.query_filter = cookbook.query_filter_string
|
||||||
require_all_tags=cookbook.require_all_tags,
|
|
||||||
require_all_tools=cookbook.require_all_tools,
|
|
||||||
)
|
|
||||||
|
|
||||||
q = q.filter(*cb_filters)
|
|
||||||
else:
|
else:
|
||||||
category_ids = self._uuids_for_items(categories, Category)
|
category_ids = self._uuids_for_items(categories, Category)
|
||||||
tag_ids = self._uuids_for_items(tags, Tag)
|
tag_ids = self._uuids_for_items(tags, Tag)
|
||||||
|
@ -290,26 +278,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||||
fltr.append(RecipeModel.household_id.in_(households))
|
fltr.append(RecipeModel.household_id.in_(households))
|
||||||
return fltr
|
return fltr
|
||||||
|
|
||||||
def by_category_and_tags(
|
|
||||||
self,
|
|
||||||
categories: list[CategoryBase] | None = None,
|
|
||||||
tags: list[TagBase] | None = None,
|
|
||||||
tools: list[RecipeTool] | None = None,
|
|
||||||
require_all_categories: bool = True,
|
|
||||||
require_all_tags: bool = True,
|
|
||||||
require_all_tools: bool = True,
|
|
||||||
) -> list[Recipe]:
|
|
||||||
fltr = self._build_recipe_filter(
|
|
||||||
categories=extract_uuids(categories) if categories else None,
|
|
||||||
tags=extract_uuids(tags) if tags else None,
|
|
||||||
tools=extract_uuids(tools) if tools else None,
|
|
||||||
require_all_categories=require_all_categories,
|
|
||||||
require_all_tags=require_all_tags,
|
|
||||||
require_all_tools=require_all_tools,
|
|
||||||
)
|
|
||||||
stmt = sa.select(RecipeModel).filter(*fltr)
|
|
||||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
|
||||||
|
|
||||||
def get_random(self, limit=1) -> list[Recipe]:
|
def get_random(self, limit=1) -> list[Recipe]:
|
||||||
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
|
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
|
||||||
if self.group_id:
|
if self.group_id:
|
||||||
|
|
|
@ -3,7 +3,6 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.repos.all_repositories import get_repositories
|
|
||||||
from mealie.routes._base import controller
|
from mealie.routes._base import controller
|
||||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||||
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
|
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
|
||||||
|
@ -59,15 +58,12 @@ class PublicCookbooksController(BasePublicHouseholdExploreController):
|
||||||
if not household or household.preferences.private_household:
|
if not household or household.preferences.private_household:
|
||||||
raise NOT_FOUND_EXCEPTION
|
raise NOT_FOUND_EXCEPTION
|
||||||
|
|
||||||
# limit recipes to only the household the cookbook belongs to
|
cross_household_recipes = self.cross_household_repos.recipes
|
||||||
recipes_repo = get_repositories(
|
recipes = cross_household_recipes.page_all(
|
||||||
self.session, group_id=self.group_id, household_id=cookbook.household_id
|
|
||||||
).recipes
|
|
||||||
recipes = recipes_repo.page_all(
|
|
||||||
PaginationQuery(
|
PaginationQuery(
|
||||||
page=1,
|
page=1,
|
||||||
per_page=-1,
|
per_page=-1,
|
||||||
query_filter="settings.public = TRUE",
|
query_filter="settings.public = TRUE AND household.preferences.privateHousehold = FALSE",
|
||||||
),
|
),
|
||||||
cookbook=cookbook,
|
cookbook=cookbook,
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import orjson
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.repos.all_repositories import get_repositories
|
|
||||||
from mealie.routes._base import controller
|
from mealie.routes._base import controller
|
||||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||||
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
|
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
|
||||||
|
@ -40,7 +39,6 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
||||||
households: list[UUID4 | str] | None = Query(None),
|
households: list[UUID4 | str] | None = Query(None),
|
||||||
) -> PaginationBase[RecipeSummary]:
|
) -> PaginationBase[RecipeSummary]:
|
||||||
cookbook_data: ReadCookBook | None = None
|
cookbook_data: ReadCookBook | None = None
|
||||||
recipes_repo = self.cross_household_recipes
|
|
||||||
if search_query.cookbook:
|
if search_query.cookbook:
|
||||||
COOKBOOK_NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
|
COOKBOOK_NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
|
||||||
if isinstance(search_query.cookbook, UUID):
|
if isinstance(search_query.cookbook, UUID):
|
||||||
|
@ -59,18 +57,13 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
||||||
if not household or household.preferences.private_household:
|
if not household or household.preferences.private_household:
|
||||||
raise COOKBOOK_NOT_FOUND_EXCEPTION
|
raise COOKBOOK_NOT_FOUND_EXCEPTION
|
||||||
|
|
||||||
# filter recipes by the cookbook's household
|
|
||||||
recipes_repo = get_repositories(
|
|
||||||
self.session, group_id=self.group_id, household_id=cookbook_data.household_id
|
|
||||||
).recipes
|
|
||||||
|
|
||||||
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
|
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
|
||||||
if q.query_filter:
|
if q.query_filter:
|
||||||
q.query_filter = f"({q.query_filter}) AND {public_filter}"
|
q.query_filter = f"({q.query_filter}) AND {public_filter}"
|
||||||
else:
|
else:
|
||||||
q.query_filter = public_filter
|
q.query_filter = public_filter
|
||||||
|
|
||||||
pagination_response = recipes_repo.page_all(
|
pagination_response = self.cross_household_recipes.page_all(
|
||||||
pagination=q,
|
pagination=q,
|
||||||
cookbook=cookbook_data,
|
cookbook=cookbook_data,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
|
|
@ -109,17 +109,8 @@ class GroupCookbookController(BaseCrudController):
|
||||||
if cookbook is None:
|
if cookbook is None:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return cookbook.cast(
|
recipe_pagination = self.repos.recipes.page_all(PaginationQuery(page=1, per_page=-1, cookbook=cookbook))
|
||||||
RecipeCookBook,
|
return cookbook.cast(RecipeCookBook, recipes=recipe_pagination.items)
|
||||||
recipes=self.repos.recipes.by_category_and_tags(
|
|
||||||
cookbook.categories,
|
|
||||||
cookbook.tags,
|
|
||||||
cookbook.tools,
|
|
||||||
cookbook.require_all_categories,
|
|
||||||
cookbook.require_all_tags,
|
|
||||||
cookbook.require_all_tools,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.put("/{item_id}", response_model=ReadCookBook)
|
@router.put("/{item_id}", response_model=ReadCookBook)
|
||||||
def update_one(self, item_id: str, data: CreateCookBook):
|
def update_one(self, item_id: str, data: CreateCookBook):
|
||||||
|
|
|
@ -12,7 +12,7 @@ from mealie.routes._base.mixins import HttpRepo
|
||||||
from mealie.schema import mapper
|
from mealie.schema import mapper
|
||||||
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
||||||
from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
|
from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
|
||||||
from mealie.schema.meal_plan.plan_rules import PlanCategory, PlanHousehold, PlanRulesDay, PlanTag
|
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
|
||||||
from mealie.schema.recipe.recipe import Recipe
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
from mealie.schema.response.responses import ErrorResponse
|
from mealie.schema.response.responses import ErrorResponse
|
||||||
|
@ -54,31 +54,15 @@ class GroupMealplanController(BaseCrudController):
|
||||||
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
|
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
|
||||||
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||||
|
|
||||||
tags: list[PlanTag] = []
|
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
|
||||||
categories: list[PlanCategory] = []
|
|
||||||
households: list[PlanHousehold] = []
|
|
||||||
for rule in rules:
|
|
||||||
if rule.tags:
|
|
||||||
tags.extend(rule.tags)
|
|
||||||
if rule.categories:
|
|
||||||
categories.extend(rule.categories)
|
|
||||||
if rule.households:
|
|
||||||
households.extend(rule.households)
|
|
||||||
|
|
||||||
if not (tags or categories or households):
|
|
||||||
return cross_household_recipes.get_random(limit=limit)
|
|
||||||
|
|
||||||
category_ids = [category.id for category in categories] or None
|
|
||||||
tag_ids = [tag.id for tag in tags] or None
|
|
||||||
household_ids = [household.id for household in households] or None
|
|
||||||
|
|
||||||
recipes_data = cross_household_recipes.page_all(
|
recipes_data = cross_household_recipes.page_all(
|
||||||
pagination=PaginationQuery(
|
pagination=PaginationQuery(
|
||||||
page=1, per_page=limit, order_by="random", pagination_seed=self.repo._random_seed()
|
page=1,
|
||||||
),
|
per_page=limit,
|
||||||
categories=category_ids,
|
query_filter=qf_string,
|
||||||
tags=tag_ids,
|
order_by="random",
|
||||||
households=household_ids,
|
pagination_seed=self.repo._random_seed(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return recipes_data.items
|
return recipes_data.items
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,5 @@
|
||||||
# This file is auto-generated by gen_schema_exports.py
|
# This file is auto-generated by gen_schema_exports.py
|
||||||
from .about import (
|
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig
|
||||||
AdminAboutInfo,
|
|
||||||
AppInfo,
|
|
||||||
AppStartupInfo,
|
|
||||||
AppStatistics,
|
|
||||||
AppTheme,
|
|
||||||
CheckAppConfig,
|
|
||||||
)
|
|
||||||
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
|
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
|
||||||
from .debug import DebugResponse
|
from .debug import DebugResponse
|
||||||
from .email import EmailReady, EmailSuccess, EmailTest
|
from .email import EmailReady, EmailSuccess, EmailTest
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import UUID4, ConfigDict, Field, field_validator
|
import sqlalchemy as sa
|
||||||
|
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
from sqlalchemy.orm.interfaces import LoaderOption
|
|
||||||
|
|
||||||
|
from mealie.core.root_logger import get_logger
|
||||||
|
from mealie.db.models.recipe import RecipeModel
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
|
from mealie.schema.recipe.recipe import RecipeSummary
|
||||||
from mealie.schema.response.pagination import PaginationBase
|
from mealie.schema.response.pagination import PaginationBase
|
||||||
|
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||||
|
|
||||||
from ...db.models.household import CookBook
|
logger = get_logger()
|
||||||
from ..recipe.recipe_category import CategoryBase, TagBase
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCookBook(MealieModel):
|
class CreateCookBook(MealieModel):
|
||||||
|
@ -19,12 +20,7 @@ class CreateCookBook(MealieModel):
|
||||||
slug: Annotated[str | None, Field(validate_default=True)] = None
|
slug: Annotated[str | None, Field(validate_default=True)] = None
|
||||||
position: int = 1
|
position: int = 1
|
||||||
public: Annotated[bool, Field(validate_default=True)] = False
|
public: Annotated[bool, Field(validate_default=True)] = False
|
||||||
categories: list[CategoryBase] = []
|
query_filter_string: str = ""
|
||||||
tags: list[TagBase] = []
|
|
||||||
tools: list[RecipeTool] = []
|
|
||||||
require_all_categories: bool = True
|
|
||||||
require_all_tags: bool = True
|
|
||||||
require_all_tools: bool = True
|
|
||||||
|
|
||||||
@field_validator("public", mode="before")
|
@field_validator("public", mode="before")
|
||||||
def validate_public(public: bool | None) -> bool:
|
def validate_public(public: bool | None) -> bool:
|
||||||
|
@ -42,6 +38,19 @@ class CreateCookBook(MealieModel):
|
||||||
|
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
@field_validator("query_filter_string")
|
||||||
|
def validate_query_filter_string(value: str) -> str:
|
||||||
|
# The query filter builder does additional validations while building the
|
||||||
|
# database query, so we make sure constructing the query is successful
|
||||||
|
builder = QueryFilterBuilder(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError("Invalid query filter string") from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SaveCookBook(CreateCookBook):
|
class SaveCookBook(CreateCookBook):
|
||||||
group_id: UUID4
|
group_id: UUID4
|
||||||
|
@ -53,14 +62,24 @@ class UpdateCookBook(SaveCookBook):
|
||||||
|
|
||||||
|
|
||||||
class ReadCookBook(UpdateCookBook):
|
class ReadCookBook(UpdateCookBook):
|
||||||
group_id: UUID4
|
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||||
household_id: UUID4
|
|
||||||
categories: list[CategoryBase] = []
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@classmethod
|
@field_validator("query_filter_string")
|
||||||
def loader_options(cls) -> list[LoaderOption]:
|
def validate_query_filter_string(value: str) -> str:
|
||||||
return [joinedload(CookBook.categories), joinedload(CookBook.tags), joinedload(CookBook.tools)]
|
# Skip validation since we are not updating the query filter string
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("query_filter", mode="before")
|
||||||
|
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||||
|
try:
|
||||||
|
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||||
|
builder = QueryFilterBuilder(query_filter_string)
|
||||||
|
return builder.as_json_model()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||||
|
return QueryFilterJSON()
|
||||||
|
|
||||||
|
|
||||||
class CookBookPagination(PaginationBase):
|
class CookBookPagination(PaginationBase):
|
||||||
|
|
|
@ -8,18 +8,7 @@ from .new_meal import (
|
||||||
SavePlanEntry,
|
SavePlanEntry,
|
||||||
UpdatePlanEntry,
|
UpdatePlanEntry,
|
||||||
)
|
)
|
||||||
from .plan_rules import (
|
from .plan_rules import PlanRulesCreate, PlanRulesDay, PlanRulesOut, PlanRulesPagination, PlanRulesSave, PlanRulesType
|
||||||
BasePlanRuleFilter,
|
|
||||||
PlanCategory,
|
|
||||||
PlanHousehold,
|
|
||||||
PlanRulesCreate,
|
|
||||||
PlanRulesDay,
|
|
||||||
PlanRulesOut,
|
|
||||||
PlanRulesPagination,
|
|
||||||
PlanRulesSave,
|
|
||||||
PlanRulesType,
|
|
||||||
PlanTag,
|
|
||||||
)
|
|
||||||
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -33,14 +22,10 @@ __all__ = [
|
||||||
"ReadPlanEntry",
|
"ReadPlanEntry",
|
||||||
"SavePlanEntry",
|
"SavePlanEntry",
|
||||||
"UpdatePlanEntry",
|
"UpdatePlanEntry",
|
||||||
"BasePlanRuleFilter",
|
|
||||||
"PlanCategory",
|
|
||||||
"PlanHousehold",
|
|
||||||
"PlanRulesCreate",
|
"PlanRulesCreate",
|
||||||
"PlanRulesDay",
|
"PlanRulesDay",
|
||||||
"PlanRulesOut",
|
"PlanRulesOut",
|
||||||
"PlanRulesPagination",
|
"PlanRulesPagination",
|
||||||
"PlanRulesSave",
|
"PlanRulesSave",
|
||||||
"PlanRulesType",
|
"PlanRulesType",
|
||||||
"PlanTag",
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,32 +1,17 @@
|
||||||
import datetime
|
import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import UUID4, ConfigDict
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import joinedload
|
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||||
from sqlalchemy.orm.interfaces import LoaderOption
|
|
||||||
|
|
||||||
from mealie.db.models.household import GroupMealPlanRules, Household
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.db.models.recipe import Category, Tag
|
from mealie.db.models.recipe import RecipeModel
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
from mealie.schema.response.pagination import PaginationBase
|
from mealie.schema.response.pagination import PaginationBase
|
||||||
|
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
class BasePlanRuleFilter(MealieModel):
|
|
||||||
id: UUID4
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
|
|
||||||
|
|
||||||
class PlanCategory(BasePlanRuleFilter):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanTag(BasePlanRuleFilter):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanHousehold(BasePlanRuleFilter):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanRulesDay(str, Enum):
|
class PlanRulesDay(str, Enum):
|
||||||
|
@ -59,9 +44,20 @@ class PlanRulesType(str, Enum):
|
||||||
class PlanRulesCreate(MealieModel):
|
class PlanRulesCreate(MealieModel):
|
||||||
day: PlanRulesDay = PlanRulesDay.unset
|
day: PlanRulesDay = PlanRulesDay.unset
|
||||||
entry_type: PlanRulesType = PlanRulesType.unset
|
entry_type: PlanRulesType = PlanRulesType.unset
|
||||||
categories: list[PlanCategory] = []
|
query_filter_string: str = ""
|
||||||
tags: list[PlanTag] = []
|
|
||||||
households: list[PlanHousehold] = []
|
@field_validator("query_filter_string")
|
||||||
|
def validate_query_filter_string(cls, value: str) -> str:
|
||||||
|
# The query filter builder does additional validations while building the
|
||||||
|
# database query, so we make sure constructing the query is successful
|
||||||
|
builder = QueryFilterBuilder(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.filter_query(sa.select(RecipeModel), RecipeModel)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError("Invalid query filter string") from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class PlanRulesSave(PlanRulesCreate):
|
class PlanRulesSave(PlanRulesCreate):
|
||||||
|
@ -71,27 +67,24 @@ class PlanRulesSave(PlanRulesCreate):
|
||||||
|
|
||||||
class PlanRulesOut(PlanRulesSave):
|
class PlanRulesOut(PlanRulesSave):
|
||||||
id: UUID4
|
id: UUID4
|
||||||
|
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@classmethod
|
@field_validator("query_filter_string")
|
||||||
def loader_options(cls) -> list[LoaderOption]:
|
def validate_query_filter_string(value: str) -> str:
|
||||||
return [
|
# Skip validation since we are not updating the query filter string
|
||||||
joinedload(GroupMealPlanRules.categories).load_only(
|
return value
|
||||||
Category.id,
|
|
||||||
Category.name,
|
@field_validator("query_filter", mode="before")
|
||||||
Category.slug,
|
def validate_query_filter(cls, _, info: ValidationInfo) -> QueryFilterJSON:
|
||||||
),
|
try:
|
||||||
joinedload(GroupMealPlanRules.tags).load_only(
|
query_filter_string: str = info.data.get("query_filter_string") or ""
|
||||||
Tag.id,
|
builder = QueryFilterBuilder(query_filter_string)
|
||||||
Tag.name,
|
return builder.as_json_model()
|
||||||
Tag.slug,
|
except Exception:
|
||||||
),
|
logger.exception(f"Invalid query filter string: {query_filter_string}")
|
||||||
joinedload(GroupMealPlanRules.households).load_only(
|
return QueryFilterJSON()
|
||||||
Household.id,
|
|
||||||
Household.name,
|
|
||||||
Household.slug,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PlanRulesPagination(PaginationBase):
|
class PlanRulesPagination(PaginationBase):
|
||||||
|
|
|
@ -1,14 +1,24 @@
|
||||||
# This file is auto-generated by gen_schema_exports.py
|
# This file is auto-generated by gen_schema_exports.py
|
||||||
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
|
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
|
||||||
from .query_filter import LogicalOperator, QueryFilter, QueryFilterComponent, RelationalKeyword, RelationalOperator
|
from .query_filter import (
|
||||||
|
LogicalOperator,
|
||||||
|
QueryFilterBuilder,
|
||||||
|
QueryFilterBuilderComponent,
|
||||||
|
QueryFilterJSON,
|
||||||
|
QueryFilterJSONPart,
|
||||||
|
RelationalKeyword,
|
||||||
|
RelationalOperator,
|
||||||
|
)
|
||||||
from .query_search import SearchFilter
|
from .query_search import SearchFilter
|
||||||
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
|
from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
|
||||||
from .validation import ValidationResponse
|
from .validation import ValidationResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LogicalOperator",
|
"LogicalOperator",
|
||||||
"QueryFilter",
|
"QueryFilterBuilder",
|
||||||
"QueryFilterComponent",
|
"QueryFilterBuilderComponent",
|
||||||
|
"QueryFilterJSON",
|
||||||
|
"QueryFilterJSONPart",
|
||||||
"RelationalKeyword",
|
"RelationalKeyword",
|
||||||
"RelationalOperator",
|
"RelationalOperator",
|
||||||
"ValidationResponse",
|
"ValidationResponse",
|
||||||
|
|
|
@ -17,6 +17,7 @@ from sqlalchemy.sql import sqltypes
|
||||||
from mealie.db.models._model_base import SqlAlchemyBase
|
from mealie.db.models._model_base import SqlAlchemyBase
|
||||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||||
from mealie.db.models._model_utils.guid import GUID
|
from mealie.db.models._model_utils.guid import GUID
|
||||||
|
from mealie.schema._mealie.mealie_model import MealieModel
|
||||||
|
|
||||||
Model = TypeVar("Model", bound=SqlAlchemyBase)
|
Model = TypeVar("Model", bound=SqlAlchemyBase)
|
||||||
|
|
||||||
|
@ -104,7 +105,21 @@ class LogicalOperator(Enum):
|
||||||
OR = "OR"
|
OR = "OR"
|
||||||
|
|
||||||
|
|
||||||
class QueryFilterComponent:
|
class QueryFilterJSONPart(MealieModel):
|
||||||
|
left_parenthesis: str | None = None
|
||||||
|
right_parenthesis: str | None = None
|
||||||
|
logical_operator: LogicalOperator | None = None
|
||||||
|
|
||||||
|
attribute_name: str | None = None
|
||||||
|
relational_operator: RelationalKeyword | RelationalOperator | None = None
|
||||||
|
value: str | list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class QueryFilterJSON(MealieModel):
|
||||||
|
parts: list[QueryFilterJSONPart] = []
|
||||||
|
|
||||||
|
|
||||||
|
class QueryFilterBuilderComponent:
|
||||||
"""A single relational statement"""
|
"""A single relational statement"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -135,7 +150,7 @@ class QueryFilterComponent:
|
||||||
] and not isinstance(value, list):
|
] and not isinstance(value, list):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"invalid query string: {relationship.value} must be given a list of values"
|
f"invalid query string: {relationship.value} must be given a list of values"
|
||||||
f"enclosed by {QueryFilter.l_list_sep} and {QueryFilter.r_list_sep}"
|
f"enclosed by {QueryFilterBuilder.l_list_sep} and {QueryFilterBuilder.r_list_sep}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if relationship is RelationalKeyword.IS or relationship is RelationalKeyword.IS_NOT:
|
if relationship is RelationalKeyword.IS or relationship is RelationalKeyword.IS_NOT:
|
||||||
|
@ -193,8 +208,18 @@ class QueryFilterComponent:
|
||||||
|
|
||||||
return sanitized_values if isinstance(self.value, list) else sanitized_values[0]
|
return sanitized_values if isinstance(self.value, list) else sanitized_values[0]
|
||||||
|
|
||||||
|
def as_json_model(self) -> QueryFilterJSONPart:
|
||||||
|
return QueryFilterJSONPart(
|
||||||
|
left_parenthesis=None,
|
||||||
|
right_parenthesis=None,
|
||||||
|
logical_operator=None,
|
||||||
|
attribute_name=self.attribute_name,
|
||||||
|
relational_operator=self.relationship,
|
||||||
|
value=self.value,
|
||||||
|
)
|
||||||
|
|
||||||
class QueryFilter:
|
|
||||||
|
class QueryFilterBuilder:
|
||||||
l_group_sep: str = "("
|
l_group_sep: str = "("
|
||||||
r_group_sep: str = ")"
|
r_group_sep: str = ")"
|
||||||
group_seps: set[str] = {l_group_sep, r_group_sep}
|
group_seps: set[str] = {l_group_sep, r_group_sep}
|
||||||
|
@ -205,13 +230,15 @@ class QueryFilter:
|
||||||
|
|
||||||
def __init__(self, filter_string: str) -> None:
|
def __init__(self, filter_string: str) -> None:
|
||||||
# parse filter string
|
# parse filter string
|
||||||
components = QueryFilter._break_filter_string_into_components(filter_string)
|
components = QueryFilterBuilder._break_filter_string_into_components(filter_string)
|
||||||
base_components = QueryFilter._break_components_into_base_components(components)
|
base_components = QueryFilterBuilder._break_components_into_base_components(components)
|
||||||
if base_components.count(QueryFilter.l_group_sep) != base_components.count(QueryFilter.r_group_sep):
|
if base_components.count(QueryFilterBuilder.l_group_sep) != base_components.count(
|
||||||
|
QueryFilterBuilder.r_group_sep
|
||||||
|
):
|
||||||
raise ValueError("invalid query string: parenthesis are unbalanced")
|
raise ValueError("invalid query string: parenthesis are unbalanced")
|
||||||
|
|
||||||
# parse base components into a filter group
|
# parse base components into a filter group
|
||||||
self.filter_components = QueryFilter._parse_base_components_into_filter_components(base_components)
|
self.filter_components = QueryFilterBuilder._parse_base_components_into_filter_components(base_components)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
joined = " ".join(
|
joined = " ".join(
|
||||||
|
@ -308,7 +335,7 @@ class QueryFilter:
|
||||||
attr_model_map: dict[int, Any] = {}
|
attr_model_map: dict[int, Any] = {}
|
||||||
model_attr: InstrumentedAttribute
|
model_attr: InstrumentedAttribute
|
||||||
for i, component in enumerate(self.filter_components):
|
for i, component in enumerate(self.filter_components):
|
||||||
if not isinstance(component, QueryFilterComponent):
|
if not isinstance(component, QueryFilterBuilderComponent):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
|
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
|
||||||
|
@ -337,7 +364,7 @@ class QueryFilter:
|
||||||
logical_operator_stack.append(component)
|
logical_operator_stack.append(component)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
component = cast(QueryFilterComponent, component)
|
component = cast(QueryFilterBuilderComponent, component)
|
||||||
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
||||||
|
|
||||||
# Keywords
|
# Keywords
|
||||||
|
@ -395,7 +422,7 @@ class QueryFilter:
|
||||||
subcomponents = []
|
subcomponents = []
|
||||||
for component in components:
|
for component in components:
|
||||||
# don't parse components comprised of only a separator
|
# don't parse components comprised of only a separator
|
||||||
if component in QueryFilter.group_seps:
|
if component in QueryFilterBuilder.group_seps:
|
||||||
subcomponents.append(component)
|
subcomponents.append(component)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -406,7 +433,7 @@ class QueryFilter:
|
||||||
if c == '"':
|
if c == '"':
|
||||||
in_quotes = not in_quotes
|
in_quotes = not in_quotes
|
||||||
|
|
||||||
if c in QueryFilter.group_seps and not in_quotes:
|
if c in QueryFilterBuilder.group_seps and not in_quotes:
|
||||||
if new_component:
|
if new_component:
|
||||||
subcomponents.append(new_component)
|
subcomponents.append(new_component)
|
||||||
|
|
||||||
|
@ -437,17 +464,17 @@ class QueryFilter:
|
||||||
list_value_components = []
|
list_value_components = []
|
||||||
for component in components:
|
for component in components:
|
||||||
# parse out lists as their own singular sub component
|
# parse out lists as their own singular sub component
|
||||||
subcomponents = component.split(QueryFilter.l_list_sep)
|
subcomponents = component.split(QueryFilterBuilder.l_list_sep)
|
||||||
for i, subcomponent in enumerate(subcomponents):
|
for i, subcomponent in enumerate(subcomponents):
|
||||||
if not i:
|
if not i:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for j, list_value_string in enumerate(subcomponent.split(QueryFilter.r_list_sep)):
|
for j, list_value_string in enumerate(subcomponent.split(QueryFilterBuilder.r_list_sep)):
|
||||||
if j % 2:
|
if j % 2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
list_value_components.append(
|
list_value_components.append(
|
||||||
[val.strip() for val in list_value_string.split(QueryFilter.list_item_sep)]
|
[val.strip() for val in list_value_string.split(QueryFilterBuilder.list_item_sep)]
|
||||||
)
|
)
|
||||||
|
|
||||||
quote_offset = 0
|
quote_offset = 0
|
||||||
|
@ -455,16 +482,16 @@ class QueryFilter:
|
||||||
for i, subcomponent in enumerate(subcomponents):
|
for i, subcomponent in enumerate(subcomponents):
|
||||||
# we are in a list subcomponent, which is already handled
|
# we are in a list subcomponent, which is already handled
|
||||||
if in_list:
|
if in_list:
|
||||||
if QueryFilter.r_list_sep in subcomponent:
|
if QueryFilterBuilder.r_list_sep in subcomponent:
|
||||||
# filter out the remainder of the list subcomponent and continue parsing
|
# filter out the remainder of the list subcomponent and continue parsing
|
||||||
base_components.append(list_value_components.pop(0))
|
base_components.append(list_value_components.pop(0))
|
||||||
subcomponent = subcomponent.split(QueryFilter.r_list_sep, maxsplit=1)[-1].strip()
|
subcomponent = subcomponent.split(QueryFilterBuilder.r_list_sep, maxsplit=1)[-1].strip()
|
||||||
in_list = False
|
in_list = False
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# don't parse components comprised of only a separator
|
# don't parse components comprised of only a separator
|
||||||
if subcomponent in QueryFilter.group_seps:
|
if subcomponent in QueryFilterBuilder.group_seps:
|
||||||
quote_offset += 1
|
quote_offset += 1
|
||||||
base_components.append(subcomponent)
|
base_components.append(subcomponent)
|
||||||
continue
|
continue
|
||||||
|
@ -479,8 +506,8 @@ class QueryFilter:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# continue parsing this subcomponent up to the list, then skip over subsequent subcomponents
|
# continue parsing this subcomponent up to the list, then skip over subsequent subcomponents
|
||||||
if not in_list and QueryFilter.l_list_sep in subcomponent:
|
if not in_list and QueryFilterBuilder.l_list_sep in subcomponent:
|
||||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilter.l_list_sep, maxsplit=1)
|
subcomponent, _new_sub_component = subcomponent.split(QueryFilterBuilder.l_list_sep, maxsplit=1)
|
||||||
subcomponent = subcomponent.strip()
|
subcomponent = subcomponent.strip()
|
||||||
subcomponents.insert(i + 1, _new_sub_component)
|
subcomponents.insert(i + 1, _new_sub_component)
|
||||||
quote_offset += 1
|
quote_offset += 1
|
||||||
|
@ -516,19 +543,19 @@ class QueryFilter:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_base_components_into_filter_components(
|
def _parse_base_components_into_filter_components(
|
||||||
base_components: list[str | list[str]],
|
base_components: list[str | list[str]],
|
||||||
) -> list[str | QueryFilterComponent | LogicalOperator]:
|
) -> list[str | QueryFilterBuilderComponent | LogicalOperator]:
|
||||||
"""Walk through base components and construct filter collections"""
|
"""Walk through base components and construct filter collections"""
|
||||||
relational_keywords = [kw.value for kw in RelationalKeyword]
|
relational_keywords = [kw.value for kw in RelationalKeyword]
|
||||||
relational_operators = [op.value for op in RelationalOperator]
|
relational_operators = [op.value for op in RelationalOperator]
|
||||||
logical_operators = [op.value for op in LogicalOperator]
|
logical_operators = [op.value for op in LogicalOperator]
|
||||||
|
|
||||||
# parse QueryFilterComponents and logical operators
|
# parse QueryFilterComponents and logical operators
|
||||||
components: list[str | QueryFilterComponent | LogicalOperator] = []
|
components: list[str | QueryFilterBuilderComponent | LogicalOperator] = []
|
||||||
for i, base_component in enumerate(base_components):
|
for i, base_component in enumerate(base_components):
|
||||||
if isinstance(base_component, list):
|
if isinstance(base_component, list):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if base_component in QueryFilter.group_seps:
|
if base_component in QueryFilterBuilder.group_seps:
|
||||||
components.append(base_component)
|
components.append(base_component)
|
||||||
|
|
||||||
elif base_component in relational_keywords or base_component in relational_operators:
|
elif base_component in relational_keywords or base_component in relational_operators:
|
||||||
|
@ -539,7 +566,7 @@ class QueryFilter:
|
||||||
relationship = RelationalOperator(base_components[i])
|
relationship = RelationalOperator(base_components[i])
|
||||||
|
|
||||||
components.append(
|
components.append(
|
||||||
QueryFilterComponent(
|
QueryFilterBuilderComponent(
|
||||||
attribute_name=base_components[i - 1], # type: ignore
|
attribute_name=base_components[i - 1], # type: ignore
|
||||||
relationship=relationship,
|
relationship=relationship,
|
||||||
value=base_components[i + 1],
|
value=base_components[i + 1],
|
||||||
|
@ -550,3 +577,47 @@ class QueryFilter:
|
||||||
components.append(LogicalOperator(base_component.upper()))
|
components.append(LogicalOperator(base_component.upper()))
|
||||||
|
|
||||||
return components
|
return components
|
||||||
|
|
||||||
|
def as_json_model(self) -> QueryFilterJSON:
|
||||||
|
parts: list[QueryFilterJSONPart] = []
|
||||||
|
|
||||||
|
current_part: QueryFilterJSONPart | None = None
|
||||||
|
left_parens: list[str] = []
|
||||||
|
right_parens: list[str] = []
|
||||||
|
last_logical_operator: LogicalOperator | None = None
|
||||||
|
|
||||||
|
def add_part():
|
||||||
|
nonlocal current_part, left_parens, right_parens, last_logical_operator
|
||||||
|
if not current_part:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_part.left_parenthesis = "".join(left_parens) or None
|
||||||
|
current_part.right_parenthesis = "".join(right_parens) or None
|
||||||
|
current_part.logical_operator = last_logical_operator
|
||||||
|
|
||||||
|
parts.append(current_part)
|
||||||
|
current_part = None
|
||||||
|
left_parens.clear()
|
||||||
|
right_parens.clear()
|
||||||
|
last_logical_operator = None
|
||||||
|
|
||||||
|
for component in self.filter_components:
|
||||||
|
if isinstance(component, QueryFilterBuilderComponent):
|
||||||
|
if current_part:
|
||||||
|
add_part()
|
||||||
|
current_part = component.as_json_model()
|
||||||
|
|
||||||
|
elif isinstance(component, LogicalOperator):
|
||||||
|
if current_part:
|
||||||
|
add_part()
|
||||||
|
last_logical_operator = component
|
||||||
|
|
||||||
|
elif isinstance(component, str):
|
||||||
|
if component == QueryFilterBuilder.l_group_sep:
|
||||||
|
left_parens.append(component)
|
||||||
|
elif component == QueryFilterBuilder.r_group_sep:
|
||||||
|
right_parens.append(component)
|
||||||
|
|
||||||
|
# add last part, if any
|
||||||
|
add_part()
|
||||||
|
return QueryFilterJSON(parts=parts)
|
||||||
|
|
|
@ -37,18 +37,18 @@ from .user_passwords import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CreateUserRegistration",
|
|
||||||
"CredentialsRequest",
|
|
||||||
"CredentialsRequestForm",
|
|
||||||
"Token",
|
|
||||||
"TokenData",
|
|
||||||
"UnlockResults",
|
|
||||||
"ForgotPassword",
|
"ForgotPassword",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
"PrivatePasswordResetToken",
|
"PrivatePasswordResetToken",
|
||||||
"ResetPassword",
|
"ResetPassword",
|
||||||
"SavePasswordResetToken",
|
"SavePasswordResetToken",
|
||||||
"ValidateResetToken",
|
"ValidateResetToken",
|
||||||
|
"CredentialsRequest",
|
||||||
|
"CredentialsRequestForm",
|
||||||
|
"Token",
|
||||||
|
"TokenData",
|
||||||
|
"UnlockResults",
|
||||||
|
"CreateUserRegistration",
|
||||||
"ChangePassword",
|
"ChangePassword",
|
||||||
"CreateToken",
|
"CreateToken",
|
||||||
"DeleteTokenResponse",
|
"DeleteTokenResponse",
|
||||||
|
|
|
@ -87,6 +87,7 @@ sort_by_size = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-ra -q"
|
addopts = "-ra -q"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
minversion = "6.0"
|
minversion = "6.0"
|
||||||
python_classes = '*Tests'
|
python_classes = '*Tests'
|
||||||
python_files = 'test_*'
|
python_files = 'test_*'
|
||||||
|
|
|
@ -3,6 +3,19 @@ from collections.abc import Generator
|
||||||
|
|
||||||
from pytest import MonkeyPatch, fixture
|
from pytest import MonkeyPatch, fixture
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_temp_dir():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
temp_dir = Path(__file__).parent / ".temp"
|
||||||
|
|
||||||
|
if temp_dir.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
_clean_temp_dir()
|
||||||
|
|
||||||
mp = MonkeyPatch()
|
mp = MonkeyPatch()
|
||||||
mp.setenv("PRODUCTION", "True")
|
mp.setenv("PRODUCTION", "True")
|
||||||
mp.setenv("TESTING", "True")
|
mp.setenv("TESTING", "True")
|
||||||
|
@ -54,11 +67,6 @@ def test_image_png():
|
||||||
@fixture(scope="session", autouse=True)
|
@fixture(scope="session", autouse=True)
|
||||||
def global_cleanup() -> Generator[None, None, None]:
|
def global_cleanup() -> Generator[None, None, None]:
|
||||||
"""Purges the .temp directory used for testing"""
|
"""Purges the .temp directory used for testing"""
|
||||||
|
|
||||||
yield None
|
yield None
|
||||||
with contextlib.suppress(Exception):
|
_clean_temp_dir()
|
||||||
temp_dir = Path(__file__).parent / ".temp"
|
|
||||||
|
|
||||||
if temp_dir.exists():
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
|
@ -178,7 +178,7 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
|
||||||
|
|
||||||
database.recipes.update_many([public_recipe, private_recipe])
|
database.recipes.update_many([public_recipe, private_recipe])
|
||||||
|
|
||||||
# Create a recipe in another household that's public with the same known tag
|
# Create a public and private recipe with a known tag in another household
|
||||||
other_database = h2_user.repos
|
other_database = h2_user.repos
|
||||||
other_household = other_database.households.get_one(h2_user.household_id)
|
other_household = other_database.households.get_one(h2_user.household_id)
|
||||||
assert other_household and other_household.preferences
|
assert other_household and other_household.preferences
|
||||||
|
@ -187,17 +187,24 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
|
||||||
other_household.preferences.recipe_public = True
|
other_household.preferences.recipe_public = True
|
||||||
other_database.household_preferences.update(household.id, household.preferences)
|
other_database.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
other_household_recipe = other_database.recipes.create(
|
other_household_public_recipe, other_household_private_recipe = database.recipes.create_many(
|
||||||
Recipe(
|
Recipe(
|
||||||
user_id=h2_user.user_id,
|
user_id=h2_user.user_id,
|
||||||
group_id=h2_user.group_id,
|
group_id=h2_user.group_id,
|
||||||
name=random_string(),
|
name=random_string(),
|
||||||
)
|
)
|
||||||
|
for _ in range(2)
|
||||||
)
|
)
|
||||||
assert other_household_recipe.settings
|
|
||||||
other_household_recipe.settings.public = True
|
assert other_household_public_recipe.settings
|
||||||
other_household_recipe.tags = [tag]
|
other_household_public_recipe.settings.public = True
|
||||||
other_database.recipes.update(other_household_recipe.slug, other_household_recipe)
|
other_household_public_recipe.tags = [tag]
|
||||||
|
|
||||||
|
assert other_household_private_recipe.settings
|
||||||
|
other_household_private_recipe.settings.public = False
|
||||||
|
other_household_private_recipe.tags = [tag]
|
||||||
|
|
||||||
|
other_database.recipes.update_many([other_household_public_recipe, other_household_private_recipe])
|
||||||
|
|
||||||
# Create a public cookbook with tag
|
# Create a public cookbook with tag
|
||||||
cookbook = database.cookbooks.create(
|
cookbook = database.cookbooks.create(
|
||||||
|
@ -206,11 +213,91 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
|
||||||
group_id=unique_user.group_id,
|
group_id=unique_user.group_id,
|
||||||
household_id=unique_user.household_id,
|
household_id=unique_user.household_id,
|
||||||
public=True,
|
public=True,
|
||||||
tags=[tag],
|
query_filter_string=f'tags.id IN ["{tag.id}"]',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the cookbook and make sure we only get the public recipe from the correct household
|
# Get the cookbook and make sure we only get the public recipes from each household
|
||||||
|
response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id))
|
||||||
|
assert response.status_code == 200
|
||||||
|
cookbook_data = response.json()
|
||||||
|
assert cookbook_data["id"] == str(cookbook.id)
|
||||||
|
|
||||||
|
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
|
||||||
|
assert len(cookbook_recipe_ids) == 2
|
||||||
|
assert str(public_recipe.id) in cookbook_recipe_ids
|
||||||
|
assert str(private_recipe.id) not in cookbook_recipe_ids
|
||||||
|
assert str(other_household_public_recipe.id) in cookbook_recipe_ids
|
||||||
|
assert str(other_household_private_recipe.id) not in cookbook_recipe_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cookbooks_private_household(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
||||||
|
database = unique_user.repos
|
||||||
|
|
||||||
|
# Create a public recipe with a known tag
|
||||||
|
group = database.groups.get_one(unique_user.group_id)
|
||||||
|
assert group and group.preferences
|
||||||
|
|
||||||
|
group.preferences.private_group = False
|
||||||
|
database.group_preferences.update(group.id, group.preferences)
|
||||||
|
|
||||||
|
household = database.households.get_one(unique_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
|
||||||
|
household.preferences.private_household = False
|
||||||
|
household.preferences.recipe_public = True
|
||||||
|
database.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
tag = database.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
|
||||||
|
public_recipe = database.recipes.create(
|
||||||
|
Recipe(
|
||||||
|
user_id=unique_user.user_id,
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
name=random_string(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert public_recipe.settings
|
||||||
|
public_recipe.settings.public = True
|
||||||
|
public_recipe.tags = [tag]
|
||||||
|
|
||||||
|
database.recipes.update(public_recipe.slug, public_recipe)
|
||||||
|
|
||||||
|
# Create a public recipe with a known tag on a private household
|
||||||
|
other_database = h2_user.repos
|
||||||
|
other_household = other_database.households.get_one(h2_user.household_id)
|
||||||
|
assert other_household and other_household.preferences
|
||||||
|
|
||||||
|
other_household.preferences.private_household = True
|
||||||
|
other_household.preferences.recipe_public = True
|
||||||
|
other_database.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
other_household_private_recipe = database.recipes.create(
|
||||||
|
Recipe(
|
||||||
|
user_id=h2_user.user_id,
|
||||||
|
group_id=h2_user.group_id,
|
||||||
|
name=random_string(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert other_household_private_recipe.settings
|
||||||
|
other_household_private_recipe.settings.public = False
|
||||||
|
other_household_private_recipe.tags = [tag]
|
||||||
|
|
||||||
|
other_database.recipes.update(other_household_private_recipe.slug, other_household_private_recipe)
|
||||||
|
|
||||||
|
# Create a public cookbook with tag
|
||||||
|
cookbook = database.cookbooks.create(
|
||||||
|
SaveCookBook(
|
||||||
|
name=random_string(),
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
household_id=unique_user.household_id,
|
||||||
|
public=True,
|
||||||
|
query_filter_string=f'tags.id IN ["{tag.id}"]',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the cookbook and make sure we only get the public recipes from each household
|
||||||
response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id))
|
response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
cookbook_data = response.json()
|
cookbook_data = response.json()
|
||||||
|
@ -219,5 +306,4 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
|
||||||
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
|
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
|
||||||
assert len(cookbook_recipe_ids) == 1
|
assert len(cookbook_recipe_ids) == 1
|
||||||
assert str(public_recipe.id) in cookbook_recipe_ids
|
assert str(public_recipe.id) in cookbook_recipe_ids
|
||||||
assert str(private_recipe.id) not in cookbook_recipe_ids
|
assert str(other_household_private_recipe.id) not in cookbook_recipe_ids
|
||||||
assert str(other_household_recipe.id) not in cookbook_recipe_ids
|
|
||||||
|
|
|
@ -244,8 +244,12 @@ def test_public_recipe_cookbook_filter(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
@pytest.mark.parametrize("other_household_private", [True, False])
|
||||||
|
def test_public_recipe_cookbook_filter_with_recipes(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, other_household_private: bool
|
||||||
|
):
|
||||||
database = unique_user.repos
|
database = unique_user.repos
|
||||||
|
database.session.rollback()
|
||||||
|
|
||||||
# Create a public and private recipe with a known tag
|
# Create a public and private recipe with a known tag
|
||||||
group = database.groups.get_one(unique_user.group_id)
|
group = database.groups.get_one(unique_user.group_id)
|
||||||
|
@ -281,14 +285,14 @@ def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, uniq
|
||||||
|
|
||||||
database.recipes.update_many([public_recipe, private_recipe])
|
database.recipes.update_many([public_recipe, private_recipe])
|
||||||
|
|
||||||
# Create a recipe in another household that's public with the same known tag
|
# Create a recipe in another household with the same known tag
|
||||||
other_database = h2_user.repos
|
other_database = h2_user.repos
|
||||||
other_household = other_database.households.get_one(h2_user.household_id)
|
other_household = other_database.households.get_one(h2_user.household_id)
|
||||||
assert other_household and other_household.preferences
|
assert other_household and other_household.preferences
|
||||||
|
|
||||||
other_household.preferences.private_household = False
|
other_household.preferences.private_household = other_household_private
|
||||||
other_household.preferences.recipe_public = True
|
other_household.preferences.recipe_public = True
|
||||||
other_database.household_preferences.update(household.id, household.preferences)
|
other_database.household_preferences.update(other_household.id, other_household.preferences)
|
||||||
|
|
||||||
other_household_recipe = other_database.recipes.create(
|
other_household_recipe = other_database.recipes.create(
|
||||||
Recipe(
|
Recipe(
|
||||||
|
@ -309,17 +313,25 @@ def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, uniq
|
||||||
group_id=unique_user.group_id,
|
group_id=unique_user.group_id,
|
||||||
household_id=unique_user.household_id,
|
household_id=unique_user.household_id,
|
||||||
public=True,
|
public=True,
|
||||||
tags=[tag],
|
query_filter_string=f'tags.id IN ["{tag.id}"]',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the cookbook's recipes and make sure we only get the public recipe from the correct household
|
# Get the cookbook's recipes and make sure we get both public recipes
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.id}
|
api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.id}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
recipe_ids: set[str] = {recipe["id"] for recipe in response.json()["items"]}
|
recipe_ids: set[str] = {recipe["id"] for recipe in response.json()["items"]}
|
||||||
assert len(recipe_ids) == 1
|
if other_household_private:
|
||||||
|
assert len(recipe_ids) == 1
|
||||||
|
else:
|
||||||
|
assert len(recipe_ids) == 2
|
||||||
|
|
||||||
assert str(public_recipe.id) in recipe_ids
|
assert str(public_recipe.id) in recipe_ids
|
||||||
assert str(private_recipe.id) not in recipe_ids
|
assert str(private_recipe.id) not in recipe_ids
|
||||||
assert str(other_household_recipe.id) not in recipe_ids
|
|
||||||
|
if other_household_private:
|
||||||
|
assert str(other_household_recipe.id) not in recipe_ids
|
||||||
|
else:
|
||||||
|
assert str(other_household_recipe.id) in recipe_ids
|
||||||
|
|
|
@ -21,7 +21,7 @@ def get_page_data(group_id: UUID | str, household_id: UUID4 | str):
|
||||||
"slug": name_and_slug,
|
"slug": name_and_slug,
|
||||||
"description": "",
|
"description": "",
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"categories": [],
|
"query_filter_string": "",
|
||||||
"group_id": str(group_id),
|
"group_id": str(group_id),
|
||||||
"household_id": str(household_id),
|
"household_id": str(household_id),
|
||||||
}
|
}
|
||||||
|
@ -143,3 +143,42 @@ def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
|
||||||
|
|
||||||
response = api_client.get(api_routes.households_cookbooks_item_id(sample.slug), headers=unique_user.token)
|
response = api_client.get(api_routes.households_cookbooks_item_id(sample.slug), headers=unique_user.token)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"qf_string, expected_code",
|
||||||
|
[
|
||||||
|
('tags.name CONTAINS ALL ["tag1","tag2"]', 200),
|
||||||
|
('badfield = "badvalue"', 422),
|
||||||
|
('recipe_category.id IN ["1"]', 422),
|
||||||
|
('created_at >= "not-a-date"', 422),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"valid qf",
|
||||||
|
"invalid field",
|
||||||
|
"invalid UUID",
|
||||||
|
"invalid date",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cookbook_validate_query_filter_string(
|
||||||
|
api_client: TestClient, unique_user: TestUser, qf_string: str, expected_code: int
|
||||||
|
):
|
||||||
|
# Create
|
||||||
|
cb_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": qf_string}
|
||||||
|
response = api_client.post(api_routes.households_cookbooks, json=cb_data, headers=unique_user.token)
|
||||||
|
assert response.status_code == expected_code if expected_code != 200 else 201
|
||||||
|
|
||||||
|
# Update
|
||||||
|
cb_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": ""}
|
||||||
|
response = api_client.post(api_routes.households_cookbooks, json=cb_data, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
cb_data = response.json()
|
||||||
|
|
||||||
|
cb_data["queryFilterString"] = qf_string
|
||||||
|
response = api_client.put(
|
||||||
|
api_routes.households_cookbooks_item_id(cb_data["id"]), json=cb_data, headers=unique_user.token
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_code if expected_code != 201 else 200
|
||||||
|
|
||||||
|
# Out; should skip validation, so this should never error out
|
||||||
|
ReadCookBook(**cb_data)
|
||||||
|
|
|
@ -40,15 +40,22 @@ def create_rule(
|
||||||
categories: list[CategoryOut] | None = None,
|
categories: list[CategoryOut] | None = None,
|
||||||
households: list[HouseholdSummary] | None = None,
|
households: list[HouseholdSummary] | None = None,
|
||||||
):
|
):
|
||||||
|
qf_parts: list[str] = []
|
||||||
|
if tags:
|
||||||
|
qf_parts.append(f'tags.id CONTAINS ALL [{",".join([str(tag.id) for tag in tags])}]')
|
||||||
|
if categories:
|
||||||
|
qf_parts.append(f'recipe_category.id CONTAINS ALL [{",".join([str(cat.id) for cat in categories])}]')
|
||||||
|
if households:
|
||||||
|
qf_parts.append(f'household_id IN [{",".join([str(household.id) for household in households])}]')
|
||||||
|
|
||||||
|
query_filter_string = " AND ".join(qf_parts)
|
||||||
return unique_user.repos.group_meal_plan_rules.create(
|
return unique_user.repos.group_meal_plan_rules.create(
|
||||||
PlanRulesSave(
|
PlanRulesSave(
|
||||||
group_id=UUID(unique_user.group_id),
|
group_id=UUID(unique_user.group_id),
|
||||||
household_id=UUID(unique_user.household_id),
|
household_id=UUID(unique_user.household_id),
|
||||||
day=day,
|
day=day,
|
||||||
entry_type=entry_type,
|
entry_type=entry_type,
|
||||||
tags=tags or [],
|
query_filter_string=query_filter_string,
|
||||||
categories=categories or [],
|
|
||||||
households=households or [],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ from mealie.schema.recipe.recipe import RecipeCategory
|
||||||
from mealie.schema.recipe.recipe_category import CategorySave
|
from mealie.schema.recipe.recipe_category import CategorySave
|
||||||
from tests import utils
|
from tests import utils
|
||||||
from tests.utils import api_routes
|
from tests.utils import api_routes
|
||||||
|
from tests.utils.factories import random_string
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,7 +33,7 @@ def plan_rule(api_client: TestClient, unique_user: TestUser):
|
||||||
"householdId": unique_user.household_id,
|
"householdId": unique_user.household_id,
|
||||||
"day": "monday",
|
"day": "monday",
|
||||||
"entryType": "breakfast",
|
"entryType": "breakfast",
|
||||||
"categories": [],
|
"queryFilterString": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
|
@ -48,12 +49,13 @@ def plan_rule(api_client: TestClient, unique_user: TestUser):
|
||||||
|
|
||||||
def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUser, category: RecipeCategory):
|
def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUser, category: RecipeCategory):
|
||||||
database = unique_user.repos
|
database = unique_user.repos
|
||||||
|
query_filter_string = f'recipe_category.id IN ["{category.id}"]'
|
||||||
payload = {
|
payload = {
|
||||||
"groupId": unique_user.group_id,
|
"groupId": unique_user.group_id,
|
||||||
"householdId": unique_user.household_id,
|
"householdId": unique_user.household_id,
|
||||||
"day": "monday",
|
"day": "monday",
|
||||||
"entryType": "breakfast",
|
"entryType": "breakfast",
|
||||||
"categories": [category.model_dump()],
|
"queryFilterString": query_filter_string,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
|
@ -67,8 +69,8 @@ def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUs
|
||||||
assert response_data["householdId"] == str(unique_user.household_id)
|
assert response_data["householdId"] == str(unique_user.household_id)
|
||||||
assert response_data["day"] == "monday"
|
assert response_data["day"] == "monday"
|
||||||
assert response_data["entryType"] == "breakfast"
|
assert response_data["entryType"] == "breakfast"
|
||||||
assert len(response_data["categories"]) == 1
|
assert len(response_data["queryFilter"]["parts"]) == 1
|
||||||
assert response_data["categories"][0]["slug"] == category.slug
|
assert response_data["queryFilter"]["parts"][0]["value"] == [str(category.id)]
|
||||||
|
|
||||||
# Validate database entry
|
# Validate database entry
|
||||||
rule = database.group_meal_plan_rules.get_one(UUID(response_data["id"]))
|
rule = database.group_meal_plan_rules.get_one(UUID(response_data["id"]))
|
||||||
|
@ -78,8 +80,7 @@ def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUs
|
||||||
assert str(rule.household_id) == unique_user.household_id
|
assert str(rule.household_id) == unique_user.household_id
|
||||||
assert rule.day == "monday"
|
assert rule.day == "monday"
|
||||||
assert rule.entry_type == "breakfast"
|
assert rule.entry_type == "breakfast"
|
||||||
assert len(rule.categories) == 1
|
assert rule.query_filter_string == query_filter_string
|
||||||
assert rule.categories[0].slug == category.slug
|
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
database.group_meal_plan_rules.delete(rule.id)
|
database.group_meal_plan_rules.delete(rule.id)
|
||||||
|
@ -96,7 +97,8 @@ def test_group_mealplan_rules_read(api_client: TestClient, unique_user: TestUser
|
||||||
assert response_data["householdId"] == str(unique_user.household_id)
|
assert response_data["householdId"] == str(unique_user.household_id)
|
||||||
assert response_data["day"] == "monday"
|
assert response_data["day"] == "monday"
|
||||||
assert response_data["entryType"] == "breakfast"
|
assert response_data["entryType"] == "breakfast"
|
||||||
assert len(response_data["categories"]) == 0
|
assert response_data["queryFilterString"] == ""
|
||||||
|
assert len(response_data["queryFilter"]["parts"]) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
|
def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
|
||||||
|
@ -119,7 +121,8 @@ def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUs
|
||||||
assert response_data["householdId"] == str(unique_user.household_id)
|
assert response_data["householdId"] == str(unique_user.household_id)
|
||||||
assert response_data["day"] == "tuesday"
|
assert response_data["day"] == "tuesday"
|
||||||
assert response_data["entryType"] == "lunch"
|
assert response_data["entryType"] == "lunch"
|
||||||
assert len(response_data["categories"]) == 0
|
assert response_data["queryFilterString"] == ""
|
||||||
|
assert len(response_data["queryFilter"]["parts"]) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_group_mealplan_rules_delete(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
|
def test_group_mealplan_rules_delete(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
|
||||||
|
@ -131,3 +134,42 @@ def test_group_mealplan_rules_delete(api_client: TestClient, unique_user: TestUs
|
||||||
|
|
||||||
response = api_client.get(api_routes.households_mealplans_rules_item_id(plan_rule.id), headers=unique_user.token)
|
response = api_client.get(api_routes.households_mealplans_rules_item_id(plan_rule.id), headers=unique_user.token)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"qf_string, expected_code",
|
||||||
|
[
|
||||||
|
('tags.name CONTAINS ALL ["tag1","tag2"]', 200),
|
||||||
|
('badfield = "badvalue"', 422),
|
||||||
|
('recipe_category.id IN ["1"]', 422),
|
||||||
|
('created_at >= "not-a-date"', 422),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"valid qf",
|
||||||
|
"invalid field",
|
||||||
|
"invalid UUID",
|
||||||
|
"invalid date",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_group_mealplan_rules_validate_query_filter_string(
|
||||||
|
api_client: TestClient, unique_user: TestUser, qf_string: str, expected_code: int
|
||||||
|
):
|
||||||
|
# Create
|
||||||
|
rule_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": qf_string}
|
||||||
|
response = api_client.post(api_routes.households_mealplans_rules, json=rule_data, headers=unique_user.token)
|
||||||
|
assert response.status_code == expected_code if expected_code != 200 else 201
|
||||||
|
|
||||||
|
# Update
|
||||||
|
rule_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": ""}
|
||||||
|
response = api_client.post(api_routes.households_mealplans_rules, json=rule_data, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
rule_data = response.json()
|
||||||
|
|
||||||
|
rule_data["queryFilterString"] = qf_string
|
||||||
|
response = api_client.put(
|
||||||
|
api_routes.households_mealplans_rules_item_id(rule_data["id"]), json=rule_data, headers=unique_user.token
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_code if expected_code != 201 else 200
|
||||||
|
|
||||||
|
# Out; should skip validation, so this should never error out
|
||||||
|
PlanRulesOut(**rule_data)
|
||||||
|
|
|
@ -257,9 +257,7 @@ def test_user_can_update_last_made_on_other_household(
|
||||||
assert new_last_made == now != old_last_made
|
assert new_last_made == now != old_last_made
|
||||||
|
|
||||||
|
|
||||||
def test_cookbook_recipes_only_includes_current_households(
|
def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
||||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
|
|
||||||
):
|
|
||||||
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
|
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
|
||||||
recipes = unique_user.repos.recipes.create_many(
|
recipes = unique_user.repos.recipes.create_many(
|
||||||
[
|
[
|
||||||
|
@ -300,4 +298,4 @@ def test_cookbook_recipes_only_includes_current_households(
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
assert recipe.id in fetched_recipe_ids
|
assert recipe.id in fetched_recipe_ids
|
||||||
for recipe in other_recipes:
|
for recipe in other_recipes:
|
||||||
assert recipe.id not in fetched_recipe_ids
|
assert recipe.id in fetched_recipe_ids
|
||||||
|
|
|
@ -858,7 +858,7 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse
|
||||||
name=random_string(),
|
name=random_string(),
|
||||||
group_id=unique_user.group_id,
|
group_id=unique_user.group_id,
|
||||||
household_id=unique_user.household_id,
|
household_id=unique_user.household_id,
|
||||||
tags=[tag],
|
query_filter_string=f'tags.id IN ["{tag.id}"]',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
from mealie.schema.response.query_filter import (
|
||||||
|
LogicalOperator,
|
||||||
|
QueryFilterBuilder,
|
||||||
|
QueryFilterJSON,
|
||||||
|
QueryFilterJSONPart,
|
||||||
|
RelationalKeyword,
|
||||||
|
RelationalOperator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_filter_builder_json():
|
||||||
|
qf = (
|
||||||
|
'(( (name = "my-recipe") AND is_active = TRUE) AND tags.name CONTAINS ALL ["tag1","tag2"]) '
|
||||||
|
'OR (name="my-other-recipe" AND (count=1 OR count=2) )'
|
||||||
|
)
|
||||||
|
builder = QueryFilterBuilder(qf)
|
||||||
|
assert builder.as_json_model() == QueryFilterJSON(
|
||||||
|
parts=[
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
left_parenthesis="(((",
|
||||||
|
attribute_name="name",
|
||||||
|
relational_operator=RelationalOperator.EQ,
|
||||||
|
value="my-recipe",
|
||||||
|
right_parenthesis=")",
|
||||||
|
),
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
logical_operator=LogicalOperator.AND,
|
||||||
|
attribute_name="is_active",
|
||||||
|
relational_operator=RelationalOperator.EQ,
|
||||||
|
value="TRUE",
|
||||||
|
right_parenthesis=")",
|
||||||
|
),
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
logical_operator=LogicalOperator.AND,
|
||||||
|
attribute_name="tags.name",
|
||||||
|
relational_operator=RelationalKeyword.CONTAINS_ALL,
|
||||||
|
value=["tag1", "tag2"],
|
||||||
|
right_parenthesis=")",
|
||||||
|
),
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
logical_operator=LogicalOperator.OR,
|
||||||
|
left_parenthesis="(",
|
||||||
|
attribute_name="name",
|
||||||
|
relational_operator=RelationalOperator.EQ,
|
||||||
|
value="my-other-recipe",
|
||||||
|
),
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
logical_operator=LogicalOperator.AND,
|
||||||
|
left_parenthesis="(",
|
||||||
|
attribute_name="count",
|
||||||
|
relational_operator=RelationalOperator.EQ,
|
||||||
|
value="1",
|
||||||
|
),
|
||||||
|
QueryFilterJSONPart(
|
||||||
|
logical_operator=LogicalOperator.OR,
|
||||||
|
attribute_name="count",
|
||||||
|
relational_operator=RelationalOperator.EQ,
|
||||||
|
value="2",
|
||||||
|
right_parenthesis="))",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
|
@ -11,6 +11,8 @@ from mealie.core.config import get_app_settings
|
||||||
from mealie.db.db_setup import session_context
|
from mealie.db.db_setup import session_context
|
||||||
from mealie.db.models._model_utils.guid import GUID
|
from mealie.db.models._model_utils.guid import GUID
|
||||||
from mealie.db.models.group import Group
|
from mealie.db.models.group import Group
|
||||||
|
from mealie.db.models.household.cookbook import CookBook
|
||||||
|
from mealie.db.models.household.mealplan import GroupMealPlanRules
|
||||||
from mealie.db.models.household.shopping_list import ShoppingList
|
from mealie.db.models.household.shopping_list import ShoppingList
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
from mealie.db.models.labels import MultiPurposeLabel
|
||||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||||
|
@ -124,6 +126,9 @@ def test_database_restore_data(backup_path: Path):
|
||||||
foods = session.query(IngredientFoodModel).all()
|
foods = session.query(IngredientFoodModel).all()
|
||||||
units = session.query(IngredientUnitModel).all()
|
units = session.query(IngredientUnitModel).all()
|
||||||
|
|
||||||
|
cookbooks = session.query(CookBook).all()
|
||||||
|
mealplan_rules = session.query(GroupMealPlanRules).all()
|
||||||
|
|
||||||
# 2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties
|
# 2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
if recipe.name:
|
if recipe.name:
|
||||||
|
@ -174,5 +179,39 @@ def test_database_restore_data(backup_path: Path):
|
||||||
user_ratings = [x.rating for x in user_to_recipes if x.rating]
|
user_ratings = [x.rating for x in user_to_recipes if x.rating]
|
||||||
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
|
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
|
||||||
|
|
||||||
|
# 2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan
|
||||||
|
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}]")
|
||||||
|
|
||||||
|
expected_query_filter_string = " AND ".join(parts)
|
||||||
|
assert cookbook.query_filter_string == expected_query_filter_string
|
||||||
|
|
||||||
|
for rule in mealplan_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}]")
|
||||||
|
|
||||||
|
expected_query_filter_string = " AND ".join(parts)
|
||||||
|
assert rule.query_filter_string == expected_query_filter_string
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
backup_v2.restore(original_data_backup)
|
backup_v2.restore(original_data_backup)
|
||||||
|
|
|
@ -37,8 +37,6 @@ admin_users_unlock = "/api/admin/users/unlock"
|
||||||
"""`/api/admin/users/unlock`"""
|
"""`/api/admin/users/unlock`"""
|
||||||
app_about = "/api/app/about"
|
app_about = "/api/app/about"
|
||||||
"""`/api/app/about`"""
|
"""`/api/app/about`"""
|
||||||
app_about_oidc = "/api/app/about/oidc"
|
|
||||||
"""`/api/app/about/oidc`"""
|
|
||||||
app_about_startup_info = "/api/app/about/startup-info"
|
app_about_startup_info = "/api/app/about/startup-info"
|
||||||
"""`/api/app/about/startup-info`"""
|
"""`/api/app/about/startup-info`"""
|
||||||
app_about_theme = "/api/app/about/theme"
|
app_about_theme = "/api/app/about/theme"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue