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

@@ -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