Refactor/conver to controllers (#923)

* add dependency injection for get_repositories

* convert events api to controller

* update generic typing

* add abstract controllers

* update test naming

* migrate admin services to controllers

* add additional admin route tests

* remove print

* add public shared dependencies

* add types

* fix typo

* add static variables for recipe json keys

* add coverage gutters config

* update controller routers

* add generic success response

* add category/tag/tool tests

* add token refresh test

* add coverage utilities

* covert comments to controller

* add todo

* add helper properties

* delete old service

* update test notes

* add unit test for pretty_stats

* remove dead code from post_webhooks

* update group routes to use controllers

* add additional group test coverage

* abstract common permission checks

* convert ingredient parser to controller

* update recipe crud to use controller

* remove dead-code

* add class lifespan tracker for debugging

* convert bulk export to controller

* migrate tools router to controller

* update recipe share to controller

* move customer router to _base

* ignore prints in flake8

* convert units and foods to new controllers

* migrate user routes to controllers

* centralize error handling

* fix invalid ref

* reorder fields

* update routers to share common handling

* update tests

* remove prints

* fix cookbooks delete

* fix cookbook get

* add controller for mealplanner

* cover report routes to controller

* remove __future__ imports

* remove dead code

* remove all base_http children and remove dead code
This commit is contained in:
Hayden 2022-01-13 13:06:52 -09:00 committed by GitHub
parent 5823a32daf
commit c4540f1395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
164 changed files with 3111 additions and 3213 deletions

1
.gitignore vendored
View File

@ -157,3 +157,4 @@ mealie/services/scraper/ingredient_nlp/model.crfmodel
dev/code-generation/generated/openapi.json
dev/code-generation/generated/test_routes.py
mealie/services/parser_services/crfpp/model.crfmodel
lcov.info

View File

@ -37,7 +37,7 @@
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.pylintEnabled": false,
"python.pythonPath": ".venv/bin/python3.9",
"python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/.pylintrc"],
"python.testing.autoTestDiscoverOnSaveEnabled": false,
"python.testing.pytestArgs": ["tests"],
"python.testing.pytestEnabled": true,
@ -45,5 +45,6 @@
"python.analysis.typeCheckingMode": "off",
"search.mode": "reuseEditor",
"vetur.validation.template": false,
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort"
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
"coverage-gutters.lcovname": "${workspaceFolder}/.coverage"
}

View File

@ -1,5 +1,3 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path

View File

@ -51,8 +51,8 @@
event: 'save',
},
]"
@delete="actions.deleteOne(webhook.id)"
@save="actions.updateOne(webhook)"
@delete="actions.deleteOne(cookbook.id)"
@save="actions.updateOne(cookbook)"
/>
</v-card-actions>
</v-expansion-panel-content>

View File

@ -59,11 +59,10 @@ lint: ## 🧺 Format, Check and Flake8
poetry run black .
poetry run flake8 mealie tests
coverage: ## ☂️ Check code coverage quickly with the default Python
poetry run pytest
poetry run pytest
poetry run coverage report -m
poetry run coveragepy-lcov
poetry run coverage html
$(BROWSER) htmlcov/index.html

View File

@ -9,7 +9,6 @@ from mealie.routes import backup_routes, router, utility_routes
from mealie.routes.about import about_router
from mealie.routes.handlers import register_debug_handler
from mealie.routes.media import media_router
from mealie.routes.site_settings import settings_router
from mealie.services.events import create_general_event
from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
@ -50,7 +49,6 @@ def api_routers():
app.include_router(router)
app.include_router(media_router)
app.include_router(about_router)
app.include_router(settings_router)
app.include_router(backup_routes.router)
app.include_router(utility_routes.router)

View File

@ -1,2 +1 @@
from .dependencies import *
from .grouped import *

View File

@ -1,74 +0,0 @@
from fastapi import BackgroundTasks, Depends
from sqlalchemy.orm.session import Session
from mealie.schema.user.user import PrivateUser
from .dependencies import generate_session, get_admin_user, get_current_user, is_logged_in
class RequestContext:
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(get_current_user),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: bool = user
class PublicDeps:
"""
PublicDeps contains the common dependencies for all read operations through the API.
Note: The user object is used to definer what assets the user has access to.
Args:
background_tasks: BackgroundTasks
session: Session
user: bool
"""
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(is_logged_in),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: bool = user
class UserDeps:
"""
UserDeps contains the common dependencies for all read operations through the API.
Note: The user must be logged in or the route will return a 401 error.
Args:
background_tasks: BackgroundTasks
session: Session
user: UserInDB
"""
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(get_current_user),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: PrivateUser = user
class AdminDeps:
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(get_admin_user),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: PrivateUser = user

31
mealie/core/exceptions.py Normal file
View File

@ -0,0 +1,31 @@
from sqlite3 import IntegrityError
from mealie.lang.providers import AbstractLocaleProvider
class PermissionDenied(Exception):
"""
This exception is raised when a user tries to access a resource that they do not have permission to access.
"""
pass
class NoEntryFound(Exception):
"""
This exception is raised when a user tries to access a resource that does not exist.
"""
pass
def mealie_registered_exceptions(t: AbstractLocaleProvider) -> dict:
"""
This function returns a dictionary of all the globally registered exceptions in the Mealie application.
"""
return {
PermissionDenied: t.t("exceptions.permission-denied"),
NoEntryFound: t.t("exceptions.no-entry-found"),
IntegrityError: t.t("exceptions.integrity-error"),
}

View File

@ -14,7 +14,6 @@ settings = get_app_settings()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s"
LOGGER_HANDLER = None
@dataclass

View File

@ -1,5 +1,3 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from pathlib import Path

View File

@ -1,5 +1,3 @@
from __future__ import annotations
from functools import wraps
from pydantic import BaseModel, Field

View File

@ -8,10 +8,12 @@ from ..recipe.category import Category, cookbooks_to_categories
class CookBook(SqlAlchemyBase, BaseMixins):
__tablename__ = "cookbooks"
id = Column(Integer, primary_key=True)
position = Column(Integer, nullable=False)
position = Column(Integer, nullable=False, default=1)
name = Column(String, nullable=False)
description = Column(String, default="")
slug = Column(String, nullable=False)
description = Column(String, default="")
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)
group_id = Column(guid.GUID, ForeignKey("groups.id"))

View File

@ -88,7 +88,9 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
collection_class=ordering_list("position"),
)
share_tokens = orm.relationship(RecipeShareTokenModel, back_populates="recipe")
share_tokens = orm.relationship(
RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan"
)
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")

View File

@ -4,5 +4,10 @@
},
"recipe": {
"unique-name-error": "Recipe names must be unique"
},
"exceptions": {
"permission_denied": "You do not have permission to perform this action",
"no-entry-found": "The requested resource was not found",
"integrity-error": "Database integrity error"
}
}

View File

@ -100,6 +100,7 @@ class AllRepositories:
@cached_property
def categories(self) -> RepositoryCategories:
# TODO: Fix Typing for Category Repository
return RepositoryCategories(self.session, pk_slug, Category, RecipeCategoryResponse)
@cached_property

View File

@ -1,18 +1,12 @@
from __future__ import annotations
from typing import Any, Callable, Generic, TypeVar, Union
from uuid import UUID
from pydantic import UUID4
from pydantic import UUID4, BaseModel
from sqlalchemy import func
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
from mealie.core.root_logger import get_logger
logger = get_logger()
T = TypeVar("T")
T = TypeVar("T", bound=BaseModel)
D = TypeVar("D")
@ -40,12 +34,12 @@ class RepositoryGeneric(Generic[T, D]):
def subscribe(self, func: Callable) -> None:
self.observers.append(func)
def by_user(self, user_id: UUID4) -> RepositoryGeneric:
def by_user(self, user_id: UUID4) -> "RepositoryGeneric[T, D]":
self.limit_by_user = True
self.user_id = user_id
return self
def by_group(self, group_id: UUID) -> RepositoryGeneric:
def by_group(self, group_id: UUID) -> "RepositoryGeneric[T, D]":
self.limit_by_group = True
self.group_id = group_id
return self
@ -147,7 +141,7 @@ class RepositoryGeneric(Generic[T, D]):
results_as_dict = [x.dict() for x in results]
return [x.get(self.primary_key) for x in results_as_dict]
def _query_one(self, match_value: str, match_key: str = None) -> D:
def _query_one(self, match_value: str | int | UUID4, match_key: str = None) -> D:
"""
Query the sql database for one item an return the sql alchemy model
object. If no match key is provided the primary_key attribute will be used.
@ -178,7 +172,7 @@ class RepositoryGeneric(Generic[T, D]):
return eff_schema.from_orm(result)
def get(
self, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
self, match_value: str | int | UUID4, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> T | list[T]:
"""Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against.
@ -237,7 +231,7 @@ class RepositoryGeneric(Generic[T, D]):
return self.schema.from_orm(new_document)
def update(self, match_value: str, new_data: dict) -> T:
def update(self, match_value: str | int | UUID4, new_data: dict) -> T:
"""Update a database entry.
Args:
session (Session): Database Session
@ -258,7 +252,7 @@ class RepositoryGeneric(Generic[T, D]):
self.session.commit()
return self.schema.from_orm(entry)
def patch(self, match_value: str, new_data: dict) -> T:
def patch(self, match_value: str | int | UUID4, new_data: dict) -> T:
new_data = new_data if isinstance(new_data, dict) else new_data.dict()
entry = self._query_one(match_value=match_value)

View File

@ -0,0 +1,4 @@
from .abc_controller import *
from .controller import *
from .dependencies import *
from .mixins import *

View File

@ -0,0 +1,58 @@
from abc import ABC
from functools import cached_property
from fastapi import Depends
from mealie.repos.all_repositories import AllRepositories
from mealie.routes._base.checks import OperationChecks
from mealie.routes._base.dependencies import SharedDependencies
class BasePublicController(ABC):
"""
This is a public class for all User restricted controllers in the API.
It includes the common SharedDependencies and some common methods used
by all Admin controllers.
"""
deps: SharedDependencies = Depends(SharedDependencies.public)
class BaseUserController(ABC):
"""
This is a base class for all User restricted controllers in the API.
It includes the common SharedDependencies and some common methods used
by all Admin controllers.
"""
deps: SharedDependencies = Depends(SharedDependencies.user)
@cached_property
def repos(self):
return AllRepositories(self.deps.session)
@property
def group_id(self):
return self.deps.acting_user.group_id
@property
def user(self):
return self.deps.acting_user
@property
def group(self):
return self.deps.repos.groups.get_one(self.group_id)
@cached_property
def checks(self) -> OperationChecks:
return OperationChecks(self.deps.acting_user)
class BaseAdminController(BaseUserController):
"""
This is a base class for all Admin restricted controllers in the API.
It includes the common Shared Dependencies and some common methods used
by all Admin controllers.
"""
deps: SharedDependencies = Depends(SharedDependencies.admin)

View File

@ -0,0 +1,39 @@
from fastapi import HTTPException, status
from mealie.schema.user.user import PrivateUser
class OperationChecks:
"""
OperationChecks class is a mixin class that can be used on routers to provide common permission
checks and raise the appropriate http error as necessary
"""
user: PrivateUser
def __init__(self, user: PrivateUser) -> None:
self.user = user
def _raise_unauthorized(self) -> None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
def _raise_forbidden(self) -> None:
raise HTTPException(status.HTTP_403_FORBIDDEN)
# =========================================
# User Permission Checks
def can_manage(self) -> bool:
if not self.user.can_manage:
self._raise_forbidden()
return True
def can_invite(self) -> bool:
if not self.user.can_invite:
self._raise_forbidden()
return True
def can_organize(self) -> bool:
if not self.user.can_organize:
self._raise_forbidden()
return True

View File

@ -1,5 +1,3 @@
from __future__ import annotations
from functools import cached_property
from logging import Logger
@ -17,34 +15,40 @@ from mealie.repos import AllRepositories
from mealie.schema.user.user import PrivateUser
def _get_logger() -> Logger:
return get_logger()
class SharedDependencies:
session: Session
t: AbstractLocaleProvider
logger: Logger
acting_user: PrivateUser | None
def __init__(self, session: Session, acting_user: PrivateUser | None) -> None:
self.t = get_locale_provider()
self.logger = _get_logger()
self.session = session
self.acting_user = acting_user
@classmethod
def public(cls, session: Session = Depends(generate_session)) -> "SharedDependencies":
return cls(session, None)
@classmethod
def user(
cls, session: Session = Depends(generate_session), user: PrivateUser = Depends(get_current_user)
cls,
session: Session = Depends(generate_session),
user: PrivateUser = Depends(get_current_user),
) -> "SharedDependencies":
return cls(session, user)
@classmethod
def admin(
cls, session: Session = Depends(generate_session), admin: PrivateUser = Depends(get_admin_user)
cls,
session: Session = Depends(generate_session),
admin: PrivateUser = Depends(get_admin_user),
) -> "SharedDependencies":
return cls(session, admin)
@cached_property
def logger(self) -> Logger:
return get_logger()
@cached_property
def settings(self) -> AppSettings:
return get_app_settings()

View File

@ -1,15 +1,30 @@
from __future__ import annotations
from logging import Logger
from typing import Callable, Type
from typing import Callable, Generic, Type, TypeVar
from fastapi import HTTPException, status
from pydantic import UUID4, BaseModel
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)
class CrudMixins(Generic[C, R, U]):
"""
The CrudMixins[C, R, U] class is a mixin class that provides a common set of methods for CRUD operations.
This class is inteded to be used in a composition pattern where a class has a mixin property. For example:
```
class MyClass:
def __init(self repo, logger):
self.mixins = CrudMixins(repo, logger)
```
"""
class CrudMixins:
repo: RepositoryGeneric
exception_msgs: Callable[[Type[Exception]], str] | None
default_message: str = "An unexpected error occurred."
@ -21,17 +36,7 @@ class CrudMixins:
exception_msgs: Callable[[Type[Exception]], str] = None,
default_message: str = None,
) -> None:
"""
The CrudMixins class is a mixin class that provides a common set of methods for CRUD operations.
This class is inteded to be used in a composition pattern where a class has a mixin property. For example:
```
class MyClass:
def __init(self repo, logger):
self.mixins = CrudMixins(repo, logger)
```
"""
self.repo = repo
self.logger = logger
self.exception_msgs = exception_msgs
@ -39,16 +44,6 @@ class CrudMixins:
if default_message:
self.default_message = default_message
def set_default_message(self, default_msg: str) -> "CrudMixins":
"""
Use this method to set a lookup function for exception messages. When an exception is raised, and
no custom message is set, the default message will be used.
IMPORTANT! The function returns the same instance of the CrudMixins class, so you can chain calls.
"""
self.default_msg = default_msg
return self
def get_exception_message(self, ext: Exception) -> str:
if self.exception_msgs:
return self.exception_msgs(type(ext))
@ -67,8 +62,8 @@ class CrudMixins:
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
)
def create_one(self, data):
item = None
def create_one(self, data: C) -> R | None:
item: R | None = None
try:
item = self.repo.create(data)
except Exception as ex:
@ -76,8 +71,8 @@ class CrudMixins:
return item
def get_one(self, item_id):
item = self.repo.get(item_id)
def get_one(self, item_id: int | str | UUID4, key: str = None) -> R:
item = self.repo.get_one(item_id, key)
if not item:
raise HTTPException(
@ -87,33 +82,35 @@ class CrudMixins:
return item
def update_one(self, data, item_id):
item = self.repo.get(item_id)
def update_one(self, data: U, item_id: int | str | UUID4) -> R:
item: R = self.repo.get_one(item_id)
if not item:
return
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=ErrorResponse.respond(message="Not found."),
)
try:
item = self.repo.update(item.id, data) # type: ignore
item = self.repo.update(item_id, data) # type: ignore
except Exception as ex:
self.handle_exception(ex)
return item
def patch_one(self, data, item_id) -> None:
self.repo.get(item_id)
def patch_one(self, data: U, item_id: int | str | UUID4) -> None:
self.repo.get_one(item_id)
try:
self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
except Exception as ex:
self.handle_exception(ex)
def delete_one(self, item_id):
self.logger.info(f"Deleting item with id {item_id}")
def delete_one(self, item_id: int | str | UUID4) -> R | None:
item: R | None = None
try:
item = self.repo.delete(item_id)
self.logger.info(item)
self.logger.info(f"Deleting item with id {item_id}")
except Exception as ex:
self.handle_exception(ex)

View File

@ -1,35 +1,25 @@
from fastapi import Depends
from sqlalchemy.orm.session import Session
from mealie.core.root_logger import get_logger
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import AdminAPIRouter
from mealie.routes._base.routers import AdminAPIRouter
from mealie.schema.events import EventsOut
from .._base import BaseAdminController, controller
router = AdminAPIRouter(prefix="/events")
logger = get_logger()
@controller(router)
class EventsController(BaseAdminController):
@router.get("", response_model=EventsOut)
async def get_events(self):
"""Get event from the Database"""
return EventsOut(total=self.repos.events.count_all(), events=self.repos.events.get_all(order_by="time_stamp"))
@router.get("", response_model=EventsOut)
async def get_events(session: Session = Depends(generate_session)):
"""Get event from the Database"""
db = get_repositories(session)
@router.delete("")
async def delete_events(self):
"""Get event from the Database"""
self.repos.events.delete_all()
return {"message": "All events deleted"}
return EventsOut(total=db.events.count_all(), events=db.events.get_all(order_by="time_stamp"))
@router.delete("")
async def delete_events(session: Session = Depends(generate_session)):
"""Get event from the Database"""
db = get_repositories(session)
db.events.delete_all()
return {"message": "All events deleted"}
@router.delete("/{id}")
async def delete_event(id: int, session: Session = Depends(generate_session)):
"""Delete event from the Database"""
db = get_repositories(session)
return db.events.delete(id)
@router.delete("/{item_id}")
async def delete_event(self, item_id: int):
"""Delete event from the Database"""
return self.repos.events.delete(item_id)

View File

@ -1,15 +1,12 @@
from mealie.routes.routers import AdminAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.admin.admin_group_service import AdminGroupService
from mealie.services.admin.admin_user_service import AdminUserService
from mealie.routes._base.routers import AdminAPIRouter
from . import admin_about, admin_email, admin_log, admin_server_tasks
from . import admin_about, admin_email, admin_log, admin_management_groups, admin_management_users, admin_server_tasks
router = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(RouterFactory(AdminUserService, prefix="/users", tags=["Admin: Users"]))
router.include_router(RouterFactory(AdminGroupService, prefix="/groups", tags=["Admin: Groups"]))
router.include_router(admin_management_users.router)
router.include_router(admin_management_groups.router)
router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])

View File

@ -1,55 +1,51 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from fastapi import APIRouter
from mealie.core.config import get_app_settings
from mealie.core.release_checker import get_latest_version
from mealie.core.settings.static import APP_VERSION
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseAdminController, controller
from mealie.schema.admin.about import AdminAboutInfo, AppStatistics, CheckAppConfig
router = APIRouter(prefix="/about")
@router.get("", response_model=AdminAboutInfo)
async def get_app_info():
"""Get general application information"""
settings = get_app_settings()
@controller(router)
class AdminAboutController(BaseAdminController):
@router.get("", response_model=AdminAboutInfo)
async def get_app_info(self):
"""Get general application information"""
settings = self.deps.settings
return AdminAboutInfo(
production=settings.PRODUCTION,
version=APP_VERSION,
versionLatest=get_latest_version(),
demo_status=settings.IS_DEMO,
api_port=settings.API_PORT,
api_docs=settings.API_DOCS,
db_type=settings.DB_ENGINE,
db_url=settings.DB_URL_PUBLIC,
default_group=settings.DEFAULT_GROUP,
)
return AdminAboutInfo(
production=settings.PRODUCTION,
version=APP_VERSION,
versionLatest=get_latest_version(),
demo_status=settings.IS_DEMO,
api_port=settings.API_PORT,
api_docs=settings.API_DOCS,
db_type=settings.DB_ENGINE,
db_url=settings.DB_URL_PUBLIC,
default_group=settings.DEFAULT_GROUP,
)
@router.get("/statistics", response_model=AppStatistics)
async def get_app_statistics(self):
@router.get("/statistics", response_model=AppStatistics)
async def get_app_statistics(session: Session = Depends(generate_session)):
db = get_repositories(session)
return AppStatistics(
total_recipes=db.recipes.count_all(),
uncategorized_recipes=db.recipes.count_uncategorized(),
untagged_recipes=db.recipes.count_untagged(),
total_users=db.users.count_all(),
total_groups=db.groups.count_all(),
)
return AppStatistics(
total_recipes=self.repos.recipes.count_all(),
uncategorized_recipes=self.repos.recipes.count_uncategorized(),
untagged_recipes=self.repos.recipes.count_untagged(),
total_users=self.repos.users.count_all(),
total_groups=self.repos.groups.count_all(),
)
@router.get("/check", response_model=CheckAppConfig)
async def check_app_config(self):
settings = self.deps.settings
url_set = settings.BASE_URL != "http://localhost:8080"
@router.get("/check", response_model=CheckAppConfig)
async def check_app_config():
settings = get_app_settings()
url_set = settings.BASE_URL != "http://localhost:8080"
return CheckAppConfig(
email_ready=settings.SMTP_ENABLE,
ldap_ready=settings.LDAP_ENABLED,
base_url_set=url_set,
is_up_to_date=get_latest_version() == APP_VERSION,
)
return CheckAppConfig(
email_ready=settings.SMTP_ENABLE,
ldap_ready=settings.LDAP_ENABLED,
base_url_set=url_set,
is_up_to_date=get_latest_version() == APP_VERSION,
)

View File

@ -1,12 +1,9 @@
from fastapi import APIRouter
from fastapi_camelcase import CamelModel
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
from mealie.routes._base import BaseAdminController, controller
from mealie.services.email import EmailService
logger = get_logger(__name__)
router = APIRouter(prefix="/email")
@ -23,24 +20,23 @@ class EmailTest(CamelModel):
email: str
@router.get("", response_model=EmailReady)
async def check_email_config():
"""Get general application information"""
settings = get_app_settings()
@controller(router)
class AdminEmailController(BaseAdminController):
@router.get("", response_model=EmailReady)
async def check_email_config(self):
"""Get general application information"""
return EmailReady(ready=self.deps.settings.SMTP_ENABLE)
return EmailReady(ready=settings.SMTP_ENABLE)
@router.post("", response_model=EmailSuccess)
async def send_test_email(self, data: EmailTest):
service = EmailService()
status = False
error = None
try:
status = service.send_test_email(data.email)
except Exception as e:
self.deps.logger.error(e)
error = str(e)
@router.post("", response_model=EmailSuccess)
async def send_test_email(data: EmailTest):
service = EmailService()
status = False
error = None
try:
status = service.send_test_email(data.email)
except Exception as e:
logger.error(e)
error = str(e)
return EmailSuccess(success=status, error=error)
return EmailSuccess(success=status, error=error)

View File

@ -0,0 +1,88 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.schema.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper
from mealie.schema.query import GetAll
from mealie.schema.response.responses import ErrorResponse
from mealie.schema.user.user import GroupBase, GroupInDB
from .._base import BaseAdminController, controller
from .._base.dependencies import SharedDependencies
from .._base.mixins import CrudMixins
router = APIRouter(prefix="/groups", tags=["Admin: Groups"])
@controller(router)
class AdminUserManagementRoutes(BaseAdminController):
deps: SharedDependencies = Depends(SharedDependencies.user)
@cached_property
def repo(self):
if not self.deps.acting_user:
raise Exception("No user is logged in.")
return self.deps.repos.groups
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations
@property
def mixins(self):
return CrudMixins[GroupBase, GroupInDB, GroupAdminUpdate](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[GroupInDB])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=GroupInDB)
@router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)
def create_one(self, data: GroupBase):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=GroupInDB)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=GroupInDB)
def update_one(self, item_id: UUID4, data: GroupAdminUpdate):
group = self.repo.get_one(item_id)
if data.preferences:
preferences = self.repos.group_preferences.get_one(value=item_id, key="group_id")
preferences = mapper(data.preferences, preferences)
group.preferences = self.repos.group_preferences.update(item_id, preferences)
if data.name not in ["", group.name]:
group.name = data.name
group = self.repo.update(item_id, group)
return group
@router.delete("/{item_id}", response_model=GroupInDB)
def delete_one(self, item_id: UUID4):
item = self.repo.get_one(item_id)
if len(item.users) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message="Cannot delete group with users"),
)
return self.mixins.delete_one(item_id)

View File

@ -0,0 +1,61 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base import BaseAdminController, controller
from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.query import GetAll
from mealie.schema.user.user import UserIn, UserOut
router = APIRouter(prefix="/users", tags=["Admin: Users"])
@controller(router)
class AdminUserManagementRoutes(BaseAdminController):
deps: SharedDependencies = Depends(SharedDependencies.user)
@cached_property
def repo(self):
if not self.deps.acting_user:
raise Exception("No user is logged in.")
return self.deps.repos.users
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations
@property
def mixins(self):
return CrudMixins[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions)
@router.get("", response_model=list[UserOut])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=UserOut)
@router.post("", response_model=UserOut)
def create_one(self, data: UserIn):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=UserOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=UserOut)
def update_one(self, item_id: UUID4, data: UserOut):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=UserOut)
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id)

View File

@ -1,18 +1,20 @@
from fastapi import Depends
from fastapi import BackgroundTasks
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base import BaseAdminController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.server.tasks import ServerTask, ServerTaskNames
from mealie.services.server_tasks import BackgroundExecutor, test_executor_func
from mealie.services.server_tasks.tasks_http_service import AdminServerTasks
router = UserAPIRouter()
@router.get("/server-tasks", response_model=list[ServerTask])
def get_all_tasks(tasks_service: AdminServerTasks = Depends(AdminServerTasks.private)):
return tasks_service.get_all()
@controller(router)
class AdminServerTasksController(BaseAdminController):
@router.get("/server-tasks", response_model=list[ServerTask])
def get_all(self):
return self.repos.server_tasks.get_all(order_by="created_at")
@router.post("/server-tasks", response_model=ServerTask)
def create_test_tasks(bg_executor: BackgroundExecutor = Depends(BackgroundExecutor.private)):
return bg_executor.dispatch(ServerTaskNames.default, test_executor_func)
@router.post("/server-tasks", response_model=ServerTask, status_code=201)
def create_test_tasks(self, bg_tasks: BackgroundTasks):
bg_executor = BackgroundExecutor(self.group.id, self.repos, bg_tasks)
return bg_executor.dispatch(ServerTaskNames.default, test_executor_func)

View File

@ -4,13 +4,14 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, status
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm.session import Session
from mealie.core import security
from mealie.core.dependencies import get_current_user
from mealie.core.security import authenticate_user
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.user import PrivateUser
from mealie.services.events import create_user_event
@ -38,6 +39,15 @@ class CustomOAuth2Form(OAuth2PasswordRequestForm):
self.client_secret = client_secret
class MealieAuthToken(BaseModel):
access_token: str
token_type: str = "bearer"
@classmethod
def respond(cls, token: str, token_type: str = "bearer") -> dict:
return cls(access_token=token, token_type=token_type).dict()
@public_router.post("/token")
def get_token(
background_tasks: BackgroundTasks,
@ -61,11 +71,11 @@ def get_token(
duration = timedelta(days=14) if data.remember_me else None
access_token = security.create_access_token(dict(sub=user.email), duration)
return {"access_token": access_token, "token_type": "bearer"}
return MealieAuthToken.respond(access_token)
@user_router.get("/refresh")
async def refresh_token(current_user: PrivateUser = Depends(get_current_user)):
"""Use a valid token to get another token"""
access_token = security.create_access_token(data=dict(sub=current_user.email))
return {"access_token": access_token, "token_type": "bearer"}
return MealieAuthToken.respond(access_token)

View File

@ -10,7 +10,7 @@ from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.routes._base.routers import AdminAPIRouter
from mealie.schema.admin import AllBackups, BackupFile, CreateBackup, ImportJob
from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports

View File

@ -2,10 +2,5 @@ from fastapi import APIRouter
from . import categories
prefix = "/categories"
router = APIRouter()
router.include_router(categories.public_router, prefix=prefix, tags=["Categories: CRUD"])
router.include_router(categories.user_router, prefix=prefix, tags=["Categories: CRUD"])
router.include_router(categories.admin_router, prefix=prefix, tags=["Categories: CRUD"])
router.include_router(categories.router)

View File

@ -1,82 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from functools import cached_property
from mealie.core.dependencies import is_logged_in
from mealie.core.root_logger import get_logger
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
from fastapi import APIRouter
from pydantic import BaseModel
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
from mealie.schema.recipe.recipe_category import CategoryBase
public_router = APIRouter()
user_router = UserAPIRouter()
admin_router = AdminAPIRouter()
logger = get_logger()
router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
@public_router.get("")
async def get_all_recipe_categories(session: Session = Depends(generate_session)):
"""Returns a list of available categories in the database"""
db = get_repositories(session)
return db.categories.get_all_limit_columns(fields=["slug", "name"])
class CategorySummary(BaseModel):
slug: str
name: str
class Config:
orm_mode = True
@public_router.get("/empty")
def get_empty_categories(session: Session = Depends(generate_session)):
"""Returns a list of categories that do not contain any recipes"""
db = get_repositories(session)
return db.categories.get_empty()
@controller(router)
class RecipeCategoryController(BaseUserController):
# =========================================================================
# CRUD Operations
@cached_property
def mixins(self):
return CrudMixins(self.repos.categories, self.deps.logger)
@public_router.get("/{category}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(
category: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)
):
"""Returns a list of recipes associated with the provided category."""
db = get_repositories(session)
@router.get("", response_model=list[CategorySummary])
def get_all(self):
"""Returns a list of available categories in the database"""
return self.repos.categories.get_all_limit_columns(fields=["slug", "name"])
category_obj = db.categories.get(category)
category_obj = RecipeCategoryResponse.from_orm(category_obj)
@router.post("", status_code=201)
def create_one(self, category: CategoryIn):
"""Creates a Category in the database"""
return self.mixins.create_one(category)
if not is_user:
category_obj.recipes = [x for x in category_obj.recipes if x.settings.public]
@router.get("/{slug}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(self, slug: str):
"""Returns a list of recipes associated with the provided category."""
category_obj = self.repos.categories.get(slug)
category_obj = RecipeCategoryResponse.from_orm(category_obj)
return category_obj
return category_obj
@router.put("/{slug}", response_model=RecipeCategoryResponse)
def update_one(self, slug: str, update_data: CategoryIn):
"""Updates an existing Tag in the database"""
return self.mixins.update_one(update_data, slug)
@router.delete("/{slug}")
def delete_one(self, slug: str):
"""
Removes a recipe category from the database. Deleting a
category does not impact a recipe. The category will be removed
from any recipes that contain it
"""
self.mixins.delete_one(slug)
@user_router.post("")
async def create_recipe_category(category: CategoryIn, session: Session = Depends(generate_session)):
"""Creates a Category in the database"""
db = get_repositories(session)
# =========================================================================
# Read All Operations
try:
return db.categories.create(category.dict())
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@admin_router.put("/{category}", response_model=RecipeCategoryResponse)
async def update_recipe_category(category: str, new_category: CategoryIn, session: Session = Depends(generate_session)):
"""Updates an existing Tag in the database"""
db = get_repositories(session)
try:
return db.categories.update(category, new_category.dict())
except Exception:
logger.exception("Failed to update category")
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@admin_router.delete("/{category}")
async def delete_recipe_category(category: str, session: Session = Depends(generate_session)):
"""
Removes a recipe category from the database. Deleting a
category does not impact a recipe. The category will be removed
from any recipes that contain it
"""
db = get_repositories(session)
try:
db.categories.delete(category)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.get("/empty", response_model=list[CategoryBase])
def get_all_empty(self):
"""Returns a list of categories that do not contain any recipes"""
return self.repos.categories.get_empty()

View File

@ -1,8 +1,74 @@
from fastapi import APIRouter
from functools import cached_property
from typing import Type
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_comments_service import RecipeCommentsService
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
router = APIRouter()
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate,
RecipeCommentOut,
RecipeCommentSave,
RecipeCommentUpdate,
)
from mealie.schema.response.responses import ErrorResponse, SuccessResponse
router.include_router(RouterFactory(RecipeCommentsService, prefix="/comments", tags=["Recipe: Comments"]))
router = APIRouter(prefix="/comments", tags=["Recipe: Comments"])
@controller(router)
class RecipeCommentRoutes(BaseUserController):
@cached_property
def repo(self):
return self.deps.repos.comments
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations
@property
def mixins(self) -> CrudMixins:
return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
def _check_comment_belongs_to_user(self, item_id: UUID4) -> None:
comment = self.repo.get_one(item_id)
if comment.user_id != self.deps.acting_user.id and not self.deps.acting_user.admin:
raise HTTPException(
status_code=403,
detail=ErrorResponse.response(message="Comment does not belong to user"),
)
@router.get("", response_model=list[RecipeCommentOut])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeCommentOut)
@router.post("", response_model=RecipeCommentOut, status_code=201)
def create_one(self, data: RecipeCommentCreate):
save_data = RecipeCommentSave(text=data.text, user_id=self.deps.acting_user.id, recipe_id=data.recipe_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=RecipeCommentOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=RecipeCommentOut)
def update_one(self, item_id: UUID4, data: RecipeCommentUpdate):
self._check_comment_belongs_to_user(item_id)
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=SuccessResponse)
def delete_one(self, item_id: UUID4):
self._check_comment_belongs_to_user(item_id)
self.mixins.delete_one(item_id)
return SuccessResponse.respond(message="Comment deleted")

View File

@ -1,59 +1,29 @@
from datetime import date, timedelta
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from mealie.schema.reports.reports import ReportCategory
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from mealie.services.group_services.meal_service import MealService
from mealie.services.group_services.reports_service import GroupReportService
from . import categories, invitations, labels, migrations, notifications, preferences, self_service, shopping_lists
from . import (
controller_cookbooks,
controller_group_notifications,
controller_group_reports,
controller_group_self_service,
controller_invitations,
controller_labels,
controller_mealplan,
controller_meaplan_config,
controller_migrations,
controller_shopping_lists,
controller_webhooks,
)
router = APIRouter()
router.include_router(self_service.user_router)
webhook_router = RouterFactory(service=WebhookService, prefix="/groups/webhooks", tags=["Groups: Webhooks"])
cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
@router.get("/groups/mealplans/today", tags=["Groups: Mealplans"])
def get_todays_meals(ms: MealService = Depends(MealService.private)):
return ms.get_today()
meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"])
@meal_plan_router.get("")
def get_all(start: date = None, limit: date = None, ms: MealService = Depends(MealService.private)):
start = start or date.today() - timedelta(days=999)
limit = limit or date.today() + timedelta(days=999)
return ms.get_slice(start, limit)
router.include_router(cookbook_router)
router.include_router(meal_plan_router)
router.include_router(categories.user_router)
router.include_router(webhook_router)
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])
router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"])
router.include_router(migrations.router, prefix="/groups/migrations", tags=["Group: Migrations"])
report_router = RouterFactory(service=GroupReportService, prefix="/groups/reports", tags=["Groups: Reports"])
@report_router.get("")
def get_all_reports(
report_type: ReportCategory = None,
gs: GroupReportService = Depends(GroupReportService.private),
):
return gs._get_all(report_type)
router.include_router(report_router)
router.include_router(shopping_lists.router)
router.include_router(labels.router)
router.include_router(notifications.router)
router.include_router(controller_group_self_service.router)
router.include_router(controller_mealplan.router)
router.include_router(controller_cookbooks.router)
router.include_router(controller_meaplan_config.router)
router.include_router(controller_webhooks.router)
router.include_router(controller_invitations.router)
router.include_router(controller_migrations.router)
router.include_router(controller_group_reports.router)
router.include_router(controller_shopping_lists.router)
router.include_router(controller_labels.router)
router.include_router(controller_group_notifications.router)

View File

@ -1,22 +0,0 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])
@user_router.get("", response_model=list[CategoryBase])
def get_mealplan_categories(group_service: GroupSelfService = Depends(GroupSelfService.read_existing)):
return group_service.item.categories
@user_router.put("", response_model=list[CategoryBase])
def update_mealplan_categories(
new_categories: list[CategoryBase], group_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
items = group_service.update_categories(new_categories)
return items.categories

View File

@ -0,0 +1,70 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
@controller(router)
class GroupCookbookController(BaseUserController):
@cached_property
def repo(self):
return self.deps.repos.cookbooks.by_group(self.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreateCookBook, ReadCookBook, UpdateCookBook](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[RecipeCookBook])
def get_all(self):
items = self.repo.get_all()
items.sort(key=lambda x: x.position)
return items
@router.post("", response_model=RecipeCookBook, status_code=201)
def create_one(self, data: CreateCookBook):
data = mapper.cast(data, SaveCookBook, group_id=self.group_id)
return self.mixins.create_one(data)
@router.put("", response_model=list[ReadCookBook])
def update_many(self, data: list[UpdateCookBook]):
updated = []
for cookbook in data:
cb = self.mixins.update_one(cookbook, cookbook.id)
updated.append(cb)
return updated
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: str):
try:
item_id = int(item_id)
return self.mixins.get_one(item_id)
except Exception:
self.mixins.get_one(item_id, key="slug")
@router.put("/{item_id}", response_model=RecipeCookBook)
def update_one(self, item_id: int, data: CreateCookBook):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=RecipeCookBook)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id)

View File

@ -1,10 +1,10 @@
from functools import cached_property
from sqlite3 import IntegrityError
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.controller import controller
from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import CrudMixins
@ -35,9 +35,9 @@ class GroupEventsNotifierController:
return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
Exception: "An unexpected error occurred.",
IntegrityError: "An unexpected error occurred.",
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")

View File

@ -0,0 +1,45 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary
router = APIRouter(prefix="/groups/reports", tags=["Groups: Reports"])
@controller(router)
class GroupReportsController(BaseUserController):
@cached_property
def repo(self):
return self.deps.repos.group_reports.by_group(self.deps.acting_user.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
return {
**mealie_registered_exceptions(self.deps.t),
}.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[ReportCreate, ReportOut, ReportCreate](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[ReportSummary])
def get_all(self, report_type: ReportCategory = None):
return self.repo.multi_query({"group_id": self.group_id, "category": report_type}, limit=9999)
@router.get("/{item_id}", response_model=ReportOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.delete("/{item_id}", status_code=204)
def delete_one(self, item_id: UUID4):
self.mixins.delete_one(item_id) # type: ignore

View File

@ -0,0 +1,49 @@
from fastapi import HTTPException, status
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.user.user import GroupInDB, UserOut
router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@controller(router)
class GroupSelfServiceController(BaseUserController):
@router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(self):
return self.group.preferences
@router.put("/preferences", response_model=ReadGroupPreferences)
def update_group_preferences(self, new_pref: UpdateGroupPreferences):
return self.repos.group_preferences.update(self.group_id, new_pref)
@router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(self):
"""Returns the Group Data for the Current User"""
return self.group
@router.get("/members", response_model=list[UserOut])
async def get_group_members(self):
"""Returns the Group of user lists"""
return self.repos.users.multi_query(query_by={"group_id": self.group.id}, override_schema=UserOut)
@router.put("/permissions", response_model=UserOut)
async def set_member_permissions(self, permissions: SetPermissions):
self.checks.can_manage()
target_user = self.repos.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.repos.users.update(permissions.user_id, target_user)

View File

@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException, status
from mealie.core.security import url_safe_token
from mealie.routes._base import BaseUserController, controller
from mealie.schema.group.invite_token import (
CreateInviteToken,
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from mealie.services.email.email_service import EmailService
router = APIRouter(prefix="/groups/invitations", tags=["Groups: Invitations"])
@controller(router)
class GroupInvitationsController(BaseUserController):
@router.get("", response_model=list[ReadInviteToken])
def get_invite_tokens(self):
return self.repos.group_invite_tokens.multi_query({"group_id": self.group_id})
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
def create_invite_token(self, uses: CreateInviteToken):
if not self.deps.acting_user.can_invite:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens")
token = SaveInviteToken(uses_left=uses.uses, group_id=self.group_id, token=url_safe_token())
return self.repos.group_invite_tokens.create(token)
@router.post("/email", response_model=EmailInitationResponse)
def email_invitation(self, invite: EmailInvitation):
email_service = EmailService()
url = f"{self.deps.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)

View File

@ -1,10 +1,10 @@
from functools import cached_property
from sqlite3 import IntegrityError
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.controller import controller
from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import CrudMixins
@ -17,15 +17,13 @@ from mealie.schema.labels import (
)
from mealie.schema.mapper import cast
from mealie.schema.query import GetAll
from mealie.services.group_services.shopping_lists import ShoppingListService
router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"])
@controller(router)
class ShoppingListRoutes:
class MultiPurposeLabelsController:
deps: SharedDependencies = Depends(SharedDependencies.user)
service: ShoppingListService = Depends(ShoppingListService.private)
@cached_property
def repo(self):
@ -35,9 +33,9 @@ class ShoppingListRoutes:
return self.deps.repos.group_multi_purpose_labels.by_group(self.deps.acting_user.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
Exception: "An unexpected error occurred.",
IntegrityError: "An unexpected error occurred.",
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")

View File

@ -0,0 +1,62 @@
from datetime import date, timedelta
from functools import cached_property
from typing import Type
from fastapi import APIRouter
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.repos.repository_meals import RepositoryMeals
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"])
@controller(router)
class GroupMealplanController(BaseUserController):
@cached_property
def repo(self) -> RepositoryMeals:
return self.repos.meals.by_group(self.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("/today", tags=["Groups: Mealplans"])
def get_todays_meals(self):
return self.repo.get_today(group_id=self.group_id)
@router.get("", response_model=list[ReadPlanEntry])
def get_all(self, start: date = None, limit: date = None):
start = start or date.today() - timedelta(days=999)
limit = limit or date.today() + timedelta(days=999)
return self.repo.get_slice(start, limit, group_id=self.group.id)
@router.post("", response_model=ReadPlanEntry, status_code=201)
def create_one(self, data: CreatePlanEntry):
data = mapper.cast(data, SavePlanEntry, group_id=self.group.id)
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=ReadPlanEntry)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=ReadPlanEntry)
def update_one(self, item_id: int, data: UpdatePlanEntry):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=ReadPlanEntry)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id)

View File

@ -0,0 +1,26 @@
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])
@controller(router)
class GroupMealplanConfigController(BaseUserController):
@property
def mixins(self):
return CrudMixins[GroupInDB, GroupInDB, GroupInDB](self.repos.groups, self.deps.logger)
@router.get("", response_model=list[CategoryBase])
def get_mealplan_categories(self):
data = self.mixins.get_one(self.deps.acting_user.group_id)
return data.categories
@router.put("", response_model=list[CategoryBase])
def update_mealplan_categories(self, new_categories: list[CategoryBase]):
data = self.mixins.get_one(self.deps.acting_user.group_id)
data.categories = new_categories
return self.mixins.update_one(data, data.id).categories

View File

@ -0,0 +1,51 @@
import shutil
from fastapi import Depends, File, Form
from fastapi.datastructures import UploadFile
from mealie.core.dependencies import temporary_zip_path
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_migration import SupportedMigrations
from mealie.schema.reports.reports import ReportSummary
from mealie.services.migrations import ChowdownMigrator, MealieAlphaMigrator, NextcloudMigrator, PaprikaMigrator
router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"])
@controller(router)
class GroupMigrationController(BaseUserController):
@router.post("", response_model=ReportSummary)
def start_data_migration(
self,
add_migration_tag: bool = Form(False),
migration_type: SupportedMigrations = Form(...),
archive: UploadFile = File(...),
temp_path: str = Depends(temporary_zip_path),
):
# Save archive to temp_path
with temp_path.open("wb") as buffer:
shutil.copyfileobj(archive.file, buffer)
args = {
"archive": temp_path,
"db": self.repos,
"session": self.deps.session,
"user_id": self.user.id,
"group_id": self.group_id,
"add_migration_tag": add_migration_tag,
}
match migration_type:
case SupportedMigrations.chowdown:
migrator = ChowdownMigrator(**args)
case SupportedMigrations.mealie_alpha:
migrator = MealieAlphaMigrator(**args)
case SupportedMigrations.nextcloud:
migrator = NextcloudMigrator(**args)
case SupportedMigrations.paprika:
migrator = PaprikaMigrator(**args)
case _:
raise ValueError(f"Unsupported migration type: {migration_type}")
return migrator.migrate(f"{migration_type.value.title()} Migration")

View File

@ -1,12 +1,12 @@
from functools import cached_property
from sqlite3 import IntegrityError
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.group.group_shopping_list import (
ShoppingListCreate,
@ -25,11 +25,13 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
@controller(router)
class ShoppingListRoutes:
deps: SharedDependencies = Depends(SharedDependencies.user)
service: ShoppingListService = Depends(ShoppingListService.private)
class ShoppingListController(BaseUserController):
event_bus: EventBusService = Depends(EventBusService)
@cached_property
def service(self):
return ShoppingListService(self.repos)
@cached_property
def repo(self):
if not self.deps.acting_user:
@ -38,9 +40,9 @@ class ShoppingListRoutes:
return self.deps.repos.group_shopping_lists.by_group(self.deps.acting_user.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
Exception: "An unexpected error occurred.",
IntegrityError: "An unexpected error occurred.",
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")

View File

@ -0,0 +1,44 @@
from functools import cached_property
from fastapi import APIRouter, Depends
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook
from mealie.schema.query import GetAll
router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"])
@controller(router)
class ReadWebhookController(BaseUserController):
@cached_property
def repo(self):
return self.repos.webhooks.by_group(self.group_id)
@property
def mixins(self) -> CrudMixins:
return CrudMixins[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger)
@router.get("", response_model=list[ReadWebhook])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ReadWebhook)
@router.post("", response_model=ReadWebhook, status_code=201)
def create_one(self, data: CreateWebhook):
save = mapper.cast(data, SaveWebhook, group_id=self.group.id)
return self.mixins.create_one(save)
@router.get("/{item_id}", response_model=ReadWebhook)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=ReadWebhook)
def update_one(self, item_id: int, data: CreateWebhook):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=ReadWebhook)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id) # type: ignore

View File

@ -1,21 +0,0 @@
from fastapi import APIRouter, Depends, status
from mealie.schema.group.invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken
from mealie.services.group_services.group_service import GroupSelfService
router = APIRouter()
@router.get("", response_model=list[ReadInviteToken])
def get_invite_tokens(g_service: GroupSelfService = Depends(GroupSelfService.private)):
return g_service.get_invite_tokens()
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
def create_invite_token(uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.private)):
return g_service.create_invite_token(uses.uses)
@router.post("/email", response_model=EmailInitationResponse)
def email_invitation(invite: EmailInvitation, g_service: GroupSelfService = Depends(GroupSelfService.private)):
return g_service.email_invitation(invite)

View File

@ -1,27 +0,0 @@
import shutil
from fastapi import Depends, File, Form
from fastapi.datastructures import UploadFile
from mealie.core.dependencies import temporary_zip_path
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_migration import SupportedMigrations
from mealie.schema.reports.reports import ReportSummary
from mealie.services.group_services.migration_service import GroupMigrationService
router = UserAPIRouter()
@router.post("", response_model=ReportSummary)
def start_data_migration(
add_migration_tag: bool = Form(False),
migration_type: SupportedMigrations = Form(...),
archive: UploadFile = File(...),
temp_path: str = Depends(temporary_zip_path),
gm_service: GroupMigrationService = Depends(GroupMigrationService.private),
):
# Save archive to temp_path
with temp_path.open("wb") as buffer:
shutil.copyfileobj(archive.file, buffer)
return gm_service.migrate(migration_type, add_migration_tag, temp_path)

View File

@ -1,19 +0,0 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.services.group_services.group_service import GroupSelfService
router = UserAPIRouter()
@router.put("", response_model=ReadGroupPreferences)
def update_group_preferences(
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.update_preferences(new_pref).preferences
@router.get("", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View File

@ -1,27 +0,0 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.user.user import GroupInDB, UserOut
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@user_router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
"""Returns the Group Data for the Current User"""
return g_service.item
@user_router.get("/members", response_model=list[UserOut])
async def get_group_members(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
"""Returns the Group of user lists"""
return g_service.get_members()
@user_router.put("/permissions", response_model=UserOut)
async def set_member_permissions(
payload: SetPermissions, g_service: GroupSelfService = Depends(GroupSelfService.manage_existing)
):
return g_service.set_member_permissions(payload)

View File

@ -3,4 +3,4 @@ from fastapi import APIRouter
from . import ingredient_parser
router = APIRouter()
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])
router.include_router(ingredient_parser.router, tags=["Recipe: Ingredient Parser"])

View File

@ -1,25 +1,21 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter
from mealie.routes._base import BaseUserController, controller
from mealie.schema.recipe import ParsedIngredient
from mealie.schema.recipe.recipe_ingredient import IngredientRequest, IngredientsRequest
from mealie.services.parser_services import IngredientParserService
from mealie.services.parser_services import get_parser
public_router = APIRouter(prefix="/parser")
router = APIRouter(prefix="/parser")
@public_router.post("/ingredients", response_model=list[ParsedIngredient])
def parse_ingredients(
ingredients: IngredientsRequest,
p_service: IngredientParserService = Depends(IngredientParserService.private),
):
p_service.set_parser(parser=ingredients.parser)
return p_service.parse_ingredients(ingredients.ingredients)
@controller(router)
class IngredientParserController(BaseUserController):
@router.post("/ingredients", response_model=list[ParsedIngredient])
def parse_ingredients(self, ingredients: IngredientsRequest):
parser = get_parser(ingredients.parser)
return parser.parse(ingredients.ingredients)
@public_router.post("/ingredient", response_model=ParsedIngredient)
def parse_ingredient(
ingredient: IngredientRequest,
p_service: IngredientParserService = Depends(IngredientParserService.private),
):
p_service.set_parser(parser=ingredient.parser)
return p_service.parse_ingredient(ingredient.ingredient)
@router.post("/ingredient", response_model=ParsedIngredient)
def parse_ingredient(self, ingredient: IngredientRequest):
parser = get_parser(ingredient.parser)
return parser.parse([ingredient.ingredient])[0]

View File

@ -1,25 +1,16 @@
from fastapi import APIRouter
from . import (
all_recipe_routes,
bulk_actions,
comments,
image_and_assets,
recipe_crud_routes,
recipe_export,
shared_routes,
)
from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, shared_routes
prefix = "/recipes"
router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_export.public_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(recipe_crud_routes.router_exports)
router.include_router(recipe_crud_routes.router)
router.include_router(image_and_assets.router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
router.include_router(bulk_actions.export_router, prefix=prefix, tags=["Recipe: Bulk Exports"])
router.include_router(bulk_actions.router, prefix=prefix)
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"])
router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])

View File

@ -1,9 +1,11 @@
from functools import cached_property
from pathlib import Path
from fastapi import APIRouter, Depends
from mealie.core.dependencies.dependencies import temporary_zip_path
from mealie.core.security import create_file_token
from mealie.routes._base import BaseUserController, controller
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe.recipe_bulk_actions import (
AssignCategories,
@ -12,66 +14,46 @@ from mealie.schema.recipe.recipe_bulk_actions import (
DeleteRecipes,
ExportRecipes,
)
from mealie.services.recipe.recipe_bulk_service import RecipeBulkActions
from mealie.schema.response.responses import SuccessResponse
from mealie.services.recipe.recipe_bulk_service import RecipeBulkActionsService
router = APIRouter(prefix="/bulk-actions")
router = APIRouter(prefix="/bulk-actions", tags=["Recipe: Bulk Actions"])
@router.post("/tag", response_model=BulkActionsResponse)
def bulk_tag_recipes(
tag_data: AssignTags,
bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private),
):
bulk_service.assign_tags(tag_data.recipes, tag_data.tags)
@controller(router)
class RecipeBulkActionsController(BaseUserController):
@cached_property
def service(self) -> RecipeBulkActionsService:
return RecipeBulkActionsService(self.repos, self.user, self.group)
@router.post("/tag", response_model=BulkActionsResponse)
def bulk_tag_recipes(self, tag_data: AssignTags):
self.service.assign_tags(tag_data.recipes, tag_data.tags)
@router.post("/categorize", response_model=BulkActionsResponse)
def bulk_categorize_recipes(
assign_cats: AssignCategories,
bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private),
):
bulk_service.assign_categories(assign_cats.recipes, assign_cats.categories)
@router.post("/categorize", response_model=BulkActionsResponse)
def bulk_categorize_recipes(self, assign_cats: AssignCategories):
self.service.assign_categories(assign_cats.recipes, assign_cats.categories)
@router.post("/delete", response_model=BulkActionsResponse)
def bulk_delete_recipes(self, delete_recipes: DeleteRecipes):
self.service.delete_recipes(delete_recipes.recipes)
@router.post("/delete", response_model=BulkActionsResponse)
def bulk_delete_recipes(
delete_recipes: DeleteRecipes,
bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private),
):
bulk_service.delete_recipes(delete_recipes.recipes)
@router.post("/export", status_code=202)
def bulk_export_recipes(self, export_recipes: ExportRecipes, temp_path=Depends(temporary_zip_path)):
self.service.export_recipes(temp_path, export_recipes.recipes)
@router.get("/export/download")
def get_exported_data_token(self, path: Path):
"""Returns a token to download a file"""
export_router = APIRouter(prefix="/bulk-actions")
return {"fileToken": create_file_token(path)}
@router.get("/export", response_model=list[GroupDataExport])
def get_exported_data(self):
return self.service.get_exports()
@export_router.post("/export")
def bulk_export_recipes(
export_recipes: ExportRecipes,
temp_path=Depends(temporary_zip_path),
bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private),
):
bulk_service.export_recipes(temp_path, export_recipes.recipes)
# return FileResponse(temp_path, filename="recipes.zip")
@export_router.get("/export/download")
def get_exported_data_token(path: Path, _: RecipeBulkActions = Depends(RecipeBulkActions.private)):
# return FileResponse(temp_path, filename="recipes.zip")
"""Returns a token to download a file"""
return {"fileToken": create_file_token(path)}
@export_router.get("/export", response_model=list[GroupDataExport])
def get_exported_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
return bulk_service.get_exports()
# return FileResponse(temp_path, filename="recipes.zip")
@export_router.delete("/export/purge")
def purge_export_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
"""Remove all exports data, including items on disk without database entry"""
amountDelete = bulk_service.purge_exports()
return {"message": f"{amountDelete} exports deleted"}
@router.delete("/export/purge", response_model=SuccessResponse)
def purge_export_data(self):
"""Remove all exports data, including items on disk without database entry"""
amountDelete = self.service.purge_exports()
return SuccessResponse.respond(f"{amountDelete} exports deleted")

View File

@ -1,20 +1,14 @@
from fastapi import Depends
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe.recipe_comments import RecipeCommentOut
router = UserAPIRouter()
@router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
async def get_recipe_comments(
slug: str,
session: Session = Depends(generate_session),
):
"""Get all comments for a recipe"""
db = get_repositories(session)
recipe = db.recipes.get_one(slug)
return db.comments.multi_query({"recipe_id": recipe.id})
@controller(router)
class RecipeCommentsController(BaseUserController):
@router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
async def get_recipe_comments(self, slug: str):
"""Get all comments for a recipe"""
recipe = self.repos.recipes.by_group(self.group_id).get_one(slug)
return self.repos.comments.multi_query({"recipe_id": recipe.id})

View File

@ -2,26 +2,30 @@ from shutil import copyfileobj
from fastapi import Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from pydantic import BaseModel
from slugify import slugify
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset
from mealie.services.image.image import scrape_image, write_image
user_router = UserAPIRouter()
router = UserAPIRouter()
@user_router.post("/{slug}/image")
class UpdateImageResponse(BaseModel):
image: str
@router.post("/{slug}/image")
def scrape_image_url(slug: str, url: CreateRecipeByUrl):
"""Removes an existing image and replaces it with the incoming file."""
scrape_image(url.url, slug)
@user_router.put("/{slug}/image")
@router.put("/{slug}/image", response_model=UpdateImageResponse)
def update_recipe_image(
slug: str,
image: bytes = File(...),
@ -33,10 +37,10 @@ def update_recipe_image(
write_image(slug, image, extension)
new_version = db.recipes.update_image(slug, extension)
return {"image": new_version}
return UpdateImageResponse(image=new_version)
@user_router.post("/{slug}/assets", response_model=RecipeAsset)
@router.post("/{slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
slug: str,
name: str = Form(...),

View File

@ -1,130 +1,245 @@
from fastapi import Depends, File
from functools import cached_property
from zipfile import ZipFile
import sqlalchemy
from fastapi import BackgroundTasks, Depends, File, HTTPException
from fastapi.datastructures import UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from mealie.core import exceptions
from mealie.core.dependencies import temporary_zip_path
from mealie.core.root_logger import get_logger
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
from mealie.core.security import create_recipe_slug_token
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByUrl, Recipe
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.query import GetAll
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
from mealie.schema.response.responses import ErrorResponse
from mealie.schema.server.tasks import ServerTaskNames
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.scraper import create_from_url
from mealie.services.scraper.scraper_strategies import RecipeScraperPackage
from mealie.services.server_tasks.background_executory import BackgroundExecutor
user_router = UserAPIRouter()
logger = get_logger()
class BaseRecipeController(BaseUserController):
@cached_property
def repo(self) -> RepositoryRecipes:
return self.repos.recipes.by_group(self.group_id)
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.group)
@cached_property
def mixins(self):
return CrudMixins[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger)
@user_router.get("", response_model=list[RecipeSummary])
async def get_all(
start: int = 0,
limit: int = None,
load_foods: bool = False,
service: RecipeService = Depends(RecipeService.private),
):
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit, load_foods))
return JSONResponse(content=json_compatible_item_data)
class RecipeGetAll(GetAll):
load_food: bool = False
@user_router.post("", status_code=201, response_model=str)
def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.private)) -> str:
"""Takes in a JSON string and loads data into the database as a new entry"""
return recipe_service.create_one(data).slug
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
@user_router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url(url: CreateRecipeByUrl, recipe_service: RecipeService = Depends(RecipeService.private)):
"""Takes in a URL and attempts to scrape data and load it into the database"""
recipe = create_from_url(url.url)
return recipe_service.create_one(recipe).slug
router_exports = UserAPIRouter(prefix="/recipes", tags=["Recipe: Exports"])
@user_router.post("/create-url/bulk", status_code=202)
def parse_recipe_url_bulk(
bulk: CreateRecipeByUrlBulk,
recipe_service: RecipeService = Depends(RecipeService.private),
bg_service: BackgroundExecutor = Depends(BackgroundExecutor.private),
):
"""Takes in a URL and attempts to scrape data and load it into the database"""
@controller(router_exports)
class RecipeExportController(BaseRecipeController):
# ==================================================================================================================
# Export Operations
def bulk_import_func(task_id: int, session: Session) -> None:
database = get_repositories(session)
task = database.server_tasks.get_one(task_id)
@router_exports.get("/exports", response_model=FormatResponse)
def get_recipe_formats_and_templates(self):
return TemplateService().templates
task.append_log("test task has started")
@router_exports.post("/{slug}/exports")
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return {"token": create_recipe_slug_token(slug)}
for b in bulk.imports:
try:
recipe = create_from_url(b.url)
@router_exports.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str, temp_dir=Depends(temporary_dir)):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
if b.tags:
recipe.tags = b.tags
"""
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_dir, template_name)
return FileResponse(file)
if b.categories:
recipe.recipe_category = b.categories
@router_exports.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str, temp_path=Depends(temporary_zip_path)):
"""Get a Recipe and It's Original Image as a Zip File"""
slug = validate_recipe_token(token)
recipe_service.create_one(recipe)
task.append_log(f"INFO: Created recipe from url: {b.url}")
except Exception as e:
task.append_log(f"Error: Failed to create recipe from url: {b.url}")
task.append_log(f"Error: {e}")
logger.error(f"Failed to create recipe from url: {b.url}")
logger.error(e)
if slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")
router = UserAPIRouter(prefix="/recipes", tags=["Recipe: CRUD"])
@controller(router)
class RecipeController(BaseRecipeController):
# =======================================================================
# URL Scraping Operations
@router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url(self, url: CreateRecipeByUrl):
"""Takes in a URL and attempts to scrape data and load it into the database"""
recipe = create_from_url(url.url)
return self.service.create_one(recipe).slug
@router.post("/create-url/bulk", status_code=202)
def parse_recipe_url_bulk(self, bulk: CreateRecipeByUrlBulk, bg_tasks: BackgroundTasks):
"""Takes in a URL and attempts to scrape data and load it into the database"""
bg_executor = BackgroundExecutor(self.group.id, self.repos, bg_tasks)
def bulk_import_func(task_id: int, session: Session) -> None:
database = get_repositories(session)
task = database.server_tasks.get_one(task_id)
task.append_log("test task has started")
for b in bulk.imports:
try:
recipe = create_from_url(b.url)
if b.tags:
recipe.tags = b.tags
if b.categories:
recipe.recipe_category = b.categories
self.service.create_one(recipe)
task.append_log(f"INFO: Created recipe from url: {b.url}")
except Exception as e:
task.append_log(f"Error: Failed to create recipe from url: {b.url}")
task.append_log(f"Error: {e}")
self.deps.logger.error(f"Failed to create recipe from url: {b.url}")
self.deps.error(e)
database.server_tasks.update(task.id, task)
task.set_finished()
database.server_tasks.update(task.id, task)
task.set_finished()
database.server_tasks.update(task.id, task)
bg_executor.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func)
bg_service.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func)
return {"details": "task has been started"}
return {"details": "task has been started"}
@router.post("/test-scrape-url")
def test_parse_recipe_url(self, url: CreateRecipeByUrl):
# Debugger should produce the same result as the scraper sees before cleaning
scraped_data = RecipeScraperPackage(url.url).scrape_url()
if scraped_data:
return scraped_data.schema.data
return "recipe_scrapers was unable to scrape this URL"
@user_router.post("/test-scrape-url")
def test_parse_recipe_url(url: CreateRecipeByUrl):
# Debugger should produce the same result as the scraper sees before cleaning
scraped_data = RecipeScraperPackage(url.url).scrape_url()
@router.post("/create-from-zip", status_code=201)
def create_recipe_from_zip(self, temp_path=Depends(temporary_zip_path), archive: UploadFile = File(...)):
"""Create recipe from archive"""
recipe = self.service.create_from_zip(archive, temp_path)
return recipe.slug
if scraped_data:
return scraped_data.schema.data
return "recipe_scrapers was unable to scrape this URL"
# ==================================================================================================================
# CRUD Operations
@router.get("", response_model=list[RecipeSummary])
def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)):
items = self.repo.summary(self.user.group_id, start=q.start, limit=q.limit, load_foods=q.load_food)
@user_router.post("/create-from-zip", status_code=201)
async def create_recipe_from_zip(
recipe_service: RecipeService = Depends(RecipeService.private),
temp_path=Depends(temporary_zip_path),
archive: UploadFile = File(...),
):
"""Create recipe from archive"""
recipe = recipe_service.create_from_zip(archive, temp_path)
return recipe.slug
new_items = []
for item in items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__
if q.load_food:
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
@user_router.get("/{slug}", response_model=Recipe)
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
"""Takes in a recipe slug, returns all data for a recipe"""
return recipe_service.item
new_items.append(new_item)
json_compatible_item_data = jsonable_encoder(RecipeSummary.construct(**x) for x in new_items)
@user_router.put("/{slug}")
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
"""Updates a recipe by existing slug and data."""
return recipe_service.update_one(data)
# Response is returned directly, to avoid validation and improve performance
return JSONResponse(content=json_compatible_item_data)
@router.get("/{slug}", response_model=Recipe)
def get_one(self, slug: str):
"""Takes in a recipe slug, returns all data for a recipe"""
return self.mixins.get_one(slug)
@user_router.patch("/{slug}")
def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
"""Updates a recipe by existing slug and data."""
return recipe_service.patch_one(data)
@router.post("", status_code=201, response_model=str)
def create_one(self, data: CreateRecipe) -> str:
"""Takes in a JSON string and loads data into the database as a new entry"""
try:
return self.service.create_one(data).slug
except Exception as e:
self.handle_exceptions(e)
def handle_exceptions(self, ex: Exception) -> None:
match type(ex):
case exceptions.PermissionDenied:
self.deps.logger.error("Permission Denied on recipe controller action")
raise HTTPException(status_code=403, detail=ErrorResponse.respond(message="Permission Denied"))
case exceptions.NoEntryFound:
self.deps.logger.error("No Entry Found on recipe controller action")
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found"))
case sqlalchemy.exc.IntegrityError:
self.deps.logger.error("SQL Integrity Error on recipe controller action")
raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists"))
@user_router.delete("/{slug}")
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
"""Deletes a recipe by slug"""
return recipe_service.delete_one()
@router.put("/{slug}")
def update_one(self, slug: str, data: Recipe):
"""Updates a recipe by existing slug and data."""
try:
data = self.service.update_one(slug, data)
except Exception as e:
self.handle_exceptions(e)
return data
@router.patch("/{slug}")
def patch_one(self, slug: str, data: Recipe):
"""Updates a recipe by existing slug and data."""
try:
data = self.service.patch_one(slug, data)
except Exception as e:
self.handle_exceptions(e)
return data
@router.delete("/{slug}")
def delete_one(self, slug: str):
"""Deletes a recipe by slug"""
try:
return self.service.delete_one(slug)
except Exception as e:
self.handle_exceptions(e)

View File

@ -1,80 +0,0 @@
from zipfile import ZipFile
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from mealie.core.dependencies import temporary_zip_path
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
from mealie.core.root_logger import get_logger
from mealie.core.security import create_recipe_slug_token
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
user_router = UserAPIRouter()
public_router = APIRouter()
logger = get_logger()
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
@user_router.get("/exports", response_model=FormatResponse)
async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeService.private)):
return TemplateService().templates
@user_router.post("/{slug}/exports")
async def get_recipe_zip_token(slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return {"token": create_recipe_slug_token(slug)}
@user_router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(
template_name: str,
recipe_service: RecipeService = Depends(RecipeService.write_existing),
temp_dir=Depends(temporary_dir),
):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
file = recipe_service.render_template(temp_dir, template_name)
return FileResponse(file)
@public_router.get("/{slug}/exports/zip")
async def get_recipe_as_zip(
token: str,
slug: str,
session: Session = Depends(generate_session),
temp_path=Depends(temporary_zip_path),
):
"""Get a Recipe and It's Original Image as a Zip File"""
slug = validate_recipe_token(token)
if slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
db = get_repositories(session)
recipe: Recipe = db.recipes.get(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")

View File

@ -1,23 +1,42 @@
from fastapi import Depends
from functools import cached_property
from mealie.routes.routers import UserAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.shared.recipe_shared_service import RecipeShareTokenSummary, SharedRecipeService
from pydantic import UUID4
router = UserAPIRouter(prefix="/shared")
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import RecipeShareTokenSummary
from mealie.schema.recipe.recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave
shared_router = RouterFactory(SharedRecipeService, prefix="/recipes", tags=["Shared: Recipes"])
router = UserAPIRouter(prefix="/shared/recipes", tags=["Shared: Recipes"])
@shared_router.get("", response_model=list[RecipeShareTokenSummary])
def get_all_shared(
recipe_id: int = None,
shared_recipe_service: SharedRecipeService = Depends(SharedRecipeService.private),
):
"""
Get all shared recipes
"""
return shared_recipe_service.get_all(recipe_id)
@controller(router)
class RecipeSharedController(BaseUserController):
@cached_property
def repo(self):
return self.repos.recipe_share_tokens.by_group(self.group_id)
@cached_property
def mixins(self):
return CrudMixins[RecipeShareTokenSave, RecipeShareToken, RecipeShareTokenCreate](self.repo, self.deps.logger)
router.include_router(shared_router)
@router.get("", response_model=list[RecipeShareTokenSummary])
def get_all(self, recipe_id: int = None):
if recipe_id:
return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary)
else:
return self.repo.get_all(override_schema=RecipeShareTokenSummary)
@router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=RecipeShareToken)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.delete("/{item_id}")
def delete_one(self, item_id: UUID4 = None) -> None:
return self.mixins.delete_one(item_id)

View File

@ -1,8 +0,0 @@
from fastapi import APIRouter
from . import site_settings
settings_router = APIRouter()
settings_router.include_router(site_settings.public_router)
settings_router.include_router(site_settings.admin_router)

View File

@ -1,35 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import GroupInDB, PrivateUser
from mealie.utils.post_webhooks import post_webhooks
public_router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
admin_router = AdminAPIRouter(prefix="/api/site-settings", tags=["Settings"])
@public_router.get("")
def get_main_settings(session: Session = Depends(generate_session)):
"""Returns basic site settings"""
db = get_repositories(session)
return db.settings.get(1)
@admin_router.post("/webhooks/test")
def test_webhooks(
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Run the function to test your webhooks"""
db = get_repositories(session)
group_entry: GroupInDB = db.groups.get(current_user.group, "name")
try:
post_webhooks(group_entry.id, session)
except Exception:
return HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,11 +1,51 @@
from fastapi import APIRouter
from functools import cached_property
from . import tags
from fastapi import APIRouter, HTTPException, status
prefix = "/tags"
from mealie.routes._base import BaseUserController, controller
from mealie.schema.recipe import RecipeTagResponse, TagIn
router = APIRouter()
router = APIRouter(prefix="/tags", tags=["Tags: CRUD"])
router.include_router(tags.public_router, prefix=prefix, tags=["Tags: CRUD"])
router.include_router(tags.user_router, prefix=prefix, tags=["Tags: CRUD"])
router.include_router(tags.admin_router, prefix=prefix, tags=["Tags: CRUD"])
@controller(router)
class TagController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tags
@router.get("")
async def get_all_recipe_tags(self):
"""Returns a list of available tags in the database"""
return self.repo.get_all_limit_columns(["slug", "name"])
@router.get("/empty")
def get_empty_tags(self):
"""Returns a list of tags that do not contain any recipes"""
return self.repo.get_empty()
@router.get("/{tag_slug}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(self, tag_slug: str):
"""Returns a list of recipes associated with the provided tag."""
return self.repo.get_one(tag_slug, override_schema=RecipeTagResponse)
@router.post("", status_code=201)
def create_recipe_tag(self, tag: TagIn):
"""Creates a Tag in the database"""
return self.repo.create(tag)
@router.put("/{tag_slug}", response_model=RecipeTagResponse)
def update_recipe_tag(self, tag_slug: str, new_tag: TagIn):
"""Updates an existing Tag in the database"""
return self.repo.update(tag_slug, new_tag)
@router.delete("/{tag_slug}")
def delete_recipe_tag(self, tag_slug: str):
"""Removes a recipe tag from the database. Deleting a
tag does not impact a recipe. The tag will be removed
from any recipes that contain it"""
try:
self.repo.delete(tag_slug)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,68 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from mealie.core.dependencies import is_logged_in
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
from mealie.schema.recipe import RecipeTagResponse, TagIn
public_router = APIRouter()
user_router = UserAPIRouter()
admin_router = AdminAPIRouter()
@public_router.get("")
async def get_all_recipe_tags(session: Session = Depends(generate_session)):
"""Returns a list of available tags in the database"""
db = get_repositories(session)
return db.tags.get_all_limit_columns(["slug", "name"])
@public_router.get("/empty")
def get_empty_tags(session: Session = Depends(generate_session)):
"""Returns a list of tags that do not contain any recipes"""
db = get_repositories(session)
return db.tags.get_empty()
@public_router.get("/{tag}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(
tag: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)
):
"""Returns a list of recipes associated with the provided tag."""
db = get_repositories(session)
tag_obj = db.tags.get(tag)
tag_obj = RecipeTagResponse.from_orm(tag_obj)
if not is_user:
tag_obj.recipes = [x for x in tag_obj.recipes if x.settings.public]
return tag_obj
@user_router.post("")
async def create_recipe_tag(tag: TagIn, session: Session = Depends(generate_session)):
"""Creates a Tag in the database"""
db = get_repositories(session)
return db.tags.create(tag.dict())
@admin_router.put("/{tag}", response_model=RecipeTagResponse)
async def update_recipe_tag(tag: str, new_tag: TagIn, session: Session = Depends(generate_session)):
"""Updates an existing Tag in the database"""
db = get_repositories(session)
return db.tags.update(tag, new_tag.dict())
@admin_router.delete("/{tag}")
async def delete_recipe_tag(tag: str, session: Session = Depends(generate_session)):
"""Removes a recipe tag from the database. Deleting a
tag does not impact a recipe. The tag will be removed
from any recipes that contain it"""
try:
db = get_repositories(session)
db.tags.delete(tag)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,18 +1,47 @@
from functools import cached_property
from fastapi import APIRouter, Depends
from mealie.schema.recipe.recipe_tool import RecipeToolResponse
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_tool_service import RecipeToolService
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe import RecipeTool
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse
router = APIRouter()
tools_router = RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"])
router = APIRouter(prefix="/tools", tags=["Recipes: Tools"])
@tools_router.get("/slug/{slug}")
async def Func(slug: str, tools_service: RecipeToolService = Depends(RecipeToolService.private)):
"""Returns a recipe by slug."""
return tools_service.db.tools.get_one(slug, "slug", override_schema=RecipeToolResponse)
@controller(router)
class RecipeToolController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tools
@property
def mixins(self) -> CrudMixins:
return CrudMixins[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger)
router.include_router(tools_router)
@router.get("", response_model=list[RecipeTool])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeTool)
@router.post("", response_model=RecipeTool, status_code=201)
def create_one(self, data: RecipeToolCreate):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=RecipeTool)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=RecipeTool)
def update_one(self, item_id: int, data: RecipeToolCreate):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=RecipeTool)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id) # type: ignore
@router.get("/slug/{tool_slug}", response_model=RecipeToolResponse)
async def get_one_by_slug(self, tool_slug: str):
return self.repo.get_one(tool_slug, "slug", override_schema=RecipeToolResponse)

View File

@ -1,10 +1,8 @@
from fastapi import APIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_food_service import RecipeFoodService
from mealie.services.recipe.recipe_unit_service import RecipeUnitService
from . import foods, units
router = APIRouter()
router.include_router(RouterFactory(RecipeFoodService, prefix="/foods", tags=["Recipes: Foods"]))
router.include_router(RouterFactory(RecipeUnitService, prefix="/units", tags=["Recipes: Units"]))
router.include_router(foods.router)
router.include_router(units.router)

View File

@ -0,0 +1,56 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
@controller(router)
class IngredientFoodsController(BaseUserController):
@cached_property
def repo(self):
return self.deps.repos.ingredient_foods
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[IngredientUnit])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit)
@router.post("", response_model=IngredientUnit, status_code=201)
def create_one(self, data: CreateIngredientUnit):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=IngredientUnit)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=IngredientUnit)
def update_one(self, item_id: int, data: CreateIngredientUnit):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientUnit)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id)

View File

@ -0,0 +1,56 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit
router = APIRouter(prefix="/units", tags=["Recipes: Units"])
@controller(router)
class IngredientUnitsController(BaseUserController):
@cached_property
def repo(self):
return self.deps.repos.ingredient_units
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[IngredientUnit])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit)
@router.post("", response_model=IngredientUnit, status_code=201)
def create_one(self, data: CreateIngredientUnit):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=IngredientUnit)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=IngredientUnit)
def update_one(self, item_id: int, data: CreateIngredientUnit):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientUnit)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id) # type: ignore

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import api_tokens, crud, favorites, images, passwords, registration
from . import api_tokens, crud, favorites, forgot_password, images, registration
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
@ -8,16 +8,9 @@ user_prefix = "/users"
router = APIRouter()
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"])
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])
router.include_router(passwords.user_router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(passwords.public_router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.public_router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(images.user_router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(api_tokens.router, prefix=user_prefix, tags=["Users: Tokens"])
router.include_router(favorites.user_router, prefix=user_prefix, tags=["Users: Favorites"])
router.include_router(crud.user_router)
router.include_router(crud.admin_router)
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(api_tokens.router)
router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"])

View File

@ -1,61 +1,50 @@
from datetime import timedelta
from fastapi import HTTPException, status
from fastapi.param_functions import Depends
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.core.security import create_access_token
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, PrivateUser
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB
router = UserAPIRouter()
router = UserAPIRouter(prefix="/users", tags=["Users: Tokens"])
@router.post("/api-tokens", status_code=status.HTTP_201_CREATED)
async def create_api_token(
token_name: LoingLiveTokenIn,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Create api_token in the Database"""
@controller(router)
class UserApiTokensController(BaseUserController):
@router.post("/api-tokens", status_code=status.HTTP_201_CREATED)
def create_api_token(
self,
token_name: LoingLiveTokenIn,
):
"""Create api_token in the Database"""
token_data = {"long_token": True, "id": str(current_user.id)}
token_data = {"long_token": True, "id": str(self.user.id)}
five_years = timedelta(1825)
token = create_access_token(token_data, five_years)
five_years = timedelta(1825)
token = create_access_token(token_data, five_years)
token_model = CreateToken(
name=token_name.name,
token=token,
user_id=current_user.id,
)
token_model = CreateToken(
name=token_name.name,
token=token,
user_id=self.user.id,
)
db = get_repositories(session)
new_token_in_db = self.repos.api_tokens.create(token_model)
new_token_in_db = db.api_tokens.create(token_model)
if new_token_in_db:
return {"token": token}
if new_token_in_db:
return {"token": token}
@router.delete("/api-tokens/{token_id}")
def delete_api_token(self, token_id: int):
"""Delete api_token from the Database"""
token: LongLiveTokenInDB = self.repos.api_tokens.get(token_id)
if not token:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database")
@router.delete("/api-tokens/{token_id}")
async def delete_api_token(
token_id: int,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Delete api_token from the Database"""
db = get_repositories(session)
token: LongLiveTokenInDB = db.api_tokens.get(token_id)
if not token:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database")
if token.user.email == current_user.email:
deleted_token = db.api_tokens.delete(token_id)
return {"token_delete": deleted_token.name}
else:
raise HTTPException(status.HTTP_403_FORBIDDEN)
if token.user.email == self.user.email:
deleted_token = self.repos.api_tokens.delete(token_id)
return {"token_delete": deleted_token.name}
else:
raise HTTPException(status.HTTP_403_FORBIDDEN)

View File

@ -1,100 +1,79 @@
from fastapi import BackgroundTasks, Depends, HTTPException, status
from fastapi import HTTPException, status
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.core import security
from mealie.core.dependencies import get_current_user
from mealie.core.security import hash_password
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
from mealie.core.security import hash_password, verify_password
from mealie.routes._base import BaseAdminController, controller
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.mixins import CrudMixins
from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser, UserBase, UserIn, UserOut
from mealie.services.events import create_user_event
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
user_router = UserAPIRouter(prefix="")
admin_router = AdminAPIRouter(prefix="")
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@admin_router.get("", response_model=list[UserOut])
async def get_all_users(session: Session = Depends(generate_session)):
db = get_repositories(session)
return db.users.get_all()
@controller(admin_router)
class AdminUserController(BaseAdminController):
@property
def mixins(self) -> CrudMixins:
return CrudMixins[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger)
@admin_router.get("", response_model=list[UserOut])
def get_all_users(self):
return self.repos.users.get_all()
@admin_router.post("", response_model=UserOut, status_code=201)
def create_user(self, new_user: UserIn):
new_user.password = hash_password(new_user.password)
return self.mixins.create_one(new_user)
@admin_router.get("/{item_id}", response_model=UserOut)
def get_user(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@admin_router.delete("/{item_id}")
def delete_user(self, item_id: UUID4):
"""Removes a user from the database. Must be the current user or a super user"""
assert_user_change_allowed(item_id, self.user)
if item_id == 1: # TODO: identify super_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER")
self.mixins.delete_one(item_id)
@admin_router.post("", response_model=UserOut, status_code=201)
async def create_user(
background_tasks: BackgroundTasks,
new_user: UserIn,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
@controller(user_router)
class UserController(BaseUserController):
@user_router.get("/self", response_model=UserOut)
def get_logged_in_user(self):
return self.user
new_user.password = hash_password(new_user.password)
background_tasks.add_task(
create_user_event, "User Created", f"Created by {current_user.full_name}", session=session
)
@user_router.put("/{item_id}")
def update_user(self, item_id: UUID4, new_data: UserBase):
assert_user_change_allowed(item_id, self.user)
db = get_repositories(session)
return db.users.create(new_user.dict())
if not self.user.admin and (new_data.admin or self.user.group != new_data.group):
# prevent a regular user from doing admin tasks on themself
raise HTTPException(status.HTTP_403_FORBIDDEN)
if self.user.id == item_id and self.user.admin and not new_data.admin:
# prevent an admin from demoting themself
raise HTTPException(status.HTTP_403_FORBIDDEN)
@admin_router.get("/{id}", response_model=UserOut)
async def get_user(id: UUID4, session: Session = Depends(generate_session)):
db = get_repositories(session)
return db.users.get(id)
self.repos.users.update(item_id, new_data.dict())
if self.user.id == item_id:
access_token = security.create_access_token(data=dict(sub=new_data.email))
return {"access_token": access_token, "token_type": "bearer"}
@admin_router.delete("/{id}")
def delete_user(
id: UUID4,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
):
"""Removes a user from the database. Must be the current user or a super user"""
@user_router.put("/{item_id}/password")
def update_password(self, password_change: ChangePassword):
"""Resets the User Password"""
if not verify_password(password_change.current_password, self.user.password):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
assert_user_change_allowed(id, current_user)
if id == 1: # TODO: identify super_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER")
try:
db = get_repositories(session)
db.users.delete(id)
background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@user_router.get("/self", response_model=UserOut)
async def get_logged_in_user(
current_user: PrivateUser = Depends(get_current_user),
):
return current_user.dict()
@user_router.put("/{id}")
async def update_user(
id: UUID4,
new_data: UserBase,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
assert_user_change_allowed(id, current_user)
if not current_user.admin and (new_data.admin or current_user.group != new_data.group):
# prevent a regular user from doing admin tasks on themself
raise HTTPException(status.HTTP_403_FORBIDDEN)
if current_user.id == id and current_user.admin and not new_data.admin:
# prevent an admin from demoting themself
raise HTTPException(status.HTTP_403_FORBIDDEN)
db = get_repositories(session)
db.users.update(id, new_data.dict())
if current_user.id == id:
access_token = security.create_access_token(data=dict(sub=new_data.email))
return {"access_token": access_token, "token_type": "bearer"}
self.user.password = hash_password(password_change.new_password)
return self.repos.users.update_password(self.user.id, self.user.password)

View File

@ -1,48 +1,31 @@
from fastapi import Depends
from sqlalchemy.orm.session import Session
from pydantic import UUID4
from mealie.core.dependencies import get_current_user
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser, UserFavorites
from mealie.schema.user import UserFavorites
user_router = UserAPIRouter()
router = UserAPIRouter()
@user_router.get("/{id}/favorites", response_model=UserFavorites)
async def get_favorites(id: str, session: Session = Depends(generate_session)):
"""Get user's favorite recipes"""
db = get_repositories(session)
return db.users.get(id, override_schema=UserFavorites)
@controller(router)
class UserFavoritesController(BaseUserController):
@router.get("/{id}/favorites", response_model=UserFavorites)
async def get_favorites(self, id: UUID4):
"""Get user's favorite recipes"""
return self.repos.users.get(id, override_schema=UserFavorites)
@router.post("/{id}/favorites/{slug}")
def add_favorite(self, id: UUID4, slug: str):
"""Adds a Recipe to the users favorites"""
assert_user_change_allowed(id, self.user)
self.user.favorite_recipes.append(slug)
self.repos.users.update(self.user.id, self.user)
@user_router.post("/{id}/favorites/{slug}")
def add_favorite(
slug: str,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Adds a Recipe to the users favorites"""
current_user.favorite_recipes.append(slug)
db = get_repositories(session)
db.users.update(current_user.id, current_user)
@user_router.delete("/{id}/favorites/{slug}")
def remove_favorite(
slug: str,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Adds a Recipe to the users favorites"""
assert_user_change_allowed(id, current_user)
current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug]
db = get_repositories(session)
db.users.update(current_user.id, current_user)
return
@router.delete("/{id}/favorites/{slug}")
def remove_favorite(self, id: UUID4, slug: str):
"""Adds a Recipe to the users favorites"""
assert_user_change_allowed(id, self.user)
self.user.favorite_recipes = [x for x in self.user.favorite_recipes if x != slug]
self.repos.users.update(self.user.id, self.user)
return

View File

@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session
from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword
from mealie.services.user_services.password_reset_service import PasswordResetService
router = APIRouter(prefix="")
@router.post("/forgot-password")
def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)):
"""Sends an email with a reset link to the user"""
f_service = PasswordResetService(session)
return f_service.send_reset_email(email.email)
@router.post("/reset-password")
def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)):
"""Resets the user password"""
f_service = PasswordResetService(session)
return f_service.reset_password(reset_password.token, reset_password.password)

View File

@ -2,48 +2,41 @@ import shutil
from pathlib import Path
from fastapi import Depends, File, HTTPException, UploadFile, status
from fastapi.routing import APIRouter
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie import utils
from mealie.core.dependencies import get_current_user
from mealie.core.dependencies.dependencies import temporary_dir
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser
from mealie.services.image import minify
public_router = APIRouter(prefix="", tags=["Users: Images"])
user_router = UserAPIRouter(prefix="", tags=["Users: Images"])
router = UserAPIRouter(prefix="", tags=["Users: Images"])
@user_router.post("/{id}/image")
def update_user_image(
id: UUID4,
profile: UploadFile = File(...),
temp_dir: Path = Depends(temporary_dir),
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Updates a User Image"""
assert_user_change_allowed(id, current_user)
@controller(router)
class UserImageController(BaseUserController):
@router.post("/{id}/image")
def update_user_image(
self,
id: UUID4,
profile: UploadFile = File(...),
temp_dir: Path = Depends(temporary_dir),
):
"""Updates a User Image"""
assert_user_change_allowed(id, self.user)
temp_img = temp_dir.joinpath(profile.filename)
temp_img = temp_dir.joinpath(profile.filename)
with temp_img.open("wb") as buffer:
shutil.copyfileobj(profile.file, buffer)
with temp_img.open("wb") as buffer:
shutil.copyfileobj(profile.file, buffer)
image = minify.to_webp(temp_img)
dest = PrivateUser.get_directory(id) / "profile.webp"
image = minify.to_webp(temp_img)
dest = PrivateUser.get_directory(id) / "profile.webp"
shutil.copyfile(image, dest)
shutil.copyfile(image, dest)
self.repos.users.patch(id, {"cache_key": utils.new_cache_key()})
db = get_repositories(session)
db.users.patch(id, {"cache_key": utils.new_cache_key()})
if not dest.is_file:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
if not dest.is_file:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,44 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings
from mealie.core.security import hash_password
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user import ChangePassword
from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword
from mealie.services.user_services import UserService
from mealie.services.user_services.password_reset_service import PasswordResetService
user_router = UserAPIRouter(prefix="")
public_router = APIRouter(prefix="")
settings = get_app_settings()
@user_router.put("/{id}/reset-password")
async def reset_user_password(id: int, session: Session = Depends(generate_session)):
new_password = hash_password(settings.DEFAULT_PASSWORD)
db = get_repositories(session)
db.users.update_password(id, new_password)
@user_router.put("/{item_id}/password")
def update_password(password_change: ChangePassword, user_service: UserService = Depends(UserService.write_existing)):
"""Resets the User Password"""
return user_service.change_password(password_change)
@public_router.post("/forgot-password")
def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)):
"""Sends an email with a reset link to the user"""
f_service = PasswordResetService(session)
return f_service.send_reset_email(email.email)
@public_router.post("/reset-password")
def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)):
"""Resets the user password"""
f_service = PasswordResetService(session)
return f_service.reset_password(reset_password.token, reset_password.password)

View File

@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, status
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BasePublicController, controller
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import UserOut
from mealie.services.user_services.registration_service import RegistrationService
@ -7,8 +9,9 @@ from mealie.services.user_services.registration_service import RegistrationServi
router = APIRouter(prefix="/register")
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_new_user(
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
):
return registration_service.register_user(data)
@controller(router)
class RegistrationController(BasePublicController):
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_new_user(self, data: CreateUserRegistration):
registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session))
return registration_service.register_user(data)

View File

@ -30,3 +30,4 @@ class CheckAppConfig(CamelModel):
email_ready: bool = False
ldap_ready: bool = False
base_url_set: bool = False
is_up_to_date: bool = False

View File

@ -12,50 +12,18 @@ class BackupOptions(BaseModel):
users: bool = True
notifications: bool = True
class Config:
schema_extra = {
"example": {
"recipes": True,
"settings": True,
"themes": True,
"groups": True,
"users": True,
}
}
class ImportJob(BackupOptions):
name: str
force: bool = False
rebase: bool = False
class Config:
schema_extra = {
"example": {
"name": "my_local_backup.zip",
"recipes": True,
"settings": True,
"themes": True,
"groups": True,
"users": True,
}
}
class CreateBackup(BaseModel):
tag: Optional[str]
options: BackupOptions
templates: Optional[List[str]]
class Config:
schema_extra = {
"example": {
"tag": "July 23rd 2021",
"options": BackupOptions(),
"template": ["recipes.md"],
}
}
class BackupFile(BaseModel):
name: str
@ -66,16 +34,3 @@ class BackupFile(BaseModel):
class AllBackups(BaseModel):
imports: List[BackupFile]
templates: List[str]
class Config:
schema_extra = {
"example": {
"imports": [
{
"name": "AutoBackup_12-1-2020.zip",
"date": datetime.now(),
}
],
"templates": ["recipes.md", "custom_template.md"],
}
}

View File

@ -9,13 +9,6 @@ from .restore import RecipeImport
class ChowdownURL(BaseModel):
url: str
class Config:
schema_extra = {
"example": {
"url": "https://chowdownrepo.com/repo",
}
}
class MigrationFile(BaseModel):
name: str

View File

@ -1,7 +1,5 @@
from uuid import UUID
from fastapi_camelcase import CamelModel
from pydantic import validator
from pydantic import UUID4, validator
from slugify import slugify
from ..recipe.recipe_category import CategoryBase, RecipeCategoryResponse
@ -25,16 +23,16 @@ class CreateCookBook(CamelModel):
return slug
class UpdateCookBook(CreateCookBook):
class SaveCookBook(CreateCookBook):
group_id: UUID4
class UpdateCookBook(SaveCookBook):
id: int
class SaveCookBook(CreateCookBook):
group_id: UUID
class ReadCookBook(UpdateCookBook):
group_id: UUID
group_id: UUID4
categories: list[CategoryBase] = []
class Config:
@ -42,7 +40,7 @@ class ReadCookBook(UpdateCookBook):
class RecipeCookBook(ReadCookBook):
group_id: UUID
group_id: UUID4
categories: list[RecipeCategoryResponse]
class Config:

View File

@ -1,5 +1,3 @@
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any, Optional

View File

@ -1,2 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .error_response import *
from .responses import *

View File

@ -15,3 +15,16 @@ class ErrorResponse(BaseModel):
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message, exception=exception).dict()
class SuccessResponse(BaseModel):
message: str
error: bool = False
@classmethod
def respond(cls, message: str) -> dict:
"""
This method is an helper to create an obect and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message).dict()

View File

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

View File

@ -0,0 +1,136 @@
cook_time = "cookTime"
cooking_method = "cookingMethod"
nutrition = "nutrition"
recipe_category = "recipeCategory"
recipe_cuisine = "recipeCuisine"
recipe_ingredient = "recipeIngredient"
recipe_instructions = "recipeInstructions"
recipe_yield = "recipeYield"
suitable_for_diet = "suitableForDiet"
estimated_cost = "estimatedCost"
perform_time = "performTime"
prep_time = "prepTime"
step = "step"
supply = "supply"
tool = "tool"
total_time = "totalTime"
y_yield = "yield"
about = "about"
abstract = "abstract"
access_mode = "accessMode"
access_mode_sufficient = "accessModeSufficient"
accessibility_api = "accessibilityAPI"
accessibility_control = "accessibilityControl"
accessibility_feature = "accessibilityFeature"
accessibility_hazard = "accessibilityHazard"
accessibility_summary = "accessibilitySummary"
accountable_person = "accountablePerson"
acquire_license_page = "acquireLicensePage"
aggregate_rating = "aggregateRating"
alternative_headline = "alternativeHeadline"
archived_at = "archivedAt"
assesses = "assesses"
associated_media = "associatedMedia"
audience = "audience"
audio = "audio"
author = "author"
award = "award"
character = "character"
citation = "citation"
comment = "comment"
comment_count = "commentCount"
conditions_of_access = "conditionsOfAccess"
content_location = "contentLocation"
content_rating = "contentRating"
content_reference_time = "contentReferenceTime"
contributor = "contributor"
copyright_holder = "copyrightHolder"
copyright_notice = "copyrightNotice"
copyright_year = "copyrightYear"
correction = "correction"
country_of_origin = "countryOfOrigin"
creative_work_status = "creativeWorkStatus"
creator = "creator"
credit_text = "creditText"
date_created = "dateCreated"
date_created = "dateCreated"
date_modified = "dateModified"
date_published = "datePublished"
discussion_url = "discussionUrl"
edit_eidr = "editEIDR"
editor = "editor"
educational_alignment = "educationalAlignment"
educational_level = "educationalLevel"
educational_use = "educationalUse"
encoding = "encoding"
encoding_format = "encodingFormat"
example_of_work = "exampleOfWork"
expires = "expires"
funder = "funder"
genre = "genre"
has_part = "hasPart"
headline = "headline"
in_language = "inLanguage"
interaction_statistic = "interactionStatistic"
interactivity_type = "interactivityType"
interpreted_as_claim = "interpretedAsClaim"
is_accessible_for_free = "isAccessibleForFree"
is_based_on = "isBasedOn"
is_family_friendly = "isFamilyFriendly"
is_part_of = "isPartOf"
keywords = "keywords"
learning_resource_type = "learningResourceType"
license = "license"
location_created = "locationCreated"
main_entity = "mainEntity"
maintainer = "maintainer"
material = "material"
material_extent = "materialExtent"
mentions = "mentions"
offers = "offers"
pattern = "pattern"
position = "position"
producer = "producer"
provider = "provider"
publication = "publication"
publisher = "publisher"
publisher_imprint = "publisherImprint"
publishing_principles = "publishingPrinciples"
recorded_at = "recordedAt"
released_event = "releasedEvent"
review = "review"
schema_version = "schemaVersion"
sd_date_published = "sdDatePublished"
sd_license = "sdLicense"
sd_publisher = "sdPublisher"
size = "size"
source_organization = "sourceOrganization"
spatial = "spatial"
spatial_coverage = "spatialCoverage"
sponsor = "sponsor"
teaches = "teaches"
temporal = "temporal"
temporal_coverage = "temporalCoverage"
text = "text"
thumbnail_url = "thumbnailUrl"
time_required = "timeRequired"
translation_of_work = "translationOfWork"
translator = "translator"
typical_age_range = "typicalAgeRange"
usage_info = "usageInfo"
version = "version"
video = "video"
work_example = "workExample"
work_translation = "workTranslation"
additional_type = "additionalType"
alternate_name = "alternateName"
description = "description"
disambiguating_description = "disambiguatingDescription"
identifier = "identifier"
image = "image"
main_entity_of_page = "mainEntityOfPage"
name = "name"
potential_action = "potentialAction"
same_as = "sameAs"
subject_of = "subjectOf"
url = "url"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More