mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-08 00:43:12 -05:00
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:
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
16
mealie/services/recipe/mixins.py
Normal file
16
mealie/services/recipe/mixins.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user