feat(frontend): Fix scheduler, forgot password flow, and minor bug fixes (#725)

* feat(frontend): 💄 add recipe title

* fix(frontend): 🐛 fixes #722 side-bar issue

* feat(frontend):  Add page titles to all pages

* minor cleanup

* refactor(backend): ♻️ rewrite scheduler to be more modulare and work

* feat(frontend):  start password reset functionality

* refactor(backend): ♻️ refactor application settings to facilitate dependency injection

* refactor(backend): 🔥 remove RECIPE_SETTINGS env variables in favor of group settings

* formatting

* refactor(backend): ♻️ align naming convention

* feat(backend):  password reset

* test(backend):  password reset

* feat(frontend):  self-service password reset

* purge password schedule

* update user creation for tests

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-10-07 09:39:47 -08:00
committed by GitHub
parent d1f0441252
commit 2e9026f9ea
121 changed files with 1461 additions and 679 deletions

View File

@@ -0,0 +1,2 @@
from .scheduler_registry import *
from .scheduler_service import *

View File

@@ -1,7 +0,0 @@
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from mealie.core.config import app_dirs, settings
app_dirs.DATA_DIR.joinpath("scheduler.db").unlink(missing_ok=True)
scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(settings.SCHEDULER_DATABASE)})

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Tuple
from pydantic import BaseModel
@dataclass
class Cron:
hours: int
minutes: int
@classmethod
def parse(cls, time_str: str) -> Cron:
time = time_str.split(":")
return Cron(hours=int(time[0]), minutes=int(time[1]))
@dataclass
class ScheduledFunc(BaseModel):
id: Tuple[str, int]
name: str
hour: int
minutes: int
callback: Callable
max_instances: int = 1
replace_existing: bool = True
args: list = []

View File

@@ -1,124 +0,0 @@
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from mealie.core import root_logger
from mealie.db.database import get_database
from mealie.db.db_setup import create_session
from mealie.db.models.event import Event
from mealie.schema.user import GroupInDB
from mealie.services.backups.exports import auto_backup_job
from mealie.services.scheduler.global_scheduler import scheduler
from mealie.services.scheduler.scheduler_utils import Cron, cron_parser
from mealie.utils.post_webhooks import post_webhooks
logger = root_logger.get_logger()
# TODO Fix Scheduler
@scheduler.scheduled_job(trigger="interval", minutes=1440)
def purge_events_database():
"""
Ran daily. Purges all events after 100
"""
logger.info("Purging Events in Database")
expiration_days = 7
limit = datetime.datetime.now() - datetime.timedelta(days=expiration_days)
session = create_session()
session.query(Event).filter(Event.time_stamp <= limit).delete()
session.commit()
session.close()
logger.info("Events Purges")
@scheduler.scheduled_job(trigger="interval", minutes=30)
def update_webhook_schedule():
"""
A scheduled background job that runs every 30 minutes to
poll the database for changes and reschedule the webhook time
"""
session = create_session()
db = get_database(session)
all_groups: list[GroupInDB] = db.groups.get_all()
for group in all_groups:
time = cron_parser(group.webhook_time)
job = JOB_STORE.get(group.name)
if not job:
logger.error(f"No job found for group: {group.name}")
logger.info(f"Creating scheduled task for {group.name}")
JOB_STORE.update(add_group_to_schedule(scheduler, group))
continue
scheduler.reschedule_job(
job.scheduled_task.id,
trigger="cron",
hour=time.hours,
minute=time.minutes,
)
session.close()
logger.info(scheduler.print_jobs())
class ScheduledFunction:
def __init__(
self,
scheduler: BackgroundScheduler,
function,
cron: Cron,
name: str,
args: list = None,
) -> None:
self.scheduled_task = scheduler.add_job(
function,
trigger="cron",
name=name,
hour=cron.hours,
minute=cron.minutes,
max_instances=1,
replace_existing=True,
args=args,
)
def add_group_to_schedule(scheduler, group: GroupInDB):
cron = cron_parser(group.webhook_time)
return {
group.name: ScheduledFunction(
scheduler,
post_webhooks,
cron=cron,
name=group.name,
args=[group.id],
)
}
def init_webhook_schedule(scheduler, job_store: dict):
session = create_session()
db = get_database(session)
all_groups: list[GroupInDB] = db.groups.get_all()
for group in all_groups:
job_store.update(add_group_to_schedule(scheduler, group))
session.close()
return job_store
logger.info("----INIT SCHEDULE OBJECT-----")
JOB_STORE = {
"backup_job": ScheduledFunction(scheduler, auto_backup_job, Cron(hours=00, minutes=00), "backups"),
}
JOB_STORE = init_webhook_schedule(scheduler=scheduler, job_store=JOB_STORE)
logger.info(scheduler.print_jobs())
scheduler.start()

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Callable
from mealie.core import root_logger
logger = root_logger.get_logger()
class SchedulerRegistry:
"""
A container class for registring and removing callbacks for the scheduler.
"""
_daily: list[Callable] = []
_hourly: list[Callable] = []
_minutely: list[Callable] = []
def _register(name: str, callbacks: list[Callable], callback: Callable):
for cb in callback:
logger.info(f"Registering {name} callback: {cb.__name__}")
callbacks.append(cb)
def register_daily(*callbacks: Callable):
SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks)
def remove_daily(callback: Callable):
logger.info(f"Removing daily callback: {callback.__name__}")
SchedulerRegistry._daily.remove(callback)
def register_hourly(*callbacks: Callable):
SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks)
def remove_hourly(callback: Callable):
logger.info(f"Removing hourly callback: {callback.__name__}")
SchedulerRegistry._hourly.remove(callback)
def register_minutely(*callbacks: Callable):
SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks)
def remove_minutely(callback: Callable):
logger.info(f"Removing minutely callback: {callback.__name__}")
SchedulerRegistry._minutely.remove(callback)

View File

@@ -0,0 +1,104 @@
from pathlib import Path
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from mealie.core import root_logger
from mealie.core.config import get_app_dirs
from .scheduled_func import ScheduledFunc
from .scheduler_registry import SchedulerRegistry
logger = root_logger.get_logger()
CWD = Path(__file__).parent
app_dirs = get_app_dirs()
TEMP_DATA = app_dirs.DATA_DIR / ".temp"
SCHEDULER_DB = TEMP_DATA / "scheduler.db"
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
MINUTES_DAY = 1440
MINUTES_15 = 15
MINUTES_HOUR = 60
class SchedulerService:
"""
SchedulerService is a wrapper class around the APScheduler library. It is resonpseible for interacting with the scheduler
and scheduling events. This includes the interval events that are registered in the SchedulerRegistry as well as cron events
that are used for sending webhooks. In most cases, unless the the schedule is dynamic, events should be registered with the
SchedulerRegistry. See app.py for examples.
"""
_scheduler: BackgroundScheduler = None
# Not Sure if this is still needed?
# _job_store: dict[str, ScheduledFunc] = {}
def start():
# Preclean
SCHEDULER_DB.unlink(missing_ok=True)
# Scaffold
TEMP_DATA.mkdir(parents=True, exist_ok=True)
# Register Interval Jobs and Start Scheduler
SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)})
SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs")
SchedulerService._scheduler.add_job(run_hourly, "interval", minutes=MINUTES_HOUR, id="Hourly Interval Jobs")
SchedulerService._scheduler.add_job(run_minutely, "interval", minutes=MINUTES_15, id="Regular Interval Jobs")
SchedulerService._scheduler.start()
@classmethod
@property
def scheduler(cls) -> BackgroundScheduler:
return SchedulerService._scheduler
def add_cron_job(job_func: ScheduledFunc):
SchedulerService.scheduler.add_job(
job_func.callback,
trigger="cron",
name=job_func.id,
hour=job_func.hour,
minute=job_func.minutes,
max_instances=job_func.max_instances,
replace_existing=job_func.replace_existing,
args=job_func.args,
)
# SchedulerService._job_store[job_func.id] = job_func
def update_cron_job(job_func: ScheduledFunc):
SchedulerService.scheduler.reschedule_job(
job_func.id,
trigger="cron",
hour=job_func.hour,
minute=job_func.minutes,
)
# SchedulerService._job_store[job_func.id] = job_func
def _scheduled_task_wrapper(callable):
try:
callable()
except Exception as e:
logger.error(f"Error in scheduled task func='{callable.__name__}': exception='{e}'")
def run_daily():
logger.info("Running daily callbacks")
for func in SchedulerRegistry._daily:
_scheduled_task_wrapper(func)
def run_hourly():
logger.info("Running hourly callbacks")
for func in SchedulerRegistry._hourly:
_scheduled_task_wrapper(func)
def run_minutely():
logger.info("Running minutely callbacks")
for func in SchedulerRegistry._minutely:
_scheduled_task_wrapper(func)

View File

@@ -1,8 +0,0 @@
import collections
Cron = collections.namedtuple("Cron", "hours minutes")
def cron_parser(time_str: str) -> Cron:
time = time_str.split(":")
return Cron(hours=int(time[0]), minutes=int(time[1]))

View File

@@ -0,0 +1,14 @@
from .auto_backup import *
from .purge_events import *
from .purge_password_reset import *
from .purge_registration import *
from .webhooks import *
"""
Tasks Package
Common recurring tasks for the server to perform. Tasks here are registered to the SchedulerRegistry class
in the app.py file as a post-startup task. This is done to ensure that the tasks are run after the server has
started up and the Scheduler object is only avaiable to a single worker.
"""

View File

@@ -0,0 +1,22 @@
from mealie.core import root_logger
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.db_setup import create_session
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
logger = root_logger.get_logger()
def auto_backup():
for backup in app_dirs.BACKUP_DIR.glob("Auto*.zip"):
backup.unlink()
templates = [template for template in app_dirs.TEMPLATE_DIR.iterdir()]
session = create_session()
backup_all(session=session, tag="Auto", templates=templates)
logger.info("generating automated backup")
create_backup_event("Automated Backup", "Automated backup created", session)
session.close()
logger.info("automated backup generated")

View File

@@ -0,0 +1,19 @@
import datetime
from mealie.core import root_logger
from mealie.db.db_setup import create_session
from mealie.db.models.event import Event
logger = root_logger.get_logger()
def purge_events_database():
"""Purges all events after 100"""
logger.info("purging events in database")
expiration_days = 7
limit = datetime.datetime.now() - datetime.timedelta(days=expiration_days)
session = create_session()
session.query(Event).filter(Event.time_stamp <= limit).delete()
session.commit()
session.close()
logger.info("events purges")

View File

@@ -0,0 +1,20 @@
import datetime
from mealie.core import root_logger
from mealie.db.db_setup import create_session
from mealie.db.models.users.password_reset import PasswordResetModel
logger = root_logger.get_logger()
MAX_DAYS_OLD = 2
def purge_password_reset_tokens():
"""Purges all events after x days"""
logger.info("purging password reset tokens")
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
session = create_session()
session.query(PasswordResetModel).filter(PasswordResetModel.created_at <= limit).delete()
session.commit()
session.close()
logger.info("password reset tokens purges")

View File

@@ -0,0 +1,20 @@
import datetime
from mealie.core import root_logger
from mealie.db.db_setup import create_session
from mealie.db.models.group import GroupInviteToken
logger = root_logger.get_logger()
MAX_DAYS_OLD = 4
def purge_group_registration():
"""Purges all events after x days"""
logger.info("purging expired registration tokens")
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
session = create_session()
session.query(GroupInviteToken).filter(GroupInviteToken.created_at <= limit).delete()
session.commit()
session.close()
logger.info("registration token purged")

View File

@@ -0,0 +1,58 @@
import json
import requests
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.db.database import get_database
from mealie.db.db_setup import create_session
from mealie.schema.group.webhook import ReadWebhook
from ..scheduled_func import Cron, ScheduledFunc
from ..scheduler_service import SchedulerService
logger = root_logger.get_logger()
def post_webhooks(webhook_id: int, session: Session = None):
session = session or create_session()
db = get_database(session)
webhook: ReadWebhook = db.webhooks.get_one(webhook_id)
if not webhook.enabled:
logger.info(f"Skipping webhook {webhook_id}. reasons: is disabled")
return
todays_recipe = db.meals.get_today(webhook.group_id)
if not todays_recipe:
return
payload = json.loads([x.json(by_alias=True) for x in todays_recipe])
response = requests.post(webhook.url, json=payload)
if response.status_code != 200:
logger.error(f"Error posting webhook to {webhook.url} ({response.status_code})")
session.close()
def update_group_webhooks():
session = create_session()
db = get_database(session)
webhooks: list[ReadWebhook] = db.webhooks.get_all()
for webhook in webhooks:
cron = Cron.parse(webhook.time)
job_func = ScheduledFunc(
id=webhook.id,
name=f"Group {webhook.group_id} webhook",
callback=post_webhooks,
hour=cron.hours,
minute=cron.minutes,
args=(webhook.id),
)
SchedulerService.add_cron_job(job_func)