feat(proj): init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
selected_budget_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
total_amount REAL NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
budget_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
spent_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def _ensure_indexes(conn: aiosqlite.Connection) -> None:
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_user
|
||||
ON budgets(user_id, is_active, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_budget_spent_at
|
||||
ON expenses(budget_id, spent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at
|
||||
ON expenses(user_id, spent_at);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _table_columns(conn: aiosqlite.Connection, table: str) -> set[str]:
|
||||
cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||
rows = await cursor.fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
|
||||
async def _has_unique_user_on_budgets(conn: aiosqlite.Connection) -> bool:
|
||||
cursor = await conn.execute("PRAGMA index_list(budgets)")
|
||||
indexes = await cursor.fetchall()
|
||||
for idx in indexes:
|
||||
# (seq, name, unique, origin, partial)
|
||||
if not idx[2]:
|
||||
continue
|
||||
name = idx[1]
|
||||
info = await conn.execute(f"PRAGMA index_info({name})")
|
||||
cols = [row[2] for row in await info.fetchall()]
|
||||
if cols == ["user_id"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_schema(conn: aiosqlite.Connection) -> None:
|
||||
"""Upgrade legacy one-budget-per-user schema in place."""
|
||||
tables = {
|
||||
row[0]
|
||||
for row in await (
|
||||
await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
).fetchall()
|
||||
}
|
||||
if "budgets" not in tables:
|
||||
return
|
||||
|
||||
budget_cols = await _table_columns(conn, "budgets")
|
||||
if "name" not in budget_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE budgets ADD COLUMN name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
if "is_active" not in budget_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE budgets ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
|
||||
user_cols = await _table_columns(conn, "users")
|
||||
if "selected_budget_id" not in user_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN selected_budget_id INTEGER"
|
||||
)
|
||||
|
||||
if await _has_unique_user_on_budgets(conn):
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE TABLE budgets_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
total_amount REAL NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO budgets_migrated (
|
||||
id, user_id, name, total_amount, start_date, end_date,
|
||||
currency, is_active, created_at
|
||||
)
|
||||
SELECT
|
||||
id, user_id,
|
||||
COALESCE(NULLIF(name, ''), 'Бюджет'),
|
||||
total_amount, start_date, end_date, currency,
|
||||
COALESCE(is_active, 1), created_at
|
||||
FROM budgets;
|
||||
DROP TABLE budgets;
|
||||
ALTER TABLE budgets_migrated RENAME TO budgets;
|
||||
"""
|
||||
)
|
||||
|
||||
expense_cols = await _table_columns(conn, "expenses")
|
||||
if "budget_id" not in expense_cols:
|
||||
await conn.execute("ALTER TABLE expenses ADD COLUMN budget_id INTEGER")
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE expenses
|
||||
SET budget_id = (
|
||||
SELECT b.id FROM budgets b
|
||||
WHERE b.user_id = expenses.user_id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE budget_id IS NULL
|
||||
"""
|
||||
)
|
||||
# Drop orphan expenses that have no budget (should be rare)
|
||||
await conn.execute("DELETE FROM expenses WHERE budget_id IS NULL")
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE TABLE expenses_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
budget_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
spent_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO expenses_migrated (
|
||||
id, user_id, budget_id, amount, note, spent_at, created_at
|
||||
)
|
||||
SELECT id, user_id, budget_id, amount, note, spent_at, created_at
|
||||
FROM expenses;
|
||||
DROP TABLE expenses;
|
||||
ALTER TABLE expenses_migrated RENAME TO expenses;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill selected budget for users who have budgets
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET selected_budget_id = (
|
||||
SELECT b.id FROM budgets b
|
||||
WHERE b.user_id = users.user_id
|
||||
ORDER BY b.is_active DESC, b.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE selected_budget_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM budgets b WHERE b.user_id = users.user_id)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE budgets
|
||||
SET name = 'Бюджет'
|
||||
WHERE name IS NULL OR TRIM(name) = ''
|
||||
"""
|
||||
)
|
||||
await _ensure_indexes(conn)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path, *, read_only: bool = False) -> None:
|
||||
self.path = path
|
||||
self.read_only = read_only
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.read_only:
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Database not found: {self.path}. "
|
||||
"Сначала запусти API, чтобы создался data/budget.db"
|
||||
)
|
||||
uri = f"file:{self.path.resolve().as_posix()}?mode=ro"
|
||||
self._conn = await aiosqlite.connect(uri, uri=True)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
return
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await migrate_schema(self._conn)
|
||||
# Fresh DBs: migrate may no-op early if tables were just created with
|
||||
# full columns — still ensure indexes exist.
|
||||
budget_cols = await _table_columns(self._conn, "budgets")
|
||||
if "is_active" in budget_cols:
|
||||
await _ensure_indexes(self._conn)
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database is not connected")
|
||||
return self._conn
|
||||
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from bot.db.database import Database
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Budget:
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
total_amount: float
|
||||
start_date: date
|
||||
end_date: date
|
||||
currency: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Expense:
|
||||
id: int
|
||||
user_id: int
|
||||
budget_id: int
|
||||
amount: float
|
||||
note: str | None
|
||||
spent_at: date
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
return date.fromisoformat(value[:10])
|
||||
|
||||
|
||||
def _row_to_budget(row: Any) -> Budget:
|
||||
return Budget(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
name=str(row["name"] or "Бюджет"),
|
||||
total_amount=float(row["total_amount"]),
|
||||
start_date=_parse_date(row["start_date"]),
|
||||
end_date=_parse_date(row["end_date"]),
|
||||
currency=str(row["currency"] or "RUB"),
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_expense(row: Any) -> Expense:
|
||||
return Expense(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
budget_id=int(row["budget_id"]),
|
||||
amount=float(row["amount"]),
|
||||
note=row["note"],
|
||||
spent_at=_parse_date(row["spent_at"]),
|
||||
)
|
||||
|
||||
|
||||
class BudgetRepository:
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
async def ensure_user(self, user_id: int) -> None:
|
||||
await self._db.conn.execute(
|
||||
"INSERT OR IGNORE INTO users(user_id) VALUES (?)",
|
||||
(user_id,),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
|
||||
async def get_selected_budget_id(self, user_id: int) -> int | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT selected_budget_id FROM users WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or row["selected_budget_id"] is None:
|
||||
return None
|
||||
return int(row["selected_budget_id"])
|
||||
|
||||
async def set_selected_budget_id(self, user_id: int, budget_id: int | None) -> None:
|
||||
await self.ensure_user(user_id)
|
||||
await self._db.conn.execute(
|
||||
"UPDATE users SET selected_budget_id = ? WHERE user_id = ?",
|
||||
(budget_id, user_id),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
|
||||
async def create_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str,
|
||||
total_amount: float,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
currency: str = "RUB",
|
||||
is_active: bool = True,
|
||||
select: bool = True,
|
||||
) -> Budget:
|
||||
await self.ensure_user(user_id)
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
INSERT INTO budgets(
|
||||
user_id, name, total_amount, start_date, end_date, currency, is_active
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
name.strip() or "Бюджет",
|
||||
total_amount,
|
||||
start_date.isoformat(),
|
||||
end_date.isoformat(),
|
||||
currency,
|
||||
1 if is_active else 0,
|
||||
),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await self._db.conn.commit()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to create budget")
|
||||
budget = _row_to_budget(row)
|
||||
if select:
|
||||
await self.set_selected_budget_id(user_id, budget.id)
|
||||
return budget
|
||||
|
||||
async def update_budget(
|
||||
self,
|
||||
budget_id: int,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
total_amount: float | None = None,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
currency: str | None = None,
|
||||
) -> Budget:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
|
||||
next_name = name.strip() if name is not None else budget.name
|
||||
next_total = total_amount if total_amount is not None else budget.total_amount
|
||||
next_start = start_date if start_date is not None else budget.start_date
|
||||
next_end = end_date if end_date is not None else budget.end_date
|
||||
next_currency = currency if currency is not None else budget.currency
|
||||
|
||||
await self._db.conn.execute(
|
||||
"""
|
||||
UPDATE budgets
|
||||
SET name = ?, total_amount = ?, start_date = ?, end_date = ?, currency = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(
|
||||
next_name or "Бюджет",
|
||||
next_total,
|
||||
next_start.isoformat(),
|
||||
next_end.isoformat(),
|
||||
next_currency,
|
||||
budget_id,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update budget")
|
||||
return updated
|
||||
|
||||
async def set_budget_active(
|
||||
self,
|
||||
budget_id: int,
|
||||
user_id: int,
|
||||
is_active: bool,
|
||||
) -> Budget:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
await self._db.conn.execute(
|
||||
"UPDATE budgets SET is_active = ? WHERE id = ? AND user_id = ?",
|
||||
(1 if is_active else 0, budget_id, user_id),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update budget activity")
|
||||
return updated
|
||||
|
||||
async def get_budget_by_id(self, budget_id: int) -> Budget | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT * FROM budgets WHERE id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_budget(row) if row else None
|
||||
|
||||
async def get_budget_for_user(self, budget_id: int, user_id: int) -> Budget | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT * FROM budgets WHERE id = ? AND user_id = ?",
|
||||
(budget_id, user_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_budget(row) if row else None
|
||||
|
||||
async def list_budgets_for_user(self, user_id: int) -> list[Budget]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_active DESC, id DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_budget(row) for row in rows]
|
||||
|
||||
async def resolve_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
*,
|
||||
require_active: bool = False,
|
||||
) -> Budget | None:
|
||||
if budget_id is not None:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
return None
|
||||
if require_active and not budget.is_active:
|
||||
raise ValueError("Бюджет неактивен — включи его или выбери другой")
|
||||
return budget
|
||||
|
||||
selected_id = await self.get_selected_budget_id(user_id)
|
||||
if selected_id is not None:
|
||||
selected = await self.get_budget_for_user(selected_id, user_id)
|
||||
if selected is not None:
|
||||
if not require_active or selected.is_active:
|
||||
return selected
|
||||
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_active DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
budget = _row_to_budget(row)
|
||||
if require_active and not budget.is_active:
|
||||
raise ValueError("Нет активного бюджета — создай или включи существующий")
|
||||
return budget
|
||||
|
||||
async def list_all_budgets(self) -> list[Budget]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
ORDER BY user_id ASC, is_active DESC, id DESC
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_budget(row) for row in rows]
|
||||
|
||||
async def add_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int,
|
||||
amount: float,
|
||||
note: str | None = None,
|
||||
spent_at: date | None = None,
|
||||
) -> Expense:
|
||||
await self.ensure_user(user_id)
|
||||
spent = spent_at or date.today()
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
INSERT INTO expenses(user_id, budget_id, amount, note, spent_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(user_id, budget_id, amount, note, spent.isoformat()),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await self._db.conn.commit()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to insert expense")
|
||||
return _row_to_expense(row)
|
||||
|
||||
async def delete_last_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
) -> Expense | None:
|
||||
if budget_id is None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
else:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE user_id = ? AND budget_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, budget_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
await self._db.conn.execute("DELETE FROM expenses WHERE id = ?", (row["id"],))
|
||||
await self._db.conn.commit()
|
||||
return _row_to_expense(row)
|
||||
|
||||
async def spent_on_date(
|
||||
self,
|
||||
budget_id: int,
|
||||
day: date,
|
||||
) -> float:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM expenses
|
||||
WHERE budget_id = ? AND spent_at = ?
|
||||
""",
|
||||
(budget_id, day.isoformat()),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return float(row["total"]) if row else 0.0
|
||||
|
||||
async def list_expenses_on_date(
|
||||
self,
|
||||
budget_id: int,
|
||||
day: date,
|
||||
) -> list[Expense]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE budget_id = ? AND spent_at = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(budget_id, day.isoformat()),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_expense(row) for row in rows]
|
||||
|
||||
async def count_expenses_for_budget(self, budget_id: int) -> int:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM expenses WHERE budget_id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cnt"]) if row else 0
|
||||
|
||||
async def sum_expenses_for_budget(self, budget_id: int) -> float:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM expenses
|
||||
WHERE budget_id = ?
|
||||
""",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return float(row["total"]) if row else 0.0
|
||||
|
||||
async def list_expenses_for_budget(
|
||||
self,
|
||||
budget_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[Expense]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE budget_id = ?
|
||||
ORDER BY spent_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(budget_id, limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_expense(row) for row in rows]
|
||||
|
||||
async def clear_expenses_for_budget_id(self, budget_id: int) -> None:
|
||||
await self._db.conn.execute(
|
||||
"DELETE FROM expenses WHERE budget_id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
Reference in New Issue
Block a user