mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: auto-capitalize the first letter of transaction descriptions
Transactions/recurring templates entered with mixed casing ("траты на
продукты") are now capitalized on create/update via a Pydantic validator,
so the fix applies to every client, not just the web UI. Adds a one-off
backfill script for existing rows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
12
backend/app/core/text.py
Normal file
12
backend/app/core/text.py
Normal file
@@ -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:]
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from decimal import Decimal
|
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
|
from app.models.enums import RecurringFrequency, TransactionType
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ class RecurringTransactionCreate(BaseModel):
|
|||||||
frequency: RecurringFrequency
|
frequency: RecurringFrequency
|
||||||
anchor_date: date_
|
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):
|
class RecurringTransactionUpdate(BaseModel):
|
||||||
account_id: int | None = None
|
account_id: int | None = None
|
||||||
@@ -32,6 +40,11 @@ class RecurringTransactionUpdate(BaseModel):
|
|||||||
anchor_date: date_ | None = None
|
anchor_date: date_ | None = None
|
||||||
is_active: bool | 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):
|
class RecurringTransactionRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from decimal import Decimal
|
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.models.enums import TransactionType
|
||||||
from app.schemas.account import AccountRead
|
from app.schemas.account import AccountRead
|
||||||
from app.schemas.category import CategoryRead
|
from app.schemas.category import CategoryRead
|
||||||
@@ -19,6 +20,13 @@ class TransactionBase(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
date: date_
|
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")
|
@model_validator(mode="after")
|
||||||
def _validate_type_specific_fields(self) -> "TransactionBase":
|
def _validate_type_specific_fields(self) -> "TransactionBase":
|
||||||
if self.type == TransactionType.TRANSFER and not self.transfer_account_id:
|
if self.type == TransactionType.TRANSFER and not self.transfer_account_id:
|
||||||
@@ -47,6 +55,11 @@ class TransactionUpdate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
date: date_ | 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):
|
class TransactionRead(TransactionBase):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
43
backend/scripts/capitalize_transaction_descriptions.py
Normal file
43
backend/scripts/capitalize_transaction_descriptions.py
Normal file
@@ -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())
|
||||||
@@ -24,6 +24,26 @@ async def test_create_expense_rejects_income_category(client: AsyncClient, accou
|
|||||||
assert resp.status_code == 400
|
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):
|
async def test_create_income_rejects_expense_category(client: AsyncClient, account_id, categories):
|
||||||
expense_category = categories["Groceries"]
|
expense_category = categories["Groceries"]
|
||||||
resp = await client.post(
|
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})
|
resp = await client.get("/transactions", params={"year": 2021, "month": 8})
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["total"] == 1
|
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):
|
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"})
|
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "99.99", "description": "updated"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert money(resp.json()["amount"]) == Decimal("99.99")
|
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})
|
refetched = await client.get("/transactions", params={"page_size": 1})
|
||||||
assert money(refetched.json()["items"][0]["amount"]) == Decimal("99.99")
|
assert money(refetched.json()["items"][0]["amount"]) == Decimal("99.99")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.0",
|
"version": "0.22.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user