1 Commits

Author SHA1 Message Date
jeanGaston 5393feefc5 Add web GUI and Docker deployment for managing the Secret Santa draw
- webapp/: FastAPI dashboard to add/remove participants and exclusions,
  view draw history, and trigger the draw (reuses main.run_draw()).
  Optional HTTP basic auth via WEBAPP_USERNAME/WEBAPP_PASSWORD.
- file_io.py: add add/remove_participant, add/remove_exclusion, load_year
  helpers backing the web GUI's CRUD actions.
- Dockerfile + docker-compose.yml: containerize the web GUI, with CSV_PATH
  bind-mounted to a host folder so participant data persists outside the
  container. Verified with a local docker compose build/up smoke test.
- README: document the web GUI, Docker usage, and the exclusions CSV file
  (existing feature that wasn't documented yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:36:02 +02:00
15 changed files with 551 additions and 40 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
.env
.env.local
__pycache__
**/__pycache__
*.pyc
data/
README.md
AGENT.md
+3
View File
@@ -16,3 +16,6 @@ DRAW_PER_PERSON=2
# Web GUI basic-auth (leave unset to disable auth — not recommended outside localhost) # Web GUI basic-auth (leave unset to disable auth — not recommended outside localhost)
WEBAPP_USERNAME=admin WEBAPP_USERNAME=admin
WEBAPP_PASSWORD=change-me WEBAPP_PASSWORD=change-me
# docker-compose only: host directory bind-mounted to /data (CSV_PATH) in the container.
# HOST_CSV_PATH=./data
+1
View File
@@ -162,3 +162,4 @@ cython_debug/
.env .env
.env.local .env.local
/data/
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/
COPY webapp/ webapp/
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "8000"]
+83 -39
View File
@@ -7,10 +7,13 @@ This Python project automates the process of organizing a Secret Santa event. It
- Randomly assign one or two recipients for each participant. - Randomly assign one or two recipients for each participant.
- Ensure participants do not receive the same recipients as the last `n` years. - Ensure participants do not receive the same recipients as the last `n` years.
- Optional giver/receiver exclusion pairs (e.g. couples who shouldn't draw each other).
- Sends personalized emails with draw results to participants. - Sends personalized emails with draw results to participants.
- Stores participant data (names, emails, and draw results) in a CSV file. - Stores participant data (names, emails, and draw results) in a CSV file.
- Modular structure for better code maintenance. - Modular structure for better code maintenance.
- All key parameters are configurable in a separate configuration file (`env.py`). - All key parameters are configurable via environment variables (`.env`).
- Optional web GUI (FastAPI) to manage participants/exclusions and trigger the draw from a browser.
- Dockerfile + docker-compose for running the web GUI as a container.
## Project Structure ## Project Structure
@@ -21,55 +24,60 @@ Random-Christmas-Bot/
│ ├── draw.py # Logic for drawing names │ ├── draw.py # Logic for drawing names
│ ├── emailer.py # Email sending functionality │ ├── emailer.py # Email sending functionality
│ ├── file_io.py # File handling (CSV reading/writing) │ ├── file_io.py # File handling (CSV reading/writing)
│ ├── main.py # Main program logic │ ├── main.py # CLI entry point, exposes run_draw()
│ ├── utils.py # Utility functions (date, time handling) │ ├── utils.py # Utility functions (date, time handling)
│ └── env.py # Configuration settings (SMTP, file paths, etc.) │ └── env.py # Loads configuration from environment variables / .env
── README.md # Project readme ── webapp/
│ ├── app.py # FastAPI app (dashboard, history, draw trigger)
│ ├── templates/ # Jinja2 templates
│ └── static/ # CSS
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example # Template — copy to .env and fill in real values
└── README.md
``` ```
### `src/env.py` ### Configuration (`.env`)
The configuration file contains SMTP settings, file paths, and customizable parameters for the draw. All configuration is read from environment variables, loaded automatically from a
`.env` file at the repo root if present (via `python-dotenv`) — no secrets live in
tracked source files. Copy `.env.example` to `.env` and fill in your real values:
Example `env.py`: ```bash
# Configuration with SMTP settings and CSV path settings
SMTP_SERVER=smtp.example.com
SMTP_PORT=25
SENDER_EMAIL=santa@example.com
CSV_PATH=path/to/your/csv/files # Path to the CSV files
CSV_PREFIX=secret_santa_DB # Prefix for CSV files
HISTORY_YEARS=2 # Number of past years to consider in the draw
DRAW_PER_PERSON=2 # Number of recipients per person
```python # Optional — override the default English email subject/body
# Configuration file with SMTP settings and CSV path settings # EMAIL_SUBJECT=Secret Santa {year} Draw
SMTP_SERVER = "smtp.example.com" # EMAIL_BODY=...
SMTP_PORT = 25
SENDER_EMAIL = "santa@example.com"
CSV_PATH = r"path\to\your\csv\files" # Path to the CSV files
CSV_PREFIX = r"secret_santa_DB" # Prefix for CSV files
HISTORY_YEARS = 2 # Number of past years to consider in the draw
DRAW_PER_PERSON = 2 # Number of recipients per person
# Email content # Optional — protect the web GUI with HTTP basic auth (recommended if exposed beyond localhost)
EMAIL_SUBJECT = "Secret Santa {year} Draw" WEBAPP_USERNAME=admin
EMAIL_BODY = """ WEBAPP_PASSWORD=change-me
Hello {name},
You have been chosen to give gifts to: {draws}.
Feel free to use your imagination and make their Christmas magical!
Merry Christmas!
This email was sent automatically, please do not reply.
"""
``` ```
## Requirements ## Requirements
- Python 3.x - Python 3.x
- SMTP server (relay, no authentication required) - SMTP server (relay, no authentication required)
- `smtplib` for sending emails (Python's built-in library)
- CSV file to store participant data - CSV file to store participant data
- Dependencies in `requirements.txt` (`python-dotenv`; `fastapi`/`uvicorn`/`jinja2`/`python-multipart` only needed for the web GUI)
## Installation ## Installation
1. Clone the repository or download the script files. 1. Clone the repository.
2. Ensure you have Python installed on your system. If not, download and install Python from [here](https://www.python.org/downloads/). 2. Ensure you have Python installed on your system. If not, download and install Python from [here](https://www.python.org/downloads/).
3. Set up the `env.py` file in the `src/` directory, adjusting the SMTP settings, CSV file path, and draw parameters as needed. 3. Install dependencies: `pip install -r requirements.txt`
4. Copy `.env.example` to `.env` and fill in the SMTP settings, CSV file path, and draw parameters as needed.
Example structure of the CSV file with the following columns: Example structure of the CSV file with the following columns:
```csv ```csv
@@ -84,7 +92,13 @@ Charlie,charlie@example.com,David,Alice
4. Name your CSVs using a consistent naming convention `[prefix]_20xx.csv` as the program will retrieve those using the prefix set in `env.py`. 4. Name your CSVs using a consistent naming convention `[prefix]_20xx.csv` as the program will retrieve those using the prefix set in `env.py`.
5. Ensure the CSV file is in the correct location as specified in `env.py`. 5. Ensure the CSV file is in the correct location as specified by `CSV_PATH`.
6. (Optional) Add a `[prefix]_exclusions.csv` file with `giver,receiver` rows to prevent
specific people from being drawn for each other:
```csv
Alice,Bob
```
## Usage ## Usage
@@ -110,20 +124,50 @@ Charlie,charlie@example.com,David,Alice
4. If any errors occur, they will be displayed in the console, and you can retry or debug as needed. 4. If any errors occur, they will be displayed in the console, and you can retry or debug as needed.
## Web GUI
Instead of editing CSVs by hand, you can run a small web dashboard to manage
participants and exclusions, view draw history, and trigger the draw from a browser:
```bash
python -m uvicorn webapp.app:app --reload
```
Then open `http://127.0.0.1:8000`. If `WEBAPP_USERNAME`/`WEBAPP_PASSWORD` are set in
`.env`, the whole app is protected with HTTP basic auth; otherwise it's open to
anyone who can reach it — only run it unauthenticated on localhost or a trusted
network. Triggering the draw from the GUI sends real emails and overwrites the
current year's CSV, exactly like running `main.py`.
## Docker
The web GUI can also run as a container:
```bash
cp .env.example .env # fill in your real values
docker compose up --build -d
```
This builds the image from the `Dockerfile`, starts it on port `8000`, and bind-mounts
`./data` (or `HOST_CSV_PATH` from `.env`) into the container as `/data` — the compose
file forces `CSV_PATH=/data` inside the container regardless of what's in `.env`, so
your CSVs live in that host folder. Stop it with `docker compose down`.
## Customization ## Customization
- **Number of recipients**: Modify `DRAW_PER_PERSON` in `env.py` to choose whether participants receive one or two recipients. - **Number of recipients**: Modify `DRAW_PER_PERSON` in `.env` to choose whether participants receive one or two recipients.
- **Email content**: Customize the email subject and body in `env.py` using placeholders like `{name}` for the participant's name and `{draws}` for their recipients. - **Email content**: Customize `EMAIL_SUBJECT`/`EMAIL_BODY` in `.env` using placeholders like `{name}` for the participant's name and `{draws}` for their recipients.
- **CSV file location**: Adjust the `CSV_PATH` in `env.py` if you prefer a different directory for the participant data. - **CSV file location**: Adjust `CSV_PATH` in `.env` if you prefer a different directory for the participant data.
- **Number of historical years**: Change `HISTORY_YEARS` in `env.py` to set how many previous years of draws should be considered. - **Number of historical years**: Change `HISTORY_YEARS` in `.env` to set how many previous years of draws should be considered.
## File Descriptions ## File Descriptions
- **`draw.py`**: Contains the logic for performing the Secret Santa draw, ensuring no repeat recipients from the last years. - **`draw.py`**: Contains the logic for performing the Secret Santa draw, ensuring no repeat recipients from the last years and honoring exclusions.
- **`emailer.py`**: Handles email sending via the SMTP server. - **`emailer.py`**: Handles email sending via the SMTP server.
- **`file_io.py`**: Responsible for reading and writing the participant data from/to the CSV file. - **`file_io.py`**: Responsible for reading and writing participant/exclusion/history data from/to CSV files.
- **`main.py`**: The main program that ties everything together and coordinates the draw and email sending. - **`main.py`**: CLI entry point; `run_draw()` loads data, performs the draw, sends emails, and saves results — reused by the web GUI.
- **`utils.py`**: Utility functions, such as fetching the current date and time. - **`utils.py`**: Utility functions, such as fetching the current date and time.
- **`webapp/app.py`**: FastAPI app exposing the dashboard, history view, and draw trigger.
## Notes ## Notes
+12
View File
@@ -0,0 +1,12 @@
services:
webgui:
build: .
ports:
- "8000:8000"
env_file:
- .env
environment:
CSV_PATH: /data
volumes:
- ${HOST_CSV_PATH:-./data}:/data
restart: unless-stopped
+4
View File
@@ -15,6 +15,10 @@ CSV_PREFIX = os.environ.get("CSV_PREFIX", "secret_santa_DB") # Prefix for CSV f
HISTORY_YEARS = int(os.environ.get("HISTORY_YEARS", "2")) # Number of past years to consider in the draw HISTORY_YEARS = int(os.environ.get("HISTORY_YEARS", "2")) # Number of past years to consider in the draw
DRAW_PER_PERSON = int(os.environ.get("DRAW_PER_PERSON", "2")) # Number of recipients per person DRAW_PER_PERSON = int(os.environ.get("DRAW_PER_PERSON", "2")) # Number of recipients per person
# Web GUI basic-auth credentials (unset => auth disabled, see webapp/app.py)
WEBAPP_USERNAME = os.environ.get("WEBAPP_USERNAME")
WEBAPP_PASSWORD = os.environ.get("WEBAPP_PASSWORD")
# Email content # Email content
EMAIL_SUBJECT = os.environ.get("EMAIL_SUBJECT", "Secret Santa {year} Draw") EMAIL_SUBJECT = os.environ.get("EMAIL_SUBJECT", "Secret Santa {year} Draw")
EMAIL_BODY = os.environ.get("EMAIL_BODY", """ EMAIL_BODY = os.environ.get("EMAIL_BODY", """
+43 -1
View File
@@ -1,4 +1,5 @@
import csv import csv
import os
from datetime import date from datetime import date
from env import CSV_PREFIX, CSV_PATH from env import CSV_PREFIX, CSV_PATH
@@ -69,7 +70,48 @@ def save_csv(data, year):
:param data: New draw data. :param data: New draw data.
:param year: The current year for naming the file. :param year: The current year for naming the file.
""" """
os.makedirs(CSV_PATH, exist_ok=True)
file_name = f"{CSV_PATH}/{CSV_PREFIX}_{year}.csv" file_name = f"{CSV_PATH}/{CSV_PREFIX}_{year}.csv"
with open(file_name, 'w', newline='', encoding='utf-8') as file: with open(file_name, 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file) writer = csv.writer(file)
writer.writerows(data) writer.writerows(data)
def load_year(year):
"""Load raw CSV rows for a specific year, or [] if the file doesn't exist."""
file_name = f"{CSV_PATH}/{CSV_PREFIX}_{year}.csv"
try:
with open(file_name, "r", encoding='utf-8') as file:
return list(csv.reader(file))
except FileNotFoundError:
return []
def add_participant(name, email):
"""Append a participant (name, email) to the current year's CSV file."""
current_year = date.today().year
os.makedirs(CSV_PATH, exist_ok=True)
file_name = f"{CSV_PATH}/{CSV_PREFIX}_{current_year}.csv"
with open(file_name, 'a', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
writer.writerow([name, email])
def remove_participant(name):
"""Remove a participant by name from the current year's CSV file."""
participants = [p for p in load_participants() if p[0] != name]
save_csv(participants, date.today().year)
def add_exclusion(giver, receiver):
"""Append a (giver, receiver) pair to the exclusions CSV file."""
os.makedirs(CSV_PATH, exist_ok=True)
file_name = f"{CSV_PATH}/{CSV_PREFIX}_exclusions.csv"
with open(file_name, 'a', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
writer.writerow([giver, receiver])
def remove_exclusion(giver, receiver):
"""Remove a (giver, receiver) pair from the exclusions CSV file."""
exclusions = {pair for pair in load_exclusions() if pair != (giver, receiver)}
os.makedirs(CSV_PATH, exist_ok=True)
file_name = f"{CSV_PATH}/{CSV_PREFIX}_exclusions.csv"
with open(file_name, 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
writer.writerows(exclusions)
View File
+114
View File
@@ -0,0 +1,114 @@
import secrets
import sys
from pathlib import Path
from typing import Annotated
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC_DIR))
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from env import CSV_PREFIX, HISTORY_YEARS, WEBAPP_PASSWORD, WEBAPP_USERNAME
from file_io import (
add_exclusion,
add_participant,
load_exclusions,
load_participants,
load_year,
remove_exclusion,
remove_participant,
)
from main import run_draw
from utils import get_last_n_years
app = FastAPI(title="Random Christmas Bot")
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
security = HTTPBasic(auto_error=False)
def require_auth(credentials: Annotated[HTTPBasicCredentials | None, Depends(security)]):
if not WEBAPP_USERNAME or not WEBAPP_PASSWORD:
return # auth disabled — no credentials configured
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Basic"},
)
valid_user = secrets.compare_digest(credentials.username, WEBAPP_USERNAME)
valid_pass = secrets.compare_digest(credentials.password, WEBAPP_PASSWORD)
if not (valid_user and valid_pass):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
@app.get("/")
def dashboard(request: Request, _: None = Depends(require_auth)):
return templates.TemplateResponse(
request,
"index.html",
{
"participants": load_participants(),
"exclusions": sorted(load_exclusions()),
"csv_prefix": CSV_PREFIX,
},
)
@app.post("/participants/add")
def participants_add(name: str = Form(...), email: str = Form(...), _: None = Depends(require_auth)):
add_participant(name.strip(), email.strip())
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/participants/remove")
def participants_remove(name: str = Form(...), _: None = Depends(require_auth)):
remove_participant(name)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/exclusions/add")
def exclusions_add(giver: str = Form(...), receiver: str = Form(...), _: None = Depends(require_auth)):
add_exclusion(giver.strip(), receiver.strip())
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/exclusions/remove")
def exclusions_remove(giver: str = Form(...), receiver: str = Form(...), _: None = Depends(require_auth)):
remove_exclusion(giver, receiver)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/history")
def history(request: Request, _: None = Depends(require_auth)):
years = get_last_n_years(HISTORY_YEARS)
year_data = [(year, load_year(year)) for year in years]
return templates.TemplateResponse(
request,
"history.html",
{"year_data": year_data},
)
@app.post("/draw/run")
def draw_run(request: Request, _: None = Depends(require_auth)):
try:
new_draw = run_draw()
error = None
except Exception as exc: # draw can legitimately fail (not enough participants, etc.)
new_draw = []
error = str(exc)
return templates.TemplateResponse(
request,
"draw_result.html",
{"new_draw": new_draw, "error": error},
)
+115
View File
@@ -0,0 +1,115 @@
:root {
--bg: #fbfaf7;
--fg: #1f2421;
--card-bg: #ffffff;
--border: #e0ddd4;
--accent: #1a6b3c;
--accent-fg: #ffffff;
--danger: #a3242a;
--hint: #6b6558;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a;
--fg: #eae7e0;
--card-bg: #1d2124;
--border: #33383d;
--accent: #2e9c5c;
--accent-fg: #0c130f;
--danger: #e0666c;
--hint: #9aa0a6;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 1.3rem; }
nav a {
color: var(--accent);
text-decoration: none;
margin-right: 1rem;
font-weight: 600;
}
nav a:hover { text-decoration: underline; }
main {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 { margin-top: 0; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
overflow-x: auto;
display: block;
}
th, td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.hint, .count { color: var(--hint); font-size: 0.9rem; }
.error { color: var(--danger); font-weight: 600; }
.inline-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin-top: 0.5rem;
}
input[type=text], input[type=email] {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
}
button {
padding: 0.4rem 0.9rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--accent-fg);
cursor: pointer;
font-weight: 600;
}
button:hover { opacity: 0.9; }
button.danger { background: var(--danger); }
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Random Christmas Bot{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>🎅 Random Christmas Bot</h1>
<nav>
<a href="/">Dashboard</a>
<a href="/history">History</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Draw result - Random Christmas Bot{% endblock %}
{% block content %}
<section class="card">
<h2>Draw result</h2>
{% if error %}
<p class="error">The draw failed: {{ error }}</p>
{% else %}
<p>Draw complete — emails sent to {{ new_draw|length }} participant(s).</p>
<table>
<thead><tr><th>Giver</th><th>Email</th><th>Recipients</th></tr></thead>
<tbody>
{% for row in new_draw %}
<tr>
<td>{{ row[0] }}</td>
<td>{{ row[1] }}</td>
<td>{{ row[2:]|join(", ") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p><a href="/">&larr; Back to dashboard</a></p>
</section>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}History - Random Christmas Bot{% endblock %}
{% block content %}
{% for year, rows in year_data %}
<section class="card">
<h2>{{ year }}</h2>
{% if rows %}
<table>
<thead><tr><th>Giver</th><th>Email</th><th>Recipients</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row[0] }}</td>
<td>{{ row[1] }}</td>
<td>{{ row[2:]|join(", ") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="hint"><em>No data for this year.</em></p>
{% endif %}
</section>
{% endfor %}
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Dashboard - Random Christmas Bot{% endblock %}
{% block content %}
<section class="card">
<h2>Participants <span class="count">({{ participants|length }})</span></h2>
<p class="hint">Data file: {{ csv_prefix }}_&lt;year&gt;.csv</p>
<table>
<thead><tr><th>Name</th><th>Email</th><th>Last recipients</th><th></th></tr></thead>
<tbody>
{% for p in participants %}
<tr>
<td>{{ p[0] }}</td>
<td>{{ p[1] }}</td>
<td>{{ p[2:]|join(", ") }}</td>
<td>
<form action="/participants/remove" method="post" class="inline-form">
<input type="hidden" name="name" value="{{ p[0] }}">
<button type="submit" class="danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4"><em>No participants yet for this year.</em></td></tr>
{% endfor %}
</tbody>
</table>
<form action="/participants/add" method="post" class="inline-form">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<button type="submit">Add participant</button>
</form>
</section>
<section class="card">
<h2>Exclusions</h2>
<p class="hint">A giver in an exclusion pair will never be assigned that receiver.</p>
<table>
<thead><tr><th>Giver</th><th>Receiver</th><th></th></tr></thead>
<tbody>
{% for giver, receiver in exclusions %}
<tr>
<td>{{ giver }}</td>
<td>{{ receiver }}</td>
<td>
<form action="/exclusions/remove" method="post" class="inline-form">
<input type="hidden" name="giver" value="{{ giver }}">
<input type="hidden" name="receiver" value="{{ receiver }}">
<button type="submit" class="danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3"><em>No exclusions configured.</em></td></tr>
{% endfor %}
</tbody>
</table>
<form action="/exclusions/add" method="post" class="inline-form">
<input type="text" name="giver" placeholder="Giver" required>
<input type="text" name="receiver" placeholder="Receiver" required>
<button type="submit">Add exclusion</button>
</form>
</section>
<section class="card">
<h2>Run the draw</h2>
<p class="hint">
This performs the draw, <strong>emails every participant their result</strong>,
and overwrites this year's CSV. It cannot be undone from here.
</p>
<form action="/draw/run" method="post" onsubmit="return confirm('Run the draw and email all participants now?');">
<button type="submit" class="danger">Run draw &amp; send emails</button>
</form>
</section>
{% endblock %}