mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-03-04 04:33:12 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
2
mealie/core/settings/__init__.py
Normal file
2
mealie/core/settings/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .directories import *
|
||||
from .settings import *
|
||||
65
mealie/core/settings/db_providers.py
Normal file
65
mealie/core/settings/db_providers.py
Normal 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
|
||||
34
mealie/core/settings/directories.py
Normal file
34
mealie/core/settings/directories.py
Normal 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)
|
||||
109
mealie/core/settings/settings.py
Normal file
109
mealie/core/settings/settings.py
Normal 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
|
||||
10
mealie/core/settings/static.py
Normal file
10
mealie/core/settings/static.py
Normal 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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .password_reset import *
|
||||
from .user_to_favorite import *
|
||||
from .users import *
|
||||
|
||||
15
mealie/db/models/users/password_reset.py
Normal file
15
mealie/db/models/users/password_reset.py
Normal 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
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
mealie/schema/user/user_passwords.py
Normal file
29
mealie/schema/user/user_passwords.py
Normal 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
|
||||
@@ -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)):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .scheduler_registry import *
|
||||
from .scheduler_service import *
|
||||
|
||||
@@ -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)})
|
||||
30
mealie/services/scheduler/scheduled_func.py
Normal file
30
mealie/services/scheduler/scheduled_func.py
Normal 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 = []
|
||||
@@ -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()
|
||||
43
mealie/services/scheduler/scheduler_registry.py
Normal file
43
mealie/services/scheduler/scheduler_registry.py
Normal 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)
|
||||
104
mealie/services/scheduler/scheduler_service.py
Normal file
104
mealie/services/scheduler/scheduler_service.py
Normal 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)
|
||||
@@ -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]))
|
||||
14
mealie/services/scheduler/tasks/__init__.py
Normal file
14
mealie/services/scheduler/tasks/__init__.py
Normal 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.
|
||||
|
||||
"""
|
||||
22
mealie/services/scheduler/tasks/auto_backup.py
Normal file
22
mealie/services/scheduler/tasks/auto_backup.py
Normal 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")
|
||||
19
mealie/services/scheduler/tasks/purge_events.py
Normal file
19
mealie/services/scheduler/tasks/purge_events.py
Normal 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")
|
||||
20
mealie/services/scheduler/tasks/purge_password_reset.py
Normal file
20
mealie/services/scheduler/tasks/purge_password_reset.py
Normal 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")
|
||||
20
mealie/services/scheduler/tasks/purge_registration.py
Normal file
20
mealie/services/scheduler/tasks/purge_registration.py
Normal 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")
|
||||
58
mealie/services/scheduler/tasks/webhooks.py
Normal file
58
mealie/services/scheduler/tasks/webhooks.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
66
mealie/services/user_services/password_reset_service.py
Normal file
66
mealie/services/user_services/password_reset_service.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user