commit 104ae25f2c8d9b0a3da8cb661d42ee1578715f0f Author: root Date: Wed Jul 1 17:58:05 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3729fea --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +data/ +.pytest_cache/ +.ruff_cache/ +.DS_Store +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..94a2c7c --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Voley — учет посещаемости волейбольной тренировки + +Веб‑приложение для отметки посещения игроков по воскресеньям. + +## Возможности + +- Публичная главная страница: выбор воскресенья, отметка посещения, график за месяц/год. +- Админ‑интерфейс под паролем: добавление игроков, управление активностью. +- Хранение данных в SQLite. + +## Запуск + +1) Создайте виртуальное окружение и установите зависимости: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +2) Задайте переменные окружения (можно через `.env` в вашей оболочке): + +- `SECRET_KEY` — секрет для подписи cookies (любая длинная строка). +- `ADMIN_PASSWORD` — пароль админки. + +Пример: + +```bash +export SECRET_KEY="change-me" +export ADMIN_PASSWORD="change-me" +``` + +3) Запустите сервер: + +```bash +uvicorn main:app --reload --port 8000 +``` + +Откройте: + +- Главная: `http://localhost:8000/` +- Админка: `http://localhost:8000/admin` + +## Данные + +SQLite база хранится в `data/voley.sqlite3` (создаётся автоматически при первом запуске). + +## Деплой на сервер + +Файлы для продакшн-деплоя находятся в `deploy/`. + +### Быстрая установка (Ubuntu/Debian) + +```bash +# На сервере: +sudo apt update && sudo apt install -y python3-venv nginx + +# Скопируйте проект на сервер, затем: +cd /path/to/voley/deploy +chmod +x install.sh +./install.sh +``` + +### Ручная установка + +1. Скопируйте проект в `/opt/voley` +2. Создайте `.env` из `.env.example` и заполните переменные +3. Создайте venv и установите зависимости: + ```bash + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt + ``` +4. Скопируйте `deploy/voley.service` в `/etc/systemd/system/` +5. Запустите: `sudo systemctl enable --now voley` +6. Настройте nginx (см. `deploy/nginx.conf`) +7. SSL: `sudo certbot --nginx -d your-domain.com` + +### Структура на сервере + +``` +/opt/voley/ +├── .env # Переменные окружения +├── .venv/ # Виртуальное окружение +├── data/ # SQLite база +│ └── voley.sqlite3 +├── main.py +├── db.py +├── auth.py +├── templates/ +└── static/ +``` + +### Полезные команды + +```bash +# Статус +sudo systemctl status voley + +# Логи +sudo journalctl -u voley -f + +# Перезапуск +sudo systemctl restart voley +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9c4c32b --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# Package marker for running as `uvicorn voley.main:app` + diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..dc52105 --- /dev/null +++ b/auth.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import base64 +import hmac +import hashlib +import os +import time +from dataclasses import dataclass +from typing import Optional + + +COOKIE_NAME = "voley_admin" + + +def _get_secret() -> bytes: + secret = os.getenv("SECRET_KEY", "").strip() + if not secret: + # Разрешаем запуск без секрета, но с сильно сниженной безопасностью. + # Для реального деплоя SECRET_KEY обязателен. + secret = "dev-unsafe-secret" + return secret.encode("utf-8") + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=") + + +def _b64url_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode((s + pad).encode("ascii")) + + +def _sign(payload: bytes) -> str: + mac = hmac.new(_get_secret(), payload, hashlib.sha256).digest() + return _b64url(mac) + + +def make_session(ttl_seconds: int = 60 * 60 * 12) -> str: + exp = int(time.time()) + int(ttl_seconds) + payload = f"exp={exp}".encode("utf-8") + sig = _sign(payload) + return f"{_b64url(payload)}.{sig}" + + +def verify_session(token: str) -> bool: + try: + payload_b64, sig = token.split(".", 1) + payload = _b64url_decode(payload_b64) + except Exception: + return False + if not hmac.compare_digest(_sign(payload), sig): + return False + try: + parts = payload.decode("utf-8").split("=", 1) + if len(parts) != 2 or parts[0] != "exp": + return False + exp = int(parts[1]) + except Exception: + return False + return time.time() < exp + + +def check_admin_password(password: str) -> bool: + expected = os.getenv("ADMIN_PASSWORD", "") + if not expected: + # Чтобы админка не осталась открытой по умолчанию. + return False + return hmac.compare_digest(password or "", expected) + + +@dataclass(frozen=True) +class AdminAuth: + is_admin: bool + token: Optional[str] = None + diff --git a/db.py b/db.py new file mode 100644 index 0000000..64d4a4d --- /dev/null +++ b/db.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import date, datetime +from typing import Any, Iterable, Iterator, Optional + + +DB_PATH = os.getenv("DB_PATH", os.path.join("data", "voley.sqlite3")) + + +def _ensure_parent_dir(path: str) -> None: + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + + +def connect() -> sqlite3.Connection: + _ensure_parent_dir(DB_PATH) + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + return conn + + +@contextmanager +def get_conn() -> Iterator[sqlite3.Connection]: + conn = connect() + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db() -> None: + with get_conn() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS attendance ( + player_id INTEGER NOT NULL, + session_date TEXT NOT NULL, + present INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY (player_id, session_date), + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(session_date); + """ + ) + + +def now_iso() -> str: + return datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + + +def iso_date(d: date) -> str: + return d.isoformat() + + +@dataclass(frozen=True) +class Player: + id: int + name: str + active: bool + + +def list_players(active_only: bool = True) -> list[Player]: + sql = "SELECT id, name, active FROM players" + params: list[Any] = [] + if active_only: + sql += " WHERE active=1" + sql += " ORDER BY name COLLATE NOCASE ASC" + with get_conn() as conn: + rows = conn.execute(sql, params).fetchall() + return [Player(id=int(r["id"]), name=str(r["name"]), active=bool(r["active"])) for r in rows] + + +def list_players_all() -> list[Player]: + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name, active FROM players ORDER BY active DESC, name COLLATE NOCASE ASC" + ).fetchall() + return [Player(id=int(r["id"]), name=str(r["name"]), active=bool(r["active"])) for r in rows] + + +def add_player(name: str) -> int: + name = (name or "").strip() + if not name: + raise ValueError("name is required") + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO players(name, active, created_at) VALUES (?, 1, ?)", + (name, now_iso()), + ) + return int(cur.lastrowid) + + +def set_player_active(player_id: int, active: bool) -> None: + with get_conn() as conn: + conn.execute("UPDATE players SET active=? WHERE id=?", (1 if active else 0, int(player_id))) + + +def rename_player(player_id: int, name: str) -> None: + name = (name or "").strip() + if not name: + raise ValueError("name is required") + with get_conn() as conn: + conn.execute("UPDATE players SET name=? WHERE id=?", (name, int(player_id))) + + +def get_attendance_map(session_date: str) -> dict[int, bool]: + with get_conn() as conn: + rows = conn.execute( + "SELECT player_id, present FROM attendance WHERE session_date=?", + (session_date,), + ).fetchall() + return {int(r["player_id"]): bool(r["present"]) for r in rows} + + +def toggle_attendance(player_id: int, session_date: str) -> bool: + with get_conn() as conn: + row = conn.execute( + "SELECT present FROM attendance WHERE player_id=? AND session_date=?", + (int(player_id), session_date), + ).fetchone() + if row is None: + conn.execute( + "INSERT INTO attendance(player_id, session_date, present, updated_at) VALUES (?, ?, 1, ?)", + (int(player_id), session_date, now_iso()), + ) + return True + new_present = 0 if bool(row["present"]) else 1 + conn.execute( + "UPDATE attendance SET present=?, updated_at=? WHERE player_id=? AND session_date=?", + (new_present, now_iso(), int(player_id), session_date), + ) + return bool(new_present) + + +def list_present_players(session_date: str, active_only: bool = True) -> list[Player]: + sql = """ + SELECT p.id, p.name, p.active + FROM attendance a + JOIN players p ON p.id = a.player_id + WHERE a.session_date = ? AND a.present = 1 + """ + params: list[Any] = [session_date] + if active_only: + sql += " AND p.active = 1" + sql += " ORDER BY p.name COLLATE NOCASE ASC" + with get_conn() as conn: + rows = conn.execute(sql, params).fetchall() + return [Player(id=int(r["id"]), name=str(r["name"]), active=bool(r["active"])) for r in rows] + + +def attendance_counts_by_sunday_in_month(year: int, month: int) -> list[tuple[str, int]]: + # Возвращает список (YYYY-MM-DD, count) для воскресений в указанном месяце. + # Считаем количество присутствий (present=1). + y = int(year) + m = int(month) + with get_conn() as conn: + rows = conn.execute( + """ + SELECT session_date, SUM(CASE WHEN present=1 THEN 1 ELSE 0 END) AS cnt + FROM attendance + WHERE substr(session_date, 1, 7) = ? + GROUP BY session_date + ORDER BY session_date ASC + """, + (f"{y:04d}-{m:02d}",), + ).fetchall() + return [(str(r["session_date"]), int(r["cnt"] or 0)) for r in rows] + + +def attendance_counts_by_month_in_year(year: int) -> list[tuple[str, int]]: + # Возвращает список (YYYY-MM, count) — суммарное число посещений по месяцам. + y = int(year) + with get_conn() as conn: + rows = conn.execute( + """ + SELECT substr(session_date, 1, 7) AS ym, + SUM(CASE WHEN present=1 THEN 1 ELSE 0 END) AS cnt + FROM attendance + WHERE substr(session_date, 1, 4) = ? + GROUP BY ym + ORDER BY ym ASC + """, + (f"{y:04d}",), + ).fetchall() + return [(str(r["ym"]), int(r["cnt"] or 0)) for r in rows] + + +def get_attendance_history() -> list[dict]: + """Возвращает историю посещаемости: (дата, имя игрока, присутствовал).""" + with get_conn() as conn: + rows = conn.execute( + """ + SELECT a.session_date, p.name, a.present + FROM attendance a + JOIN players p ON p.id = a.player_id + WHERE a.present = 1 + ORDER BY a.session_date DESC, p.name COLLATE NOCASE ASC + """ + ).fetchall() + return [{"date": str(r["session_date"]), "name": str(r["name"]), "present": bool(r["present"])} for r in rows] + diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..728c9a0 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +APP_DIR="/opt/voley" +APP_USER="www-data" + +echo "=== Установка Voley ===" + +# Создание директории +sudo mkdir -p $APP_DIR +sudo mkdir -p $APP_DIR/data + +# Копирование файлов +sudo cp -r ../*.py ../requirements.txt ../templates ../static $APP_DIR/ +sudo cp ../.env $APP_DIR/.env + +# Виртуальное окружение +sudo python3 -m venv $APP_DIR/.venv +sudo $APP_DIR/.venv/bin/pip install --upgrade pip +sudo $APP_DIR/.venv/bin/pip install -r $APP_DIR/requirements.txt + +# Права +sudo chown -R $APP_USER:$APP_USER $APP_DIR + +# systemd +sudo cp voley.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable voley +sudo systemctl start voley + +# nginx +sudo cp nginx.conf /etc/nginx/sites-available/voley +sudo ln -sf /etc/nginx/sites-available/voley /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx + +echo "" +echo "=== Готово! ===" +echo "1. Отредактируйте $APP_DIR/.env (SECRET_KEY, ADMIN_PASSWORD)" +echo "2. Измените домен в /etc/nginx/sites-available/voley" +echo "3. Перезапустите: sudo systemctl restart voley" +echo "4. SSL: sudo certbot --nginx -d voley.example.com" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..df54bd1 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name volleyball.my.to; # Замените на ваш домен + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static { + alias /opt/voley/static; + expires 7d; + add_header Cache-Control "public, immutable"; + } + + # Опционально: SSL через certbot + # listen 443 ssl; + # ssl_certificate /etc/letsencrypt/live/volleyball.my.to/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/volleyball.my.to/privkey.pem; +} diff --git a/deploy/voley.service b/deploy/voley.service new file mode 100644 index 0000000..f9fdf85 --- /dev/null +++ b/deploy/voley.service @@ -0,0 +1,16 @@ +[Unit] +Description=Voley Attendance App +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/voley +EnvironmentFile=/opt/voley/.env +ExecStart=/opt/voley/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/main.py b/main.py new file mode 100644 index 0000000..06e0ad3 --- /dev/null +++ b/main.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import os +import random +from datetime import date, datetime, timedelta +from typing import Optional + +from fastapi import Cookie, FastAPI, Form, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +try: + # запуск как пакет: `uvicorn voley.main:app` + from . import db # type: ignore + from .auth import COOKIE_NAME, check_admin_password, make_session, verify_session # type: ignore +except Exception: + # запуск из папки voley: `uvicorn main:app` + import db # type: ignore + from auth import COOKIE_NAME, check_admin_password, make_session, verify_session # type: ignore + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") +STATIC_DIR = os.path.join(BASE_DIR, "static") + +app = FastAPI(title="Voley Attendance") +templates = Jinja2Templates(directory=TEMPLATES_DIR) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +BG_JPG_PATH = os.path.join(TEMPLATES_DIR, "bg.jpg") + + +@app.on_event("startup") +def _startup() -> None: + db.init_db() + try: + templates.env.globals["bg_version"] = int(os.path.getmtime(BG_JPG_PATH)) + except Exception: + templates.env.globals["bg_version"] = 0 + + +def _parse_date_yyyy_mm_dd(s: str) -> Optional[date]: + try: + return datetime.strptime(s, "%Y-%m-%d").date() + except Exception: + return None + + +def _nearest_sunday(d: date) -> date: + # Python: Monday=0 ... Sunday=6 + delta = (d.weekday() - 6) % 7 + return d - timedelta(days=delta) + + +def _selected_sunday(date_q: Optional[str]) -> date: + today = date.today() + if date_q: + parsed = _parse_date_yyyy_mm_dd(date_q) + if parsed: + return _nearest_sunday(parsed) + return _nearest_sunday(today) + + +def _is_admin(token: Optional[str]) -> bool: + return bool(token) and verify_session(token) + + +@app.get("/assets/bg.jpg") +def bg_image() -> FileResponse: + if not os.path.exists(BG_JPG_PATH): + raise HTTPException(status_code=404, detail="bg.jpg not found") + return FileResponse(BG_JPG_PATH, media_type="image/jpeg") + + +def _sundays_in_month(year: int, month: int) -> list[date]: + y = int(year) + m = int(month) + first = date(y, m, 1) + # первый день следующего месяца + if m == 12: + nxt = date(y + 1, 1, 1) + else: + nxt = date(y, m + 1, 1) + + # weekday: Monday=0 ... Sunday=6 + offset = (6 - first.weekday()) % 7 + d = first + timedelta(days=offset) + out: list[date] = [] + while d < nxt: + out.append(d) + d = d + timedelta(days=7) + return out + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request, date: Optional[str] = None, view: str = "month") -> HTMLResponse: + selected = _selected_sunday(date) + session_date = selected.isoformat() + players = db.list_players(active_only=True) + present_map = db.get_attendance_map(session_date=session_date) + + # Данные для графика. + if view not in ("month", "year"): + view = "month" + if view == "month": + counts = db.attendance_counts_by_sunday_in_month(selected.year, selected.month) + counts_map = {d: c for (d, c) in counts} + sundays = _sundays_in_month(selected.year, selected.month) + chart_labels = [d.isoformat() for d in sundays] + chart_values = [int(counts_map.get(d.isoformat(), 0)) for d in sundays] + chart_title = f"Посещаемость за {selected.strftime('%m.%Y')} (по воскресеньям)" + else: + counts = db.attendance_counts_by_month_in_year(selected.year) + counts_map = {ym: c for (ym, c) in counts} + chart_labels = [f"{selected.year:04d}-{m:02d}" for m in range(1, 13)] + chart_values = [int(counts_map.get(ym, 0)) for ym in chart_labels] + chart_title = f"Посещаемость за {selected.year} (по месяцам)" + + n = max(0, len(chart_labels)) + if n <= 8: + chart_label_step = 1 + elif n <= 16: + chart_label_step = 2 + else: + chart_label_step = 3 + + return templates.TemplateResponse( + "index.html", + { + "request": request, + "selected_date": session_date, + "players": players, + "present_map": present_map, + "chart_view": view, + "chart_title": chart_title, + "chart_labels": chart_labels, + "chart_values": chart_values, + "chart_label_step": chart_label_step, + "chart_labels_json": json.dumps(chart_labels, ensure_ascii=False), + "chart_values_json": json.dumps(chart_values, ensure_ascii=False), + }, + ) + + +@app.post("/api/attendance/toggle", response_class=HTMLResponse) +def toggle_attendance( + request: Request, + player_id: int = Form(...), + session_date: str = Form(...), +) -> HTMLResponse: + d = _parse_date_yyyy_mm_dd(session_date) + if not d: + raise HTTPException(status_code=400, detail="bad session_date") + session_date = _nearest_sunday(d).isoformat() + + new_present = db.toggle_attendance(player_id=player_id, session_date=session_date) + + # Рендерим одну строку игрока для обновления на клиенте. + # Имя тянем из списка активных + всех, чтобы корректно показать даже если игрок деактивирован. + players = db.list_players_all() + player = next((p for p in players if p.id == int(player_id)), None) + if not player: + raise HTTPException(status_code=404, detail="player not found") + + return templates.TemplateResponse( + "partials/player_row.html", + { + "request": request, + "player": player, + "session_date": session_date, + "present": new_present, + }, + ) + + +@app.post("/api/teams/random", response_class=HTMLResponse) +def random_teams(request: Request, session_date: str = Form(...)) -> HTMLResponse: + d = _parse_date_yyyy_mm_dd(session_date) + if not d: + raise HTTPException(status_code=400, detail="bad session_date") + session_date = _nearest_sunday(d).isoformat() + + present_players = db.list_present_players(session_date=session_date, active_only=True) + names = [p.name for p in present_players] + random.shuffle(names) + mid = (len(names) + 1) // 2 + team_a = names[:mid] + team_b = names[mid:] + + return templates.TemplateResponse( + "partials/teams.html", + { + "request": request, + "session_date": session_date, + "team_a": team_a, + "team_b": team_b, + "count": len(names), + }, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def admin(request: Request, voley_admin: Optional[str] = Cookie(default=None, alias=COOKIE_NAME)) -> HTMLResponse: + if not _is_admin(voley_admin): + return RedirectResponse(url="/admin/login", status_code=302) + players = db.list_players_all() + return templates.TemplateResponse( + "admin_players.html", + {"request": request, "players": players}, + ) + + +@app.get("/admin/login", response_class=HTMLResponse) +def admin_login_get(request: Request) -> HTMLResponse: + return templates.TemplateResponse("admin_login.html", {"request": request, "error": None}) + + +@app.post("/admin/login") +def admin_login_post(password: str = Form(...)) -> RedirectResponse: + if not check_admin_password(password): + # Возвращаемся на логин с флагом. + return RedirectResponse(url="/admin/login?error=1", status_code=302) + resp = RedirectResponse(url="/admin", status_code=302) + resp.set_cookie( + COOKIE_NAME, + make_session(), + httponly=True, + samesite="lax", + secure=False, # включите True за HTTPS + path="/", + max_age=60 * 60 * 12, + ) + return resp + + +@app.get("/admin/logout") +def admin_logout() -> RedirectResponse: + resp = RedirectResponse(url="/", status_code=302) + resp.delete_cookie(COOKIE_NAME, path="/") + return resp + + +@app.post("/admin/players/add") +def admin_add_player( + voley_admin: Optional[str] = Cookie(default=None, alias=COOKIE_NAME), + name: str = Form(...), +) -> RedirectResponse: + if not _is_admin(voley_admin): + raise HTTPException(status_code=401, detail="not authorized") + db.add_player(name) + return RedirectResponse(url="/admin", status_code=303) + + +@app.post("/admin/players/{player_id}/toggle-active") +def admin_toggle_active( + player_id: int, + voley_admin: Optional[str] = Cookie(default=None, alias=COOKIE_NAME), +) -> RedirectResponse: + if not _is_admin(voley_admin): + raise HTTPException(status_code=401, detail="not authorized") + players = db.list_players_all() + p = next((x for x in players if x.id == int(player_id)), None) + if not p: + raise HTTPException(status_code=404, detail="player not found") + db.set_player_active(player_id, not p.active) + return RedirectResponse(url="/admin", status_code=303) + + +@app.post("/admin/players/{player_id}/rename") +def admin_rename_player( + player_id: int, + voley_admin: Optional[str] = Cookie(default=None, alias=COOKIE_NAME), + name: str = Form(...), +) -> RedirectResponse: + if not _is_admin(voley_admin): + raise HTTPException(status_code=401, detail="not authorized") + db.rename_player(player_id, name) + return RedirectResponse(url="/admin", status_code=303) + + +@app.get("/api/export/attendance.xlsx") +def export_attendance_xlsx() -> StreamingResponse: + """Выгрузка истории посещаемости в Excel.""" + from collections import defaultdict + from io import BytesIO + + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + + history = db.get_attendance_history() + + # Группируем по датам: {date: [names]} + by_date: dict[str, list[str]] = defaultdict(list) + for rec in history: + by_date[rec["date"]].append(rec["name"]) + + wb = Workbook() + ws = wb.active + ws.title = "Посещаемость" + + # Заголовки + ws["A1"] = "Дата" + ws["B1"] = "Игрок" + header_font = Font(bold=True) + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font_white = Font(bold=True, color="FFFFFF") + for cell in [ws["A1"], ws["B1"]]: + cell.font = header_font_white + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center") + + row = 2 + for dt in sorted(by_date.keys(), reverse=True): + names = by_date[dt] + for name in names: + ws.cell(row=row, column=1, value=dt) + ws.cell(row=row, column=2, value=name) + row += 1 + + # Ширина колонок + ws.column_dimensions["A"].width = 14 + ws.column_dimensions["B"].width = 25 + + buffer = BytesIO() + wb.save(buffer) + buffer.seek(0) + + return StreamingResponse( + buffer, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=attendance.xlsx"}, + ) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c391245 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +jinja2>=3.1 +python-multipart>=0.0.9 +openpyxl>=3.1 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..0e94212 --- /dev/null +++ b/static/app.js @@ -0,0 +1,295 @@ +/* global flatpickr, Chart */ + +(function () { + function qs(sel) { + return document.querySelector(sel); + } + + function isoFromDate(d) { + // d is Date + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; + } + + function nearestSunday(d) { + // JS Date: Sunday=0 ... Saturday=6 + const day = d.getDay(); + const delta = day === 0 ? 0 : day; // go back to Sunday + const out = new Date(d); + out.setDate(d.getDate() - delta); + out.setHours(12, 0, 0, 0); + return out; + } + + function initDatePicker() { + const el = qs("#datePicker"); + if (!el || typeof flatpickr === "undefined") return; + + flatpickr.localize(flatpickr.l10ns.ru); + + flatpickr(el, { + dateFormat: "Y-m-d", + defaultDate: el.value || null, + disableMobile: false, + disable: [ + function (date) { + // allow only Sundays (0) + return date.getDay() !== 0; + }, + ], + onChange: function (selectedDates) { + if (!selectedDates || !selectedDates.length) return; + const sunday = nearestSunday(selectedDates[0]); + const iso = isoFromDate(sunday); + const url = new URL(window.location.href); + url.searchParams.set("date", iso); + // сохраняем текущий view графика, если есть + if (!url.searchParams.get("view") && window.__VOLEY__ && window.__VOLEY__.chartView) { + url.searchParams.set("view", window.__VOLEY__.chartView); + } + window.location.href = url.toString(); + }, + }); + } + + async function handleAttendanceSubmit(ev) { + const form = ev.target.closest("form.attendance-form"); + if (!form) return; + ev.preventDefault(); + + const fd = new FormData(form); + const row = form.closest(".player-row"); + if (!row) return; + + // небольшая блокировка кнопки + const btn = form.querySelector("button[type='submit']"); + if (btn) { + btn.disabled = true; + btn.classList.add("opacity-70"); + } + + try { + const resp = await fetch(form.action, { method: "POST", body: fd }); + if (!resp.ok) throw new Error("network"); + const html = await resp.text(); + + // заменяем целиком строку игрока + const tmp = document.createElement("div"); + tmp.innerHTML = html; + const newRow = tmp.firstElementChild; + if (newRow) row.replaceWith(newRow); + + // после отметки обновим команды (если блок есть) + refreshTeams(); + } catch (e) { + alert("Не удалось сохранить отметку. Попробуйте ещё раз."); + if (btn) { + btn.disabled = false; + btn.classList.remove("opacity-70"); + } + } + } + + async function refreshTeams() { + const box = qs("#teamsBox"); + if (!box || !window.__VOLEY__ || !window.__VOLEY__.selectedDate) return; + try { + const fd = new FormData(); + fd.set("session_date", window.__VOLEY__.selectedDate); + const resp = await fetch("/api/teams/random", { method: "POST", body: fd }); + if (!resp.ok) return; + box.innerHTML = await resp.text(); + } catch (_e) { + // молча + } + } + + function initAttendance() { + document.addEventListener("submit", function (ev) { + const t = ev.target; + if (t && t.matches && t.matches("form.attendance-form")) { + handleAttendanceSubmit(ev); + } + }); + } + + function initTeams() { + document.addEventListener("submit", function (ev) { + const t = ev.target; + if (t && t.matches && t.matches("form.teams-form")) { + ev.preventDefault(); + const fd = new FormData(t); + // защита от отсутствующего hidden input (или пустого значения) + if ((!fd.get("session_date") || String(fd.get("session_date")).trim() === "") && window.__VOLEY__ && window.__VOLEY__.selectedDate) { + fd.set("session_date", window.__VOLEY__.selectedDate); + } + fetch(t.action, { method: "POST", body: fd }) + .then((r) => r.text()) + .then((html) => { + const box = qs("#teamsBox"); + if (box) box.innerHTML = html; + }) + .catch(function () {}); + } + }); + } + + function initChart() { + const canvas = qs("#attendanceChart"); + if (!canvas || !window.__VOLEY__) return; + + const labels = window.__VOLEY__.chartLabels || []; + const values = window.__VOLEY__.chartValues || []; + + const ctx = canvas.getContext("2d"); + + function formatLabel(s) { + // month: YYYY-MM-DD -> DD.MM + // year: YYYY-MM -> MM + if (typeof s !== "string") return String(s); + if (s.length === 10 && s[4] === "-" && s[7] === "-") return s.slice(8, 10) + "." + s.slice(5, 7); + if (s.length === 7 && s[4] === "-") return s.slice(5, 7); + return s; + } + + function renderVanillaBarChart() { + // Простая отрисовка без зависимостей (если CDN недоступен) + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const w = Math.max(240, Math.floor(rect.width)); + const h = Math.max(160, Math.floor(rect.height)); + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = w + "px"; + canvas.style.height = h + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // clear + ctx.clearRect(0, 0, w, h); + + // palette + const grid = "rgba(148, 163, 184, 0.15)"; + const text = "rgba(226, 232, 240, 0.85)"; + const bar = "rgba(99, 102, 241, 0.78)"; + const barStroke = "rgba(99, 102, 241, 1)"; + + const padL = 40; + const padR = 10; + const padT = 10; + const padB = 28; + + const plotW = w - padL - padR; + const plotH = h - padT - padB; + const maxV = Math.max(1, ...values.map((x) => (Number.isFinite(+x) ? +x : 0))); + + // grid lines + y labels (0..max) + ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Arial"; + ctx.fillStyle = text; + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + + const steps = Math.min(5, maxV); + for (let i = 0; i <= steps; i++) { + const yVal = Math.round((maxV * i) / steps); + const y = padT + plotH - (plotH * i) / steps; + ctx.beginPath(); + ctx.moveTo(padL, y); + ctx.lineTo(w - padR, y); + ctx.stroke(); + ctx.fillText(String(yVal), 6, y + 4); + } + + const n = Math.max(0, labels.length); + if (n === 0) { + ctx.fillText("Нет данных для графика", padL, padT + 18); + return; + } + + const gap = Math.min(10, Math.max(4, Math.floor(plotW / (n * 6)))); + const barW = Math.max(6, Math.floor((plotW - gap * (n - 1)) / n)); + + for (let i = 0; i < n; i++) { + const v = Number.isFinite(+values[i]) ? +values[i] : 0; + const bh = Math.round((v / maxV) * plotH); + const x = padL + i * (barW + gap); + const y = padT + plotH - bh; + + // bar + ctx.fillStyle = bar; + ctx.strokeStyle = barStroke; + ctx.beginPath(); + ctx.rect(x, y, barW, bh); + ctx.fill(); + ctx.stroke(); + + // x label (every Nth to avoid overlap) + const every = n <= 8 ? 1 : n <= 16 ? 2 : 3; + if (i % every === 0) { + ctx.fillStyle = text; + const lab = formatLabel(labels[i]); + ctx.fillText(lab, x, h - 10); + } + } + } + + // Пытаемся Chart.js, но если не загрузился — рисуем fallback. + try { + if (typeof Chart === "undefined") { + renderVanillaBarChart(); + } else { + // eslint-disable-next-line no-new + new Chart(ctx, { + type: "bar", + data: { + labels: labels, + datasets: [ + { + label: "Посещений", + data: values, + borderWidth: 1, + backgroundColor: "rgba(99, 102, 241, 0.75)", + borderColor: "rgba(99, 102, 241, 1)", + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { enabled: true }, + }, + scales: { + x: { + ticks: { color: "rgba(226, 232, 240, 0.8)" }, + grid: { color: "rgba(148, 163, 184, 0.15)" }, + }, + y: { + beginAtZero: true, + ticks: { color: "rgba(226, 232, 240, 0.8)", precision: 0 }, + grid: { color: "rgba(148, 163, 184, 0.15)" }, + }, + }, + }, + }); + } + } catch (e) { + renderVanillaBarChart(); + } + + // на ресайзе перерисуем только fallback (Chart.js сам умеет) + if (typeof Chart === "undefined") { + window.addEventListener("resize", function () { + renderVanillaBarChart(); + }); + } + } + + initDatePicker(); + initAttendance(); + initTeams(); + initChart(); +})(); + diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..824165b --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Voley — вход в админку{% endblock %} +{% block header %}Админка{% endblock %} + +{% block nav %} + На главную +{% endblock %} + +{% block content %} +
+

Вход

+

Введите пароль администратора.

+ + {% if request.query_params.get('error') %} +
+ Неверный пароль. +
+ {% endif %} + +
+ + +
+
+{% endblock %} diff --git a/templates/admin_players.html b/templates/admin_players.html new file mode 100644 index 0000000..9c34378 --- /dev/null +++ b/templates/admin_players.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}Voley — админка{% endblock %} +{% block header %}Админка{% endblock %} + +{% block nav %} +
+ На главную + Выйти +
+{% endblock %} + +{% block content %} +
+

Добавить игрока

+
+ + +
+
+ +
+
+

Игроки

+
{{ players|length }} всего
+
+ +
+ {% for p in players %} +
+
+
+
+ {{ p.name }} + {% if not p.active %} + неактивен + {% endif %} +
+
ID: {{ p.id }}
+
+ +
+ +
+
+ +
+ + +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..603f5a6 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,69 @@ + + + + + + {% block title %}Voley{% endblock %} + + + + + + + + + + + + + +
+
+ + + + + +
+
+
+ +
+
+
+
Волейбол
+

{% block header %}Учет посещаемости{% endblock %}

+
+ +
+ + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/bg.jpg b/templates/bg.jpg new file mode 100644 index 0000000..757d859 Binary files /dev/null and b/templates/bg.jpg differ diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a185dcf --- /dev/null +++ b/templates/index.html @@ -0,0 +1,206 @@ +{% extends "base.html" %} + +{% block title %}Voley — отметка посещения{% endblock %} +{% block header %}Отметка посещения{% endblock %} + +{% block content %} +
+
+
+
Выбранное воскресенье
+
{{ selected_date }}
+
+ +
+ + +
+
+
+ +
+
+

Игроки

+
{{ players|length }} активн.
+
+ +
+ {% for player in players %} + {% set present = present_map.get(player.id, False) %} + {% include "partials/player_row.html" %} + {% endfor %} +
+
+ +
+
+ {# По умолчанию рендерим команды детерминированно (по алфавиту): #} + {% set present_names = [] %} + {% for player in players %} + {% if present_map.get(player.id, False) %} + {% set _ = present_names.append(player.name) %} + {% endif %} + {% endfor %} + {% set count = present_names|length %} + {% set mid = (count + 1) // 2 %} + {% set team_a = present_names[:mid] %} + {% set team_b = present_names[mid:] %} + {% include "partials/teams.html" %} +
+
+ +
+
+
+

График

+
{{ chart_title }}
+
+ +
+ +
+ {# SVG-график без JS/CDN — всегда отображается #} + {% set max_v = (chart_values|max) if chart_values else 0 %} + {% if max_v < 1 %}{% set max_v = 1 %}{% endif %} + {% set n = chart_labels|length %} + + + + + + + + + + + + + + + + + + + + + + {# параметры области построения #} + {% set padL = 16 %} + {% set padR = 3 %} + {% set padT = 8 %} + {% set padB = 18 %} + {% set plotW = 200 - padL - padR %} + {% set plotH = 100 - padT - padB %} + + + + + + + {% for p in [0,25,50,75,100] %} + {% set y = padT + plotH - (plotH * p / 100) %} + + {{ (max_v * p / 100)|round|int }} + {% endfor %} + + {% if n == 0 %} + Нет данных + {% else %} + {% set barGap = 1.4 %} + {% set slot = plotW / n %} + {% set barW = slot - barGap %} + {% if barW < 0.9 %}{% set barW = 0.9 %}{% endif %} + + {% for lab in chart_labels %} + {% set i = loop.index0 %} + {% set v = chart_values[i] %} + {% set h = (v / max_v) * plotH %} + {% set x = padL + (i * slot) + (barGap/2) %} + {% set y = padT + plotH - h %} + {% set cx = x + (barW/2) %} + + {{ lab }}: {{ v }} + + + {# значение над столбцом (если место позволяет) #} + {% if v > 0 and barW >= 2.2 %} + {{ v }} + {% endif %} + + {% if (i % chart_label_step) == 0 %} + {% if chart_view == 'month' and lab|length == 10 %} + {% set show = lab[8:10] ~ '.' ~ lab[5:7] %} + {% elif chart_view == 'year' and lab|length == 7 %} + {% set show = lab[5:7] %} + {% else %} + {% set show = lab %} + {% endif %} + {{ show }} + {% endif %} + {% endfor %} + {% endif %} + +
+
+ +
+
+
+

Выгрузка данных

+
История посещений игроков по датам
+
+ Скачать Excel +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/partials/player_row.html b/templates/partials/player_row.html new file mode 100644 index 0000000..0510070 --- /dev/null +++ b/templates/partials/player_row.html @@ -0,0 +1,27 @@ +
+
+
{{ player.name }}
+
Нажмите, чтобы {% if present %}снять отметку{% else %}отметиться{% endif %}
+
+ +
+ + + + +
+
diff --git a/templates/partials/teams.html b/templates/partials/teams.html new file mode 100644 index 0000000..20bb557 --- /dev/null +++ b/templates/partials/teams.html @@ -0,0 +1,47 @@ +
+
+
+

Команды

+
Отметились: {{ count }} · Дата: {{ session_date }}
+
+
+ + +
+
+ + {% if count < 2 %} +
+ Нужно минимум 2 отметившихся, чтобы разделить на команды. +
+ {% else %} +
+
+
Команда A
+
    + {% for n in team_a %} +
  1. + {{ loop.index }}. {{ n }} +
  2. + {% endfor %} +
+
+
+
Команда B
+
    + {% for n in team_b %} +
  1. + {{ loop.index }}. {{ n }} +
  2. + {% endfor %} +
+
+
+ {% endif %} +
+