diff --git a/Makefile b/Makefile index 57e173c..6c48e76 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,10 @@ docker-create-db-migration: ## Create new alembic database migration aka databa docker-test: ## Run project tests docker-compose -f docker-compose.yml -f docker-compose.test.yml run --rm app pytest +.PHONY: docker-test-snapshot +docker-test-snapshot: ## Run project tests with inline snapshot + docker-compose -f docker-compose.yml -f docker-compose.test.yml run --rm app pytest --inline-snapshot=create + .PHONY: safety safety: ## Check project and dependencies with safety https://github.com/pyupio/safety docker-compose run --rm app safety check diff --git a/alembic/env.py b/alembic/env.py index 5897a08..116a0bc 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -31,7 +31,9 @@ async def run_migrations_online(): and associate a connection with the context. """ - connectable = create_async_engine(settings.asyncpg_url.unicode_string(), future=True) + connectable = create_async_engine( + settings.asyncpg_url.unicode_string(), future=True + ) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) diff --git a/alembic/versions/20240102_2012_68cd4c3a0af0_.py b/alembic/versions/20240102_2012_68cd4c3a0af0_.py index da157e1..69a2a83 100644 --- a/alembic/versions/20240102_2012_68cd4c3a0af0_.py +++ b/alembic/versions/20240102_2012_68cd4c3a0af0_.py @@ -5,12 +5,13 @@ Revises: Create Date: 2024-01-02 20:12:46.214651 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '68cd4c3a0af0' +revision = "68cd4c3a0af0" down_revision = None branch_labels = None depends_on = None @@ -18,125 +19,168 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('nonsense', - sa.Column('id', sa.UUID(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('name'), - sa.UniqueConstraint('id'), - sa.UniqueConstraint('name'), - schema='happy_hog' + op.create_table( + "nonsense", + sa.Column("id", sa.UUID(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("name"), + sa.UniqueConstraint("id"), + sa.UniqueConstraint("name"), + schema="happy_hog", ) - op.create_table('stuff', - sa.Column('id', sa.UUID(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('name'), - sa.UniqueConstraint('id'), - sa.UniqueConstraint('name'), - schema='happy_hog' + op.create_table( + "stuff", + sa.Column("id", sa.UUID(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("name"), + sa.UniqueConstraint("id"), + sa.UniqueConstraint("name"), + schema="happy_hog", ) - op.create_table('character', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('speech_count', sa.Integer(), nullable=False), - sa.Column('abbrev', sa.String(length=32), nullable=True), - sa.Column('description', sa.String(length=2056), nullable=True), - sa.PrimaryKeyConstraint('id', name='character_pkey'), - schema='shakespeare' + op.create_table( + "character", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("speech_count", sa.Integer(), nullable=False), + sa.Column("abbrev", sa.String(length=32), nullable=True), + sa.Column("description", sa.String(length=2056), nullable=True), + sa.PrimaryKeyConstraint("id", name="character_pkey"), + schema="shakespeare", ) - op.create_table('wordform', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('plain_text', sa.String(length=64), nullable=False), - sa.Column('phonetic_text', sa.String(length=64), nullable=False), - sa.Column('stem_text', sa.String(length=64), nullable=False), - sa.Column('occurences', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id', name='wordform_pkey'), - schema='shakespeare' + op.create_table( + "wordform", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("plain_text", sa.String(length=64), nullable=False), + sa.Column("phonetic_text", sa.String(length=64), nullable=False), + sa.Column("stem_text", sa.String(length=64), nullable=False), + sa.Column("occurences", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id", name="wordform_pkey"), + schema="shakespeare", ) - op.create_table('work', - sa.Column('id', sa.String(length=32), nullable=False), - sa.Column('title', sa.String(length=32), nullable=False), - sa.Column('long_title', sa.String(length=64), nullable=False), - sa.Column('year', sa.Integer(), nullable=False), - sa.Column('genre_type', sa.String(length=1), nullable=False), - sa.Column('source', sa.String(length=16), nullable=False), - sa.Column('total_words', sa.Integer(), nullable=False), - sa.Column('total_paragraphs', sa.Integer(), nullable=False), - sa.Column('notes', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('id', name='work_pkey'), - schema='shakespeare' + op.create_table( + "work", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("title", sa.String(length=32), nullable=False), + sa.Column("long_title", sa.String(length=64), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("genre_type", sa.String(length=1), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False), + sa.Column("total_words", sa.Integer(), nullable=False), + sa.Column("total_paragraphs", sa.Integer(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id", name="work_pkey"), + schema="shakespeare", ) - op.create_table('user', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('first_name', sa.String(), nullable=False), - sa.Column('last_name', sa.String(), nullable=False), - sa.Column('_password', sa.LargeBinary(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') + op.create_table( + "user", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("first_name", sa.String(), nullable=False), + sa.Column("last_name", sa.String(), nullable=False), + sa.Column("_password", sa.LargeBinary(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), ) - op.create_table('stuff_full_of_nonsense', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('stuff_id', sa.UUID(), nullable=False), - sa.Column('nonsense_id', sa.UUID(), nullable=False), - sa.Column('but_why', sa.String(), nullable=True), - sa.ForeignKeyConstraint(['nonsense_id'], ['happy_hog.nonsense.id'], ), - sa.ForeignKeyConstraint(['stuff_id'], ['happy_hog.stuff.id'], ), - sa.PrimaryKeyConstraint('id'), - schema='happy_hog' + op.create_table( + "stuff_full_of_nonsense", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("stuff_id", sa.UUID(), nullable=False), + sa.Column("nonsense_id", sa.UUID(), nullable=False), + sa.Column("but_why", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["nonsense_id"], + ["happy_hog.nonsense.id"], + ), + sa.ForeignKeyConstraint( + ["stuff_id"], + ["happy_hog.stuff.id"], + ), + sa.PrimaryKeyConstraint("id"), + schema="happy_hog", ) - op.create_table('chapter', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('work_id', sa.String(length=32), nullable=False), - sa.Column('section_number', sa.Integer(), nullable=False), - sa.Column('chapter_number', sa.Integer(), nullable=False), - sa.Column('description', sa.String(length=256), nullable=False), - sa.ForeignKeyConstraint(['work_id'], ['shakespeare.work.id'], name='chapter_work_id_fkey'), - sa.PrimaryKeyConstraint('id', name='chapter_pkey'), - sa.UniqueConstraint('work_id', 'section_number', 'chapter_number', name='chapter_work_id_section_number_chapter_number_key'), - schema='shakespeare' + op.create_table( + "chapter", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("work_id", sa.String(length=32), nullable=False), + sa.Column("section_number", sa.Integer(), nullable=False), + sa.Column("chapter_number", sa.Integer(), nullable=False), + sa.Column("description", sa.String(length=256), nullable=False), + sa.ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="chapter_work_id_fkey" + ), + sa.PrimaryKeyConstraint("id", name="chapter_pkey"), + sa.UniqueConstraint( + "work_id", + "section_number", + "chapter_number", + name="chapter_work_id_section_number_chapter_number_key", + ), + schema="shakespeare", ) - op.create_table('character_work', - sa.Column('character_id', sa.String(length=32), nullable=False), - sa.Column('work_id', sa.String(length=32), nullable=False), - sa.ForeignKeyConstraint(['character_id'], ['shakespeare.character.id'], name='character_work_character_id_fkey'), - sa.ForeignKeyConstraint(['work_id'], ['shakespeare.work.id'], name='character_work_work_id_fkey'), - sa.PrimaryKeyConstraint('character_id', 'work_id', name='character_work_pkey'), - schema='shakespeare' + op.create_table( + "character_work", + sa.Column("character_id", sa.String(length=32), nullable=False), + sa.Column("work_id", sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["shakespeare.character.id"], + name="character_work_character_id_fkey", + ), + sa.ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="character_work_work_id_fkey" + ), + sa.PrimaryKeyConstraint("character_id", "work_id", name="character_work_pkey"), + schema="shakespeare", ) - op.create_table('paragraph', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('work_id', sa.String(length=32), nullable=False), - sa.Column('paragraph_num', sa.Integer(), nullable=False), - sa.Column('character_id', sa.String(length=32), nullable=False), - sa.Column('plain_text', sa.Text(), nullable=False), - sa.Column('phonetic_text', sa.Text(), nullable=False), - sa.Column('stem_text', sa.Text(), nullable=False), - sa.Column('paragraph_type', sa.String(length=1), nullable=False), - sa.Column('section_number', sa.Integer(), nullable=False), - sa.Column('chapter_number', sa.Integer(), nullable=False), - sa.Column('char_count', sa.Integer(), nullable=False), - sa.Column('word_count', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['character_id'], ['shakespeare.character.id'], name='paragraph_character_id_fkey'), - sa.ForeignKeyConstraint(['work_id', 'section_number', 'chapter_number'], ['shakespeare.chapter.work_id', 'shakespeare.chapter.section_number', 'shakespeare.chapter.chapter_number'], name='paragraph_chapter_fkey'), - sa.ForeignKeyConstraint(['work_id'], ['shakespeare.work.id'], name='paragraph_work_id_fkey'), - sa.PrimaryKeyConstraint('id', name='paragraph_pkey'), - schema='shakespeare' + op.create_table( + "paragraph", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("work_id", sa.String(length=32), nullable=False), + sa.Column("paragraph_num", sa.Integer(), nullable=False), + sa.Column("character_id", sa.String(length=32), nullable=False), + sa.Column("plain_text", sa.Text(), nullable=False), + sa.Column("phonetic_text", sa.Text(), nullable=False), + sa.Column("stem_text", sa.Text(), nullable=False), + sa.Column("paragraph_type", sa.String(length=1), nullable=False), + sa.Column("section_number", sa.Integer(), nullable=False), + sa.Column("chapter_number", sa.Integer(), nullable=False), + sa.Column("char_count", sa.Integer(), nullable=False), + sa.Column("word_count", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["shakespeare.character.id"], + name="paragraph_character_id_fkey", + ), + sa.ForeignKeyConstraint( + ["work_id", "section_number", "chapter_number"], + [ + "shakespeare.chapter.work_id", + "shakespeare.chapter.section_number", + "shakespeare.chapter.chapter_number", + ], + name="paragraph_chapter_fkey", + ), + sa.ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="paragraph_work_id_fkey" + ), + sa.PrimaryKeyConstraint("id", name="paragraph_pkey"), + schema="shakespeare", ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('paragraph', schema='shakespeare') - op.drop_table('character_work', schema='shakespeare') - op.drop_table('chapter', schema='shakespeare') - op.drop_table('stuff_full_of_nonsense', schema='happy_hog') - op.drop_table('user') - op.drop_table('work', schema='shakespeare') - op.drop_table('wordform', schema='shakespeare') - op.drop_table('character', schema='shakespeare') - op.drop_table('stuff', schema='happy_hog') - op.drop_table('nonsense', schema='happy_hog') + op.drop_table("paragraph", schema="shakespeare") + op.drop_table("character_work", schema="shakespeare") + op.drop_table("chapter", schema="shakespeare") + op.drop_table("stuff_full_of_nonsense", schema="happy_hog") + op.drop_table("user") + op.drop_table("work", schema="shakespeare") + op.drop_table("wordform", schema="shakespeare") + op.drop_table("character", schema="shakespeare") + op.drop_table("stuff", schema="happy_hog") + op.drop_table("nonsense", schema="happy_hog") # ### end Alembic commands ### diff --git a/app/api/nonsense.py b/app/api/nonsense.py index 3cf8116..6f87a7a 100644 --- a/app/api/nonsense.py +++ b/app/api/nonsense.py @@ -12,7 +12,9 @@ router = APIRouter(prefix="/v1/nonsense") @router.post("/", status_code=status.HTTP_201_CREATED, response_model=NonsenseResponse) -async def create_nonsense(payload: NonsenseSchema, db_session: AsyncSession = Depends(get_db)): +async def create_nonsense( + payload: NonsenseSchema, db_session: AsyncSession = Depends(get_db) +): nonsense = Nonsense(**payload.model_dump()) await nonsense.save(db_session) return nonsense @@ -103,7 +105,9 @@ async def import_nonsense( # If an error occurs, roll back the session await db_session.rollback() # Raise an HTTP exception with a 422 status code - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)) from ex + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) + ) from ex finally: # Ensure that the database session is closed, regardless of whether an error occurred or not await db_session.close() @@ -147,4 +151,4 @@ async def import_nonsense( # # TODO: https://medium.com/@amitosh/full-text-search-fts-with-postgresql-and-sqlalchemy-edc436330a0c -# TODO: https://www.postgresql.org/docs/13/textsearch-intro.html \ No newline at end of file +# TODO: https://www.postgresql.org/docs/13/textsearch-intro.html diff --git a/app/api/stuff.py b/app/api/stuff.py index d6dc341..8c21cb1 100644 --- a/app/api/stuff.py +++ b/app/api/stuff.py @@ -13,21 +13,29 @@ router = APIRouter(prefix="/v1/stuff") @router.post("/add_many", status_code=status.HTTP_201_CREATED) -async def create_multi_stuff(payload: list[StuffSchema], db_session: AsyncSession = Depends(get_db)): +async def create_multi_stuff( + payload: list[StuffSchema], db_session: AsyncSession = Depends(get_db) +): try: stuff_instances = [Stuff(**stuff.model_dump()) for stuff in payload] db_session.add_all(stuff_instances) await db_session.commit() except SQLAlchemyError as ex: # logger.exception(ex) - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)) from ex + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) + ) from ex else: - logger.info(f"{len(stuff_instances)} instances of Stuff inserted into database.") + logger.info( + f"{len(stuff_instances)} instances of Stuff inserted into database." + ) return True @router.post("", status_code=status.HTTP_201_CREATED, response_model=StuffResponse) -async def create_stuff(payload: StuffSchema, db_session: AsyncSession = Depends(get_db)): +async def create_stuff( + payload: StuffSchema, db_session: AsyncSession = Depends(get_db) +): stuff = Stuff(**payload.model_dump()) await stuff.save(db_session) return stuff diff --git a/app/api/user.py b/app/api/user.py index 875348a..de5069b 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -13,7 +13,9 @@ router = APIRouter(prefix="/v1/user") @router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserResponse) -async def create_user(payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db)): +async def create_user( + payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db) +): logger.info(f"Creating user: {payload}") _user: User = User(**payload.model_dump()) await _user.save(db_session) @@ -23,15 +25,23 @@ async def create_user(payload: UserSchema, request: Request, db_session: AsyncSe return _user -@router.post("/token", status_code=status.HTTP_201_CREATED, response_model=TokenResponse) -async def get_token_for_user(user: UserLogin, request: Request, db_session: AsyncSession = Depends(get_db)): +@router.post( + "/token", status_code=status.HTTP_201_CREATED, response_model=TokenResponse +) +async def get_token_for_user( + user: UserLogin, request: Request, db_session: AsyncSession = Depends(get_db) +): _user: User = await User.find(db_session, [User.email == user.email]) # TODO: out exception handling to external module if not _user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) if not _user.check_password(user.password): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password is incorrect") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Password is incorrect" + ) # TODO: add refresh token _token = await create_access_token(_user, request) diff --git a/app/config.py b/app/config.py index 9bb375b..bf40388 100644 --- a/app/config.py +++ b/app/config.py @@ -7,9 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", - env_ignore_empty=True, - extra="ignore" + env_file=".env", env_ignore_empty=True, extra="ignore" ) jwt_algorithm: str = os.getenv("JWT_ALGORITHM") jwt_expire: int = os.getenv("JWT_EXPIRE") @@ -46,7 +44,6 @@ class Settings(BaseSettings): host=self.REDIS_HOST, port=self.REDIS_PORT, path=self.REDIS_DB, - ) @computed_field diff --git a/app/main.py b/app/main.py index 9449a6d..f9a3b11 100644 --- a/app/main.py +++ b/app/main.py @@ -21,8 +21,6 @@ async def lifespan(app: FastAPI): # Load the redis connection app.state.redis = await get_redis() - - try: # Initialize the cache with the redis connection redis_cache = await get_cache() @@ -43,4 +41,9 @@ app.include_router(user_router) app.include_router(health_router, prefix="/v1/public/health", tags=["Health, Public"]) -app.include_router(health_router, prefix="/v1/health", tags=["Health, Bearer"], dependencies=[Depends(AuthBearer())]) +app.include_router( + health_router, + prefix="/v1/health", + tags=["Health, Bearer"], + dependencies=[Depends(AuthBearer())], +) diff --git a/app/models/base.py b/app/models/base.py index 53920e0..3f5a531 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -26,7 +26,9 @@ class Base(DeclarativeBase): db_session.add(self) return await db_session.commit() except SQLAlchemyError as ex: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)) from ex + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) + ) from ex async def delete(self, db_session: AsyncSession): """ @@ -39,7 +41,9 @@ class Base(DeclarativeBase): await db_session.commit() return True except SQLAlchemyError as ex: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)) from ex + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) + ) from ex async def update(self, db: AsyncSession, **kwargs): """ @@ -53,7 +57,9 @@ class Base(DeclarativeBase): setattr(self, k, v) return await db.commit() except SQLAlchemyError as ex: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex)) from ex + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=repr(ex) + ) from ex async def save_or_update(self, db: AsyncSession): try: diff --git a/app/models/nonsense.py b/app/models/nonsense.py index ebe98f9..2321750 100644 --- a/app/models/nonsense.py +++ b/app/models/nonsense.py @@ -12,7 +12,9 @@ from app.models.base import Base class Nonsense(Base): __tablename__ = "nonsense" __table_args__ = ({"schema": "happy_hog"},) - id: Mapped[uuid:UUID] = mapped_column(UUID(as_uuid=True), unique=True, default=uuid.uuid4, autoincrement=True) + id: Mapped[uuid:UUID] = mapped_column( + UUID(as_uuid=True), unique=True, default=uuid.uuid4, autoincrement=True + ) name: Mapped[str] = mapped_column(String, primary_key=True, unique=True) description: Mapped[str | None] # TODO: apply relation to other tables @@ -31,7 +33,9 @@ class Nonsense(Base): if instance is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail={"Record not found": f"There is no record for requested name value : {name}"}, + detail={ + "Record not found": f"There is no record for requested name value : {name}" + }, ) else: return instance diff --git a/app/models/shakespeare.py b/app/models/shakespeare.py index 411984e..6aa752c 100644 --- a/app/models/shakespeare.py +++ b/app/models/shakespeare.py @@ -34,7 +34,10 @@ class Character(Base): """ __tablename__ = "character" - __table_args__ = (PrimaryKeyConstraint("id", name="character_pkey"), {"schema": "shakespeare"}) + __table_args__ = ( + PrimaryKeyConstraint("id", name="character_pkey"), + {"schema": "shakespeare"}, + ) id: Mapped[str] = mapped_column(String(32), primary_key=True) name: Mapped[str] = mapped_column(String(64)) @@ -45,7 +48,9 @@ class Character(Base): work: Mapped[list["Work"]] = relationship( "Work", secondary="shakespeare.character_work", back_populates="character" ) - paragraph: Mapped[list["Paragraph"]] = relationship("Paragraph", back_populates="character") + paragraph: Mapped[list["Paragraph"]] = relationship( + "Paragraph", back_populates="character" + ) class Wordform(Base): @@ -65,7 +70,10 @@ class Wordform(Base): """ __tablename__ = "wordform" - __table_args__ = (PrimaryKeyConstraint("id", name="wordform_pkey"), {"schema": "shakespeare"}) + __table_args__ = ( + PrimaryKeyConstraint("id", name="wordform_pkey"), + {"schema": "shakespeare"}, + ) id: Mapped[int] = mapped_column(Integer, primary_key=True) plain_text: Mapped[str] = mapped_column(String(64)) @@ -98,7 +106,10 @@ class Work(Base): """ __tablename__ = "work" - __table_args__ = (PrimaryKeyConstraint("id", name="work_pkey"), {"schema": "shakespeare"}) + __table_args__ = ( + PrimaryKeyConstraint("id", name="work_pkey"), + {"schema": "shakespeare"}, + ) id: Mapped[str] = mapped_column(String(32), primary_key=True) title: Mapped[str] = mapped_column(String(32)) @@ -114,7 +125,9 @@ class Work(Base): "Character", secondary="shakespeare.character_work", back_populates="work" ) chapter: Mapped[list["Chapter"]] = relationship("Chapter", back_populates="work") - paragraph: Mapped[list["Paragraph"]] = relationship("Paragraph", back_populates="work") + paragraph: Mapped[list["Paragraph"]] = relationship( + "Paragraph", back_populates="work" + ) class Chapter(Base): @@ -137,10 +150,15 @@ class Chapter(Base): __tablename__ = "chapter" __table_args__ = ( - ForeignKeyConstraint(["work_id"], ["shakespeare.work.id"], name="chapter_work_id_fkey"), + ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="chapter_work_id_fkey" + ), PrimaryKeyConstraint("id", name="chapter_pkey"), UniqueConstraint( - "work_id", "section_number", "chapter_number", name="chapter_work_id_section_number_chapter_number_key" + "work_id", + "section_number", + "chapter_number", + name="chapter_work_id_section_number_chapter_number_key", ), {"schema": "shakespeare"}, ) @@ -152,7 +170,9 @@ class Chapter(Base): description: Mapped[str] = mapped_column(String(256)) work: Mapped["Work"] = relationship("Work", back_populates="chapter") - paragraph: Mapped[list["Paragraph"]] = relationship("Paragraph", back_populates="chapter") + paragraph: Mapped[list["Paragraph"]] = relationship( + "Paragraph", back_populates="chapter" + ) t_character_work = Table( @@ -160,8 +180,14 @@ t_character_work = Table( Base.metadata, Column("character_id", String(32), primary_key=True, nullable=False), Column("work_id", String(32), primary_key=True, nullable=False), - ForeignKeyConstraint(["character_id"], ["shakespeare.character.id"], name="character_work_character_id_fkey"), - ForeignKeyConstraint(["work_id"], ["shakespeare.work.id"], name="character_work_work_id_fkey"), + ForeignKeyConstraint( + ["character_id"], + ["shakespeare.character.id"], + name="character_work_character_id_fkey", + ), + ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="character_work_work_id_fkey" + ), PrimaryKeyConstraint("character_id", "work_id", name="character_work_pkey"), schema="shakespeare", ) @@ -198,13 +224,23 @@ class Paragraph(Base): __tablename__ = "paragraph" __table_args__ = ( - ForeignKeyConstraint(["character_id"], ["shakespeare.character.id"], name="paragraph_character_id_fkey"), + ForeignKeyConstraint( + ["character_id"], + ["shakespeare.character.id"], + name="paragraph_character_id_fkey", + ), ForeignKeyConstraint( ["work_id", "section_number", "chapter_number"], - ["shakespeare.chapter.work_id", "shakespeare.chapter.section_number", "shakespeare.chapter.chapter_number"], + [ + "shakespeare.chapter.work_id", + "shakespeare.chapter.section_number", + "shakespeare.chapter.chapter_number", + ], name="paragraph_chapter_fkey", ), - ForeignKeyConstraint(["work_id"], ["shakespeare.work.id"], name="paragraph_work_id_fkey"), + ForeignKeyConstraint( + ["work_id"], ["shakespeare.work.id"], name="paragraph_work_id_fkey" + ), PrimaryKeyConstraint("id", name="paragraph_pkey"), {"schema": "shakespeare"}, ) @@ -222,7 +258,9 @@ class Paragraph(Base): char_count: Mapped[int] = mapped_column(Integer) word_count: Mapped[int] = mapped_column(Integer) - character: Mapped["Character"] = relationship("Character", back_populates="paragraph") + character: Mapped["Character"] = relationship( + "Character", back_populates="paragraph" + ) chapter: Mapped["Chapter"] = relationship("Chapter", back_populates="paragraph") work: Mapped["Work"] = relationship("Work", back_populates="paragraph") @@ -244,6 +282,12 @@ class Paragraph(Base): - List[Paragraph]: A list of `Paragraph` objects that are associated with the specified character. """ - stmt = select(cls).join(Character).join(Chapter).join(Work).where(Character.name == character) + stmt = ( + select(cls) + .join(Character) + .join(Chapter) + .join(Work) + .where(Character.name == character) + ) result = await db_session.execute(stmt) return result.scalars().all() diff --git a/app/models/stuff.py b/app/models/stuff.py index af9b86b..5717f92 100644 --- a/app/models/stuff.py +++ b/app/models/stuff.py @@ -13,11 +13,15 @@ from app.models.nonsense import Nonsense class Stuff(Base): __tablename__ = "stuff" __table_args__ = ({"schema": "happy_hog"},) - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True, default=uuid.uuid4, autoincrement=True) + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), unique=True, default=uuid.uuid4, autoincrement=True + ) name: Mapped[str] = mapped_column(String, primary_key=True, unique=True) description: Mapped[str | None] - nonsense: Mapped["Nonsense"] = relationship("Nonsense", secondary="happy_hog.stuff_full_of_nonsense") + nonsense: Mapped["Nonsense"] = relationship( + "Nonsense", secondary="happy_hog.stuff_full_of_nonsense" + ) @classmethod async def find(cls, db_session: AsyncSession, name: str): @@ -42,7 +46,11 @@ class Stuff(Base): class StuffFullOfNonsense(Base): __tablename__ = "stuff_full_of_nonsense" __table_args__ = ({"schema": "happy_hog"},) - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, primary_key=True) + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), default=uuid.uuid4, primary_key=True + ) stuff_id: Mapped[Stuff] = mapped_column(UUID, ForeignKey("happy_hog.stuff.id")) - nonsense_id: Mapped["Nonsense"] = mapped_column(UUID, ForeignKey("happy_hog.nonsense.id")) + nonsense_id: Mapped["Nonsense"] = mapped_column( + UUID, ForeignKey("happy_hog.nonsense.id") + ) but_why: Mapped[str | None] diff --git a/app/models/user.py b/app/models/user.py index 34bab01..4c06770 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -15,7 +15,9 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") class User(Base): - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, primary_key=True) + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), default=uuid.uuid4, primary_key=True + ) email: Mapped[str] = mapped_column(String, nullable=False, unique=True) first_name: Mapped[str] = mapped_column(String, nullable=False) last_name: Mapped[str] = mapped_column(String, nullable=False) @@ -28,7 +30,9 @@ class User(Base): @password.setter def password(self, password: SecretStr): _password_string = password.get_secret_value() - self._password = bcrypt.hashpw(_password_string.encode("utf-8"), bcrypt.gensalt()) + self._password = bcrypt.hashpw( + _password_string.encode("utf-8"), bcrypt.gensalt() + ) def check_password(self, password: SecretStr): return pwd_context.verify(password.get_secret_value(), self.password) diff --git a/app/schemas/user.py b/app/schemas/user.py index d7a8dd3..dce7785 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -8,10 +8,20 @@ config = ConfigDict(from_attributes=True) # TODO: add pydantic field validator for strong password class UserSchema(BaseModel): model_config = config - email: EmailStr = Field(title="User’s email", description="User’s email", examples=["john@domain.com"]) - first_name: str = Field(title="User’s first name", description="User’s first name", examples=["John"]) - last_name: str = Field(title="User’s last name", description="User’s last name", examples=["Doe"]) - password: SecretStr = Field(title="User’s password", description="User’s password", examples=["@SuperSecret123"]) + email: EmailStr = Field( + title="User’s email", description="User’s email", examples=["john@domain.com"] + ) + first_name: str = Field( + title="User’s first name", description="User’s first name", examples=["John"] + ) + last_name: str = Field( + title="User’s last name", description="User’s last name", examples=["Doe"] + ) + password: SecretStr = Field( + title="User’s password", + description="User’s password", + examples=["@SuperSecret123"], + ) class UserResponse(BaseModel): @@ -23,11 +33,19 @@ class UserResponse(BaseModel): class TokenResponse(BaseModel): - access_token: str = Field(title="User’s access token", description="User’s access token") + access_token: str = Field( + title="User’s access token", description="User’s access token" + ) token_type: str = Field(title="User’s token type", description="User’s token type") class UserLogin(BaseModel): model_config = config - email: EmailStr = Field(title="User’s email", description="User’s email", examples=["john@domain.com"]) - password: SecretStr = Field(title="User’s password", description="User’s password", examples=["@SuperSecret123"]) + email: EmailStr = Field( + title="User’s email", description="User’s email", examples=["john@domain.com"] + ) + password: SecretStr = Field( + title="User’s password", + description="User’s password", + examples=["@SuperSecret123"], + ) diff --git a/app/services/auth.py b/app/services/auth.py index 3db0f36..96de5b4 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -30,9 +30,13 @@ class AuthBearer(HTTPBearer): if not credentials: raise HTTPException(status_code=403, detail="Invalid authorization code.") if credentials.scheme != "Bearer": - raise HTTPException(status_code=403, detail="Invalid authentication scheme.") + raise HTTPException( + status_code=403, detail="Invalid authentication scheme." + ) if not await verify_jwt(request, credentials.credentials): - raise HTTPException(status_code=403, detail="Invalid token or expired token.") + raise HTTPException( + status_code=403, detail="Invalid token or expired token." + ) return credentials.credentials @@ -43,8 +47,12 @@ async def create_access_token(user: User, request: Request): "expiry": time.time() + global_settings.jwt_expire, "platform": request.headers.get("User-Agent"), } - token = jwt.encode(payload, str(user.password), algorithm=global_settings.jwt_algorithm) + token = jwt.encode( + payload, str(user.password), algorithm=global_settings.jwt_algorithm + ) - _bool = await set_to_redis(request, token, str(payload), ex=global_settings.jwt_expire) + _bool = await set_to_redis( + request, token, str(payload), ex=global_settings.jwt_expire + ) if _bool: return token diff --git a/app/utils/logging.py b/app/utils/logging.py index e6aec43..84975ff 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -19,4 +19,6 @@ class AppLogger(metaclass=SingletonMeta): class RichConsoleHandler(RichHandler): def __init__(self, width=200, style=None, **kwargs): - super().__init__(console=Console(color_system="256", width=width, style=style), **kwargs) + super().__init__( + console=Console(color_system="256", width=width, style=style), **kwargs + ) diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 4fdd02e..0f27e9b 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -2,18 +2,36 @@ import pytest from httpx import AsyncClient from starlette import status import jwt +from inline_snapshot import snapshot +from dirty_equals import IsStr, IsUUID, IsPositiveFloat pytestmark = pytest.mark.anyio # TODO: parametrize test with diff urls async def test_add_user(client: AsyncClient): - payload = {"email": "joe@grillazz.com", "first_name": "Joe", "last_name": "Garcia", "password": "s1lly"} + payload = { + "email": "joe@grillazz.com", + "first_name": "Joe", + "last_name": "Garcia", + "password": "s1lly", + } response = await client.post("/user/", json=payload) assert response.status_code == status.HTTP_201_CREATED - claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False}) - assert claimset["email"] == payload["email"] - assert claimset["expiry"] > 0 + assert response.json() == snapshot( + { + "id": IsUUID(4), + "email": "joe@grillazz.com", + "first_name": "Joe", + "last_name": "Garcia", + "access_token": IsStr(), + } + ) + + claimset = jwt.decode( + response.json()["access_token"], options={"verify_signature": False} + ) + assert claimset["expiry"] == IsPositiveFloat() assert claimset["platform"] == "python-httpx/0.27.0" @@ -22,9 +40,11 @@ async def test_get_token(client: AsyncClient): payload = {"email": "joe@grillazz.com", "password": "s1lly"} response = await client.post("/user/token", json=payload) assert response.status_code == status.HTTP_201_CREATED - claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False}) + claimset = jwt.decode( + response.json()["access_token"], options={"verify_signature": False} + ) assert claimset["email"] == payload["email"] - assert claimset["expiry"] > 0 + assert claimset["expiry"] == IsPositiveFloat() assert claimset["platform"] == "python-httpx/0.27.0" diff --git a/tests/api/test_import_xlsx.py b/tests/api/test_import_xlsx.py index 5ea68cd..f0dd9b4 100644 --- a/tests/api/test_import_xlsx.py +++ b/tests/api/test_import_xlsx.py @@ -24,4 +24,4 @@ async def test_import_animals(client: AsyncClient): ) assert response.status_code == expected_status - assert response.json() == {'filename': 'nonsense.xlsx', 'nonsense_records': 10} + assert response.json() == {"filename": "nonsense.xlsx", "nonsense_records": 10} diff --git a/tests/conftest.py b/tests/conftest.py index 88b5ff6..a3809ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,6 @@ async def client(start_db) -> AsyncClient: transport = ASGITransport( app=app, - ) async with AsyncClient( # app=app,