From 7abb977dfe6edc6c18b1dbb05b3f53c6c57c78f5 Mon Sep 17 00:00:00 2001 From: ebreton Date: Fri, 12 Apr 2019 20:51:16 +0200 Subject: [PATCH 01/26] removed postgres_password from alembic.ini, read it from env var instead --- .../backend/app/alembic.ini | 3 --- .../backend/app/alembic/env.py | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic.ini b/{{cookiecutter.project_slug}}/backend/app/alembic.ini index 61bc2fdcbf..921aaf17b8 100755 --- a/{{cookiecutter.project_slug}}/backend/app/alembic.ini +++ b/{{cookiecutter.project_slug}}/backend/app/alembic.ini @@ -35,9 +35,6 @@ script_location = alembic # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = postgresql://postgres:{{cookiecutter.postgres_password}}@db/app - - # Logging configuration [loggers] keys = root,sqlalchemy,alembic diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/env.py b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py index ae47ee586c..b7770629dd 100755 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/env.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py @@ -1,4 +1,7 @@ from __future__ import with_statement + +import os + from alembic import context from sqlalchemy import engine_from_config, pool from logging.config import fileConfig @@ -27,6 +30,15 @@ # ... etc. +def get_url(): + return "postgresql://%s:%s@%s/%s" % ( + os.getenv("POSTGRES_USER", "postgres"), + os.getenv("POSTGRES_PASSWORD", ""), + os.getenv("POSTGRES_SERVER", "db"), + os.getenv("POSTGRES_DB", "app"), + ) + + def run_migrations_offline(): """Run migrations in 'offline' mode. @@ -39,7 +51,7 @@ def run_migrations_offline(): script output. """ - url = config.get_main_option("sqlalchemy.url") + url = get_url() context.configure( url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True ) @@ -55,8 +67,10 @@ def run_migrations_online(): and associate a connection with the context. """ + configuration = config.get_section(config.config_ini_section) + configuration['sqlalchemy.url'] = get_url() connectable = engine_from_config( - config.get_section(config.config_ini_section), + configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, ) From 14fe548bcab8834274896628a86107e8cdcfde10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 20 Apr 2019 19:48:51 +0400 Subject: [PATCH 02/26] :recycle: use f-strings for PostgreSQL URL --- .../backend/app/alembic/env.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/env.py b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py index b7770629dd..df37198286 100755 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/env.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py @@ -31,12 +31,11 @@ def get_url(): - return "postgresql://%s:%s@%s/%s" % ( - os.getenv("POSTGRES_USER", "postgres"), - os.getenv("POSTGRES_PASSWORD", ""), - os.getenv("POSTGRES_SERVER", "db"), - os.getenv("POSTGRES_DB", "app"), - ) + user = os.getenv("POSTGRES_USER", "postgres") + password = os.getenv("POSTGRES_PASSWORD", "") + server = os.getenv("POSTGRES_SERVER", "db") + db = os.getenv("POSTGRES_DB", "app") + return f"postgresql://{user}:{password}@{server}/{db}" def run_migrations_offline(): From d18d065232afa7c3de214b34a277cfc6b78f9d11 Mon Sep 17 00:00:00 2001 From: ebreton Date: Fri, 3 May 2019 23:39:57 +0200 Subject: [PATCH 03/26] Add CrudBase along with SubItem for the showcase --- .../versions/d4867f3a4c0a_first_revision.py | 14 ++ .../backend/app/app/crud/__init__.py | 13 +- .../backend/app/app/crud/base.py | 161 ++++++++++++++++++ .../backend/app/app/crud/item.py | 60 +++---- .../backend/app/app/crud/sub_item.py | 21 +++ .../backend/app/app/db_models/item.py | 1 + .../backend/app/app/db_models/sub_item.py | 11 ++ .../backend/app/app/models/sub_item.py | 35 ++++ .../backend/app/app/tests/crud/test_item.py | 2 +- .../app/app/tests/crud/test_sub_item.py | 43 +++++ 10 files changed, 319 insertions(+), 42 deletions(-) create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/base.py create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py create mode 100755 {{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py index 68b3ee4f1c..b1d9796570 100644 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py @@ -30,6 +30,7 @@ def upgrade(): op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) + op.create_table('item', sa.Column('id', sa.Integer(), nullable=False), sa.Column('title', sa.String(), nullable=True), @@ -41,11 +42,24 @@ def upgrade(): op.create_index(op.f('ix_item_description'), 'item', ['description'], unique=False) op.create_index(op.f('ix_item_id'), 'item', ['id'], unique=False) op.create_index(op.f('ix_item_title'), 'item', ['title'], unique=False) + + op.create_table('subitem', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_subitem_id'), 'subitem', ['id'], unique=False) + op.create_index(op.f('ix_subitem_name'), 'subitem', ['name'], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_subitem_name'), table_name='subitem') + op.drop_index(op.f('ix_subitem_id'), table_name='subitem') + op.drop_table('subitem') op.drop_index(op.f('ix_item_title'), table_name='item') op.drop_index(op.f('ix_item_id'), table_name='item') op.drop_index(op.f('ix_item_description'), table_name='item') diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index 9330490546..f3b8a91892 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1 +1,12 @@ -from . import item, user +from . import user + +from .item import item +from .sub_item import sub_item + + +# For a new basic set of CRUD operations, on a new object, let's say 'Group', +# you could also simply add the following lines: + +# from app.crud.base import CrudBase +# from app.db_models.group import Group +# group = CrudBase(Group) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py new file mode 100644 index 0000000000..b9f079911d --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -0,0 +1,161 @@ +from typing import List, Optional + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app.db.base_class import Base +from pydantic import BaseModel + + +class CrudBase: + + def __init__(self, db_model: Base): + """ + CrudBase instances are used to provide the basic CRUD methods for a given object type (get, get_multi, update, create and delete). + + In order to use it, follow this steps when you define a new DB model: + - create a class that inherites from CrudBase + - override basic methods with proper types (to get better completion in your IDE) + - create an instance of your newly created class, providing the DB model as an argument + + E.g.: + + # model definition in app/models/item.py + class ItemCreate(...) + ... + + class ItemUpdate(...) + ... + + # model definition in app/db_models/item.py + class Item(Base): + id: int + ... + + # crud definition in app/crud/item.py + from app.db_models.item import Item + from app.models.item import ItemUpdate, ItemCreate + from app.crud.base import CrudBase + + + class CrudItem(CrudBase): + + def get(self, db_session: Session, obj_id: int) -> Optional[Item]: + return super(CrudItem, self).get(db_session, obj_id=obj_id) + + def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: + return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) + + def create(self, db_session: Session, *, obj_in: ItemCreate) -> Item: + return super(CrudItem, self).create(db_session, obj_in=obj_in) + + def update(self, db_session: Session, *, obj: Base, obj_in: ItemUpdate) -> Item: + return super(CrudItem, self).update(db_session, obj=obj, obj_in=obj_in) + + + crud_item = CrudItem(Item) + + Arguments: + db_model {Base} -- Class of the DB model which CRUD methods will be provided for + """ # noqa + self.db_model = db_model + + def get(self, db_session: Session, obj_id: int) -> Optional[Base]: + """ + get returns the object from the Database that matches the given obj_id + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + obj_id {int} -- ID of the object in the Database. It must be defined by a PrimaryKey on the 'id' column. + + Returns: + Optional[Base] -- Returns an instance of self.db_model class if an object is found in the Database for the given obj_id. Returns None if there is no match found. + """ # noqa + return db_session.query(self.db_model).get(obj_id) + + def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Base]]: + """ + get_multi queries all Database rows, without any filters, but with offset and limit options (for pagination purpose) + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + + Keyword Arguments: + skip {int} -- Number of rows to skip from the results (default: {0}) + limit {int} -- Maximum number of rows to return (default: {100}) + + Returns: + List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. + """ # noqa + return db_session.query(self.db_model).offset(skip).limit(limit).all() + + def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: + """ + create adds a new row in the Database in the table defined by self.db_model. The column values are populated from the 'obj_in' pydantic object + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + obj_in {BaseModel} -- A pydantic object that contains all mandatory values needed to create the Database row. + + Returns: + Base -- The object inserted in the Database + """ # noqa + obj_in_data = jsonable_encoder(obj_in) + obj = self.db_model(**obj_in_data) + db_session.add(obj) + db_session.commit() + db_session.refresh(obj) + return obj + + def update(self, db_session: Session, *, obj: Base, obj_in: BaseModel) -> Base: + """ + update modifies an existing row (fetched from given obj) in the Database with values from given obj_in + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + obj {Base} -- A DB instance of the object to update + obj_in {BaseModel} -- A pydantic object that contains all values to update. + + Returns: + Base -- The updated DB object, with all its attributes + """ # noqa + obj_data = jsonable_encoder(obj) + update_data = obj_in.dict(skip_defaults=True) + for field in obj_data: + if field in update_data: + setattr(obj, field, update_data[field]) + db_session.add(obj) + db_session.commit() + db_session.refresh(obj) + return obj + + def delete(self, db_session: Session, obj_id: int) -> int: + """ + delete removes the row from the database with the obj_id ID + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + obj_id {int} -- ID of the row to remove from the Database. It must be defined by a PrimaryKey on the 'id' column. + + Returns: + int -- number of rows deleted, i.e. 1 if the object has been found and deleted, 0 otherwise + """ # noqa + queried = db_session.query(self.db_model).filter(self.db_model.id == obj_id) + counted = queried.count() + if counted > 0: + queried.delete() + db_session.commit() + return counted + + def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]: + """ + remove does the same job as delete, with a different return valie + + Returns: + deleted object, if the deletion was successfull + None if the object was already deleted from the Database + """ # noqa + obj = db_session.query(self.db_model).get(obj_id) + db_session.delete(obj) + db_session.commit() + return obj diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index 9cec459f54..30943cb568 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -5,51 +5,31 @@ from app.db_models.item import Item from app.models.item import ItemCreate, ItemUpdate +from app.crud.base import CrudBase -def get(db_session: Session, *, id: int) -> Optional[Item]: - return db_session.query(Item).filter(Item.id == id).first() +class CrudItem(CrudBase): + """ + This is provided as a showcase of which methods to override, with the benefit to adjusting + both the types of the arguments and of the returned objects to the proper 'Item*' classes + """ + def get(self, db_session: Session, id: int) -> Optional[Item]: + return super(CrudItem, self).get(db_session, obj_id=id) -def get_multi(db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: - return db_session.query(Item).offset(skip).limit(limit).all() + def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: + return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) + def create(self, db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item: + item_in_data = jsonable_encoder(item_in) + item = Item(**item_in_data, owner_id=owner_id) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + return item -def get_multi_by_owner( - db_session: Session, *, owner_id: int, skip=0, limit=100 -) -> List[Optional[Item]]: - return ( - db_session.query(Item) - .filter(Item.owner_id == owner_id) - .offset(skip) - .limit(limit) - .all() - ) + def update(self, db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item: + return super(CrudItem, self).update(db_session, obj=item, obj_in=item_in) -def create(db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item: - item_in_data = jsonable_encoder(item_in) - item = Item(**item_in_data, owner_id=owner_id) - db_session.add(item) - db_session.commit() - db_session.refresh(item) - return item - - -def update(db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item: - item_data = jsonable_encoder(item) - update_data = item_in.dict(skip_defaults=True) - for field in item_data: - if field in update_data: - setattr(item, field, update_data[field]) - db_session.add(item) - db_session.commit() - db_session.refresh(item) - return item - - -def remove(db_session: Session, *, id: int): - item = db_session.query(Item).filter(Item.id == id).first() - db_session.delete(item) - db_session.commit() - return item +item = CrudItem(Item) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py new file mode 100644 index 0000000000..d7d03b046a --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py @@ -0,0 +1,21 @@ +from typing import Optional +from sqlalchemy.orm import Session, subqueryload + +from app.db_models.sub_item import SubItem +from app.crud.base import CrudBase + + +class CrudSubItem(CrudBase): + """ + This example shows how to change the behaviour of a default GET operation (by returning the foreign objects with all its attribute, instead of solely its id) + """ + + def get(self, db_session: Session, obj_id: int) -> Optional[SubItem]: + return ( + db_session.query(SubItem) + .options(subqueryload(SubItem.item)) + .get(obj_id) + ) + + +sub_item = CrudSubItem(SubItem) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py index 685687a098..88a973cdbe 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py @@ -10,3 +10,4 @@ class Item(Base): description = Column(String, index=True) owner_id = Column(Integer, ForeignKey("user.id")) owner = relationship("User", back_populates="items") + sub_items = relationship("SubItem", back_populates="item") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py new file mode 100755 index 0000000000..7cb62c0026 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class SubItem(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + item_id = Column(Integer, ForeignKey("item.id")) + item = relationship("Item", back_populates="sub_items") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py new file mode 100644 index 0000000000..ca497c33b0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from .item import Item + + +# Shared properties +class SubItemBase(BaseModel): + name: str = None + item_id: int + + +# Properties to receive on item creation +class SubItemCreate(SubItemBase): + name: str + + +# Properties to receive on item update +class SubItemUpdate(SubItemBase): + item_id: int = None + + +# Properties shared by models stored in DB +class SubItemInDBBase(SubItemBase): + id: int + name: str + + +# Properties to return to client +class SubItem(SubItemInDBBase): + item : Item + + +# Properties properties stored in DB +class SubItemInDB(SubItemInDBBase): + pass 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 33d8b7bbee..2bfda0b0a7 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 @@ -52,7 +52,7 @@ def test_delete_item(): item_in = ItemCreate(title=title, description=description) user = create_random_user() item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) - item2 = crud.item.remove(db_session=db_session, id=item.id) + item2 = crud.item.remove(db_session=db_session, obj_id=item.id) item3 = crud.item.get(db_session=db_session, id=item.id) assert item3 is None assert item2.id == item.id diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py new file mode 100644 index 0000000000..16c04b4ecb --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py @@ -0,0 +1,43 @@ +from app import crud +from app.models.sub_item import SubItemCreate, SubItemUpdate +from app.tests.utils.utils import random_lower_string +from app.tests.utils.item import create_random_item +from app.db.session import db_session + + +ITEM = create_random_item() +SUB_ITEM = None + + +def test_create_sub_item(): + global SUB_ITEM + name = random_lower_string() + sub_item_in = SubItemCreate(name=name, item_id=ITEM.id) + SUB_ITEM = crud.sub_item.create(db_session=db_session, obj_in=sub_item_in) + assert SUB_ITEM.name == name + assert SUB_ITEM.item_id == ITEM.id + + +def test_get_sub_item(): + stored_sub_item = crud.sub_item.get(db_session=db_session, obj_id=SUB_ITEM.id) + assert stored_sub_item.id == SUB_ITEM.id + assert stored_sub_item.name == SUB_ITEM.name + assert stored_sub_item.item_id == SUB_ITEM.item_id + + assert stored_sub_item.item is not None + assert stored_sub_item.item.title == ITEM.title + + +def test_update_sub_item(): + new_name = random_lower_string() + sub_item_update = SubItemUpdate(name=new_name) + updated_sub_item = crud.sub_item.update( + db_session=db_session, obj=SUB_ITEM, obj_in=sub_item_update + ) + assert updated_sub_item.id == SUB_ITEM.id + assert updated_sub_item.name == new_name + + +def test_delete_sub_item(): + assert crud.sub_item.delete(db_session=db_session, obj_id=SUB_ITEM.id) == 1 + assert crud.sub_item.get(db_session=db_session, obj_id=SUB_ITEM.id) is None From 8033e6aec2dd548b6fd12d3b0f2ecb29f5042857 Mon Sep 17 00:00:00 2001 From: ebreton Date: Thu, 5 Sep 2019 20:38:37 +0200 Subject: [PATCH 04/26] Add subitem --- .../backend/app/app/db/base.py | 1 + .../backend/app/app/db_models/item.py | 1 + .../backend/app/app/db_models/subitem.py | 12 ++++++++++++ 3 files changed, 14 insertions(+) create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py index 1665277300..f7e78061eb 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py @@ -3,3 +3,4 @@ from app.db.base_class import Base # noqa from app.db_models.user import User # noqa from app.db_models.item import Item # noqa +from app.db_models.subitem import SubItem # noqa diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py index 685687a098..3855355108 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py @@ -10,3 +10,4 @@ class Item(Base): description = Column(String, index=True) owner_id = Column(Integer, ForeignKey("user.id")) owner = relationship("User", back_populates="items") + subitems = relationship("SubItem", back_populates="item") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py new file mode 100644 index 0000000000..4fc8f95195 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class SubItem(Base): + id = Column(Integer, primary_key=True, index=True) + title = Column(String, index=True) + description = Column(String, index=True) + item_id = Column(Integer, ForeignKey("item.id")) + item = relationship("Item", back_populates="subitems") From c10da2fa17604b819e5a2c2bd07a324d0b3df454 Mon Sep 17 00:00:00 2001 From: ebreton Date: Mon, 9 Sep 2019 22:20:13 +0200 Subject: [PATCH 05/26] Add orm_mode --- {{cookiecutter.project_slug}}/backend/app/app/models/item.py | 3 +++ .../backend/app/app/models/sub_item.py | 3 +++ {{cookiecutter.project_slug}}/backend/app/app/models/user.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py index cc7511e920..635c4f300c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py @@ -6,6 +6,9 @@ class ItemBase(BaseModel): title: str = None description: str = None + class Config: + orm_mode = True + # Properties to receive on item creation class ItemCreate(ItemBase): diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py index ca497c33b0..c84bc602ee 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py @@ -8,6 +8,9 @@ class SubItemBase(BaseModel): name: str = None item_id: int + class Config: + orm_mode = True + # Properties to receive on item creation class SubItemCreate(SubItemBase): diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index 51f1b02579..fa5686da5b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -10,6 +10,9 @@ class UserBase(BaseModel): is_superuser: Optional[bool] = False full_name: Optional[str] = None + class Config: + orm_mode = True + class UserBaseInDB(UserBase): id: int = None From 5efdeccda51d6a2f846a9423921026b113b03494 Mon Sep 17 00:00:00 2001 From: ebreton Date: Mon, 9 Sep 2019 22:20:25 +0200 Subject: [PATCH 06/26] Follow comments on PR --- .../backend/app/app/crud/base.py | 34 ++++++++++++++++++- .../backend/app/app/crud/item.py | 3 ++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index b9f079911d..49458d2186 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -73,6 +73,21 @@ def get(self, db_session: Session, obj_id: int) -> Optional[Base]: """ # noqa return db_session.query(self.db_model).get(obj_id) + def get_first_by(self, db_session: Session, **kwargs: Any) -> Optional[Base]: + """ + get_by provides extended filtering capabilities: it returns the first object that matches all given **kwargs + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + + Keyword Arguments: + kwargs {dict} -- filters formated as {attribute_name: attribute_value} + + Returns: + Optional[Base] -- Returns an instance of self.db_model class if an object is found in the Database. Returns None if there is no match found. + """ # noqa + return db_session.query(self.db_model).filter_by(**kwargs).first() + def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Base]]: """ get_multi queries all Database rows, without any filters, but with offset and limit options (for pagination purpose) @@ -89,6 +104,23 @@ def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[ """ # noqa return db_session.query(self.db_model).offset(skip).limit(limit).all() + def get_multi_by(self, db_session: Session, *, skip=0, limit=100, **kwargs: Any) -> List[Optional[Base]]: + """ + get_multi_by behaves like get_by but returns all filtered objects with the same pagination behavior as in get_multi + + Arguments: + db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. + + Keyword Arguments: + skip {int} -- Number of rows to skip from the results (default: {0}) + limit {int} -- Maximum number of rows to return (default: {100}) + kwargs {dict} -- filters formated as {attribute_name: attribute_value} + + Returns: + List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. + """ # noqa + return db_session.query(self.db_model).filter_by(**kwargs).offset(skip).limit(limit).all() + def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: """ create adds a new row in the Database in the table defined by self.db_model. The column values are populated from the 'obj_in' pydantic object @@ -149,7 +181,7 @@ def delete(self, db_session: Session, obj_id: int) -> int: def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]: """ - remove does the same job as delete, with a different return valie + remove does the same job as delete, with a different return value Returns: deleted object, if the deletion was successfull diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index 30943cb568..196f1b76ca 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -20,6 +20,9 @@ def get(self, db_session: Session, id: int) -> Optional[Item]: def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) + def get_multi_by_owner(db_session: Session, *, owner_id: int, skip=0, limit=100) -> List[Optional[Item]]: + return self.get_multi_by(db_session, owner_id=owner_id, skip=skip, limit=limit) + def create(self, db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item: item_in_data = jsonable_encoder(item_in) item = Item(**item_in_data, owner_id=owner_id) From 9db15d8080c3f8faa0ad94caea1cd305802583a9 Mon Sep 17 00:00:00 2001 From: ebreton Date: Mon, 9 Sep 2019 22:26:23 +0200 Subject: [PATCH 07/26] Renamed models into schemas --- {{cookiecutter.project_slug}}/README.md | 2 +- .../backend/app/app/api/api_v1/endpoints/items.py | 2 +- .../backend/app/app/api/api_v1/endpoints/login.py | 6 +++--- .../backend/app/app/api/api_v1/endpoints/users.py | 2 +- .../backend/app/app/api/api_v1/endpoints/utils.py | 4 ++-- .../backend/app/app/api/utils/security.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/crud/base.py | 4 ++-- {{cookiecutter.project_slug}}/backend/app/app/crud/item.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/crud/user.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/db/init_db.py | 2 +- .../backend/app/app/{models => schemas}/__init__.py | 0 .../backend/app/app/{models => schemas}/item.py | 0 .../backend/app/app/{models => schemas}/msg.py | 0 .../backend/app/app/{models => schemas}/sub_item.py | 0 .../backend/app/app/{models => schemas}/token.py | 0 .../backend/app/app/{models => schemas}/user.py | 0 .../backend/app/app/tests/api/api_v1/test_users.py | 2 +- .../backend/app/app/tests/crud/test_item.py | 2 +- .../backend/app/app/tests/crud/test_sub_item.py | 2 +- .../backend/app/app/tests/crud/test_user.py | 2 +- .../backend/app/app/tests/utils/item.py | 2 +- .../backend/app/app/tests/utils/user.py | 2 +- 22 files changed, 20 insertions(+), 20 deletions(-) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/__init__.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/item.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/msg.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/sub_item.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/token.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{models => schemas}/user.py (100%) diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index defd2dd47e..ddef72ed39 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -55,7 +55,7 @@ If your Docker is not running in `localhost` (the URLs above wouldn't work) chec 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 SQLAlchemy models in `./backend/app/app/db_models/`, 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. +Modify or add SQLAlchemy models in `./backend/app/app/db_models/`, Pydantic schemas in `./backend/app/app/schemas/`, 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/app/api/api_v1/endpoints/items.py b/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py index 131e9852d4..13df5c2936 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 @@ -7,7 +7,7 @@ from app.api.utils.db import get_db from app.api.utils.security import get_current_active_user from app.db_models.user import User as DBUser -from app.models.item import Item, ItemCreate, ItemUpdate +from app.schemas.item import Item, ItemCreate, ItemUpdate router = APIRouter() 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 1db861be5f..fcc5f6a7b4 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 @@ -11,9 +11,9 @@ from app.core.jwt import create_access_token from app.core.security import get_password_hash from app.db_models.user import User as DBUser -from app.models.msg import Msg -from app.models.token import Token -from app.models.user import User +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, 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 966fe12ddc..5ca5d91c95 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 @@ -10,7 +10,7 @@ from app.api.utils.security import get_current_active_superuser, get_current_active_user from app.core import config from app.db_models.user import User as DBUser -from app.models.user import User, UserCreate, UserInDB, UserUpdate +from app.schemas.user import User, UserCreate, UserInDB, UserUpdate from app.utils import send_new_account_email router = APIRouter() 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 cc43abe52a..82ef9b316f 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 @@ -3,8 +3,8 @@ from app.api.utils.security import get_current_active_superuser from app.core.celery_app import celery_app -from app.models.msg import Msg -from app.models.user import UserInDB +from app.schemas.msg import Msg +from app.schemas.user import UserInDB from app.utils import send_test_email router = APIRouter() diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py index 0e761f7e43..dcad1027d4 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py @@ -10,7 +10,7 @@ from app.core import config from app.core.jwt import ALGORITHM from app.db_models.user import User -from app.models.token import TokenPayload +from app.schemas.token import TokenPayload reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index 49458d2186..23baefa033 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -20,7 +20,7 @@ def __init__(self, db_model: Base): E.g.: - # model definition in app/models/item.py + # model definition in app/schemas/item.py class ItemCreate(...) ... @@ -34,7 +34,7 @@ class Item(Base): # crud definition in app/crud/item.py from app.db_models.item import Item - from app.models.item import ItemUpdate, ItemCreate + from app.schemas.item import ItemUpdate, ItemCreate from app.crud.base import CrudBase diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index 196f1b76ca..1346a79f0b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.db_models.item import Item -from app.models.item import ItemCreate, ItemUpdate +from app.schemas.item import ItemCreate, ItemUpdate from app.crud.base import CrudBase diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py index 66f80753c0..4312397c8e 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py @@ -5,7 +5,7 @@ from app.core.security import get_password_hash, verify_password from app.db_models.user import User -from app.models.user import UserCreate, UserUpdate +from app.schemas.user import UserCreate, UserUpdate def get(db_session: Session, *, user_id: int) -> Optional[User]: 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 6374273132..e5c6719397 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py @@ -1,6 +1,6 @@ from app import crud from app.core import config -from app.models.user import UserCreate +from app.schemas.user import UserCreate # make sure all SQL Alchemy models are imported before initializing DB # otherwise, SQL Alchemy might fail to initialize properly relationships diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/__init__.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/item.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/item.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/msg.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/msg.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/msg.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/msg.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/sub_item.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/sub_item.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/token.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/token.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/token.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/models/user.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/user.py 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 119ed219fd..04f3c342d1 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 @@ -3,7 +3,7 @@ from app import crud from app.core import config from app.db.session import db_session -from app.models.user import UserCreate +from app.schemas.user import UserCreate from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import get_server_api, random_lower_string 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 2bfda0b0a7..5eb3c121d5 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,5 +1,5 @@ from app import crud -from app.models.item import ItemCreate, ItemUpdate +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 diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py index 16c04b4ecb..816c62619e 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py @@ -1,5 +1,5 @@ from app import crud -from app.models.sub_item import SubItemCreate, SubItemUpdate +from app.schemas.sub_item import SubItemCreate, SubItemUpdate from app.tests.utils.utils import random_lower_string from app.tests.utils.item import create_random_item from app.db.session import db_session 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 e239cbac01..da93784601 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 @@ -2,7 +2,7 @@ from app import crud from app.db.session import db_session -from app.models.user import UserCreate +from app.schemas.user import UserCreate from app.tests.utils.utils import random_lower_string 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 49398fa55d..026fc2bb8e 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py @@ -1,6 +1,6 @@ from app import crud from app.db.session import db_session -from app.models.item import ItemCreate +from app.schemas.item import ItemCreate from app.tests.utils.user import create_random_user from app.tests.utils.utils import random_lower_string 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 6a5b947e4a..f6c8f0e23c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py @@ -3,7 +3,7 @@ from app import crud from app.core import config from app.db.session import db_session -from app.models.user import UserCreate +from app.schemas.user import UserCreate from app.tests.utils.utils import random_lower_string From f6a5bf6057f8d78de1dff510eb7c4bcf6612efe4 Mon Sep 17 00:00:00 2001 From: ebreton Date: Mon, 9 Sep 2019 22:26:55 +0200 Subject: [PATCH 08/26] Rename db_models into models --- {{cookiecutter.project_slug}}/README.md | 4 ++-- .../backend/app/app/api/api_v1/endpoints/items.py | 2 +- .../backend/app/app/api/api_v1/endpoints/login.py | 2 +- .../backend/app/app/api/api_v1/endpoints/users.py | 2 +- .../backend/app/app/api/utils/security.py | 2 +- .../backend/app/app/crud/__init__.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/crud/base.py | 4 ++-- {{cookiecutter.project_slug}}/backend/app/app/crud/item.py | 2 +- .../backend/app/app/crud/sub_item.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/crud/user.py | 2 +- {{cookiecutter.project_slug}}/backend/app/app/db/base.py | 6 +++--- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index ddef72ed39..abbb7cd634 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -55,7 +55,7 @@ If your Docker is not running in `localhost` (the URLs above wouldn't work) chec 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 SQLAlchemy models in `./backend/app/app/db_models/`, Pydantic schemas in `./backend/app/app/schemas/`, 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. +Modify or add SQLAlchemy models in `./backend/app/app/models/`, Pydantic schemas in `./backend/app/app/schemas/`, 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`. @@ -205,7 +205,7 @@ Make sure you create a "revision" of your models and that you "upgrade" your dat docker-compose exec backend bash ``` -* If you created a new model in `./backend/app/app/db_models/`, make sure to import it in `./backend/app/app/db/base.py`, that Python module (`base.py`) that imports all the models will be used by Alembic. +* If you created a new model in `./backend/app/app/models/`, make sure to import it in `./backend/app/app/db/base.py`, that Python module (`base.py`) that imports all the models will be used by Alembic. * After changing a model (for example, adding a column), inside the container, create a revision, e.g.: 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 13df5c2936..c3cc54a93e 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 @@ -6,7 +6,7 @@ from app import crud from app.api.utils.db import get_db from app.api.utils.security import get_current_active_user -from app.db_models.user import User as DBUser +from app.models.user import User as DBUser from app.schemas.item import Item, ItemCreate, ItemUpdate router = APIRouter() 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 fcc5f6a7b4..b2c9bffe2c 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 @@ -10,7 +10,7 @@ from app.core import config from app.core.jwt import create_access_token from app.core.security import get_password_hash -from app.db_models.user import User as DBUser +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 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 5ca5d91c95..864251d61f 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 @@ -9,7 +9,7 @@ from app.api.utils.db import get_db from app.api.utils.security import get_current_active_superuser, get_current_active_user from app.core import config -from app.db_models.user import User as DBUser +from app.models.user import User as DBUser from app.schemas.user import User, UserCreate, UserInDB, UserUpdate from app.utils import send_new_account_email diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py index dcad1027d4..f87cc8b7fa 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py @@ -9,7 +9,7 @@ from app.api.utils.db import get_db from app.core import config from app.core.jwt import ALGORITHM -from app.db_models.user import User +from app.models.user import User from app.schemas.token import TokenPayload reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index f3b8a91892..d0b1f31b41 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -8,5 +8,5 @@ # you could also simply add the following lines: # from app.crud.base import CrudBase -# from app.db_models.group import Group +# from app.models.group import Group # group = CrudBase(Group) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index 23baefa033..a4cf5d5e6b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -27,13 +27,13 @@ class ItemCreate(...) class ItemUpdate(...) ... - # model definition in app/db_models/item.py + # model definition in app/models/item.py class Item(Base): id: int ... # crud definition in app/crud/item.py - from app.db_models.item import Item + from app.models.item import Item from app.schemas.item import ItemUpdate, ItemCreate from app.crud.base import CrudBase diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index 1346a79f0b..3f3e745a52 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -3,7 +3,7 @@ from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session -from app.db_models.item import Item +from app.models.item import Item from app.schemas.item import ItemCreate, ItemUpdate from app.crud.base import CrudBase diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py index d7d03b046a..93aac6a5e5 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py @@ -1,7 +1,7 @@ from typing import Optional from sqlalchemy.orm import Session, subqueryload -from app.db_models.sub_item import SubItem +from app.models.sub_item import SubItem from app.crud.base import CrudBase diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py index 4312397c8e..472c3bd05b 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.core.security import get_password_hash, verify_password -from app.db_models.user import User +from app.models.user import User from app.schemas.user import UserCreate, UserUpdate diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py index f7e78061eb..90eca9c9bf 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py @@ -1,6 +1,6 @@ # Import all the models, so that Base has them before being # imported by Alembic from app.db.base_class import Base # noqa -from app.db_models.user import User # noqa -from app.db_models.item import Item # noqa -from app.db_models.subitem import SubItem # noqa +from app.models.user import User # noqa +from app.models.item import Item # noqa +from app.models.subitem import SubItem # noqa From 9ce092132a2153653b215c63c82b0a2da5f280d0 Mon Sep 17 00:00:00 2001 From: ebreton Date: Mon, 9 Sep 2019 22:32:47 +0200 Subject: [PATCH 09/26] Rename db_models to models --- .../backend/app/app/crud/{sub_item.py => subitem.py} | 0 .../backend/app/app/db_models/sub_item.py | 11 ----------- .../backend/app/app/{db_models => models}/__init__.py | 0 .../backend/app/app/{db_models => models}/item.py | 1 + .../backend/app/app/{db_models => models}/subitem.py | 1 + .../backend/app/app/{db_models => models}/user.py | 0 .../backend/app/app/schemas/item.py | 11 +++++------ .../app/app/schemas/{sub_item.py => subitem.py} | 11 +++-------- 8 files changed, 10 insertions(+), 25 deletions(-) rename {{cookiecutter.project_slug}}/backend/app/app/crud/{sub_item.py => subitem.py} (100%) delete mode 100755 {{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py rename {{cookiecutter.project_slug}}/backend/app/app/{db_models => models}/__init__.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/{db_models => models}/item.py (99%) rename {{cookiecutter.project_slug}}/backend/app/app/{db_models => models}/subitem.py (99%) rename {{cookiecutter.project_slug}}/backend/app/app/{db_models => models}/user.py (100%) rename {{cookiecutter.project_slug}}/backend/app/app/schemas/{sub_item.py => subitem.py} (79%) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py rename to {{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py deleted file mode 100755 index 7cb62c0026..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/db_models/sub_item.py +++ /dev/null @@ -1,11 +0,0 @@ -from sqlalchemy import Column, ForeignKey, Integer, String -from sqlalchemy.orm import relationship - -from app.db.base_class import Base - - -class SubItem(Base): - id = Column(Integer, primary_key=True, index=True) - name = Column(String, index=True) - item_id = Column(Integer, ForeignKey("item.id")) - item = relationship("Item", back_populates="sub_items") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/db_models/__init__.py rename to {{cookiecutter.project_slug}}/backend/app/app/models/__init__.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py similarity index 99% rename from {{cookiecutter.project_slug}}/backend/app/app/db_models/item.py rename to {{cookiecutter.project_slug}}/backend/app/app/models/item.py index 3855355108..6037ee9ada 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/db_models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py @@ -8,6 +8,7 @@ class Item(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) description = Column(String, index=True) + owner_id = Column(Integer, ForeignKey("user.id")) owner = relationship("User", back_populates="items") subitems = relationship("SubItem", back_populates="item") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/models/subitem.py similarity index 99% rename from {{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py rename to {{cookiecutter.project_slug}}/backend/app/app/models/subitem.py index 4fc8f95195..40ec9728df 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db_models/subitem.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/subitem.py @@ -8,5 +8,6 @@ class SubItem(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) description = Column(String, index=True) + item_id = Column(Integer, ForeignKey("item.id")) item = relationship("Item", back_populates="subitems") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db_models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/app/app/db_models/user.py rename to {{cookiecutter.project_slug}}/backend/app/app/models/user.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py index 635c4f300c..5f8c129298 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py @@ -1,10 +1,12 @@ from pydantic import BaseModel +from .user import User # Shared properties class ItemBase(BaseModel): title: str = None description: str = None + owner_id: int = None class Config: orm_mode = True @@ -13,6 +15,7 @@ class Config: # Properties to receive on item creation class ItemCreate(ItemBase): title: str + owner_id: int # Properties to receive on item update @@ -24,14 +27,10 @@ class ItemUpdate(ItemBase): class ItemInDBBase(ItemBase): id: int title: str - owner_id: int + + owner: User # Properties to return to client class Item(ItemInDBBase): pass - - -# Properties properties stored in DB -class ItemInDB(ItemInDBBase): - pass diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py similarity index 79% rename from {{cookiecutter.project_slug}}/backend/app/app/schemas/sub_item.py rename to {{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py index c84bc602ee..39be1ba668 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/sub_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py @@ -5,7 +5,8 @@ # Shared properties class SubItemBase(BaseModel): - name: str = None + title: str = None + description: str = None item_id: int class Config: @@ -14,7 +15,7 @@ class Config: # Properties to receive on item creation class SubItemCreate(SubItemBase): - name: str + title: str # Properties to receive on item update @@ -25,14 +26,8 @@ class SubItemUpdate(SubItemBase): # Properties shared by models stored in DB class SubItemInDBBase(SubItemBase): id: int - name: str # Properties to return to client class SubItem(SubItemInDBBase): item : Item - - -# Properties properties stored in DB -class SubItemInDB(SubItemInDBBase): - pass From 5f8a300899aa68c5866b38981c59104bfa024342 Mon Sep 17 00:00:00 2001 From: ebreton Date: Tue, 10 Sep 2019 21:21:38 +0200 Subject: [PATCH 10/26] Forward args passed to test.sh down to test-start.sh --- test.sh | 2 +- {{cookiecutter.project_slug}}/scripts/test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test.sh b/test.sh index 780ed884d3..6e8deb8435 100644 --- a/test.sh +++ b/test.sh @@ -9,6 +9,6 @@ cookiecutter --config-file ./testing-config.yml --no-input -f ./ cd ./testing-project -bash ./scripts/test.sh +bash ./scripts/test.sh $* cd ../ diff --git a/{{cookiecutter.project_slug}}/scripts/test.sh b/{{cookiecutter.project_slug}}/scripts/test.sh index 84f9cce12e..15f5139759 100644 --- a/{{cookiecutter.project_slug}}/scripts/test.sh +++ b/{{cookiecutter.project_slug}}/scripts/test.sh @@ -15,5 +15,5 @@ config > docker-stack.yml docker-compose -f docker-stack.yml build docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error docker-compose -f docker-stack.yml up -d -docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh +docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh $* docker-compose -f docker-stack.yml down -v --remove-orphans From 3acade8e4517a2e279a65235db27c6d662f896ea Mon Sep 17 00:00:00 2001 From: ebreton Date: Tue, 10 Sep 2019 21:22:01 +0200 Subject: [PATCH 11/26] ignore cache, Pilfile.lock and docker-stack.yml --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2b65cea186..4f3fb8501f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .vscode -testing-project .mypy_cache +.pytest_cache +Pipfile.lock + +testing-project +docker-stack.yml From e464bd3d9be41d41055454343ce1a126bcbb32e7 Mon Sep 17 00:00:00 2001 From: ebreton Date: Tue, 10 Sep 2019 21:35:24 +0200 Subject: [PATCH 12/26] Fix tests --- .../versions/d4867f3a4c0a_first_revision.py | 43 ++++++++++--------- .../app/app/api/api_v1/endpoints/items.py | 3 +- .../app/app/api/api_v1/endpoints/users.py | 4 +- .../app/app/api/api_v1/endpoints/utils.py | 6 +-- .../backend/app/app/crud/__init__.py | 2 +- .../backend/app/app/crud/base.py | 2 +- .../backend/app/app/crud/item.py | 9 +--- .../backend/app/app/crud/subitem.py | 4 +- .../backend/app/app/models/user.py | 1 + .../backend/app/app/schemas/item.py | 15 ++++--- .../backend/app/app/schemas/subitem.py | 11 ++--- .../backend/app/app/schemas/user.py | 27 ++++++------ .../app/app/tests/api/api_v1/test_items.py | 8 +++- .../backend/app/app/tests/crud/test_item.py | 16 +++---- .../app/app/tests/crud/test_sub_item.py | 26 +++++------ .../backend/app/app/tests/utils/item.py | 6 +-- 16 files changed, 96 insertions(+), 87 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py index b1d9796570..a61721d5b6 100644 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py @@ -19,46 +19,49 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('full_name', sa.String(), nullable=True), - sa.Column('email', sa.String(), nullable=True), - sa.Column('hashed_password', sa.String(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('is_superuser', sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint('id') + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=True), + sa.Column('hashed_password', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) op.create_table('item', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(), nullable=True), - sa.Column('description', sa.String(), nullable=True), - sa.Column('owner_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_item_description'), 'item', ['description'], unique=False) op.create_index(op.f('ix_item_id'), 'item', ['id'], unique=False) op.create_index(op.f('ix_item_title'), 'item', ['title'], unique=False) op.create_table('subitem', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=True), - sa.Column('item_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), - sa.PrimaryKeyConstraint('id') + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.PrimaryKeyConstraint('id') ) + op.create_index(op.f('ix_subitem_description'), 'subitem', ['description'], unique=False) op.create_index(op.f('ix_subitem_id'), 'subitem', ['id'], unique=False) - op.create_index(op.f('ix_subitem_name'), 'subitem', ['name'], unique=False) + op.create_index(op.f('ix_subitem_title'), 'subitem', ['title'], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_subitem_name'), table_name='subitem') + op.drop_index(op.f('ix_subitem_title'), table_name='subitem') op.drop_index(op.f('ix_subitem_id'), table_name='subitem') + op.drop_index(op.f('ix_subitem_description'), table_name='subitem') op.drop_table('subitem') op.drop_index(op.f('ix_item_title'), table_name='item') op.drop_index(op.f('ix_item_id'), table_name='item') 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 c3cc54a93e..24a6a0ac93 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 @@ -41,7 +41,8 @@ def create_item( """ Create new item. """ - item = crud.item.create(db_session=db, item_in=item_in, owner_id=current_user.id) + item_in.owner_id = current_user.id + item = crud.item.create(db_session=db, item_in=item_in) return item 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 864251d61f..8b50fa6f7e 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 @@ -10,7 +10,7 @@ from app.api.utils.security import get_current_active_superuser, get_current_active_user from app.core import config from app.models.user import User as DBUser -from app.schemas.user import User, UserCreate, UserInDB, UserUpdate +from app.schemas.user import User, UserCreate, UserUpdate from app.utils import send_new_account_email router = APIRouter() @@ -141,7 +141,7 @@ def update_user( db: Session = Depends(get_db), user_id: int, user_in: UserUpdate, - current_user: UserInDB = Depends(get_current_active_superuser), + current_user: User = Depends(get_current_active_superuser), ): """ 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 82ef9b316f..9e146179e1 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 @@ -4,7 +4,7 @@ from app.api.utils.security import get_current_active_superuser from app.core.celery_app import celery_app from app.schemas.msg import Msg -from app.schemas.user import UserInDB +from app.schemas.user import User from app.utils import send_test_email router = APIRouter() @@ -12,7 +12,7 @@ @router.post("/test-celery/", response_model=Msg, status_code=201) def test_celery( - msg: Msg, current_user: UserInDB = Depends(get_current_active_superuser) + msg: Msg, current_user: User = Depends(get_current_active_superuser) ): """ Test Celery worker. @@ -23,7 +23,7 @@ def test_celery( @router.post("/test-email/", response_model=Msg, status_code=201) def test_email( - email_to: EmailStr, current_user: UserInDB = Depends(get_current_active_superuser) + email_to: EmailStr, current_user: User = Depends(get_current_active_superuser) ): """ Test emails. diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index d0b1f31b41..bfa02fd8c8 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1,7 +1,7 @@ from . import user from .item import item -from .sub_item import sub_item +from .subitem import subitem # For a new basic set of CRUD operations, on a new object, let's say 'Group', diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index a4cf5d5e6b..bc7ba8779c 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 +from typing import List, Optional, Any from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index 3f3e745a52..b15b0ecf91 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -23,13 +23,8 @@ def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[ def get_multi_by_owner(db_session: Session, *, owner_id: int, skip=0, limit=100) -> List[Optional[Item]]: return self.get_multi_by(db_session, owner_id=owner_id, skip=skip, limit=limit) - def create(self, db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item: - item_in_data = jsonable_encoder(item_in) - item = Item(**item_in_data, owner_id=owner_id) - db_session.add(item) - db_session.commit() - db_session.refresh(item) - return item + def create(self, db_session: Session, *, item_in: ItemCreate) -> Item: + return super(CrudItem, self).create(db_session, obj_in=item_in) def update(self, db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item: return super(CrudItem, self).update(db_session, obj=item, obj_in=item_in) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py index 93aac6a5e5..553bedc402 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py @@ -1,7 +1,7 @@ from typing import Optional from sqlalchemy.orm import Session, subqueryload -from app.models.sub_item import SubItem +from app.models.subitem import SubItem from app.crud.base import CrudBase @@ -18,4 +18,4 @@ def get(self, db_session: Session, obj_id: int) -> Optional[SubItem]: ) -sub_item = CrudSubItem(SubItem) +subitem = CrudSubItem(SubItem) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index 1052908a4b..eadf6d60f5 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -11,4 +11,5 @@ class User(Base): hashed_password = Column(String) 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/item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py index 5f8c129298..f79c904d5d 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py @@ -1,3 +1,4 @@ +from typing import List, Optional from pydantic import BaseModel from .user import User @@ -12,13 +13,13 @@ class Config: orm_mode = True -# Properties to receive on item creation +# Mandatory properties for item creation class ItemCreate(ItemBase): title: str owner_id: int -# Properties to receive on item update +# Specific properties to receive on item update class ItemUpdate(ItemBase): pass @@ -26,11 +27,13 @@ class ItemUpdate(ItemBase): # Properties shared by models stored in DB class ItemInDBBase(ItemBase): id: int - title: str - - owner: User # Properties to return to client class Item(ItemInDBBase): - pass + owner: User + + +class ItemExpanded(ItemInDBBase): + owner: User + subitems: Optional[List['SubItem']] diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py index 39be1ba668..45c5efd9a2 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py @@ -7,20 +7,21 @@ class SubItemBase(BaseModel): title: str = None description: str = None - item_id: int + item_id: int = None class Config: orm_mode = True -# Properties to receive on item creation +# Mandatory properties for item creation class SubItemCreate(SubItemBase): title: str + item_id: int -# Properties to receive on item update +# Specific properties to receive on item update class SubItemUpdate(SubItemBase): - item_id: int = None + pass # Properties shared by models stored in DB @@ -30,4 +31,4 @@ class SubItemInDBBase(SubItemBase): # Properties to return to client class SubItem(SubItemInDBBase): - item : Item + item: Item diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py index fa5686da5b..2fe327fcb6 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel @@ -6,34 +6,35 @@ # Shared properties class UserBase(BaseModel): email: Optional[str] = None + full_name: Optional[str] = None is_active: Optional[bool] = True is_superuser: Optional[bool] = False - full_name: Optional[str] = None class Config: orm_mode = True -class UserBaseInDB(UserBase): - id: int = None - - # Properties to receive via API on creation -class UserCreate(UserBaseInDB): +class UserCreate(UserBase): email: str password: str # Properties to receive via API on update -class UserUpdate(UserBaseInDB): +class UserUpdate(UserBase): password: Optional[str] = None -# Additional properties to return via API -class User(UserBaseInDB): +# Properties shared by models stored in DB +class UserBaseInDBBase(UserBase): + id: int = None + hashed_password: str + + +# Properties to return to client +class User(UserBaseInDBBase): pass -# Additional properties stored in DB -class UserInDB(UserBaseInDB): - hashed_password: str +class UserExpanded(UserBaseInDBBase): + items: Optional[List['Item']] 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 330ae360bd..e4311c92a9 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 @@ -3,16 +3,20 @@ from app.core import config 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 def test_create_item(superuser_token_headers): + user = create_random_user() server_api = get_server_api() - data = {"title": "Foo", "description": "Fighters"} + data = {"title": "Foo", "description": "Fighters", "owner_id": user.id} response = requests.post( f"{server_api}{config.API_V1_STR}/items/", headers=superuser_token_headers, json=data, ) + assert response.status_code == 200 + content = response.json() assert content["title"] == data["title"] assert content["description"] == data["description"] @@ -27,6 +31,8 @@ def test_read_item(superuser_token_headers): f"{server_api}{config.API_V1_STR}/items/{item.id}", headers=superuser_token_headers, ) + assert response.status_code == 200 + content = response.json() assert content["title"] == item.title assert content["description"] == item.description 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 5eb3c121d5..2d9dc77b0b 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 @@ -8,9 +8,9 @@ def test_create_item(): title = random_lower_string() description = random_lower_string() - item_in = ItemCreate(title=title, description=description) user = create_random_user() - item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) + item_in = ItemCreate(title=title, description=description, owner_id=user.id) + item = crud.item.create(db_session=db_session, item_in=item_in) assert item.title == title assert item.description == description assert item.owner_id == user.id @@ -19,9 +19,9 @@ def test_create_item(): def test_get_item(): title = random_lower_string() description = random_lower_string() - item_in = ItemCreate(title=title, description=description) user = create_random_user() - item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) + item_in = ItemCreate(title=title, description=description, owner_id=user.id) + item = crud.item.create(db_session=db_session, item_in=item_in) stored_item = crud.item.get(db_session=db_session, id=item.id) assert item.id == stored_item.id assert item.title == stored_item.title @@ -32,9 +32,9 @@ def test_get_item(): def test_update_item(): title = random_lower_string() description = random_lower_string() - item_in = ItemCreate(title=title, description=description) user = create_random_user() - item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) + item_in = ItemCreate(title=title, description=description, owner_id=user.id) + item = crud.item.create(db_session=db_session, item_in=item_in) description2 = random_lower_string() item_update = ItemUpdate(description=description2) item2 = crud.item.update( @@ -49,9 +49,9 @@ def test_update_item(): def test_delete_item(): title = random_lower_string() description = random_lower_string() - item_in = ItemCreate(title=title, description=description) user = create_random_user() - item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) + item_in = ItemCreate(title=title, description=description, owner_id=user.id) + item = crud.item.create(db_session=db_session, item_in=item_in) item2 = crud.item.remove(db_session=db_session, obj_id=item.id) item3 = crud.item.get(db_session=db_session, id=item.id) assert item3 is None diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py index 816c62619e..2d553661d4 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py @@ -1,5 +1,5 @@ from app import crud -from app.schemas.sub_item import SubItemCreate, SubItemUpdate +from app.schemas.subitem import SubItemCreate, SubItemUpdate from app.tests.utils.utils import random_lower_string from app.tests.utils.item import create_random_item from app.db.session import db_session @@ -11,17 +11,17 @@ def test_create_sub_item(): global SUB_ITEM - name = random_lower_string() - sub_item_in = SubItemCreate(name=name, item_id=ITEM.id) - SUB_ITEM = crud.sub_item.create(db_session=db_session, obj_in=sub_item_in) - assert SUB_ITEM.name == name + title = random_lower_string() + sub_item_in = SubItemCreate(title=title, item_id=ITEM.id) + SUB_ITEM = crud.subitem.create(db_session=db_session, obj_in=sub_item_in) + assert SUB_ITEM.title == title assert SUB_ITEM.item_id == ITEM.id def test_get_sub_item(): - stored_sub_item = crud.sub_item.get(db_session=db_session, obj_id=SUB_ITEM.id) + stored_sub_item = crud.subitem.get(db_session=db_session, obj_id=SUB_ITEM.id) assert stored_sub_item.id == SUB_ITEM.id - assert stored_sub_item.name == SUB_ITEM.name + assert stored_sub_item.title == SUB_ITEM.title assert stored_sub_item.item_id == SUB_ITEM.item_id assert stored_sub_item.item is not None @@ -29,15 +29,15 @@ def test_get_sub_item(): def test_update_sub_item(): - new_name = random_lower_string() - sub_item_update = SubItemUpdate(name=new_name) - updated_sub_item = crud.sub_item.update( + new_title = random_lower_string() + sub_item_update = SubItemUpdate(title=new_title) + updated_sub_item = crud.subitem.update( db_session=db_session, obj=SUB_ITEM, obj_in=sub_item_update ) assert updated_sub_item.id == SUB_ITEM.id - assert updated_sub_item.name == new_name + assert updated_sub_item.title == new_title def test_delete_sub_item(): - assert crud.sub_item.delete(db_session=db_session, obj_id=SUB_ITEM.id) == 1 - assert crud.sub_item.get(db_session=db_session, obj_id=SUB_ITEM.id) is None + assert crud.subitem.delete(db_session=db_session, obj_id=SUB_ITEM.id) == 1 + assert crud.subitem.get(db_session=db_session, obj_id=SUB_ITEM.id) is None 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 026fc2bb8e..14a63e66f8 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py @@ -11,7 +11,5 @@ def create_random_item(owner_id: int = None): 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( - db_session=db_session, item_in=item_in, owner_id=owner_id - ) + item_in = ItemCreate(title=title, owner_id=owner_id, description=description, id=id) + return crud.item.create(db_session=db_session, item_in=item_in) From 8b2f5591d62ab4d3b23f9aecc1fba2298c07c867 Mon Sep 17 00:00:00 2001 From: ebreton Date: Thu, 19 Sep 2019 23:03:36 +0200 Subject: [PATCH 13/26] Update tests --- test-backend.sh | 19 +++ .../versions/d4867f3a4c0a_first_revision.py | 2 + .../app/app/api/api_v1/endpoints/items.py | 8 +- .../app/app/api/api_v1/endpoints/login.py | 4 +- .../app/app/api/api_v1/endpoints/users.py | 122 +++++++-------- .../backend/app/app/api/utils/security.py | 6 +- .../backend/app/app/core/config.py | 2 + .../backend/app/app/crud/__init__.py | 15 +- .../backend/app/app/crud/base.py | 79 ++++++---- .../backend/app/app/crud/item.py | 21 ++- .../backend/app/app/crud/subitem.py | 21 --- .../backend/app/app/crud/user.py | 146 +++++++++++------- .../backend/app/app/db/base.py | 3 +- .../backend/app/app/db/base_class.py | 8 + .../backend/app/app/db/init_db.py | 7 +- .../backend/app/app/models/__init__.py | 8 + .../backend/app/app/models/user.py | 7 +- .../backend/app/app/schemas/user.py | 37 +++-- .../app/app/tests/api/api_v1/test_users.py | 30 ++-- .../backend/app/app/tests/conftest.py | 18 +++ .../backend/app/app/tests/crud/test_item.py | 2 +- .../backend/app/app/tests/crud/test_user.py | 56 +++---- .../backend/app/app/tests/utils/user.py | 32 +++- {{cookiecutter.project_slug}}/scripts/test.sh | 1 + 24 files changed, 384 insertions(+), 270 deletions(-) create mode 100644 test-backend.sh delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py diff --git a/test-backend.sh b/test-backend.sh new file mode 100644 index 0000000000..09b1adc883 --- /dev/null +++ b/test-backend.sh @@ -0,0 +1,19 @@ +#! /usr/bin/env bash + +# pre-requisite: use test.sh script at least once before using this script + +# this script produces the same result as test.sh, but faster +# because it rsyncs the latest modification into testing-project +# instead of removing it and generating it again + +# Exit in case of error +set -e + +# push new src files +rsync -av \{\{cookiecutter.project_slug\}\}/backend testing-project/ + +# restart backend container +docker-compose -f testing-project/docker-stack.yml restart backend + +# run tests +docker-compose -f testing-project/docker-stack.yml exec -T backend-tests /tests-start.sh $* diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py index a61721d5b6..c863cb3fb8 100644 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py @@ -25,6 +25,8 @@ def upgrade(): sa.Column('hashed_password', sa.String(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=True), sa.Column('is_superuser', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) 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 24a6a0ac93..78a42c7e5f 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 @@ -22,7 +22,7 @@ def read_items( """ Retrieve items. """ - if crud.user.is_superuser(current_user): + if current_user.is_superuser: items = crud.item.get_multi(db, skip=skip, limit=limit) else: items = crud.item.get_multi_by_owner( @@ -60,7 +60,7 @@ def update_item( item = crud.item.get(db_session=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): + if not current_user.is_superuser and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") item = crud.item.update(db_session=db, item=item, item_in=item_in) return item @@ -79,7 +79,7 @@ def read_user_me( item = crud.item.get(db_session=db, id=id) if not item: raise HTTPException(status_code=400, detail="Item not found") - if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): + if not current_user.is_superuser and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") return item @@ -97,7 +97,7 @@ def delete_item( item = crud.item.get(db_session=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): + if not current_user.is_superuser 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) 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 b2c9bffe2c..ce5e5859f8 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 @@ -35,7 +35,7 @@ def login_access_token( ) if not user: raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not crud.user.is_active(user): + elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES) return { @@ -87,7 +87,7 @@ def reset_password(token: str = Body(...), new_password: str = Body(...), db: Se status_code=404, detail="The user with this username does not exist in the system.", ) - elif not crud.user.is_active(user): + elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") hashed_password = get_password_hash(new_password) user.hashed_password = hashed_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 8b50fa6f7e..f9a63a7884 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 @@ -16,6 +16,36 @@ router = APIRouter() +@router.get("/me", response_model=User) +def read_user_me( + db: Session = Depends(get_db), + current_user: DBUser = Depends(get_current_active_user), +): + """ + Get current user. + """ + return current_user + + +@router.get("/{user_id}", response_model=User) +def read_user_by_id( + user_id: int, + current_user: DBUser = Depends(get_current_active_user), + db: Session = Depends(get_db), +): + """ + Get a specific user by id. + """ + user = crud.user.get(db, obj_id=user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) + return user + + @router.get("/", response_model=List[User]) def read_users( db: Session = Depends(get_db), @@ -30,6 +60,33 @@ def read_users( return users +@router.post("/open", response_model=User) +def create_user_open( + *, + db: Session = Depends(get_db), + password: str = Body(...), + email: EmailStr = Body(...), + full_name: str = Body(None), +): + """ + Create new user without the need to be logged in. + """ + if not config.USERS_OPEN_REGISTRATION: + raise HTTPException( + status_code=403, + detail="Open user resgistration is forbidden on this server", + ) + user = crud.user.get_by_email(db, email=email) + if user: + raise HTTPException( + 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 = crud.user.create(db, obj_in=user_in) + return user + + @router.post("/", response_model=User) def create_user( *, @@ -46,7 +103,7 @@ def create_user( status_code=400, detail="The user with this username already exists in the system.", ) - user = crud.user.create(db, user_in=user_in) + user = crud.user.create(db, obj_in=user_in) if config.EMAILS_ENABLED and user_in.email: send_new_account_email( email_to=user_in.email, username=user_in.email, password=user_in.password @@ -74,64 +131,7 @@ def update_user_me( user_in.full_name = full_name if email is not None: user_in.email = email - user = crud.user.update(db, user=current_user, user_in=user_in) - return user - - -@router.get("/me", response_model=User) -def read_user_me( - db: Session = Depends(get_db), - current_user: DBUser = Depends(get_current_active_user), -): - """ - Get current user. - """ - return current_user - - -@router.post("/open", response_model=User) -def create_user_open( - *, - db: Session = Depends(get_db), - password: str = Body(...), - email: EmailStr = Body(...), - full_name: str = Body(None), -): - """ - Create new user without the need to be logged in. - """ - if not config.USERS_OPEN_REGISTRATION: - raise HTTPException( - status_code=403, - detail="Open user resgistration is forbidden on this server", - ) - user = crud.user.get_by_email(db, email=email) - if user: - raise HTTPException( - 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 = crud.user.create(db, user_in=user_in) - return user - - -@router.get("/{user_id}", response_model=User) -def read_user_by_id( - user_id: int, - current_user: DBUser = Depends(get_current_active_user), - db: Session = Depends(get_db), -): - """ - Get a specific user by id. - """ - user = crud.user.get(db, user_id=user_id) - if user == current_user: - return user - if not crud.user.is_superuser(current_user): - raise HTTPException( - status_code=400, detail="The user doesn't have enough privileges" - ) + user = crud.user.update(db, obj=current_user, obj_in=user_in) return user @@ -146,11 +146,11 @@ def update_user( """ Update a user. """ - user = crud.user.get(db, user_id=user_id) + user = crud.user.get(db, obj_id=user_id) if not user: raise HTTPException( status_code=404, detail="The user with this username does not exist in the system", ) - user = crud.user.update(db, user=user, user_in=user_in) + user = crud.user.update(db, obj=user, obj_in=user_in) return user diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py index f87cc8b7fa..14bc010ce2 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py @@ -25,20 +25,20 @@ def get_current_user( raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" ) - user = crud.user.get(db, user_id=token_data.user_id) + user = crud.user.get(db, obj_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): + if not current_user.is_active: 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): + if not current_user.is_superuser: raise HTTPException( status_code=400, detail="The user doesn't have enough privileges" ) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py index 07e42b084e..a1c5142b79 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py @@ -50,4 +50,6 @@ def getenv_boolean(var_name, default_value=False): FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER") FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD") +EMAIL_TEST_USER = "test@test.com" + USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index bfa02fd8c8..b1466b4e99 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1,12 +1,11 @@ -from . import user - +from .user import user from .item import item -from .subitem import subitem - -# For a new basic set of CRUD operations, on a new object, let's say 'Group', +# For a new basic set of CRUD operations, on a new object, let's say 'SubItem', # you could also simply add the following lines: -# from app.crud.base import CrudBase -# from app.models.group import Group -# group = CrudBase(Group) +from .base import CrudBase +from app.models import subitem as models_subitem +from app.schemas import subitem as schemas_subitem + +subitem = CrudBase(models_subitem.SubItem, schemas_subitem.SubItem) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index bc7ba8779c..cbe1056941 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -1,3 +1,4 @@ +import logging from typing import List, Optional, Any from fastapi.encoders import jsonable_encoder @@ -9,13 +10,13 @@ class CrudBase: - def __init__(self, db_model: Base): + def __init__(self, model: Base, schema: BaseModel): """ CrudBase instances are used to provide the basic CRUD methods for a given object type (get, get_multi, update, create and delete). In order to use it, follow this steps when you define a new DB model: - create a class that inherites from CrudBase - - override basic methods with proper types (to get better completion in your IDE) + - override basic methods with proper types in order to get better completion in your IDE - create an instance of your newly created class, providing the DB model as an argument E.g.: @@ -33,32 +34,37 @@ class Item(Base): ... # crud definition in app/crud/item.py - from app.models.item import Item - from app.schemas.item import ItemUpdate, ItemCreate + from typing import List, Optional + from sqlalchemy.orm import Session + + import app.models.item as models_item + import app.schemas.item as schemas_item + from app.crud.base import CrudBase class CrudItem(CrudBase): - def get(self, db_session: Session, obj_id: int) -> Optional[Item]: + def get(self, db_session: Session, obj_id: int) -> Optional[models_item.Item]: return super(CrudItem, self).get(db_session, obj_id=obj_id) - def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: + def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[models_item.Item]]: return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) - def create(self, db_session: Session, *, obj_in: ItemCreate) -> Item: + def create(self, db_session: Session, *, obj_in: schemas_item.ItemCreate) -> models_item.Item: return super(CrudItem, self).create(db_session, obj_in=obj_in) - def update(self, db_session: Session, *, obj: Base, obj_in: ItemUpdate) -> Item: + def update(self, db_session: Session, *, obj: models_item.Item, obj_in: schemas_item.ItemUpdate) -> models_item.Item: return super(CrudItem, self).update(db_session, obj=obj, obj_in=obj_in) - crud_item = CrudItem(Item) + crud_item = CrudItem(models_item.Item, schemas_item.Item) Arguments: - db_model {Base} -- Class of the DB model which CRUD methods will be provided for + model {Base} -- Class of the DB model which CRUD methods will be provided for """ # noqa - self.db_model = db_model + self.model = model + self.schema = schema def get(self, db_session: Session, obj_id: int) -> Optional[Base]: """ @@ -69,9 +75,9 @@ def get(self, db_session: Session, obj_id: int) -> Optional[Base]: obj_id {int} -- ID of the object in the Database. It must be defined by a PrimaryKey on the 'id' column. Returns: - Optional[Base] -- Returns an instance of self.db_model class if an object is found in the Database for the given obj_id. Returns None if there is no match found. + Optional[Base] -- Returns an instance of self.model class if an object is found in the Database for the given obj_id. Returns None if there is no match found. """ # noqa - return db_session.query(self.db_model).get(obj_id) + return db_session.query(self.model).filter(self.model.id == obj_id).first() def get_first_by(self, db_session: Session, **kwargs: Any) -> Optional[Base]: """ @@ -84,9 +90,9 @@ def get_first_by(self, db_session: Session, **kwargs: Any) -> Optional[Base]: kwargs {dict} -- filters formated as {attribute_name: attribute_value} Returns: - Optional[Base] -- Returns an instance of self.db_model class if an object is found in the Database. Returns None if there is no match found. + Optional[Base] -- Returns an instance of self.model class if an object is found in the Database. Returns None if there is no match found. """ # noqa - return db_session.query(self.db_model).filter_by(**kwargs).first() + return db_session.query(self.model).filter_by(**kwargs).first() def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Base]]: """ @@ -102,7 +108,7 @@ def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[ Returns: List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. """ # noqa - return db_session.query(self.db_model).offset(skip).limit(limit).all() + return db_session.query(self.model).offset(skip).limit(limit).all() def get_multi_by(self, db_session: Session, *, skip=0, limit=100, **kwargs: Any) -> List[Optional[Base]]: """ @@ -119,11 +125,11 @@ def get_multi_by(self, db_session: Session, *, skip=0, limit=100, **kwargs: Any) Returns: List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. """ # noqa - return db_session.query(self.db_model).filter_by(**kwargs).offset(skip).limit(limit).all() + return db_session.query(self.model).filter_by(**kwargs).offset(skip).limit(limit).all() def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: """ - create adds a new row in the Database in the table defined by self.db_model. The column values are populated from the 'obj_in' pydantic object + create adds a new row in the Database in the table defined by self.model. The column values are populated from the 'obj_in' pydantic object Arguments: db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. @@ -132,14 +138,19 @@ def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: Returns: Base -- The object inserted in the Database """ # noqa - obj_in_data = jsonable_encoder(obj_in) - obj = self.db_model(**obj_in_data) + obj = self.model.from_schema(obj_in) + logging.info( + f"\033[33mCreating\033[0m \033[35m{obj.__class__.__name__}\033[0m" + f" \033[33mwith\033[0m {obj_in}" + ) db_session.add(obj) db_session.commit() db_session.refresh(obj) return obj - def update(self, db_session: Session, *, obj: Base, obj_in: BaseModel) -> Base: + def update( + self, db_session: Session, *, obj: Base, obj_in: BaseModel + ) -> Base: """ update modifies an existing row (fetched from given obj) in the Database with values from given obj_in @@ -151,11 +162,21 @@ def update(self, db_session: Session, *, obj: Base, obj_in: BaseModel) -> Base: Returns: Base -- The updated DB object, with all its attributes """ # noqa - obj_data = jsonable_encoder(obj) + obj_data = obj.to_schema(self.schema).dict(skip_defaults=True) update_data = obj_in.dict(skip_defaults=True) - for field in obj_data: - if field in update_data: - setattr(obj, field, update_data[field]) + formatted_data = { + key: value + for key, value in jsonable_encoder(obj_in).items() + if key in update_data and key in obj_data + } + logging.info( + f"\033[33mUpdating\033[0m \033[35m{obj.__class__.__name__}\033[0m={obj_data}" + f" \033[33mwith\033[0m {formatted_data}" + ) + + for field, value in formatted_data.items(): + setattr(obj, field, value) + db_session.add(obj) db_session.commit() db_session.refresh(obj) @@ -172,7 +193,11 @@ def delete(self, db_session: Session, obj_id: int) -> int: Returns: int -- number of rows deleted, i.e. 1 if the object has been found and deleted, 0 otherwise """ # noqa - queried = db_session.query(self.db_model).filter(self.db_model.id == obj_id) + logging.info( + f"\033[31mDeleting\033[0m \033[35m{self.model.__name__}\033[0m" + f" \033[33mwith\033[0m id={obj_id}" + ) + queried = db_session.query(self.model).filter(self.model.id == obj_id) counted = queried.count() if counted > 0: queried.delete() @@ -187,7 +212,7 @@ def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]: deleted object, if the deletion was successfull None if the object was already deleted from the Database """ # noqa - obj = db_session.query(self.db_model).get(obj_id) + obj = db_session.query(self.model).get(obj_id) db_session.delete(obj) db_session.commit() return obj diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py index b15b0ecf91..b460bbc18c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py @@ -3,8 +3,8 @@ from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate +from app.models import item as models_item +from app.schemas import item as schemas_item from app.crud.base import CrudBase @@ -14,20 +14,25 @@ class CrudItem(CrudBase): both the types of the arguments and of the returned objects to the proper 'Item*' classes """ - def get(self, db_session: Session, id: int) -> Optional[Item]: + def get(self, db_session: Session, id: int + ) -> Optional[models_item.Item]: return super(CrudItem, self).get(db_session, obj_id=id) - def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]: + def get_multi(self, db_session: Session, *, skip=0, limit=100 + ) -> List[Optional[models_item.Item]]: return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) - def get_multi_by_owner(db_session: Session, *, owner_id: int, skip=0, limit=100) -> List[Optional[Item]]: + def get_multi_by_owner(db_session: Session, *, owner_id: int, skip=0, limit=100 + ) -> List[Optional[models_item.Item]]: return self.get_multi_by(db_session, owner_id=owner_id, skip=skip, limit=limit) - def create(self, db_session: Session, *, item_in: ItemCreate) -> Item: + def create(self, db_session: Session, *, item_in: schemas_item.ItemCreate + ) -> models_item.Item: return super(CrudItem, self).create(db_session, obj_in=item_in) - def update(self, db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item: + def update(self, db_session: Session, *, item: models_item.Item, item_in: schemas_item.ItemUpdate + ) -> models_item.Item: return super(CrudItem, self).update(db_session, obj=item, obj_in=item_in) -item = CrudItem(Item) +item = CrudItem(models_item.Item, schemas_item.ItemInDBBase) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py deleted file mode 100644 index 553bedc402..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/subitem.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional -from sqlalchemy.orm import Session, subqueryload - -from app.models.subitem import SubItem -from app.crud.base import CrudBase - - -class CrudSubItem(CrudBase): - """ - This example shows how to change the behaviour of a default GET operation (by returning the foreign objects with all its attribute, instead of solely its id) - """ - - def get(self, db_session: Session, obj_id: int) -> Optional[SubItem]: - return ( - db_session.query(SubItem) - .options(subqueryload(SubItem.item)) - .get(obj_id) - ) - - -subitem = CrudSubItem(SubItem) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py index 472c3bd05b..ded4c784eb 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py @@ -1,65 +1,91 @@ +import logging from typing import List, Optional -from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session +import app.models.user as models_user +import app.schemas.user as schemas_user from app.core.security import get_password_hash, verify_password -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate - - -def get(db_session: Session, *, user_id: int) -> Optional[User]: - return db_session.query(User).filter(User.id == user_id).first() - - -def get_by_email(db_session: Session, *, email: str) -> Optional[User]: - return db_session.query(User).filter(User.email == email).first() - - -def authenticate(db_session: Session, *, email: str, password: str) -> Optional[User]: - user = get_by_email(db_session, email=email) - if not user: - return None - if not verify_password(password, user.hashed_password): - return None - return user - - -def is_active(user) -> bool: - return user.is_active - - -def is_superuser(user) -> bool: - return user.is_superuser - - -def get_multi(db_session: Session, *, skip=0, limit=100) -> List[Optional[User]]: - return db_session.query(User).offset(skip).limit(limit).all() - - -def create(db_session: Session, *, user_in: UserCreate) -> User: - user = User( - email=user_in.email, - hashed_password=get_password_hash(user_in.password), - full_name=user_in.full_name, - is_superuser=user_in.is_superuser, - ) - db_session.add(user) - db_session.commit() - db_session.refresh(user) - return user - - -def update(db_session: Session, *, user: User, user_in: UserUpdate) -> User: - user_data = jsonable_encoder(user) - update_data = user_in.dict(skip_defaults=True) - for field in user_data: - if field in update_data: - setattr(user, field, update_data[field]) - if user_in.password: - passwordhash = get_password_hash(user_in.password) - user.hashed_password = passwordhash - db_session.add(user) - db_session.commit() - db_session.refresh(user) - return user +from app.crud.base import CrudBase + + +class CrudUser(CrudBase): + """ + CrudUser provides authentication methods like `authenticate` + + Basic methods like `get` are overriden to leverage auto-completion from IDE + + It also overrides `create` and `update` in order to hash the `password` appropriately + """ + + def get(self, db_session: Session, obj_id: int + ) -> Optional[models_user.User]: + return super(CrudUser, self).get(db_session, obj_id=obj_id) + + def get_multi(self, db_session: Session, *, skip=0, limit=100 + ) -> List[Optional[models_user.User]]: + return super(CrudUser, self).get_multi(db_session, skip=skip, limit=limit) + + def get_by_email(self, db_session: Session, *, email: str + ) -> Optional[models_user.User]: + return ( + db_session.query(models_user.User) + .filter(models_user.User.email == email) + .first() + ) + + def authenticate(self, db_session: Session, *, email: str, password: str + ) -> Optional[models_user.User]: + user = self.get_by_email(db_session, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + logging.info( + f"\033[33mAuthenticated\033[0m \033[35mUser\033[0m \033[33mwith\033[0m email={email}" + ) + return user + + def create(self, db_session: Session, *, obj_in: schemas_user.UserCreate + ) -> models_user.User: + user = models_user.User( + full_name=obj_in.full_name, + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + is_active=obj_in.is_active, + is_superuser=obj_in.is_superuser, + ) + logging.info( + f"\033[33mCreating\033[0m \033[35mUser\033[0m \033[33mwith\033[0m {obj_in}" + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + def update( + self, + db_session: Session, + *, + obj: models_user.User, + obj_in: schemas_user.UserUpdate, + ) -> models_user.User: + user_data = obj.to_schema(schemas_user.UserUpdate).dict() + update_data = obj_in.dict(skip_defaults=True) + logging.info( + f"\033[33mUpdating\033[0m \033[35mUser\033[0m {user_data} with {update_data}" + ) + for field in user_data: + if field in update_data: + setattr(user, field, update_data[field]) + if obj_in.password: + passwordhash = get_password_hash(obj_in.password) + obj.hashed_password = passwordhash + db_session.add(obj) + db_session.commit() + db_session.refresh(obj) + logging.debug(f"User updated to {obj.to_schema(schemas_user.UserBaseInDB)}") + return obj + + +user = CrudUser(models_user.User, schemas_user.UserBaseInDB) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py index 90eca9c9bf..0824329ace 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py @@ -1,6 +1,7 @@ # 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.subitem import SubItem # noqa + +from app.db.base_class import Base # 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 2228a37c12..1d7b5a8131 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py @@ -1,3 +1,4 @@ +from fastapi.encoders import jsonable_encoder from sqlalchemy.ext.declarative import declarative_base, declared_attr @@ -7,5 +8,12 @@ class CustomBase(object): def __tablename__(cls): return cls.__name__.lower() + def to_schema(self, schema_cls): + return schema_cls(**self.__dict__) + + @classmethod + def from_schema(cls, schema_obj): + return cls(**jsonable_encoder(schema_obj)) + Base = declarative_base(cls=CustomBase) 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 e5c6719397..622da124bc 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py @@ -2,11 +2,6 @@ from app.core import config from app.schemas.user import UserCreate -# make sure all SQL Alchemy models are imported before initializing DB -# otherwise, SQL Alchemy might fail to initialize properly relationships -# for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 -from app.db import base - def init_db(db_session): # Tables should be created with Alembic migrations @@ -21,4 +16,4 @@ def init_db(db_session): password=config.FIRST_SUPERUSER_PASSWORD, is_superuser=True, ) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py index e69de29bb2..2b32466451 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py @@ -0,0 +1,8 @@ +# WATCH OUT +# +# load SQLalchemy classes, to avoid exception further down, such as: +# sqlalchemy.exc.InvalidRequestError: When initializing mapper mapped class User->users, +# expression 'APIKey' failed to locate a name ("name 'APIKey' is not defined"). If this is a class name, +# consider adding this relationship() to the +# class after both dependent classes have been defined. +from app.db import base # noqa diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index eadf6d60f5..18ac49d9aa 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -1,15 +1,20 @@ -from sqlalchemy import Boolean, Column, Integer, String +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String from sqlalchemy.orm import relationship from app.db.base_class import Base 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) is_active = Column(Boolean(), default=True) is_superuser = Column(Boolean(), default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) items = relationship("Item", back_populates="owner") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py index 2fe327fcb6..dacb2f9e45 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py @@ -1,40 +1,49 @@ from typing import List, Optional +from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Schema # Shared properties class UserBase(BaseModel): - email: Optional[str] = None - full_name: Optional[str] = None - is_active: Optional[bool] = True - is_superuser: Optional[bool] = False + email:str = None + full_name:str = None + is_active:bool = True + is_superuser:bool = False class Config: orm_mode = True +# Additional properties stored in DB +class UserBaseInDB(UserBase): + id: int = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + # Properties to receive via API on creation -class UserCreate(UserBase): +class UserCreate(UserBaseInDB): email: str password: str + created_at: datetime = Schema(datetime.utcnow()) # Properties to receive via API on update -class UserUpdate(UserBase): - password: Optional[str] = None +class UserUpdate(UserBaseInDB): + password: str = None + updated_at: datetime = Schema(datetime.utcnow()) -# Properties shared by models stored in DB -class UserBaseInDBBase(UserBase): - id: int = None +# Additional properties stored in DB +class UserInDB(UserBaseInDB): hashed_password: str -# Properties to return to client -class User(UserBaseInDBBase): +# Additional properties to return via API +class User(UserBaseInDB): pass -class UserExpanded(UserBaseInDBBase): +class UserExpanded(User): items: Optional[List['Item']] 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 04f3c342d1..40ffa58a10 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 @@ -4,7 +4,6 @@ from app.core import config from app.db.session import db_session from app.schemas.user import UserCreate -from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import get_server_api, random_lower_string @@ -20,6 +19,18 @@ def test_get_users_superuser_me(superuser_token_headers): assert current_user["email"] == config.FIRST_SUPERUSER +def test_get_users_normaluser_me(normaluser_token_headers): + server_api = get_server_api() + r = requests.get( + f"{server_api}{config.API_V1_STR}/users/me", headers=normaluser_token_headers + ) + current_user = r.json() + assert current_user + assert current_user["is_active"] is True + assert current_user["is_superuser"] is False + assert current_user["email"] == config.EMAIL_TEST_USER + + def test_create_user_new_email(superuser_token_headers): server_api = get_server_api() username = random_lower_string() @@ -41,7 +52,7 @@ def test_get_existing_user(superuser_token_headers): username = random_lower_string() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) user_id = user.id r = requests.get( f"{server_api}{config.API_V1_STR}/users/{user_id}", @@ -59,7 +70,7 @@ def test_create_user_existing_username(superuser_token_headers): # username = email password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) # noqa data = {"email": username, "password": password} r = requests.post( f"{server_api}{config.API_V1_STR}/users/", @@ -71,16 +82,15 @@ def test_create_user_existing_username(superuser_token_headers): assert "_id" not in created_user -def test_create_user_by_normal_user(): +def test_create_user_by_normal_user(normaluser_token_headers): server_api = get_server_api() username = random_lower_string() password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, user_in=user_in) - user_token_headers = user_authentication_headers(server_api, username, password) data = {"email": username, "password": password} r = requests.post( - f"{server_api}{config.API_V1_STR}/users/", headers=user_token_headers, json=data + f"{server_api}{config.API_V1_STR}/users/", + headers=normaluser_token_headers, + json=data, ) assert r.status_code == 400 @@ -90,12 +100,12 @@ def test_retrieve_users(superuser_token_headers): username = random_lower_string() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) username2 = random_lower_string() password2 = random_lower_string() user_in2 = UserCreate(email=username2, password=password2) - user2 = crud.user.create(db_session, user_in=user_in2) + user2 = crud.user.create(db_session, obj_in=user_in2) # noqa r = requests.get( f"{server_api}{config.API_V1_STR}/users/", headers=superuser_token_headers diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py index 0e3c044bc9..cb3a356ffd 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +from app.core import config +from app.tests.utils.user import byemail_authentication_token from app.tests.utils.utils import get_server_api, get_superuser_token_headers @@ -11,3 +13,19 @@ def server_api(): @pytest.fixture(scope="module") def superuser_token_headers(): return get_superuser_token_headers() + + +@pytest.fixture(scope="module") +def normaluser_token_headers(): + return byemail_authentication_token(config.EMAIL_TEST_USER) + + +@pytest.fixture(scope="module") +def normaluser(normaluser_token_headers): + user = crud.user.get_by_email(db_session, email=config.EMAIL_TEST_USER) + if not user: + user_in = schemas_user.UserCreate( + email=config.EMAIL_TEST_USER, password=fake.password(), city_id=51 + ) + user = crud.user.create(db_session=db_session, obj_in=user_in) + return user 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 2d9dc77b0b..772f69dd7e 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 @@ -36,7 +36,7 @@ def test_update_item(): item_in = ItemCreate(title=title, description=description, owner_id=user.id) item = crud.item.create(db_session=db_session, item_in=item_in) description2 = random_lower_string() - item_update = ItemUpdate(description=description2) + item_update = ItemUpdate(description=description2, owner=user) item2 = crud.item.update( db_session=db_session, item=item, item_in=item_update ) 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 da93784601..84ba72a96d 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,8 +1,7 @@ -from fastapi.encoders import jsonable_encoder - from app import crud from app.db.session import db_session -from app.schemas.user import UserCreate +from app.schemas.user import User, UserCreate +from app.tests.utils.user import create_random_user from app.tests.utils.utils import random_lower_string @@ -10,7 +9,7 @@ def test_create_user(): email = random_lower_string() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) assert user.email == email assert hasattr(user, "hashed_password") @@ -19,7 +18,7 @@ def test_authenticate_user(): email = random_lower_string() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, user_in=user_in) + user = crud.user.create(db_session, obj_in=user_in) authenticated_user = crud.user.authenticate( db_session, email=email, password=password ) @@ -35,49 +34,34 @@ def test_not_authenticate_user(): def test_check_if_user_is_active(): - email = random_lower_string() - password = random_lower_string() - user_in = UserCreate(email=email, password=password) - user = crud.user.create(db_session, user_in=user_in) - is_active = crud.user.is_active(user) - assert is_active is True + user = create_random_user() + assert user.is_active + + +def test_check_if_user_is_superuser_normal_user(): + user = create_random_user() + assert not user.is_superuser def test_check_if_user_is_active_inactive(): email = random_lower_string() password = random_lower_string() - user_in = UserCreate(email=email, password=password, disabled=True) - print(user_in) - user = crud.user.create(db_session, user_in=user_in) - print(user) - is_active = crud.user.is_active(user) - print(is_active) - assert is_active + user_in = UserCreate(email=email, password=password, is_active=False) + user = crud.user.create(db_session, obj_in=user_in) + assert not user.is_active + assert not user.is_active def test_check_if_user_is_superuser(): email = random_lower_string() password = random_lower_string() user_in = UserCreate(email=email, password=password, is_superuser=True) - user = crud.user.create(db_session, user_in=user_in) - is_superuser = crud.user.is_superuser(user) - assert is_superuser is True - - -def test_check_if_user_is_superuser_normal_user(): - username = random_lower_string() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, user_in=user_in) - is_superuser = crud.user.is_superuser(user) - assert is_superuser is False + user = crud.user.create(db_session, obj_in=user_in) + assert user.is_superuser def test_get_user(): - password = random_lower_string() - username = random_lower_string() - user_in = UserCreate(email=username, password=password, is_superuser=True) - user = crud.user.create(db_session, user_in=user_in) - user_2 = crud.user.get(db_session, user_id=user.id) + user = create_random_user() + user_2 = crud.user.get(db_session, obj_id=user.id) assert user.email == user_2.email - assert jsonable_encoder(user) == jsonable_encoder(user_2) + assert user.to_schema(User) == user_2.to_schema(User) 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 f6c8f0e23c..6af59dc454 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py @@ -3,8 +3,16 @@ from app import crud from app.core import config from app.db.session import db_session -from app.schemas.user import UserCreate -from app.tests.utils.utils import random_lower_string +from app.schemas.user import UserCreate, UserUpdate +from app.tests.utils.utils import random_lower_string, get_server_api + + +def create_random_user(): + email = random_lower_string() + 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) + return user def user_authentication_headers(server_api, email, password): @@ -17,9 +25,19 @@ def user_authentication_headers(server_api, email, password): return headers -def create_random_user(): - email = random_lower_string() +def byemail_authentication_token(email): + """ + Return a valid token for the user with given email, eventhough the user existed in the first place or not. + + The function generated the User if necessary, and update it with a fresh password otherwise. That allows to use again and again the same user during the test (instead of creating a new one every time), without storing any password in the code. + """ # noqa password = random_lower_string() - user_in = UserCreate(username=email, email=email, password=password) - user = crud.user.create(db_session=db_session, user_in=user_in) - return user + user = crud.user.get_by_email(db_session, email=email) + if not user: + user_in = UserCreate(email=email, password=password, city_id=51) + user = crud.user.create(db_session=db_session, obj_in=user_in) + else: + user_in = UserUpdate(password=password) + user = crud.user.update(db_session, obj=user, obj_in=user_in) + + return user_authentication_headers(get_server_api(), email, password) diff --git a/{{cookiecutter.project_slug}}/scripts/test.sh b/{{cookiecutter.project_slug}}/scripts/test.sh index 15f5139759..5491a4009a 100644 --- a/{{cookiecutter.project_slug}}/scripts/test.sh +++ b/{{cookiecutter.project_slug}}/scripts/test.sh @@ -9,6 +9,7 @@ docker-compose \ -f docker-compose.shared.env.yml \ -f docker-compose.shared.depends.yml \ -f docker-compose.deploy.build.yml \ +-f docker-compose.dev.volumes.yml \ -f docker-compose.test.yml \ config > docker-stack.yml From fa7adb95a074516cd26079d5cc669e8a81cddacb Mon Sep 17 00:00:00 2001 From: ebreton Date: Thu, 19 Sep 2019 23:05:15 +0200 Subject: [PATCH 14/26] Rename test-backend.sh to test-again.sh, improve doc --- test-backend.sh => test-again.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename test-backend.sh => test-again.sh (79%) diff --git a/test-backend.sh b/test-again.sh similarity index 79% rename from test-backend.sh rename to test-again.sh index 09b1adc883..ceb5969f28 100644 --- a/test-backend.sh +++ b/test-again.sh @@ -1,6 +1,8 @@ #! /usr/bin/env bash -# pre-requisite: use test.sh script at least once before using this script +# pre-requisite: +# - container for training-project running +# - which means run test.sh script at least once before using this script # this script produces the same result as test.sh, but faster # because it rsyncs the latest modification into testing-project From efa4d85c30e53ad5c0e7a8f0e0a0c98bc7b45ab6 Mon Sep 17 00:00:00 2001 From: ebreton Date: Wed, 4 Dec 2019 21:52:26 +0100 Subject: [PATCH 15/26] Fix typo and missing argument in CrudBase docstring --- .../app/app/api/api_v1/endpoints/users.py | 2 +- .../backend/app/app/crud/base.py | 19 ++++++++++--------- .../backend/app/app/crud/user.py | 6 +++--- 3 files changed, 14 insertions(+), 13 deletions(-) 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 f9a63a7884..aee876cbdc 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 @@ -74,7 +74,7 @@ def create_user_open( if not config.USERS_OPEN_REGISTRATION: raise HTTPException( status_code=403, - detail="Open user resgistration is forbidden on this server", + detail="Open user registration is forbidden on this server", ) user = crud.user.get_by_email(db, email=email) if user: diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index cbe1056941..c57c89fbde 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -15,7 +15,7 @@ def __init__(self, model: Base, schema: BaseModel): CrudBase instances are used to provide the basic CRUD methods for a given object type (get, get_multi, update, create and delete). In order to use it, follow this steps when you define a new DB model: - - create a class that inherites from CrudBase + - create a class that inherits from CrudBase - override basic methods with proper types in order to get better completion in your IDE - create an instance of your newly created class, providing the DB model as an argument @@ -62,6 +62,7 @@ def update(self, db_session: Session, *, obj: models_item.Item, obj_in: schemas_ Arguments: model {Base} -- Class of the DB model which CRUD methods will be provided for + schema {BaseModel} -- Class of the schema of the entity, used during the update process. """ # noqa self.model = model self.schema = schema @@ -87,7 +88,7 @@ def get_first_by(self, db_session: Session, **kwargs: Any) -> Optional[Base]: db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. Keyword Arguments: - kwargs {dict} -- filters formated as {attribute_name: attribute_value} + kwargs {dict} -- filters formatted as {attribute_name: attribute_value} Returns: Optional[Base] -- Returns an instance of self.model class if an object is found in the Database. Returns None if there is no match found. @@ -106,7 +107,7 @@ def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[ limit {int} -- Maximum number of rows to return (default: {100}) Returns: - List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. + List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objects are found. """ # noqa return db_session.query(self.model).offset(skip).limit(limit).all() @@ -120,10 +121,10 @@ def get_multi_by(self, db_session: Session, *, skip=0, limit=100, **kwargs: Any) Keyword Arguments: skip {int} -- Number of rows to skip from the results (default: {0}) limit {int} -- Maximum number of rows to return (default: {100}) - kwargs {dict} -- filters formated as {attribute_name: attribute_value} + kwargs {dict} -- filters formatted as {attribute_name: attribute_value} Returns: - List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found. + List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objects are found. """ # noqa return db_session.query(self.model).filter_by(**kwargs).offset(skip).limit(limit).all() @@ -141,7 +142,7 @@ def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: obj = self.model.from_schema(obj_in) logging.info( f"\033[33mCreating\033[0m \033[35m{obj.__class__.__name__}\033[0m" - f" \033[33mwith\033[0m {obj_in}" + f"\033[33m with\033[0m {obj_in}" ) db_session.add(obj) db_session.commit() @@ -171,7 +172,7 @@ def update( } logging.info( f"\033[33mUpdating\033[0m \033[35m{obj.__class__.__name__}\033[0m={obj_data}" - f" \033[33mwith\033[0m {formatted_data}" + f"\033[33m with\033[0m {formatted_data}" ) for field, value in formatted_data.items(): @@ -195,7 +196,7 @@ def delete(self, db_session: Session, obj_id: int) -> int: """ # noqa logging.info( f"\033[31mDeleting\033[0m \033[35m{self.model.__name__}\033[0m" - f" \033[33mwith\033[0m id={obj_id}" + f"\033[33m with\033[0m id={obj_id}" ) queried = db_session.query(self.model).filter(self.model.id == obj_id) counted = queried.count() @@ -209,7 +210,7 @@ def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]: remove does the same job as delete, with a different return value Returns: - deleted object, if the deletion was successfull + deleted object, if the deletion was successful None if the object was already deleted from the Database """ # noqa obj = db_session.query(self.model).get(obj_id) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py index ded4c784eb..98f0b429d9 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py @@ -13,7 +13,7 @@ class CrudUser(CrudBase): """ CrudUser provides authentication methods like `authenticate` - Basic methods like `get` are overriden to leverage auto-completion from IDE + Basic methods like `get` are overridden to leverage auto-completion from IDE It also overrides `create` and `update` in order to hash the `password` appropriately """ @@ -42,7 +42,7 @@ def authenticate(self, db_session: Session, *, email: str, password: str if not verify_password(password, user.hashed_password): return None logging.info( - f"\033[33mAuthenticated\033[0m \033[35mUser\033[0m \033[33mwith\033[0m email={email}" + f"\033[33mAuthenticated\033[0m \033[35mUser\033[0m\033[33m with\033[0m email={email}" ) return user @@ -56,7 +56,7 @@ def create(self, db_session: Session, *, obj_in: schemas_user.UserCreate is_superuser=obj_in.is_superuser, ) logging.info( - f"\033[33mCreating\033[0m \033[35mUser\033[0m \033[33mwith\033[0m {obj_in}" + f"\033[33mCreating\033[0m \033[35mUser\033[0m\033[33m with\033[0m {obj_in}" ) db_session.add(user) db_session.commit() From 92ad76cf1dd52e05dad8e66a656f2f939c5be243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 21:57:44 +0100 Subject: [PATCH 16/26] :wrench: Update testing scripts --- {{cookiecutter.project_slug}}/scripts/test-local.sh | 2 +- {{cookiecutter.project_slug}}/scripts/test.sh | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/{{cookiecutter.project_slug}}/scripts/test-local.sh b/{{cookiecutter.project_slug}}/scripts/test-local.sh index baccd71242..221d15a12f 100644 --- a/{{cookiecutter.project_slug}}/scripts/test-local.sh +++ b/{{cookiecutter.project_slug}}/scripts/test-local.sh @@ -27,4 +27,4 @@ docker-compose \ docker-compose -f docker-stack.yml build docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error docker-compose -f docker-stack.yml up -d -docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh +docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@" diff --git a/{{cookiecutter.project_slug}}/scripts/test.sh b/{{cookiecutter.project_slug}}/scripts/test.sh index 5491a4009a..c226f73891 100644 --- a/{{cookiecutter.project_slug}}/scripts/test.sh +++ b/{{cookiecutter.project_slug}}/scripts/test.sh @@ -9,12 +9,11 @@ docker-compose \ -f docker-compose.shared.env.yml \ -f docker-compose.shared.depends.yml \ -f docker-compose.deploy.build.yml \ --f docker-compose.dev.volumes.yml \ -f docker-compose.test.yml \ config > docker-stack.yml docker-compose -f docker-stack.yml build docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error docker-compose -f docker-stack.yml up -d -docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh $* +docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@" docker-compose -f docker-stack.yml down -v --remove-orphans From 470661f44733580f87cd1bb41360962e00f6892c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 21:58:44 +0100 Subject: [PATCH 17/26] :recycle: Refactor CRUD utils to use generics and types --- .../backend/app/app/crud/__init__.py | 15 +- .../backend/app/app/crud/base.py | 226 +++--------------- .../backend/app/app/crud/crud_item.py | 34 +++ .../backend/app/app/crud/crud_user.py | 44 ++++ .../backend/app/app/crud/item.py | 38 --- .../backend/app/app/crud/user.py | 91 ------- 6 files changed, 117 insertions(+), 331 deletions(-) create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py create mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/item.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/crud/user.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py index b1466b4e99..20491baed7 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py @@ -1,11 +1,10 @@ -from .user import user -from .item import item +from .crud_user import user +from .crud_item import item -# For a new basic set of CRUD operations, on a new object, let's say 'SubItem', -# you could also simply add the following lines: +# For a new basic set of CRUD operations you could just do -from .base import CrudBase -from app.models import subitem as models_subitem -from app.schemas import subitem as schemas_subitem +# from .base import CRUDBase +# from app.models.item import Item +# from app.schemas.item import ItemCreate, ItemUpdate -subitem = CrudBase(models_subitem.SubItem, schemas_subitem.SubItem) +# item = CRUDBase[Item, ItemCreate, ItemUpdate](Item) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py index c57c89fbde..720a9a9d38 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py @@ -1,219 +1,57 @@ -import logging -from typing import List, Optional, Any +from typing import List, Optional, Generic, TypeVar, Type from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel from sqlalchemy.orm import Session from app.db.base_class import Base -from pydantic import BaseModel +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) -class CrudBase: - def __init__(self, model: Base, schema: BaseModel): +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): """ - CrudBase instances are used to provide the basic CRUD methods for a given object type (get, get_multi, update, create and delete). - - In order to use it, follow this steps when you define a new DB model: - - create a class that inherits from CrudBase - - override basic methods with proper types in order to get better completion in your IDE - - create an instance of your newly created class, providing the DB model as an argument - - E.g.: - - # model definition in app/schemas/item.py - class ItemCreate(...) - ... - - class ItemUpdate(...) - ... - - # model definition in app/models/item.py - class Item(Base): - id: int - ... - - # crud definition in app/crud/item.py - from typing import List, Optional - from sqlalchemy.orm import Session - - import app.models.item as models_item - import app.schemas.item as schemas_item - - from app.crud.base import CrudBase + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + **Parameters** - class CrudItem(CrudBase): - - def get(self, db_session: Session, obj_id: int) -> Optional[models_item.Item]: - return super(CrudItem, self).get(db_session, obj_id=obj_id) - - def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[models_item.Item]]: - return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) - - def create(self, db_session: Session, *, obj_in: schemas_item.ItemCreate) -> models_item.Item: - return super(CrudItem, self).create(db_session, obj_in=obj_in) - - def update(self, db_session: Session, *, obj: models_item.Item, obj_in: schemas_item.ItemUpdate) -> models_item.Item: - return super(CrudItem, self).update(db_session, obj=obj, obj_in=obj_in) - - - crud_item = CrudItem(models_item.Item, schemas_item.Item) - - Arguments: - model {Base} -- Class of the DB model which CRUD methods will be provided for - schema {BaseModel} -- Class of the schema of the entity, used during the update process. - """ # noqa - self.model = model - self.schema = schema - - def get(self, db_session: Session, obj_id: int) -> Optional[Base]: + * `model`: A SQLAlchemy model class + * `schema`: A Pydantic model (schema) class """ - get returns the object from the Database that matches the given obj_id - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - obj_id {int} -- ID of the object in the Database. It must be defined by a PrimaryKey on the 'id' column. - - Returns: - Optional[Base] -- Returns an instance of self.model class if an object is found in the Database for the given obj_id. Returns None if there is no match found. - """ # noqa - return db_session.query(self.model).filter(self.model.id == obj_id).first() + self.model = model - def get_first_by(self, db_session: Session, **kwargs: Any) -> Optional[Base]: - """ - get_by provides extended filtering capabilities: it returns the first object that matches all given **kwargs - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - - Keyword Arguments: - kwargs {dict} -- filters formatted as {attribute_name: attribute_value} - - Returns: - Optional[Base] -- Returns an instance of self.model class if an object is found in the Database. Returns None if there is no match found. - """ # noqa - return db_session.query(self.model).filter_by(**kwargs).first() + def get(self, db_session: Session, id: int) -> Optional[ModelType]: + return db_session.query(self.model).filter(self.model.id == id).first() - def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Base]]: - """ - get_multi queries all Database rows, without any filters, but with offset and limit options (for pagination purpose) - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - - Keyword Arguments: - skip {int} -- Number of rows to skip from the results (default: {0}) - limit {int} -- Maximum number of rows to return (default: {100}) - - Returns: - List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objects are found. - """ # noqa + 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_by(self, db_session: Session, *, skip=0, limit=100, **kwargs: Any) -> List[Optional[Base]]: - """ - get_multi_by behaves like get_by but returns all filtered objects with the same pagination behavior as in get_multi - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - - Keyword Arguments: - skip {int} -- Number of rows to skip from the results (default: {0}) - limit {int} -- Maximum number of rows to return (default: {100}) - kwargs {dict} -- filters formatted as {attribute_name: attribute_value} - - Returns: - List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objects are found. - """ # noqa - return db_session.query(self.model).filter_by(**kwargs).offset(skip).limit(limit).all() - - def create(self, db_session: Session, *, obj_in: BaseModel) -> Base: - """ - create adds a new row in the Database in the table defined by self.model. The column values are populated from the 'obj_in' pydantic object - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - obj_in {BaseModel} -- A pydantic object that contains all mandatory values needed to create the Database row. - - Returns: - Base -- The object inserted in the Database - """ # noqa - obj = self.model.from_schema(obj_in) - logging.info( - f"\033[33mCreating\033[0m \033[35m{obj.__class__.__name__}\033[0m" - f"\033[33m with\033[0m {obj_in}" - ) - db_session.add(obj) + def create(self, db_session: 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(obj) - return obj + db_session.refresh(db_obj) + return db_obj def update( - self, db_session: Session, *, obj: Base, obj_in: BaseModel - ) -> Base: - """ - update modifies an existing row (fetched from given obj) in the Database with values from given obj_in - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - obj {Base} -- A DB instance of the object to update - obj_in {BaseModel} -- A pydantic object that contains all values to update. - - Returns: - Base -- The updated DB object, with all its attributes - """ # noqa - obj_data = obj.to_schema(self.schema).dict(skip_defaults=True) + self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType + ) -> ModelType: + obj_data = jsonable_encoder(db_obj) update_data = obj_in.dict(skip_defaults=True) - formatted_data = { - key: value - for key, value in jsonable_encoder(obj_in).items() - if key in update_data and key in obj_data - } - logging.info( - f"\033[33mUpdating\033[0m \033[35m{obj.__class__.__name__}\033[0m={obj_data}" - f"\033[33m with\033[0m {formatted_data}" - ) - - for field, value in formatted_data.items(): - setattr(obj, field, value) - - db_session.add(obj) + 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(obj) - return obj + db_session.refresh(db_obj) + return db_obj - def delete(self, db_session: Session, obj_id: int) -> int: - """ - delete removes the row from the database with the obj_id ID - - Arguments: - db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes. - obj_id {int} -- ID of the row to remove from the Database. It must be defined by a PrimaryKey on the 'id' column. - - Returns: - int -- number of rows deleted, i.e. 1 if the object has been found and deleted, 0 otherwise - """ # noqa - logging.info( - f"\033[31mDeleting\033[0m \033[35m{self.model.__name__}\033[0m" - f"\033[33m with\033[0m id={obj_id}" - ) - queried = db_session.query(self.model).filter(self.model.id == obj_id) - counted = queried.count() - if counted > 0: - queried.delete() - db_session.commit() - return counted - - def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]: - """ - remove does the same job as delete, with a different return value - - Returns: - deleted object, if the deletion was successful - None if the object was already deleted from the Database - """ # noqa - obj = db_session.query(self.model).get(obj_id) + def remove(self, db_session: Session, *, id: int) -> ModelType: + obj = db_session.query(self.model).get(id) db_session.delete(obj) db_session.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 new file mode 100644 index 0000000000..16db7bf999 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py @@ -0,0 +1,34 @@ +from typing import List + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +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 + ) -> 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) + return db_obj + + def get_multi_by_owner( + self, db_session: Session, *, owner_id: int, skip=0, limit=100 + ) -> List[Item]: + return ( + db_session.query(self.model) + .filter(Item.owner_id == owner_id) + .offset(skip) + .limit(limit) + .all() + ) + + +item = CRUDItem(Item) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py new file mode 100644 index 0000000000..e5d9d55afc --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py @@ -0,0 +1,44 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate +from app.core.security import verify_password, get_password_hash +from app.crud.base import CRUDBase + + +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 create(self, db_session: 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) + return db_obj + + def authenticate( + self, db_session: Session, *, email: str, password: str + ) -> Optional[User]: + user = self.get_by_email(db_session, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + return user.is_active + + def is_superuser(self, user: User) -> bool: + return user.is_superuser + + +user = CRUDUser(User) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py deleted file mode 100644 index b460bbc18c..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/item.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import List, Optional - -from fastapi.encoders import jsonable_encoder -from sqlalchemy.orm import Session - -from app.models import item as models_item -from app.schemas import item as schemas_item -from app.crud.base import CrudBase - - -class CrudItem(CrudBase): - """ - This is provided as a showcase of which methods to override, with the benefit to adjusting - both the types of the arguments and of the returned objects to the proper 'Item*' classes - """ - - def get(self, db_session: Session, id: int - ) -> Optional[models_item.Item]: - return super(CrudItem, self).get(db_session, obj_id=id) - - def get_multi(self, db_session: Session, *, skip=0, limit=100 - ) -> List[Optional[models_item.Item]]: - return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit) - - def get_multi_by_owner(db_session: Session, *, owner_id: int, skip=0, limit=100 - ) -> List[Optional[models_item.Item]]: - return self.get_multi_by(db_session, owner_id=owner_id, skip=skip, limit=limit) - - def create(self, db_session: Session, *, item_in: schemas_item.ItemCreate - ) -> models_item.Item: - return super(CrudItem, self).create(db_session, obj_in=item_in) - - def update(self, db_session: Session, *, item: models_item.Item, item_in: schemas_item.ItemUpdate - ) -> models_item.Item: - return super(CrudItem, self).update(db_session, obj=item, obj_in=item_in) - - -item = CrudItem(models_item.Item, schemas_item.ItemInDBBase) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py b/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py deleted file mode 100644 index 98f0b429d9..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/crud/user.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging -from typing import List, Optional - -from sqlalchemy.orm import Session - -import app.models.user as models_user -import app.schemas.user as schemas_user -from app.core.security import get_password_hash, verify_password -from app.crud.base import CrudBase - - -class CrudUser(CrudBase): - """ - CrudUser provides authentication methods like `authenticate` - - Basic methods like `get` are overridden to leverage auto-completion from IDE - - It also overrides `create` and `update` in order to hash the `password` appropriately - """ - - def get(self, db_session: Session, obj_id: int - ) -> Optional[models_user.User]: - return super(CrudUser, self).get(db_session, obj_id=obj_id) - - def get_multi(self, db_session: Session, *, skip=0, limit=100 - ) -> List[Optional[models_user.User]]: - return super(CrudUser, self).get_multi(db_session, skip=skip, limit=limit) - - def get_by_email(self, db_session: Session, *, email: str - ) -> Optional[models_user.User]: - return ( - db_session.query(models_user.User) - .filter(models_user.User.email == email) - .first() - ) - - def authenticate(self, db_session: Session, *, email: str, password: str - ) -> Optional[models_user.User]: - user = self.get_by_email(db_session, email=email) - if not user: - return None - if not verify_password(password, user.hashed_password): - return None - logging.info( - f"\033[33mAuthenticated\033[0m \033[35mUser\033[0m\033[33m with\033[0m email={email}" - ) - return user - - def create(self, db_session: Session, *, obj_in: schemas_user.UserCreate - ) -> models_user.User: - user = models_user.User( - full_name=obj_in.full_name, - email=obj_in.email, - hashed_password=get_password_hash(obj_in.password), - is_active=obj_in.is_active, - is_superuser=obj_in.is_superuser, - ) - logging.info( - f"\033[33mCreating\033[0m \033[35mUser\033[0m\033[33m with\033[0m {obj_in}" - ) - db_session.add(user) - db_session.commit() - db_session.refresh(user) - return user - - def update( - self, - db_session: Session, - *, - obj: models_user.User, - obj_in: schemas_user.UserUpdate, - ) -> models_user.User: - user_data = obj.to_schema(schemas_user.UserUpdate).dict() - update_data = obj_in.dict(skip_defaults=True) - logging.info( - f"\033[33mUpdating\033[0m \033[35mUser\033[0m {user_data} with {update_data}" - ) - for field in user_data: - if field in update_data: - setattr(user, field, update_data[field]) - if obj_in.password: - passwordhash = get_password_hash(obj_in.password) - obj.hashed_password = passwordhash - db_session.add(obj) - db_session.commit() - db_session.refresh(obj) - logging.debug(f"User updated to {obj.to_schema(schemas_user.UserBaseInDB)}") - return obj - - -user = CrudUser(models_user.User, schemas_user.UserBaseInDB) From 359581f2db3e4f65aaac561cd86e2691cd8d530a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 21:59:46 +0100 Subject: [PATCH 18/26] :rewind: Revert model changes, to have the minimum changes --- .../backend/app/app/models/__init__.py | 8 -------- .../backend/app/app/models/item.py | 2 -- .../backend/app/app/models/subitem.py | 13 ------------- .../backend/app/app/models/user.py | 8 +------- 4 files changed, 1 insertion(+), 30 deletions(-) mode change 100755 => 100644 {{cookiecutter.project_slug}}/backend/app/app/models/__init__.py delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/models/subitem.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py old mode 100755 new mode 100644 index 2b32466451..e69de29bb2 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py @@ -1,8 +0,0 @@ -# WATCH OUT -# -# load SQLalchemy classes, to avoid exception further down, such as: -# sqlalchemy.exc.InvalidRequestError: When initializing mapper mapped class User->users, -# expression 'APIKey' failed to locate a name ("name 'APIKey' is not defined"). If this is a class name, -# consider adding this relationship() to the -# class after both dependent classes have been defined. -from app.db import base # noqa diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py index 6037ee9ada..685687a098 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/item.py @@ -8,7 +8,5 @@ class Item(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) description = Column(String, index=True) - owner_id = Column(Integer, ForeignKey("user.id")) owner = relationship("User", back_populates="items") - subitems = relationship("SubItem", back_populates="item") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/models/subitem.py deleted file mode 100644 index 40ec9728df..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/subitem.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlalchemy import Column, ForeignKey, Integer, String -from sqlalchemy.orm import relationship - -from app.db.base_class import Base - - -class SubItem(Base): - id = Column(Integer, primary_key=True, index=True) - title = Column(String, index=True) - description = Column(String, index=True) - - item_id = Column(Integer, ForeignKey("item.id")) - item = relationship("Item", back_populates="subitems") diff --git a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py index 18ac49d9aa..1052908a4b 100755 --- a/{{cookiecutter.project_slug}}/backend/app/app/models/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/models/user.py @@ -1,20 +1,14 @@ -from datetime import datetime - -from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy.orm import relationship from app.db.base_class import Base 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) is_active = Column(Boolean(), default=True) is_superuser = Column(Boolean(), default=False) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow) - items = relationship("Item", back_populates="owner") From 4d6de8cfaf10c0b3326d01b414a10a61900c907a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:00:49 +0100 Subject: [PATCH 19/26] :rewind: Revert DB base and changes, separate CRUD from DB models --- {{cookiecutter.project_slug}}/backend/app/app/db/base.py | 4 +--- .../backend/app/app/db/base_class.py | 8 -------- .../backend/app/app/db/init_db.py | 5 +++++ 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py index 0824329ace..81c92fda15 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base.py @@ -1,7 +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.subitem import SubItem # noqa - -from app.db.base_class import Base # 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 1d7b5a8131..2228a37c12 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py @@ -1,4 +1,3 @@ -from fastapi.encoders import jsonable_encoder from sqlalchemy.ext.declarative import declarative_base, declared_attr @@ -8,12 +7,5 @@ class CustomBase(object): def __tablename__(cls): return cls.__name__.lower() - def to_schema(self, schema_cls): - return schema_cls(**self.__dict__) - - @classmethod - def from_schema(cls, schema_obj): - return cls(**jsonable_encoder(schema_obj)) - Base = declarative_base(cls=CustomBase) 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 622da124bc..243a5817df 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py @@ -2,6 +2,11 @@ from app.core import config from app.schemas.user import UserCreate +# make sure all SQL Alchemy models are imported before initializing DB +# otherwise, SQL Alchemy might fail to initialize properly relationships +# for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 +from app.db import base + def init_db(db_session): # Tables should be created with Alembic migrations From cc2a76991ef1ca7770d235f414ede1fc03987889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:02:24 +0100 Subject: [PATCH 20/26] :rewind: Revert changes in code line order --- .../versions/d4867f3a4c0a_first_revision.py | 45 ++++++------------- .../backend/app/app/core/config.py | 4 +- 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py index c863cb3fb8..68b3ee4f1c 100644 --- a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py @@ -19,52 +19,33 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('full_name', sa.String(), nullable=True), - sa.Column('email', sa.String(), nullable=True), - sa.Column('hashed_password', sa.String(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('is_superuser', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=True), + sa.Column('hashed_password', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) - op.create_table('item', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(), nullable=True), - sa.Column('description', sa.String(), nullable=True), - sa.Column('owner_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_item_description'), 'item', ['description'], unique=False) op.create_index(op.f('ix_item_id'), 'item', ['id'], unique=False) op.create_index(op.f('ix_item_title'), 'item', ['title'], unique=False) - - op.create_table('subitem', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(), nullable=True), - sa.Column('description', sa.String(), nullable=True), - sa.Column('item_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_subitem_description'), 'subitem', ['description'], unique=False) - op.create_index(op.f('ix_subitem_id'), 'subitem', ['id'], unique=False) - op.create_index(op.f('ix_subitem_title'), 'subitem', ['title'], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_subitem_title'), table_name='subitem') - op.drop_index(op.f('ix_subitem_id'), table_name='subitem') - op.drop_index(op.f('ix_subitem_description'), table_name='subitem') - op.drop_table('subitem') op.drop_index(op.f('ix_item_title'), table_name='item') op.drop_index(op.f('ix_item_id'), table_name='item') op.drop_index(op.f('ix_item_description'), table_name='item') diff --git a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py index a1c5142b79..e413c5eaa0 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/core/config.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/core/config.py @@ -50,6 +50,6 @@ def getenv_boolean(var_name, default_value=False): FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER") FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD") -EMAIL_TEST_USER = "test@test.com" - USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION") + +EMAIL_TEST_USER = "test@example.com" From f4f7d71cae18c7b1f332564898bf5bfbac5c8046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:03:08 +0100 Subject: [PATCH 21/26] :recycle: Refactor Pydantic models, revert changes not related to the PR, add orm_mode to the correct model --- .../backend/app/app/schemas/item.py | 23 ++++++------ .../backend/app/app/schemas/subitem.py | 34 ------------------ .../backend/app/app/schemas/user.py | 36 +++++++------------ 3 files changed, 24 insertions(+), 69 deletions(-) delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py index f79c904d5d..dc4e382658 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py @@ -1,4 +1,3 @@ -from typing import List, Optional from pydantic import BaseModel from .user import User @@ -7,19 +6,14 @@ class ItemBase(BaseModel): title: str = None description: str = None - owner_id: int = None - - class Config: - orm_mode = True -# Mandatory properties for item creation +# Properties to receive on item creation class ItemCreate(ItemBase): title: str - owner_id: int -# Specific properties to receive on item update +# Properties to receive on item update class ItemUpdate(ItemBase): pass @@ -27,13 +21,18 @@ class ItemUpdate(ItemBase): # Properties shared by models stored in DB class ItemInDBBase(ItemBase): id: int + title: str + owner_id: int + + class Config: + orm_mode = True # Properties to return to client class Item(ItemInDBBase): - owner: User + pass -class ItemExpanded(ItemInDBBase): - owner: User - subitems: Optional[List['SubItem']] +# Properties properties stored in DB +class ItemInDB(ItemInDBBase): + pass diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py deleted file mode 100644 index 45c5efd9a2..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/subitem.py +++ /dev/null @@ -1,34 +0,0 @@ -from pydantic import BaseModel - -from .item import Item - - -# Shared properties -class SubItemBase(BaseModel): - title: str = None - description: str = None - item_id: int = None - - class Config: - orm_mode = True - - -# Mandatory properties for item creation -class SubItemCreate(SubItemBase): - title: str - item_id: int - - -# Specific properties to receive on item update -class SubItemUpdate(SubItemBase): - pass - - -# Properties shared by models stored in DB -class SubItemInDBBase(SubItemBase): - id: int - - -# Properties to return to client -class SubItem(SubItemInDBBase): - item: Item diff --git a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py index dacb2f9e45..ed776c53ad 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py @@ -1,43 +1,32 @@ -from typing import List, Optional -from datetime import datetime +from typing import Optional -from pydantic import BaseModel, Schema +from pydantic import BaseModel # Shared properties class UserBase(BaseModel): - email:str = None - full_name:str = None - is_active:bool = True - is_superuser:bool = False + email: Optional[str] = None + is_active: Optional[bool] = True + is_superuser: Optional[bool] = False + full_name: Optional[str] = None - class Config: - orm_mode = True - -# Additional properties stored in DB class UserBaseInDB(UserBase): id: int = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + + class Config: + orm_mode = True # Properties to receive via API on creation class UserCreate(UserBaseInDB): email: str password: str - created_at: datetime = Schema(datetime.utcnow()) # Properties to receive via API on update class UserUpdate(UserBaseInDB): - password: str = None - updated_at: datetime = Schema(datetime.utcnow()) - - -# Additional properties stored in DB -class UserInDB(UserBaseInDB): - hashed_password: str + password: Optional[str] = None # Additional properties to return via API @@ -45,5 +34,6 @@ class User(UserBaseInDB): pass -class UserExpanded(User): - items: Optional[List['Item']] +# Additional properties stored in DB +class UserInDB(UserBaseInDB): + hashed_password: str From f7615ddca8796209f3988cb54909a44704e125fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:04:46 +0100 Subject: [PATCH 22/26] :sparkles: Use new CRUD utils, revert changes not related to PR --- .../app/app/api/api_v1/endpoints/items.py | 15 ++- .../app/app/api/api_v1/endpoints/login.py | 4 +- .../app/app/api/api_v1/endpoints/users.py | 124 +++++++++--------- .../app/app/api/api_v1/endpoints/utils.py | 7 +- 4 files changed, 76 insertions(+), 74 deletions(-) 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 78a42c7e5f..f4db9d99ce 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 @@ -22,7 +22,7 @@ def read_items( """ Retrieve items. """ - if current_user.is_superuser: + if crud.user.is_superuser(current_user): items = crud.item.get_multi(db, skip=skip, limit=limit) else: items = crud.item.get_multi_by_owner( @@ -41,8 +41,9 @@ def create_item( """ Create new item. """ - item_in.owner_id = current_user.id - item = crud.item.create(db_session=db, item_in=item_in) + item = crud.item.create_with_owner( + db_session=db, obj_in=item_in, owner_id=current_user.id + ) return item @@ -60,9 +61,9 @@ def update_item( item = crud.item.get(db_session=db, id=id) if not item: raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + 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, item=item, item_in=item_in) + item = crud.item.update(db_session=db, db_obj=item, obj_in=item_in) return item @@ -79,7 +80,7 @@ def read_user_me( item = crud.item.get(db_session=db, id=id) if not item: raise HTTPException(status_code=400, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") return item @@ -97,7 +98,7 @@ def delete_item( item = crud.item.get(db_session=db, id=id) if not item: raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + 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) 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 ce5e5859f8..b2c9bffe2c 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 @@ -35,7 +35,7 @@ def login_access_token( ) if not user: raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not user.is_active: + elif not crud.user.is_active(user): raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES) return { @@ -87,7 +87,7 @@ def reset_password(token: str = Body(...), new_password: str = Body(...), db: Se status_code=404, detail="The user with this username does not exist in the system.", ) - elif not user.is_active: + elif not crud.user.is_active(user): raise HTTPException(status_code=400, detail="Inactive user") hashed_password = get_password_hash(new_password) user.hashed_password = hashed_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 aee876cbdc..941bab669d 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 @@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.encoders import jsonable_encoder -from pydantic.types import EmailStr +from pydantic.networks import EmailStr from sqlalchemy.orm import Session from app import crud @@ -16,36 +16,6 @@ router = APIRouter() -@router.get("/me", response_model=User) -def read_user_me( - db: Session = Depends(get_db), - current_user: DBUser = Depends(get_current_active_user), -): - """ - Get current user. - """ - return current_user - - -@router.get("/{user_id}", response_model=User) -def read_user_by_id( - user_id: int, - current_user: DBUser = Depends(get_current_active_user), - db: Session = Depends(get_db), -): - """ - Get a specific user by id. - """ - user = crud.user.get(db, obj_id=user_id) - if user == current_user: - return user - if not current_user.is_superuser: - raise HTTPException( - status_code=400, detail="The user doesn't have enough privileges" - ) - return user - - @router.get("/", response_model=List[User]) def read_users( db: Session = Depends(get_db), @@ -60,33 +30,6 @@ def read_users( return users -@router.post("/open", response_model=User) -def create_user_open( - *, - db: Session = Depends(get_db), - password: str = Body(...), - email: EmailStr = Body(...), - full_name: str = Body(None), -): - """ - Create new user without the need to be logged in. - """ - if not config.USERS_OPEN_REGISTRATION: - raise HTTPException( - status_code=403, - detail="Open user registration is forbidden on this server", - ) - user = crud.user.get_by_email(db, email=email) - if user: - raise HTTPException( - 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 = crud.user.create(db, obj_in=user_in) - return user - - @router.post("/", response_model=User) def create_user( *, @@ -131,7 +74,64 @@ def update_user_me( user_in.full_name = full_name if email is not None: user_in.email = email - user = crud.user.update(db, obj=current_user, obj_in=user_in) + user = crud.user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/me", response_model=User) +def read_user_me( + db: Session = Depends(get_db), + current_user: DBUser = Depends(get_current_active_user), +): + """ + Get current user. + """ + return current_user + + +@router.post("/open", response_model=User) +def create_user_open( + *, + db: Session = Depends(get_db), + password: str = Body(...), + email: EmailStr = Body(...), + full_name: str = Body(None), +): + """ + Create new user without the need to be logged in. + """ + if not config.USERS_OPEN_REGISTRATION: + raise HTTPException( + status_code=403, + detail="Open user registration is forbidden on this server", + ) + user = crud.user.get_by_email(db, email=email) + if user: + raise HTTPException( + 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 = crud.user.create(db, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=User) +def read_user_by_id( + user_id: int, + current_user: DBUser = Depends(get_current_active_user), + db: Session = Depends(get_db), +): + """ + Get a specific user by id. + """ + user = crud.user.get(db, id=user_id) + if user == current_user: + return user + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) return user @@ -141,16 +141,16 @@ def update_user( db: Session = Depends(get_db), user_id: int, user_in: UserUpdate, - current_user: User = Depends(get_current_active_superuser), + current_user: DBUser = Depends(get_current_active_superuser), ): """ Update a user. """ - user = crud.user.get(db, obj_id=user_id) + user = crud.user.get(db, id=user_id) if not user: raise HTTPException( status_code=404, detail="The user with this username does not exist in the system", ) - user = crud.user.update(db, obj=user, obj_in=user_in) + user = crud.user.update(db, db_obj=user, obj_in=user_in) return 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 9e146179e1..e562e97f5c 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,10 +1,11 @@ from fastapi import APIRouter, Depends -from pydantic.types import EmailStr +from pydantic.networks import EmailStr from app.api.utils.security import get_current_active_superuser from app.core.celery_app import celery_app from app.schemas.msg import Msg from app.schemas.user import User +from app.models.user import User as DBUser from app.utils import send_test_email router = APIRouter() @@ -12,7 +13,7 @@ @router.post("/test-celery/", response_model=Msg, status_code=201) def test_celery( - msg: Msg, current_user: User = Depends(get_current_active_superuser) + msg: Msg, current_user: DBUser = Depends(get_current_active_superuser) ): """ Test Celery worker. @@ -23,7 +24,7 @@ def test_celery( @router.post("/test-email/", response_model=Msg, status_code=201) def test_email( - email_to: EmailStr, current_user: User = Depends(get_current_active_superuser) + email_to: EmailStr, current_user: DBUser = Depends(get_current_active_superuser) ): """ Test emails. From 79f01695f2f50236ce0ebb43b57a811ef736abf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:05:16 +0100 Subject: [PATCH 23/26] :sparkles: Use new CRUD utils in security utils --- .../backend/app/app/api/utils/security.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py index 14bc010ce2..8f8b0e3fbb 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py @@ -25,20 +25,20 @@ def get_current_user( raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" ) - user = crud.user.get(db, obj_id=token_data.user_id) + 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 current_user.is_active: + 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 current_user.is_superuser: + if not crud.user.is_superuser(current_user): raise HTTPException( status_code=400, detail="The user doesn't have enough privileges" ) From 2a45871f65a41b51f045e90465f4e4f129e37db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:06:26 +0100 Subject: [PATCH 24/26] :white_check_mark: Use new CRUD utils in tests --- .../app/app/tests/api/api_v1/test_items.py | 4 +- .../app/app/tests/api/api_v1/test_users.py | 12 ++--- .../backend/app/app/tests/conftest.py | 17 ++----- .../backend/app/app/tests/crud/test_item.py | 30 +++++++------ .../app/app/tests/crud/test_sub_item.py | 43 ------------------ .../backend/app/app/tests/crud/test_user.py | 45 ++++++++++++------- .../backend/app/app/tests/utils/item.py | 6 ++- .../backend/app/app/tests/utils/user.py | 30 ++++++------- 8 files changed, 75 insertions(+), 112 deletions(-) delete mode 100644 {{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py 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 e4311c92a9..b295ba57d2 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 @@ -9,14 +9,13 @@ def test_create_item(superuser_token_headers): user = create_random_user() server_api = get_server_api() - data = {"title": "Foo", "description": "Fighters", "owner_id": user.id} + data = {"title": "Foo", "description": "Fighters"} response = requests.post( f"{server_api}{config.API_V1_STR}/items/", headers=superuser_token_headers, json=data, ) assert response.status_code == 200 - content = response.json() assert content["title"] == data["title"] assert content["description"] == data["description"] @@ -32,7 +31,6 @@ def test_read_item(superuser_token_headers): headers=superuser_token_headers, ) assert response.status_code == 200 - content = response.json() assert content["title"] == item.title assert content["description"] == item.description 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 40ffa58a10..4d8b3bc101 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 @@ -19,10 +19,10 @@ def test_get_users_superuser_me(superuser_token_headers): assert current_user["email"] == config.FIRST_SUPERUSER -def test_get_users_normaluser_me(normaluser_token_headers): +def test_get_users_normal_user_me(normal_user_token_headers): server_api = get_server_api() r = requests.get( - f"{server_api}{config.API_V1_STR}/users/me", headers=normaluser_token_headers + f"{server_api}{config.API_V1_STR}/users/me", headers=normal_user_token_headers ) current_user = r.json() assert current_user @@ -70,7 +70,7 @@ def test_create_user_existing_username(superuser_token_headers): # username = email password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.user.create(db_session, obj_in=user_in) # noqa + crud.user.create(db_session, obj_in=user_in) data = {"email": username, "password": password} r = requests.post( f"{server_api}{config.API_V1_STR}/users/", @@ -82,14 +82,14 @@ def test_create_user_existing_username(superuser_token_headers): assert "_id" not in created_user -def test_create_user_by_normal_user(normaluser_token_headers): +def test_create_user_by_normal_user(normal_user_token_headers): server_api = get_server_api() username = random_lower_string() password = random_lower_string() data = {"email": username, "password": password} r = requests.post( f"{server_api}{config.API_V1_STR}/users/", - headers=normaluser_token_headers, + headers=normal_user_token_headers, json=data, ) assert r.status_code == 400 @@ -105,7 +105,7 @@ def test_retrieve_users(superuser_token_headers): username2 = random_lower_string() password2 = random_lower_string() user_in2 = UserCreate(email=username2, password=password2) - user2 = crud.user.create(db_session, obj_in=user_in2) # noqa + crud.user.create(db_session, obj_in=user_in2) r = requests.get( f"{server_api}{config.API_V1_STR}/users/", headers=superuser_token_headers diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py index cb3a356ffd..e383ed183f 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py @@ -1,8 +1,8 @@ import pytest from app.core import config -from app.tests.utils.user import byemail_authentication_token from app.tests.utils.utils import get_server_api, get_superuser_token_headers +from app.tests.utils.user import authentication_token_from_email @pytest.fixture(scope="module") @@ -16,16 +16,5 @@ def superuser_token_headers(): @pytest.fixture(scope="module") -def normaluser_token_headers(): - return byemail_authentication_token(config.EMAIL_TEST_USER) - - -@pytest.fixture(scope="module") -def normaluser(normaluser_token_headers): - user = crud.user.get_by_email(db_session, email=config.EMAIL_TEST_USER) - if not user: - user_in = schemas_user.UserCreate( - email=config.EMAIL_TEST_USER, password=fake.password(), city_id=51 - ) - user = crud.user.create(db_session=db_session, obj_in=user_in) - return user +def normal_user_token_headers(): + return authentication_token_from_email(config.EMAIL_TEST_USER) 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 772f69dd7e..7b9d438ddd 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 @@ -8,9 +8,11 @@ def test_create_item(): title = random_lower_string() description = random_lower_string() + item_in = ItemCreate(title=title, description=description) user = create_random_user() - item_in = ItemCreate(title=title, description=description, owner_id=user.id) - item = crud.item.create(db_session=db_session, item_in=item_in) + item = crud.item.create_with_owner( + db_session=db_session, obj_in=item_in, owner_id=user.id + ) assert item.title == title assert item.description == description assert item.owner_id == user.id @@ -19,9 +21,11 @@ def test_create_item(): def test_get_item(): title = random_lower_string() description = random_lower_string() + item_in = ItemCreate(title=title, description=description) user = create_random_user() - item_in = ItemCreate(title=title, description=description, owner_id=user.id) - item = crud.item.create(db_session=db_session, item_in=item_in) + 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) assert item.id == stored_item.id assert item.title == stored_item.title @@ -32,14 +36,14 @@ def test_get_item(): def test_update_item(): title = random_lower_string() description = random_lower_string() + item_in = ItemCreate(title=title, description=description) user = create_random_user() - item_in = ItemCreate(title=title, description=description, owner_id=user.id) - item = crud.item.create(db_session=db_session, item_in=item_in) - description2 = random_lower_string() - item_update = ItemUpdate(description=description2, owner=user) - item2 = crud.item.update( - db_session=db_session, item=item, item_in=item_update + item = crud.item.create_with_owner( + db_session=db_session, 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) assert item.id == item2.id assert item.title == item2.title assert item2.description == description2 @@ -49,10 +53,10 @@ def test_update_item(): def test_delete_item(): title = random_lower_string() description = random_lower_string() + item_in = ItemCreate(title=title, description=description) user = create_random_user() - item_in = ItemCreate(title=title, description=description, owner_id=user.id) - item = crud.item.create(db_session=db_session, item_in=item_in) - item2 = crud.item.remove(db_session=db_session, obj_id=item.id) + 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) assert item3 is None assert item2.id == item.id diff --git a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py b/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py deleted file mode 100644 index 2d553661d4..0000000000 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_sub_item.py +++ /dev/null @@ -1,43 +0,0 @@ -from app import crud -from app.schemas.subitem import SubItemCreate, SubItemUpdate -from app.tests.utils.utils import random_lower_string -from app.tests.utils.item import create_random_item -from app.db.session import db_session - - -ITEM = create_random_item() -SUB_ITEM = None - - -def test_create_sub_item(): - global SUB_ITEM - title = random_lower_string() - sub_item_in = SubItemCreate(title=title, item_id=ITEM.id) - SUB_ITEM = crud.subitem.create(db_session=db_session, obj_in=sub_item_in) - assert SUB_ITEM.title == title - assert SUB_ITEM.item_id == ITEM.id - - -def test_get_sub_item(): - stored_sub_item = crud.subitem.get(db_session=db_session, obj_id=SUB_ITEM.id) - assert stored_sub_item.id == SUB_ITEM.id - assert stored_sub_item.title == SUB_ITEM.title - assert stored_sub_item.item_id == SUB_ITEM.item_id - - assert stored_sub_item.item is not None - assert stored_sub_item.item.title == ITEM.title - - -def test_update_sub_item(): - new_title = random_lower_string() - sub_item_update = SubItemUpdate(title=new_title) - updated_sub_item = crud.subitem.update( - db_session=db_session, obj=SUB_ITEM, obj_in=sub_item_update - ) - assert updated_sub_item.id == SUB_ITEM.id - assert updated_sub_item.title == new_title - - -def test_delete_sub_item(): - assert crud.subitem.delete(db_session=db_session, obj_id=SUB_ITEM.id) == 1 - assert crud.subitem.get(db_session=db_session, obj_id=SUB_ITEM.id) is None 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 84ba72a96d..b4e73396c0 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,7 +1,8 @@ +from fastapi.encoders import jsonable_encoder + from app import crud from app.db.session import db_session -from app.schemas.user import User, UserCreate -from app.tests.utils.user import create_random_user +from app.schemas.user import UserCreate from app.tests.utils.utils import random_lower_string @@ -34,22 +35,21 @@ def test_not_authenticate_user(): def test_check_if_user_is_active(): - user = create_random_user() - assert user.is_active - - -def test_check_if_user_is_superuser_normal_user(): - user = create_random_user() - assert not user.is_superuser + email = random_lower_string() + password = random_lower_string() + user_in = UserCreate(email=email, password=password) + user = crud.user.create(db_session, obj_in=user_in) + is_active = crud.user.is_active(user) + assert is_active is True def test_check_if_user_is_active_inactive(): email = random_lower_string() password = random_lower_string() - user_in = UserCreate(email=email, password=password, is_active=False) + user_in = UserCreate(email=email, password=password, disabled=True) user = crud.user.create(db_session, obj_in=user_in) - assert not user.is_active - assert not user.is_active + is_active = crud.user.is_active(user) + assert is_active def test_check_if_user_is_superuser(): @@ -57,11 +57,24 @@ def test_check_if_user_is_superuser(): password = random_lower_string() user_in = UserCreate(email=email, password=password, is_superuser=True) user = crud.user.create(db_session, obj_in=user_in) - assert user.is_superuser + is_superuser = crud.user.is_superuser(user) + assert is_superuser is True + + +def test_check_if_user_is_superuser_normal_user(): + username = random_lower_string() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.user.create(db_session, obj_in=user_in) + is_superuser = crud.user.is_superuser(user) + assert is_superuser is False def test_get_user(): - user = create_random_user() - user_2 = crud.user.get(db_session, obj_id=user.id) + password = random_lower_string() + username = random_lower_string() + 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) assert user.email == user_2.email - assert user.to_schema(User) == user_2.to_schema(User) + 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 index 14a63e66f8..95950f2e7d 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py @@ -11,5 +11,7 @@ def create_random_item(owner_id: int = None): owner_id = user.id title = random_lower_string() description = random_lower_string() - item_in = ItemCreate(title=title, owner_id=owner_id, description=description, id=id) - return crud.item.create(db_session=db_session, item_in=item_in) + 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 + ) 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 6af59dc454..d8856607d3 100644 --- a/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py +++ b/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py @@ -4,15 +4,7 @@ from app.core import config from app.db.session import db_session from app.schemas.user import UserCreate, UserUpdate -from app.tests.utils.utils import random_lower_string, get_server_api - - -def create_random_user(): - email = random_lower_string() - 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) - return user +from app.tests.utils.utils import get_server_api, random_lower_string def user_authentication_headers(server_api, email, password): @@ -25,19 +17,27 @@ def user_authentication_headers(server_api, email, password): return headers -def byemail_authentication_token(email): +def create_random_user(): + email = random_lower_string() + 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) + return user + + +def authentication_token_from_email(email): """ - Return a valid token for the user with given email, eventhough the user existed in the first place or not. + Return a valid token for the user with given email. - The function generated the User if necessary, and update it with a fresh password otherwise. That allows to use again and again the same user during the test (instead of creating a new one every time), without storing any password in the code. - """ # noqa + If the user doesn't exist it is created first. + """ password = random_lower_string() user = crud.user.get_by_email(db_session, email=email) if not user: - user_in = UserCreate(email=email, password=password, city_id=51) + user_in = UserCreate(username=email, email=email, password=password) user = crud.user.create(db_session=db_session, obj_in=user_in) else: user_in = UserUpdate(password=password) - user = crud.user.update(db_session, obj=user, obj_in=user_in) + user = crud.user.update(db_session, obj_in=user, db_obj=user_in) return user_authentication_headers(get_server_api(), email, password) From e6f6a8616de3459c301e77e1df09e932ad6da126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:06:46 +0100 Subject: [PATCH 25/26] :arrow_up: Upgrade FastAPI and Uvicorn version --- {{cookiecutter.project_slug}}/backend/backend.dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.project_slug}}/backend/backend.dockerfile b/{{cookiecutter.project_slug}}/backend/backend.dockerfile index d30f17d58e..e9aa21b9f3 100644 --- a/{{cookiecutter.project_slug}}/backend/backend.dockerfile +++ b/{{cookiecutter.project_slug}}/backend/backend.dockerfile @@ -1,6 +1,6 @@ FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 -RUN pip install celery~=4.3 passlib[bcrypt] tenacity requests emails "fastapi>=0.16.0" uvicorn gunicorn pyjwt python-multipart email_validator jinja2 psycopg2-binary alembic SQLAlchemy +RUN pip install celery~=4.3 passlib[bcrypt] tenacity requests emails "fastapi>=0.47.0" "uvicorn>=0.11.1" gunicorn pyjwt python-multipart email_validator jinja2 psycopg2-binary alembic SQLAlchemy # For development, Jupyter remote kernel, Hydrogen # Using inside the container: From a4b8c89e07f10594582e0e7e632bcb26790c0a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 19 Jan 2020 22:16:16 +0100 Subject: [PATCH 26/26] :recycle: Update files, refactor, simplify --- .gitignore | 6 +----- test-again.sh | 21 ------------------- test.sh | 2 +- .../app/app/tests/api/api_v1/test_items.py | 1 - 4 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 test-again.sh diff --git a/.gitignore b/.gitignore index 4f3fb8501f..2b65cea186 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ .vscode -.mypy_cache -.pytest_cache -Pipfile.lock - testing-project -docker-stack.yml +.mypy_cache diff --git a/test-again.sh b/test-again.sh deleted file mode 100644 index ceb5969f28..0000000000 --- a/test-again.sh +++ /dev/null @@ -1,21 +0,0 @@ -#! /usr/bin/env bash - -# pre-requisite: -# - container for training-project running -# - which means run test.sh script at least once before using this script - -# this script produces the same result as test.sh, but faster -# because it rsyncs the latest modification into testing-project -# instead of removing it and generating it again - -# Exit in case of error -set -e - -# push new src files -rsync -av \{\{cookiecutter.project_slug\}\}/backend testing-project/ - -# restart backend container -docker-compose -f testing-project/docker-stack.yml restart backend - -# run tests -docker-compose -f testing-project/docker-stack.yml exec -T backend-tests /tests-start.sh $* diff --git a/test.sh b/test.sh index 6e8deb8435..f783f27e77 100644 --- a/test.sh +++ b/test.sh @@ -9,6 +9,6 @@ cookiecutter --config-file ./testing-config.yml --no-input -f ./ cd ./testing-project -bash ./scripts/test.sh $* +bash ./scripts/test.sh "$@" cd ../ 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 b295ba57d2..b4804b92f7 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 @@ -7,7 +7,6 @@ def test_create_item(superuser_token_headers): - user = create_random_user() server_api = get_server_api() data = {"title": "Foo", "description": "Fighters"} response = requests.post(