diff --git a/frontend/api/class-interfaces/group-webhooks.ts b/frontend/api/class-interfaces/group-webhooks.ts new file mode 100644 index 000000000000..099f755b2904 --- /dev/null +++ b/frontend/api/class-interfaces/group-webhooks.ts @@ -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 { + baseRoute = routes.webhooks; + itemRoute = routes.webhooksId; +} diff --git a/frontend/api/class-interfaces/groups.ts b/frontend/api/class-interfaces/groups.ts index 87f6660b59c4..eb5f7e04aa8f 100644 --- a/frontend/api/class-interfaces/groups.ts +++ b/frontend/api/class-interfaces/groups.ts @@ -1,4 +1,3 @@ -import { requests } from "../requests"; import { BaseCRUDAPI } from "./_base"; import { GroupInDB } from "~/types/api-types/user"; @@ -7,10 +6,17 @@ const prefix = "/api"; const routes = { groups: `${prefix}/groups`, groupsSelf: `${prefix}/groups/self`, + categories: `${prefix}/groups/categories`, groupsId: (id: string | number) => `${prefix}/groups/${id}`, }; +interface Category { + id: number; + name: string; + slug: string; +} + export interface CreateGroup { name: string; } @@ -21,6 +27,14 @@ export class GroupAPI extends BaseCRUDAPI { /** Returns the Group Data for the Current User */ async getCurrentUserGroup() { - return await requests.get(routes.groupsSelf); + return await this.requests.get(routes.groupsSelf); + } + + async getCategories() { + return await this.requests.get(routes.categories); + } + + async setCategories(payload: Category[]) { + return await this.requests.put(routes.categories, payload); } } diff --git a/frontend/api/index.ts b/frontend/api/index.ts index bce4725f1676..2af113f2359d 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -12,6 +12,7 @@ import { NotificationsAPI } from "./class-interfaces/event-notifications"; import { FoodAPI } from "./class-interfaces/recipe-foods"; import { UnitAPI } from "./class-interfaces/recipe-units"; import { CookbookAPI } from "./class-interfaces/cookbooks"; +import { WebhooksAPI } from "./class-interfaces/group-webhooks"; import { ApiRequestInstance } from "~/types/api"; class Api { @@ -29,6 +30,7 @@ class Api { public foods: FoodAPI; public units: UnitAPI; public cookbooks: CookbookAPI; + public groupWebhooks: WebhooksAPI; // Utils public upload: UploadFile; @@ -49,6 +51,7 @@ class Api { this.users = new UserApi(requests); this.groups = new GroupAPI(requests); this.cookbooks = new CookbookAPI(requests); + this.groupWebhooks = new WebhooksAPI(requests); // Admin this.debug = new DebugAPI(requests); diff --git a/frontend/composables/use-cookbooks.ts b/frontend/composables/use-group-cookbooks.ts similarity index 100% rename from frontend/composables/use-cookbooks.ts rename to frontend/composables/use-group-cookbooks.ts diff --git a/frontend/composables/use-group-webhooks.ts b/frontend/composables/use-group-webhooks.ts new file mode 100644 index 000000000000..f80c61001f73 --- /dev/null +++ b/frontend/composables/use-group-webhooks.ts @@ -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 }; +}; diff --git a/frontend/composables/use-groups.ts b/frontend/composables/use-groups.ts index 15f266e8b79b..1dbc12d4e65e 100644 --- a/frontend/composables/use-groups.ts +++ b/frontend/composables/use-groups.ts @@ -1,8 +1,33 @@ import { useAsync, ref } from "@nuxtjs/composition-api"; +import { useAsyncKey } from "./use-utils"; import { useApiSingleton } from "~/composables/use-api"; 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 () { const api = useApiSingleton(); diff --git a/frontend/layouts/admin.vue b/frontend/layouts/admin.vue index f365b4a4e941..66c36570096d 100644 --- a/frontend/layouts/admin.vue +++ b/frontend/layouts/admin.vue @@ -61,6 +61,11 @@ export default defineComponent({ to: "/user/group/cookbooks", title: this.$t("sidebar.cookbooks"), }, + { + icon: this.$globals.icons.webhook, + to: "/user/group/webhooks", + title: "Webhooks", + }, ], adminLinks: [ { diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 7a953d0364fa..be4969d60165 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -31,7 +31,7 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; import AppHeader from "@/components/Layout/AppHeader.vue"; import AppSidebar from "@/components/Layout/AppSidebar.vue"; import AppFloatingButton from "@/components/Layout/AppFloatingButton.vue"; -import { useCookbooks } from "~/composables/use-cookbooks"; +import { useCookbooks } from "~/composables/use-group-cookbooks"; export default defineComponent({ components: { AppHeader, AppSidebar, AppFloatingButton }, diff --git a/frontend/pages/cookbooks/_slug.vue b/frontend/pages/cookbooks/_slug.vue index a8bce5ac407b..292c258d0f39 100644 --- a/frontend/pages/cookbooks/_slug.vue +++ b/frontend/pages/cookbooks/_slug.vue @@ -25,7 +25,7 @@ - \ No newline at end of file diff --git a/mealie/app.py b/mealie/app.py index c1940babbff6..85d48ad2a805 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -26,6 +26,7 @@ app.add_middleware(GZipMiddleware, minimum_size=1000) def start_scheduler(): + return # TODO: Disable Scheduler for now import mealie.services.scheduler.scheduled_jobs # noqa: F401 diff --git a/mealie/db/data_access_layer/db_access.py b/mealie/db/data_access_layer/db_access.py index 454fe057377a..92713a13b70c 100644 --- a/mealie/db/data_access_layer/db_access.py +++ b/mealie/db/data_access_layer/db_access.py @@ -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.event import Event, EventNotification 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.recipe.category import Category 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.events import Event as EventSchema from mealie.schema.events import EventNotificationIn +from mealie.schema.group.webhook import ReadWebhook from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut from mealie.schema.recipe import ( CommentOut, @@ -42,7 +44,6 @@ DEFAULT_PK = "id" class CategoryDataAccessModel(BaseAccessModel): def get_empty(self, session: Session): - self.schema return session.query(Category).filter(~Category.recipes.any()).all() @@ -77,10 +78,13 @@ class DatabaseAccessLayer: self.event_notifications = BaseAccessModel(DEFAULT_PK, EventNotification, EventNotificationIn) self.events = BaseAccessModel(DEFAULT_PK, Event, EventSchema) - # Users / Groups + # Users self.users = UserDataAccessModel(DEFAULT_PK, User, PrivateUser) self.api_tokens = BaseAccessModel(DEFAULT_PK, LongLiveToken, LongLiveTokenInDB) + + # Group Data self.groups = GroupDataAccessModel(DEFAULT_PK, Group, GroupInDB) self.meals = BaseAccessModel(DEFAULT_PK, MealPlan, MealPlanOut) + self.webhooks = BaseAccessModel(DEFAULT_PK, GroupWebhooksModel, ReadWebhook) self.shopping_lists = BaseAccessModel(DEFAULT_PK, ShoppingList, ShoppingListOut) self.cookbooks = BaseAccessModel(DEFAULT_PK, CookBook, ReadCookBook) diff --git a/mealie/db/models/_model_utils.py b/mealie/db/models/_model_utils.py index 9eaee5458ab2..e102d9c6bc57 100644 --- a/mealie/db/models/_model_utils.py +++ b/mealie/db/models/_model_utils.py @@ -9,7 +9,7 @@ def handle_one_to_many_list(get_attr, relation_cls, all_elements: list[dict]): updated_elems = [] 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) diff --git a/mealie/db/models/group/__init__.py b/mealie/db/models/group/__init__.py new file mode 100644 index 000000000000..abd1f750a4a5 --- /dev/null +++ b/mealie/db/models/group/__init__.py @@ -0,0 +1 @@ +from .group import * diff --git a/mealie/db/models/group.py b/mealie/db/models/group/group.py similarity index 54% rename from mealie/db/models/group.py rename to mealie/db/models/group/group.py index 0650686cce1f..0b455e074263 100644 --- a/mealie/db/models/group.py +++ b/mealie/db/models/group/group.py @@ -5,14 +5,10 @@ from sqlalchemy.orm.session import Session from mealie.core.config import settings from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.cookbook import CookBook +from mealie.db.models.group.webhooks import GroupWebhooksModel from mealie.db.models.recipe.category import Category, group2categories - -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")) +from .._model_utils import auto_init class Group(SqlAlchemyBase, BaseMixins): @@ -20,25 +16,17 @@ class Group(SqlAlchemyBase, BaseMixins): id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, index=True, nullable=False, unique=True) 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") - 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) - categories = orm.relationship("Category", secondary=group2categories, single_parent=True) + shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True) - # Webhook Settings - webhook_enable = sa.Column(sa.Boolean, default=False) - webhook_time = sa.Column(sa.String, default="00:00") - 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] + @auto_init({"users", "webhooks", "shopping_lists", "cookbooks"}) + def __init__(self, **_) -> None: + pass @staticmethod def get_ref(session: Session, name: str): diff --git a/mealie/db/models/group/webhooks.py b/mealie/db/models/group/webhooks.py new file mode 100644 index 000000000000..33194114a054 --- /dev/null +++ b/mealie/db/models/group/webhooks.py @@ -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 diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index edcc67256d7c..f4f320dcbc30 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -61,7 +61,6 @@ class Category(SqlAlchemyBase, BaseMixins): if not session or not match_value: return None - print(match_value) slug = slugify(match_value) result = session.query(Category).filter(Category.slug == slug).one_or_none() diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index 544459501099..a84244e0f3b6 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -1,9 +1,18 @@ 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.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.admin_router) diff --git a/mealie/routes/groups/categories.py b/mealie/routes/groups/categories.py new file mode 100644 index 000000000000..61bffa6a9fa1 --- /dev/null +++ b/mealie/routes/groups/categories.py @@ -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 diff --git a/mealie/routes/groups/cookbooks.py b/mealie/routes/groups/cookbooks.py deleted file mode 100644 index d6dba1b0692e..000000000000 --- a/mealie/routes/groups/cookbooks.py +++ /dev/null @@ -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() diff --git a/mealie/routes/groups/crud.py b/mealie/routes/groups/crud.py index e3d812ab49ae..cd26f132bea5 100644 --- a/mealie/routes/groups/crud.py +++ b/mealie/routes/groups/crud.py @@ -12,17 +12,6 @@ admin_router = AdminAPIRouter(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]) async def get_all_groups( session: Session = Depends(generate_session), diff --git a/mealie/routes/groups/self_service.py b/mealie/routes/groups/self_service.py new file mode 100644 index 000000000000..d2f85742a492 --- /dev/null +++ b/mealie/routes/groups/self_service.py @@ -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 diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index fd4cd4ba4050..de6767b13c21 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -27,7 +27,7 @@ logger = get_logger() @public_router.get("/{slug}", response_model=Recipe) def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)): """ 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) diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py new file mode 100644 index 000000000000..a7719646c004 --- /dev/null +++ b/mealie/schema/group/__init__.py @@ -0,0 +1 @@ +from .webhook import * diff --git a/mealie/schema/group/webhook.py b/mealie/schema/group/webhook.py new file mode 100644 index 000000000000..b2bbefaae071 --- /dev/null +++ b/mealie/schema/group/webhook.py @@ -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 diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 773314eae247..8070f633ace9 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional from fastapi_camelcase import CamelModel from pydantic.types import constr @@ -119,9 +119,7 @@ class UpdateGroup(GroupBase): name: str categories: Optional[list[CategoryBase]] = [] - webhook_urls: list[str] = [] - webhook_time: str = "00:00" - webhook_enable: bool + webhooks: list[Any] = [] class GroupInDB(UpdateGroup): @@ -136,7 +134,6 @@ class GroupInDB(UpdateGroup): def getter_dict(_cls, orm_model: Group): return { **GetterDict(orm_model), - "webhook_urls": [x.url for x in orm_model.webhook_urls if x], } diff --git a/mealie/services/base_http_service/__init__.py b/mealie/services/base_http_service/__init__.py index 834ba288322a..7e003fb22af3 100644 --- a/mealie/services/base_http_service/__init__.py +++ b/mealie/services/base_http_service/__init__.py @@ -1,2 +1,3 @@ from .base_http_service import * from .base_service import * +from .router_factory import * diff --git a/mealie/services/base_http_service/base_http_service.py b/mealie/services/base_http_service/base_http_service.py index 8d76fa1bcf2d..297e3ea49422 100644 --- a/mealie/services/base_http_service/base_http_service.py +++ b/mealie/services/base_http_service/base_http_service.py @@ -1,6 +1,7 @@ +from abc import ABC, abstractmethod 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 mealie.core.config import get_app_dirs, get_settings @@ -16,13 +17,13 @@ T = TypeVar("T") 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 http services that are injected via `Depends` into a route function. To use, 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 - `D`: Not yet implemented + `D`: Item returned from database layer Child Requirements: 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. """ + 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 + # Config + _restrict_by_group = False + _group_id_cache = None + def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None: self.session = session or SessionLocal() self.user = user @@ -45,33 +67,32 @@ class BaseHttpService(Generic[T, D]): self.app_dirs = get_app_dirs() self.settings = get_settings() - def assert_existing(self, data: T) -> None: - raise NotImplementedError("`assert_existing` must by implemented by child class") - - 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) + @property + 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") + self._group_id_cache = group.id + return self._group_id_cache @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 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.assert_existing(id) + new_class.assert_existing(item_id) return new_class @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 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.assert_existing(id) + new_class.assert_existing(item_id) return new_class @classmethod @@ -87,3 +108,27 @@ class BaseHttpService(Generic[T, D]): A Base instance to be used as a router dependency """ 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) diff --git a/mealie/services/base_http_service/router_factory.py b/mealie/services/base_http_service/router_factory.py new file mode 100644 index 000000000000..4046111b8ac7 --- /dev/null +++ b/mealie/services/base_http_service/router_factory.py @@ -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"] diff --git a/mealie/services/cookbook/cookbook_service.py b/mealie/services/cookbook/cookbook_service.py index 3adc7452c324..e970425ba5d5 100644 --- a/mealie/services/cookbook/cookbook_service.py +++ b/mealie/services/cookbook/cookbook_service.py @@ -3,55 +3,33 @@ from __future__ import annotations from fastapi import HTTPException, status 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.events import create_group_event logger = get_logger(module=__name__) -class CookbookService(BaseHttpService[int, str]): - """ - 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 - """ - +class CookbookService(BaseHttpService[int, ReadCookBook]): 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 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): + def populate_item(self, id: int | str): try: id = int(id) except Exception: pass 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: - 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]: 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: 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: raise HTTPException( 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: - if not self.cookbook: + if not self.item: return - target_id = id or self.cookbook.id - self.cookbook = self.db.cookbooks.update(self.session, target_id, data) + target_id = id or self.item.id + 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]: updated = [] @@ -87,10 +65,10 @@ class CookbookService(BaseHttpService[int, str]): return updated def delete_one(self, id: int = None) -> ReadCookBook: - if not self.cookbook: + if not self.item: return - target_id = id or self.cookbook.id - self.cookbook = self.db.cookbooks.delete(self.session, target_id) + target_id = id or self.item.id + self.item = self.db.cookbooks.delete(self.session, target_id) - return self.cookbook + return self.item diff --git a/mealie/services/group/__init__.py b/mealie/services/group/__init__.py new file mode 100644 index 000000000000..96ebf5e679be --- /dev/null +++ b/mealie/services/group/__init__.py @@ -0,0 +1,2 @@ +from .group_service import * +from .webhook_service import * diff --git a/mealie/services/group/group_service.py b/mealie/services/group/group_service.py new file mode 100644 index 000000000000..c1dea8ab8f19 --- /dev/null +++ b/mealie/services/group/group_service.py @@ -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) diff --git a/mealie/services/group/webhook_service.py b/mealie/services/group/webhook_service.py new file mode 100644 index 000000000000..3a5565c09ad1 --- /dev/null +++ b/mealie/services/group/webhook_service.py @@ -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 diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 7f9f73830db1..3e4e304273e8 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -14,7 +14,7 @@ from mealie.services.events import create_recipe_event logger = get_logger(module=__name__) -class RecipeService(BaseHttpService[str, str]): +class RecipeService(BaseHttpService[str, Recipe]): """ Class Methods: `read_existing`: Reads an existing recipe from the database. @@ -23,7 +23,6 @@ class RecipeService(BaseHttpService[str, str]): """ event_func = create_recipe_event - recipe: Recipe # Required for proper type hints @classmethod def write_existing(cls, slug: str, deps: WriteDeps = Depends()): @@ -34,17 +33,17 @@ class RecipeService(BaseHttpService[str, str]): return super().write_existing(slug, deps) 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) - 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) - def pupulate_recipe(self, slug: str) -> Recipe: - self.recipe = self.db.recipes.get(self.session, slug) - return self.recipe + def populate_item(self, slug: str) -> Recipe: + self.item = self.db.recipes.get(self.session, slug) + return self.item # CRUD METHODS 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) try: - self.recipe = self.db.recipes.create(self.session, create_data) + self.item = self.db.recipes.create(self.session, create_data) except IntegrityError: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"}) self._create_event( "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: - original_slug = self.recipe.slug + original_slug = self.item.slug 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: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"}) self._check_assets(original_slug) - return self.recipe + return self.item def patch_recipe(self, patch_data: Recipe) -> Recipe: - original_slug = self.recipe.slug + original_slug = self.item.slug 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) ) except IntegrityError: @@ -87,7 +86,7 @@ class RecipeService(BaseHttpService[str, str]): self._check_assets(original_slug) - return self.recipe + return self.item def delete_recipe(self) -> Recipe: """removes a recipe from the database and purges the existing files from the filesystem. @@ -100,7 +99,7 @@ class RecipeService(BaseHttpService[str, str]): """ 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() except Exception: raise HTTPException(status.HTTP_400_BAD_REQUEST) @@ -110,18 +109,18 @@ class RecipeService(BaseHttpService[str, str]): 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.""" - if original_slug != self.recipe.slug: + if original_slug != self.item.slug: current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug) try: - copytree(current_dir, self.recipe.directory, dirs_exist_ok=True) - logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.recipe.slug}") + copytree(current_dir, self.item.directory, dirs_exist_ok=True) + logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.item.slug}") except FileNotFoundError: 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 if file.is_dir(): continue @@ -129,6 +128,6 @@ class RecipeService(BaseHttpService[str, str]): file.unlink() def _delete_assets(self) -> None: - recipe_dir = self.recipe.directory + recipe_dir = self.item.directory rmtree(recipe_dir, ignore_errors=True) - logger.info(f"Recipe Directory Removed: {self.recipe.slug}") + logger.info(f"Recipe Directory Removed: {self.item.slug}") diff --git a/tests/integration_tests/test_group_routes.py b/tests/integration_tests/test_group_routes.py index 2bb3d5b8a0ab..22c5bbd397ea 100644 --- a/tests/integration_tests/test_group_routes.py +++ b/tests/integration_tests/test_group_routes.py @@ -29,9 +29,7 @@ def test_update_group(api_client: TestClient, api_routes: AppRoutes, admin_token "name": "New Group Name", "id": 2, "categories": [], - "webhookUrls": [], - "webhookTime": "00:00", - "webhookEnable": False, + "webhooks": [], "users": [], "mealplans": [], "shoppingLists": [],