884 lines
38 KiB
Python
884 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
import secrets
|
||
|
||
import aiosqlite
|
||
|
||
from app.categories import CATEGORY_META, DEFAULT_SUBCATEGORIES, category_label
|
||
from app.domain import (
|
||
BudgetCategory,
|
||
Category,
|
||
FamilyInfo,
|
||
FamilyJoinRequest,
|
||
Totals,
|
||
Transaction,
|
||
TransactionKind,
|
||
)
|
||
|
||
|
||
class FamilyJoinError(ValueError):
|
||
pass
|
||
|
||
|
||
class Database:
|
||
def __init__(self, path: Path) -> None:
|
||
self.path = path
|
||
|
||
async def initialize(self) -> None:
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute("PRAGMA journal_mode=WAL")
|
||
await db.execute("PRAGMA foreign_keys=ON")
|
||
await db.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
user_id INTEGER PRIMARY KEY,
|
||
chat_id INTEGER NOT NULL,
|
||
username TEXT,
|
||
full_name TEXT NOT NULL,
|
||
timezone TEXT NOT NULL,
|
||
report_time TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS households (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
invite_code TEXT NOT NULL UNIQUE,
|
||
created_by INTEGER NOT NULL REFERENCES users(user_id),
|
||
notification_chat_id INTEGER,
|
||
notification_chat_title TEXT,
|
||
debt_base_kopecks INTEGER,
|
||
debt_started_at TEXT,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS household_members (
|
||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(user_id) ON DELETE CASCADE,
|
||
joined_at TEXT NOT NULL,
|
||
PRIMARY KEY (household_id, user_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS budget_categories (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '📁',
|
||
parent_id INTEGER REFERENCES budget_categories(id) ON DELETE RESTRICT,
|
||
system_key TEXT,
|
||
created_by INTEGER REFERENCES users(user_id),
|
||
created_at TEXT NOT NULL,
|
||
UNIQUE (household_id, system_key)
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_category_name
|
||
ON budget_categories(household_id, COALESCE(parent_id, 0), lower(name));
|
||
|
||
CREATE TABLE IF NOT EXISTS pending_family_joins (
|
||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(user_id) ON DELETE CASCADE,
|
||
requested_at TEXT NOT NULL,
|
||
PRIMARY KEY (household_id, user_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS transactions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||
amount_kopecks INTEGER NOT NULL CHECK (amount_kopecks > 0),
|
||
description TEXT NOT NULL,
|
||
kind TEXT NOT NULL CHECK (kind IN ('expense', 'income')),
|
||
category TEXT NOT NULL,
|
||
household_id INTEGER REFERENCES households(id),
|
||
budget_category_id INTEGER REFERENCES budget_categories(id),
|
||
occurred_at TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_transactions_user_date
|
||
ON transactions(user_id, occurred_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS daily_reports (
|
||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||
local_date TEXT NOT NULL,
|
||
sent_at TEXT NOT NULL,
|
||
PRIMARY KEY (user_id, local_date)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS family_daily_reports (
|
||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||
local_date TEXT NOT NULL,
|
||
sent_at TEXT NOT NULL,
|
||
PRIMARY KEY (household_id, local_date)
|
||
);
|
||
"""
|
||
)
|
||
columns_cursor = await db.execute("PRAGMA table_info(transactions)")
|
||
columns = {row[1] for row in await columns_cursor.fetchall()}
|
||
if "household_id" not in columns:
|
||
await db.execute(
|
||
"ALTER TABLE transactions ADD COLUMN household_id INTEGER REFERENCES households(id)"
|
||
)
|
||
if "budget_category_id" not in columns:
|
||
await db.execute(
|
||
"ALTER TABLE transactions ADD COLUMN budget_category_id INTEGER "
|
||
"REFERENCES budget_categories(id)"
|
||
)
|
||
await db.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_transactions_household_date "
|
||
"ON transactions(household_id, occurred_at)"
|
||
)
|
||
household_columns_cursor = await db.execute("PRAGMA table_info(households)")
|
||
household_columns = {row[1] for row in await household_columns_cursor.fetchall()}
|
||
if "notification_chat_id" not in household_columns:
|
||
await db.execute("ALTER TABLE households ADD COLUMN notification_chat_id INTEGER")
|
||
if "notification_chat_title" not in household_columns:
|
||
await db.execute("ALTER TABLE households ADD COLUMN notification_chat_title TEXT")
|
||
if "debt_base_kopecks" not in household_columns:
|
||
await db.execute("ALTER TABLE households ADD COLUMN debt_base_kopecks INTEGER")
|
||
if "debt_started_at" not in household_columns:
|
||
await db.execute("ALTER TABLE households ADD COLUMN debt_started_at TEXT")
|
||
household_cursor = await db.execute("SELECT id FROM households")
|
||
for household_row in await household_cursor.fetchall():
|
||
await self._seed_default_categories(db, int(household_row[0]))
|
||
await db.execute(
|
||
"""
|
||
UPDATE transactions
|
||
SET budget_category_id=(
|
||
SELECT bc.id FROM budget_categories bc
|
||
WHERE bc.household_id=transactions.household_id
|
||
AND bc.system_key=transactions.category
|
||
)
|
||
WHERE household_id IS NOT NULL AND budget_category_id IS NULL
|
||
"""
|
||
)
|
||
await db.commit()
|
||
|
||
async def upsert_user(
|
||
self,
|
||
*,
|
||
user_id: int,
|
||
chat_id: int,
|
||
username: str | None,
|
||
full_name: str,
|
||
timezone: str,
|
||
report_time: str,
|
||
now: datetime,
|
||
) -> None:
|
||
timestamp = now.isoformat()
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute(
|
||
"""
|
||
INSERT INTO users (
|
||
user_id, chat_id, username, full_name, timezone,
|
||
report_time, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(user_id) DO UPDATE SET
|
||
chat_id=excluded.chat_id,
|
||
username=excluded.username,
|
||
full_name=excluded.full_name,
|
||
timezone=excluded.timezone,
|
||
report_time=excluded.report_time,
|
||
updated_at=excluded.updated_at
|
||
""",
|
||
(user_id, chat_id, username, full_name, timezone, report_time, timestamp, timestamp),
|
||
)
|
||
await db.commit()
|
||
|
||
async def add_transaction(
|
||
self,
|
||
*,
|
||
user_id: int,
|
||
amount_kopecks: int,
|
||
description: str,
|
||
kind: TransactionKind,
|
||
category: Category,
|
||
budget_category_id: int | None = None,
|
||
occurred_at: datetime,
|
||
) -> Transaction:
|
||
now = datetime.now(tz=occurred_at.tzinfo)
|
||
async with aiosqlite.connect(self.path) as db:
|
||
user_cursor = await db.execute(
|
||
"""
|
||
SELECT hm.household_id, u.full_name
|
||
FROM users u
|
||
LEFT JOIN household_members hm ON hm.user_id=u.user_id
|
||
WHERE u.user_id=?
|
||
""",
|
||
(user_id,),
|
||
)
|
||
user_row = await user_cursor.fetchone()
|
||
household_id = user_row[0] if user_row else None
|
||
author_name = str(user_row[1]) if user_row else None
|
||
cursor = await db.execute(
|
||
"""
|
||
INSERT INTO transactions (
|
||
user_id, amount_kopecks, description, kind, category, household_id,
|
||
budget_category_id, occurred_at, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
user_id,
|
||
amount_kopecks,
|
||
description,
|
||
kind.value,
|
||
category.value,
|
||
household_id,
|
||
budget_category_id,
|
||
occurred_at.isoformat(),
|
||
now.isoformat(),
|
||
),
|
||
)
|
||
await db.commit()
|
||
transaction_id = int(cursor.lastrowid)
|
||
transaction = await self.get_transaction(transaction_id, user_id)
|
||
if transaction is None:
|
||
raise RuntimeError("Операция не сохранилась")
|
||
return transaction
|
||
|
||
async def get_transaction(self, transaction_id: int, user_id: int) -> Transaction | None:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT t.*, u.full_name AS author_name,
|
||
c.name AS budget_category_name, c.icon AS budget_category_icon,
|
||
p.name AS parent_category_name, p.icon AS parent_category_icon
|
||
FROM transactions t JOIN users u ON u.user_id=t.user_id
|
||
LEFT JOIN budget_categories c ON c.id=t.budget_category_id
|
||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||
WHERE t.id=? AND t.user_id=?
|
||
""",
|
||
(transaction_id, user_id),
|
||
)
|
||
row = await cursor.fetchone()
|
||
return self._row_to_transaction(row) if row else None
|
||
|
||
async def update_category(
|
||
self, transaction_id: int, user_id: int, category: Category
|
||
) -> bool:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"UPDATE transactions SET category=? WHERE id=? AND user_id=? AND kind='expense'",
|
||
(category.value, transaction_id, user_id),
|
||
)
|
||
await db.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
async def delete_transaction(self, transaction_id: int, user_id: int) -> bool:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"DELETE FROM transactions WHERE id=? AND user_id=?",
|
||
(transaction_id, user_id),
|
||
)
|
||
await db.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
async def totals(
|
||
self, user_id: int, start: datetime | None = None, end: datetime | None = None
|
||
) -> Totals:
|
||
where = [self._scope_clause("t")]
|
||
params: list[object] = [user_id, user_id]
|
||
if start is not None:
|
||
where.append("occurred_at >= ?")
|
||
params.append(start.isoformat())
|
||
if end is not None:
|
||
where.append("occurred_at < ?")
|
||
params.append(end.isoformat())
|
||
query = f"""
|
||
SELECT
|
||
COALESCE(SUM(CASE WHEN kind='income' THEN amount_kopecks ELSE 0 END), 0),
|
||
COALESCE(SUM(CASE WHEN kind='expense' THEN amount_kopecks ELSE 0 END), 0)
|
||
FROM transactions t WHERE {' AND '.join(where)}
|
||
"""
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(query, params)
|
||
row = await cursor.fetchone()
|
||
return Totals(income=int(row[0]), expense=int(row[1]))
|
||
|
||
async def category_totals(
|
||
self, user_id: int, start: datetime | None = None, end: datetime | None = None
|
||
) -> dict[str, int]:
|
||
where = [self._scope_clause("t"), "t.kind='expense'"]
|
||
params: list[object] = [user_id, user_id]
|
||
if start is not None:
|
||
where.append("occurred_at>=?")
|
||
params.append(start.isoformat())
|
||
if end is not None:
|
||
where.append("occurred_at<?")
|
||
params.append(end.isoformat())
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
f"""
|
||
SELECT t.category, c.name, c.icon, p.name, p.icon,
|
||
SUM(t.amount_kopecks) AS total
|
||
FROM transactions t
|
||
LEFT JOIN budget_categories c ON c.id=t.budget_category_id
|
||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||
WHERE {' AND '.join(where)}
|
||
GROUP BY t.category, c.id, p.id ORDER BY total DESC
|
||
""",
|
||
params,
|
||
)
|
||
rows = await cursor.fetchall()
|
||
result: dict[str, int] = {}
|
||
for legacy_key, name, icon, parent_name, parent_icon, total in rows:
|
||
if name and parent_name:
|
||
label = f"{parent_icon or '📁'} {parent_name} → {icon or '📁'} {name}"
|
||
elif name:
|
||
label = f"{icon or '📁'} {name}"
|
||
else:
|
||
label = category_label(Category(legacy_key))
|
||
result[label] = result.get(label, 0) + int(total)
|
||
return result
|
||
|
||
async def recent_transactions(self, user_id: int, limit: int = 10) -> list[Transaction]:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(
|
||
f"""
|
||
SELECT t.*, u.full_name AS author_name,
|
||
c.name AS budget_category_name, c.icon AS budget_category_icon,
|
||
p.name AS parent_category_name, p.icon AS parent_category_icon
|
||
FROM transactions t JOIN users u ON u.user_id=t.user_id
|
||
LEFT JOIN budget_categories c ON c.id=t.budget_category_id
|
||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||
WHERE {self._scope_clause("t")}
|
||
ORDER BY t.occurred_at DESC, t.id DESC LIMIT ?
|
||
""",
|
||
(user_id, user_id, limit),
|
||
)
|
||
rows = await cursor.fetchall()
|
||
return [self._row_to_transaction(row) for row in rows]
|
||
|
||
async def create_family(self, user_id: int, now: datetime) -> FamilyInfo:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute("PRAGMA foreign_keys=ON")
|
||
await db.execute("BEGIN IMMEDIATE")
|
||
existing = await self._family_info_in_connection(db, user_id)
|
||
if existing is not None:
|
||
await db.rollback()
|
||
return existing
|
||
|
||
for _ in range(10):
|
||
code = "FIN-" + "".join(
|
||
secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(6)
|
||
)
|
||
try:
|
||
cursor = await db.execute(
|
||
"INSERT INTO households(invite_code, created_by, created_at) VALUES (?, ?, ?)",
|
||
(code, user_id, now.isoformat()),
|
||
)
|
||
break
|
||
except aiosqlite.IntegrityError:
|
||
continue
|
||
else:
|
||
await db.rollback()
|
||
raise RuntimeError("Не удалось создать уникальный код семьи")
|
||
|
||
household_id = int(cursor.lastrowid)
|
||
await self._seed_default_categories(db, household_id)
|
||
await db.execute(
|
||
"INSERT INTO household_members(household_id, user_id, joined_at) VALUES (?, ?, ?)",
|
||
(household_id, user_id, now.isoformat()),
|
||
)
|
||
await db.execute(
|
||
"UPDATE transactions SET household_id=? WHERE user_id=? AND household_id IS NULL",
|
||
(household_id, user_id),
|
||
)
|
||
await db.execute(
|
||
"""
|
||
UPDATE transactions
|
||
SET budget_category_id=(
|
||
SELECT id FROM budget_categories
|
||
WHERE household_id=? AND system_key=transactions.category
|
||
)
|
||
WHERE user_id=? AND household_id=? AND budget_category_id IS NULL
|
||
""",
|
||
(household_id, user_id, household_id),
|
||
)
|
||
await db.commit()
|
||
info = await self.family_info(user_id)
|
||
if info is None:
|
||
raise RuntimeError("Семья не сохранилась")
|
||
return info
|
||
|
||
async def request_family_join(
|
||
self, user_id: int, invite_code: str, now: datetime
|
||
) -> FamilyJoinRequest:
|
||
code = invite_code.strip().upper()
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute("PRAGMA foreign_keys=ON")
|
||
await db.execute("BEGIN IMMEDIATE")
|
||
if await self._family_info_in_connection(db, user_id) is not None:
|
||
await db.rollback()
|
||
raise FamilyJoinError("Вы уже состоите в семейном бюджете")
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT h.id, owner.chat_id, candidate.full_name,
|
||
(SELECT COUNT(*) FROM household_members WHERE household_id=h.id)
|
||
FROM households h
|
||
JOIN users owner ON owner.user_id=h.created_by
|
||
JOIN users candidate ON candidate.user_id=?
|
||
WHERE h.invite_code=?
|
||
""",
|
||
(user_id, code),
|
||
)
|
||
row = await cursor.fetchone()
|
||
if row is None:
|
||
await db.rollback()
|
||
raise FamilyJoinError("Код приглашения не найден")
|
||
if int(row[3]) >= 2:
|
||
await db.rollback()
|
||
raise FamilyJoinError("В этом семейном бюджете уже два участника")
|
||
await db.execute(
|
||
"""
|
||
INSERT INTO pending_family_joins(household_id, user_id, requested_at)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(user_id) DO UPDATE SET
|
||
household_id=excluded.household_id,
|
||
requested_at=excluded.requested_at
|
||
""",
|
||
(row[0], user_id, now.isoformat()),
|
||
)
|
||
await db.commit()
|
||
return FamilyJoinRequest(
|
||
candidate_user_id=user_id,
|
||
candidate_name=str(row[2]),
|
||
owner_chat_id=int(row[1]),
|
||
)
|
||
|
||
async def resolve_family_join(
|
||
self,
|
||
*,
|
||
owner_user_id: int,
|
||
candidate_user_id: int,
|
||
approve: bool,
|
||
now: datetime,
|
||
) -> tuple[bool, int, FamilyInfo | None]:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute("PRAGMA foreign_keys=ON")
|
||
await db.execute("BEGIN IMMEDIATE")
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT p.household_id, u.chat_id
|
||
FROM pending_family_joins p
|
||
JOIN households h ON h.id=p.household_id
|
||
JOIN users u ON u.user_id=p.user_id
|
||
WHERE h.created_by=? AND p.user_id=?
|
||
""",
|
||
(owner_user_id, candidate_user_id),
|
||
)
|
||
row = await cursor.fetchone()
|
||
if row is None:
|
||
await db.rollback()
|
||
raise FamilyJoinError("Запрос уже обработан или не найден")
|
||
household_id, candidate_chat_id = int(row[0]), int(row[1])
|
||
await db.execute(
|
||
"DELETE FROM pending_family_joins WHERE household_id=? AND user_id=?",
|
||
(household_id, candidate_user_id),
|
||
)
|
||
if not approve:
|
||
await db.commit()
|
||
return False, candidate_chat_id, None
|
||
count_cursor = await db.execute(
|
||
"SELECT COUNT(*) FROM household_members WHERE household_id=?",
|
||
(household_id,),
|
||
)
|
||
if int((await count_cursor.fetchone())[0]) >= 2:
|
||
await db.rollback()
|
||
raise FamilyJoinError("В семейном бюджете уже два участника")
|
||
await db.execute(
|
||
"INSERT INTO household_members(household_id, user_id, joined_at) VALUES (?, ?, ?)",
|
||
(household_id, candidate_user_id, now.isoformat()),
|
||
)
|
||
await db.execute(
|
||
"UPDATE transactions SET household_id=? WHERE user_id=? AND household_id IS NULL",
|
||
(household_id, candidate_user_id),
|
||
)
|
||
await db.execute(
|
||
"""
|
||
UPDATE transactions
|
||
SET budget_category_id=(
|
||
SELECT id FROM budget_categories
|
||
WHERE household_id=? AND system_key=transactions.category
|
||
)
|
||
WHERE user_id=? AND household_id=? AND budget_category_id IS NULL
|
||
""",
|
||
(household_id, candidate_user_id, household_id),
|
||
)
|
||
await db.commit()
|
||
return True, candidate_chat_id, await self.family_info(candidate_user_id)
|
||
|
||
async def bind_family_chat(
|
||
self, owner_user_id: int, chat_id: int, chat_title: str
|
||
) -> FamilyInfo:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
UPDATE households
|
||
SET notification_chat_id=?, notification_chat_title=?
|
||
WHERE created_by=?
|
||
""",
|
||
(chat_id, chat_title, owner_user_id),
|
||
)
|
||
await db.commit()
|
||
if cursor.rowcount == 0:
|
||
raise FamilyJoinError("Привязать чат может только создатель семейного бюджета")
|
||
info = await self.family_info(owner_user_id)
|
||
if info is None:
|
||
raise FamilyJoinError("Семейный бюджет не найден")
|
||
return info
|
||
|
||
async def set_family_debt(
|
||
self, owner_user_id: int, amount_kopecks: int, now: datetime
|
||
) -> int:
|
||
if amount_kopecks < 0:
|
||
raise ValueError("Долг не может быть отрицательным")
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
UPDATE households
|
||
SET debt_base_kopecks=?, debt_started_at=?
|
||
WHERE created_by=?
|
||
""",
|
||
(amount_kopecks, now.isoformat(), owner_user_id),
|
||
)
|
||
await db.commit()
|
||
if cursor.rowcount == 0:
|
||
raise FamilyJoinError("Изменить общий долг может только создатель бюджета")
|
||
return amount_kopecks
|
||
|
||
async def family_debt(self, user_id: int) -> int | None:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT h.debt_base_kopecks, h.debt_started_at,
|
||
COALESCE(SUM(
|
||
CASE
|
||
WHEN t.kind='expense' THEN t.amount_kopecks
|
||
WHEN t.kind='income' THEN -t.amount_kopecks
|
||
ELSE 0
|
||
END
|
||
), 0)
|
||
FROM households h
|
||
JOIN household_members hm ON hm.household_id=h.id
|
||
LEFT JOIN transactions t
|
||
ON t.household_id=h.id AND t.occurred_at>h.debt_started_at
|
||
WHERE hm.user_id=?
|
||
GROUP BY h.id
|
||
""",
|
||
(user_id,),
|
||
)
|
||
row = await cursor.fetchone()
|
||
if row is None or row[0] is None or row[1] is None:
|
||
return None
|
||
return int(row[0]) + int(row[2])
|
||
|
||
async def budget_categories(self, user_id: int) -> list[BudgetCategory]:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT c.id, c.name, c.icon, c.parent_id, p.name, p.icon, c.system_key
|
||
FROM household_members hm
|
||
JOIN budget_categories c ON c.household_id=hm.household_id
|
||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||
WHERE hm.user_id=? AND (c.system_key IS NULL OR c.system_key<>'income')
|
||
ORDER BY COALESCE(p.id, c.id), c.parent_id IS NOT NULL, c.name
|
||
""",
|
||
(user_id,),
|
||
)
|
||
rows = await cursor.fetchall()
|
||
return [
|
||
BudgetCategory(
|
||
id=int(row[0]),
|
||
name=str(row[1]),
|
||
icon=str(row[2]),
|
||
parent_id=int(row[3]) if row[3] is not None else None,
|
||
parent_name=str(row[4]) if row[4] is not None else None,
|
||
parent_icon=str(row[5]) if row[5] is not None else None,
|
||
system_key=str(row[6]) if row[6] is not None else None,
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
async def resolve_budget_category(
|
||
self, user_id: int, main_category: Category, subcategory_key: str | None
|
||
) -> int | None:
|
||
key = subcategory_key or main_category.value
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT c.id
|
||
FROM household_members hm
|
||
JOIN budget_categories c ON c.household_id=hm.household_id
|
||
WHERE hm.user_id=? AND c.system_key=?
|
||
""",
|
||
(user_id, key),
|
||
)
|
||
row = await cursor.fetchone()
|
||
return int(row[0]) if row else None
|
||
|
||
async def create_budget_category(
|
||
self,
|
||
*,
|
||
user_id: int,
|
||
name: str,
|
||
parent_id: int | None,
|
||
now: datetime,
|
||
) -> BudgetCategory:
|
||
clean_name = " ".join(name.split()).strip()
|
||
if not 2 <= len(clean_name) <= 40:
|
||
raise ValueError("Название должно содержать от 2 до 40 символов")
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute("PRAGMA foreign_keys=ON")
|
||
household_cursor = await db.execute(
|
||
"SELECT household_id FROM household_members WHERE user_id=?", (user_id,)
|
||
)
|
||
household_row = await household_cursor.fetchone()
|
||
if household_row is None:
|
||
raise FamilyJoinError("Категории настраиваются внутри семейного бюджета")
|
||
household_id = int(household_row[0])
|
||
count_cursor = await db.execute(
|
||
"SELECT COUNT(*) FROM budget_categories WHERE household_id=?", (household_id,)
|
||
)
|
||
if int((await count_cursor.fetchone())[0]) >= 60:
|
||
raise ValueError("Достигнут лимит в 60 категорий и подкатегорий")
|
||
if parent_id is not None:
|
||
parent_cursor = await db.execute(
|
||
"""
|
||
SELECT 1 FROM budget_categories
|
||
WHERE id=? AND household_id=? AND parent_id IS NULL
|
||
AND (system_key IS NULL OR system_key<>'income')
|
||
""",
|
||
(parent_id, household_id),
|
||
)
|
||
if await parent_cursor.fetchone() is None:
|
||
raise ValueError("Основная категория не найдена")
|
||
try:
|
||
cursor = await db.execute(
|
||
"""
|
||
INSERT INTO budget_categories(
|
||
household_id, name, icon, parent_id, created_by, created_at
|
||
) VALUES (?, ?, '📁', ?, ?, ?)
|
||
""",
|
||
(household_id, clean_name, parent_id, user_id, now.isoformat()),
|
||
)
|
||
await db.commit()
|
||
except aiosqlite.IntegrityError as exc:
|
||
raise ValueError("Такая категория уже существует") from exc
|
||
category_id = int(cursor.lastrowid)
|
||
return next(
|
||
category for category in await self.budget_categories(user_id)
|
||
if category.id == category_id
|
||
)
|
||
|
||
async def update_transaction_budget_category(
|
||
self, transaction_id: int, user_id: int, category_id: int
|
||
) -> bool:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"""
|
||
UPDATE transactions
|
||
SET budget_category_id=?
|
||
WHERE id=? AND user_id=? AND kind='expense'
|
||
AND household_id=(
|
||
SELECT household_id FROM budget_categories WHERE id=?
|
||
)
|
||
""",
|
||
(category_id, transaction_id, user_id, category_id),
|
||
)
|
||
await db.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
async def family_info(self, user_id: int) -> FamilyInfo | None:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
return await self._family_info_in_connection(db, user_id)
|
||
|
||
async def family_recipients(self, user_id: int) -> list[aiosqlite.Row]:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT u.user_id, u.chat_id, u.full_name
|
||
FROM household_members mine
|
||
JOIN household_members other ON other.household_id=mine.household_id
|
||
JOIN users u ON u.user_id=other.user_id
|
||
WHERE mine.user_id=? AND other.user_id<>?
|
||
""",
|
||
(user_id, user_id),
|
||
)
|
||
return await cursor.fetchall()
|
||
|
||
async def family_notification_chats(self, user_id: int) -> list[int]:
|
||
recipients = [int(row["chat_id"]) for row in await self.family_recipients(user_id)]
|
||
info = await self.family_info(user_id)
|
||
if info and info.notification_chat_id is not None:
|
||
recipients.append(info.notification_chat_id)
|
||
return list(dict.fromkeys(recipients))
|
||
|
||
async def _family_info_in_connection(
|
||
self, db: aiosqlite.Connection, user_id: int
|
||
) -> FamilyInfo | None:
|
||
cursor = await db.execute(
|
||
"""
|
||
SELECT h.id, h.invite_code, h.created_by,
|
||
h.notification_chat_id, h.notification_chat_title
|
||
FROM households h
|
||
JOIN household_members hm ON hm.household_id=h.id
|
||
WHERE hm.user_id=?
|
||
""",
|
||
(user_id,),
|
||
)
|
||
row = await cursor.fetchone()
|
||
if row is None:
|
||
return None
|
||
members_cursor = await db.execute(
|
||
"""
|
||
SELECT u.full_name
|
||
FROM household_members hm JOIN users u ON u.user_id=hm.user_id
|
||
WHERE hm.household_id=? ORDER BY hm.joined_at
|
||
""",
|
||
(row[0],),
|
||
)
|
||
members = tuple(str(member[0]) for member in await members_cursor.fetchall())
|
||
return FamilyInfo(
|
||
household_id=int(row[0]),
|
||
invite_code=str(row[1]),
|
||
members=members,
|
||
owner_user_id=int(row[2]),
|
||
notification_chat_id=int(row[3]) if row[3] is not None else None,
|
||
notification_chat_title=str(row[4]) if row[4] is not None else None,
|
||
)
|
||
|
||
async def family_report_target(self, user_id: int) -> tuple[int, int] | None:
|
||
info = await self.family_info(user_id)
|
||
if info is None or info.notification_chat_id is None:
|
||
return None
|
||
return info.household_id, info.notification_chat_id
|
||
|
||
async def family_report_was_sent(self, household_id: int, local_date: str) -> bool:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"SELECT 1 FROM family_daily_reports WHERE household_id=? AND local_date=?",
|
||
(household_id, local_date),
|
||
)
|
||
return await cursor.fetchone() is not None
|
||
|
||
async def mark_family_report_sent(
|
||
self, household_id: int, local_date: str, sent_at: datetime
|
||
) -> None:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO family_daily_reports(household_id, local_date, sent_at)
|
||
VALUES (?, ?, ?)
|
||
""",
|
||
(household_id, local_date, sent_at.isoformat()),
|
||
)
|
||
await db.commit()
|
||
|
||
async def users_for_reports(self) -> list[aiosqlite.Row]:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(
|
||
"SELECT user_id, chat_id, timezone, report_time FROM users"
|
||
)
|
||
return await cursor.fetchall()
|
||
|
||
async def report_was_sent(self, user_id: int, local_date: str) -> bool:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
cursor = await db.execute(
|
||
"SELECT 1 FROM daily_reports WHERE user_id=? AND local_date=?",
|
||
(user_id, local_date),
|
||
)
|
||
return await cursor.fetchone() is not None
|
||
|
||
async def mark_report_sent(self, user_id: int, local_date: str, sent_at: datetime) -> None:
|
||
async with aiosqlite.connect(self.path) as db:
|
||
await db.execute(
|
||
"INSERT OR IGNORE INTO daily_reports(user_id, local_date, sent_at) VALUES (?, ?, ?)",
|
||
(user_id, local_date, sent_at.isoformat()),
|
||
)
|
||
await db.commit()
|
||
|
||
@staticmethod
|
||
def _row_to_transaction(row: aiosqlite.Row) -> Transaction:
|
||
return Transaction(
|
||
id=int(row["id"]),
|
||
user_id=int(row["user_id"]),
|
||
amount_kopecks=int(row["amount_kopecks"]),
|
||
description=str(row["description"]),
|
||
kind=TransactionKind(row["kind"]),
|
||
category=Category(row["category"]),
|
||
occurred_at=datetime.fromisoformat(row["occurred_at"]),
|
||
author_name=str(row["author_name"]) if "author_name" in row.keys() else None,
|
||
budget_category_id=(
|
||
int(row["budget_category_id"])
|
||
if "budget_category_id" in row.keys() and row["budget_category_id"] is not None
|
||
else None
|
||
),
|
||
budget_category_name=(
|
||
str(row["budget_category_name"])
|
||
if "budget_category_name" in row.keys() and row["budget_category_name"] is not None
|
||
else None
|
||
),
|
||
budget_category_icon=(
|
||
str(row["budget_category_icon"])
|
||
if "budget_category_icon" in row.keys() and row["budget_category_icon"] is not None
|
||
else None
|
||
),
|
||
parent_category_name=(
|
||
str(row["parent_category_name"])
|
||
if "parent_category_name" in row.keys() and row["parent_category_name"] is not None
|
||
else None
|
||
),
|
||
parent_category_icon=(
|
||
str(row["parent_category_icon"])
|
||
if "parent_category_icon" in row.keys() and row["parent_category_icon"] is not None
|
||
else None
|
||
),
|
||
)
|
||
|
||
@staticmethod
|
||
async def _seed_default_categories(
|
||
db: aiosqlite.Connection, household_id: int
|
||
) -> None:
|
||
created_at = datetime.now().astimezone().isoformat()
|
||
for category in Category:
|
||
icon, name = CATEGORY_META[category]
|
||
await db.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO budget_categories(
|
||
household_id, name, icon, parent_id, system_key, created_at
|
||
) VALUES (?, ?, ?, NULL, ?, ?)
|
||
""",
|
||
(household_id, name, icon, category.value, created_at),
|
||
)
|
||
parent_cursor = await db.execute(
|
||
"SELECT id FROM budget_categories WHERE household_id=? AND system_key=?",
|
||
(household_id, category.value),
|
||
)
|
||
parent_id = int((await parent_cursor.fetchone())[0])
|
||
for system_key, sub_icon, sub_name, _keywords in DEFAULT_SUBCATEGORIES.get(
|
||
category, ()
|
||
):
|
||
await db.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO budget_categories(
|
||
household_id, name, icon, parent_id, system_key, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(household_id, sub_name, sub_icon, parent_id, system_key, created_at),
|
||
)
|
||
|
||
@staticmethod
|
||
def _scope_clause(alias: str) -> str:
|
||
return f"""(
|
||
({alias}.household_id IS NOT NULL AND {alias}.household_id=(
|
||
SELECT household_id FROM household_members WHERE user_id=?
|
||
))
|
||
OR ({alias}.household_id IS NULL AND {alias}.user_id=?)
|
||
)"""
|