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 { 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<GroupInDB, CreateGroup> {
/** 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<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 { 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);

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 { 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();

View File

@ -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: [
{

View File

@ -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 },

View File

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

View File

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

View File

@ -1,21 +1,33 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Group Settings">
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>
<section>
<BaseCardSectionTitle title="Group Settings">
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>
<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>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { useGroup } from "~/composables/use-groups";
export default defineComponent({
layout: "admin",
setup() {
return {};
const { categories, actions } = useGroup();
return {
categories,
actions,
};
},
});
</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():
return # TODO: Disable Scheduler for now
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.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)

View File

@ -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)

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.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):

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:
return None
print(match_value)
slug = slugify(match_value)
result = session.query(Category).filter(Category.slug == slug).one_or_none()

View File

@ -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)

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.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),

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)
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)

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 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],
}

View File

@ -1,2 +1,3 @@
from .base_http_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 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)

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 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

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__)
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}")

View File

@ -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": [],