Refactor/conver to controllers (#923)

* add dependency injection for get_repositories

* convert events api to controller

* update generic typing

* add abstract controllers

* update test naming

* migrate admin services to controllers

* add additional admin route tests

* remove print

* add public shared dependencies

* add types

* fix typo

* add static variables for recipe json keys

* add coverage gutters config

* update controller routers

* add generic success response

* add category/tag/tool tests

* add token refresh test

* add coverage utilities

* covert comments to controller

* add todo

* add helper properties

* delete old service

* update test notes

* add unit test for pretty_stats

* remove dead code from post_webhooks

* update group routes to use controllers

* add additional group test coverage

* abstract common permission checks

* convert ingredient parser to controller

* update recipe crud to use controller

* remove dead-code

* add class lifespan tracker for debugging

* convert bulk export to controller

* migrate tools router to controller

* update recipe share to controller

* move customer router to _base

* ignore prints in flake8

* convert units and foods to new controllers

* migrate user routes to controllers

* centralize error handling

* fix invalid ref

* reorder fields

* update routers to share common handling

* update tests

* remove prints

* fix cookbooks delete

* fix cookbook get

* add controller for mealplanner

* cover report routes to controller

* remove __future__ imports

* remove dead code

* remove all base_http children and remove dead code
This commit is contained in:
Hayden
2022-01-13 13:06:52 -09:00
committed by GitHub
parent 5823a32daf
commit c4540f1395
164 changed files with 3111 additions and 3213 deletions

View File

@@ -1,2 +0,0 @@
from .http_services import *
from .router_factory import *

View File

@@ -1,156 +0,0 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Generic, Type, TypeVar
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_app_settings
from mealie.core.root_logger import get_logger
from mealie.db.db_setup import SessionLocal
from mealie.lang import get_locale_provider
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.user import PrivateUser
logger = get_logger()
T = TypeVar("T")
D = TypeVar("D")
CLS_DEP = TypeVar("CLS_DEP") # Generic Used for the class method dependencies
class BaseHttpService(Generic[T, D], ABC):
"""
The BaseHttpService class is a generic class that can be used to create
http services that are injected via `Depends` into a route function. To use,
you must define the Generic type arguments:
`T`: The type passed into the *_existing functions (e.g. id) which is then passed into assert_existing
`D`: Item returned from database layer
"""
item: D = None
# Function that Generate Corrsesponding Routes through RouterFactory:
# if the method is defined or != `None` than the corresponding route is defined through the RouterFactory.
# If the method is not defined, then the route will be excluded from creation. This service based articheture
# is being adopted as apart of the v1 migration
get_all: Callable = None
create_one: Callable = None
update_one: Callable = None
update_many: Callable = None
populate_item: Callable = None
delete_one: Callable = None
delete_all: Callable = None
# Type Definitions
_schema = None
# Function called to create a server side event
event_func: Callable = None
# Config
_restrict_by_group = False
_group_id_cache = None
def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None:
self.session = session or SessionLocal()
self.user = user
self.logged_in = bool(self.user)
self.background_tasks = background_tasks
# Static Globals Dependency Injection
self.db = get_repositories(session)
self.app_dirs = get_app_dirs()
self.settings = get_app_settings()
self.t = get_locale_provider().t
def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)):
new_class = cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
new_class.assert_existing(item_id)
return new_class
return classmethod(cls_method)
def _class_method_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, deps: CLS_DEP = Depends(dependency)):
return cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
return classmethod(cls_method)
@classmethod
@abstractmethod
def public(cls, deps: Any):
pass
@classmethod
@abstractmethod
def private(cls, deps: Any):
pass
@classmethod
@abstractmethod
def read_existing(cls, deps: Any):
pass
@classmethod
@abstractmethod
def write_existing(cls, deps: Any):
pass
@abstractmethod
def populate_item(self) -> None:
pass
@property
def group_id(self):
# TODO: Populate Group in Private User Call WARNING: May require significant refactoring
if not self._group_id_cache:
group = self.db.groups.get_one(self.user.group, "name")
self._group_id_cache = group.id
return self._group_id_cache
def cast(self, item: BaseModel, dest, assign_owner=True) -> T:
"""cast a pydantic model to the destination type
Args:
item (BaseModel): A pydantic model containing data
dest ([type]): A type to cast the data to
assign_owner (bool, optional): If true, will assign the user_id and group_id to the dest type. Defaults to True.
Returns:
TypeVar(dest): Returns the destionation model type
"""
data = item.dict()
if assign_owner:
data["user_id"] = self.user.id
data["group_id"] = self.group_id
return dest(**data)
def assert_existing(self, id: T) -> None:
self.populate_item(id)
self._check_item()
def _check_item(self) -> None:
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.__class__._restrict_by_group:
group_id = getattr(self.item, "group_id", False)
if not group_id or group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
if hasattr(self, "check_item"):
self.check_item()
def _create_event(self, title: str, message: str) -> None:
if not self.__class__.event_func:
raise NotImplementedError("`event_func` must be set by child class")
self.background_tasks.add_task(self.__class__.event_func, title, message, self.session)

View File

@@ -1,83 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
from fastapi import HTTPException, status
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from mealie.core.root_logger import get_logger
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.response import ErrorResponse
C = TypeVar("C", bound=BaseModel)
R = TypeVar("R", bound=BaseModel)
U = TypeVar("U", bound=BaseModel)
DAL = TypeVar("DAL", bound=RepositoryGeneric)
logger = get_logger()
class CrudHttpMixins(Generic[C, R, U], ABC):
item: R
session: Session
@property
@abstractmethod
def repo(self) -> DAL:
...
def populate_item(self, id: int) -> R:
self.item = self.repo.get_one(id)
return self.item
def _create_one(self, data: C, default_msg="generic-create-error", exception_msgs: dict | None = None) -> R:
try:
self.item = self.repo.create(data)
except Exception as ex:
logger.exception(ex)
self.session.rollback()
msg = default_msg
if exception_msgs:
msg = exception_msgs.get(type(ex), default_msg)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(
message=msg,
exception=str(ex),
).dict(),
)
return self.item
def _update_one(self, data: U, item_id: int = None) -> R:
if not self.item:
return
target_id = item_id or self.item.id
self.item = self.repo.update(target_id, data)
return self.item
def _patch_one(self, data: U, item_id: int) -> None:
try:
self.item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
except IntegrityError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "generic-patch-error"})
def _delete_one(self, item_id: int = None) -> R:
target_id = item_id or self.item.id
logger.info(f"Deleting item with id {target_id}")
try:
self.item = self.repo.delete(target_id)
except Exception as ex:
logger.exception(ex)
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail={"message": "generic-delete-error", "exception": str(ex)}
)
return self.item

View File

@@ -1,60 +0,0 @@
from abc import abstractmethod
from typing import TypeVar
from mealie.core.dependencies.grouped import AdminDeps, PublicDeps, UserDeps
from .base_http_service import BaseHttpService
T = TypeVar("T")
D = TypeVar("D")
class PublicHttpService(BaseHttpService[T, D]):
"""
PublicHttpService sets the class methods to PublicDeps for read actions
and UserDeps for write actions which are inaccessible to not logged in users.
"""
read_existing = BaseHttpService._existing_factory(PublicDeps)
write_existing = BaseHttpService._existing_factory(UserDeps)
public = BaseHttpService._class_method_factory(PublicDeps)
private = BaseHttpService._class_method_factory(UserDeps)
@abstractmethod
def populate_item(self) -> None:
...
class UserHttpService(BaseHttpService[T, D]):
"""
UserHttpService sets the class methods to UserDeps which are inaccessible
to not logged in users.
"""
read_existing = BaseHttpService._existing_factory(UserDeps)
write_existing = BaseHttpService._existing_factory(UserDeps)
public = BaseHttpService._class_method_factory(UserDeps)
private = BaseHttpService._class_method_factory(UserDeps)
@abstractmethod
def populate_item(self) -> None:
...
class AdminHttpService(BaseHttpService[T, D]):
"""
AdminHttpService restricts the class methods to AdminDeps which are restricts
all class methods to users who are administrators.
"""
read_existing = BaseHttpService._existing_factory(AdminDeps)
write_existing = BaseHttpService._existing_factory(AdminDeps)
public = BaseHttpService._class_method_factory(AdminDeps)
private = BaseHttpService._class_method_factory(AdminDeps)
@abstractmethod
def populate_item(self) -> None:
...

View File

@@ -1,214 +0,0 @@
import inspect
from typing import Any, Callable, Optional, Sequence, Type, TypeVar, get_type_hints
from fastapi import APIRouter
from fastapi.params import Depends
from fastapi.types import DecoratedCallable
from pydantic import BaseModel
from .base_http_service import BaseHttpService
"""
This code is largely based off of the FastAPI Crud Router
https://github.com/awtkns/fastapi-crudrouter/blob/master/fastapi_crudrouter/core/_base.py
"""
T = TypeVar("T", bound=BaseModel)
S = TypeVar("S", bound=BaseHttpService)
DEPENDENCIES = Optional[Sequence[Depends]]
def get_return(func: Callable, default) -> Type:
return get_type_hints(func).get("return", default)
def get_func_args(func: Callable) -> Sequence[str]:
for _, value in get_type_hints(func).items():
if value:
return value
else:
return None
class RouterFactory(APIRouter):
schema: Type[T]
_base_path: str = "/"
def __init__(self, service: Type[S], prefix: Optional[str] = None, tags: Optional[list[str]] = None, *_, **kwargs):
"""
RouterFactory takes a concrete service class derived from the BaseHttpService class and returns common
CRUD Routes for the service. The following features are implmeneted in the RouterFactory:
1. API endpoint Descriptions are read from the docstrings of the methods in the passed in service class
2. Return types are inferred from the concrete service schema, or specified from the return type annotations.
This provides flexibility to return different types based on each route depending on client needs.
3. Arguemnt types are inferred for Post and Put routes where the first type annotated argument is the data that
is beging posted or updated. Note that this is only done for the first argument of the method.
4. The Get and Delete routes assume that you've defined the `write_existing` and `read_existing` methods in the
service class. The dependencies defined in the `write_existing` and `read_existing` methods are passed directly
to the FastAPI router and as such should include the `item_id` or equilivent argument.
"""
self.service: Type[S] = service
self.schema: Type[T] = service._schema
prefix = str(prefix or self.schema.__name__).lower()
prefix = self._base_path + prefix.strip("/")
tags = tags or [prefix.strip("/").capitalize()]
super().__init__(prefix=prefix, tags=tags, **kwargs)
if self.service.get_all:
self._add_api_route(
"",
self._get_all(),
methods=["GET"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Get All",
description=inspect.cleandoc(self.service.get_all.__doc__ or ""),
)
if self.service.create_one:
self._add_api_route(
"",
self._create(),
methods=["POST"],
response_model=self.schema,
summary="Create One",
status_code=201,
description=inspect.cleandoc(self.service.create_one.__doc__ or ""),
)
if self.service.update_many:
self._add_api_route(
"",
self._update_many(),
methods=["PUT"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Update Many",
description=inspect.cleandoc(self.service.update_many.__doc__ or ""),
)
if self.service.delete_all:
self._add_api_route(
"",
self._delete_all(),
methods=["DELETE"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Delete All",
description=inspect.cleandoc(self.service.delete_all.__doc__ or ""),
)
if self.service.populate_item:
self._add_api_route(
"/{item_id}",
self._get_one(),
methods=["GET"],
response_model=get_type_hints(self.service.populate_item).get("return", self.schema),
summary="Get One",
description=inspect.cleandoc(self.service.populate_item.__doc__ or ""),
)
if self.service.update_one:
self._add_api_route(
"/{item_id}",
self._update(),
methods=["PUT"],
response_model=self.schema,
summary="Update One",
description=inspect.cleandoc(self.service.update_one.__doc__ or ""),
)
if self.service.delete_one:
self._add_api_route(
"/{item_id}",
self._delete_one(),
methods=["DELETE"],
response_model=self.schema,
summary="Delete One",
description=inspect.cleandoc(self.service.delete_one.__doc__ or ""),
)
def _add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:
dependencies = []
super().add_api_route(path, endpoint, dependencies=dependencies, **kwargs)
def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
"""Overrides and exiting route if it exists"""
methods = kwargs["methods"] if "methods" in kwargs else ["GET"]
self.remove_api_route(path, methods)
return super().api_route(path, *args, **kwargs)
def get(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["Get"])
return super().get(path, *args, **kwargs)
def post(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["POST"])
return super().post(path, *args, **kwargs)
def put(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["PUT"])
return super().put(path, *args, **kwargs)
def delete(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["DELETE"])
return super().delete(path, *args, **kwargs)
def remove_api_route(self, path: str, methods: list[str]) -> None:
methods_ = set(methods)
for route in self.routes:
if route.path == f"{self.prefix}{path}" and route.methods == methods_:
self.routes.remove(route)
def _get_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
service_dep = getattr(self.service, "get_all_dep", self.service.private)
def route(service: S = Depends(service_dep)) -> T: # type: ignore
return service.get_all()
return route
def _get_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.item
return route
def _create(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
create_schema = get_func_args(self.service.create_one) or self.schema
def route(data: create_schema, service: S = Depends(self.service.private)) -> T: # type: ignore
return service.create_one(data)
return route
def _update(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
update_schema = get_func_args(self.service.update_one) or self.schema
def route(data: update_schema, service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.update_one(data)
return route
def _update_many(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
update_many_schema = get_func_args(self.service.update_many) or list[self.schema]
def route(data: update_many_schema, service: S = Depends(self.service.private)) -> T: # type: ignore
return service.update_many(data)
return route
def _delete_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.delete_one()
return route
def _delete_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
raise NotImplementedError
@staticmethod
def get_routes() -> list[str]:
return ["get_all", "create", "delete_all", "get_one", "update", "delete_one"]

View File

@@ -1,9 +1,11 @@
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.root_logger import get_logger
from mealie.lang import get_locale_provider
class BaseService:
def __init__(self) -> None:
self.app_dirs = get_app_dirs()
self.directories = get_app_dirs()
self.settings = get_app_settings()
self.t = get_locale_provider()
self.logger = get_logger()

View File

@@ -1 +0,0 @@
from .backup_service import *

View File

@@ -1,62 +0,0 @@
from __future__ import annotations
from functools import cached_property
from fastapi import HTTPException, status
from pydantic import UUID4
from mealie.schema.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper
from mealie.schema.response import ErrorResponse
from mealie.schema.user.user import GroupBase, GroupInDB
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService
from mealie.services.events import create_group_event
from mealie.services.group_services.group_utils import create_new_group
class AdminGroupService(
CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate],
AdminHttpService[UUID4, GroupInDB],
):
event_func = create_group_event
_schema = GroupInDB
@cached_property
def repo(self):
return self.db.groups
def populate_item(self, id: UUID4) -> GroupInDB:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[GroupInDB]:
return self.repo.get_all()
def create_one(self, data: GroupBase) -> GroupInDB:
return create_new_group(self.db, data)
def update_one(self, data: GroupAdminUpdate, item_id: UUID4 = None) -> GroupInDB:
target_id = item_id or data.id
if data.preferences:
preferences = self.db.group_preferences.get_one(value=target_id, key="group_id")
preferences = mapper(data.preferences, preferences)
self.item.preferences = self.db.group_preferences.update(target_id, preferences)
if data.name not in ["", self.item.name]:
self.item.name = data.name
self.item = self.repo.update(target_id, self.item)
return self.item
def delete_one(self, id: UUID4 = None) -> GroupInDB:
target_id = id or self.item.id
if len(self.item.users) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(message="Cannot delete group with users").dict(),
)
return self._delete_one(target_id)

View File

@@ -1,36 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.user.user import UserIn, UserOut
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService
from mealie.services.events import create_user_event
class AdminUserService(
CrudHttpMixins[UserOut, UserIn, UserIn],
AdminHttpService[int, UserOut],
):
event_func = create_user_event
_schema = UserOut
@cached_property
def repo(self):
return self.db.users
def populate_item(self, id: int) -> UserOut:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[UserOut]:
return self.repo.get_all()
def create_one(self, data: UserIn) -> UserOut:
return self._create_one(data)
def update_one(self, data: UserOut, item_id: int = None) -> UserOut:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> UserOut:
return self._delete_one(id)

View File

@@ -1,36 +0,0 @@
import operator
from mealie.schema.admin.backup import AllBackups, BackupFile, CreateBackup
from mealie.services._base_http_service import AdminHttpService
from mealie.services.events import create_backup_event
from .exporter import Exporter
class BackupHttpService(AdminHttpService):
event_func = create_backup_event
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.exporter = Exporter()
def get_all(self) -> AllBackups:
imports = []
for archive in self.app_dirs.BACKUP_DIR.glob("*.zip"):
backup = BackupFile(name=archive.name, date=archive.stat().st_ctime)
imports.append(backup)
templates = [template.name for template in self.app_dirs.TEMPLATE_DIR.glob("*.*")]
imports.sort(key=operator.attrgetter("date"), reverse=True)
return AllBackups(imports=imports, templates=templates)
def create_one(self, options: CreateBackup):
pass
def delete_one(self):
pass
class BackupService:
pass

View File

@@ -1,2 +0,0 @@
class Exporter:
pass

View File

@@ -1,21 +0,0 @@
from mealie.services._base_http_service import AdminHttpService
from .importer import Importer
class ImportHttpService(AdminHttpService):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.exporter = Importer()
def get_all(self):
pass
def get_one(self):
pass
def create(self):
pass
def delete_one(self):
pass

View File

@@ -1,2 +0,0 @@
class Importer:
pass

View File

@@ -288,28 +288,15 @@ def import_database(
import_settings=True,
import_users=True,
import_groups=True,
import_notifications=True,
force_import: bool = False,
rebase: bool = False,
**_,
):
import_session = ImportDatabase(user, session, archive, force_import)
recipe_report = []
if import_recipes:
recipe_report = import_session.import_recipes()
settings_report = []
if import_settings:
settings_report = import_session.import_settings()
group_report = []
if import_groups:
group_report = import_session.import_groups()
user_report = []
if import_users:
user_report = import_session.import_users()
recipe_report = import_session.import_recipes() if import_recipes else []
settings_report = import_session.import_settings() if import_settings else []
group_report = import_session.import_groups() if import_groups else []
user_report = import_session.import_users() if import_users else []
notification_report = []
import_session.clean_up()

View File

@@ -17,7 +17,7 @@ def create_general_event(title, text, session=None):
save_event(title=title, text=text, category=category, session=session)
def create_recipe_event(title, text, session=None, attachment=None):
def create_recipe_event(title, text, session=None, **_):
category = EventCategory.recipe
save_event(title=title, text=text, category=category, session=session)
@@ -27,16 +27,6 @@ def create_backup_event(title, text, session=None):
save_event(title=title, text=text, category=category, session=session)
def create_scheduled_event(title, text, session=None):
category = EventCategory.scheduled
save_event(title=title, text=text, category=category, session=session)
def create_migration_event(title, text, session=None):
category = EventCategory.migration
save_event(title=title, text=text, category=category, session=session)
def create_group_event(title, text, session=None):
category = EventCategory.group
save_event(title=title, text=text, category=category, session=session)

View File

@@ -1,3 +0,0 @@
from .cookbook_service import *
from .group_service import *
from .webhook_service import *

View File

@@ -1,61 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.core.root_logger import get_logger
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
from mealie.utils.error_messages import ErrorMessages
logger = get_logger(module=__name__)
class CookbookService(
CrudHttpMixins[CreateCookBook, ReadCookBook, UpdateCookBook],
UserHttpService[int, ReadCookBook],
):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadCookBook
@cached_property
def repo(self):
return self.db.cookbooks
def populate_item(self, item_id: int) -> RecipeCookBook:
try:
item_id = int(item_id)
except Exception:
pass
if isinstance(item_id, int):
self.item = self.repo.get_one(item_id, override_schema=RecipeCookBook)
else:
self.item = self.repo.get_one(item_id, key="slug", override_schema=RecipeCookBook)
def get_all(self) -> list[ReadCookBook]:
items = self.repo.get(self.group_id, "group_id", limit=999)
items.sort(key=lambda x: x.position)
return items
def create_one(self, data: CreateCookBook) -> ReadCookBook:
data = self.cast(data, SaveCookBook)
return self._create_one(data, ErrorMessages.cookbook_create_failure)
def update_one(self, data: UpdateCookBook, id: int = None) -> ReadCookBook:
return self._update_one(data, id)
def update_many(self, data: list[UpdateCookBook]) -> list[ReadCookBook]:
updated = []
for cookbook in data:
cb = self.repo.update(cookbook.id, cookbook)
updated.append(cb)
return updated
def delete_one(self, id: int = None) -> ReadCookBook:
return self._delete_one(id)

View File

@@ -1,108 +0,0 @@
from __future__ import annotations
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
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB, PrivateUser, UserOut
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.email import EmailService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class GroupSelfService(UserHttpService[int, str]):
_restrict_by_group = False
event_func = create_group_event
item: GroupInDB
@classmethod
def read_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().read_existing(item_id=0, deps=deps)
@classmethod
def write_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().write_existing(item_id=0, deps=deps)
@classmethod
def manage_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
if not deps.user.can_manage:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return super().write_existing(item_id=0, deps=deps)
def populate_item(self, _: str = None) -> GroupInDB:
self.item = self.db.groups.get(self.group_id)
return self.item
# ====================================================================
# Manage Menbers
def get_members(self) -> list[UserOut]:
return self.db.users.multi_query(query_by={"group_id": self.item.id}, override_schema=UserOut)
def set_member_permissions(self, permissions: SetPermissions) -> PrivateUser:
target_user = self.db.users.get(permissions.user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if target_user.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group")
target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage
target_user.can_organize = permissions.can_organize
return self.db.users.update(permissions.user_id, target_user)
# ====================================================================
# Meal Categories
def update_categories(self, new_categories: list[CategoryBase]):
self.item.categories = new_categories
return self.db.groups.update(self.group_id, self.item)
# ====================================================================
# Preferences
def update_preferences(self, new_preferences: UpdateGroupPreferences):
self.db.group_preferences.update(self.group_id, new_preferences)
return self.populate_item()
# ====================================================================
# Group Invites
def create_invite_token(self, uses: int = 1) -> None:
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=url_safe_token())
return self.db.group_invite_tokens.create(token)
def get_invite_tokens(self) -> list[ReadInviteToken]:
return self.db.group_invite_tokens.multi_query({"group_id": self.group_id})
def email_invitation(self, invite: EmailInvitation) -> EmailInitationResponse:
email_service = EmailService()
url = f"{self.settings.BASE_URL}/register?token={invite.token}"
success = False
error = None
try:
success = email_service.send_invitation(address=invite.email, invitation_url=url)
except Exception as e:
error = str(e)
return EmailInitationResponse(success=success, error=error)
# ====================================================================
# Export / Import Recipes

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
from datetime import date
from functools import cached_property
from mealie.core.root_logger import get_logger
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
from .._base_http_service.crud_http_mixins import CrudHttpMixins
from .._base_http_service.http_services import UserHttpService
from ..events import create_group_event
logger = get_logger(module=__name__)
class MealService(CrudHttpMixins[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry], UserHttpService[int, ReadPlanEntry]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadPlanEntry
item: ReadPlanEntry
@cached_property
def repo(self):
return self.db.meals
def populate_item(self, id: int) -> ReadPlanEntry:
self.item = self.repo.get_one(id)
return self.item
def get_slice(self, start: date = None, end: date = None) -> list[ReadPlanEntry]:
# 2 days ago
return self.repo.get_slice(start, end, group_id=self.group_id)
def get_today(self) -> list[ReadPlanEntry]:
return self.repo.get_today(group_id=self.group_id)
def create_one(self, data: CreatePlanEntry) -> ReadPlanEntry:
data = self.cast(data, SavePlanEntry)
return self._create_one(data)
def update_one(self, data: UpdatePlanEntry, id: int = None) -> ReadPlanEntry:
target_id = id or self.item.id
return self._update_one(data, target_id)
def delete_one(self, id: int = None) -> ReadPlanEntry:
target_id = id or self.item.id
return self._delete_one(target_id)

View File

@@ -1,54 +0,0 @@
from __future__ import annotations
from functools import cached_property
from pathlib import Path
from pydantic.types import UUID4
from mealie.core.root_logger import get_logger
from mealie.schema.group.group_migration import SupportedMigrations
from mealie.schema.reports.reports import ReportOut, ReportSummary
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
from mealie.services.migrations import ChowdownMigrator, NextcloudMigrator
from mealie.services.migrations.mealie_alpha import MealieAlphaMigrator
from mealie.services.migrations.paprika import PaprikaMigrator
logger = get_logger(module=__name__)
class GroupMigrationService(UserHttpService[int, ReportOut]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReportOut
@cached_property
def repo(self):
raise NotImplementedError
def populate_item(self, _: UUID4) -> ReportOut:
return None
def migrate(self, migration: SupportedMigrations, add_migration_tag: bool, archive: Path) -> ReportSummary:
args = {
"archive": archive,
"db": self.db,
"session": self.session,
"user_id": self.user.id,
"group_id": self.group_id,
"add_migration_tag": add_migration_tag,
}
if migration == SupportedMigrations.nextcloud:
self.migration_type = NextcloudMigrator(**args)
if migration == SupportedMigrations.chowdown:
self.migration_type = ChowdownMigrator(**args)
if migration == SupportedMigrations.paprika:
self.migration_type = PaprikaMigrator(**args)
if migration == SupportedMigrations.mealie_alpha:
self.migration_type = MealieAlphaMigrator(**args)
return self.migration_type.migrate(f"{migration.value.title()} Migration")

View File

@@ -1,31 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.core.root_logger import get_logger
from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class GroupReportService(CrudHttpMixins[ReportOut, ReportCreate, ReportCreate], UserHttpService[int, ReportOut]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReportOut
@cached_property
def repo(self):
return self.db.group_reports
def populate_item(self, id: int) -> ReportOut:
self.item = self.repo.get_one(id)
return self.item
def _get_all(self, report_type: ReportCategory = None) -> list[ReportSummary]:
return self.repo.multi_query({"group_id": self.group_id, "category": report_type}, limit=9999)
def delete_one(self, id: int = None) -> ReportOut:
return self._delete_one(id)

View File

@@ -1,30 +1,17 @@
from __future__ import annotations
from functools import cached_property
from pydantic import UUID4
from mealie.schema.group import ShoppingListCreate, ShoppingListOut, ShoppingListSummary
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group import ShoppingListOut
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
class ShoppingListService(
CrudHttpMixins[ShoppingListOut, ShoppingListCreate, ShoppingListCreate],
UserHttpService[int, ShoppingListOut],
):
event_func = create_group_event
_restrict_by_group = True
_schema = ShoppingListSummary
@cached_property
def repo(self):
return self.db.group_shopping_lists
class ShoppingListService:
def __init__(self, repos: AllRepositories):
self.repos = repos
self.repo = repos.group_shopping_lists
def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
recipe = self.db.recipes.get_one(recipe_id, "id")
recipe = self.repos.recipes.get_one(recipe_id, "id")
shopping_list = self.repo.get_one(list_id)
to_create = []

View File

@@ -1,39 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.core.root_logger import get_logger
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class WebhookService(CrudHttpMixins[ReadWebhook, CreateWebhook, CreateWebhook], UserHttpService[int, ReadWebhook]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadWebhook
@cached_property
def repo(self):
return self.db.webhooks
def populate_item(self, id: int) -> ReadWebhook:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[ReadWebhook]:
return self.repo.get(self.group_id, match_key="group_id", limit=9999)
def create_one(self, data: CreateWebhook) -> ReadWebhook:
data = self.cast(data, SaveWebhook)
return self._create_one(data)
def update_one(self, data: CreateWebhook, item_id: int = None) -> ReadWebhook:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> ReadWebhook:
return self._delete_one(id)

View File

@@ -1,5 +1,4 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
import requests
@@ -8,18 +7,6 @@ from mealie.core import root_logger
from mealie.schema.recipe import Recipe
from mealie.services.image import minify
logger = root_logger.get_logger()
@dataclass
class ImageOptions:
ORIGINAL_IMAGE: str = "original.webp"
MINIFIED_IMAGE: str = "min-original.webp"
TINY_IMAGE: str = "tiny-original.webp"
IMG_OPTIONS = ImageOptions()
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
image_dir = Recipe(slug=recipe_slug).image_dir
@@ -42,6 +29,7 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
def scrape_image(image_url: str, slug: str) -> Path:
logger = root_logger.get_logger()
logger.info(f"Image URL: {image_url}")
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"

View File

@@ -1,2 +1,4 @@
from .chowdown import *
from .mealie_alpha import *
from .nextcloud import *
from .paprika import *

View File

@@ -1,2 +1 @@
from .ingredient_parser import *
from .ingredient_parser_service import *

View File

@@ -8,38 +8,6 @@ def joinLine(columns):
return "\t".join(columns)
def cleanUnicodeFractions(s):
"""
Replace unicode fractions with ascii representation, preceded by a
space.
"1\x215e" => "1 7/8"
"""
fractions = {
"\x215b": "1/8",
"\x215c": "3/8",
"\x215d": "5/8",
"\x215e": "7/8",
"\x2159": "1/6",
"\x215a": "5/6",
"\x2155": "1/5",
"\x2156": "2/5",
"\x2157": "3/5",
"\x2158": "4/5",
"\xbc": " 1/4",
"\xbe": "3/4",
"\x2153": "1/3",
"\x2154": "2/3",
"\xbd": "1/2",
}
for f_unicode, f_ascii in fractions.items():
s = s.replace(f_unicode, " " + f_ascii)
return s
def unclump(s):
"""
Replacess $'s with spaces. The reverse of clumpFractions.
@@ -47,15 +15,6 @@ def unclump(s):
return re.sub(r"\$", " ", s)
def normalizeToken(s):
"""
ToDo: FIX THIS. We used to use the pattern.en package to singularize words, but
in the name of simple deployments, we took it out. We should fix this at some
point.
"""
return singularize(s)
def getFeatures(token, index, tokens):
"""
Returns a list of features for a given token.

View File

@@ -21,8 +21,9 @@ class ABCIngredientParser(ABC):
Abstract class for ingredient parsers.
"""
@abstractmethod
def parse_one(self, ingredient_string: str) -> ParsedIngredient:
pass
...
@abstractmethod
def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
@@ -97,6 +98,10 @@ class NLPParser(ABCIngredientParser):
crf_models = crfpp.convert_list_to_crf_model(ingredients)
return [self._crf_to_ingredient(crf_model) for crf_model in crf_models]
def parse_one(self, ingredient: str) -> ParsedIngredient:
items = self.parse_one([ingredient])
return items[0]
__registrar = {
RegisteredParser.nlp: NLPParser,

View File

@@ -1,33 +0,0 @@
from mealie.schema.recipe import RecipeIngredient
from mealie.services._base_http_service.http_services import UserHttpService
from .ingredient_parser import ABCIngredientParser, RegisteredParser, get_parser
class IngredientParserService(UserHttpService):
parser: ABCIngredientParser
def __init__(self, parser: RegisteredParser = RegisteredParser.nlp, *args, **kwargs) -> None:
self.set_parser(parser)
super().__init__(*args, **kwargs)
def set_parser(self, parser: RegisteredParser) -> None:
self.parser = get_parser(parser)
def populate_item(self) -> None:
"""Satisfy abstract method"""
pass
def parse_ingredient(self, ingredient: str) -> RecipeIngredient:
parsed = self.parser.parse([ingredient])
if parsed:
return parsed[0]
# TODO: Raise Exception
def parse_ingredients(self, ingredients: list[str]) -> list[RecipeIngredient]:
parsed = self.parser.parse(ingredients)
if parsed:
return parsed
# TODO: Raise Exception

View File

@@ -1,40 +0,0 @@
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.user.user import PrivateUser
step_text = """Recipe steps as well as other fields in the recipe page support markdown syntax.
**Add a link**
[My Link](https://beta.mealie.io)
**Imbed an image**
Use the `height="100"` or `width="100"` attributes to set the size of the image.
<img height="100" src="https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=960&q=80"></img>
"""
ingredient_note = "1 Cup Flour"
def recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
if not additional_attrs.get("recipe_ingredient"):
additional_attrs["recipe_ingredient"] = [RecipeIngredient(note=ingredient_note)]
if not additional_attrs.get("recipe_instructions"):
additional_attrs["recipe_instructions"] = [RecipeStep(text=step_text)]
return Recipe(**additional_attrs)

View File

@@ -1,33 +1,29 @@
from __future__ import annotations
from pathlib import Path
from mealie.core.root_logger import get_logger
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe import CategoryBase, Recipe
from mealie.schema.recipe import CategoryBase
from mealie.schema.recipe.recipe_category import TagBase
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
from mealie.services.exporter import Exporter, RecipeExporter
logger = get_logger(__name__)
class RecipeBulkActions(UserHttpService[int, Recipe]):
event_func = create_recipe_event
_restrict_by_group = True
def populate_item(self, _: int) -> Recipe:
return
class RecipeBulkActionsService(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
super().__init__()
def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
recipe_exporter = RecipeExporter(self.db, self.group_id, slugs)
exporter = Exporter(self.group_id, temp_path, [recipe_exporter])
recipe_exporter = RecipeExporter(self.repos, self.group.id, slugs)
exporter = Exporter(self.group.id, temp_path, [recipe_exporter])
exporter.run(self.db)
exporter.run(self.repos)
def get_exports(self) -> list[GroupDataExport]:
return self.db.group_exports.multi_query({"group_id": self.group_id})
return self.repos.group_exports.multi_query({"group_id": self.group.id})
def purge_exports(self) -> int:
all_exports = self.get_exports()
@@ -36,13 +32,13 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
for export in all_exports:
try:
Path(export.path).unlink(missing_ok=True)
self.db.group_exports.delete(export.id)
self.repos.group_exports.delete(export.id)
exports_deleted += 1
except Exception as e:
logger.error(f"Failed to delete export {export.id}")
logger.error(e)
self.logger.error(f"Failed to delete export {export.id}")
self.logger.error(e)
group = self.db.groups.get_one(self.group_id)
group = self.repos.groups.get_one(self.group.id)
for match in group.directory.glob("**/export/*zip"):
if match.is_file():
@@ -53,38 +49,38 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None:
for slug in recipes:
recipe = self.db.recipes.get_one(slug)
recipe = self.repos.recipes.get_one(slug)
if recipe is None:
logger.error(f"Failed to tag recipe {slug}, no recipe found")
self.logger.error(f"Failed to tag recipe {slug}, no recipe found")
recipe.tags += tags
try:
self.db.recipes.update(slug, recipe)
self.repos.recipes.update(slug, recipe)
except Exception as e:
logger.error(f"Failed to tag recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to tag recipe {slug}")
self.logger.error(e)
def assign_categories(self, recipes: list[str], categories: list[CategoryBase]) -> None:
for slug in recipes:
recipe = self.db.recipes.get_one(slug)
recipe = self.repos.recipes.get_one(slug)
if recipe is None:
logger.error(f"Failed to categorize recipe {slug}, no recipe found")
self.logger.error(f"Failed to categorize recipe {slug}, no recipe found")
recipe.recipe_category += categories
try:
self.db.recipes.update(slug, recipe)
self.repos.recipes.update(slug, recipe)
except Exception as e:
logger.error(f"Failed to categorize recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to categorize recipe {slug}")
self.logger.error(e)
def delete_recipes(self, recipes: list[str]) -> None:
for slug in recipes:
try:
self.db.recipes.delete(slug)
self.repos.recipes.delete(slug)
except Exception as e:
logger.error(f"Failed to delete recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to delete recipe {slug}")
self.logger.error(e)

View File

@@ -1,52 +0,0 @@
from __future__ import annotations
from functools import cached_property
from uuid import UUID
from fastapi import HTTPException
from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate,
RecipeCommentOut,
RecipeCommentSave,
RecipeCommentUpdate,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeCommentsService(
CrudHttpMixins[RecipeCommentOut, RecipeCommentCreate, RecipeCommentCreate],
UserHttpService[UUID, RecipeCommentOut],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeCommentOut
@cached_property
def repo(self):
return self.db.comments
def _check_comment_belongs_to_user(self) -> None:
if self.item.user_id != self.user.id and not self.user.admin:
raise HTTPException(detail="Comment does not belong to user")
def populate_item(self, id: UUID) -> RecipeCommentOut:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[RecipeCommentOut]:
return self.repo.get_all()
def create_one(self, data: RecipeCommentCreate) -> RecipeCommentOut:
save_data = RecipeCommentSave(text=data.text, user_id=self.user.id, recipe_id=data.recipe_id)
return self._create_one(save_data)
def update_one(self, data: RecipeCommentUpdate, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._update_one(data, item_id)
def delete_one(self, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._delete_one(item_id)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeFoodService(
CrudHttpMixins[IngredientFood, CreateIngredientFood, CreateIngredientFood],
UserHttpService[int, IngredientFood],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = IngredientFood
@cached_property
def repo(self):
return self.db.ingredient_foods
def populate_item(self, id: int) -> IngredientFood:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[IngredientFood]:
return self.repo.get_all()
def create_one(self, data: CreateIngredientFood) -> IngredientFood:
return self._create_one(data)
def update_one(self, data: IngredientFood, item_id: int = None) -> IngredientFood:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> IngredientFood:
return self._delete_one(id)

View File

@@ -1,111 +1,123 @@
import json
import shutil
from functools import cached_property
from pathlib import Path
from shutil import copytree, rmtree
from typing import Union
from zipfile import ZipFile
from fastapi import Depends, HTTPException, UploadFile, status
from sqlalchemy import exc
from fastapi import UploadFile
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
from mealie.core import exceptions
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
from mealie.services.image.image import write_image
from mealie.services.recipe.mixins import recipe_creation_factory
from .template_service import TemplateService
logger = get_logger(module=__name__)
step_text = """Recipe steps as well as other fields in the recipe page support markdown syntax.
**Add a link**
[My Link](https://beta.mealie.io)
**Imbed an image**
Use the `height="100"` or `width="100"` attributes to set the size of the image.
<img height="100" src="https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=960&q=80"></img>
"""
ingredient_note = "1 Cup Flour"
class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpService[str, Recipe]):
"""
Class Methods:
`read_existing`: Reads an existing recipe from the database.
`write_existing`: Updates an existing recipe in the database.
`base`: Requires write permissions, but doesn't perform recipe checks
"""
class RecipeService(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
super().__init__()
event_func = create_recipe_event
def _get_recipe(self, slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.")
return recipe
@cached_property
def exception_key(self) -> dict:
return {exc.IntegrityError: self.t("recipe.unique-name-error")}
def can_update(self, recipe: Recipe) -> bool:
return recipe.settings.locked is False or self.user.id == recipe.user_id
@cached_property
def repo(self) -> RepositoryRecipes:
return self.db.recipes.by_group(self.group_id)
def can_lock_unlock(self, recipe: Recipe) -> bool:
return recipe.user_id == self.user.id
@classmethod
def write_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps)
def check_assets(self, recipe: Recipe, original_slug: str) -> None:
"""Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug."""
if original_slug != recipe.slug:
current_dir = self.directories.RECIPE_DATA_DIR.joinpath(original_slug)
@classmethod
def read_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps)
try:
copytree(current_dir, recipe.directory, dirs_exist_ok=True)
self.logger.info(f"Renaming Recipe Directory: {original_slug} -> {recipe.slug}")
except FileNotFoundError:
self.logger.error(f"Recipe Directory not Found: {original_slug}")
def assert_existing(self, slug: str):
self.populate_item(slug)
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
all_asset_files = [x.file_name for x in recipe.assets]
if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN)
for file in recipe.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
if file.name not in all_asset_files:
file.unlink()
def can_update(self) -> bool:
if self.item.settings.locked and self.user.id != self.item.user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def delete_assets(self, recipe: Recipe) -> None:
recipe_dir = recipe.directory
rmtree(recipe_dir, ignore_errors=True)
self.logger.info(f"Recipe Directory Removed: {recipe.slug}")
return True
@staticmethod
def _recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
def get_all(self, start=0, limit=None, load_foods=False) -> list[RecipeSummary]:
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit, load_foods=load_foods)
if not additional_attrs.get("recipe_ingredient"):
additional_attrs["recipe_ingredient"] = [RecipeIngredient(note=ingredient_note)]
new_items = []
if not additional_attrs.get("recipe_instructions"):
additional_attrs["recipe_instructions"] = [RecipeStep(text=step_text)]
for item in items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__
if load_foods:
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
new_items.append(new_item)
return [RecipeSummary.construct(**x) for x in new_items]
return Recipe(**additional_attrs)
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
group = self.db.groups.get(self.group_id, "id")
create_data: Recipe = recipe_creation_factory(
create_data: Recipe = self._recipe_creation_factory(
self.user,
name=create_data.name,
additional_attrs=create_data.dict(),
)
create_data.settings = RecipeSettings(
public=group.preferences.recipe_public,
show_nutrition=group.preferences.recipe_show_nutrition,
show_assets=group.preferences.recipe_show_assets,
landscape_view=group.preferences.recipe_landscape_view,
disable_comments=group.preferences.recipe_disable_comments,
disable_amount=group.preferences.recipe_disable_amount,
public=self.group.preferences.recipe_public,
show_nutrition=self.group.preferences.recipe_show_nutrition,
show_assets=self.group.preferences.recipe_show_assets,
landscape_view=self.group.preferences.recipe_landscape_view,
disable_comments=self.group.preferences.recipe_disable_comments,
disable_amount=self.group.preferences.recipe_disable_amount,
)
self._create_one(create_data, self.t("generic.server-error"), self.exception_key)
self._create_event(
"Recipe Created",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
)
return self.item
return self.repos.recipes.create(create_data)
def create_from_zip(self, archive: UploadFile, temp_path: Path) -> Recipe:
"""
@@ -127,69 +139,46 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
with myzip.open(file) as myfile:
recipe_image = myfile.read()
self.create_one(Recipe(**recipe_dict))
recipe = self.create_one(Recipe(**recipe_dict))
if self.item:
write_image(self.item.slug, recipe_image, "webp")
if recipe:
write_image(recipe.slug, recipe_image, "webp")
return self.item
return recipe
def update_one(self, update_data: Recipe) -> Recipe:
self.can_update()
def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
recipe = self._get_recipe(slug)
if not self.can_update(recipe):
raise exceptions.PermissionDenied("You do not have permission to edit this recipe.")
if recipe.settings.locked != new_data.settings.locked and not self.can_lock_unlock(recipe):
raise exceptions.PermissionDenied("You do not have permission to lock/unlock this recipe.")
if self.item.settings.locked != update_data.settings.locked and self.item.user_id != self.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return recipe
original_slug = self.item.slug
self._update_one(update_data, original_slug)
def update_one(self, slug: str, update_data: Recipe) -> Recipe:
recipe = self._pre_update_check(slug, update_data)
new_data = self.repos.recipes.update(slug, update_data)
self.check_assets(new_data, recipe.slug)
return new_data
self.check_assets(original_slug)
return self.item
def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
recipe = self._pre_update_check(slug, patch_data)
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
new_data = self.repos.recipes.patch(recipe.slug, patch_data)
def patch_one(self, patch_data: Recipe) -> Recipe:
self.can_update()
self.check_assets(new_data, recipe.slug)
return new_data
original_slug = self.item.slug
self._patch_one(patch_data, original_slug)
self.check_assets(original_slug)
return self.item
def delete_one(self) -> Recipe:
self.can_update()
self._delete_one(self.item.slug)
self.delete_assets()
self._create_event("Recipe Delete", f"'{self.item.name}' deleted by {self.user.full_name}")
return self.item
def check_assets(self, original_slug: str) -> None:
"""Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug."""
if original_slug != self.item.slug:
current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug)
try:
copytree(current_dir, self.item.directory, dirs_exist_ok=True)
logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.item.slug}")
except FileNotFoundError:
logger.error(f"Recipe Directory not Found: {original_slug}")
all_asset_files = [x.file_name for x in self.item.assets]
for file in self.item.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
if file.name not in all_asset_files:
file.unlink()
def delete_assets(self) -> None:
recipe_dir = self.item.directory
rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {self.item.slug}")
def delete_one(self, slug) -> Recipe:
recipe = self._get_recipe(slug)
self.can_update(recipe)
data = self.repos.recipes.delete(slug)
self.delete_assets(data)
return data
# =================================================================
# Recipe Template Methods
def render_template(self, temp_dir: Path, template: str = None) -> Path:
def render_template(self, recipe: Recipe, temp_dir: Path, template: str = None) -> Path:
t_service = TemplateService(temp_dir)
return t_service.render(self.item, template)
return t_service.render(recipe, template)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe import RecipeTool, RecipeToolCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeToolService(
CrudHttpMixins[RecipeTool, RecipeToolCreate, RecipeToolCreate],
UserHttpService[int, RecipeTool],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeTool
@cached_property
def repo(self):
return self.db.tools
def populate_item(self, id: int) -> RecipeTool:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[RecipeTool]:
return self.repo.get_all()
def create_one(self, data: RecipeToolCreate) -> RecipeTool:
return self._create_one(data)
def update_one(self, data: RecipeTool, item_id: int = None) -> RecipeTool:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> RecipeTool:
return self._delete_one(id)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeUnitService(
CrudHttpMixins[IngredientUnit, CreateIngredientUnit, CreateIngredientUnit],
UserHttpService[int, IngredientUnit],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = IngredientUnit
@cached_property
def repo(self):
return self.db.ingredient_units
def populate_item(self, id: int) -> IngredientUnit:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[IngredientUnit]:
return self.repo.get_all()
def create_one(self, data: CreateIngredientUnit) -> IngredientUnit:
return self._create_one(data)
def update_one(self, data: IngredientUnit, item_id: int = None) -> IngredientUnit:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> IngredientUnit:
return self._delete_one(id)

View File

@@ -32,7 +32,7 @@ class TemplateService(BaseService):
Returns a list of all templates available to render.
"""
return {
TemplateType.jinja2.value: [x.name for x in self.app_dirs.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.jinja2.value: [x.name for x in self.directories.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.json.value: ["raw"],
TemplateType.zip.value: ["zip"],
}
@@ -98,7 +98,7 @@ class TemplateService(BaseService):
"""
self.__check_temp(self._render_jinja2)
j2_template: Path = self.app_dirs.TEMPLATE_DIR / j2_template
j2_template: Path = self.directories.TEMPLATE_DIR / j2_template
if not j2_template.is_file():
raise FileNotFoundError(f"Template '{j2_template}' not found.")

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Tuple
@@ -12,7 +10,7 @@ class Cron:
minutes: int
@classmethod
def parse(cls, time_str: str) -> Cron:
def parse(cls, time_str: str) -> "Cron":
time = time_str.split(":")
return Cron(hours=int(time[0]), minutes=int(time[1]))

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import Callable, Iterable
from mealie.core import root_logger

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from pathlib import Path
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore

View File

@@ -72,11 +72,6 @@ def category(category: str):
return []
def clean_html(raw_html):
cleanr = re.compile("<.*?>")
return re.sub(cleanr, "", raw_html)
def clean_nutrition(nutrition: Optional[dict]) -> dict[str, str]:
# Assumes that all units are supplied in grams, except sodium which may be in mg.

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import Type
from mealie.schema.recipe.recipe import Recipe

View File

@@ -1,10 +1,7 @@
from __future__ import annotations
from enum import Enum
from uuid import uuid4
from fastapi import HTTPException, status
from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
from slugify import slugify
from mealie.core.root_logger import get_logger
@@ -13,7 +10,11 @@ from mealie.services.image.image import scrape_image
from .recipe_scraper import RecipeScraper
logger = get_logger()
class ParserErrors(str, Enum):
BAD_RECIPE_DATA = "BAD_RECIPE_DATA"
NO_RECIPE_DATA = "NO_RECIPE_DATA"
CONNECTION_ERROR = "CONNECTION_ERROR"
def create_from_url(url: str) -> Recipe:
@@ -32,6 +33,7 @@ def create_from_url(url: str) -> Recipe:
if not new_recipe:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.BAD_RECIPE_DATA.value})
logger = get_logger()
logger.info(f"Image {new_recipe.image}")
new_recipe.image = download_image_for_recipe(new_recipe.slug, new_recipe.image)
@@ -42,60 +44,13 @@ def create_from_url(url: str) -> Recipe:
return new_recipe
class ParserErrors(str, Enum):
BAD_RECIPE_DATA = "BAD_RECIPE_DATA"
NO_RECIPE_DATA = "NO_RECIPE_DATA"
CONNECTION_ERROR = "CONNECTION_ERROR"
def scrape_from_url(url: str):
"""Entry function to scrape a recipe from a url
This will determine if a url can be parsed and return None if not, to allow another parser to try.
This keyword is used on the frontend to reference a localized string to present on the UI.
Args:
url (str): String Representing the URL
Raises:
HTTPException: 400_BAD_REQUEST - See ParserErrors Class for Key Details
Returns:
Optional[Scraped schema for cleaning]
"""
try:
scraped_schema = scrape_me(url)
except (WebsiteNotImplementedError, AttributeError):
try:
scraped_schema = scrape_me(url, wild_mode=True)
except (NoSchemaFoundInWildMode, AttributeError):
logger.error("Recipe Scraper was unable to extract a recipe.")
return None
except ConnectionError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.CONNECTION_ERROR.value})
# Check to see if the recipe is valid
try:
ingredients = scraped_schema.ingredients()
instruct = scraped_schema.instructions()
except Exception:
ingredients = []
instruct = []
if instruct and ingredients:
return scraped_schema
# recipe_scrapers did not get a valid recipe.
# Return None to let another scraper try.
return None
def download_image_for_recipe(slug, image_url) -> str | None:
img_name = None
try:
img_path = scrape_image(image_url, slug)
img_name = img_path.name
except Exception as e:
logger = get_logger()
logger.error(f"Error Scraping Image: {e}")
img_name = None

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable, Tuple
@@ -41,10 +39,6 @@ class ABCScraperStrategy(ABC):
class RecipeScraperPackage(ABCScraperStrategy):
"""
Abstract class for all recipe parsers.
"""
def clean_scraper(self, scraped_data: SchemaScraperFactory.SchemaScraper, url: str) -> Recipe:
def try_get_default(func_call: Callable, get_attr: str, default: Any, clean_func=None):
value = default

View File

@@ -1,2 +1 @@
from .background_executory import *
from .tasks_http_service import *

View File

@@ -2,15 +2,23 @@ from random import getrandbits
from time import sleep
from typing import Any, Callable
from fastapi import BackgroundTasks
from pydantic import UUID4
from sqlalchemy.orm import Session
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.server.tasks import ServerTask, ServerTaskCreate, ServerTaskNames
from .._base_http_service.http_services import UserHttpService
class BackgroundExecutor:
sleep_time = 60
def __init__(self, group_id: UUID4, repos: AllRepositories, bg: BackgroundTasks) -> None:
self.group_id = group_id
self.repos = repos
self.background_tasks = bg
class BackgroundExecutor(UserHttpService):
def populate_item(self, _: int) -> ServerTask:
pass
@@ -24,9 +32,9 @@ class BackgroundExecutor(UserHttpService):
"""
server_task = ServerTaskCreate(group_id=self.group_id, name=task_name)
server_task = self.db.server_tasks.create(server_task)
server_task = self.repos.server_tasks.create(server_task)
self.background_tasks.add_task(func, *args, **kwargs, task_id=server_task.id, session=self.session)
self.background_tasks.add_task(func, *args, **kwargs, task_id=server_task.id, session=self.repos.session)
return server_task
@@ -38,7 +46,7 @@ def test_executor_func(task_id: int, session: Session) -> None:
task.append_log("test task has started")
task.append_log("test task sleeping for 60 seconds")
sleep(60)
sleep(BackgroundExecutor.sleep_time)
task.append_log("test task has finished sleep")

View File

@@ -1,36 +0,0 @@
from functools import cached_property
from mealie.schema.server import ServerTask
from mealie.services._base_http_service.http_services import AdminHttpService, UserHttpService
class ServerTasksHttpService(UserHttpService[int, ServerTask]):
_restrict_by_group = True
_schema = ServerTask
@cached_property
def repo(self):
return self.db.server_tasks
def populate_item(self, id: int) -> ServerTask:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[ServerTask]:
return self.repo.multi_query(query_by={"group_id": self.group_id}, order_by="created_at")
class AdminServerTasks(AdminHttpService[int, ServerTask]):
_restrict_by_group = True
_schema = ServerTask
@cached_property
def repo(self):
return self.db.server_tasks
def populate_item(self, id: int) -> ServerTask:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[ServerTask]:
return self.repo.get_all(order_by="created_at")

View File

@@ -1,51 +0,0 @@
from functools import cached_property
from pydantic import UUID4
from mealie.schema.recipe.recipe_share_token import (
RecipeShareToken,
RecipeShareTokenCreate,
RecipeShareTokenSave,
RecipeShareTokenSummary,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class SharedRecipeService(
CrudHttpMixins[RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenCreate],
UserHttpService[UUID4, RecipeShareToken],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeShareToken
@cached_property
def repo(self):
return self.db.recipe_share_tokens
def populate_item(self, id: UUID4) -> RecipeShareToken:
self.item = self.repo.get_one(id)
return self.item
def get_all(self, recipe_id=None) -> list[RecipeShareTokenSummary]:
# sourcery skip: assign-if-exp, inline-immediately-returned-variable
if recipe_id:
return self.db.recipe_share_tokens.multi_query(
{"group_id": self.group_id, "recipe_id": recipe_id},
override_schema=RecipeShareTokenSummary,
)
else:
return self.db.recipe_share_tokens.multi_query(
{"group_id": self.group_id}, override_schema=RecipeShareTokenSummary
)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id)
return self._create_one(save_data)
def delete_one(self, item_id: UUID4 = None) -> None:
item_id = item_id or self.item.id
return self.repo.delete(item_id)

View File

@@ -1 +0,0 @@
from .user_service import *

View File

@@ -1,56 +1,23 @@
from logging import Logger
from uuid import uuid4
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_user_event
from mealie.services.group_services.group_utils import create_new_group
logger = get_logger(module=__name__)
class RegistrationService:
logger: Logger
repos: AllRepositories
class RegistrationService(PublicHttpService[int, str]):
event_func = create_user_event
def populate_item() -> None:
pass
def register_user(self, registration: CreateUserRegistration) -> PrivateUser:
self.registration = registration
logger.info(f"Registering user {registration.username}")
token_entry = None
new_group = False
if registration.group:
new_group = True
group = self._register_new_group()
elif registration.group_token and 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)
else:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
user = self._create_new_user(group, new_group)
if token_entry and user:
token_entry.uses_left = token_entry.uses_left - 1
if token_entry.uses_left == 0:
self.db.group_invite_tokens.delete(token_entry.token)
else:
self.db.group_invite_tokens.update(token_entry.token, token_entry)
return user
def __init__(self, logger: Logger, db: AllRepositories):
self.logger = logger
self.repos = db
def _create_new_user(self, group: GroupInDB, new_group=bool) -> PrivateUser:
new_user = UserIn(
@@ -65,7 +32,7 @@ class RegistrationService(PublicHttpService[int, str]):
can_organize=new_group,
)
return self.db.users.create(new_user)
return self.repos.users.create(new_user)
def _register_new_group(self) -> GroupInDB:
group_data = GroupBase(name=self.registration.group)
@@ -82,4 +49,36 @@ class RegistrationService(PublicHttpService[int, str]):
recipe_disable_amount=self.registration.advanced,
)
return create_new_group(self.db, group_data, group_preferences)
return create_new_group(self.repos, group_data, group_preferences)
def register_user(self, registration: CreateUserRegistration) -> PrivateUser:
self.registration = registration
self.logger.info(f"Registering user {registration.username}")
token_entry = None
new_group = False
if registration.group:
new_group = True
group = self._register_new_group()
elif registration.group_token and registration.group_token != "":
token_entry = self.repos.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.repos.groups.get(token_entry.group_id)
else:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
user = self._create_new_user(group, new_group)
if token_entry and user:
token_entry.uses_left = token_entry.uses_left - 1
if token_entry.uses_left == 0:
self.repos.group_invite_tokens.delete(token_entry.token)
else:
self.repos.group_invite_tokens.update(token_entry.token, token_entry)
return user

View File

@@ -1,50 +0,0 @@
from pathlib import Path
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password, verify_password
from mealie.schema.user.user import ChangePassword, PrivateUser
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_user_event
logger = get_logger(module=__name__)
class UserService(UserHttpService[int, str]):
event_func = create_user_event
acting_user: PrivateUser = None
def populate_item(self, item_id: int) -> None:
self.acting_user = self.db.users.get_one(item_id)
return self.acting_user
def assert_existing(self, id) -> PrivateUser:
self.populate_item(id)
self._populate_target_user(id)
self._assert_user_change_allowed()
return self.target_user
def _assert_user_change_allowed(self) -> None:
if self.acting_user.id != self.target_user.id and not self.acting_user.admin:
# only admins can edit other users
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN")
def _populate_target_user(self, id: int = None):
if id:
self.target_user = self.db.users.get(id)
if not self.target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND)
else:
self.target_user = self.acting_user
def change_password(self, password_change: ChangePassword) -> PrivateUser:
if not verify_password(password_change.current_password, self.target_user.password):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
self.target_user.password = hash_password(password_change.new_password)
return self.db.users.update_password(self.target_user.id, self.target_user.password)
def set_profile_picture(self, file_path: Path) -> PrivateUser:
pass