diff --git a/README.md b/README.md index 36c2d72..fff5fa6 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,17 @@ After using this generator, your new project (the directory created) will contai ## Release Notes +* PR #15: + * Update CRUD utils to use types better. + * Simplify Pydantic model names, from `UserInCreate` to `UserCreate`, etc. + * Upgrade packages. + * Add new generic "Items" models, crud utils, endpoints, and tests. To facilitate re-using them to create new functionality. As they are simple and generic (not like Users), it's easier to copy-paste and adapt them to each use case. + * Update endpoints/*path operations* to simplify code and use new utilities, prefix and tags in `include_router`. + * Update testing utils. + * Update linting rules, relax vulture to reduce false positives. + * Add full text search for items. + * Update project README.md with tips about how to start with backend. + ### 0.2.1 * Fix frontend hijacking /docs in development. Using latest https://github.com/tiangolo/node-frontend with custom Nginx configs in frontend. PR #14. diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index 3123508..86f5855 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -53,7 +53,9 @@ If your Docker is not running in `localhost` (the URLs above wouldn't work) chec ### General workflow -Modify or add Pydantic models in `./backend/app/app/models` and API endpoints in `./backend/app/app/api/`. +Open your editor at `./backend/app/` (instead of the project root: `./`), so that you see an `./app/` directory with your code inside. That way, your editor will be able to find all the imports, etc. + +Modify or add Pydantic models in `./backend/app/app/models/`, API endpoints in `./backend/app/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/app/crud/`. The easiest might be to copy the ones for Items (models, endpoints, and CRUD utils) and update them to your needs. Add and modify tasks to the Celery worker in `./backend/app/app/worker.py`. diff --git a/{{cookiecutter.project_slug}}/backend/app/Pipfile b/{{cookiecutter.project_slug}}/backend/app/Pipfile index 4a6a12e..224ff57 100644 --- a/{{cookiecutter.project_slug}}/backend/app/Pipfile +++ b/{{cookiecutter.project_slug}}/backend/app/Pipfile @@ -20,7 +20,7 @@ pyjwt = "*" python-multipart = "*" email-validator = "*" requests = "*" -celery = "==4.2.1" +celery = "~=4.3" passlib = {extras = ["bcrypt"],version = "*"} tenacity = "*" pydantic = "*" diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py index 6803319..f4ac95f 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import role, token, user, utils +from app.api.api_v1.endpoints import items, login, roles, users, utils api_router = APIRouter() -api_router.include_router(role.router) -api_router.include_router(token.router) -api_router.include_router(user.router) -api_router.include_router(utils.router) +api_router.include_router(login.router, tags=["login"]) +api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) +api_router.include_router(items.router, prefix="/items", tags=["items"]) 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 new file mode 100644 index 0000000..194ee2b --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py @@ -0,0 +1,134 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException + +from app import crud +from app.api.utils.security import get_current_active_user +from app.db.database import get_default_bucket +from app.models.item import Item, ItemCreate, ItemUpdate +from app.models.user import UserInDB + +router = APIRouter() + + +@router.get("/", response_model=List[Item]) +def read_items( + skip: int = 0, + limit: int = 100, + current_user: UserInDB = Depends(get_current_active_user), +): + """ + Retrieve items. + + If superuser, all the items. + + If normal user, the items owned by this user. + """ + bucket = get_default_bucket() + if crud.user.is_superuser(current_user): + docs = crud.item.get_multi(bucket, skip=skip, limit=limit) + else: + docs = crud.item.get_multi_by_owner( + bucket=bucket, owner_username=current_user.username, skip=skip, limit=limit + ) + return docs + + +@router.get("/search/", response_model=List[Item]) +def search_items( + q: str, + skip: int = 0, + limit: int = 100, + current_user: UserInDB = Depends(get_current_active_user), +): + """ + Search items, use Bleve Query String syntax: + http://blevesearch.com/docs/Query-String-Query/ + + For typeahead suffix with `*`. For example, a query with: `title:foo*` will match + items containing `football`, `fool proof`, etc. + """ + bucket = get_default_bucket() + if crud.user.is_superuser(current_user): + docs = crud.item.search(bucket=bucket, query_string=q, skip=skip, limit=limit) + else: + docs = crud.item.search_with_owner( + bucket=bucket, + query_string=q, + username=current_user.username, + skip=skip, + limit=limit, + ) + return docs + + +@router.post("/", response_model=Item) +def create_item( + *, item_in: ItemCreate, current_user: UserInDB = Depends(get_current_active_user) +): + """ + Create new item. + """ + bucket = get_default_bucket() + id = crud.utils.generate_new_id() + doc = crud.item.upsert( + bucket=bucket, id=id, doc_in=item_in, owner_username=current_user.username + ) + return doc + + +@router.put("/{id}", response_model=Item) +def update_item( + *, + id: str, + item_in: ItemUpdate, + current_user: UserInDB = Depends(get_current_active_user), +): + """ + Update an item. + """ + bucket = get_default_bucket() + doc = crud.item.get(bucket=bucket, id=id) + if not doc: + raise HTTPException(status_code=404, detail="Item not found") + if not crud.user.is_superuser(current_user) and ( + doc.owner_username != current_user.username + ): + raise HTTPException(status_code=400, detail="Not enough permissions") + doc = crud.item.update( + bucket=bucket, id=id, doc_in=item_in, owner_username=doc.owner_username + ) + return doc + + +@router.get("/{id}", response_model=Item) +def read_item(id: str, current_user: UserInDB = Depends(get_current_active_user)): + """ + Get item by ID. + """ + bucket = get_default_bucket() + doc = crud.item.get(bucket=bucket, id=id) + if not doc: + raise HTTPException(status_code=404, detail="Item not found") + if not crud.user.is_superuser(current_user) and ( + doc.owner_username != current_user.username + ): + raise HTTPException(status_code=400, detail="Not enough permissions") + return doc + + +@router.delete("/{id}", response_model=Item) +def delete_item(id: str, current_user: UserInDB = Depends(get_current_active_user)): + """ + Delete an item by ID. + """ + bucket = get_default_bucket() + doc = crud.item.get(bucket=bucket, id=id) + if not doc: + raise HTTPException(status_code=404, detail="Item not found") + if not crud.user.is_superuser(current_user) and ( + doc.owner_username != current_user.username + ): + raise HTTPException(status_code=400, detail="Not enough permissions") + doc = crud.item.remove(bucket=bucket, id=id) + return doc diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/token.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py similarity index 84% rename from {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/token.py rename to {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py index 6ddbf00..7d6e060 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/token.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py @@ -10,7 +10,7 @@ from app.db.database import get_default_bucket from app.models.msg import Msg from app.models.token import Token -from app.models.user import User, UserInDB, UserInUpdate +from app.models.user import User, UserInDB, UserUpdate from app.utils import ( generate_password_reset_token, send_reset_password_email, @@ -20,10 +20,10 @@ router = APIRouter() -@router.post("/login/access-token", response_model=Token, tags=["login"]) +@router.post("/login/access-token", response_model=Token) def login(form_data: OAuth2PasswordRequestForm = Depends()): """ - OAuth2 compatible token login, get an access token for future requests + OAuth2 compatible token login, get an access token for future requests. """ bucket = get_default_bucket() user = crud.user.authenticate( @@ -42,18 +42,18 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()): } -@router.post("/login/test-token", tags=["login"], response_model=User) +@router.post("/login/test-token", response_model=User) def test_token(current_user: UserInDB = Depends(get_current_user)): """ - Test access token + Test access token. """ return current_user -@router.post("/password-recovery/{username}", tags=["login"], response_model=Msg) +@router.post("/password-recovery/{username}", response_model=Msg) def recover_password(username: str): """ - Password Recovery + Password Recovery. """ bucket = get_default_bucket() user = crud.user.get(bucket, username=username) @@ -70,10 +70,10 @@ def recover_password(username: str): return {"msg": "Password recovery email sent"} -@router.post("/reset-password/", tags=["login"], response_model=Msg) +@router.post("/reset-password/", response_model=Msg) def reset_password(token: str, new_password: str): """ - Reset password + Reset password. """ username = verify_password_reset_token(token) if not username: @@ -87,6 +87,6 @@ def reset_password(token: str, new_password: str): ) elif not crud.user.is_active(user): raise HTTPException(status_code=400, detail="Inactive user") - user_in = UserInUpdate(name=username, password=new_password) + user_in = UserUpdate(name=username, password=new_password) user = crud.user.update(bucket, username=username, user_in=user_in) return {"msg": "Password updated successfully"} diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/role.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/roles.py similarity index 86% rename from {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/role.py rename to {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/roles.py index e2dcfea..a5c0c62 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/role.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/roles.py @@ -8,10 +8,10 @@ router = APIRouter() -@router.get("/roles/", response_model=Roles) +@router.get("/", response_model=Roles) def read_roles(current_user: UserInDB = Depends(get_current_active_superuser)): """ - Retrieve roles + Retrieve roles. """ roles = crud.utils.ensure_enums_to_strs(RoleEnum) return {"roles": roles} diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/user.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py similarity index 82% rename from {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/user.py rename to {{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py index 815bee2..3526bdd 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py @@ -7,27 +7,27 @@ from app.api.utils.security import get_current_active_superuser, get_current_active_user from app.core import config from app.db.database import get_default_bucket -from app.models.user import User, UserInCreate, UserInDB, UserInUpdate +from app.models.user import User, UserCreate, UserInDB, UserUpdate from app.utils import send_new_account_email router = APIRouter() -@router.get("/users/", tags=["users"], response_model=List[User]) +@router.get("/", response_model=List[User]) def read_users( skip: int = 0, limit: int = 100, current_user: UserInDB = Depends(get_current_active_superuser), ): """ - Retrieve users + Retrieve users. """ bucket = get_default_bucket() users = crud.user.get_multi(bucket, skip=skip, limit=limit) return users -@router.get("/users/search/", tags=["users"], response_model=List[User]) +@router.get("/search/", response_model=List[User]) def search_users( q: str, skip: int = 0, @@ -46,14 +46,14 @@ def search_users( return users -@router.post("/users/", tags=["users"], response_model=User) +@router.post("/", response_model=User) def create_user( *, - user_in: UserInCreate, + user_in: UserCreate, current_user: UserInDB = Depends(get_current_active_superuser), ): """ - Create new user + Create new user. """ bucket = get_default_bucket() user = crud.user.get(bucket, username=user_in.username) @@ -70,7 +70,7 @@ def create_user( return user -@router.put("/users/me", tags=["users"], response_model=User) +@router.put("/me", response_model=User) def update_user_me( *, password: str = Body(None), @@ -79,9 +79,9 @@ def update_user_me( current_user: UserInDB = Depends(get_current_active_user), ): """ - Update own user + Update own user. """ - user_in = UserInUpdate(**current_user.dict()) + user_in = UserUpdate(**current_user.dict()) if password is not None: user_in.password = password if full_name is not None: @@ -93,15 +93,15 @@ def update_user_me( return user -@router.get("/users/me", tags=["users"], response_model=User) +@router.get("/me", response_model=User) def read_user_me(current_user: UserInDB = Depends(get_current_active_user)): """ - Get current user + Get current user. """ return current_user -@router.post("/users/open", tags=["users"], response_model=User) +@router.post("/open", response_model=User) def create_user_open( *, username: str = Body(...), @@ -110,7 +110,7 @@ def create_user_open( full_name: str = Body(None), ): """ - Create new user without the need to be logged in + Create new user without the need to be logged in. """ if not config.USERS_OPEN_REGISTRATION: raise HTTPException( @@ -124,7 +124,7 @@ def create_user_open( status_code=400, detail="The user with this username already exists in the system", ) - user_in = UserInCreate( + user_in = UserCreate( username=username, password=password, email=email, full_name=full_name ) user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) @@ -135,10 +135,10 @@ def create_user_open( return user -@router.get("/users/{username}", tags=["users"], response_model=User) +@router.get("/{username}", response_model=User) def read_user(username: str, current_user: UserInDB = Depends(get_current_active_user)): """ - Get a specific user by username (email) + Get a specific user by username (email). """ bucket = get_default_bucket() user = crud.user.get(bucket, username=username) @@ -151,15 +151,15 @@ def read_user(username: str, current_user: UserInDB = Depends(get_current_active return user -@router.put("/users/{username}", tags=["users"], response_model=User) +@router.put("/{username}", response_model=User) def update_user( *, username: str, - user_in: UserInUpdate, + user_in: UserUpdate, current_user: UserInDB = Depends(get_current_active_superuser), ): """ - Update a user + Update a user. """ bucket = get_default_bucket() user = crud.user.get(bucket, username=username) 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 18995ce..cc43abe 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 @@ -10,23 +10,23 @@ router = APIRouter() -@router.post("/test-celery/", tags=["utils"], response_model=Msg, status_code=201) +@router.post("/test-celery/", response_model=Msg, status_code=201) def test_celery( msg: Msg, current_user: UserInDB = Depends(get_current_active_superuser) ): """ - Test Celery worker + Test Celery worker. """ celery_app.send_task("app.worker.test_celery", args=[msg.msg]) return {"msg": "Word received"} -@router.post("/test-email/", tags=["utils"], response_model=Msg, status_code=201) +@router.post("/test-email/", response_model=Msg, status_code=201) def test_email( email_to: EmailStr, current_user: UserInDB = Depends(get_current_active_superuser) ): """ - Test emails + Test emails. """ send_test_email(email_to=email_to) return {"msg": "Test email sent"} diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index f3b5b99..1bc5b1a 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1 +1 @@ -from . import user, utils +from . import item, user, utils diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py new file mode 100644 index 0000000..fe142ce --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -0,0 +1,131 @@ +from couchbase.bucket import Bucket +from couchbase.n1ql import CONSISTENCY_REQUEST, N1QLQuery + +from app.core import config +from app.models.config import ITEM_DOC_TYPE +from app.models.item import ItemCreate, ItemInDB, ItemUpdate + +from . import utils + +# Same as file name /app/app/search_index_definitions/items.json +full_text_index_name = "items" + + +def get_doc_id(id: str): + return f"{ITEM_DOC_TYPE}::{id}" + + +def get(bucket: Bucket, *, id: str): + doc_id = get_doc_id(id) + return utils.get_doc(bucket=bucket, doc_id=doc_id, doc_model=ItemInDB) + + +def upsert( + bucket: Bucket, + *, + id: str, + doc_in: ItemCreate, + owner_username: str, + persist_to=0, + ttl=0, +): + doc_id = get_doc_id(id) + doc = ItemInDB(**doc_in.dict(), id=id, owner_username=owner_username) + return utils.upsert( + bucket=bucket, doc_id=doc_id, doc_in=doc, persist_to=persist_to, ttl=ttl + ) + + +def update( + bucket: Bucket, + *, + id: str, + doc_in: ItemUpdate, + owner_username=None, + persist_to=0, + ttl=0, +): + doc_id = get_doc_id(id=id) + doc = get(bucket, id=id) + doc = doc.copy(update=doc_in.dict(skip_defaults=True)) + if owner_username is not None: + doc.owner_username = owner_username + return utils.upsert( + bucket=bucket, doc_id=doc_id, doc_in=doc, persist_to=persist_to, ttl=ttl + ) + + +def remove(bucket: Bucket, *, id: str, persist_to=0): + doc_id = get_doc_id(id) + return utils.remove( + bucket=bucket, doc_id=doc_id, doc_model=ItemInDB, persist_to=persist_to + ) + + +def get_multi(bucket: Bucket, *, skip=0, limit=100): + return utils.get_docs( + bucket=bucket, + doc_type=ITEM_DOC_TYPE, + doc_model=ItemInDB, + skip=skip, + limit=limit, + ) + + +def get_multi_by_owner(bucket: Bucket, *, owner_username: str, skip=0, limit=100): + query_str = f"SELECT *, META().id as doc_id FROM {config.COUCHBASE_BUCKET_NAME} WHERE type = $type AND owner_username = $owner_username LIMIT $limit OFFSET $skip;" + q = N1QLQuery( + query_str, + bucket=config.COUCHBASE_BUCKET_NAME, + type=ITEM_DOC_TYPE, + owner_username=owner_username, + limit=limit, + skip=skip, + ) + q.consistency = CONSISTENCY_REQUEST + doc_results = bucket.n1ql_query(q) + return utils.doc_results_to_model(doc_results, doc_model=ItemInDB) + + +def search(bucket: Bucket, *, query_string: str, skip=0, limit=100): + docs = utils.search_get_docs( + bucket=bucket, + query_string=query_string, + index_name=full_text_index_name, + doc_model=ItemInDB, + skip=skip, + limit=limit, + ) + return docs + + +def search_with_owner( + bucket: Bucket, *query_string: str, username: str, skip=0, limit=100 +): + username_filter = f"owner_username:{username}" + if username_filter not in query_string: + query_string = f"{query_string} {username_filter}" + docs = utils.search_get_docs( + bucket=bucket, + query_string=query_string, + index_name=full_text_index_name, + doc_model=ItemInDB, + skip=skip, + limit=limit, + ) + return docs + + +def search_get_search_results_to_docs( + bucket: Bucket, *, query_string: str, skip=0, limit=100 +): + docs = utils.search_by_type_get_results_to_docs( + bucket=bucket, + query_string=query_string, + index_name=full_text_index_name, + doc_type=ITEM_DOC_TYPE, + doc_model=ItemInDB, + skip=skip, + limit=limit, + ) + return docs diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py index a26c6c4..d85104c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py @@ -7,15 +7,16 @@ from app.core.security import get_password_hash, verify_password from app.models.config import USERPROFILE_DOC_TYPE from app.models.role import RoleEnum -from app.models.user import UserInCreate, UserInDB, UserInUpdate, UserSyncIn +from app.models.user import UserCreate, UserInDB, UserSyncIn, UserUpdate from . import utils +# Same as file name /app/app/search_index_definitions/users.json full_text_index_name = "users" -def get_doc_id(username): - return f"userprofile::{username}" +def get_doc_id(username: str): + return f"{USERPROFILE_DOC_TYPE}::{username}" def get(bucket: Bucket, *, username: str): @@ -33,7 +34,7 @@ def get_by_email(bucket: Bucket, *, email: str): ) q.consistency = CONSISTENCY_REQUEST doc_results = bucket.n1ql_query(q) - users = utils.results_to_model(doc_results, doc_model=UserInDB) + users = utils.doc_results_to_model(doc_results, doc_model=UserInDB) if not users: return None return users[0] @@ -42,7 +43,6 @@ def get_by_email(bucket: Bucket, *, email: str): def insert_sync_gateway(user: UserSyncIn): name = user.name url = f"http://{config.COUCHBASE_SYNC_GATEWAY_HOST}:{config.COUCHBASE_SYNC_GATEWAY_PORT}/{config.COUCHBASE_SYNC_GATEWAY_DATABASE}/_user/{name}" - data = jsonable_encoder(user) response = requests.put(url, json=data) return response.status_code == 200 or response.status_code == 201 @@ -59,10 +59,9 @@ def update_sync_gateway(user: UserSyncIn): return response.status_code == 200 or response.status_code == 201 -def upsert_in_db(bucket: Bucket, *, user_in: UserInCreate, persist_to=0): +def upsert_in_db(bucket: Bucket, *, user_in: UserCreate, persist_to=0): user_doc_id = get_doc_id(user_in.username) passwordhash = get_password_hash(user_in.password) - user = UserInDB(**user_in.dict(), hashed_password=passwordhash) doc_data = jsonable_encoder(user) with bucket.durability( @@ -72,14 +71,10 @@ def upsert_in_db(bucket: Bucket, *, user_in: UserInCreate, persist_to=0): return user -def update_in_db(bucket: Bucket, *, username: str, user_in: UserInUpdate, persist_to=0): +def update_in_db(bucket: Bucket, *, username: str, user_in: UserUpdate, persist_to=0): user_doc_id = get_doc_id(username) stored_user = get(bucket, username=username) - for field in stored_user.fields: - if field in user_in.fields: - value_in = getattr(user_in, field) - if value_in is not None: - setattr(stored_user, field, value_in) + stored_user = stored_user.copy(update=user_in.dict(skip_defaults=True)) if user_in.password: passwordhash = get_password_hash(user_in.password) stored_user.hashed_password = passwordhash @@ -91,14 +86,14 @@ def update_in_db(bucket: Bucket, *, username: str, user_in: UserInUpdate, persis return stored_user -def upsert(bucket: Bucket, *, user_in: UserInCreate, persist_to=0): +def upsert(bucket: Bucket, *, user_in: UserCreate, persist_to=0): user = upsert_in_db(bucket, user_in=user_in, persist_to=persist_to) user_in_sync = UserSyncIn(**user_in.dict(), name=user_in.username) assert insert_sync_gateway(user_in_sync) return user -def update(bucket: Bucket, *, username: str, user_in: UserInUpdate, persist_to=0): +def update(bucket: Bucket, *, username: str, user_in: UserUpdate, persist_to=0): user = update_in_db( bucket, username=username, user_in=user_in, persist_to=persist_to ) @@ -141,20 +136,23 @@ def get_multi(bucket: Bucket, *, skip=0, limit=100): return users -def search_docs(bucket: Bucket, *, query_string: str, skip=0, limit=100): - users = utils.search_docs( +def search(bucket: Bucket, *, query_string: str, skip=0, limit=100): + users = utils.search_get_docs( bucket=bucket, query_string=query_string, index_name=full_text_index_name, doc_model=UserInDB, + doc_type=USERPROFILE_DOC_TYPE, skip=skip, limit=limit, ) return users -def search(bucket: Bucket, *, query_string: str, skip=0, limit=100): - users = utils.search_results_by_type( +def search_get_search_results_to_docs( + bucket: Bucket, *, query_string: str, skip=0, limit=100 +): + users = utils.search_by_type_get_results_to_docs( bucket=bucket, query_string=query_string, index_name=full_text_index_name, diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/utils.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/utils.py index 74d18a8..dd22f93 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/utils.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/utils.py @@ -1,14 +1,17 @@ import uuid from enum import Enum -from typing import List, Sequence, Type, Union +from typing import List, Optional, Sequence, Type, TypeVar, Union from couchbase.bucket import Bucket from couchbase.fulltext import MatchAllQuery, QueryStringQuery from couchbase.n1ql import CONSISTENCY_REQUEST, N1QLQuery +from fastapi.encoders import jsonable_encoder from pydantic import BaseModel from pydantic.fields import Field, Shape -from app.core.config import COUCHBASE_BUCKET_NAME +from app.core import config + +PydanticModel = TypeVar("PydanticModel", bound=BaseModel) def generate_new_id(): @@ -25,19 +28,23 @@ def ensure_enums_to_strs(items: Union[Sequence[Union[Enum, str]], Type[Enum]]): return str_items -def get_all_documents_by_type(bucket: Bucket, *, doc_type: str, skip=0, limit=100): - query_str = f"SELECT *, META().id as id FROM {COUCHBASE_BUCKET_NAME} WHERE type = $type LIMIT $limit OFFSET $skip;" +def get_doc_results_by_type(bucket: Bucket, *, doc_type: str, skip=0, limit=100): + query_str = f"SELECT *, META().id as doc_id FROM {config.COUCHBASE_BUCKET_NAME} WHERE type = $type LIMIT $limit OFFSET $skip;" q = N1QLQuery( - query_str, bucket=COUCHBASE_BUCKET_NAME, type=doc_type, limit=limit, skip=skip + query_str, + bucket=config.COUCHBASE_BUCKET_NAME, + type=doc_type, + limit=limit, + skip=skip, ) q.consistency = CONSISTENCY_REQUEST result = bucket.n1ql_query(q) return result -def get_documents_by_keys( - bucket: Bucket, *, keys: List[str], doc_model=Type[BaseModel] -): +def get_docs_by_keys( + bucket: Bucket, *, keys: List[str], doc_model=Type[PydanticModel] +) -> List[PydanticModel]: results = bucket.get_multi(keys, quiet=True) docs = [] for result in results.values(): @@ -46,18 +53,28 @@ def get_documents_by_keys( return docs -def results_to_model(results_from_couchbase: list, *, doc_model: Type[BaseModel]): +def doc_result_to_model( + couchbase_result, *, doc_model: Type[PydanticModel] +) -> PydanticModel: + data = couchbase_result[config.COUCHBASE_BUCKET_NAME] + doc = doc_model(**data) + return doc + + +def doc_results_to_model( + results_from_couchbase: list, *, doc_model: Type[PydanticModel] +) -> List[PydanticModel]: items = [] for doc in results_from_couchbase: - data = doc[COUCHBASE_BUCKET_NAME] + data = doc[config.COUCHBASE_BUCKET_NAME] doc = doc_model(**data) items.append(doc) return items def search_results_to_model( - results_from_couchbase: list, *, doc_model: Type[BaseModel] -): + results_from_couchbase: list, *, doc_model: Type[PydanticModel] +) -> List[PydanticModel]: items = [] for doc in results_from_couchbase: data = doc.get("fields") @@ -79,15 +96,17 @@ def search_results_to_model( def get_docs( - bucket: Bucket, *, doc_type: str, doc_model=Type[BaseModel], skip=0, limit=100 -): - doc_results = get_all_documents_by_type( + bucket: Bucket, *, doc_type: str, doc_model=Type[PydanticModel], skip=0, limit=100 +) -> List[PydanticModel]: + doc_results = get_doc_results_by_type( bucket, doc_type=doc_type, skip=skip, limit=limit ) - return results_to_model(doc_results, doc_model=doc_model) + return doc_results_to_model(doc_results, doc_model=doc_model) -def get_doc(bucket: Bucket, *, doc_id: str, doc_model: Type[BaseModel]): +def get_doc( + bucket: Bucket, *, doc_id: str, doc_model: Type[PydanticModel] +) -> Optional[PydanticModel]: result = bucket.get(doc_id, quiet=True) if not result.value: return None @@ -95,14 +114,65 @@ def get_doc(bucket: Bucket, *, doc_id: str, doc_model: Type[BaseModel]): return model -def search_docs_get_doc_ids( +def upsert( + bucket: Bucket, *, doc_id: str, doc_in: PydanticModel, persist_to=0, ttl=0 +) -> Optional[PydanticModel]: + doc_data = jsonable_encoder(doc_in) + with bucket.durability( + persist_to=persist_to, timeout=config.COUCHBASE_DURABILITY_TIMEOUT_SECS + ): + result = bucket.upsert(doc_id, doc_data, ttl=ttl) + if result.success: + return doc_in + return None + + +def update( + bucket: Bucket, + *, + doc_id: str, + doc: PydanticModel, + doc_updated: PydanticModel, + persist_to=0, + ttl=0, +): + doc_updated = doc.copy(update=doc_updated.dict(skip_defaults=True)) + data = jsonable_encoder(doc_updated) + with bucket.durability( + persist_to=persist_to, timeout=config.COUCHBASE_DURABILITY_TIMEOUT_SECS + ): + result = bucket.upsert(doc_id, data, ttl=ttl) + if result.success: + return doc_updated + + +def remove( + bucket: Bucket, *, doc_id: str, doc_model: Type[PydanticModel] = None, persist_to=0 +) -> Optional[Union[PydanticModel, bool]]: + result = bucket.get(doc_id, quiet=True) + if not result.value: + return None + if doc_model: + model = doc_model(**result.value) + with bucket.durability( + persist_to=persist_to, timeout=config.COUCHBASE_DURABILITY_TIMEOUT_SECS + ): + result = bucket.remove(doc_id) + if not result.success: + return None + if doc_model: + return model + return True + + +def search_get_doc_ids( bucket: Bucket, *, query_string: str, index_name: str, skip: int = 0, limit: int = 100, -): +) -> List[str]: query = QueryStringQuery(query_string) hits = bucket.search(index_name, query, skip=skip, limit=limit) doc_ids = [] @@ -111,7 +181,7 @@ def search_docs_get_doc_ids( return doc_ids -def search_get_results( +def search_get_search_results( bucket: Bucket, *, query_string: str, @@ -130,7 +200,7 @@ def search_get_results( return docs -def search_get_results_by_type( +def search_by_type_get_search_results( bucket: Bucket, *, query_string: str, @@ -152,16 +222,23 @@ def search_get_results_by_type( return docs -def search_docs( +def search_get_docs( bucket: Bucket, *, query_string: str, index_name: str, - doc_model: Type[BaseModel], + doc_model: Type[PydanticModel], + doc_type: str = None, skip=0, limit=100, -): - keys = search_docs_get_doc_ids( +) -> List[PydanticModel]: + if doc_type is not None: + type_filter = f"type:{doc_type}" + if not query_string: + query_string = type_filter + if query_string and type_filter not in query_string: + query_string += f" {type_filter}" + keys = search_get_doc_ids( bucket=bucket, query_string=query_string, index_name=index_name, @@ -170,20 +247,19 @@ def search_docs( ) if not keys: return [] - doc_results = get_documents_by_keys(bucket=bucket, keys=keys, doc_model=doc_model) - return doc_results + return get_docs_by_keys(bucket=bucket, keys=keys, doc_model=doc_model) -def search_results( +def search_get_search_results_to_docs( bucket: Bucket, *, query_string: str, index_name: str, - doc_model: Type[BaseModel], + doc_model: Type[PydanticModel], skip=0, limit=100, -): - doc_results = search_get_results( +) -> List[PydanticModel]: + doc_results = search_get_search_results( bucket=bucket, query_string=query_string, index_name=index_name, @@ -193,17 +269,17 @@ def search_results( return search_results_to_model(doc_results, doc_model=doc_model) -def search_results_by_type( +def search_by_type_get_results_to_docs( bucket: Bucket, *, query_string: str, index_name: str, doc_type: str, - doc_model: Type[BaseModel], + doc_model: Type[PydanticModel], skip=0, limit=100, -): - doc_results = search_get_results_by_type( +) -> List[PydanticModel]: + doc_results = search_by_type_get_search_results( bucket=bucket, query_string=query_string, index_name=index_name, 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 750921a..fa74bea 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py @@ -15,7 +15,7 @@ ) from app.db.full_text_search_utils import ensure_create_full_text_indexes from app.models.role import RoleEnum -from app.models.user import UserInCreate +from app.models.user import UserCreate def init_db(): @@ -75,7 +75,7 @@ def init_db(): ) logging.info("after ensure_create_couchbase_app_user sync") logging.info("before upsert_user first superuser") - user_in = UserInCreate( + user_in = UserCreate( username=config.FIRST_SUPERUSER, password=config.FIRST_SUPERUSER_PASSWORD, email=config.FIRST_SUPERUSER, diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/config.py b/{{cookiecutter.project_slug}}/backend/app/app/models/config.py index 7c55d2c..810612e 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/config.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/config.py @@ -1 +1,2 @@ USERPROFILE_DOC_TYPE = "userprofile" +ITEM_DOC_TYPE = "item" diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py new file mode 100644 index 0000000..63855bf --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + +from app.models.config import ITEM_DOC_TYPE + + +# Shared properties +class ItemBase(BaseModel): + title: str = None + description: str = None + + +# Properties to receive on item creation +class ItemCreate(ItemBase): + title: str + + +# Properties to receive on item update +class ItemUpdate(ItemBase): + pass + + +# Properties to return to client +class Item(ItemBase): + id: str + title: str + owner_username: str + + +# Properties properties stored in DB +class ItemInDB(ItemBase): + type: str = ITEM_DOC_TYPE + id: str + title: str + owner_username: str diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index 7a71cfc..328783b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -6,7 +6,7 @@ from app.models.role import RoleEnum -# Shared properties +# Shared properties in Couchbase and Sync Gateway class UserBase(BaseModel): email: Optional[str] = None admin_roles: Optional[List[Union[str, RoleEnum]]] = None @@ -14,13 +14,14 @@ class UserBase(BaseModel): disabled: Optional[bool] = None +# Shared properties in Couchbase class UserBaseInDB(UserBase): username: Optional[str] = None full_name: Optional[str] = None # Properties to receive via API on creation -class UserInCreate(UserBaseInDB): +class UserCreate(UserBaseInDB): username: str password: str admin_roles: List[Union[str, RoleEnum]] = [] @@ -29,7 +30,7 @@ class UserInCreate(UserBaseInDB): # Properties to receive via API on update -class UserInUpdate(UserBaseInDB): +class UserUpdate(UserBaseInDB): password: Optional[str] = None @@ -42,8 +43,10 @@ class User(UserBaseInDB): class UserInDB(UserBaseInDB): type: str = USERPROFILE_DOC_TYPE hashed_password: str + username: str +# Additional properties in Sync Gateway class UserSyncIn(UserBase): name: str password: Optional[str] = None diff --git a/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items.json b/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items.json new file mode 100644 index 0000000..870a716 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items.json @@ -0,0 +1,15 @@ +{ + "name": "items", + "type": "fulltext-alias", + "params": { + "targets": { + "items_01": {} + } + }, + "sourceType": "nil", + "sourceName": "", + "sourceUUID": "", + "sourceParams": null, + "planParams": {}, + "uuid": "" +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items_01.json b/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items_01.json new file mode 100644 index 0000000..34f60df --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items_01.json @@ -0,0 +1,116 @@ +{ + "name": "items_01", + "type": "fulltext-index", + "params": { + "mapping": { + "types": { + "item": { + "enabled": true, + "dynamic": false, + "properties": { + "owner_username": { + "enabled": true, + "dynamic": false, + "fields": [ + { + "name": "owner_username", + "type": "text", + "analyzer": "keyword", + "store": false, + "index": true, + "include_term_vectors": true, + "include_in_all": true + } + ] + }, + "description": { + "enabled": true, + "dynamic": false, + "fields": [ + { + "name": "description", + "type": "text", + "store": false, + "index": true, + "include_term_vectors": true, + "include_in_all": true + } + ] + }, + "title": { + "enabled": true, + "dynamic": false, + "fields": [ + { + "name": "title", + "type": "text", + "store": false, + "index": true, + "include_term_vectors": true, + "include_in_all": true + } + ] + }, + "type": { + "enabled": true, + "dynamic": false, + "fields": [ + { + "name": "type", + "type": "text", + "store": false, + "index": true, + "include_term_vectors": false, + "include_in_all": false + } + ] + }, + "id": { + "enabled": true, + "dynamic": false, + "fields": [ + { + "name": "id", + "type": "text", + "store": false, + "index": true, + "include_term_vectors": false, + "include_in_all": false + } + ] + } + } + } + }, + "default_mapping": { + "enabled": false, + "dynamic": true + }, + "default_type": "_default", + "default_analyzer": "standard", + "default_datetime_parser": "dateTimeOptional", + "default_field": "_all", + "store_dynamic": false, + "index_dynamic": true + }, + "store": { + "indexType": "scorch", + "kvStoreName": "" + }, + "doc_config": { + "mode": "type_field", + "type_field": "type", + "docid_prefix_delim": "", + "docid_regexp": "" + } + }, + "sourceType": "couchbase", + "sourceName": "app", + "sourceUUID": "", + "sourceParams": {}, + "planParams": { + "maxPartitionsPerPIndex": 171, + "numReplicas": 0 + }, + "uuid": "" +} \ No newline at end of file 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 eb48030..2270243 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 @@ -8,7 +8,7 @@ def test_celery_worker_test(superuser_token_headers): server_api = get_server_api() data = {"msg": "test"} r = requests.post( - f"{server_api}{config.API_V1_STR}/test-celery/", + f"{server_api}{config.API_V1_STR}/utils/test-celery/", json=data, headers=superuser_token_headers, ) 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 new file mode 100644 index 0000000..c148afa --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py @@ -0,0 +1,34 @@ +import requests + +from app.core import config +from app.tests.utils.item import create_random_item +from app.tests.utils.utils import get_server_api + + +def test_create_item(superuser_token_headers): + server_api = get_server_api() + data = {"title": "Foo", "description": "Fighters"} + response = requests.post( + f"{server_api}{config.API_V1_STR}/items/", + headers=superuser_token_headers, + json=data, + ) + content = response.json() + assert content["title"] == data["title"] + assert content["description"] == data["description"] + assert "id" in content + assert "owner_username" in content + + +def test_read_item(superuser_token_headers): + item = create_random_item() + server_api = get_server_api() + response = requests.get( + f"{server_api}{config.API_V1_STR}/items/{item.id}", + headers=superuser_token_headers, + ) + content = response.json() + assert content["title"] == item.title + assert content["description"] == item.description + assert content["id"] == item.id + assert content["owner_username"] == item.owner_username diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_token.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_token.py rename to {{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_user.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py similarity index 88% rename from {{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_user.py rename to {{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py index 8dae7d9..01e4ebc 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py @@ -3,7 +3,7 @@ from app import crud from app.core import config from app.db.database import get_default_bucket -from app.models.user import UserInCreate +from app.models.user import UserCreate from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import get_server_api, random_lower_string @@ -41,7 +41,7 @@ def test_get_existing_user(superuser_token_headers): server_api = get_server_api() username = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=username, email=username, password=password) + user_in = UserCreate(username=username, email=username, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) r = requests.get( @@ -59,7 +59,7 @@ def test_create_user_existing_username(superuser_token_headers): username = random_lower_string() # username = email password = random_lower_string() - user_in = UserInCreate(username=username, email=username, password=password) + user_in = UserCreate(username=username, email=username, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) data = {"username": username, "password": password} @@ -77,7 +77,7 @@ def test_create_user_by_normal_user(): server_api = get_server_api() username = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=username, email=username, password=password) + user_in = UserCreate(username=username, email=username, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) user_token_headers = user_authentication_headers(server_api, username, password) @@ -92,13 +92,13 @@ def test_retrieve_users(superuser_token_headers): server_api = get_server_api() username = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=username, email=username, password=password) + user_in = UserCreate(username=username, email=username, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) username2 = random_lower_string() password2 = random_lower_string() - user_in2 = UserInCreate(username=username2, email=username2, password=password2) + user_in2 = UserCreate(username=username2, email=username2, password=password2) user2 = crud.user.upsert(bucket, user_in=user_in, persist_to=1) r = requests.get( 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 new file mode 100644 index 0000000..c2d36fb --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py @@ -0,0 +1,85 @@ +from app import crud +from app.db.database import get_default_bucket +from app.models.config import ITEM_DOC_TYPE +from app.models.item import ItemCreate, ItemUpdate +from app.tests.utils.user import create_random_user +from app.tests.utils.utils import random_lower_string + + +def test_create_item(): + title = random_lower_string() + description = random_lower_string() + id = crud.utils.generate_new_id() + item_in = ItemCreate(title=title, description=description) + bucket = get_default_bucket() + user = create_random_user() + item = crud.item.upsert( + bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 + ) + assert item.id == id + assert item.type == ITEM_DOC_TYPE + assert item.title == title + assert item.description == description + assert item.owner_username == user.username + + +def test_get_item(): + title = random_lower_string() + description = random_lower_string() + id = crud.utils.generate_new_id() + item_in = ItemCreate(title=title, description=description) + bucket = get_default_bucket() + user = create_random_user() + item = crud.item.upsert( + bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 + ) + stored_item = crud.item.get(bucket=bucket, id=id) + assert item.id == stored_item.id + assert item.title == stored_item.title + assert item.description == stored_item.description + assert item.owner_username == stored_item.owner_username + + +def test_update_item(): + title = random_lower_string() + description = random_lower_string() + id = crud.utils.generate_new_id() + item_in = ItemCreate(title=title, description=description) + bucket = get_default_bucket() + user = create_random_user() + item = crud.item.upsert( + bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 + ) + description2 = random_lower_string() + item_update = ItemUpdate(description=description2) + item2 = crud.item.update( + bucket=bucket, + id=id, + doc_in=item_update, + owner_username=item.owner_username, + persist_to=1, + ) + assert item.id == item2.id + assert item.title == item2.title + assert item.description == description + assert item2.description == description2 + assert item.owner_username == item2.owner_username + + +def test_delete_item(): + title = random_lower_string() + description = random_lower_string() + id = crud.utils.generate_new_id() + item_in = ItemCreate(title=title, description=description) + bucket = get_default_bucket() + user = create_random_user() + item = crud.item.upsert( + bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 + ) + item2 = crud.item.remove(bucket=bucket, id=id, persist_to=1) + item3 = crud.item.get(bucket=bucket, id=id) + assert item3 is None + assert item2.id == id + assert item2.title == title + assert item2.description == description + assert item2.owner_username == user.username 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 454a80a..5a45a23 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 @@ -3,14 +3,14 @@ from app import crud from app.db.database import get_default_bucket from app.models.role import RoleEnum -from app.models.user import UserInCreate +from app.models.user import UserCreate from app.tests.utils.utils import random_lower_string def test_create_user(): email = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=email, email=email, password=password) + user_in = UserCreate(username=email, email=email, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) assert hasattr(user, "username") @@ -23,7 +23,7 @@ def test_create_user(): def test_authenticate_user(): email = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=email, email=email, password=password) + user_in = UserCreate(username=email, email=email, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) authenticated_user = crud.user.authenticate( @@ -44,7 +44,7 @@ def test_not_authenticate_user(): def test_check_if_user_is_active(): email = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=email, email=email, password=password) + user_in = UserCreate(username=email, email=email, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) is_active = crud.user.is_active(user) @@ -54,9 +54,7 @@ def test_check_if_user_is_active(): def test_check_if_user_is_active_inactive(): email = random_lower_string() password = random_lower_string() - user_in = UserInCreate( - username=email, email=email, password=password, disabled=True - ) + user_in = UserCreate(username=email, email=email, password=password, disabled=True) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) is_active = crud.user.is_active(user) @@ -66,7 +64,7 @@ def test_check_if_user_is_active_inactive(): def test_check_if_user_is_superuser(): email = random_lower_string() password = random_lower_string() - user_in = UserInCreate( + user_in = UserCreate( username=email, email=email, password=password, admin_roles=[RoleEnum.superuser] ) bucket = get_default_bucket() @@ -78,7 +76,7 @@ def test_check_if_user_is_superuser(): def test_check_if_user_is_superuser_normal_user(): username = random_lower_string() password = random_lower_string() - user_in = UserInCreate(username=username, email=username, password=password) + user_in = UserCreate(username=username, email=username, password=password) bucket = get_default_bucket() user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) is_superuser = crud.user.is_superuser(user) @@ -88,14 +86,14 @@ def test_check_if_user_is_superuser_normal_user(): def test_get_user(): password = random_lower_string() username = random_lower_string() - user_in = UserInCreate( + user_in = UserCreate( username=username, email=username, password=password, admin_roles=[RoleEnum.superuser], ) bucket = get_default_bucket() - user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) + user = crud.user.upsert(bucket=bucket, user_in=user_in, persist_to=1) user_2 = crud.user.get(bucket, username=username) assert user.username == user_2.username assert jsonable_encoder(user) == jsonable_encoder(user_2) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py new file mode 100644 index 0000000..304c15c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py @@ -0,0 +1,23 @@ +from app import crud +from app.db.database import get_default_bucket +from app.models.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_username: str = None): + if owner_username is None: + user = create_random_user() + owner_username = user.username + title = random_lower_string() + description = random_lower_string() + id = crud.utils.generate_new_id() + item_in = ItemCreate(title=title, description=description, id=id) + bucket = get_default_bucket() + return crud.item.upsert( + bucket=bucket, + id=id, + doc_in=item_in, + owner_username=owner_username, + persist_to=1, + ) 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 9f8009f..1e9f696 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py @@ -1,6 +1,10 @@ import requests +from app import crud from app.core import config +from app.db.database import get_default_bucket +from app.models.user import UserCreate +from app.tests.utils.utils import random_lower_string def user_authentication_headers(server_api, email, password): @@ -11,3 +15,12 @@ def user_authentication_headers(server_api, email, password): auth_token = response["access_token"] headers = {"Authorization": f"Bearer {auth_token}"} return headers + + +def create_random_user(): + email = random_lower_string() + password = random_lower_string() + user_in = UserCreate(username=email, email=email, password=password) + bucket = get_default_bucket() + user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) + return user 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 e623af8..eaae325 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py @@ -3,7 +3,7 @@ from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed from app.db.database import get_default_bucket -from app.tests.api.api_v1.test_token import test_get_access_token +from app.tests.api.api_v1.test_login import test_get_access_token logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) diff --git a/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh b/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh index 08bb841..11184af 100644 --- a/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh +++ b/{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh @@ -5,4 +5,4 @@ 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 -vulture app +vulture app --min-confidence 70 diff --git a/{{cookiecutter.project_slug}}/backend/backend.dockerfile b/{{cookiecutter.project_slug}}/backend/backend.dockerfile index f8ecd34..593e1d7 100644 --- a/{{cookiecutter.project_slug}}/backend/backend.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/backend.dockerfile @@ -5,7 +5,7 @@ RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - RUN echo "deb http://packages.couchbase.com/ubuntu stretch stretch/main" > /etc/apt/sources.list.d/couchbase.list RUN apt-get update && apt-get install -y libcouchbase-dev libcouchbase2-bin build-essential -RUN pip install celery==4.2.1 passlib[bcrypt] tenacity requests couchbase emails "fastapi>=0.7.1" uvicorn gunicorn pyjwt python-multipart email_validator jinja2 +RUN pip install celery~=4.3 passlib[bcrypt] tenacity requests couchbase emails "fastapi>=0.16.0" uvicorn gunicorn pyjwt python-multipart email_validator jinja2 # For development, Jupyter remote kernel, Hydrogen # Using inside the container: diff --git a/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile b/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile index 7c4076e..92d2084 100644 --- a/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile @@ -5,7 +5,7 @@ RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - RUN echo "deb http://packages.couchbase.com/ubuntu stretch stretch/main" > /etc/apt/sources.list.d/couchbase.list RUN apt-get update && apt-get install -y libcouchbase-dev build-essential -RUN pip install raven celery==4.2.1 passlib[bcrypt] tenacity requests "fastapi>=0.7.1" couchbase emails pyjwt email_validator jinja2 +RUN pip install raven celery~=4.3 passlib[bcrypt] tenacity requests "fastapi>=0.16.0" couchbase emails pyjwt email_validator jinja2 # For development, Jupyter remote kernel, Hydrogen # Using inside the container: diff --git a/{{cookiecutter.project_slug}}/backend/tests.dockerfile b/{{cookiecutter.project_slug}}/backend/tests.dockerfile index 15aeeab..684d4ad 100644 --- a/{{cookiecutter.project_slug}}/backend/tests.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/tests.dockerfile @@ -4,7 +4,7 @@ RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - RUN echo "deb http://packages.couchbase.com/ubuntu stretch stretch/main" > /etc/apt/sources.list.d/couchbase.list RUN apt-get update && apt-get install -y libcouchbase-dev build-essential -RUN pip install requests pytest tenacity passlib[bcrypt] couchbase "fastapi>=0.7.1" +RUN pip install requests pytest tenacity passlib[bcrypt] couchbase "fastapi>=0.16.0" # For development, Jupyter remote kernel, Hydrogen # Using inside the container: