diff --git a/.gitignore b/.gitignore index 18f79fe..c8d408e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ venv/ .pytest_cache/ .ruff_cache/ *.egg-info/ +.coverage +htmlcov/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..00e0b30 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index d1f9e8d..8e60cac 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,13 @@ API REST para gestión de gastos personales. Proyecto de portfolio con foco en * python -m venv .venv source .venv/bin/activate pip install -r requirements-dev.txt +pre-commit install alembic upgrade head uvicorn app.main:app --reload ``` +`pre-commit install` deja `ruff check --fix` y `ruff format` corriendo antes de cada commit, así un fallo de lint se detecta en tu máquina y no en el CI. + Docs interactivas en `http://localhost:8000/docs`. ## Migraciones (Alembic) diff --git a/alembic/env.py b/alembic/env.py index 248d2c3..c7f4033 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -65,9 +65,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/896f574a4c93_create_initial_schema.py b/alembic/versions/896f574a4c93_create_initial_schema.py index 4079eb4..fa3f0c8 100644 --- a/alembic/versions/896f574a4c93_create_initial_schema.py +++ b/alembic/versions/896f574a4c93_create_initial_schema.py @@ -1,10 +1,11 @@ """create initial schema Revision ID: 896f574a4c93 -Revises: +Revises: Create Date: 2026-08-12 09:28:21.452383 """ + from typing import Sequence, Union from alembic import op @@ -12,7 +13,7 @@ # revision identifiers, used by Alembic. -revision: str = '896f574a4c93' +revision: str = "896f574a4c93" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -20,40 +21,52 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('hashed_password', sa.String(length=255), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_table('categories', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('expenses', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False), - sa.Column('description', sa.String(length=255), nullable=False), - sa.Column('date', sa.Date(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('category_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + "expenses", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("description", sa.String(length=255), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('expenses') - op.drop_table('categories') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') + op.drop_table("expenses") + op.drop_table("categories") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") # ### end Alembic commands ### diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index c4ad3e4..8f5d531 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -16,7 +16,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)) -> User: existing = db.query(User).filter(User.email == user_in.email).first() if existing: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" + ) user = User(email=user_in.email, hashed_password=hash_password(user_in.password)) db.add(user) @@ -28,7 +30,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)) -> User: @router.post("/login", response_model=Token) @limiter.limit("5/minute") def login( - request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) + request: Request, + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db), ) -> Token: user = db.query(User).filter(User.email == form_data.username).first() if not user or not verify_password(form_data.password, user.hashed_password): diff --git a/app/core/security.py b/app/core/security.py index e907daf..b4834d7 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -18,14 +18,18 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def create_access_token(subject: str) -> str: - expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.access_token_expire_minutes + ) payload = {"sub": subject, "exp": expire} return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) def decode_access_token(token: str) -> Optional[str]: try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.algorithm] + ) return payload.get("sub") except JWTError: return None diff --git a/app/db/session.py b/app/db/session.py index d0c47b5..2fabc69 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -5,7 +5,9 @@ from app.core.config import settings -connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +connect_args = ( + {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +) engine = create_engine(settings.database_url, connect_args=connect_args) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/main.py b/app/main.py index 3399cb8..5bf6adf 100644 --- a/app/main.py +++ b/app/main.py @@ -53,9 +53,13 @@ def http_exception_handler(request, exc: StarletteHTTPException) -> JSONResponse @app.exception_handler(RequestValidationError) def validation_exception_handler(request, exc: RequestValidationError) -> JSONResponse: - return JSONResponse(status_code=422, content={"error": "Validation failed", "details": exc.errors()}) + return JSONResponse( + status_code=422, content={"error": "Validation failed", "details": exc.errors()} + ) @app.exception_handler(RateLimitExceeded) def rate_limit_exceeded_handler(request, exc: RateLimitExceeded) -> JSONResponse: - return JSONResponse(status_code=429, content={"error": f"Rate limit exceeded: {exc.detail}"}) + return JSONResponse( + status_code=429, content={"error": f"Rate limit exceeded: {exc.detail}"} + ) diff --git a/app/models/expense.py b/app/models/expense.py index 6f1ad46..b2b2024 100644 --- a/app/models/expense.py +++ b/app/models/expense.py @@ -19,10 +19,14 @@ class Expense(Base): amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) description: Mapped[str] = mapped_column(String(255), nullable=False) date: Mapped[date_] = mapped_column(Date, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc) + ) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False) + category_id: Mapped[int] = mapped_column( + ForeignKey("categories.id"), nullable=False + ) owner: Mapped["User"] = relationship(back_populates="expenses") category: Mapped["Category"] = relationship(back_populates="expenses") diff --git a/app/models/user.py b/app/models/user.py index c7f86cd..133eeda 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -15,9 +15,17 @@ class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + email: Mapped[str] = mapped_column( + String(255), unique=True, index=True, nullable=False + ) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc) + ) - categories: Mapped[list["Category"]] = relationship(back_populates="owner", cascade="all, delete-orphan") - expenses: Mapped[list["Expense"]] = relationship(back_populates="owner", cascade="all, delete-orphan") + categories: Mapped[list["Category"]] = relationship( + back_populates="owner", cascade="all, delete-orphan" + ) + expenses: Mapped[list["Expense"]] = relationship( + back_populates="owner", cascade="all, delete-orphan" + ) diff --git a/app/services/category_service.py b/app/services/category_service.py index eda5557..5c20ce0 100644 --- a/app/services/category_service.py +++ b/app/services/category_service.py @@ -5,7 +5,9 @@ from app.schemas.category import CategoryCreate, CategoryUpdate -def list_categories(db: Session, user_id: int, skip: int = 0, limit: int = 50) -> list[Category]: +def list_categories( + db: Session, user_id: int, skip: int = 0, limit: int = 50 +) -> list[Category]: return ( db.query(Category) .filter(Category.user_id == user_id) @@ -26,14 +28,20 @@ def create_category(db: Session, user_id: int, data: CategoryCreate) -> Category def get_category_or_404(db: Session, user_id: int, category_id: int) -> Category: category = ( - db.query(Category).filter(Category.id == category_id, Category.user_id == user_id).first() + db.query(Category) + .filter(Category.id == category_id, Category.user_id == user_id) + .first() ) if category is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Category not found" + ) return category -def update_category(db: Session, user_id: int, category_id: int, data: CategoryUpdate) -> Category: +def update_category( + db: Session, user_id: int, category_id: int, data: CategoryUpdate +) -> Category: category = get_category_or_404(db, user_id, category_id) if data.name is not None: category.name = data.name diff --git a/app/services/expense_service.py b/app/services/expense_service.py index fa54954..cea06d8 100644 --- a/app/services/expense_service.py +++ b/app/services/expense_service.py @@ -6,7 +6,9 @@ from app.services.category_service import get_category_or_404 -def list_expenses(db: Session, user_id: int, skip: int = 0, limit: int = 50) -> list[Expense]: +def list_expenses( + db: Session, user_id: int, skip: int = 0, limit: int = 50 +) -> list[Expense]: return ( db.query(Expense) .filter(Expense.user_id == user_id) @@ -33,13 +35,21 @@ def create_expense(db: Session, user_id: int, data: ExpenseCreate) -> Expense: def get_expense_or_404(db: Session, user_id: int, expense_id: int) -> Expense: - expense = db.query(Expense).filter(Expense.id == expense_id, Expense.user_id == user_id).first() + expense = ( + db.query(Expense) + .filter(Expense.id == expense_id, Expense.user_id == user_id) + .first() + ) if expense is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expense not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Expense not found" + ) return expense -def update_expense(db: Session, user_id: int, expense_id: int, data: ExpenseUpdate) -> Expense: +def update_expense( + db: Session, user_id: int, expense_id: int, data: ExpenseUpdate +) -> Expense: expense = get_expense_or_404(db, user_id, expense_id) if data.category_id is not None: get_category_or_404(db, user_id, data.category_id) diff --git a/requirements-dev.txt b/requirements-dev.txt index 47f95ec..e276d9e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ pytest==8.3.3 pytest-cov==5.0.0 httpx==0.27.2 ruff==0.6.9 +pre-commit==4.3.0 diff --git a/tests/conftest.py b/tests/conftest.py index 7a2c398..65e84a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,7 +47,9 @@ def override_get_db(): @pytest.fixture() def auth_headers(client): - client.post("/auth/register", json={"email": "test@example.com", "password": "secret123"}) + client.post( + "/auth/register", json={"email": "test@example.com", "password": "secret123"} + ) response = client.post( "/auth/login", data={"username": "test@example.com", "password": "secret123"}, diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 3eb8792..792b74a 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -1,5 +1,7 @@ def test_register_and_login(client): - response = client.post("/auth/register", json={"email": "a@example.com", "password": "secret123"}) + response = client.post( + "/auth/register", json={"email": "a@example.com", "password": "secret123"} + ) assert response.status_code == 201 assert response.json()["email"] == "a@example.com" @@ -11,13 +13,19 @@ def test_register_and_login(client): def test_register_duplicate_email_fails(client): - client.post("/auth/register", json={"email": "dup@example.com", "password": "secret123"}) - response = client.post("/auth/register", json={"email": "dup@example.com", "password": "secret123"}) + client.post( + "/auth/register", json={"email": "dup@example.com", "password": "secret123"} + ) + response = client.post( + "/auth/register", json={"email": "dup@example.com", "password": "secret123"} + ) assert response.status_code == 400 def test_login_wrong_password_fails(client): - client.post("/auth/register", json={"email": "b@example.com", "password": "secret123"}) + client.post( + "/auth/register", json={"email": "b@example.com", "password": "secret123"} + ) response = client.post( "/auth/login", data={"username": "b@example.com", "password": "wrong"} ) diff --git a/tests/integration/test_categories.py b/tests/integration/test_categories.py index 94c7a19..0224f60 100644 --- a/tests/integration/test_categories.py +++ b/tests/integration/test_categories.py @@ -1,5 +1,7 @@ def test_get_category_returns_owned_category(client, auth_headers): - create_response = client.post("/categories", json={"name": "Transporte"}, headers=auth_headers) + create_response = client.post( + "/categories", json={"name": "Transporte"}, headers=auth_headers + ) category_id = create_response.json()["id"] response = client.get(f"/categories/{category_id}", headers=auth_headers) @@ -14,14 +16,20 @@ def test_get_category_404_when_missing(client, auth_headers): def test_get_category_404_when_owned_by_another_user(client, auth_headers): - create_response = client.post("/categories", json={"name": "Ocio"}, headers=auth_headers) + create_response = client.post( + "/categories", json={"name": "Ocio"}, headers=auth_headers + ) category_id = create_response.json()["id"] - client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"}) + client.post( + "/auth/register", json={"email": "other@example.com", "password": "secret123"} + ) login_response = client.post( "/auth/login", data={"username": "other@example.com", "password": "secret123"} ) - other_user_headers = {"Authorization": f"Bearer {login_response.json()['access_token']}"} + other_user_headers = { + "Authorization": f"Bearer {login_response.json()['access_token']}" + } response = client.get(f"/categories/{category_id}", headers=other_user_headers) diff --git a/tests/integration/test_rate_limit.py b/tests/integration/test_rate_limit.py index 4cce128..7b86c2d 100644 --- a/tests/integration/test_rate_limit.py +++ b/tests/integration/test_rate_limit.py @@ -1,5 +1,8 @@ def test_login_is_rate_limited_after_five_attempts_per_minute(client): - client.post("/auth/register", json={"email": "ratelimit@example.com", "password": "secret123"}) + client.post( + "/auth/register", + json={"email": "ratelimit@example.com", "password": "secret123"}, + ) for _ in range(5): response = client.post( diff --git a/tests/unit/test_category_service.py b/tests/unit/test_category_service.py index f280d73..2539552 100644 --- a/tests/unit/test_category_service.py +++ b/tests/unit/test_category_service.py @@ -18,7 +18,9 @@ def test_list_categories_filters_paginates_and_orders(): def test_create_category_sets_owner_and_persists(): db = MagicMock() - result = category_service.create_category(db, user_id=1, data=CategoryCreate(name="Ocio")) + result = category_service.create_category( + db, user_id=1, data=CategoryCreate(name="Ocio") + ) assert result.name == "Ocio" assert result.user_id == 1 db.add.assert_called_once_with(result) @@ -52,7 +54,9 @@ def test_update_category_applies_only_provided_fields(): fake_category.name = "Old" db.query.return_value.filter.return_value.first.return_value = fake_category - category_service.update_category(db, user_id=1, category_id=1, data=CategoryUpdate(name="New")) + category_service.update_category( + db, user_id=1, category_id=1, data=CategoryUpdate(name="New") + ) assert fake_category.name == "New" db.commit.assert_called_once() @@ -64,7 +68,9 @@ def test_update_category_keeps_name_when_not_provided(): fake_category.name = "Old" db.query.return_value.filter.return_value.first.return_value = fake_category - category_service.update_category(db, user_id=1, category_id=1, data=CategoryUpdate()) + category_service.update_category( + db, user_id=1, category_id=1, data=CategoryUpdate() + ) assert fake_category.name == "Old" diff --git a/tests/unit/test_expense_service.py b/tests/unit/test_expense_service.py index 40104f1..f9171a9 100644 --- a/tests/unit/test_expense_service.py +++ b/tests/unit/test_expense_service.py @@ -20,7 +20,9 @@ def test_list_expenses_paginates_and_orders(): @patch("app.services.expense_service.get_category_or_404") def test_create_expense_validates_category_belongs_to_user(mock_get_category): db = MagicMock() - data = ExpenseCreate(amount=12.5, description="Cafe", date=date(2026, 1, 1), category_id=5) + data = ExpenseCreate( + amount=12.5, description="Cafe", date=date(2026, 1, 1), category_id=5 + ) result = expense_service.create_expense(db, user_id=1, data=data) @@ -32,9 +34,13 @@ def test_create_expense_validates_category_belongs_to_user(mock_get_category): @patch("app.services.expense_service.get_category_or_404") def test_create_expense_propagates_404_for_foreign_category(mock_get_category): - mock_get_category.side_effect = HTTPException(status_code=404, detail="Category not found") + mock_get_category.side_effect = HTTPException( + status_code=404, detail="Category not found" + ) db = MagicMock() - data = ExpenseCreate(amount=12.5, description="Cafe", date=date(2026, 1, 1), category_id=999) + data = ExpenseCreate( + amount=12.5, description="Cafe", date=date(2026, 1, 1), category_id=999 + ) with pytest.raises(HTTPException) as exc_info: expense_service.create_expense(db, user_id=1, data=data) @@ -59,7 +65,9 @@ def test_update_expense_revalidates_new_category_ownership(mock_get_category): fake_expense = MagicMock(category_id=1) db.query.return_value.filter.return_value.first.return_value = fake_expense - expense_service.update_expense(db, user_id=1, expense_id=1, data=ExpenseUpdate(category_id=2)) + expense_service.update_expense( + db, user_id=1, expense_id=1, data=ExpenseUpdate(category_id=2) + ) mock_get_category.assert_called_once_with(db, 1, 2) assert fake_expense.category_id == 2 @@ -70,7 +78,9 @@ def test_update_expense_only_touches_provided_fields(): fake_expense = MagicMock(amount=10, description="Old", date=date(2026, 1, 1)) db.query.return_value.filter.return_value.first.return_value = fake_expense - expense_service.update_expense(db, user_id=1, expense_id=1, data=ExpenseUpdate(description="New")) + expense_service.update_expense( + db, user_id=1, expense_id=1, data=ExpenseUpdate(description="New") + ) assert fake_expense.description == "New" assert fake_expense.amount == 10