mirror of
https://github.com/hay-kot/mealie.git
synced 2025-07-05 20:42:23 -07: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>
|
||||
<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-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||
<RecipeOrganizerSelector v-model="cookbook.categories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tags" selector-type="tags" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tools" selector-type="tools" />
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="cookbook.queryFilter"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<v-switch v-model="cookbook.public" hide-details single-line>
|
||||
<template #label>
|
||||
{{ $t('cookbook.public-cookbook') }}
|
||||
|
@ -14,33 +16,19 @@
|
|||
</HelpIcon>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
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({
|
||||
components: { RecipeOrganizerSelector },
|
||||
components: { QueryFilterBuilder },
|
||||
props: {
|
||||
cookbook: {
|
||||
type: Object as () => ReadCookBook,
|
||||
|
@ -51,5 +39,50 @@ export default defineComponent({
|
|||
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>
|
||||
|
|
|
@ -4,11 +4,13 @@
|
|||
<BaseDialog
|
||||
v-if="editTarget"
|
||||
v-model="dialogStates.edit"
|
||||
:width="650"
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$t('general.edit')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-disabled="!editTarget.queryFilterString"
|
||||
@submit="editCookbook"
|
||||
>
|
||||
<v-card-text>
|
||||
|
|
|
@ -6,12 +6,10 @@
|
|||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
<GroupHouseholdSelector
|
||||
v-model="inputHouseholds"
|
||||
multiselect
|
||||
:description="$tc('meal-plan.mealplan-households-description')"
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="queryFilter"
|
||||
@input="handleQueryFilterInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -25,14 +23,14 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import GroupHouseholdSelector from "~/components/Domain/Household/GroupHouseholdSelector.vue";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { PlanCategory, PlanHousehold, PlanTag } from "~/lib/api/types/meal-plan";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
GroupHouseholdSelector,
|
||||
RecipeOrganizerSelector,
|
||||
QueryFilterBuilder,
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
|
@ -43,17 +41,13 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "unset",
|
||||
},
|
||||
categories: {
|
||||
type: Array as () => PlanCategory[],
|
||||
default: () => [],
|
||||
queryFilterString: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
tags: {
|
||||
type: Array as () => PlanTag[],
|
||||
default: () => [],
|
||||
},
|
||||
households: {
|
||||
type: Array as () => PlanHousehold[],
|
||||
default: () => [],
|
||||
queryFilter: {
|
||||
type: Object as () => QueryFilterJSON,
|
||||
default: null,
|
||||
},
|
||||
showHelp: {
|
||||
type: Boolean,
|
||||
|
@ -100,41 +94,65 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const inputCategories = computed({
|
||||
const inputQueryFilterString = computed({
|
||||
get: () => {
|
||||
return props.categories;
|
||||
return props.queryFilterString;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:categories", val);
|
||||
context.emit("update:query-filter-string", val);
|
||||
},
|
||||
});
|
||||
|
||||
const inputTags = computed({
|
||||
get: () => {
|
||||
return props.tags;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:tags", val);
|
||||
},
|
||||
});
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
inputQueryFilterString.value = value || "";
|
||||
};
|
||||
|
||||
const inputHouseholds = computed({
|
||||
get: () => {
|
||||
return props.households;
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
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 {
|
||||
MEAL_TYPE_OPTIONS,
|
||||
MEAL_DAY_OPTIONS,
|
||||
inputDay,
|
||||
inputEntryType,
|
||||
inputCategories,
|
||||
inputTags,
|
||||
inputHouseholds,
|
||||
inputQueryFilterString,
|
||||
handleQueryFilterInput,
|
||||
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 = {
|
||||
"categories": "category.category",
|
||||
"tags": "tag.tag",
|
||||
"tools": "tool.tool"
|
||||
"tools": "tool.tool",
|
||||
"foods": "shopping-list.food",
|
||||
"households": "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
|
|
@ -8,13 +8,12 @@
|
|||
deletable-chips
|
||||
item-text="name"
|
||||
multiple
|
||||
:prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam :
|
||||
selectorType === Organizer.Category ? $globals.icons.categories :
|
||||
$globals.icons.tags"
|
||||
:prepend-inner-icon="icon"
|
||||
return-object
|
||||
v-bind="inputAttrs"
|
||||
auto-select-first
|
||||
:search-input.sync="searchInput"
|
||||
class="pa-0"
|
||||
@change="resetSearchInput"
|
||||
>
|
||||
<template #selection="data">
|
||||
|
@ -46,11 +45,11 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||
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 { useTagStore } from "~/composables/store/use-tag-store";
|
||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
|
@ -58,7 +57,14 @@ export default defineComponent({
|
|||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool | string)[] | undefined,
|
||||
type: Array as () => (
|
||||
| HouseholdSummary
|
||||
| RecipeTag
|
||||
| RecipeCategory
|
||||
| RecipeTool
|
||||
| IngredientFood
|
||||
| string
|
||||
)[] | undefined,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
|
@ -80,6 +86,14 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
|
@ -96,9 +110,13 @@ export default defineComponent({
|
|||
}
|
||||
});
|
||||
|
||||
const { i18n } = useContext();
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.showLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return i18n.t("tag.tags");
|
||||
|
@ -106,30 +124,57 @@ export default defineComponent({
|
|||
return i18n.t("category.categories");
|
||||
case Organizer.Tool:
|
||||
return i18n.t("tool.tools");
|
||||
case Organizer.Food:
|
||||
return i18n.t("general.foods");
|
||||
case Organizer.Household:
|
||||
return i18n.t("household.households");
|
||||
default:
|
||||
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
|
||||
|
||||
const store = (() => {
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
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) {
|
||||
|
@ -140,7 +185,7 @@ export default defineComponent({
|
|||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
@ -162,6 +207,7 @@ export default defineComponent({
|
|||
dialog,
|
||||
storeItem: items,
|
||||
label,
|
||||
icon,
|
||||
selected,
|
||||
removeByIndex,
|
||||
searchInput,
|
||||
|
@ -170,3 +216,10 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
</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") }}
|
||||
</BaseButton>
|
||||
<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 }}
|
||||
<template v-if="submitIcon" #icon>
|
||||
{{ submitIcon }}
|
||||
|
@ -125,6 +125,10 @@ export default defineComponent({
|
|||
return this.$t("general.create");
|
||||
},
|
||||
},
|
||||
submitDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
keepOpen: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
|
|
|
@ -51,6 +51,9 @@ export function useReadOnlyActions<T extends BoundT>(
|
|||
}
|
||||
|
||||
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
loading.value = true;
|
||||
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>) {
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.getAll(page, perPage, params);
|
||||
|
||||
|
|
|
@ -103,6 +103,8 @@ export const useCookbooks = function () {
|
|||
loading.value = true;
|
||||
const { data } = await api.cookbooks.createOne({
|
||||
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) {
|
||||
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.",
|
||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||
"organizers": "Organizers",
|
||||
"caution": "Caution"
|
||||
"caution": "Caution",
|
||||
"show-advanced": "Show Advanced",
|
||||
"add-field": "Add Field",
|
||||
"date-created": "Date Created",
|
||||
"date-updated": "Date Updated"
|
||||
},
|
||||
"group": {
|
||||
"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",
|
||||
"meal-plan-rules": "Meal Plan Rules",
|
||||
"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.",
|
||||
"recipe-rules": "Recipe Rules",
|
||||
"applies-to-all-days": "Applies to all days",
|
||||
|
@ -1319,7 +1323,7 @@
|
|||
},
|
||||
"cookbook": {
|
||||
"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-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
|
||||
"filter-options": "Filter Options",
|
||||
|
@ -1332,5 +1336,28 @@
|
|||
"household-cookbook-name": "{0} Cookbook {1}",
|
||||
"create-a-cookbook": "Create a 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
|
||||
*/
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
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;
|
||||
queryFilterString: string;
|
||||
}
|
||||
export interface ReadCookBook {
|
||||
name: string;
|
||||
|
@ -40,15 +23,23 @@ export interface ReadCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
requireAllCategories?: boolean;
|
||||
requireAllTags?: boolean;
|
||||
requireAllTools?: boolean;
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: 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 {
|
||||
name: string;
|
||||
|
@ -56,15 +47,11 @@ export interface RecipeCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
requireAllCategories?: boolean;
|
||||
requireAllTags?: boolean;
|
||||
requireAllTools?: boolean;
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
recipes: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeSummary {
|
||||
|
@ -104,18 +91,20 @@ export interface RecipeTag {
|
|||
slug: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface RecipeTool {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface SaveCookBook {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
requireAllCategories?: boolean;
|
||||
requireAllTags?: boolean;
|
||||
requireAllTools?: boolean;
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
|
@ -125,12 +114,7 @@ export interface UpdateCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
requireAllCategories?: boolean;
|
||||
requireAllTags?: boolean;
|
||||
requireAllTools?: boolean;
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
|
|
|
@ -8,6 +8,9 @@
|
|||
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
|
||||
export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "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 {
|
||||
id: string;
|
||||
|
@ -31,44 +34,36 @@ export interface ListItem {
|
|||
quantity?: number;
|
||||
checked?: boolean;
|
||||
}
|
||||
export interface PlanCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface PlanHousehold {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface PlanRulesCreate {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
}
|
||||
export interface PlanTag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
queryFilterString: string;
|
||||
}
|
||||
export interface PlanRulesOut {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: 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 {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
queryFilterString: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
|
@ -126,6 +121,7 @@ export interface RecipeTool {
|
|||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface SavePlanEntry {
|
||||
date: string;
|
||||
|
|
|
@ -24,10 +24,17 @@ export interface PaginationData<T> {
|
|||
items: T[];
|
||||
}
|
||||
|
||||
export type RecipeOrganizer = "categories" | "tags" | "tools";
|
||||
export type RecipeOrganizer =
|
||||
| "categories"
|
||||
| "tags"
|
||||
| "tools"
|
||||
| "foods"
|
||||
| "households";
|
||||
|
||||
export enum Organizer {
|
||||
Category = "categories",
|
||||
Tag = "tags",
|
||||
Tool = "tools",
|
||||
Food = "foods",
|
||||
Household = "households",
|
||||
}
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
export type OrderByNullPosition = "first" | "last";
|
||||
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 {
|
||||
message: string;
|
||||
|
@ -25,6 +28,17 @@ export interface PaginationQuery {
|
|||
queryFilter?: 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 {
|
||||
cookbook?: string | null;
|
||||
requireAllCategories?: boolean;
|
||||
|
|
|
@ -14,7 +14,6 @@ export class MealPlanAPI extends BaseCRUDAPI<CreatePlanEntry, ReadPlanEntry, Upd
|
|||
itemRoute = routes.mealplanId;
|
||||
|
||||
async setRandom(payload: CreateRandomEntry) {
|
||||
console.log(payload);
|
||||
return await this.requests.post<ReadPlanEntry>(routes.random, payload);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,16 +4,19 @@
|
|||
<BaseDialog
|
||||
v-if="createTarget"
|
||||
v-model="dialogStates.create"
|
||||
:width="650"
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$t('cookbook.create-a-cookbook')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-disabled="!createTarget.queryFilterString"
|
||||
@submit="actions.updateOne(createTarget)"
|
||||
@cancel="actions.deleteOne(createTarget.id)"
|
||||
@cancel="deleteCreateTarget()"
|
||||
>
|
||||
<v-card-text>
|
||||
<CookbookEditor
|
||||
:key="createTargetKey"
|
||||
:cookbook=createTarget
|
||||
:actions="actions"
|
||||
/>
|
||||
|
@ -36,7 +39,7 @@
|
|||
|
||||
<!-- Cookbook Page -->
|
||||
<!-- Page Title -->
|
||||
<v-container class="narrow-container">
|
||||
<v-container class="px-12">
|
||||
<BasePageTitle divider>
|
||||
<template #header>
|
||||
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
|
||||
|
@ -51,7 +54,7 @@
|
|||
<!-- Cookbook List -->
|
||||
<v-expansion-panels class="mt-2">
|
||||
<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">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon large left>
|
||||
|
@ -84,6 +87,7 @@
|
|||
icon: $globals.icons.save,
|
||||
text: $tc('general.save'),
|
||||
event: 'save',
|
||||
disabled: !cookbook.queryFilterString
|
||||
},
|
||||
]"
|
||||
@delete="deleteEventHandler(cookbook)"
|
||||
|
@ -99,7 +103,7 @@
|
|||
|
||||
<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 { useCookbooks } from "@/composables/use-group-cookbooks";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
@ -116,10 +120,12 @@ export default defineComponent({
|
|||
const { cookbooks, actions } = useCookbooks();
|
||||
|
||||
// create
|
||||
const createTargetKey = ref(0);
|
||||
const createTarget = ref<ReadCookBook | null>(null);
|
||||
async function createCookbook() {
|
||||
await actions.createOne().then((cookbook) => {
|
||||
createTarget.value = cookbook as ReadCookBook;
|
||||
createTargetKey.value++;
|
||||
});
|
||||
dialogStates.create = true;
|
||||
}
|
||||
|
@ -138,11 +144,37 @@ export default defineComponent({
|
|||
dialogStates.delete = false;
|
||||
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 {
|
||||
cookbooks,
|
||||
actions,
|
||||
dialogStates,
|
||||
// create
|
||||
createTargetKey,
|
||||
createTarget,
|
||||
createCookbook,
|
||||
|
||||
|
@ -150,6 +182,7 @@ export default defineComponent({
|
|||
deleteTarget,
|
||||
deleteEventHandler,
|
||||
deleteCookbook,
|
||||
deleteCreateTarget,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
|
|
@ -15,16 +15,15 @@
|
|||
{{ $t('meal-plan.new-rule-description') }}
|
||||
|
||||
<GroupMealPlanRuleForm
|
||||
:key="createDataFormKey"
|
||||
class="mt-2"
|
||||
:day.sync="createData.day"
|
||||
:entry-type.sync="createData.entryType"
|
||||
:categories.sync="createData.categories"
|
||||
:tags.sync="createData.tags"
|
||||
:households.sync="createData.households"
|
||||
:query-filter-string.sync="createData.queryFilterString"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<BaseButton create @click="createRule" />
|
||||
<BaseButton create :disabled="!createData.queryFilterString" @click="createRule" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
|
@ -117,12 +116,11 @@
|
|||
<GroupMealPlanRuleForm
|
||||
:day.sync="allRules[idx].day"
|
||||
:entry-type.sync="allRules[idx].entryType"
|
||||
:categories.sync="allRules[idx].categories"
|
||||
:tags.sync="allRules[idx].tags"
|
||||
:households.sync="allRules[idx].households"
|
||||
:query-filter-string.sync="allRules[idx].queryFilterString"
|
||||
:query-filter="allRules[idx].queryFilter"
|
||||
/>
|
||||
<div class="d-flex justify-end">
|
||||
<BaseButton update @click="updateRule(rule)" />
|
||||
<BaseButton update :disabled="!allRules[idx].queryFilterString" @click="updateRule(rule)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
@ -181,12 +179,11 @@ export default defineComponent({
|
|||
// ======================================================
|
||||
// Creating Rules
|
||||
|
||||
const createDataFormKey = ref(0);
|
||||
const createData = ref<PlanRulesCreate>({
|
||||
entryType: "unset",
|
||||
day: "unset",
|
||||
categories: [],
|
||||
tags: [],
|
||||
households: [],
|
||||
queryFilterString: "",
|
||||
});
|
||||
|
||||
async function createRule() {
|
||||
|
@ -196,10 +193,9 @@ export default defineComponent({
|
|||
createData.value = {
|
||||
entryType: "unset",
|
||||
day: "unset",
|
||||
categories: [],
|
||||
tags: [],
|
||||
households: [],
|
||||
queryFilterString: "",
|
||||
};
|
||||
createDataFormKey.value++;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -220,6 +216,7 @@ export default defineComponent({
|
|||
|
||||
return {
|
||||
allRules,
|
||||
createDataFormKey,
|
||||
createData,
|
||||
createRule,
|
||||
deleteRule,
|
||||
|
|
|
@ -33,7 +33,9 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
|||
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String, default="")
|
||||
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(
|
||||
Category, secondary=cookbooks_to_categories, single_parent=True
|
||||
)
|
||||
|
|
|
@ -40,8 +40,9 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
|||
entry_type: Mapped[str] = mapped_column(
|
||||
String, nullable=False, default=""
|
||||
) # "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)
|
||||
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
||||
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:
|
||||
from .group.group import Group
|
||||
from .group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
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.schema._mealie import MealieModel
|
||||
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 ._utils import NOT_SET, NotSet
|
||||
|
@ -349,8 +349,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||
|
||||
if pagination.query_filter:
|
||||
try:
|
||||
query_filter = QueryFilter(pagination.query_filter)
|
||||
query = query_filter.filter_query(query, model=self.model)
|
||||
query_filter_builder = QueryFilterBuilder(pagination.query_filter)
|
||||
query = query_filter_builder.filter_query(query, model=self.model)
|
||||
|
||||
except ValueError as e:
|
||||
self.logger.error(e)
|
||||
|
@ -434,7 +434,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||
order_by = order_by_val
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
@ -19,13 +19,7 @@ from mealie.db.models.recipe.tool import Tool
|
|||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import (
|
||||
RecipeCategory,
|
||||
RecipePagination,
|
||||
RecipeSummary,
|
||||
RecipeTool,
|
||||
)
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
|
||||
from mealie.schema.response.pagination import (
|
||||
OrderByNullPosition,
|
||||
OrderDirection,
|
||||
|
@ -33,7 +27,6 @@ from mealie.schema.response.pagination import (
|
|||
)
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from ..schema._mealie.mealie_model import extract_uuids
|
||||
from .repository_generic import HouseholdRepositoryGeneric
|
||||
|
||||
|
||||
|
@ -173,17 +166,12 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
q = q.filter_by(**fltr)
|
||||
|
||||
if cookbook:
|
||||
cb_filters = self._build_recipe_filter(
|
||||
households=[cookbook.household_id],
|
||||
categories=extract_uuids(cookbook.categories),
|
||||
tags=extract_uuids(cookbook.tags),
|
||||
tools=extract_uuids(cookbook.tools),
|
||||
require_all_categories=cookbook.require_all_categories,
|
||||
require_all_tags=cookbook.require_all_tags,
|
||||
require_all_tools=cookbook.require_all_tools,
|
||||
)
|
||||
|
||||
q = q.filter(*cb_filters)
|
||||
if pagination_result.query_filter and cookbook.query_filter_string:
|
||||
pagination_result.query_filter = (
|
||||
f"({pagination_result.query_filter}) AND ({cookbook.query_filter_string})"
|
||||
)
|
||||
else:
|
||||
pagination_result.query_filter = cookbook.query_filter_string
|
||||
else:
|
||||
category_ids = self._uuids_for_items(categories, Category)
|
||||
tag_ids = self._uuids_for_items(tags, Tag)
|
||||
|
@ -290,26 +278,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
fltr.append(RecipeModel.household_id.in_(households))
|
||||
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]:
|
||||
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
|
||||
if self.group_id:
|
||||
|
|
|
@ -3,7 +3,6 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
|
||||
|
@ -59,15 +58,12 @@ class PublicCookbooksController(BasePublicHouseholdExploreController):
|
|||
if not household or household.preferences.private_household:
|
||||
raise NOT_FOUND_EXCEPTION
|
||||
|
||||
# limit recipes to only the household the cookbook belongs to
|
||||
recipes_repo = get_repositories(
|
||||
self.session, group_id=self.group_id, household_id=cookbook.household_id
|
||||
).recipes
|
||||
recipes = recipes_repo.page_all(
|
||||
cross_household_recipes = self.cross_household_repos.recipes
|
||||
recipes = cross_household_recipes.page_all(
|
||||
PaginationQuery(
|
||||
page=1,
|
||||
per_page=-1,
|
||||
query_filter="settings.public = TRUE",
|
||||
query_filter="settings.public = TRUE AND household.preferences.privateHousehold = FALSE",
|
||||
),
|
||||
cookbook=cookbook,
|
||||
)
|
||||
|
|
|
@ -4,7 +4,6 @@ import orjson
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
|
||||
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
|
||||
|
@ -40,7 +39,6 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
|||
households: list[UUID4 | str] | None = Query(None),
|
||||
) -> PaginationBase[RecipeSummary]:
|
||||
cookbook_data: ReadCookBook | None = None
|
||||
recipes_repo = self.cross_household_recipes
|
||||
if search_query.cookbook:
|
||||
COOKBOOK_NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
|
||||
if isinstance(search_query.cookbook, UUID):
|
||||
|
@ -59,18 +57,13 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
|||
if not household or household.preferences.private_household:
|
||||
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)"
|
||||
if q.query_filter:
|
||||
q.query_filter = f"({q.query_filter}) AND {public_filter}"
|
||||
else:
|
||||
q.query_filter = public_filter
|
||||
|
||||
pagination_response = recipes_repo.page_all(
|
||||
pagination_response = self.cross_household_recipes.page_all(
|
||||
pagination=q,
|
||||
cookbook=cookbook_data,
|
||||
categories=categories,
|
||||
|
|
|
@ -109,17 +109,8 @@ class GroupCookbookController(BaseCrudController):
|
|||
if cookbook is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return cookbook.cast(
|
||||
RecipeCookBook,
|
||||
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,
|
||||
),
|
||||
)
|
||||
recipe_pagination = self.repos.recipes.page_all(PaginationQuery(page=1, per_page=-1, cookbook=cookbook))
|
||||
return cookbook.cast(RecipeCookBook, recipes=recipe_pagination.items)
|
||||
|
||||
@router.put("/{item_id}", response_model=ReadCookBook)
|
||||
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.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
||||
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.response.pagination import PaginationQuery
|
||||
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)
|
||||
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||
|
||||
tags: list[PlanTag] = []
|
||||
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
|
||||
|
||||
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
|
||||
recipes_data = cross_household_recipes.page_all(
|
||||
pagination=PaginationQuery(
|
||||
page=1, per_page=limit, order_by="random", pagination_seed=self.repo._random_seed()
|
||||
),
|
||||
categories=category_ids,
|
||||
tags=tag_ids,
|
||||
households=household_ids,
|
||||
page=1,
|
||||
per_page=limit,
|
||||
query_filter=qf_string,
|
||||
order_by="random",
|
||||
pagination_seed=self.repo._random_seed(),
|
||||
)
|
||||
)
|
||||
return recipes_data.items
|
||||
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .about import (
|
||||
AdminAboutInfo,
|
||||
AppInfo,
|
||||
AppStartupInfo,
|
||||
AppStatistics,
|
||||
AppTheme,
|
||||
CheckAppConfig,
|
||||
)
|
||||
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig
|
||||
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
|
||||
from .debug import DebugResponse
|
||||
from .email import EmailReady, EmailSuccess, EmailTest
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
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 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.recipe.recipe import RecipeSummary, RecipeTool
|
||||
from mealie.schema.recipe.recipe import RecipeSummary
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
from ...db.models.household import CookBook
|
||||
from ..recipe.recipe_category import CategoryBase, TagBase
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class CreateCookBook(MealieModel):
|
||||
|
@ -19,12 +20,7 @@ class CreateCookBook(MealieModel):
|
|||
slug: Annotated[str | None, Field(validate_default=True)] = None
|
||||
position: int = 1
|
||||
public: Annotated[bool, Field(validate_default=True)] = False
|
||||
categories: list[CategoryBase] = []
|
||||
tags: list[TagBase] = []
|
||||
tools: list[RecipeTool] = []
|
||||
require_all_categories: bool = True
|
||||
require_all_tags: bool = True
|
||||
require_all_tools: bool = True
|
||||
query_filter_string: str = ""
|
||||
|
||||
@field_validator("public", mode="before")
|
||||
def validate_public(public: bool | None) -> bool:
|
||||
|
@ -42,6 +38,19 @@ class CreateCookBook(MealieModel):
|
|||
|
||||
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):
|
||||
group_id: UUID4
|
||||
|
@ -53,14 +62,24 @@ class UpdateCookBook(SaveCookBook):
|
|||
|
||||
|
||||
class ReadCookBook(UpdateCookBook):
|
||||
group_id: UUID4
|
||||
household_id: UUID4
|
||||
categories: list[CategoryBase] = []
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(CookBook.categories), joinedload(CookBook.tags), joinedload(CookBook.tools)]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# 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):
|
||||
|
|
|
@ -8,18 +8,7 @@ from .new_meal import (
|
|||
SavePlanEntry,
|
||||
UpdatePlanEntry,
|
||||
)
|
||||
from .plan_rules import (
|
||||
BasePlanRuleFilter,
|
||||
PlanCategory,
|
||||
PlanHousehold,
|
||||
PlanRulesCreate,
|
||||
PlanRulesDay,
|
||||
PlanRulesOut,
|
||||
PlanRulesPagination,
|
||||
PlanRulesSave,
|
||||
PlanRulesType,
|
||||
PlanTag,
|
||||
)
|
||||
from .plan_rules import PlanRulesCreate, PlanRulesDay, PlanRulesOut, PlanRulesPagination, PlanRulesSave, PlanRulesType
|
||||
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
||||
|
||||
__all__ = [
|
||||
|
@ -33,14 +22,10 @@ __all__ = [
|
|||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"BasePlanRuleFilter",
|
||||
"PlanCategory",
|
||||
"PlanHousehold",
|
||||
"PlanRulesCreate",
|
||||
"PlanRulesDay",
|
||||
"PlanRulesOut",
|
||||
"PlanRulesPagination",
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"PlanTag",
|
||||
]
|
||||
|
|
|
@ -1,32 +1,17 @@
|
|||
import datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, field_validator
|
||||
|
||||
from mealie.db.models.household import GroupMealPlanRules, Household
|
||||
from mealie.db.models.recipe import Category, Tag
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models.recipe import RecipeModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder, QueryFilterJSON
|
||||
|
||||
|
||||
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)
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class PlanRulesDay(str, Enum):
|
||||
|
@ -59,9 +44,20 @@ class PlanRulesType(str, Enum):
|
|||
class PlanRulesCreate(MealieModel):
|
||||
day: PlanRulesDay = PlanRulesDay.unset
|
||||
entry_type: PlanRulesType = PlanRulesType.unset
|
||||
categories: list[PlanCategory] = []
|
||||
tags: list[PlanTag] = []
|
||||
households: list[PlanHousehold] = []
|
||||
query_filter_string: str = ""
|
||||
|
||||
@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):
|
||||
|
@ -71,27 +67,24 @@ class PlanRulesSave(PlanRulesCreate):
|
|||
|
||||
class PlanRulesOut(PlanRulesSave):
|
||||
id: UUID4
|
||||
query_filter: Annotated[QueryFilterJSON, Field(validate_default=True)] = None # type: ignore
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(GroupMealPlanRules.categories).load_only(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.tags).load_only(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.households).load_only(
|
||||
Household.id,
|
||||
Household.name,
|
||||
Household.slug,
|
||||
),
|
||||
]
|
||||
@field_validator("query_filter_string")
|
||||
def validate_query_filter_string(value: str) -> str:
|
||||
# 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 PlanRulesPagination(PaginationBase):
|
||||
|
|
|
@ -1,14 +1,24 @@
|
|||
# This file is auto-generated by gen_schema_exports.py
|
||||
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 .responses import ErrorResponse, FileTokenResponse, SuccessResponse
|
||||
from .validation import ValidationResponse
|
||||
|
||||
__all__ = [
|
||||
"LogicalOperator",
|
||||
"QueryFilter",
|
||||
"QueryFilterComponent",
|
||||
"QueryFilterBuilder",
|
||||
"QueryFilterBuilderComponent",
|
||||
"QueryFilterJSON",
|
||||
"QueryFilterJSONPart",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"ValidationResponse",
|
||||
|
|
|
@ -17,6 +17,7 @@ from sqlalchemy.sql import sqltypes
|
|||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
|
||||
Model = TypeVar("Model", bound=SqlAlchemyBase)
|
||||
|
||||
|
@ -104,7 +105,21 @@ class LogicalOperator(Enum):
|
|||
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"""
|
||||
|
||||
@staticmethod
|
||||
|
@ -135,7 +150,7 @@ class QueryFilterComponent:
|
|||
] and not isinstance(value, list):
|
||||
raise ValueError(
|
||||
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:
|
||||
|
@ -193,8 +208,18 @@ class QueryFilterComponent:
|
|||
|
||||
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 = "("
|
||||
r_group_sep: str = ")"
|
||||
group_seps: set[str] = {l_group_sep, r_group_sep}
|
||||
|
@ -205,13 +230,15 @@ class QueryFilter:
|
|||
|
||||
def __init__(self, filter_string: str) -> None:
|
||||
# parse filter string
|
||||
components = QueryFilter._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilter._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilter.l_group_sep) != base_components.count(QueryFilter.r_group_sep):
|
||||
components = QueryFilterBuilder._break_filter_string_into_components(filter_string)
|
||||
base_components = QueryFilterBuilder._break_components_into_base_components(components)
|
||||
if base_components.count(QueryFilterBuilder.l_group_sep) != base_components.count(
|
||||
QueryFilterBuilder.r_group_sep
|
||||
):
|
||||
raise ValueError("invalid query string: parenthesis are unbalanced")
|
||||
|
||||
# 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:
|
||||
joined = " ".join(
|
||||
|
@ -308,7 +335,7 @@ class QueryFilter:
|
|||
attr_model_map: dict[int, Any] = {}
|
||||
model_attr: InstrumentedAttribute
|
||||
for i, component in enumerate(self.filter_components):
|
||||
if not isinstance(component, QueryFilterComponent):
|
||||
if not isinstance(component, QueryFilterBuilderComponent):
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
else:
|
||||
component = cast(QueryFilterComponent, component)
|
||||
component = cast(QueryFilterBuilderComponent, component)
|
||||
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
||||
|
||||
# Keywords
|
||||
|
@ -395,7 +422,7 @@ class QueryFilter:
|
|||
subcomponents = []
|
||||
for component in components:
|
||||
# don't parse components comprised of only a separator
|
||||
if component in QueryFilter.group_seps:
|
||||
if component in QueryFilterBuilder.group_seps:
|
||||
subcomponents.append(component)
|
||||
continue
|
||||
|
||||
|
@ -406,7 +433,7 @@ class QueryFilter:
|
|||
if c == '"':
|
||||
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:
|
||||
subcomponents.append(new_component)
|
||||
|
||||
|
@ -437,17 +464,17 @@ class QueryFilter:
|
|||
list_value_components = []
|
||||
for component in components:
|
||||
# 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):
|
||||
if not i:
|
||||
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:
|
||||
continue
|
||||
|
||||
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
|
||||
|
@ -455,16 +482,16 @@ class QueryFilter:
|
|||
for i, subcomponent in enumerate(subcomponents):
|
||||
# we are in a list subcomponent, which is already handled
|
||||
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
|
||||
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
|
||||
else:
|
||||
continue
|
||||
|
||||
# don't parse components comprised of only a separator
|
||||
if subcomponent in QueryFilter.group_seps:
|
||||
if subcomponent in QueryFilterBuilder.group_seps:
|
||||
quote_offset += 1
|
||||
base_components.append(subcomponent)
|
||||
continue
|
||||
|
@ -479,8 +506,8 @@ class QueryFilter:
|
|||
continue
|
||||
|
||||
# continue parsing this subcomponent up to the list, then skip over subsequent subcomponents
|
||||
if not in_list and QueryFilter.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilter.l_list_sep, maxsplit=1)
|
||||
if not in_list and QueryFilterBuilder.l_list_sep in subcomponent:
|
||||
subcomponent, _new_sub_component = subcomponent.split(QueryFilterBuilder.l_list_sep, maxsplit=1)
|
||||
subcomponent = subcomponent.strip()
|
||||
subcomponents.insert(i + 1, _new_sub_component)
|
||||
quote_offset += 1
|
||||
|
@ -516,19 +543,19 @@ class QueryFilter:
|
|||
@staticmethod
|
||||
def _parse_base_components_into_filter_components(
|
||||
base_components: list[str | list[str]],
|
||||
) -> list[str | QueryFilterComponent | LogicalOperator]:
|
||||
) -> list[str | QueryFilterBuilderComponent | LogicalOperator]:
|
||||
"""Walk through base components and construct filter collections"""
|
||||
relational_keywords = [kw.value for kw in RelationalKeyword]
|
||||
relational_operators = [op.value for op in RelationalOperator]
|
||||
logical_operators = [op.value for op in LogicalOperator]
|
||||
|
||||
# parse QueryFilterComponents and logical operators
|
||||
components: list[str | QueryFilterComponent | LogicalOperator] = []
|
||||
components: list[str | QueryFilterBuilderComponent | LogicalOperator] = []
|
||||
for i, base_component in enumerate(base_components):
|
||||
if isinstance(base_component, list):
|
||||
continue
|
||||
|
||||
if base_component in QueryFilter.group_seps:
|
||||
if base_component in QueryFilterBuilder.group_seps:
|
||||
components.append(base_component)
|
||||
|
||||
elif base_component in relational_keywords or base_component in relational_operators:
|
||||
|
@ -539,7 +566,7 @@ class QueryFilter:
|
|||
relationship = RelationalOperator(base_components[i])
|
||||
|
||||
components.append(
|
||||
QueryFilterComponent(
|
||||
QueryFilterBuilderComponent(
|
||||
attribute_name=base_components[i - 1], # type: ignore
|
||||
relationship=relationship,
|
||||
value=base_components[i + 1],
|
||||
|
@ -550,3 +577,47 @@ class QueryFilter:
|
|||
components.append(LogicalOperator(base_component.upper()))
|
||||
|
||||
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__ = [
|
||||
"CreateUserRegistration",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"ForgotPassword",
|
||||
"PasswordResetToken",
|
||||
"PrivatePasswordResetToken",
|
||||
"ResetPassword",
|
||||
"SavePasswordResetToken",
|
||||
"ValidateResetToken",
|
||||
"CredentialsRequest",
|
||||
"CredentialsRequestForm",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UnlockResults",
|
||||
"CreateUserRegistration",
|
||||
"ChangePassword",
|
||||
"CreateToken",
|
||||
"DeleteTokenResponse",
|
||||
|
|
|
@ -87,6 +87,7 @@ sort_by_size = true
|
|||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra -q"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
minversion = "6.0"
|
||||
python_classes = '*Tests'
|
||||
python_files = 'test_*'
|
||||
|
|
|
@ -3,6 +3,19 @@ from collections.abc import Generator
|
|||
|
||||
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.setenv("PRODUCTION", "True")
|
||||
mp.setenv("TESTING", "True")
|
||||
|
@ -54,11 +67,6 @@ def test_image_png():
|
|||
@fixture(scope="session", autouse=True)
|
||||
def global_cleanup() -> Generator[None, None, None]:
|
||||
"""Purges the .temp directory used for testing"""
|
||||
|
||||
yield None
|
||||
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()
|
||||
|
|
|
@ -178,7 +178,7 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
|
|||
|
||||
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_household = other_database.households.get_one(h2_user.household_id)
|
||||
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_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(
|
||||
user_id=h2_user.user_id,
|
||||
group_id=h2_user.group_id,
|
||||
name=random_string(),
|
||||
)
|
||||
for _ in range(2)
|
||||
)
|
||||
assert other_household_recipe.settings
|
||||
other_household_recipe.settings.public = True
|
||||
other_household_recipe.tags = [tag]
|
||||
other_database.recipes.update(other_household_recipe.slug, other_household_recipe)
|
||||
|
||||
assert other_household_public_recipe.settings
|
||||
other_household_public_recipe.settings.public = True
|
||||
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
|
||||
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,
|
||||
household_id=unique_user.household_id,
|
||||
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))
|
||||
assert response.status_code == 200
|
||||
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"]}
|
||||
assert len(cookbook_recipe_ids) == 1
|
||||
assert str(public_recipe.id) in cookbook_recipe_ids
|
||||
assert str(private_recipe.id) not in cookbook_recipe_ids
|
||||
assert str(other_household_recipe.id) not in cookbook_recipe_ids
|
||||
assert str(other_household_private_recipe.id) not in cookbook_recipe_ids
|
||||
|
|
|
@ -244,8 +244,12 @@ def test_public_recipe_cookbook_filter(
|
|||
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.session.rollback()
|
||||
|
||||
# Create a public and private recipe with a known tag
|
||||
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])
|
||||
|
||||
# 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_household = other_database.households.get_one(h2_user.household_id)
|
||||
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_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(
|
||||
Recipe(
|
||||
|
@ -309,17 +313,25 @@ def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, uniq
|
|||
group_id=unique_user.group_id,
|
||||
household_id=unique_user.household_id,
|
||||
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(
|
||||
api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.id}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
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(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,
|
||||
"description": "",
|
||||
"position": 0,
|
||||
"categories": [],
|
||||
"query_filter_string": "",
|
||||
"group_id": str(group_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)
|
||||
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,
|
||||
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(
|
||||
PlanRulesSave(
|
||||
group_id=UUID(unique_user.group_id),
|
||||
household_id=UUID(unique_user.household_id),
|
||||
day=day,
|
||||
entry_type=entry_type,
|
||||
tags=tags or [],
|
||||
categories=categories or [],
|
||||
households=households or [],
|
||||
query_filter_string=query_filter_string,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ from mealie.schema.recipe.recipe import RecipeCategory
|
|||
from mealie.schema.recipe.recipe_category import CategorySave
|
||||
from tests import utils
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_string
|
||||
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,
|
||||
"day": "monday",
|
||||
"entryType": "breakfast",
|
||||
"categories": [],
|
||||
"queryFilterString": "",
|
||||
}
|
||||
|
||||
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):
|
||||
database = unique_user.repos
|
||||
query_filter_string = f'recipe_category.id IN ["{category.id}"]'
|
||||
payload = {
|
||||
"groupId": unique_user.group_id,
|
||||
"householdId": unique_user.household_id,
|
||||
"day": "monday",
|
||||
"entryType": "breakfast",
|
||||
"categories": [category.model_dump()],
|
||||
"queryFilterString": query_filter_string,
|
||||
}
|
||||
|
||||
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["day"] == "monday"
|
||||
assert response_data["entryType"] == "breakfast"
|
||||
assert len(response_data["categories"]) == 1
|
||||
assert response_data["categories"][0]["slug"] == category.slug
|
||||
assert len(response_data["queryFilter"]["parts"]) == 1
|
||||
assert response_data["queryFilter"]["parts"][0]["value"] == [str(category.id)]
|
||||
|
||||
# Validate database entry
|
||||
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 rule.day == "monday"
|
||||
assert rule.entry_type == "breakfast"
|
||||
assert len(rule.categories) == 1
|
||||
assert rule.categories[0].slug == category.slug
|
||||
assert rule.query_filter_string == query_filter_string
|
||||
|
||||
# Cleanup
|
||||
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["day"] == "monday"
|
||||
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):
|
||||
|
@ -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["day"] == "tuesday"
|
||||
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):
|
||||
|
@ -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)
|
||||
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
|
||||
|
||||
|
||||
def test_cookbook_recipes_only_includes_current_households(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
|
||||
):
|
||||
def test_cookbook_recipes_includes_all_households(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))
|
||||
recipes = unique_user.repos.recipes.create_many(
|
||||
[
|
||||
|
@ -300,4 +298,4 @@ def test_cookbook_recipes_only_includes_current_households(
|
|||
for recipe in recipes:
|
||||
assert recipe.id in fetched_recipe_ids
|
||||
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(),
|
||||
group_id=unique_user.group_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.models._model_utils.guid import GUID
|
||||
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.labels import MultiPurposeLabel
|
||||
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()
|
||||
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
|
||||
for recipe in recipes:
|
||||
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]
|
||||
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:
|
||||
backup_v2.restore(original_data_backup)
|
||||
|
|
|
@ -37,8 +37,6 @@ admin_users_unlock = "/api/admin/users/unlock"
|
|||
"""`/api/admin/users/unlock`"""
|
||||
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"
|
||||
"""`/api/app/about/startup-info`"""
|
||||
app_about_theme = "/api/app/about/theme"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue