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:
parent
40d1f586cd
commit
d1024e272d
43 changed files with 1153 additions and 175 deletions
|
@ -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
|
||||
|
||||
|
|
14
frontend/api/class-interfaces/group-mealplan-rules.ts
Normal file
14
frontend/api/class-interfaces/group-mealplan-rules.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
112
frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
Normal file
112
frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
Normal 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>
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 {
|
179
frontend/pages/group/mealplan/settings.vue
Normal file
179
frontend/pages/group/mealplan/settings.vue
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,6 +3,7 @@ export interface Icon {
|
|||
primary: string;
|
||||
|
||||
// General
|
||||
bolwMixOutline: string;
|
||||
foods: string;
|
||||
units: string;
|
||||
alert: string;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
29
mealie/repos/repository_meal_plan_rules.py
Normal file
29
mealie/repos/repository_meal_plan_rules.py
Normal 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()]
|
|
@ -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)
|
||||
]
|
||||
|
|
|
@ -12,6 +12,7 @@ router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
|
|||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
44
mealie/routes/groups/controller_mealplan_rules.py
Normal file
44
mealie/routes/groups/controller_mealplan_rules.py
Normal 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
|
|
@ -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 *
|
||||
|
|
|
@ -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):
|
||||
|
|
63
mealie/schema/meal_plan/plan_rules.py
Normal file
63
mealie/schema/meal_plan/plan_rules.py
Normal 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
|
|
@ -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]
|
||||
|
|
|
@ -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()
|
||||
|
|
29
mealie/services/group_services/service_group_meals.py
Normal file
29
mealie/services/group_services/service_group_meals.py
Normal 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)
|
|
@ -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]:
|
||||
|
|
|
@ -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
|
115
tests/unit_tests/repository_tests/test_recipe_repository.py
Normal file
115
tests/unit_tests/repository_tests/test_recipe_repository.py
Normal 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
|
20
tests/unit_tests/schema_tests/test_meal_plan.py
Normal file
20
tests/unit_tests/schema_tests/test_meal_plan.py
Normal 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
|
Loading…
Reference in a new issue