Initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
data/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
104
README.md
Normal file
104
README.md
Normal 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
2
__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Package marker for running as `uvicorn voley.main:app`
|
||||||
|
|
||||||
75
auth.py
Normal file
75
auth.py
Normal 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
217
db.py
Normal 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
41
deploy/install.sh
Executable 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
23
deploy/nginx.conf
Normal 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
16
deploy/voley.service
Normal 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
335
main.py
Normal 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
5
requirements.txt
Normal 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
295
static/app.js
Normal 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();
|
||||||
|
})();
|
||||||
|
|
||||||
37
templates/admin_login.html
Normal file
37
templates/admin_login.html
Normal 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 %}
|
||||||
79
templates/admin_players.html
Normal file
79
templates/admin_players.html
Normal 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
69
templates/base.html
Normal 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
BIN
templates/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
206
templates/index.html
Normal file
206
templates/index.html
Normal 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 %}
|
||||||
27
templates/partials/player_row.html
Normal file
27
templates/partials/player_row.html
Normal 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>
|
||||||
47
templates/partials/teams.html
Normal file
47
templates/partials/teams.html
Normal 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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user