feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)

This commit is contained in:
Michael Genson 2024-10-17 10:35:39 -05:00 committed by GitHub
parent 2a9a6fa5e6
commit b8e62ab8dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2043 additions and 440 deletions

View file

@ -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 ###

View file

@ -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>

View file

@ -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>

View file

@ -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,
};
},
});

View 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>

View file

@ -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] || "";
});

View file

@ -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>

View file

@ -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,

View file

@ -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);

View file

@ -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);

View 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,
};
}

View file

@ -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"
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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",
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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() {

View file

@ -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,

View file

@ -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
)

View file

@ -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)

View file

@ -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

View file

@ -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
)

View file

@ -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:

View file

@ -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,
)

View file

@ -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,

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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",
]

View file

@ -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):

View file

@ -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",

View file

@ -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)

View file

@ -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",

View file

@ -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_*'

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,
)
)

View file

@ -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)

View file

@ -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

View file

@ -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}"]',
)
)

View file

@ -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="))",
),
]
)

View file

@ -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)

View file

@ -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"