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

@@ -2,15 +2,18 @@ import uvicorn
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from mealie.core.config import APP_VERSION, settings
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
from mealie.core.settings.static import APP_VERSION
from mealie.routes import backup_routes, migration_routes, router, utility_routes
from mealie.routes.about import about_router
from mealie.routes.media import media_router
from mealie.routes.site_settings import settings_router
from mealie.services.events import create_general_event
from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
logger = get_logger()
settings = get_app_settings()
app = FastAPI(
title="Mealie",
@@ -24,24 +27,28 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
def start_scheduler():
return # TODO: Disable Scheduler for now
import mealie.services.scheduler.scheduled_jobs # noqa: F401
SchedulerService.start()
SchedulerRegistry.register_daily(
tasks.purge_events_database,
tasks.purge_group_registration,
tasks.auto_backup,
tasks.purge_password_reset_tokens,
)
SchedulerRegistry.register_hourly()
SchedulerRegistry.register_minutely(tasks.update_group_webhooks)
logger.info(SchedulerService.scheduler.print_jobs())
def api_routers():
# Authentication
app.include_router(router)
# Recipes
app.include_router(media_router)
app.include_router(about_router)
# Meal Routes
# Settings Routes
app.include_router(settings_router)
# Backups/Imports Routes
app.include_router(backup_routes.router)
# Migration Routes
app.include_router(migration_routes.router)
# Debug routes
app.include_router(utility_routes.router)
@@ -51,6 +58,7 @@ api_routers()
@app.on_event("startup")
def system_startup():
start_scheduler()
logger.info("-----SYSTEM STARTUP----- \n")
logger.info("------APP SETTINGS------")
logger.info(
@@ -64,9 +72,12 @@ def system_startup():
"DB_URL", # replace by DB_URL_PUBLIC for logs
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"SMTP_USER",
"SMTP_PASSWORD",
},
)
)
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
@@ -77,6 +88,7 @@ def main():
port=settings.API_PORT,
reload=True,
reload_dirs=["mealie"],
reload_delay=2,
debug=True,
log_level="debug",
use_colors=True,

View File

@@ -1,209 +1,39 @@
import os
import secrets
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional, Union
import dotenv
from pydantic import BaseSettings, Field, PostgresDsn, validator
APP_VERSION = "v1.0.0b"
DB_VERSION = "v1.0.0b"
from mealie.core.settings.settings import app_settings_constructor
from .settings import AppDirectories, AppSettings
from .settings.static import APP_VERSION, DB_VERSION
APP_VERSION
DB_VERSION
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env")
dotenv.load_dotenv(ENV)
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
def determine_data_dir(production: bool) -> Path:
global CWD
if production:
def determine_data_dir() -> Path:
global PRODUCTION
global BASE_DIR
if PRODUCTION:
return Path("/app/data")
return CWD.parent.parent.joinpath("dev", "data")
def determine_secrets(data_dir: Path, production: bool) -> str:
if not production:
return "shh-secret-test-key"
secrets_file = data_dir.joinpath(".secret")
if secrets_file.is_file():
with open(secrets_file, "r") as f:
return f.read()
else:
with open(secrets_file, "w") as f:
new_secret = secrets.token_hex(32)
f.write(new_secret)
return new_secret
# General
DATA_DIR = determine_data_dir(PRODUCTION)
class AppDirectories:
def __init__(self, cwd, data_dir) -> None:
self.DATA_DIR: Path = data_dir
self.WEB_PATH: Path = cwd.joinpath("dist")
self.IMG_DIR: Path = data_dir.joinpath("img")
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR: Path = data_dir.joinpath("users")
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.ensure_directories()
def ensure_directories(self):
required_dirs = [
self.IMG_DIR,
self.BACKUP_DIR,
self.DEBUG_DIR,
self.MIGRATION_DIR,
self.TEMPLATE_DIR,
self.NEXTCLOUD_DIR,
self.CHOWDOWN_DIR,
self.RECIPE_DATA_DIR,
self.USER_DIR,
]
for dir in required_dirs:
dir.mkdir(parents=True, exist_ok=True)
app_dirs = AppDirectories(CWD, DATA_DIR)
def determine_sqlite_path(path=False, suffix=DB_VERSION) -> str:
global app_dirs
db_path = app_dirs.DATA_DIR.joinpath(f"mealie_{suffix}.db") # ! Temporary Until Alembic
if path:
return db_path
return "sqlite:///" + str(db_path.absolute())
class AppSettings(BaseSettings):
global DATA_DIR
PRODUCTION: bool = Field(True, env="PRODUCTION")
BASE_URL: str = "http://localhost:8080"
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True
@property
def DOCS_URL(self) -> str:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str:
return "/redoc" if self.API_DOCS else None
SECRET: str = determine_secrets(DATA_DIR, PRODUCTION)
DB_ENGINE: str = "sqlite" # Optional: 'sqlite', 'postgres'
POSTGRES_USER: str = "mealie"
POSTGRES_PASSWORD: str = "mealie"
POSTGRES_SERVER: str = "postgres"
POSTGRES_PORT: str = 5432
POSTGRES_DB: str = "mealie"
DB_URL: Union[str, PostgresDsn] = None # Actual DB_URL is calculated with `assemble_db_connection`
@validator("DB_URL", pre=True)
def assemble_db_connection(cls, v: Optional[str], values: dict[str, Any]) -> Any:
engine = values.get("DB_ENGINE", "sqlite")
if engine == "postgres":
host = f"{values.get('POSTGRES_SERVER')}:{values.get('POSTGRES_PORT')}"
return PostgresDsn.build(
scheme="postgresql",
user=values.get("POSTGRES_USER"),
password=values.get("POSTGRES_PASSWORD"),
host=host,
path=f"/{values.get('POSTGRES_DB') or ''}",
)
return determine_sqlite_path()
DB_URL_PUBLIC: str = "" # hide credentials to show on logs/frontend
@validator("DB_URL_PUBLIC", pre=True)
def public_db_url(cls, v: Optional[str], values: dict[str, Any]) -> str:
url = values.get("DB_URL")
engine = values.get("DB_ENGINE", "sqlite")
if engine != "postgres":
# sqlite
return url
user = values.get("POSTGRES_USER")
password = values.get("POSTGRES_PASSWORD")
return url.replace(user, "*****", 1).replace(password, "*****", 1)
DEFAULT_GROUP: str = "Home"
DEFAULT_EMAIL: str = "changeme@email.com"
DEFAULT_PASSWORD: str = "MyPassword"
SCHEDULER_DATABASE = f"sqlite:///{app_dirs.DATA_DIR.joinpath('scheduler.db')}"
TOKEN_TIME: int = 2 # Time in Hours
# Recipe Default Settings
RECIPE_PUBLIC: bool = True
RECIPE_SHOW_NUTRITION: bool = True
RECIPE_SHOW_ASSETS: bool = True
RECIPE_LANDSCAPE_VIEW: bool = True
RECIPE_DISABLE_COMMENTS: bool = False
RECIPE_DISABLE_AMOUNT: bool = False
# ===============================================
# Email Configuration
SMTP_HOST: Optional[str]
SMTP_PORT: Optional[str] = "587"
SMTP_FROM_NAME: Optional[str] = "Mealie"
SMTP_TLS: Optional[bool] = True
SMTP_FROM_EMAIL: Optional[str]
SMTP_USER: Optional[str]
SMTP_PASSWORD: Optional[str]
@property
def SMTP_ENABLE(self) -> bool:
"""Validates all SMTP variables are set"""
required = {
self.SMTP_HOST,
self.SMTP_PORT,
self.SMTP_FROM_NAME,
self.SMTP_TLS,
self.SMTP_FROM_EMAIL,
self.SMTP_USER,
self.SMTP_PASSWORD,
}
return "" not in required and None not in required
class Config:
env_file = BASE_DIR.joinpath(".env")
env_file_encoding = "utf-8"
settings = AppSettings()
return BASE_DIR.joinpath("dev", "data")
@lru_cache
def get_app_dirs() -> AppDirectories:
global app_dirs
return app_dirs
return AppDirectories(determine_data_dir())
@lru_cache
def get_settings() -> AppSettings:
global settings
return settings
def get_app_settings() -> AppSettings:
return app_settings_constructor(env_file=ENV, production=PRODUCTION, data_dir=determine_data_dir())

View File

@@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs, settings
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData
@@ -16,6 +16,8 @@ from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
ALGORITHM = "HS256"
app_dirs = get_app_dirs()
settings = get_app_settings()
async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)) -> bool:

View File

@@ -3,9 +3,13 @@ import sys
from dataclasses import dataclass
from functools import lru_cache
from mealie.core.config import DATA_DIR
from mealie.core.config import determine_data_dir
from .config import settings
DATA_DIR = determine_data_dir()
from .config import get_app_settings
settings = get_app_settings()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
DATE_FORMAT = "%d-%b-%y %H:%M:%S"

View File

@@ -1,13 +1,16 @@
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from jose import jwt
from passlib.context import CryptContext
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.db.database import get_database
from mealie.schema.user import PrivateUser
settings = get_app_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
@@ -43,26 +46,15 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser:
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Compares a plain string to a hashed password
Args:
plain_password (str): raw password string
hashed_password (str): hashed password from the database
Returns:
bool: Returns True if a match return False
"""
"""Compares a plain string to a hashed password"""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving
a new password to the database.
Args:
password (str): Password String
Returns:
str: Hashed Password
"""
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return pwd_context.hash(password)
def url_safe_token() -> str:
"""Generates a cryptographic token without embedded data. Used for password reset tokens and invitation tokens"""
return secrets.token_urlsafe(24)

View File

@@ -0,0 +1,2 @@
from .directories import *
from .settings import *

View File

@@ -0,0 +1,65 @@
from abc import ABC, abstractproperty
from pathlib import Path
from pydantic import BaseModel, BaseSettings, PostgresDsn
class AbstractDBProvider(ABC):
@abstractproperty
def db_url(self) -> str:
pass
@property
def db_url_public(self) -> str:
pass
class SQLiteProvider(AbstractDBProvider, BaseModel):
data_dir: Path
prefix: str = ""
@property
def db_path(self):
return self.data_dir / f"{self.prefix}mealie.db"
@property
def db_url(self) -> str:
return "sqlite:///" + str(self.db_path.absolute())
@property
def db_url_public(self) -> str:
return self.db_url
class PostgresProvider(AbstractDBProvider, BaseSettings):
POSTGRES_USER: str = "mealie"
POSTGRES_PASSWORD: str = "mealie"
POSTGRES_SERVER: str = "postgres"
POSTGRES_PORT: str = 5432
POSTGRES_DB: str = "mealie"
@property
def db_url(self) -> str:
host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}"
return PostgresDsn.build(
scheme="postgresql",
user=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=host,
path=f"/{self.POSTGRES_DB or ''}",
)
@property
def db_url_public(self) -> str:
user = self.POSTGRES_USER
password = self.POSTGRES_PASSWORD
return self.db_url.replace(user, "*****", 1).replace(password, "*****", 1)
def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_encoding="utf-8") -> AbstractDBProvider:
if provider_name == "sqlite":
return SQLiteProvider(data_dir=data_dir)
elif provider_name == "postgres":
return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding)
else:
return

View File

@@ -0,0 +1,34 @@
from pathlib import Path
class AppDirectories:
def __init__(self, data_dir) -> None:
self.DATA_DIR: Path = data_dir
self.IMG_DIR: Path = data_dir.joinpath("img")
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR: Path = data_dir.joinpath("users")
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.ensure_directories()
def ensure_directories(self):
required_dirs = [
self.IMG_DIR,
self.BACKUP_DIR,
self.DEBUG_DIR,
self.MIGRATION_DIR,
self.TEMPLATE_DIR,
self.NEXTCLOUD_DIR,
self.CHOWDOWN_DIR,
self.RECIPE_DATA_DIR,
self.USER_DIR,
]
for dir in required_dirs:
dir.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,109 @@
import secrets
from pathlib import Path
from typing import Optional
from pydantic import BaseSettings
from .db_providers import AbstractDBProvider, db_provider_factory
def determine_secrets(data_dir: Path, production: bool) -> str:
if not production:
return "shh-secret-test-key"
secrets_file = data_dir.joinpath(".secret")
if secrets_file.is_file():
with open(secrets_file, "r") as f:
return f.read()
else:
with open(secrets_file, "w") as f:
new_secret = secrets.token_hex(32)
f.write(new_secret)
return new_secret
class AppSettings(BaseSettings):
PRODUCTION: bool
BASE_URL: str = "http://localhost:8080"
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True
TOKEN_TIME: int = 48 # Time in Hours
SECRET: str
@property
def DOCS_URL(self) -> str:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str:
return "/redoc" if self.API_DOCS else None
# ===============================================
# Database Configuration
DB_ENGINE: str = "sqlite" # Options: 'sqlite', 'postgres'
DB_PROVIDER: AbstractDBProvider = None
@property
def DB_URL(self) -> str:
return self.DB_PROVIDER.db_url
@property
def DB_URL_PUBLIC(self) -> str:
return self.DB_PROVIDER.db_url_public
DEFAULT_GROUP: str = "Home"
DEFAULT_EMAIL: str = "changeme@email.com"
DEFAULT_PASSWORD: str = "MyPassword"
# ===============================================
# Email Configuration
SMTP_HOST: Optional[str]
SMTP_PORT: Optional[str] = "587"
SMTP_FROM_NAME: Optional[str] = "Mealie"
SMTP_TLS: Optional[bool] = True
SMTP_FROM_EMAIL: Optional[str]
SMTP_USER: Optional[str]
SMTP_PASSWORD: Optional[str]
@property
def SMTP_ENABLE(self) -> bool:
"""Validates all SMTP variables are set"""
required = {
self.SMTP_HOST,
self.SMTP_PORT,
self.SMTP_FROM_NAME,
self.SMTP_TLS,
self.SMTP_FROM_EMAIL,
self.SMTP_USER,
self.SMTP_PASSWORD,
}
return "" not in required and None not in required
class Config:
arbitrary_types_allowed = True
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
"""
app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated
directly, but rather through this factory function.
"""
app_settings = AppSettings(
_env_file=env_file,
_env_file_encoding=env_encoding,
**{"SECRET": determine_secrets(data_dir, production)},
)
app_settings.DB_PROVIDER = db_provider_factory(
app_settings.DB_ENGINE or "sqlite",
data_dir,
env_file=env_file,
env_encoding=env_encoding,
)
return app_settings

View File

@@ -0,0 +1,10 @@
import os
from pathlib import Path
APP_VERSION = "v1.0.0b"
DB_VERSION = "v1.0.0b"
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent.parent
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]

View File

@@ -16,6 +16,7 @@ from mealie.db.models.recipe.tag import Tag
from mealie.db.models.settings import SiteSettings
from mealie.db.models.sign_up import SignUp
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.schema.admin import SiteSettings as SiteSettingsSchema
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
@@ -27,6 +28,7 @@ from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import CommentOut, Recipe, RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
from ._access_model import AccessModel
from .group_access_model import GroupDataAccessModel
@@ -117,6 +119,10 @@ class Database:
def api_tokens(self) -> AccessModel:
return AccessModel(self.session, pk_id, LongLiveToken, LongLiveTokenInDB)
@cached_property
def tokens_pw_reset(self) -> AccessModel[PrivatePasswordResetToken, PasswordResetModel]:
return AccessModel(self.session, pk_token, PasswordResetModel, PrivatePasswordResetToken)
# ================================================================
# Group Items
@@ -126,7 +132,7 @@ class Database:
@cached_property
def group_invite_tokens(self) -> AccessModel:
return AccessModel(self.session, "token", GroupInviteToken, ReadInviteToken)
return AccessModel(self.session, pk_token, GroupInviteToken, ReadInviteToken)
@cached_property
def group_preferences(self) -> AccessModel:

View File

@@ -5,9 +5,9 @@ from ._access_model import AccessModel
class UserDataAccessModel(AccessModel[PrivateUser, User]):
def update_password(self, session, id, password: str):
def update_password(self, id, password: str):
entry = self._query_one(match_value=id)
entry.update_password(password)
session.commit()
self.session.commit()
return self.schema.from_orm(entry)

View File

@@ -1,9 +1,10 @@
from mealie.core import root_logger
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.core.security import hash_password
from mealie.db.data_access_layer.access_model_factory import Database
logger = root_logger.get_logger("init_users")
settings = get_app_settings()
def dev_users() -> list[dict]:

View File

@@ -2,7 +2,9 @@ import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session
from mealie.core.config import settings
from mealie.core.config import get_app_settings
settings = get_app_settings()
def sql_global_init(db_url: str):

View File

@@ -1,5 +1,5 @@
from mealie.core import root_logger
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.db.data_access_layer.access_model_factory import Database
from mealie.db.data_initialization.init_units_foods import default_recipe_unit_init
from mealie.db.data_initialization.init_users import default_user_init
@@ -13,6 +13,8 @@ from mealie.services.group_services.group_utils import create_new_group
logger = root_logger.get_logger("init_db")
settings = get_app_settings()
def create_all_models():
import mealie.db.models._all_models # noqa: F401

View File

@@ -2,7 +2,7 @@ import sqlalchemy as sa
import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.db.models.group.invite_tokens import GroupInviteToken
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -13,6 +13,8 @@ from .cookbook import CookBook
from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel
settings = get_app_settings()
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"

View File

@@ -1,2 +1,3 @@
from .password_reset import *
from .user_to_favorite import *
from .users import *

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
class PasswordResetModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "password_reset_tokens"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="password_reset_tokens", uselist=False)
token = Column(String(64), unique=True, nullable=False)
def __init__(self, user_id, token, **_):
self.user_id = user_id
self.token = token

View File

@@ -1,11 +1,13 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from .._model_base import BaseMixins, SqlAlchemyBase
from ..group import Group
from .user_to_favorite import users_to_favorites
settings = get_app_settings()
class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens"
@@ -48,6 +50,10 @@ class User(SqlAlchemyBase, BaseMixins):
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
password_reset_tokens = orm.relationship(
"PasswordResetModel", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.core.config import APP_VERSION, get_settings
from mealie.core.config import get_app_settings
from mealie.core.settings.static import APP_VERSION
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.schema.admin.about import AdminAboutInfo, AppStatistics, CheckAppConfig
@@ -12,7 +13,7 @@ router = APIRouter(prefix="/about")
@router.get("", response_model=AdminAboutInfo)
async def get_app_info():
""" Get general application information """
settings = get_settings()
settings = get_app_settings()
return AdminAboutInfo(
production=settings.PRODUCTION,
@@ -40,7 +41,7 @@ async def get_app_statistics(session: Session = Depends(generate_session)):
@router.get("/check", response_model=CheckAppConfig)
async def check_app_config():
settings = get_settings()
settings = get_app_settings()
url_set = settings.BASE_URL != "http://localhost:8080"

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter
from fastapi_camelcase import CamelModel
from mealie.core.config import get_settings
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
from mealie.services.email import EmailService
@@ -26,7 +26,7 @@ class EmailTest(CamelModel):
@router.get("", response_model=EmailReady)
async def check_email_config():
""" Get general application information """
settings = get_settings()
settings = get_app_settings()
return EmailReady(ready=settings.SMTP_ENABLE)

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from mealie.core.config import APP_VERSION, get_settings
from mealie.core.config import APP_VERSION, get_app_settings
from mealie.schema.admin.about import AppInfo
router = APIRouter(prefix="/about")
@@ -9,7 +9,7 @@ router = APIRouter(prefix="/about")
@router.get("", response_model=AppInfo)
async def get_app_info():
""" Get general application information """
settings = get_settings()
settings = get_app_settings()
return AppInfo(
version=APP_VERSION,

View File

@@ -5,7 +5,9 @@ from pathlib import Path
from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token

View File

@@ -5,7 +5,9 @@ from typing import List
from fastapi import Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.routes.users.crud import get_logged_in_user

View File

@@ -13,6 +13,7 @@ router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"]
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])
router.include_router(passwords.user_router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(passwords.public_router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.public_router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(images.user_router, prefix=user_prefix, tags=["Users: Images"])

View File

@@ -4,7 +4,9 @@ from fastapi import Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from fastapi.routing import APIRouter
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.core.dependencies import get_current_user
from mealie.routes.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed

View File

@@ -1,15 +1,19 @@
from fastapi import Depends
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.core.security import hash_password
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user import ChangePassword
from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword
from mealie.services.user_services import UserService
from mealie.services.user_services.password_reset_service import PasswordResetService
user_router = UserAPIRouter(prefix="")
public_router = APIRouter(prefix="")
settings = get_app_settings()
@user_router.put("/{id}/reset-password")
@@ -25,3 +29,17 @@ def update_password(password_change: ChangePassword, user_service: UserService =
""" Resets the User Password"""
return user_service.change_password(password_change)
@public_router.post("/forgot-password")
def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)):
""" Sends an email with a reset link to the user"""
f_service = PasswordResetService(session)
return f_service.send_reset_email(email.email)
@public_router.post("/reset-password")
def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)):
""" Resets the user password"""
f_service = PasswordResetService(session)
return f_service.reset_password(reset_password.token, reset_password.password)

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, validator
from pydantic.utils import GetterDict
from slugify import slugify
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
from mealie.db.models.recipe.recipe import RecipeModel
from .recipe_asset import RecipeAsset
@@ -18,6 +18,8 @@ from .recipe_nutrition import Nutrition
from .recipe_settings import RecipeSettings
from .recipe_step import RecipeStep
app_dirs = get_app_dirs()
class CreateRecipeByURL(BaseModel):
url: str

View File

@@ -1,15 +1,17 @@
from fastapi_camelcase import CamelModel
from mealie.core.config import settings
from mealie.core.config import get_app_settings
settings = get_app_settings()
class RecipeSettings(CamelModel):
public: bool = settings.RECIPE_PUBLIC
show_nutrition: bool = settings.RECIPE_SHOW_NUTRITION
show_assets: bool = settings.RECIPE_SHOW_ASSETS
landscape_view: bool = settings.RECIPE_LANDSCAPE_VIEW
disable_comments: bool = settings.RECIPE_DISABLE_COMMENTS
disable_amount: bool = settings.RECIPE_DISABLE_AMOUNT
public: bool = False
show_nutrition: bool = False
show_assets: bool = False
landscape_view: bool = False
disable_comments: bool = True
disable_amount: bool = True
class Config:
orm_mode = True

View File

@@ -5,7 +5,7 @@ from fastapi_camelcase import CamelModel
from pydantic.types import constr
from pydantic.utils import GetterDict
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.db.models.users import User
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary
@@ -13,6 +13,8 @@ from mealie.schema.recipe import RecipeSummary
from ..meal_plan import ShoppingListOut
from ..recipe import CategoryBase
settings = get_app_settings()
class LoingLiveTokenIn(CamelModel):
name: str

View File

@@ -0,0 +1,29 @@
from fastapi_camelcase import CamelModel
from .user import PrivateUser
class ForgotPassword(CamelModel):
email: str
class ValidateResetToken(CamelModel):
token: str
class ResetPassword(ValidateResetToken):
email: str
password: str
passwordConfirm: str
class SavePasswordResetToken(CamelModel):
user_id: int
token: str
class PrivatePasswordResetToken(SavePasswordResetToken):
user: PrivateUser
class Config:
orm_mode = True

View File

@@ -5,7 +5,7 @@ from fastapi import BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs, get_settings
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.db.db_setup import SessionLocal
@@ -63,7 +63,7 @@ class BaseHttpService(Generic[T, D], ABC):
# Static Globals Dependency Injection
self.db = get_database(session)
self.app_dirs = get_app_dirs()
self.settings = get_settings()
self.settings = get_app_settings()
def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)):

View File

@@ -1,7 +1,7 @@
from mealie.core.config import get_app_dirs, get_settings
from mealie.core.config import get_app_dirs, get_app_settings
class BaseService:
def __init__(self) -> None:
self.app_dirs = get_app_dirs()
self.settings = get_settings()
self.settings = get_app_settings()

View File

@@ -9,10 +9,10 @@ from pathvalidate import sanitize_filename
from pydantic.main import BaseModel
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.database import get_database
from mealie.db.db_setup import create_session
from mealie.services.events import create_backup_event
logger = root_logger.get_logger()
@@ -141,15 +141,3 @@ def backup_all(
db_export.export_items(all_notifications, "notifications")
return db_export.finish_export()
def auto_backup_job():
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("Auto Backup Called")
create_backup_event("Automated Backup", "Automated backup created", session)
session.close()

View File

@@ -7,7 +7,9 @@ from typing import Callable
from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.database import get_database
from mealie.schema.admin import (
CommentImport,

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.core.security import url_safe_token
from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.group.invite_token import EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
@@ -86,7 +85,7 @@ class GroupSelfService(UserHttpService[int, str]):
if not self.user.can_invite:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens")
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=url_safe_token())
return self.db.group_invite_tokens.create(token)
def get_invite_tokens(self) -> list[ReadInviteToken]:

View File

@@ -5,10 +5,11 @@ from pathlib import Path
from PIL import Image
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
from mealie.schema.recipe import Recipe
logger = root_logger.get_logger()
app_dirs = get_app_dirs()
@dataclass

View File

@@ -3,7 +3,9 @@ from typing import Optional
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.schema.admin import MigrationImport
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import helpers

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)

View File

@@ -6,7 +6,7 @@ from typing import Optional
from pydantic import BaseModel, validator
from mealie.core.config import settings
from mealie.core.config import get_app_settings
from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
@@ -15,6 +15,8 @@ from .pre_processor import pre_process_string
CWD = Path(__file__).parent
MODEL_PATH = CWD / "model.crfmodel"
settings = get_app_settings()
INGREDIENT_TEXT = [
"2 tablespoons honey",

View File

@@ -4,7 +4,9 @@ import extruct
from slugify import slugify
from w3lib.html import get_base_url
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
LAST_JSON = app_dirs.DEBUG_DIR.joinpath("last_recipe.json")

View File

@@ -8,7 +8,9 @@ from fastapi import HTTPException, status
from recipe_scrapers import NoSchemaFoundInWildMode, SchemaScraperFactory, WebsiteNotImplementedError, scrape_me
from slugify import slugify
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.core.root_logger import get_logger
from mealie.schema.recipe import Recipe, RecipeStep
from mealie.services.image.image import scrape_image

View File

@@ -0,0 +1,66 @@
from fastapi import HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password, url_safe_token
from mealie.db.database import get_database
from mealie.schema.user.user_passwords import SavePasswordResetToken
from mealie.services._base_service import BaseService
from mealie.services.email import EmailService
logger = get_logger(__name__)
class PasswordResetService(BaseService):
def __init__(self, session: Session) -> None:
self.db = get_database(session)
super().__init__()
def generate_reset_token(self, email: str) -> SavePasswordResetToken:
user = self.db.users.get_one(email, "email")
if user is None:
logger.error(f"failed to create password reset for {email=}: user doesn't exists")
# Do not raise exception here as we don't want to confirm to the client that the Email doens't exists
return
# Create Reset Token
token = url_safe_token()
save_token = SavePasswordResetToken(user_id=user.id, token=token)
return self.db.tokens_pw_reset.create(save_token)
def send_reset_email(self, email: str):
token_entry = self.generate_reset_token(email)
# Send Email
email_servive = EmailService()
reset_url = f"{self.settings.BASE_URL}/reset-password?token={token_entry.token}"
try:
email_servive.send_forgot_password(email, reset_url)
except Exception as e:
logger.error(f"failed to send reset email: {e}")
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to send reset email")
def reset_password(self, token: str, new_password: str):
# Validate Token
token_entry = self.db.tokens_pw_reset.get_one(token, "token")
if token_entry is None:
logger.error("failed to reset password: invalid token")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token")
user = self.db.users.get_one(token_entry.user_id)
# Update Password
password_hash = hash_password(new_password)
new_user = self.db.users.update_password(user.id, password_hash)
# Confirm Password
if new_user.password != password_hash:
logger.error("failed to reset password: invalid password")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid password")
# Delete Token from DB
self.db.tokens_pw_reset.delete(token_entry.token)

View File

@@ -30,7 +30,7 @@ class RegistrationService(PublicHttpService[int, str]):
group = self._register_new_group()
elif registration.group_token and registration.group_token != "":
token_entry = self.db.group_invite_tokens.get(registration.group_token)
token_entry = self.db.group_invite_tokens.get_one(registration.group_token)
if not token_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"})
group = self.db.groups.get(token_entry.group_id)

View File

@@ -2,7 +2,9 @@ import tempfile
import zipfile
from pathlib import Path
from mealie.core.config import app_dirs
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
def unpack_zip(selection: Path) -> tempfile.TemporaryDirectory: