Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
default_language_version:
python: python3.11
python: python3.12
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ services:
- 27017:27017
restart: on-failure
environment:
- MONGODB_ADVERTISED_HOSTNAME=localhost
- ALLOW_EMPTY_PASSWORD=yes
- MONGODB_ADVERTISED_HOSTNAME=${MONGO_HOST}
- MONGODB_ROOT_USER=${MONGO_ROOT_USER}
- MONGODB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
344 changes: 331 additions & 13 deletions poetry.lock

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
[tool.poetry]
name = "tdd project"
name = "store"
version = "0.0.1"
description = ""
authors = ["Nayanna Nara <nayanna501@gmail.com>"]
readme = "README.md"
# package-mode = false

[tool.poetry.dependencies]
python = "^3.12"
python = ">=3.12,<4.0"
fastapi = "^0.104.1"
uvicorn = "^0.24.0.post1"
pydantic = "^2.5.1"
Expand All @@ -17,13 +18,12 @@ pytest-asyncio = "^0.21.1"
pre-commit = "^3.5.0"
httpx = "^0.25.1"

[tool.poetry.group.dev.dependencies]
ipdb = "^0.13.13"

[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = [
"--strict-config",
"--strict-markers",
"--ignore=docs_src",
]
addopts = ["--strict-config", "--strict-markers", "--ignore=docs_src"]
xfail_strict = true
junit_family = "xunit2"

Expand Down
27 changes: 19 additions & 8 deletions store/controllers/product.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
from pydantic import UUID4
from store.core.exceptions import NotFoundException
from store.core.exceptions import InsertionException, NotFoundException

from store.schemas.product import ProductIn, ProductOut, ProductUpdate, ProductUpdateOut
from store.usecases.product import ProductUsecase
Expand All @@ -13,7 +14,10 @@
async def post(
body: ProductIn = Body(...), usecase: ProductUsecase = Depends()
) -> ProductOut:
return await usecase.create(body=body)
try:
return await usecase.create(body=body)
except InsertionException as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=exc.message)


@router.get(path="/{id}", status_code=status.HTTP_200_OK)
Expand All @@ -27,17 +31,24 @@ async def get(


@router.get(path="/", status_code=status.HTTP_200_OK)
async def query(usecase: ProductUsecase = Depends()) -> List[ProductOut]:
return await usecase.query()
async def query(
min_price: Optional[Decimal] = Query(None, description="Minimum price filter"),
max_price: Optional[Decimal] = Query(None, description="Maximum price filter"),
usecase: ProductUsecase = Depends(),
) -> List[ProductOut]:
return await usecase.query(min_price=min_price, max_price=max_price)


@router.patch(path="/{id}", status_code=status.HTTP_200_OK)
async def patch(
id: UUID4 = Path(alias="id"),
body: ProductUpdate = Body(...),
usecase: ProductUsecase = Depends(),
) -> ProductUpdateOut:
return await usecase.update(id=id, body=body)
) -> Optional[ProductUpdateOut]:
try:
return await usecase.update(id=id, body=body)
except NotFoundException as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=exc.message)


@router.delete(path="/{id}", status_code=status.HTTP_204_NO_CONTENT)
Expand Down
15 changes: 13 additions & 2 deletions store/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "Store API"
ROOT_PATH: str = "/"

DATABASE_URL: str
MONGO_HOST: str
MONGO_ROOT_USER: str
MONGO_ROOT_PASSWORD: str
MONGO_DB_PORT: int = 27017
MONGO_DB_NAME: str = "banco_store"

@property
def DATABASE_URL(self) -> str:
return (
f"mongodb://{self.MONGO_ROOT_USER}:{self.MONGO_ROOT_PASSWORD}"
f"@{self.MONGO_HOST}:{self.MONGO_DB_PORT}/{self.MONGO_DB_NAME}?authSource=admin"
)

model_config = SettingsConfigDict(env_file=".env")


settings = Settings()
settings = Settings() # type: ignore
4 changes: 4 additions & 0 deletions store/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ def __init__(self, message: str | None = None) -> None:

class NotFoundException(BaseException):
message = "Not Found"


class InsertionException(BaseException):
message = "Falha ao inserir produto"
6 changes: 4 additions & 2 deletions store/db/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

class MongoClient:
def __init__(self) -> None:
self.client: AsyncIOMotorClient = AsyncIOMotorClient(settings.DATABASE_URL)
self.client: "AsyncIOMotorClient" = AsyncIOMotorClient( # type: ignore
settings.DATABASE_URL, uuidRepresentation="standard"
)

def get(self) -> AsyncIOMotorClient:
def get(self) -> "AsyncIOMotorClient": # type: ignore
return self.client


Expand Down
6 changes: 3 additions & 3 deletions store/models/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
import uuid
Expand All @@ -8,8 +8,8 @@

class CreateBaseModel(BaseModel):
id: UUID4 = Field(default_factory=uuid.uuid4)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

@model_serializer
def set_model(self) -> dict[str, Any]:
Expand Down
11 changes: 7 additions & 4 deletions store/schemas/base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from bson import Decimal128
from pydantic import UUID4, BaseModel, Field, model_validator


class BaseSchemaMixin(BaseModel):
class Config:
from_attributes = True
model_config = {
"from_attributes": True,
"json_encoders": {Decimal128: lambda v: str(Decimal(str(v)))},
}


class OutSchema(BaseModel):
Expand All @@ -19,5 +21,6 @@ def set_schema(cls, data):
for key, value in data.items():
if isinstance(value, Decimal128):
data[key] = Decimal(str(value))

elif isinstance(value, datetime) and value.tzinfo is None:
data[key] = value.replace(tzinfo=timezone.utc)
return data
2 changes: 2 additions & 0 deletions store/schemas/product.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Optional
from bson import Decimal128
Expand Down Expand Up @@ -31,6 +32,7 @@ class ProductUpdate(BaseSchemaMixin):
quantity: Optional[int] = Field(None, description="Product quantity")
price: Optional[Decimal_] = Field(None, description="Product price")
status: Optional[bool] = Field(None, description="Product status")
updated_at: Optional[datetime] = Field(None, description="Product update timestamp")


class ProductUpdateOut(ProductOut):
Expand Down
51 changes: 43 additions & 8 deletions store/usecases/product.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
from typing import List
from decimal import Decimal
from typing import List, Optional
from uuid import UUID
from datetime import datetime, timezone
from bson import Decimal128
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
import pymongo
from store.db.mongo import db_client
from store.models.product import ProductModel
from store.schemas.product import ProductIn, ProductOut, ProductUpdate, ProductUpdateOut
from store.core.exceptions import NotFoundException
from store.core.exceptions import InsertionException, NotFoundException


class ProductUsecase:
def __init__(self) -> None:
self.client: AsyncIOMotorClient = db_client.get()
self.database: AsyncIOMotorDatabase = self.client.get_database()
client = db_client.get()
database = client.get_database()
self.client: "AsyncIOMotorClient" = client # type: ignore
self.database: "AsyncIOMotorDatabase" = database # type: ignore
self.collection = self.database.get_collection("products")

async def create(self, body: ProductIn) -> ProductOut:
existing = await self.collection.find_one({"name": body.name})
if existing:
raise InsertionException(f"Produto de nome '{body.name}' já existe.")

product_model = ProductModel(**body.model_dump())
await self.collection.insert_one(product_model.model_dump())

Expand All @@ -28,15 +37,41 @@ async def get(self, id: UUID) -> ProductOut:

return ProductOut(**result)

async def query(self) -> List[ProductOut]:
return [ProductOut(**item) async for item in self.collection.find()]
async def query(
self, min_price: Optional[Decimal] = None, max_price: Optional[Decimal] = None
) -> List[ProductOut]:
# Converte os valores Decimal para Decimal128
query = {}

if min_price is not None or max_price is not None:
price_query = {}
if min_price is not None:
price_query["$gte"] = Decimal128(str(min_price))
if max_price is not None:
price_query["$lte"] = Decimal128(str(max_price))
query["price"] = price_query

# Executa a consulta diretamente
cursor = self.collection.find(query)
return [ProductOut(**item) async for item in cursor]

async def update(self, id: UUID, body: ProductUpdate) -> ProductUpdateOut:
update_data = body.model_dump(exclude_none=True)
if "updated_at" in update_data:
if isinstance(update_data["updated_at"], str):
update_data["updated_at"] = datetime.fromisoformat(
update_data["updated_at"]
)
else:
update_data["updated_at"] = datetime.now(timezone.utc)

result = await self.collection.find_one_and_update(
filter={"id": id},
update={"$set": body.model_dump(exclude_none=True)},
update={"$set": update_data},
return_document=pymongo.ReturnDocument.AFTER,
)
if not result:
raise NotFoundException(message=f"Produto não encontrado com id : {id}")

return ProductUpdateOut(**result)

Expand All @@ -47,7 +82,7 @@ async def delete(self, id: UUID) -> bool:

result = await self.collection.delete_one({"id": id})

return True if result.deleted_count > 0 else False
return result.deleted_count > 0


product_usecase = ProductUsecase()
12 changes: 6 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from store.schemas.product import ProductIn, ProductUpdate
from store.usecases.product import product_usecase
from tests.factories import product_data, products_data
from httpx import AsyncClient
import httpx


@pytest.fixture(scope="session")
Expand All @@ -33,11 +33,11 @@ async def clear_collections(mongo_client):


@pytest.fixture
async def client() -> AsyncClient:
async def client() -> "httpx.AsyncClient": # type: ignore
from store.main import app

async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
async with httpx.AsyncClient(app=app, base_url="http://test") as ac:
yield ac # pyright: ignore[reportReturnType]


@pytest.fixture
Expand All @@ -52,12 +52,12 @@ def product_id() -> UUID:

@pytest.fixture
def product_in(product_id):
return ProductIn(**product_data(), id=product_id)
return ProductIn(**product_data(), id=product_id) # type: ignore


@pytest.fixture
def product_up(product_id):
return ProductUpdate(**product_data(), id=product_id)
return ProductUpdate(**product_data(), id=product_id) # type: ignore


@pytest.fixture
Expand Down
Loading