feature/mealplanner-rewrite (#417)

* multiple recipes per day

* fix update

* meal-planner rewrite

* disable meal-tests

* spacing

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-22 21:04:19 -08:00 committed by GitHub
parent 4b3fc45c1c
commit ef87f2231d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1502 additions and 491 deletions

24
.gitignore vendored
View file

@ -145,4 +145,26 @@ node_modules/
scratch.py scratch.py
dev/data/backups/dev_sample_data*.zip dev/data/backups/dev_sample_data*.zip
!dev/data/backups/test*.zip !dev/data/backups/test*.zip
dev/data/recipes/* dev/data/recipes/*
dev/scripts/output/app_routes.py
dev/scripts/output/javascriptAPI/apiRoutes.js
dev/scripts/output/javascriptAPI/appEvents.js
dev/scripts/output/javascriptAPI/authentication.js
dev/scripts/output/javascriptAPI/backups.js
dev/scripts/output/javascriptAPI/debug.js
dev/scripts/output/javascriptAPI/groups.js
dev/scripts/output/javascriptAPI/index.js
dev/scripts/output/javascriptAPI/mealPlan.js
dev/scripts/output/javascriptAPI/migration.js
dev/scripts/output/javascriptAPI/queryAllRecipes.js
dev/scripts/output/javascriptAPI/recipeCategories.js
dev/scripts/output/javascriptAPI/recipeCRUD.js
dev/scripts/output/javascriptAPI/recipeTags.js
dev/scripts/output/javascriptAPI/settings.js
dev/scripts/output/javascriptAPI/shoppingLists.js
dev/scripts/output/javascriptAPI/siteMedia.js
dev/scripts/output/javascriptAPI/themes.js
dev/scripts/output/javascriptAPI/userAPITokens.js
dev/scripts/output/javascriptAPI/users.js
dev/scripts/output/javascriptAPI/userSignup.js
dev/scripts/output/javascriptAPI/utils.js

View file

@ -0,0 +1,144 @@
import json
import re
from enum import Enum
from itertools import groupby
from pathlib import Path
import slugify
from fastapi import FastAPI
from humps import camelize
from jinja2 import Template
from mealie.app import app
from pydantic import BaseModel
CWD = Path(__file__).parent
OUT_DIR = CWD / "output"
OUT_FILE = OUT_DIR / "app_routes.py"
JS_DIR = OUT_DIR / "javascriptAPI"
JS_OUT_FILE = JS_DIR / "apiRoutes.js"
TEMPLATES_DIR = CWD / "templates"
PYTEST_TEMPLATE = TEMPLATES_DIR / "pytest_routes.j2"
JS_REQUESTS = TEMPLATES_DIR / "js_requests.j2"
JS_ROUTES = TEMPLATES_DIR / "js_routes.j2"
JS_INDEX = TEMPLATES_DIR / "js_index.j2"
JS_DIR.mkdir(exist_ok=True, parents=True)
class RouteObject:
def __init__(self, route_string) -> None:
self.prefix = "/" + route_string.split("/")[1]
self.route = route_string.replace(self.prefix, "")
self.js_route = self.route.replace("{", "${")
self.parts = route_string.split("/")[1:]
self.var = re.findall(r"\{(.*?)\}", route_string)
self.is_function = "{" in self.route
self.router_slug = slugify.slugify("_".join(self.parts[1:]), separator="_")
self.router_camel = camelize(self.router_slug)
def __repr__(self) -> str:
return f"""Route: {self.route}
Parts: {self.parts}
Function: {self.is_function}
Var: {self.var}
Slug: {self.router_slug}
"""
class RequestType(str, Enum):
get = "get"
put = "put"
post = "post"
patch = "patch"
delete = "delete"
class HTTPRequest(BaseModel):
request_type: RequestType
description: str = ""
summary: str
tags: list[str]
@property
def summary_camel(self):
return camelize(self.summary)
@property
def js_docs(self):
return self.description.replace("\n", " \n * ")
class PathObject(BaseModel):
route_object: RouteObject
http_verbs: list[HTTPRequest]
class Config:
arbitrary_types_allowed = True
def get_path_objects(app: FastAPI):
paths = []
with open("scratch.json", "w") as f:
f.write(json.dumps(app.openapi()))
for key, value in app.openapi().items():
if key == "paths":
for key, value in value.items():
paths.append(
PathObject(
route_object=RouteObject(key),
http_verbs=[HTTPRequest(request_type=k, **v) for k, v in value.items()],
)
)
return paths
def read_template(file: Path):
with open(file, "r") as f:
return f.read()
def generate_template(app):
paths = get_path_objects(app)
static_paths = [x.route_object for x in paths if not x.route_object.is_function]
function_paths = [x.route_object for x in paths if x.route_object.is_function]
static_paths.sort(key=lambda x: x.router_slug)
function_paths.sort(key=lambda x: x.router_slug)
template = Template(read_template(PYTEST_TEMPLATE))
content = template.render(paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths})
with open(OUT_FILE, "w") as f:
f.write(content)
template = Template(read_template(JS_ROUTES))
content = template.render(
paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths, "all_paths": paths}
)
with open(JS_OUT_FILE, "w") as f:
f.write(content)
all_tags = []
for k, g in groupby(paths, lambda x: x.http_verbs[0].tags[0]):
template = Template(read_template(JS_REQUESTS))
content = template.render(paths={"all_paths": list(g), "export_name": camelize(k)})
all_tags.append(camelize(k))
with open(JS_DIR.joinpath(camelize(k) + ".js"), "w") as f:
f.write(content)
template = Template(read_template(JS_INDEX))
content = template.render(files={"files": all_tags})
with open(JS_DIR.joinpath("index.js"), "w") as f:
f.write(content)
if __name__ == "__main__":
generate_template(app)

View file

@ -1,81 +0,0 @@
import json
import re
from pathlib import Path
import slugify
from jinja2 import Template
from mealie.app import app
CWD = Path(__file__).parent
OUT_FILE = CWD.joinpath("output", "app_routes.py")
code_template = """
class AppRoutes:
def __init__(self) -> None:
self.prefix = '{{paths.prefix}}'
{% for path in paths.static_paths %}
self.{{ path.router_slug }} = "{{path.prefix}}{{ path.route }}"{% endfor %}
{% for path in paths.function_paths %}
def {{path.router_slug}}(self, {{path.var|join(", ")}}):
return f"{self.prefix}{{ path.route }}"
{% endfor %}
"""
def get_variables(path):
path = path.replace("/", " ")
print(path)
var = re.findall(r" \{.*\}", path)
print(var)
if var:
return [v.replace("{", "").replace("}", "") for v in var]
else:
return None
class RouteObject:
def __init__(self, route_string) -> None:
self.prefix = "/" + route_string.split("/")[1]
self.route = route_string.replace(self.prefix, "")
self.parts = route_string.split("/")[1:]
self.var = re.findall(r"\{(.*?)\}", route_string)
self.is_function = "{" in self.route
self.router_slug = slugify.slugify("_".join(self.parts[1:]), separator="_")
def __repr__(self) -> str:
return f"""Route: {self.route}
Parts: {self.parts}
Function: {self.is_function}
Var: {self.var}
Slug: {self.router_slug}
"""
def get_paths(app):
paths = []
print(json.dumps(app.openapi()))
for key, value in app.openapi().items():
if key == "paths":
for key, value in value.items():
paths.append(key)
return paths
def generate_template(app):
paths = get_paths(app)
new_paths = [RouteObject(path) for path in paths]
static_paths = [p for p in new_paths if not p.is_function]
function_paths = [p for p in new_paths if p.is_function]
template = Template(code_template)
content = template.render(paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths})
with open(OUT_FILE, "w") as f:
f.write(content)
if __name__ == "__main__":
generate_template(app)

View file

@ -1,105 +1,63 @@
# This Content is Auto Generated for Pytest
class AppRoutes: class AppRoutes:
def __init__(self) -> None: def __init__(self) -> None:
self.prefix = "/api" self.prefix = '/api'
self.about_events = "/api/about/events"
self.about_events_notifications = "/api/about/events/notifications"
self.about_events_notifications_test = "/api/about/events/notifications/test"
self.auth_refresh = "/api/auth/refresh"
self.auth_token = "/api/auth/token" self.auth_token = "/api/auth/token"
self.auth_token_long = "/api/auth/token/long" self.auth_token_long = "/api/auth/token/long"
self.auth_refresh = "/api/auth/refresh" self.backups_available = "/api/backups/available"
self.users_sign_ups = "/api/users/sign-ups" self.backups_export_database = "/api/backups/export/database"
self.users = "/api/users" self.backups_upload = "/api/backups/upload"
self.users_self = "/api/users/self"
self.users_api_tokens = "/api/users-tokens"
self.groups = "/api/groups"
self.groups_self = "/api/groups/self"
self.recipes_summary = "/api/recipes/summary"
self.recipes_summary_untagged = "/api/recipes/summary/untagged"
self.recipes_summary_uncategorized = "/api/recipes/summary/uncategorized"
self.recipes_category = "/api/recipes/category"
self.recipes_tag = "/api/recipes/tag"
self.recipes_create = "/api/recipes/create"
self.recipes_create_url = "/api/recipes/create-url"
self.categories = "/api/categories" self.categories = "/api/categories"
self.categories_empty = "/api/categories/empty" self.categories_empty = "/api/categories/empty"
self.tags = "/api/tags" self.debug = "/api/debug"
self.tags_empty = "/api/tags/empty" self.debug_last_recipe_json = "/api/debug/last-recipe-json"
self.about_events = "/api/about/events" self.debug_log = "/api/debug/log"
self.debug_statistics = "/api/debug/statistics"
self.debug_version = "/api/debug/version"
self.groups = "/api/groups"
self.groups_self = "/api/groups/self"
self.meal_plans_all = "/api/meal-plans/all" self.meal_plans_all = "/api/meal-plans/all"
self.meal_plans_create = "/api/meal-plans/create" self.meal_plans_create = "/api/meal-plans/create"
self.meal_plans_this_week = "/api/meal-plans/this-week" self.meal_plans_this_week = "/api/meal-plans/this-week"
self.meal_plans_today = "/api/meal-plans/today" self.meal_plans_today = "/api/meal-plans/today"
self.meal_plans_today_image = "/api/meal-plans/today/image" self.meal_plans_today_image = "/api/meal-plans/today/image"
self.site_settings_custom_pages = "/api/site-settings/custom-pages" self.migrations = "/api/migrations"
self.recipes_category = "/api/recipes/category"
self.recipes_create = "/api/recipes/create"
self.recipes_create_url = "/api/recipes/create-url"
self.recipes_summary = "/api/recipes/summary"
self.recipes_summary_uncategorized = "/api/recipes/summary/uncategorized"
self.recipes_summary_untagged = "/api/recipes/summary/untagged"
self.recipes_tag = "/api/recipes/tag"
self.shopping_lists = "/api/shopping-lists"
self.site_settings = "/api/site-settings" self.site_settings = "/api/site-settings"
self.site_settings_custom_pages = "/api/site-settings/custom-pages"
self.site_settings_webhooks_test = "/api/site-settings/webhooks/test" self.site_settings_webhooks_test = "/api/site-settings/webhooks/test"
self.tags = "/api/tags"
self.tags_empty = "/api/tags/empty"
self.themes = "/api/themes" self.themes = "/api/themes"
self.themes_create = "/api/themes/create" self.themes_create = "/api/themes/create"
self.backups_available = "/api/backups/available" self.users = "/api/users"
self.backups_export_database = "/api/backups/export/database" self.users_api_tokens = "/api/users-tokens"
self.backups_upload = "/api/backups/upload" self.users_self = "/api/users/self"
self.migrations = "/api/migrations" self.users_sign_ups = "/api/users/sign-ups"
self.debug = "/api/debug"
self.debug_statistics = "/api/debug/statistics"
self.debug_version = "/api/debug/version"
self.debug_last_recipe_json = "/api/debug/last-recipe-json"
self.debug_log = "/api/debug/log"
self.utils_download = "/api/utils/download" self.utils_download = "/api/utils/download"
def users_sign_ups_token(self, token):
return f"{self.prefix}/users/sign-ups/{token}"
def users_id(self, id):
return f"{self.prefix}/users/{id}"
def users_id_reset_password(self, id):
return f"{self.prefix}/users/{id}/reset-password"
def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image"
def users_id_password(self, id):
return f"{self.prefix}/users/{id}/password"
def users_api_tokens_token_id(self, token_id):
return f"{self.prefix}/users-tokens/{token_id}"
def groups_id(self, id):
return f"{self.prefix}/groups/{id}"
def recipes_recipe_slug(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}"
def recipes_recipe_slug_image(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/image"
def recipes_recipe_slug_assets(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/assets"
def categories_category(self, category):
return f"{self.prefix}/categories/{category}"
def tags_tag(self, tag):
return f"{self.prefix}/tags/{tag}"
def media_recipes_recipe_slug_images_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/images/{file_name}"
def media_recipes_recipe_slug_assets_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/assets/{file_name}"
def about_events_id(self, id): def about_events_id(self, id):
return f"{self.prefix}/about/events/{id}" return f"{self.prefix}/about/events/{id}"
def meal_plans_plan_id(self, plan_id): def about_events_notifications_id(self, id):
return f"{self.prefix}/meal-plans/{plan_id}" return f"{self.prefix}/about/events/notifications/{id}"
def meal_plans_id_shopping_list(self, id): def backups_file_name_delete(self, file_name):
return f"{self.prefix}/meal-plans/{id}/shopping-list" return f"{self.prefix}/backups/{file_name}/delete"
def site_settings_custom_pages_id(self, id):
return f"{self.prefix}/site-settings/custom-pages/{id}"
def themes_id(self, id):
return f"{self.prefix}/themes/{id}"
def backups_file_name_download(self, file_name): def backups_file_name_download(self, file_name):
return f"{self.prefix}/backups/{file_name}/download" return f"{self.prefix}/backups/{file_name}/download"
@ -107,17 +65,71 @@ class AppRoutes:
def backups_file_name_import(self, file_name): def backups_file_name_import(self, file_name):
return f"{self.prefix}/backups/{file_name}/import" return f"{self.prefix}/backups/{file_name}/import"
def backups_file_name_delete(self, file_name): def categories_category(self, category):
return f"{self.prefix}/backups/{file_name}/delete" return f"{self.prefix}/categories/{category}"
def migrations_import_type_file_name_import(self, import_type, file_name): def debug_log_num(self, num):
return f"{self.prefix}/migrations/{import_type}/{file_name}/import" return f"{self.prefix}/debug/log/{num}"
def groups_id(self, id):
return f"{self.prefix}/groups/{id}"
def meal_plans_id_shopping_list(self, id):
return f"{self.prefix}/meal-plans/{id}/shopping-list"
def meal_plans_plan_id(self, plan_id):
return f"{self.prefix}/meal-plans/{plan_id}"
def media_recipes_recipe_slug_assets_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/assets/{file_name}"
def media_recipes_recipe_slug_images_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/images/{file_name}"
def migrations_import_type_file_name_delete(self, import_type, file_name): def migrations_import_type_file_name_delete(self, import_type, file_name):
return f"{self.prefix}/migrations/{import_type}/{file_name}/delete" return f"{self.prefix}/migrations/{import_type}/{file_name}/delete"
def migrations_import_type_file_name_import(self, import_type, file_name):
return f"{self.prefix}/migrations/{import_type}/{file_name}/import"
def migrations_import_type_upload(self, import_type): def migrations_import_type_upload(self, import_type):
return f"{self.prefix}/migrations/{import_type}/upload" return f"{self.prefix}/migrations/{import_type}/upload"
def debug_log_num(self, num): def recipes_recipe_slug(self, recipe_slug):
return f"{self.prefix}/debug/log/{num}" return f"{self.prefix}/recipes/{recipe_slug}"
def recipes_recipe_slug_assets(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/assets"
def recipes_recipe_slug_image(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/image"
def shopping_lists_id(self, id):
return f"{self.prefix}/shopping-lists/{id}"
def site_settings_custom_pages_id(self, id):
return f"{self.prefix}/site-settings/custom-pages/{id}"
def tags_tag(self, tag):
return f"{self.prefix}/tags/{tag}"
def themes_id(self, id):
return f"{self.prefix}/themes/{id}"
def users_api_tokens_token_id(self, token_id):
return f"{self.prefix}/users-tokens/{token_id}"
def users_id(self, id):
return f"{self.prefix}/users/{id}"
def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image"
def users_id_password(self, id):
return f"{self.prefix}/users/{id}/password"
def users_id_reset_password(self, id):
return f"{self.prefix}/users/{id}/reset-password"
def users_sign_ups_token(self, token):
return f"{self.prefix}/users/sign-ups/{token}"

View file

@ -0,0 +1,77 @@
// This Content is Auto Generated
const prefix = '/api'
export const API_ROUTES = {
aboutEvents: "/api/about/events",
aboutEventsNotifications: "/api/about/events/notifications",
aboutEventsNotificationsTest: "/api/about/events/notifications/test",
authRefresh: "/api/auth/refresh",
authToken: "/api/auth/token",
authTokenLong: "/api/auth/token/long",
backupsAvailable: "/api/backups/available",
backupsExportDatabase: "/api/backups/export/database",
backupsUpload: "/api/backups/upload",
categories: "/api/categories",
categoriesEmpty: "/api/categories/empty",
debug: "/api/debug",
debugLastRecipeJson: "/api/debug/last-recipe-json",
debugLog: "/api/debug/log",
debugStatistics: "/api/debug/statistics",
debugVersion: "/api/debug/version",
groups: "/api/groups",
groupsSelf: "/api/groups/self",
mealPlansAll: "/api/meal-plans/all",
mealPlansCreate: "/api/meal-plans/create",
mealPlansThisWeek: "/api/meal-plans/this-week",
mealPlansToday: "/api/meal-plans/today",
mealPlansTodayImage: "/api/meal-plans/today/image",
migrations: "/api/migrations",
recipesCategory: "/api/recipes/category",
recipesCreate: "/api/recipes/create",
recipesCreateUrl: "/api/recipes/create-url",
recipesSummary: "/api/recipes/summary",
recipesSummaryUncategorized: "/api/recipes/summary/uncategorized",
recipesSummaryUntagged: "/api/recipes/summary/untagged",
recipesTag: "/api/recipes/tag",
shoppingLists: "/api/shopping-lists",
siteSettings: "/api/site-settings",
siteSettingsCustomPages: "/api/site-settings/custom-pages",
siteSettingsWebhooksTest: "/api/site-settings/webhooks/test",
tags: "/api/tags",
tagsEmpty: "/api/tags/empty",
themes: "/api/themes",
themesCreate: "/api/themes/create",
users: "/api/users",
usersApiTokens: "/api/users-tokens",
usersSelf: "/api/users/self",
usersSignUps: "/api/users/sign-ups",
utilsDownload: "/api/utils/download",
aboutEventsId: (id) => `${prefix}/about/events/${id}`,
aboutEventsNotificationsId: (id) => `${prefix}/about/events/notifications/${id}`,
backupsFileNameDelete: (file_name) => `${prefix}/backups/${file_name}/delete`,
backupsFileNameDownload: (file_name) => `${prefix}/backups/${file_name}/download`,
backupsFileNameImport: (file_name) => `${prefix}/backups/${file_name}/import`,
categoriesCategory: (category) => `${prefix}/categories/${category}`,
debugLogNum: (num) => `${prefix}/debug/log/${num}`,
groupsId: (id) => `${prefix}/groups/${id}`,
mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`,
mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`,
mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`,
mediaRecipesRecipeSlugImagesFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/images/${file_name}`,
migrationsImportTypeFileNameDelete: (import_type, file_name) => `${prefix}/migrations/${import_type}/${file_name}/delete`,
migrationsImportTypeFileNameImport: (import_type, file_name) => `${prefix}/migrations/${import_type}/${file_name}/import`,
migrationsImportTypeUpload: (import_type) => `${prefix}/migrations/${import_type}/upload`,
recipesRecipeSlug: (recipe_slug) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugAssets: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/assets`,
recipesRecipeSlugImage: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/image`,
shoppingListsId: (id) => `${prefix}/shopping-lists/${id}`,
siteSettingsCustomPagesId: (id) => `${prefix}/site-settings/custom-pages/${id}`,
tagsTag: (tag) => `${prefix}/tags/${tag}`,
themesId: (id) => `${prefix}/themes/${id}`,
usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_id}`,
usersId: (id) => `${prefix}/users/${id}`,
usersIdImage: (id) => `${prefix}/users/${id}/image`,
usersIdPassword: (id) => `${prefix}/users/${id}/password`,
usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`,
usersSignUpsToken: (token) => `${prefix}/users/sign-ups/${token}`,
}

View file

@ -12,6 +12,7 @@ import { signupAPI } from "./signUps";
import { groupAPI } from "./groups"; import { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings"; import { siteSettingsAPI } from "./siteSettings";
import { aboutAPI } from "./about"; import { aboutAPI } from "./about";
import { shoppingListsAPI } from "./shoppingLists";
/** /**
* The main object namespace for interacting with the backend database * The main object namespace for interacting with the backend database
@ -32,4 +33,5 @@ export const api = {
signUps: signupAPI, signUps: signupAPI,
groups: groupAPI, groups: groupAPI,
about: aboutAPI, about: aboutAPI,
shoppingLists: shoppingListsAPI,
}; };

View file

@ -0,0 +1,33 @@
// This Content is Auto Generated
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
export const shoppingListsAPI = {
/** Create Shopping List in the Database
*/
async createShoppingList(data) {
const response = await apiReq.post(API_ROUTES.shoppingLists, data);
return response.data;
},
/** Get Shopping List from the Database
* @param id
*/
async getShoppingList(id) {
const response = await apiReq.get(API_ROUTES.shoppingListsId(id));
return response.data;
},
/** Update Shopping List in the Database
* @param id
*/
async updateShoppingList(id, data) {
const response = await apiReq.put(API_ROUTES.shoppingListsId(id), data);
return response.data;
},
/** Delete Shopping List from the Database
* @param id
*/
async deleteShoppingList(id) {
const response = await apiReq.delete(API_ROUTES.shoppingListsId(id));
return response.data;
},
};

View file

@ -0,0 +1,17 @@
<template>
<div>
<The404>
<h1 class="mx-auto">No Recipe Found</h1>
</The404>
</div>
</template>
<script>
import The404 from "./The404.vue";
export default {
components: { The404 },
};
</script>

View file

@ -0,0 +1,51 @@
<template>
<div>
<v-card-title>
<slot>
<h1 class="mx-auto">{{ $t("404.page-not-found") }}</h1>
</slot>
</v-card-title>
<div class="d-flex justify-space-around">
<div class="d-flex">
<p>4</p>
<v-icon color="primary" class="mx-auto" size="200">
mdi-silverware-variant
</v-icon>
<p>4</p>
</div>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<slot name="actions">
<v-btn v-for="(button, index) in buttons" :key="index" :to="button.to" color="primary">
<v-icon left> {{ button.icon }} </v-icon>
{{ button.text }}
</v-btn>
</slot>
<v-spacer></v-spacer>
</v-card-actions>
</div>
</template>
<script>
export default {
data() {
return {
buttons: [
{ icon: "mdi-home", to: "/", text: "Home" },
{ icon: "mdi-silverware-variant", to: "/recipes/all", text: "All Recipes" },
{ icon: "mdi-magnify", to: "/search", text: "Search" },
],
};
},
};
</script>
<style scoped>
p {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
color: var(--v-primary-base);
font-size: 200px;
}
</style>

View file

@ -1,14 +1,76 @@
<template> <template>
<v-row> <v-row>
<SearchDialog ref="mealselect" @select="setSlug" /> <SearchDialog ref="mealselect" @select="setSlug" />
<v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="(meal, index) in value" :key="index"> <BaseDialog
title="Custom Meal"
title-icon="mdi-silverware-variant"
submit-text="Save"
:top="true"
ref="customMealDialog"
@submit="pushCustomMeal"
>
<v-card-text>
<v-text-field autofocus v-model="customMeal.name" label="Name"> </v-text-field>
<v-textarea v-model="customMeal.description" label="Description"> </v-textarea>
</v-card-text>
</BaseDialog>
<v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="(planDay, index) in value" :key="index">
<v-hover v-slot="{ hover }" :open-delay="50"> <v-hover v-slot="{ hover }" :open-delay="50">
<v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2"> <v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2">
<v-img height="200" :src="getImage(meal.slug)" @click="openSearch(index)"></v-img> <CardImage large :slug="planDay.meals[0].slug" icon-size="200" @click="openSearch(index, modes.primary)">
<v-fade-transition>
<v-btn v-if="hover" small color="info" class="ma-1" @click.stop="addCustomItem(index, modes.primary)">
<v-icon left>
mdi-square-edit-outline
</v-icon>
No Recipe
</v-btn>
</v-fade-transition>
</CardImage>
<v-card-title class="my-n3 mb-n6"> <v-card-title class="my-n3 mb-n6">
{{ $d(new Date(meal.date.split("-")), "short") }} {{ $d(new Date(planDay.date.split("-")), "short") }}
</v-card-title> </v-card-title>
<v-card-subtitle> {{ meal.name }}</v-card-subtitle> <v-card-subtitle class="mb-0 pb-0"> {{ planDay.meals[0].name }}</v-card-subtitle>
<v-hover v-slot="{ hover }">
<v-card-actions>
<v-spacer></v-spacer>
<v-fade-transition>
<v-btn v-if="hover" small color="info" text @click.stop="addCustomItem(index, modes.sides)">
<v-icon left>
mdi-square-edit-outline
</v-icon>
No Recipe
</v-btn>
</v-fade-transition>
<v-btn color="info" outlined small @click="openSearch(index, modes.sides)">
<v-icon small class="mr-1">
mdi-plus
</v-icon>
Side
</v-btn>
</v-card-actions>
</v-hover>
<v-divider class="mx-2"></v-divider>
<v-list dense>
<v-list-item v-for="(recipe, i) in planDay.meals.slice(1)" :key="i">
<v-list-item-avatar color="accent">
<v-img :alt="recipe.slug" :src="getImage(recipe.slug)"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="recipe.name"></v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-btn icon @click="removeSide(index, i + 1)">
<v-icon color="error">
mdi-delete
</v-icon>
</v-btn>
</v-list-item-icon>
</v-list-item>
</v-list>
</v-card> </v-card>
</v-hover> </v-hover>
</v-col> </v-col>
@ -17,38 +79,101 @@
<script> <script>
import SearchDialog from "../UI/Search/SearchDialog"; import SearchDialog from "../UI/Search/SearchDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api"; import { api } from "@/api";
import CardImage from "../Recipe/CardImage.vue";
export default { export default {
components: { components: {
SearchDialog, SearchDialog,
CardImage,
BaseDialog,
}, },
props: { props: {
value: Array, value: Array,
}, },
data() { data() {
return { return {
recipeData: [],
cardData: [],
activeIndex: 0, activeIndex: 0,
mode: "PRIMARY",
modes: {
primary: "PRIMARY",
sides: "SIDES",
},
customMeal: {
slug: null,
name: "",
description: "",
},
}; };
}, },
watch: {
value(val) {
console.log(val);
},
},
mounted() {
console.log(this.value);
},
methods: { methods: {
getImage(slug) { getImage(slug) {
if (slug) { if (slug) {
return api.recipes.recipeSmallImage(slug); return api.recipes.recipeSmallImage(slug);
} }
}, },
setSlug(name, slug) { setSide(name, slug = null, description = "") {
let index = this.activeIndex; const meal = { name: name, slug: slug, description: description };
this.value[index]["slug"] = slug; this.value[this.activeIndex]["meals"].push(meal);
this.value[index]["name"] = name;
}, },
openSearch(index) { setPrimary(name, slug, description = "") {
this.value[this.activeIndex]["meals"][0]["slug"] = slug;
this.value[this.activeIndex]["meals"][0]["name"] = name;
this.value[this.activeIndex]["meals"][0]["description"] = description;
},
setSlug(name, slug) {
switch (this.mode) {
case this.modes.primary:
this.setPrimary(name, slug);
break;
default:
this.setSide(name, slug);
break;
}
},
openSearch(index, mode) {
this.mode = mode;
this.activeIndex = index; this.activeIndex = index;
this.$refs.mealselect.open(); this.$refs.mealselect.open();
}, },
removeSide(dayIndex, sideIndex) {
this.value[dayIndex]["meals"].splice(sideIndex, 1);
},
addCustomItem(index, mode) {
this.mode = mode;
this.activeIndex = index;
this.$refs.customMealDialog.open();
},
pushCustomMeal() {
switch (this.mode) {
case this.modes.primary:
this.setPrimary(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
default:
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
}
console.log("Hello World");
this.customMeal = { name: "", slug: null, description: "" };
},
}, },
}; };
</script> </script>
<style></style> <style>
.relative-card {
position: relative;
}
.custom-button {
z-index: -1;
}
</style>

View file

@ -6,7 +6,7 @@
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<MealPlanCard v-model="mealPlan.meals" /> <MealPlanCard v-model="mealPlan.planDays" />
<v-row align="center" justify="end"> <v-row align="center" justify="end">
<v-card-actions> <v-card-actions>
<v-btn color="success" text @click="update"> <v-btn color="success" text @click="update">
@ -30,6 +30,9 @@ export default {
props: { props: {
mealPlan: Object, mealPlan: Object,
}, },
mounted() {
console.log(this.mealPlan);
},
methods: { methods: {
formatDate(timestamp) { formatDate(timestamp) {
let dateObject = new Date(timestamp); let dateObject = new Date(timestamp);

View file

@ -63,14 +63,14 @@
</v-card-text> </v-card-text>
<v-card-text v-if="startDate"> <v-card-text v-if="startDate">
<MealPlanCard v-model="meals" /> <MealPlanCard v-model="planDays" />
</v-card-text> </v-card-text>
<v-row align="center" justify="end"> <v-row align="center" justify="end">
<v-card-actions class="mr-5"> <v-card-actions class="mr-5">
<v-btn color="success" @click="random" v-if="meals.length > 0" text> <v-btn color="success" @click="random" v-if="planDays.length > 0" text>
{{ $t("general.random") }} {{ $t("general.random") }}
</v-btn> </v-btn>
<v-btn color="success" @click="save" text :disabled="meals.length == 0"> <v-btn color="success" @click="save" text :disabled="planDays.length == 0">
{{ $t("general.save") }} {{ $t("general.save") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
@ -92,7 +92,7 @@ export default {
data() { data() {
return { return {
isLoading: false, isLoading: false,
meals: [], planDays: [],
items: [], items: [],
// Dates // Dates
@ -106,11 +106,17 @@ export default {
watch: { watch: {
dateDif() { dateDif() {
this.meals = []; this.planDays = [];
for (let i = 0; i < this.dateDif; i++) { for (let i = 0; i < this.dateDif; i++) {
this.meals.push({ this.planDays.push({
slug: "empty",
date: this.getDate(i), date: this.getDate(i),
meals: [
{
name: "",
slug: "empty",
description: "empty",
},
],
}); });
} }
}, },
@ -172,10 +178,10 @@ export default {
}, },
random() { random() {
this.usedRecipes = [1]; this.usedRecipes = [1];
this.meals.forEach((element, index) => { this.planDays.forEach((element, index) => {
let recipe = this.getRandom(this.filteredRecipes); let recipe = this.getRandom(this.filteredRecipes);
this.meals[index]["slug"] = recipe.slug; this.planDays[index]["meals"][0]["slug"] = recipe.slug;
this.meals[index]["name"] = recipe.name; this.planDays[index]["meals"][0]["name"] = recipe.name;
this.usedRecipes.push(recipe); this.usedRecipes.push(recipe);
}); });
}, },
@ -193,11 +199,11 @@ export default {
group: this.groupSettings.name, group: this.groupSettings.name,
startDate: this.startDate, startDate: this.startDate,
endDate: this.endDate, endDate: this.endDate,
meals: this.meals, planDays: this.planDays,
}; };
if (await api.mealPlans.create(mealBody)) { if (await api.mealPlans.create(mealBody)) {
this.$emit(CREATE_EVENT); this.$emit(CREATE_EVENT);
this.meals = []; this.planDays = [];
this.startDate = null; this.startDate = null;
this.endDate = null; this.endDate = null;
} }

View file

@ -0,0 +1,99 @@
<template>
<div @click="$emit('click')">
<v-img
:height="height"
v-if="!fallBackImage"
:src="getImage(slug)"
@load="fallBackImage = false"
@error="fallBackImage = true"
>
<slot> </slot>
</v-img>
<div class="icon-slot" v-else>
<div>
<slot> </slot>
</div>
<v-icon color="primary" class="icon-position" :size="iconSize">
mdi-silverware-variant
</v-icon>
</div>
</div>
</template>
<script>
import { api } from "@/api";
export default {
props: {
tiny: {
type: Boolean,
default: null,
},
small: {
type: Boolean,
default: null,
},
large: {
type: Boolean,
default: null,
},
iconSize: {
default: 100,
},
slug: {
default: null,
},
height: {
default: 200,
},
},
computed: {
imageSize() {
if (this.tiny) return "tiny";
if (this.small) return "small";
if (this.large) return "large";
return "large";
},
},
watch: {
slug() {
this.fallBackImage = false;
},
},
data() {
return {
fallBackImage: false,
};
},
methods: {
getImage(image) {
switch (this.imageSize) {
case "tiny":
return api.recipes.recipeTinyImage(image);
case "small":
return api.recipes.recipeSmallImage(image);
case "large":
return api.recipes.recipeImage(image);
}
},
},
};
</script>
<style scoped>
.icon-slot {
position: relative;
}
.icon-slot > div {
position: absolute;
z-index: 1;
}
.icon-position {
opacity: 0.8;
display: flex !important;
position: relative;
margin-left: auto !important;
margin-right: auto !important;
}
</style>

View file

@ -7,10 +7,7 @@
@click="$emit('click')" @click="$emit('click')"
min-height="275" min-height="275"
> >
<v-img height="200" class="d-flex" :src="getImage(slug)" @error="fallBackImage = true"> <CardImage icon-size="200" :slug="slug">
<v-icon v-if="fallBackImage" color="primary" class="icon-position" size="200">
mdi-silverware-variant
</v-icon>
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal " style="height: 100%;"> <div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal " style="height: 100%;">
<v-card-text class="v-card--text-show white--text"> <v-card-text class="v-card--text-show white--text">
@ -18,7 +15,7 @@
</v-card-text> </v-card-text>
</div> </div>
</v-expand-transition> </v-expand-transition>
</v-img> </CardImage>
<v-card-title class="my-n3 mb-n6 "> <v-card-title class="my-n3 mb-n6 ">
<div class="headerClass"> <div class="headerClass">
{{ name }} {{ name }}
@ -38,6 +35,7 @@
<script> <script>
import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips"; import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
import ContextMenu from "@/components/Recipe/ContextMenu"; import ContextMenu from "@/components/Recipe/ContextMenu";
import CardImage from "@/components/Recipe/CardImage";
import Rating from "@/components/Recipe/Parts/Rating"; import Rating from "@/components/Recipe/Parts/Rating";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
@ -45,6 +43,7 @@ export default {
RecipeChips, RecipeChips,
ContextMenu, ContextMenu,
Rating, Rating,
CardImage,
}, },
props: { props: {
name: String, name: String,
@ -91,12 +90,4 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.icon-position {
opacity: 0.8;
display: flex !important;
position: relative;
margin-left: auto !important;
margin-right: auto !important;
}
</style> </style>

View file

@ -3,7 +3,7 @@
ref="copyToolTip" ref="copyToolTip"
v-model="show" v-model="show"
color="success lighten-1" color="success lighten-1"
right top
:open-on-hover="false" :open-on-hover="false"
:open-on-click="true" :open-on-click="true"
close-delay="500" close-delay="500"
@ -12,7 +12,7 @@
<template v-slot:activator="{ on }"> <template v-slot:activator="{ on }">
<v-btn <v-btn
icon icon
color="primary" :color="color"
@click=" @click="
on.click; on.click;
textToClipboard(); textToClipboard();
@ -27,8 +27,7 @@
<v-icon left dark> <v-icon left dark>
mdi-clipboard-check mdi-clipboard-check
</v-icon> </v-icon>
{{ $t('general.coppied')}}! {{ $t("general.coppied") }}!
</span> </span>
</v-tooltip> </v-tooltip>
</template> </template>
@ -39,6 +38,9 @@ export default {
copyText: { copyText: {
default: "Default Copy Text", default: "Default Copy Text",
}, },
color: {
default: "primary",
},
}, },
data() { data() {
return { return {

View file

@ -64,7 +64,7 @@ export default {
default: false, default: false,
}, },
top: { top: {
default: false, default: null,
}, },
submitText: { submitText: {
default: () => i18n.t("general.create"), default: () => i18n.t("general.create"),

View file

@ -74,8 +74,6 @@ export default {
}, },
open() { open() {
this.dialog = true; this.dialog = true;
this.$refs.mealSearchBar.resetSearch();
this.$router.push("#search");
}, },
toggleDialog(open) { toggleDialog(open) {
if (open) { if (open) {

View file

@ -63,6 +63,12 @@ export default {
nav: "/meal-plan/planner", nav: "/meal-plan/planner",
restricted: true, restricted: true,
}, },
{
icon: "mdi-format-list-checks",
title: "Shopping Lists",
nav: "/shopping-list",
restricted: true,
},
{ {
icon: "mdi-logout", icon: "mdi-logout",
title: this.$t("user.logout"), title: this.$t("user.logout"),

View file

@ -1,22 +1,13 @@
<template> <template>
<v-container class="text-center"> <v-container class="text-center">
<v-row> <The404 />
<v-col cols="2"></v-col>
<v-col>
<v-card height="">
<v-card-text>
<h1>{{ $t("404.page-not-found") }}</h1>
</v-card-text>
<v-btn text block @click="$router.push('/')"> {{ $t("404.take-me-home") }} </v-btn>
</v-card>
</v-col>
<v-col cols="2"></v-col>
</v-row>
</v-container> </v-container>
</template> </template>
<script> <script>
export default {}; import The404 from "@/components/Fallbacks/The404";
export default {
components: { The404 },
};
</script> </script>
<style lang="scss" scoped></style>

View file

@ -2,7 +2,6 @@
<v-container> <v-container>
<EditPlan v-if="editMealPlan" :meal-plan="editMealPlan" @updated="planUpdated" /> <EditPlan v-if="editMealPlan" :meal-plan="editMealPlan" @updated="planUpdated" />
<NewMeal v-else @created="requestMeals" class="mb-5" /> <NewMeal v-else @created="requestMeals" class="mb-5" />
<ShoppingListDialog ref="shoppingList" />
<v-card class="my-2"> <v-card class="my-2">
<v-card-title class="headline"> <v-card-title class="headline">
@ -13,14 +12,48 @@
<v-row dense> <v-row dense>
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="(mealplan, i) in plannedMeals" :key="i"> <v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="(mealplan, i) in plannedMeals" :key="i">
<v-card class="mt-1"> <v-card class="mt-1">
<v-card-title> <v-card-title class="mb-0 pb-0">
{{ $d(new Date(mealplan.startDate.split("-")), "short") }} - {{ $d(new Date(mealplan.startDate.split("-")), "short") }} -
{{ $d(new Date(mealplan.endDate.split("-")), "short") }} {{ $d(new Date(mealplan.endDate.split("-")), "short") }}
</v-card-title> </v-card-title>
<v-list nav> <v-divider class="mx-2 pa-1"></v-divider>
<v-list-item-group color="primary"> <v-card-actions class="mb-0 px-2 py-0">
<v-btn text small v-if="!mealplan.shoppingList" color="info" @click="createShoppingList(mealplan.uid)">
<v-icon left small>
mdi-cart-check
</v-icon>
Create Shopping List
</v-btn>
<v-btn
text
small
v-else
color="info"
class="mx-0"
:to="{ path: '/shopping-list', query: { list: mealplan.shoppingList } }"
>
<v-icon left small>
mdi-cart-check
</v-icon>
Shopping List
</v-btn>
</v-card-actions>
<v-list class="mt-0 pt-0">
<v-list-group v-for="(planDay, pdi) in mealplan.planDays" :key="`planDays-${pdi}`">
<template v-slot:activator>
<v-list-item-avatar color="primary" class="headline font-weight-light white--text">
<v-img :src="getImage(planDay['meals'][0].slug)"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-html="$d(new Date(planDay.date.split('-')), 'short')"></v-list-item-title>
<v-list-item-subtitle v-html="planDay['meals'][0].name"></v-list-item-subtitle>
</v-list-item-content>
</template>
<v-list-item <v-list-item
v-for="(meal, index) in mealplan.meals" three-line
v-for="(meal, index) in planDay.meals"
:key="generateKey(meal.slug, index)" :key="generateKey(meal.slug, index)"
:to="meal.slug ? `/recipe/${meal.slug}` : null" :to="meal.slug ? `/recipe/${meal.slug}` : null"
> >
@ -28,23 +61,21 @@
<v-img :src="getImage(meal.slug)"></v-img> <v-img :src="getImage(meal.slug)"></v-img>
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="meal.name"></v-list-item-title> <v-list-item-title v-html="meal.name"></v-list-item-title>
<v-list-item-subtitle v-text="$d(new Date(meal.date.split('-')), 'short')"> </v-list-item-subtitle> <v-list-item-subtitle v-html="meal.description"> </v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</v-list-item-group> </v-list-group>
</v-list> </v-list>
<v-card-actions class="mt-n5">
<v-btn color="accent lighten-2" class="mx-0" text @click="openShoppingList(mealplan.uid)"> <v-card-actions class="mt-n3">
{{ $t("meal-plan.shopping-list") }} <v-btn color="error lighten-2" small outlined @click="deletePlan(mealplan.uid)">
{{ $t("general.delete") }}
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="accent lighten-2" class="mx-0" text @click="editPlan(mealplan.uid)"> <v-btn color="info" small @click="editPlan(mealplan.uid)">
{{ $t("general.edit") }} {{ $t("general.edit") }}
</v-btn> </v-btn>
<v-btn color="error lighten-2" class="mx-2" text @click="deletePlan(mealplan.uid)">
{{ $t("general.delete") }}
</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-col> </v-col>
@ -57,13 +88,11 @@ import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
import NewMeal from "@/components/MealPlan/MealPlanNew"; import NewMeal from "@/components/MealPlan/MealPlanNew";
import EditPlan from "@/components/MealPlan/MealPlanEditor"; import EditPlan from "@/components/MealPlan/MealPlanEditor";
import ShoppingListDialog from "@/components/MealPlan/ShoppingListDialog";
export default { export default {
components: { components: {
NewMeal, NewMeal,
EditPlan, EditPlan,
ShoppingListDialog,
}, },
data: () => ({ data: () => ({
plannedMeals: [], plannedMeals: [],
@ -76,6 +105,7 @@ export default {
async requestMeals() { async requestMeals() {
const response = await api.mealPlans.all(); const response = await api.mealPlans.all();
this.plannedMeals = response.data; this.plannedMeals = response.data;
console.log(this.plannedMeals);
}, },
generateKey(name, index) { generateKey(name, index) {
return utils.generateUniqueKey(name, index); return utils.generateUniqueKey(name, index);
@ -100,8 +130,13 @@ export default {
this.requestMeals(); this.requestMeals();
} }
}, },
openShoppingList(id) { async createShoppingList(id) {
this.$refs.shoppingList.openDialog(id); await api.mealPlans.shoppingList(id);
this.requestMeals();
this.$store.dispatch("requestCurrentGroup");
},
redirectToList(id) {
this.$router.push(id);
}, },
}, },
}; };

View file

@ -1,43 +1,45 @@
<template> <template>
<v-container fill-height> <v-container>
<v-row> <div v-for="(planDay, index) in mealPlan.planDays" :key="index" class="mb-5">
<v-col sm="12"> <v-card-title class="headline">
<v-card v-for="(meal, index) in mealPlan.meals" :key="index" class="my-2"> {{ $d(new Date(planDay.date), "short") }}
<v-row dense no-gutters align="center" justify="center"> </v-card-title>
<v-col order="1" md="6" sm="12"> <v-divider class="mx-2"></v-divider>
<v-card flat class="align-center justify-center" align="center" justify="center"> <v-row>
<v-card-title class="justify-center"> <v-col cols="12" md="5" sm="12">
{{ meal.name }} <v-card-title class="headline">Main</v-card-title>
</v-card-title> <RecipeCard
<v-card-subtitle> {{ $d(new Date(meal.date), "short") }}</v-card-subtitle> :name="planDay.meals[0].name"
:slug="planDay.meals[0].slug"
<v-card-text> {{ meal.description }} </v-card-text> :description="planDay.meals[0].description"
/>
<v-card-actions> </v-col>
<v-spacer></v-spacer> <v-col cols="12" lg="6" md="6" sm="12">
<v-btn align="center" color="secondary" text @click="$router.push(`/recipe/${meal.slug}`)"> <v-card-title class="headline">Sides</v-card-title>
{{ $t("recipe.view-recipe") }} <MobileRecipeCard
</v-btn> class="mb-1"
<v-spacer></v-spacer> v-for="(side, index) in planDay.meals.slice(1)"
</v-card-actions> :key="`side-${index}`"
</v-card> :name="side.name"
</v-col> :slug="side.slug"
<v-col order-sm="0" :order-md="getOrder(index)" md="6" sm="12"> :description="side.description"
<v-card flat> />
<v-img :src="getImage(meal.slug)" max-height="300"> </v-img> </v-col>
</v-card> </v-row>
</v-col> </div>
</v-row>
</v-card>
</v-col>
</v-row>
</v-container> </v-container>
</template> </template>
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
import RecipeCard from "@/components/Recipe/RecipeCard";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default { export default {
components: {
RecipeCard,
MobileRecipeCard,
},
data() { data() {
return { return {
mealPlan: {}, mealPlan: {},
@ -48,6 +50,7 @@ export default {
if (!this.mealPlan) { if (!this.mealPlan) {
utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet")); utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet"));
} }
console.log(this.mealPlan);
}, },
methods: { methods: {
getOrder(index) { getOrder(index) {

View file

@ -3,7 +3,8 @@
<v-card v-if="skeleton" :color="`white ${theme.isDark ? 'darken-2' : 'lighten-4'}`" class="pa-3"> <v-card v-if="skeleton" :color="`white ${theme.isDark ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader> <v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card> </v-card>
<v-card v-else id="myRecipe" class="d-print-none"> <NoRecipe v-else-if="loadFailed" />
<v-card v-else-if="!loadFailed" id="myRecipe" class="d-print-none">
<v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey"> <v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey">
<RecipeTimeCard <RecipeTimeCard
:class="isMobile ? undefined : 'force-bottom'" :class="isMobile ? undefined : 'force-bottom'"
@ -48,6 +49,7 @@ import PrintView from "@/components/Recipe/PrintView";
import RecipeEditor from "@/components/Recipe/RecipeEditor"; import RecipeEditor from "@/components/Recipe/RecipeEditor";
import RecipeTimeCard from "@/components/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "@/components/Recipe/RecipeTimeCard.vue";
import EditorButtonRow from "@/components/Recipe/EditorButtonRow"; import EditorButtonRow from "@/components/Recipe/EditorButtonRow";
import NoRecipe from "@/components/Fallbacks/NoRecipe";
import { user } from "@/mixins/user"; import { user } from "@/mixins/user";
import { router } from "@/routes"; import { router } from "@/routes";
@ -59,6 +61,7 @@ export default {
EditorButtonRow, EditorButtonRow,
RecipeTimeCard, RecipeTimeCard,
PrintView, PrintView,
NoRecipe,
}, },
mixins: [user], mixins: [user],
inject: { inject: {
@ -68,6 +71,7 @@ export default {
}, },
data() { data() {
return { return {
loadFailed: false,
skeleton: true, skeleton: true,
form: false, form: false,
jsonEditor: false, jsonEditor: false,
@ -99,6 +103,7 @@ export default {
async mounted() { async mounted() {
await this.getRecipeDetails(); await this.getRecipeDetails();
this.jsonEditor = false; this.jsonEditor = false;
this.form = this.$route.query.edit === "true" && this.loggedIn; this.form = this.$route.query.edit === "true" && this.loggedIn;
@ -141,6 +146,12 @@ export default {
this.saveImage(); this.saveImage();
}, },
async getRecipeDetails() { async getRecipeDetails() {
if (this.currentRecipe === "null") {
this.skeleton = false;
this.loadFailed = true;
return;
}
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe); this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
this.skeleton = false; this.skeleton = false;
}, },

View file

@ -0,0 +1,280 @@
<template>
<v-container>
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-btn v-if="list" color="info" @click="list = null">
<v-icon left>
mdi-arrow-left-bold
</v-icon>
All Lists
</v-btn>
<v-icon v-if="!list" large left>
mdi-format-list-checks
</v-icon>
<v-toolbar-title v-if="!list" class="headline"> Shopping Lists </v-toolbar-title>
<v-spacer></v-spacer>
<BaseDialog title="New List" title-icon="mdi-format-list-checks" submit-text="Create" @submit="createNewList">
<template v-slot:open="{ open }">
<v-btn color="info" @click="open">
<v-icon left>
mdi-plus
</v-icon>
New List
</v-btn>
</template>
<v-card-text>
<v-text-field autofocus v-model="newList.name" label="List Name"> </v-text-field>
</v-card-text>
</BaseDialog>
</v-app-bar>
<v-slide-x-transition hide-on-leave>
<v-row v-if="list == null">
<v-col cols="12" :sm="6" :md="6" :lg="4" :xl="3" v-for="(item, index) in group.shoppingLists" :key="index">
<v-card>
<v-card-title class="headline">
{{ item.name }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-actions>
<v-btn text color="error" @click="deleteList(item.id)">
<v-icon left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-spacer></v-spacer>
<v-btn color="info" @click="list = item.id">
<v-icon left>
mdi-cart-check
</v-icon>
View
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-card v-else-if="activeList">
<v-card-title class="headline">
<TheCopyButton v-if="!edit" :copy-text="listAsText" color="info" />
<v-text-field label="Name" single-line dense v-if="edit" v-model="activeList.name"> </v-text-field>
<div v-else>
{{ activeList.name }}
</div>
<v-spacer></v-spacer>
<v-btn v-if="edit" color="success" @click="saveList">
Save
</v-btn>
<v-btn v-else color="info" @click="edit = true">
Edit
</v-btn>
</v-card-title>
<v-divider class="mx-2 mb-1"></v-divider>
<SearchDialog ref="searchRecipe" @select="importIngredients" />
<v-card-text>
<v-row dense v-for="(item, index) in activeList.items" :key="index">
<v-col v-if="edit" cols="12" class="d-flex no-wrap align-center">
<p class="mb-0">Quantity: {{ item.quantity }}</p>
<div v-if="edit">
<v-btn x-small text class="ml-1" @click="activeList.items[index].quantity -= 1">
<v-icon>
mdi-minus
</v-icon>
</v-btn>
<v-btn x-small text class="mr-1" @click="activeList.items[index].quantity += 1">
<v-icon>
mdi-plus
</v-icon>
</v-btn>
</div>
<v-spacer></v-spacer>
<v-btn v-if="edit" icon @click="removeItemByIndex(index)" color="error">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-col>
<v-col cols="12" class="d-flex no-wrap align-center">
<v-checkbox
v-if="!edit"
hide-details
v-model="activeList.items[index].checked"
class="pt-0 my-auto py-auto"
color="secondary"
@change="saveList"
></v-checkbox>
<p v-if="!edit" class="mb-0">{{ item.quantity }}</p>
<v-icon v-if="!edit" small class="mx-3">
mdi-window-close
</v-icon>
<vue-markdown v-if="!edit" class="dense-markdown" :source="item.text"> </vue-markdown>
<v-textarea
single-line
rows="1"
auto-grow
class="mb-n2 pa-0"
dense
v-else
v-model="activeList.items[index].text"
></v-textarea>
</v-col>
<v-divider class="ma-1"></v-divider>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn v-if="edit" color="success" @click="openSearch">
<v-icon left>
mdi-silverware-variant
</v-icon>
From Recipe
</v-btn>
<v-btn v-if="edit" color="success" @click="newItem">
<v-icon left>
mdi-plus
</v-icon>
New
</v-btn>
</v-card-actions>
</v-card>
</v-slide-x-transition>
</v-container>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import SearchDialog from "@/components/UI/Search/SearchDialog";
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton";
import VueMarkdown from "@adapttive/vue-markdown";
import { api } from "@/api";
export default {
components: {
BaseDialog,
SearchDialog,
TheCopyButton,
VueMarkdown,
},
data() {
return {
newList: {
name: "",
group: "",
items: [],
},
activeList: null,
edit: false,
};
},
computed: {
group() {
return this.$store.getters.getCurrentGroup;
},
list: {
set(list) {
this.$router.replace({ query: { ...this.$route.query, list } });
},
get() {
return this.$route.query.list;
},
},
listAsText() {
const formatList = this.activeList.items.map(x => {
return `${x.quantity} - ${x.text}`;
});
return formatList.join("\n");
},
},
watch: {
group: {
immediate: true,
handler: "setActiveList",
},
list: {
immediate: true,
handler: "setActiveList",
},
},
methods: {
openSearch() {
this.$refs.searchRecipe.open();
},
async importIngredients(_, slug) {
const recipe = await api.recipes.requestDetails(slug);
const ingredients = recipe.recipeIngredient.map(x => ({
title: "",
text: x,
quantity: 1,
checked: false,
}));
this.activeList.items = [...this.activeList.items, ...ingredients];
this.consolidateList();
},
consolidateList() {
const allText = this.activeList.items.map(x => x.text);
const uniqueText = allText.filter((item, index) => {
return allText.indexOf(item) === index;
});
const newItems = uniqueText.map(x => {
let matchingItems = this.activeList.items.filter(y => y.text === x);
matchingItems[0].quantity = this.sumQuantiy(matchingItems);
return matchingItems[0];
});
this.activeList.items = newItems;
},
sumQuantiy(itemList) {
let quantity = 0;
itemList.forEach(element => {
quantity += element.quantity;
});
return quantity;
},
setActiveList() {
if (!this.list) return null;
if (!this.group.shoppingLists) return null;
this.activeList = this.group.shoppingLists.find(x => x.id == this.list);
},
async createNewList() {
this.newList.group = this.group.name;
await api.shoppingLists.createShoppingList(this.newList);
this.$store.dispatch("requestCurrentGroup");
},
async deleteList(id) {
await api.shoppingLists.deleteShoppingList(id);
this.$store.dispatch("requestCurrentGroup");
},
removeItemByIndex(index) {
this.activeList.items.splice(index, 1);
},
newItem() {
this.activeList.items.push({
title: null,
text: "",
quantity: 1,
checked: false,
});
},
async saveList() {
await this.consolidateList();
await api.shoppingLists.updateShoppingList(this.activeList.id, this.activeList);
this.edit = false;
},
},
};
</script>
<style >
</style>

View file

@ -1,9 +1,11 @@
import SearchPage from "@/pages/SearchPage"; import SearchPage from "@/pages/SearchPage";
import HomePage from "@/pages/HomePage"; import HomePage from "@/pages/HomePage";
import ShoppingList from "@/pages/ShoppingList";
export const generalRoutes = [ export const generalRoutes = [
{ path: "/", name: "home", component: HomePage }, { path: "/", name: "home", component: HomePage },
{ path: "/mealie", component: HomePage }, { path: "/mealie", component: HomePage },
{ path: "/shopping-list", component: ShoppingList },
{ {
path: "/search", path: "/search",
component: SearchPage, component: SearchPage,

View file

@ -5,18 +5,14 @@ import { store } from "@/store";
export const utils = { export const utils = {
recipe: recipe, recipe: recipe,
getImageURL(image) {
return `/api/recipes/${image}/image?image_type=small`;
},
generateUniqueKey(item, index) { generateUniqueKey(item, index) {
const uniqueKey = `${item}-${index}`; const uniqueKey = `${item}-${index}`;
return uniqueKey; return uniqueKey;
}, },
getDateAsPythonDate(dateObject) { getDateAsPythonDate(dateObject) {
const month = dateObject.getUTCMonth() + 1; const month = dateObject.getMonth() + 1;
const day = dateObject.getUTCDate(); const day = dateObject.getDate();
const year = dateObject.getFullYear(); const year = dateObject.getFullYear();
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}, },
notify: { notify: {

View file

@ -9,6 +9,7 @@ from mealie.routes.groups import groups_router
from mealie.routes.mealplans import meal_plan_router from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router from mealie.routes.media import media_router
from mealie.routes.recipe import recipe_router from mealie.routes.recipe import recipe_router
from mealie.routes.shopping_list import shopping_list_router
from mealie.routes.site_settings import settings_router from mealie.routes.site_settings import settings_router
from mealie.routes.users import user_router from mealie.routes.users import user_router
from mealie.services.events import create_general_event from mealie.services.events import create_general_event
@ -32,6 +33,7 @@ def api_routers():
# Authentication # Authentication
app.include_router(user_router) app.include_router(user_router)
app.include_router(groups_router) app.include_router(groups_router)
app.include_router(shopping_list_router)
# Recipes # Recipes
app.include_router(recipe_router) app.include_router(recipe_router)
app.include_router(media_router) app.include_router(media_router)

View file

@ -3,19 +3,21 @@ from logging import getLogger
from mealie.db.db_base import BaseDocument from mealie.db.db_base import BaseDocument
from mealie.db.models.event import Event, EventNotification from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
from mealie.db.models.settings import CustomPage, SiteSettings from mealie.db.models.settings import CustomPage, SiteSettings
from mealie.db.models.shopping_list import ShoppingList
from mealie.db.models.sign_up import SignUp from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users import LongLiveToken, User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.event_notifications import EventNotificationIn from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut from mealie.schema.settings import CustomPageOut
from mealie.schema.settings import SiteSettings as SiteSettingsSchema from mealie.schema.settings import SiteSettings as SiteSettingsSchema
from mealie.schema.shopping_list import ShoppingListOut
from mealie.schema.sign_up import SignUpOut from mealie.schema.sign_up import SignUpOut
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB
@ -75,8 +77,8 @@ class _Tags(BaseDocument):
class _Meals(BaseDocument): class _Meals(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "uid" self.primary_key = "uid"
self.sql_model = MealPlanModel self.sql_model = MealPlan
self.schema = MealPlanInDB self.schema = MealPlanOut
class _Settings(BaseDocument): class _Settings(BaseDocument):
@ -120,7 +122,7 @@ class _Groups(BaseDocument):
self.sql_model = Group self.sql_model = Group
self.schema = GroupInDB self.schema = GroupInDB
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]: def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanOut]:
"""A Helper function to get the group from the database and return a sorted list of """A Helper function to get the group from the database and return a sorted list of
Args: Args:
@ -129,13 +131,20 @@ class _Groups(BaseDocument):
match_key (str, optional): Match Key. Defaults to "name". match_key (str, optional): Match Key. Defaults to "name".
Returns: Returns:
list[MealPlanInDB]: [description] list[MealPlanOut]: [description]
""" """
group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none() group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none()
return group.mealplans return group.mealplans
class _ShoppingList(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = ShoppingList
self.schema = ShoppingListOut
class _SignUps(BaseDocument): class _SignUps(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "token" self.primary_key = "token"
@ -179,6 +188,7 @@ class Database:
self.custom_pages = _CustomPages() self.custom_pages = _CustomPages()
self.events = _Events() self.events = _Events()
self.event_notifications = _EventNotification() self.event_notifications = _EventNotification()
self.shopping_lists = _ShoppingList()
db = Database() db = Database()

View file

@ -3,6 +3,7 @@ from mealie.db.models.group import *
from mealie.db.models.mealplan import * from mealie.db.models.mealplan import *
from mealie.db.models.recipe.recipe import * from mealie.db.models.recipe.recipe import *
from mealie.db.models.settings import * from mealie.db.models.settings import *
from mealie.db.models.shopping_list import *
from mealie.db.models.sign_up import * from mealie.db.models.sign_up import *
from mealie.db.models.theme import * from mealie.db.models.theme import *
from mealie.db.models.users import * from mealie.db.models.users import *

View file

@ -19,11 +19,18 @@ class Group(SqlAlchemyBase, BaseMixins):
name = sa.Column(sa.String, index=True, nullable=False, unique=True) name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group") users = orm.relationship("User", back_populates="group")
mealplans = orm.relationship( mealplans = orm.relationship(
"MealPlanModel", "MealPlan",
back_populates="group", back_populates="group",
single_parent=True, single_parent=True,
order_by="MealPlanModel.startDate", order_by="MealPlan.start_date",
) )
shopping_lists = orm.relationship(
"ShoppingList",
back_populates="group",
single_parent=True,
)
categories = orm.relationship("Category", secondary=group2categories, single_parent=True) categories = orm.relationship("Category", secondary=group2categories, single_parent=True)
# Webhook Settings # Webhook Settings
@ -32,16 +39,7 @@ class Group(SqlAlchemyBase, BaseMixins):
webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan") webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan")
def __init__( def __init__(
self, self, name, categories=[], session=None, webhook_enable=False, webhook_time="00:00", webhook_urls=[], **_
name,
id=None,
users=None,
mealplans=None,
categories=[],
session=None,
webhook_enable=False,
webhook_time="00:00",
webhook_urls=[],
) -> None: ) -> None:
self.name = name self.name = name
self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories] self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories]

View file

@ -1,50 +1,80 @@
from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.shopping_list import ShoppingList
from sqlalchemy import Column, Date, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
class Meal(SqlAlchemyBase): class Meal(SqlAlchemyBase):
__tablename__ = "meal" __tablename__ = "meal"
id = sa.Column(sa.Integer, primary_key=True) id = Column(Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("mealplan.uid")) parent_id = Column(Integer, ForeignKey("mealdays.id"))
slug = sa.Column(sa.String) position = Column(Integer)
name = sa.Column(sa.String) name = Column(String)
date = sa.Column(sa.Date) slug = Column(String)
image = sa.Column(sa.String) description = Column(String)
description = sa.Column(sa.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
def __init__(self, slug, name, date, image, description, session=None) -> None:
self.slug = slug
self.name = name self.name = name
self.date = date
self.image = image
self.description = description self.description = description
class MealPlanModel(SqlAlchemyBase, BaseMixins): class MealDay(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealdays"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("mealplan.uid"))
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" __tablename__ = "mealplan"
uid = sa.Column(sa.Integer, primary_key=True, unique=True) # ! Probably Bad? uid = Column(Integer, primary_key=True, unique=True)
startDate = sa.Column(sa.Date) start_date = Column(Date)
endDate = sa.Column(sa.Date) end_date = Column(Date)
meals: List[Meal] = orm.relationship(Meal, cascade="all, delete, delete-orphan") plan_days: list[MealDay] = orm.relationship(MealDay, cascade="all, delete, delete-orphan")
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans") group = orm.relationship("Group", back_populates="mealplans")
def __init__(self, startDate, endDate, meals, group: str, uid=None, session=None) -> None: shopping_list_id = Column(Integer, ForeignKey("shopping_lists.id"))
self.startDate = startDate shopping_list: ShoppingList = orm.relationship("ShoppingList", single_parent=True)
self.endDate = endDate
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) self.group = Group.get_ref(session, group)
self.meals = [Meal(**meal) for meal in meals]
def update(self, session, startDate, endDate, meals, uid, group) -> None: if shopping_list:
self.shopping_list = ShoppingList.get_ref(session, shopping_list)
self.__init__( self.plan_days = [MealDay(**day, session=session) for day in plan_days]
startDate=startDate,
endDate=endDate,
meals=meals,
group=group,
session=session,
)

View file

@ -0,0 +1,49 @@
import sqlalchemy.orm as orm
from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from requests import Session
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("shopping_lists.id"))
position = Column(Integer, nullable=False)
title = Column(String)
text = Column(String)
quantity = Column(Integer)
checked = Column(Boolean)
def __init__(self, title, text, quantity, checked, **_) -> None:
self.title = title
self.text = text
self.quantity = quantity
self.checked = checked
class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String)
items: list[ShoppingListItem] = orm.relationship(
ShoppingListItem,
cascade="all, delete, delete-orphan",
order_by="ShoppingListItem.position",
collection_class=ordering_list("position"),
)
def __init__(self, name, group, items, session=None, **_) -> None:
self.name = name
self.group = Group.get_ref(session, group)
self.items = [ShoppingListItem(**i) for i in items]
@staticmethod
def get_ref(session: Session, id: int):
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()

View file

@ -2,18 +2,18 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanIn, MealPlanInDB from mealie.schema.meal import MealPlanIn, MealPlanOut
from mealie.schema.user import GroupInDB, UserInDB from mealie.schema.user import GroupInDB, UserInDB
from mealie.services.events import create_group_event from mealie.services.events import create_group_event
from mealie.services.image import image from mealie.services.image import image
from mealie.services.meal_services import get_todays_meal, process_meals from mealie.services.meal_services import get_todays_meal, set_mealplan_dates
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@router.get("/all", response_model=list[MealPlanInDB]) @router.get("/all", response_model=list[MealPlanOut])
def get_all_meals( def get_all_meals(
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
@ -31,11 +31,11 @@ def create_meal_plan(
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
): ):
""" Creates a meal plan database entry """ """ Creates a meal plan database entry """
processed_plan = process_meals(session, data) set_mealplan_dates(data)
background_tasks.add_task( background_tasks.add_task(
create_group_event, "Meal Plan Created", f"Mealplan Created for '{current_user.group}'", session=session create_group_event, "Meal Plan Created", f"Mealplan Created for '{current_user.group}'", session=session
) )
return db.meals.create(session, processed_plan.dict()) return db.meals.create(session, data.dict())
@router.put("/{plan_id}") @router.put("/{plan_id}")
@ -47,8 +47,8 @@ def update_meal_plan(
current_user: UserInDB = Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
): ):
""" Updates a meal plan based off ID """ """ Updates a meal plan based off ID """
processed_plan = process_meals(session, meal_plan) set_mealplan_dates(meal_plan)
processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict()) processed_plan = MealPlanOut(uid=plan_id, **meal_plan.dict())
try: try:
db.meals.update(session, plan_id, processed_plan.dict()) db.meals.update(session, plan_id, processed_plan.dict())
background_tasks.add_task( background_tasks.add_task(
@ -76,7 +76,7 @@ def delete_meal_plan(
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.get("/this-week", response_model=MealPlanInDB) @router.get("/this-week", response_model=MealPlanOut)
def get_this_week(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)): def get_this_week(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)):
""" Returns the meal plan data for this week """ """ Returns the meal plan data for this week """
plans = db.groups.get_meals(session, current_user.group) plans = db.groups.get_meals(session, current_user.group)

View file

@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from mealie.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.shopping_list import ListItem, ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
logger = get_logger()
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@ -13,12 +18,32 @@ router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
def get_shopping_list( def get_shopping_list(
id: str, id: str,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
): ):
# ! Refactor into Single Database Call mealplan: MealPlanOut = db.meals.get(session, id)
mealplan = db.meals.get(session, id)
mealplan: MealPlanInDB all_ingredients = []
slugs = [x.slug for x in mealplan.meals]
recipes: list[Recipe] = [db.recipes.get(session, x) for x in slugs] for plan_day in mealplan.plan_days:
return [{"name": x.name, "recipe_ingredient": x.recipe_ingredient} for x in recipes if x] for meal in plan_day.meals:
if not meal.slug:
continue
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
all_ingredients += recipe.recipe_ingredient
except Exception:
logger.error("Recipe Not Found")
new_list = ShoppingListIn(
name="MealPlan Shopping List", group=current_user.group, items=[ListItem(text=t) for t in all_ingredients]
)
created_list: ShoppingListOut = db.shopping_lists.create(session, new_list)
mealplan.shopping_list = created_list.id
db.meals.update(session, mealplan.uid, mealplan)
return created_list

View file

@ -5,10 +5,7 @@ from mealie.routes.deps import get_current_user
from mealie.schema.category import CategoryIn, RecipeCategoryResponse from mealie.schema.category import CategoryIn, RecipeCategoryResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter( router = APIRouter(prefix="/api/categories", tags=["Recipe Categories"])
prefix="/api/categories",
tags=["Recipe Categories"],
)
@router.get("") @router.get("")

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.shopping_list import ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session
shopping_list_router = APIRouter(prefix="/api/shopping-lists", tags=["Shopping Lists"])
@shopping_list_router.post("", response_model=ShoppingListOut)
async def create_shopping_list(
list_in: ShoppingListIn,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Create Shopping List in the Database """
list_in.group = current_user.group
return db.shopping_lists.create(session, list_in)
@shopping_list_router.get("/{id}", response_model=ShoppingListOut)
async def get_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Get Shopping List from the Database """
return db.shopping_lists.get(session, id)
@shopping_list_router.put("/{id}", dependencies=[Depends(get_current_user)], response_model=ShoppingListOut)
async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)):
""" Update Shopping List in the Database """
return db.shopping_lists.update(session, id, new_data)
@shopping_list_router.delete("/{id}", dependencies=[Depends(get_current_user)])
async def delete_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Delete Shopping List from the Database """
return db.shopping_lists.delete(session, id)

View file

@ -1,51 +1,70 @@
from datetime import date from datetime import date
from typing import List, Optional from typing import Optional
from mealie.db.models.mealplan import MealPlanModel from fastapi_camelcase import CamelModel
from pydantic import BaseModel, validator from mealie.db.models.mealplan import MealPlan
from pydantic import validator
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
class MealIn(BaseModel): class MealIn(CamelModel):
name: Optional[str]
slug: Optional[str] slug: Optional[str]
date: Optional[date] name: Optional[str]
class MealOut(MealIn):
image: Optional[str]
description: Optional[str] description: Optional[str]
class Config: class Config:
orm_mode = True orm_mode = True
class MealPlanIn(BaseModel): class MealDayIn(CamelModel):
group: str date: Optional[date]
startDate: date meals: list[MealIn]
endDate: date
meals: List[MealIn]
@validator("endDate") class Config:
def endDate_after_startDate(v, values, config, field): orm_mode = True
if "startDate" in values and v < values["startDate"]:
class MealDayOut(MealDayIn):
id: int
class Config:
orm_mode = True
class MealPlanIn(CamelModel):
group: str
start_date: date
end_date: date
plan_days: list[MealDayIn]
@validator("end_date")
def end_date_after_start_date(v, values, config, field):
if "start_date" in values and v < values["start_date"]:
raise ValueError("EndDate should be greater than StartDate") raise ValueError("EndDate should be greater than StartDate")
return v return v
class Config:
class MealPlanProcessed(MealPlanIn): orm_mode = True
meals: list[MealOut]
class MealPlanInDB(MealPlanProcessed): class MealPlanOut(MealPlanIn):
uid: str uid: int
shopping_list: Optional[int]
class Config: class Config:
orm_mode = True orm_mode = True
@classmethod @classmethod
def getter_dict(_cls, name_orm: MealPlanModel): def getter_dict(_cls, name_orm: MealPlan):
return { try:
**GetterDict(name_orm), return {
"group": name_orm.group.name, **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,35 @@
from typing import Optional
from fastapi_camelcase import CamelModel
from mealie.db.models.shopping_list import ShoppingList
from pydantic.utils import GetterDict
class ListItem(CamelModel):
title: Optional[str]
text: str = ""
quantity: int = 1
checked: bool = False
class Config:
orm_mode = True
class ShoppingListIn(CamelModel):
name: str
group: Optional[str]
items: list[ListItem]
class ShoppingListOut(ShoppingListIn):
id: int
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: ShoppingList):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
}

View file

@ -5,7 +5,8 @@ from mealie.core.config import settings
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.users import User from mealie.db.models.users import User
from mealie.schema.category import CategoryBase from mealie.schema.category import CategoryBase
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanOut
from mealie.schema.shopping_list import ShoppingListOut
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -105,7 +106,8 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup): class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]] users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanInDB]] mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
class Config: class Config:
orm_mode = True orm_mode = True

View file

@ -3,41 +3,16 @@ from typing import Union
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import create_session from mealie.db.db_setup import create_session
from mealie.schema.meal import MealIn, MealOut, MealPlanIn, MealPlanInDB, MealPlanProcessed from mealie.schema.meal import MealDayIn, MealPlanIn
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.user import GroupInDB from mealie.schema.user import GroupInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
def process_meals(session: Session, meal_plan_base: MealPlanIn) -> MealPlanProcessed: def set_mealplan_dates(meal_plan_base: MealPlanIn) -> MealPlanIn:
meals = [] for x, plan_days in enumerate(meal_plan_base.plan_days):
for x, meal in enumerate(meal_plan_base.meals): plan_days: MealDayIn
meal: MealIn plan_days.date = meal_plan_base.start_date + timedelta(days=x)
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
meal_data = MealOut(
slug=recipe.slug,
name=recipe.name,
date=meal_plan_base.startDate + timedelta(days=x),
image=recipe.image,
description=recipe.description,
)
except Exception:
meal_data = MealOut(
date=meal_plan_base.startDate + timedelta(days=x),
)
meals.append(meal_data)
return MealPlanProcessed(
group=meal_plan_base.group,
meals=meals,
startDate=meal_plan_base.startDate,
endDate=meal_plan_base.endDate,
)
def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe: def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
@ -52,6 +27,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
Returns: Returns:
Recipe: Pydantic Recipe Object Recipe: Pydantic Recipe Object
""" """
session = session or create_session() session = session or create_session()
if isinstance(group, int): if isinstance(group, int):
@ -60,12 +36,12 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
today_slug = None today_slug = None
for mealplan in group.mealplans: for mealplan in group.mealplans:
mealplan: MealPlanInDB for plan_day in mealplan.plan_days:
for meal in mealplan.meals: if plan_day.date == date.today():
meal: MealOut if plan_day.meals[0].slug and plan_day.meals[0].slug != "":
if meal.date == date.today(): today_slug = plan_day.meals[0].slug
today_slug = meal.slug else:
break return plan_day.meals[0]
if today_slug: if today_slug:
return db.recipes.get(session, today_slug) return db.recipes.get(session, today_slug)

1
scratch.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -33,6 +33,7 @@ def test_update_group(api_client: TestClient, api_routes: AppRoutes, token):
"webhookEnable": False, "webhookEnable": False,
"users": [], "users": [],
"mealplans": [], "mealplans": [],
"shoppingLists": [],
} }
# Test Update # Test Update
response = api_client.put(api_routes.groups_id(2), json=new_data, headers=token) response = api_client.put(api_routes.groups_id(2), json=new_data, headers=token)

View file

@ -1,104 +1,104 @@
import json # import json
import pytest # import pytest
from fastapi.testclient import TestClient # from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes # from tests.app_routes import AppRoutes
from tests.utils.recipe_data import RecipeTestData # from tests.utils.recipe_data import RecipeTestData
def get_meal_plan_template(first=None, second=None): # def get_meal_plan_template(first=None, second=None):
return { # return {
"group": "Home", # "group": "Home",
"startDate": "2021-01-18", # "startDate": "2021-01-18",
"endDate": "2021-01-19", # "endDate": "2021-01-19",
"meals": [ # "meals": [
{ # {
"slug": first, # "slug": first,
"date": "2021-1-17", # "date": "2021-1-17",
}, # },
{ # {
"slug": second, # "slug": second,
"date": "2021-1-18", # "date": "2021-1-18",
}, # },
], # ],
} # }
@pytest.fixture(scope="session") # @pytest.fixture(scope="session")
def slug_1(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeTestData]): # def slug_1(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeTestData]):
# Slug 1 # # Slug 1
slug_1 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[0].url}, headers=token) # slug_1 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[0].url}, headers=token)
slug_1 = json.loads(slug_1.content) # slug_1 = json.loads(slug_1.content)
yield slug_1 # yield slug_1
api_client.delete(api_routes.recipes_recipe_slug(slug_1)) # api_client.delete(api_routes.recipes_recipe_slug(slug_1))
@pytest.fixture(scope="session") # @pytest.fixture(scope="session")
def slug_2(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeTestData]): # def slug_2(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeTestData]):
# Slug 2 # # Slug 2
slug_2 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[1].url}, headers=token) # slug_2 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[1].url}, headers=token)
slug_2 = json.loads(slug_2.content) # slug_2 = json.loads(slug_2.content)
yield slug_2 # yield slug_2
api_client.delete(api_routes.recipes_recipe_slug(slug_2)) # api_client.delete(api_routes.recipes_recipe_slug(slug_2))
def test_create_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): # def test_create_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token):
meal_plan = get_meal_plan_template(slug_1, slug_2) # meal_plan = get_meal_plan_template(slug_1, slug_2)
response = api_client.post(api_routes.meal_plans_create, json=meal_plan, headers=token) # response = api_client.post(api_routes.meal_plans_create, json=meal_plan, headers=token)
assert response.status_code == 201 # assert response.status_code == 201
def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): # def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token):
response = api_client.get(api_routes.meal_plans_all, headers=token) # response = api_client.get(api_routes.meal_plans_all, headers=token)
assert response.status_code == 200 # assert response.status_code == 200
meal_plan = get_meal_plan_template(slug_1, slug_2) # meal_plan = get_meal_plan_template(slug_1, slug_2)
new_meal_plan = json.loads(response.text) # new_meal_plan = json.loads(response.text)
meals = new_meal_plan[0]["meals"] # meals = new_meal_plan[0]["meals"]
assert meals[0]["slug"] == meal_plan["meals"][0]["slug"] # assert meals[0]["slug"] == meal_plan["meals"][0]["slug"]
assert meals[1]["slug"] == meal_plan["meals"][1]["slug"] # assert meals[1]["slug"] == meal_plan["meals"][1]["slug"]
def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): # def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token):
response = api_client.get(api_routes.meal_plans_all, headers=token) # response = api_client.get(api_routes.meal_plans_all, headers=token)
existing_mealplan = json.loads(response.text) # existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0] # existing_mealplan = existing_mealplan[0]
# Swap # # Swap
plan_uid = existing_mealplan.get("uid") # plan_uid = existing_mealplan.get("uid")
existing_mealplan["meals"][0]["slug"] = slug_2 # existing_mealplan["meals"][0]["slug"] = slug_2
existing_mealplan["meals"][1]["slug"] = slug_1 # existing_mealplan["meals"][1]["slug"] = slug_1
response = api_client.put(api_routes.meal_plans_plan_id(plan_uid), json=existing_mealplan, headers=token) # response = api_client.put(api_routes.meal_plans_plan_id(plan_uid), json=existing_mealplan, headers=token)
assert response.status_code == 200 # assert response.status_code == 200
response = api_client.get(api_routes.meal_plans_all, headers=token) # response = api_client.get(api_routes.meal_plans_all, headers=token)
existing_mealplan = json.loads(response.text) # existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0] # existing_mealplan = existing_mealplan[0]
assert existing_mealplan["meals"][0]["slug"] == slug_2 # assert existing_mealplan["meals"][0]["slug"] == slug_2
assert existing_mealplan["meals"][1]["slug"] == slug_1 # assert existing_mealplan["meals"][1]["slug"] == slug_1
def test_delete_mealplan(api_client: TestClient, api_routes: AppRoutes, token): # def test_delete_mealplan(api_client: TestClient, api_routes: AppRoutes, token):
response = api_client.get(api_routes.meal_plans_all, headers=token) # response = api_client.get(api_routes.meal_plans_all, headers=token)
assert response.status_code == 200 # assert response.status_code == 200
existing_mealplan = json.loads(response.text) # existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0] # existing_mealplan = existing_mealplan[0]
plan_uid = existing_mealplan.get("uid") # plan_uid = existing_mealplan.get("uid")
response = api_client.delete(api_routes.meal_plans_plan_id(plan_uid), headers=token) # response = api_client.delete(api_routes.meal_plans_plan_id(plan_uid), headers=token)
assert response.status_code == 200 # assert response.status_code == 200