[Feat] Migrate from Pages to Cookbooks (#664)

* feat:  Add Description to Cookbooks

* feat(frontend):  Cookbook view page

* feat(frontend): 💄 Add final UI touches

* fix(backend): 🐛 Add get by slug or id

* fix linting issue

* test(backend):  Update tests from pages -> cookbooks

* refactor(backend): 🔥 Delete old page files

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-08-31 18:51:34 -08:00 committed by GitHub
parent 165fd8efd6
commit 9b1bf56a5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 167 additions and 173 deletions

View File

@ -11,6 +11,7 @@ export interface CreateCookBook {
export interface CookBook extends CreateCookBook { export interface CookBook extends CreateCookBook {
id: number; id: number;
slug: string; slug: string;
description: string;
position: number; position: number;
group_id: number; group_id: number;
categories: Category[] | CategoryBase[]; categories: Category[] | CategoryBase[];

View File

@ -5,6 +5,22 @@ import { CookBook } from "~/api/class-interfaces/cookbooks";
let cookbookStore: Ref<CookBook[] | null> | null = null; let cookbookStore: Ref<CookBook[] | null> | null = null;
export const useCookbook = function () {
function getOne(id: string | number) {
const api = useApiSingleton();
const units = useAsync(async () => {
const { data } = await api.cookbooks.getOne(id);
return data;
}, useAsyncKey());
return units;
}
return { getOne };
};
export const useCookbooks = function () { export const useCookbooks = function () {
const api = useApiSingleton(); const api = useApiSingleton();
const loading = ref(false); const loading = ref(false);
@ -45,10 +61,10 @@ export const useCookbooks = function () {
loading.value = true; loading.value = true;
const { data } = await api.cookbooks.createOne({ const { data } = await api.cookbooks.createOne({
// @ts-ignore. I"m thinking this will always be defined. // @ts-ignore. I"m thinking this will always be defined.
name: "New Cookbook" + String(cookbookStore?.value?.length + 1 || 1), name: "Cookbook " + String(cookbookStore?.value?.length + 1 || 1),
}); });
if (data && cookbookStore?.value) { if (data && cookbookStore?.value) {
cookbookStore.value.unshift(data); cookbookStore.value.push(data);
} else { } else {
this.refreshAll(); this.refreshAll();
} }

View File

@ -402,7 +402,9 @@
}, },
"sidebar": { "sidebar": {
"all-recipes": "All Recipes", "all-recipes": "All Recipes",
"backups": "Backups",
"categories": "Categories", "categories": "Categories",
"cookbooks": "Cookbooks",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"home-page": "Home Page", "home-page": "Home Page",
"manage-users": "Manage Users", "manage-users": "Manage Users",
@ -411,8 +413,7 @@
"search": "Search", "search": "Search",
"site-settings": "Site Settings", "site-settings": "Site Settings",
"tags": "Tags", "tags": "Tags",
"toolbox": "Toolbox", "toolbox": "Toolbox"
"backups": "Backups"
}, },
"signup": { "signup": {
"error-signing-up": "Error Signing Up", "error-signing-up": "Error Signing Up",

View File

@ -58,8 +58,8 @@ export default defineComponent({
}, },
{ {
icon: this.$globals.icons.pages, icon: this.$globals.icons.pages,
to: "/user/group/pages", to: "/user/group/cookbooks",
title: this.$t("settings.pages"), title: this.$t("sidebar.cookbooks"),
}, },
], ],
adminLinks: [ adminLinks: [

View File

@ -0,0 +1,49 @@
<template>
<v-container v-if="book" fluid>
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
</v-app-bar>
<v-card flat>
<v-card-text class="py-0">
{{ book.description }}
</v-card-text>
</v-card>
<v-tabs v-model="tab" show-arrows>
<v-tab v-for="(cat, index) in book.categories" :key="index">
{{ cat.name }}
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item v-for="(cat, idx) in book.categories" :key="`tabs` + idx">
<RecipeCardSection class="mb-5 mx-1" :recipes="cat.recipes" />
</v-tab-item>
</v-tabs-items>
</v-container>
</template>
<script lang="ts">
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
import { useCookbook } from "~/composables/use-cookbooks";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const route = useRoute();
const slug = route.value.params.slug;
const { getOne } = useCookbook();
const tab = ref(null);
const book = getOne(slug);
return {
book,
tab,
};
},
});
</script>
<style scoped>
</style>

View File

@ -6,27 +6,33 @@
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()"> <draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
<v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 my-border rounded"> <v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 my-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline"> <v-expansion-panel-header disable-icon-rotate class="headline">
{{ cookbook.name }} <div class="d-flex align-center">
<v-icon large left>
{{ $globals.icons.pages }}
</v-icon>
{{ cookbook.name }}
</div>
<template #actions> <template #actions>
<v-btn color="info" fab small class="ml-auto mr-2"> <v-icon class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
<v-btn color="info" fab small class="ml-2">
<v-icon color="white"> <v-icon color="white">
{{ $globals.icons.edit }} {{ $globals.icons.edit }}
</v-icon> </v-icon>
</v-btn> </v-btn>
<v-icon class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template> </template>
</v-expansion-panel-header> </v-expansion-panel-header>
<v-expansion-panel-content> <v-expansion-panel-content>
<v-card-text> <v-card-text>
<v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field> <v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field>
<v-textarea v-model="cookbooks[index].description" auto-grow :rows="2" label="Description"></v-textarea>
<DomainRecipeCategoryTagSelector v-model="cookbooks[index].categories" /> <DomainRecipeCategoryTagSelector v-model="cookbooks[index].categories" />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseButton delete @click="actions.deleteOne(cookbook.id)" /> <BaseButton delete @click="actions.deleteOne(cookbook.id)" />
<BaseButton update @click="actions.updateOne(cookbook)"> </BaseButton> <BaseButton save @click="actions.updateOne(cookbook)"> </BaseButton>
</v-card-actions> </v-card-actions>
</v-expansion-panel-content> </v-expansion-panel-content>
</v-expansion-panel> </v-expansion-panel>

View File

@ -10,6 +10,7 @@ class CookBook(SqlAlchemyBase, BaseMixins):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
position = Column(Integer, nullable=False) position = Column(Integer, nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(String, default="")
slug = Column(String, nullable=False) slug = Column(String, nullable=False)
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)

View File

@ -44,7 +44,6 @@ def export_database(background_tasks: BackgroundTasks, data: BackupJob, session:
templates=data.templates, templates=data.templates,
export_recipes=data.options.recipes, export_recipes=data.options.recipes,
export_settings=data.options.settings, export_settings=data.options.settings,
export_pages=data.options.pages,
export_users=data.options.users, export_users=data.options.users,
export_groups=data.options.groups, export_groups=data.options.groups,
export_notifications=data.options.notifications, export_notifications=data.options.notifications,
@ -92,7 +91,6 @@ def import_database(
archive=import_data.name, archive=import_data.name,
import_recipes=import_data.recipes, import_recipes=import_data.recipes,
import_settings=import_data.settings, import_settings=import_data.settings,
import_pages=import_data.pages,
import_users=import_data.users, import_users=import_data.users,
import_groups=import_data.groups, import_groups=import_data.groups,
force_import=import_data.force, force_import=import_data.force,

View File

@ -1,7 +1,7 @@
from fastapi import Depends from fastapi import Depends
from mealie.routes.routers import UserAPIRouter from mealie.routes.routers import UserAPIRouter
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook
from mealie.services.cookbook import CookbookService from mealie.services.cookbook import CookbookService
user_router = UserAPIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) user_router = UserAPIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
@ -28,7 +28,7 @@ def update_many(data: list[ReadCookBook], cb_service: CookbookService = Depends(
return cb_service.update_many(data) return cb_service.update_many(data)
@user_router.get("/{id}", response_model=ReadCookBook) @user_router.get("/{id}", response_model=RecipeCookBook)
def get_cookbook(cb_service: CookbookService = Depends(CookbookService.write_existing)): def get_cookbook(cb_service: CookbookService = Depends(CookbookService.write_existing)):
""" Get cookbook from the Database """ """ Get cookbook from the Database """
# Get Item # Get Item

View File

@ -1,10 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import custom_pages, site_settings from . import site_settings
settings_router = APIRouter() settings_router = APIRouter()
settings_router.include_router(custom_pages.public_router)
settings_router.include_router(custom_pages.admin_router)
settings_router.include_router(site_settings.public_router) settings_router.include_router(site_settings.public_router)
settings_router.include_router(site_settings.admin_router) settings_router.include_router(site_settings.admin_router)

View File

@ -1,70 +0,0 @@
from typing import Union
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.admin import CustomPageBase, CustomPageOut
public_router = APIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"])
admin_router = AdminAPIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"])
@public_router.get("")
def get_custom_pages(session: Session = Depends(generate_session)):
""" Returns the sites custom pages """
return db.custom_pages.get_all(session)
@admin_router.post("")
async def create_new_page(
new_page: CustomPageBase,
session: Session = Depends(generate_session),
):
""" Creates a new Custom Page """
db.custom_pages.create(session, new_page.dict())
@admin_router.put("")
async def update_multiple_pages(pages: list[CustomPageOut], session: Session = Depends(generate_session)):
""" Update multiple custom pages """
for page in pages:
db.custom_pages.update(session, page.id, page.dict())
@public_router.get("/{id}")
async def get_single_page(
id: Union[int, str],
session: Session = Depends(generate_session),
):
""" Removes a custom page from the database """
if isinstance(id, int):
return db.custom_pages.get(session, id)
elif isinstance(id, str):
return db.custom_pages.get(session, id, "slug")
@admin_router.put("/{id}")
async def update_single_page(
data: CustomPageOut,
id: int,
session: Session = Depends(generate_session),
):
""" Removes a custom page from the database """
return db.custom_pages.update(session, id, data.dict())
@admin_router.delete("/{id}")
async def delete_custom_page(
id: int,
session: Session = Depends(generate_session),
):
""" Removes a custom page from the database """
db.custom_pages.delete(session, id)
return

View File

@ -7,7 +7,6 @@ from pydantic import BaseModel
class BackupOptions(BaseModel): class BackupOptions(BaseModel):
recipes: bool = True recipes: bool = True
settings: bool = True settings: bool = True
pages: bool = True
themes: bool = True themes: bool = True
groups: bool = True groups: bool = True
users: bool = True users: bool = True

View File

@ -2,11 +2,12 @@ from fastapi_camelcase import CamelModel
from pydantic import validator from pydantic import validator
from slugify import slugify from slugify import slugify
from ..recipe.recipe_category import CategoryBase from ..recipe.recipe_category import CategoryBase, RecipeCategoryResponse
class CreateCookBook(CamelModel): class CreateCookBook(CamelModel):
name: str name: str
description: str = ""
slug: str = None slug: str = None
position: int = 1 position: int = 1
categories: list[CategoryBase] = [] categories: list[CategoryBase] = []
@ -36,3 +37,11 @@ class ReadCookBook(UpdateCookBook):
class Config: class Config:
orm_mode = True orm_mode = True
class RecipeCookBook(ReadCookBook):
group_id: int
categories: list[RecipeCategoryResponse]
class Config:
orm_mode = True

View File

@ -1,4 +1,4 @@
from typing import List, Optional from typing import List
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -23,9 +23,10 @@ class CategoryBase(CategoryIn):
class RecipeCategoryResponse(CategoryBase): class RecipeCategoryResponse(CategoryBase):
recipes: Optional[List["Recipe"]] recipes: List["Recipe"] = []
class Config: class Config:
orm_mode = True
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}} schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}

View File

@ -108,7 +108,6 @@ def backup_all(
templates=None, templates=None,
export_recipes=True, export_recipes=True,
export_settings=True, export_settings=True,
export_pages=True,
export_users=True, export_users=True,
export_groups=True, export_groups=True,
export_notifications=True, export_notifications=True,
@ -136,10 +135,6 @@ def backup_all(
all_settings = db.settings.get_all(session) all_settings = db.settings.get_all(session)
db_export.export_items(all_settings, "settings") db_export.export_items(all_settings, "settings")
if export_pages:
all_pages = db.custom_pages.get_all(session)
db_export.export_items(all_pages, "pages")
if export_notifications: if export_notifications:
all_notifications = db.event_notifications.get_all(session) all_notifications = db.event_notifications.get_all(session)
db_export.export_items(all_notifications, "notifications") db_export.export_items(all_notifications, "notifications")

View File

@ -11,8 +11,6 @@ from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
from mealie.schema.admin import ( from mealie.schema.admin import (
CommentImport, CommentImport,
CustomPageImport,
CustomPageOut,
GroupImport, GroupImport,
NotificationImport, NotificationImport,
RecipeImport, RecipeImport,
@ -189,19 +187,6 @@ class ImportDatabase:
return [import_status] return [import_status]
def import_pages(self):
pages_file = self.import_dir.joinpath("pages", "pages.json")
pages = ImportDatabase.read_models_file(pages_file, CustomPageOut)
page_imports = []
for page in pages:
import_stats = self.import_model(
db_table=db.custom_pages, model=page, return_model=CustomPageImport, name_attr="name", search_key="slug"
)
page_imports.append(import_stats)
return page_imports
def import_groups(self): def import_groups(self):
groups_file = self.import_dir.joinpath("groups", "groups.json") groups_file = self.import_dir.joinpath("groups", "groups.json")
groups = ImportDatabase.read_models_file(groups_file, UpdateGroup) groups = ImportDatabase.read_models_file(groups_file, UpdateGroup)
@ -326,7 +311,6 @@ def import_database(
archive, archive,
import_recipes=True, import_recipes=True,
import_settings=True, import_settings=True,
import_pages=True,
import_users=True, import_users=True,
import_groups=True, import_groups=True,
import_notifications=True, import_notifications=True,
@ -343,10 +327,6 @@ def import_database(
if import_settings: if import_settings:
settings_report = import_session.import_settings() settings_report = import_session.import_settings()
page_report = []
if import_pages:
page_report = import_session.import_pages()
group_report = [] group_report = []
if import_groups: if import_groups:
group_report = import_session.import_groups() group_report = import_session.import_groups()
@ -367,7 +347,6 @@ def import_database(
return { return {
"recipeImports": recipe_report, "recipeImports": recipe_report,
"settingsImports": settings_report, "settingsImports": settings_report,
"pageImports": page_report,
"groupImports": group_report, "groupImports": group_report,
"userImports": user_report, "userImports": user_report,
"notificationImports": notification_report, "notificationImports": notification_report,

View File

@ -1,14 +1,16 @@
from __future__ import annotations
from fastapi import HTTPException, status from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, SaveCookBook from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook
from mealie.services.base_http_service.base_http_service import BaseHttpService from mealie.services.base_http_service.base_http_service import BaseHttpService
from mealie.services.events import create_group_event from mealie.services.events import create_group_event
logger = get_logger(module=__name__) logger = get_logger(module=__name__)
class CookbookService(BaseHttpService[str, str]): class CookbookService(BaseHttpService[int, str]):
""" """
Class Methods: Class Methods:
`read_existing`: Reads an existing recipe from the database. `read_existing`: Reads an existing recipe from the database.
@ -39,8 +41,17 @@ class CookbookService(BaseHttpService[str, str]):
if self.cookbook.group_id != self.group_id: if self.cookbook.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN) raise HTTPException(status.HTTP_403_FORBIDDEN)
def populate_cookbook(self, id): def populate_cookbook(self, id: int | str):
self.cookbook = self.db.cookbooks.get(self.session, id) try:
id = int(id)
except Exception:
pass
if isinstance(id, int):
self.cookbook = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook)
else:
self.cookbook = self.db.cookbooks.get_one(self.session, id, key="slug", override_schema=RecipeCookBook)
def get_all(self) -> list[ReadCookBook]: def get_all(self) -> list[ReadCookBook]:
items = self.db.cookbooks.get(self.session, self.group_id, "group_id", limit=999) items = self.db.cookbooks.get(self.session, self.group_id, "group_id", limit=999)

View File

@ -134,7 +134,9 @@ def insideParenthesis(token, tokens):
return True return True
else: else:
line = " ".join(tokens) line = " ".join(tokens)
return re.match(r".*\(.*" + re.escape(token) + ".*\).*", line) is not None return (
re.match(r".*\(.*" + re.escape(token) + ".*\).*", line) is not None # noqa: W605 - invalid dscape sequence
)
def displayIngredient(ingredient): def displayIngredient(ingredient):
@ -222,7 +224,7 @@ def import_data(lines):
# turn B-NAME/123 back into "name" # turn B-NAME/123 back into "name"
tag, confidence = re.split(r"/", columns[-1], 1) tag, confidence = re.split(r"/", columns[-1], 1)
tag = re.sub("^[BI]\-", "", tag).lower() tag = re.sub("^[BI]\-", "", tag).lower() # noqa: W605 - invalid dscape sequence
# ---- DISPLAY ---- # ---- DISPLAY ----
# build a structure which groups each token by its tag, so we can # build a structure which groups each token by its tag, so we can

View File

@ -28,7 +28,7 @@ class AppRoutes:
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.group_cookbook = "/api/groups/cookbooks"
self.site_settings = "/api/site-settings" self.site_settings = "/api/site-settings"
self.site_settings_webhooks_test = "/api/site-settings/webhooks/test" self.site_settings_webhooks_test = "/api/site-settings/webhooks/test"
self.themes = "/api/themes" self.themes = "/api/themes"
@ -95,8 +95,8 @@ class AppRoutes:
def meal_plans_id_shopping_list(self, id): def meal_plans_id_shopping_list(self, id):
return f"{self.prefix}/meal-plans/{id}/shopping-list" return f"{self.prefix}/meal-plans/{id}/shopping-list"
def site_settings_custom_pages_id(self, id): def group_cookbook_id(self, id):
return f"{self.prefix}/site-settings/custom-pages/{id}" return f"{self.prefix}/groups/cookbooks/{id}"
def themes_id(self, id): def themes_id(self, id):
return f"{self.prefix}/themes/{id}" return f"{self.prefix}/themes/{id}"

View File

@ -1,44 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
@pytest.fixture()
def page_data():
return {"name": "My New Page", "position": 0, "categories": []}
def test_create_page(api_client: TestClient, api_routes: AppRoutes, admin_token, page_data):
response = api_client.post(api_routes.site_settings_custom_pages, json=page_data, headers=admin_token)
assert response.status_code == 200
def test_read_page(api_client: TestClient, api_routes: AppRoutes, page_data):
response = api_client.get(api_routes.site_settings_custom_pages_id(1))
page_data["id"] = 1
page_data["slug"] = "my-new-page"
assert json.loads(response.text) == page_data
def test_update_page(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token):
page_data["id"] = 1
page_data["name"] = "My New Name"
response = api_client.put(api_routes.site_settings_custom_pages_id(1), json=page_data, headers=admin_token)
assert response.status_code == 200
def test_delete_page(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.site_settings_custom_pages_id(1), headers=admin_token)
assert response.status_code == 200
response = api_client.get(api_routes.site_settings_custom_pages_id(1))
assert json.loads(response.text) is None

View File

@ -0,0 +1,43 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
@pytest.fixture()
def page_data():
return {"name": "My New Page", "description": "", "position": 0, "categories": [], "groupId": 1}
def test_create_cookbook(api_client: TestClient, api_routes: AppRoutes, admin_token, page_data):
response = api_client.post(api_routes.group_cookbook, json=page_data, headers=admin_token)
assert response.status_code == 200
def test_read_cookbook(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token):
response = api_client.get(api_routes.group_cookbook_id(1), headers=admin_token)
page_data["id"] = 1
page_data["slug"] = "my-new-page"
assert json.loads(response.text) == page_data
def test_update_cookbook(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token):
page_data["id"] = 1
page_data["name"] = "My New Name"
response = api_client.put(api_routes.group_cookbook_id(1), json=page_data, headers=admin_token)
assert response.status_code == 200
def test_delete_cookbook(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.group_cookbook_id(1), headers=admin_token)
assert response.status_code == 200
response = api_client.get(api_routes.group_cookbook_id(1), headers=admin_token)
assert response.status_code == 404

View File

@ -15,7 +15,6 @@ def backup_data():
"settings": False, # ! Broken "settings": False, # ! Broken
"groups": True, "groups": True,
"users": True, "users": True,
"pages": True,
} }