mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	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:
		
							
								
								
									
										25
									
								
								frontend/api/class-interfaces/group-webhooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/api/class-interfaces/group-webhooks.ts
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
| @@ -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); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
							
								
								
									
										75
									
								
								frontend/composables/use-group-webhooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								frontend/composables/use-group-webhooks.ts
									
									
									
									
									
										Normal 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 }; | ||||
| }; | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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: [ | ||||
|         { | ||||
|   | ||||
| @@ -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 }, | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
							
								
								
									
										68
									
								
								frontend/pages/user/group/webhooks.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								frontend/pages/user/group/webhooks.vue
									
									
									
									
									
										Normal 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> | ||||
| @@ -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 | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
							
								
								
									
										1
									
								
								mealie/db/models/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								mealie/db/models/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| from .group import * | ||||
| @@ -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): | ||||
							
								
								
									
										20
									
								
								mealie/db/models/group/webhooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								mealie/db/models/group/webhooks.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										22
									
								
								mealie/routes/groups/categories.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								mealie/routes/groups/categories.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -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() | ||||
| @@ -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), | ||||
|   | ||||
							
								
								
									
										14
									
								
								mealie/routes/groups/self_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								mealie/routes/groups/self_service.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										1
									
								
								mealie/schema/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								mealie/schema/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| from .webhook import * | ||||
							
								
								
									
										19
									
								
								mealie/schema/group/webhook.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								mealie/schema/group/webhook.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -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], | ||||
|             } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,2 +1,3 @@ | ||||
| from .base_http_service import * | ||||
| from .base_service import * | ||||
| from .router_factory import * | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										190
									
								
								mealie/services/base_http_service/router_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								mealie/services/base_http_service/router_factory.py
									
									
									
									
									
										Normal 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"] | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										2
									
								
								mealie/services/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								mealie/services/group/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| from .group_service import * | ||||
| from .webhook_service import * | ||||
							
								
								
									
										47
									
								
								mealie/services/group/group_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mealie/services/group/group_service.py
									
									
									
									
									
										Normal 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) | ||||
							
								
								
									
										54
									
								
								mealie/services/group/webhook_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								mealie/services/group/webhook_service.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -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}") | ||||
|   | ||||
| @@ -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": [], | ||||
|   | ||||
		Reference in New Issue
	
	Block a user