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:
parent
50a341ed3f
commit
190773c5d7
74 changed files with 1992 additions and 1229 deletions
2
.github/workflows/backend-tests.yml
vendored
2
.github/workflows/backend-tests.yml
vendored
|
@ -43,7 +43,7 @@ jobs:
|
||||||
- name: Set up python
|
- name: Set up python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: "3.10"
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
# ----- install & configure poetry -----
|
# ----- install & configure poetry -----
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
|
|
|
@ -476,7 +476,6 @@ ignore-comments=yes
|
||||||
|
|
||||||
# Ignore docstrings when computing similarities.
|
# Ignore docstrings when computing similarities.
|
||||||
ignore-docstrings=yes
|
ignore-docstrings=yes
|
||||||
w54
|
|
||||||
# Ignore imports when computing similarities.
|
# Ignore imports when computing similarities.
|
||||||
ignore-imports=no
|
ignore-imports=no
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
###############################################
|
###############################################
|
||||||
# Base Image
|
# Base Image
|
||||||
###############################################
|
###############################################
|
||||||
FROM python:3.9-slim as python-base
|
FROM python:3.10-slim as python-base
|
||||||
|
|
||||||
ENV MEALIE_HOME="/app"
|
ENV MEALIE_HOME="/app"
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import black
|
import black
|
||||||
|
import isort
|
||||||
from jinja2 import Template
|
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"""
|
"""Render and Format a Jinja2 Template for Python Code"""
|
||||||
tplt = Template(template_file.read_text())
|
if isinstance(template_file, Path):
|
||||||
|
tplt = Template(template_file.read_text())
|
||||||
|
else:
|
||||||
|
tplt = Template(template_file)
|
||||||
|
|
||||||
text = tplt.render(data=data)
|
text = tplt.render(data=data)
|
||||||
text = black.format_str(text, mode=black.FileMode())
|
text = black.format_str(text, mode=black.FileMode())
|
||||||
dest.write_text(text)
|
dest.write_text(text)
|
||||||
|
isort.file(dest)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
CWD = Path(__file__).parent
|
CWD = Path(__file__).parent
|
||||||
|
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
class Directories:
|
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 { CategoriesAPI } from "./class-interfaces/categories";
|
||||||
import { TagsAPI } from "./class-interfaces/tags";
|
import { TagsAPI } from "./class-interfaces/tags";
|
||||||
import { UtilsAPI } from "./class-interfaces/utils";
|
import { UtilsAPI } from "./class-interfaces/utils";
|
||||||
import { NotificationsAPI } from "./class-interfaces/event-notifications";
|
|
||||||
import { FoodAPI } from "./class-interfaces/recipe-foods";
|
import { FoodAPI } from "./class-interfaces/recipe-foods";
|
||||||
import { UnitAPI } from "./class-interfaces/recipe-units";
|
import { UnitAPI } from "./class-interfaces/recipe-units";
|
||||||
import { CookbookAPI } from "./class-interfaces/group-cookbooks";
|
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 { GroupReportsApi } from "./class-interfaces/group-reports";
|
||||||
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
|
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
|
||||||
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
|
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
|
||||||
|
import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier";
|
||||||
import { ApiRequestInstance } from "~/types/api";
|
import { ApiRequestInstance } from "~/types/api";
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
// private static instance: Api;
|
|
||||||
public recipes: RecipeAPI;
|
public recipes: RecipeAPI;
|
||||||
public users: UserApi;
|
public users: UserApi;
|
||||||
public groups: GroupAPI;
|
public groups: GroupAPI;
|
||||||
|
@ -35,7 +34,6 @@ class Api {
|
||||||
public categories: CategoriesAPI;
|
public categories: CategoriesAPI;
|
||||||
public tags: TagsAPI;
|
public tags: TagsAPI;
|
||||||
public utils: UtilsAPI;
|
public utils: UtilsAPI;
|
||||||
public notifications: NotificationsAPI;
|
|
||||||
public foods: FoodAPI;
|
public foods: FoodAPI;
|
||||||
public units: UnitAPI;
|
public units: UnitAPI;
|
||||||
public cookbooks: CookbookAPI;
|
public cookbooks: CookbookAPI;
|
||||||
|
@ -50,14 +48,10 @@ class Api {
|
||||||
public tools: ToolsApi;
|
public tools: ToolsApi;
|
||||||
public shopping: ShoppingApi;
|
public shopping: ShoppingApi;
|
||||||
public multiPurposeLabels: MultiPurposeLabelsApi;
|
public multiPurposeLabels: MultiPurposeLabelsApi;
|
||||||
// Utils
|
public groupEventNotifier: GroupEventNotifierApi;
|
||||||
public upload: UploadFile;
|
public upload: UploadFile;
|
||||||
|
|
||||||
constructor(requests: ApiRequestInstance) {
|
constructor(requests: ApiRequestInstance) {
|
||||||
// if (Api.instance instanceof Api) {
|
|
||||||
// return Api.instance;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Recipes
|
// Recipes
|
||||||
this.recipes = new RecipeAPI(requests);
|
this.recipes = new RecipeAPI(requests);
|
||||||
this.categories = new CategoriesAPI(requests);
|
this.categories = new CategoriesAPI(requests);
|
||||||
|
@ -84,7 +78,6 @@ class Api {
|
||||||
// Admin
|
// Admin
|
||||||
this.events = new EventsAPI(requests);
|
this.events = new EventsAPI(requests);
|
||||||
this.backups = new BackupAPI(requests);
|
this.backups = new BackupAPI(requests);
|
||||||
this.notifications = new NotificationsAPI(requests);
|
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
this.upload = new UploadFile(requests);
|
this.upload = new UploadFile(requests);
|
||||||
|
@ -92,9 +85,9 @@ class Api {
|
||||||
|
|
||||||
this.email = new EmailAPI(requests);
|
this.email = new EmailAPI(requests);
|
||||||
this.bulk = new BulkActionsAPI(requests);
|
this.bulk = new BulkActionsAPI(requests);
|
||||||
|
this.groupEventNotifier = new GroupEventNotifierApi(requests);
|
||||||
|
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
// Api.instance = this;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,19 +107,43 @@
|
||||||
|
|
||||||
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
||||||
|
|
||||||
<div class="ml-auto">
|
<template v-if="edit">
|
||||||
<BaseOverflowButton
|
<v-icon class="handle ml-auto mr-2">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||||
v-if="edit"
|
<div>
|
||||||
small
|
<BaseButtonGroup
|
||||||
mode="event"
|
:buttons="[
|
||||||
:items="actionEvents || []"
|
{
|
||||||
@merge-above="mergeAbove(index - 1, index)"
|
icon: $globals.icons.dotsVertical,
|
||||||
@toggle-section="toggleShowTitle(step.id)"
|
text: $t('general.delete'),
|
||||||
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
|
children: [
|
||||||
>
|
{
|
||||||
</BaseOverflowButton>
|
text: 'Toggle Section',
|
||||||
</div>
|
event: 'toggle-section',
|
||||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
},
|
||||||
|
{
|
||||||
|
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)"
|
||||||
|
@preview-step="togglePreviewState(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-fade-transition>
|
<v-fade-transition>
|
||||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||||
{{ $globals.icons.checkboxMarkedCircle }}
|
{{ $globals.icons.checkboxMarkedCircle }}
|
||||||
|
@ -127,7 +151,11 @@
|
||||||
</v-fade-transition>
|
</v-fade-transition>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text v-if="edit">
|
<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
|
<div
|
||||||
v-for="ing in step.ingredientReferences"
|
v-for="ing in step.ingredientReferences"
|
||||||
:key="ing.referenceId"
|
: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 {
|
return {
|
||||||
|
togglePreviewState,
|
||||||
|
previewStates,
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
actionEvents,
|
actionEvents,
|
||||||
activeRefs,
|
activeRefs,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column">
|
<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">
|
<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>
|
||||||
<div class="d-flex align-center justify-space-between">
|
<div class="d-flex justify-space-between">
|
||||||
<div>
|
<div>
|
||||||
<v-card-title class="headline pb-0">
|
<v-card-title class="headline pb-0">
|
||||||
<slot name="title"> </slot>
|
<slot name="title"> </slot>
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<v-img max-width="150px" max-height="125" :src="image"></v-img>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -189,7 +189,7 @@ export default defineComponent({
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
drawer,
|
drawer,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,5 +2,21 @@
|
||||||
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
|
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
|
||||||
<b>Experimental Feature</b>
|
<b>Experimental Feature</b>
|
||||||
<div>This page contains experimental or still-baking features. Please excuse the mess.</div>
|
<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>
|
</v-alert>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
issue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -1,17 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-tabs v-model="tab" height="30px" class="my-1">
|
<div v-if="displayPreview" class="d-flex justify-end">
|
||||||
<v-tab>
|
<BaseButtonGroup
|
||||||
<v-icon small left> {{ $globals.icons.edit }}</v-icon>
|
:buttons="[
|
||||||
Edit
|
{
|
||||||
</v-tab>
|
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
|
||||||
<v-tab>
|
text: previewState ? $t('general.edit') : 'Preview Markdown',
|
||||||
<v-icon small left> {{ $globals.icons.eye }}</v-icon>
|
event: 'toggle',
|
||||||
Preview
|
},
|
||||||
</v-tab>
|
]"
|
||||||
</v-tabs>
|
@toggle="previewState = !previewState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-if="tab == 0"
|
v-if="!previewState"
|
||||||
v-model="inputVal"
|
v-model="inputVal"
|
||||||
:class="label == '' ? '' : 'mt-5'"
|
:class="label == '' ? '' : 'mt-5'"
|
||||||
:label="label"
|
:label="label"
|
||||||
|
@ -27,7 +29,7 @@
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import VueMarkdown from "@adapttive/vue-markdown";
|
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({
|
export default defineComponent({
|
||||||
name: "MarkdownEditor",
|
name: "MarkdownEditor",
|
||||||
|
@ -43,10 +45,28 @@ export default defineComponent({
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
preview: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
displayPreview: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const state = reactive({
|
const fallbackPreview = ref(false);
|
||||||
tab: 0,
|
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({
|
const inputVal = computed({
|
||||||
|
@ -58,10 +78,11 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
previewState,
|
||||||
inputVal,
|
inputVal,
|
||||||
...toRefs(state),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</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",
|
to: "/admin/toolbox",
|
||||||
title: i18n.t("sidebar.toolbox"),
|
title: i18n.t("sidebar.toolbox"),
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
icon: $globals.icons.bellAlert,
|
|
||||||
to: "/admin/toolbox/notifications",
|
|
||||||
title: i18n.t("events.notification"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: $globals.icons.foods,
|
icon: $globals.icons.foods,
|
||||||
to: "/admin/toolbox/foods",
|
to: "/admin/toolbox/foods",
|
||||||
|
|
|
@ -41,9 +41,6 @@
|
||||||
:search="search"
|
:search="search"
|
||||||
@click:row="handleRowClick"
|
@click:row="handleRowClick"
|
||||||
>
|
>
|
||||||
<template #item.shoppingLists="{ item }">
|
|
||||||
{{ item.shoppingLists.length }}
|
|
||||||
</template>
|
|
||||||
<template #item.users="{ item }">
|
<template #item.users="{ item }">
|
||||||
{{ item.users.length }}
|
{{ item.users.length }}
|
||||||
</template>
|
</template>
|
||||||
|
@ -99,7 +96,6 @@ export default defineComponent({
|
||||||
{ text: i18n.t("general.name"), value: "name" },
|
{ text: i18n.t("general.name"), value: "name" },
|
||||||
{ text: i18n.t("user.total-users"), value: "users" },
|
{ text: i18n.t("user.total-users"), value: "users" },
|
||||||
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
|
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
|
||||||
{ text: i18n.t("shopping-list.shopping-lists"), value: "shoppingLists" },
|
|
||||||
{ text: i18n.t("general.delete"), value: "actions" },
|
{ text: i18n.t("general.delete"), value: "actions" },
|
||||||
],
|
],
|
||||||
updateMode: false,
|
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>
|
||||||
<template #title> {{ shoppingList.name }} </template>
|
<template #title> {{ shoppingList.name }} </template>
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
<BannerExperimental />
|
<BannerExperimental issue="https://github.com/hay-kot/mealie/issues/916" />
|
||||||
<!-- Viewer -->
|
<!-- Viewer -->
|
||||||
<section v-if="!edit" class="py-2">
|
<section v-if="!edit" class="py-2">
|
||||||
<div v-if="!byLabel">
|
<div v-if="!byLabel">
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<BaseButton create @click="actions.createOne()" />
|
<BaseButton create @click="actions.createOne()" />
|
||||||
<v-expansion-panels class="mt-2">
|
<v-expansion-panels class="mt-2">
|
||||||
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
|
<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">
|
<v-expansion-panel-header disable-icon-rotate class="headline">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-icon large left>
|
<v-icon large left>
|
||||||
|
@ -23,8 +23,8 @@
|
||||||
<v-icon class="handle">
|
<v-icon class="handle">
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-btn color="info" fab small class="ml-2">
|
<v-btn icon small class="ml-2">
|
||||||
<v-icon color="white">
|
<v-icon>
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
@ -38,8 +38,22 @@
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<BaseButton delete @click="actions.deleteOne(cookbook.id)" />
|
<BaseButtonGroup
|
||||||
<BaseButton save @click="actions.updateOne(cookbook)"> </BaseButton>
|
: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-card-actions>
|
||||||
</v-expansion-panel-content>
|
</v-expansion-panel-content>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
|
@ -70,9 +84,3 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</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()" />
|
<BaseButton create @click="actions.createOne()" />
|
||||||
<v-expansion-panels class="mt-2">
|
<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">
|
<v-expansion-panel-header disable-icon-rotate class="headline">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-icon large left :color="webhook.enabled ? 'info' : null">
|
<v-icon large left :color="webhook.enabled ? 'info' : null">
|
||||||
|
@ -20,8 +20,8 @@
|
||||||
{{ webhook.name }} - {{ webhook.time }}
|
{{ webhook.name }} - {{ webhook.time }}
|
||||||
</div>
|
</div>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<v-btn color="info" fab small class="ml-2">
|
<v-btn small icon class="ml-2">
|
||||||
<v-icon color="white">
|
<v-icon>
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
@ -34,16 +34,28 @@
|
||||||
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
|
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
|
||||||
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions class="py-0 justify-end">
|
||||||
<BaseButton secondary color="info">
|
<BaseButtonGroup
|
||||||
<template #icon>
|
:buttons="[
|
||||||
{{ $globals.icons.testTube }}
|
{
|
||||||
</template>
|
icon: $globals.icons.delete,
|
||||||
Test
|
text: $t('general.delete'),
|
||||||
</BaseButton>
|
event: 'delete',
|
||||||
<v-spacer></v-spacer>
|
},
|
||||||
<BaseButton delete @click="actions.deleteOne(webhook.id)" />
|
{
|
||||||
<BaseButton save @click="actions.updateOne(webhook)" />
|
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-card-actions>
|
||||||
</v-expansion-panel-content>
|
</v-expansion-panel-content>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
|
|
|
@ -98,6 +98,15 @@
|
||||||
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</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">
|
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Members', to: '/user/group/members' }"
|
:link="{ text: 'Manage Members', to: '/user/group/members' }"
|
||||||
|
@ -129,7 +138,7 @@
|
||||||
</section>
|
</section>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||||
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
||||||
|
@ -218,4 +227,3 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</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
|
/* 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 {
|
export interface CreateWebhook {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
time?: 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 {
|
export interface ReadWebhook {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -19,6 +218,11 @@ export interface ReadWebhook {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
export interface SaveInviteToken {
|
||||||
|
usesLeft: number;
|
||||||
|
groupId: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
export interface SaveWebhook {
|
export interface SaveWebhook {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -26,3 +230,78 @@ export interface SaveWebhook {
|
||||||
time?: string;
|
time?: string;
|
||||||
groupId: 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
|
/* 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 type RegisteredParser = "nlp" | "brute";
|
||||||
|
|
||||||
|
export interface AssignCategories {
|
||||||
|
recipes: string[];
|
||||||
|
categories: CategoryBase[];
|
||||||
|
}
|
||||||
export interface CategoryBase {
|
export interface CategoryBase {
|
||||||
name: string;
|
name: string;
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
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 {
|
export interface CategoryIn {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
@ -47,6 +70,13 @@ export interface CreateRecipeByUrl {
|
||||||
export interface CreateRecipeByUrlBulk {
|
export interface CreateRecipeByUrlBulk {
|
||||||
imports: CreateRecipeBulk[];
|
imports: CreateRecipeBulk[];
|
||||||
}
|
}
|
||||||
|
export interface DeleteRecipes {
|
||||||
|
recipes: string[];
|
||||||
|
}
|
||||||
|
export interface ExportRecipes {
|
||||||
|
recipes: string[];
|
||||||
|
exportType?: ExportTypes & string;
|
||||||
|
}
|
||||||
export interface IngredientConfidence {
|
export interface IngredientConfidence {
|
||||||
average?: number;
|
average?: number;
|
||||||
comment?: number;
|
comment?: number;
|
||||||
|
@ -60,6 +90,12 @@ export interface IngredientFood {
|
||||||
description?: string;
|
description?: string;
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A list of ingredient references.
|
||||||
|
*/
|
||||||
|
export interface IngredientReferences {
|
||||||
|
referenceId?: string;
|
||||||
|
}
|
||||||
export interface IngredientRequest {
|
export interface IngredientRequest {
|
||||||
parser?: RegisteredParser & string;
|
parser?: RegisteredParser & string;
|
||||||
ingredient: string;
|
ingredient: string;
|
||||||
|
@ -141,12 +177,6 @@ export interface RecipeStep {
|
||||||
text: string;
|
text: string;
|
||||||
ingredientReferences?: IngredientReferences[];
|
ingredientReferences?: IngredientReferences[];
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* A list of ingredient references.
|
|
||||||
*/
|
|
||||||
export interface IngredientReferences {
|
|
||||||
referenceId?: string;
|
|
||||||
}
|
|
||||||
export interface RecipeSettings {
|
export interface RecipeSettings {
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
showNutrition?: boolean;
|
showNutrition?: boolean;
|
||||||
|
@ -198,6 +228,30 @@ export interface RecipeCommentUpdate {
|
||||||
id: string;
|
id: string;
|
||||||
text: 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 {
|
export interface RecipeSlug {
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
@ -247,11 +301,6 @@ export interface RecipeToolResponse {
|
||||||
recipes?: Recipe[];
|
recipes?: Recipe[];
|
||||||
}
|
}
|
||||||
export interface SlugResponse {}
|
export interface SlugResponse {}
|
||||||
export interface TagBase {
|
|
||||||
name: string;
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
export interface TagIn {
|
export interface TagIn {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,19 @@ export interface CreateToken {
|
||||||
userId: string;
|
userId: string;
|
||||||
token: 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 {
|
export interface GroupBase {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
@ -28,7 +41,6 @@ export interface GroupInDB {
|
||||||
categories?: CategoryBase[];
|
categories?: CategoryBase[];
|
||||||
webhooks?: unknown[];
|
webhooks?: unknown[];
|
||||||
users?: UserOut[];
|
users?: UserOut[];
|
||||||
shoppingLists?: ShoppingListOut[];
|
|
||||||
preferences?: ReadGroupPreferences;
|
preferences?: ReadGroupPreferences;
|
||||||
}
|
}
|
||||||
export interface UserOut {
|
export interface UserOut {
|
||||||
|
@ -52,18 +64,6 @@ export interface LongLiveTokenOut {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
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 {
|
export interface ReadGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
|
@ -103,6 +103,11 @@ export interface PrivateUser {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
export interface PrivatePasswordResetToken {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
user: PrivateUser;
|
||||||
|
}
|
||||||
export interface RecipeSummary {
|
export interface RecipeSummary {
|
||||||
id?: number;
|
id?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
@ -166,6 +171,16 @@ export interface CreateIngredientFood {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
export interface ResetPassword {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
}
|
||||||
|
export interface SavePasswordResetToken {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
export interface SignUpIn {
|
export interface SignUpIn {
|
||||||
name: string;
|
name: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
|
@ -231,3 +246,6 @@ export interface UserIn {
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
password: string;
|
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 BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
||||||
import ReportTable from "@/components/global/ReportTable.vue";
|
import ReportTable from "@/components/global/ReportTable.vue";
|
||||||
import AppToolbar from "@/components/global/AppToolbar.vue";
|
import AppToolbar from "@/components/global/AppToolbar.vue";
|
||||||
|
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
|
||||||
import BaseButton from "@/components/global/BaseButton.vue";
|
import BaseButton from "@/components/global/BaseButton.vue";
|
||||||
|
import BannerExperimental from "@/components/global/BannerExperimental.vue";
|
||||||
import BaseDialog from "@/components/global/BaseDialog.vue";
|
import BaseDialog from "@/components/global/BaseDialog.vue";
|
||||||
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
|
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
|
||||||
import BaseStatCard from "@/components/global/BaseStatCard.vue";
|
import BaseStatCard from "@/components/global/BaseStatCard.vue";
|
||||||
|
@ -31,7 +33,9 @@ declare module "vue" {
|
||||||
BaseOverflowButton: typeof BaseOverflowButton;
|
BaseOverflowButton: typeof BaseOverflowButton;
|
||||||
ReportTable: typeof ReportTable;
|
ReportTable: typeof ReportTable;
|
||||||
AppToolbar: typeof AppToolbar;
|
AppToolbar: typeof AppToolbar;
|
||||||
|
BaseButtonGroup: typeof BaseButtonGroup;
|
||||||
BaseButton: typeof BaseButton;
|
BaseButton: typeof BaseButton;
|
||||||
|
BannerExperimental: typeof BannerExperimental;
|
||||||
BaseDialog: typeof BaseDialog;
|
BaseDialog: typeof BaseDialog;
|
||||||
RecipeJsonEditor: typeof RecipeJsonEditor;
|
RecipeJsonEditor: typeof RecipeJsonEditor;
|
||||||
BaseStatCard: typeof BaseStatCard;
|
BaseStatCard: typeof BaseStatCard;
|
||||||
|
|
8
makefile
8
makefile
|
@ -60,8 +60,6 @@ lint: ## 🧺 Format, Check and Flake8
|
||||||
poetry run flake8 mealie tests
|
poetry run flake8 mealie tests
|
||||||
|
|
||||||
|
|
||||||
lint-frontend: ## 🧺 Run yarn lint
|
|
||||||
cd frontend && yarn lint
|
|
||||||
|
|
||||||
coverage: ## ☂️ Check code coverage quickly with the default Python
|
coverage: ## ☂️ Check code coverage quickly with the default Python
|
||||||
poetry run pytest
|
poetry run pytest
|
||||||
|
@ -95,6 +93,12 @@ frontend: ## 🎬 Start Mealie Frontend Development Server
|
||||||
frontend-build: ## 🏗 Build Frontend in frontend/dist
|
frontend-build: ## 🏗 Build Frontend in frontend/dist
|
||||||
cd frontend && yarn run build
|
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
|
.PHONY: docs
|
||||||
docs: ## 📄 Start Mkdocs Development Server
|
docs: ## 📄 Start Mkdocs Development Server
|
||||||
poetry run python dev/scripts/api_docs_gen.py && \
|
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":
|
elif provider_name == "sqlite":
|
||||||
return SQLiteProvider(data_dir=data_dir)
|
return SQLiteProvider(data_dir=data_dir)
|
||||||
else:
|
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 mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||||
|
|
||||||
from ._model_utils import auto_init
|
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):
|
class Event(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "events"
|
__tablename__ = "events"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from .cookbook import *
|
from .cookbook import *
|
||||||
|
from .events import *
|
||||||
from .exports import *
|
from .exports import *
|
||||||
from .group import *
|
from .group import *
|
||||||
from .invite_tokens 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 as sa
|
||||||
import sqlalchemy.orm as orm
|
import sqlalchemy.orm as orm
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
@ -22,7 +20,7 @@ settings = get_app_settings()
|
||||||
|
|
||||||
class Group(SqlAlchemyBase, BaseMixins):
|
class Group(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "groups"
|
__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)
|
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||||
users = orm.relationship("User", back_populates="group")
|
users = orm.relationship("User", back_populates="group")
|
||||||
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
|
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)
|
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
|
||||||
shopping_lists = orm.relationship("ShoppingList", **common_args)
|
shopping_lists = orm.relationship("ShoppingList", **common_args)
|
||||||
group_reports = orm.relationship("ReportModel", **common_args)
|
group_reports = orm.relationship("ReportModel", **common_args)
|
||||||
|
group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
exclude = {
|
exclude = {
|
||||||
|
|
|
@ -2,9 +2,10 @@ from functools import cached_property
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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 import Group, GroupMealPlan, ReportEntryModel, ReportModel
|
||||||
from mealie.db.models.group.cookbook import CookBook
|
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.exports import GroupDataExportsModel
|
||||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
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.db.models.users.password_reset import PasswordResetModel
|
||||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||||
from mealie.schema.events import Event as EventSchema
|
from mealie.schema.events import Event as EventSchema
|
||||||
from mealie.schema.events import EventNotificationIn
|
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||||
from mealie.schema.group.group_exports import GroupDataExport
|
from mealie.schema.group.group_exports import GroupDataExport
|
||||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
|
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
|
||||||
|
@ -116,10 +117,6 @@ class AllRepositories:
|
||||||
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
|
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
|
||||||
return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut)
|
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
|
@cached_property
|
||||||
def events(self) -> RepositoryGeneric[EventSchema, Event]:
|
def events(self) -> RepositoryGeneric[EventSchema, Event]:
|
||||||
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
|
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
|
||||||
|
@ -193,3 +190,7 @@ class AllRepositories:
|
||||||
@cached_property
|
@cached_property
|
||||||
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
||||||
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
|
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]:
|
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
|
eff_schema = override_schema or self.schema
|
||||||
|
|
||||||
|
filter = self._filter_builder()
|
||||||
|
|
||||||
order_attr = None
|
order_attr = None
|
||||||
if order_by:
|
if order_by:
|
||||||
order_attr = getattr(self.sql_model, str(order_by))
|
order_attr = getattr(self.sql_model, str(order_by))
|
||||||
|
@ -77,10 +79,18 @@ class RepositoryGeneric(Generic[T, D]):
|
||||||
|
|
||||||
return [
|
return [
|
||||||
eff_schema.from_orm(x)
|
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(
|
def multi_query(
|
||||||
self,
|
self,
|
||||||
|
@ -92,6 +102,8 @@ class RepositoryGeneric(Generic[T, D]):
|
||||||
) -> list[T]:
|
) -> list[T]:
|
||||||
eff_schema = override_schema or self.schema
|
eff_schema = override_schema or self.schema
|
||||||
|
|
||||||
|
filer = self._filter_builder(**query_by)
|
||||||
|
|
||||||
order_attr = None
|
order_attr = None
|
||||||
if order_by:
|
if order_by:
|
||||||
order_attr = getattr(self.sql_model, str(order_by))
|
order_attr = getattr(self.sql_model, str(order_by))
|
||||||
|
@ -100,7 +112,7 @@ class RepositoryGeneric(Generic[T, D]):
|
||||||
return [
|
return [
|
||||||
eff_schema.from_orm(x)
|
eff_schema.from_orm(x)
|
||||||
for x in self.session.query(self.sql_model)
|
for x in self.session.query(self.sql_model)
|
||||||
.filter_by(**query_by)
|
.filter_by(**filer)
|
||||||
.order_by(order_attr)
|
.order_by(order_attr)
|
||||||
.offset(start)
|
.offset(start)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
|
@ -76,6 +76,17 @@ class CrudMixins:
|
||||||
|
|
||||||
return item
|
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):
|
def update_one(self, data, item_id):
|
||||||
item = self.repo.get(item_id)
|
item = self.repo.get(item_id)
|
||||||
|
|
||||||
|
@ -98,11 +109,11 @@ class CrudMixins:
|
||||||
self.handle_exception(ex)
|
self.handle_exception(ex)
|
||||||
|
|
||||||
def delete_one(self, item_id):
|
def delete_one(self, item_id):
|
||||||
item = self.repo.get(item_id)
|
self.logger.info(f"Deleting item with id {item_id}")
|
||||||
self.logger.info(f"Deleting item with id {item}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item = self.repo.delete(item)
|
item = self.repo.delete(item_id)
|
||||||
|
self.logger.info(item)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self.handle_exception(ex)
|
self.handle_exception(ex)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from . import events, notifications
|
from . import events
|
||||||
|
|
||||||
about_router = APIRouter(prefix="/api/about")
|
about_router = APIRouter(prefix="/api/about")
|
||||||
|
|
||||||
about_router.include_router(events.router, tags=["Events: CRUD"])
|
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.meal_service import MealService
|
||||||
from mealie.services.group_services.reports_service import GroupReportService
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
@ -56,3 +56,4 @@ def get_all_reports(
|
||||||
router.include_router(report_router)
|
router.include_router(report_router)
|
||||||
router.include_router(shopping_lists.router)
|
router.include_router(shopping_lists.router)
|
||||||
router.include_router(labels.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.mapper import cast
|
||||||
from mealie.schema.query import GetAll
|
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
|
from mealie.services.group_services.shopping_lists import ShoppingListService
|
||||||
|
|
||||||
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
|
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:
|
class ShoppingListRoutes:
|
||||||
deps: SharedDependencies = Depends(SharedDependencies.user)
|
deps: SharedDependencies = Depends(SharedDependencies.user)
|
||||||
service: ShoppingListService = Depends(ShoppingListService.private)
|
service: ShoppingListService = Depends(ShoppingListService.private)
|
||||||
|
event_bus: EventBusService = Depends(EventBusService)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
|
@ -56,7 +59,16 @@ class ShoppingListRoutes:
|
||||||
@router.post("", response_model=ShoppingListOut)
|
@router.post("", response_model=ShoppingListOut)
|
||||||
def create_one(self, data: ShoppingListCreate):
|
def create_one(self, data: ShoppingListCreate):
|
||||||
save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id)
|
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)
|
@router.get("/{item_id}", response_model=ShoppingListOut)
|
||||||
def get_one(self, item_id: UUID4):
|
def get_one(self, item_id: UUID4):
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .about import *
|
from .about import *
|
||||||
from .backup import *
|
from .backup import *
|
||||||
from .migration import *
|
from .migration import *
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .cookbook import *
|
from .cookbook import *
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
from .event_notifications import *
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .events import *
|
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 .group_shopping_list import *
|
||||||
|
from .invite_token import *
|
||||||
from .webhook 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
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from pydantic import UUID4
|
from .multi_purpose_label import *
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
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 .meal import *
|
||||||
from .new_meal import *
|
from .new_meal import *
|
||||||
from .shopping_list import *
|
from .shopping_list import *
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .recipe import *
|
from .recipe import *
|
||||||
|
from .recipe_asset import *
|
||||||
|
from .recipe_bulk_actions import *
|
||||||
from .recipe_category import *
|
from .recipe_category import *
|
||||||
from .recipe_comments import *
|
from .recipe_comments import *
|
||||||
from .recipe_image_types import *
|
from .recipe_image_types import *
|
||||||
from .recipe_ingredient 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 .recipe_tool import *
|
||||||
from .request_helpers import *
|
from .request_helpers import *
|
||||||
|
|
|
@ -2,7 +2,7 @@ import enum
|
||||||
|
|
||||||
from fastapi_camelcase import CamelModel
|
from fastapi_camelcase import CamelModel
|
||||||
|
|
||||||
from . import CategoryBase, TagBase
|
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||||
|
|
||||||
|
|
||||||
class ExportTypes(str, enum.Enum):
|
class ExportTypes(str, enum.Enum):
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi_camelcase import CamelModel
|
from fastapi_camelcase import CamelModel
|
||||||
from pydantic.utils import GetterDict
|
from pydantic.utils import GetterDict
|
||||||
|
|
||||||
|
@ -23,7 +21,7 @@ class CategoryBase(CategoryIn):
|
||||||
|
|
||||||
|
|
||||||
class RecipeCategoryResponse(CategoryBase):
|
class RecipeCategoryResponse(CategoryBase):
|
||||||
recipes: List["Recipe"] = []
|
recipes: "list[Recipe]" = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
@ -42,7 +40,7 @@ class RecipeTagResponse(RecipeCategoryResponse):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
from . import Recipe
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
|
|
||||||
RecipeCategoryResponse.update_forward_refs()
|
RecipeCategoryResponse.update_forward_refs()
|
||||||
RecipeTagResponse.update_forward_refs()
|
RecipeTagResponse.update_forward_refs()
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .reports import *
|
from .reports import *
|
||||||
|
|
|
@ -1,17 +1,2 @@
|
||||||
from typing import Optional
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
from .error_response import *
|
||||||
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()
|
|
||||||
|
|
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 *
|
from .tasks import *
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .auth import *
|
from .auth import *
|
||||||
|
from .registration import *
|
||||||
from .sign_up import *
|
from .sign_up import *
|
||||||
from .user 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.core.config import get_app_dirs
|
||||||
from mealie.repos.all_repositories import get_repositories
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
|
from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport
|
||||||
from mealie.schema.events import EventNotificationIn
|
|
||||||
from mealie.schema.recipe import Recipe, RecipeCommentOut
|
from mealie.schema.recipe import Recipe, RecipeCommentOut
|
||||||
from mealie.schema.user import PrivateUser, UpdateGroup
|
from mealie.schema.user import PrivateUser, UpdateGroup
|
||||||
from mealie.services.image import minify
|
from mealie.services.image import minify
|
||||||
|
@ -159,24 +158,6 @@ class ImportDatabase:
|
||||||
|
|
||||||
minify.migrate_images()
|
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):
|
def import_settings(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -330,11 +311,6 @@ def import_database(
|
||||||
user_report = import_session.import_users()
|
user_report = import_session.import_users()
|
||||||
|
|
||||||
notification_report = []
|
notification_report = []
|
||||||
if import_notifications:
|
|
||||||
notification_report = import_session.import_notifications()
|
|
||||||
|
|
||||||
# if import_recipes:
|
|
||||||
# import_session.import_comments()
|
|
||||||
|
|
||||||
import_session.clean_up()
|
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 sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.db.db_setup import create_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
|
from mealie.schema.events import Event, EventCategory
|
||||||
|
|
||||||
|
|
||||||
def test_notification(notification_url, event=None) -> bool:
|
def save_event(title, text, category, session: Session):
|
||||||
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):
|
|
||||||
event = Event(title=title, text=text, category=category)
|
event = Event(title=title, text=text, category=category)
|
||||||
session = session or create_session()
|
session = session or create_session()
|
||||||
db = get_repositories(session)
|
db = get_repositories(session)
|
||||||
db.events.create(event.dict())
|
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):
|
def create_general_event(title, text, session=None):
|
||||||
category = EventCategory.general
|
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):
|
def create_recipe_event(title, text, session=None, attachment=None):
|
||||||
category = EventCategory.recipe
|
category = EventCategory.recipe
|
||||||
|
save_event(title=title, text=text, category=category, session=session)
|
||||||
save_event(title=title, text=text, category=category, session=session, attachment=attachment)
|
|
||||||
|
|
||||||
|
|
||||||
def create_backup_event(title, text, session=None):
|
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"
|
start = "mealie.app:main"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.10"
|
||||||
aiofiles = "0.5.0"
|
aiofiles = "0.5.0"
|
||||||
aniso8601 = "7.0.0"
|
aniso8601 = "7.0.0"
|
||||||
appdirs = "1.4.4"
|
appdirs = "1.4.4"
|
||||||
fastapi = "^0.71.0"
|
fastapi = "^0.71.0"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.13.0"}
|
uvicorn = {extras = ["standard"], version = "^0.13.0"}
|
||||||
APScheduler = "^3.6.3"
|
APScheduler = "^3.8.1"
|
||||||
SQLAlchemy = "^1.4.29"
|
SQLAlchemy = "^1.4.29"
|
||||||
Jinja2 = "^2.11.2"
|
Jinja2 = "^2.11.2"
|
||||||
python-dotenv = "^0.15.0"
|
python-dotenv = "^0.15.0"
|
||||||
|
@ -28,10 +28,10 @@ fastapi-camelcase = "^1.0.2"
|
||||||
bcrypt = "^3.2.0"
|
bcrypt = "^3.2.0"
|
||||||
python-jose = "^3.3.0"
|
python-jose = "^3.3.0"
|
||||||
passlib = "^1.7.4"
|
passlib = "^1.7.4"
|
||||||
lxml = "4.6.2"
|
lxml = "^4.7.1"
|
||||||
Pillow = "^8.2.0"
|
Pillow = "^8.2.0"
|
||||||
pathvalidate = "^2.4.1"
|
pathvalidate = "^2.4.1"
|
||||||
apprise = "0.9.3"
|
apprise = "^0.9.6"
|
||||||
recipe-scrapers = "^13.5.0"
|
recipe-scrapers = "^13.5.0"
|
||||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
|
@ -39,20 +39,20 @@ emails = "^0.6"
|
||||||
python-i18n = "^0.3.9"
|
python-i18n = "^0.3.9"
|
||||||
python-ldap = "^3.3.1"
|
python-ldap = "^3.3.1"
|
||||||
pydantic = "^1.9.0"
|
pydantic = "^1.9.0"
|
||||||
|
tzdata = "^2021.5"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pylint = "^2.6.0"
|
pylint = "^2.6.0"
|
||||||
pytest = "^6.2.1"
|
pytest = "^6.2.1"
|
||||||
pytest-cov = "^2.11.0"
|
pytest-cov = "^2.11.0"
|
||||||
mkdocs-material = "^7.0.2"
|
mkdocs-material = "^7.0.2"
|
||||||
flake8 = "^3.9.0"
|
flake8 = "^4.0.1"
|
||||||
coverage = "^5.5"
|
coverage = "^5.5"
|
||||||
pydantic-to-typescript = "^1.0.7"
|
pydantic-to-typescript = "^1.0.7"
|
||||||
rich = "^10.7.0"
|
rich = "^10.7.0"
|
||||||
isort = "^5.9.3"
|
isort = "^5.9.3"
|
||||||
regex = "2021.9.30" # TODO: Remove during Upgrade -> https://github.com/psf/black/issues/2524
|
|
||||||
flake8-print = "^4.0.0"
|
flake8-print = "^4.0.0"
|
||||||
black = "^21.11b1"
|
black = "^21.12b0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
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
|
Loading…
Reference in a new issue