feat(backend): rewrite mealplanner with simple api (#683)

* feat(backend):  new meal-planner feature

* feat(frontend):  new meal plan feature

* refactor(backend): ♻️ refactor base services classes and add mixins for crud

* feat(frontend):  add UI/API for mealplanner

* feat(backend):  add get_today and get_slice options for mealplanner

* test(backend):  add and update group mealplanner tests

* fix(backend): 🐛 Fix recipe_id column type for PG

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-12 11:05:09 -08:00 committed by GitHub
parent bdaf758712
commit b542583303
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 869 additions and 255 deletions

View File

@ -12,15 +12,14 @@ export interface CrudAPIInterface {
export interface CrudAPIMethodsInterface {
// CRUD Methods
getAll(): any
createOne(): any
getOne(): any
updateOne(): any
patchOne(): any
deleteOne(): any
getAll(): any;
createOne(): any;
getOne(): any;
updateOne(): any;
patchOne(): any;
deleteOne(): any;
}
export abstract class BaseAPI {
requests: ApiRequestInstance;
@ -33,9 +32,9 @@ export abstract class BaseCRUDAPI<T, U> extends BaseAPI implements CrudAPIInterf
abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string;
async getAll(start = 0, limit = 9999) {
async getAll(start = 0, limit = 9999, params = {}) {
return await this.requests.get<T[]>(this.baseRoute, {
params: { start, limit },
params: { start, limit, ...params },
});
}

View File

@ -0,0 +1,32 @@
import { BaseCRUDAPI } from "./_base";
const prefix = "/api";
const routes = {
mealplan: `${prefix}/groups/mealplans`,
mealplanId: (id: string | number) => `${prefix}/groups/mealplans/${id}`,
};
type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack";
export interface CreateMealPlan {
date: string;
entryType: PlanEntryType;
title: string;
text: string;
recipeId?: number;
}
export interface UpdateMealPlan extends CreateMealPlan {
id: number;
groupId: number;
}
export interface MealPlan extends UpdateMealPlan {
recipe: any;
}
export class MealPlanAPI extends BaseCRUDAPI<MealPlan, CreateMealPlan> {
baseRoute = routes.mealplan;
itemRoute = routes.mealplanId;
}

View File

@ -10,10 +10,11 @@ import { UtilsAPI } from "./class-interfaces/utils";
import { NotificationsAPI } from "./class-interfaces/event-notifications";
import { FoodAPI } from "./class-interfaces/recipe-foods";
import { UnitAPI } from "./class-interfaces/recipe-units";
import { CookbookAPI } from "./class-interfaces/cookbooks";
import { CookbookAPI } from "./class-interfaces/group-cookbooks";
import { WebhooksAPI } from "./class-interfaces/group-webhooks";
import { AdminAboutAPI } from "./class-interfaces/admin-about";
import { RegisterAPI } from "./class-interfaces/user-registration";
import { MealPlanAPI } from "./class-interfaces/group-mealplan";
import { ApiRequestInstance } from "~/types/api";
class AdminAPI {
@ -48,6 +49,7 @@ class Api {
public cookbooks: CookbookAPI;
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
public mealplans: MealPlanAPI;
// Utils
public upload: UploadFile;
@ -70,6 +72,7 @@ class Api {
this.cookbooks = new CookbookAPI(requests);
this.groupWebhooks = new WebhooksAPI(requests);
this.register = new RegisterAPI(requests);
this.mealplans = new MealPlanAPI(requests);
// Admin
this.events = new EventsAPI(requests);

View File

@ -30,6 +30,7 @@
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
<RecipeContextMenu :slug="slug" :name="name" />
</v-card-actions>
<slot></slot>
</v-card>
</v-hover>
</v-lazy>
@ -58,11 +59,13 @@ export default {
},
rating: {
type: Number,
required: false,
default: 0,
},
image: {
type: String,
default: null,
required: false,
default: "abc123",
},
route: {
type: Boolean,

View File

@ -9,36 +9,41 @@
@click="$emit('selected')"
>
<v-list-item three-line>
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<v-img
v-if="!fallBackImage"
:src="getImage(slug)"
@load="fallBackImage = false"
@error="fallBackImage = true"
></v-img>
<v-icon v-else color="primary" class="icon-position" size="100">
{{ $globals.icons.primary }}
</v-icon>
</v-list-item-avatar>
<slot name="avatar">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<v-img
v-if="!fallBackImage"
:src="getImage(slug)"
@load="fallBackImage = false"
@error="fallBackImage = true"
></v-img>
<v-icon v-else color="primary" class="icon-position" size="100">
{{ $globals.icons.primary }}
</v-icon>
</v-list-item-avatar>
</slot>
<v-list-item-content>
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<div class="d-flex justify-center align-center">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
<v-spacer></v-spacer>
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
<slot name="actions">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
<v-spacer></v-spacer>
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
</slot>
</div>
</v-list-item-content>
</v-list-item>
<slot />
</v-card>
</v-expand-transition>
</v-lazy>

View File

@ -35,7 +35,7 @@
</template>
<script>
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
const CREATED_ITEM_EVENT = "created-item";
export default defineComponent({

View File

@ -22,7 +22,7 @@
</template>
<script lang="ts">
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
type SelectionValue = "include" | "exclude" | "any";

View File

@ -1,7 +1,7 @@
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { CookBook } from "~/api/class-interfaces/cookbooks";
import { CookBook } from "~/api/class-interfaces/group-cookbooks";
let cookbookStore: Ref<CookBook[] | null> | null = null;

View File

@ -0,0 +1,80 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { addDays, subDays, format } from "date-fns";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { CreateMealPlan, UpdateMealPlan } from "~/api/class-interfaces/group-mealplan";
export const useMealplans = function () {
const api = useApiSingleton();
const loading = ref(false);
const validForm = ref(true);
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const query = {
start: format(subDays(new Date(), 30), "yyyy-MM-dd"),
limit: format(addDays(new Date(), 30), "yyyy-MM-dd"),
};
// @ts-ignore
const { data } = await api.mealplans.getAll(query.start, query.limit);
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const query = {
start: format(subDays(new Date(), 30), "yyyy-MM-dd"),
limit: format(addDays(new Date(), 30), "yyyy-MM-dd"),
};
// @ts-ignore
const { data } = await api.mealplans.getAll(query.start, query.limit);
if (data) {
mealplans.value = data;
}
loading.value = false;
},
async createOne(payload: CreateMealPlan) {
loading.value = true;
const { data } = await api.mealplans.createOne(payload);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async updateOne(updateData: UpdateMealPlan) {
if (!updateData.id) {
return;
}
loading.value = true;
// @ts-ignore
const { data } = await api.mealplans.updateOne(updateData.id, updateData);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.mealplans.deleteOne(id);
if (data) {
this.refreshAll();
}
},
};
const mealplans = actions.getAll();
return { mealplans, actions, validForm };
};

View File

@ -24,6 +24,7 @@
"@vue/composition-api": "^1.0.5",
"@vueuse/core": "^5.2.0",
"core-js": "^3.15.1",
"date-fns": "^2.23.0",
"fuse.js": "^6.4.6",
"nuxt": "^2.15.7",
"vuedraggable": "^2.24.3",
@ -51,4 +52,4 @@
"resolutions": {
"vite": "2.3.8"
}
}
}

View File

@ -1,16 +1,209 @@
<template>
<div></div>
</template>
<v-container>
<v-card>
<v-card-title class="headline">New Recipe</v-card-title>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newMeal.date"
label="Date"
hint="MM/DD/YYYY format"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newMeal.date" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>
<v-autocomplete
v-if="!noteOnly"
v-model="newMeal.recipeId"
label="Meal Recipe"
:items="allRecipes"
item-text="name"
item-value="id"
:return-object="false"
></v-autocomplete>
<template v-else>
<v-text-field v-model="newMeal.title" label="Meal Title"> </v-text-field>
<v-textarea v-model="newMeal.text" label="Meal Note"> </v-textarea>
</template>
</v-card-text>
<v-card-actions>
<v-switch v-model="noteOnly" label="Note Only"></v-switch>
<v-spacer></v-spacer>
<BaseButton @click="actions.createOne(newMeal)" />
</v-card-actions>
</v-card>
<div class="d-flex justify-center my-2 align-center" style="gap: 10px">
<v-btn icon color="info" rounded outlined @click="backOneWeek">
<v-icon>{{ $globals.icons.back }} </v-icon>
</v-btn>
<v-btn rounded outlined readonly style="pointer-events: none">
{{ $d(weekRange.start, "short") }} - {{ $d(weekRange.end, "short") }}
</v-btn>
<v-btn icon color="info" rounded outlined @click="forwardOneWeek">
<v-icon>{{ $globals.icons.forward }} </v-icon>
</v-btn>
</div>
<v-row class="mt-2">
<v-col v-for="(plan, index) in mealsByDate" :key="index" cols="12" sm="12" md="4" lg="3" xl="2">
<p class="h5 text-center">
{{ $d(plan.date, "short") }}
</p>
<draggable
tag="div"
:value="plan.meals"
group="meals"
:data-index="index"
:data-box="plan.date"
style="min-height: 150px"
@end="onMoveCallback"
>
<v-hover v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" open-delay="100">
<v-card class="my-2">
<v-list-item>
<v-list-item-content>
<v-list-item-title class="mb-1">
{{ mealplan.recipe ? mealplan.recipe.name : mealplan.title }}
</v-list-item-title>
<v-list-item-subtitle>
{{ mealplan.recipe ? mealplan.recipe.description : mealplan.text }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</v-hover>
</draggable>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"
export default defineComponent({
setup() {
return {}
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
import { isSameDay, addDays, subDays, parseISO, format } from "date-fns";
import { SortableEvent } from "sortablejs"; // eslint-disable-line
import draggable from "vuedraggable";
import { useMealplans } from "~/composables/use-group-mealplan";
import { useRecipes, allRecipes } from "~/composables/use-recipes";
export default defineComponent({
components: {
draggable,
},
setup() {
const { mealplans, actions } = useMealplans();
useRecipes(true, true);
const state = reactive({
hover: {},
pickerMenu: null,
noteOnly: false,
start: null as Date | null,
today: new Date(),
end: null as Date | null,
});
function filterMealByDate(date: Date) {
if (!mealplans.value) return;
return mealplans.value.filter((meal) => {
const mealDate = parseISO(meal.date);
return isSameDay(mealDate, date);
});
}
})
</script>
<style scoped>
</style>
function forwardOneWeek() {
if (!state.today) return;
// @ts-ignore
state.today = addDays(state.today, +5);
}
function backOneWeek() {
if (!state.today) return;
// @ts-ignore
state.today = addDays(state.today, -5);
}
function onMoveCallback(evt: SortableEvent) {
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent;
if (ogEvent && ogEvent.type !== "drop") {
// The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event");
} else {
// A Meal was moved, set the new date value and make a update request and refresh the meals
const fromMealsByIndex = evt.from.getAttribute("data-index");
const toMealsByIndex = evt.to.getAttribute("data-index");
if (fromMealsByIndex) {
// @ts-ignore
const mealData = mealsByDate.value[fromMealsByIndex].meals[evt.oldIndex as number];
// @ts-ignore
const destDate = mealsByDate.value[toMealsByIndex].date;
mealData.date = format(destDate, "yyyy-MM-dd");
actions.updateOne(mealData);
}
}
}
const mealsByDate = computed(() => {
return days.value.map((day) => {
return { date: day, meals: filterMealByDate(day as any) };
});
});
const weekRange = computed(() => {
// @ts-ignore - Not Sure Why This is not working
const end = addDays(state.today, 2);
// @ts-ignore - Not sure why the type is invalid
const start = subDays(state.today, 2);
return { start, end, today: state.today };
});
const days = computed(() => {
if (weekRange.value?.start === null) return [];
return Array.from(Array(8).keys()).map(
// @ts-ignore
(i) => new Date(weekRange.value.start.getTime() + i * 24 * 60 * 60 * 1000)
);
});
const newMeal = reactive({
date: null,
title: "",
text: "",
recipeId: null,
});
return {
mealplans,
actions,
newMeal,
allRecipes,
...toRefs(state),
mealsByDate,
onMoveCallback,
backOneWeek,
forwardOneWeek,
weekRange,
days,
};
},
});
</script>

View File

@ -1,16 +1,16 @@
<template>
<div></div>
</template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"
export default defineComponent({
setup() {
return {}
}
})
</script>
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
});
</script>
<style scoped>
</style>
</style>

View File

@ -67,7 +67,7 @@
<script>
import Fuse from "fuse.js";
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";

View File

@ -95,6 +95,7 @@ import {
mdiFoodApple,
mdiBeakerOutline,
mdiArrowLeftBoldOutline,
mdiArrowRightBoldOutline,
} from "@mdi/js";
const icons = {
@ -204,6 +205,9 @@ const icons = {
admin: mdiAccountCog,
group: mdiAccountGroup,
accountPlusOutline: mdiAccountPlusOutline,
forward: mdiArrowRightBoldOutline,
back: mdiArrowLeftBoldOutline,
};
// eslint-disable-next-line no-empty-pattern

View File

@ -16,7 +16,7 @@
"~/*": ["./*"],
"@/*": ["./*"]
},
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next", "@types/sortablejs"]
},
"exclude": ["node_modules", ".nuxt", "dist"],
"vueCompilerOptions": {

View File

@ -4218,6 +4218,11 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
date-fns@^2.23.0:
version "2.23.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.23.0.tgz#4e886c941659af0cf7b30fafdd1eaa37e88788a9"
integrity sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"

View File

@ -3,14 +3,14 @@ from logging import getLogger
from sqlalchemy.orm.session import Session
from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.data_access_layer.meal_access_model import MealDataAccessModel
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.group import Group, GroupMealPlan
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
@ -25,7 +25,8 @@ from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.meal_plan import ShoppingListOut
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import (
CommentOut,
IngredientFood,
@ -90,7 +91,7 @@ class DatabaseAccessLayer:
# Group Data
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
self.meals = MealDataAccessModel(pk_id, GroupMealPlan, ReadPlanEntry)
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
self.cookbooks = BaseAccessModel(pk_id, CookBook, ReadCookBook)

View File

@ -0,0 +1,26 @@
from datetime import date
from sqlalchemy.orm.session import Session
from mealie.db.models.group import GroupMealPlan
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from ._base_access_model import BaseAccessModel
class MealDataAccessModel(BaseAccessModel[ReadPlanEntry, GroupMealPlan]):
def get_slice(self, session: Session, start: date, end: date, group_id: int) -> list[ReadPlanEntry]:
start = start.strftime("%Y-%m-%d")
end = end.strftime("%Y-%m-%d")
qry = session.query(GroupMealPlan).filter(
GroupMealPlan.date.between(start, end),
GroupMealPlan.group_id == group_id,
)
return [self.schema.from_orm(x) for x in qry.all()]
def get_today(self, session: Session, group_id: int) -> list[ReadPlanEntry]:
today = date.today()
qry = session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
return [self.schema.from_orm(x) for x in qry.all()]

View File

@ -10,7 +10,7 @@ from mealie.db.models._model_base import SqlAlchemyBase
from mealie.schema.admin import SiteSettings
from mealie.schema.user.user import GroupBase
from mealie.services.events import create_general_event
from mealie.services.group_services.group_mixins import create_new_group
from mealie.services.group_services.group_utils import create_new_group
logger = root_logger.get_logger("init_db")

View File

@ -1,6 +1,5 @@
from .event import *
from .group import *
from .mealplan import *
from .recipe.recipe import *
from .settings import *
from .sign_up import *

View File

@ -1,6 +1,7 @@
from .cookbook import *
from .group import *
from .invite_tokens import *
from .mealplan import *
from .preferences import *
from .shopping_list import *
from .webhooks import *

View File

@ -10,6 +10,7 @@ from .._model_utils import auto_init
from ..group.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group2categories
from .cookbook import CookBook
from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel
@ -34,12 +35,14 @@ class Group(SqlAlchemyBase, BaseMixins):
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
mealplans = orm.relationship(
GroupMealPlan, back_populates="group", single_parent=True, order_by="GroupMealPlan.date"
)
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens"})
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens", "mealplans"})
def __init__(self, **_) -> None:
pass

View File

@ -0,0 +1,24 @@
from sqlalchemy import Column, Date, ForeignKey, String, orm
from sqlalchemy.sql.sqltypes import Integer
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans"
date = Column(Date, index=True, nullable=False)
entry_type = Column(String, index=True, nullable=False)
title = Column(String, index=True, nullable=False)
text = Column(String, nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), index=True)
group = orm.relationship("Group", back_populates="mealplans")
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe = orm.relationship("RecipeModel", back_populates="meal_entries", uselist=False)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -1,82 +0,0 @@
import sqlalchemy.orm as orm
from sqlalchemy import Column, Date, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.group import Group
from mealie.db.models.recipe.recipe import RecipeModel
from .group.shopping_list import ShoppingList
class Meal(SqlAlchemyBase):
__tablename__ = "meal"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("mealdays.id"))
position = Column(Integer)
name = Column(String)
slug = Column(String)
description = Column(String)
def __init__(self, slug, name="", description="", session=None) -> None:
if slug and slug != "":
recipe: RecipeModel = session.query(RecipeModel).filter(RecipeModel.slug == slug).one_or_none()
if recipe:
name = recipe.name
self.slug = recipe.slug
description = recipe.description
self.name = name
self.description = description
class MealDay(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealdays"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("mealplan.id"))
date = Column(Date)
meals: list[Meal] = orm.relationship(
Meal,
cascade="all, delete, delete-orphan",
order_by="Meal.position",
collection_class=ordering_list("position"),
)
def __init__(self, date, meals: list, session=None):
self.date = date
self.meals = [Meal(**m, session=session) for m in meals]
class MealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealplan"
# TODO: Migrate to use ID as PK
start_date = Column(Date)
end_date = Column(Date)
plan_days: list[MealDay] = orm.relationship(MealDay, cascade="all, delete, delete-orphan")
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans")
shopping_list_id = Column(Integer, ForeignKey("shopping_lists.id"))
shopping_list: ShoppingList = orm.relationship("ShoppingList", single_parent=True)
def __init__(
self,
start_date,
end_date,
plan_days,
group: str,
shopping_list: int = None,
session=None,
**_,
) -> None:
self.start_date = start_date
self.end_date = end_date
self.group = Group.get_ref(session, group)
if shopping_list:
self.shopping_list = ShoppingList.get_ref(session, shopping_list)
self.plan_days = [MealDay(**day, session=session) for day in plan_days]

View File

@ -34,7 +34,9 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
favorited_by: list = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe")
favorited_by = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
# General Recipe Properties
name = sa.Column(sa.String, nullable=False)

View File

@ -1,8 +1,7 @@
from fastapi import APIRouter
from . import app_about, app_defaults
from . import app_about
router = APIRouter(prefix="/app")
router.include_router(app_about.router, tags=["App: About"])
router.include_router(app_defaults.router, tags=["App: Defaults"])

View File

@ -1,12 +0,0 @@
from fastapi import APIRouter
from mealie.schema.recipe.recipe_settings import RecipeSettings
router = APIRouter(prefix="/defaults")
@router.get("/recipe", response_model=RecipeSettings)
async def get_recipe_settings_defaults():
""" Returns the Default Settings for Recieps as set by ENV variables """
return RecipeSettings()

View File

@ -1,16 +1,39 @@
from fastapi import APIRouter
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from mealie.services.group_services.meal_service import MealService
from . import categories, invitations, preferences, self_service
router = APIRouter()
router.include_router(self_service.user_router)
webhook_router = RouterFactory(service=WebhookService, prefix="/groups/webhooks", tags=["Groups: Webhooks"])
cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
router.include_router(self_service.user_router)
@router.get("/groups/mealplans/today", tags=["Groups: Mealplans"])
def get_todays_meals(m_service: MealService = Depends(MealService.private)):
return m_service.get_today()
meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"])
@meal_plan_router.get("")
def get_all(start: date = None, limit: date = None, m_service: MealService = Depends(MealService.private)):
start = start or date.today() - timedelta(days=999)
limit = limit or date.today() + timedelta(days=999)
return m_service.get_slice(start, limit)
router.include_router(cookbook_router)
router.include_router(meal_plan_router)
router.include_router(categories.user_router)
router.include_router(webhook_router)
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])

View File

@ -1,2 +1,3 @@
from .meal import *
from .new_meal import *
from .shopping_list import *

View File

@ -3,9 +3,6 @@ from typing import Optional
from fastapi_camelcase import CamelModel
from pydantic import validator
from pydantic.utils import GetterDict
from mealie.db.models.mealplan import MealPlan
class MealIn(CamelModel):
@ -54,18 +51,3 @@ class MealPlanOut(MealPlanIn):
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: MealPlan):
try:
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
"shopping_list": name_orm.shopping_list.id,
}
except Exception:
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
"shopping_list": None,
}

View File

@ -0,0 +1,51 @@
from datetime import date
from enum import Enum
from typing import Optional
from fastapi_camelcase import CamelModel
from pydantic import validator
from mealie.schema.recipe.recipe import RecipeSummary
class PlanEntryType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
snack = "snack"
class CreatePlanEntry(CamelModel):
date: date
entry_type: PlanEntryType = PlanEntryType.breakfast
title: str = ""
text: str = ""
recipe_id: Optional[int]
@validator("recipe_id", always=True)
@classmethod
def id_or_title(cls, value, values):
print(value, values)
if bool(value) is False and bool(values["title"]) is False:
raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided")
return value
class UpdatePlanEntry(CreatePlanEntry):
id: int
group_id: int
class SavePlanEntry(CreatePlanEntry):
group_id: int
class Config:
orm_mode = True
class ReadPlanEntry(UpdatePlanEntry):
recipe: Optional[RecipeSummary]
class Config:
orm_mode = True

View File

@ -10,7 +10,7 @@ from mealie.db.models.users import User
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary
from ..meal_plan import MealPlanOut, ShoppingListOut
from ..meal_plan import ShoppingListOut
from ..recipe import CategoryBase
@ -129,7 +129,6 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
preferences: Optional[ReadGroupPreferences] = None

View File

@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
from typing import Any, Callable, Generic, Type, TypeVar
from fastapi import BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs, get_settings
@ -113,6 +114,25 @@ class BaseHttpService(Generic[T, D], ABC):
self._group_id_cache = group.id
return self._group_id_cache
def cast(self, item: BaseModel, dest, assign_owner=True) -> T:
"""cast a pydantic model to the destination type
Args:
item (BaseModel): A pydantic model containing data
dest ([type]): A type to cast the data to
assign_owner (bool, optional): If true, will assign the user_id and group_id to the dest type. Defaults to True.
Returns:
TypeVar(dest): Returns the destionation model type
"""
data = item.dict()
if assign_owner:
data["user_id"] = self.user.id
data["group_id"] = self.group_id
return dest(**data)
def assert_existing(self, id: T) -> None:
self.populate_item(id)
self._check_item()
@ -135,30 +155,3 @@ class BaseHttpService(Generic[T, D], ABC):
raise NotImplementedError("`event_func` must be set by child class")
self.background_tasks.add_task(self.__class__.event_func, title, message, self.session)
# Generic CRUD Functions
def _create_one(self, data: Any, exception_msg="generic-create-error") -> D:
try:
self.item = self.db_access.create(self.session, data)
except Exception as ex:
logger.exception(ex)
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": exception_msg, "exception": str(ex)})
return self.item
def _update_one(self, data: Any, id: int = None) -> D:
if not self.item:
return
target_id = id or self.item.id
self.item = self.db_access.update(self.session, target_id, data)
return self.item
def _delete_one(self, id: int = None) -> D:
if not self.item:
return
target_id = id or self.item.id
self.item = self.db_access.delete(self.session, target_id)
return self.item

View File

@ -0,0 +1,49 @@
from typing import Generic, TypeVar
from fastapi import HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from mealie.core.root_logger import get_logger
from mealie.db.data_access_layer.db_access import DatabaseAccessLayer
C = TypeVar("C", bound=BaseModel)
R = TypeVar("R", bound=BaseModel)
U = TypeVar("U", bound=BaseModel)
DAL = TypeVar("DAL", bound=DatabaseAccessLayer)
logger = get_logger()
class CrudHttpMixins(Generic[C, R, U]):
item: C
session: Session
dal: DAL
def _create_one(self, data: C, exception_msg="generic-create-error") -> R:
try:
self.item = self.dal.create(self.session, data)
except Exception as ex:
logger.exception(ex)
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": exception_msg, "exception": str(ex)})
return self.item
def _update_one(self, data: U, item_id: int = None) -> R:
if not self.item:
return
target_id = item_id or self.item.id
self.item = self.dal.update(self.session, target_id, data)
return self.item
def _patch_one(self) -> None:
raise NotImplementedError
def _delete_one(self, item_id: int = None) -> None:
if not self.item:
return
target_id = item_id or self.item.id
self.item = self.dal.delete(self.session, target_id)
return self.item

View File

@ -75,6 +75,7 @@ class RouterFactory(APIRouter):
methods=["POST"],
response_model=self.schema,
summary="Create One",
status_code=201,
description=inspect.cleandoc(self.service.create_one.__doc__ or ""),
)
@ -162,7 +163,9 @@ class RouterFactory(APIRouter):
self.routes.remove(route)
def _get_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.private)) -> T: # type: ignore
service_dep = getattr(self.service, "get_all_dep", self.service.private)
def route(service: S = Depends(service_dep)) -> T: # type: ignore
return service.get_all()
return route

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
from mealie.utils.error_messages import ErrorMessages
@ -10,13 +11,18 @@ from mealie.utils.error_messages import ErrorMessages
logger = get_logger(module=__name__)
class CookbookService(UserHttpService[int, ReadCookBook]):
class CookbookService(
UserHttpService[int, ReadCookBook],
CrudHttpMixins[CreateCookBook, ReadCookBook, UpdateCookBook],
):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadCookBook
db_access = get_database().cookbooks
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dal = get_database().cookbooks
def populate_item(self, item_id: int) -> RecipeCookBook:
try:
@ -36,7 +42,7 @@ class CookbookService(UserHttpService[int, ReadCookBook]):
return items
def create_one(self, data: CreateCookBook) -> ReadCookBook:
data = SaveCookBook(group_id=self.group_id, **data.dict())
data = self.cast(data, SaveCookBook)
return self._create_one(data, ErrorMessages.cookbook_create_failure)
def update_one(self, data: UpdateCookBook, id: int = None) -> ReadCookBook:

View File

@ -46,7 +46,6 @@ class GroupSelfService(UserHttpService[int, str]):
def update_categories(self, new_categories: list[CategoryBase]):
self.item.categories = new_categories
return self.db.groups.update(self.session, self.group_id, self.item)
def update_preferences(self, new_preferences: UpdateGroupPreferences):

View File

@ -7,7 +7,7 @@ def create_new_group(session, g_base: GroupBase, g_preferences: CreateGroupPrefe
db = get_database()
created_group = db.groups.create(session, g_base)
g_preferences = g_preferences or CreateGroupPreferences(group_id=0)
g_preferences = g_preferences or CreateGroupPreferences(group_id=0) # Assign Temporary ID before group is created
g_preferences.group_id = created_group.id

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import date
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
from .._base_http_service.crud_http_mixins import CrudHttpMixins
from .._base_http_service.http_services import UserHttpService
from ..events import create_group_event
logger = get_logger(module=__name__)
class MealService(UserHttpService[int, ReadPlanEntry], CrudHttpMixins[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadPlanEntry
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dal = get_database().meals
def populate_item(self, id: int) -> ReadPlanEntry:
self.item = self.db.meals.get_one(self.session, id)
return self.item
def get_slice(self, start: date = None, end: date = None) -> list[ReadPlanEntry]:
# 2 days ago
return self.db.meals.get_slice(self.session, start, end, group_id=self.group_id)
def get_today(self) -> list[ReadPlanEntry]:
return self.db.meals.get_today(self.session, group_id=self.group_id)
def create_one(self, data: CreatePlanEntry) -> ReadPlanEntry:
data = self.cast(data, SavePlanEntry)
return self._create_one(data)
def update_one(self, data: UpdatePlanEntry, id: int = None) -> ReadPlanEntry:
target_id = id or self.item.id
return self._update_one(data, target_id)
def delete_one(self, id: int = None) -> ReadPlanEntry:
target_id = id or self.item.id
return self._delete_one(target_id)

View File

@ -1,23 +1,25 @@
from __future__ import annotations
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class WebhookService(UserHttpService[int, ReadWebhook]):
class WebhookService(UserHttpService[int, ReadWebhook], CrudHttpMixins[ReadWebhook, CreateWebhook, CreateWebhook]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadWebhook
_create_schema = CreateWebhook
_update_schema = CreateWebhook
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dal = get_database().webhooks
def populate_item(self, id: int) -> ReadWebhook:
self.item = self.db.webhooks.get_one(self.session, id)
@ -27,29 +29,11 @@ class WebhookService(UserHttpService[int, ReadWebhook]):
return self.db.webhooks.get(self.session, self.group_id, match_key="group_id", limit=9999)
def create_one(self, data: CreateWebhook) -> ReadWebhook:
try:
self.item = self.db.webhooks.create(self.session, SaveWebhook(group_id=self.group_id, **data.dict()))
except Exception as ex:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail={"message": "WEBHOOK_CREATION_ERROR", "exception": str(ex)}
)
data = self.cast(data, SaveWebhook)
return self._create_one(data)
return self.item
def update_one(self, data: CreateWebhook, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.item = self.db.webhooks.update(self.session, target_id, data)
return self.item
def update_one(self, data: CreateWebhook, item_id: int = None) -> ReadWebhook:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.db.webhooks.delete(self.session, target_id)
return self.item
return self._delete_one(id)

View File

@ -7,7 +7,7 @@ from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_user_event
from mealie.services.group_services.group_mixins import create_new_group
from mealie.services.group_services.group_utils import create_new_group
logger = get_logger(module=__name__)

View File

@ -17,7 +17,7 @@ def page_data():
def test_create_cookbook(api_client: TestClient, admin_token, page_data):
response = api_client.post(Routes.base, json=page_data, headers=admin_token)
assert response.status_code == 200
assert response.status_code == 201
def test_read_cookbook(api_client: TestClient, page_data, admin_token):

View File

@ -0,0 +1,167 @@
from datetime import date, timedelta
from fastapi.testclient import TestClient
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/groups/mealplans"
recipe = "/api/recipes"
today = "/api/groups/mealplans/today"
def all_slice(start: str, end: str):
return f"{Routes.base}?start={start}&limit={end}"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe_slug(recipe_id: int) -> str:
return f"{Routes.recipe}/{recipe_id}"
def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser):
title = random_string(length=25)
text = random_string(length=25)
new_plan = CreatePlanEntry(date=date.today(), entry_type="breakfast", title=title, text=text).dict()
new_plan["date"] = date.today().strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token)
assert response.status_code == 201
response_json = response.json()
assert response_json["title"] == title
assert response_json["text"] == text
def test_create_mealplan_with_recipe(api_client: TestClient, unique_user: TestUser):
recipe_name = random_string(length=25)
response = api_client.post(Routes.recipe, json={"name": recipe_name}, headers=unique_user.token)
assert response.status_code == 201
response = api_client.get(Routes.recipe_slug(recipe_name), headers=unique_user.token)
recipe = response.json()
recipe_id = recipe["id"]
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True)
new_plan["date"] = date.today().strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token)
response_json = response.json()
assert response.status_code == 201
assert response_json["recipe"]["slug"] == recipe_name
def test_crud_mealplan(api_client: TestClient, unique_user: TestUser):
new_plan = CreatePlanEntry(
date=date.today(),
entry_type="breakfast",
title=random_string(),
text=random_string(),
).dict()
# Create
new_plan["date"] = date.today().strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token)
response_json = response.json()
assert response.status_code == 201
plan_id = response_json["id"]
# Update
response_json["title"] = random_string()
response_json["text"] = random_string()
response = api_client.put(Routes.item(plan_id), headers=unique_user.token, json=response_json)
assert response.status_code == 200
assert response.json()["title"] == response_json["title"]
assert response.json()["text"] == response_json["text"]
# Delete
response = api_client.delete(Routes.item(plan_id), headers=unique_user.token)
assert response.status_code == 200
response = api_client.get(Routes.item(plan_id), headers=unique_user.token)
assert response.status_code == 404
def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser):
for _ in range(3):
new_plan = CreatePlanEntry(
date=date.today(),
entry_type="breakfast",
title=random_string(),
text=random_string(),
).dict()
new_plan["date"] = date.today().strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token)
assert response.status_code == 201
response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 200
assert len(response.json()) >= 3
def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser):
# Make List of 10 dates from now to +10 days
dates = [date.today() + timedelta(days=x) for x in range(10)]
# Make a list of 10 meal plans
meal_plans = [
CreatePlanEntry(date=date, entry_type="breakfast", title=random_string(), text=random_string()).dict()
for date in dates
]
# Add the meal plans to the database
for meal_plan in meal_plans:
meal_plan["date"] = meal_plan["date"].strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=meal_plan, headers=unique_user.token)
assert response.status_code == 201
# Get meal slice of meal plans from database
slices = [dates, dates[1:2], dates[2:3], dates[3:4], dates[4:5]]
for date_range in slices:
start = date_range[0].strftime("%Y-%m-%d")
end = date_range[-1].strftime("%Y-%m-%d")
response = api_client.get(Routes.all_slice(start, end), headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
for meal_plan in response_json:
assert meal_plan["date"] in [date.strftime("%Y-%m-%d") for date in date_range]
def test_get_mealplan_today(api_client: TestClient, unique_user: TestUser):
# Create Meal Plans for today
test_meal_plans = [
CreatePlanEntry(date=date.today(), entry_type="breakfast", title=random_string(), text=random_string()).dict()
for _ in range(3)
]
# Add the meal plans to the database
for meal_plan in test_meal_plans:
meal_plan["date"] = meal_plan["date"].strftime("%Y-%m-%d")
response = api_client.post(Routes.base, json=meal_plan, headers=unique_user.token)
assert response.status_code == 201
# Get meal plan for today
response = api_client.get(Routes.today, headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
for meal_plan in response_json:
assert meal_plan["date"] == date.today().strftime("%Y-%m-%d")

View File

@ -19,7 +19,7 @@ def webhook_data():
def test_create_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
assert response.status_code == 200
assert response.status_code == 201
def test_read_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):

View File

@ -0,0 +1,24 @@
from datetime import date
import pytest
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
def test_create_plan_with_title():
entry = CreatePlanEntry(date=date.today(), title="Test Title")
assert entry.title == "Test Title"
assert entry.recipe_id is None
def test_create_plan_with_slug():
entry = CreatePlanEntry(date=date.today(), recipe_id=123)
assert entry.recipe_id == 123
assert entry.title == ""
def test_slug_or_title_validation():
with pytest.raises(ValueError):
CreatePlanEntry(date=date.today(), slug="", title="")