From eed33d276d04a719e7555be6d0c4ccda42d49716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 20 Apr 2020 19:03:13 +0200 Subject: [PATCH] :recycle: Refactor backend, settings, DB sessions, types, configs, plugins (#158) * :recycle: Refactor backend, update DB session handling * :sparkles: Add mypy config and plugins * :heavy_plus_sign: Use Python-jose instead of PyJWT as it has some extra functionalities and features * :sparkles: Add/update scripts for test, lint, format * :wrench: Update lint and format configs * :art: Update import format, comments, and types * :art: Add types to config * :sparkles: Add types for all the code, and small fixes * :art: Use global imports to simplify exploring with Jupyter * :recycle: Import schemas and models, instead of each class * :truck: Rename db_session to db for simplicity * :pushpin: Update dependencies installation for testing --- .../backend/app/.flake8 | 3 + .../backend/app/.gitignore | 3 + .../app/app/api/api_v1/endpoints/items.py | 69 +++++++++--------- .../app/app/api/api_v1/endpoints/login.py | 38 +++++----- .../app/app/api/api_v1/endpoints/users.py | 71 +++++++++---------- .../app/app/api/api_v1/endpoints/utils.py | 22 +++--- .../backend/app/app/api/deps.py | 61 ++++++++++++++++ .../backend/app/app/api/utils/__init__.py | 0 .../backend/app/app/api/utils/db.py | 5 -- .../backend/app/app/api/utils/security.py | 45 ------------ .../backend/app/app/backend_pre_start.py | 9 +-- .../backend/app/app/celeryworker_pre_start.py | 9 +-- .../backend/app/app/core/config.py | 43 ++++++----- .../backend/app/app/core/jwt.py | 19 ----- .../backend/app/app/core/security.py | 27 ++++++- .../backend/app/app/crud/__init__.py | 2 +- .../backend/app/app/crud/base.py | 47 +++++++----- .../backend/app/app/crud/crud_item.py | 14 ++-- .../backend/app/app/crud/crud_user.py | 40 ++++++----- .../backend/app/app/db/base.py | 2 +- .../backend/app/app/db/base_class.py | 6 +- .../backend/app/app/db/init_db.py | 17 ++--- .../backend/app/app/db/session.py | 7 +- .../backend/app/app/initial_data.py | 9 +-- .../backend/app/app/main.py | 16 ++--- .../backend/app/app/models/__init__.py | 2 + .../backend/app/app/models/item.py | 5 ++ .../backend/app/app/models/user.py | 9 ++- .../backend/app/app/schemas/__init__.py | 4 ++ .../backend/app/app/schemas/item.py | 6 +- .../backend/app/app/schemas/token.py | 4 +- .../backend/app/app/schemas/user.py | 4 +- .../app/app/tests/api/api_v1/test_celery.py | 4 +- .../app/app/tests/api/api_v1/test_items.py | 9 +-- .../app/app/tests/api/api_v1/test_login.py | 6 +- .../app/app/tests/api/api_v1/test_users.py | 42 ++++++----- .../backend/app/app/tests/conftest.py | 19 +++-- .../backend/app/app/tests/crud/test_item.py | 44 +++++------- .../backend/app/app/tests/crud/test_user.py | 56 +++++++-------- .../backend/app/app/tests/utils/item.py | 15 ++-- .../backend/app/app/tests/utils/user.py | 31 ++++---- .../backend/app/app/tests/utils/utils.py | 9 +-- .../backend/app/app/tests_pre_start.py | 9 +-- .../backend/app/app/utils.py | 37 +++++----- .../backend/app/app/worker.py | 4 +- .../backend/app/mypy.ini | 4 ++ .../backend/app/pyproject.toml | 12 +++- .../backend/app/scripts/format-imports.sh | 6 ++ .../backend/app/scripts/format.sh | 6 ++ .../backend/app/scripts/lint.sh | 7 +- .../backend/app/scripts/test-cov-html.sh | 6 ++ .../backend/app/scripts/test.sh | 6 ++ .../backend/app/tests-start.sh | 2 +- .../backend/backend.dockerfile | 13 ++-- .../backend/celeryworker.dockerfile | 10 +-- .../docker-compose.override.yml | 6 +- .../docker-compose.yml | 4 ++ .../scripts/build-push.sh | 2 +- {{cookiecutter.project_slug}}/scripts/test.sh | 1 + 59 files changed, 545 insertions(+), 443 deletions(-) create mode 100644 {{cookiecutter.project_slug}}/backend/app/.flake8 create mode 100644 {{cookiecutter.project_slug}}/backend/app/.gitignore create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/api/deps.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/api/utils/__init__.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/api/utils/db.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/core/jwt.py create mode 100644 {{cookiecutter.project_slug}}/backend/app/mypy.ini create mode 100755 {{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh create mode 100755 {{cookiecutter.project_slug}}/backend/app/scripts/format.sh create mode 100755 {{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh create mode 100755 {{cookiecutter.project_slug}}/backend/app/scripts/test.sh diff --git a/{{cookiecutter.project_slug}}/backend/app/.flake8 b/{{cookiecutter.project_slug}}/backend/app/.flake8 new file mode 100644 index 0000000000..710dc9c030 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache diff --git a/{{cookiecutter.project_slug}}/backend/app/.gitignore b/{{cookiecutter.project_slug}}/backend/app/.gitignore new file mode 100644 index 0000000000..f511683016 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/.gitignore @@ -0,0 +1,3 @@ +.mypy_cache +.coverage +htmlcov diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py index bddb472375..e88885cd80 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py @@ -1,24 +1,21 @@ -from typing import List +from typing import Any, List from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app import crud -from app.api.utils.db import get_db -from app.api.utils.security import get_current_active_user -from app.models.user import User as DBUser -from app.schemas.item import Item, ItemCreate, ItemUpdate +from app import crud, models, schemas +from app.api import deps router = APIRouter() -@router.get("/", response_model=List[Item]) +@router.get("/", response_model=List[schemas.Item]) def read_items( - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), skip: int = 0, limit: int = 100, - current_user: DBUser = Depends(get_current_active_user), -): + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Retrieve items. """ @@ -26,58 +23,56 @@ def read_items( items = crud.item.get_multi(db, skip=skip, limit=limit) else: items = crud.item.get_multi_by_owner( - db_session=db, owner_id=current_user.id, skip=skip, limit=limit + db=db, owner_id=current_user.id, skip=skip, limit=limit ) return items -@router.post("/", response_model=Item) +@router.post("/", response_model=schemas.Item) def create_item( *, - db: Session = Depends(get_db), - item_in: ItemCreate, - current_user: DBUser = Depends(get_current_active_user), -): + db: Session = Depends(deps.get_db), + item_in: schemas.ItemCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Create new item. """ - item = crud.item.create_with_owner( - db_session=db, obj_in=item_in, owner_id=current_user.id - ) + item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=current_user.id) return item -@router.put("/{id}", response_model=Item) +@router.put("/{id}", response_model=schemas.Item) def update_item( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), id: int, - item_in: ItemUpdate, - current_user: DBUser = Depends(get_current_active_user), -): + item_in: schemas.ItemUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Update an item. """ - item = crud.item.get(db_session=db, id=id) + item = crud.item.get(db=db, id=id) if not item: raise HTTPException(status_code=404, detail="Item not found") if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") - item = crud.item.update(db_session=db, db_obj=item, obj_in=item_in) + item = crud.item.update(db=db, db_obj=item, obj_in=item_in) return item -@router.get("/{id}", response_model=Item) +@router.get("/{id}", response_model=schemas.Item) def read_item( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), id: int, - current_user: DBUser = Depends(get_current_active_user), -): + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Get item by ID. """ - item = crud.item.get(db_session=db, id=id) + item = crud.item.get(db=db, id=id) if not item: raise HTTPException(status_code=404, detail="Item not found") if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): @@ -85,20 +80,20 @@ def read_item( return item -@router.delete("/{id}", response_model=Item) +@router.delete("/{id}", response_model=schemas.Item) def delete_item( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), id: int, - current_user: DBUser = Depends(get_current_active_user), -): + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Delete an item. """ - item = crud.item.get(db_session=db, id=id) + item = crud.item.get(db=db, id=id) if not item: raise HTTPException(status_code=404, detail="Item not found") if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") - item = crud.item.remove(db_session=db, id=id) + item = crud.item.remove(db=db, id=id) return item diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py index 34eadc25de..4dc3a9b248 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py @@ -1,19 +1,15 @@ from datetime import timedelta +from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session -from app import crud -from app.api.utils.db import get_db -from app.api.utils.security import get_current_user +from app import crud, models, schemas +from app.api import deps +from app.core import security from app.core.config import settings -from app.core.jwt import create_access_token from app.core.security import get_password_hash -from app.models.user import User as DBUser -from app.schemas.msg import Msg -from app.schemas.token import Token -from app.schemas.user import User from app.utils import ( generate_password_reset_token, send_reset_password_email, @@ -23,10 +19,10 @@ router = APIRouter() -@router.post("/login/access-token", response_model=Token) +@router.post("/login/access-token", response_model=schemas.Token) def login_access_token( - db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() -): + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: """ OAuth2 compatible token login, get an access token for future requests """ @@ -39,23 +35,23 @@ def login_access_token( raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return { - "access_token": create_access_token( - data={"user_id": user.id}, expires_delta=access_token_expires + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires ), "token_type": "bearer", } -@router.post("/login/test-token", response_model=User) -def test_token(current_user: DBUser = Depends(get_current_user)): +@router.post("/login/test-token", response_model=schemas.User) +def test_token(current_user: models.User = Depends(deps.get_current_user)) -> Any: """ Test access token """ return current_user -@router.post("/password-recovery/{email}", response_model=Msg) -def recover_password(email: str, db: Session = Depends(get_db)): +@router.post("/password-recovery/{email}", response_model=schemas.Msg) +def recover_password(email: str, db: Session = Depends(deps.get_db)) -> Any: """ Password Recovery """ @@ -73,10 +69,12 @@ def recover_password(email: str, db: Session = Depends(get_db)): return {"msg": "Password recovery email sent"} -@router.post("/reset-password/", response_model=Msg) +@router.post("/reset-password/", response_model=schemas.Msg) def reset_password( - token: str = Body(...), new_password: str = Body(...), db: Session = Depends(get_db) -): + token: str = Body(...), + new_password: str = Body(...), + db: Session = Depends(deps.get_db), +) -> Any: """ Reset password """ diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py index 0c506eac75..c8f89b63d8 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py @@ -1,28 +1,25 @@ -from typing import List +from typing import Any, List from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.encoders import jsonable_encoder from pydantic.networks import EmailStr from sqlalchemy.orm import Session -from app import crud -from app.api.utils.db import get_db -from app.api.utils.security import get_current_active_superuser, get_current_active_user +from app import crud, models, schemas +from app.api import deps from app.core.config import settings -from app.models.user import User as DBUser -from app.schemas.user import User, UserCreate, UserUpdate from app.utils import send_new_account_email router = APIRouter() -@router.get("/", response_model=List[User]) +@router.get("/", response_model=List[schemas.User]) def read_users( - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), skip: int = 0, limit: int = 100, - current_user: DBUser = Depends(get_current_active_superuser), -): + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: """ Retrieve users. """ @@ -30,13 +27,13 @@ def read_users( return users -@router.post("/", response_model=User) +@router.post("/", response_model=schemas.User) def create_user( *, - db: Session = Depends(get_db), - user_in: UserCreate, - current_user: DBUser = Depends(get_current_active_superuser), -): + db: Session = Depends(deps.get_db), + user_in: schemas.UserCreate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: """ Create new user. """ @@ -54,20 +51,20 @@ def create_user( return user -@router.put("/me", response_model=User) +@router.put("/me", response_model=schemas.User) def update_user_me( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), password: str = Body(None), full_name: str = Body(None), email: EmailStr = Body(None), - current_user: DBUser = Depends(get_current_active_user), -): + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Update own user. """ current_user_data = jsonable_encoder(current_user) - user_in = UserUpdate(**current_user_data) + user_in = schemas.UserUpdate(**current_user_data) if password is not None: user_in.password = password if full_name is not None: @@ -78,25 +75,25 @@ def update_user_me( return user -@router.get("/me", response_model=User) +@router.get("/me", response_model=schemas.User) def read_user_me( - db: Session = Depends(get_db), - current_user: DBUser = Depends(get_current_active_user), -): + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: """ Get current user. """ return current_user -@router.post("/open", response_model=User) +@router.post("/open", response_model=schemas.User) def create_user_open( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), password: str = Body(...), email: EmailStr = Body(...), full_name: str = Body(None), -): +) -> Any: """ Create new user without the need to be logged in. """ @@ -111,17 +108,17 @@ def create_user_open( status_code=400, detail="The user with this username already exists in the system", ) - user_in = UserCreate(password=password, email=email, full_name=full_name) + user_in = schemas.UserCreate(password=password, email=email, full_name=full_name) user = crud.user.create(db, obj_in=user_in) return user -@router.get("/{user_id}", response_model=User) +@router.get("/{user_id}", response_model=schemas.User) def read_user_by_id( user_id: int, - current_user: DBUser = Depends(get_current_active_user), - db: Session = Depends(get_db), -): + current_user: models.User = Depends(deps.get_current_active_user), + db: Session = Depends(deps.get_db), +) -> Any: """ Get a specific user by id. """ @@ -135,14 +132,14 @@ def read_user_by_id( return user -@router.put("/{user_id}", response_model=User) +@router.put("/{user_id}", response_model=schemas.User) def update_user( *, - db: Session = Depends(get_db), + db: Session = Depends(deps.get_db), user_id: int, - user_in: UserUpdate, - current_user: DBUser = Depends(get_current_active_superuser), -): + user_in: schemas.UserUpdate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: """ Update a user. """ diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py index 91c074d451..71fe68d0ce 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py @@ -1,18 +1,21 @@ +from typing import Any + from fastapi import APIRouter, Depends from pydantic.networks import EmailStr -from app.api.utils.security import get_current_active_superuser +from app import models, schemas +from app.api import deps from app.core.celery_app import celery_app -from app.schemas.msg import Msg -from app.schemas.user import User # noqa: F401 -from app.models.user import User as DBUser from app.utils import send_test_email router = APIRouter() -@router.post("/test-celery/", response_model=Msg, status_code=201) -def test_celery(msg: Msg, current_user: DBUser = Depends(get_current_active_superuser)): +@router.post("/test-celery/", response_model=schemas.Msg, status_code=201) +def test_celery( + msg: schemas.Msg, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: """ Test Celery worker. """ @@ -20,10 +23,11 @@ def test_celery(msg: Msg, current_user: DBUser = Depends(get_current_active_supe return {"msg": "Word received"} -@router.post("/test-email/", response_model=Msg, status_code=201) +@router.post("/test-email/", response_model=schemas.Msg, status_code=201) def test_email( - email_to: EmailStr, current_user: DBUser = Depends(get_current_active_superuser) -): + email_to: EmailStr, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: """ Test emails. """ diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/deps.py b/{{cookiecutter.project_slug}}/backend/app/app/api/deps.py new file mode 100644 index 0000000000..a0109afe84 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/deps.py @@ -0,0 +1,61 @@ +from typing import Generator + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.core import security +from app.core.config import settings +from app.db.session import SessionLocal + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token" +) + + +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) +) -> models.User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = schemas.TokenPayload(**payload) + except (jwt.JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = crud.user.get(db, id=token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_current_active_user( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not crud.user.is_active(current_user): + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) + return current_user diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/db.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/db.py deleted file mode 100644 index 24a437e740..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/db.py +++ /dev/null @@ -1,5 +0,0 @@ -from starlette.requests import Request - - -def get_db(request: Request): - return request.state.db diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py deleted file mode 100644 index e00334cc43..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py +++ /dev/null @@ -1,45 +0,0 @@ -import jwt -from fastapi import Depends, HTTPException, Security -from fastapi.security import OAuth2PasswordBearer -from jwt import PyJWTError -from sqlalchemy.orm import Session -from starlette.status import HTTP_403_FORBIDDEN - -from app import crud -from app.api.utils.db import get_db -from app.core.config import settings -from app.core.jwt import ALGORITHM -from app.models.user import User -from app.schemas.token import TokenPayload - -reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token") - - -def get_current_user( - db: Session = Depends(get_db), token: str = Security(reusable_oauth2) -): - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) - token_data = TokenPayload(**payload) - except PyJWTError: - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" - ) - user = crud.user.get(db, id=token_data.user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - return user - - -def get_current_active_user(current_user: User = Security(get_current_user)): - if not crud.user.is_active(current_user): - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -def get_current_active_superuser(current_user: User = Security(get_current_user)): - if not crud.user.is_superuser(current_user): - raise HTTPException( - status_code=400, detail="The user doesn't have enough privileges" - ) - return current_user diff --git a/{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py b/{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py index e0bfa489f1..3363a41542 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py @@ -2,7 +2,7 @@ from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed -from app.db.session import db_session +from app.db.session import SessionLocal logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -17,16 +17,17 @@ before=before_log(logger, logging.INFO), after=after_log(logger, logging.WARN), ) -def init(): +def init() -> None: try: + db = SessionLocal() # Try to create session to check if DB is awake - db_session.execute("SELECT 1") + db.execute("SELECT 1") except Exception as e: logger.error(e) raise e -def main(): +def main() -> None: logger.info("Initializing service") init() logger.info("Service finished initializing") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py b/{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py index e0bfa489f1..81de37134f 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py @@ -2,7 +2,7 @@ from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed -from app.db.session import db_session +from app.db.session import SessionLocal logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -17,16 +17,17 @@ before=before_log(logger, logging.INFO), after=after_log(logger, logging.WARN), ) -def init(): +def init() -> None: try: # Try to create session to check if DB is awake - db_session.execute("SELECT 1") + db = SessionLocal() + db.execute("SELECT 1") except Exception as e: logger.error(e) raise e -def main(): +def main() -> None: logger.info("Initializing service") init() logger.info("Service finished initializing") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py index 51a32c90aa..8b07276dac 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py @@ -1,17 +1,14 @@ import secrets -from typing import List +from typing import Any, Dict, List, Optional, Union from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator class Settings(BaseSettings): - API_V1_STR: str = "/api/v1" - SECRET_KEY: str = secrets.token_urlsafe(32) - - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days - + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 SERVER_NAME: str SERVER_HOST: AnyHttpUrl # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins @@ -20,16 +17,18 @@ class Settings(BaseSettings): BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] @validator("BACKEND_CORS_ORIGINS", pre=True) - def assemble_cors_origins(cls, v): + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: if isinstance(v, str) and not v.startswith("["): return [i.strip() for i in v.split(",")] - return v + elif isinstance(v, (list, str)): + return v + raise ValueError(v) PROJECT_NAME: str - SENTRY_DSN: HttpUrl = None + SENTRY_DSN: Optional[HttpUrl] = None @validator("SENTRY_DSN", pre=True) - def sentry_dsn_can_be_blank(cls, v): + def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]: if len(v) == 0: return None return v @@ -38,10 +37,10 @@ def sentry_dsn_can_be_blank(cls, v): POSTGRES_USER: str POSTGRES_PASSWORD: str POSTGRES_DB: str - SQLALCHEMY_DATABASE_URI: PostgresDsn = None + SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None @validator("SQLALCHEMY_DATABASE_URI", pre=True) - def assemble_db_connection(cls, v, values): + def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any: if isinstance(v, str): return v return PostgresDsn.build( @@ -53,15 +52,15 @@ def assemble_db_connection(cls, v, values): ) SMTP_TLS: bool = True - SMTP_PORT: int = None - SMTP_HOST: str = None - SMTP_USER: str = None - SMTP_PASSWORD: str = None - EMAILS_FROM_EMAIL: EmailStr = None - EMAILS_FROM_NAME: str = None + SMTP_PORT: Optional[int] = None + SMTP_HOST: Optional[str] = None + SMTP_USER: Optional[str] = None + SMTP_PASSWORD: Optional[str] = None + EMAILS_FROM_EMAIL: Optional[EmailStr] = None + EMAILS_FROM_NAME: Optional[str] = None @validator("EMAILS_FROM_NAME") - def get_project_name(cls, v, values): + def get_project_name(cls, v: Optional[str], values: Dict[str, Any]) -> str: if not v: return values["PROJECT_NAME"] return v @@ -71,18 +70,16 @@ def get_project_name(cls, v, values): EMAILS_ENABLED: bool = False @validator("EMAILS_ENABLED", pre=True) - def get_emails_enabled(cls, v, values): + def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool: return bool( values.get("SMTP_HOST") and values.get("SMTP_PORT") and values.get("EMAILS_FROM_EMAIL") ) - EMAIL_TEST_USER: EmailStr = "test@example.com" - + EMAIL_TEST_USER: EmailStr = "test@example.com" # type: ignore FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str - USERS_OPEN_REGISTRATION: bool = False class Config: diff --git a/{{cookiecutter.project_slug}}/backend/app/app/core/jwt.py b/{{cookiecutter.project_slug}}/backend/app/app/core/jwt.py deleted file mode 100644 index 6cdf6ddaa2..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/core/jwt.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime, timedelta - -import jwt - -from app.core.config import settings - -ALGORITHM = "HS256" -access_token_jwt_subject = "access" - - -def create_access_token(*, data: dict, expires_delta: timedelta = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire, "sub": access_token_jwt_subject}) - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt diff --git a/{{cookiecutter.project_slug}}/backend/app/app/core/security.py b/{{cookiecutter.project_slug}}/backend/app/app/core/security.py index 8d2a49f3a4..6c6ee8bc30 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/core/security.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/core/security.py @@ -1,11 +1,34 @@ +from datetime import datetime, timedelta +from typing import Any, Union + +from jose import jwt from passlib.context import CryptContext +from app.core.config import settings + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -def verify_password(plain_password: str, hashed_password: str): +ALGORITHM = "HS256" + + +def create_access_token( + subject: Union[str, Any], expires_delta: timedelta = None +) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) -def get_password_hash(password: str): +def get_password_hash(password: str) -> str: return pwd_context.hash(password) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index e84e698c37..ffaa0d9301 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1,5 +1,5 @@ -from .crud_user import user # noqa: F401 from .crud_item import item # noqa: F401 +from .crud_user import user # noqa: F401 # For a new basic set of CRUD operations you could just do diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index 7ddf099eb5..2b6f1f10cd 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Generic, TypeVar, Type +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union from fastapi.encoders import jsonable_encoder from pydantic import BaseModel @@ -23,35 +23,44 @@ def __init__(self, model: Type[ModelType]): """ self.model = model - def get(self, db_session: Session, id: int) -> Optional[ModelType]: - return db_session.query(self.model).filter(self.model.id == id).first() + def get(self, db: Session, id: Any) -> Optional[ModelType]: + return db.query(self.model).filter(self.model.id == id).first() - def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[ModelType]: - return db_session.query(self.model).offset(skip).limit(limit).all() + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() - def create(self, db_session: Session, *, obj_in: CreateSchemaType) -> ModelType: + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: obj_in_data = jsonable_encoder(obj_in) - db_obj = self.model(**obj_in_data) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) + db_obj = self.model(**obj_in_data) # type: ignore + db.add(db_obj) + db.commit() + db.refresh(db_obj) return db_obj def update( - self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] ) -> ModelType: obj_data = jsonable_encoder(db_obj) - update_data = obj_in.dict(exclude_unset=True) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) for field in obj_data: if field in update_data: setattr(db_obj, field, update_data[field]) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) + db.add(db_obj) + db.commit() + db.refresh(db_obj) return db_obj - def remove(self, db_session: Session, *, id: int) -> ModelType: - obj = db_session.query(self.model).get(id) - db_session.delete(obj) - db_session.commit() + def remove(self, db: Session, *, id: int) -> ModelType: + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() return obj diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py index 16db7bf999..dcb87cdc5c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py @@ -3,27 +3,27 @@ from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session +from app.crud.base import CRUDBase from app.models.item import Item from app.schemas.item import ItemCreate, ItemUpdate -from app.crud.base import CRUDBase class CRUDItem(CRUDBase[Item, ItemCreate, ItemUpdate]): def create_with_owner( - self, db_session: Session, *, obj_in: ItemCreate, owner_id: int + self, db: Session, *, obj_in: ItemCreate, owner_id: int ) -> Item: obj_in_data = jsonable_encoder(obj_in) db_obj = self.model(**obj_in_data, owner_id=owner_id) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) + db.add(db_obj) + db.commit() + db.refresh(db_obj) return db_obj def get_multi_by_owner( - self, db_session: Session, *, owner_id: int, skip=0, limit=100 + self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100 ) -> List[Item]: return ( - db_session.query(self.model) + db.query(self.model) .filter(Item.owner_id == owner_id) .offset(skip) .limit(limit) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py index e8ade85fb5..14525d326f 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py @@ -1,42 +1,44 @@ -from typing import Optional +from typing import Any, Dict, Optional, Union from sqlalchemy.orm import Session -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate, UserInDB -from app.core.security import verify_password, get_password_hash +from app.core.security import get_password_hash, verify_password from app.crud.base import CRUDBase +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): - def get_by_email(self, db_session: Session, *, email: str) -> Optional[User]: - return db_session.query(User).filter(User.email == email).first() + def get_by_email(self, db: Session, *, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() - def create(self, db_session: Session, *, obj_in: UserCreate) -> User: + def create(self, db: Session, *, obj_in: UserCreate) -> User: db_obj = User( email=obj_in.email, hashed_password=get_password_hash(obj_in.password), full_name=obj_in.full_name, is_superuser=obj_in.is_superuser, ) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) + db.add(db_obj) + db.commit() + db.refresh(db_obj) return db_obj - def update(self, db_session: Session, *, db_obj: User, obj_in: UserUpdate) -> User: - if obj_in.password: + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: update_data = obj_in.dict(exclude_unset=True) - hashed_password = get_password_hash(obj_in.password) + if update_data["password"]: + hashed_password = get_password_hash(update_data["password"]) del update_data["password"] update_data["hashed_password"] = hashed_password - use_obj_in = UserInDB.parse_obj(update_data) - return super().update(db_session, db_obj=db_obj, obj_in=use_obj_in) + return super().update(db, db_obj=db_obj, obj_in=update_data) - def authenticate( - self, db_session: Session, *, email: str, password: str - ) -> Optional[User]: - user = self.get_by_email(db_session, email=email) + def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: + user = self.get_by_email(db, email=email) if not user: return None if not verify_password(password, user.hashed_password): diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py index 81c92fda15..b7ec44fec1 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py @@ -1,5 +1,5 @@ # Import all the models, so that Base has them before being # imported by Alembic from app.db.base_class import Base # noqa -from app.models.user import User # noqa from app.models.item import Item # noqa +from app.models.user import User # noqa diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py index 3dadcb3316..3b759110b6 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py @@ -1,9 +1,13 @@ +from typing import Any + from sqlalchemy.ext.declarative import as_declarative, declared_attr @as_declarative() class Base: + id: Any + __name__: str # Generate __tablename__ automatically @declared_attr - def __tablename__(cls): + def __tablename__(cls) -> str: return cls.__name__.lower() diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py index 4c216d2162..bc1dd2e06c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py @@ -1,24 +1,25 @@ -from app import crud +from sqlalchemy.orm import Session + +from app import crud, schemas from app.core.config import settings -from app.schemas.user import UserCreate +from app.db import base # noqa: F401 -# make sure all SQL Alchemy models are imported before initializing DB +# make sure all SQL Alchemy models are imported (app.db.base) before initializing DB # otherwise, SQL Alchemy might fail to initialize relationships properly # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 -from app.db import base # noqa: F401 -def init_db(db_session): +def init_db(db: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create # the tables un-commenting the next line # Base.metadata.create_all(bind=engine) - user = crud.user.get_by_email(db_session, email=settings.FIRST_SUPERUSER) + user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) if not user: - user_in = UserCreate( + user_in = schemas.UserCreate( email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, ) - user = crud.user.create(db_session, obj_in=user_in) # noqa: F841 + user = crud.user.create(db, obj_in=user_in) # noqa: F841 diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/session.py b/{{cookiecutter.project_slug}}/backend/app/app/db/session.py index b7bf6ea8ba..9edb2fa1d0 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/session.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/session.py @@ -1,10 +1,7 @@ from sqlalchemy import create_engine -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import sessionmaker from app.core.config import settings engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) -db_session = scoped_session( - sessionmaker(autocommit=False, autoflush=False, bind=engine) -) -Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/initial_data.py b/{{cookiecutter.project_slug}}/backend/app/app/initial_data.py index a572ada5d8..c50646d2df 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/initial_data.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/initial_data.py @@ -1,17 +1,18 @@ import logging from app.db.init_db import init_db -from app.db.session import db_session +from app.db.session import SessionLocal logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def init(): - init_db(db_session) +def init() -> None: + db = SessionLocal() + init_db(db) -def main(): +def main() -> None: logger.info("Creating initial data") init() logger.info("Initial data created") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/main.py b/{{cookiecutter.project_slug}}/backend/app/app/main.py index ed3a5c3f08..d5d0a79493 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/main.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/main.py @@ -1,12 +1,12 @@ from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request from app.api.api_v1.api import api_router from app.core.config import settings -from app.db.session import Session -app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json") +app = FastAPI( + title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json" +) # Set all CORS enabled origins if settings.BACKEND_CORS_ORIGINS: @@ -16,14 +16,6 @@ allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - ), + ) app.include_router(api_router, prefix=settings.API_V1_STR) - - -@app.middleware("http") -async def db_session_middleware(request: Request, call_next): - request.state.db = Session() - response = await call_next(request) - request.state.db.close() - return response diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py index e69de29bb2..a9c6bdb1ef 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py @@ -0,0 +1,2 @@ +from .item import Item +from .user import User diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py index 685687a098..205535e4b4 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING + from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship from app.db.base_class import Base +if TYPE_CHECKING: + from .user import User # noqa: F401 + class Item(Base): id = Column(Integer, primary_key=True, index=True) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index 1052908a4b..1e8e1a011b 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -1,14 +1,19 @@ +from typing import TYPE_CHECKING + from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy.orm import relationship from app.db.base_class import Base +if TYPE_CHECKING: + from .item import Item # noqa: F401 + class User(Base): id = Column(Integer, primary_key=True, index=True) full_name = Column(String, index=True) - email = Column(String, unique=True, index=True) - hashed_password = Column(String) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) is_active = Column(Boolean(), default=True) is_superuser = Column(Boolean(), default=False) items = relationship("Item", back_populates="owner") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py index e69de29bb2..6b41593dbb 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py @@ -0,0 +1,4 @@ +from .item import Item, ItemCreate, ItemInDB, ItemUpdate +from .msg import Msg +from .token import Token, TokenPayload +from .user import User, UserCreate, UserInDB, UserUpdate diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py index 49ff3784d6..cb3c744d6b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel from .user import User # noqa: F401 @@ -5,8 +7,8 @@ # Shared properties class ItemBase(BaseModel): - title: str = None - description: str = None + title: Optional[str] = None + description: Optional[str] = None # Properties to receive on item creation diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py index 75c0f4a4c1..ea85b460da 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -7,4 +9,4 @@ class Token(BaseModel): class TokenPayload(BaseModel): - user_id: int = None + sub: Optional[int] = None diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py index 32e3a98cf1..7f5c85ac68 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py @@ -7,7 +7,7 @@ class UserBase(BaseModel): email: Optional[EmailStr] = None is_active: Optional[bool] = True - is_superuser: Optional[bool] = False + is_superuser: bool = False full_name: Optional[str] = None @@ -23,7 +23,7 @@ class UserUpdate(UserBase): class UserInDBBase(UserBase): - id: int = None + id: Optional[int] = None class Config: orm_mode = True diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py index e7eb054c0d..5bbcbf9c31 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py @@ -1,10 +1,12 @@ +from typing import Dict + import requests from app.core.config import settings from app.tests.utils.utils import get_server_api -def test_celery_worker_test(superuser_token_headers): +def test_celery_worker_test(superuser_token_headers: Dict[str, str]) -> None: server_api = get_server_api() data = {"msg": "test"} r = requests.post( diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py index 0a6c996ff4..7e89ece1be 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py @@ -1,12 +1,13 @@ import requests +from sqlalchemy.orm import Session from app.core.config import settings from app.tests.utils.item import create_random_item -from app.tests.utils.utils import get_server_api from app.tests.utils.user import create_random_user # noqa: F401 +from app.tests.utils.utils import get_server_api -def test_create_item(superuser_token_headers): +def test_create_item(superuser_token_headers: dict, db: Session) -> None: server_api = get_server_api() data = {"title": "Foo", "description": "Fighters"} response = requests.post( @@ -22,8 +23,8 @@ def test_create_item(superuser_token_headers): assert "owner_id" in content -def test_read_item(superuser_token_headers): - item = create_random_item() +def test_read_item(superuser_token_headers: dict, db: Session) -> None: + item = create_random_item(db) server_api = get_server_api() response = requests.get( f"{server_api}{settings.API_V1_STR}/items/{item.id}", diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py index 26506f81d9..465492c657 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py @@ -1,10 +1,12 @@ +from typing import Dict + import requests from app.core.config import settings from app.tests.utils.utils import get_server_api -def test_get_access_token(): +def test_get_access_token() -> None: server_api = get_server_api() login_data = { "username": settings.FIRST_SUPERUSER, @@ -19,7 +21,7 @@ def test_get_access_token(): assert tokens["access_token"] -def test_use_access_token(superuser_token_headers): +def test_use_access_token(superuser_token_headers: Dict[str, str]) -> None: server_api = get_server_api() r = requests.post( f"{server_api}{settings.API_V1_STR}/login/test-token", diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py index 0d4db33beb..d4d6979155 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py @@ -1,13 +1,15 @@ +from typing import Dict + import requests +from sqlalchemy.orm import Session from app import crud from app.core.config import settings -from app.db.session import db_session from app.schemas.user import UserCreate -from app.tests.utils.utils import get_server_api, random_lower_string, random_email +from app.tests.utils.utils import get_server_api, random_email, random_lower_string -def test_get_users_superuser_me(superuser_token_headers): +def test_get_users_superuser_me(superuser_token_headers: Dict[str, str]) -> None: server_api = get_server_api() r = requests.get( f"{server_api}{settings.API_V1_STR}/users/me", headers=superuser_token_headers @@ -19,7 +21,7 @@ def test_get_users_superuser_me(superuser_token_headers): assert current_user["email"] == settings.FIRST_SUPERUSER -def test_get_users_normal_user_me(normal_user_token_headers): +def test_get_users_normal_user_me(normal_user_token_headers: Dict[str, str]) -> None: server_api = get_server_api() r = requests.get( f"{server_api}{settings.API_V1_STR}/users/me", headers=normal_user_token_headers @@ -31,7 +33,7 @@ def test_get_users_normal_user_me(normal_user_token_headers): assert current_user["email"] == settings.EMAIL_TEST_USER -def test_create_user_new_email(superuser_token_headers): +def test_create_user_new_email(superuser_token_headers: dict, db: Session) -> None: server_api = get_server_api() username = random_email() password = random_lower_string() @@ -43,16 +45,17 @@ def test_create_user_new_email(superuser_token_headers): ) assert 200 <= r.status_code < 300 created_user = r.json() - user = crud.user.get_by_email(db_session, email=username) + user = crud.user.get_by_email(db, email=username) + assert user assert user.email == created_user["email"] -def test_get_existing_user(superuser_token_headers): +def test_get_existing_user(superuser_token_headers: dict, db: Session) -> None: server_api = get_server_api() username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) user_id = user.id r = requests.get( f"{server_api}{settings.API_V1_STR}/users/{user_id}", @@ -60,17 +63,20 @@ def test_get_existing_user(superuser_token_headers): ) assert 200 <= r.status_code < 300 api_user = r.json() - user = crud.user.get_by_email(db_session, email=username) - assert user.email == api_user["email"] + existing_user = crud.user.get_by_email(db, email=username) + assert existing_user + assert existing_user.email == api_user["email"] -def test_create_user_existing_username(superuser_token_headers): +def test_create_user_existing_username( + superuser_token_headers: dict, db: Session +) -> None: server_api = get_server_api() username = random_email() # username = email password = random_lower_string() user_in = UserCreate(email=username, password=password) - crud.user.create(db_session, obj_in=user_in) + crud.user.create(db, obj_in=user_in) data = {"email": username, "password": password} r = requests.post( f"{server_api}{settings.API_V1_STR}/users/", @@ -82,7 +88,7 @@ def test_create_user_existing_username(superuser_token_headers): assert "_id" not in created_user -def test_create_user_by_normal_user(normal_user_token_headers): +def test_create_user_by_normal_user(normal_user_token_headers: Dict[str, str]) -> None: server_api = get_server_api() username = random_email() password = random_lower_string() @@ -95,17 +101,17 @@ def test_create_user_by_normal_user(normal_user_token_headers): assert r.status_code == 400 -def test_retrieve_users(superuser_token_headers): +def test_retrieve_users(superuser_token_headers: dict, db: Session) -> None: server_api = get_server_api() username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, obj_in=user_in) + crud.user.create(db, obj_in=user_in) username2 = random_email() password2 = random_lower_string() user_in2 = UserCreate(email=username2, password=password2) - crud.user.create(db_session, obj_in=user_in2) + crud.user.create(db, obj_in=user_in2) r = requests.get( f"{server_api}{settings.API_V1_STR}/users/", headers=superuser_token_headers @@ -113,5 +119,5 @@ def test_retrieve_users(superuser_token_headers): all_users = r.json() assert len(all_users) > 1 - for user in all_users: - assert "email" in user + for item in all_users: + assert "email" in item diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py index 08b92ce009..37d20956d6 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py @@ -1,20 +1,29 @@ +from typing import Dict, Iterator + import pytest +from sqlalchemy.orm import Session from app.core.config import settings -from app.tests.utils.utils import get_server_api, get_superuser_token_headers +from app.db.session import SessionLocal from app.tests.utils.user import authentication_token_from_email +from app.tests.utils.utils import get_server_api, get_superuser_token_headers + + +@pytest.fixture(scope="session") +def db() -> Iterator[Session]: + yield SessionLocal() @pytest.fixture(scope="module") -def server_api(): +def server_api() -> str: return get_server_api() @pytest.fixture(scope="module") -def superuser_token_headers(): +def superuser_token_headers() -> Dict[str, str]: return get_superuser_token_headers() @pytest.fixture(scope="module") -def normal_user_token_headers(): - return authentication_token_from_email(settings.EMAIL_TEST_USER) +def normal_user_token_headers(db: Session) -> Dict[str, str]: + return authentication_token_from_email(email=settings.EMAIL_TEST_USER, db=db) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py index 739aa8e0a2..e529144ef6 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py @@ -1,65 +1,59 @@ +from sqlalchemy.orm import Session + from app import crud from app.schemas.item import ItemCreate, ItemUpdate from app.tests.utils.user import create_random_user from app.tests.utils.utils import random_lower_string -from app.db.session import db_session -def test_create_item(): +def test_create_item(db: Session) -> None: title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description) - user = create_random_user() - item = crud.item.create_with_owner( - db_session=db_session, obj_in=item_in, owner_id=user.id - ) + user = create_random_user(db) + item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) assert item.title == title assert item.description == description assert item.owner_id == user.id -def test_get_item(): +def test_get_item(db: Session) -> None: title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description) - user = create_random_user() - item = crud.item.create_with_owner( - db_session=db_session, obj_in=item_in, owner_id=user.id - ) - stored_item = crud.item.get(db_session=db_session, id=item.id) + user = create_random_user(db) + item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) + stored_item = crud.item.get(db=db, id=item.id) + assert stored_item assert item.id == stored_item.id assert item.title == stored_item.title assert item.description == stored_item.description assert item.owner_id == stored_item.owner_id -def test_update_item(): +def test_update_item(db: Session) -> None: title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description) - user = create_random_user() - item = crud.item.create_with_owner( - db_session=db_session, obj_in=item_in, owner_id=user.id - ) + user = create_random_user(db) + item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) description2 = random_lower_string() item_update = ItemUpdate(description=description2) - item2 = crud.item.update(db_session=db_session, db_obj=item, obj_in=item_update) + item2 = crud.item.update(db=db, db_obj=item, obj_in=item_update) assert item.id == item2.id assert item.title == item2.title assert item2.description == description2 assert item.owner_id == item2.owner_id -def test_delete_item(): +def test_delete_item(db: Session) -> None: title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description) - user = create_random_user() - item = crud.item.create_with_owner( - db_session=db_session, obj_in=item_in, owner_id=user.id - ) - item2 = crud.item.remove(db_session=db_session, id=item.id) - item3 = crud.item.get(db_session=db_session, id=item.id) + user = create_random_user(db) + item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) + item2 = crud.item.remove(db=db, id=item.id) + item3 = crud.item.get(db=db, id=item.id) assert item3 is None assert item2.id == item.id assert item2.title == title diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py index d1c14437a2..2caee5b870 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py @@ -1,94 +1,94 @@ from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session from app import crud -from app.core.security import get_password_hash, verify_password -from app.db.session import db_session +from app.core.security import verify_password from app.schemas.user import UserCreate, UserUpdate -from app.tests.utils.utils import random_lower_string, random_email +from app.tests.utils.utils import random_email, random_lower_string -def test_create_user(): +def test_create_user(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) assert user.email == email assert hasattr(user, "hashed_password") -def test_authenticate_user(): +def test_authenticate_user(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, obj_in=user_in) - authenticated_user = crud.user.authenticate( - db_session, email=email, password=password - ) + user = crud.user.create(db, obj_in=user_in) + authenticated_user = crud.user.authenticate(db, email=email, password=password) assert authenticated_user assert user.email == authenticated_user.email -def test_not_authenticate_user(): +def test_not_authenticate_user(db: Session) -> None: email = random_email() password = random_lower_string() - user = crud.user.authenticate(db_session, email=email, password=password) + user = crud.user.authenticate(db, email=email, password=password) assert user is None -def test_check_if_user_is_active(): +def test_check_if_user_is_active(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) is_active = crud.user.is_active(user) assert is_active is True -def test_check_if_user_is_active_inactive(): +def test_check_if_user_is_active_inactive(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password, disabled=True) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) is_active = crud.user.is_active(user) assert is_active -def test_check_if_user_is_superuser(): +def test_check_if_user_is_superuser(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password, is_superuser=True) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) is_superuser = crud.user.is_superuser(user) assert is_superuser is True -def test_check_if_user_is_superuser_normal_user(): +def test_check_if_user_is_superuser_normal_user(db: Session) -> None: username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) is_superuser = crud.user.is_superuser(user) assert is_superuser is False -def test_get_user(): +def test_get_user(db: Session) -> None: password = random_lower_string() username = random_email() user_in = UserCreate(email=username, password=password, is_superuser=True) - user = crud.user.create(db_session, obj_in=user_in) - user_2 = crud.user.get(db_session, id=user.id) + user = crud.user.create(db, obj_in=user_in) + user_2 = crud.user.get(db, id=user.id) + assert user_2 assert user.email == user_2.email assert jsonable_encoder(user) == jsonable_encoder(user_2) -def test_update_user(): +def test_update_user(db: Session) -> None: password = random_lower_string() email = random_email() user_in = UserCreate(email=email, password=password, is_superuser=True) - user = crud.user.create(db_session, obj_in=user_in) + user = crud.user.create(db, obj_in=user_in) new_password = random_lower_string() - user_in = UserUpdate(password=new_password, is_superuser=True) - crud.user.update(db_session, db_obj=user, obj_in=user_in) - user_2 = crud.user.get(db_session, id=user.id) + user_in_update = UserUpdate(password=new_password, is_superuser=True) + crud.user.update(db, db_obj=user, obj_in=user_in_update) + user_2 = crud.user.get(db, id=user.id) + assert user_2 assert user.email == user_2.email assert verify_password(new_password, user_2.hashed_password) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py index 95950f2e7d..e28f967078 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py @@ -1,17 +1,18 @@ -from app import crud -from app.db.session import db_session +from typing import Optional + +from sqlalchemy.orm import Session + +from app import crud, models from app.schemas.item import ItemCreate from app.tests.utils.user import create_random_user from app.tests.utils.utils import random_lower_string -def create_random_item(owner_id: int = None): +def create_random_item(db: Session, *, owner_id: Optional[int] = None) -> models.Item: if owner_id is None: - user = create_random_user() + user = create_random_user(db) owner_id = user.id title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description, id=id) - return crud.item.create_with_owner( - db_session=db_session, obj_in=item_in, owner_id=owner_id - ) + return crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=owner_id) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py index 42f80ce73b..e45d787cbb 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py @@ -1,43 +1,50 @@ +from typing import Dict + import requests +from sqlalchemy.orm import Session from app import crud from app.core.config import settings -from app.db.session import db_session +from app.models.user import User from app.schemas.user import UserCreate, UserUpdate -from app.tests.utils.utils import get_server_api, random_lower_string, random_email +from app.tests.utils.utils import get_server_api, random_email, random_lower_string -def user_authentication_headers(server_api, email, password): +def user_authentication_headers( + server_api: str, email: str, password: str +) -> Dict[str, str]: data = {"username": email, "password": password} - r = requests.post(f"{server_api}{settings.API_V1_STR}/login/access-token", data=data) + r = requests.post( + f"{server_api}{settings.API_V1_STR}/login/access-token", data=data + ) response = r.json() auth_token = response["access_token"] headers = {"Authorization": f"Bearer {auth_token}"} return headers -def create_random_user(): +def create_random_user(db: Session) -> User: email = random_email() password = random_lower_string() user_in = UserCreate(username=email, email=email, password=password) - user = crud.user.create(db_session=db_session, obj_in=user_in) + user = crud.user.create(db=db, obj_in=user_in) return user -def authentication_token_from_email(email): +def authentication_token_from_email(*, email: str, db: Session) -> Dict[str, str]: """ Return a valid token for the user with given email. If the user doesn't exist it is created first. """ password = random_lower_string() - user = crud.user.get_by_email(db_session, email=email) + user = crud.user.get_by_email(db, email=email) if not user: - user_in = UserCreate(username=email, email=email, password=password) - user = crud.user.create(db_session=db_session, obj_in=user_in) + user_in_create = UserCreate(username=email, email=email, password=password) + user = crud.user.create(db, obj_in=user_in_create) else: - user_in = UserUpdate(password=password) - user = crud.user.update(db_session, db_obj=user, obj_in=user_in) + user_in_update = UserUpdate(password=password) + user = crud.user.update(db, db_obj=user, obj_in=user_in_update) return user_authentication_headers(get_server_api(), email, password) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py index 7fb38c8478..563274e0d5 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py @@ -1,25 +1,26 @@ import random import string +from typing import Dict import requests from app.core.config import settings -def random_lower_string(): +def random_lower_string() -> str: return "".join(random.choices(string.ascii_lowercase, k=32)) -def random_email(): +def random_email() -> str: return f"{random_lower_string()}@{random_lower_string()}.com" -def get_server_api(): +def get_server_api() -> str: server_name = f"http://{settings.SERVER_NAME}" return server_name -def get_superuser_token_headers(): +def get_superuser_token_headers() -> Dict[str, str]: server_api = get_server_api() login_data = { "username": settings.FIRST_SUPERUSER, diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py b/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py index c1b0ccaf1a..70dbd4f8c8 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py @@ -2,7 +2,7 @@ from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed -from app.db.session import db_session +from app.db.session import SessionLocal from app.tests.api.api_v1.test_login import test_get_access_token logging.basicConfig(level=logging.INFO) @@ -18,10 +18,11 @@ before=before_log(logger, logging.INFO), after=after_log(logger, logging.WARN), ) -def init(): +def init() -> None: try: # Try to create session to check if DB is awake - db_session.execute("SELECT 1") + db = SessionLocal() + db.execute("SELECT 1") # Wait for API to be awake, run one simple tests to authenticate test_get_access_token() except Exception as e: @@ -29,7 +30,7 @@ def init(): raise e -def main(): +def main() -> None: logger.info("Initializing service") init() logger.info("Service finished initializing") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/utils.py b/{{cookiecutter.project_slug}}/backend/app/app/utils.py index a8582bc541..b1aba6bc00 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/utils.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/utils.py @@ -1,19 +1,21 @@ import logging from datetime import datetime, timedelta from pathlib import Path -from typing import Optional +from typing import Any, Dict, Optional import emails -import jwt from emails.template import JinjaTemplate -from jwt.exceptions import InvalidTokenError +from jose import jwt from app.core.config import settings -password_reset_jwt_subject = "preset" - -def send_email(email_to: str, subject_template="", html_template="", environment={}): +def send_email( + email_to: str, + subject_template: str = "", + html_template: str = "", + environment: Dict[str, Any] = {}, +) -> None: assert settings.EMAILS_ENABLED, "no provided configuration for email variables" message = emails.Message( subject=JinjaTemplate(subject_template), @@ -31,7 +33,7 @@ def send_email(email_to: str, subject_template="", html_template="", environment logging.info(f"send email result: {response}") -def send_test_email(email_to: str): +def send_test_email(email_to: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - Test email" with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f: @@ -44,17 +46,13 @@ def send_test_email(email_to: str): ) -def send_reset_password_email(email_to: str, email: str, token: str): +def send_reset_password_email(email_to: str, email: str, token: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - Password recovery for user {email}" with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f: template_str = f.read() - if hasattr(token, "decode"): - use_token = token.decode() - else: - use_token = token server_host = settings.SERVER_HOST - link = f"{server_host}/reset-password?token={use_token}" + link = f"{server_host}/reset-password?token={token}" send_email( email_to=email_to, subject_template=subject, @@ -69,7 +67,7 @@ def send_reset_password_email(email_to: str, email: str, token: str): ) -def send_new_account_email(email_to: str, username: str, password: str): +def send_new_account_email(email_to: str, username: str, password: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - New account for user {username}" with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f: @@ -89,23 +87,20 @@ def send_new_account_email(email_to: str, username: str, password: str): ) -def generate_password_reset_token(email): +def generate_password_reset_token(email: str) -> str: delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) now = datetime.utcnow() expires = now + delta exp = expires.timestamp() encoded_jwt = jwt.encode( - {"exp": exp, "nbf": now, "sub": password_reset_jwt_subject, "email": email}, - settings.SECRET_KEY, - algorithm="HS256", + {"exp": exp, "nbf": now, "sub": email}, settings.SECRET_KEY, algorithm="HS256", ) return encoded_jwt -def verify_password_reset_token(token) -> Optional[str]: +def verify_password_reset_token(token: str) -> Optional[str]: try: decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - assert decoded_token["sub"] == password_reset_jwt_subject return decoded_token["email"] - except InvalidTokenError: + except jwt.JWTError: return None diff --git a/{{cookiecutter.project_slug}}/backend/app/app/worker.py b/{{cookiecutter.project_slug}}/backend/app/app/worker.py index 499ed065ff..5fea53c961 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/worker.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/worker.py @@ -1,11 +1,11 @@ from raven import Client -from app.core.config import settings from app.core.celery_app import celery_app +from app.core.config import settings client_sentry = Client(settings.SENTRY_DSN) @celery_app.task(acks_late=True) -def test_celery(word: str): +def test_celery(word: str) -> str: return f"test task return {word}" diff --git a/{{cookiecutter.project_slug}}/backend/app/mypy.ini b/{{cookiecutter.project_slug}}/backend/app/mypy.ini new file mode 100644 index 0000000000..9813db029b --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +plugins = pydantic.mypy, sqlmypy +ignore_missing_imports = True +disallow_untyped_defs = True diff --git a/{{cookiecutter.project_slug}}/backend/app/pyproject.toml b/{{cookiecutter.project_slug}}/backend/app/pyproject.toml index 33c46fbf94..ea4e04e198 100644 --- a/{{cookiecutter.project_slug}}/backend/app/pyproject.toml +++ b/{{cookiecutter.project_slug}}/backend/app/pyproject.toml @@ -8,7 +8,6 @@ authors = ["Admin "] python = "^3.7" uvicorn = "^0.11.3" fastapi = "^0.54.1" -pyjwt = "^1.7.1" python-multipart = "^0.0.5" email-validator = "^1.0.5" requests = "^2.23.0" @@ -24,6 +23,7 @@ psycopg2-binary = "^2.8.5" alembic = "^1.4.2" sqlalchemy = "^1.3.16" pytest = "^5.4.1" +python-jose = {extras = ["cryptography"], version = "^3.1.0"} [tool.poetry.dev-dependencies] mypy = "^0.770" @@ -32,9 +32,15 @@ isort = "^4.3.21" autoflake = "^1.3.1" flake8 = "^3.7.9" pytest = "^5.4.1" -jupyter = "^1.0.0" -vulture = "^1.4" +sqlalchemy-stubs = "^0.3" +pytest-cov = "^2.8.1" +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +line_length = 88 [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" + diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh new file mode 100755 index 0000000000..68f70eea75 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh @@ -0,0 +1,6 @@ +#!/bin/sh -e +set -x + +# Sort imports one per line, so autoflake can remove unused imports +isort --recursive --force-single-line-imports --apply app +sh ./scripts/format.sh diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/format.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/format.sh new file mode 100755 index 0000000000..71d9779e41 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/format.sh @@ -0,0 +1,6 @@ +#!/bin/sh -e +set -x + +autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py +black app +isort --recursive --apply app diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh index 11184afd99..a00fcba632 100644 --- a/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh @@ -2,7 +2,8 @@ set -x -autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py -isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 --recursive --apply app -black app +mypy app +black app --check +isort --recursive --check-only app vulture app --min-confidence 70 +flake8 diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh new file mode 100755 index 0000000000..4a1fd3b75a --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +bash scripts/test.sh --cov-report=html "${@}" diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/test.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/test.sh new file mode 100755 index 0000000000..fba8e95576 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest --cov=app --cov-report=term-missing app/tests "${@}" diff --git a/{{cookiecutter.project_slug}}/backend/app/tests-start.sh b/{{cookiecutter.project_slug}}/backend/app/tests-start.sh index a3ee2fd863..099c2b3973 100644 --- a/{{cookiecutter.project_slug}}/backend/app/tests-start.sh +++ b/{{cookiecutter.project_slug}}/backend/app/tests-start.sh @@ -3,4 +3,4 @@ set -e python /app/app/tests_pre_start.py -pytest "$@" /app/app/tests/ +bash ./scripts/test.sh "$@" diff --git a/{{cookiecutter.project_slug}}/backend/backend.dockerfile b/{{cookiecutter.project_slug}}/backend/backend.dockerfile index 6108868ead..8c39c502af 100644 --- a/{{cookiecutter.project_slug}}/backend/backend.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/backend.dockerfile @@ -10,17 +10,16 @@ RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get- # Copy poetry.lock* in case it doesn't exist in the repo COPY ./app/pyproject.toml ./app/poetry.lock* /app/ -RUN poetry install --no-dev --no-root + +# Allow installing dev dependencies to run tests +ARG INSTALL_DEV=false +RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" # For development, Jupyter remote kernel, Hydrogen # Using inside the container: # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 -ARG env=prod -RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi" -EXPOSE 8888 +ARG INSTALL_JUPYTER=false +RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" COPY ./app /app - ENV PYTHONPATH=/app - -EXPOSE 80 diff --git a/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile b/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile index 6de9b7e908..4695a7b7ec 100644 --- a/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile @@ -10,14 +10,16 @@ RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get- # Copy poetry.lock* in case it doesn't exist in the repo COPY ./app/pyproject.toml ./app/poetry.lock* /app/ -RUN poetry install --no-dev --no-root + +# Allow installing dev dependencies to run tests +ARG INSTALL_DEV=false +RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" # For development, Jupyter remote kernel, Hydrogen # Using inside the container: # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 -ARG env=prod -RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi" -EXPOSE 8888 +ARG INSTALL_JUPYTER=false +RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" ENV C_FORCE_ROOT=1 diff --git a/{{cookiecutter.project_slug}}/docker-compose.override.yml b/{{cookiecutter.project_slug}}/docker-compose.override.yml index 931cb2a189..9683d4ba19 100644 --- a/{{cookiecutter.project_slug}}/docker-compose.override.yml +++ b/{{cookiecutter.project_slug}}/docker-compose.override.yml @@ -37,7 +37,8 @@ services: context: ./backend dockerfile: backend.dockerfile args: - env: dev + INSTALL_DEV: ${INSTALL_DEV-true} + INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} # command: bash -c "while true; do sleep 1; done" # Infinite loop to keep container live doing nothing command: /start-reload.sh labels: @@ -57,7 +58,8 @@ services: context: ./backend dockerfile: celeryworker.dockerfile args: - env: dev + INSTALL_DEV: ${INSTALL_DEV-true} + INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} frontend: build: diff --git a/{{cookiecutter.project_slug}}/docker-compose.yml b/{{cookiecutter.project_slug}}/docker-compose.yml index 8bdf3db11d..f15d0c629e 100644 --- a/{{cookiecutter.project_slug}}/docker-compose.yml +++ b/{{cookiecutter.project_slug}}/docker-compose.yml @@ -115,6 +115,8 @@ services: build: context: ./backend dockerfile: backend.dockerfile + args: + INSTALL_DEV: ${INSTALL_DEV-false} deploy: labels: - traefik.frontend.rule=PathPrefix:/api,/docs,/redoc @@ -137,6 +139,8 @@ services: build: context: ./backend dockerfile: celeryworker.dockerfile + args: + INSTALL_DEV: ${INSTALL_DEV-false} frontend: image: '${DOCKER_IMAGE_FRONTEND}:${TAG-latest}' diff --git a/{{cookiecutter.project_slug}}/scripts/build-push.sh b/{{cookiecutter.project_slug}}/scripts/build-push.sh index 425703de89..fce8e34f28 100644 --- a/{{cookiecutter.project_slug}}/scripts/build-push.sh +++ b/{{cookiecutter.project_slug}}/scripts/build-push.sh @@ -5,6 +5,6 @@ set -e TAG=${TAG} \ FRONTEND_ENV=${FRONTEND_ENV-production} \ -. ./scripts/build.sh +sh ./scripts/build.sh docker-compose -f docker-compose.yml push diff --git a/{{cookiecutter.project_slug}}/scripts/test.sh b/{{cookiecutter.project_slug}}/scripts/test.sh index 78b4db90e0..ef63b5e4d0 100644 --- a/{{cookiecutter.project_slug}}/scripts/test.sh +++ b/{{cookiecutter.project_slug}}/scripts/test.sh @@ -6,6 +6,7 @@ set -e DOMAIN=backend \ SMTP_HOST="" \ TRAEFIK_PUBLIC_NETWORK_IS_EXTERNAL=false \ +INSTALL_DEV=true \ docker-compose \ -f docker-compose.yml \ config > docker-stack.yml