Feature/automated meal planner (#939)

* cleanup oversized buttons

* add get all by category function to reciep repos

* fix shopping-list can_merge logic

* use randomized data for testing

* add random getter to repository for meal-planner

* add stub route for random meals

* cleanup global namespace

* add rules database type

* fix type

* add plan rules schema

* test plan rules methods

* add mealplan rules controller

* add new repository

* update frontend types

* formatting

* fix regression

* update autogenerated types

* add api class for mealplan rules

* add tests and fix bugs

* fix data returns

* proof of concept rules editor

* add tag support

* remove old group categories

* add tag support

* implement random by rules api

* change snack to sides

* remove incorrect typing

* split repo for custom methods

* fix query and use and_ clause

* use repo function

* remove old test

* update changelog
This commit is contained in:
Hayden 2022-02-07 19:03:11 -09:00 committed by GitHub
parent 40d1f586cd
commit d1024e272d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1153 additions and 175 deletions

View file

@ -64,6 +64,7 @@
- Drag and Drop meals between days
- Add Recipes or Notes to a specific day
- New context menu action for recipes to add a recipe to a specific day on the meal-plan
- New rule based meal plan generator/selector. You can now create rules to restrict the addition of recipes for specific days or meal types (breakfast, lunch, dinner, side). You can also create rules that match against "all" days or "all" meal types to create global rules based around tags and categories. This gives you the most flexibility in creating meal plans.
### 🥙 Recipes

View file

@ -0,0 +1,14 @@
import { BaseCRUDAPI } from "../_base";
import { PlanRulesCreate, PlanRulesOut } from "~/types/api-types/meal-plan";
const prefix = "/api";
const routes = {
rule: `${prefix}/groups/mealplans/rules`,
ruleId: (id: string | number) => `${prefix}/groups/mealplans/rules/${id}`,
};
export class MealPlanRulesApi extends BaseCRUDAPI<PlanRulesOut, PlanRulesCreate> {
baseRoute = routes.rule;
itemRoute = routes.ruleId;
}

View file

@ -1,13 +1,15 @@
import { BaseCRUDAPI } from "../_base";
import { CreatRandomEntry } from "~/types/api-types/meal-plan";
const prefix = "/api";
const routes = {
mealplan: `${prefix}/groups/mealplans`,
random: `${prefix}/groups/mealplans/random`,
mealplanId: (id: string | number) => `${prefix}/groups/mealplans/${id}`,
};
type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack";
type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
export interface CreateMealPlan {
date: string;
@ -29,4 +31,9 @@ export interface MealPlan extends UpdateMealPlan {
export class MealPlanAPI extends BaseCRUDAPI<MealPlan, CreateMealPlan> {
baseRoute = routes.mealplan;
itemRoute = routes.mealplanId;
async setRandom(payload: CreatRandomEntry) {
console.log(payload);
return await this.requests.post<MealPlan>(routes.random, payload);
}
}

View file

@ -23,6 +23,7 @@ import { GroupReportsApi } from "./class-interfaces/group-reports";
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier";
import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules";
import { ApiRequestInstance } from "~/types/api";
class Api {
@ -40,6 +41,7 @@ class Api {
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
public mealplans: MealPlanAPI;
public mealplanRules: MealPlanRulesApi;
public email: EmailAPI;
public bulk: BulkActionsAPI;
public groupMigration: GroupMigrationApi;
@ -67,6 +69,7 @@ class Api {
this.groupWebhooks = new WebhooksAPI(requests);
this.register = new RegisterAPI(requests);
this.mealplans = new MealPlanAPI(requests);
this.mealplanRules = new MealPlanRulesApi(requests);
this.grouperServerTasks = new GroupServerTaskAPI(requests);
// Group

View file

@ -0,0 +1,112 @@
<template>
<div>
<div class="d-md-flex" style="gap: 10px">
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" label="Rule Day"></v-select>
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" label="Meal Type"></v-select>
</div>
<RecipeCategoryTagSelector v-model="inputCategories" />
<RecipeCategoryTagSelector v-model="inputTags" :tag-selector="true" />
{{ inputDay === "unset" ? "This rule will apply to all days" : `This rule applies on ${inputDay}s` }}
{{ inputEntryType === "unset" ? "for all meal types" : ` and for ${inputEntryType} meal types` }}
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
const MEAL_TYPE_OPTIONS = [
{ text: "Breakfast", value: "breakfast" },
{ text: "Lunch", value: "lunch" },
{ text: "Dinner", value: "dinner" },
{ text: "Side", value: "side" },
{ text: "Any", value: "unset" },
];
const MEAL_DAY_OPTIONS = [
{ text: "Monday", value: "monday" },
{ text: "Tuesday", value: "tuesday" },
{ text: "Wednesday", value: "wednesday" },
{ text: "Thursday", value: "thursday" },
{ text: "Friday", value: "friday" },
{ text: "Sunday", value: "saturday" },
{ text: "Sunday", value: "sunday" },
{ text: "Any", value: "unset" },
];
export default defineComponent({
components: {
RecipeCategoryTagSelector,
},
props: {
day: {
type: String,
default: "unset",
},
entryType: {
type: String,
default: "unset",
},
categories: {
type: Array,
default: () => [],
},
tags: {
type: Array,
default: () => [],
},
showHelp: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const inputDay = computed({
get: () => {
return props.day;
},
set: (val) => {
context.emit("update:day", val);
},
});
const inputEntryType = computed({
get: () => {
return props.entryType;
},
set: (val) => {
context.emit("update:entry-type", val);
},
});
const inputCategories = computed({
get: () => {
return props.categories;
},
set: (val) => {
context.emit("update:categories", val);
},
});
const inputTags = computed({
get: () => {
return props.tags;
},
set: (val) => {
context.emit("update:tags", val);
},
});
return {
MEAL_TYPE_OPTIONS,
MEAL_DAY_OPTIONS,
inputDay,
inputEntryType,
inputCategories,
inputTags,
};
},
});
</script>

View file

@ -4,13 +4,13 @@ import { useAsyncKey } from "./use-utils";
import { useUserApi } from "~/composables/api";
import { CreateMealPlan, UpdateMealPlan } from "~/api/class-interfaces/group-mealplan";
export type MealType = "breakfast" | "lunch" | "dinner" | "snack";
export type MealType = "breakfast" | "lunch" | "dinner" | "side";
export const planTypeOptions = [
{ text: "Breakfast", value: "breakfast" },
{ text: "Lunch", value: "lunch" },
{ text: "Dinner", value: "dinner" },
{ text: "Snack", value: "snack" },
{ text: "Side", value: "side" },
];
export interface DateRange {

View file

@ -34,32 +34,6 @@ export const useGroupSelf = function () {
return { actions, group };
};
export const useGroupCategories = function () {
const api = useUserApi();
const actions = {
getAll() {
const units = useAsync(async () => {
const { data } = await api.groups.getCategories();
return data;
}, useAsyncKey());
return units;
},
async updateAll() {
if (!categories.value) {
return;
}
const { data } = await api.groups.setCategories(categories.value);
categories.value = data;
},
};
const categories = actions.getAll();
return { actions, categories };
};
export const useGroups = function () {
const api = useUserApi();
const loading = ref(false);

View file

@ -60,7 +60,6 @@
</v-app>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core";
@ -151,7 +150,7 @@ export default defineComponent({
{
icon: this.$globals.icons.calendarMultiselect,
title: this.$t("meal-plan.meal-planner"),
to: "/meal-plan/planner",
to: "/group/mealplan/planner",
restricted: true,
},
{

View file

@ -70,7 +70,10 @@
</v-btn>
</div>
</div>
<v-switch v-model="edit" label="Editor"></v-switch>
<div class="d-flex align-center justify-space-between">
<v-switch v-model="edit" label="Editor"></v-switch>
<ButtonLink :icon="$globals.icons.calendar" to="/group/mealplan/settings" text="Settings" />
</div>
<v-row class="">
<v-col
v-for="(plan, index) in mealsByDate"
@ -143,9 +146,6 @@
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn color="info" class="mr-2" small icon>
<v-icon>{{ $globals.icons.cartCheck }}</v-icon>
</v-btn>
<v-btn color="error" small icon @click="actions.deleteOne(mealplan.id)">
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn>
@ -154,20 +154,50 @@
</draggable>
<!-- Day Column Actions -->
<v-card outlined class="mt-auto">
<v-card-actions class="d-flex">
<div style="width: 50%">
<v-btn block text @click="randomMeal(plan.date)">
<v-icon large>{{ $globals.icons.diceMultiple }}</v-icon>
</v-btn>
</div>
<div style="width: 50%">
<v-btn block text @click="openDialog(plan.date)">
<v-icon large>{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</div>
</v-card-actions>
</v-card>
<div class="d-flex justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.diceMultiple,
text: 'Random Meal',
event: 'random',
children: [
{
icon: $globals.icons.dice,
text: 'Breakfast',
event: 'randomBreakfast',
},
{
icon: $globals.icons.dice,
text: 'Lunch',
event: 'randomLunch',
},
],
},
{
icon: $globals.icons.potSteam,
text: 'Random Dinner',
event: 'randomDinner',
},
{
icon: $globals.icons.bolwMixOutline,
text: 'Random Side',
event: 'randomSide',
},
{
icon: $globals.icons.createAlt,
text: $t('general.new'),
event: 'create',
},
]"
@create="openDialog(plan.date)"
@randomBreakfast="randomMeal(plan.date, 'breakfast')"
@randomLunch="randomMeal(plan.date, 'lunch')"
@randomDinner="randomMeal(plan.date, 'dinner')"
@randomSide="randomMeal(plan.date, 'side')"
/>
</div>
</template>
<template v-else-if="plan.meals">
<RecipeCard
@ -211,6 +241,7 @@ import { useRecipes, allRecipes } from "~/composables/recipes";
import RecipeCardImage from "~/components/Domain/Recipe/RecipeCardImage.vue";
import RecipeCard from "~/components/Domain/Recipe/RecipeCard.vue";
import { PlanEntryType } from "~/types/api-types/meal-plan";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: {
@ -234,6 +265,8 @@ export default defineComponent({
};
});
const api = useUserApi();
const { mealplans, actions, loading } = useMealplans(weekRange);
useRecipes(true, true);
@ -329,19 +362,15 @@ export default defineComponent({
newMeal.recipeId = undefined;
}
async function randomMeal(date: Date) {
// TODO: Refactor to use API call to get random recipe
const randomRecipe = allRecipes.value?.[Math.floor(Math.random() * allRecipes.value.length)];
if (!randomRecipe) return;
async function randomMeal(date: Date, type: PlanEntryType) {
const { data } = await api.mealplans.setRandom({
date: format(date, "yyyy-MM-dd"),
entryType: type,
});
newMeal.date = format(date, "yyyy-MM-dd");
newMeal.recipeId = randomRecipe.id;
console.log(newMeal.recipeId, randomRecipe.id);
await actions.createOne({ ...newMeal });
resetDialog();
if (data) {
actions.refreshAll();
}
}
return {

View file

@ -0,0 +1,179 @@
<template>
<v-container class="md-container">
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
</template>
<template #title> Meal Plan Rules </template>
Here you can set rules for auto selecting recipes for you 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.
</BasePageTitle>
<v-card>
<v-card-title class="headline"> New Rule </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
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.
<GroupMealPlanRuleForm
class="mt-2"
:day.sync="createData.day"
:entry-type.sync="createData.entryType"
:categories.sync="createData.categories"
:tags.sync="createData.tags"
/>
</v-card-text>
<v-card-actions class="justify-end">
<BaseButton create @click="createRule" />
</v-card-actions>
</v-card>
<section>
<BaseCardSectionTitle class="mt-10" title="Recipe Rules" />
<div>
<div v-for="(rule, idx) in allRules" :key="rule.id">
<v-card class="my-2">
<v-card-title>
{{ rule.day }} - {{ rule.entryType }}
<span class="ml-auto">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.edit,
text: $t('general.edit'),
event: 'edit',
},
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]"
@delete="deleteRule(rule.id)"
@edit="toggleEditState(rule.id)"
/>
</span>
</v-card-title>
<v-card-text>
<template v-if="!editState[rule.id]">
<div>Categories: {{ rule.categories.map((c) => c.name).join(", ") }}</div>
<div>Tags: {{ rule.tags.map((t) => t.name).join(", ") }}</div>
</template>
<template v-else>
<GroupMealPlanRuleForm
:day.sync="allRules[idx].day"
:entry-type.sync="allRules[idx].entryType"
:categories.sync="allRules[idx].categories"
:tags.sync="allRules[idx].tags"
/>
<div class="d-flex justify-end">
<BaseButton update @click="updateRule(rule)" />
</div>
</template>
</v-card-text>
</v-card>
</div>
</div>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, useAsync } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { PlanRulesCreate, PlanRulesOut } from "~/types/api-types/meal-plan";
import GroupMealPlanRuleForm from "~/components/Domain/Group/GroupMealPlanRuleForm.vue";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({
components: {
GroupMealPlanRuleForm,
},
props: {
value: {
type: Boolean,
default: false,
},
},
setup() {
const api = useUserApi();
// ======================================================
// Manage All
const editState = ref<{ [key: string]: boolean }>({});
const allRules = ref<PlanRulesOut[]>([]);
function toggleEditState(id: string) {
editState.value[id] = !editState.value[id];
editState.value = { ...editState.value };
}
async function refreshAll() {
const { data } = await api.mealplanRules.getAll();
if (data) {
allRules.value = data;
}
}
useAsync(async () => {
await refreshAll();
}, useAsyncKey());
// ======================================================
// Creating Rules
const createData = ref<PlanRulesCreate>({
entryType: "unset",
day: "unset",
categories: [],
tags: [],
});
async function createRule() {
const { data } = await api.mealplanRules.createOne(createData.value);
if (data) {
refreshAll();
createData.value = {
entryType: "unset",
day: "unset",
categories: [],
tags: [],
};
}
}
async function deleteRule(ruleId: string) {
const { data } = await api.mealplanRules.deleteOne(ruleId);
if (data) {
refreshAll();
}
}
async function updateRule(rule: PlanRulesOut) {
const { data } = await api.mealplanRules.updateOne(rule.id, rule);
if (data) {
refreshAll();
toggleEditState(rule.id);
}
}
return {
allRules,
createData,
createRule,
deleteRule,
editState,
updateRule,
toggleEditState,
};
},
head: {
title: "Meal Plan Settings",
},
});
</script>

View file

@ -7,16 +7,6 @@
<template #title> Group Settings </template>
These items are shared within your group. Editing one of them will change it for the whole group!
</BasePageTitle>
<section>
<BaseCardSectionTitle title="Mealplan Categories">
Set the categories below for the ones that you want to be included in your mealplan random generation.
</BaseCardSectionTitle>
<DomainRecipeCategoryTagSelector v-if="categories" v-model="categories" />
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton save @click="actions.updateAll()" />
</v-card-actions>
</section>
<section v-if="group">
<BaseCardSectionTitle class="mt-10" title="Group Preferences"></BaseCardSectionTitle>
@ -82,14 +72,13 @@
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { useGroupCategories, useGroupSelf } from "~/composables/use-groups";
import { useGroupSelf } from "~/composables/use-groups";
export default defineComponent({
setup() {
const { categories, actions } = useGroupCategories();
const { group, actions: groupActions } = useGroupSelf();
const { i18n } = useContext();
@ -126,8 +115,6 @@ export default defineComponent({
];
return {
categories,
actions,
group,
groupActions,
allDays,
@ -140,5 +127,3 @@ export default defineComponent({
},
});
</script>

View file

@ -89,7 +89,7 @@ export interface Recipe {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -107,14 +107,20 @@ export interface Recipe {
};
comments?: RecipeCommentOut[];
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeIngredient {
@ -143,8 +149,8 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -156,7 +162,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface RecipeStep {
id?: string;

View file

@ -45,7 +45,7 @@ export interface Recipe {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -63,14 +63,20 @@ export interface Recipe {
};
comments?: RecipeCommentOut[];
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeIngredient {
@ -99,8 +105,8 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -112,7 +118,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface RecipeStep {
id?: string;

View file

@ -180,8 +180,8 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -234,7 +234,7 @@ export interface RecipeSummary {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -243,14 +243,20 @@ export interface RecipeSummary {
dateAdded?: string;
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeIngredient {
@ -272,7 +278,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface SaveInviteToken {
usesLeft: number;

View file

@ -5,8 +5,19 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack";
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset";
export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "unset";
export interface Category {
id: number;
name: string;
slug: string;
}
export interface CreatRandomEntry {
date: string;
entryType?: PlanEntryType & string;
}
export interface CreatePlanEntry {
date: string;
entryType?: PlanEntryType & string;
@ -48,6 +59,32 @@ export interface MealPlanOut {
id: number;
shoppingList?: number;
}
export interface PlanRulesCreate {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: Category[];
tags?: Tag[];
}
export interface Tag {
id: number;
name: string;
slug: string;
}
export interface PlanRulesOut {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: Category[];
tags?: Tag[];
groupId: string;
id: string;
}
export interface PlanRulesSave {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: Category[];
tags?: Tag[];
groupId: string;
}
export interface ReadPlanEntry {
date: string;
entryType?: PlanEntryType & string;
@ -71,7 +108,7 @@ export interface RecipeSummary {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -80,14 +117,20 @@ export interface RecipeSummary {
dateAdded?: string;
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeIngredient {
@ -116,8 +159,8 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -129,7 +172,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface SavePlanEntry {
date: string;

View file

@ -42,13 +42,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
export interface CreateIngredientUnit {
name: string;
@ -65,10 +58,12 @@ export interface CreateRecipeBulk {
tags?: RecipeTag[];
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
@ -100,8 +95,14 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
groupId: string;
id: string;
}
/**
* A list of ingredient references.
@ -160,7 +161,7 @@ export interface Recipe {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -179,9 +180,9 @@ export interface Recipe {
comments?: RecipeCommentOut[];
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeStep {
@ -281,7 +282,7 @@ export interface RecipeSummary {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;

View file

@ -121,7 +121,7 @@ export interface RecipeSummary {
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeTag[];
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
@ -130,14 +130,20 @@ export interface RecipeSummary {
dateAdded?: string;
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
name: string;
slug: string;
id?: number;
onHand?: boolean;
}
export interface RecipeIngredient {
@ -166,8 +172,8 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
id: number;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -179,7 +185,6 @@ export interface CreateIngredientFood {
name: string;
description?: string;
labelId?: string;
label?: MultiPurposeLabelSummary;
}
export interface ResetPassword {
token: string;

View file

@ -3,6 +3,7 @@ export interface Icon {
primary: string;
// General
bolwMixOutline: string;
foods: string;
units: string;
alert: string;

View file

@ -104,6 +104,7 @@ import {
mdiRefresh,
mdiArrowRightBold,
mdiChevronRight,
mdiBowlMixOutline,
} from "@mdi/js";
export const icons = {
@ -111,6 +112,7 @@ export const icons = {
primary: mdiSilverwareVariant,
// General
bolwMixOutline: mdiBowlMixOutline,
foods: mdiFoodApple,
units: mdiBeakerOutline,
alert: mdiAlert,

View file

@ -15,8 +15,6 @@ from .cookbook import CookBook
from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel
settings = get_app_settings()
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
@ -75,6 +73,8 @@ class Group(SqlAlchemyBase, BaseMixins):
@staticmethod
def get_ref(session: Session, name: str):
settings = get_app_settings()
item = session.query(Group).filter(Group.name == name).one_or_none()
if item is None:
item = session.query(Group).filter(Group.name == settings.DEFAULT_GROUP).one()

View file

@ -1,8 +1,28 @@
from sqlalchemy import Column, Date, ForeignKey, String, orm
from sqlalchemy.sql.sqltypes import Integer
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init
from ..recipe.category import Category, plan_rules_to_categories
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules"
id = Column(GUID, primary_key=True, default=GUID.generate)
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
day = Column(String, nullable=False, default="unset") # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type = Column(String, nullable=False, default="") # "breakfast", "lunch", "dinner", "side"
categories = orm.relationship(Category, secondary=plan_rules_to_categories, uselist=True)
tags = orm.relationship(Tag, secondary=plan_rules_to_tags, uselist=True)
@auto_init()
def __init__(self, **_) -> None:
pass
class GroupMealPlan(SqlAlchemyBase, BaseMixins):

View file

@ -18,6 +18,13 @@ group2categories = sa.Table(
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
)
plan_rules_to_categories = sa.Table(
"plan_rules_to_categories",
SqlAlchemyBase.metadata,
sa.Column("group_plan_rule_id", GUID, sa.ForeignKey("group_meal_plan_rules.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
)
recipes2categories = sa.Table(
"recipes2categories",
SqlAlchemyBase.metadata,

View file

@ -72,7 +72,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
recipe_category = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship(

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm import validates
from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import guid
logger = root_logger.get_logger()
@ -15,6 +16,13 @@ recipes2tags = sa.Table(
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
)
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")),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
)
class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags"
@ -42,8 +50,7 @@ class Tag(SqlAlchemyBase, BaseMixins):
slug = slugify(match_value)
result = session.query(Tag).filter(Tag.slug == slug).one_or_none()
if result:
if result := session.query(Tag).filter(Tag.slug == slug).one_or_none():
logger.debug("Category exists, associating recipe")
return result
else:

View file

@ -8,6 +8,7 @@ from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.events import GroupEventNotifierModel
from mealie.db.models.group.exports import GroupDataExportsModel
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.mealplan import GroupMealPlanRules
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import (
ShoppingList,
@ -28,6 +29,7 @@ from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.sign_up import SignUp
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.group.group_events import GroupEventNotifierOut
@ -43,6 +45,7 @@ from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.labels import MultiPurposeLabelOut
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.meal_plan.plan_rules import PlanRulesOut
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
@ -58,10 +61,10 @@ from .repository_recipes import RepositoryRecipes
from .repository_shopping_list import RepositoryShoppingList
from .repository_users import RepositoryUsers
pk_id = "id"
pk_slug = "slug"
pk_token = "token"
pk_group_id = "group_id"
PK_ID = "id"
PK_SLUG = "slug"
PK_TOKEN = "token"
PK_GROUP_ID = "group_id"
class RepositoryCategories(RepositoryGeneric):
@ -86,134 +89,147 @@ class AllRepositories:
self.session = session
# ================================================================
# Recipe Items
# Recipe
@cached_property
def recipes(self) -> RepositoryRecipes:
return RepositoryRecipes(self.session, pk_slug, RecipeModel, Recipe)
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
@cached_property
def ingredient_foods(self) -> RepositoryGeneric[IngredientFood, IngredientFoodModel]:
return RepositoryGeneric(self.session, pk_id, IngredientFoodModel, IngredientFood)
return RepositoryGeneric(self.session, PK_ID, IngredientFoodModel, IngredientFood)
@cached_property
def ingredient_units(self) -> RepositoryGeneric[IngredientUnit, IngredientUnitModel]:
return RepositoryGeneric(self.session, pk_id, IngredientUnitModel, IngredientUnit)
return RepositoryGeneric(self.session, PK_ID, IngredientUnitModel, IngredientUnit)
@cached_property
def tools(self) -> RepositoryGeneric[RecipeTool, Tool]:
return RepositoryGeneric(self.session, pk_id, Tool, RecipeTool)
return RepositoryGeneric(self.session, PK_ID, Tool, RecipeTool)
@cached_property
def comments(self) -> RepositoryGeneric[RecipeCommentOut, RecipeComment]:
return RepositoryGeneric(self.session, pk_id, RecipeComment, RecipeCommentOut)
return RepositoryGeneric(self.session, PK_ID, RecipeComment, RecipeCommentOut)
@cached_property
def categories(self) -> RepositoryCategories:
# TODO: Fix Typing for Category Repository
return RepositoryCategories(self.session, pk_slug, Category, RecipeCategoryResponse)
return RepositoryCategories(self.session, PK_SLUG, Category, RecipeCategoryResponse)
@cached_property
def tags(self) -> RepositoryTags:
return RepositoryTags(self.session, pk_slug, Tag, RecipeTagResponse)
return RepositoryTags(self.session, PK_SLUG, Tag, RecipeTagResponse)
@cached_property
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:
return RepositoryGeneric(self.session, pk_id, RecipeShareTokenModel, RecipeShareToken)
return RepositoryGeneric(self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken)
# ================================================================
# Site Items
# Site
@cached_property
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut)
return RepositoryGeneric(self.session, PK_ID, SignUp, SignUpOut)
@cached_property
def events(self) -> RepositoryGeneric[EventSchema, Event]:
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
return RepositoryGeneric(self.session, PK_ID, Event, EventSchema)
# ================================================================
# User Items
# User
@cached_property
def users(self) -> RepositoryUsers:
return RepositoryUsers(self.session, pk_id, User, PrivateUser)
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
@cached_property
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
return RepositoryGeneric(self.session, pk_id, LongLiveToken, LongLiveTokenInDB)
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
@cached_property
def tokens_pw_reset(self) -> RepositoryGeneric[PrivatePasswordResetToken, PasswordResetModel]:
return RepositoryGeneric(self.session, pk_token, PasswordResetModel, PrivatePasswordResetToken)
return RepositoryGeneric(self.session, PK_TOKEN, PasswordResetModel, PrivatePasswordResetToken)
# ================================================================
# Group Items
# Group
@cached_property
def server_tasks(self) -> RepositoryGeneric[ServerTask, ServerTaskModel]:
return RepositoryGeneric(self.session, pk_id, ServerTaskModel, ServerTask)
return RepositoryGeneric(self.session, PK_ID, ServerTaskModel, ServerTask)
@cached_property
def groups(self) -> RepositoryGroup:
return RepositoryGroup(self.session, pk_id, Group, GroupInDB)
return RepositoryGroup(self.session, PK_ID, Group, GroupInDB)
@cached_property
def group_invite_tokens(self) -> RepositoryGeneric[ReadInviteToken, GroupInviteToken]:
return RepositoryGeneric(self.session, pk_token, GroupInviteToken, ReadInviteToken)
return RepositoryGeneric(self.session, PK_TOKEN, GroupInviteToken, ReadInviteToken)
@cached_property
def group_preferences(self) -> RepositoryGeneric[ReadGroupPreferences, GroupPreferencesModel]:
return RepositoryGeneric(self.session, pk_group_id, GroupPreferencesModel, ReadGroupPreferences)
return RepositoryGeneric(self.session, PK_GROUP_ID, GroupPreferencesModel, ReadGroupPreferences)
@cached_property
def group_exports(self) -> RepositoryGeneric[GroupDataExport, GroupDataExportsModel]:
return RepositoryGeneric(self.session, pk_id, GroupDataExportsModel, GroupDataExport)
@cached_property
def meals(self) -> RepositoryMeals:
return RepositoryMeals(self.session, pk_id, GroupMealPlan, ReadPlanEntry)
@cached_property
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return RepositoryGeneric(self.session, pk_id, CookBook, ReadCookBook)
@cached_property
def webhooks(self) -> RepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
return RepositoryGeneric(self.session, pk_id, GroupWebhooksModel, ReadWebhook)
return RepositoryGeneric(self.session, PK_ID, GroupDataExportsModel, GroupDataExport)
@cached_property
def group_reports(self) -> RepositoryGeneric[ReportOut, ReportModel]:
return RepositoryGeneric(self.session, pk_id, ReportModel, ReportOut)
return RepositoryGeneric(self.session, PK_ID, ReportModel, ReportOut)
@cached_property
def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]:
return RepositoryGeneric(self.session, pk_id, ReportEntryModel, ReportEntryOut)
return RepositoryGeneric(self.session, PK_ID, ReportEntryModel, ReportEntryOut)
@cached_property
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return RepositoryGeneric(self.session, PK_ID, CookBook, ReadCookBook)
# ================================================================
# Meal Plan
@cached_property
def meals(self) -> RepositoryMeals:
return RepositoryMeals(self.session, PK_ID, GroupMealPlan, ReadPlanEntry)
@cached_property
def group_meal_plan_rules(self) -> RepositoryMealPlanRules:
return RepositoryMealPlanRules(self.session, PK_ID, GroupMealPlanRules, PlanRulesOut)
@cached_property
def webhooks(self) -> RepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
return RepositoryGeneric(self.session, PK_ID, GroupWebhooksModel, ReadWebhook)
# ================================================================
# Shopping List
@cached_property
def group_shopping_lists(self) -> RepositoryShoppingList:
return RepositoryShoppingList(self.session, pk_id, ShoppingList, ShoppingListOut)
return RepositoryShoppingList(self.session, PK_ID, ShoppingList, ShoppingListOut)
@cached_property
def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut)
return RepositoryGeneric(self.session, PK_ID, ShoppingListItem, ShoppingListItemOut)
@cached_property
def group_shopping_list_item_references(
self,
) -> RepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]:
return RepositoryGeneric(self.session, pk_id, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
return RepositoryGeneric(self.session, PK_ID, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
@cached_property
def group_shopping_list_recipe_refs(
self,
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
return RepositoryGeneric(self.session, pk_id, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
return RepositoryGeneric(self.session, PK_ID, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
@cached_property
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)
# ================================================================
# Group Events
@cached_property
def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
return RepositoryGeneric(self.session, pk_id, GroupEventNotifierModel, GroupEventNotifierOut)
return RepositoryGeneric(self.session, PK_ID, GroupEventNotifierModel, GroupEventNotifierOut)

View file

@ -0,0 +1,29 @@
from uuid import UUID
from sqlalchemy import or_
from mealie.db.models.group.mealplan import GroupMealPlanRules
from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesType
from .repository_generic import RepositoryGeneric
class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules]):
def by_group(self, group_id: UUID) -> "RepositoryMealPlanRules":
return super().by_group(group_id)
def get_rules(self, day: PlanRulesDay, entry_type: PlanRulesType) -> list[PlanRulesOut]:
qry = self.session.query(GroupMealPlanRules).filter(
or_(
GroupMealPlanRules.day.is_(day),
GroupMealPlanRules.day.is_(None),
GroupMealPlanRules.day.is_(PlanRulesDay.unset.value),
),
or_(
GroupMealPlanRules.entry_type.is_(entry_type),
GroupMealPlanRules.entry_type.is_(None),
GroupMealPlanRules.entry_type.is_(PlanRulesType.unset.value),
),
)
return [self.schema.from_orm(x) for x in qry.all()]

View file

@ -1,17 +1,25 @@
from random import randint
from typing import Any
from uuid import UUID
from sqlalchemy import and_, func
from sqlalchemy.orm import joinedload
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredient
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag
from .repository_generic import RepositoryGeneric
class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
def by_group(self, group_id: UUID) -> "RepositoryRecipes":
return super().by_group(group_id)
def get_all_public(self, limit: int = None, order_by: str = None, start=0, override_schema=None):
eff_schema = override_schema or self.schema
@ -80,3 +88,59 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.limit(limit)
.all()
)
def get_by_categories(self, categories: list[RecipeCategory]) -> list[Recipe]:
"""
get_by_categories returns all the Recipes that contain every category provided in the list
"""
ids = [x.id for x in categories]
return [
self.schema.from_orm(x)
for x in self.session.query(RecipeModel)
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
.all()
]
def get_random_by_categories_and_tags(self, categories: list[RecipeCategory], tags: list[RecipeTag]) -> Recipe:
"""
get_random_by_categories returns a single random Recipe that contains every category provided
in the list. This uses a function built in to Postgres and SQLite to get a random row limited
to 1 entry.
"""
# See Also:
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
filters = [
RecipeModel.group_id == self.group_id,
]
if categories:
cat_ids = [x.id for x in categories]
for cat_id in cat_ids:
filters.append(RecipeModel.recipe_category.any(Category.id.is_(cat_id)))
if tags:
tag_ids = [x.id for x in tags]
for tag_id in tag_ids:
filters.append(RecipeModel.tags.any(Tag.id.is_(tag_id)))
return [
self.schema.from_orm(x)
for x in self.session.query(RecipeModel)
.filter(and_(*filters))
.order_by(func.random()) # Postgres and SQLite specific
.limit(1)
]
def get_random(self, limit=1) -> list[Recipe]:
return [
self.schema.from_orm(x)
for x in self.session.query(RecipeModel)
.filter(RecipeModel.group_id == self.group_id)
.order_by(func.random()) # Postgres and SQLite specific
.limit(limit)
]

View file

@ -12,6 +12,7 @@ router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
class CategorySummary(BaseModel):
id: int
slug: str
name: str

View file

@ -8,7 +8,8 @@ from . import (
controller_invitations,
controller_labels,
controller_mealplan,
controller_meaplan_config,
controller_mealplan_config,
controller_mealplan_rules,
controller_migrations,
controller_shopping_lists,
controller_webhooks,
@ -17,9 +18,10 @@ from . import (
router = APIRouter()
router.include_router(controller_group_self_service.router)
router.include_router(controller_mealplan_rules.router)
router.include_router(controller_mealplan_config.router)
router.include_router(controller_mealplan.router)
router.include_router(controller_cookbooks.router)
router.include_router(controller_meaplan_config.router)
router.include_router(controller_webhooks.router)
router.include_router(controller_invitations.router)
router.include_router(controller_migrations.router)

View file

@ -2,7 +2,7 @@ from datetime import date, timedelta
from functools import cached_property
from typing import Type
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.repos.repository_meals import RepositoryMeals
@ -10,6 +10,10 @@ from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
from mealie.schema.meal_plan.new_meal import CreatRandomEntry
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.response.responses import ErrorResponse
router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"])
@ -34,10 +38,54 @@ class GroupMealplanController(BaseUserController):
self.registered_exceptions,
)
@router.get("/today", tags=["Groups: Mealplans"])
@router.get("/today")
def get_todays_meals(self):
return self.repo.get_today(group_id=self.group_id)
@router.post("/random", response_model=ReadPlanEntry)
def create_random_meal(self, data: CreatRandomEntry):
"""
create_random_meal is a route that provides the randomized funcitonality for mealplaners.
It operates by following the rules setout in the Groups mealplan settings. If not settings
are set, it will default return any random meal.
Refer to the mealplan settings routes for more information on how rules can be applied
to the random meal selector.
"""
# Get relavent group rules
rules = self.repos.group_meal_plan_rules.by_group(self.group_id).get_rules(
PlanRulesDay.from_date(data.date), data.entry_type.value
)
recipe_repo = self.repos.recipes.by_group(self.group_id)
random_recipes: Recipe = []
if not rules: # If no rules are set, return any random recipe from the group
random_recipes = recipe_repo.get_random()
else: # otherwise construct a query based on the rules
tags = []
categories = []
for rule in rules:
if rule.tags:
tags.extend(rule.tags)
if rule.categories:
categories.extend(rule.categories)
if tags or categories:
random_recipes = self.repos.recipes.by_group(self.group_id).get_random_by_categories_and_tags(
categories, tags
)
else:
random_recipes = recipe_repo.get_random()
try:
recipe = random_recipes[0]
return self.mixins.create_one(
SavePlanEntry(date=data.date, entry_type=data.entry_type, recipe_id=recipe.id, group_id=self.group_id)
)
except IndexError:
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No recipes match your rules"))
@router.get("", response_model=list[ReadPlanEntry])
def get_all(self, start: date = None, limit: date = None):
start = start or date.today() - timedelta(days=999)

View file

@ -0,0 +1,44 @@
from functools import cached_property
from pydantic import UUID4
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema import mapper
from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesSave
router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"])
@controller(router)
class GroupMealplanConfigController(BaseUserController):
@cached_property
def repo(self):
return self.repos.group_meal_plan_rules.by_group(self.group_id)
@cached_property
def mixins(self):
return CrudMixins[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger)
@router.get("", response_model=list[PlanRulesOut])
def get_all(self):
return self.repo.get_all(override_schema=PlanRulesOut)
@router.post("", response_model=PlanRulesOut, status_code=201)
def create_one(self, data: PlanRulesCreate):
save = mapper.cast(data, PlanRulesSave, group_id=self.group.id)
return self.mixins.create_one(save)
@router.get("/{item_id}", response_model=PlanRulesOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=PlanRulesOut)
def update_one(self, item_id: UUID4, data: PlanRulesCreate):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=PlanRulesOut)
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id) # type: ignore

View file

@ -1,4 +1,5 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .meal import *
from .new_meal import *
from .plan_rules import *
from .shopping_list import *

View file

@ -13,7 +13,12 @@ class PlanEntryType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
snack = "snack"
side = "side"
class CreatRandomEntry(CamelModel):
date: date
entry_type: PlanEntryType = PlanEntryType.dinner
class CreatePlanEntry(CamelModel):

View file

@ -0,0 +1,63 @@
import datetime
from enum import Enum
from fastapi_camelcase import CamelModel
from pydantic import UUID4
class Category(CamelModel):
id: int
name: str
slug: str
class Config:
orm_mode = True
class Tag(Category):
class Config:
orm_mode = True
class PlanRulesDay(str, Enum):
monday = "monday"
tuesday = "tuesday"
wednesday = "wednesday"
thursday = "thursday"
friday = "friday"
saturday = "saturday"
sunday = "sunday"
unset = "unset"
@staticmethod
def from_date(date: datetime.date):
"""Returns the enum value for the date passed in"""
try:
return PlanRulesDay[(date.strftime("%A").lower())]
except KeyError:
return PlanRulesDay.unset
class PlanRulesType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
unset = "unset"
class PlanRulesCreate(CamelModel):
day: PlanRulesDay = PlanRulesDay.unset
entry_type: PlanRulesType = PlanRulesType.unset
categories: list[Category] = []
tags: list[Tag] = []
class PlanRulesSave(PlanRulesCreate):
group_id: UUID4
class PlanRulesOut(PlanRulesSave):
id: UUID4
class Config:
orm_mode = True

View file

@ -24,6 +24,7 @@ app_dirs = get_app_dirs()
class RecipeTag(CamelModel):
id: int = 0
name: str
slug: str
@ -78,7 +79,7 @@ class RecipeSummary(CamelModel):
perform_time: Optional[str] = None
description: Optional[str] = ""
recipe_category: Optional[list[RecipeTag]] = []
recipe_category: Optional[list[RecipeCategory]] = []
tags: Optional[list[RecipeTag]] = []
tools: list[RecipeTool] = []
rating: Optional[int]

View file

@ -15,11 +15,11 @@ class UnitFoodBase(CamelModel):
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 = None
label: MultiPurposeLabelSummary = None
class IngredientFood(CreateIngredientFood):
id: int
label: MultiPurposeLabelSummary = None
class Config:
orm_mode = True
@ -86,5 +86,4 @@ class IngredientRequest(CamelModel):
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
CreateIngredientFood.update_forward_refs()
IngredientFood.update_forward_refs()

View file

@ -0,0 +1,29 @@
import random
from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe, RecipeCategory
from mealie.services._base_service import BaseService
class MealPlanService(BaseService):
def __init__(self, group_id: UUID4, repos: AllRepositories):
self.group_id = group_id
self.repos = repos
def get_random_recipe(self, categories: list[RecipeCategory] = None) -> Recipe:
"""get_random_recipe returns a single recipe matching a specific criteria of
categories. if no categories are provided, a single recipe is returned from the
entire recipe databas.
Note that the recipe must contain ALL categories in the list provided.
Args:
categories (list[RecipeCategory], optional): [description]. Defaults to None.
Returns:
Recipe: [description]
"""
recipes = self.repos.recipes.by_group(self.group_id).get_by_categories(categories)
return random.choice(recipes)

View file

@ -23,15 +23,26 @@ class ShoppingListService:
can_merge checks if the two items can be merged together.
"""
# If no food or units are present check against the notes field.
if not all([item1.food, item1.unit, item2.food, item2.unit]):
# Check if foods are equal
foods_is_none = item1.food_id is None and item2.food_id is None
foods_not_none = not foods_is_none
foods_equal = item1.food_id == item2.food_id
# Check if units are equal
units_is_none = item1.unit_id is None and item2.unit_id is None
units_not_none = not units_is_none
units_equal = item1.unit_id == item2.unit_id
# Check if Notes are equal
if foods_is_none and units_is_none:
return item1.note == item2.note
# If the items have the same food and unit they can be merged.
if item1.unit == item2.unit and item1.food == item2.food:
return True
if foods_not_none and units_not_none:
return foods_equal and units_equal
if foods_not_none:
return foods_equal
# Otherwise Assume They Can't Be Merged
return False
def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]:

View file

@ -0,0 +1,127 @@
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from pydantic import UUID4
from mealie.repos.all_repositories import AllRepositories
from mealie.schema.meal_plan.plan_rules import PlanRulesOut, PlanRulesSave
from mealie.schema.recipe.recipe import RecipeCategory
from tests import utils
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/groups/mealplans/rules"
@staticmethod
def item(item_id: UUID4) -> str:
return f"{Routes.base}/{item_id}"
@pytest.fixture(scope="function")
def category(database: AllRepositories):
slug = utils.random_string(length=10)
model = database.categories.create(RecipeCategory(slug=slug, name=slug))
yield model
try:
database.categories.delete(model.slug)
except Exception:
pass
@pytest.fixture(scope="function")
def plan_rule(database: AllRepositories, unique_user: TestUser):
schema = PlanRulesSave(
group_id=unique_user.group_id,
day="monday",
entry_type="breakfast",
categories=[],
)
model = database.group_meal_plan_rules.create(schema)
yield model
try:
database.group_meal_plan_rules.delete(model.id)
except Exception:
pass
def test_group_mealplan_rules_create(
api_client: TestClient, unique_user: TestUser, category: RecipeCategory, database: AllRepositories
):
payload = {
"groupId": unique_user.group_id,
"day": "monday",
"entryType": "breakfast",
"categories": [category.dict()],
}
response = api_client.post(Routes.base, json=payload, headers=unique_user.token)
assert response.status_code == 201
# Validate the response data
response_data = response.json()
assert response_data["groupId"] == str(unique_user.group_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
# Validate database entry
rule = database.group_meal_plan_rules.get_one(UUID(response_data["id"]))
assert str(rule.group_id) == unique_user.group_id
assert rule.day == "monday"
assert rule.entry_type == "breakfast"
assert len(rule.categories) == 1
assert rule.categories[0].slug == category.slug
# Cleanup
database.group_meal_plan_rules.delete(rule.id)
def test_group_mealplan_rules_read(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
response = api_client.get(Routes.item(plan_rule.id), headers=unique_user.token)
assert response.status_code == 200
# Validate the response data
response_data = response.json()
assert response_data["id"] == str(plan_rule.id)
assert response_data["groupId"] == str(unique_user.group_id)
assert response_data["day"] == "monday"
assert response_data["entryType"] == "breakfast"
assert len(response_data["categories"]) == 0
def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
payload = {
"groupId": unique_user.group_id,
"day": "tuesday",
"entryType": "lunch",
}
response = api_client.put(Routes.item(plan_rule.id), json=payload, headers=unique_user.token)
assert response.status_code == 200
# Validate the response data
response_data = response.json()
assert response_data["id"] == str(plan_rule.id)
assert response_data["groupId"] == str(unique_user.group_id)
assert response_data["day"] == "tuesday"
assert response_data["entryType"] == "lunch"
assert len(response_data["categories"]) == 0
def test_group_mealplan_rules_delete(
api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut, database: AllRepositories
):
response = api_client.delete(Routes.item(plan_rule.id), headers=unique_user.token)
assert response.status_code == 200
# Validate no entry in database
assert database.group_meal_plan_rules.get_one(plan_rule.id) is None

View file

@ -0,0 +1,115 @@
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.recipe.recipe import Recipe, RecipeCategory
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_user: TestUser):
# Bootstrap the database with categories
slug1, slug2, slug3 = [random_string(10) for _ in range(3)]
categories = [
RecipeCategory(name=slug1, slug=slug1),
RecipeCategory(name=slug2, slug=slug2),
RecipeCategory(name=slug3, slug=slug3),
]
created_categories = []
for category in categories:
model = database.categories.create(category)
created_categories.append(model)
# Bootstrap the database with recipes
recipes = []
for idx in range(15):
if idx % 3 == 0:
category = created_categories[0]
elif idx % 3 == 1:
category = created_categories[1]
else:
category = created_categories[2]
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
recipe_category=[category],
),
)
created_recipes = []
for recipe in recipes:
models = database.recipes.create(recipe)
created_recipes.append(models)
# Get all recipes by category
for category in created_categories:
repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id)
recipes = repo.get_by_categories([category])
assert len(recipes) == 5
for recipe in recipes:
found_cat = recipe.recipe_category[0]
assert found_cat.name == category.name
assert found_cat.slug == category.slug
assert found_cat.id == category.id
def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_user: TestUser):
slug1, slug2 = [random_string(10) for _ in range(2)]
categories = [
RecipeCategory(name=slug1, slug=slug1),
RecipeCategory(name=slug2, slug=slug2),
]
created_categories = []
known_category_ids = []
for category in categories:
model = database.categories.create(category)
created_categories.append(model)
known_category_ids.append(model.id)
# Bootstrap the database with recipes
recipes = []
for _ in range(10):
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
recipe_category=created_categories,
),
)
# Insert Non-Category Recipes
recipes.append(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
)
for recipe in recipes:
database.recipes.create(recipe)
# Get all recipes by both categories
repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id)
by_category = repo.get_by_categories(created_categories)
assert len(by_category) == 10
for recipe in by_category:
for category in recipe.recipe_category:
assert category.id in known_category_ids

View file

@ -0,0 +1,20 @@
from datetime import datetime
import pytest
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
test_cases = [
(datetime(2022, 2, 7), PlanRulesDay.monday),
(datetime(2022, 2, 8), PlanRulesDay.tuesday),
(datetime(2022, 2, 9), PlanRulesDay.wednesday),
(datetime(2022, 2, 10), PlanRulesDay.thursday),
(datetime(2022, 2, 11), PlanRulesDay.friday),
(datetime(2022, 2, 12), PlanRulesDay.saturday),
(datetime(2022, 2, 13), PlanRulesDay.sunday),
]
@pytest.mark.parametrize("date, expected", test_cases)
def test_date_obj_to_enum(date: datetime, expected: PlanRulesDay):
assert PlanRulesDay.from_date(date) == expected