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

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