diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6d7303 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.env +.env.local +__pycache__ +**/__pycache__ +*.pyc +data/ +README.md +AGENT.md diff --git a/.env.example b/.env.example index 12e4379..c6b58d1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 9d1b693..9b1a301 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,4 @@ cython_debug/ .env .env.local +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..700db40 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d7824f0..0ee6ad2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d114a68 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/env.py b/src/env.py index e6f87e7..1d7c3b2 100644 --- a/src/env.py +++ b/src/env.py @@ -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", """ diff --git a/src/file_io.py b/src/file_io.py index 089989d..9b3690e 100644 --- a/src/file_io.py +++ b/src/file_io.py @@ -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) \ No newline at end of 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) \ No newline at end of file diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 0000000..abcef4c --- /dev/null +++ b/webapp/app.py @@ -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}, + ) diff --git a/webapp/static/style.css b/webapp/static/style.css new file mode 100644 index 0000000..492cfc6 --- /dev/null +++ b/webapp/static/style.css @@ -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); } diff --git a/webapp/templates/base.html b/webapp/templates/base.html new file mode 100644 index 0000000..b2eea5a --- /dev/null +++ b/webapp/templates/base.html @@ -0,0 +1,21 @@ + + +
+ + +The draw failed: {{ error }}
+ {% else %} +Draw complete — emails sent to {{ new_draw|length }} participant(s).
+| Giver | Recipients | |
|---|---|---|
| {{ row[0] }} | +{{ row[1] }} | +{{ row[2:]|join(", ") }} | +
| Giver | Recipients | |
|---|---|---|
| {{ row[0] }} | +{{ row[1] }} | +{{ row[2:]|join(", ") }} | +
No data for this year.
+ {% endif %} +Data file: {{ csv_prefix }}_<year>.csv
+| Name | Last recipients | ||
|---|---|---|---|
| {{ p[0] }} | +{{ p[1] }} | +{{ p[2:]|join(", ") }} | ++ + | +
| No participants yet for this year. | |||
A giver in an exclusion pair will never be assigned that receiver.
+| Giver | Receiver | |
|---|---|---|
| {{ giver }} | +{{ receiver }} | ++ + | +
| No exclusions configured. | ||
+ This performs the draw, emails every participant their result, + and overwrites this year's CSV. It cannot be undone from here. +
+ +