mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-29 21:37:15 -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:
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
18
mealie/routes/groups/invitations.py
Normal file
18
mealie/routes/groups/invitations.py
Normal 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)
|
||||
19
mealie/routes/groups/preferences.py
Normal file
19
mealie/routes/groups/preferences.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user