diff --git a/.env.example b/.env.example index 296bb79..95d5bb9 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,5 @@ EMAIL_PASSWORD = "" PYTHON_TOGO_API_BASE_URL="" PYTHON_TOGO_API_KEY="" PYTHON_TOGO_API_TIMEOUT_SECONDS= -PYTHON_TOGO_EVENT_CODE="" \ No newline at end of file +PYTHON_TOGO_EVENT_CODE="" +ADMIN_API_KEY="" \ No newline at end of file diff --git a/app/main.py b/app/main.py index 882b730..c34a4fc 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ typing._ClassVar = typing.ClassVar from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware from app.routers.router_2025 import router_2025 from app.routers.router_2026 import router as router_2026 from fastapi import FastAPI, Request, HTTPException, status @@ -45,6 +46,81 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _build_api_proxy_url(path: str) -> str: + base = settings.python_togo_api_base_url.rstrip("/") + if base.endswith("/api/v2"): + return f"{base}/{path}" + return f"{base}/api/v2/{path}" + + +@app.api_route("/api/v2/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def _proxy_api_v2(request: Request, path: str): + backend_url = _build_api_proxy_url(path) + query_string = request.url.query + target = f"{backend_url}?{query_string}" if query_string else backend_url + + headers = dict(request.headers) + headers.pop("host", None) + headers["X-API-Key"] = settings.python_togo_api_key + body = await request.body() + + async with httpx.AsyncClient( + timeout=settings.python_togo_api_timeout_seconds + ) as client: + response = await client.request( + method=request.method, + url=target, + headers=headers, + content=body if body else None, + follow_redirects=False, + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + +@app.api_route("/api/feedback", methods=["POST"]) +@app.api_route("/api/feedback/", methods=["POST"]) +async def _proxy_feedback(request: Request): + base = settings.python_togo_api_base_url.rstrip("/").removesuffix("/api/v2") + backend_url = f"{base}/api/feedback/" + query_string = request.url.query + target = f"{backend_url}?{query_string}" if query_string else backend_url + + headers = dict(request.headers) + headers.pop("host", None) + headers["X-API-Key"] = settings.python_togo_api_key + body = await request.body() + + async with httpx.AsyncClient( + timeout=settings.python_togo_api_timeout_seconds + ) as client: + response = await client.post( + target, + headers=headers, + content=body if body else None, + follow_redirects=False, + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + BASE_DIR = Path(__file__).resolve().parent template = Jinja2Templates(directory=str(BASE_DIR / "templates")) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") @@ -203,4 +279,4 @@ async def send_contact_message(payload: ContactFormPayload): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8800) + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/app/routers/router_2026.py b/app/routers/router_2026.py index 8e761a5..d70cfa4 100644 --- a/app/routers/router_2026.py +++ b/app/routers/router_2026.py @@ -33,6 +33,12 @@ grant_results_date = date(2026, 8, 15) +feedback_open_at = datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc) + + +def _feedback_is_open() -> bool: + return datetime.now(timezone.utc) >= feedback_open_at + PartnerType = Literal[ "partnership", @@ -1668,9 +1674,19 @@ def volunteers(request: Request): @router.get("/feedback") -def feedback(request: Request): - # TODO - create a feedback page with a form to submit feedback - pass +async def feedback(request: Request): + feedback_is_open = _feedback_is_open() + return await _render_page_with_event( + request=request, + name="2026_feedback.html", + active_page="feedback", + page_css="feedback.css", + page_title="PyCon Togo 2026 - Feedback", + extra_context={ + "feedback_is_open": feedback_is_open, + "feedback_open_at": feedback_open_at, + }, + ) @router.get("/shop") @@ -1847,3 +1863,56 @@ def _road_to_pycon(request: Request): @router.get("/streamyard") def _streamyard(request: Request): return RedirectResponse(url="https://streamyard.com/phrvxehbva", status_code=302) + + +@router.get("/api/v2/registrations") +async def api_v2_registrations(request: Request): + event_id = request.query_params.get("event_id") + url = _build_api_url("/registrations") + if event_id: + url = f"{url}?event_id={event_id}" + + headers = { + "Accept": "application/json", + } + + authorization = request.headers.get("authorization") + if authorization: + headers["Authorization"] = authorization + + try: + async with httpx.AsyncClient(timeout=settings.python_togo_api_timeout_seconds) as client: + response = await client.get(url, headers=headers) + except httpx.RequestError: + raise HTTPException(status_code=500, detail="Error retrieving registrations") + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="Not authenticated") + if response.status_code == 403: + raise HTTPException(status_code=403, detail="Admin access required") + if response.status_code >= 400: + raise HTTPException(status_code=500, detail="Error retrieving registrations") + + return JSONResponse(status_code=response.status_code, content=response.json()) + + +@router.get("/admin/login") +async def admin_login(request: Request): + return await _render_page_with_event( + request=request, + name="2026_admin_login.html", + active_page="support", + page_css="admin-registrations.css", + page_title="PyCon Togo 2026 - Admin - Connexion", + ) + + +@router.get("/admin/registrations") +async def admin_registrations(request: Request): + return await _render_page_with_event( + request=request, + name="2026_admin_registrations.html", + active_page="support", + page_css="admin-registrations.css", + page_title="PyCon Togo 2026 - Admin - Inscriptions", + ) diff --git a/app/schemas/settings.py b/app/schemas/settings.py index 4165403..90b588d 100644 --- a/app/schemas/settings.py +++ b/app/schemas/settings.py @@ -46,6 +46,11 @@ class Settings(BaseModel): title="Python Togo Event Code", description="Event code used in sponsorship inquiry endpoint path", ) + admin_api_key: str = Field( + "", + title="Admin API Key", + description="Secret key used to authenticate admin requests to the Python Togo API", + ) redis_url: str = Field( "redis://localhost:6379/0", title="Redis URL", diff --git a/app/static/2026/css/pages/admin-login.css b/app/static/2026/css/pages/admin-login.css new file mode 100644 index 0000000..a6511cb --- /dev/null +++ b/app/static/2026/css/pages/admin-login.css @@ -0,0 +1,160 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Login + ═══════════════════════════════════════════════ */ + +.admin-login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(135deg, var(--green-pale) 0%, var(--cream) 50%, var(--gold-pale) 100%); +} + +.admin-login-card { + width: 100%; + max-width: 420px; + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: 40px 36px; + border: 1px solid var(--border); +} + +.admin-login-header { + text-align: center; + margin-bottom: 28px; +} + +.admin-login-logo { + width: 56px; + height: 56px; + border-radius: var(--radius); + background: var(--green); + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + color: var(--white); + font-weight: 800; + font-size: 1.25rem; + letter-spacing: -0.02em; +} + +.admin-login-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + margin: 0 0 6px; + letter-spacing: -0.01em; +} + +.admin-login-subtitle { + font-size: 0.95rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +.admin-login-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.admin-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-field label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-mid); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.admin-field input { + width: 100%; + padding: 12px 14px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + background: var(--off-white); + color: var(--text); + font-size: 1rem; + font-family: var(--font-body); + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + outline: none; +} + +.admin-field input:hover { + border-color: var(--border-mid); + background: var(--white); +} + +.admin-field input:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px var(--green-dim); + background: var(--white); +} + +.admin-login-actions { + margin-top: 6px; +} + +.admin-login-submit { + width: 100%; + padding: 13px 16px; + border-radius: var(--radius-sm); + background: var(--green); + color: var(--white); + font-weight: 600; + font-size: 1rem; + letter-spacing: 0.01em; + border: none; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.admin-login-submit:hover { + background: var(--green-mid); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.admin-login-submit:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.admin-login-submit:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.admin-login-error { + margin-top: 4px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--red-pale); + color: var(--red); + font-size: 0.9rem; + line-height: 1.4; + border: 1px solid rgba(206, 17, 38, 0.15); +} + +@media (max-width: 480px) { + .admin-login-card { + padding: 28px 20px; + border-radius: var(--radius); + } + + .admin-login-title { + font-size: 1.25rem; + } +} diff --git a/app/static/2026/css/pages/admin-registrations.css b/app/static/2026/css/pages/admin-registrations.css new file mode 100644 index 0000000..9641df4 --- /dev/null +++ b/app/static/2026/css/pages/admin-registrations.css @@ -0,0 +1,192 @@ +/* Admin registrations page specific styling */ +.admin-header { + margin-bottom: 28px; +} + +.admin-title { + font-size: clamp(1.6rem, 2.4vw, 2.2rem); + font-weight: 700; + margin: 0 0 8px; +} + +.admin-subtitle { + color: #555; + margin: 0; + font-size: 1rem; +} + +.admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.admin-filter { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-filter label { + font-weight: 600; + font-size: 0.95rem; + color: #333; +} + +.admin-filter select { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid #d6dbe6; + background: #fff; + font-size: 0.95rem; + min-width: 220px; +} + +.admin-meta { + display: flex; + align-items: center; +} + +.admin-count { + font-weight: 600; + color: #333; + background: #eef2ff; + padding: 8px 14px; + border-radius: 999px; + font-size: 0.95rem; +} + +.admin-table-wrapper { + overflow-x: auto; + background: #fff; + border-radius: 18px; + box-shadow: 0 10px 30px rgba(14, 22, 40, 0.08); + padding: 4px; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.admin-table thead { + background: #f7f9ff; +} + +.admin-table th { + text-align: left; + padding: 14px 16px; + font-weight: 700; + color: #334155; + white-space: nowrap; + border-bottom: 1px solid #e5e9f2; +} + +.admin-table td { + padding: 14px 16px; + border-bottom: 1px solid #f1f5f9; + color: #1f2937; + vertical-align: middle; +} + +.admin-table tbody tr:last-child td { + border-bottom: none; +} + +.admin-table tbody tr:hover { + background: #f8fafc; +} + +.admin-cell-primary { + font-weight: 600; + color: #0f172a; +} + +.admin-cell-secondary { + font-size: 0.85rem; + color: #6b7280; + margin-top: 2px; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.badge-status { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-student { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-standard { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-premium { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-ticket-dinner { + background: #eef2ff; + color: #1e3a8a; +} + +.badge-payment { + background: #fff7ed; + color: #7c2d12; +} + +.admin-loading, +.admin-error, +.admin-empty { + text-align: center; + padding: 28px 16px; + color: #6b7280; +} + +.admin-error { + color: #b91c1c; + background: #fef2f2; +} + +@media (max-width: 768px) { + .admin-toolbar { + flex-direction: column; + align-items: stretch; + } + + .admin-filter { + flex-direction: column; + align-items: stretch; + } + + .admin-filter select { + width: 100%; + } + + .admin-meta { + justify-content: flex-start; + } +} diff --git a/app/static/2026/css/pages/feedback.css b/app/static/2026/css/pages/feedback.css new file mode 100644 index 0000000..292b487 --- /dev/null +++ b/app/static/2026/css/pages/feedback.css @@ -0,0 +1,145 @@ +.feedback-card { + max-width: 820px; + margin: 0 auto; + background: #fff; + border-radius: 16px; + padding: 32px; + box-shadow: 0 4px 24px rgba(0,0,0,0.06); +} +.feedback-header { margin-bottom: 24px; } +.feedback-title { + color: #056d1e; + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 8px; +} +.feedback-subtitle { + color: #444; + font-size: 1rem; + margin: 0; + line-height: 1.5; +} +.feedback-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.feedback-full { grid-column: 1 / -1; } +.feedback-field { display: flex; flex-direction: column; gap: 6px; } +.feedback-label { + font-weight: 600; + color: #2D5016; + font-size: 0.9rem; + margin-bottom: 4px; +} +.feedback-form input[type="text"], +.feedback-form input[type="number"], +.feedback-form select, +.feedback-form textarea { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgba(0,0,0,0.08); + background: #ffffff; + color: #111; + font-size: 0.95rem; + font-family: inherit; +} +.feedback-form textarea { min-height: 100px; resize: vertical; } +.feedback-radio-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.feedback-radio-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(5,109,30,0.04); + border: 1px solid rgba(0,0,0,0.05); + color: #056d1e; + font-size: 0.9rem; + cursor: pointer; +} +.feedback-radio-item input { margin-right: 4px; } +.feedback-checkbox-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.feedback-checkbox-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(5,109,30,0.04); + border: 1px solid rgba(0,0,0,0.05); + color: #056d1e; + font-size: 0.9rem; + cursor: pointer; +} +.feedback-checkbox-item input { margin-right: 4px; } +.feedback-actions { + display: flex; + gap: 12px; + align-items: center; + margin-top: 20px; + grid-column: 1 / -1; +} +.feedback-status { + grid-column: 1 / -1; + margin-top: 16px; + padding: 12px 16px; + border-radius: 10px; + display: none; + align-items: center; + gap: 10px; + font-weight: 600; +} +.feedback-status.success { + background: rgba(5,109,30,0.06); + border: 2px solid rgba(255,212,59,0.18); + color: #2D5016; +} +.feedback-status.error { + background: rgba(255,0,0,0.04); + border: 2px solid rgba(255,0,0,0.12); + color: #800; +} +.feedback-status-msg { flex: 1; } +.feedback-coming-soon-text { + color: #444; + font-size: 1.1rem; + line-height: 1.6; + margin: 0; + text-align: center; +} +.feedback-coming-soon-date { + color: #2D5016; + font-weight: 600; + font-size: 0.95rem; + margin: 16px 0 0; + text-align: center; +} +.feedback-form-wrapper.feedback-form-locked { + position: relative; + pointer-events: none; +} +.feedback-form-wrapper.feedback-form-locked .feedback-form * { + pointer-events: none; +} +.feedback-form-wrapper.feedback-form-locked .feedback-form .feedback-status { + pointer-events: auto; +} +.feedback-closed-banner { + margin-bottom: 20px; + padding: 16px 20px; + border-radius: 10px; + border: 2px solid rgba(255, 0, 0, 0.12); + background: rgba(255, 0, 0, 0.04); + color: #800; +} +.feedback-closed-banner strong { + font-weight: 700; + display: block; + margin-bottom: 4px; +} +@media (max-width: 720px) { + .feedback-card { padding: 20px; } + .feedback-grid { grid-template-columns: 1fr; } +} diff --git a/app/static/2026/js/admin-auth.js b/app/static/2026/js/admin-auth.js new file mode 100644 index 0000000..5630f75 --- /dev/null +++ b/app/static/2026/js/admin-auth.js @@ -0,0 +1,203 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Auth + ═══════════════════════════════════════════════ */ + +const AdminAuth = (() => { + const ACCESS_TOKEN_KEY = "admin_access_token"; + const REFRESH_TOKEN_KEY = "admin_refresh_token"; + const REFRESH_MARGIN_MS = 60 * 1000; + + function getAccessToken() { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function getRefreshToken() { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function isAuthenticated() { + return Boolean(getAccessToken()); + } + + function clearTokens() { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + // ignore storage errors + } + } + + function setTokens({ access_token, refresh_token }) { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, access_token || ""); + localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token || ""); + } catch { + // ignore storage errors + } + } + + async function refreshAccessToken() { + const refresh_token = getRefreshToken(); + if (!refresh_token) { + clearTokens(); + return null; + } + + try { + const res = await fetch("/api/v2/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${refresh_token}`, + }, + body: JSON.stringify({ refresh_token }), + }); + + if (!res.ok) { + clearTokens(); + return null; + } + + const data = await res.json(); + if (data?.access_token) { + setTokens({ + access_token: data.access_token, + refresh_token: data.refresh_token || refresh_token, + }); + return data.access_token; + } + + clearTokens(); + return null; + } catch { + clearTokens(); + return null; + } + } + + async function getValidAccessToken() { + const token = getAccessToken(); + if (!token) return null; + + const decoded = parseJwt(token); + if (!decoded || !decoded.exp) return await refreshAccessToken(); + + const expiresInMs = decoded.exp * 1000 - Date.now(); + if (expiresInMs <= REFRESH_MARGIN_MS) { + return await refreshAccessToken(); + } + + return token; + } + + function parseJwt(token) { + if (!token || typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const json = decodeURIComponent( + atob(payload) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + return JSON.parse(json); + } catch { + return null; + } + } + + async function login({ email, password }) { + const res = await fetch("/api/v2/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + if (!res.ok) { + const message = data?.detail || data?.message || "Login failed"; + throw new Error(message); + } + + setTokens({ + access_token: data.access_token || "", + refresh_token: data.refresh_token || "", + }); + + return data; + } + + async function logout() { + const refresh_token = getRefreshToken(); + const access_token = getAccessToken(); + clearTokens(); + + if (refresh_token && access_token) { + try { + await fetch("/api/v2/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${access_token}`, + }, + body: JSON.stringify({ refresh_token }), + }); + } catch { + // ignore logout errors + } + } + } + + async function authedFetch(url, options = {}) { + const accessToken = await getValidAccessToken(); + if (!accessToken) { + clearTokens(); + window.location.href = "/admin/login"; + return Promise.reject(new Error("Unauthorized")); + } + + const headers = new Headers(options.headers || {}); + headers.set("Authorization", `Bearer ${accessToken}`); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + + const res = await fetch(url, { ...options, headers }); + + if (res.status === 401) { + const refreshed = await refreshAccessToken(); + if (!refreshed) { + clearTokens(); + window.location.href = "/admin/login"; + return Promise.reject(new Error("Unauthorized")); + } + + headers.set("Authorization", `Bearer ${refreshed}`); + return fetch(url, { ...options, headers }); + } + + return res; + } + + return { + getAccessToken, + getRefreshToken, + isAuthenticated, + clearTokens, + setTokens, + login, + logout, + authedFetch, + }; +})(); diff --git a/app/static/2026/js/admin-login.js b/app/static/2026/js/admin-login.js new file mode 100644 index 0000000..d937ce1 --- /dev/null +++ b/app/static/2026/js/admin-login.js @@ -0,0 +1,50 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Login Page + ═══════════════════════════════════════════════ */ + +(function () { + const form = document.getElementById("adminLoginForm"); + const emailInput = document.getElementById("adminEmail"); + const passwordInput = document.getElementById("adminPassword"); + const errorBox = document.getElementById("adminLoginError"); + + if (!form) return; + + function showError(message) { + if (!errorBox) return; + errorBox.textContent = message; + errorBox.style.display = "block"; + } + + function clearError() { + if (!errorBox) return; + errorBox.textContent = ""; + errorBox.style.display = "none"; + } + + async function onSubmit(e) { + e.preventDefault(); + clearError(); + + const email = (emailInput?.value || "").trim(); + const password = passwordInput?.value || ""; + + if (!email || !password) { + showError("Email and password are required."); + return; + } + + const submitBtn = form.querySelector(".admin-login-submit"); + if (submitBtn) submitBtn.disabled = true; + + try { + await AdminAuth.login({ email, password }); + window.location.href = "/admin/registrations"; + } catch (err) { + showError(err.message || "Login failed."); + if (submitBtn) submitBtn.disabled = false; + } + } + + form.addEventListener("submit", onSubmit); +})(); diff --git a/app/static/2026/js/admin-registrations.js b/app/static/2026/js/admin-registrations.js new file mode 100644 index 0000000..13e314c --- /dev/null +++ b/app/static/2026/js/admin-registrations.js @@ -0,0 +1,208 @@ +/* ═══════════════════════════════════════════════ + PyCon Togo 2026 - Admin Registrations + ═══════════════════════════════════════════════ */ + +(function () { + const API_URL = "/api/v2/registrations"; + let allRegistrations = []; + let currentFilter = ""; + + function t(key, fallback) { + if (typeof translations !== "undefined" && translations[currentLang || "en"]) { + const keys = key.split("."); + let value = translations[currentLang || "en"]; + for (const k of keys) { + value = value && value[k]; + if (!value) return fallback; + } + return value; + } + return fallback; + } + + async function loadRegistrations() { + const loadingRow = document.getElementById("registrationsLoading"); + const errorRow = document.getElementById("registrationsError"); + const emptyRow = document.getElementById("registrationsEmpty"); + const errorText = document.getElementById("registrationsErrorText"); + + if (loadingRow) loadingRow.style.display = ""; + if (errorRow) errorRow.style.display = "none"; + if (emptyRow) emptyRow.style.display = "none"; + + try { + const res = await AdminAuth.authedFetch(API_URL, { + headers: { "Accept": "application/json" }, + }); + + if (res.status === 403) { + throw new Error(t("errors.adminRequired", "Admin access required")); + } + if (res.status === 500) { + throw new Error(t("errors.retrieving", "Error retrieving registrations")); + } + if (!res.ok) { + throw new Error(t("errors.unexpected", "Unexpected error")); + } + + allRegistrations = await res.json(); + renderRegistrations(allRegistrations); + } catch (err) { + console.error("Failed to load registrations:", err); + if (loadingRow) loadingRow.style.display = "none"; + if (errorRow) { + errorRow.style.display = ""; + errorText.textContent = err.message || t("errors.loadFailed", "Failed to load registrations."); + } + } + } + + function formatDate(iso) { + if (!iso) return ""; + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(currentLang === "fr" ? "fr-FR" : "en-US", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } + } + + function renderRegistrations(list) { + const tbody = document.getElementById("registrationsBody"); + const loadingRow = document.getElementById("registrationsLoading"); + const errorRow = document.getElementById("registrationsError"); + const emptyRow = document.getElementById("registrationsEmpty"); + const countEl = document.getElementById("registrationsCount"); + + if (!tbody) return; + + if (loadingRow) loadingRow.style.display = "none"; + if (errorRow) errorRow.style.display = "none"; + + const filtered = currentFilter + ? list.filter((r) => (r.ticket_type || "") === currentFilter) + : list; + + if (countEl) { + const total = filtered.length; + const label = + total === 1 + ? t("admin.registrationSingular", "1 registration") + : t("admin.registrationPlural", "{count} registrations"); + countEl.textContent = label.replace("{count}", String(total)); + } + + tbody.innerHTML = ""; + + if (filtered.length === 0) { + const tr = document.createElement("tr"); + tr.innerHTML = + `${t("admin.noRegistrations", "No registrations found.")}`; + tbody.appendChild(tr); + return; + } + + filtered.forEach((r) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + +
${escapeHtml(r.full_name || "")}
+
${escapeHtml(r.email || "")}
+ + ${escapeHtml(r.attendance_status || "")} + ${escapeHtml(ticketTypeLabel(r.ticket_type))} + ${escapeHtml(String(r.ticket_quantity ?? ""))} + ${escapeHtml(r.payment_status || "")} + ${escapeHtml(r.payment_reference || "")} + ${escapeHtml(formatDate(r.created_at))} + `; + tbody.appendChild(tr); + }); + } + + function escapeHtml(str) { + if (str == null) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function ticketTypeClass(type) { + const normalized = (type || "").toLowerCase(); + if (normalized.includes("student")) return "badge-ticket-student"; + if (normalized.includes("standard")) return "badge-ticket-standard"; + if (normalized.includes("premium")) return "badge-ticket-premium"; + if (normalized.includes("dinner")) return "badge-ticket-dinner"; + return "badge-ticket"; + } + + function ticketTypeLabel(type) { + const normalized = (type || "").toLowerCase(); + if (normalized.includes("student")) return t("admin.ticketStudent", "Student"); + if (normalized.includes("standard")) return t("admin.ticketStandard", "Standard"); + if (normalized.includes("premium")) return t("admin.ticketPremium", "Premium"); + if (normalized.includes("dinner")) return t("admin.ticketDinner", "Dinner"); + return type; + } + + function populateTicketTypeFilter(list) { + const select = document.getElementById("ticketTypeFilter"); + if (!select) return; + + const types = Array.from( + new Set(list.map((r) => r.ticket_type).filter(Boolean)) + ).sort(); + + const fallbackTypes = ["dinner", "premium", "standard", "student"]; + const orderedTypes = [ + ...types, + ...fallbackTypes.filter((t) => !types.includes(t)), + ]; + + orderedTypes.forEach((type) => { + const opt = document.createElement("option"); + opt.value = type; + opt.textContent = ticketTypeLabel(type); + select.appendChild(opt); + }); + + select.addEventListener("change", () => { + currentFilter = select.value; + renderRegistrations(allRegistrations); + }); + } + + function init() { + if (!AdminAuth.isAuthenticated()) { + window.location.href = "/admin/login"; + return; + } + + loadRegistrations().then(() => { + populateTicketTypeFilter(allRegistrations); + }); + + const logoutBtn = document.getElementById("adminLogoutBtn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", async () => { + await AdminAuth.logout(); + window.location.href = "/admin/login"; + }); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/app/static/2026/js/feedback.js b/app/static/2026/js/feedback.js new file mode 100644 index 0000000..00b63d6 --- /dev/null +++ b/app/static/2026/js/feedback.js @@ -0,0 +1,148 @@ +(function() { + "use strict"; + + var form = document.getElementById("feedbackForm"); + if (!form) return; + + var apiUrl = "https://api.pycontg.pytogo.org/api/feedback/"; + var submitBtn = document.getElementById("submitBtn"); + var resetBtn = document.getElementById("resetBtn"); + var statusEl = document.getElementById("formStatus"); + var statusMsg = statusEl.querySelector(".feedback-status-msg"); + var feedbackIsOpen = form.dataset.feedbackOpen === "true"; + + var i18n = { + fr: { + required: "Veuillez remplir les champs requis.", + days_required: "Veuillez sélectionner au moins un jour.", + not_open: "Le formulaire n'est pas encore ouvert.", + sending: "Envoi…", + success: "Merci ! Votre retour a été envoyé.", + error: "Une erreur est survenue. Veuillez réessayer plus tard.", + network: "Erreur réseau. Veuillez vérifier votre connexion." + }, + en: { + required: "Please fill in the required fields.", + days_required: "Please select at least one day.", + not_open: "The feedback form is not yet open.", + sending: "Sending…", + success: "Thank you! Your feedback has been sent.", + error: "An error occurred. Please try again later.", + network: "Network error. Please check your connection." + } + }; + + function getLang() { + var docLang = (document.documentElement.lang || "fr").toLowerCase(); + return docLang.startsWith("en") ? "en" : (docLang.startsWith("fr") ? "fr" : "fr"); + } + + function showStatus(success, text) { + statusEl.className = "feedback-status " + (success ? "success" : "error"); + statusMsg.textContent = text; + statusEl.style.display = "flex"; + } + + function hideStatus() { + statusEl.style.display = "none"; + statusMsg.textContent = ""; + } + + function setDaysError(show) { + var daysError = document.querySelector(".feedback-checkbox-error"); + if (!daysError) return; + daysError.style.display = show ? "block" : "none"; + } + + function daysSelected(fd) { + return fd.getAll("days").length > 0; + } + + var dayCheckboxes = document.querySelectorAll('input[type="checkbox"][name="days"]'); + dayCheckboxes.forEach(function(cb) { + cb.addEventListener("change", function() { + if (daysSelected(new FormData(form))) setDaysError(false); + }); + }); + + if (resetBtn) { + resetBtn.addEventListener("click", function() { + form.reset(); + setDaysError(false); + hideStatus(); + }); + } + + form.addEventListener("submit", async function(e) { + e.preventDefault(); + hideStatus(); + + var lang = getLang(); + var t = i18n[lang] || i18n.en; + + if (!feedbackIsOpen) { + showStatus(false, t.not_open); + return; + } + + var fd = new FormData(form); + + var payload = { + sex: fd.get("sex") || null, + age: fd.get("age") || null, + profession: (fd.get("profession") || "").trim() || null, + country: (fd.get("country") || "").trim() || null, + python_level: fd.get("python_level") || null, + days: fd.getAll("days"), + heard: fd.get("heard") || null, + rating: fd.get("rating") ? Number(fd.get("rating")) : null, + overall: (fd.get("overall") || "").trim() || null, + favorite: (fd.get("favorite") || "").trim() || null, + improvements: (fd.get("improvements") || "").trim() || null, + comments: (fd.get("comments") || "").trim() || null + }; + + if (!payload.days || payload.days.length === 0) { + setDaysError(true); + showStatus(false, t.days_required || t.required); + return; + } else { + setDaysError(false); + } + + if (!payload.sex && !payload.age && !payload.profession && !payload.heard && !payload.overall && !payload.favorite) { + showStatus(false, t.required); + return; + } + + submitBtn.disabled = true; + var originalText = submitBtn.textContent; + submitBtn.textContent = t.sending; + + try { + var res = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + showStatus(true, t.success); + form.reset(); + setDaysError(false); + } else { + var msg = t.error; + try { + var data = await res.json(); + if (data && data.message) msg = data.message + " - " + msg; + } catch (err) {} + showStatus(false, msg); + } + } catch (err) { + showStatus(false, t.network); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } + }); +})(); diff --git a/app/templates/2026/2026_admin_login.html b/app/templates/2026/2026_admin_login.html new file mode 100644 index 0000000..2245ffa --- /dev/null +++ b/app/templates/2026/2026_admin_login.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +
+
+ + + + +{% endblock %} diff --git a/app/templates/2026/2026_admin_registrations.html b/app/templates/2026/2026_admin_registrations.html new file mode 100644 index 0000000..4134a03 --- /dev/null +++ b/app/templates/2026/2026_admin_registrations.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Inscriptions

+

Liste des participants inscrits

+
+ +
+
+ + +
+
+
+ 0 inscriptions +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ParticipantAccèsType de billetQuantitéStatut paiementRéf. paiementCréé le
Chargement des inscriptions...
+
+
+
+
+ + + +{% endblock %} diff --git a/app/templates/2026/2026_feedback.html b/app/templates/2026/2026_feedback.html new file mode 100644 index 0000000..fed688c --- /dev/null +++ b/app/templates/2026/2026_feedback.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ +
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/2026/base.html b/app/templates/2026/base.html index 1e1d11e..a48b196 100644 --- a/app/templates/2026/base.html +++ b/app/templates/2026/base.html @@ -42,7 +42,7 @@ Home @@ -155,6 +157,9 @@ data-i18n-fr="Visiter Togo">Visit Togo Contact + Feedback
@@ -245,6 +250,7 @@
Navigation
Sponsors Team Job Board + Feedback Contact