mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: allow deleting default categories once they have no transactions
Раньше стандартные категории нельзя было удалить вообще. Теперь можно — если у категории нет ни одной обычной транзакции, ни строки сплита с ней. Пользовательские категории удаляются как и раньше, без проверок. - delete_category() проверяет Transaction.category_id и TransactionSplit.category_id для стандартных категорий перед удалением. - Кнопка удаления в списке категорий больше не блокируется заранее для стандартных — бэкенд решает, бейдж «стандартная» остался чисто информационным. - При отказе (400) показывается понятное сообщение вместо тихого игнорирования клика.
This commit is contained in:
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind
|
from app.models.enums import CategoryKind
|
||||||
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
@@ -75,6 +76,21 @@ async def delete_category(category_id: int, session: AsyncSession = Depends(get_
|
|||||||
if category is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Category not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
if category.is_default:
|
if category.is_default:
|
||||||
raise HTTPException(status_code=400, detail="Default categories cannot be deleted")
|
# A default (seeded) category can be removed once it's unused — but
|
||||||
|
# never while transactions still point at it, or a whole year's
|
||||||
|
# worth of history would silently lose its category (the FK is
|
||||||
|
# ON DELETE SET NULL, so nothing would error, it would just vanish
|
||||||
|
# from every report). A custom category has no such guard: the user
|
||||||
|
# created it and can freely delete it, same as before.
|
||||||
|
has_transaction = (
|
||||||
|
await session.execute(select(Transaction.id).where(Transaction.category_id == category_id).limit(1))
|
||||||
|
).first()
|
||||||
|
has_split = (
|
||||||
|
await session.execute(select(TransactionSplit.id).where(TransactionSplit.category_id == category_id).limit(1))
|
||||||
|
).first()
|
||||||
|
if has_transaction is not None or has_split is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Default categories can only be deleted once they have no transactions"
|
||||||
|
)
|
||||||
await session.delete(category)
|
await session.delete(category)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Category subcategories (parent_id): creation rules and the one-level-deep
|
"""Category subcategories (parent_id): creation rules and the one-level-deep
|
||||||
invariant enforced in api/routes/categories.py.
|
invariant enforced in api/routes/categories.py. Also default-category
|
||||||
|
deletion: a seeded category can be removed once nothing references it, but
|
||||||
|
never while it still has transactions.
|
||||||
"""
|
"""
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.helpers import txn_payload as _txn
|
||||||
|
|
||||||
|
|
||||||
async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories):
|
async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
@@ -52,8 +56,9 @@ async def test_a_category_with_subcategories_cannot_itself_become_a_subcategory(
|
|||||||
|
|
||||||
|
|
||||||
async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories):
|
async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories):
|
||||||
# A default category can't be deleted at all (separate rule) — use a
|
# A default category with transactions can't be deleted (separate rule,
|
||||||
# fresh, non-default parent so this test isolates the parent/child rule.
|
# tested below) — use a fresh, non-default parent so this test isolates
|
||||||
|
# the parent/child rule.
|
||||||
parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
|
parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
|
||||||
parent_id = parent.json()["id"]
|
parent_id = parent.json()["id"]
|
||||||
created = await client.post(
|
created = await client.post(
|
||||||
@@ -67,3 +72,65 @@ async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClien
|
|||||||
refetched = await client.get("/categories")
|
refetched = await client.get("/categories")
|
||||||
child = next(c for c in refetched.json() if c["id"] == child_id)
|
child = next(c for c in refetched.json() if c["id"] == child_id)
|
||||||
assert child["parent_id"] is None
|
assert child["parent_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_category_with_no_transactions_can_be_deleted(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
assert categories["Groceries"]["is_default"] is True
|
||||||
|
|
||||||
|
resp = await client.delete(f"/categories/{groceries}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
refetched = await client.get("/categories")
|
||||||
|
assert groceries not in [c["id"] for c in refetched.json()]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_category_with_a_transaction_cannot_be_deleted(client: AsyncClient, account_id, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
await client.post("/transactions", json=_txn(account_id, category_id=groceries))
|
||||||
|
|
||||||
|
resp = await client.delete(f"/categories/{groceries}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
refetched = await client.get("/categories")
|
||||||
|
assert groceries in [c["id"] for c in refetched.json()]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_category_used_only_by_a_split_line_cannot_be_deleted(client: AsyncClient, account_id, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
sweets = (
|
||||||
|
await client.post(
|
||||||
|
"/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(
|
||||||
|
account_id,
|
||||||
|
amount="100.00",
|
||||||
|
category_id=None,
|
||||||
|
splits=[
|
||||||
|
{"category_id": groceries, "amount": "70.00"},
|
||||||
|
{"category_id": sweets, "amount": "30.00"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.delete(f"/categories/{groceries}")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_category_becomes_deletable_once_its_transaction_is_removed(
|
||||||
|
client: AsyncClient, account_id, categories
|
||||||
|
):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
created = await client.post("/transactions", json=_txn(account_id, category_id=groceries))
|
||||||
|
txn_id = created.json()["id"]
|
||||||
|
|
||||||
|
blocked = await client.delete(f"/categories/{groceries}")
|
||||||
|
assert blocked.status_code == 400
|
||||||
|
|
||||||
|
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
|
||||||
|
|
||||||
|
resp = await client.delete(f"/categories/{groceries}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|||||||
@@ -49,10 +49,8 @@ function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("common.delete")}
|
aria-label={t("common.delete")}
|
||||||
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
|
|
||||||
disabled={category.is_default}
|
|
||||||
onClick={() => onDelete(category)}
|
onClick={() => onDelete(category)}
|
||||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ const ru = {
|
|||||||
"category.incomeSectionTitle": "Категории доходов",
|
"category.incomeSectionTitle": "Категории доходов",
|
||||||
"category.empty": "Категорий пока нет.",
|
"category.empty": "Категорий пока нет.",
|
||||||
"category.defaultBadge": "стандартная",
|
"category.defaultBadge": "стандартная",
|
||||||
"category.defaultCannotDelete": "Стандартные категории нельзя удалить",
|
"category.defaultDeleteBlocked": "Стандартную категорию можно удалить только после того, как у неё не останется транзакций.",
|
||||||
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
|
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
|
||||||
"category.kind.expense": "Расход",
|
"category.kind.expense": "Расход",
|
||||||
"category.kind.income": "Доход",
|
"category.kind.income": "Доход",
|
||||||
@@ -910,7 +910,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"category.incomeSectionTitle": "Income categories",
|
"category.incomeSectionTitle": "Income categories",
|
||||||
"category.empty": "No categories yet.",
|
"category.empty": "No categories yet.",
|
||||||
"category.defaultBadge": "default",
|
"category.defaultBadge": "default",
|
||||||
"category.defaultCannotDelete": "Default categories cannot be deleted",
|
"category.defaultDeleteBlocked": "A default category can only be deleted once it has no transactions left.",
|
||||||
"category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
|
"category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
|
||||||
"category.kind.expense": "Expense",
|
"category.kind.expense": "Expense",
|
||||||
"category.kind.income": "Income",
|
"category.kind.income": "Income",
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ export function CategoriesPage() {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(category: Category) {
|
async function handleDelete(category: Category) {
|
||||||
// Default categories are protected server-side too (400) — the list
|
if (!window.confirm(t("category.confirmDelete", { name: category.name }))) return;
|
||||||
// already disables the delete button for them, this is just a guard.
|
try {
|
||||||
if (category.is_default) return;
|
await deleteCategory.mutateAsync(category.id);
|
||||||
if (window.confirm(t("category.confirmDelete", { name: category.name }))) {
|
} catch {
|
||||||
deleteCategory.mutate(category.id);
|
// The only way a delete 400s is a default category that still has
|
||||||
|
// transactions pointing at it (api/routes/categories.py) — a custom
|
||||||
|
// category has no such guard and always succeeds.
|
||||||
|
window.alert(t("category.defaultDeleteBlocked"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user