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:
Hayden 2022-01-09 21:04:24 -09:00 committed by GitHub
parent 50a341ed3f
commit 190773c5d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 1992 additions and 1229 deletions

View file

@ -43,7 +43,7 @@ jobs:
- name: Set up python
uses: actions/setup-python@v2
with:
python-version: 3.9
python-version: "3.10"
#----------------------------------------------
# ----- install & configure poetry -----
#----------------------------------------------

View file

@ -476,7 +476,6 @@ ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
w54
# Ignore imports when computing similarities.
ignore-imports=no

View file

@ -1,7 +1,7 @@
###############################################
# Base Image
###############################################
FROM python:3.9-slim as python-base
FROM python:3.10-slim as python-base
ENV MEALIE_HOME="/app"

View file

@ -1,18 +1,26 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Tuple
import black
import isort
from jinja2 import Template
def render_python_template(template_file: Path, dest: Path, data: dict) -> str:
def render_python_template(template_file: Path | str, dest: Path, data: dict) -> str:
"""Render and Format a Jinja2 Template for Python Code"""
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 = black.format_str(text, mode=black.FileMode())
dest.write_text(text)
isort.file(dest)
@dataclass

View file

@ -1,6 +1,7 @@
from pathlib import Path
CWD = Path(__file__).parent
PROJECT_DIR = Path(__file__).parent.parent.parent
class Directories:

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

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

View file

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

View file

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

View file

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

View 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`, {});
}
}

View file

@ -7,7 +7,6 @@ import { UploadFile } from "./class-interfaces/upload";
import { CategoriesAPI } from "./class-interfaces/categories";
import { TagsAPI } from "./class-interfaces/tags";
import { UtilsAPI } from "./class-interfaces/utils";
import { NotificationsAPI } from "./class-interfaces/event-notifications";
import { FoodAPI } from "./class-interfaces/recipe-foods";
import { UnitAPI } from "./class-interfaces/recipe-units";
import { CookbookAPI } from "./class-interfaces/group-cookbooks";
@ -23,10 +22,10 @@ import { GroupMigrationApi } from "./class-interfaces/group-migrations";
import { GroupReportsApi } from "./class-interfaces/group-reports";
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier";
import { ApiRequestInstance } from "~/types/api";
class Api {
// private static instance: Api;
public recipes: RecipeAPI;
public users: UserApi;
public groups: GroupAPI;
@ -35,7 +34,6 @@ class Api {
public categories: CategoriesAPI;
public tags: TagsAPI;
public utils: UtilsAPI;
public notifications: NotificationsAPI;
public foods: FoodAPI;
public units: UnitAPI;
public cookbooks: CookbookAPI;
@ -50,14 +48,10 @@ class Api {
public tools: ToolsApi;
public shopping: ShoppingApi;
public multiPurposeLabels: MultiPurposeLabelsApi;
// Utils
public groupEventNotifier: GroupEventNotifierApi;
public upload: UploadFile;
constructor(requests: ApiRequestInstance) {
// if (Api.instance instanceof Api) {
// return Api.instance;
// }
// Recipes
this.recipes = new RecipeAPI(requests);
this.categories = new CategoriesAPI(requests);
@ -84,7 +78,6 @@ class Api {
// Admin
this.events = new EventsAPI(requests);
this.backups = new BackupAPI(requests);
this.notifications = new NotificationsAPI(requests);
// Utils
this.upload = new UploadFile(requests);
@ -92,9 +85,9 @@ class Api {
this.email = new EmailAPI(requests);
this.bulk = new BulkActionsAPI(requests);
this.groupEventNotifier = new GroupEventNotifierApi(requests);
Object.freeze(this);
// Api.instance = this;
}
}

View file

@ -107,19 +107,43 @@
{{ $t("recipe.step-index", { step: index + 1 }) }}
<div class="ml-auto">
<BaseOverflowButton
v-if="edit"
small
mode="event"
:items="actionEvents || []"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
>
</BaseOverflowButton>
</div>
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
<template v-if="edit">
<v-icon class="handle ml-auto mr-2">{{ $globals.icons.arrowUpDown }}</v-icon>
<div>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.dotsVertical,
text: $t('general.delete'),
children: [
{
text: 'Toggle Section',
event: 'toggle-section',
},
{
text: 'Link Ingredients',
event: 'link-ingredients',
},
{
text: 'Merge Above',
event: 'merge-above',
},
],
},
{
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? $t('general.edit') : 'Preview Markdown',
event: 'preview-step',
},
]"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
@preview-step="togglePreviewState(index)"
/>
</div>
</template>
<v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
{{ $globals.icons.checkboxMarkedCircle }}
@ -127,7 +151,11 @@
</v-fade-transition>
</v-card-title>
<v-card-text v-if="edit">
<MarkdownEditor v-model="value[index]['text']" />
<MarkdownEditor
v-model="value[index]['text']"
:preview.sync="previewStates[index]"
:display-preview="false"
/>
<div
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
@ -410,7 +438,17 @@ export default defineComponent({
}
}
const previewStates = ref<boolean[]>([]);
function togglePreviewState(index: number) {
const temp = [...previewStates.value];
temp[index] = !temp[index];
previewStates.value = temp;
}
return {
togglePreviewState,
previewStates,
...toRefs(state),
actionEvents,
activeRefs,

View file

@ -1,9 +1,9 @@
<template>
<v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column">
<div v-if="$vuetify.breakpoint.smAndDown" class="pa-2 mx-auto">
<v-img max-width="150px" max-height="125" :src="image"></v-img>
<v-img max-width="150px" max-height="125" :src="image" />
</div>
<div class="d-flex align-center justify-space-between">
<div class="d-flex justify-space-between">
<div>
<v-card-title class="headline pb-0">
<slot name="title"> </slot>
@ -14,7 +14,7 @@
</v-card-text>
</div>
</div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="px-10">
<div v-if="$vuetify.breakpoint.mdAndUp" class="py-2 px-10 my-auto">
<v-img max-width="150px" max-height="125" :src="image"></v-img>
</div>
</div>

View file

@ -189,7 +189,7 @@ export default defineComponent({
return {
...toRefs(state),
drawer,
}
};
},
});
</script>

View file

@ -2,5 +2,21 @@
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
<b>Experimental Feature</b>
<div>This page contains experimental or still-baking features. Please excuse the mess.</div>
<div v-if="issue != ''" class="py-2">
<a :href="issue" target="_blank"> Track our progress here</a>
</div>
</v-alert>
</template>
<script lang="ts">
export default {
props: {
issue: {
type: String,
required: false,
default: "",
},
},
};
</script>

View file

@ -1,17 +1,19 @@
<template>
<div>
<v-tabs v-model="tab" height="30px" class="my-1">
<v-tab>
<v-icon small left> {{ $globals.icons.edit }}</v-icon>
Edit
</v-tab>
<v-tab>
<v-icon small left> {{ $globals.icons.eye }}</v-icon>
Preview
</v-tab>
</v-tabs>
<div v-if="displayPreview" class="d-flex justify-end">
<BaseButtonGroup
:buttons="[
{
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
text: previewState ? $t('general.edit') : 'Preview Markdown',
event: 'toggle',
},
]"
@toggle="previewState = !previewState"
/>
</div>
<v-textarea
v-if="tab == 0"
v-if="!previewState"
v-model="inputVal"
:class="label == '' ? '' : 'mt-5'"
:label="label"
@ -27,7 +29,7 @@
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import { defineComponent, reactive, toRefs, computed } from "@nuxtjs/composition-api";
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
export default defineComponent({
name: "MarkdownEditor",
@ -43,10 +45,28 @@ export default defineComponent({
type: String,
default: "",
},
preview: {
type: Boolean,
default: undefined,
},
displayPreview: {
type: Boolean,
default: true,
},
},
setup(props, context) {
const state = reactive({
tab: 0,
const fallbackPreview = ref(false);
const previewState = computed({
get: () => {
return props.preview ?? fallbackPreview.value;
},
set: (val) => {
if (props.preview) {
context.emit("input:preview", val);
} else {
fallbackPreview.value = val;
}
},
});
const inputVal = computed({
@ -58,10 +78,11 @@ export default defineComponent({
},
});
return {
previewState,
inputVal,
...toRefs(state),
};
},
});
</script>

View file

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

View file

@ -60,11 +60,6 @@ export default defineComponent({
to: "/admin/toolbox",
title: i18n.t("sidebar.toolbox"),
children: [
{
icon: $globals.icons.bellAlert,
to: "/admin/toolbox/notifications",
title: i18n.t("events.notification"),
},
{
icon: $globals.icons.foods,
to: "/admin/toolbox/foods",

View file

@ -41,9 +41,6 @@
:search="search"
@click:row="handleRowClick"
>
<template #item.shoppingLists="{ item }">
{{ item.shoppingLists.length }}
</template>
<template #item.users="{ item }">
{{ item.users.length }}
</template>
@ -99,7 +96,6 @@ export default defineComponent({
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("user.total-users"), value: "users" },
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
{ text: i18n.t("shopping-list.shopping-lists"), value: "shoppingLists" },
{ text: i18n.t("general.delete"), value: "actions" },
],
updateMode: false,

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
</template>
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerExperimental />
<BannerExperimental issue="https://github.com/hay-kot/mealie/issues/916" />
<!-- Viewer -->
<section v-if="!edit" class="py-2">
<div v-if="!byLabel">

View file

@ -11,7 +11,7 @@
<BaseButton create @click="actions.createOne()" />
<v-expansion-panels class="mt-2">
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
<v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 my-border rounded">
<v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 left-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline">
<div class="d-flex align-center">
<v-icon large left>
@ -23,8 +23,8 @@
<v-icon class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
<v-btn color="info" fab small class="ml-2">
<v-icon color="white">
<v-btn icon small class="ml-2">
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
@ -38,8 +38,22 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton delete @click="actions.deleteOne(cookbook.id)" />
<BaseButton save @click="actions.updateOne(cookbook)"> </BaseButton>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.save,
text: $t('general.save'),
event: 'save',
},
]"
@delete="actions.deleteOne(webhook.id)"
@save="actions.updateOne(webhook)"
/>
</v-card-actions>
</v-expansion-panel-content>
</v-expansion-panel>
@ -70,9 +84,3 @@ export default defineComponent({
},
});
</script>
<style>
.my-border {
border-left: 5px solid var(--v-primary-base) !important;
}
</style>

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

View file

@ -11,7 +11,7 @@
<BaseButton create @click="actions.createOne()" />
<v-expansion-panels class="mt-2">
<v-expansion-panel v-for="(webhook, index) in webhooks" :key="index" class="my-2 my-border rounded">
<v-expansion-panel v-for="(webhook, index) in webhooks" :key="index" class="my-2 left-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline">
<div class="d-flex align-center">
<v-icon large left :color="webhook.enabled ? 'info' : null">
@ -20,8 +20,8 @@
{{ webhook.name }} - {{ webhook.time }}
</div>
<template #actions>
<v-btn color="info" fab small class="ml-2">
<v-icon color="white">
<v-btn small icon class="ml-2">
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
@ -34,16 +34,28 @@
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
</v-card-text>
<v-card-actions>
<BaseButton secondary color="info">
<template #icon>
{{ $globals.icons.testTube }}
</template>
Test
</BaseButton>
<v-spacer></v-spacer>
<BaseButton delete @click="actions.deleteOne(webhook.id)" />
<BaseButton save @click="actions.updateOne(webhook)" />
<v-card-actions class="py-0 justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.testTube,
text: $t('general.test'),
event: 'test',
},
{
icon: $globals.icons.save,
text: $t('general.save'),
event: 'save',
},
]"
@delete="actions.deleteOne(webhook.id)"
@save="actions.updateOne(webhook)"
/>
</v-card-actions>
</v-expansion-panel-content>
</v-expansion-panel>

View file

@ -98,6 +98,15 @@
Setup webhooks that trigger on days that you have have mealplan scheduled.
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Notifiers', to: '/user/group/notifiers' }"
:image="require('~/static/svgs/manage-notifiers.svg')"
>
<template #title> Notifiers </template>
Setup email and push notifications that trigger on specific events.
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Members', to: '/user/group/members' }"
@ -129,7 +138,7 @@
</section>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
@ -218,4 +227,3 @@ export default defineComponent({
},
});
</script>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -5,12 +5,211 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type SupportedMigrations = "nextcloud" | "chowdown" | "paprika" | "mealie_alpha";
export interface CreateGroupPreferences {
privateGroup?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
recipeShowAssets?: boolean;
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
groupId: string;
}
export interface CreateInviteToken {
uses: number;
}
export interface CreateWebhook {
enabled?: boolean;
name?: string;
url?: string;
time?: string;
}
export interface DataMigrationCreate {
sourceType: SupportedMigrations;
}
export interface EmailInitationResponse {
success: boolean;
error?: string;
}
export interface EmailInvitation {
email: string;
token: string;
}
export interface GroupAdminUpdate {
id: string;
name: string;
preferences: UpdateGroupPreferences;
}
export interface UpdateGroupPreferences {
privateGroup?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
recipeShowAssets?: boolean;
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
}
export interface GroupDataExport {
id: string;
groupId: string;
name: string;
filename: string;
path: string;
size: string;
expires: string;
}
export interface GroupEventNotifierCreate {
name: string;
appriseUrl: string;
}
/**
* These events are in-sync with the EventTypes found in the EventBusService.
* If you modify this, make sure to update the EventBusService as well.
*/
export interface GroupEventNotifierOptions {
recipeCreated?: boolean;
recipeUpdated?: boolean;
recipeDeleted?: boolean;
userSignup?: boolean;
dataMigrations?: boolean;
dataExport?: boolean;
dataImport?: boolean;
mealplanEntryCreated?: boolean;
shoppingListCreated?: boolean;
shoppingListUpdated?: boolean;
shoppingListDeleted?: boolean;
cookbookCreated?: boolean;
cookbookUpdated?: boolean;
cookbookDeleted?: boolean;
tagCreated?: boolean;
tagUpdated?: boolean;
tagDeleted?: boolean;
categoryCreated?: boolean;
categoryUpdated?: boolean;
categoryDeleted?: boolean;
}
/**
* These events are in-sync with the EventTypes found in the EventBusService.
* If you modify this, make sure to update the EventBusService as well.
*/
export interface GroupEventNotifierOptionsOut {
recipeCreated?: boolean;
recipeUpdated?: boolean;
recipeDeleted?: boolean;
userSignup?: boolean;
dataMigrations?: boolean;
dataExport?: boolean;
dataImport?: boolean;
mealplanEntryCreated?: boolean;
shoppingListCreated?: boolean;
shoppingListUpdated?: boolean;
shoppingListDeleted?: boolean;
cookbookCreated?: boolean;
cookbookUpdated?: boolean;
cookbookDeleted?: boolean;
tagCreated?: boolean;
tagUpdated?: boolean;
tagDeleted?: boolean;
categoryCreated?: boolean;
categoryUpdated?: boolean;
categoryDeleted?: boolean;
id: string;
}
/**
* These events are in-sync with the EventTypes found in the EventBusService.
* If you modify this, make sure to update the EventBusService as well.
*/
export interface GroupEventNotifierOptionsSave {
recipeCreated?: boolean;
recipeUpdated?: boolean;
recipeDeleted?: boolean;
userSignup?: boolean;
dataMigrations?: boolean;
dataExport?: boolean;
dataImport?: boolean;
mealplanEntryCreated?: boolean;
shoppingListCreated?: boolean;
shoppingListUpdated?: boolean;
shoppingListDeleted?: boolean;
cookbookCreated?: boolean;
cookbookUpdated?: boolean;
cookbookDeleted?: boolean;
tagCreated?: boolean;
tagUpdated?: boolean;
tagDeleted?: boolean;
categoryCreated?: boolean;
categoryUpdated?: boolean;
categoryDeleted?: boolean;
notifierId: string;
}
export interface GroupEventNotifierOut {
id: string;
name: string;
enabled: boolean;
groupId: string;
options: GroupEventNotifierOptionsOut;
}
export interface GroupEventNotifierPrivate {
id: string;
name: string;
enabled: boolean;
groupId: string;
options: GroupEventNotifierOptionsOut;
appriseUrl: string;
}
export interface GroupEventNotifierSave {
name: string;
appriseUrl: string;
enabled?: boolean;
groupId: string;
options?: GroupEventNotifierOptions;
}
export interface GroupEventNotifierUpdate {
name: string;
appriseUrl?: string;
enabled?: boolean;
groupId: string;
options?: GroupEventNotifierOptions;
id: string;
}
export interface IngredientFood {
name: string;
description?: string;
id: number;
}
export interface IngredientUnit {
name: string;
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
}
export interface MultiPurposeLabelSummary {
name: string;
groupId: string;
id: string;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
recipeShowAssets?: boolean;
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
groupId: string;
id: number;
}
export interface ReadInviteToken {
token: string;
usesLeft: number;
groupId: string;
}
export interface ReadWebhook {
enabled?: boolean;
name?: string;
@ -19,6 +218,11 @@ export interface ReadWebhook {
groupId: string;
id: number;
}
export interface SaveInviteToken {
usesLeft: number;
groupId: string;
token: string;
}
export interface SaveWebhook {
enabled?: boolean;
name?: string;
@ -26,3 +230,78 @@ export interface SaveWebhook {
time?: string;
groupId: string;
}
export interface SetPermissions {
userId: string;
canManage?: boolean;
canInvite?: boolean;
canOrganize?: boolean;
}
/**
* Create Shopping List
*/
export interface ShoppingListCreate {
name?: string;
}
export interface ShoppingListItemCreate {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: number;
unit?: IngredientUnit;
foodId?: number;
food?: IngredientFood;
recipeId?: number;
labelId?: string;
}
export interface ShoppingListItemOut {
shoppingListId: string;
checked?: boolean;
position?: number;
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: number;
unit?: IngredientUnit;
foodId?: number;
food?: IngredientFood;
recipeId?: number;
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
}
/**
* Create Shopping List
*/
export interface ShoppingListOut {
name?: string;
groupId: string;
id: string;
listItems?: ShoppingListItemOut[];
}
/**
* Create Shopping List
*/
export interface ShoppingListSave {
name?: string;
groupId: string;
}
/**
* Create Shopping List
*/
export interface ShoppingListSummary {
name?: string;
groupId: string;
id: string;
}
/**
* Create Shopping List
*/
export interface ShoppingListUpdate {
name?: string;
groupId: string;
id: string;
listItems?: ShoppingListItemOut[];
}

View 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;
}

View file

@ -5,13 +5,36 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute";
export interface AssignCategories {
recipes: string[];
categories: CategoryBase[];
}
export interface CategoryBase {
name: string;
id: number;
slug: string;
}
export interface AssignTags {
recipes: string[];
tags: TagBase[];
}
export interface TagBase {
name: string;
id: number;
slug: string;
}
export interface BulkActionError {
recipe: string;
error: string;
}
export interface BulkActionsResponse {
success: boolean;
message: string;
errors?: BulkActionError[];
}
export interface CategoryIn {
name: string;
}
@ -47,6 +70,13 @@ export interface CreateRecipeByUrl {
export interface CreateRecipeByUrlBulk {
imports: CreateRecipeBulk[];
}
export interface DeleteRecipes {
recipes: string[];
}
export interface ExportRecipes {
recipes: string[];
exportType?: ExportTypes & string;
}
export interface IngredientConfidence {
average?: number;
comment?: number;
@ -60,6 +90,12 @@ export interface IngredientFood {
description?: string;
id: number;
}
/**
* A list of ingredient references.
*/
export interface IngredientReferences {
referenceId?: string;
}
export interface IngredientRequest {
parser?: RegisteredParser & string;
ingredient: string;
@ -141,12 +177,6 @@ export interface RecipeStep {
text: string;
ingredientReferences?: IngredientReferences[];
}
/**
* A list of ingredient references.
*/
export interface IngredientReferences {
referenceId?: string;
}
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
@ -198,6 +228,30 @@ export interface RecipeCommentUpdate {
id: string;
text: string;
}
export interface RecipeShareToken {
recipeId: number;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
recipe: Recipe;
}
export interface RecipeShareTokenCreate {
recipeId: number;
expiresAt?: string;
}
export interface RecipeShareTokenSave {
recipeId: number;
expiresAt?: string;
groupId: string;
}
export interface RecipeShareTokenSummary {
recipeId: number;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
}
export interface RecipeSlug {
slug: string;
}
@ -247,11 +301,6 @@ export interface RecipeToolResponse {
recipes?: Recipe[];
}
export interface SlugResponse {}
export interface TagBase {
name: string;
id: number;
slug: string;
}
export interface TagIn {
name: string;
}

View file

@ -19,6 +19,19 @@ export interface CreateToken {
userId: string;
token: string;
}
export interface CreateUserRegistration {
group?: string;
groupToken?: string;
email: string;
username: string;
password: string;
passwordConfirm: string;
advanced?: boolean;
private?: boolean;
}
export interface ForgotPassword {
email: string;
}
export interface GroupBase {
name: string;
}
@ -28,7 +41,6 @@ export interface GroupInDB {
categories?: CategoryBase[];
webhooks?: unknown[];
users?: UserOut[];
shoppingLists?: ShoppingListOut[];
preferences?: ReadGroupPreferences;
}
export interface UserOut {
@ -52,18 +64,6 @@ export interface LongLiveTokenOut {
id: number;
createdAt: string;
}
export interface ShoppingListOut {
name: string;
group?: string;
items: ListItem[];
id: number;
}
export interface ListItem {
title?: string;
text?: string;
quantity?: number;
checked?: boolean;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
firstDayOfWeek?: number;
@ -103,6 +103,11 @@ export interface PrivateUser {
cacheKey: string;
password: string;
}
export interface PrivatePasswordResetToken {
userId: string;
token: string;
user: PrivateUser;
}
export interface RecipeSummary {
id?: number;
userId?: string;
@ -166,6 +171,16 @@ export interface CreateIngredientFood {
name: string;
description?: string;
}
export interface ResetPassword {
token: string;
email: string;
password: string;
passwordConfirm: string;
}
export interface SavePasswordResetToken {
userId: string;
token: string;
}
export interface SignUpIn {
name: string;
admin: boolean;
@ -231,3 +246,6 @@ export interface UserIn {
canOrganize?: boolean;
password: string;
}
export interface ValidateResetToken {
token: string;
}

View file

@ -5,7 +5,9 @@
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
@ -31,7 +33,9 @@ declare module "vue" {
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
BaseStatCard: typeof BaseStatCard;

View file

@ -60,8 +60,6 @@ lint: ## 🧺 Format, Check and Flake8
poetry run flake8 mealie tests
lint-frontend: ## 🧺 Run yarn lint
cd frontend && yarn lint
coverage: ## ☂️ Check code coverage quickly with the default Python
poetry run pytest
@ -95,6 +93,12 @@ frontend: ## 🎬 Start Mealie Frontend Development Server
frontend-build: ## 🏗 Build Frontend in frontend/dist
cd frontend && yarn run build
frontend-generate: ## 🏗 Generate Code for Frontend
poetry run python dev/code-generation/gen_frontend_types.py
frontend-lint: ## 🧺 Run yarn lint
cd frontend && yarn lint
.PHONY: docs
docs: ## 📄 Start Mkdocs Development Server
poetry run python dev/scripts/api_docs_gen.py && \

View file

@ -62,4 +62,4 @@ def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_
elif provider_name == "sqlite":
return SQLiteProvider(data_dir=data_dir)
else:
return
return SQLiteProvider(data_dir=data_dir)

View file

@ -1,31 +1,10 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy import Column, DateTime, Integer, String
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from ._model_utils import auto_init
class EventNotification(SqlAlchemyBase, BaseMixins):
__tablename__ = "event_notifications"
id = Column(Integer, primary_key=True)
name = Column(String)
type = Column(String)
notification_url = Column(String)
# Event Types
general = Column(Boolean, default=False)
recipe = Column(Boolean, default=False)
backup = Column(Boolean, default=False)
scheduled = Column(Boolean, default=False)
migration = Column(Boolean, default=False)
group = Column(Boolean, default=False)
user = Column(Boolean, default=False)
@auto_init()
def __init__(self, **_) -> None:
pass
class Event(SqlAlchemyBase, BaseMixins):
__tablename__ = "events"
id = Column(Integer, primary_key=True)

View file

@ -1,4 +1,5 @@
from .cookbook import *
from .events import *
from .exports import *
from .group import *
from .invite_tokens import *

View 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

View file

@ -1,5 +1,3 @@
import uuid
import sqlalchemy as sa
import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session
@ -22,7 +20,7 @@ settings = get_app_settings()
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id = sa.Column(GUID, primary_key=True, default=uuid.uuid4)
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
@ -57,6 +55,7 @@ class Group(SqlAlchemyBase, BaseMixins):
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
shopping_lists = orm.relationship("ShoppingList", **common_args)
group_reports = orm.relationship("ReportModel", **common_args)
group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args)
class Config:
exclude = {

View file

@ -2,9 +2,10 @@ from functools import cached_property
from sqlalchemy.orm import Session
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.event import Event
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.events import GroupEventNotifierModel
from mealie.db.models.group.exports import GroupDataExportsModel
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel
@ -24,7 +25,7 @@ from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_events import GroupEventNotifierOut
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
@ -116,10 +117,6 @@ class AllRepositories:
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut)
@cached_property
def event_notifications(self) -> RepositoryGeneric[EventNotificationIn, EventNotification]:
return RepositoryGeneric(self.session, pk_id, EventNotification, EventNotificationIn)
@cached_property
def events(self) -> RepositoryGeneric[EventSchema, Event]:
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
@ -193,3 +190,7 @@ class AllRepositories:
@cached_property
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
@cached_property
def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
return RepositoryGeneric(self.session, pk_id, GroupEventNotifierModel, GroupEventNotifierOut)

View file

@ -70,6 +70,8 @@ class RepositoryGeneric(Generic[T, D]):
def get_all(self, limit: int = None, order_by: str = None, start=0, override_schema=None) -> list[T]:
eff_schema = override_schema or self.schema
filter = self._filter_builder()
order_attr = None
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
@ -77,10 +79,18 @@ class RepositoryGeneric(Generic[T, D]):
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model).order_by(order_attr).offset(start).limit(limit).all()
for x in self.session.query(self.sql_model)
.order_by(order_attr)
.filter_by(**filter)
.offset(start)
.limit(limit)
.all()
]
return [eff_schema.from_orm(x) for x in self.session.query(self.sql_model).offset(start).limit(limit).all()]
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model).filter_by(**filter).offset(start).limit(limit).all()
]
def multi_query(
self,
@ -92,6 +102,8 @@ class RepositoryGeneric(Generic[T, D]):
) -> list[T]:
eff_schema = override_schema or self.schema
filer = self._filter_builder(**query_by)
order_attr = None
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
@ -100,7 +112,7 @@ class RepositoryGeneric(Generic[T, D]):
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model)
.filter_by(**query_by)
.filter_by(**filer)
.order_by(order_attr)
.offset(start)
.limit(limit)

View file

@ -76,6 +76,17 @@ class CrudMixins:
return item
def get_one(self, item_id):
item = self.repo.get(item_id)
if not item:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=ErrorResponse.respond(message="Not found."),
)
return item
def update_one(self, data, item_id):
item = self.repo.get(item_id)
@ -98,11 +109,11 @@ class CrudMixins:
self.handle_exception(ex)
def delete_one(self, item_id):
item = self.repo.get(item_id)
self.logger.info(f"Deleting item with id {item}")
self.logger.info(f"Deleting item with id {item_id}")
try:
item = self.repo.delete(item)
item = self.repo.delete(item_id)
self.logger.info(item)
except Exception as ex:
self.handle_exception(ex)

View file

@ -1,8 +1,7 @@
from fastapi import APIRouter
from . import events, notifications
from . import events
about_router = APIRouter(prefix="/api/about")
about_router.include_router(events.router, tags=["Events: CRUD"])
about_router.include_router(notifications.router, tags=["Events: Notifications"])

View file

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

View file

@ -8,7 +8,7 @@ from mealie.services.group_services import CookbookService, WebhookService
from mealie.services.group_services.meal_service import MealService
from mealie.services.group_services.reports_service import GroupReportService
from . import categories, invitations, labels, migrations, preferences, self_service, shopping_lists
from . import categories, invitations, labels, migrations, notifications, preferences, self_service, shopping_lists
router = APIRouter()
@ -56,3 +56,4 @@ def get_all_reports(
router.include_router(report_router)
router.include_router(shopping_lists.router)
router.include_router(labels.router)
router.include_router(notifications.router)

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

View file

@ -17,6 +17,8 @@ from mealie.schema.group.group_shopping_list import (
)
from mealie.schema.mapper import cast
from mealie.schema.query import GetAll
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.message_types import EventTypes
from mealie.services.group_services.shopping_lists import ShoppingListService
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
@ -26,6 +28,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
class ShoppingListRoutes:
deps: SharedDependencies = Depends(SharedDependencies.user)
service: ShoppingListService = Depends(ShoppingListService.private)
event_bus: EventBusService = Depends(EventBusService)
@cached_property
def repo(self):
@ -56,7 +59,16 @@ class ShoppingListRoutes:
@router.post("", response_model=ShoppingListOut)
def create_one(self, data: ShoppingListCreate):
save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id)
return self.mixins.create_one(save_data)
val = self.mixins.create_one(save_data)
if val:
self.event_bus.dispatch(
self.deps.acting_user.group_id,
EventTypes.shopping_list_created,
msg="A new shopping list has been created.",
)
return val
@router.get("/{item_id}", response_model=ShoppingListOut)
def get_one(self, item_id: UUID4):

View file

@ -1,3 +1,4 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .about import *
from .backup import *
from .migration import *

View file

@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .cookbook import *

View file

@ -1,2 +1,2 @@
from .event_notifications import *
# GENERATED CODE - DO NOT MODIFY BY HAND
from .events import *

View file

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

View file

@ -1,2 +1,10 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .group import *
from .group_events import *
from .group_exports import *
from .group_migration import *
from .group_permissions import *
from .group_preferences import *
from .group_shopping_list import *
from .invite_token import *
from .webhook import *

View 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

View file

@ -1,36 +1,2 @@
from fastapi_camelcase import CamelModel
from pydantic import UUID4
from mealie.schema.recipe import IngredientFood
class MultiPurposeLabelCreate(CamelModel):
name: str
class MultiPurposeLabelSave(MultiPurposeLabelCreate):
group_id: UUID4
class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
id: UUID4
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
pass
class Config:
orm_mode = True
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
shopping_list_items: "list[ShoppingListItemOut]" = []
foods: list[IngredientFood] = []
class Config:
orm_mode = True
from mealie.schema.group.group_shopping_list import ShoppingListItemOut
MultiPurposeLabelOut.update_forward_refs()
# GENERATED CODE - DO NOT MODIFY BY HAND
from .multi_purpose_label import *

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

View file

@ -1,3 +1,4 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .meal import *
from .new_meal import *
from .shopping_list import *

View file

@ -1,7 +1,15 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .recipe import *
from .recipe_asset import *
from .recipe_bulk_actions import *
from .recipe_category import *
from .recipe_comments import *
from .recipe_image_types import *
from .recipe_ingredient import *
from .recipe_notes import *
from .recipe_nutrition import *
from .recipe_settings import *
from .recipe_share_token import *
from .recipe_step import *
from .recipe_tool import *
from .request_helpers import *

View file

@ -2,7 +2,7 @@ import enum
from fastapi_camelcase import CamelModel
from . import CategoryBase, TagBase
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
class ExportTypes(str, enum.Enum):

View file

@ -1,5 +1,3 @@
from typing import List
from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict
@ -23,7 +21,7 @@ class CategoryBase(CategoryIn):
class RecipeCategoryResponse(CategoryBase):
recipes: List["Recipe"] = []
recipes: "list[Recipe]" = []
class Config:
orm_mode = True
@ -42,7 +40,7 @@ class RecipeTagResponse(RecipeCategoryResponse):
pass
from . import Recipe
from mealie.schema.recipe.recipe import Recipe
RecipeCategoryResponse.update_forward_refs()
RecipeTagResponse.update_forward_refs()

View file

@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .reports import *

View file

@ -1,17 +1,2 @@
from typing import Optional
from pydantic import BaseModel
class ErrorResponse(BaseModel):
message: str
error: bool = True
exception: Optional[str] = None
@classmethod
def respond(cls, message: str, exception: Optional[str] = None) -> dict:
"""
This method is an helper to create an obect and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message, exception=exception).dict()
# GENERATED CODE - DO NOT MODIFY BY HAND
from .error_response import *

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

View file

@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .tasks import *

View file

@ -1,3 +1,6 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .auth import *
from .registration import *
from .sign_up import *
from .user import *
from .user_passwords import *

View file

@ -9,8 +9,7 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs
from mealie.repos.all_repositories import get_repositories
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
from mealie.schema.events import EventNotificationIn
from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport
from mealie.schema.recipe import Recipe, RecipeCommentOut
from mealie.schema.user import PrivateUser, UpdateGroup
from mealie.services.image import minify
@ -159,24 +158,6 @@ class ImportDatabase:
minify.migrate_images()
def import_notifications(self):
notify_file = self.import_dir.joinpath("notifications", "notifications.json")
notifications = ImportDatabase.read_models_file(notify_file, EventNotificationIn)
import_notifications = []
for notify in notifications:
import_status = self.import_model(
db_table=self.db.event_notifications,
model=notify,
return_model=NotificationImport,
name_attr="name",
search_key="notification_url",
)
import_notifications.append(import_status)
return import_notifications
def import_settings(self):
return []
@ -330,11 +311,6 @@ def import_database(
user_report = import_session.import_users()
notification_report = []
if import_notifications:
notification_report = import_session.import_notifications()
# if import_recipes:
# import_session.import_comments()
import_session.clean_up()

View 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],
)

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

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

View file

@ -1,4 +1,3 @@
import apprise
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import create_session
@ -6,44 +5,12 @@ from mealie.repos.all_repositories import get_repositories
from mealie.schema.events import Event, EventCategory
def test_notification(notification_url, event=None) -> bool:
if event is None:
event = Event(
title="Test Notification",
text="This is a test message from the Mealie API server",
category=EventCategory.general.value,
)
post_notifications(event, [notification_url], hard_fail=True)
def post_notifications(event: Event, notification_urls=list[str], hard_fail=False, attachment=None):
asset = apprise.AppriseAsset(async_mode=False)
apobj = apprise.Apprise(asset=asset)
for dest in notification_urls:
status = apobj.add(dest)
if not status and hard_fail:
raise Exception("Apprise URL Add Failed")
apobj.notify(
body=event.text,
title=event.title,
attach=str(attachment),
)
def save_event(title, text, category, session: Session, attachment=None):
def save_event(title, text, category, session: Session):
event = Event(title=title, text=text, category=category)
session = session or create_session()
db = get_repositories(session)
db.events.create(event.dict())
notification_objects = db.event_notifications.get(match_value=True, match_key=category, limit=9999)
notification_urls = [x.notification_url for x in notification_objects]
post_notifications(event, notification_urls, attachment=attachment)
def create_general_event(title, text, session=None):
category = EventCategory.general
@ -52,8 +19,7 @@ def create_general_event(title, text, session=None):
def create_recipe_event(title, text, session=None, attachment=None):
category = EventCategory.recipe
save_event(title=title, text=text, category=category, session=session, attachment=attachment)
save_event(title=title, text=text, category=category, session=session)
def create_backup_event(title, text, session=None):

610
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,13 +9,13 @@ license = "MIT"
start = "mealie.app:main"
[tool.poetry.dependencies]
python = "^3.9"
python = "^3.10"
aiofiles = "0.5.0"
aniso8601 = "7.0.0"
appdirs = "1.4.4"
fastapi = "^0.71.0"
uvicorn = {extras = ["standard"], version = "^0.13.0"}
APScheduler = "^3.6.3"
APScheduler = "^3.8.1"
SQLAlchemy = "^1.4.29"
Jinja2 = "^2.11.2"
python-dotenv = "^0.15.0"
@ -28,10 +28,10 @@ fastapi-camelcase = "^1.0.2"
bcrypt = "^3.2.0"
python-jose = "^3.3.0"
passlib = "^1.7.4"
lxml = "4.6.2"
lxml = "^4.7.1"
Pillow = "^8.2.0"
pathvalidate = "^2.4.1"
apprise = "0.9.3"
apprise = "^0.9.6"
recipe-scrapers = "^13.5.0"
psycopg2-binary = {version = "^2.9.1", optional = true}
gunicorn = "^20.1.0"
@ -39,20 +39,20 @@ emails = "^0.6"
python-i18n = "^0.3.9"
python-ldap = "^3.3.1"
pydantic = "^1.9.0"
tzdata = "^2021.5"
[tool.poetry.dev-dependencies]
pylint = "^2.6.0"
pytest = "^6.2.1"
pytest-cov = "^2.11.0"
mkdocs-material = "^7.0.2"
flake8 = "^3.9.0"
flake8 = "^4.0.1"
coverage = "^5.5"
pydantic-to-typescript = "^1.0.7"
rich = "^10.7.0"
isort = "^5.9.3"
regex = "2021.9.30" # TODO: Remove during Upgrade -> https://github.com/psf/black/issues/2524
flake8-print = "^4.0.0"
black = "^21.11b1"
black = "^21.12b0"
[build-system]
requires = ["poetry-core>=1.0.0"]

View file

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