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