Initial commit

This commit is contained in:
root
2026-07-01 17:58:05 +03:00
commit 104ae25f2c
18 changed files with 1585 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
data/
.pytest_cache/
.ruff_cache/
.DS_Store
.env

104
README.md Normal file
View File

@@ -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
```

2
__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# Package marker for running as `uvicorn voley.main:app`

75
auth.py Normal file
View File

@@ -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

217
db.py Normal file
View File

@@ -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]

41
deploy/install.sh Executable file
View File

@@ -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"

23
deploy/nginx.conf Normal file
View File

@@ -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;
}

16
deploy/voley.service Normal file
View File

@@ -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

335
main.py Normal file
View File

@@ -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"},
)

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.27
jinja2>=3.1
python-multipart>=0.0.9
openpyxl>=3.1

295
static/app.js Normal file
View File

@@ -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();
})();

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Voley — вход в админку{% endblock %}
{% block header %}Админка{% endblock %}
{% block nav %}
<a class="text-slate-300 hover:text-white" href="/">На главную</a>
{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-5 max-w-md">
<h2 class="text-lg font-semibold">Вход</h2>
<p class="text-sm text-slate-400 mt-1">Введите пароль администратора.</p>
{% if request.query_params.get('error') %}
<div class="mt-4 rounded-xl border border-rose-900 bg-rose-950/40 px-3 py-2 text-sm text-rose-200">
Неверный пароль.
</div>
{% endif %}
<form class="mt-4 grid gap-3" method="post" action="/admin/login">
<input
name="password"
type="password"
class="w-full rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Пароль"
required
/>
<button
type="submit"
class="w-full px-4 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500"
>
Войти
</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Voley — админка{% endblock %}
{% block header %}Админка{% endblock %}
{% block nav %}
<div class="flex items-center gap-4">
<a class="text-slate-300 hover:text-white" href="/">На главную</a>
<a class="text-slate-300 hover:text-white" href="/admin/logout">Выйти</a>
</div>
{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-4 sm:p-5">
<h2 class="text-lg font-semibold">Добавить игрока</h2>
<form class="mt-3 flex flex-col sm:flex-row gap-3" method="post" action="/admin/players/add">
<input
name="name"
class="flex-1 rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Имя игрока"
required
/>
<button class="px-4 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500" type="submit">
Добавить
</button>
</form>
</section>
<section class="mt-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Игроки</h2>
<div class="text-xs text-slate-400">{{ players|length }} всего</div>
</div>
<div class="mt-3 grid gap-3">
{% for p in players %}
<div class="glass rounded-2xl p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ p.name }}
{% if not p.active %}
<span class="ml-2 text-xs font-semibold px-2 py-1 rounded-lg bg-slate-800 text-slate-200">неактивен</span>
{% endif %}
</div>
<div class="text-xs text-slate-400">ID: {{ p.id }}</div>
</div>
<form method="post" action="/admin/players/{{ p.id }}/toggle-active">
<button
class="px-3 py-2 rounded-xl text-sm font-semibold border
{% if p.active %}
bg-slate-950/40 border-slate-800 text-slate-200 hover:border-slate-600
{% else %}
bg-emerald-600 border-emerald-500 text-white
{% endif %}"
type="submit"
>
{% if p.active %}Деактивировать{% else %}Активировать{% endif %}
</button>
</form>
</div>
<form class="mt-3 flex flex-col sm:flex-row gap-3" method="post" action="/admin/players/{{ p.id }}/rename">
<input
name="name"
value="{{ p.name }}"
class="flex-1 rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
<button class="px-3 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500" type="submit">
Переименовать
</button>
</form>
</div>
{% endfor %}
</div>
</section>
{% endblock %}

69
templates/base.html Normal file
View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Voley{% endblock %}</title>
<!-- Tailwind (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Flatpickr -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css" />
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
<style>
.container-max { max-width: 980px; }
.fp-theme .flatpickr-calendar { border-radius: 14px; }
/* Glassmorphism */
.glass {
background: rgba(15, 23, 42, 0.38);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow:
0 10px 30px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.06);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
}
.glass-hover:hover { border-color: rgba(148, 163, 184, 0.30); }
</style>
</head>
<body class="min-h-screen text-slate-100 relative overflow-x-hidden">
<!-- Фон-картинка + затемнение/размытие (картинку положите в templates/bg.jpg) -->
<div class="fixed inset-0 -z-10">
<div class="absolute inset-0 bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900"></div>
<!-- именно <img>, чтобы браузер точно загрузил файл -->
<img
src="/assets/bg.jpg?v={{ bg_version }}"
alt=""
class="absolute inset-0 w-full h-full object-cover opacity-55"
loading="eager"
decoding="async"
/>
<!-- подложка и “дымка”, чтобы контент читался -->
<div class="absolute inset-0 bg-slate-950/45 backdrop-blur-sm"></div>
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(99,102,241,0.18),transparent_55%)]"></div>
</div>
<div class="mx-auto container-max px-4 pb-16">
<header class="pt-6 pb-4 flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-widest text-slate-400">Волейбол</div>
<h1 class="text-2xl font-semibold">{% block header %}Учет посещаемости{% endblock %}</h1>
</div>
<nav class="text-sm text-slate-300">
{% block nav %}{% endblock %}
</nav>
</header>
{% block content %}{% endblock %}
</div>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

BIN
templates/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

206
templates/index.html Normal file
View File

@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}Voley — отметка посещения{% endblock %}
{% block header %}Отметка посещения{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-4 sm:p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="text-sm text-slate-300">Выбранное воскресенье</div>
<div class="text-xl font-semibold">{{ selected_date }}</div>
</div>
<div class="w-full sm:w-[320px]">
<label class="text-sm text-slate-300">Календарь (только воскресенья)</label>
<input
id="datePicker"
class="mt-2 w-full rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Выберите воскресенье"
value="{{ selected_date }}"
/>
</div>
</div>
</section>
<section class="mt-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Игроки</h2>
<div class="text-xs text-slate-400">{{ players|length }} активн.</div>
</div>
<div id="playersList" class="mt-3 grid gap-3">
{% for player in players %}
{% set present = present_map.get(player.id, False) %}
{% include "partials/player_row.html" %}
{% endfor %}
</div>
</section>
<section class="mt-8" id="teamsSection">
<div id="teamsBox">
{# По умолчанию рендерим команды детерминированно (по алфавиту): #}
{% 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" %}
</div>
</section>
<section class="mt-8 glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">График</h2>
<div class="text-xs text-slate-400">{{ chart_title }}</div>
</div>
<div class="flex gap-2">
<a
href="/?date={{ selected_date }}&view=month"
class="px-3 py-2 rounded-xl border text-sm {{ 'bg-indigo-600 border-indigo-500' if chart_view=='month' else 'bg-slate-950/40 border-slate-800 text-slate-200' }}"
>Месяц</a>
<a
href="/?date={{ selected_date }}&view=year"
class="px-3 py-2 rounded-xl border text-sm {{ 'bg-indigo-600 border-indigo-500' if chart_view=='year' else 'bg-slate-950/40 border-slate-800 text-slate-200' }}"
>Год</a>
</div>
</div>
<div class="mt-4 h-56 sm:h-64">
{# 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 %}
<svg viewBox="0 0 200 100" class="w-full h-full block" style="width:100%;height:100%;display:block" role="img" aria-label="{{ chart_title }}">
<defs>
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(129,140,248,0.95)"></stop>
<stop offset="55%" stop-color="rgba(99,102,241,0.86)"></stop>
<stop offset="100%" stop-color="rgba(59,130,246,0.62)"></stop>
</linearGradient>
<linearGradient id="bgGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="rgba(99,102,241,0.20)"></stop>
<stop offset="60%" stop-color="rgba(15,23,42,0.00)"></stop>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1.2" stdDeviation="1.2" flood-color="rgba(0,0,0,0.45)"></feDropShadow>
</filter>
</defs>
<!-- карточка/фон -->
<rect x="0" y="0" width="200" height="100" rx="5" fill="rgba(2,6,23,0.22)"></rect>
<rect x="0" y="0" width="200" height="100" rx="5" fill="url(#bgGlow)"></rect>
<rect x="0.8" y="0.8" width="198.4" height="98.4" rx="5" fill="transparent" stroke="rgba(148,163,184,0.16)" stroke-width="0.7"></rect>
{# параметры области построения #}
{% set padL = 16 %}
{% set padR = 3 %}
{% set padT = 8 %}
{% set padB = 18 %}
{% set plotW = 200 - padL - padR %}
{% set plotH = 100 - padT - padB %}
<!-- оси -->
<line x1="{{ padL }}" y1="{{ padT + plotH }}" x2="{{ 200 - padR }}" y2="{{ padT + plotH }}" stroke="rgba(148,163,184,0.18)" stroke-width="0.7"></line>
<line x1="{{ padL }}" y1="{{ padT }}" x2="{{ padL }}" y2="{{ padT + plotH }}" stroke="rgba(148,163,184,0.14)" stroke-width="0.7"></line>
<!-- сетка + подписи Y -->
{% for p in [0,25,50,75,100] %}
{% set y = padT + plotH - (plotH * p / 100) %}
<line x1="{{ padL }}" y1="{{ y }}" x2="{{ 200 - padR }}" y2="{{ y }}" stroke="rgba(148,163,184,0.12)" stroke-width="0.6"></line>
<text x="5" y="{{ y + 1.7 }}" font-size="3.6" fill="rgba(226,232,240,0.70)">{{ (max_v * p / 100)|round|int }}</text>
{% endfor %}
{% if n == 0 %}
<text x="{{ padL }}" y="50" font-size="5" fill="rgba(226,232,240,0.85)">Нет данных</text>
{% 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) %}
<rect
x="{{ x }}"
y="{{ y }}"
width="{{ barW }}"
height="{{ h }}"
fill="url(#barGrad)"
stroke="rgba(165,180,252,0.55)"
stroke-width="0.25"
rx="1.1"
filter="url(#softShadow)"
>
<title>{{ lab }}: {{ v }}</title>
</rect>
{# значение над столбцом (если место позволяет) #}
{% if v > 0 and barW >= 2.2 %}
<text
x="{{ cx }}"
y="{{ y - 1.4 }}"
text-anchor="middle"
font-size="3.6"
fill="rgba(226,232,240,0.88)"
>{{ v }}</text>
{% 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 %}
<text
x="{{ cx }}"
y="96.7"
text-anchor="middle"
font-size="3.6"
fill="rgba(226,232,240,0.70)"
>{{ show }}</text>
{% endif %}
{% endfor %}
{% endif %}
</svg>
</div>
</section>
<section class="mt-8 glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">Выгрузка данных</h2>
<div class="text-xs text-slate-400">История посещений игроков по датам</div>
</div>
<a
href="/api/export/attendance.xlsx"
class="px-4 py-2 rounded-xl bg-green-600 hover:bg-green-500 border border-green-500 text-sm font-medium transition-colors"
download
>Скачать Excel</a>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
window.__VOLEY__ = {
selectedDate: {{ selected_date|tojson }},
chartLabels: {{ chart_labels_json|safe }},
chartValues: {{ chart_values_json|safe }},
chartView: {{ chart_view|tojson }},
};
</script>
{% endblock %}

View File

@@ -0,0 +1,27 @@
<div
class="player-row glass glass-hover rounded-2xl p-4 flex items-center justify-between gap-3 transition"
data-player-id="{{ player.id }}"
data-player-name="{{ player.name|lower }}"
>
<div class="min-w-0">
<div class="text-base font-medium truncate">{{ player.name }}</div>
<div class="text-xs text-slate-400">Нажмите, чтобы {% if present %}снять отметку{% else %}отметиться{% endif %}</div>
</div>
<form class="attendance-form flex items-center gap-3" method="post" action="/api/attendance/toggle">
<input type="hidden" name="player_id" value="{{ player.id }}" />
<input type="hidden" name="session_date" value="{{ session_date if session_date is defined else selected_date }}" />
<button
type="submit"
class="min-w-[120px] sm:min-w-[140px] px-4 py-2 rounded-xl text-sm font-semibold border transition
{% if present %}
bg-emerald-600 border-emerald-500 text-white
{% else %}
bg-slate-950/50 border-slate-800 text-slate-200 hover:border-slate-600
{% endif %}"
>
{% if present %}Пришёл{% else %}Не пришёл{% endif %}
</button>
</form>
</div>

View File

@@ -0,0 +1,47 @@
<div class="glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">Команды</h2>
<div class="text-xs text-slate-400">Отметились: {{ count }} · Дата: {{ session_date }}</div>
</div>
<form class="teams-form" method="post" action="/api/teams/random">
<input type="hidden" name="session_date" value="{{ session_date if session_date is defined else selected_date }}" />
<button
type="submit"
class="px-3 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500"
>
Перемешать
</button>
</form>
</div>
{% if count < 2 %}
<div class="mt-4 text-sm text-slate-300">
Нужно минимум 2 отметившихся, чтобы разделить на команды.
</div>
{% else %}
<div class="mt-4 grid sm:grid-cols-2 gap-3">
<div class="glass rounded-2xl p-4">
<div class="text-sm font-semibold text-slate-200">Команда A</div>
<ol class="mt-2 space-y-1 text-slate-100">
{% for n in team_a %}
<li class="flex items-center justify-between">
<span class="truncate">{{ loop.index }}. {{ n }}</span>
</li>
{% endfor %}
</ol>
</div>
<div class="glass rounded-2xl p-4">
<div class="text-sm font-semibold text-slate-200">Команда B</div>
<ol class="mt-2 space-y-1 text-slate-100">
{% for n in team_b %}
<li class="flex items-center justify-between">
<span class="truncate">{{ loop.index }}. {{ n }}</span>
</li>
{% endfor %}
</ol>
</div>
</div>
{% endif %}
</div>