update admin layout
This commit is contained in:
1 parent
8f82ee9958
commit
500625a498
3 files changed
+247
-86
No files matched your search
@@ -1,10 +1,22 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 (
|
from stats import (
|
||||||
apply_rank_changes,
|
apply_rank_changes,
|
||||||
@@ -236,6 +248,87 @@ def admin_session_state() -> str:
|
|||||||
return redirect(url_for("admin_dashboard"))
|
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"])
|
@app.route("/admin", methods=["GET", "POST"])
|
||||||
def admin_dashboard() -> str:
|
def admin_dashboard() -> str:
|
||||||
if not is_admin():
|
if not is_admin():
|
||||||
@@ -286,7 +379,7 @@ def admin_dashboard() -> str:
|
|||||||
sessions = build_session_summaries(events)
|
sessions = build_session_summaries(events)
|
||||||
recent_sessions = sessions[:6]
|
recent_sessions = sessions[:6]
|
||||||
open_sessions = [session for session in sessions if session.status == "open"]
|
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(
|
return render_template(
|
||||||
"admin_dashboard.html",
|
"admin_dashboard.html",
|
||||||
|
|||||||
@@ -1000,6 +1000,38 @@ td a:hover {
|
|||||||
border-color: rgba(255, 166, 77, 0.35);
|
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) {
|
@media (max-width: 980px) {
|
||||||
.site-shell {
|
.site-shell {
|
||||||
width: min(100% - 24px, 100%);
|
width: min(100% - 24px, 100%);
|
||||||
|
|||||||
+120
-84
@@ -52,101 +52,137 @@
|
|||||||
<button class="primary-button" type="submit">Add event</button>
|
<button class="primary-button" type="submit">Add event</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form class="panel form-card" method="post" action="{{ url_for('admin_session_state') }}">
|
<section class="grid">
|
||||||
<p class="eyebrow">Session state</p>
|
<form class="panel form-card" method="post" action="{{ url_for('admin_session_state') }}">
|
||||||
<h2>Open or close a session</h2>
|
<p class="eyebrow">Session state</p>
|
||||||
|
<h2>Open or close a session</h2>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Session date</span>
|
<span>Session date</span>
|
||||||
<input type="date" name="session_date" required>
|
<input type="date" name="session_date" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="primary-button" type="submit" name="state" value="open">Open session</button>
|
<button class="primary-button" type="submit" name="state" value="open">Open session</button>
|
||||||
<button class="secondary-button" type="submit" name="state" value="closed">Close session</button>
|
<button class="secondary-button" type="submit" name="state" value="closed">Close session</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="stack-gap">
|
<section class="panel form-card">
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Recent sessions</p>
|
<p class="eyebrow">Tools</p>
|
||||||
<h2>Current totals</h2>
|
<h2>Audit CSV</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap small-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Players</th>
|
|
||||||
<th>Buy-ins</th>
|
|
||||||
<th>Cash-outs</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for session in recent_sessions %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('session_detail', session_date=session.session_date) }}">
|
|
||||||
{{ session.session_date | pretty_date }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ session.entries|length }}</td>
|
|
||||||
<td>{{ session.total_buy_in_cents | money }}</td>
|
|
||||||
<td>{{ session.total_cash_out_cents | money }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge status-{{ session.status }}">
|
|
||||||
{{ session.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
|
|
||||||
<input type="hidden" name="session_date" value="{{ session.session_date }}">
|
|
||||||
{% if session.status == "open" %}
|
|
||||||
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
|
|
||||||
{% else %}
|
|
||||||
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4">No sessions yet.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
<div class="admin-tools-grid">
|
||||||
<div class="panel-header">
|
<div class="helper-box">
|
||||||
<div>
|
<p class="muted-text">
|
||||||
<p class="eyebrow">Recent raw events</p>
|
Export the current audit trail or import a replacement CSV. Import creates a timestamped backup first.
|
||||||
<h2>Ledger feed</h2>
|
</p>
|
||||||
</div>
|
<div class="button-row">
|
||||||
</div>
|
<a class="primary-button" href="{{ url_for('admin_export_csv') }}">Export CSV</a>
|
||||||
<div class="event-feed condensed">
|
|
||||||
{% for event in recent_events %}
|
|
||||||
<article class="event-card">
|
|
||||||
<div class="event-card-top">
|
|
||||||
<strong>{{ event.player_name }}</strong>
|
|
||||||
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
</div>
|
||||||
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
|
|
||||||
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
|
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="helper-box import-form">
|
||||||
</article>
|
<label for="csv_file">
|
||||||
{% else %}
|
<span>Import replacement CSV</span>
|
||||||
<p class="muted-text">No recent events.</p>
|
</label>
|
||||||
{% endfor %}
|
<input id="csv_file" name="csv_file" type="file" accept=".csv,text/csv" required>
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="submit"
|
||||||
|
onclick="return confirm('Replace the current audit CSV? A backup will be created first.');"
|
||||||
|
>
|
||||||
|
Import CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent sessions</p>
|
||||||
|
<h2>Current totals</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap small-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Buy-ins</th>
|
||||||
|
<th>Cash-outs</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in recent_sessions %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('session_detail', session_date=session.session_date) }}">
|
||||||
|
{{ session.session_date | pretty_date }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ session.entries|length }}</td>
|
||||||
|
<td>{{ session.total_buy_in_cents | money }}</td>
|
||||||
|
<td>{{ session.total_cash_out_cents | money }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ session.status }}">
|
||||||
|
{{ session.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
|
||||||
|
<input type="hidden" name="session_date" value="{{ session.session_date }}">
|
||||||
|
{% if session.status == "open" %}
|
||||||
|
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No sessions yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent raw events</p>
|
||||||
|
<h2>Ledger feed</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed condensed">
|
||||||
|
{% for event in recent_events %}
|
||||||
|
<article class="event-card">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<strong>{{ event.player_name }}</strong>
|
||||||
|
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
|
||||||
|
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text">No recent events.</p>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in new issue
Block a user