Spaces:
Sleeping
Sleeping
praneethys
commited on
Commit
•
81ec8d6
1
Parent(s):
f92858b
transactions endpoints and pytests (#6)
Browse files- feat: transactions router (a4ec21611e3d805e9794849cef9670d942b66bfb)
- test: setup pytests (312b13fe2aa3dd3313a4ed8b7ed867970b8cecae)
- test: pytests for users crud (4192f9373528dddb14adf8a5ba898d041bd67604)
- fix: fix users endpoints (d4e18c8e44180217438085404a32110734c56f17)
- fix: fix transaction api responses (114f9f75c844e20b2b1c4dff8b928cedf5bba63b)
- .env.example +2 -2
- Dockerfile +3 -0
- app/api/routers/transaction.py +29 -0
- app/api/routers/user.py +27 -23
- app/model/base.py +9 -4
- app/model/transaction.py +31 -0
- app/model/user.py +22 -26
- app/schema/base.py +5 -1
- app/schema/index.py +20 -4
- config/index.py +2 -0
- main.py +2 -0
- migration/env.py +2 -1
- migration/versions/7ea44cbc5b1f_default_timezone.py +69 -0
- migration/versions/8feaedca36f9_users_datatype_updates.py +74 -0
- tests/conftest.py +86 -0
- tests/pytest.ini +2 -0
- tests/test_postgresdb_connection.py +0 -15
- tests/test_users_crud.py +49 -0
.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=
|
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=
|
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 |
-
|
32 |
-
|
33 |
-
return
|
34 |
except Exception as e:
|
35 |
raise HTTPException(status_code=500, detail=str(e))
|
36 |
|
37 |
|
38 |
@r.get(
|
39 |
-
"/{
|
40 |
-
response_model=
|
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(
|
48 |
-
user = await UserModel.get(db,
|
49 |
if not user:
|
50 |
raise HTTPException(status_code=404, detail="User not found")
|
51 |
return user
|
52 |
|
53 |
|
54 |
@r.put(
|
55 |
-
"/{
|
56 |
-
response_model=
|
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(
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
|
|
|
|
|
|
|
|
69 |
|
70 |
|
71 |
@r.delete(
|
72 |
-
"/{
|
73 |
-
response_model=
|
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(
|
81 |
-
user = await UserModel.get(db,
|
82 |
if not user:
|
83 |
raise HTTPException(status_code=404, detail="User not found")
|
84 |
-
|
|
|
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
|
|
|
3 |
|
4 |
from app.engine.postgresdb import Base
|
5 |
|
6 |
|
7 |
class BaseModel:
|
8 |
-
id =
|
9 |
-
created_at =
|
10 |
-
updated_at
|
|
|
|
|
|
|
|
|
|
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 |
-
|
22 |
-
|
|
|
|
|
|
|
23 |
await db.commit()
|
24 |
-
return
|
25 |
|
26 |
@classmethod
|
27 |
async def update(cls: "type[User]", db: AsyncSession, id: int, **kwargs) -> "User":
|
28 |
-
query = (
|
29 |
-
|
30 |
-
|
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 |
-
|
50 |
-
|
51 |
-
|
|
|
52 |
|
53 |
@classmethod
|
54 |
-
async def delete(cls: "type[User]", db: AsyncSession,
|
55 |
query = (
|
56 |
sql.update(cls)
|
57 |
-
.where(cls.
|
58 |
.values(is_deleted=True)
|
59 |
.execution_options(synchronize_session="fetch")
|
60 |
-
.returning(cls.id)
|
61 |
)
|
62 |
-
users = await db.
|
|
|
63 |
await db.commit()
|
64 |
-
return
|
|
|
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
|
|
|
|
|
|
|
|
|
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(
|
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
|
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 |
-
|
|
|
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
|