Refactor/group page (#666)

* refactor(backend): ♻️ Refactor base class to be abstract and create a router factory method

* feat(frontend):  add group edit

* refactor(backend):  add group edit support

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-01 21:39:40 -08:00 committed by GitHub
parent 9b1bf56a5d
commit 990244e37e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 749 additions and 196 deletions

View File

@ -0,0 +1,25 @@
import { BaseCRUDAPI } from "./_base";
const prefix = "/api";
const routes = {
webhooks: `${prefix}/groups/webhooks`,
webhooksId: (id: string | number) => `${prefix}/groups/webhooks/${id}`,
};
export interface CreateGroupWebhook {
enabled: boolean;
name: string;
url: string;
time: string;
}
export interface GroupWebhook extends CreateGroupWebhook {
id: string;
groupId: string;
}
export class WebhooksAPI extends BaseCRUDAPI<GroupWebhook, CreateGroupWebhook> {
baseRoute = routes.webhooks;
itemRoute = routes.webhooksId;
}

View File

@ -1,4 +1,3 @@
import { requests } from "../requests";
import { BaseCRUDAPI } from "./_base"; import { BaseCRUDAPI } from "./_base";
import { GroupInDB } from "~/types/api-types/user"; import { GroupInDB } from "~/types/api-types/user";
@ -7,10 +6,17 @@ const prefix = "/api";
const routes = { const routes = {
groups: `${prefix}/groups`, groups: `${prefix}/groups`,
groupsSelf: `${prefix}/groups/self`, groupsSelf: `${prefix}/groups/self`,
categories: `${prefix}/groups/categories`,
groupsId: (id: string | number) => `${prefix}/groups/${id}`, groupsId: (id: string | number) => `${prefix}/groups/${id}`,
}; };
interface Category {
id: number;
name: string;
slug: string;
}
export interface CreateGroup { export interface CreateGroup {
name: string; name: string;
} }
@ -21,6 +27,14 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
/** Returns the Group Data for the Current User /** Returns the Group Data for the Current User
*/ */
async getCurrentUserGroup() { async getCurrentUserGroup() {
return await requests.get(routes.groupsSelf); return await this.requests.get(routes.groupsSelf);
}
async getCategories() {
return await this.requests.get<Category[]>(routes.categories);
}
async setCategories(payload: Category[]) {
return await this.requests.put<Category[]>(routes.categories, payload);
} }
} }

View File

@ -12,6 +12,7 @@ import { NotificationsAPI } from "./class-interfaces/event-notifications";
import { FoodAPI } from "./class-interfaces/recipe-foods"; import { FoodAPI } from "./class-interfaces/recipe-foods";
import { UnitAPI } from "./class-interfaces/recipe-units"; import { UnitAPI } from "./class-interfaces/recipe-units";
import { CookbookAPI } from "./class-interfaces/cookbooks"; import { CookbookAPI } from "./class-interfaces/cookbooks";
import { WebhooksAPI } from "./class-interfaces/group-webhooks";
import { ApiRequestInstance } from "~/types/api"; import { ApiRequestInstance } from "~/types/api";
class Api { class Api {
@ -29,6 +30,7 @@ class Api {
public foods: FoodAPI; public foods: FoodAPI;
public units: UnitAPI; public units: UnitAPI;
public cookbooks: CookbookAPI; public cookbooks: CookbookAPI;
public groupWebhooks: WebhooksAPI;
// Utils // Utils
public upload: UploadFile; public upload: UploadFile;
@ -49,6 +51,7 @@ class Api {
this.users = new UserApi(requests); this.users = new UserApi(requests);
this.groups = new GroupAPI(requests); this.groups = new GroupAPI(requests);
this.cookbooks = new CookbookAPI(requests); this.cookbooks = new CookbookAPI(requests);
this.groupWebhooks = new WebhooksAPI(requests);
// Admin // Admin
this.debug = new DebugAPI(requests); this.debug = new DebugAPI(requests);

View File

@ -0,0 +1,75 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { GroupWebhook } from "~/api/class-interfaces/group-webhooks";
export const useGroupWebhooks = function () {
const api = useApiSingleton();
const loading = ref(false);
const validForm = ref(true);
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const { data } = await api.groupWebhooks.getAll();
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.groupWebhooks.getAll();
if (data) {
webhooks.value = data;
}
loading.value = false;
},
async createOne() {
loading.value = true;
const payload = {
enabled: false,
name: "New Webhook",
url: "",
time: "00:00",
};
const { data } = await api.groupWebhooks.createOne(payload);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async updateOne(updateData: GroupWebhook) {
if (!updateData.id) {
return;
}
loading.value = true;
const { data } = await api.groupWebhooks.updateOne(updateData.id, updateData);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.groupWebhooks.deleteOne(id);
if (data) {
this.refreshAll();
}
},
};
const webhooks = actions.getAll();
return { webhooks, actions, validForm };
};

View File

@ -1,8 +1,33 @@
import { useAsync, ref } from "@nuxtjs/composition-api"; import { useAsync, ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { CreateGroup } from "~/api/class-interfaces/groups"; import { CreateGroup } from "~/api/class-interfaces/groups";
export const useGroup = function () {
const api = useApiSingleton();
const actions = {
getAll() {
const units = useAsync(async () => {
const { data } = await api.groups.getCategories();
return data;
}, useAsyncKey());
return units;
},
async updateAll() {
if (!categories.value) {
return;
}
const { data } = await api.groups.setCategories(categories.value);
categories.value = data;
},
};
const categories = actions.getAll();
return { actions, categories };
};
export const useGroups = function () { export const useGroups = function () {
const api = useApiSingleton(); const api = useApiSingleton();

View File

@ -61,6 +61,11 @@ export default defineComponent({
to: "/user/group/cookbooks", to: "/user/group/cookbooks",
title: this.$t("sidebar.cookbooks"), title: this.$t("sidebar.cookbooks"),
}, },
{
icon: this.$globals.icons.webhook,
to: "/user/group/webhooks",
title: "Webhooks",
},
], ],
adminLinks: [ adminLinks: [
{ {

View File

@ -31,7 +31,7 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue"; import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue"; import AppSidebar from "@/components/Layout/AppSidebar.vue";
import AppFloatingButton from "@/components/Layout/AppFloatingButton.vue"; import AppFloatingButton from "@/components/Layout/AppFloatingButton.vue";
import { useCookbooks } from "~/composables/use-cookbooks"; import { useCookbooks } from "~/composables/use-group-cookbooks";
export default defineComponent({ export default defineComponent({
components: { AppHeader, AppSidebar, AppFloatingButton }, components: { AppHeader, AppSidebar, AppFloatingButton },

View File

@ -25,7 +25,7 @@
<script lang="ts"> <script lang="ts">
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api"; import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
import { useCookbook } from "~/composables/use-cookbooks"; import { useCookbook } from "~/composables/use-group-cookbooks";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
setup() { setup() {

View File

@ -43,27 +43,24 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import { useCookbooks } from "@/composables/use-cookbooks"; import { useCookbooks } from "@/composables/use-group-cookbooks";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
export default defineComponent({ export default defineComponent({
components: { draggable }, components: { draggable },
layout: "admin", layout: "admin",
setup() { setup() {
const { cookbooks, actions, workingCookbookData, deleteTargetId, validForm } = useCookbooks(); const { cookbooks, actions } = useCookbooks();
return { return {
cookbooks, cookbooks,
actions, actions,
workingCookbookData,
deleteTargetId,
validForm,
}; };
}, },
}); });
</script> </script>
<style scoped> <style>
.my-border { .my-border {
border-left: 5px solid var(--v-primary-base); border-left: 5px solid var(--v-primary-base);
} }

View File

@ -1,21 +1,33 @@
<template> <template>
<v-container fluid> <v-container fluid>
<section>
<BaseCardSectionTitle title="Group Settings"> <BaseCardSectionTitle title="Group Settings">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque! distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle> </BaseCardSectionTitle>
<div v-if="categories" class="d-flex">
<DomainRecipeCategoryTagSelector v-model="categories" class="mt-5 mr-5" />
<BaseButton save class="mt-auto mb-3" @click="actions.updateAll()" />
</div>
</section>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import { useGroup } from "~/composables/use-groups";
export default defineComponent({ export default defineComponent({
layout: "admin", layout: "admin",
setup() { setup() {
return {}; const { categories, actions } = useGroup();
return {
categories,
actions,
};
}, },
}); });
</script> </script>

View File

@ -0,0 +1,68 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="MealPlan Webhooks">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
<BaseButton create @click="actions.createOne()" />
<v-expansion-panels class="mt-2">
<v-expansion-panel v-for="(webhook, index) in webhooks" :key="index" class="my-2 my-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline">
<div class="d-flex align-center">
<v-icon large left :color="webhook.enabled ? 'info' : null">
{{ $globals.icons.webhook }}
</v-icon>
{{ webhook.name }} - {{ webhook.time }}
</div>
<template #actions>
<v-btn color="info" fab small class="ml-2">
<v-icon color="white">
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content>
<v-card-text>
<v-switch v-model="webhook.enabled" label="Enabled"></v-switch>
<v-text-field v-model="webhook.name" label="Webhook Name"></v-text-field>
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
</v-card-text>
<v-card-actions>
<BaseButton secondary color="info">
<template #icon>
{{ $globals.icons.testTube }}
</template>
Test
</BaseButton>
<v-spacer></v-spacer>
<BaseButton delete @click="actions.deleteOne(webhook.id)" />
<BaseButton save @click="actions.updateOne(webhook)" />
</v-card-actions>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { useGroupWebhooks } from "~/composables/use-group-webhooks";
export default defineComponent({
layout: "admin",
setup() {
const { actions, webhooks } = useGroupWebhooks();
return {
actions,
webhooks,
};
},
});
</script>
<style scoped>
</style>

View File

@ -26,6 +26,7 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
def start_scheduler(): def start_scheduler():
return # TODO: Disable Scheduler for now
import mealie.services.scheduler.scheduled_jobs # noqa: F401 import mealie.services.scheduler.scheduled_jobs # noqa: F401

View File

@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.models.cookbook import CookBook from mealie.db.models.cookbook import CookBook
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.group.webhooks import GroupWebhooksModel
from mealie.db.models.mealplan import MealPlan from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.comment import RecipeComment
@ -19,6 +20,7 @@ from mealie.schema.admin import SiteSettings as SiteSettingsSchema
from mealie.schema.cookbook import ReadCookBook from mealie.schema.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn from mealie.schema.events import EventNotificationIn
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.recipe import ( from mealie.schema.recipe import (
CommentOut, CommentOut,
@ -42,7 +44,6 @@ DEFAULT_PK = "id"
class CategoryDataAccessModel(BaseAccessModel): class CategoryDataAccessModel(BaseAccessModel):
def get_empty(self, session: Session): def get_empty(self, session: Session):
self.schema
return session.query(Category).filter(~Category.recipes.any()).all() return session.query(Category).filter(~Category.recipes.any()).all()
@ -77,10 +78,13 @@ class DatabaseAccessLayer:
self.event_notifications = BaseAccessModel(DEFAULT_PK, EventNotification, EventNotificationIn) self.event_notifications = BaseAccessModel(DEFAULT_PK, EventNotification, EventNotificationIn)
self.events = BaseAccessModel(DEFAULT_PK, Event, EventSchema) self.events = BaseAccessModel(DEFAULT_PK, Event, EventSchema)
# Users / Groups # Users
self.users = UserDataAccessModel(DEFAULT_PK, User, PrivateUser) self.users = UserDataAccessModel(DEFAULT_PK, User, PrivateUser)
self.api_tokens = BaseAccessModel(DEFAULT_PK, LongLiveToken, LongLiveTokenInDB) self.api_tokens = BaseAccessModel(DEFAULT_PK, LongLiveToken, LongLiveTokenInDB)
# Group Data
self.groups = GroupDataAccessModel(DEFAULT_PK, Group, GroupInDB) self.groups = GroupDataAccessModel(DEFAULT_PK, Group, GroupInDB)
self.meals = BaseAccessModel(DEFAULT_PK, MealPlan, MealPlanOut) self.meals = BaseAccessModel(DEFAULT_PK, MealPlan, MealPlanOut)
self.webhooks = BaseAccessModel(DEFAULT_PK, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(DEFAULT_PK, ShoppingList, ShoppingListOut) self.shopping_lists = BaseAccessModel(DEFAULT_PK, ShoppingList, ShoppingListOut)
self.cookbooks = BaseAccessModel(DEFAULT_PK, CookBook, ReadCookBook) self.cookbooks = BaseAccessModel(DEFAULT_PK, CookBook, ReadCookBook)

View File

@ -9,7 +9,7 @@ def handle_one_to_many_list(get_attr, relation_cls, all_elements: list[dict]):
updated_elems = [] updated_elems = []
for elem in all_elements: for elem in all_elements:
elem_id = elem.get("id", None) elem_id = elem.get(get_attr, None)
existing_elem = relation_cls.get_ref(match_value=elem_id) existing_elem = relation_cls.get_ref(match_value=elem_id)

View File

@ -0,0 +1 @@
from .group import *

View File

@ -5,14 +5,10 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import settings from mealie.core.config import settings
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.cookbook import CookBook from mealie.db.models.cookbook import CookBook
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.db.models.recipe.category import Category, group2categories from mealie.db.models.recipe.category import Category, group2categories
from .._model_utils import auto_init
class WebhookURLModel(SqlAlchemyBase):
__tablename__ = "webhook_urls"
id = sa.Column(sa.Integer, primary_key=True)
url = sa.Column(sa.String)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
class Group(SqlAlchemyBase, BaseMixins): class Group(SqlAlchemyBase, BaseMixins):
@ -20,25 +16,17 @@ class Group(SqlAlchemyBase, BaseMixins):
id = sa.Column(sa.Integer, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)
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")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date") mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True) webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True) cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
categories = orm.relationship("Category", secondary=group2categories, single_parent=True) shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
# Webhook Settings @auto_init({"users", "webhooks", "shopping_lists", "cookbooks"})
webhook_enable = sa.Column(sa.Boolean, default=False) def __init__(self, **_) -> None:
webhook_time = sa.Column(sa.String, default="00:00") pass
webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan")
def __init__(
self, name, categories=[], session=None, webhook_enable=False, webhook_time="00:00", webhook_urls=[], **_
) -> None:
self.name = name
self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories]
self.webhook_enable = webhook_enable
self.webhook_time = webhook_time
self.webhook_urls = [WebhookURLModel(url=x) for x in webhook_urls]
@staticmethod @staticmethod
def get_ref(session: Session, name: str): def get_ref(session: Session, name: str):

View File

@ -0,0 +1,20 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "webhook_urls"
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey("groups.id"), index=True)
enabled = Column(Boolean, default=False)
name = Column(String)
url = Column(String)
time = Column(String, default="00:00")
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -61,7 +61,6 @@ class Category(SqlAlchemyBase, BaseMixins):
if not session or not match_value: if not session or not match_value:
return None return None
print(match_value)
slug = slugify(match_value) slug = slugify(match_value)
result = session.query(Category).filter(Category.slug == slug).one_or_none() result = session.query(Category).filter(Category.slug == slug).one_or_none()

View File

@ -1,9 +1,18 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import cookbooks, crud from mealie.services.base_http_service import RouterFactory
from mealie.services.cookbook.cookbook_service import CookbookService
from mealie.services.group.webhook_service import WebhookService
from . import categories, crud, self_service
router = APIRouter() router = APIRouter()
router.include_router(cookbooks.user_router) webhook_router = RouterFactory(service=WebhookService, prefix="/groups/webhooks", tags=["Groups: Webhooks"])
cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
router.include_router(self_service.user_router)
router.include_router(cookbook_router)
router.include_router(categories.user_router)
router.include_router(webhook_router)
router.include_router(crud.user_router) router.include_router(crud.user_router)
router.include_router(crud.admin_router) router.include_router(crud.admin_router)

View File

@ -0,0 +1,22 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.services.group.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])
@user_router.get("", response_model=list[CategoryBase])
def get_mealplan_categories(group_service: GroupSelfService = Depends(GroupSelfService.read_existing)):
return group_service.item.categories
@user_router.put("", response_model=list[CategoryBase])
def update_mealplan_categories(
new_categories: list[CategoryBase], group_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
items = group_service.update_categories(new_categories)
return items.categories

View File

@ -1,49 +0,0 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook
from mealie.services.cookbook import CookbookService
user_router = UserAPIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
@user_router.get("", response_model=list[ReadCookBook])
def get_all_cookbook(cb_service: CookbookService = Depends(CookbookService.private)):
""" Get cookbook from the Database """
# Get Item
return cb_service.get_all()
@user_router.post("", response_model=ReadCookBook)
def create_cookbook(data: CreateCookBook, cb_service: CookbookService = Depends(CookbookService.private)):
""" Create cookbook in the Database """
# Create Item
return cb_service.create_one(data)
@user_router.put("", response_model=list[ReadCookBook])
def update_many(data: list[ReadCookBook], cb_service: CookbookService = Depends(CookbookService.private)):
""" Create cookbook in the Database """
# Create Item
return cb_service.update_many(data)
@user_router.get("/{id}", response_model=RecipeCookBook)
def get_cookbook(cb_service: CookbookService = Depends(CookbookService.write_existing)):
""" Get cookbook from the Database """
# Get Item
return cb_service.cookbook
@user_router.put("/{id}")
def update_cookbook(data: CreateCookBook, cb_service: CookbookService = Depends(CookbookService.write_existing)):
""" Update cookbook in the Database """
# Update Item
return cb_service.update_one(data)
@user_router.delete("/{id}")
def delete_cookbook(cd_service: CookbookService = Depends(CookbookService.write_existing)):
""" Delete cookbook from the Database """
# Delete Item
return cd_service.delete_one()

View File

@ -12,17 +12,6 @@ admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"]) user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
@user_router.get("/self", response_model=GroupInDB)
async def get_current_user_group(
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Returns the Group Data for the Current User """
current_user: PrivateUser
return db.groups.get(session, current_user.group, "name")
@admin_router.get("", response_model=list[GroupInDB]) @admin_router.get("", response_model=list[GroupInDB])
async def get_all_groups( async def get_all_groups(
session: Session = Depends(generate_session), session: Session = Depends(generate_session),

View File

@ -0,0 +1,14 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user.user import GroupInDB
from mealie.services.group.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/self", tags=["Groups: Self Service"])
@user_router.get("", response_model=GroupInDB)
async def get_logged_in_user_group(g_self_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
""" Returns the Group Data for the Current User """
return g_self_service.item

View File

@ -27,7 +27,7 @@ logger = get_logger()
@public_router.get("/{slug}", response_model=Recipe) @public_router.get("/{slug}", response_model=Recipe)
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)): def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
""" Takes in a recipe slug, returns all data for a recipe """ """ Takes in a recipe slug, returns all data for a recipe """
return recipe_service.recipe return recipe_service.item
@user_router.post("", status_code=201, response_model=str) @user_router.post("", status_code=201, response_model=str)

View File

@ -0,0 +1 @@
from .webhook import *

View File

@ -0,0 +1,19 @@
from fastapi_camelcase import CamelModel
class CreateWebhook(CamelModel):
enabled: bool = True
name: str = ""
url: str = ""
time: str = "00:00"
class SaveWebhook(CreateWebhook):
group_id: int
class ReadWebhook(SaveWebhook):
id: int
class Config:
orm_mode = True

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Any, Optional
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic.types import constr from pydantic.types import constr
@ -119,9 +119,7 @@ class UpdateGroup(GroupBase):
name: str name: str
categories: Optional[list[CategoryBase]] = [] categories: Optional[list[CategoryBase]] = []
webhook_urls: list[str] = [] webhooks: list[Any] = []
webhook_time: str = "00:00"
webhook_enable: bool
class GroupInDB(UpdateGroup): class GroupInDB(UpdateGroup):
@ -136,7 +134,6 @@ class GroupInDB(UpdateGroup):
def getter_dict(_cls, orm_model: Group): def getter_dict(_cls, orm_model: Group):
return { return {
**GetterDict(orm_model), **GetterDict(orm_model),
"webhook_urls": [x.url for x in orm_model.webhook_urls if x],
} }

View File

@ -1,2 +1,3 @@
from .base_http_service import * from .base_http_service import *
from .base_service import * from .base_service import *
from .router_factory import *

View File

@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from typing import Callable, Generic, TypeVar from typing import Callable, Generic, TypeVar
from fastapi import BackgroundTasks, Depends from fastapi import BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs, get_settings from mealie.core.config import get_app_dirs, get_settings
@ -16,13 +17,13 @@ T = TypeVar("T")
D = TypeVar("D") D = TypeVar("D")
class BaseHttpService(Generic[T, D]): class BaseHttpService(Generic[T, D], ABC):
"""The BaseHttpService class is a generic class that can be used to create """The BaseHttpService class is a generic class that can be used to create
http services that are injected via `Depends` into a route function. To use, http services that are injected via `Depends` into a route function. To use,
you must define the Generic type arguments: you must define the Generic type arguments:
`T`: The type passed into the *_existing functions (e.g. id) which is then passed into assert_existing `T`: The type passed into the *_existing functions (e.g. id) which is then passed into assert_existing
`D`: Not yet implemented `D`: Item returned from database layer
Child Requirements: Child Requirements:
Define the following functions: Define the following functions:
@ -32,8 +33,29 @@ class BaseHttpService(Generic[T, D]):
`event_func`: A function that is called when an event is created. `event_func`: A function that is called when an event is created.
""" """
item: D = None
# Function that Generate Corrsesponding Routes through RouterFactor
get_all: Callable = None
create_one: Callable = None
update_one: Callable = None
update_many: Callable = None
populate_item: Callable = None
delete_one: Callable = None
delete_all: Callable = None
# Type Definitions
_schema = None
_create_schema = None
_update_schema = None
# Function called to create a server side event
event_func: Callable = None event_func: Callable = None
# Config
_restrict_by_group = False
_group_id_cache = None
def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None: def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None:
self.session = session or SessionLocal() self.session = session or SessionLocal()
self.user = user self.user = user
@ -45,33 +67,32 @@ class BaseHttpService(Generic[T, D]):
self.app_dirs = get_app_dirs() self.app_dirs = get_app_dirs()
self.settings = get_settings() self.settings = get_settings()
def assert_existing(self, data: T) -> None: @property
raise NotImplementedError("`assert_existing` must by implemented by child class") def group_id(self):
# TODO: Populate Group in Private User Call WARNING: May require significant refactoring
def _create_event(self, title: str, message: str) -> None: if not self._group_id_cache:
if not self.__class__.event_func: group = self.db.groups.get(self.session, self.user.group, "name")
raise NotImplementedError("`event_func` must be set by child class") self._group_id_cache = group.id
return self._group_id_cache
self.background_tasks.add_task(self.__class__.event_func, title, message, self.session)
@classmethod @classmethod
def read_existing(cls, id: T, deps: ReadDeps = Depends()): def read_existing(cls, item_id: T, deps: ReadDeps = Depends()):
""" """
Used for dependency injection for routes that require an existing recipe. If the recipe doesn't exist Used for dependency injection for routes that require an existing recipe. If the recipe doesn't exist
or the user doens't not have the required permissions, the proper HTTP Status code will be raised. or the user doens't not have the required permissions, the proper HTTP Status code will be raised.
""" """
new_class = cls(deps.session, deps.user, deps.bg_task) new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(id) new_class.assert_existing(item_id)
return new_class return new_class
@classmethod @classmethod
def write_existing(cls, id: T, deps: WriteDeps = Depends()): def write_existing(cls, item_id: T, deps: WriteDeps = Depends()):
""" """
Used for dependency injection for routes that require an existing recipe. The only difference between Used for dependency injection for routes that require an existing recipe. The only difference between
read_existing and write_existing is that the user is required to be logged in on write_existing method. read_existing and write_existing is that the user is required to be logged in on write_existing method.
""" """
new_class = cls(deps.session, deps.user, deps.bg_task) new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(id) new_class.assert_existing(item_id)
return new_class return new_class
@classmethod @classmethod
@ -87,3 +108,27 @@ class BaseHttpService(Generic[T, D]):
A Base instance to be used as a router dependency A Base instance to be used as a router dependency
""" """
return cls(deps.session, deps.user, deps.bg_task) return cls(deps.session, deps.user, deps.bg_task)
@abstractmethod
def populate_item(self) -> None:
...
def assert_existing(self, id: T) -> None:
self.populate_item(id)
self._check_item()
def _check_item(self) -> None:
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.__class__._restrict_by_group:
group_id = getattr(self.item, "group_id", False)
if not group_id or group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def _create_event(self, title: str, message: str) -> None:
if not self.__class__.event_func:
raise NotImplementedError("`event_func` must be set by child class")
self.background_tasks.add_task(self.__class__.event_func, title, message, self.session)

View File

@ -0,0 +1,190 @@
from typing import Any, Callable, Optional, Sequence, Type, TypeVar
from fastapi import APIRouter
from fastapi.params import Depends
from fastapi.types import DecoratedCallable
from pydantic import BaseModel
from .base_http_service import BaseHttpService
""""
This code is largely based off of the FastAPI Crud Router
https://github.com/awtkns/fastapi-crudrouter/blob/master/fastapi_crudrouter/core/_base.py
"""
T = TypeVar("T", bound=BaseModel)
S = TypeVar("S", bound=BaseHttpService)
DEPENDENCIES = Optional[Sequence[Depends]]
class RouterFactory(APIRouter):
schema: Type[T]
create_schema: Type[T]
update_schema: Type[T]
_base_path: str = "/"
def __init__(
self,
service: Type[S],
prefix: Optional[str] = None,
tags: Optional[list[str]] = None,
*args,
**kwargs,
):
self.service: Type[S] = service
self.schema: Type[T] = service._schema
# HACK: Special Case for Coobooks, not sure this is a good way to handle the abstraction :/
if hasattr(self.service, "_get_one_schema"):
self.get_one_schema = self.service._get_one_schema
else:
self.get_one_schema = self.schema
self.update_schema: Type[T] = service._update_schema
self.create_schema: Type[T] = service._create_schema
prefix = str(prefix or self.schema.__name__).lower()
prefix = self._base_path + prefix.strip("/")
tags = tags or [prefix.strip("/").capitalize()]
super().__init__(prefix=prefix, tags=tags, **kwargs)
if self.service.get_all:
self._add_api_route(
"",
self._get_all(),
methods=["GET"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Get All",
)
if self.service.create_one:
self._add_api_route(
"",
self._create(),
methods=["POST"],
response_model=self.schema,
summary="Create One",
)
if self.service.update_many:
self._add_api_route(
"",
self._update_many(),
methods=["PUT"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Update Many",
)
if self.service.delete_all:
self._add_api_route(
"",
self._delete_all(),
methods=["DELETE"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Delete All",
)
if self.service.populate_item:
self._add_api_route(
"/{item_id}",
self._get_one(),
methods=["GET"],
response_model=self.get_one_schema,
summary="Get One",
)
if self.service.update_one:
self._add_api_route(
"/{item_id}",
self._update(),
methods=["PUT"],
response_model=self.schema,
summary="Update One",
)
if self.service.delete_one:
self._add_api_route(
"/{item_id}",
self._delete_one(),
methods=["DELETE"],
response_model=self.schema,
summary="Delete One",
)
def _add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:
dependencies = []
super().add_api_route(path, endpoint, dependencies=dependencies, **kwargs)
def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
"""Overrides and exiting route if it exists"""
methods = kwargs["methods"] if "methods" in kwargs else ["GET"]
self.remove_api_route(path, methods)
return super().api_route(path, *args, **kwargs)
def get(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["Get"])
return super().get(path, *args, **kwargs)
def post(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["POST"])
return super().post(path, *args, **kwargs)
def put(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["PUT"])
return super().put(path, *args, **kwargs)
def delete(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["DELETE"])
return super().delete(path, *args, **kwargs)
def remove_api_route(self, path: str, methods: list[str]) -> None:
methods_ = set(methods)
for route in self.routes:
if route.path == f"{self.prefix}{path}" and route.methods == methods_:
self.routes.remove(route)
def _get_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.private)) -> T: # type: ignore
return service.get_all()
return route
def _get_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.item
return route
def _create(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: self.create_schema, service: S = Depends(self.service.private)) -> T: # type: ignore
return service.create_one(data)
return route
def _update(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: self.update_schema, service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.update_one(data)
return route
def _update_many(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: list[self.update_schema], service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.update_many(data)
return route
def _delete_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.delete_one()
return route
def _delete_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
raise NotImplementedError
@staticmethod
def get_routes() -> list[str]:
return ["get_all", "create", "delete_all", "get_one", "update", "delete_one"]

View File

@ -3,55 +3,33 @@ 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, RecipeCookBook, SaveCookBook from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
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[int, str]): class CookbookService(BaseHttpService[int, ReadCookBook]):
"""
Class Methods:
`read_existing`: Reads an existing recipe from the database.
`write_existing`: Updates an existing recipe in the database.
`base`: Requires write permissions, but doesn't perform recipe checks
"""
event_func = create_group_event event_func = create_group_event
cookbook: ReadCookBook # Required for proper type hints _restrict_by_group = True
_group_id_cache = None _schema = ReadCookBook
_create_schema = CreateCookBook
_update_schema = UpdateCookBook
_get_one_schema = RecipeCookBook
@property def populate_item(self, id: int | str):
def group_id(self):
# TODO: Populate Group in Private User Call WARNING: May require significant refactoring
if not self._group_id_cache:
group = self.db.groups.get(self.session, self.user.group, "name")
print(group)
self._group_id_cache = group.id
return self._group_id_cache
def assert_existing(self, id: str):
self.populate_cookbook(id)
if not self.cookbook:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.cookbook.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def populate_cookbook(self, id: int | str):
try: try:
id = int(id) id = int(id)
except Exception: except Exception:
pass pass
if isinstance(id, int): if isinstance(id, int):
self.cookbook = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook) self.item = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook)
else: else:
self.cookbook = self.db.cookbooks.get_one(self.session, id, key="slug", override_schema=RecipeCookBook) self.item = 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)
@ -60,22 +38,22 @@ class CookbookService(BaseHttpService[int, str]):
def create_one(self, data: CreateCookBook) -> ReadCookBook: def create_one(self, data: CreateCookBook) -> ReadCookBook:
try: try:
self.cookbook = self.db.cookbooks.create(self.session, SaveCookBook(group_id=self.group_id, **data.dict())) self.item = self.db.cookbooks.create(self.session, SaveCookBook(group_id=self.group_id, **data.dict()))
except Exception as ex: except Exception as ex:
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail={"message": "PAGE_CREATION_ERROR", "exception": str(ex)} status.HTTP_400_BAD_REQUEST, detail={"message": "PAGE_CREATION_ERROR", "exception": str(ex)}
) )
return self.cookbook return self.item
def update_one(self, data: CreateCookBook, id: int = None) -> ReadCookBook: def update_one(self, data: CreateCookBook, id: int = None) -> ReadCookBook:
if not self.cookbook: if not self.item:
return return
target_id = id or self.cookbook.id target_id = id or self.item.id
self.cookbook = self.db.cookbooks.update(self.session, target_id, data) self.item = self.db.cookbooks.update(self.session, target_id, data)
return self.cookbook return self.item
def update_many(self, data: list[ReadCookBook]) -> list[ReadCookBook]: def update_many(self, data: list[ReadCookBook]) -> list[ReadCookBook]:
updated = [] updated = []
@ -87,10 +65,10 @@ class CookbookService(BaseHttpService[int, str]):
return updated return updated
def delete_one(self, id: int = None) -> ReadCookBook: def delete_one(self, id: int = None) -> ReadCookBook:
if not self.cookbook: if not self.item:
return return
target_id = id or self.cookbook.id target_id = id or self.item.id
self.cookbook = self.db.cookbooks.delete(self.session, target_id) self.item = self.db.cookbooks.delete(self.session, target_id)
return self.cookbook return self.item

View File

@ -0,0 +1,2 @@
from .group_service import *
from .webhook_service import *

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import WriteDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
from mealie.services.base_http_service.base_http_service import BaseHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class GroupSelfService(BaseHttpService[int, str]):
_restrict_by_group = True
event_func = create_group_event
item: GroupInDB
@classmethod
def read_existing(cls, deps: WriteDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().read_existing(item_id=0, deps=deps)
@classmethod
def write_existing(cls, deps: WriteDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().write_existing(item_id=0, deps=deps)
def assert_existing(self, _: str = None):
self.populate_item()
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.item.id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def populate_item(self, _: str = None):
self.item = self.db.groups.get(self.session, self.group_id)
def update_categories(self, new_categories: list[CategoryBase]):
if not self.item:
return
self.item.categories = new_categories
return self.db.groups.update(self.session, self.group_id, self.item)

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
from mealie.services.base_http_service.base_http_service import BaseHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class WebhookService(BaseHttpService[int, ReadWebhook]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadWebhook
_create_schema = CreateWebhook
_update_schema = CreateWebhook
def populate_item(self, id: int | str):
self.item = self.db.webhooks.get_one(self.session, id)
def get_all(self) -> list[ReadWebhook]:
return self.db.webhooks.get(self.session, self.group_id, match_key="group_id", limit=9999)
def create_one(self, data: CreateWebhook) -> ReadWebhook:
try:
self.item = self.db.webhooks.create(self.session, SaveWebhook(group_id=self.group_id, **data.dict()))
except Exception as ex:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail={"message": "WEBHOOK_CREATION_ERROR", "exception": str(ex)}
)
return self.item
def update_one(self, data: CreateWebhook, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.item = self.db.webhooks.update(self.session, target_id, data)
return self.item
def delete_one(self, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.db.webhooks.delete(self.session, target_id)
return self.item

View File

@ -14,7 +14,7 @@ from mealie.services.events import create_recipe_event
logger = get_logger(module=__name__) logger = get_logger(module=__name__)
class RecipeService(BaseHttpService[str, str]): class RecipeService(BaseHttpService[str, Recipe]):
""" """
Class Methods: Class Methods:
`read_existing`: Reads an existing recipe from the database. `read_existing`: Reads an existing recipe from the database.
@ -23,7 +23,6 @@ class RecipeService(BaseHttpService[str, str]):
""" """
event_func = create_recipe_event event_func = create_recipe_event
recipe: Recipe # Required for proper type hints
@classmethod @classmethod
def write_existing(cls, slug: str, deps: WriteDeps = Depends()): def write_existing(cls, slug: str, deps: WriteDeps = Depends()):
@ -34,17 +33,17 @@ class RecipeService(BaseHttpService[str, str]):
return super().write_existing(slug, deps) return super().write_existing(slug, deps)
def assert_existing(self, slug: str): def assert_existing(self, slug: str):
self.pupulate_recipe(slug) self.populate_item(slug)
if not self.recipe: if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND) raise HTTPException(status.HTTP_404_NOT_FOUND)
if not self.recipe.settings.public and not self.user: if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN) raise HTTPException(status.HTTP_403_FORBIDDEN)
def pupulate_recipe(self, slug: str) -> Recipe: def populate_item(self, slug: str) -> Recipe:
self.recipe = self.db.recipes.get(self.session, slug) self.item = self.db.recipes.get(self.session, slug)
return self.recipe return self.item
# CRUD METHODS # CRUD METHODS
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe: def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
@ -52,34 +51,34 @@ class RecipeService(BaseHttpService[str, str]):
create_data = Recipe(name=create_data.name) create_data = Recipe(name=create_data.name)
try: try:
self.recipe = self.db.recipes.create(self.session, create_data) self.item = self.db.recipes.create(self.session, create_data)
except IntegrityError: except IntegrityError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"}) raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
self._create_event( self._create_event(
"Recipe Created (URL)", "Recipe Created (URL)",
f"'{self.recipe.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.recipe.slug}", f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
) )
return self.recipe return self.item
def update_recipe(self, update_data: Recipe) -> Recipe: def update_recipe(self, update_data: Recipe) -> Recipe:
original_slug = self.recipe.slug original_slug = self.item.slug
try: try:
self.recipe = self.db.recipes.update(self.session, original_slug, update_data) self.item = self.db.recipes.update(self.session, original_slug, update_data)
except IntegrityError: except IntegrityError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"}) raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
self._check_assets(original_slug) self._check_assets(original_slug)
return self.recipe return self.item
def patch_recipe(self, patch_data: Recipe) -> Recipe: def patch_recipe(self, patch_data: Recipe) -> Recipe:
original_slug = self.recipe.slug original_slug = self.item.slug
try: try:
self.recipe = self.db.recipes.patch( self.item = self.db.recipes.patch(
self.session, original_slug, patch_data.dict(exclude_unset=True, exclude_defaults=True) self.session, original_slug, patch_data.dict(exclude_unset=True, exclude_defaults=True)
) )
except IntegrityError: except IntegrityError:
@ -87,7 +86,7 @@ class RecipeService(BaseHttpService[str, str]):
self._check_assets(original_slug) self._check_assets(original_slug)
return self.recipe return self.item
def delete_recipe(self) -> Recipe: def delete_recipe(self) -> Recipe:
"""removes a recipe from the database and purges the existing files from the filesystem. """removes a recipe from the database and purges the existing files from the filesystem.
@ -100,7 +99,7 @@ class RecipeService(BaseHttpService[str, str]):
""" """
try: try:
recipe: Recipe = self.db.recipes.delete(self.session, self.recipe.slug) recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
self._delete_assets() self._delete_assets()
except Exception: except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)
@ -110,18 +109,18 @@ class RecipeService(BaseHttpService[str, str]):
def _check_assets(self, original_slug: str) -> None: def _check_assets(self, original_slug: str) -> None:
"""Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug.""" """Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug."""
if original_slug != self.recipe.slug: if original_slug != self.item.slug:
current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug) current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug)
try: try:
copytree(current_dir, self.recipe.directory, dirs_exist_ok=True) copytree(current_dir, self.item.directory, dirs_exist_ok=True)
logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.recipe.slug}") logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.item.slug}")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Recipe Directory not Found: {original_slug}") logger.error(f"Recipe Directory not Found: {original_slug}")
all_asset_files = [x.file_name for x in self.recipe.assets] all_asset_files = [x.file_name for x in self.item.assets]
for file in self.recipe.asset_dir.iterdir(): for file in self.item.asset_dir.iterdir():
file: Path file: Path
if file.is_dir(): if file.is_dir():
continue continue
@ -129,6 +128,6 @@ class RecipeService(BaseHttpService[str, str]):
file.unlink() file.unlink()
def _delete_assets(self) -> None: def _delete_assets(self) -> None:
recipe_dir = self.recipe.directory recipe_dir = self.item.directory
rmtree(recipe_dir, ignore_errors=True) rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {self.recipe.slug}") logger.info(f"Recipe Directory Removed: {self.item.slug}")

View File

@ -29,9 +29,7 @@ def test_update_group(api_client: TestClient, api_routes: AppRoutes, admin_token
"name": "New Group Name", "name": "New Group Name",
"id": 2, "id": 2,
"categories": [], "categories": [],
"webhookUrls": [], "webhooks": [],
"webhookTime": "00:00",
"webhookEnable": False,
"users": [], "users": [],
"mealplans": [], "mealplans": [],
"shoppingLists": [], "shoppingLists": [],