Build Telegram savings tracker WebApp
This commit is contained in:
commit
29bd6b3fd8
19 changed files with 6155 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.git
|
||||
.env
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
data
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
TELEGRAM_TOKEN=123456789:replace_me
|
||||
BASE_APP_URL=https://example.com
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.env
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
data/
|
||||
.DS_Store
|
||||
21
Caddyfile
Normal file
21
Caddyfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{$BASE_APP_URL:http://localhost} {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy no-referrer
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
handle_path /app* {
|
||||
reverse_proxy app:8000
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy app:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
redir /app/ 308
|
||||
}
|
||||
}
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FROM ghcr.io/astral-sh/uv:0.12.9 AS uv
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
DATA_DIR=/data
|
||||
|
||||
COPY --from=uv /uv /uvx /bin/
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY app ./app
|
||||
COPY static ./static
|
||||
|
||||
RUN mkdir -p /data/avatars && chown -R 10001:10001 /app /data
|
||||
USER 10001:10001
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
62
README.md
Normal file
62
README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Пожитки
|
||||
|
||||
Telegram WebApp для учёта поступлений и расходов. Бот принимает записи через
|
||||
`Telegram.WebApp.sendData`, сохраняет подтверждённый Telegram ID отправителя,
|
||||
имя, username и аватар. WebApp показывает баланс, категории, общий рейтинг и
|
||||
рейтинг среди друзей.
|
||||
|
||||
## Запуск
|
||||
|
||||
1. Создайте бота через BotFather и получите токен.
|
||||
2. Скопируйте `.env.example` в `.env` и укажите:
|
||||
|
||||
```dotenv
|
||||
TELEGRAM_TOKEN=123456789:your_token
|
||||
BASE_APP_URL=https://example.com
|
||||
```
|
||||
|
||||
3. Направьте DNS домена на сервер и откройте порты 80/443.
|
||||
4. Запустите:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Caddy автоматически получает TLS-сертификат. WebApp доступен по
|
||||
`https://example.com/app/`, read-only API — по `https://example.com/api/`.
|
||||
|
||||
Команда `/start` выводит reply-кнопку запуска WebApp. Это важно: Telegram
|
||||
поддерживает `WebApp.sendData()` для приложения, открытого через
|
||||
`KeyboardButton`, и присылает payload боту в `web_app_data`.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/healthz` — проверка состояния;
|
||||
- `GET /api/leaderboard?limit=100` — общий рейтинг;
|
||||
- `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей;
|
||||
- `GET /api/users/{id}/summary` — публичная сводка пользователя;
|
||||
- `GET /api/users/{id}/friends` — Telegram ID друзей;
|
||||
- `GET /api/avatars/{id}` — сохранённый аватар;
|
||||
- `GET /api/docs` — OpenAPI UI.
|
||||
|
||||
Процент накоплений: `(поступления − расходы) / поступления × 100`. При нулевых
|
||||
поступлениях он равен 0%. Суммы хранятся целым числом копеек, SQLite работает в
|
||||
WAL-режиме, данные лежат в Docker volume `app-data`.
|
||||
|
||||
API намеренно не принимает операции записи. Пользовательский ID для операции
|
||||
берётся из Telegram-сообщения, поэтому его нельзя подменить payload-ом WebApp.
|
||||
При этом лидерборды и сводки публичны — не публикуйте там сведения, которые не
|
||||
хотите показывать другим участникам.
|
||||
|
||||
## Разработка
|
||||
|
||||
Зависимости управляются `uv`:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest
|
||||
TELEGRAM_TOKEN='' DATA_DIR=./data uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Для браузерного предпросмотра можно открыть `/app/?user_id=123`. Отправка
|
||||
операции вне Telegram специально отключена.
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Pozhitki Telegram WebApp backend."""
|
||||
189
app/bot.py
Normal file
189
app/bot.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import telebot
|
||||
from telebot import types
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import Database, TransactionInput, amount_to_cents
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATEGORY_PATTERN = re.compile(r"^[^\x00-\x1f]{1,40}$")
|
||||
|
||||
|
||||
def parse_transaction(raw: str) -> TransactionInput:
|
||||
try:
|
||||
payload: dict[str, Any] = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise ValueError("Не удалось прочитать данные") from exc
|
||||
|
||||
if payload.get("type") != "transaction" or payload.get("v") != 1:
|
||||
raise ValueError("Неизвестный формат данных")
|
||||
|
||||
kind = payload.get("kind")
|
||||
if kind not in {"income", "expense"}:
|
||||
raise ValueError("Некорректный тип операции")
|
||||
|
||||
category = str(payload.get("category", "")).strip()
|
||||
if not CATEGORY_PATTERN.fullmatch(category):
|
||||
raise ValueError("Категория должна содержать от 1 до 40 символов")
|
||||
|
||||
note = str(payload.get("note", "")).strip() or None
|
||||
if note and len(note) > 160:
|
||||
raise ValueError("Комментарий слишком длинный")
|
||||
|
||||
occurred_at_raw = str(payload.get("occurred_at", "")).strip()
|
||||
try:
|
||||
occurred_at = datetime.fromisoformat(occurred_at_raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
occurred_at = datetime.now(UTC)
|
||||
if occurred_at.tzinfo is None:
|
||||
occurred_at = occurred_at.replace(tzinfo=UTC)
|
||||
|
||||
return TransactionInput(
|
||||
kind=kind,
|
||||
amount_cents=amount_to_cents(payload.get("amount")),
|
||||
category=category,
|
||||
note=note,
|
||||
occurred_at=occurred_at.astimezone(UTC).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
class TelegramBotService:
|
||||
def __init__(self, settings: Settings, database: Database):
|
||||
self.settings = settings
|
||||
self.database = database
|
||||
self.bot = telebot.TeleBot(settings.telegram_token, threaded=True)
|
||||
self.thread: Thread | None = None
|
||||
self._register_handlers()
|
||||
|
||||
def _keyboard(self) -> types.ReplyKeyboardMarkup:
|
||||
keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True)
|
||||
keyboard.add(
|
||||
types.KeyboardButton(
|
||||
text="Открыть Пожитки",
|
||||
web_app=types.WebAppInfo(url=self.settings.webapp_url),
|
||||
)
|
||||
)
|
||||
return keyboard
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
@self.bot.message_handler(commands=["start"])
|
||||
def start(message: types.Message) -> None:
|
||||
user = message.from_user
|
||||
if user is None:
|
||||
return
|
||||
self._save_user(user)
|
||||
|
||||
parts = (message.text or "").split(maxsplit=1)
|
||||
friend_added = False
|
||||
if len(parts) == 2 and parts[1].startswith("friend_"):
|
||||
try:
|
||||
inviter_id = int(parts[1].removeprefix("friend_"))
|
||||
except ValueError:
|
||||
inviter_id = 0
|
||||
if inviter_id > 0:
|
||||
friend_added = self.database.add_friendship(user.id, inviter_id)
|
||||
|
||||
prefix = "Теперь вы друзья!\n\n" if friend_added else ""
|
||||
self.bot.send_message(
|
||||
message.chat.id,
|
||||
prefix
|
||||
+ "Пожитки считают доходы, расходы и процент накоплений. "
|
||||
"Нажмите кнопку ниже, чтобы открыть приложение.",
|
||||
reply_markup=self._keyboard(),
|
||||
)
|
||||
|
||||
@self.bot.message_handler(content_types=["web_app_data"])
|
||||
def web_app_data(message: types.Message) -> None:
|
||||
user = message.from_user
|
||||
if user is None or message.web_app_data is None:
|
||||
return
|
||||
self._save_user(user)
|
||||
try:
|
||||
item = parse_transaction(message.web_app_data.data)
|
||||
self.database.add_transaction(user.id, item)
|
||||
except ValueError as exc:
|
||||
self.bot.send_message(
|
||||
message.chat.id,
|
||||
f"Не получилось сохранить операцию: {exc}",
|
||||
reply_markup=self._keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
operation = "Доход" if item.kind == "income" else "Расход"
|
||||
self.bot.send_message(
|
||||
message.chat.id,
|
||||
f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.",
|
||||
reply_markup=self._keyboard(),
|
||||
)
|
||||
|
||||
@self.bot.message_handler(commands=["help"])
|
||||
def help_message(message: types.Message) -> None:
|
||||
self.bot.send_message(
|
||||
message.chat.id,
|
||||
"Добавляйте операции через WebApp. Ссылка «Стать друзьями» находится на вкладке рейтинга.",
|
||||
reply_markup=self._keyboard(),
|
||||
)
|
||||
|
||||
def _save_user(self, user: types.User) -> None:
|
||||
self.database.upsert_user(
|
||||
user_id=user.id,
|
||||
first_name=user.first_name or "Пользователь",
|
||||
last_name=user.last_name,
|
||||
username=user.username,
|
||||
)
|
||||
try:
|
||||
photos = self.bot.get_user_profile_photos(user.id, limit=1)
|
||||
if not photos.photos:
|
||||
return
|
||||
photo = photos.photos[0][-1]
|
||||
if self.database.get_avatar_file_id(user.id) == photo.file_id:
|
||||
return
|
||||
file_info = self.bot.get_file(photo.file_id)
|
||||
content = self.bot.download_file(file_info.file_path)
|
||||
suffix = Path(file_info.file_path).suffix or ".jpg"
|
||||
filename = f"{user.id}{suffix}"
|
||||
target = self.settings.avatars_dir / filename
|
||||
self.settings.avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
self.database.upsert_user(
|
||||
user_id=user.id,
|
||||
first_name=user.first_name or "Пользователь",
|
||||
last_name=user.last_name,
|
||||
username=user.username,
|
||||
avatar_file_id=photo.file_id,
|
||||
avatar_path=filename,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Could not update avatar for Telegram user %s", user.id)
|
||||
|
||||
def start(self) -> None:
|
||||
me = self.bot.get_me()
|
||||
if me.username:
|
||||
self.database.set_meta("bot_username", me.username)
|
||||
self.bot.set_my_commands(
|
||||
[
|
||||
types.BotCommand("start", "Открыть приложение"),
|
||||
types.BotCommand("help", "Как пользоваться"),
|
||||
]
|
||||
)
|
||||
self.thread = Thread(
|
||||
target=self.bot.infinity_polling,
|
||||
kwargs={"skip_pending": True, "timeout": 30, "long_polling_timeout": 30},
|
||||
name="telegram-polling",
|
||||
daemon=True,
|
||||
)
|
||||
self.thread.start()
|
||||
logger.info("Telegram bot @%s started", me.username)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.bot.stop_polling()
|
||||
32
app/config.py
Normal file
32
app/config.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
telegram_token: str
|
||||
base_app_url: str
|
||||
data_dir: Path
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
return self.data_dir / "pozhitki.sqlite3"
|
||||
|
||||
@property
|
||||
def avatars_dir(self) -> Path:
|
||||
return self.data_dir / "avatars"
|
||||
|
||||
@property
|
||||
def webapp_url(self) -> str:
|
||||
return f"{self.base_app_url.rstrip('/')}/app/"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings(
|
||||
telegram_token=os.getenv("TELEGRAM_TOKEN", "").strip(),
|
||||
base_app_url=os.getenv("BASE_APP_URL", "http://localhost").strip(),
|
||||
data_dir=Path(os.getenv("DATA_DIR", "/data")),
|
||||
)
|
||||
323
app/db.py
Normal file
323
app/db.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
nickname TEXT NOT NULL,
|
||||
username TEXT,
|
||||
avatar_file_id TEXT,
|
||||
avatar_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense')),
|
||||
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
|
||||
category TEXT NOT NULL,
|
||||
note TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_user_id
|
||||
ON transactions(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
user_id_low INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
user_id_high INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id_low, user_id_high),
|
||||
CHECK (user_id_low < user_id_high)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransactionInput:
|
||||
kind: str
|
||||
amount_cents: int
|
||||
category: str
|
||||
note: str | None
|
||||
occurred_at: str
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def amount_to_cents(value: Any) -> int:
|
||||
try:
|
||||
amount = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
except (InvalidOperation, ValueError, TypeError) as exc:
|
||||
raise ValueError("Некорректная сумма") from exc
|
||||
cents = int(amount * 100)
|
||||
if cents <= 0:
|
||||
raise ValueError("Сумма должна быть больше нуля")
|
||||
if cents > 100_000_000_000_00:
|
||||
raise ValueError("Сумма слишком большая")
|
||||
return cents
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=15)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA busy_timeout = 15000")
|
||||
return connection
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(self.connect()) as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
connection.commit()
|
||||
|
||||
def upsert_user(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
first_name: str,
|
||||
last_name: str | None,
|
||||
username: str | None,
|
||||
avatar_file_id: str | None = None,
|
||||
avatar_path: str | None = None,
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
nickname = " ".join(part for part in (first_name, last_name) if part).strip()
|
||||
nickname = nickname or f"Пользователь {user_id}"
|
||||
with closing(self.connect()) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
user_id, first_name, last_name, nickname, username,
|
||||
avatar_file_id, avatar_path, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
nickname = excluded.nickname,
|
||||
username = excluded.username,
|
||||
avatar_file_id = COALESCE(excluded.avatar_file_id, users.avatar_file_id),
|
||||
avatar_path = COALESCE(excluded.avatar_path, users.avatar_path),
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
first_name,
|
||||
last_name,
|
||||
nickname,
|
||||
username,
|
||||
avatar_file_id,
|
||||
avatar_path,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get_avatar_file_id(self, user_id: int) -> str | None:
|
||||
with closing(self.connect()) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT avatar_file_id FROM users WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return row["avatar_file_id"] if row else None
|
||||
|
||||
def add_transaction(self, user_id: int, item: TransactionInput) -> int:
|
||||
with closing(self.connect()) as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO transactions (
|
||||
user_id, kind, amount_cents, category, note,
|
||||
occurred_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
item.kind,
|
||||
item.amount_cents,
|
||||
item.category,
|
||||
item.note,
|
||||
item.occurred_at,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def add_friendship(self, first_user_id: int, second_user_id: int) -> bool:
|
||||
if first_user_id == second_user_id:
|
||||
return False
|
||||
low, high = sorted((first_user_id, second_user_id))
|
||||
with closing(self.connect()) as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO friendships (user_id_low, user_id_high, created_at)
|
||||
SELECT ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM users WHERE user_id = ?)
|
||||
AND EXISTS (SELECT 1 FROM users WHERE user_id = ?)
|
||||
""",
|
||||
(low, high, utc_now(), low, high),
|
||||
)
|
||||
connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def friend_ids(self, user_id: int) -> list[int]:
|
||||
with closing(self.connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT CASE
|
||||
WHEN user_id_low = ? THEN user_id_high
|
||||
ELSE user_id_low
|
||||
END AS friend_id
|
||||
FROM friendships
|
||||
WHERE user_id_low = ? OR user_id_high = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
return [int(row["friend_id"]) for row in rows]
|
||||
|
||||
def set_meta(self, key: str, value: str) -> None:
|
||||
with closing(self.connect()) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO app_meta (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get_meta(self, key: str) -> str | None:
|
||||
with closing(self.connect()) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT value FROM app_meta WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
def summary(self, user_id: int) -> dict[str, Any] | None:
|
||||
with closing(self.connect()) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.nickname,
|
||||
u.username,
|
||||
u.avatar_path,
|
||||
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
|
||||
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
|
||||
FROM users u
|
||||
LEFT JOIN transactions t ON t.user_id = u.user_id
|
||||
WHERE u.user_id = ?
|
||||
GROUP BY u.user_id
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
categories = connection.execute(
|
||||
"""
|
||||
SELECT kind, category, SUM(amount_cents) AS amount_cents
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
GROUP BY kind, category
|
||||
ORDER BY amount_cents DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return self._serialize_totals(dict(row), categories)
|
||||
|
||||
def leaderboard(
|
||||
self, *, user_ids: Iterable[int] | None = None, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
ids = list(dict.fromkeys(user_ids or []))
|
||||
where = ""
|
||||
params: list[Any] = []
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
where = f"WHERE u.user_id IN ({placeholders})"
|
||||
params.extend(ids)
|
||||
params.append(limit)
|
||||
with closing(self.connect()) as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.nickname,
|
||||
u.username,
|
||||
u.avatar_path,
|
||||
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
|
||||
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
|
||||
FROM users u
|
||||
LEFT JOIN transactions t ON t.user_id = u.user_id
|
||||
{where}
|
||||
GROUP BY u.user_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) > 0
|
||||
THEN 1.0 * (
|
||||
SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) -
|
||||
SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END)
|
||||
) / SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END) -
|
||||
SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END)) DESC,
|
||||
u.user_id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
result = []
|
||||
for rank, row in enumerate(rows, start=1):
|
||||
item = self._serialize_totals(dict(row), [])
|
||||
item["rank"] = rank
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _serialize_totals(
|
||||
row: dict[str, Any], categories: Iterable[sqlite3.Row]
|
||||
) -> dict[str, Any]:
|
||||
income = int(row["income_cents"])
|
||||
expense = int(row["expense_cents"])
|
||||
balance = income - expense
|
||||
percent = round(balance * 100 / income, 1) if income else 0.0
|
||||
return {
|
||||
"user_id": int(row["user_id"]),
|
||||
"nickname": row["nickname"],
|
||||
"username": row["username"],
|
||||
"avatar_url": (
|
||||
f"/api/avatars/{row['user_id']}" if row.get("avatar_path") else None
|
||||
),
|
||||
"income_cents": income,
|
||||
"expense_cents": expense,
|
||||
"balance_cents": balance,
|
||||
"saved_percent": percent,
|
||||
"categories": [dict(category) for category in categories],
|
||||
}
|
||||
128
app/main.py
Normal file
128
app/main.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.bot import TelegramBotService
|
||||
from app.config import load_settings
|
||||
from app.db import Database
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = load_settings()
|
||||
database = Database(settings.database_path)
|
||||
bot_service: TelegramBotService | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
global bot_service
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
database.initialize()
|
||||
if settings.telegram_token:
|
||||
try:
|
||||
bot_service = TelegramBotService(settings, database)
|
||||
bot_service.start()
|
||||
except Exception:
|
||||
logger.exception("Telegram bot could not start; HTTP application remains available")
|
||||
else:
|
||||
logger.warning("TELEGRAM_TOKEN is empty; running HTTP application without the bot")
|
||||
yield
|
||||
if bot_service:
|
||||
bot_service.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Пожитки API",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def public_config() -> dict[str, str | None]:
|
||||
return {"bot_username": database.get_meta("bot_username")}
|
||||
|
||||
|
||||
@app.get("/api/users/{user_id}/summary")
|
||||
def user_summary(user_id: int) -> dict:
|
||||
result = database.summary(user_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/users/{user_id}/friends")
|
||||
def user_friends(user_id: int) -> dict[str, list[int]]:
|
||||
return {"user_ids": database.friend_ids(user_id)}
|
||||
|
||||
|
||||
def parse_user_ids(raw: str | None) -> list[int] | None:
|
||||
if not raw:
|
||||
return None
|
||||
values: list[int] = []
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
value = int(item)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="user_ids должен быть списком чисел") from exc
|
||||
if value <= 0:
|
||||
raise HTTPException(status_code=422, detail="user_ids должен содержать положительные ID")
|
||||
values.append(value)
|
||||
if len(values) > 100:
|
||||
raise HTTPException(status_code=422, detail="Можно запросить не более 100 пользователей")
|
||||
return values or None
|
||||
|
||||
|
||||
@app.get("/api/leaderboard")
|
||||
def leaderboard(
|
||||
user_ids: str | None = Query(
|
||||
default=None, description="Telegram user ID через запятую"
|
||||
),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
) -> dict[str, list[dict]]:
|
||||
return {"items": database.leaderboard(user_ids=parse_user_ids(user_ids), limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/avatars/{user_id}", response_class=FileResponse)
|
||||
def avatar(user_id: int) -> FileResponse:
|
||||
summary = database.summary(user_id)
|
||||
if not summary or not summary["avatar_url"]:
|
||||
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT avatar_path FROM users WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||
filename = Path(row["avatar_path"]).name
|
||||
path = settings.avatars_dir / filename
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||
media_type = mimetypes.guess_type(path)[0] or "image/jpeg"
|
||||
return FileResponse(path, media_type=media_type)
|
||||
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="webapp")
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TELEGRAM_TOKEN: ${TELEGRAM_TOKEN:?Set TELEGRAM_TOKEN in .env}
|
||||
BASE_APP_URL: ${BASE_APP_URL:?Set BASE_APP_URL in .env}
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- app-data:/data
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2.10-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
BASE_APP_URL: ${BASE_APP_URL:?Set BASE_APP_URL in .env}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[project]
|
||||
name = "pozhitki"
|
||||
version = "0.1.0"
|
||||
description = "Telegram WebApp for tracking income, expenses and savings"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1",
|
||||
"pytelegrambotapi>=4.23.0,<5",
|
||||
"uvicorn[standard]>=0.30.0,<1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.27.0,<1",
|
||||
"pytest>=8.3.0,<9",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
236
static/app.js
Normal file
236
static/app.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const tgUser = tg?.initDataUnsafe?.user;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const previewUserId = Number(query.get("user_id")) || 0;
|
||||
const user = tgUser || (previewUserId ? { id: previewUserId, first_name: "Предпросмотр" } : null);
|
||||
const userId = Number(user?.id) || 0;
|
||||
|
||||
const state = { kind: "income", board: "all", summary: null, config: null };
|
||||
const categories = {
|
||||
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
|
||||
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const money = new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 2 });
|
||||
|
||||
function initTelegram() {
|
||||
if (!tg) return;
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
tg.enableClosingConfirmation?.();
|
||||
if (tg.isVersionAtLeast?.("6.1")) tg.setHeaderColor("secondary_bg_color");
|
||||
if (tg.isVersionAtLeast?.("7.10")) tg.setBottomBarColor("secondary_bg_color");
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
window.setTimeout(() => toast.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
function setIdentity() {
|
||||
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ") || "Гость";
|
||||
$("#userName").textContent = name;
|
||||
$("#userInitial").textContent = name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function bindNavigation() {
|
||||
document.querySelectorAll(".bottom-nav button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll(".bottom-nav button, .view").forEach((item) => item.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
$(`#${button.dataset.view}`).classList.add("active");
|
||||
if (button.dataset.view === "leaderboardView") loadLeaderboard();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillCategories() {
|
||||
const select = $("#category");
|
||||
select.innerHTML = categories[state.kind].map((category) => `<option value="${category}">${category}</option>`).join("");
|
||||
$("#customCategoryWrap").classList.add("hidden");
|
||||
$("#customCategory").required = false;
|
||||
}
|
||||
|
||||
function bindForm() {
|
||||
document.querySelectorAll(".segment").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll(".segment").forEach((item) => item.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
state.kind = button.dataset.kind;
|
||||
fillCategories();
|
||||
});
|
||||
});
|
||||
$("#category").addEventListener("change", (event) => {
|
||||
const custom = event.target.value === "Другое";
|
||||
$("#customCategoryWrap").classList.toggle("hidden", !custom);
|
||||
$("#customCategory").required = custom;
|
||||
if (custom) $("#customCategory").focus();
|
||||
});
|
||||
$("#transactionForm").addEventListener("submit", submitTransaction);
|
||||
}
|
||||
|
||||
function submitTransaction(event) {
|
||||
event.preventDefault();
|
||||
const amount = Number($("#amount").value);
|
||||
const selected = $("#category").value;
|
||||
const category = selected === "Другое" ? $("#customCategory").value.trim() : selected;
|
||||
if (!Number.isFinite(amount) || amount <= 0 || !category) {
|
||||
showToast("Проверьте сумму и категорию");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
v: 1,
|
||||
type: "transaction",
|
||||
kind: state.kind,
|
||||
amount: amount.toFixed(2),
|
||||
category: category.slice(0, 40),
|
||||
note: $("#note").value.trim().slice(0, 160),
|
||||
occurred_at: new Date().toISOString(),
|
||||
};
|
||||
const encoded = JSON.stringify(payload);
|
||||
if (encoded.length > 4096) {
|
||||
showToast("Запись получилась слишком длинной");
|
||||
return;
|
||||
}
|
||||
if (!tg?.sendData || !tg.initData) {
|
||||
showToast("Сохранение доступно при открытии из Telegram");
|
||||
return;
|
||||
}
|
||||
tg.HapticFeedback?.notificationOccurred("success");
|
||||
tg.sendData(encoded);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
if (!userId) return;
|
||||
try {
|
||||
state.summary = await fetchJson(`../api/users/${userId}/summary`);
|
||||
renderSummary(state.summary);
|
||||
} catch (error) {
|
||||
console.warn("Summary is not available", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
$("#balanceValue").textContent = money.format(summary.balance_cents / 100);
|
||||
$("#incomeValue").textContent = money.format(summary.income_cents / 100);
|
||||
$("#expenseValue").textContent = money.format(summary.expense_cents / 100);
|
||||
const percent = Number(summary.saved_percent);
|
||||
$("#savedPercent").textContent = `${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
$("#savingProgress").style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||
renderCategories(summary.categories || []);
|
||||
}
|
||||
|
||||
function renderCategories(items) {
|
||||
const list = $("#categoryList");
|
||||
if (!items.length) return;
|
||||
const max = Math.max(...items.map((item) => item.amount_cents));
|
||||
$("#categoryCaption").textContent = `${items.length} категорий`;
|
||||
list.classList.remove("empty-state");
|
||||
list.innerHTML = items.slice(0, 8).map((item) => `
|
||||
<div class="category-item ${item.kind === "expense" ? "expense-item" : ""}">
|
||||
<strong>${escapeHtml(item.category)}</strong>
|
||||
<span>${item.kind === "income" ? "+" : "−"}${money.format(item.amount_cents / 100)}</span>
|
||||
<div class="category-bar"><i style="width:${Math.max(4, item.amount_cents * 100 / max)}%"></i></div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const element = document.createElement("span");
|
||||
element.textContent = value;
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function bindLeaderboard() {
|
||||
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
|
||||
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
|
||||
$("#shareButton").addEventListener("click", shareInvite);
|
||||
}
|
||||
|
||||
function switchBoard(board) {
|
||||
state.board = board;
|
||||
$("#allBoardButton").classList.toggle("active", board === "all");
|
||||
$("#friendsBoardButton").classList.toggle("active", board === "friends");
|
||||
loadLeaderboard();
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const list = $("#leaderboardList");
|
||||
list.className = "leaderboard-list loading";
|
||||
list.textContent = "Загружаем рейтинг…";
|
||||
$("#friendHint").classList.add("hidden");
|
||||
try {
|
||||
let url = "../api/leaderboard?limit=100";
|
||||
if (state.board === "friends") {
|
||||
if (!userId) throw new Error("No Telegram user");
|
||||
const friends = await fetchJson(`../api/users/${userId}/friends`);
|
||||
const ids = [userId, ...friends.user_ids];
|
||||
url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`;
|
||||
$("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0);
|
||||
}
|
||||
const data = await fetchJson(url);
|
||||
renderLeaderboard(data.items || []);
|
||||
} catch (error) {
|
||||
list.textContent = "Не удалось загрузить рейтинг";
|
||||
console.warn("Leaderboard is not available", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeaderboard(items) {
|
||||
const list = $("#leaderboardList");
|
||||
list.className = "leaderboard-list";
|
||||
if (!items.length) {
|
||||
list.classList.add("loading");
|
||||
list.textContent = "В рейтинге пока никого нет";
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((item, index) => {
|
||||
const initial = escapeHtml((item.nickname || "?").slice(0, 1).toUpperCase());
|
||||
const avatar = item.avatar_url
|
||||
? `<img class="leader-avatar" src="..${item.avatar_url}" alt="" loading="lazy" />`
|
||||
: `<span class="leader-avatar avatar-fallback">${initial}</span>`;
|
||||
const percent = Number(item.saved_percent);
|
||||
return `<div class="leader-row ${item.user_id === userId ? "me" : ""}">
|
||||
<span class="rank">${index + 1}</span>
|
||||
${avatar}
|
||||
<span class="leader-name"><strong>${escapeHtml(item.nickname)}</strong><small>${item.user_id === userId ? "Это вы" : money.format(item.balance_cents / 100)}</small></span>
|
||||
<span class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function shareInvite() {
|
||||
if (!userId) {
|
||||
showToast("Откройте приложение из Telegram");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.config ||= await fetchJson("../api/config");
|
||||
if (!state.config.bot_username) throw new Error("Bot username is missing");
|
||||
const invite = `https://t.me/${state.config.bot_username}?start=friend_${userId}`;
|
||||
const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(invite)}&text=${encodeURIComponent("Давай копить вместе в Пожитках")}`;
|
||||
if (tg?.openTelegramLink) tg.openTelegramLink(shareUrl);
|
||||
else window.open(shareUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
showToast("Ссылка появится после запуска бота");
|
||||
}
|
||||
}
|
||||
|
||||
initTelegram();
|
||||
setIdentity();
|
||||
bindNavigation();
|
||||
bindForm();
|
||||
bindLeaderboard();
|
||||
fillCategories();
|
||||
loadSummary();
|
||||
})();
|
||||
114
static/index.html
Normal file
114
static/index.html
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#f1f0ea" />
|
||||
<title>Пожитки</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script src="./telegram-web-app.js"></script>
|
||||
<script src="./app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Личный капитал</p>
|
||||
<h1>Пожитки</h1>
|
||||
</div>
|
||||
<div class="user-chip" aria-label="Текущий пользователь">
|
||||
<span id="userInitial" class="mini-avatar">Я</span>
|
||||
<span id="userName">Гость</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="dashboardView" class="view active" aria-labelledby="dashboardTab">
|
||||
<article class="hero-card">
|
||||
<p>Накоплено</p>
|
||||
<strong id="balanceValue">0 ₽</strong>
|
||||
<div class="saving-row">
|
||||
<span id="savedPercent">0%</span>
|
||||
<div class="progress"><i id="savingProgress"></i></div>
|
||||
</div>
|
||||
<small>от всех поступлений</small>
|
||||
</article>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric income">
|
||||
<span class="metric-icon">↗</span>
|
||||
<div><small>Поступления</small><strong id="incomeValue">0 ₽</strong></div>
|
||||
</article>
|
||||
<article class="metric expense">
|
||||
<span class="metric-icon">↘</span>
|
||||
<div><small>Расходы</small><strong id="expenseValue">0 ₽</strong></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
<h2>Структура денег</h2>
|
||||
<span id="categoryCaption">Пока пусто</span>
|
||||
</div>
|
||||
<div id="categoryList" class="category-list empty-state">
|
||||
Добавьте первую операцию — категории появятся здесь.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="addView" class="view" aria-labelledby="addTab">
|
||||
<div class="section-title add-heading">
|
||||
<div><p class="eyebrow">Новая запись</p><h2>Движение денег</h2></div>
|
||||
</div>
|
||||
<form id="transactionForm" class="transaction-form">
|
||||
<div class="segmented" role="group" aria-label="Тип операции">
|
||||
<button type="button" class="segment active" data-kind="income">Поступление</button>
|
||||
<button type="button" class="segment" data-kind="expense">Расход</button>
|
||||
</div>
|
||||
|
||||
<label class="amount-field">
|
||||
<span>Сумма</span>
|
||||
<div><input id="amount" name="amount" type="number" inputmode="decimal" min="0.01" step="0.01" placeholder="0" required /><b>₽</b></div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Категория</span>
|
||||
<select id="category" name="category" required></select>
|
||||
</label>
|
||||
|
||||
<label id="customCategoryWrap" class="hidden">
|
||||
<span>Своя категория</span>
|
||||
<input id="customCategory" maxlength="40" placeholder="Например, Фриланс" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Комментарий <em>необязательно</em></span>
|
||||
<input id="note" name="note" maxlength="160" placeholder="За что или откуда" />
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit">Сохранить через Telegram</button>
|
||||
<p class="form-hint">Telegram передаст запись боту вместе с вашим подтверждённым ID.</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="leaderboardView" class="view" aria-labelledby="leaderboardTab">
|
||||
<div class="section-title board-heading">
|
||||
<div><p class="eyebrow">Сберегательный рейтинг</p><h2>Лидерборд</h2></div>
|
||||
<button id="shareButton" class="icon-button" type="button" aria-label="Пригласить друга">↗</button>
|
||||
</div>
|
||||
<div class="board-switch" role="tablist">
|
||||
<button id="allBoardButton" class="active" type="button">Все</button>
|
||||
<button id="friendsBoardButton" type="button">Друзья</button>
|
||||
</div>
|
||||
<div id="leaderboardList" class="leaderboard-list loading">Загружаем рейтинг…</div>
|
||||
<p id="friendHint" class="friend-hint hidden">Поделитесь ссылкой: когда друг запустит бота, он появится здесь.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="bottom-nav" aria-label="Основная навигация">
|
||||
<button id="dashboardTab" class="active" data-view="dashboardView"><span>◫</span>Обзор</button>
|
||||
<button id="addTab" data-view="addView"><span class="add-mark">+</span>Добавить</button>
|
||||
<button id="leaderboardTab" data-view="leaderboardView"><span>♜</span>Рейтинг</button>
|
||||
</nav>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
131
static/styles.css
Normal file
131
static/styles.css
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #f1f0ea);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #ffffff);
|
||||
--text: var(--tg-theme-text-color, #17201c);
|
||||
--muted: var(--tg-theme-hint-color, #707a74);
|
||||
--accent: var(--tg-theme-button-color, #216a4b);
|
||||
--accent-text: var(--tg-theme-button-text-color, #ffffff);
|
||||
--link: var(--tg-theme-link-color, #216a4b);
|
||||
--danger: var(--tg-theme-destructive-text-color, #bd4a3e);
|
||||
--line: color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--shadow: 0 16px 50px color-mix(in srgb, var(--text) 9%, transparent);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: max(14px, env(safe-area-inset-top)) 18px calc(96px + env(safe-area-inset-bottom));
|
||||
background-image:
|
||||
radial-gradient(circle at 90% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 32%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--surface) 35%, transparent), transparent 28%);
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.shell { width: min(100%, 560px); margin: 0 auto; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 2px 26px; }
|
||||
.eyebrow { margin: 0 0 3px; text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; color: var(--muted); }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: 30px; line-height: 1; letter-spacing: -.04em; }
|
||||
h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
|
||||
.user-chip { display: flex; align-items: center; gap: 8px; max-width: 48%; padding: 5px 11px 5px 5px; border: 1px solid var(--line); border-radius: 999px; background: color-mix(in srgb, var(--surface) 72%, transparent); font-size: 12px; font-weight: 650; white-space: nowrap; overflow: hidden; }
|
||||
.mini-avatar { display: grid; flex: 0 0 auto; place-items: center; width: 29px; height: 29px; border-radius: 50%; background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
.view { display: none; animation: reveal .25s ease; }
|
||||
.view.active { display: block; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
|
||||
|
||||
.hero-card {
|
||||
min-height: 210px;
|
||||
padding: 27px;
|
||||
border-radius: 29px;
|
||||
color: #f5f7f3;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,.11), transparent 45%),
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--accent) 82%, #132e24), color-mix(in srgb, var(--accent) 50%, #0c1914));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card p { margin-bottom: 15px; opacity: .72; font-size: 13px; }
|
||||
.hero-card strong { display: block; margin-bottom: 32px; font-size: clamp(38px, 10vw, 54px); letter-spacing: -.055em; line-height: 1; overflow-wrap: anywhere; }
|
||||
.saving-row { display: flex; align-items: center; gap: 12px; }
|
||||
.saving-row span { min-width: 48px; font-weight: 800; }
|
||||
.progress { flex: 1; height: 7px; overflow: hidden; border-radius: 99px; background: rgba(255,255,255,.2); }
|
||||
.progress i { display: block; width: 0; height: 100%; border-radius: inherit; background: #e8d487; transition: width .4s ease; }
|
||||
.hero-card small { display: block; margin-top: 7px; opacity: .55; font-size: 11px; }
|
||||
|
||||
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; margin: 12px 0 28px; }
|
||||
.metric { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 17px 15px; border: 1px solid var(--line); border-radius: 20px; background: var(--surface); }
|
||||
.metric-icon { display: grid; place-items: center; flex: 0 0 auto; width: 35px; height: 35px; border-radius: 12px; font-size: 19px; }
|
||||
.income .metric-icon { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.expense .metric-icon { color: var(--danger); background: color-mix(in srgb, var(--danger) 13%, transparent); }
|
||||
.metric div { min-width: 0; }
|
||||
.metric small { display: block; margin-bottom: 4px; color: var(--muted); font-size: 10px; }
|
||||
.metric strong { display: block; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.section-title { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 0 3px 14px; }
|
||||
.section-title > span { color: var(--muted); font-size: 11px; }
|
||||
.category-list { overflow: hidden; border: 1px solid var(--line); border-radius: 21px; background: var(--surface); }
|
||||
.empty-state { padding: 25px; color: var(--muted); text-align: center; font-size: 13px; line-height: 1.5; }
|
||||
.category-item { display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; padding: 15px 17px; border-bottom: 1px solid var(--line); }
|
||||
.category-item:last-child { border-bottom: 0; }
|
||||
.category-item strong { font-size: 13px; }
|
||||
.category-item span { color: var(--muted); font-size: 12px; }
|
||||
.category-item .category-bar { grid-column: 1 / -1; height: 3px; overflow: hidden; border-radius: 4px; background: var(--line); }
|
||||
.category-bar i { display: block; height: 100%; background: var(--accent); }
|
||||
.category-item.expense-item .category-bar i { background: var(--danger); }
|
||||
|
||||
.add-heading { margin: 5px 3px 24px; }
|
||||
.transaction-form { display: grid; gap: 18px; }
|
||||
.segmented, .board-switch { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 15px; background: color-mix(in srgb, var(--text) 7%, transparent); }
|
||||
.segment, .board-switch button { padding: 11px 10px; border: 0; border-radius: 11px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 650; }
|
||||
.segment.active, .board-switch button.active { color: var(--text); background: var(--surface); box-shadow: 0 3px 12px color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.transaction-form label { display: grid; gap: 8px; font-size: 12px; font-weight: 700; }
|
||||
.transaction-form label > span { margin-left: 4px; }
|
||||
.transaction-form em { color: var(--muted); font-size: 10px; font-style: normal; font-weight: 500; }
|
||||
.transaction-form input, .transaction-form select { width: 100%; min-height: 50px; padding: 0 15px; outline: none; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); }
|
||||
.transaction-form input:focus, .transaction-form select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.amount-field > div { position: relative; }
|
||||
.amount-field input { height: 78px; padding-right: 54px; font-size: 35px; font-weight: 750; letter-spacing: -.04em; }
|
||||
.amount-field b { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 24px; }
|
||||
.primary-button { min-height: 54px; margin-top: 3px; border: 0; border-radius: 16px; background: var(--accent); color: var(--accent-text); font-weight: 750; box-shadow: 0 10px 25px color-mix(in srgb, var(--accent) 28%, transparent); }
|
||||
.form-hint { margin: -8px 15px 0; color: var(--muted); font-size: 10px; line-height: 1.45; text-align: center; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.board-heading { align-items: center; margin-top: 5px; margin-bottom: 20px; }
|
||||
.icon-button { width: 42px; height: 42px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--link); font-size: 21px; }
|
||||
.board-switch { margin-bottom: 13px; }
|
||||
.leaderboard-list { overflow: hidden; min-height: 100px; border: 1px solid var(--line); border-radius: 22px; background: var(--surface); }
|
||||
.leaderboard-list.loading { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
|
||||
.leader-row { display: grid; grid-template-columns: 25px 42px 1fr auto; align-items: center; gap: 10px; padding: 13px 14px; border-bottom: 1px solid var(--line); }
|
||||
.leader-row:last-child { border-bottom: 0; }
|
||||
.leader-row.me { background: color-mix(in srgb, var(--accent) 9%, var(--surface)); }
|
||||
.rank { color: var(--muted); text-align: center; font-size: 12px; font-weight: 750; }
|
||||
.leader-avatar { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; background: color-mix(in srgb, var(--accent) 16%, var(--surface)); }
|
||||
.avatar-fallback { display: grid; place-items: center; color: var(--accent); font-weight: 800; }
|
||||
.leader-name { min-width: 0; }
|
||||
.leader-name strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.leader-name small { color: var(--muted); font-size: 10px; }
|
||||
.leader-percent { font-size: 15px; font-weight: 850; color: var(--accent); }
|
||||
.leader-percent.negative { color: var(--danger); }
|
||||
.friend-hint { margin: 13px 18px; color: var(--muted); font-size: 11px; line-height: 1.5; text-align: center; }
|
||||
|
||||
.bottom-nav { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 8px max(15px, calc((100vw - 560px) / 2)) max(8px, env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(18px); }
|
||||
.bottom-nav button { display: grid; place-items: center; gap: 2px; padding: 3px; border: 0; background: transparent; color: var(--muted); font-size: 9px; font-weight: 650; }
|
||||
.bottom-nav button > span { font-size: 22px; line-height: 30px; }
|
||||
.bottom-nav button.active { color: var(--accent); }
|
||||
.bottom-nav .add-mark { display: grid; place-items: center; width: 42px; height: 32px; border-radius: 12px; background: var(--accent); color: var(--accent-text); }
|
||||
.toast { position: fixed; z-index: 20; left: 50%; bottom: calc(92px + env(safe-area-inset-bottom)); max-width: calc(100% - 36px); padding: 11px 16px; border-radius: 12px; transform: translate(-50%, 20px); background: var(--text); color: var(--bg); opacity: 0; pointer-events: none; transition: .2s ease; font-size: 12px; box-shadow: var(--shadow); }
|
||||
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
@media (min-width: 600px) {
|
||||
body { padding-top: 28px; }
|
||||
.bottom-nav { left: 50%; bottom: 14px; width: 540px; border: 1px solid var(--line); border-radius: 22px; transform: translateX(-50%); padding: 8px 22px; box-shadow: var(--shadow); }
|
||||
}
|
||||
3397
static/telegram-web-app.js
Normal file
3397
static/telegram-web-app.js
Normal file
File diff suppressed because it is too large
Load diff
75
tests/test_core.py
Normal file
75
tests/test_core.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.bot import parse_transaction
|
||||
from app.db import Database
|
||||
|
||||
|
||||
def make_database(tmp_path: Path) -> Database:
|
||||
database = Database(tmp_path / "test.sqlite3")
|
||||
database.initialize()
|
||||
return database
|
||||
|
||||
|
||||
def add_user(database: Database, user_id: int, name: str) -> None:
|
||||
database.upsert_user(
|
||||
user_id=user_id,
|
||||
first_name=name,
|
||||
last_name=None,
|
||||
username=None,
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_and_leaderboard(tmp_path: Path) -> None:
|
||||
database = make_database(tmp_path)
|
||||
add_user(database, 1, "Аня")
|
||||
item = parse_transaction(
|
||||
'{"v":1,"type":"transaction","kind":"income","amount":"1000.50","category":"Зарплата","occurred_at":"2026-01-01T00:00:00Z"}'
|
||||
)
|
||||
database.add_transaction(1, item)
|
||||
expense = parse_transaction(
|
||||
'{"v":1,"type":"transaction","kind":"expense","amount":"250.25","category":"Еда","occurred_at":"2026-01-02T00:00:00Z"}'
|
||||
)
|
||||
database.add_transaction(1, expense)
|
||||
|
||||
summary = database.summary(1)
|
||||
assert summary is not None
|
||||
assert summary["income_cents"] == 100_050
|
||||
assert summary["expense_cents"] == 25_025
|
||||
assert summary["saved_percent"] == 75.0
|
||||
assert database.leaderboard()[0]["nickname"] == "Аня"
|
||||
|
||||
|
||||
def test_friendship_is_symmetric_and_idempotent(tmp_path: Path) -> None:
|
||||
database = make_database(tmp_path)
|
||||
add_user(database, 3, "Три")
|
||||
add_user(database, 7, "Семь")
|
||||
|
||||
assert database.add_friendship(7, 3)
|
||||
assert not database.add_friendship(3, 7)
|
||||
assert database.friend_ids(3) == [7]
|
||||
assert database.friend_ids(7) == [3]
|
||||
|
||||
|
||||
def test_filtered_leaderboard(tmp_path: Path) -> None:
|
||||
database = make_database(tmp_path)
|
||||
for user_id in (1, 2, 3):
|
||||
add_user(database, user_id, str(user_id))
|
||||
|
||||
result = database.leaderboard(user_ids=[1, 3])
|
||||
assert [item["user_id"] for item in result] == [1, 3]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
"not-json",
|
||||
'{"v":1,"type":"transaction","kind":"other","amount":"10","category":"Еда"}',
|
||||
'{"v":1,"type":"transaction","kind":"income","amount":"-1","category":"Зарплата"}',
|
||||
'{"v":1,"type":"transaction","kind":"income","amount":"10","category":""}',
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_webapp_payload(payload: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_transaction(payload)
|
||||
Loading…
Add table
Reference in a new issue