mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-06 16:03:12 -05:00
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:
@@ -1,2 +0,0 @@
|
||||
from .http_services import *
|
||||
from .router_factory import *
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
...
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .backup_service import *
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
class Exporter:
|
||||
pass
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
class Importer:
|
||||
pass
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .cookbook_service import *
|
||||
from .group_service import *
|
||||
from .webhook_service import *
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from .chowdown import *
|
||||
from .mealie_alpha import *
|
||||
from .nextcloud import *
|
||||
from .paprika import *
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
from .ingredient_parser import *
|
||||
from .ingredient_parser_service import *
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from mealie.core import root_logger
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
from .background_executory import *
|
||||
from .tasks_http_service import *
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
from .user_service import *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user