diff --git a/app.py b/app.py
index a7fbbb2..d8fd051 100644
--- a/app.py
+++ b/app.py
@@ -1,10 +1,22 @@
#!/usr/bin/env python3
from __future__ import annotations
+import csv
import os
+import shutil
+from datetime import datetime, timezone
from pathlib import Path
-from flask import Flask, flash, redirect, render_template, request, session, url_for
+from flask import (
+ Flask,
+ flash,
+ redirect,
+ render_template,
+ request,
+ send_file,
+ session,
+ url_for,
+)
from stats import (
apply_rank_changes,
@@ -236,6 +248,87 @@ def admin_session_state() -> str:
return redirect(url_for("admin_dashboard"))
+@app.get("/admin/export")
+def admin_export_csv():
+ if not is_admin():
+ flash("Admin login required.", "error")
+ return redirect(url_for("admin_login"))
+
+ export_name = (
+ f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
+ )
+
+ return send_file(
+ DATA_PATH,
+ as_attachment=True,
+ download_name=export_name,
+ mimetype="text/csv",
+ )
+
+
+@app.post("/admin/import")
+def admin_import_csv():
+ if not is_admin():
+ flash("Admin login required.", "error")
+ return redirect(url_for("admin_login"))
+
+ uploaded_file = request.files.get("csv_file")
+ if uploaded_file is None or uploaded_file.filename == "":
+ flash("Choose a CSV file to import.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ if not uploaded_file.filename.lower().endswith(".csv"):
+ flash("Only CSV files are allowed.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ try:
+ uploaded_text = uploaded_file.stream.read().decode("utf-8-sig")
+ except UnicodeDecodeError:
+ flash("CSV file must be valid UTF-8 text.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ uploaded_lines = uploaded_text.splitlines()
+ if not uploaded_lines:
+ flash("CSV file is empty.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ try:
+ current_header = next(
+ csv.reader(open(DATA_PATH, "r", encoding="utf-8", newline=""))
+ )
+ except Exception:
+ flash("Could not read the current audit CSV header.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ try:
+ uploaded_header = next(csv.reader(uploaded_lines))
+ except Exception:
+ flash("Could not read the uploaded CSV header.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ if uploaded_header != current_header:
+ flash("CSV header does not match the current audit format.", "error")
+ return redirect(url_for("admin_dashboard"))
+
+ data_dir = os.path.dirname(DATA_PATH)
+ backup_name = (
+ f"entries_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
+ )
+ backup_path = os.path.join(data_dir, backup_name)
+
+ try:
+ shutil.copy2(DATA_PATH, backup_path)
+
+ with open(DATA_PATH, "w", encoding="utf-8", newline="") as handle:
+ handle.write(uploaded_text)
+
+ flash(f"CSV imported successfully. Backup created: {backup_name}", "success")
+ except Exception:
+ flash("Import failed. Existing CSV was not updated.", "error")
+
+ return redirect(url_for("admin_dashboard"))
+
+
@app.route("/admin", methods=["GET", "POST"])
def admin_dashboard() -> str:
if not is_admin():
@@ -286,7 +379,7 @@ def admin_dashboard() -> str:
sessions = build_session_summaries(events)
recent_sessions = sessions[:6]
open_sessions = [session for session in sessions if session.status == "open"]
- recent_events = list(reversed(events[-20:]))
+ recent_events = list(reversed(events[-8:]))
return render_template(
"admin_dashboard.html",
diff --git a/static/css/style.css b/static/css/style.css
index 890d74b..ce7fbef 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1000,6 +1000,38 @@ td a:hover {
border-color: rgba(255, 166, 77, 0.35);
}
+.admin-tools-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+}
+
+.import-form input[type="file"] {
+ width: 100%;
+ margin-top: 0.5rem;
+ margin-bottom: 1rem;
+ color: var(--text-muted);
+}
+
+.import-form input[type="file"]::file-selector-button {
+ appearance: none;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text-strong);
+ border-radius: 10px;
+ padding: 0.55rem 0.8rem;
+ font: inherit;
+ font-size: 0.9rem;
+ font-weight: 600;
+ margin-right: 0.75rem;
+ cursor: pointer;
+}
+
+.import-form input[type="file"]::file-selector-button:hover {
+ background: rgba(255, 166, 77, 0.12);
+ border-color: rgba(255, 166, 77, 0.28);
+}
+
@media (max-width: 980px) {
.site-shell {
width: min(100% - 24px, 100%);
diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html
index 029e231..5255252 100644
--- a/templates/admin_dashboard.html
+++ b/templates/admin_dashboard.html
@@ -52,101 +52,137 @@
-
-
-
+
-
-
-
- {% for event in recent_events %}
-
-
-
{{ event.player_name }}
-
{{ event.event_type }}
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Players |
+ Buy-ins |
+ Cash-outs |
+ Status |
+ Action |
+
+
+
+ {% for session in recent_sessions %}
+
+ |
+
+ {{ session.session_date | pretty_date }}
+
+ |
+ {{ session.entries|length }} |
+ {{ session.total_buy_in_cents | money }} |
+ {{ session.total_cash_out_cents | money }} |
+
+
+ {{ session.status }}
+
+ |
+
+
+ |
+
+ {% else %}
+
+ | No sessions yet. |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+ {% for event in recent_events %}
+
+
+ {{ event.player_name }}
+ {{ event.event_type }}
+
+ {{ event.amount_cents | money }}
+ {{ event.session_date | pretty_date }}
+ {% if event.note %}{{ event.note }}
{% endif %}
+
+ {% else %}
+
No recent events.
+ {% endfor %}
{% endblock %}