diff --git a/backend/app/core/text.py b/backend/app/core/text.py new file mode 100644 index 0000000..31db09c --- /dev/null +++ b/backend/app/core/text.py @@ -0,0 +1,12 @@ +"""Shared text-formatting helpers.""" + + +def capitalize_first_letter(value: str) -> str: + """Uppercase the first character, leaving the rest of the string untouched. + + Unlike str.capitalize()/str.title(), this never lowercases the rest of the + string — "iPhone 15" stays "iPhone 15", not "Iphone 15". + """ + if not value: + return value + return value[0].upper() + value[1:] diff --git a/backend/app/schemas/recurring.py b/backend/app/schemas/recurring.py index 8920a46..17e7e7e 100644 --- a/backend/app/schemas/recurring.py +++ b/backend/app/schemas/recurring.py @@ -1,8 +1,9 @@ from datetime import date as date_ from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from app.core.text import capitalize_first_letter from app.models.enums import RecurringFrequency, TransactionType @@ -18,6 +19,13 @@ class RecurringTransactionCreate(BaseModel): frequency: RecurringFrequency anchor_date: date_ + # Keeps templates consistent with regular transactions — see + # app/schemas/transaction.py for the same rule. + @field_validator("description") + @classmethod + def _capitalize_description(cls, value: str) -> str: + return capitalize_first_letter(value) + class RecurringTransactionUpdate(BaseModel): account_id: int | None = None @@ -32,6 +40,11 @@ class RecurringTransactionUpdate(BaseModel): anchor_date: date_ | None = None is_active: bool | None = None + @field_validator("description") + @classmethod + def _capitalize_description(cls, value: str | None) -> str | None: + return capitalize_first_letter(value) if value is not None else None + class RecurringTransactionRead(BaseModel): id: int diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 697a8ff..9c6d089 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -1,8 +1,9 @@ from datetime import date as date_ from decimal import Decimal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.core.text import capitalize_first_letter from app.models.enums import TransactionType from app.schemas.account import AccountRead from app.schemas.category import CategoryRead @@ -19,6 +20,13 @@ class TransactionBase(BaseModel): notes: str | None = None date: date_ + # Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed + # casing from quick manual entry doesn't need fixing by hand later. + @field_validator("description") + @classmethod + def _capitalize_description(cls, value: str) -> str: + return capitalize_first_letter(value) + @model_validator(mode="after") def _validate_type_specific_fields(self) -> "TransactionBase": if self.type == TransactionType.TRANSFER and not self.transfer_account_id: @@ -47,6 +55,11 @@ class TransactionUpdate(BaseModel): notes: str | None = None date: date_ | None = None + @field_validator("description") + @classmethod + def _capitalize_description(cls, value: str | None) -> str | None: + return capitalize_first_letter(value) if value is not None else None + class TransactionRead(TransactionBase): model_config = ConfigDict(from_attributes=True) diff --git a/backend/scripts/capitalize_transaction_descriptions.py b/backend/scripts/capitalize_transaction_descriptions.py new file mode 100644 index 0000000..32719ff --- /dev/null +++ b/backend/scripts/capitalize_transaction_descriptions.py @@ -0,0 +1,43 @@ +"""One-off backfill: capitalizes the first letter of every existing +transaction/recurring-template description, so historical rows entered with +mixed casing ("покупка продуктов", "ПОКУПКА хлеба") match the auto- +capitalization now applied on create/update (see app/core/text.py and the +description validators in app/schemas/transaction.py, app/schemas/recurring.py). + +Run it once against a running stack with: + + docker compose exec backend python -m scripts.capitalize_transaction_descriptions + +Safe to re-run — already-capitalized descriptions are left untouched. +""" +import asyncio + +from sqlalchemy import select + +from app.core.text import capitalize_first_letter +from app.db.session import AsyncSessionLocal +from app.models import RecurringTransaction, Transaction + + +async def _capitalize_all(session, model) -> int: + result = await session.execute(select(model)) + updated = 0 + for row in result.scalars().all(): + fixed = capitalize_first_letter(row.description) + if fixed != row.description: + row.description = fixed + updated += 1 + return updated + + +async def main() -> None: + async with AsyncSessionLocal() as session: + transactions_updated = await _capitalize_all(session, Transaction) + recurring_updated = await _capitalize_all(session, RecurringTransaction) + await session.commit() + print(f"Transactions updated: {transactions_updated}") + print(f"Recurring templates updated: {recurring_updated}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py index 51ac40f..c16469d 100644 --- a/backend/tests/test_transactions.py +++ b/backend/tests/test_transactions.py @@ -24,6 +24,26 @@ async def test_create_expense_rejects_income_category(client: AsyncClient, accou assert resp.status_code == 400 +async def test_create_capitalizes_description_first_letter(client: AsyncClient, account_id, categories): + category = categories["Groceries"] + resp = await client.post( + "/transactions", + json=_txn(account_id, category_id=category["id"], description="траты на продукты"), + ) + assert resp.status_code == 201 + assert resp.json()["description"] == "Траты на продукты" + + +async def test_update_capitalizes_description_first_letter(client: AsyncClient, account_id, categories): + category = categories["Groceries"] + created = await client.post("/transactions", json=_txn(account_id, category_id=category["id"])) + txn_id = created.json()["id"] + + resp = await client.patch(f"/transactions/{txn_id}", json={"description": "new coffee shop"}) + assert resp.status_code == 200 + assert resp.json()["description"] == "New coffee shop" + + async def test_create_income_rejects_expense_category(client: AsyncClient, account_id, categories): expense_category = categories["Groceries"] resp = await client.post( @@ -97,7 +117,7 @@ async def test_list_filters_by_year_and_month_excludes_other_months(client: Asyn resp = await client.get("/transactions", params={"year": 2021, "month": 8}) body = resp.json() assert body["total"] == 1 - assert [item["description"] for item in body["items"]] == ["august transaction"] + assert [item["description"] for item in body["items"]] == ["August transaction"] async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_id, categories): @@ -209,7 +229,7 @@ async def test_update_transaction_persists_changes(client: AsyncClient, account_ resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "99.99", "description": "updated"}) assert resp.status_code == 200 assert money(resp.json()["amount"]) == Decimal("99.99") - assert resp.json()["description"] == "updated" + assert resp.json()["description"] == "Updated" refetched = await client.get("/transactions", params={"page_size": 1}) assert money(refetched.json()["items"][0]["amount"]) == Decimal("99.99") diff --git a/frontend/package.json b/frontend/package.json index 30a300c..6dbbf4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.21.0", + "version": "0.22.0", "type": "module", "scripts": { "dev": "vite",