Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5393feefc5 |
@@ -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)
|
# 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
|
||||||
|
|||||||
@@ -162,3 +162,4 @@ cython_debug/
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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.
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
@@ -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)
|
||||||
+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