Files
volleyball_miniapp/main.py
2026-07-01 17:58:05 +03:00

336 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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