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:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
__pycache__
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
data/
|
||||
README.md
|
||||
AGENT.md
|
||||
@@ -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
|
||||
|
||||
@@ -162,3 +162,4 @@ cython_debug/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
/data/
|
||||
|
||||
+13
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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", """
|
||||
|
||||
@@ -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)
|
||||
+114
@@ -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},
|
||||
)
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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="/">← Back to dashboard</a></p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 }}_<year>.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 & send emails</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user