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>
This commit is contained in:
2026-07-20 14:36:02 +02:00
parent 4c5a926227
commit 5393feefc5
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)
WEBAPP_USERNAME=admin
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.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.
- 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.
- Stores participant data (names, emails, and draw results) in a CSV file.
- 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
@@ -21,55 +24,60 @@ Random-Christmas-Bot/
│ ├── draw.py # Logic for drawing names
│ ├── emailer.py # Email sending functionality
│ ├── 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)
│ └── 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
# Configuration file with SMTP settings and CSV path settings
SMTP_SERVER = "smtp.example.com"
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
# Optional — override the default English email subject/body
# EMAIL_SUBJECT=Secret Santa {year} Draw
# EMAIL_BODY=...
# Email content
EMAIL_SUBJECT = "Secret Santa {year} Draw"
EMAIL_BODY = """
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.
"""
# Optional — protect the web GUI with HTTP basic auth (recommended if exposed beyond localhost)
WEBAPP_USERNAME=admin
WEBAPP_PASSWORD=change-me
```
## Requirements
- Python 3.x
- SMTP server (relay, no authentication required)
- `smtplib` for sending emails (Python's built-in library)
- CSV file to store participant data
- Dependencies in `requirements.txt` (`python-dotenv`; `fastapi`/`uvicorn`/`jinja2`/`python-multipart` only needed for the web GUI)
## 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/).
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:
```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`.
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
@@ -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.
## 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
- **Number of recipients**: Modify `DRAW_PER_PERSON` in `env.py` 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.
- **CSV file location**: Adjust the `CSV_PATH` in `env.py` 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 recipients**: Modify `DRAW_PER_PERSON` in `.env` to choose whether participants receive one or two 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 `CSV_PATH` in `.env` if you prefer a different directory for the participant data.
- **Number of historical years**: Change `HISTORY_YEARS` in `.env` to set how many previous years of draws should be considered.
## 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.
- **`file_io.py`**: Responsible for reading and writing the participant data from/to the CSV file.
- **`main.py`**: The main program that ties everything together and coordinates the draw and email sending.
- **`file_io.py`**: Responsible for reading and writing participant/exclusion/history data from/to CSV files.
- **`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.
- **`webapp/app.py`**: FastAPI app exposing the dashboard, history view, and draw trigger.
## 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
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_SUBJECT = os.environ.get("EMAIL_SUBJECT", "Secret Santa {year} Draw")
EMAIL_BODY = os.environ.get("EMAIL_BODY", """
+42
View File
@@ -1,4 +1,5 @@
import csv
import os
from datetime import date
from env import CSV_PREFIX, CSV_PATH
@@ -69,7 +70,48 @@ def save_csv(data, year):
:param data: New draw data.
: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"
with open(file_name, 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
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 %}