From 0b662f6fa3e46ab69a92cfe49c737ac39db799ad Mon Sep 17 00:00:00 2001 From: Benedikt_MBP Date: Thu, 12 Feb 2026 01:55:13 +0000 Subject: [PATCH 1/2] Add dashboard, group, players, reports, and admin functionality - Implemented dashboard router with player statistics and recent changes. - Created group router for group statistics and API endpoints. - Developed players router for listing, searching, and player detail views. - Added reports router for generating weekly and yearly reports. - Introduced bot state management for shared state between Discord bot and FastAPI. - Implemented CSV service for reading EHB history and recent changes. - Created ranks service for player ranking and distribution. - Added templates for dashboard, group stats, player list, player detail, and reports. - Developed static CSS and JS for styling and charting functionalities. - Implemented admin panel for bot status and configuration. --- Dockerfile | 2 + docker-compose.yml | 2 + python/WOM.py | 53 ++++++-- python/requirements.txt | 5 +- python/web/__init__.py | 5 + python/web/app.py | 40 ++++++ python/web/dependencies.py | 10 ++ python/web/routers/__init__.py | 0 python/web/routers/admin.py | 75 +++++++++++ python/web/routers/charts.py | 42 +++++++ python/web/routers/dashboard.py | 32 +++++ python/web/routers/group.py | 45 +++++++ python/web/routers/players.py | 53 ++++++++ python/web/routers/reports.py | 51 ++++++++ python/web/services/__init__.py | 0 python/web/services/bot_state.py | 42 +++++++ python/web/services/csv_service.py | 90 ++++++++++++++ python/web/services/ranks_service.py | 79 ++++++++++++ python/web/services/report_service.py | 51 ++++++++ python/web/static/css/style.css | 67 ++++++++++ python/web/static/js/app.js | 116 +++++++++++++++++ python/web/templates/admin.html | 101 +++++++++++++++ python/web/templates/base.html | 46 +++++++ python/web/templates/chart.html | 77 ++++++++++++ python/web/templates/dashboard.html | 117 ++++++++++++++++++ python/web/templates/group_stats.html | 44 +++++++ python/web/templates/partials/log_feed.html | 6 + python/web/templates/partials/player_row.html | 9 ++ python/web/templates/partials/rank_table.html | 9 ++ python/web/templates/player_detail.html | 46 +++++++ python/web/templates/player_list.html | 36 ++++++ python/web/templates/report_weekly.html | 18 +++ python/web/templates/report_yearly.html | 23 ++++ 33 files changed, 1384 insertions(+), 8 deletions(-) create mode 100644 python/web/__init__.py create mode 100644 python/web/app.py create mode 100644 python/web/dependencies.py create mode 100644 python/web/routers/__init__.py create mode 100644 python/web/routers/admin.py create mode 100644 python/web/routers/charts.py create mode 100644 python/web/routers/dashboard.py create mode 100644 python/web/routers/group.py create mode 100644 python/web/routers/players.py create mode 100644 python/web/routers/reports.py create mode 100644 python/web/services/__init__.py create mode 100644 python/web/services/bot_state.py create mode 100644 python/web/services/csv_service.py create mode 100644 python/web/services/ranks_service.py create mode 100644 python/web/services/report_service.py create mode 100644 python/web/static/css/style.css create mode 100644 python/web/static/js/app.js create mode 100644 python/web/templates/admin.html create mode 100644 python/web/templates/base.html create mode 100644 python/web/templates/chart.html create mode 100644 python/web/templates/dashboard.html create mode 100644 python/web/templates/group_stats.html create mode 100644 python/web/templates/partials/log_feed.html create mode 100644 python/web/templates/partials/player_row.html create mode 100644 python/web/templates/partials/rank_table.html create mode 100644 python/web/templates/player_detail.html create mode 100644 python/web/templates/player_list.html create mode 100644 python/web/templates/report_weekly.html create mode 100644 python/web/templates/report_yearly.html diff --git a/Dockerfile b/Dockerfile index c70a656..41ea00c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,6 @@ RUN pip install --no-cache-dir -r /app/python/requirements.txt COPY python /app/python +EXPOSE 8080 + CMD ["python", "python/WOM.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 70be6af..cb2207e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,4 +10,6 @@ services: - ./python/config.ini:/app/python/config.ini:ro - ./python/ranks.ini:/app/python/ranks.ini:ro - ./data:/app/data + ports: + - "8080:8080" restart: unless-stopped diff --git a/python/WOM.py b/python/WOM.py index 62bea15..a9a6660 100644 --- a/python/WOM.py +++ b/python/WOM.py @@ -14,6 +14,9 @@ from utils.rank_utils import load_ranks, save_ranks from utils.log_csv import log_ehb_to_csv from utils.commands import setup_commands +import uvicorn +from web import create_app +from web.services.bot_state import BotState class Client(BaseClient): @@ -52,7 +55,9 @@ def log(message: str): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") formatted_message = f"{timestamp} - {message}" print(formatted_message) # Print to terminal - + if 'bot_state' in globals() and bot_state is not None: + bot_state.log_buffer.append(formatted_message) + # Send to GUI if it's running botgui_module = sys.modules.get('gui') or sys.modules.get('__main__') if botgui_module and hasattr(botgui_module, 'BotGUI'): @@ -92,6 +97,11 @@ def get_messageable_channel(channel_id: int) -> Optional[discord.abc.Messageable silent = config['settings'].getboolean('silent', False) debug = config['settings'].getboolean('debug', False) +# Web interface settings +web_enabled = config['web'].getboolean('enabled', False) if config.has_section('web') else False +web_host = config['web'].get('host', '0.0.0.0') if config.has_section('web') else '0.0.0.0' +web_port = int(config['web'].get('port', '8080')) if config.has_section('web') else 8080 + if api_key: log("Wise Old Man API key loaded.") else: @@ -247,7 +257,8 @@ async def check_for_rank_changes(): save_ranks(ranks_data) log("Rank check completed successfully!") - + bot_state.last_rank_check = datetime.now() + else: log(f"Failed to fetch group details: {result.unwrap_err()}") except Exception as e: @@ -344,6 +355,7 @@ async def refresh_group_data(): @tasks.loop(seconds=check_interval * 48) async def refresh_group_task(): msg = await refresh_group_data() + bot_state.last_group_refresh = datetime.now() if post_to_discord: channel = get_messageable_channel(channel_id) if channel: @@ -379,6 +391,20 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb): log(f"Error sending message: {e}") +# Shared state for web interface +bot_state = BotState( + wom_client=wom_client, + discord_client=discord_client, + group_id=group_id, + group_passcode=group_passcode, + get_rank=get_rank, + check_interval=check_interval, + post_to_discord=post_to_discord, + silent=silent, + debug=debug, +) + + # Initialize Additional Commands @@ -409,11 +435,24 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb): async def main(): async with wom_client, contextlib.AsyncExitStack() as stack: - await discord_client.start(discord_token) - try: - await asyncio.Future() # run forever - except asyncio.CancelledError: - await discord_client.close() + bot_state.list_all_members_and_ranks = list_all_members_and_ranks + bot_state.check_for_rank_changes = check_for_rank_changes + bot_state.refresh_group_data = refresh_group_data + bot_state.log_func = log + bot_state.bot_started_at = datetime.now() + + tasks_to_run = [discord_client.start(discord_token)] + + if web_enabled: + web_app = create_app(bot_state) + uvi_config = uvicorn.Config( + web_app, host=web_host, port=web_port, log_level="info" + ) + server = uvicorn.Server(uvi_config) + tasks_to_run.append(server.serve()) + log(f"Web interface starting on http://{web_host}:{web_port}") + + await asyncio.gather(*tasks_to_run) try: loop.run_until_complete(main()) diff --git a/python/requirements.txt b/python/requirements.txt index 1283074..53f6c1e 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -3,4 +3,7 @@ wom.py>=0.1.0 aiohttp>=3.9.1 asyncio>=3.4.3 requests -pytest \ No newline at end of file +pytest +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +jinja2>=3.1.0 \ No newline at end of file diff --git a/python/web/__init__.py b/python/web/__init__.py new file mode 100644 index 0000000..266b256 --- /dev/null +++ b/python/web/__init__.py @@ -0,0 +1,5 @@ +"""Web interface for WOMupdtr.""" + +from .app import create_app + +__all__ = ["create_app"] diff --git a/python/web/app.py b/python/web/app.py new file mode 100644 index 0000000..afc7bca --- /dev/null +++ b/python/web/app.py @@ -0,0 +1,40 @@ +"""FastAPI application factory for the WOMupdtr web interface.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from .services.bot_state import BotState + +_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def create_app(state: BotState) -> FastAPI: + """Build and return the configured FastAPI application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.bot_state = state + yield + + app = FastAPI(title="WOMupdtr Dashboard", lifespan=lifespan) + + # Static files + static_dir = os.path.join(_BASE_DIR, "static") + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + # Register routers (imported here to avoid circular imports) + from .routers import admin, charts, dashboard, group, players, reports + + app.include_router(dashboard.router) + app.include_router(players.router, prefix="/players", tags=["players"]) + app.include_router(reports.router, prefix="/reports", tags=["reports"]) + app.include_router(charts.router, prefix="/charts", tags=["charts"]) + app.include_router(admin.router, prefix="/admin", tags=["admin"]) + app.include_router(group.router, prefix="/group", tags=["group"]) + + return app diff --git a/python/web/dependencies.py b/python/web/dependencies.py new file mode 100644 index 0000000..aa2400f --- /dev/null +++ b/python/web/dependencies.py @@ -0,0 +1,10 @@ +"""FastAPI dependency injection helpers.""" + +from fastapi import Request + +from .services.bot_state import BotState + + +def get_bot_state(request: Request) -> BotState: + """Retrieve the shared BotState from the FastAPI application.""" + return request.app.state.bot_state diff --git a/python/web/routers/__init__.py b/python/web/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/web/routers/admin.py b/python/web/routers/admin.py new file mode 100644 index 0000000..842b9a8 --- /dev/null +++ b/python/web/routers/admin.py @@ -0,0 +1,75 @@ +"""Admin router - bot control, logs, config.""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from ..dependencies import get_bot_state +from ..services.bot_state import BotState + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/", response_class=HTMLResponse) +async def admin_page(request: Request, state: BotState = Depends(get_bot_state)): + return templates.TemplateResponse("admin.html", { + "request": request, + "bot_state": state, + }) + + +@router.post("/force-check", response_class=HTMLResponse) +async def force_check(state: BotState = Depends(get_bot_state)): + if state.check_for_rank_changes and hasattr(state.check_for_rank_changes, '__call__'): + try: + await state.check_for_rank_changes() + return HTMLResponse('

Rank check triggered successfully.

') + except Exception as e: + return HTMLResponse(f'

Error: {e}

') + return HTMLResponse('

Rank check function not available.

') + + +@router.post("/refresh-group", response_class=HTMLResponse) +async def refresh_group(state: BotState = Depends(get_bot_state)): + if state.refresh_group_data: + try: + msg = await state.refresh_group_data() + return HTMLResponse(f'

{msg}

') + except Exception as e: + return HTMLResponse(f'

Error: {e}

') + return HTMLResponse('

Refresh function not available.

') + + +@router.get("/logs", response_class=HTMLResponse) +async def get_logs(request: Request, state: BotState = Depends(get_bot_state)): + log_lines = list(state.log_buffer)[-100:] # Last 100 lines + return templates.TemplateResponse("partials/log_feed.html", { + "request": request, + "log_lines": log_lines, + }) + + +@router.get("/status") +async def bot_status(state: BotState = Depends(get_bot_state)): + return JSONResponse(content={ + "bot_started_at": state.bot_started_at.isoformat() if state.bot_started_at else None, + "last_rank_check": state.last_rank_check.isoformat() if state.last_rank_check else None, + "last_group_refresh": state.last_group_refresh.isoformat() if state.last_group_refresh else None, + "check_interval": state.check_interval, + "silent": state.silent, + "debug": state.debug, + "post_to_discord": state.post_to_discord, + }) + + +@router.post("/config", response_class=HTMLResponse) +async def update_config(request: Request, state: BotState = Depends(get_bot_state)): + form = await request.form() + state.silent = "silent" in form + state.debug = "debug" in form + return HTMLResponse('

Configuration updated.

') diff --git a/python/web/routers/charts.py b/python/web/routers/charts.py new file mode 100644 index 0000000..02ad5b1 --- /dev/null +++ b/python/web/routers/charts.py @@ -0,0 +1,42 @@ +"""Charts router - chart pages and JSON data APIs.""" + +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from ..services.ranks_service import get_all_players_sorted, get_rank_distribution +from ..services.csv_service import get_player_ehb_history + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/", response_class=HTMLResponse) +async def charts_page(request: Request): + players = get_all_players_sorted() + return templates.TemplateResponse("chart.html", { + "request": request, + "players": players, + }) + + +@router.get("/api/ehb-history") +async def ehb_history_api(player: str = Query(...)): + history = get_player_ehb_history(player) + return JSONResponse(content=history) + + +@router.get("/api/rank-distribution") +async def rank_distribution_api(): + dist = get_rank_distribution() + return JSONResponse(content=dist) + + +@router.get("/api/top-players") +async def top_players_api(limit: int = Query(15)): + players = get_all_players_sorted()[:limit] + return JSONResponse(content=players) diff --git a/python/web/routers/dashboard.py b/python/web/routers/dashboard.py new file mode 100644 index 0000000..46e4ace --- /dev/null +++ b/python/web/routers/dashboard.py @@ -0,0 +1,32 @@ +"""Dashboard router - main landing page.""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from ..dependencies import get_bot_state +from ..services.bot_state import BotState +from ..services.ranks_service import get_all_players_sorted, get_rank_distribution +from ..services.csv_service import get_recent_changes + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, state: BotState = Depends(get_bot_state)): + players = get_all_players_sorted() + rank_dist = get_rank_distribution() + recent = get_recent_changes(limit=20) + return templates.TemplateResponse("dashboard.html", { + "request": request, + "players": players, + "rank_distribution": rank_dist, + "recent_changes": recent, + "bot_state": state, + "player_count": len(players), + }) diff --git a/python/web/routers/group.py b/python/web/routers/group.py new file mode 100644 index 0000000..6416fbd --- /dev/null +++ b/python/web/routers/group.py @@ -0,0 +1,45 @@ +"""Group router - group statistics.""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from ..services.ranks_service import get_all_players_sorted, get_rank_distribution, get_rank_thresholds + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/", response_class=HTMLResponse) +async def group_page(request: Request): + players = get_all_players_sorted() + rank_dist = get_rank_distribution() + thresholds = get_rank_thresholds() + total_ehb = sum(p["ehb"] for p in players) + avg_ehb = total_ehb / len(players) if players else 0 + return templates.TemplateResponse("group_stats.html", { + "request": request, + "players": players, + "rank_distribution": rank_dist, + "total_ehb": total_ehb, + "avg_ehb": avg_ehb, + "rank_thresholds": thresholds, + }) + + +@router.get("/api/stats") +async def group_stats_api(): + players = get_all_players_sorted() + rank_dist = get_rank_distribution() + total_ehb = sum(p["ehb"] for p in players) + avg_ehb = total_ehb / len(players) if players else 0 + return JSONResponse(content={ + "total_players": len(players), + "total_ehb": round(total_ehb, 2), + "avg_ehb": round(avg_ehb, 2), + "rank_distribution": rank_dist, + }) diff --git a/python/web/routers/players.py b/python/web/routers/players.py new file mode 100644 index 0000000..d41c627 --- /dev/null +++ b/python/web/routers/players.py @@ -0,0 +1,53 @@ +"""Players router - list, search, detail, history.""" + +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from ..dependencies import get_bot_state +from ..services.bot_state import BotState +from ..services.ranks_service import get_all_players_sorted, get_player_detail, search_players +from ..services.csv_service import get_player_ehb_history + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/", response_class=HTMLResponse) +async def player_list(request: Request): + players = get_all_players_sorted() + return templates.TemplateResponse("player_list.html", { + "request": request, + "players": players, + "query": "", + }) + + +@router.get("/search", response_class=HTMLResponse) +async def player_search(request: Request, q: str = Query("")): + players = search_players(q) + return templates.TemplateResponse("partials/player_row.html", { + "request": request, + "players": players, + }) + + +@router.get("/{username}", response_class=HTMLResponse) +async def player_detail(request: Request, username: str): + player = get_player_detail(username) + if not player: + return HTMLResponse("

Player not found

", status_code=404) + return templates.TemplateResponse("player_detail.html", { + "request": request, + "player": player, + }) + + +@router.get("/{username}/history") +async def player_history(username: str): + history = get_player_ehb_history(username) + return JSONResponse(content=history) diff --git a/python/web/routers/reports.py b/python/web/routers/reports.py new file mode 100644 index 0000000..f8763c3 --- /dev/null +++ b/python/web/routers/reports.py @@ -0,0 +1,51 @@ +"""Reports router - weekly and yearly reports.""" + +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from ..dependencies import get_bot_state +from ..services.bot_state import BotState +from ..services.report_service import get_weekly_report, get_yearly_report + +import os + +router = APIRouter() +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates") +) + + +@router.get("/weekly", response_class=HTMLResponse) +async def weekly_report(request: Request, state: BotState = Depends(get_bot_state)): + try: + messages = await get_weekly_report(state) + report_lines = [] + for msg in messages: + report_lines.extend(msg.split("\n")) + except Exception as e: + report_lines = [f"Error generating weekly report: {e}"] + return templates.TemplateResponse("report_weekly.html", { + "request": request, + "report_lines": report_lines, + }) + + +@router.get("/yearly", response_class=HTMLResponse) +async def yearly_report( + request: Request, + year: int = Query(None), + state: BotState = Depends(get_bot_state), +): + try: + messages = await get_yearly_report(state, year=year) + report_lines = [] + for msg in messages: + report_lines.extend(msg.split("\n")) + except Exception as e: + report_lines = [f"Error generating yearly report: {e}"] + return templates.TemplateResponse("report_yearly.html", { + "request": request, + "report_lines": report_lines, + "year": year, + }) diff --git a/python/web/services/__init__.py b/python/web/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/web/services/bot_state.py b/python/web/services/bot_state.py new file mode 100644 index 0000000..10c4a70 --- /dev/null +++ b/python/web/services/bot_state.py @@ -0,0 +1,42 @@ +"""Shared state container between the Discord bot and the FastAPI web server.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Optional + + +@dataclass +class BotState: + """Mutable state shared between the Discord bot and web interface. + + Created once at startup and passed to both the Discord bot event loop + and the FastAPI application via ``app.state``. + """ + + # Core client references (set during startup) + wom_client: Any = None + discord_client: Any = None + group_id: int = 0 + group_passcode: str = "" + + # Function references injected from WOM.py + get_rank: Optional[Callable] = None + list_all_members_and_ranks: Optional[Callable] = None + check_for_rank_changes: Optional[Callable] = None + refresh_group_data: Optional[Callable] = None + log_func: Optional[Callable] = None + + # Runtime config (readable/writable from admin panel) + check_interval: int = 300 + post_to_discord: bool = True + silent: bool = False + debug: bool = False + + # Runtime telemetry + log_buffer: deque = field(default_factory=lambda: deque(maxlen=500)) + last_rank_check: Optional[datetime] = None + last_group_refresh: Optional[datetime] = None + bot_started_at: Optional[datetime] = None diff --git a/python/web/services/csv_service.py b/python/web/services/csv_service.py new file mode 100644 index 0000000..ea0de80 --- /dev/null +++ b/python/web/services/csv_service.py @@ -0,0 +1,90 @@ +"""Service layer for reading EHB CSV history data.""" + +import csv +import os + +from utils.log_csv import _resolve_csv_path + + +def get_player_ehb_history(username): + """Return list of {timestamp, ehb} for a specific player, sorted by time.""" + resolved_path = _resolve_csv_path("ehb_log.csv") + if not os.path.exists(resolved_path): + return [] + + history = [] + try: + with open(resolved_path, mode="r", newline="", encoding="utf-8") as f: + reader = csv.reader(f) + for row in reader: + if len(row) < 3: + continue + ts = row[0].strip() + name = row[1].strip() + try: + ehb = float(row[2].strip()) + except ValueError: + continue + if name.lower() == username.lower(): + history.append({"timestamp": ts, "ehb": ehb}) + except Exception: + return [] + + history.sort(key=lambda e: e["timestamp"]) + return history + + +def get_recent_changes(limit=20): + """Return the most recent EHB changes across all players.""" + resolved_path = _resolve_csv_path("ehb_log.csv") + if not os.path.exists(resolved_path): + return [] + + entries = [] + try: + with open(resolved_path, mode="r", newline="", encoding="utf-8") as f: + reader = csv.reader(f) + for row in reader: + if len(row) < 3: + continue + ts = row[0].strip() + name = row[1].strip() + try: + ehb = float(row[2].strip()) + except ValueError: + continue + entries.append({"timestamp": ts, "username": name, "ehb": ehb}) + except Exception: + return [] + + entries.sort(key=lambda e: e["timestamp"], reverse=True) + return entries[:limit] + + +def get_all_ehb_entries(): + """Return all CSV entries grouped by player: {username: [{timestamp, ehb}]}.""" + resolved_path = _resolve_csv_path("ehb_log.csv") + if not os.path.exists(resolved_path): + return {} + + grouped = {} + try: + with open(resolved_path, mode="r", newline="", encoding="utf-8") as f: + reader = csv.reader(f) + for row in reader: + if len(row) < 3: + continue + ts = row[0].strip() + name = row[1].strip() + try: + ehb = float(row[2].strip()) + except ValueError: + continue + grouped.setdefault(name, []).append({"timestamp": ts, "ehb": ehb}) + except Exception: + return {} + + for entries in grouped.values(): + entries.sort(key=lambda e: e["timestamp"]) + + return grouped diff --git a/python/web/services/ranks_service.py b/python/web/services/ranks_service.py new file mode 100644 index 0000000..d514209 --- /dev/null +++ b/python/web/services/ranks_service.py @@ -0,0 +1,79 @@ +"""Service layer wrapping rank_utils for web consumption.""" + +import configparser +import os +from utils.rank_utils import load_ranks, next_rank + +_RANKS_INI = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'ranks.ini') + + +def get_all_players_sorted(): + """Return list of player dicts sorted by EHB descending.""" + ranks = load_ranks() + players = [] + for username, data in ranks.items(): + players.append({ + "username": username, + "ehb": data.get("last_ehb", 0), + "rank": data.get("rank", "Unknown"), + "discord_name": data.get("discord_name", []), + }) + players.sort(key=lambda p: p["ehb"], reverse=True) + return players + + +def get_player_detail(username): + """Return detailed player info or None if not found.""" + ranks = load_ranks() + data = ranks.get(username) + if not data: + # Try case-insensitive match + for key, val in ranks.items(): + if key.lower() == username.lower(): + username = key + data = val + break + if not data: + return None + return { + "username": username, + "ehb": data.get("last_ehb", 0), + "rank": data.get("rank", "Unknown"), + "discord_name": data.get("discord_name", []), + "next_rank": next_rank(username), + } + + +def get_rank_distribution(): + """Return dict of rank_name -> count.""" + ranks = load_ranks() + dist = {} + for data in ranks.values(): + rank = data.get("rank", "Unknown") + dist[rank] = dist.get(rank, 0) + 1 + return dist + + +def search_players(query): + """Case-insensitive search by username prefix/substring.""" + if not query: + return get_all_players_sorted() + query_lower = query.lower() + all_players = get_all_players_sorted() + return [p for p in all_players if query_lower in p["username"].lower()] + + +def get_rank_thresholds(): + """Read ranks.ini and return ordered list of (lower_bound, upper_bound_or_none, rank_name).""" + config = configparser.ConfigParser() + config.read(_RANKS_INI) + thresholds = [] + for range_key, rank_name in config['Group Ranking'].items(): + if '+' in range_key: + lower = int(range_key.replace('+', '')) + thresholds.append({"lower": lower, "upper": None, "name": rank_name}) + else: + lower, upper = map(int, range_key.split('-')) + thresholds.append({"lower": lower, "upper": upper, "name": rank_name}) + thresholds.sort(key=lambda t: t["lower"]) + return thresholds diff --git a/python/web/services/report_service.py b/python/web/services/report_service.py new file mode 100644 index 0000000..a63f18b --- /dev/null +++ b/python/web/services/report_service.py @@ -0,0 +1,51 @@ +"""Service layer for generating reports via the web interface.""" + +from datetime import datetime, timezone + +from weeklyupdater import ( + generate_weekly_report_messages, + generate_yearly_report_messages, + most_recent_week_end, + most_recent_year_end, +) + + +async def get_weekly_report(bot_state): + """Generate the most recent weekly report as a list of text chunks.""" + now = datetime.now(timezone.utc) + end_date = most_recent_week_end(now) + + def log(msg): + if bot_state.log_func: + bot_state.log_func(msg) + + messages = await generate_weekly_report_messages( + wom_client=bot_state.wom_client, + group_id=bot_state.group_id, + end_date=end_date, + log=log, + ) + return messages + + +async def get_yearly_report(bot_state, year=None): + """Generate a yearly report. If year is None, uses the most recent completed year.""" + now = datetime.now(timezone.utc) + + if year is not None: + from weeklyupdater.yearly_reporter import _year_boundary_1200_utc + end_date = _year_boundary_1200_utc(year + 1) + else: + end_date = most_recent_year_end(now) + + def log(msg): + if bot_state.log_func: + bot_state.log_func(msg) + + messages = await generate_yearly_report_messages( + wom_client=bot_state.wom_client, + group_id=bot_state.group_id, + end_date=end_date, + log=log, + ) + return messages diff --git a/python/web/static/css/style.css b/python/web/static/css/style.css new file mode 100644 index 0000000..879c2b2 --- /dev/null +++ b/python/web/static/css/style.css @@ -0,0 +1,67 @@ +/* Rank badge base */ +.rank-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 0.85em; + color: #fff; +} + +/* Rank colors - OSRS gem tiers */ +.rank-goblin { background-color: #2d5016; color: #fff; } +.rank-opal { background-color: #a8c8e0; color: #1a1a2e; } +.rank-sapphire { background-color: #0f52ba; color: #fff; } +.rank-emerald { background-color: #50c878; color: #1a1a2e; } +.rank-red-topaz { background-color: #ff6347; color: #fff; } +.rank-ruby { background-color: #e0115f; color: #fff; } +.rank-diamond { background-color: #b9f2ff; color: #1a1a2e; } +.rank-dragonstone { background-color: #7851a9; color: #fff; } +.rank-onyx { background-color: #353839; color: #fff; border: 1px solid #666; } +.rank-zenyte { background-color: #ffd700; color: #1a1a2e; } + +/* Stat cards */ +.stat-card { + text-align: center; + padding: 1.5rem; +} + +.stat-value { + font-size: 1.8rem; + font-weight: bold; + margin: 0.5rem 0 0 0; +} + +/* Log viewer */ +.log-feed { + background: #1a1a2e; + color: #0f0; + font-family: monospace; + padding: 1rem; + max-height: 400px; + overflow-y: auto; + border-radius: 8px; +} + +.log-line { + padding: 2px 0; + font-size: 0.85em; +} + +.log-empty { + color: #666; + font-style: italic; +} + +/* Navigation active state */ +nav a[aria-current="page"], +nav a.active { + color: var(--pico-primary); + font-weight: bold; +} + +/* Report pre-formatted text */ +article pre { + white-space: pre-wrap; + word-wrap: break-word; +} diff --git a/python/web/static/js/app.js b/python/web/static/js/app.js new file mode 100644 index 0000000..9b8070a --- /dev/null +++ b/python/web/static/js/app.js @@ -0,0 +1,116 @@ +// OSRS-themed colors for rank distribution +const RANK_COLORS = { + 'goblin': '#2d5016', + 'Opal': '#a8c8e0', + 'Sapphire': '#0f52ba', + 'Emerald': '#50c878', + 'Red Topaz': '#ff6347', + 'Ruby': '#e0115f', + 'Diamond': '#b9f2ff', + 'Dragonstone': '#7851a9', + 'Onyx': '#353839', + 'Zenyte': '#ffd700', +}; + +async function loadRankDistribution() { + const canvas = document.getElementById('rankDistChart'); + if (!canvas) return; + const resp = await fetch('/charts/api/rank-distribution'); + const data = await resp.json(); + const labels = Object.keys(data); + const values = Object.values(data); + const colors = labels.map(l => RANK_COLORS[l] || '#888'); + new Chart(canvas, { + type: 'doughnut', + data: { + labels: labels, + datasets: [{ + data: values, + backgroundColor: colors, + borderWidth: 1, + borderColor: '#333', + }] + }, + options: { + responsive: true, + plugins: { + legend: { position: 'right', labels: { color: '#ccc' } }, + title: { display: true, text: 'Rank Distribution', color: '#ccc' } + } + } + }); +} + +async function loadTopPlayers(limit = 15) { + const canvas = document.getElementById('topPlayersChart'); + if (!canvas) return; + const resp = await fetch(`/charts/api/top-players?limit=${limit}`); + const data = await resp.json(); + new Chart(canvas, { + type: 'bar', + data: { + labels: data.map(p => p.username), + datasets: [{ + label: 'EHB', + data: data.map(p => p.ehb), + backgroundColor: data.map(p => RANK_COLORS[p.rank] || '#4CAF50'), + borderWidth: 1, + borderColor: '#333', + }] + }, + options: { + responsive: true, + indexAxis: 'y', + plugins: { + legend: { display: false }, + title: { display: true, text: 'Top Players by EHB', color: '#ccc' } + }, + scales: { + x: { ticks: { color: '#ccc' }, grid: { color: '#333' } }, + y: { ticks: { color: '#ccc' }, grid: { color: '#333' } } + } + } + }); +} + +async function loadPlayerChart(username) { + const canvas = document.getElementById('ehbChart'); + if (!canvas) return; + const resp = await fetch(`/charts/api/ehb-history?player=${encodeURIComponent(username)}`); + const data = await resp.json(); + if (!data.length) { + canvas.parentElement.innerHTML = '

No EHB history data available for this player.

'; + return; + } + new Chart(canvas, { + type: 'line', + data: { + labels: data.map(e => e.timestamp), + datasets: [{ + label: 'EHB', + data: data.map(e => e.ehb), + borderColor: '#4CAF50', + backgroundColor: 'rgba(76, 175, 80, 0.1)', + fill: true, + tension: 0.3, + }] + }, + options: { + responsive: true, + plugins: { + legend: { display: false }, + title: { display: true, text: `${username} - EHB History`, color: '#ccc' } + }, + scales: { + x: { ticks: { color: '#ccc', maxTicksLimit: 10 }, grid: { color: '#333' } }, + y: { ticks: { color: '#ccc' }, grid: { color: '#333' } } + } + } + }); +} + +// Auto-initialize charts on page load +document.addEventListener('DOMContentLoaded', () => { + loadRankDistribution(); + loadTopPlayers(); +}); diff --git a/python/web/templates/admin.html b/python/web/templates/admin.html new file mode 100644 index 0000000..ca4d8d1 --- /dev/null +++ b/python/web/templates/admin.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} + +{% block title %}Admin - WOMupdtr{% endblock %} + +{% block content %} +

Admin Panel

+ +
+

Bot Status

+
+
+ Started At:
+ {{ bot_state.bot_started_at if bot_state.bot_started_at else "Not started" }} +
+
+ Uptime:
+ + N/A + +
+
+ Last Rank Check:
+ {{ bot_state.last_rank_check if bot_state.last_rank_check else "Never" }} +
+
+ Last Group Refresh:
+ {{ bot_state.last_group_refresh if bot_state.last_group_refresh else "Never" }} +
+
+ Check Interval:
+ {{ bot_state.check_interval if bot_state.check_interval else "N/A" }} seconds +
+
+
+ +
+

Actions

+
+ + +
+
+
+ +
+

Configuration

+
+ + +
+
+ +
+

Log Viewer

+
+
Loading logs...
+
+
+ + +{% endblock %} diff --git a/python/web/templates/base.html b/python/web/templates/base.html new file mode 100644 index 0000000..77d840a --- /dev/null +++ b/python/web/templates/base.html @@ -0,0 +1,46 @@ + + + + + + {% block title %}WOMupdtr{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + diff --git a/python/web/templates/chart.html b/python/web/templates/chart.html new file mode 100644 index 0000000..46241d7 --- /dev/null +++ b/python/web/templates/chart.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block title %}Charts - WOMupdtr{% endblock %} + +{% block content %} +

Charts

+ +
+
+

Rank Distribution

+ +
+
+

Top Players by EHB

+ +
+
+ +
+

Individual Player EHB History

+ + +
+ + +{% endblock %} diff --git a/python/web/templates/dashboard.html b/python/web/templates/dashboard.html new file mode 100644 index 0000000..5b7d945 --- /dev/null +++ b/python/web/templates/dashboard.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - WOMupdtr{% endblock %} + +{% block content %} +

Dashboard

+ +
+
+

Total Players

+

{{ player_count }}

+
+
+

Total EHB

+

{{ "%.2f"|format(players|sum(attribute='ehb')) }}

+
+
+

Average EHB

+

{{ "%.2f"|format(players|sum(attribute='ehb') / player_count) if player_count > 0 else "0.00" }}

+
+
+

Bot Uptime

+

+ {% if bot_state and bot_state.bot_started_at %} + Loading... + {% else %} + N/A + {% endif %} +

+
+
+ +
+
+

Rank Distribution

+ {% if rank_distribution %} + + + {% for rank, count in rank_distribution.items() %} + + + + + + {% endfor %} + +
{{ rank }}{{ count }} players + +
+ {% else %} +

No rank data available.

+ {% endif %} +
+ +
+

Recent Activity

+ {% if recent_changes %} + + + + + + + + + + {% for change in recent_changes[:10] %} + + + + + + {% endfor %} + +
TimePlayerEHB
{{ change.timestamp }}{{ change.username }}{{ "%.2f"|format(change.ehb) }}
+ {% else %} +

No recent activity.

+ {% endif %} +
+
+ +
+

Bot Status

+
+
+ Last Rank Check:
+ {{ bot_state.last_rank_check if bot_state and bot_state.last_rank_check else "Never" }} +
+
+ Check Interval:
+ {{ bot_state.check_interval if bot_state and bot_state.check_interval else "N/A" }} seconds +
+
+ Last Group Refresh:
+ {{ bot_state.last_group_refresh if bot_state and bot_state.last_group_refresh else "Never" }} +
+
+
+ + +{% endblock %} diff --git a/python/web/templates/group_stats.html b/python/web/templates/group_stats.html new file mode 100644 index 0000000..0d9293b --- /dev/null +++ b/python/web/templates/group_stats.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}Group Stats - WOMupdtr{% endblock %} + +{% block content %} +

Group Stats

+ +
+
+

Total Players

+

{{ players|length }}

+
+
+

Total EHB

+

{{ "%.2f"|format(total_ehb) }}

+
+
+

Average EHB

+

{{ "%.2f"|format(avg_ehb) }}

+
+
+ +
+

Rank Distribution

+ + + + + + + + + + {% for threshold in rank_thresholds %} + + + + + + {% endfor %} + +
RankEHB RangePlayers
{{ threshold.name }}{{ "%.1f"|format(threshold.lower) }} - {{ "%.1f"|format(threshold.upper) if threshold.upper else "+" }}{{ rank_distribution.get(threshold.name, 0) if rank_distribution else 0 }}
+
+{% endblock %} diff --git a/python/web/templates/partials/log_feed.html b/python/web/templates/partials/log_feed.html new file mode 100644 index 0000000..aee4242 --- /dev/null +++ b/python/web/templates/partials/log_feed.html @@ -0,0 +1,6 @@ +{% for line in log_lines %} +
{{ line }}
+{% endfor %} +{% if not log_lines %} +
No log entries yet.
+{% endif %} diff --git a/python/web/templates/partials/player_row.html b/python/web/templates/partials/player_row.html new file mode 100644 index 0000000..a9635d3 --- /dev/null +++ b/python/web/templates/partials/player_row.html @@ -0,0 +1,9 @@ +{% for player in players %} + + {{ loop.index }} + {{ player.username }} + {{ player.rank }} + {{ "%.2f"|format(player.ehb) }} + {{ player.discord_name|join(", ") if player.discord_name else "None" }} + +{% endfor %} diff --git a/python/web/templates/partials/rank_table.html b/python/web/templates/partials/rank_table.html new file mode 100644 index 0000000..a9635d3 --- /dev/null +++ b/python/web/templates/partials/rank_table.html @@ -0,0 +1,9 @@ +{% for player in players %} + + {{ loop.index }} + {{ player.username }} + {{ player.rank }} + {{ "%.2f"|format(player.ehb) }} + {{ player.discord_name|join(", ") if player.discord_name else "None" }} + +{% endfor %} diff --git a/python/web/templates/player_detail.html b/python/web/templates/player_detail.html new file mode 100644 index 0000000..a93c7ae --- /dev/null +++ b/python/web/templates/player_detail.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}{{ player.username }} - WOMupdtr{% endblock %} + +{% block content %} +

{{ player.username }}

+ +
+
+

Current EHB

+

{{ "%.2f"|format(player.ehb) }}

+
+
+

Current Rank

+

{{ player.rank }}

+
+
+

Next Rank

+

{{ player.next_rank if player.next_rank else "Max Rank" }}

+
+
+ +
+

Fans

+ {% if player.discord_name %} + + {% else %} +

No fans yet.

+ {% endif %} +
+ +
+

EHB History

+ +
+ + +{% endblock %} diff --git a/python/web/templates/player_list.html b/python/web/templates/player_list.html new file mode 100644 index 0000000..337638c --- /dev/null +++ b/python/web/templates/player_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Players - WOMupdtr{% endblock %} + +{% block content %} +

Players

+ + + + + + + + + + + + + + + {% for player in players %} + + + + + + + + {% endfor %} + +
#PlayerRankEHBFans
{{ loop.index }}{{ player.username }}{{ player.rank }}{{ "%.2f"|format(player.ehb) }}{{ player.discord_name|join(", ") if player.discord_name else "None" }}
+{% endblock %} diff --git a/python/web/templates/report_weekly.html b/python/web/templates/report_weekly.html new file mode 100644 index 0000000..61693c5 --- /dev/null +++ b/python/web/templates/report_weekly.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}Weekly Report - WOMupdtr{% endblock %} + +{% block content %} +

Weekly Report

+ +Generate Fresh Report + +
+ {% if report_lines %} +
{% for line in report_lines %}{{ line }}
+{% endfor %}
+ {% else %} +

No weekly report data available.

+ {% endif %} +
+{% endblock %} diff --git a/python/web/templates/report_yearly.html b/python/web/templates/report_yearly.html new file mode 100644 index 0000000..2539043 --- /dev/null +++ b/python/web/templates/report_yearly.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Yearly Report - WOMupdtr{% endblock %} + +{% block content %} +

Yearly Report{% if year %} - {{ year }}{% endif %}

+ +
+
+ + +
+
+ +
+ {% if report_lines %} +
{% for line in report_lines %}{{ line }}
+{% endfor %}
+ {% else %} +

No yearly report data available.

+ {% endif %} +
+{% endblock %} From 1d1c9be9340fa20d023293605c70a3f7ed93514c Mon Sep 17 00:00:00 2001 From: ShakyPizza <105827753+ShakyPizza@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:05:33 +0000 Subject: [PATCH 2/2] Fix missing emoji characters in rank-up Discord messages --- python/WOM.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/python/WOM.py b/python/WOM.py index a9a6660..33c7982 100644 --- a/python/WOM.py +++ b/python/WOM.py @@ -372,7 +372,7 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb): # Ensure discord_names is always a list if not isinstance(discord_names, list): discord_names = [discord_names] if discord_names else [] - fans_display = " ".join(discord_names) if discord_names else "0 😭😭😭" + fans_display = " ".join(discord_names) if discord_names else "0 😭😭😭" # Only send message if the rank has changed if new_rank != old_rank: @@ -380,8 +380,8 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb): if channel: if post_to_discord: await channel.send( - f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** ' - f'with **{ehb}** EHB! 🎉\n' + f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** ' + f'with **{ehb}** EHB! 🎉\n' f'**Fans:** {fans_display}' ) log(f"Sent rank up message for {username} to channel: {channel}") @@ -470,4 +470,3 @@ async def main(): except Exception as e: print(f"Error during final cleanup: {e}") print("Cleanup complete. Goodbye!") -