feat(backend): start multi-tenant support (WIP) (#680)

* fix ts types

* feat(code-generation): ♻️ update code-generation formats

* new scope

* add step button

* fix linter error

* update code-generation tags

* feat(backend):  start multi-tenant support

* feat(backend):  group invitation token generation and signup

* refactor(backend): ♻️ move group admin actions to admin router

* set url base to include `/admin`

* feat(frontend):  generate user sign-up links

* test(backend):  refactor test-suite to further decouple tests (WIP)

* feat(backend): 🐛 assign owner on backup import for recipes

* fix(backend): 🐛 assign recipe owner on migration from other service

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-09-09 08:51:29 -08:00
committed by GitHub
parent 3c504e7048
commit bdaf758712
90 changed files with 1793 additions and 949 deletions

View File

@@ -10,7 +10,6 @@ from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router
from mealie.routes.site_settings import settings_router
from mealie.services.events import create_general_event
from mealie.services.recipe.all_recipe_service import subscripte_to_recipe_events
logger = get_logger()
@@ -71,7 +70,6 @@ def system_startup():
)
)
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
subscripte_to_recipe_events()
def main():

View File

@@ -6,6 +6,18 @@ 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.

View File

@@ -52,6 +52,21 @@ class BaseAccessModel(Generic[T, D]):
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
def multi_query(
self,
session: Session,
query_by: dict[str, str],
start=0,
limit: int = None,
override_schema=None,
) -> list[T]:
eff_schema = override_schema or self.schema
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter_by(**query_by).offset(start).limit(limit).all()
]
def get_all_limit_columns(self, session: Session, fields: list[str], limit: int = None) -> list[D]:
"""Queries the database for the selected model. Restricts return responses to the
keys specified under "fields"
@@ -105,11 +120,6 @@ class BaseAccessModel(Generic[T, D]):
eff_schema = override_schema or self.schema
return eff_schema.from_orm(result)
def get_many(
self, session: Session, value: str, key: str = None, limit=1, any_case=False, override_schema=None
) -> list[T]:
pass
def get(
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> T | list[T]:

View File

@@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.group.webhooks import GroupWebhooksModel
@@ -22,6 +23,7 @@ from mealie.schema.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.recipe import (
@@ -87,6 +89,7 @@ class DatabaseAccessLayer:
# Group Data
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)

View File

@@ -1,3 +1,6 @@
from .cookbook import *
from .group import *
from .invite_tokens import *
from .preferences import *
from .shopping_list import *
from .webhooks import *

View File

@@ -3,6 +3,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session
from mealie.core.config import settings
from mealie.db.models.group.invite_tokens import GroupInviteToken
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
@@ -14,11 +15,13 @@ from .preferences import GroupPreferencesModel
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
invite_tokens = orm.relationship(
GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True
)
preferences = orm.relationship(
GroupPreferencesModel,
back_populates="group",
@@ -27,13 +30,16 @@ class Group(SqlAlchemyBase, BaseMixins):
cascade="all, delete-orphan",
)
# Recipes
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens"})
def __init__(self, **_) -> None:
pass

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "invite_tokens"
token = Column(String, index=True, nullable=False, unique=True)
uses_left = Column(Integer, nullable=False, default=1)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="invite_tokens")
@auto_init()
def __init__(self, **_):
pass

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import validates
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from ..users import users_to_favorites
from .api_extras import ApiExtras
from .assets import RecipeAsset
from .category import recipes2categories
@@ -22,6 +23,19 @@ from .tool import Tool
class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
slug = sa.Column(sa.String, index=True)
# ID Relationships
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
favorited_by: list = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
# General Recipe Properties
name = sa.Column(sa.String, nullable=False)
description = sa.Column(sa.String)
@@ -57,7 +71,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
@@ -69,10 +82,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime)
# Favorited By
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
class Config:
get_attr = "slug"

View File

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

View File

@@ -0,0 +1,10 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from .._model_base import SqlAlchemyBase
users_to_favorites = Table(
"users_to_favorites",
SqlAlchemyBase.metadata,
Column("user_id", Integer, ForeignKey("users.id")),
Column("recipe_id", Integer, ForeignKey("recipes.id")),
)

View File

@@ -1,9 +1,10 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import settings
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.group import Group
from mealie.db.models.recipe.recipe import RecipeModel
from .._model_base import BaseMixins, SqlAlchemyBase
from ..group import Group
from .user_to_favorite import users_to_favorites
class LongLiveToken(SqlAlchemyBase, BaseMixins):
@@ -33,6 +34,8 @@ class User(SqlAlchemyBase, BaseMixins):
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
# Recipes
tokens: list[LongLiveToken] = orm.relationship(
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
@@ -41,7 +44,10 @@ class User(SqlAlchemyBase, BaseMixins):
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
def __init__(
self,
@@ -65,9 +71,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.password = password
self.advanced = advanced
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
self.favorite_recipes = []
if self.username is None:
self.username = full_name
@@ -99,10 +103,6 @@ class User(SqlAlchemyBase, BaseMixins):
if password:
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
def update_password(self, password):
self.password = password

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter
from . import admin_about, admin_log
from . import admin_about, admin_group, admin_log
router = APIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(admin_group.router, tags=["Admin: Group"])

View File

@@ -4,24 +4,21 @@ from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
from mealie.services.events import create_group_event
admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
router = AdminAPIRouter(prefix="/groups")
@admin_router.get("", response_model=list[GroupInDB])
async def get_all_groups(
session: Session = Depends(generate_session),
):
@router.get("", response_model=list[GroupInDB])
async def get_all_groups(session: Session = Depends(generate_session)):
""" Returns a list of all groups in the database """
return db.groups.get_all(session)
@admin_router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase,
@@ -37,17 +34,13 @@ async def create_group(
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@admin_router.put("/{id}")
async def update_group_data(
id: int,
group_data: UpdateGroup,
session: Session = Depends(generate_session),
):
@router.put("/{id}")
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
""" Updates a User Group """
db.groups.update(session, id, group_data.dict())
@admin_router.delete("/{id}")
@router.delete("/{id}")
async def delete_user_group(
background_tasks: BackgroundTasks,
id: int,

View File

@@ -6,11 +6,13 @@ from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, s
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.admin import BackupJob, ImportJob, Imports, LocalBackup
from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
@@ -82,10 +84,12 @@ def import_database(
file_name: str,
import_data: ImportJob,
session: Session = Depends(generate_session),
user: PrivateUser = Depends(get_current_user),
):
""" Import a database backup file generated from Mealie. """
db_import = imports.import_database(
user=user,
session=session,
archive=import_data.name,
import_recipes=import_data.recipes,

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from . import categories, crud, self_service
from . import categories, invitations, preferences, self_service
router = APIRouter()
@@ -13,5 +13,5 @@ router.include_router(self_service.user_router)
router.include_router(cookbook_router)
router.include_router(categories.user_router)
router.include_router(webhook_router)
router.include_router(crud.user_router)
router.include_router(crud.admin_router)
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])
router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"])

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends, status
from mealie.schema.group.invite_token import CreateInviteToken, 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.write_existing)):
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.write_existing)
):
return g_service.create_invite_token(uses.uses)

View File

@@ -0,0 +1,19 @@
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,7 +1,6 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.user.user import GroupInDB
from mealie.services.group_services.group_service import GroupSelfService
@@ -13,15 +12,3 @@ async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSe
""" Returns the Group Data for the Current User """
return g_service.item
@user_router.put("/preferences", 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
@user_router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View File

@@ -25,7 +25,7 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
and should not hit the API in production"""
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
if recipe_image:
if recipe_image.exists():
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -8,7 +8,9 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.routes.users.crud import get_logged_in_user
from mealie.schema.admin import MigrationFile, Migrations
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import migration
router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"])
@@ -36,10 +38,15 @@ def get_all_migration_options():
@router.post("/{import_type}/{file_name}/import")
def import_migration(import_type: migration.Migration, file_name: str, session: Session = Depends(generate_session)):
def import_migration(
import_type: migration.Migration,
file_name: str,
session: Session = Depends(generate_session),
user: PrivateUser = Depends(get_logged_in_user),
):
""" Imports all the recipes in a given directory """
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
return migration.migrate(import_type, file_path, session)
return migration.migrate(user, import_type, file_path, session)
@router.delete("/{import_type}/{file_name}/delete", status_code=status.HTTP_200_OK)

View File

@@ -8,7 +8,6 @@ router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(recipe_crud_routes.public_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])

View File

@@ -4,28 +4,10 @@ from sqlalchemy.orm.session import Session
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.schema.recipe import RecipeSummary
from mealie.services.recipe.all_recipe_service import AllRecipesService
router = APIRouter()
@router.get("")
def get_recipe_summary(all_recipes_service: AllRecipesService.query = Depends()):
"""
Returns key the recipe summary data for recipes in the database. You can perform
slice operations to set the skip/end amounts for recipes. All recipes are sorted by the added date.
**Query Parameters**
- skip: The database entry to start at. (0 Indexed)
- end: The number of entries to return.
skip=2, end=10 will return entries
"""
return all_recipes_service.get_recipes()
@router.get("/summary/untagged", response_model=list[RecipeSummary])
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary)

View File

@@ -2,7 +2,7 @@ import json
import shutil
from zipfile import ZipFile
from fastapi import APIRouter, Depends, File
from fastapi import Depends, File
from fastapi.datastructures import UploadFile
from scrape_schema_recipe import scrape_url
from sqlalchemy.orm.session import Session
@@ -14,26 +14,24 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes
from mealie.schema.recipe.recipe import CreateRecipe
from mealie.schema.recipe.recipe import CreateRecipe, RecipeSummary
from mealie.services.image.image import write_image
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.scraper.scraper import create_from_url
user_router = UserAPIRouter()
public_router = APIRouter()
logger = get_logger()
@public_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
@user_router.get("", response_model=list[RecipeSummary])
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
return service.get_all(start, limit)
@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_recipe(data).slug
return recipe_service.create_one(data).slug
@user_router.post("/create-url", status_code=201, response_model=str)
@@ -41,7 +39,7 @@ def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Dep
""" 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_recipe(recipe).slug
return recipe_service.create_one(recipe).slug
@user_router.post("/test-scrape-url")
@@ -80,7 +78,13 @@ async def create_recipe_from_zip(
return recipe
@public_router.get("/{slug}/zip")
@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
@user_router.get("/{slug}/zip")
async def get_recipe_as_zip(
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
):
@@ -102,17 +106,17 @@ async def get_recipe_as_zip(
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Updates a recipe by existing slug and data. """
return recipe_service.update_recipe(data)
return recipe_service.update_one(data)
@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_recipe(data)
return recipe_service.patch_one(data)
@user_router.delete("/{slug}")
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Deletes a recipe by slug """
return recipe_service.delete_recipe()
return recipe_service.delete_one()

View File

@@ -18,27 +18,27 @@ async def get_all(
@router.post("", response_model=IngredientFood, status_code=status.HTTP_201_CREATED)
async def create_unit(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
async def create_food(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
""" Create unit in the Database """
return db.ingredient_foods.create(session, unit)
@router.get("/{id}")
async def get_unit(id: str, session: Session = Depends(generate_session)):
async def get_food(id: str, session: Session = Depends(generate_session)):
""" Get unit from the Database """
return db.ingredient_foods.get(session, id)
@router.put("/{id}")
async def update_unit(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
async def update_food(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
""" Update unit in the Database """
return db.ingredient_foods.update(session, id, unit)
@router.delete("/{id}")
async def delete_unit(id: str, session: Session = Depends(generate_session)):
async def delete_food(id: str, session: Session = Depends(generate_session)):
""" Delete unit from the Database """
return db.ingredient_foods.delete(session, id)

View File

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

View File

@@ -8,7 +8,7 @@ router = APIRouter(prefix="/register")
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def reset_user_password(
def register_new_user(
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
):
return registration_service.register_user(data)

View File

@@ -1,72 +0,0 @@
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_admin_user
from mealie.core.security import hash_password
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import PrivateUser, SignUpIn, SignUpOut, SignUpToken, UserIn
from mealie.services.events import create_user_event
public_router = APIRouter(prefix="/sign-ups")
admin_router = AdminAPIRouter(prefix="/sign-ups")
@admin_router.get("", response_model=list[SignUpOut])
async def get_all_open_sign_ups(session: Session = Depends(generate_session)):
""" Returns a list of open sign up links """
return db.sign_ups.get_all(session)
@admin_router.post("", response_model=SignUpToken)
async def create_user_sign_up_key(
background_tasks: BackgroundTasks,
key_data: SignUpIn,
current_user: PrivateUser = Depends(get_admin_user),
session: Session = Depends(generate_session),
):
""" Generates a Random Token that a new user can sign up with """
sign_up = {
"token": str(uuid.uuid1().hex),
"name": key_data.name,
"admin": key_data.admin,
}
background_tasks.add_task(
create_user_event, "Sign-up Token Created", f"Created by {current_user.full_name}", session=session
)
return db.sign_ups.create(session, sign_up)
@public_router.post("/{token}")
async def create_user_with_token(
background_tasks: BackgroundTasks, token: str, new_user: UserIn, session: Session = Depends(generate_session)
):
""" Creates a user with a valid sign up token """
# Validate Token
db_entry: SignUpOut = db.sign_ups.get(session, token, limit=1)
if not db_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
# Create User
new_user.admin = db_entry.admin
new_user.password = hash_password(new_user.password)
db.users.create(session, new_user.dict())
# DeleteToken
background_tasks.add_task(
create_user_event, "Sign-up Token Used", f"New User {new_user.full_name}", session=session
)
db.sign_ups.delete(session, token)
@admin_router.delete("/{token}")
async def delete_token(token: str, session: Session = Depends(generate_session)):
""" Removed a token from the database """
db.sign_ups.delete(session, token)

View File

@@ -0,0 +1,20 @@
from fastapi_camelcase import CamelModel
class CreateInviteToken(CamelModel):
uses: int
class SaveInviteToken(CamelModel):
uses_left: int
group_id: int
token: str
class ReadInviteToken(CamelModel):
token: str
uses_left: int
group_id: int
class Config:
orm_mode = True

View File

@@ -32,11 +32,15 @@ class CreateRecipe(CamelModel):
class RecipeSummary(CamelModel):
id: Optional[int]
user_id: int = 0
group_id: int = 0
name: Optional[str]
slug: str = ""
image: Optional[Any]
description: Optional[str]
description: Optional[str] = ""
recipe_category: Optional[list[str]] = []
tags: Optional[list[str]] = []
rating: Optional[int]
@@ -112,32 +116,6 @@ class Recipe(RecipeSummary):
"extras": {x.key_name: x.value for x in name_orm.extras},
}
schema_extra = {
"example": {
"name": "Chicken and Rice With Leeks and Salsa Verde",
"description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.",
"image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg",
"recipe_yield": "4 Servings",
"recipe_ingredient": [
"1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)",
"Kosher salt, freshly ground pepper",
"3 Tbsp. unsalted butter, divided",
],
"recipe_instructions": [
{
"text": "Season chicken with salt and pepper.",
},
],
"slug": "chicken-and-rice-with-leeks-and-salsa-verde",
"tags": ["favorite", "yummy!"],
"recipe_category": ["Dinner", "Pasta"],
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
"org_url": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
"rating": 3,
"extras": {"message": "Don't forget to defrost the chicken!"},
}
}
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values):
if not values.get("name"):

View File

@@ -81,6 +81,7 @@ class UserIn(UserBase):
class UserOut(UserBase):
id: int
group: str
group_id: int
tokens: Optional[list[LongLiveTokenOut]]
favorite_recipes: Optional[list[str]] = []
@@ -112,6 +113,7 @@ class UserFavorites(UserBase):
class PrivateUser(UserOut):
password: str
group_id: int
class Config:
orm_mode = True

View File

@@ -27,6 +27,7 @@ from mealie.services.image import minify
class ImportDatabase:
def __init__(
self,
user: PrivateUser,
session: Session,
zip_archive: str,
force_import: bool = False,
@@ -41,6 +42,7 @@ class ImportDatabase:
Raises:
Exception: If the zip file does not exists an exception raise.
"""
self.user = user
self.session = session
self.archive = app_dirs.BACKUP_DIR.joinpath(zip_archive)
self.force_imports = force_import
@@ -66,6 +68,9 @@ class ImportDatabase:
for recipe in recipes:
recipe: Recipe
recipe.group_id = self.user.group_id
recipe.user_id = self.user.id
import_status = self.import_model(
db_table=db.recipes,
model=recipe,
@@ -308,6 +313,7 @@ class ImportDatabase:
def import_database(
session: Session,
user: PrivateUser,
archive,
import_recipes=True,
import_settings=True,
@@ -317,7 +323,7 @@ def import_database(
force_import: bool = False,
rebase: bool = False,
):
import_session = ImportDatabase(session, archive, force_import)
import_session = ImportDatabase(user, session, archive, force_import)
recipe_report = []
if import_recipes:

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken, SaveInviteToken
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
from mealie.services._base_http_service.http_services import UserHttpService
@@ -50,3 +53,10 @@ class GroupSelfService(UserHttpService[int, str]):
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
return self.populate_item()
def create_invite_token(self, uses: int = 1) -> None:
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
return self.db.group_tokens.create(self.session, token)
def get_invite_tokens(self) -> list[ReadInviteToken]:
return self.db.group_tokens.multi_query(self.session, {"group_id": self.group_id})

View File

@@ -10,6 +10,7 @@ from mealie.core import root_logger
from mealie.db.database import db
from mealie.schema.admin import MigrationImport
from mealie.schema.recipe import Recipe
from mealie.schema.user.user import PrivateUser
from mealie.services.image import image
from mealie.services.scraper import cleaner
from mealie.utils.unzip import unpack_zip
@@ -34,6 +35,8 @@ class MigrationBase(BaseModel):
session: Optional[Any]
key_aliases: Optional[list[MigrationAlias]]
user: PrivateUser
@property
def temp_dir(self) -> TemporaryDirectory:
"""unpacks the migration_file into a temporary directory
@@ -162,6 +165,10 @@ class MigrationBase(BaseModel):
"""
for recipe in validated_recipes:
recipe.user_id = self.user.id
recipe.group_id = self.user.group_id
exception = ""
status = False
try:

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.schema.admin import MigrationImport
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
@@ -18,8 +19,8 @@ class ChowdownMigration(MigrationBase):
]
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
cd_migration = ChowdownMigration(user=user, migration_file=zip_path, session=session)
with cd_migration.temp_dir as dir:
chow_dir = next(Path(dir).iterdir())

View File

@@ -19,7 +19,7 @@ class Migration(str, Enum):
chowdown = "chowdown"
def migrate(migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
def migrate(user, migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
"""The new entry point for accessing migrations within the 'migrations' service.
Using the 'Migrations' enum class as a selector for migration_type to direct which function
to call. All migrations will return a MigrationImport object that is built for displaying
@@ -37,10 +37,10 @@ def migrate(migration_type: str, file_path: Path, session: Session) -> list[Migr
logger.info(f"Starting Migration from {migration_type}")
if migration_type == Migration.nextcloud.value:
migration_imports = nextcloud.migrate(session, file_path)
migration_imports = nextcloud.migrate(user, session, file_path)
elif migration_type == Migration.chowdown.value:
migration_imports = chowdown.migrate(session, file_path)
migration_imports = chowdown.migrate(user, session, file_path)
else:
return []

View File

@@ -6,6 +6,7 @@ from slugify import slugify
from sqlalchemy.orm.session import Session
from mealie.schema.admin import MigrationImport
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
@@ -42,9 +43,9 @@ class NextcloudMigration(MigrationBase):
]
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
nc_migration = NextcloudMigration(user=user, migration_file=zip_path, session=session)
with nc_migration.temp_dir as dir:
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)

View File

@@ -1,71 +0,0 @@
import json
from functools import lru_cache
from fastapi import Depends, Response
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import is_logged_in
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import SessionLocal, generate_session
from mealie.schema.recipe import RecipeSummary
logger = get_logger()
class AllRecipesService:
def __init__(self, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)):
self.start = 0
self.limit = 9999
self.session = session or SessionLocal()
self.is_user = is_user
@classmethod
def query(
cls, start=0, limit=9999, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)
):
set_query = cls(session, is_user)
set_query.start = start
set_query.limit = limit
return set_query
def get_recipes(self):
if self.is_user:
return get_all_recipes_user(self.limit, self.start)
else:
return get_all_recipes_public(self.limit, self.start)
@lru_cache(maxsize=1)
def get_all_recipes_user(limit, start):
with SessionLocal() as session:
all_recipes: list[RecipeSummary] = db.recipes.get_all(
session, limit=limit, start=start, order_by="date_updated", override_schema=RecipeSummary
)
all_recipes_json = [recipe.dict() for recipe in all_recipes]
return Response(content=json.dumps(jsonable_encoder(all_recipes_json)), media_type="application/json")
@lru_cache(maxsize=1)
def get_all_recipes_public(limit, start):
with SessionLocal() as session:
all_recipes: list[RecipeSummary] = db.recipes.get_all_public(
session, limit=limit, start=start, order_by="date_updated", override_schema=RecipeSummary
)
all_recipes_json = [recipe.dict() for recipe in all_recipes]
return Response(content=json.dumps(jsonable_encoder(all_recipes_json)), media_type="application/json")
def clear_all_cache():
get_all_recipes_user.cache_clear()
get_all_recipes_public.cache_clear()
logger.info("All Recipes Cache Cleared")
def subscripte_to_recipe_events():
db.recipes.subscribe(clear_all_cache)
logger.info("All Recipes Subscribed to Database Events")

View File

@@ -0,0 +1,16 @@
from mealie.schema.recipe import Recipe
from mealie.schema.user.user import PrivateUser
def recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
return Recipe(**additional_attrs)

View File

@@ -7,14 +7,15 @@ from sqlalchemy.exc import IntegrityError
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.services.recipe.mixins import recipe_creation_factory
logger = get_logger(module=__name__)
class RecipeService(PublicHttpService[str, Recipe]):
class RecipeService(UserHttpService[str, Recipe]):
"""
Class Methods:
`read_existing`: Reads an existing recipe from the database.
@@ -46,9 +47,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
# CRUD METHODS
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
if isinstance(create_data, CreateRecipe):
create_data = Recipe(name=create_data.name)
def get_all(self, start=0, limit=None):
return self.db.recipes.multi_query(
self.session, {"group_id": self.user.group_id}, start=start, limit=limit, override_schema=RecipeSummary
)
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
try:
self.item = self.db.recipes.create(self.session, create_data)
@@ -56,13 +61,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
self._create_event(
"Recipe Created (URL)",
"Recipe Created",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
)
return self.item
def update_recipe(self, update_data: Recipe) -> Recipe:
def update_one(self, update_data: Recipe) -> Recipe:
original_slug = self.item.slug
try:
@@ -74,7 +79,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
def patch_recipe(self, patch_data: Recipe) -> Recipe:
def patch_one(self, patch_data: Recipe) -> Recipe:
original_slug = self.item.slug
try:
@@ -88,16 +93,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
def delete_recipe(self) -> Recipe:
"""removes a recipe from the database and purges the existing files from the filesystem.
Raises:
HTTPException: 400 Bad Request
Returns:
Recipe: The deleted recipe
"""
def delete_one(self) -> Recipe:
try:
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
self._delete_assets()

View File

@@ -1,3 +1,5 @@
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password
from mealie.schema.group.group_preferences import CreateGroupPreferences
@@ -20,13 +22,37 @@ class RegistrationService(PublicHttpService[int, str]):
self.registration = registration
logger.info(f"Registering user {registration.username}")
token_entry = None
if registration.group:
group = self._create_new_group()
else:
group = self._existing_group_ref()
group = self._register_new_group()
return self._create_new_user(group)
elif registration.group_token and registration.group_token != "":
token_entry = self.db.group_tokens.get(self.session, registration.group_token)
print("Token Entry", token_entry)
if not token_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"})
group = self.db.groups.get(self.session, token_entry.group_id)
else:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
user = self._create_new_user(group)
if token_entry and user:
token_entry.uses_left = token_entry.uses_left - 1
if token_entry.uses_left == 0:
self.db.group_tokens.delete(self.session, token_entry.token)
else:
self.db.group_tokens.update(self.session, token_entry.token, token_entry)
return user
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
new_user = UserIn(
@@ -40,7 +66,7 @@ class RegistrationService(PublicHttpService[int, str]):
return self.db.users.create(self.session, new_user)
def _create_new_group(self) -> GroupInDB:
def _register_new_group(self) -> GroupInDB:
group_data = GroupBase(name=self.registration.group)
group_preferences = CreateGroupPreferences(
@@ -56,6 +82,3 @@ class RegistrationService(PublicHttpService[int, str]):
)
return create_new_group(self.session, group_data, group_preferences)
def _existing_group_ref(self) -> GroupInDB:
pass