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