Update manifest.json with new file metadata and add new test files
Gitea Actions CI / detect (push) Successful in 33s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 28s
Gitea Actions CI / Compose & nginx validation (push) Failing after 1m8s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 50s
Gitea Actions CI / detect (push) Successful in 33s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 28s
Gitea Actions CI / Compose & nginx validation (push) Failing after 1m8s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 50s
- Updated timestamps and AST hashes for existing files in the backend and frontend. - Added new test files: `backend/tests/test_review_queue_batch.py`, `frontend/src/test/review-queue-save.test.tsx`, `frontend/src/test/app-layout.test.tsx`, and `frontend/src/test/ledger-view.test.tsx`. - Updated documentation file `docs/tasks/0001-fixes.md` with new metadata.
This commit is contained in:
@@ -125,6 +125,25 @@ def ensure_mcc_dictionary(db: Session) -> None:
|
||||
db.flush()
|
||||
|
||||
|
||||
def ensure_others_category(db: Session, *, space_id: uuid.UUID) -> Category:
|
||||
"""The system «Остальное» fallback category, created on first use."""
|
||||
others = (
|
||||
db.query(Category)
|
||||
.filter_by(space_id=space_id, system_key=mcc.OTHERS_SYSTEM_KEY)
|
||||
.one_or_none()
|
||||
)
|
||||
if others is None:
|
||||
others = Category(
|
||||
space_id=space_id,
|
||||
kind="monthly",
|
||||
system_key=mcc.OTHERS_SYSTEM_KEY,
|
||||
custom_name=mcc.OTHERS_CATEGORY_NAME,
|
||||
)
|
||||
db.add(others)
|
||||
db.flush()
|
||||
return others
|
||||
|
||||
|
||||
def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str, Category]:
|
||||
"""Idempotently pre-create the 20 popular categories plus «Остальное».
|
||||
|
||||
@@ -162,17 +181,7 @@ def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str,
|
||||
db.add(CategoryMcc(space_id=space_id, category_id=category.id, mcc_code=code))
|
||||
claimed.add(code)
|
||||
result[definition.system_key] = category
|
||||
others = existing.get(mcc.OTHERS_SYSTEM_KEY)
|
||||
if others is None:
|
||||
others = Category(
|
||||
space_id=space_id,
|
||||
kind="monthly",
|
||||
system_key=mcc.OTHERS_SYSTEM_KEY,
|
||||
custom_name=mcc.OTHERS_CATEGORY_NAME,
|
||||
)
|
||||
db.add(others)
|
||||
db.flush()
|
||||
result[mcc.OTHERS_SYSTEM_KEY] = others
|
||||
result[mcc.OTHERS_SYSTEM_KEY] = ensure_others_category(db, space_id=space_id)
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
|
||||
@@ -52,6 +52,19 @@ class OperationPatchBody(BaseModel):
|
||||
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
|
||||
|
||||
|
||||
class CategorizeBatchItemBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
operation_id: uuid.UUID = Field(alias="operationId")
|
||||
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
|
||||
|
||||
|
||||
class CategorizeBatchBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
items: Annotated[list[CategorizeBatchItemBody], Field(min_length=1, max_length=200)]
|
||||
|
||||
|
||||
class AdjustBalanceBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@@ -289,6 +302,18 @@ def patch_operation(
|
||||
return operation_json(db, operation)
|
||||
|
||||
|
||||
@router.post("/operations/categorize-batch")
|
||||
def categorize_operations_batch(
|
||||
body: CategorizeBatchBody, db: Db, context: Context, _csrf: Csrf
|
||||
) -> dict:
|
||||
return service.categorize_batch(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
items=[(item.operation_id, item.category_id) for item in body.items],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/adjust-balance")
|
||||
def adjust_balance(
|
||||
account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf
|
||||
|
||||
@@ -480,6 +480,45 @@ def update_operation(
|
||||
return operation
|
||||
|
||||
|
||||
def categorize_batch(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
items: list[tuple[uuid.UUID, uuid.UUID | None]],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Assign categories to uncategorized operations in one transaction.
|
||||
|
||||
Items without an explicit category fall back to the system «Остальное»
|
||||
category, so nothing stays uncategorized after a review-queue save.
|
||||
Operations that already carry a category (user work done elsewhere) or
|
||||
that were voided in the meantime are skipped, never overwritten.
|
||||
"""
|
||||
from app.categorization.service import ensure_others_category
|
||||
|
||||
fallback = ensure_others_category(db, space_id=space_id)
|
||||
updated: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for operation_id, category_id in items:
|
||||
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
|
||||
if operation.status == "voided" or operation.category_id is not None:
|
||||
skipped.append(str(operation.id))
|
||||
continue
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
|
||||
target = category_id if category_id is not None else fallback.id
|
||||
_validate_category(db, space_id=space_id, category_id=target)
|
||||
operation.category_id = target
|
||||
updated.append(str(operation.id))
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operations.batch_categorized",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"updated": updated, "skipped": skipped},
|
||||
)
|
||||
return {"updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
def archive_account(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
|
||||
) -> Account:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def bootstrap(client: TestClient) -> str:
|
||||
invite = client.post(
|
||||
"/api/v1/admin/invitations",
|
||||
headers={"X-Admin-Token": "test-admin-token"},
|
||||
json={"email": "Queue@Example.com"},
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
accepted = client.post(
|
||||
"/api/v1/auth/invitations/accept",
|
||||
json={
|
||||
"token": invite.json()["token"],
|
||||
"password": "a-long-password-for-tests",
|
||||
"fullName": "Test Person",
|
||||
"locale": "en",
|
||||
},
|
||||
)
|
||||
assert accepted.status_code == 201, accepted.text
|
||||
return client.cookies["csrf_token"]
|
||||
|
||||
|
||||
def create_account(client: TestClient, csrf: str) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/accounts",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"name": "Wallet",
|
||||
"type": "cash",
|
||||
"openedOn": "2026-02-01",
|
||||
"openingBalanceKopecks": 100_000,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def create_expense(client: TestClient, csrf: str, account_id: str, description: str) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/operations",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"kind": "expense",
|
||||
"accountId": account_id,
|
||||
"amountKopecks": 1_500,
|
||||
"occurredOn": "2026-02-10",
|
||||
"description": description,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def category_ids(client: TestClient) -> dict[str, str]:
|
||||
response = client.get(
|
||||
"/api/v1/budget/category-definitions", params={"includeArchived": "false"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return {
|
||||
item["systemKey"]: item["id"]
|
||||
for item in response.json()
|
||||
if item["systemKey"] is not None
|
||||
}
|
||||
|
||||
|
||||
def operations_by_id(client: TestClient) -> dict[str, dict]:
|
||||
response = client.get("/api/v1/operations", params={"kind": "expense", "limit": 200})
|
||||
assert response.status_code == 200, response.text
|
||||
return {item["id"]: item for item in response.json()["items"]}
|
||||
|
||||
|
||||
def test_batch_assigns_picked_categories_and_moves_leftovers_to_others(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
csrf = bootstrap(client)
|
||||
account_id = create_account(client, csrf)
|
||||
categories = category_ids(client)
|
||||
first = create_expense(client, csrf, account_id, "Coffee")
|
||||
second = create_expense(client, csrf, account_id, "Groceries")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/operations/categorize-batch",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"items": [
|
||||
{"operationId": first, "categoryId": categories["groceries"]},
|
||||
{"operationId": second},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"updated": [first, second], "skipped": []}
|
||||
|
||||
operations = operations_by_id(client)
|
||||
assert operations[first]["categoryKey"] == "groceries"
|
||||
assert operations[second]["categoryKey"] == "others"
|
||||
assert operations[second]["categoryName"] == "Остальное"
|
||||
|
||||
left = client.get("/api/v1/operations", params={"uncategorized": "true"})
|
||||
assert left.json()["items"] == []
|
||||
|
||||
|
||||
def test_batch_never_overwrites_existing_categories_or_voided_operations(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
csrf = bootstrap(client)
|
||||
account_id = create_account(client, csrf)
|
||||
categories = category_ids(client)
|
||||
categorized = create_expense(client, csrf, account_id, "Already done")
|
||||
voided = create_expense(client, csrf, account_id, "Voided")
|
||||
pending = create_expense(client, csrf, account_id, "Pending")
|
||||
|
||||
patch = client.patch(
|
||||
f"/api/v1/operations/{categorized}",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"categoryId": categories["groceries"]},
|
||||
)
|
||||
assert patch.status_code == 200, patch.text
|
||||
void = client.post(
|
||||
f"/api/v1/operations/{voided}/void",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"reason": "duplicate"},
|
||||
)
|
||||
assert void.status_code == 200, void.text
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/operations/categorize-batch",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"items": [
|
||||
{"operationId": categorized, "categoryId": categories["cafes"]},
|
||||
{"operationId": voided, "categoryId": categories["cafes"]},
|
||||
{"operationId": pending, "categoryId": categories["cafes"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"updated": [pending], "skipped": [categorized, voided]}
|
||||
|
||||
operations = operations_by_id(client)
|
||||
assert operations[categorized]["categoryKey"] == "groceries"
|
||||
assert operations[pending]["categoryKey"] == "cafes"
|
||||
|
||||
|
||||
def test_batch_is_atomic_when_one_category_is_unknown(client: TestClient) -> None:
|
||||
csrf = bootstrap(client)
|
||||
account_id = create_account(client, csrf)
|
||||
first = create_expense(client, csrf, account_id, "First")
|
||||
second = create_expense(client, csrf, account_id, "Second")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/operations/categorize-batch",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"items": [
|
||||
{"operationId": first, "categoryId": "00000000-0000-0000-0000-000000000000"},
|
||||
{"operationId": second},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404, response.text
|
||||
assert response.json()["error"]["code"] == "BUDGET_CATEGORY_NOT_FOUND"
|
||||
|
||||
operations = operations_by_id(client)
|
||||
assert operations[first]["categoryId"] is None
|
||||
assert operations[second]["categoryId"] is None
|
||||
|
||||
|
||||
def test_batch_rejects_empty_payload(client: TestClient) -> None:
|
||||
csrf = bootstrap(client)
|
||||
response = client.post(
|
||||
"/api/v1/operations/categorize-batch",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"items": []},
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
Reference in New Issue
Block a user