From 05f2eab1ea763734c0f916bdb0fa322a96adba7c Mon Sep 17 00:00:00 2001 From: hay-kot Date: Sat, 21 Aug 2021 15:05:00 -0800 Subject: [PATCH] refactor(backend): :recycle: organize and tag user routes by path --- .vscode/settings.json | 8 +- mealie/app.py | 5 +- mealie/routes/__init__.py | 8 ++ mealie/routes/auth/__init__.py | 8 ++ mealie/routes/{users => auth}/auth.py | 4 +- mealie/routes/debug_routes.py | 8 +- mealie/routes/routers.py | 4 +- mealie/routes/users/__init__.py | 29 ++-- mealie/routes/users/_helpers.py | 8 ++ mealie/routes/users/api_tokens.py | 2 +- mealie/routes/users/crud.py | 187 +++++--------------------- mealie/routes/users/favorites.py | 47 +++++++ mealie/routes/users/images.py | 48 +++++++ mealie/routes/users/passwords.py | 41 ++++++ mealie/routes/users/sign_up.py | 7 +- 15 files changed, 232 insertions(+), 182 deletions(-) create mode 100644 mealie/routes/auth/__init__.py rename mealie/routes/{users => auth}/auth.py (92%) create mode 100644 mealie/routes/users/_helpers.py create mode 100644 mealie/routes/users/favorites.py create mode 100644 mealie/routes/users/images.py create mode 100644 mealie/routes/users/passwords.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 52b2299bffbe..6726260b3b18 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,8 @@ { "python.formatting.provider": "black", "python.pythonPath": ".venv/bin/python3.9", - "python.linting.pylintEnabled": false, "python.linting.enabled": true, "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, "python.testing.pytestEnabled": true, "python.testing.autoTestDiscoverOnSaveEnabled": false, "python.testing.pytestArgs": ["tests"], @@ -20,7 +18,9 @@ "editor.formatOnSave": true, "eslint.workingDirectories": ["./frontend"], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": true, + "source.organizeImports": false }, - "vetur.validation.template": false + "vetur.validation.template": false, + "python.linting.pylintEnabled": false } diff --git a/mealie/app.py b/mealie/app.py index 54b6f048472a..96c50bfa9359 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -4,7 +4,7 @@ from fastapi.middleware.gzip import GZipMiddleware from mealie.core.config import APP_VERSION, settings from mealie.core.root_logger import get_logger -from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes +from mealie.routes import backup_routes, debug_routes, migration_routes, router, theme_routes, utility_routes from mealie.routes.about import about_router from mealie.routes.groups import groups_router from mealie.routes.mealplans import meal_plan_router @@ -12,7 +12,6 @@ from mealie.routes.media import media_router from mealie.routes.recipe import recipe_router from mealie.routes.shopping_list import shopping_list_router from mealie.routes.site_settings import settings_router -from mealie.routes.users import user_router from mealie.services.events import create_general_event from mealie.services.recipe.all_recipes import subscripte_to_recipe_events @@ -35,7 +34,7 @@ def start_scheduler(): def api_routers(): # Authentication - app.include_router(user_router) + app.include_router(router) app.include_router(groups_router) app.include_router(shopping_list_router) # Recipes diff --git a/mealie/routes/__init__.py b/mealie/routes/__init__.py index e69de29bb2d1..71c814c38168 100644 --- a/mealie/routes/__init__.py +++ b/mealie/routes/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from . import auth, users + +router = APIRouter(prefix="/api") + +router.include_router(auth.router) +router.include_router(users.router) diff --git a/mealie/routes/auth/__init__.py b/mealie/routes/auth/__init__.py new file mode 100644 index 000000000000..229d3aeee7ef --- /dev/null +++ b/mealie/routes/auth/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from . import auth + +router = APIRouter(prefix="/auth") + +router.include_router(auth.public_router) +router.include_router(auth.user_router) diff --git a/mealie/routes/users/auth.py b/mealie/routes/auth/auth.py similarity index 92% rename from mealie/routes/users/auth.py rename to mealie/routes/auth/auth.py index 0d167562b60f..4496189792b6 100644 --- a/mealie/routes/users/auth.py +++ b/mealie/routes/auth/auth.py @@ -10,8 +10,8 @@ from mealie.schema.user import UserInDB from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -public_router = APIRouter(prefix="/api/auth", tags=["Authentication"]) -user_router = UserAPIRouter(prefix="/api/auth", tags=["Authentication"]) +public_router = APIRouter(tags=["Authentication"]) +user_router = UserAPIRouter(tags=["Authentication"]) @public_router.post("/token/long") diff --git a/mealie/routes/debug_routes.py b/mealie/routes/debug_routes.py index bd7afaa8241d..e4a458c80d63 100644 --- a/mealie/routes/debug_routes.py +++ b/mealie/routes/debug_routes.py @@ -1,6 +1,6 @@ from fastapi import Depends from fastapi.routing import APIRouter -from mealie.core.config import APP_VERSION, app_dirs, settings +from mealie.core.config import APP_VERSION, settings from mealie.core.root_logger import LOGGER_FILE from mealie.core.security import create_file_token from mealie.db.database import db @@ -50,12 +50,6 @@ async def get_mealie_version(): ) -@admin_router.get("/last-recipe-json") -async def get_last_recipe_json(): - """ Returns a token to download a file """ - return {"fileToken": create_file_token(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"))} - - @admin_router.get("/log/{num}") async def get_log(num: int): """ Doc Str """ diff --git a/mealie/routes/routers.py b/mealie/routes/routers.py index 996f7bdca3cc..ad802b2806fa 100644 --- a/mealie/routes/routers.py +++ b/mealie/routes/routers.py @@ -1,8 +1,8 @@ +from typing import List, Optional + from fastapi import APIRouter, Depends from mealie.routes.deps import get_admin_user, get_current_user -from typing import List, Optional - class AdminAPIRouter(APIRouter): """ Router for functions to be protected behind admin authentication """ diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index a01219f8742a..68a3391b8e37 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -1,14 +1,23 @@ from fastapi import APIRouter -from . import api_tokens, auth, crud, sign_up +from . import api_tokens, crud, favorites, images, passwords, sign_up -user_router = APIRouter() +# Must be used because of the way FastAPI works with nested routes +user_prefix = "/users" -user_router.include_router(auth.public_router) -user_router.include_router(auth.user_router) -user_router.include_router(sign_up.public_router) -user_router.include_router(sign_up.admin_router) -user_router.include_router(crud.public_router) -user_router.include_router(crud.user_router) -user_router.include_router(crud.admin_router) -user_router.include_router(api_tokens.router) +router = APIRouter() + +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"]) + +router.include_router(passwords.user_router, prefix=user_prefix, tags=["Users: Passwords"]) + +router.include_router(images.public_router, prefix=user_prefix, tags=["Users: Images"]) +router.include_router(images.user_router, prefix=user_prefix, tags=["Users: Images"]) + +router.include_router(api_tokens.router, prefix=user_prefix, tags=["Users: Tokens"]) + +router.include_router(favorites.user_router, prefix=user_prefix, tags=["Users: Favorites"]) diff --git a/mealie/routes/users/_helpers.py b/mealie/routes/users/_helpers.py new file mode 100644 index 000000000000..561d1acafa0c --- /dev/null +++ b/mealie/routes/users/_helpers.py @@ -0,0 +1,8 @@ +from fastapi import HTTPException, status +from mealie.schema.user.user import UserInDB + + +def assert_user_change_allowed(id: int, current_user: UserInDB): + if current_user.id != id and not current_user.admin: + # only admins can edit other users + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") diff --git a/mealie/routes/users/api_tokens.py b/mealie/routes/users/api_tokens.py index 502d6648ac61..0684e9262f96 100644 --- a/mealie/routes/users/api_tokens.py +++ b/mealie/routes/users/api_tokens.py @@ -10,7 +10,7 @@ from mealie.routes.routers import UserAPIRouter from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, UserInDB from sqlalchemy.orm.session import Session -router = UserAPIRouter(prefix="/api/users", tags=["User API Tokens"]) +router = UserAPIRouter() @router.post("/api-tokens", status_code=status.HTTP_201_CREATED) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index ddc2473aeb92..82177f91d5a9 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -1,28 +1,22 @@ -import shutil - -from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status -from fastapi.responses import FileResponse -from fastapi.routing import APIRouter +from fastapi import BackgroundTasks, Depends, HTTPException, status from mealie.core import security -from mealie.core.config import app_dirs, settings -from mealie.core.security import get_password_hash, verify_password +from mealie.core.security import get_password_hash from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user from mealie.routes.routers import AdminAPIRouter, UserAPIRouter -from mealie.schema.user import ChangePassword, UserBase, UserFavorites, UserIn, UserInDB, UserOut +from mealie.routes.users._helpers import assert_user_change_allowed +from mealie.schema.user import UserBase, UserIn, UserInDB, UserOut from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -public_router = APIRouter(prefix="/api/users", tags=["Users"]) -user_router = UserAPIRouter(prefix="/api/users", tags=["Users"]) -admin_router = AdminAPIRouter(prefix="/api/users", tags=["Users"]) +user_router = UserAPIRouter(prefix="") +admin_router = AdminAPIRouter(prefix="") -def assert_user_change_allowed(id: int, current_user: UserInDB): - if current_user.id != id and not current_user.admin: - # only admins can edit other users - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") +@admin_router.get("", response_model=list[UserOut]) +async def get_all_users(session: Session = Depends(generate_session)): + return db.users.get_all(session) @admin_router.post("", response_model=UserOut, status_code=201) @@ -40,9 +34,33 @@ async def create_user( return db.users.create(session, new_user.dict()) -@admin_router.get("", response_model=list[UserOut]) -async def get_all_users(session: Session = Depends(generate_session)): - return db.users.get_all(session) +@admin_router.get("/{id}", response_model=UserOut) +async def get_user( + id: int, + session: Session = Depends(generate_session), +): + return db.users.get(session, id) + + +@admin_router.delete("/{id}") +def delete_user( + background_tasks: BackgroundTasks, + id: int, + session: Session = Depends(generate_session), + current_user: UserInDB = Depends(get_current_user), +): + """ Removes a user from the database. Must be the current user or a super user""" + + assert_user_change_allowed(id, current_user) + + if id == 1: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER") + + try: + db.users.delete(session, id) + background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session) + except Exception: + raise HTTPException(status.HTTP_400_BAD_REQUEST) @user_router.get("/self", response_model=UserOut) @@ -52,24 +70,6 @@ async def get_logged_in_user( return current_user.dict() -@admin_router.get("/{id}", response_model=UserOut) -async def get_user_by_id( - id: int, - session: Session = Depends(generate_session), -): - return db.users.get(session, id) - - -@user_router.put("/{id}/reset-password") -async def reset_user_password( - id: int, - session: Session = Depends(generate_session), -): - - new_password = get_password_hash(settings.DEFAULT_PASSWORD) - db.users.update_password(session, id, new_password) - - @user_router.put("/{id}") async def update_user( id: int, @@ -91,117 +91,4 @@ async def update_user( db.users.update(session, id, new_data.dict()) if current_user.id == id: access_token = security.create_access_token(data=dict(sub=new_data.email)) - token = {"access_token": access_token, "token_type": "bearer"} - return token - - -@public_router.get("/{id}/image") -async def get_user_image(id: str): - """ Returns a users profile picture """ - user_dir = app_dirs.USER_DIR.joinpath(id) - for recipe_image in user_dir.glob("profile_image.*"): - return FileResponse(recipe_image) - else: - raise HTTPException(status.HTTP_404_NOT_FOUND) - - -@user_router.post("/{id}/image") -def update_user_image( - id: str, - profile_image: UploadFile = File(...), - current_user: UserInDB = Depends(get_current_user), -): - """ Updates a User Image """ - - assert_user_change_allowed(id, current_user) - - extension = profile_image.filename.split(".")[-1] - - app_dirs.USER_DIR.joinpath(id).mkdir(parents=True, exist_ok=True) - - [x.unlink() for x in app_dirs.USER_DIR.joinpath(id).glob("profile_image.*")] - - dest = app_dirs.USER_DIR.joinpath(id, f"profile_image.{extension}") - - with dest.open("wb") as buffer: - shutil.copyfileobj(profile_image.file, buffer) - - if not dest.is_file: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) - - -@user_router.put("/{id}/password") -def update_password( - id: int, - password_change: ChangePassword, - current_user: UserInDB = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """ Resets the User Password""" - - assert_user_change_allowed(id, current_user) - match_passwords = verify_password(password_change.current_password, current_user.password) - - if not (match_passwords): - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - new_password = get_password_hash(password_change.new_password) - db.users.update_password(session, id, new_password) - - -@user_router.get("/{id}/favorites", response_model=UserFavorites) -async def get_favorites(id: str, session: Session = Depends(generate_session)): - """ Get user's favorite recipes """ - - return db.users.get(session, id, override_schema=UserFavorites) - - -@user_router.post("/{id}/favorites/{slug}") -def add_favorite( - slug: str, - current_user: UserInDB = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """ Adds a Recipe to the users favorites """ - - assert_user_change_allowed(id, current_user) - current_user.favorite_recipes.append(slug) - - db.users.update(session, current_user.id, current_user) - - -@user_router.delete("/{id}/favorites/{slug}") -def remove_favorite( - slug: str, - current_user: UserInDB = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """ Adds a Recipe to the users favorites """ - - assert_user_change_allowed(id, current_user) - current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug] - - db.users.update(session, current_user.id, current_user) - - return - - -@admin_router.delete("/{id}") -def delete_user( - background_tasks: BackgroundTasks, - id: int, - session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), -): - """ Removes a user from the database. Must be the current user or a super user""" - - assert_user_change_allowed(id, current_user) - - if id == 1: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER") - - try: - db.users.delete(session, id) - background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) + return {"access_token": access_token, "token_type": "bearer"} diff --git a/mealie/routes/users/favorites.py b/mealie/routes/users/favorites.py new file mode 100644 index 000000000000..62a0f143b325 --- /dev/null +++ b/mealie/routes/users/favorites.py @@ -0,0 +1,47 @@ +from fastapi import Depends +from mealie.db.database import db +from mealie.db.db_setup import generate_session +from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter +from mealie.routes.users._helpers import assert_user_change_allowed +from mealie.schema.user import UserFavorites, UserInDB +from sqlalchemy.orm.session import Session + +user_router = UserAPIRouter() + + +@user_router.get("/{id}/favorites", response_model=UserFavorites) +async def get_favorites(id: str, session: Session = Depends(generate_session)): + """ Get user's favorite recipes """ + + return db.users.get(session, id, override_schema=UserFavorites) + + +@user_router.post("/{id}/favorites/{slug}") +def add_favorite( + slug: str, + current_user: UserInDB = Depends(get_current_user), + session: Session = Depends(generate_session), +): + """ Adds a Recipe to the users favorites """ + + assert_user_change_allowed(id, current_user) + current_user.favorite_recipes.append(slug) + + db.users.update(session, current_user.id, current_user) + + +@user_router.delete("/{id}/favorites/{slug}") +def remove_favorite( + slug: str, + current_user: UserInDB = Depends(get_current_user), + session: Session = Depends(generate_session), +): + """ Adds a Recipe to the users favorites """ + + assert_user_change_allowed(id, current_user) + current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug] + + db.users.update(session, current_user.id, current_user) + + return diff --git a/mealie/routes/users/images.py b/mealie/routes/users/images.py new file mode 100644 index 000000000000..a2e257ff5f58 --- /dev/null +++ b/mealie/routes/users/images.py @@ -0,0 +1,48 @@ +import shutil + +from fastapi import Depends, File, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from fastapi.routing import APIRouter +from mealie.core.config import app_dirs +from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter +from mealie.routes.users._helpers import assert_user_change_allowed +from mealie.schema.user import UserInDB + +public_router = APIRouter(prefix="", tags=["Users: Images"]) +user_router = UserAPIRouter(prefix="", tags=["Users: Images"]) + + +@public_router.get("/{id}/image") +async def get_user_image(id: str): + """ Returns a users profile picture """ + user_dir = app_dirs.USER_DIR.joinpath(id) + for recipe_image in user_dir.glob("profile_image.*"): + return FileResponse(recipe_image) + else: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + +@user_router.post("/{id}/image") +def update_user_image( + id: str, + profile_image: UploadFile = File(...), + current_user: UserInDB = Depends(get_current_user), +): + """ Updates a User Image """ + + assert_user_change_allowed(id, current_user) + + extension = profile_image.filename.split(".")[-1] + + app_dirs.USER_DIR.joinpath(id).mkdir(parents=True, exist_ok=True) + + [x.unlink() for x in app_dirs.USER_DIR.joinpath(id).glob("profile_image.*")] + + dest = app_dirs.USER_DIR.joinpath(id, f"profile_image.{extension}") + + with dest.open("wb") as buffer: + shutil.copyfileobj(profile_image.file, buffer) + + if not dest.is_file: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/mealie/routes/users/passwords.py b/mealie/routes/users/passwords.py new file mode 100644 index 000000000000..85d91b5e93b4 --- /dev/null +++ b/mealie/routes/users/passwords.py @@ -0,0 +1,41 @@ +from fastapi import Depends, HTTPException, status +from mealie.core.config import settings +from mealie.core.security import get_password_hash, verify_password +from mealie.db.database import db +from mealie.db.db_setup import generate_session +from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter +from mealie.routes.users._helpers import assert_user_change_allowed +from mealie.schema.user import ChangePassword, UserInDB +from sqlalchemy.orm.session import Session + +user_router = UserAPIRouter(prefix="") + + +@user_router.put("/{id}/reset-password") +async def reset_user_password( + id: int, + session: Session = Depends(generate_session), +): + + new_password = get_password_hash(settings.DEFAULT_PASSWORD) + db.users.update_password(session, id, new_password) + + +@user_router.put("/{id}/password") +def update_password( + id: int, + password_change: ChangePassword, + current_user: UserInDB = Depends(get_current_user), + session: Session = Depends(generate_session), +): + """ Resets the User Password""" + + assert_user_change_allowed(id, current_user) + match_passwords = verify_password(password_change.current_password, current_user.password) + + if not (match_passwords): + raise HTTPException(status.HTTP_400_BAD_REQUEST) + + new_password = get_password_hash(password_change.new_password) + db.users.update_password(session, id, new_password) diff --git a/mealie/routes/users/sign_up.py b/mealie/routes/users/sign_up.py index 9033c78a738f..e963faab2cdd 100644 --- a/mealie/routes/users/sign_up.py +++ b/mealie/routes/users/sign_up.py @@ -6,12 +6,13 @@ from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_admin_user from mealie.routes.routers import AdminAPIRouter -from mealie.schema.user import SignUpIn, SignUpOut, SignUpToken, UserIn, UserInDB +from mealie.schema.user import (SignUpIn, SignUpOut, SignUpToken, UserIn, + UserInDB) from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -public_router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) -admin_router = AdminAPIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) +public_router = APIRouter(prefix="/sign-ups") +admin_router = AdminAPIRouter(prefix="/sign-ups") @admin_router.get("", response_model=list[SignUpOut])