Initial commit
This commit is contained in:
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"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user