mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: mealplan-webhooks (#1403)
* fix type errors on event bus * webhooks fields required for new implementation * db migration * wip: webhook query + tests and stub function * ignore type checker error * type and method cleanup * datetime and time utc validator * update testing code for utc scheduled time * fix file cmp function call * update version_number * add support for translating "time" objects when restoring backup * bump recipe-scrapers * use specific import syntax * generate frontend types * utilize names exports * use utc times * add task to scheduler * implement new scheduler functionality * stub for type annotation * implement meal-plan data getter * add experimental banner
This commit is contained in:
parent
b1256f4ad2
commit
5a053cdcd6
@ -0,0 +1,31 @@
|
||||
"""add new webhook fields
|
||||
|
||||
|
||||
Revision ID: f30cf048c228
|
||||
Revises: ab0bae02578f
|
||||
Create Date: 2022-06-15 21:05:34.851857
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f30cf048c228"
|
||||
down_revision = "ab0bae02578f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("webhook_urls", sa.Column("webhook_type", sa.String(), nullable=True))
|
||||
op.add_column("webhook_urls", sa.Column("scheduled_time", sa.Time(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("webhook_urls", "scheduled_time")
|
||||
op.drop_column("webhook_urls", "webhook_type")
|
||||
# ### end Alembic commands ###
|
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text>
|
||||
<v-switch v-model="webhookCopy.enabled" label="Enabled"></v-switch>
|
||||
<v-text-field v-model="webhookCopy.name" label="Webhook Name"></v-text-field>
|
||||
<v-text-field v-model="webhookCopy.url" label="Webhook Url"></v-text-field>
|
||||
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 justify-end">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.testTube,
|
||||
text: $tc('general.test'),
|
||||
event: 'test',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $tc('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@delete="$emit('delete', webhookCopy.id)"
|
||||
@save="handleSave"
|
||||
@test="$emit('test', webhookCopy.id)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { ReadWebhook } from "~/types/api-types/group";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object as () => ReadWebhook,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["delete", "save", "test"],
|
||||
setup(props, { emit }) {
|
||||
const itemUTC = ref(props.webhook.scheduledTime);
|
||||
const itemLocal = ref(timeUTCToLocal(props.webhook.scheduledTime));
|
||||
|
||||
const scheduledTime = computed({
|
||||
get() {
|
||||
return itemLocal.value;
|
||||
},
|
||||
set(v) {
|
||||
itemUTC.value = timeLocalToUTC(v);
|
||||
itemLocal.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
const webhookCopy = ref({ ...props.webhook });
|
||||
|
||||
function handleSave() {
|
||||
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||
emit("save", webhookCopy.value);
|
||||
}
|
||||
|
||||
return {
|
||||
webhookCopy,
|
||||
scheduledTime,
|
||||
handleSave,
|
||||
itemUTC,
|
||||
itemLocal,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$tc("settings.webhooks.webhooks"),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -37,7 +37,7 @@ export const useGroupWebhooks = function () {
|
||||
enabled: false,
|
||||
name: "New Webhook",
|
||||
url: "",
|
||||
time: "00:00",
|
||||
scheduledTime: "00:00",
|
||||
};
|
||||
|
||||
const { data } = await api.groupWebhooks.createOne(payload);
|
||||
@ -52,8 +52,23 @@ export const useGroupWebhooks = function () {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to UTC time
|
||||
const [hours, minutes] = updateData.scheduledTime.split(":");
|
||||
|
||||
const newDt = new Date();
|
||||
newDt.setHours(Number(hours));
|
||||
newDt.setMinutes(Number(minutes));
|
||||
|
||||
updateData.scheduledTime = `${pad(newDt.getUTCHours(), 2)}:${pad(newDt.getUTCMinutes(), 2)}`;
|
||||
console.log(updateData.scheduledTime);
|
||||
|
||||
const payload = {
|
||||
...updateData,
|
||||
scheduledTime: updateData.scheduledTime,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.groupWebhooks.updateOne(updateData.id, updateData);
|
||||
const { data } = await api.groupWebhooks.updateOne(updateData.id, payload);
|
||||
if (data) {
|
||||
this.refreshAll();
|
||||
}
|
||||
@ -73,3 +88,25 @@ export const useGroupWebhooks = function () {
|
||||
|
||||
return { webhooks, actions, validForm };
|
||||
};
|
||||
|
||||
function pad(num: number, size: number) {
|
||||
let numStr = num.toString();
|
||||
while (numStr.length < size) numStr = "0" + numStr;
|
||||
return numStr;
|
||||
}
|
||||
|
||||
export function timeUTCToLocal(time: string): string {
|
||||
const [hours, minutes] = time.split(":");
|
||||
const dt = new Date();
|
||||
dt.setUTCMinutes(Number(minutes));
|
||||
dt.setUTCHours(Number(hours));
|
||||
return `${pad(dt.getHours(), 2)}:${pad(dt.getMinutes(), 2)}`;
|
||||
}
|
||||
|
||||
export function timeLocalToUTC(time: string) {
|
||||
const [hours, minutes] = time.split(":");
|
||||
const dt = new Date();
|
||||
dt.setHours(Number(hours));
|
||||
dt.setMinutes(Number(minutes));
|
||||
return `${pad(dt.getUTCHours(), 2)}:${pad(dt.getUTCMinutes(), 2)}`;
|
||||
}
|
||||
|
@ -5,10 +5,16 @@
|
||||
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-webhooks.svg')"></v-img>
|
||||
</template>
|
||||
<template #title> Webhooks </template>
|
||||
The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the webhooks
|
||||
will be sent with the data from the recipe that is scheduled for the day
|
||||
<v-card-text class="pb-0">
|
||||
The webhooks defined below will be executed when a meal is defined for the day. At the scheduled time the
|
||||
webhooks will be sent with the data from the recipe that is scheduled for the day. Note that webhook execution
|
||||
is not exact. The webhooks are executed on a 5 minutes interval so the webhooks will be executed within 5 +/-
|
||||
minutes of the scheduled.
|
||||
</v-card-text>
|
||||
</BasePageTitle>
|
||||
|
||||
<BannerExperimental />
|
||||
|
||||
<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 left-border rounded">
|
||||
@ -17,7 +23,7 @@
|
||||
<v-icon large left :color="webhook.enabled ? 'info' : null">
|
||||
{{ $globals.icons.webhook }}
|
||||
</v-icon>
|
||||
{{ webhook.name }} - {{ webhook.time }}
|
||||
{{ webhook.name }} - {{ timeDisplay(timeUTCToLocal(webhook.scheduledTime)) }}
|
||||
</div>
|
||||
<template #actions>
|
||||
<v-btn small icon class="ml-2">
|
||||
@ -28,35 +34,12 @@
|
||||
</template>
|
||||
</v-expansion-panel-header>
|
||||
<v-expansion-panel-content>
|
||||
<v-card-text>
|
||||
<v-switch v-model="webhook.enabled" label="Enabled"></v-switch>
|
||||
<v-text-field v-model="webhook.name" label="Webhook Name"></v-text-field>
|
||||
<v-text-field v-model="webhook.url" label="Webhook Url"></v-text-field>
|
||||
<v-time-picker v-model="webhook.time" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||
</v-card-text>
|
||||
<v-card-actions 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>
|
||||
<GroupWebhookEditor
|
||||
:key="webhook.id"
|
||||
:webhook="webhook"
|
||||
@save="actions.updateOne($event)"
|
||||
@delete="actions.deleteOne($event)"
|
||||
/>
|
||||
</v-expansion-panel-content>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
@ -65,15 +48,28 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useGroupWebhooks } from "~/composables/use-group-webhooks";
|
||||
import { useGroupWebhooks, timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
import GroupWebhookEditor from "~/components/Domain/Group/GroupWebhookEditor.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { GroupWebhookEditor },
|
||||
setup() {
|
||||
const { actions, webhooks } = useGroupWebhooks();
|
||||
function timeDisplay(time: string): string {
|
||||
// returns the time in the format HH:MM AM/PM
|
||||
const [hours, minutes] = time.split(":");
|
||||
const ampm = Number(hours) < 12 ? "AM" : "PM";
|
||||
const hour = Number(hours) % 12 || 12;
|
||||
const minute = minutes.padStart(2, "0");
|
||||
return `${hour}:${minute} ${ampm}`;
|
||||
}
|
||||
|
||||
return {
|
||||
webhooks,
|
||||
actions,
|
||||
timeLocalToUTC,
|
||||
timeUTCToLocal,
|
||||
timeDisplay,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
@ -5,6 +5,7 @@
|
||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||
*/
|
||||
|
||||
export type WebhookType = "mealplan";
|
||||
export type SupportedMigrations = "nextcloud" | "chowdown" | "paprika" | "mealie_alpha";
|
||||
|
||||
export interface CreateGroupPreferences {
|
||||
@ -25,7 +26,8 @@ export interface CreateWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
time?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface DataMigrationCreate {
|
||||
sourceType: SupportedMigrations;
|
||||
@ -231,7 +233,8 @@ export interface ReadWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
time?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
@ -304,7 +307,8 @@ export interface SaveWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
time?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
}
|
||||
export interface SeederConfig {
|
||||
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
@ -18,6 +18,7 @@ import DevDumpJson from "@/components/global/DevDumpJson.vue";
|
||||
import LanguageDialog from "@/components/global/LanguageDialog.vue";
|
||||
import InputQuantity from "@/components/global/InputQuantity.vue";
|
||||
import ToggleState from "@/components/global/ToggleState.vue";
|
||||
import ContextMenu from "@/components/global/ContextMenu.vue";
|
||||
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
||||
import CrudTable from "@/components/global/CrudTable.vue";
|
||||
import InputColor from "@/components/global/InputColor.vue";
|
||||
@ -55,6 +56,7 @@ declare module "vue" {
|
||||
LanguageDialog: typeof LanguageDialog;
|
||||
InputQuantity: typeof InputQuantity;
|
||||
ToggleState: typeof ToggleState;
|
||||
ContextMenu: typeof ContextMenu;
|
||||
AppButtonCopy: typeof AppButtonCopy;
|
||||
CrudTable: typeof CrudTable;
|
||||
InputColor: typeof InputColor;
|
||||
|
@ -57,6 +57,10 @@ async def start_scheduler():
|
||||
tasks.purge_group_data_exports,
|
||||
)
|
||||
|
||||
SchedulerRegistry.register_minutely(
|
||||
tasks.post_group_webhooks,
|
||||
)
|
||||
|
||||
SchedulerRegistry.print_jobs()
|
||||
|
||||
await SchedulerService.start()
|
||||
|
@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, Time, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
@ -14,8 +16,15 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
enabled = Column(Boolean, default=False)
|
||||
name = Column(String)
|
||||
url = Column(String)
|
||||
|
||||
# New Fields
|
||||
webhook_type = Column(String, default="") # Future use for different types of webhooks
|
||||
scheduled_time = Column(Time, default=lambda: datetime.now().time())
|
||||
|
||||
# Columne is no longer used but is kept for since it's super annoying to
|
||||
# delete a column in SQLite and it's not a big deal to keep it around
|
||||
time = Column(String, default="00:00")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
...
|
||||
|
@ -8,6 +8,9 @@ from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
|
||||
def by_group(self, group_id: UUID) -> "RepositoryMeals":
|
||||
return super().by_group(group_id) # type: ignore
|
||||
|
||||
def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]:
|
||||
start_str = start.strftime("%Y-%m-%d")
|
||||
end_str = end.strftime("%Y-%m-%d")
|
||||
|
@ -9,4 +9,4 @@ from .group_seeder import *
|
||||
from .group_shopping_list import *
|
||||
from .group_statistics import *
|
||||
from .invite_token import *
|
||||
from .webhook import *
|
||||
from .webhook import * # type: ignore
|
||||
|
@ -1,15 +1,51 @@
|
||||
import datetime
|
||||
import enum
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4
|
||||
from isodate import parse_time
|
||||
from pydantic import UUID4, validator
|
||||
from pydantic.datetime_parse import parse_datetime
|
||||
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
|
||||
class WebhookType(str, enum.Enum):
|
||||
mealplan = "mealplan"
|
||||
|
||||
|
||||
class CreateWebhook(MealieModel):
|
||||
enabled: bool = True
|
||||
name: str = ""
|
||||
url: str = ""
|
||||
time: str = "00:00"
|
||||
|
||||
webhook_type: WebhookType = WebhookType.mealplan
|
||||
scheduled_time: datetime.time
|
||||
|
||||
@validator("scheduled_time", pre=True)
|
||||
@classmethod
|
||||
def validate_scheduled_time(cls, v):
|
||||
"""
|
||||
Validator accepts both datetime and time values from external sources.
|
||||
DateTime types are parsed and converted to time objects without timezones
|
||||
|
||||
type: time is treated as a UTC value
|
||||
type: datetime is treated as a value with a timezone
|
||||
"""
|
||||
parser_funcs = [
|
||||
lambda x: parse_datetime(x).astimezone(datetime.timezone.utc).time(),
|
||||
parse_time,
|
||||
]
|
||||
|
||||
if isinstance(v, datetime.time):
|
||||
return v
|
||||
|
||||
for parser_func in parser_funcs:
|
||||
try:
|
||||
return parser_func(v)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
raise ValueError(f"Invalid scheduled time: {v}")
|
||||
|
||||
|
||||
class SaveWebhook(CreateWebhook):
|
||||
|
@ -18,10 +18,12 @@ class AlchemyExporter(BaseService):
|
||||
|
||||
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at"}
|
||||
look_for_date = {"date_added", "date"}
|
||||
look_for_time = {"scheduled_time"}
|
||||
|
||||
class DateTimeParser(BaseModel):
|
||||
date: datetime.date = None
|
||||
time: datetime.datetime = None
|
||||
dt: datetime.datetime = None
|
||||
time: datetime.time = None
|
||||
|
||||
def __init__(self, connection_str: str) -> None:
|
||||
super().__init__()
|
||||
@ -44,10 +46,11 @@ class AlchemyExporter(BaseService):
|
||||
data[key] = [AlchemyExporter.convert_to_datetime(item) for item in value]
|
||||
elif isinstance(value, str):
|
||||
if key in AlchemyExporter.look_for_datetime:
|
||||
data[key] = AlchemyExporter.DateTimeParser(time=value).time
|
||||
data[key] = AlchemyExporter.DateTimeParser(dt=value).dt
|
||||
if key in AlchemyExporter.look_for_date:
|
||||
data[key] = AlchemyExporter.DateTimeParser(date=value).date
|
||||
|
||||
if key in AlchemyExporter.look_for_time:
|
||||
data[key] = AlchemyExporter.DateTimeParser(time=value).time
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
@ -37,7 +37,7 @@ class EventBusService:
|
||||
self.bg = bg
|
||||
self._publisher = ApprisePublisher
|
||||
self.session = session
|
||||
self.group_id = None
|
||||
self.group_id: UUID4 | None = None
|
||||
|
||||
@property
|
||||
def publisher(self) -> PublisherLike:
|
||||
@ -55,7 +55,7 @@ class EventBusService:
|
||||
def dispatch(
|
||||
self, group_id: UUID4, event_type: EventTypes, msg: str = "", event_source: EventSource = None
|
||||
) -> None:
|
||||
self.group_id = group_id # type: ignore
|
||||
self.group_id = group_id
|
||||
|
||||
def _dispatch(event_source: EventSource = None):
|
||||
if urls := self.get_urls(event_type):
|
||||
|
@ -1,6 +1,14 @@
|
||||
from .purge_group_exports import *
|
||||
from .purge_password_reset import *
|
||||
from .purge_registration import *
|
||||
from .post_webhooks import post_group_webhooks
|
||||
from .purge_group_exports import purge_group_data_exports
|
||||
from .purge_password_reset import purge_password_reset_tokens
|
||||
from .purge_registration import purge_group_registration
|
||||
|
||||
__all__ = [
|
||||
"post_group_webhooks",
|
||||
"purge_password_reset_tokens",
|
||||
"purge_group_data_exports",
|
||||
"purge_group_registration",
|
||||
]
|
||||
|
||||
"""
|
||||
Tasks Package
|
||||
|
54
mealie/services/scheduler/tasks/post_webhooks.py
Normal file
54
mealie/services/scheduler/tasks/post_webhooks.py
Normal file
@ -0,0 +1,54 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import UUID4
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from mealie.db.db_setup import create_session
|
||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
|
||||
last_ran = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def get_scheduled_webhooks(session: Session, bottom: datetime, top: datetime) -> list[GroupWebhooksModel]:
|
||||
"""
|
||||
get_scheduled_webhooks queries the database for all webhooks scheduled between the bottom and
|
||||
top time ranges. It returns a list of GroupWebhooksModel objects.
|
||||
"""
|
||||
|
||||
return (
|
||||
session.query(GroupWebhooksModel)
|
||||
.where(
|
||||
GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison
|
||||
GroupWebhooksModel.scheduled_time > bottom.astimezone(timezone.utc).time(),
|
||||
GroupWebhooksModel.scheduled_time <= top.astimezone(timezone.utc).time(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def post_group_webhooks() -> None:
|
||||
global last_ran
|
||||
session = create_session()
|
||||
results = get_scheduled_webhooks(session, last_ran, datetime.now())
|
||||
|
||||
last_ran = datetime.now(timezone.utc)
|
||||
|
||||
repos = get_repositories(session)
|
||||
|
||||
memo = {}
|
||||
|
||||
def get_meals(group_id: UUID4):
|
||||
if group_id not in memo:
|
||||
memo[group_id] = repos.meals.get_all(group_id=group_id)
|
||||
return memo[group_id]
|
||||
|
||||
for result in results:
|
||||
meals = get_meals(result.group_id)
|
||||
|
||||
if not meals:
|
||||
continue
|
||||
|
||||
requests.post(result.url, json=jsonable_encoder(meals))
|
8
poetry.lock
generated
8
poetry.lock
generated
@ -1188,7 +1188,7 @@ rdflib = ">=5.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "recipe-scrapers"
|
||||
version = "14.3.0"
|
||||
version = "14.3.1"
|
||||
description = "Python package, scraping recipes from all over the internet"
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -1545,7 +1545,7 @@ pgsql = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "3a4e90f6b5b8a7ff46824949e7269f0d95905aa0351ff8478f096c97113ce855"
|
||||
content-hash = "3eb07af7a1e1a96c0c308f2263258c27332f51454e3d8ba2e8dbb821d46236ca"
|
||||
|
||||
[metadata.files]
|
||||
aiofiles = [
|
||||
@ -2437,8 +2437,8 @@ rdflib-jsonld = [
|
||||
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
||||
]
|
||||
recipe-scrapers = [
|
||||
{file = "recipe_scrapers-14.3.0-py3-none-any.whl", hash = "sha256:ff3344b741999671ec0aa74482f10aaac63ffc95b02fc6efbc853b3e3cfe6805"},
|
||||
{file = "recipe_scrapers-14.3.0.tar.gz", hash = "sha256:9954e01af9cfe2b3c08e3103ef8b4aae4c24257be2cb37711430365ffe57e055"},
|
||||
{file = "recipe_scrapers-14.3.1-py3-none-any.whl", hash = "sha256:0f15aee46dfc071627c5af8d068b0b2076f13c4525042e3e64455a5c43b49a80"},
|
||||
{file = "recipe_scrapers-14.3.1.tar.gz", hash = "sha256:547ffd03aa9b8060f6167d7ee5dd5196acf4f5b31dbed3ab921d9051cc74201d"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||
|
@ -30,7 +30,7 @@ passlib = "^1.7.4"
|
||||
lxml = "^4.7.1"
|
||||
Pillow = "^8.2.0"
|
||||
apprise = "^0.9.6"
|
||||
recipe-scrapers = "^14.3.0"
|
||||
recipe-scrapers = "^14.3.1"
|
||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||
gunicorn = "^20.1.0"
|
||||
emails = "^0.6"
|
||||
|
@ -1,10 +1,11 @@
|
||||
import contextlib
|
||||
from collections.abc import Generator
|
||||
|
||||
from pytest import MonkeyPatch, fixture
|
||||
|
||||
mp = MonkeyPatch()
|
||||
mp.setenv("PRODUCTION", "True")
|
||||
mp.setenv("TESTING", "True")
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@ -34,11 +35,9 @@ def api_client():
|
||||
|
||||
yield TestClient(app)
|
||||
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
settings = config.get_app_settings()
|
||||
settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
@ -52,16 +51,13 @@ def test_image_png():
|
||||
|
||||
|
||||
@fixture(scope="session", autouse=True)
|
||||
def global_cleanup() -> None:
|
||||
def global_cleanup() -> Generator[None, None, None]:
|
||||
"""Purges the .temp directory used for testing"""
|
||||
yield None
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
temp_dir = Path(__file__).parent / ".temp"
|
||||
|
||||
if temp_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
@ -1,71 +1,74 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils import assert_derserialize, jsonify
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
class Routes:
|
||||
base = "/api/groups/webhooks"
|
||||
|
||||
@staticmethod
|
||||
def item(item_id: int) -> str:
|
||||
return f"{Routes.base}/{item_id}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def webhook_data():
|
||||
return {"enabled": True, "name": "Test-Name", "url": "https://my-fake-url.com", "time": "00:00"}
|
||||
return {
|
||||
"enabled": True,
|
||||
"name": "Test-Name",
|
||||
"url": "https://my-fake-url.com",
|
||||
"time": "00:00",
|
||||
"scheduledTime": datetime.now(),
|
||||
}
|
||||
|
||||
|
||||
def test_create_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
||||
|
||||
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_read_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
|
||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
||||
id = response.json()["id"]
|
||||
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||
item_id = response.json()["id"]
|
||||
|
||||
response = api_client.get(Routes.item(id), headers=unique_user.token)
|
||||
response = api_client.get(Routes.item(item_id), headers=unique_user.token)
|
||||
webhook = assert_derserialize(response, 200)
|
||||
|
||||
webhook = response.json()
|
||||
|
||||
assert webhook["id"]
|
||||
assert webhook["id"] == item_id
|
||||
assert webhook["name"] == webhook_data["name"]
|
||||
assert webhook["url"] == webhook_data["url"]
|
||||
assert webhook["time"] == webhook_data["time"]
|
||||
assert webhook["scheduledTime"] == str(webhook_data["scheduledTime"].astimezone(timezone.utc).time())
|
||||
assert webhook["enabled"] == webhook_data["enabled"]
|
||||
|
||||
|
||||
def test_update_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
||||
id = response.json()["id"]
|
||||
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||
item_dict = assert_derserialize(response, 201)
|
||||
item_id = item_dict["id"]
|
||||
|
||||
webhook_data["name"] = "My New Name"
|
||||
webhook_data["url"] = "https://my-new-fake-url.com"
|
||||
webhook_data["time"] = "01:00"
|
||||
webhook_data["enabled"] = False
|
||||
|
||||
response = api_client.put(Routes.item(id), json=webhook_data, headers=unique_user.token)
|
||||
response = api_client.put(Routes.item(item_id), json=jsonify(webhook_data), headers=unique_user.token)
|
||||
updated_webhook = assert_derserialize(response, 200)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
updated_webhook = response.json()
|
||||
assert updated_webhook["name"] == webhook_data["name"]
|
||||
assert updated_webhook["url"] == webhook_data["url"]
|
||||
assert updated_webhook["time"] == webhook_data["time"]
|
||||
assert updated_webhook["enabled"] == webhook_data["enabled"]
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_delete_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
|
||||
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
|
||||
id = response.json()["id"]
|
||||
|
||||
response = api_client.delete(Routes.item(id), headers=unique_user.token)
|
||||
response = api_client.post(Routes.base, json=jsonify(webhook_data), headers=unique_user.token)
|
||||
item_dict = assert_derserialize(response, 201)
|
||||
item_id = item_dict["id"]
|
||||
|
||||
response = api_client.delete(Routes.item(item_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(Routes.item(id), headers=unique_user.token)
|
||||
response = api_client.get(Routes.item(item_id), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
|
||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||
|
||||
ALEMBIC_VERSIONS = [
|
||||
{"version_num": "ab0bae02578f"},
|
||||
{"version_num": "f30cf048c228"},
|
||||
]
|
||||
|
||||
|
||||
|
@ -22,7 +22,7 @@ def match_file_tree(path_a: Path, path_b: Path):
|
||||
assert b_file.exists()
|
||||
match_file_tree(a_file, b_file)
|
||||
else:
|
||||
assert filecmp(path_a, path_b)
|
||||
assert filecmp.cmp(path_a, path_b)
|
||||
|
||||
|
||||
def test_database_backup():
|
||||
|
@ -0,0 +1,65 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.group.webhook import SaveWebhook, WebhookType
|
||||
from mealie.services.scheduler.tasks.post_webhooks import get_scheduled_webhooks
|
||||
from tests.utils import random_string
|
||||
from tests.utils.factories import random_bool
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
def webhook_factory(
|
||||
group_id: str | UUID4,
|
||||
enabled: bool = True,
|
||||
name: str = "",
|
||||
url: str = "",
|
||||
scheduled_time: datetime | None = None,
|
||||
webhook_type: str = WebhookType.mealplan,
|
||||
) -> SaveWebhook:
|
||||
return SaveWebhook(
|
||||
enabled=enabled,
|
||||
name=name or random_string(),
|
||||
url=url or random_string(),
|
||||
webhook_type=webhook_type,
|
||||
scheduled_time=scheduled_time.time() if scheduled_time else datetime.now().time(),
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_user: TestUser):
|
||||
"""
|
||||
get_scheduled_webhooks_test tests the get_scheduled_webhooks function.
|
||||
"""
|
||||
|
||||
expected: list[SaveWebhook] = []
|
||||
|
||||
start = datetime.now()
|
||||
|
||||
for _ in range(5):
|
||||
new_item = webhook_factory(group_id=unique_user.group_id, enabled=random_bool())
|
||||
out_of_range_item = webhook_factory(
|
||||
group_id=unique_user.group_id,
|
||||
enabled=random_bool(),
|
||||
scheduled_time=(start - timedelta(minutes=20)),
|
||||
)
|
||||
|
||||
database.webhooks.create(new_item)
|
||||
database.webhooks.create(out_of_range_item)
|
||||
|
||||
if new_item.enabled:
|
||||
expected.append(new_item)
|
||||
|
||||
results = get_scheduled_webhooks(database.session, start, datetime.now() + timedelta(minutes=5))
|
||||
|
||||
assert len(results) == len(expected)
|
||||
|
||||
for result in results:
|
||||
assert result.enabled
|
||||
|
||||
for expected_item in expected:
|
||||
|
||||
if result.name == expected_item.name: # Names are uniquely generated so we can use this to compare
|
||||
assert result.enabled == expected_item.enabled
|
||||
break
|
Loading…
x
Reference in New Issue
Block a user