transactions endpoints and pytests

#6
.env.example CHANGED
@@ -47,7 +47,7 @@ APP_PORT=8000
47
  # Postgres database configuration
48
  POSTGRES_USER=postgres
49
  POSTGRES_PASSWORD=postgres
50
- POSTGRES_DB_NAME=codepath_project_postgres_local
51
  POSTGRES_DB_HOST=localhost
52
  POSTGRES_DB_PORT=5432
53
- SQLALCHEMY_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_DB_HOST}:${POSTGRES_DB_PORT}/${POSTGRES_DB_NAME}
 
47
  # Postgres database configuration
48
  POSTGRES_USER=postgres
49
  POSTGRES_PASSWORD=postgres
50
+ POSTGRES_DB_NAME=postgres
51
  POSTGRES_DB_HOST=localhost
52
  POSTGRES_DB_PORT=5432
53
+ SQLALCHEMY_DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_DB_HOST}:${POSTGRES_DB_PORT}/${POSTGRES_DB_NAME}
Dockerfile CHANGED
@@ -25,4 +25,7 @@ COPY . .
25
  # Make port 8000 available to the world outside this container
26
  EXPOSE 8000
27
 
 
 
 
28
  CMD ["python", "main.py"]
 
25
  # Make port 8000 available to the world outside this container
26
  EXPOSE 8000
27
 
28
+ # Run migrations
29
+ RUN alembic upgrade head
30
+
31
  CMD ["python", "main.py"]
app/api/routers/transaction.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.model.transaction import Transaction as TransactionModel
5
+ from app.schema.index import TransactionResponse
6
+ from app.engine.postgresdb import get_db_session
7
+
8
+ transaction_router = r = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
9
+
10
+
11
+ @r.get(
12
+ "/{user_id}",
13
+ response_model=List[TransactionResponse],
14
+ responses={
15
+ 200: {"description": "New user created"},
16
+ 400: {"description": "Bad request"},
17
+ 204: {"description": "No content"},
18
+ 500: {"description": "Internal server error"},
19
+ },
20
+ )
21
+ async def get_transactions(user_id: int, db: AsyncSession = Depends(get_db_session)):
22
+ """
23
+ Retrieve all transactions.
24
+ """
25
+ result = await TransactionModel.get_by_user(db, user_id)
26
+ all_rows = result.all()
27
+ if len(all_rows) == 0:
28
+ raise HTTPException(status_code=status.HTTP_204_NO_CONTENT, detail="No transactions found for this user")
29
+ return all_rows
app/api/routers/user.py CHANGED
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
 
5
  from app.engine.postgresdb import get_db_session
6
- from app.schema.index import UserCreate, User as UserSchema
7
  from app.model.user import User as UserModel
8
 
9
 
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
13
 
14
  @r.post(
15
  "/",
16
- response_model=UserSchema,
17
  responses={
18
  200: {"description": "New user created"},
19
  400: {"description": "Bad request"},
@@ -23,63 +23,67 @@ logger = logging.getLogger(__name__)
23
  )
24
  async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db_session)):
25
  try:
26
- logger.info(f"Checking if user exists: {user.dict()}")
27
  db_user = await UserModel.get(db, email=user.email)
28
  if db_user and not db_user.is_deleted:
29
  raise HTTPException(status_code=409, detail="User already exists")
30
 
31
- logger.info(f"Creating user: {user.dict()}")
32
- db_user = await UserModel.create(db, **user.dict())
33
- return db_user
34
  except Exception as e:
35
  raise HTTPException(status_code=500, detail=str(e))
36
 
37
 
38
  @r.get(
39
- "/{user_id}",
40
- response_model=UserSchema,
41
  responses={
42
  200: {"description": "User found"},
43
  404: {"description": "User not found"},
44
  500: {"description": "Internal server error"},
45
  },
46
  )
47
- async def get_user(user_id: int, db: AsyncSession = Depends(get_db_session)):
48
- user = await UserModel.get(db, id=user_id)
49
  if not user:
50
  raise HTTPException(status_code=404, detail="User not found")
51
  return user
52
 
53
 
54
  @r.put(
55
- "/{user_id}",
56
- response_model=UserSchema,
57
  responses={
58
  200: {"description": "User updated"},
59
  404: {"description": "User not found"},
60
  500: {"description": "Internal server error"},
61
  },
62
  )
63
- async def update_user(user_id: int, user_payload: UserCreate, db: AsyncSession = Depends(get_db_session)):
64
- user = await UserModel.get(db, id=user_id)
65
- if not user:
66
- raise HTTPException(status_code=404, detail="User not found")
67
- user = await UserModel.update(db, id=user_id, **user_payload.dict())
68
- return user
 
 
 
 
69
 
70
 
71
  @r.delete(
72
- "/{user_id}",
73
- response_model=UserSchema,
74
  responses={
75
  200: {"description": "User deleted"},
76
  404: {"description": "User not found"},
77
  500: {"description": "Internal server error"},
78
  },
79
  )
80
- async def delete_user(user_id: int, db: AsyncSession = Depends(get_db_session)):
81
- user = await UserModel.get(db, id=user_id)
82
  if not user:
83
  raise HTTPException(status_code=404, detail="User not found")
84
- user = await UserModel.delete(db, id=user_id)
 
85
  return user
 
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
 
5
  from app.engine.postgresdb import get_db_session
6
+ from app.schema.index import UserCreate, User as UserSchema, UserResponse, UserUpdate
7
  from app.model.user import User as UserModel
8
 
9
 
 
13
 
14
  @r.post(
15
  "/",
16
+ response_model=UserResponse,
17
  responses={
18
  200: {"description": "New user created"},
19
  400: {"description": "Bad request"},
 
23
  )
24
  async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db_session)):
25
  try:
 
26
  db_user = await UserModel.get(db, email=user.email)
27
  if db_user and not db_user.is_deleted:
28
  raise HTTPException(status_code=409, detail="User already exists")
29
 
30
+ await UserModel.create(db, **user.dict())
31
+ user = await UserModel.get(db, email=user.email)
32
+ return user
33
  except Exception as e:
34
  raise HTTPException(status_code=500, detail=str(e))
35
 
36
 
37
  @r.get(
38
+ "/{email}",
39
+ response_model=UserResponse,
40
  responses={
41
  200: {"description": "User found"},
42
  404: {"description": "User not found"},
43
  500: {"description": "Internal server error"},
44
  },
45
  )
46
+ async def get_user(email: str, db: AsyncSession = Depends(get_db_session)):
47
+ user = await UserModel.get(db, email=email)
48
  if not user:
49
  raise HTTPException(status_code=404, detail="User not found")
50
  return user
51
 
52
 
53
  @r.put(
54
+ "/{email}",
55
+ response_model=UserResponse,
56
  responses={
57
  200: {"description": "User updated"},
58
  404: {"description": "User not found"},
59
  500: {"description": "Internal server error"},
60
  },
61
  )
62
+ async def update_user(email: str, user_payload: UserUpdate, db: AsyncSession = Depends(get_db_session)):
63
+ try:
64
+ user = await UserModel.get(db, email=email)
65
+ if not user:
66
+ raise HTTPException(status_code=404, detail="User not found")
67
+ await UserModel.update(db, id=user.id, **user_payload.dict())
68
+ user = await UserModel.get(db, email=email)
69
+ return user
70
+ except Exception as e:
71
+ raise HTTPException(status_code=500, detail=str(e))
72
 
73
 
74
  @r.delete(
75
+ "/{email}",
76
+ response_model=UserResponse,
77
  responses={
78
  200: {"description": "User deleted"},
79
  404: {"description": "User not found"},
80
  500: {"description": "Internal server error"},
81
  },
82
  )
83
+ async def delete_user(email: str, db: AsyncSession = Depends(get_db_session)):
84
+ user = await UserModel.get(db, email=email)
85
  if not user:
86
  raise HTTPException(status_code=404, detail="User not found")
87
+ await UserModel.delete(db, email=email)
88
+ user = await UserModel.get(db, email=email)
89
  return user
app/model/base.py CHANGED
@@ -1,10 +1,15 @@
1
  from datetime import datetime, timezone
2
- from sqlalchemy import Column, DateTime, Integer
 
3
 
4
  from app.engine.postgresdb import Base
5
 
6
 
7
  class BaseModel:
8
- id = Column(Integer, primary_key=True, index=True)
9
- created_at = Column(DateTime, default=datetime.now(timezone.utc))
10
- updated_at = Column(DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
 
 
 
 
 
1
  from datetime import datetime, timezone
2
+ from sqlalchemy import DateTime
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
 
5
  from app.engine.postgresdb import Base
6
 
7
 
8
  class BaseModel:
9
+ id: Mapped[int] = mapped_column(primary_key=True, index=True, autoincrement=True)
10
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now(timezone.utc))
11
+ updated_at: Mapped[datetime] = mapped_column(
12
+ DateTime(timezone=True),
13
+ default=datetime.now(timezone.utc),
14
+ onupdate=datetime.now(timezone.utc),
15
+ )
app/model/transaction.py CHANGED
@@ -1,6 +1,9 @@
1
  from datetime import datetime
 
2
  from sqlalchemy import ForeignKey
3
  from sqlalchemy.orm import relationship, Mapped, mapped_column
 
 
4
 
5
  from app.model.base import BaseModel
6
  from app.engine.postgresdb import Base
@@ -16,3 +19,31 @@ class Transaction(Base, BaseModel):
16
 
17
  user_id = mapped_column(ForeignKey("users.id"))
18
  user = relationship("User", back_populates="transactions")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import datetime
2
+ from typing import List
3
  from sqlalchemy import ForeignKey
4
  from sqlalchemy.orm import relationship, Mapped, mapped_column
5
+ from sqlalchemy.sql import expression as sql
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
 
8
  from app.model.base import BaseModel
9
  from app.engine.postgresdb import Base
 
19
 
20
  user_id = mapped_column(ForeignKey("users.id"))
21
  user = relationship("User", back_populates="transactions")
22
+
23
+ @classmethod
24
+ async def create(cls: "type[Transaction]", db: AsyncSession, **kwargs) -> "Transaction":
25
+ query = sql.insert(cls).values(**kwargs).returning(cls.id)
26
+ transactions = await db.scalars(query)
27
+ transaction = transactions.first()
28
+ await db.commit()
29
+ return transaction
30
+
31
+ @classmethod
32
+ async def update(cls: "type[Transaction]", db: AsyncSession, id: int, **kwargs) -> "Transaction":
33
+ query = (
34
+ sql.update(cls)
35
+ .where(cls.id == id)
36
+ .values(**kwargs)
37
+ .execution_options(synchronize_session="fetch")
38
+ .returning(cls.id)
39
+ )
40
+ transactions = await db.scalars(query)
41
+ transaction = transactions.first()
42
+ await db.commit()
43
+ return transaction
44
+
45
+ @classmethod
46
+ async def get_by_user(cls: "type[Transaction]", db: AsyncSession, user_id: int) -> "List[Transaction]":
47
+ query = sql.select(cls).where(cls.user_id == user_id)
48
+ transactions = await db.scalars(query)
49
+ return transactions
app/model/user.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from sqlalchemy.orm import relationship, Mapped, mapped_column
2
  from sqlalchemy.sql import expression as sql
3
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -5,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
5
  from app.model.base import BaseModel
6
  from app.engine.postgresdb import Base
7
 
 
 
8
 
9
  class User(Base, BaseModel):
10
  __tablename__ = "users"
@@ -18,47 +21,40 @@ class User(Base, BaseModel):
18
 
19
  @classmethod
20
  async def create(cls: "type[User]", db: AsyncSession, **kwargs) -> "User":
21
- query = sql.insert(cls).values(**kwargs).returning(cls.id)
22
- users = await db.execute(query)
 
 
 
23
  await db.commit()
24
- return users.first()
25
 
26
  @classmethod
27
  async def update(cls: "type[User]", db: AsyncSession, id: int, **kwargs) -> "User":
28
- query = (
29
- sql.update(cls)
30
- .where(cls.id == id)
31
- .values(**kwargs)
32
- .execution_options(synchronize_session="fetch")
33
- .returning(cls.id)
34
- )
35
- users = await db.execute(query)
36
  await db.commit()
37
- return users.first()
38
-
39
- @classmethod
40
- async def get(cls: "type[User]", db: AsyncSession, id: int) -> "User":
41
- query = sql.select(cls).where(cls.id == id)
42
- users = await db.execute(query)
43
- (user,) = users.first()
44
  return user
45
 
46
  @classmethod
47
  async def get(cls: "type[User]", db: AsyncSession, email: str) -> "User":
 
48
  query = sql.select(cls).where(cls.email == email)
49
- users = await db.execute(query)
50
- (user,) = users.first()
51
- return user
 
52
 
53
  @classmethod
54
- async def delete(cls: "type[User]", db: AsyncSession, id: int) -> "User":
55
  query = (
56
  sql.update(cls)
57
- .where(cls.id == id)
58
  .values(is_deleted=True)
59
  .execution_options(synchronize_session="fetch")
60
- .returning(cls.id)
61
  )
62
- users = await db.execute(query)
 
63
  await db.commit()
64
- return users.first()
 
1
+ import logging
2
  from sqlalchemy.orm import relationship, Mapped, mapped_column
3
  from sqlalchemy.sql import expression as sql
4
  from sqlalchemy.ext.asyncio import AsyncSession
 
6
  from app.model.base import BaseModel
7
  from app.engine.postgresdb import Base
8
 
9
+ logger = logging.getLogger(__name__)
10
+
11
 
12
  class User(Base, BaseModel):
13
  __tablename__ = "users"
 
21
 
22
  @classmethod
23
  async def create(cls: "type[User]", db: AsyncSession, **kwargs) -> "User":
24
+ logging.info(f"Creating user: {kwargs}")
25
+ query = sql.insert(cls).values(**kwargs)
26
+ users = await db.scalars(query)
27
+ user = users.first()
28
+ logging.info(f"User created: {users.first()}")
29
  await db.commit()
30
+ return user
31
 
32
  @classmethod
33
  async def update(cls: "type[User]", db: AsyncSession, id: int, **kwargs) -> "User":
34
+ query = sql.update(cls).where(cls.id == id).values(**kwargs).execution_options(synchronize_session="fetch")
35
+ users = await db.scalars(query)
36
+ user = users.first()
 
 
 
 
 
37
  await db.commit()
 
 
 
 
 
 
 
38
  return user
39
 
40
  @classmethod
41
  async def get(cls: "type[User]", db: AsyncSession, email: str) -> "User":
42
+ logging.info(f"Getting user: {email}")
43
  query = sql.select(cls).where(cls.email == email)
44
+ logging.info(f"Query: {query}")
45
+ users = await db.scalars(query)
46
+ logging.info(f"Users: {users}")
47
+ return users.first()
48
 
49
  @classmethod
50
+ async def delete(cls: "type[User]", db: AsyncSession, email: str) -> "User":
51
  query = (
52
  sql.update(cls)
53
+ .where(cls.email == email)
54
  .values(is_deleted=True)
55
  .execution_options(synchronize_session="fetch")
 
56
  )
57
+ users = await db.scalars(query)
58
+ user = users.first()
59
  await db.commit()
60
+ return user
app/schema/base.py CHANGED
@@ -2,7 +2,11 @@ from datetime import datetime
2
  from pydantic import BaseModel
3
 
4
 
5
- class BaseModel(BaseModel):
 
 
 
 
6
  id: int
7
  created_at: datetime
8
  updated_at: datetime
 
2
  from pydantic import BaseModel
3
 
4
 
5
+ class PydanticBaseModel(BaseModel):
6
+ pass
7
+
8
+
9
+ class BaseModel(PydanticBaseModel):
10
  id: int
11
  created_at: datetime
12
  updated_at: datetime
app/schema/index.py CHANGED
@@ -1,8 +1,8 @@
1
  from enum import Enum
2
  from datetime import datetime
3
- from typing import List
4
 
5
- from app.schema.base import BaseModel
6
 
7
 
8
  class TransactionType(str, Enum):
@@ -10,12 +10,25 @@ class TransactionType(str, Enum):
10
  EXPENSE = "expense"
11
 
12
 
13
- class UserCreate(BaseModel):
14
  name: str
15
  email: str
16
  hashed_password: str
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class User(BaseModel):
20
  name: str
21
  email: str
@@ -24,10 +37,13 @@ class User(BaseModel):
24
  transactions: "List[Transaction]" = []
25
 
26
 
27
- class Transaction(BaseModel):
28
  transaction_date: datetime
29
  category: str
30
  name_description: str
31
  amount: float
32
  type: TransactionType
 
 
 
33
  user: User
 
1
  from enum import Enum
2
  from datetime import datetime
3
+ from typing import List, Optional
4
 
5
+ from app.schema.base import BaseModel, PydanticBaseModel
6
 
7
 
8
  class TransactionType(str, Enum):
 
10
  EXPENSE = "expense"
11
 
12
 
13
+ class UserCreate(PydanticBaseModel):
14
  name: str
15
  email: str
16
  hashed_password: str
17
 
18
 
19
+ class UserUpdate(PydanticBaseModel):
20
+ name: str
21
+ email: str
22
+ hashed_password: str
23
+
24
+
25
+ class UserResponse(PydanticBaseModel):
26
+ id: int
27
+ name: str
28
+ email: str
29
+ is_deleted: bool
30
+
31
+
32
  class User(BaseModel):
33
  name: str
34
  email: str
 
37
  transactions: "List[Transaction]" = []
38
 
39
 
40
+ class TransactionResponse(PydanticBaseModel):
41
  transaction_date: datetime
42
  category: str
43
  name_description: str
44
  amount: float
45
  type: TransactionType
46
+
47
+
48
+ class Transaction(TransactionResponse):
49
  user: User
config/index.py CHANGED
@@ -26,9 +26,11 @@ class Config:
26
  POSTGRES_USER = os.getenv("POSTGRES_USER")
27
  POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
28
  POSTGRES_DB_NAME = os.getenv("POSTGRES_DB_NAME")
 
29
  POSTGRES_DB_HOST = os.getenv("POSTGRES_DB_HOST")
30
  POSTGRES_DB_PORT = os.getenv("POSTGRES_DB_PORT")
31
  SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_DB_HOST}:{POSTGRES_DB_PORT}/{POSTGRES_DB_NAME}"
 
32
 
33
 
34
  config = Config
 
26
  POSTGRES_USER = os.getenv("POSTGRES_USER")
27
  POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
28
  POSTGRES_DB_NAME = os.getenv("POSTGRES_DB_NAME")
29
+ POSTGRES_TEST_DB_NAME = os.getenv("POSTGRES_TEST_DB_NAME")
30
  POSTGRES_DB_HOST = os.getenv("POSTGRES_DB_HOST")
31
  POSTGRES_DB_PORT = os.getenv("POSTGRES_DB_PORT")
32
  SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_DB_HOST}:{POSTGRES_DB_PORT}/{POSTGRES_DB_NAME}"
33
+ SQLALCHEMY_TEST_DATABASE_URL = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_DB_HOST}:{POSTGRES_DB_PORT}/{POSTGRES_TEST_DB_NAME}"
34
 
35
 
36
  config = Config
main.py CHANGED
@@ -7,6 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.responses import RedirectResponse
8
  from app.api.routers.chat import chat_router
9
  from app.api.routers.user import user_router
 
10
  from app.settings import init_settings
11
  from fastapi.staticfiles import StaticFiles
12
  from alembic.config import Config
@@ -61,6 +62,7 @@ def init_app():
61
  app.mount("/api/data", StaticFiles(directory="data"), name="static")
62
  app.include_router(chat_router, prefix="/api/chat")
63
  app.include_router(user_router)
 
64
 
65
  return app
66
 
 
7
  from fastapi.responses import RedirectResponse
8
  from app.api.routers.chat import chat_router
9
  from app.api.routers.user import user_router
10
+ from app.api.routers.transaction import transaction_router
11
  from app.settings import init_settings
12
  from fastapi.staticfiles import StaticFiles
13
  from alembic.config import Config
 
62
  app.mount("/api/data", StaticFiles(directory="data"), name="static")
63
  app.include_router(chat_router, prefix="/api/chat")
64
  app.include_router(user_router)
65
+ app.include_router(transaction_router)
66
 
67
  return app
68
 
migration/env.py CHANGED
@@ -22,7 +22,8 @@ if config.config_file_name is not None:
22
  fileConfig(config.config_file_name)
23
 
24
  # Import all models so they're registered with SQLAlchemy.
25
- from app.model import *
 
26
 
27
  # add your model's MetaData object here
28
  # for 'autogenerate' support
 
22
  fileConfig(config.config_file_name)
23
 
24
  # Import all models so they're registered with SQLAlchemy.
25
+ import app.model.transaction
26
+ import app.model.user
27
 
28
  # add your model's MetaData object here
29
  # for 'autogenerate' support
migration/versions/7ea44cbc5b1f_default_timezone.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """default_timezone
2
+
3
+ Revision ID: 7ea44cbc5b1f
4
+ Revises: 8feaedca36f9
5
+ Create Date: 2024-06-02 14:36:01.552518
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+ from sqlalchemy.dialects import postgresql
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "7ea44cbc5b1f"
17
+ down_revision: Union[str, None] = "8feaedca36f9"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.alter_column(
25
+ "transactions",
26
+ "created_at",
27
+ existing_type=postgresql.TIMESTAMP(),
28
+ type_=sa.DateTime(timezone=True),
29
+ nullable=False,
30
+ )
31
+ op.alter_column(
32
+ "transactions",
33
+ "updated_at",
34
+ existing_type=postgresql.TIMESTAMP(),
35
+ type_=sa.DateTime(timezone=True),
36
+ nullable=False,
37
+ )
38
+ op.alter_column(
39
+ "users", "created_at", existing_type=postgresql.TIMESTAMP(), type_=sa.DateTime(timezone=True), nullable=False
40
+ )
41
+ op.alter_column(
42
+ "users", "updated_at", existing_type=postgresql.TIMESTAMP(), type_=sa.DateTime(timezone=True), nullable=False
43
+ )
44
+ # ### end Alembic commands ###
45
+
46
+
47
+ def downgrade() -> None:
48
+ # ### commands auto generated by Alembic - please adjust! ###
49
+ op.alter_column(
50
+ "users", "updated_at", existing_type=sa.DateTime(timezone=True), type_=postgresql.TIMESTAMP(), nullable=True
51
+ )
52
+ op.alter_column(
53
+ "users", "created_at", existing_type=sa.DateTime(timezone=True), type_=postgresql.TIMESTAMP(), nullable=True
54
+ )
55
+ op.alter_column(
56
+ "transactions",
57
+ "updated_at",
58
+ existing_type=sa.DateTime(timezone=True),
59
+ type_=postgresql.TIMESTAMP(),
60
+ nullable=True,
61
+ )
62
+ op.alter_column(
63
+ "transactions",
64
+ "created_at",
65
+ existing_type=sa.DateTime(timezone=True),
66
+ type_=postgresql.TIMESTAMP(),
67
+ nullable=True,
68
+ )
69
+ # ### end Alembic commands ###
migration/versions/8feaedca36f9_users_datatype_updates.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Remove_Transactions_From_Users
2
+
3
+ Revision ID: 8feaedca36f9
4
+ Revises: cd515c44401d
5
+ Create Date: 2024-06-02 01:26:54.731002
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import postgresql
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '8feaedca36f9'
16
+ down_revision: Union[str, None] = 'cd515c44401d'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ op.alter_column('transactions', 'transaction_date',
24
+ existing_type=postgresql.TIMESTAMP(),
25
+ nullable=False)
26
+ op.alter_column('transactions', 'category',
27
+ existing_type=sa.VARCHAR(),
28
+ nullable=False)
29
+ op.alter_column('transactions', 'name_description',
30
+ existing_type=sa.VARCHAR(),
31
+ nullable=False)
32
+ op.alter_column('transactions', 'amount',
33
+ existing_type=sa.DOUBLE_PRECISION(precision=53),
34
+ nullable=False)
35
+ op.alter_column('transactions', 'type',
36
+ existing_type=sa.VARCHAR(),
37
+ nullable=False)
38
+ op.add_column('users', sa.Column('hashed_password', sa.String(), nullable=False))
39
+ op.add_column('users', sa.Column('is_deleted', sa.Boolean(), nullable=False))
40
+ op.alter_column('users', 'name',
41
+ existing_type=sa.VARCHAR(),
42
+ nullable=False)
43
+ op.alter_column('users', 'email',
44
+ existing_type=sa.VARCHAR(),
45
+ nullable=False)
46
+ # ### end Alembic commands ###
47
+
48
+
49
+ def downgrade() -> None:
50
+ # ### commands auto generated by Alembic - please adjust! ###
51
+ op.alter_column('users', 'email',
52
+ existing_type=sa.VARCHAR(),
53
+ nullable=True)
54
+ op.alter_column('users', 'name',
55
+ existing_type=sa.VARCHAR(),
56
+ nullable=True)
57
+ op.drop_column('users', 'is_deleted')
58
+ op.drop_column('users', 'hashed_password')
59
+ op.alter_column('transactions', 'type',
60
+ existing_type=sa.VARCHAR(),
61
+ nullable=True)
62
+ op.alter_column('transactions', 'amount',
63
+ existing_type=sa.DOUBLE_PRECISION(precision=53),
64
+ nullable=True)
65
+ op.alter_column('transactions', 'name_description',
66
+ existing_type=sa.VARCHAR(),
67
+ nullable=True)
68
+ op.alter_column('transactions', 'category',
69
+ existing_type=sa.VARCHAR(),
70
+ nullable=True)
71
+ op.alter_column('transactions', 'transaction_date',
72
+ existing_type=postgresql.TIMESTAMP(),
73
+ nullable=True)
74
+ # ### end Alembic commands ###
tests/conftest.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Credits: https://github.com/ThomasAitken/demo-fastapi-async-sqlalchemy/blob/main/backend/app/conftest.py
2
+
3
+ import asyncio
4
+ from contextlib import ExitStack
5
+
6
+ import pytest
7
+ from alembic.config import Config
8
+ from alembic.migration import MigrationContext
9
+ from alembic.operations import Operations
10
+ from alembic.script import ScriptDirectory
11
+ from config.index import config as settings
12
+ from app.engine.postgresdb import Base, get_db_session, postgresdb as sessionmanager
13
+ from main import app as actual_app
14
+ from asyncpg import Connection
15
+ from fastapi.testclient import TestClient
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def app():
20
+ with ExitStack():
21
+ yield actual_app
22
+
23
+
24
+ @pytest.fixture
25
+ def client(app):
26
+ with TestClient(app) as c:
27
+ yield c
28
+
29
+
30
+ @pytest.fixture(scope="session")
31
+ def event_loop(request):
32
+ loop = asyncio.get_event_loop_policy().new_event_loop()
33
+ yield loop
34
+ loop.close()
35
+
36
+
37
+ def run_migrations(connection: Connection):
38
+ config = Config("alembic.ini")
39
+ config.set_main_option("script_location", "app/migration")
40
+ config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_TEST_DATABASE_URL)
41
+ script = ScriptDirectory.from_config(config)
42
+
43
+ def upgrade(rev, context):
44
+ return script._upgrade_revs("head", rev)
45
+
46
+ context = MigrationContext.configure(connection, opts={"target_metadata": Base.metadata, "fn": upgrade})
47
+
48
+ with context.begin_transaction():
49
+ with Operations.context(context):
50
+ context.run_migrations()
51
+
52
+
53
+ @pytest.fixture(scope="session", autouse=True)
54
+ async def setup_database():
55
+ # Run alembic migrations on test DB
56
+ async with sessionmanager.connect() as connection:
57
+ await connection.run_sync(run_migrations)
58
+
59
+ yield
60
+
61
+ # Teardown
62
+ await sessionmanager.close()
63
+
64
+
65
+ # Each test function is a clean slate
66
+ @pytest.fixture(scope="function", autouse=True)
67
+ async def transactional_session():
68
+ async with sessionmanager.session() as session:
69
+ try:
70
+ await session.begin()
71
+ yield session
72
+ finally:
73
+ await session.rollback() # Rolls back the outer transaction
74
+
75
+
76
+ @pytest.fixture(scope="function")
77
+ async def db_session(transactional_session):
78
+ yield transactional_session
79
+
80
+
81
+ @pytest.fixture(scope="function", autouse=True)
82
+ async def session_override(app, db_session):
83
+ async def get_db_session_override():
84
+ yield db_session[0]
85
+
86
+ app.dependency_overrides[get_db_session] = get_db_session_override
tests/pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
tests/test_postgresdb_connection.py DELETED
@@ -1,15 +0,0 @@
1
- import pytest
2
- from app.engine.postgresdb import get_db
3
-
4
-
5
- @pytest.mark.skip
6
- def test_postgres_db_connection():
7
- """
8
- Tests the connection to the postgres db
9
- """
10
- with get_db() as session:
11
- # Execute a query to test the connection
12
- result = session.execute(f"SELECT 1").scalar()
13
-
14
- # Assert that the result is equal to 1
15
- assert result == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_users_crud.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from httpx import AsyncClient
3
+ import pytest
4
+
5
+ from app.core.config import settings
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_users_crud(httpx_async_client: AsyncClient):
10
+ # Test CREATE
11
+ user_data = {
12
+ "name": "John Doe",
13
+ "email": "john.doe@example.com",
14
+ "password": "password123",
15
+ }
16
+ response = await httpx_async_client.post(
17
+ f"{settings.API_V1_STR}/users/",
18
+ data=json.dumps(user_data),
19
+ headers={"Content-Type": "application/json"},
20
+ )
21
+ assert response.status_code == 200
22
+ user_id = response.json()["user_id"]
23
+
24
+ # Test READ
25
+ response = await httpx_async_client.get(f"{settings.API_V1_STR}/users/{user_id}")
26
+ assert response.status_code == 200
27
+ user = response.json()
28
+ assert user["name"] == user_data["name"]
29
+ assert user["email"] == user_data["email"]
30
+ assert "password" not in user
31
+
32
+ # Test UPDATE
33
+ updated_user_data = {
34
+ "name": "Jane Doe",
35
+ }
36
+ response = await httpx_async_client.put(
37
+ f"{settings.API_V1_STR}/users/{user_id}",
38
+ data=json.dumps(updated_user_data),
39
+ headers={"Content-Type": "application/json"},
40
+ )
41
+ assert response.status_code == 200
42
+ updated_user = response.json()
43
+ assert updated_user["name"] == updated_user_data["name"]
44
+ assert updated_user["email"] == user_data["email"]
45
+ assert "password" not in updated_user
46
+
47
+ # Test DELETE
48
+ response = await httpx_async_client.delete(f"{settings.API_V1_STR}/users/{user_id}")
49
+ assert response.status_code == 200