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:
Hayden 2022-06-17 13:25:47 -08:00 committed by GitHub
parent b1256f4ad2
commit 5a053cdcd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 428 additions and 93 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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