mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	Feature/group based notifications (#918)
* fix group page * setup group notification for backend * update type generators * script to auto-generate schema exports * setup frontend CRUD interface * remove old notifications UI * drop old events api * add test functionality * update naming for fields * add event dispatcher functionality * bump to python 3.10 * bump python version * purge old event code * use-async apprise * set mealie logo as image * unify styles for buttons rows * add links to banners
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/backend-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/backend-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -43,7 +43,7 @@ jobs: | ||||
|       - name: Set up python | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|           python-version: "3.10" | ||||
|       #---------------------------------------------- | ||||
|       #  -----  install & configure poetry  ----- | ||||
|       #---------------------------------------------- | ||||
|   | ||||
| @@ -476,7 +476,6 @@ ignore-comments=yes | ||||
|  | ||||
| # Ignore docstrings when computing similarities. | ||||
| ignore-docstrings=yes | ||||
| w54 | ||||
| # Ignore imports when computing similarities. | ||||
| ignore-imports=no | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| ############################################### | ||||
| # Base Image | ||||
| ############################################### | ||||
| FROM python:3.9-slim as python-base | ||||
| FROM python:3.10-slim as python-base | ||||
|  | ||||
| ENV MEALIE_HOME="/app" | ||||
|  | ||||
|   | ||||
| @@ -1,18 +1,26 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import re | ||||
| from dataclasses import dataclass | ||||
| from pathlib import Path | ||||
| from typing import Tuple | ||||
|  | ||||
| import black | ||||
| import isort | ||||
| from jinja2 import Template | ||||
|  | ||||
|  | ||||
| def render_python_template(template_file: Path, dest: Path, data: dict) -> str: | ||||
| def render_python_template(template_file: Path | str, dest: Path, data: dict) -> str: | ||||
|     """Render and Format a Jinja2 Template for Python Code""" | ||||
|     if isinstance(template_file, Path): | ||||
|         tplt = Template(template_file.read_text()) | ||||
|     else: | ||||
|         tplt = Template(template_file) | ||||
|  | ||||
|     text = tplt.render(data=data) | ||||
|     text = black.format_str(text, mode=black.FileMode()) | ||||
|     dest.write_text(text) | ||||
|     isort.file(dest) | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| CWD = Path(__file__).parent | ||||
| PROJECT_DIR = Path(__file__).parent.parent.parent | ||||
|  | ||||
|  | ||||
| class Directories: | ||||
|   | ||||
							
								
								
									
										99
									
								
								dev/code-generation/gen_frontend_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								dev/code-generation/gen_frontend_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| from jinja2 import Template | ||||
| from pydantic2ts import generate_typescript_defs | ||||
|  | ||||
| # ============================================================ | ||||
| # Global Compoenents Generator | ||||
|  | ||||
| template = """// This Code is auto generated by gen_global_components.py | ||||
| {% for name in global %} import {{ name }} from "@/components/global/{{ name }}.vue"; | ||||
| {% endfor %} | ||||
| {% for name in layout %} import {{ name }} from "@/components/layout/{{ name }}.vue"; | ||||
| {% endfor %} | ||||
|  | ||||
| declare module "vue" { | ||||
|   export interface GlobalComponents { | ||||
|     // Global Components | ||||
|     {% for name in global %} {{ name }}: typeof {{ name }}; | ||||
|     {% endfor %} // Layout Components | ||||
|     {% for name in layout %} {{ name }}: typeof {{ name }}; | ||||
|     {% endfor %} | ||||
|   } | ||||
| } | ||||
|  | ||||
| export {}; | ||||
| """ | ||||
|  | ||||
| CWD = Path(__file__).parent | ||||
| PROJECT_DIR = Path(__file__).parent.parent.parent | ||||
|  | ||||
|  | ||||
| def generate_global_components_types() -> None: | ||||
|     destination_file = PROJECT_DIR / "frontend" / "types" / "components.d.ts" | ||||
|  | ||||
|     component_paths = { | ||||
|         "global": PROJECT_DIR / "frontend" / "components" / "global", | ||||
|         "layout": PROJECT_DIR / "frontend" / "components" / "Layout", | ||||
|     } | ||||
|  | ||||
|     def render_template(template: str, data: dict) -> None: | ||||
|         template = Template(template) | ||||
|         return template.render(**data) | ||||
|  | ||||
|     def build_data() -> dict: | ||||
|         data = {} | ||||
|         for name, path in component_paths.items(): | ||||
|             components = [component.stem for component in path.glob("*.vue")] | ||||
|             data[name] = components | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def write_template(text: str) -> None: | ||||
|         destination_file.write_text(text) | ||||
|  | ||||
|     text = render_template(template, build_data()) | ||||
|     write_template(text) | ||||
|  | ||||
|  | ||||
| # ============================================================ | ||||
| # Pydantic To Typescript Generator | ||||
|  | ||||
|  | ||||
| def generate_typescript_types() -> None: | ||||
|     def path_to_module(path: Path): | ||||
|         path: str = str(path) | ||||
|  | ||||
|         path = path.removeprefix(str(PROJECT_DIR)) | ||||
|         path = path.removeprefix("/") | ||||
|         path = path.replace("/", ".") | ||||
|  | ||||
|         return path | ||||
|  | ||||
|     schema_path = PROJECT_DIR / "mealie" / "schema" | ||||
|     types_dir = PROJECT_DIR / "frontend" / "types" / "api-types" | ||||
|  | ||||
|     for module in schema_path.iterdir(): | ||||
|  | ||||
|         if not module.is_dir() or not module.joinpath("__init__.py").is_file(): | ||||
|             continue | ||||
|  | ||||
|         ts_out_name = module.name.replace("_", "-") + ".ts" | ||||
|  | ||||
|         out_path = types_dir.joinpath(ts_out_name) | ||||
|  | ||||
|         print(module)  # noqa | ||||
|         try: | ||||
|             path_as_module = path_to_module(module) | ||||
|             generate_typescript_defs(path_as_module, str(out_path), exclude=("CamelModel")) | ||||
|         except Exception as e: | ||||
|             print(f"Failed to generate {module}")  # noqa | ||||
|             print(e)  # noqa | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     print("\n-- Starting Global Components Generator --")  # noqa | ||||
|     generate_global_components_types() | ||||
|  | ||||
|     print("\n-- Starting Pydantic To Typescript Generator --")  # noqa | ||||
|     generate_typescript_types() | ||||
							
								
								
									
										35
									
								
								dev/code-generation/gen_schema_exports.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								dev/code-generation/gen_schema_exports.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| from _gen_utils import render_python_template | ||||
| from _static import PROJECT_DIR | ||||
|  | ||||
| template = """# GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| {% for file in data.files %}from .{{ file }} import * | ||||
| {% endfor %} | ||||
| """ | ||||
|  | ||||
| SCHEMA_PATH = PROJECT_DIR / "mealie" / "schema" | ||||
|  | ||||
|  | ||||
| def generate_init_files() -> None: | ||||
|  | ||||
|     for schema in SCHEMA_PATH.iterdir(): | ||||
|         if not schema.is_dir(): | ||||
|             print(f"Skipping {schema}") | ||||
|             continue | ||||
|  | ||||
|         print(f"Generating {schema}") | ||||
|         init_file = schema.joinpath("__init__.py") | ||||
|  | ||||
|         module_files = [ | ||||
|             f.stem for f in schema.iterdir() if f.is_file() and f.suffix == ".py" and not f.stem.startswith("_") | ||||
|         ] | ||||
|         render_python_template(template, init_file, {"files": module_files}) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     print("Starting...") | ||||
|     generate_init_files() | ||||
|     print("Finished...") | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
| @@ -1,58 +0,0 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| from jinja2 import Template | ||||
|  | ||||
| template = """// This Code is auto generated by gen_global_components.py | ||||
| {% for name in global %} import {{ name }} from "@/components/global/{{ name }}.vue"; | ||||
| {% endfor %} | ||||
| {% for name in layout %} import {{ name }} from "@/components/layout/{{ name }}.vue"; | ||||
| {% endfor %} | ||||
|  | ||||
| declare module "vue" { | ||||
|   export interface GlobalComponents { | ||||
|     // Global Components | ||||
|     {% for name in global %} {{ name }}: typeof {{ name }}; | ||||
|     {% endfor %} // Layout Components | ||||
|     {% for name in layout %} {{ name }}: typeof {{ name }}; | ||||
|     {% endfor %} | ||||
|   } | ||||
| } | ||||
|  | ||||
| export {}; | ||||
| """ | ||||
|  | ||||
| project_dir = Path(__file__).parent.parent.parent | ||||
|  | ||||
| destination_file = project_dir / "frontend" / "types" / "components.d.ts" | ||||
|  | ||||
| component_paths = { | ||||
|     "global": project_dir / "frontend" / "components" / "global", | ||||
|     "layout": project_dir / "frontend" / "components" / "Layout", | ||||
| } | ||||
|  | ||||
|  | ||||
| def render_template(template: str, data: dict) -> None: | ||||
|     template = Template(template) | ||||
|  | ||||
|     return template.render(**data) | ||||
|  | ||||
|  | ||||
| def build_data(component_paths: dict) -> dict: | ||||
|     data = {} | ||||
|     for name, path in component_paths.items(): | ||||
|         components = [] | ||||
|         for component in path.glob("*.vue"): | ||||
|             components.append(component.stem) | ||||
|         data[name] = components | ||||
|  | ||||
|     return data | ||||
|  | ||||
|  | ||||
| def write_template(text: str) -> None: | ||||
|     destination_file.write_text(text) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     data = build_data(component_paths) | ||||
|     text = render_template(template, build_data(component_paths)) | ||||
|     write_template(text) | ||||
| @@ -1,37 +0,0 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| from pydantic2ts import generate_typescript_defs | ||||
|  | ||||
| CWD = Path(__file__).parent | ||||
|  | ||||
| PROJECT_DIR = Path(__file__).parent.parent.parent | ||||
| SCHEMA_PATH = PROJECT_DIR / "mealie" / "schema" | ||||
|  | ||||
| TYPES_DIR = CWD / "output" / "types" / "api-types" | ||||
|  | ||||
|  | ||||
| def path_to_module(path: Path): | ||||
|     path: str = str(path) | ||||
|  | ||||
|     path = path.removeprefix(str(PROJECT_DIR)) | ||||
|     path = path.removeprefix("/") | ||||
|     path = path.replace("/", ".") | ||||
|  | ||||
|     return path | ||||
|  | ||||
|  | ||||
| for module in SCHEMA_PATH.iterdir(): | ||||
|  | ||||
|     if not module.is_dir() or not module.joinpath("__init__.py").is_file(): | ||||
|         continue | ||||
|  | ||||
|     ts_out_name = module.name.replace("_", "-") + ".ts" | ||||
|  | ||||
|     out_path = TYPES_DIR.joinpath(ts_out_name) | ||||
|  | ||||
|     print(module) | ||||
|     try: | ||||
|         path_as_module = path_to_module(module) | ||||
|         generate_typescript_defs(path_as_module, str(out_path), exclude=("CamelModel")) | ||||
|     except Exception: | ||||
|         pass | ||||
| @@ -1,41 +0,0 @@ | ||||
| import { BaseCRUDAPI } from "../_base"; | ||||
|  | ||||
| export type EventCategory = "general" | "recipe" | "backup" | "scheduled" | "migration" | "group" | "user"; | ||||
| export type DeclaredTypes = "General" | "Discord" | "Gotify" | "Pushover" | "Home Assistant"; | ||||
| export type GotifyPriority = "low" | "moderate" | "normal" | "high"; | ||||
|  | ||||
| export interface EventNotification { | ||||
|   id?: number; | ||||
|   name?: string; | ||||
|   type?: DeclaredTypes & string; | ||||
|   general?: boolean; | ||||
|   recipe?: boolean; | ||||
|   backup?: boolean; | ||||
|   scheduled?: boolean; | ||||
|   migration?: boolean; | ||||
|   group?: boolean; | ||||
|   user?: boolean; | ||||
| } | ||||
|  | ||||
| export interface CreateEventNotification extends EventNotification { | ||||
|   notificationUrl?: string; | ||||
| } | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| const routes = { | ||||
|   aboutEventsNotifications: `${prefix}/about/events/notifications`, | ||||
|   aboutEventsNotificationsTest: `${prefix}/about/events/notifications/test`, | ||||
|  | ||||
|   aboutEventsNotificationsId: (id: number) => `${prefix}/about/events/notifications/${id}`, | ||||
| }; | ||||
|  | ||||
| export class NotificationsAPI extends BaseCRUDAPI<EventNotification, CreateEventNotification> { | ||||
|   baseRoute = routes.aboutEventsNotifications; | ||||
|   itemRoute = routes.aboutEventsNotificationsId; | ||||
|   /** Returns the Group Data for the Current User | ||||
|    */ | ||||
|   async testNotification(id: number | null = null, testUrl: string | null = null) { | ||||
|     return await this.requests.post(routes.aboutEventsNotificationsTest, { id, testUrl }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										18
									
								
								frontend/api/class-interfaces/group-event-notifier.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/api/class-interfaces/group-event-notifier.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { BaseCRUDAPI } from "../_base"; | ||||
| import { GroupEventNotifierCreate, GroupEventNotifierOut } from "~/types/api-types/group"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| const routes = { | ||||
|   eventNotifier: `${prefix}/groups/events/notifications`, | ||||
|   eventNotifierId: (id: string | number) => `${prefix}/groups/events/notifications/${id}`, | ||||
| }; | ||||
|  | ||||
| export class GroupEventNotifierApi extends BaseCRUDAPI<GroupEventNotifierOut, GroupEventNotifierCreate> { | ||||
|   baseRoute = routes.eventNotifier; | ||||
|   itemRoute = routes.eventNotifierId; | ||||
|  | ||||
|   async test(itemId: string) { | ||||
|     return await this.requests.post(`${this.baseRoute}/${itemId}/test`, {}); | ||||
|   } | ||||
| } | ||||
| @@ -7,7 +7,6 @@ import { UploadFile } from "./class-interfaces/upload"; | ||||
| import { CategoriesAPI } from "./class-interfaces/categories"; | ||||
| import { TagsAPI } from "./class-interfaces/tags"; | ||||
| import { UtilsAPI } from "./class-interfaces/utils"; | ||||
| 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/group-cookbooks"; | ||||
| @@ -23,10 +22,10 @@ import { GroupMigrationApi } from "./class-interfaces/group-migrations"; | ||||
| import { GroupReportsApi } from "./class-interfaces/group-reports"; | ||||
| import { ShoppingApi } from "./class-interfaces/group-shopping-lists"; | ||||
| import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels"; | ||||
| import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier"; | ||||
| import { ApiRequestInstance } from "~/types/api"; | ||||
|  | ||||
| class Api { | ||||
|   // private static instance: Api; | ||||
|   public recipes: RecipeAPI; | ||||
|   public users: UserApi; | ||||
|   public groups: GroupAPI; | ||||
| @@ -35,7 +34,6 @@ class Api { | ||||
|   public categories: CategoriesAPI; | ||||
|   public tags: TagsAPI; | ||||
|   public utils: UtilsAPI; | ||||
|   public notifications: NotificationsAPI; | ||||
|   public foods: FoodAPI; | ||||
|   public units: UnitAPI; | ||||
|   public cookbooks: CookbookAPI; | ||||
| @@ -50,14 +48,10 @@ class Api { | ||||
|   public tools: ToolsApi; | ||||
|   public shopping: ShoppingApi; | ||||
|   public multiPurposeLabels: MultiPurposeLabelsApi; | ||||
|   // Utils | ||||
|   public groupEventNotifier: GroupEventNotifierApi; | ||||
|   public upload: UploadFile; | ||||
|  | ||||
|   constructor(requests: ApiRequestInstance) { | ||||
|     // if (Api.instance instanceof Api) { | ||||
|     //   return Api.instance; | ||||
|     // } | ||||
|  | ||||
|     // Recipes | ||||
|     this.recipes = new RecipeAPI(requests); | ||||
|     this.categories = new CategoriesAPI(requests); | ||||
| @@ -84,7 +78,6 @@ class Api { | ||||
|     // Admin | ||||
|     this.events = new EventsAPI(requests); | ||||
|     this.backups = new BackupAPI(requests); | ||||
|     this.notifications = new NotificationsAPI(requests); | ||||
|  | ||||
|     // Utils | ||||
|     this.upload = new UploadFile(requests); | ||||
| @@ -92,9 +85,9 @@ class Api { | ||||
|  | ||||
|     this.email = new EmailAPI(requests); | ||||
|     this.bulk = new BulkActionsAPI(requests); | ||||
|     this.groupEventNotifier = new GroupEventNotifierApi(requests); | ||||
|  | ||||
|     Object.freeze(this); | ||||
|     // Api.instance = this; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -107,19 +107,43 @@ | ||||
|  | ||||
|               {{ $t("recipe.step-index", { step: index + 1 }) }} | ||||
|  | ||||
|               <div class="ml-auto"> | ||||
|                 <BaseOverflowButton | ||||
|                   v-if="edit" | ||||
|                   small | ||||
|                   mode="event" | ||||
|                   :items="actionEvents || []" | ||||
|               <template v-if="edit"> | ||||
|                 <v-icon class="handle ml-auto mr-2">{{ $globals.icons.arrowUpDown }}</v-icon> | ||||
|                 <div> | ||||
|                   <BaseButtonGroup | ||||
|                     :buttons="[ | ||||
|                       { | ||||
|                         icon: $globals.icons.dotsVertical, | ||||
|                         text: $t('general.delete'), | ||||
|                         children: [ | ||||
|                           { | ||||
|                             text: 'Toggle Section', | ||||
|                             event: 'toggle-section', | ||||
|                           }, | ||||
|                           { | ||||
|                             text: 'Link Ingredients', | ||||
|                             event: 'link-ingredients', | ||||
|                           }, | ||||
|                           { | ||||
|                             text: 'Merge Above', | ||||
|                             event: 'merge-above', | ||||
|                           }, | ||||
|                         ], | ||||
|                       }, | ||||
|                       { | ||||
|                         icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye, | ||||
|                         text: previewStates[index] ? $t('general.edit') : 'Preview Markdown', | ||||
|                         event: 'preview-step', | ||||
|                       }, | ||||
|                     ]" | ||||
|                     @merge-above="mergeAbove(index - 1, index)" | ||||
|                     @toggle-section="toggleShowTitle(step.id)" | ||||
|                     @link-ingredients="openDialog(index, step.ingredientReferences, step.text)" | ||||
|                 > | ||||
|                 </BaseOverflowButton> | ||||
|                     @preview-step="togglePreviewState(index)" | ||||
|                   /> | ||||
|                 </div> | ||||
|               <v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon> | ||||
|               </template> | ||||
|  | ||||
|               <v-fade-transition> | ||||
|                 <v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success"> | ||||
|                   {{ $globals.icons.checkboxMarkedCircle }} | ||||
| @@ -127,7 +151,11 @@ | ||||
|               </v-fade-transition> | ||||
|             </v-card-title> | ||||
|             <v-card-text v-if="edit"> | ||||
|               <MarkdownEditor v-model="value[index]['text']" /> | ||||
|               <MarkdownEditor | ||||
|                 v-model="value[index]['text']" | ||||
|                 :preview.sync="previewStates[index]" | ||||
|                 :display-preview="false" | ||||
|               /> | ||||
|               <div | ||||
|                 v-for="ing in step.ingredientReferences" | ||||
|                 :key="ing.referenceId" | ||||
| @@ -410,7 +438,17 @@ export default defineComponent({ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const previewStates = ref<boolean[]>([]); | ||||
|  | ||||
|     function togglePreviewState(index: number) { | ||||
|       const temp = [...previewStates.value]; | ||||
|       temp[index] = !temp[index]; | ||||
|       previewStates.value = temp; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       togglePreviewState, | ||||
|       previewStates, | ||||
|       ...toRefs(state), | ||||
|       actionEvents, | ||||
|       activeRefs, | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| <template> | ||||
|   <v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column"> | ||||
|     <div v-if="$vuetify.breakpoint.smAndDown" class="pa-2 mx-auto"> | ||||
|       <v-img max-width="150px" max-height="125" :src="image"></v-img> | ||||
|       <v-img max-width="150px" max-height="125" :src="image" /> | ||||
|     </div> | ||||
|     <div class="d-flex align-center justify-space-between"> | ||||
|     <div class="d-flex justify-space-between"> | ||||
|       <div> | ||||
|         <v-card-title class="headline pb-0"> | ||||
|           <slot name="title"> </slot> | ||||
| @@ -14,7 +14,7 @@ | ||||
|           </v-card-text> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div v-if="$vuetify.breakpoint.mdAndUp" class="px-10"> | ||||
|       <div v-if="$vuetify.breakpoint.mdAndUp" class="py-2 px-10 my-auto"> | ||||
|         <v-img max-width="150px" max-height="125" :src="image"></v-img> | ||||
|       </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -189,7 +189,7 @@ export default defineComponent({ | ||||
|     return { | ||||
|       ...toRefs(state), | ||||
|       drawer, | ||||
|     } | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|   | ||||
| @@ -2,5 +2,21 @@ | ||||
|   <v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert"> | ||||
|     <b>Experimental Feature</b> | ||||
|     <div>This page contains experimental or still-baking features. Please excuse the mess.</div> | ||||
|     <div v-if="issue != ''" class="py-2"> | ||||
|       <a :href="issue" target="_blank"> Track our progress here</a> | ||||
|     </div> | ||||
|   </v-alert> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <script lang="ts"> | ||||
| export default { | ||||
|   props: { | ||||
|     issue: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: "", | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| @@ -1,17 +1,19 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <v-tabs v-model="tab" height="30px" class="my-1"> | ||||
|       <v-tab> | ||||
|         <v-icon small left> {{ $globals.icons.edit }}</v-icon> | ||||
|         Edit | ||||
|       </v-tab> | ||||
|       <v-tab> | ||||
|         <v-icon small left> {{ $globals.icons.eye }}</v-icon> | ||||
|         Preview | ||||
|       </v-tab> | ||||
|     </v-tabs> | ||||
|     <div v-if="displayPreview" class="d-flex justify-end"> | ||||
|       <BaseButtonGroup | ||||
|         :buttons="[ | ||||
|           { | ||||
|             icon: previewState ? $globals.icons.edit : $globals.icons.eye, | ||||
|             text: previewState ? $t('general.edit') : 'Preview Markdown', | ||||
|             event: 'toggle', | ||||
|           }, | ||||
|         ]" | ||||
|         @toggle="previewState = !previewState" | ||||
|       /> | ||||
|     </div> | ||||
|     <v-textarea | ||||
|       v-if="tab == 0" | ||||
|       v-if="!previewState" | ||||
|       v-model="inputVal" | ||||
|       :class="label == '' ? '' : 'mt-5'" | ||||
|       :label="label" | ||||
| @@ -27,7 +29,7 @@ | ||||
| // @ts-ignore | ||||
| import VueMarkdown from "@adapttive/vue-markdown"; | ||||
|  | ||||
| import { defineComponent, reactive, toRefs, computed } from "@nuxtjs/composition-api"; | ||||
| import { defineComponent, computed, ref } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   name: "MarkdownEditor", | ||||
| @@ -43,10 +45,28 @@ export default defineComponent({ | ||||
|       type: String, | ||||
|       default: "", | ||||
|     }, | ||||
|     preview: { | ||||
|       type: Boolean, | ||||
|       default: undefined, | ||||
|     }, | ||||
|     displayPreview: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     const state = reactive({ | ||||
|       tab: 0, | ||||
|     const fallbackPreview = ref(false); | ||||
|     const previewState = computed({ | ||||
|       get: () => { | ||||
|         return props.preview ?? fallbackPreview.value; | ||||
|       }, | ||||
|       set: (val) => { | ||||
|         if (props.preview) { | ||||
|           context.emit("input:preview", val); | ||||
|         } else { | ||||
|           fallbackPreview.value = val; | ||||
|         } | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     const inputVal = computed({ | ||||
| @@ -58,10 +78,11 @@ export default defineComponent({ | ||||
|       }, | ||||
|     }); | ||||
|     return { | ||||
|       previewState, | ||||
|       inputVal, | ||||
|       ...toRefs(state), | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,89 +0,0 @@ | ||||
| import { useAsync, ref } from "@nuxtjs/composition-api"; | ||||
| import { useAsyncKey } from "./use-utils"; | ||||
| import { CreateEventNotification } from "@/api/class-interfaces/event-notifications"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
|  | ||||
| const notificationTypes = ["General", "Discord", "Gotify", "Pushover", "Home Assistant"]; | ||||
|  | ||||
| export const useNotifications = function () { | ||||
|   const api = useUserApi(); | ||||
|   const loading = ref(false); | ||||
|  | ||||
|   const createNotificationData = ref<CreateEventNotification>({ | ||||
|     name: "", | ||||
|     type: "General", | ||||
|     general: true, | ||||
|     recipe: true, | ||||
|     backup: true, | ||||
|     scheduled: true, | ||||
|     migration: true, | ||||
|     group: true, | ||||
|     user: true, | ||||
|     notificationUrl: "", | ||||
|   }); | ||||
|  | ||||
|   const deleteTarget = ref(0); | ||||
|  | ||||
|   function getNotifications() { | ||||
|     loading.value = true; | ||||
|     const notifications = useAsync(async () => { | ||||
|       const { data } = await api.notifications.getAll(); | ||||
|       return data; | ||||
|     }, useAsyncKey()); | ||||
|     loading.value = false; | ||||
|     return notifications; | ||||
|   } | ||||
|  | ||||
|   async function refreshNotifications() { | ||||
|     loading.value = true; | ||||
|     const { data } = await api.notifications.getAll(); | ||||
|     if (data) { | ||||
|       notifications.value = data; | ||||
|     } | ||||
|     loading.value = false; | ||||
|   } | ||||
|  | ||||
|   async function createNotification() { | ||||
|     if (createNotificationData.value.name === "" || createNotificationData.value.notificationUrl === "") { | ||||
|       return; | ||||
|     } | ||||
|     const { response } = await api.notifications.createOne(createNotificationData.value); | ||||
|  | ||||
|     if (response?.status === 200) { | ||||
|       refreshNotifications(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function deleteNotification() { | ||||
|     const { response } = await api.notifications.deleteOne(deleteTarget.value); | ||||
|     if (response?.status === 200) { | ||||
|       refreshNotifications(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function testById(id: number) { | ||||
|     const { data } = await api.notifications.testNotification(id, null); | ||||
|     console.log(data); | ||||
|   } | ||||
|  | ||||
|   async function testByUrl(testUrl: string) { | ||||
|     const { data } = await api.notifications.testNotification(null, testUrl); | ||||
|     console.log(data); | ||||
|   } | ||||
|  | ||||
|   const notifications = getNotifications(); | ||||
|  | ||||
|   return { | ||||
|     createNotification, | ||||
|     deleteNotification, | ||||
|     refreshNotifications, | ||||
|     getNotifications, | ||||
|     testById, | ||||
|     testByUrl, | ||||
|     notifications, | ||||
|     loading, | ||||
|     createNotificationData, | ||||
|     notificationTypes, | ||||
|     deleteTarget, | ||||
|   }; | ||||
| }; | ||||
| @@ -60,11 +60,6 @@ export default defineComponent({ | ||||
|         to: "/admin/toolbox", | ||||
|         title: i18n.t("sidebar.toolbox"), | ||||
|         children: [ | ||||
|           { | ||||
|             icon: $globals.icons.bellAlert, | ||||
|             to: "/admin/toolbox/notifications", | ||||
|             title: i18n.t("events.notification"), | ||||
|           }, | ||||
|           { | ||||
|             icon: $globals.icons.foods, | ||||
|             to: "/admin/toolbox/foods", | ||||
|   | ||||
| @@ -41,9 +41,6 @@ | ||||
|         :search="search" | ||||
|         @click:row="handleRowClick" | ||||
|       > | ||||
|         <template #item.shoppingLists="{ item }"> | ||||
|           {{ item.shoppingLists.length }} | ||||
|         </template> | ||||
|         <template #item.users="{ item }"> | ||||
|           {{ item.users.length }} | ||||
|         </template> | ||||
| @@ -99,7 +96,6 @@ export default defineComponent({ | ||||
|         { text: i18n.t("general.name"), value: "name" }, | ||||
|         { text: i18n.t("user.total-users"), value: "users" }, | ||||
|         { text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" }, | ||||
|         { text: i18n.t("shopping-list.shopping-lists"), value: "shoppingLists" }, | ||||
|         { text: i18n.t("general.delete"), value: "actions" }, | ||||
|       ], | ||||
|       updateMode: false, | ||||
|   | ||||
| @@ -1,24 +0,0 @@ | ||||
| <template> | ||||
|   <v-container fluid> | ||||
|     <BaseCardSectionTitle title="Manage Categories"> </BaseCardSectionTitle> | ||||
|   </v-container> | ||||
| </template> | ||||
|      | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   layout: "admin", | ||||
|   setup() { | ||||
|     return {}; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("sidebar.categories") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|      | ||||
| <style scoped> | ||||
| </style> | ||||
| @@ -1,226 +0,0 @@ | ||||
| <template> | ||||
|   <v-container fluid> | ||||
|     <BaseCardSectionTitle title="Event Notifications"> | ||||
|       {{ $t("events.new-notification-form-description") }} | ||||
|  | ||||
|       <div class="d-flex justify-space-around"> | ||||
|         <a href="https://github.com/caronc/apprise/wiki" target="_blanks"> Apprise </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_gotify" target="_blanks"> Gotify </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_discord" target="_blanks"> Discord </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_homeassistant" target="_blanks"> Home Assistant </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_matrix" target="_blanks"> Matrix </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_pushover" target="_blanks"> Pushover </a> | ||||
|       </div> | ||||
|     </BaseCardSectionTitle> | ||||
|  | ||||
|     <BaseDialog | ||||
|       ref="domDeleteConfirmation" | ||||
|       :title="$t('settings.backup.delete-backup')" | ||||
|       color="error" | ||||
|       :icon="$globals.icons.alertCircle" | ||||
|       @confirm="deleteNotification()" | ||||
|     > | ||||
|       <v-card-text> | ||||
|         {{ $t("general.confirm-delete-generic") }} | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <v-toolbar color="background" flat class="justify-between"> | ||||
|       <BaseDialog | ||||
|         :icon="$globals.icons.bellAlert" | ||||
|         :title="$t('general.new') + ' ' + $t('events.notification')" | ||||
|         @submit="createNotification" | ||||
|       > | ||||
|         <template #activator="{ open }"> | ||||
|           <BaseButton @click="open"> {{ $t("events.notification") }}</BaseButton> | ||||
|         </template> | ||||
|  | ||||
|         <v-card-text> | ||||
|           <v-select | ||||
|             v-model="createNotificationData.type" | ||||
|             :items="notificationTypes" | ||||
|             :label="$t('general.type')" | ||||
|           ></v-select> | ||||
|           <v-text-field v-model="createNotificationData.name" :label="$t('general.name')"></v-text-field> | ||||
|           <v-text-field | ||||
|             v-model="createNotificationData.notificationUrl" | ||||
|             :label="$t('events.apprise-url')" | ||||
|           ></v-text-field> | ||||
|  | ||||
|           <BaseButton | ||||
|             class="d-flex ml-auto" | ||||
|             small | ||||
|             color="info" | ||||
|             @click="testByUrl(createNotificationData.notificationUrl)" | ||||
|           > | ||||
|             <template #icon> {{ $globals.icons.testTube }}</template> | ||||
|             {{ $t("general.test") }} | ||||
|           </BaseButton> | ||||
|  | ||||
|           <p class="text-uppercase">{{ $t("events.subscribed-events") }}</p> | ||||
|           <div class="d-flex flex-wrap justify-center"> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.general" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('general.general')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.recipe" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('general.recipe')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.backup" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('settings.backup-and-exports')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.scheduled" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('events.scheduled')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.migration" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('settings.migrations')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.group" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('group.group')" | ||||
|             ></v-checkbox> | ||||
|             <v-checkbox | ||||
|               v-model="createNotificationData.user" | ||||
|               class="mb-n2 mt-n2 mx-2" | ||||
|               :label="$t('user.user')" | ||||
|             ></v-checkbox> | ||||
|           </div> | ||||
|         </v-card-text> | ||||
|       </BaseDialog> | ||||
|     </v-toolbar> | ||||
|  | ||||
|     <!-- Data Table --> | ||||
|     <v-data-table | ||||
|       :headers="headers" | ||||
|       :items="notifications || []" | ||||
|       class="elevation-0" | ||||
|       hide-default-footer | ||||
|       disable-pagination | ||||
|     > | ||||
|       <template v-for="boolHeader in headers" #[`item.${boolHeader.value}`]="{ item }"> | ||||
|         <div :key="boolHeader.value"> | ||||
|           <div v-if="boolHeader.value === 'type'"> | ||||
|             {{ item[boolHeader.value] }} | ||||
|           </div> | ||||
|           <v-icon | ||||
|             v-else-if="item[boolHeader.value] === true || item[boolHeader.value] === false" | ||||
|             :color="item[boolHeader.value] ? 'success' : 'gray'" | ||||
|           > | ||||
|             {{ item[boolHeader.value] ? $globals.icons.check : $globals.icons.close }} | ||||
|           </v-icon> | ||||
|           <div v-else-if="boolHeader.value === 'actions'" class="d-flex"> | ||||
|             <BaseButton | ||||
|               class="mr-1" | ||||
|               delete | ||||
|               x-small | ||||
|               minor | ||||
|               @click=" | ||||
|                 deleteTarget = item.id; | ||||
|                 domDeleteConfirmation.open(); | ||||
|               " | ||||
|             /> | ||||
|             <BaseButton edit x-small @click="testById(item.id)"> | ||||
|               <template #icon> | ||||
|                 {{ $globals.icons.testTube }} | ||||
|               </template> | ||||
|               {{ $t("general.test") }} | ||||
|             </BaseButton> | ||||
|           </div> | ||||
|           <div v-else> | ||||
|             {{ item[boolHeader.value] }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|     </v-data-table> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, reactive, useContext, toRefs, ref } from "@nuxtjs/composition-api"; | ||||
| import { useNotifications } from "@/composables/use-notifications"; | ||||
| export default defineComponent({ | ||||
|   layout: "admin", | ||||
|   setup() { | ||||
|     const { i18n } = useContext(); | ||||
|  | ||||
|     const state = reactive({ | ||||
|       headers: [ | ||||
|         { text: i18n.t("general.type"), value: "type" }, | ||||
|         { text: i18n.t("general.name"), value: "name" }, | ||||
|         { text: i18n.t("general.general"), value: "general", align: "center" }, | ||||
|         { text: i18n.t("general.recipe"), value: "recipe", align: "center" }, | ||||
|         { text: i18n.t("events.database"), value: "backup", align: "center" }, | ||||
|         { text: i18n.t("events.scheduled"), value: "scheduled", align: "center" }, | ||||
|         { text: i18n.t("settings.migrations"), value: "migration", align: "center" }, | ||||
|         { text: i18n.t("group.group"), value: "group", align: "center" }, | ||||
|         { text: i18n.t("user.user"), value: "user", align: "center" }, | ||||
|         { text: "", value: "actions" }, | ||||
|       ], | ||||
|       keepDialogOpen: false, | ||||
|       notifications: [], | ||||
|       newNotification: { | ||||
|         type: "General", | ||||
|         name: "", | ||||
|         notificationUrl: "", | ||||
|       }, | ||||
|       newNotificationOptions: { | ||||
|         general: true, | ||||
|         recipe: true, | ||||
|         backup: true, | ||||
|         scheduled: true, | ||||
|         migration: true, | ||||
|         group: true, | ||||
|         user: true, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     const { | ||||
|       deleteNotification, | ||||
|       createNotification, | ||||
|       refreshNotifications, | ||||
|       notifications, | ||||
|       loading, | ||||
|       testById, | ||||
|       testByUrl, | ||||
|       createNotificationData, | ||||
|       notificationTypes, | ||||
|       deleteTarget, | ||||
|     } = useNotifications(); | ||||
|  | ||||
|     // API | ||||
|     const domDeleteConfirmation = ref(null); | ||||
|     return { | ||||
|       ...toRefs(state), | ||||
|       domDeleteConfirmation, | ||||
|       notifications, | ||||
|       loading, | ||||
|       createNotificationData, | ||||
|       deleteNotification, | ||||
|       deleteTarget, | ||||
|       createNotification, | ||||
|       refreshNotifications, | ||||
|       testById, | ||||
|       testByUrl, | ||||
|       notificationTypes, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("events.notification") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| </style> | ||||
| @@ -1,24 +0,0 @@ | ||||
| <template> | ||||
|   <v-container fluid> | ||||
|     <BaseCardSectionTitle title="Manage Tags"> </BaseCardSectionTitle> | ||||
|   </v-container> | ||||
| </template> | ||||
|      | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   layout: "admin", | ||||
|   setup() { | ||||
|     return {}; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("sidebar.tags") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|      | ||||
| <style scoped> | ||||
| </style> | ||||
| @@ -6,7 +6,7 @@ | ||||
|       </template> | ||||
|       <template #title> {{ shoppingList.name }} </template> | ||||
|     </BasePageTitle> | ||||
|     <BannerExperimental /> | ||||
|     <BannerExperimental issue="https://github.com/hay-kot/mealie/issues/916" /> | ||||
|     <!-- Viewer --> | ||||
|     <section v-if="!edit" class="py-2"> | ||||
|       <div v-if="!byLabel"> | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|     <BaseButton create @click="actions.createOne()" /> | ||||
|     <v-expansion-panels class="mt-2"> | ||||
|       <draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()"> | ||||
|         <v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 my-border rounded"> | ||||
|         <v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 left-border rounded"> | ||||
|           <v-expansion-panel-header disable-icon-rotate class="headline"> | ||||
|             <div class="d-flex align-center"> | ||||
|               <v-icon large left> | ||||
| @@ -23,8 +23,8 @@ | ||||
|               <v-icon class="handle"> | ||||
|                 {{ $globals.icons.arrowUpDown }} | ||||
|               </v-icon> | ||||
|               <v-btn color="info" fab small class="ml-2"> | ||||
|                 <v-icon color="white"> | ||||
|               <v-btn icon small class="ml-2"> | ||||
|                 <v-icon> | ||||
|                   {{ $globals.icons.edit }} | ||||
|                 </v-icon> | ||||
|               </v-btn> | ||||
| @@ -38,8 +38,22 @@ | ||||
|             </v-card-text> | ||||
|             <v-card-actions> | ||||
|               <v-spacer></v-spacer> | ||||
|               <BaseButton delete @click="actions.deleteOne(cookbook.id)" /> | ||||
|               <BaseButton save @click="actions.updateOne(cookbook)"> </BaseButton> | ||||
|               <BaseButtonGroup | ||||
|                 :buttons="[ | ||||
|                   { | ||||
|                     icon: $globals.icons.delete, | ||||
|                     text: $t('general.delete'), | ||||
|                     event: 'delete', | ||||
|                   }, | ||||
|                   { | ||||
|                     icon: $globals.icons.save, | ||||
|                     text: $t('general.save'), | ||||
|                     event: 'save', | ||||
|                   }, | ||||
|                 ]" | ||||
|                 @delete="actions.deleteOne(webhook.id)" | ||||
|                 @save="actions.updateOne(webhook)" | ||||
|               /> | ||||
|             </v-card-actions> | ||||
|           </v-expansion-panel-content> | ||||
|         </v-expansion-panel> | ||||
| @@ -70,9 +84,3 @@ export default defineComponent({ | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|      | ||||
| <style> | ||||
| .my-border { | ||||
|   border-left: 5px solid var(--v-primary-base) !important; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										307
									
								
								frontend/pages/user/group/notifiers.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								frontend/pages/user/group/notifiers.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,307 @@ | ||||
| <template> | ||||
|   <v-container class="narrow-container"> | ||||
|     <BaseDialog | ||||
|       v-model="deleteDialog" | ||||
|       color="error" | ||||
|       :title="$t('general.confirm')" | ||||
|       :icon="$globals.icons.alertCircle" | ||||
|       @confirm="deleteNotifier(deleteTargetId)" | ||||
|     > | ||||
|       <v-card-text> | ||||
|         {{ $t("general.confirm-delete-generic") }} | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|     <BaseDialog v-model="createDialog" @submit="createNewNotifier"> | ||||
|       <v-card-text> | ||||
|         <v-text-field v-model="createNotifierData.name" :label="$t('general.name')"></v-text-field> | ||||
|         <v-text-field v-model="createNotifierData.appriseUrl" :label="$t('events.apprise-url')"></v-text-field> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BasePageTitle divider> | ||||
|       <template #header> | ||||
|         <v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-notifiers.svg')"></v-img> | ||||
|       </template> | ||||
|       <template #title> Event Notifiers </template> | ||||
|       {{ $t("events.new-notification-form-description") }} | ||||
|  | ||||
|       <div class="mt-3 d-flex justify-space-around"> | ||||
|         <a href="https://github.com/caronc/apprise/wiki" target="_blanks"> Apprise </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_gotify" target="_blanks"> Gotify </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_discord" target="_blanks"> Discord </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_homeassistant" target="_blanks"> Home Assistant </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_matrix" target="_blanks"> Matrix </a> | ||||
|         <a href="https://github.com/caronc/apprise/wiki/Notify_pushover" target="_blanks"> Pushover </a> | ||||
|       </div> | ||||
|     </BasePageTitle> | ||||
|  | ||||
|     <BannerExperimental issue="https://github.com/hay-kot/mealie/issues/833" /> | ||||
|  | ||||
|     <BaseButton create @click="createDialog = true" /> | ||||
|     <v-expansion-panels v-if="notifiers" class="mt-2"> | ||||
|       <v-expansion-panel v-for="(notifier, index) in notifiers" :key="index" class="my-2 left-border rounded"> | ||||
|         <v-expansion-panel-header disable-icon-rotate class="headline"> | ||||
|           <div class="d-flex align-center"> | ||||
|             {{ notifier.name }} | ||||
|           </div> | ||||
|           <template #actions> | ||||
|             <v-btn icon class="ml-2"> | ||||
|               <v-icon> | ||||
|                 {{ $globals.icons.edit }} | ||||
|               </v-icon> | ||||
|             </v-btn> | ||||
|           </template> | ||||
|         </v-expansion-panel-header> | ||||
|         <v-expansion-panel-content> | ||||
|           <v-text-field v-model="notifiers[index].name" label="Name"></v-text-field> | ||||
|           <v-text-field v-model="notifiers[index].appriseUrl" label="Apprise URL (skipped in blank)"></v-text-field> | ||||
|           <v-checkbox v-model="notifiers[index].enabled" label="Enable Notifier" dense></v-checkbox> | ||||
|  | ||||
|           <v-divider></v-divider> | ||||
|           <p class="pt-4">What events should this notifier subscribe to?</p> | ||||
|           <template v-for="(opt, idx) in optionsKeys"> | ||||
|             <v-checkbox | ||||
|               v-if="!opt.divider" | ||||
|               :key="'option-' + idx" | ||||
|               v-model="notifiers[index].options[opt.key]" | ||||
|               hide-details | ||||
|               dense | ||||
|               :label="opt.text" | ||||
|             ></v-checkbox> | ||||
|             <div v-else :key="'divider-' + idx" class="mt-4"> | ||||
|               {{ opt.text }} | ||||
|             </div> | ||||
|           </template> | ||||
|           <v-card-actions class="py-0"> | ||||
|             <v-spacer></v-spacer> | ||||
|             <BaseButtonGroup | ||||
|               :buttons="[ | ||||
|                 { | ||||
|                   icon: $globals.icons.delete, | ||||
|                   text: $t('general.delete'), | ||||
|                   event: 'delete', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.testTube, | ||||
|                   text: $t('general.test'), | ||||
|                   event: 'test', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.save, | ||||
|                   text: $t('general.save'), | ||||
|                   event: 'save', | ||||
|                 }, | ||||
|               ]" | ||||
|               @delete="openDelete(notifier)" | ||||
|               @save="saveNotifier(notifier)" | ||||
|               @test="testNotifier(notifier)" | ||||
|             /> | ||||
|           </v-card-actions> | ||||
|         </v-expansion-panel-content> | ||||
|       </v-expansion-panel> | ||||
|     </v-expansion-panels> | ||||
|   </v-container> | ||||
| </template> | ||||
| <script lang="ts"> | ||||
| import { defineComponent, useAsync, reactive, useContext, toRefs } from "@nuxtjs/composition-api"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
| import { GroupEventNotifierCreate, GroupEventNotifierOut } from "~/types/api-types/group"; | ||||
|  | ||||
| interface OptionKey { | ||||
|   text: string; | ||||
|   key: string; | ||||
| } | ||||
|  | ||||
| interface OptionDivider { | ||||
|   divider: true; | ||||
|   text: string; | ||||
| } | ||||
|  | ||||
| export default defineComponent({ | ||||
|   setup() { | ||||
|     const api = useUserApi(); | ||||
|  | ||||
|     const state = reactive({ | ||||
|       deleteDialog: false, | ||||
|       createDialog: false, | ||||
|       deleteTargetId: "", | ||||
|     }); | ||||
|  | ||||
|     const notifiers = useAsync(async () => { | ||||
|       const { data } = await api.groupEventNotifier.getAll(); | ||||
|       return data ?? []; | ||||
|     }, useAsyncKey()); | ||||
|  | ||||
|     async function refreshNotifiers() { | ||||
|       const { data } = await api.groupEventNotifier.getAll(); | ||||
|       notifiers.value = data ?? []; | ||||
|     } | ||||
|  | ||||
|     const createNotifierData: GroupEventNotifierCreate = reactive({ | ||||
|       name: "", | ||||
|       enabled: true, | ||||
|       appriseUrl: "", | ||||
|     }); | ||||
|  | ||||
|     async function createNewNotifier() { | ||||
|       await api.groupEventNotifier.createOne(createNotifierData); | ||||
|       refreshNotifiers(); | ||||
|     } | ||||
|  | ||||
|     function openDelete(notifier: GroupEventNotifierOut) { | ||||
|       state.deleteDialog = true; | ||||
|       state.deleteTargetId = notifier.id; | ||||
|     } | ||||
|  | ||||
|     async function deleteNotifier(targetId: string) { | ||||
|       await api.groupEventNotifier.deleteOne(targetId); | ||||
|       refreshNotifiers(); | ||||
|       state.deleteTargetId = ""; | ||||
|     } | ||||
|  | ||||
|     async function saveNotifier(notifier: GroupEventNotifierOut) { | ||||
|       await api.groupEventNotifier.updateOne(notifier.id, notifier); | ||||
|       refreshNotifiers(); | ||||
|     } | ||||
|  | ||||
|     async function testNotifier(notifier: GroupEventNotifierOut) { | ||||
|       await api.groupEventNotifier.test(notifier.id); | ||||
|     } | ||||
|  | ||||
|     // =============================================================== | ||||
|     // Options Definitions | ||||
|     const { i18n } = useContext(); | ||||
|  | ||||
|     const optionsKeys: (OptionKey | OptionDivider)[] = [ | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Recipe Events", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.create") as string, | ||||
|         key: "recipeCreated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.update") as string, | ||||
|         key: "recipeUpdated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.delete") as string, | ||||
|         key: "recipeDeleted", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "User Events", | ||||
|       }, | ||||
|       { | ||||
|         text: "When a new user joins your group", | ||||
|         key: "userSignup", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Data Events", | ||||
|       }, | ||||
|       { | ||||
|         text: "When a new data migration is completed", | ||||
|         key: "dataMigrations", | ||||
|       }, | ||||
|       { | ||||
|         text: "When a data export is completed", | ||||
|         key: "dataExport", | ||||
|       }, | ||||
|       { | ||||
|         text: "When a data import is completed", | ||||
|         key: "dataImport", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Mealplan Events", | ||||
|       }, | ||||
|       { | ||||
|         text: "When a user in your group creates a new mealplan", | ||||
|         key: "mealplanEntryCreated", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Shopping List Events", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.create") as string, | ||||
|         key: "shoppingListCreated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.update") as string, | ||||
|         key: "shoppingListUpdated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.delete") as string, | ||||
|         key: "shoppingListDeleted", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Cookbook Events", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.create") as string, | ||||
|         key: "cookbookCreated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.update") as string, | ||||
|         key: "cookbookUpdated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.delete") as string, | ||||
|         key: "cookbookDeleted", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Tag Events", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.create") as string, | ||||
|         key: "tagCreated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.update") as string, | ||||
|         key: "tagUpdated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.delete") as string, | ||||
|         key: "tagDeleted", | ||||
|       }, | ||||
|       { | ||||
|         divider: true, | ||||
|         text: "Category Events", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.create") as string, | ||||
|         key: "categoryCreated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.update") as string, | ||||
|         key: "categoryUpdated", | ||||
|       }, | ||||
|       { | ||||
|         text: i18n.t("general.delete") as string, | ||||
|         key: "categoryDeleted", | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     return { | ||||
|       ...toRefs(state), | ||||
|       openDelete, | ||||
|       optionsKeys, | ||||
|       notifiers, | ||||
|       createNotifierData, | ||||
|       deleteNotifier, | ||||
|       testNotifier, | ||||
|       saveNotifier, | ||||
|       createNewNotifier, | ||||
|     }; | ||||
|   }, | ||||
|   head: { | ||||
|     title: "Notifiers", | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -11,7 +11,7 @@ | ||||
|  | ||||
|     <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 v-for="(webhook, index) in webhooks" :key="index" class="my-2 left-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"> | ||||
| @@ -20,8 +20,8 @@ | ||||
|             {{ webhook.name }} - {{ webhook.time }} | ||||
|           </div> | ||||
|           <template #actions> | ||||
|             <v-btn color="info" fab small class="ml-2"> | ||||
|               <v-icon color="white"> | ||||
|             <v-btn small icon class="ml-2"> | ||||
|               <v-icon> | ||||
|                 {{ $globals.icons.edit }} | ||||
|               </v-icon> | ||||
|             </v-btn> | ||||
| @@ -34,16 +34,28 @@ | ||||
|             <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 class="py-0 justify-end"> | ||||
|             <BaseButtonGroup | ||||
|               :buttons="[ | ||||
|                 { | ||||
|                   icon: $globals.icons.delete, | ||||
|                   text: $t('general.delete'), | ||||
|                   event: 'delete', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.testTube, | ||||
|                   text: $t('general.test'), | ||||
|                   event: 'test', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.save, | ||||
|                   text: $t('general.save'), | ||||
|                   event: 'save', | ||||
|                 }, | ||||
|               ]" | ||||
|               @delete="actions.deleteOne(webhook.id)" | ||||
|               @save="actions.updateOne(webhook)" | ||||
|             /> | ||||
|           </v-card-actions> | ||||
|         </v-expansion-panel-content> | ||||
|       </v-expansion-panel> | ||||
|   | ||||
| @@ -98,6 +98,15 @@ | ||||
|             Setup webhooks that trigger on days that you have have mealplan scheduled. | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col v-if="user.advanced" cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             :link="{ text: 'Manage Notifiers', to: '/user/group/notifiers' }" | ||||
|             :image="require('~/static/svgs/manage-notifiers.svg')" | ||||
|           > | ||||
|             <template #title> Notifiers </template> | ||||
|             Setup email and push notifications that trigger on specific events. | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col v-if="user.canManage" cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             :link="{ text: 'Manage Members', to: '/user/group/members' }" | ||||
| @@ -218,4 +227,3 @@ export default defineComponent({ | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|      | ||||
|   | ||||
							
								
								
									
										1
									
								
								frontend/static/svgs/manage-notifiers.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/static/svgs/manage-notifiers.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 9.2 KiB | 
| @@ -5,12 +5,211 @@ | ||||
| /* Do not modify it by hand - just update the pydantic models and then re-run the script | ||||
| */ | ||||
|  | ||||
| export type SupportedMigrations = "nextcloud" | "chowdown" | "paprika" | "mealie_alpha"; | ||||
|  | ||||
| export interface CreateGroupPreferences { | ||||
|   privateGroup?: boolean; | ||||
|   firstDayOfWeek?: number; | ||||
|   recipePublic?: boolean; | ||||
|   recipeShowNutrition?: boolean; | ||||
|   recipeShowAssets?: boolean; | ||||
|   recipeLandscapeView?: boolean; | ||||
|   recipeDisableComments?: boolean; | ||||
|   recipeDisableAmount?: boolean; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface CreateInviteToken { | ||||
|   uses: number; | ||||
| } | ||||
| export interface CreateWebhook { | ||||
|   enabled?: boolean; | ||||
|   name?: string; | ||||
|   url?: string; | ||||
|   time?: string; | ||||
| } | ||||
| export interface DataMigrationCreate { | ||||
|   sourceType: SupportedMigrations; | ||||
| } | ||||
| export interface EmailInitationResponse { | ||||
|   success: boolean; | ||||
|   error?: string; | ||||
| } | ||||
| export interface EmailInvitation { | ||||
|   email: string; | ||||
|   token: string; | ||||
| } | ||||
| export interface GroupAdminUpdate { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   preferences: UpdateGroupPreferences; | ||||
| } | ||||
| export interface UpdateGroupPreferences { | ||||
|   privateGroup?: boolean; | ||||
|   firstDayOfWeek?: number; | ||||
|   recipePublic?: boolean; | ||||
|   recipeShowNutrition?: boolean; | ||||
|   recipeShowAssets?: boolean; | ||||
|   recipeLandscapeView?: boolean; | ||||
|   recipeDisableComments?: boolean; | ||||
|   recipeDisableAmount?: boolean; | ||||
| } | ||||
| export interface GroupDataExport { | ||||
|   id: string; | ||||
|   groupId: string; | ||||
|   name: string; | ||||
|   filename: string; | ||||
|   path: string; | ||||
|   size: string; | ||||
|   expires: string; | ||||
| } | ||||
| export interface GroupEventNotifierCreate { | ||||
|   name: string; | ||||
|   appriseUrl: string; | ||||
| } | ||||
| /** | ||||
|  * These events are in-sync with the EventTypes found in the EventBusService. | ||||
|  * If you modify this, make sure to update the EventBusService as well. | ||||
|  */ | ||||
| export interface GroupEventNotifierOptions { | ||||
|   recipeCreated?: boolean; | ||||
|   recipeUpdated?: boolean; | ||||
|   recipeDeleted?: boolean; | ||||
|   userSignup?: boolean; | ||||
|   dataMigrations?: boolean; | ||||
|   dataExport?: boolean; | ||||
|   dataImport?: boolean; | ||||
|   mealplanEntryCreated?: boolean; | ||||
|   shoppingListCreated?: boolean; | ||||
|   shoppingListUpdated?: boolean; | ||||
|   shoppingListDeleted?: boolean; | ||||
|   cookbookCreated?: boolean; | ||||
|   cookbookUpdated?: boolean; | ||||
|   cookbookDeleted?: boolean; | ||||
|   tagCreated?: boolean; | ||||
|   tagUpdated?: boolean; | ||||
|   tagDeleted?: boolean; | ||||
|   categoryCreated?: boolean; | ||||
|   categoryUpdated?: boolean; | ||||
|   categoryDeleted?: boolean; | ||||
| } | ||||
| /** | ||||
|  * These events are in-sync with the EventTypes found in the EventBusService. | ||||
|  * If you modify this, make sure to update the EventBusService as well. | ||||
|  */ | ||||
| export interface GroupEventNotifierOptionsOut { | ||||
|   recipeCreated?: boolean; | ||||
|   recipeUpdated?: boolean; | ||||
|   recipeDeleted?: boolean; | ||||
|   userSignup?: boolean; | ||||
|   dataMigrations?: boolean; | ||||
|   dataExport?: boolean; | ||||
|   dataImport?: boolean; | ||||
|   mealplanEntryCreated?: boolean; | ||||
|   shoppingListCreated?: boolean; | ||||
|   shoppingListUpdated?: boolean; | ||||
|   shoppingListDeleted?: boolean; | ||||
|   cookbookCreated?: boolean; | ||||
|   cookbookUpdated?: boolean; | ||||
|   cookbookDeleted?: boolean; | ||||
|   tagCreated?: boolean; | ||||
|   tagUpdated?: boolean; | ||||
|   tagDeleted?: boolean; | ||||
|   categoryCreated?: boolean; | ||||
|   categoryUpdated?: boolean; | ||||
|   categoryDeleted?: boolean; | ||||
|   id: string; | ||||
| } | ||||
| /** | ||||
|  * These events are in-sync with the EventTypes found in the EventBusService. | ||||
|  * If you modify this, make sure to update the EventBusService as well. | ||||
|  */ | ||||
| export interface GroupEventNotifierOptionsSave { | ||||
|   recipeCreated?: boolean; | ||||
|   recipeUpdated?: boolean; | ||||
|   recipeDeleted?: boolean; | ||||
|   userSignup?: boolean; | ||||
|   dataMigrations?: boolean; | ||||
|   dataExport?: boolean; | ||||
|   dataImport?: boolean; | ||||
|   mealplanEntryCreated?: boolean; | ||||
|   shoppingListCreated?: boolean; | ||||
|   shoppingListUpdated?: boolean; | ||||
|   shoppingListDeleted?: boolean; | ||||
|   cookbookCreated?: boolean; | ||||
|   cookbookUpdated?: boolean; | ||||
|   cookbookDeleted?: boolean; | ||||
|   tagCreated?: boolean; | ||||
|   tagUpdated?: boolean; | ||||
|   tagDeleted?: boolean; | ||||
|   categoryCreated?: boolean; | ||||
|   categoryUpdated?: boolean; | ||||
|   categoryDeleted?: boolean; | ||||
|   notifierId: string; | ||||
| } | ||||
| export interface GroupEventNotifierOut { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   enabled: boolean; | ||||
|   groupId: string; | ||||
|   options: GroupEventNotifierOptionsOut; | ||||
| } | ||||
| export interface GroupEventNotifierPrivate { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   enabled: boolean; | ||||
|   groupId: string; | ||||
|   options: GroupEventNotifierOptionsOut; | ||||
|   appriseUrl: string; | ||||
| } | ||||
| export interface GroupEventNotifierSave { | ||||
|   name: string; | ||||
|   appriseUrl: string; | ||||
|   enabled?: boolean; | ||||
|   groupId: string; | ||||
|   options?: GroupEventNotifierOptions; | ||||
| } | ||||
| export interface GroupEventNotifierUpdate { | ||||
|   name: string; | ||||
|   appriseUrl?: string; | ||||
|   enabled?: boolean; | ||||
|   groupId: string; | ||||
|   options?: GroupEventNotifierOptions; | ||||
|   id: string; | ||||
| } | ||||
| export interface IngredientFood { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   fraction?: boolean; | ||||
|   abbreviation?: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface MultiPurposeLabelSummary { | ||||
|   name: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| export interface ReadGroupPreferences { | ||||
|   privateGroup?: boolean; | ||||
|   firstDayOfWeek?: number; | ||||
|   recipePublic?: boolean; | ||||
|   recipeShowNutrition?: boolean; | ||||
|   recipeShowAssets?: boolean; | ||||
|   recipeLandscapeView?: boolean; | ||||
|   recipeDisableComments?: boolean; | ||||
|   recipeDisableAmount?: boolean; | ||||
|   groupId: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface ReadInviteToken { | ||||
|   token: string; | ||||
|   usesLeft: number; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface ReadWebhook { | ||||
|   enabled?: boolean; | ||||
|   name?: string; | ||||
| @@ -19,6 +218,11 @@ export interface ReadWebhook { | ||||
|   groupId: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface SaveInviteToken { | ||||
|   usesLeft: number; | ||||
|   groupId: string; | ||||
|   token: string; | ||||
| } | ||||
| export interface SaveWebhook { | ||||
|   enabled?: boolean; | ||||
|   name?: string; | ||||
| @@ -26,3 +230,78 @@ export interface SaveWebhook { | ||||
|   time?: string; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface SetPermissions { | ||||
|   userId: string; | ||||
|   canManage?: boolean; | ||||
|   canInvite?: boolean; | ||||
|   canOrganize?: boolean; | ||||
| } | ||||
| /** | ||||
|  * Create Shopping List | ||||
|  */ | ||||
| export interface ShoppingListCreate { | ||||
|   name?: string; | ||||
| } | ||||
| export interface ShoppingListItemCreate { | ||||
|   shoppingListId: string; | ||||
|   checked?: boolean; | ||||
|   position?: number; | ||||
|   isFood?: boolean; | ||||
|   note?: string; | ||||
|   quantity?: number; | ||||
|   unitId?: number; | ||||
|   unit?: IngredientUnit; | ||||
|   foodId?: number; | ||||
|   food?: IngredientFood; | ||||
|   recipeId?: number; | ||||
|   labelId?: string; | ||||
| } | ||||
| export interface ShoppingListItemOut { | ||||
|   shoppingListId: string; | ||||
|   checked?: boolean; | ||||
|   position?: number; | ||||
|   isFood?: boolean; | ||||
|   note?: string; | ||||
|   quantity?: number; | ||||
|   unitId?: number; | ||||
|   unit?: IngredientUnit; | ||||
|   foodId?: number; | ||||
|   food?: IngredientFood; | ||||
|   recipeId?: number; | ||||
|   labelId?: string; | ||||
|   id: string; | ||||
|   label?: MultiPurposeLabelSummary; | ||||
| } | ||||
| /** | ||||
|  * Create Shopping List | ||||
|  */ | ||||
| export interface ShoppingListOut { | ||||
|   name?: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   listItems?: ShoppingListItemOut[]; | ||||
| } | ||||
| /** | ||||
|  * Create Shopping List | ||||
|  */ | ||||
| export interface ShoppingListSave { | ||||
|   name?: string; | ||||
|   groupId: string; | ||||
| } | ||||
| /** | ||||
|  * Create Shopping List | ||||
|  */ | ||||
| export interface ShoppingListSummary { | ||||
|   name?: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| /** | ||||
|  * Create Shopping List | ||||
|  */ | ||||
| export interface ShoppingListUpdate { | ||||
|   name?: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   listItems?: ShoppingListItemOut[]; | ||||
| } | ||||
|   | ||||
							
								
								
									
										59
									
								
								frontend/types/api-types/labels.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/types/api-types/labels.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| /* tslint:disable */ | ||||
| /* eslint-disable */ | ||||
| /** | ||||
| /* This file was automatically generated from pydantic models by running pydantic2ts. | ||||
| /* Do not modify it by hand - just update the pydantic models and then re-run the script | ||||
| */ | ||||
|  | ||||
| export interface IngredientFood { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface MultiPurposeLabelCreate { | ||||
|   name: string; | ||||
| } | ||||
| export interface MultiPurposeLabelOut { | ||||
|   name: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   shoppingListItems?: ShoppingListItemOut[]; | ||||
|   foods?: IngredientFood[]; | ||||
| } | ||||
| export interface ShoppingListItemOut { | ||||
|   shoppingListId: string; | ||||
|   checked?: boolean; | ||||
|   position?: number; | ||||
|   isFood?: boolean; | ||||
|   note?: string; | ||||
|   quantity?: number; | ||||
|   unitId?: number; | ||||
|   unit?: IngredientUnit; | ||||
|   foodId?: number; | ||||
|   food?: IngredientFood; | ||||
|   recipeId?: number; | ||||
|   labelId?: string; | ||||
|   id: string; | ||||
|   label?: MultiPurposeLabelSummary; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   fraction?: boolean; | ||||
|   abbreviation?: string; | ||||
|   id: number; | ||||
| } | ||||
| export interface MultiPurposeLabelSummary { | ||||
|   name: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| export interface MultiPurposeLabelSave { | ||||
|   name: string; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface MultiPurposeLabelUpdate { | ||||
|   name: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| @@ -5,13 +5,36 @@ | ||||
| /* Do not modify it by hand - just update the pydantic models and then re-run the script | ||||
| */ | ||||
|  | ||||
| export type ExportTypes = "json"; | ||||
| export type RegisteredParser = "nlp" | "brute"; | ||||
|  | ||||
| export interface AssignCategories { | ||||
|   recipes: string[]; | ||||
|   categories: CategoryBase[]; | ||||
| } | ||||
| export interface CategoryBase { | ||||
|   name: string; | ||||
|   id: number; | ||||
|   slug: string; | ||||
| } | ||||
| export interface AssignTags { | ||||
|   recipes: string[]; | ||||
|   tags: TagBase[]; | ||||
| } | ||||
| export interface TagBase { | ||||
|   name: string; | ||||
|   id: number; | ||||
|   slug: string; | ||||
| } | ||||
| export interface BulkActionError { | ||||
|   recipe: string; | ||||
|   error: string; | ||||
| } | ||||
| export interface BulkActionsResponse { | ||||
|   success: boolean; | ||||
|   message: string; | ||||
|   errors?: BulkActionError[]; | ||||
| } | ||||
| export interface CategoryIn { | ||||
|   name: string; | ||||
| } | ||||
| @@ -47,6 +70,13 @@ export interface CreateRecipeByUrl { | ||||
| export interface CreateRecipeByUrlBulk { | ||||
|   imports: CreateRecipeBulk[]; | ||||
| } | ||||
| export interface DeleteRecipes { | ||||
|   recipes: string[]; | ||||
| } | ||||
| export interface ExportRecipes { | ||||
|   recipes: string[]; | ||||
|   exportType?: ExportTypes & string; | ||||
| } | ||||
| export interface IngredientConfidence { | ||||
|   average?: number; | ||||
|   comment?: number; | ||||
| @@ -60,6 +90,12 @@ export interface IngredientFood { | ||||
|   description?: string; | ||||
|   id: number; | ||||
| } | ||||
| /** | ||||
|  * A list of ingredient references. | ||||
|  */ | ||||
| export interface IngredientReferences { | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface IngredientRequest { | ||||
|   parser?: RegisteredParser & string; | ||||
|   ingredient: string; | ||||
| @@ -141,12 +177,6 @@ export interface RecipeStep { | ||||
|   text: string; | ||||
|   ingredientReferences?: IngredientReferences[]; | ||||
| } | ||||
| /** | ||||
|  * A list of ingredient references. | ||||
|  */ | ||||
| export interface IngredientReferences { | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface RecipeSettings { | ||||
|   public?: boolean; | ||||
|   showNutrition?: boolean; | ||||
| @@ -198,6 +228,30 @@ export interface RecipeCommentUpdate { | ||||
|   id: string; | ||||
|   text: string; | ||||
| } | ||||
| export interface RecipeShareToken { | ||||
|   recipeId: number; | ||||
|   expiresAt?: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   createdAt: string; | ||||
|   recipe: Recipe; | ||||
| } | ||||
| export interface RecipeShareTokenCreate { | ||||
|   recipeId: number; | ||||
|   expiresAt?: string; | ||||
| } | ||||
| export interface RecipeShareTokenSave { | ||||
|   recipeId: number; | ||||
|   expiresAt?: string; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface RecipeShareTokenSummary { | ||||
|   recipeId: number; | ||||
|   expiresAt?: string; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   createdAt: string; | ||||
| } | ||||
| export interface RecipeSlug { | ||||
|   slug: string; | ||||
| } | ||||
| @@ -247,11 +301,6 @@ export interface RecipeToolResponse { | ||||
|   recipes?: Recipe[]; | ||||
| } | ||||
| export interface SlugResponse {} | ||||
| export interface TagBase { | ||||
|   name: string; | ||||
|   id: number; | ||||
|   slug: string; | ||||
| } | ||||
| export interface TagIn { | ||||
|   name: string; | ||||
| } | ||||
|   | ||||
| @@ -19,6 +19,19 @@ export interface CreateToken { | ||||
|   userId: string; | ||||
|   token: string; | ||||
| } | ||||
| export interface CreateUserRegistration { | ||||
|   group?: string; | ||||
|   groupToken?: string; | ||||
|   email: string; | ||||
|   username: string; | ||||
|   password: string; | ||||
|   passwordConfirm: string; | ||||
|   advanced?: boolean; | ||||
|   private?: boolean; | ||||
| } | ||||
| export interface ForgotPassword { | ||||
|   email: string; | ||||
| } | ||||
| export interface GroupBase { | ||||
|   name: string; | ||||
| } | ||||
| @@ -28,7 +41,6 @@ export interface GroupInDB { | ||||
|   categories?: CategoryBase[]; | ||||
|   webhooks?: unknown[]; | ||||
|   users?: UserOut[]; | ||||
|   shoppingLists?: ShoppingListOut[]; | ||||
|   preferences?: ReadGroupPreferences; | ||||
| } | ||||
| export interface UserOut { | ||||
| @@ -52,18 +64,6 @@ export interface LongLiveTokenOut { | ||||
|   id: number; | ||||
|   createdAt: string; | ||||
| } | ||||
| export interface ShoppingListOut { | ||||
|   name: string; | ||||
|   group?: string; | ||||
|   items: ListItem[]; | ||||
|   id: number; | ||||
| } | ||||
| export interface ListItem { | ||||
|   title?: string; | ||||
|   text?: string; | ||||
|   quantity?: number; | ||||
|   checked?: boolean; | ||||
| } | ||||
| export interface ReadGroupPreferences { | ||||
|   privateGroup?: boolean; | ||||
|   firstDayOfWeek?: number; | ||||
| @@ -103,6 +103,11 @@ export interface PrivateUser { | ||||
|   cacheKey: string; | ||||
|   password: string; | ||||
| } | ||||
| export interface PrivatePasswordResetToken { | ||||
|   userId: string; | ||||
|   token: string; | ||||
|   user: PrivateUser; | ||||
| } | ||||
| export interface RecipeSummary { | ||||
|   id?: number; | ||||
|   userId?: string; | ||||
| @@ -166,6 +171,16 @@ export interface CreateIngredientFood { | ||||
|   name: string; | ||||
|   description?: string; | ||||
| } | ||||
| export interface ResetPassword { | ||||
|   token: string; | ||||
|   email: string; | ||||
|   password: string; | ||||
|   passwordConfirm: string; | ||||
| } | ||||
| export interface SavePasswordResetToken { | ||||
|   userId: string; | ||||
|   token: string; | ||||
| } | ||||
| export interface SignUpIn { | ||||
|   name: string; | ||||
|   admin: boolean; | ||||
| @@ -231,3 +246,6 @@ export interface UserIn { | ||||
|   canOrganize?: boolean; | ||||
|   password: string; | ||||
| } | ||||
| export interface ValidateResetToken { | ||||
|   token: string; | ||||
| } | ||||
|   | ||||
							
								
								
									
										4
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -5,7 +5,9 @@ | ||||
|  import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; | ||||
|  import ReportTable from "@/components/global/ReportTable.vue"; | ||||
|  import AppToolbar from "@/components/global/AppToolbar.vue"; | ||||
|  import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue"; | ||||
|  import BaseButton from "@/components/global/BaseButton.vue"; | ||||
|  import BannerExperimental from "@/components/global/BannerExperimental.vue"; | ||||
|  import BaseDialog from "@/components/global/BaseDialog.vue"; | ||||
|  import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue"; | ||||
|  import BaseStatCard from "@/components/global/BaseStatCard.vue"; | ||||
| @@ -31,7 +33,9 @@ declare module "vue" { | ||||
|      BaseOverflowButton: typeof BaseOverflowButton; | ||||
|      ReportTable: typeof ReportTable; | ||||
|      AppToolbar: typeof AppToolbar; | ||||
|      BaseButtonGroup: typeof BaseButtonGroup; | ||||
|      BaseButton: typeof BaseButton; | ||||
|      BannerExperimental: typeof BannerExperimental; | ||||
|      BaseDialog: typeof BaseDialog; | ||||
|      RecipeJsonEditor: typeof RecipeJsonEditor; | ||||
|      BaseStatCard: typeof BaseStatCard; | ||||
|   | ||||
							
								
								
									
										8
									
								
								makefile
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								makefile
									
									
									
									
									
								
							| @@ -60,8 +60,6 @@ lint: ## 🧺 Format, Check and Flake8 | ||||
| 	poetry run flake8 mealie tests | ||||
|  | ||||
|  | ||||
| lint-frontend: ## 🧺 Run yarn lint | ||||
| 	cd frontend && yarn lint | ||||
|  | ||||
| coverage: ## ☂️  Check code coverage quickly with the default Python | ||||
| 	poetry run pytest | ||||
| @@ -95,6 +93,12 @@ frontend: ## 🎬 Start Mealie Frontend Development Server | ||||
| frontend-build: ## 🏗  Build Frontend in frontend/dist | ||||
| 	cd frontend && yarn run build | ||||
|  | ||||
| frontend-generate: ## 🏗  Generate Code for Frontend | ||||
| 	poetry run python dev/code-generation/gen_frontend_types.py | ||||
|  | ||||
| frontend-lint: ## 🧺 Run yarn lint | ||||
| 	cd frontend && yarn lint | ||||
|  | ||||
| .PHONY: docs | ||||
| docs: ## 📄 Start Mkdocs Development Server | ||||
| 	poetry run python dev/scripts/api_docs_gen.py && \ | ||||
|   | ||||
| @@ -62,4 +62,4 @@ def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_ | ||||
|     elif provider_name == "sqlite": | ||||
|         return SQLiteProvider(data_dir=data_dir) | ||||
|     else: | ||||
|         return | ||||
|         return SQLiteProvider(data_dir=data_dir) | ||||
|   | ||||
| @@ -1,31 +1,10 @@ | ||||
| from sqlalchemy import Boolean, Column, DateTime, Integer, String | ||||
| from sqlalchemy import Column, DateTime, Integer, String | ||||
|  | ||||
| from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase | ||||
|  | ||||
| from ._model_utils import auto_init | ||||
|  | ||||
|  | ||||
| class EventNotification(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "event_notifications" | ||||
|     id = Column(Integer, primary_key=True) | ||||
|     name = Column(String) | ||||
|     type = Column(String) | ||||
|     notification_url = Column(String) | ||||
|  | ||||
|     # Event Types | ||||
|     general = Column(Boolean, default=False) | ||||
|     recipe = Column(Boolean, default=False) | ||||
|     backup = Column(Boolean, default=False) | ||||
|     scheduled = Column(Boolean, default=False) | ||||
|     migration = Column(Boolean, default=False) | ||||
|     group = Column(Boolean, default=False) | ||||
|     user = Column(Boolean, default=False) | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class Event(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "events" | ||||
|     id = Column(Integer, primary_key=True) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| from .cookbook import * | ||||
| from .events import * | ||||
| from .exports import * | ||||
| from .group import * | ||||
| from .invite_tokens import * | ||||
|   | ||||
							
								
								
									
										61
									
								
								mealie/db/models/group/events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								mealie/db/models/group/events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| from sqlalchemy import Boolean, Column, ForeignKey, String, orm | ||||
|  | ||||
| from .._model_base import BaseMixins, SqlAlchemyBase | ||||
| from .._model_utils import GUID, auto_init | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "group_events_notifier_options" | ||||
|  | ||||
|     id = Column(GUID, primary_key=True, default=GUID.generate) | ||||
|     event_notifier_id = Column(GUID, ForeignKey("group_events_notifiers.id"), nullable=False) | ||||
|  | ||||
|     recipe_created = Column(Boolean, default=False, nullable=False) | ||||
|     recipe_updated = Column(Boolean, default=False, nullable=False) | ||||
|     recipe_deleted = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     user_signup = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     data_migrations = Column(Boolean, default=False, nullable=False) | ||||
|     data_export = Column(Boolean, default=False, nullable=False) | ||||
|     data_import = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     mealplan_entry_created = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     shopping_list_created = Column(Boolean, default=False, nullable=False) | ||||
|     shopping_list_updated = Column(Boolean, default=False, nullable=False) | ||||
|     shopping_list_deleted = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     cookbook_created = Column(Boolean, default=False, nullable=False) | ||||
|     cookbook_updated = Column(Boolean, default=False, nullable=False) | ||||
|     cookbook_deleted = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     tag_created = Column(Boolean, default=False, nullable=False) | ||||
|     tag_updated = Column(Boolean, default=False, nullable=False) | ||||
|     tag_deleted = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     category_created = Column(Boolean, default=False, nullable=False) | ||||
|     category_updated = Column(Boolean, default=False, nullable=False) | ||||
|     category_deleted = Column(Boolean, default=False, nullable=False) | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "group_events_notifiers" | ||||
|  | ||||
|     id = Column(GUID, primary_key=True, default=GUID.generate) | ||||
|     name = Column(String, nullable=False) | ||||
|     enabled = Column(String, default=True, nullable=False) | ||||
|     apprise_url = Column(String, nullable=False) | ||||
|  | ||||
|     group = orm.relationship("Group", back_populates="group_event_notifiers", single_parent=True) | ||||
|     group_id = Column(GUID, ForeignKey("groups.id"), index=True) | ||||
|  | ||||
|     options = orm.relationship(GroupEventNotifierOptionsModel, uselist=False, cascade="all, delete-orphan") | ||||
|  | ||||
|     @auto_init() | ||||
|     def __init__(self, **_) -> None: | ||||
|         pass | ||||
| @@ -1,5 +1,3 @@ | ||||
| import uuid | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| import sqlalchemy.orm as orm | ||||
| from sqlalchemy.orm.session import Session | ||||
| @@ -22,7 +20,7 @@ settings = get_app_settings() | ||||
|  | ||||
| class Group(SqlAlchemyBase, BaseMixins): | ||||
|     __tablename__ = "groups" | ||||
|     id = sa.Column(GUID, primary_key=True, default=uuid.uuid4) | ||||
|     id = sa.Column(GUID, primary_key=True, default=GUID.generate) | ||||
|     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, uselist=True) | ||||
| @@ -57,6 +55,7 @@ class Group(SqlAlchemyBase, BaseMixins): | ||||
|     data_exports = orm.relationship("GroupDataExportsModel", **common_args) | ||||
|     shopping_lists = orm.relationship("ShoppingList", **common_args) | ||||
|     group_reports = orm.relationship("ReportModel", **common_args) | ||||
|     group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args) | ||||
|  | ||||
|     class Config: | ||||
|         exclude = { | ||||
|   | ||||
| @@ -2,9 +2,10 @@ from functools import cached_property | ||||
|  | ||||
| from sqlalchemy.orm import Session | ||||
|  | ||||
| from mealie.db.models.event import Event, EventNotification | ||||
| from mealie.db.models.event import Event | ||||
| from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel | ||||
| from mealie.db.models.group.cookbook import CookBook | ||||
| from mealie.db.models.group.events import GroupEventNotifierModel | ||||
| from mealie.db.models.group.exports import GroupDataExportsModel | ||||
| from mealie.db.models.group.invite_tokens import GroupInviteToken | ||||
| from mealie.db.models.group.preferences import GroupPreferencesModel | ||||
| @@ -24,7 +25,7 @@ from mealie.db.models.users import LongLiveToken, User | ||||
| from mealie.db.models.users.password_reset import PasswordResetModel | ||||
| from mealie.schema.cookbook.cookbook import ReadCookBook | ||||
| from mealie.schema.events import Event as EventSchema | ||||
| from mealie.schema.events import EventNotificationIn | ||||
| from mealie.schema.group.group_events import GroupEventNotifierOut | ||||
| from mealie.schema.group.group_exports import GroupDataExport | ||||
| from mealie.schema.group.group_preferences import ReadGroupPreferences | ||||
| from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut | ||||
| @@ -116,10 +117,6 @@ class AllRepositories: | ||||
|     def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]: | ||||
|         return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut) | ||||
|  | ||||
|     @cached_property | ||||
|     def event_notifications(self) -> RepositoryGeneric[EventNotificationIn, EventNotification]: | ||||
|         return RepositoryGeneric(self.session, pk_id, EventNotification, EventNotificationIn) | ||||
|  | ||||
|     @cached_property | ||||
|     def events(self) -> RepositoryGeneric[EventSchema, Event]: | ||||
|         return RepositoryGeneric(self.session, pk_id, Event, EventSchema) | ||||
| @@ -193,3 +190,7 @@ class AllRepositories: | ||||
|     @cached_property | ||||
|     def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]: | ||||
|         return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut) | ||||
|  | ||||
|     @cached_property | ||||
|     def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]: | ||||
|         return RepositoryGeneric(self.session, pk_id, GroupEventNotifierModel, GroupEventNotifierOut) | ||||
|   | ||||
| @@ -70,6 +70,8 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|     def get_all(self, limit: int = None, order_by: str = None, start=0, override_schema=None) -> list[T]: | ||||
|         eff_schema = override_schema or self.schema | ||||
|  | ||||
|         filter = self._filter_builder() | ||||
|  | ||||
|         order_attr = None | ||||
|         if order_by: | ||||
|             order_attr = getattr(self.sql_model, str(order_by)) | ||||
| @@ -77,10 +79,18 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|  | ||||
|             return [ | ||||
|                 eff_schema.from_orm(x) | ||||
|                 for x in self.session.query(self.sql_model).order_by(order_attr).offset(start).limit(limit).all() | ||||
|                 for x in self.session.query(self.sql_model) | ||||
|                 .order_by(order_attr) | ||||
|                 .filter_by(**filter) | ||||
|                 .offset(start) | ||||
|                 .limit(limit) | ||||
|                 .all() | ||||
|             ] | ||||
|  | ||||
|         return [eff_schema.from_orm(x) for x in self.session.query(self.sql_model).offset(start).limit(limit).all()] | ||||
|         return [ | ||||
|             eff_schema.from_orm(x) | ||||
|             for x in self.session.query(self.sql_model).filter_by(**filter).offset(start).limit(limit).all() | ||||
|         ] | ||||
|  | ||||
|     def multi_query( | ||||
|         self, | ||||
| @@ -92,6 +102,8 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|     ) -> list[T]: | ||||
|         eff_schema = override_schema or self.schema | ||||
|  | ||||
|         filer = self._filter_builder(**query_by) | ||||
|  | ||||
|         order_attr = None | ||||
|         if order_by: | ||||
|             order_attr = getattr(self.sql_model, str(order_by)) | ||||
| @@ -100,7 +112,7 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|         return [ | ||||
|             eff_schema.from_orm(x) | ||||
|             for x in self.session.query(self.sql_model) | ||||
|             .filter_by(**query_by) | ||||
|             .filter_by(**filer) | ||||
|             .order_by(order_attr) | ||||
|             .offset(start) | ||||
|             .limit(limit) | ||||
|   | ||||
| @@ -76,6 +76,17 @@ class CrudMixins: | ||||
|  | ||||
|         return item | ||||
|  | ||||
|     def get_one(self, item_id): | ||||
|         item = self.repo.get(item_id) | ||||
|  | ||||
|         if not item: | ||||
|             raise HTTPException( | ||||
|                 status.HTTP_404_NOT_FOUND, | ||||
|                 detail=ErrorResponse.respond(message="Not found."), | ||||
|             ) | ||||
|  | ||||
|         return item | ||||
|  | ||||
|     def update_one(self, data, item_id): | ||||
|         item = self.repo.get(item_id) | ||||
|  | ||||
| @@ -98,11 +109,11 @@ class CrudMixins: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|     def delete_one(self, item_id): | ||||
|         item = self.repo.get(item_id) | ||||
|         self.logger.info(f"Deleting item with id {item}") | ||||
|         self.logger.info(f"Deleting item with id {item_id}") | ||||
|  | ||||
|         try: | ||||
|             item = self.repo.delete(item) | ||||
|             item = self.repo.delete(item_id) | ||||
|             self.logger.info(item) | ||||
|         except Exception as ex: | ||||
|             self.handle_exception(ex) | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| from fastapi import APIRouter | ||||
|  | ||||
| from . import events, notifications | ||||
| from . import events | ||||
|  | ||||
| about_router = APIRouter(prefix="/api/about") | ||||
|  | ||||
| about_router.include_router(events.router, tags=["Events: CRUD"]) | ||||
| about_router.include_router(notifications.router, tags=["Events: Notifications"]) | ||||
|   | ||||
| @@ -1,67 +0,0 @@ | ||||
| from http.client import HTTPException | ||||
|  | ||||
| from fastapi import Depends, status | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.core.root_logger import get_logger | ||||
| from mealie.db.db_setup import generate_session | ||||
| from mealie.repos.all_repositories import get_repositories | ||||
| from mealie.routes.routers import AdminAPIRouter | ||||
| from mealie.schema.events import EventNotificationIn, EventNotificationOut, TestEvent | ||||
| from mealie.services.events import test_notification | ||||
|  | ||||
| router = AdminAPIRouter() | ||||
|  | ||||
| logger = get_logger() | ||||
|  | ||||
|  | ||||
| @router.post("/notifications") | ||||
| async def create_event_notification( | ||||
|     event_data: EventNotificationIn, | ||||
|     session: Session = Depends(generate_session), | ||||
| ): | ||||
|     """Create event_notification in the Database""" | ||||
|     db = get_repositories(session) | ||||
|  | ||||
|     return db.event_notifications.create(event_data) | ||||
|  | ||||
|  | ||||
| @router.post("/notifications/test") | ||||
| async def test_notification_route( | ||||
|     test_data: TestEvent, | ||||
|     session: Session = Depends(generate_session), | ||||
| ): | ||||
|     """Create event_notification in the Database""" | ||||
|     db = get_repositories(session) | ||||
|  | ||||
|     if test_data.id: | ||||
|         event_obj: EventNotificationIn = db.event_notifications.get(test_data.id) | ||||
|         test_data.test_url = event_obj.notification_url | ||||
|  | ||||
|     try: | ||||
|         test_notification(test_data.test_url) | ||||
|     except Exception as e: | ||||
|         logger.error(e) | ||||
|         raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) | ||||
|  | ||||
|  | ||||
| @router.get("/notifications", response_model=list[EventNotificationOut]) | ||||
| async def get_all_event_notification(session: Session = Depends(generate_session)): | ||||
|     """Get all event_notification from the Database""" | ||||
|     db = get_repositories(session) | ||||
|     return db.event_notifications.get_all(override_schema=EventNotificationOut) | ||||
|  | ||||
|  | ||||
| @router.put("/notifications/{id}") | ||||
| async def update_event_notification(id: int, session: Session = Depends(generate_session)): | ||||
|     """Update event_notification in the Database""" | ||||
|     # not yet implemented | ||||
|     raise HTTPException(status.HTTP_405_METHOD_NOT_ALLOWED) | ||||
|  | ||||
|  | ||||
| @router.delete("/notifications/{id}") | ||||
| async def delete_event_notification(id: int, session: Session = Depends(generate_session)): | ||||
|     """Delete event_notification from the Database""" | ||||
|     # Delete Item | ||||
|     db = get_repositories(session) | ||||
|     return db.event_notifications.delete(id) | ||||
| @@ -8,7 +8,7 @@ from mealie.services.group_services import CookbookService, WebhookService | ||||
| from mealie.services.group_services.meal_service import MealService | ||||
| from mealie.services.group_services.reports_service import GroupReportService | ||||
|  | ||||
| from . import categories, invitations, labels, migrations, preferences, self_service, shopping_lists | ||||
| from . import categories, invitations, labels, migrations, notifications, preferences, self_service, shopping_lists | ||||
|  | ||||
| router = APIRouter() | ||||
|  | ||||
| @@ -56,3 +56,4 @@ def get_all_reports( | ||||
| router.include_router(report_router) | ||||
| router.include_router(shopping_lists.router) | ||||
| router.include_router(labels.router) | ||||
| router.include_router(notifications.router) | ||||
|   | ||||
							
								
								
									
										85
									
								
								mealie/routes/groups/notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								mealie/routes/groups/notifications.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| from functools import cached_property | ||||
| from sqlite3 import IntegrityError | ||||
| from typing import Type | ||||
|  | ||||
| from fastapi import APIRouter, Depends | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.routes._base.controller import controller | ||||
| from mealie.routes._base.dependencies import SharedDependencies | ||||
| from mealie.routes._base.mixins import CrudMixins | ||||
| from mealie.schema.group.group_events import ( | ||||
|     GroupEventNotifierCreate, | ||||
|     GroupEventNotifierOut, | ||||
|     GroupEventNotifierPrivate, | ||||
|     GroupEventNotifierSave, | ||||
|     GroupEventNotifierUpdate, | ||||
| ) | ||||
| from mealie.schema.mapper import cast | ||||
| from mealie.schema.query import GetAll | ||||
| from mealie.services.event_bus_service.event_bus_service import EventBusService | ||||
|  | ||||
| router = APIRouter(prefix="/groups/events/notifications", tags=["Group: Event Notifications"]) | ||||
|  | ||||
|  | ||||
| @controller(router) | ||||
| class GroupEventsNotifierController: | ||||
|     deps: SharedDependencies = Depends(SharedDependencies.user) | ||||
|     event_bus: EventBusService = Depends(EventBusService) | ||||
|  | ||||
|     @cached_property | ||||
|     def repo(self): | ||||
|         if not self.deps.acting_user: | ||||
|             raise Exception("No user is logged in.") | ||||
|  | ||||
|         return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id) | ||||
|  | ||||
|     def registered_exceptions(self, ex: Type[Exception]) -> str: | ||||
|         registered = { | ||||
|             Exception: "An unexpected error occurred.", | ||||
|             IntegrityError: "An unexpected error occurred.", | ||||
|         } | ||||
|  | ||||
|         return registered.get(ex, "An unexpected error occurred.") | ||||
|  | ||||
|     # ======================================================================= | ||||
|     # CRUD Operations | ||||
|  | ||||
|     @property | ||||
|     def mixins(self) -> CrudMixins: | ||||
|         return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") | ||||
|  | ||||
|     @router.get("", response_model=list[GroupEventNotifierOut]) | ||||
|     def get_all(self, q: GetAll = Depends(GetAll)): | ||||
|         return self.repo.get_all(start=q.start, limit=q.limit) | ||||
|  | ||||
|     @router.post("", response_model=GroupEventNotifierOut, status_code=201) | ||||
|     def create_one(self, data: GroupEventNotifierCreate): | ||||
|         save_data = cast(data, GroupEventNotifierSave, group_id=self.deps.acting_user.group_id) | ||||
|         return self.mixins.create_one(save_data) | ||||
|  | ||||
|     @router.get("/{item_id}", response_model=GroupEventNotifierOut) | ||||
|     def get_one(self, item_id: UUID4): | ||||
|         return self.mixins.get_one(item_id) | ||||
|  | ||||
|     @router.put("/{item_id}", response_model=GroupEventNotifierOut) | ||||
|     def update_one(self, item_id: UUID4, data: GroupEventNotifierUpdate): | ||||
|         if data.apprise_url is None: | ||||
|             current_data: GroupEventNotifierPrivate = self.repo.get_one( | ||||
|                 item_id, override_schema=GroupEventNotifierPrivate | ||||
|             ) | ||||
|             data.apprise_url = current_data.apprise_url | ||||
|  | ||||
|         return self.mixins.update_one(data, item_id) | ||||
|  | ||||
|     @router.delete("/{item_id}", status_code=204) | ||||
|     def delete_one(self, item_id: UUID4): | ||||
|         self.mixins.delete_one(item_id)  # type: ignore | ||||
|  | ||||
|     # ======================================================================= | ||||
|     # Test Event Notifications | ||||
|  | ||||
|     @router.post("/{item_id}/test", status_code=204) | ||||
|     def test_notification(self, item_id: UUID4): | ||||
|         item: GroupEventNotifierPrivate = self.repo.get_one(item_id, override_schema=GroupEventNotifierPrivate) | ||||
|         self.event_bus.test_publisher(item.apprise_url) | ||||
| @@ -17,6 +17,8 @@ from mealie.schema.group.group_shopping_list import ( | ||||
| ) | ||||
| from mealie.schema.mapper import cast | ||||
| from mealie.schema.query import GetAll | ||||
| from mealie.services.event_bus_service.event_bus_service import EventBusService | ||||
| from mealie.services.event_bus_service.message_types import EventTypes | ||||
| from mealie.services.group_services.shopping_lists import ShoppingListService | ||||
|  | ||||
| router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) | ||||
| @@ -26,6 +28,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists | ||||
| class ShoppingListRoutes: | ||||
|     deps: SharedDependencies = Depends(SharedDependencies.user) | ||||
|     service: ShoppingListService = Depends(ShoppingListService.private) | ||||
|     event_bus: EventBusService = Depends(EventBusService) | ||||
|  | ||||
|     @cached_property | ||||
|     def repo(self): | ||||
| @@ -56,7 +59,16 @@ class ShoppingListRoutes: | ||||
|     @router.post("", response_model=ShoppingListOut) | ||||
|     def create_one(self, data: ShoppingListCreate): | ||||
|         save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id) | ||||
|         return self.mixins.create_one(save_data) | ||||
|         val = self.mixins.create_one(save_data) | ||||
|  | ||||
|         if val: | ||||
|             self.event_bus.dispatch( | ||||
|                 self.deps.acting_user.group_id, | ||||
|                 EventTypes.shopping_list_created, | ||||
|                 msg="A new shopping list has been created.", | ||||
|             ) | ||||
|  | ||||
|         return val | ||||
|  | ||||
|     @router.get("/{item_id}", response_model=ShoppingListOut) | ||||
|     def get_one(self, item_id: UUID4): | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .about import * | ||||
| from .backup import * | ||||
| from .migration import * | ||||
|   | ||||
| @@ -1 +1,2 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .cookbook import * | ||||
|   | ||||
| @@ -1,2 +1,2 @@ | ||||
| from .event_notifications import * | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .events import * | ||||
|   | ||||
| @@ -1,61 +0,0 @@ | ||||
| from enum import Enum | ||||
| from typing import Optional | ||||
|  | ||||
| from fastapi_camelcase import CamelModel | ||||
|  | ||||
|  | ||||
| class DeclaredTypes(str, Enum): | ||||
|     general = "General" | ||||
|     discord = "Discord" | ||||
|     gotify = "Gotify" | ||||
|     pushover = "Pushover" | ||||
|     home_assistant = "Home Assistant" | ||||
|  | ||||
|  | ||||
| class EventNotificationOut(CamelModel): | ||||
|     id: Optional[int] | ||||
|     name: str = "" | ||||
|     type: DeclaredTypes = DeclaredTypes.general | ||||
|     general: bool = True | ||||
|     recipe: bool = True | ||||
|     backup: bool = True | ||||
|     scheduled: bool = True | ||||
|     migration: bool = True | ||||
|     group: bool = True | ||||
|     user: bool = True | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class EventNotificationIn(EventNotificationOut): | ||||
|     notification_url: str = "" | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class Discord(CamelModel): | ||||
|     webhook_id: str | ||||
|     webhook_token: str | ||||
|  | ||||
|     @property | ||||
|     def create_url(self) -> str: | ||||
|         return f"discord://{self.webhook_id}/{self.webhook_token}/" | ||||
|  | ||||
|  | ||||
| class GotifyPriority(str, Enum): | ||||
|     low = "low" | ||||
|     moderate = "moderate" | ||||
|     normal = "normal" | ||||
|     high = "high" | ||||
|  | ||||
|  | ||||
| class Gotify(CamelModel): | ||||
|     hostname: str | ||||
|     token: str | ||||
|     priority: GotifyPriority = GotifyPriority.normal | ||||
|  | ||||
|     @property | ||||
|     def create_url(self) -> str: | ||||
|         return f"gotifys://{self.hostname}/{self.token}/?priority={self.priority}" | ||||
| @@ -1,2 +1,10 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .group import * | ||||
| from .group_events import * | ||||
| from .group_exports import * | ||||
| from .group_migration import * | ||||
| from .group_permissions import * | ||||
| from .group_preferences import * | ||||
| from .group_shopping_list import * | ||||
| from .invite_token import * | ||||
| from .webhook import * | ||||
|   | ||||
							
								
								
									
										89
									
								
								mealie/schema/group/group_events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								mealie/schema/group/group_events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| # ============================================================================= | ||||
| # Group Events Notifier Options | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierOptions(CamelModel): | ||||
|     """ | ||||
|     These events are in-sync with the EventTypes found in the EventBusService. | ||||
|     If you modify this, make sure to update the EventBusService as well. | ||||
|     """ | ||||
|  | ||||
|     recipe_created: bool = False | ||||
|     recipe_updated: bool = False | ||||
|     recipe_deleted: bool = False | ||||
|  | ||||
|     user_signup: bool = False | ||||
|  | ||||
|     data_migrations: bool = False | ||||
|     data_export: bool = False | ||||
|     data_import: bool = False | ||||
|  | ||||
|     mealplan_entry_created: bool = False | ||||
|  | ||||
|     shopping_list_created: bool = False | ||||
|     shopping_list_updated: bool = False | ||||
|     shopping_list_deleted: bool = False | ||||
|  | ||||
|     cookbook_created: bool = False | ||||
|     cookbook_updated: bool = False | ||||
|     cookbook_deleted: bool = False | ||||
|  | ||||
|     tag_created: bool = False | ||||
|     tag_updated: bool = False | ||||
|     tag_deleted: bool = False | ||||
|  | ||||
|     category_created: bool = False | ||||
|     category_updated: bool = False | ||||
|     category_deleted: bool = False | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierOptionsSave(GroupEventNotifierOptions): | ||||
|     notifier_id: UUID4 | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierOptionsOut(GroupEventNotifierOptions): | ||||
|     id: UUID4 | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| # ======================================================================= | ||||
| # Notifiers | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierCreate(CamelModel): | ||||
|     name: str | ||||
|     apprise_url: str | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierSave(GroupEventNotifierCreate): | ||||
|     enabled: bool = True | ||||
|     group_id: UUID4 | ||||
|     options: GroupEventNotifierOptions = GroupEventNotifierOptions() | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierUpdate(GroupEventNotifierSave): | ||||
|     id: UUID4 | ||||
|     apprise_url: str = None | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierOut(CamelModel): | ||||
|     id: UUID4 | ||||
|     name: str | ||||
|     enabled: bool | ||||
|     group_id: UUID4 | ||||
|     options: GroupEventNotifierOptionsOut | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class GroupEventNotifierPrivate(GroupEventNotifierOut): | ||||
|     apprise_url: str | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
| @@ -1,36 +1,2 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.schema.recipe import IngredientFood | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelCreate(CamelModel): | ||||
|     name: str | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSave(MultiPurposeLabelCreate): | ||||
|     group_id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelUpdate(MultiPurposeLabelSave): | ||||
|     id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): | ||||
|     pass | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelOut(MultiPurposeLabelUpdate): | ||||
|     shopping_list_items: "list[ShoppingListItemOut]" = [] | ||||
|     foods: list[IngredientFood] = [] | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| from mealie.schema.group.group_shopping_list import ShoppingListItemOut | ||||
|  | ||||
| MultiPurposeLabelOut.update_forward_refs() | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .multi_purpose_label import * | ||||
|   | ||||
							
								
								
									
										36
									
								
								mealie/schema/labels/multi_purpose_label.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								mealie/schema/labels/multi_purpose_label.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.schema.recipe import IngredientFood | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelCreate(CamelModel): | ||||
|     name: str | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSave(MultiPurposeLabelCreate): | ||||
|     group_id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelUpdate(MultiPurposeLabelSave): | ||||
|     id: UUID4 | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): | ||||
|     pass | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelOut(MultiPurposeLabelUpdate): | ||||
|     shopping_list_items: "list[ShoppingListItemOut]" = [] | ||||
|     foods: list[IngredientFood] = [] | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
|  | ||||
|  | ||||
| from mealie.schema.group.group_shopping_list import ShoppingListItemOut | ||||
|  | ||||
| MultiPurposeLabelOut.update_forward_refs() | ||||
| @@ -1,3 +1,4 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .meal import * | ||||
| from .new_meal import * | ||||
| from .shopping_list import * | ||||
|   | ||||
| @@ -1,7 +1,15 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .recipe import * | ||||
| from .recipe_asset import * | ||||
| from .recipe_bulk_actions import * | ||||
| from .recipe_category import * | ||||
| from .recipe_comments import * | ||||
| from .recipe_image_types import * | ||||
| from .recipe_ingredient import * | ||||
| from .recipe_notes import * | ||||
| from .recipe_nutrition import * | ||||
| from .recipe_settings import * | ||||
| from .recipe_share_token import * | ||||
| from .recipe_step import * | ||||
| from .recipe_tool import * | ||||
| from .request_helpers import * | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import enum | ||||
|  | ||||
| from fastapi_camelcase import CamelModel | ||||
|  | ||||
| from . import CategoryBase, TagBase | ||||
| from mealie.schema.recipe.recipe_category import CategoryBase, TagBase | ||||
|  | ||||
|  | ||||
| class ExportTypes(str, enum.Enum): | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| from typing import List | ||||
|  | ||||
| from fastapi_camelcase import CamelModel | ||||
| from pydantic.utils import GetterDict | ||||
|  | ||||
| @@ -23,7 +21,7 @@ class CategoryBase(CategoryIn): | ||||
|  | ||||
|  | ||||
| class RecipeCategoryResponse(CategoryBase): | ||||
|     recipes: List["Recipe"] = [] | ||||
|     recipes: "list[Recipe]" = [] | ||||
|  | ||||
|     class Config: | ||||
|         orm_mode = True | ||||
| @@ -42,7 +40,7 @@ class RecipeTagResponse(RecipeCategoryResponse): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| from . import Recipe | ||||
| from mealie.schema.recipe.recipe import Recipe | ||||
|  | ||||
| RecipeCategoryResponse.update_forward_refs() | ||||
| RecipeTagResponse.update_forward_refs() | ||||
|   | ||||
| @@ -1 +1,2 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .reports import * | ||||
|   | ||||
| @@ -1,17 +1,2 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
|  | ||||
| class ErrorResponse(BaseModel): | ||||
|     message: str | ||||
|     error: bool = True | ||||
|     exception: Optional[str] = None | ||||
|  | ||||
|     @classmethod | ||||
|     def respond(cls, message: str, exception: Optional[str] = None) -> dict: | ||||
|         """ | ||||
|         This method is an helper to create an obect and convert to a dictionary | ||||
|         in the same call, for use while providing details to a HTTPException | ||||
|         """ | ||||
|         return cls(message=message, exception=exception).dict() | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .error_response import * | ||||
|   | ||||
							
								
								
									
										17
									
								
								mealie/schema/response/error_response.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								mealie/schema/response/error_response.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
|  | ||||
| class ErrorResponse(BaseModel): | ||||
|     message: str | ||||
|     error: bool = True | ||||
|     exception: Optional[str] = None | ||||
|  | ||||
|     @classmethod | ||||
|     def respond(cls, message: str, exception: Optional[str] = None) -> dict: | ||||
|         """ | ||||
|         This method is an helper to create an obect and convert to a dictionary | ||||
|         in the same call, for use while providing details to a HTTPException | ||||
|         """ | ||||
|         return cls(message=message, exception=exception).dict() | ||||
| @@ -1 +1,2 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .tasks import * | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .auth import * | ||||
| from .registration import * | ||||
| from .sign_up import * | ||||
| from .user import * | ||||
| from .user_passwords import * | ||||
|   | ||||
| @@ -9,8 +9,7 @@ from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.core.config import get_app_dirs | ||||
| from mealie.repos.all_repositories import get_repositories | ||||
| from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport | ||||
| from mealie.schema.events import EventNotificationIn | ||||
| from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport | ||||
| from mealie.schema.recipe import Recipe, RecipeCommentOut | ||||
| from mealie.schema.user import PrivateUser, UpdateGroup | ||||
| from mealie.services.image import minify | ||||
| @@ -159,24 +158,6 @@ class ImportDatabase: | ||||
|  | ||||
|         minify.migrate_images() | ||||
|  | ||||
|     def import_notifications(self): | ||||
|         notify_file = self.import_dir.joinpath("notifications", "notifications.json") | ||||
|         notifications = ImportDatabase.read_models_file(notify_file, EventNotificationIn) | ||||
|         import_notifications = [] | ||||
|  | ||||
|         for notify in notifications: | ||||
|             import_status = self.import_model( | ||||
|                 db_table=self.db.event_notifications, | ||||
|                 model=notify, | ||||
|                 return_model=NotificationImport, | ||||
|                 name_attr="name", | ||||
|                 search_key="notification_url", | ||||
|             ) | ||||
|  | ||||
|             import_notifications.append(import_status) | ||||
|  | ||||
|         return import_notifications | ||||
|  | ||||
|     def import_settings(self): | ||||
|         return [] | ||||
|  | ||||
| @@ -330,11 +311,6 @@ def import_database( | ||||
|         user_report = import_session.import_users() | ||||
|  | ||||
|     notification_report = [] | ||||
|     if import_notifications: | ||||
|         notification_report = import_session.import_notifications() | ||||
|  | ||||
|     # if import_recipes: | ||||
|     #     import_session.import_comments() | ||||
|  | ||||
|     import_session.clean_up() | ||||
|  | ||||
|   | ||||
							
								
								
									
										0
									
								
								mealie/services/event_bus_service/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								mealie/services/event_bus_service/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										46
									
								
								mealie/services/event_bus_service/event_bus_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								mealie/services/event_bus_service/event_bus_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| from fastapi import BackgroundTasks, Depends | ||||
| from pydantic import UUID4 | ||||
|  | ||||
| from mealie.db.db_setup import generate_session | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
| from mealie.schema.group.group_events import GroupEventNotifierPrivate | ||||
|  | ||||
| from .message_types import EventBusMessage, EventTypes | ||||
| from .publisher import ApprisePublisher, PublisherLike | ||||
|  | ||||
|  | ||||
| class EventBusService: | ||||
|     def __init__(self, bg: BackgroundTasks, session=Depends(generate_session)) -> None: | ||||
|         self.bg = bg | ||||
|         self._publisher = ApprisePublisher | ||||
|         self.session = session | ||||
|         self.group_id = None | ||||
|  | ||||
|     @property | ||||
|     def publisher(self) -> PublisherLike: | ||||
|         return self._publisher() | ||||
|  | ||||
|     def get_urls(self, event_type: EventTypes) -> list[str]: | ||||
|         repos = AllRepositories(self.session) | ||||
|  | ||||
|         notifiers: list[GroupEventNotifierPrivate] = repos.group_event_notifier.by_group(self.group_id).multi_query( | ||||
|             {"enabled": True}, override_schema=GroupEventNotifierPrivate | ||||
|         ) | ||||
|  | ||||
|         return [notifier.apprise_url for notifier in notifiers if getattr(notifier.options, event_type.name)] | ||||
|  | ||||
|     def dispatch(self, group_id: UUID4, event_type: EventTypes, msg: str = "") -> None: | ||||
|         self.group_id = group_id | ||||
|  | ||||
|         def _dispatch(): | ||||
|             if urls := self.get_urls(event_type): | ||||
|                 self.publisher.publish(EventBusMessage.from_type(event_type, body=msg), urls) | ||||
|  | ||||
|         self.bg.add_task(_dispatch) | ||||
|  | ||||
|     def test_publisher(self, url: str) -> None: | ||||
|         self.bg.add_task( | ||||
|             self.publisher.publish, | ||||
|             event=EventBusMessage.from_type(EventTypes.test_message, body="This is a test event."), | ||||
|             notification_urls=[url], | ||||
|         ) | ||||
							
								
								
									
										47
									
								
								mealie/services/event_bus_service/message_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mealie/services/event_bus_service/message_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| from enum import Enum, auto | ||||
|  | ||||
|  | ||||
| class EventTypes(Enum): | ||||
|     test_message = auto() | ||||
|  | ||||
|     recipe_created = auto() | ||||
|     recipe_updated = auto() | ||||
|     recipe_deleted = auto() | ||||
|  | ||||
|     user_signup = auto() | ||||
|  | ||||
|     data_migrations = auto() | ||||
|     data_export = auto() | ||||
|     data_import = auto() | ||||
|  | ||||
|     mealplan_entry_created = auto() | ||||
|  | ||||
|     shopping_list_created = auto() | ||||
|     shopping_list_updated = auto() | ||||
|     shopping_list_deleted = auto() | ||||
|  | ||||
|     cookbook_created = auto() | ||||
|     cookbook_updated = auto() | ||||
|     cookbook_deleted = auto() | ||||
|  | ||||
|     tag_created = auto() | ||||
|     tag_updated = auto() | ||||
|     tag_deleted = auto() | ||||
|  | ||||
|     category_created = auto() | ||||
|     category_updated = auto() | ||||
|     category_deleted = auto() | ||||
|  | ||||
|  | ||||
| class EventBusMessage: | ||||
|     title: str | ||||
|     body: str = "" | ||||
|  | ||||
|     def __init__(self, title, body) -> None: | ||||
|         self.title = title | ||||
|         self.body = body | ||||
|  | ||||
|     @classmethod | ||||
|     def from_type(cls, event_type: EventTypes, body: str = "") -> "EventBusMessage": | ||||
|         title = event_type.name.replace("_", " ").title() | ||||
|         return cls(title=title, body=body) | ||||
							
								
								
									
										29
									
								
								mealie/services/event_bus_service/publisher.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								mealie/services/event_bus_service/publisher.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| from typing import Protocol | ||||
|  | ||||
| import apprise | ||||
|  | ||||
| from mealie.services.event_bus_service.event_bus_service import EventBusMessage | ||||
|  | ||||
|  | ||||
| class PublisherLike(Protocol): | ||||
|     def publish(self, event: EventBusMessage, notification_urls: list[str]): | ||||
|         ... | ||||
|  | ||||
|  | ||||
| class ApprisePublisher: | ||||
|     def __init__(self, hard_fail=False) -> None: | ||||
|         asset = apprise.AppriseAsset( | ||||
|             async_mode=True, | ||||
|             image_url_mask="https://raw.githubusercontent.com/hay-kot/mealie/dev/frontend/public/img/icons/android-chrome-maskable-512x512.png", | ||||
|         ) | ||||
|         self.apprise = apprise.Apprise(asset=asset) | ||||
|         self.hard_fail = hard_fail | ||||
|  | ||||
|     def publish(self, event: EventBusMessage, notification_urls: list[str]): | ||||
|         for dest in notification_urls: | ||||
|             status = self.apprise.add(dest) | ||||
|  | ||||
|             if not status and self.hard_fail: | ||||
|                 raise Exception("Apprise URL Add Failed") | ||||
|  | ||||
|         self.apprise.notify(title=event.title, body=event.body) | ||||
| @@ -1,4 +1,3 @@ | ||||
| import apprise | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.db.db_setup import create_session | ||||
| @@ -6,44 +5,12 @@ from mealie.repos.all_repositories import get_repositories | ||||
| from mealie.schema.events import Event, EventCategory | ||||
|  | ||||
|  | ||||
| def test_notification(notification_url, event=None) -> bool: | ||||
|     if event is None: | ||||
|         event = Event( | ||||
|             title="Test Notification", | ||||
|             text="This is a test message from the Mealie API server", | ||||
|             category=EventCategory.general.value, | ||||
|         ) | ||||
|  | ||||
|     post_notifications(event, [notification_url], hard_fail=True) | ||||
|  | ||||
|  | ||||
| def post_notifications(event: Event, notification_urls=list[str], hard_fail=False, attachment=None): | ||||
|     asset = apprise.AppriseAsset(async_mode=False) | ||||
|     apobj = apprise.Apprise(asset=asset) | ||||
|  | ||||
|     for dest in notification_urls: | ||||
|         status = apobj.add(dest) | ||||
|  | ||||
|         if not status and hard_fail: | ||||
|             raise Exception("Apprise URL Add Failed") | ||||
|  | ||||
|     apobj.notify( | ||||
|         body=event.text, | ||||
|         title=event.title, | ||||
|         attach=str(attachment), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def save_event(title, text, category, session: Session, attachment=None): | ||||
| def save_event(title, text, category, session: Session): | ||||
|     event = Event(title=title, text=text, category=category) | ||||
|     session = session or create_session() | ||||
|     db = get_repositories(session) | ||||
|     db.events.create(event.dict()) | ||||
|  | ||||
|     notification_objects = db.event_notifications.get(match_value=True, match_key=category, limit=9999) | ||||
|     notification_urls = [x.notification_url for x in notification_objects] | ||||
|     post_notifications(event, notification_urls, attachment=attachment) | ||||
|  | ||||
|  | ||||
| def create_general_event(title, text, session=None): | ||||
|     category = EventCategory.general | ||||
| @@ -52,8 +19,7 @@ def create_general_event(title, text, session=None): | ||||
|  | ||||
| def create_recipe_event(title, text, session=None, attachment=None): | ||||
|     category = EventCategory.recipe | ||||
|  | ||||
|     save_event(title=title, text=text, category=category, session=session, attachment=attachment) | ||||
|     save_event(title=title, text=text, category=category, session=session) | ||||
|  | ||||
|  | ||||
| def create_backup_event(title, text, session=None): | ||||
|   | ||||
							
								
								
									
										610
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										610
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -9,13 +9,13 @@ license = "MIT" | ||||
| start = "mealie.app:main" | ||||
|  | ||||
| [tool.poetry.dependencies] | ||||
| python = "^3.9" | ||||
| python = "^3.10" | ||||
| aiofiles = "0.5.0" | ||||
| aniso8601 = "7.0.0" | ||||
| appdirs = "1.4.4" | ||||
| fastapi = "^0.71.0" | ||||
| uvicorn = {extras = ["standard"], version = "^0.13.0"} | ||||
| APScheduler = "^3.6.3" | ||||
| APScheduler = "^3.8.1" | ||||
| SQLAlchemy = "^1.4.29" | ||||
| Jinja2 = "^2.11.2" | ||||
| python-dotenv = "^0.15.0" | ||||
| @@ -28,10 +28,10 @@ fastapi-camelcase = "^1.0.2" | ||||
| bcrypt = "^3.2.0" | ||||
| python-jose = "^3.3.0" | ||||
| passlib = "^1.7.4" | ||||
| lxml = "4.6.2" | ||||
| lxml = "^4.7.1" | ||||
| Pillow = "^8.2.0" | ||||
| pathvalidate = "^2.4.1" | ||||
| apprise = "0.9.3" | ||||
| apprise = "^0.9.6" | ||||
| recipe-scrapers = "^13.5.0" | ||||
| psycopg2-binary = {version = "^2.9.1", optional = true} | ||||
| gunicorn = "^20.1.0" | ||||
| @@ -39,20 +39,20 @@ emails = "^0.6" | ||||
| python-i18n = "^0.3.9" | ||||
| python-ldap = "^3.3.1" | ||||
| pydantic = "^1.9.0" | ||||
| tzdata = "^2021.5" | ||||
|  | ||||
| [tool.poetry.dev-dependencies] | ||||
| pylint = "^2.6.0" | ||||
| pytest = "^6.2.1" | ||||
| pytest-cov = "^2.11.0" | ||||
| mkdocs-material = "^7.0.2" | ||||
| flake8 = "^3.9.0" | ||||
| flake8 = "^4.0.1" | ||||
| coverage = "^5.5" | ||||
| pydantic-to-typescript = "^1.0.7" | ||||
| rich = "^10.7.0" | ||||
| isort = "^5.9.3" | ||||
| regex = "2021.9.30" # TODO: Remove during Upgrade -> https://github.com/psf/black/issues/2524 | ||||
| flake8-print = "^4.0.0" | ||||
| black = "^21.11b1" | ||||
| black = "^21.12b0" | ||||
|  | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
|   | ||||
| @@ -0,0 +1,118 @@ | ||||
| from fastapi.testclient import TestClient | ||||
|  | ||||
| from mealie.schema.group.group_events import GroupEventNotifierCreate, GroupEventNotifierOptions | ||||
| from tests.utils.assertion_helpers import assert_ignore_keys | ||||
| from tests.utils.factories import random_bool, random_string | ||||
| from tests.utils.fixture_schemas import TestUser | ||||
|  | ||||
|  | ||||
| class Routes: | ||||
|     base = "/api/groups/events/notifications" | ||||
|  | ||||
|     def item(item_id: int) -> str: | ||||
|         return f"{Routes.base}/{item_id}" | ||||
|  | ||||
|  | ||||
| def preferences_generator(): | ||||
|     return GroupEventNotifierOptions( | ||||
|         recipe_created=random_bool(), | ||||
|         recipe_updated=random_bool(), | ||||
|         recipe_deleted=random_bool(), | ||||
|         user_signup=random_bool(), | ||||
|         data_migrations=random_bool(), | ||||
|         data_export=random_bool(), | ||||
|         data_import=random_bool(), | ||||
|         mealplan_entry_created=random_bool(), | ||||
|         shopping_list_created=random_bool(), | ||||
|         shopping_list_updated=random_bool(), | ||||
|         shopping_list_deleted=random_bool(), | ||||
|         cookbook_created=random_bool(), | ||||
|         cookbook_updated=random_bool(), | ||||
|         cookbook_deleted=random_bool(), | ||||
|         tag_created=random_bool(), | ||||
|         tag_updated=random_bool(), | ||||
|         tag_deleted=random_bool(), | ||||
|         category_created=random_bool(), | ||||
|         category_updated=random_bool(), | ||||
|         category_deleted=random_bool(), | ||||
|     ).dict(by_alias=True) | ||||
|  | ||||
|  | ||||
| def notifier_generator(): | ||||
|     return GroupEventNotifierCreate( | ||||
|         name=random_string(), | ||||
|         apprise_url=random_string(), | ||||
|     ).dict(by_alias=True) | ||||
|  | ||||
|  | ||||
| def test_create_notification(api_client: TestClient, unique_user: TestUser): | ||||
|     payload = notifier_generator() | ||||
|     response = api_client.post(Routes.base, json=payload, headers=unique_user.token) | ||||
|     assert response.status_code == 201 | ||||
|  | ||||
|     payload_as_dict = response.json() | ||||
|  | ||||
|     assert payload_as_dict["name"] == payload["name"] | ||||
|     assert payload_as_dict["enabled"] is True | ||||
|  | ||||
|     # Ensure Apprise URL Staysa Private | ||||
|     assert "apprise_url" not in payload_as_dict | ||||
|  | ||||
|     # Cleanup | ||||
|     response = api_client.delete(Routes.item(payload_as_dict["id"]), headers=unique_user.token) | ||||
|  | ||||
|  | ||||
| def test_ensure_apprise_url_is_secret(api_client: TestClient, unique_user: TestUser): | ||||
|     payload = notifier_generator() | ||||
|     response = api_client.post(Routes.base, json=payload, headers=unique_user.token) | ||||
|     assert response.status_code == 201 | ||||
|  | ||||
|     payload_as_dict = response.json() | ||||
|  | ||||
|     # Ensure Apprise URL Staysa Private | ||||
|     assert "apprise_url" not in payload_as_dict | ||||
|  | ||||
|  | ||||
| def test_update_notification(api_client: TestClient, unique_user: TestUser): | ||||
|     payload = notifier_generator() | ||||
|     response = api_client.post(Routes.base, json=payload, headers=unique_user.token) | ||||
|     assert response.status_code == 201 | ||||
|  | ||||
|     update_payload = response.json() | ||||
|  | ||||
|     # Set Update Values | ||||
|     update_payload["name"] = random_string() | ||||
|     update_payload["enabled"] = random_bool() | ||||
|     update_payload["options"] = preferences_generator() | ||||
|  | ||||
|     response = api_client.put(Routes.item(update_payload["id"]), json=update_payload, headers=unique_user.token) | ||||
|  | ||||
|     assert response.status_code == 200 | ||||
|  | ||||
|     # Re-Get The Item | ||||
|     response = api_client.get(Routes.item(update_payload["id"]), headers=unique_user.token) | ||||
|     assert response.status_code == 200 | ||||
|  | ||||
|     # Validate Updated Values | ||||
|     updated_payload = response.json() | ||||
|  | ||||
|     assert updated_payload["name"] == update_payload["name"] | ||||
|     assert updated_payload["enabled"] == update_payload["enabled"] | ||||
|     assert_ignore_keys(updated_payload["options"], update_payload["options"]) | ||||
|  | ||||
|     # Cleanup | ||||
|     response = api_client.delete(Routes.item(update_payload["id"]), headers=unique_user.token) | ||||
|  | ||||
|  | ||||
| def test_delete_notification(api_client: TestClient, unique_user: TestUser): | ||||
|     payload = notifier_generator() | ||||
|     response = api_client.post(Routes.base, json=payload, headers=unique_user.token) | ||||
|     assert response.status_code == 201 | ||||
|  | ||||
|     payload_as_dict = response.json() | ||||
|  | ||||
|     response = api_client.delete(Routes.item(payload_as_dict["id"]), headers=unique_user.token) | ||||
|     assert response.status_code == 204 | ||||
|  | ||||
|     response = api_client.get(Routes.item(payload_as_dict["id"]), headers=unique_user.token) | ||||
|     assert response.status_code == 404 | ||||
		Reference in New Issue
	
	Block a user