Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ EMAIL_PASSWORD = ""
PYTHON_TOGO_API_BASE_URL=""
PYTHON_TOGO_API_KEY=""
PYTHON_TOGO_API_TIMEOUT_SECONDS=
PYTHON_TOGO_EVENT_CODE=""
PYTHON_TOGO_EVENT_CODE=""
ADMIN_API_KEY=""
78 changes: 77 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
75 changes: 72 additions & 3 deletions app/routers/router_2026.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
)
5 changes: 5 additions & 0 deletions app/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 160 additions & 0 deletions app/static/2026/css/pages/admin-login.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading