Compare commits

...

2 Commits

Author SHA1 Message Date
jeanGaston 4c5a926227 Add AGENT.md with branching, commit, and secret-handling guidelines
Codifies the dev/main branching model and secret-handling rules being
introduced alongside the upcoming container/web GUI work, so future changes
(by humans or agents) stay consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:20:50 +02:00
jeanGaston 9e2f761544 Add exclusion support, multi-year history helper, and remove secrets from env.py
- draw.py/file_io.py/main.py: wire in exclusion pairs so the draw can avoid
  configured giver/receiver combinations.
- utils.py: add get_last_n_years helper.
- env.py: load configuration from environment variables (via .env) instead of
  hardcoding real SMTP/CSV secrets in a tracked file; add .env.example as the
  template and gitignore .env.
- test_draws.py: drop hardcoded personal path, use env.py config instead.
- add requirements.txt for the upcoming web GUI/container work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:16:12 +02:00
11 changed files with 219 additions and 32 deletions
+18
View File
@@ -0,0 +1,18 @@
# Copy this file to .env and fill in your real values.
# .env is gitignored and is the only place secrets should live.
SMTP_SERVER=smtp.example.com
SMTP_PORT=25
SENDER_EMAIL=santa@example.com
CSV_PATH=path/to/your/csv/files
CSV_PREFIX=secret_santa_DB
HISTORY_YEARS=2
DRAW_PER_PERSON=2
# Optional overrides — {name}/{draws}/{year} placeholders are filled in at send time.
# EMAIL_SUBJECT=Secret Santa {year} Draw
# EMAIL_BODY="Hello {name},\n\nYou have been chosen to give gifts to: {draws}.\n"
# Web GUI basic-auth (leave unset to disable auth — not recommended outside localhost)
WEBAPP_USERNAME=admin
WEBAPP_PASSWORD=change-me
+2
View File
@@ -0,0 +1,2 @@
# Specify filepatterns you want to assign special attributes.
+2 -1
View File
@@ -160,4 +160,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
src/env.py
.env
.env.local
+67
View File
@@ -0,0 +1,67 @@
# Agent instructions for this repository
This file guides both human contributors and AI coding agents working in this
repo. Follow it unless the user explicitly asks for something different.
## Branching model
- `main` — always deployable. Only receives finished, working changes.
- `dev` — integration branch for in-progress work (new features, larger
refactors, anything not yet proven). Merge to `main` only once it works.
- Feature branches (optional): `feature/<short-name>` or `fix/<short-name>`
cut from `dev` for anything that touches multiple files or takes more than
one sitting. Small, obvious fixes can go straight to `dev`.
Rebase feature branches on `dev` before opening a PR/merge if `dev` has moved
on; don't merge `dev` into `main` with unresolved conflicts.
## Commit practices
- Commit early and often — one logical change per commit. Don't bundle an
unrelated refactor with a bug fix.
- Write commit messages in the imperative mood ("Add exclusion support", not
"Added" or "Adding"), with a short summary line (~50-70 chars) and, if
needed, a blank line followed by bullet points explaining *why*.
- Never commit secrets: SMTP credentials, real participant CSV paths, API
keys. Real configuration lives in a local `.env` file (gitignored) —
`env.py` only reads `os.environ`/`.env` and must never hardcode real values.
If you add a new secret, add its placeholder to `.env.example` too.
- Before committing, check `git status` and `git diff` for anything
unexpected (stray debug prints, personal file paths, credentials).
- Prefer creating new commits over amending, unless explicitly asked to amend
and the previous commit hasn't been pushed/shared.
- Don't use `--no-verify`, force-push, or rewrite shared history without
explicit user approval.
- Run whatever tests/scripts are relevant before committing (`src/test_draws.py`
for conflict checks; manually exercise `main.run_draw()` or the web GUI for
behavior changes — there's no CI yet).
## Code style
- Keep the existing flat-module layout in `src/` (`draw.py`, `file_io.py`,
`emailer.py`, `env.py`, `main.py`, `utils.py`); they import each other with
plain `from x import y`, so `src/` must stay on `sys.path` for anything that
imports them (see `webapp/app.py` for how the web GUI does this).
- No comments explaining *what* code does — only *why*, when it's non-obvious
(e.g. a workaround, an invariant, a subtle constraint).
- Don't add abstractions, config flags, or error handling for cases that
can't happen. Keep changes scoped to what was asked.
- Type hints are used inconsistently today (see `utils.py` vs `file_io.py`);
match the style of the file you're editing rather than mixing conventions.
## Secrets & data
- `.env` (real secrets, gitignored) vs `.env.example` (committed template) —
keep them in sync whenever a new config key is added.
- Participant CSV data lives outside the repo (`CSV_PATH`), never commit
sample data containing real names/emails.
- The web GUI can trigger real emails to real people via `run_draw()` — treat
"run draw" as a destructive/one-way action (it also overwrites this year's
CSV) and confirm with the user before wiring up anything that calls it
automatically (e.g. a cron/scheduled container).
## Deployment
- `Dockerfile` builds the web GUI service; `docker-compose.yml` wires it up
with a volume for `CSV_PATH` and an `.env` file for config. See the
README's Docker section for usage.
+5
View File
@@ -0,0 +1,5 @@
python-dotenv>=1.0
fastapi>=0.111
uvicorn[standard]>=0.30
jinja2>=3.1
python-multipart>=0.0.9
+8 -5
View File
@@ -1,12 +1,13 @@
from random import choice
def draw_names(current_participants, history_data, draws_per_person, max_attempts=5):
def draw_names(current_participants, history_data, draws_per_person, exclusions, max_attempts=5):
"""
Perform the Secret Santa draw considering past years' draws.
Perform the Secret Santa draw considering past years' draws and exclusions.
:param current_participants: This year's participant list.
:param history_data: Historical draw data to avoid repeats.
:param draws_per_person: Number of people each participant should give gifts to.
:param max_attempts: Maximum number of retry attempts before loosening exclusions. by default : 5.
:param exclusion_file: Path to the exclusion file.
:param max_attempts: Maximum number of retry attempts before loosening exclusions.
:return: The new draw results.
"""
participants = [p[0] for p in current_participants] # Get participant names
@@ -14,6 +15,7 @@ def draw_names(current_participants, history_data, draws_per_person, max_attempt
new_draw = [] # Store new draw results
for attempt in range(max_attempts):
print(attempt)
new_draw.clear()
already_drawn.clear()
success = True # Track if draw is successful in this attempt
@@ -23,8 +25,9 @@ def draw_names(current_participants, history_data, draws_per_person, max_attempt
# Collect previous recipients to avoid drawing the same person again
previous_recipients = {recipient for record in history_data if record[0] == giver for recipient in record[2:]}
# Create a set of available participants excluding the giver and previous recipients
# Create a set of available participants excluding the giver, previous recipients, and exclusions
available_participants = set(participants) - {giver} - previous_recipients
available_participants -= {receiver for receiver in participants if (giver, receiver) in exclusions}
new_recipients = []
# Ensure there are enough available participants for the draw
@@ -48,4 +51,4 @@ def draw_names(current_participants, history_data, draws_per_person, max_attempt
print(f"Attempt {attempt + 1} failed, retrying...")
# If all attempts fail, raise an error
raise ValueError("Unable to complete a valid draw after maximum attempts.")
raise ValueError("Unable to complete a valid draw after maximum attempts.")
+19 -11
View File
@@ -1,15 +1,23 @@
# 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
# Configuration is loaded from environment variables (see .env.example),
# so no secrets need to live in this file or in version control.
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
SMTP_SERVER = os.environ.get("SMTP_SERVER", "smtp.example.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "25"))
SENDER_EMAIL = os.environ.get("SENDER_EMAIL", "santa@example.com")
CSV_PATH = os.environ.get("CSV_PATH", r"path\to\your\csv\files") # Path to the CSV files
CSV_PREFIX = os.environ.get("CSV_PREFIX", "secret_santa_DB") # Prefix for CSV files
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
# Email content
EMAIL_SUBJECT = "Secret Santa {year} Draw"
EMAIL_BODY = """
EMAIL_SUBJECT = os.environ.get("EMAIL_SUBJECT", "Secret Santa {year} Draw")
EMAIL_BODY = os.environ.get("EMAIL_BODY", """
Hello {name},
You have been chosen to give gifts to: {draws}.
@@ -18,4 +26,4 @@ Feel free to use your imagination and make their Christmas magical!
Merry Christmas!
This email was sent automatically, please do not reply.
"""
""")
+18
View File
@@ -45,6 +45,24 @@ def load_participants():
return participants_data
def load_exclusions():
"""
Load exclusion pairs from a CSV file.
:return: A set of tuples representing invalid combinations (giver, receiver).
"""
exclusions = set()
current_year = date.today().year
try:
file_name = f"{CSV_PATH}/{CSV_PREFIX}_exclusions.csv"
with open(file_name, 'r', encoding='utf-8') as file:
reader = csv.reader(file)
for row in reader:
if len(row) == 2:
exclusions.add(tuple(row))
except FileNotFoundError:
print(f"Exclusion file not found: {file_name}. No exclusions will be applied.")
return exclusions
def save_csv(data, year):
"""
Save the new draw results to a CSV file named with the current year.
+14 -15
View File
@@ -1,5 +1,5 @@
from env import DRAW_PER_PERSON, HISTORY_YEARS, EMAIL_SUBJECT, EMAIL_BODY
from file_io import load_history, save_csv, load_participants
from file_io import load_history, save_csv, load_participants, load_exclusions
from draw import draw_names
from emailer import send_email
from utils import get_current_time
@@ -17,22 +17,21 @@ def send_all_emails(new_draw):
send_email(receiver_email, subject, message)
print(f"Email sent to {name} ({receiver_email})")
def run_draw():
"""Load participants/history, perform the draw, email results, and persist them. Returns the new draw."""
history_data = load_history(HISTORY_YEARS)
current_year = date.today().year
current_participants = load_participants()
exclusions = load_exclusions()
new_draw = draw_names(current_participants, history_data, DRAW_PER_PERSON, exclusions)
send_all_emails(new_draw)
save_csv(new_draw, current_year)
return new_draw
if __name__ == "__main__":
try:
# Load data from historical CSVs and this year's participants
history_data = load_history(HISTORY_YEARS)
current_year = date.today().year
current_participants = load_participants() # Load participants data
# Perform the draw
new_draw = draw_names(current_participants, history_data, DRAW_PER_PERSON)
# Send emails to participants
send_all_emails(new_draw)
# Save new draw results
save_csv(new_draw, current_year)
new_draw = run_draw()
print(f"Process completed at {get_current_time()[1]} on {get_current_time()[0]}")
except Exception as e:
+57
View File
@@ -0,0 +1,57 @@
import csv
from typing import List, Tuple, Set
from env import CSV_PATH, CSV_PREFIX
# Load CSV data from a file
def load_csv(filepath: str) -> List[List[str]]:
with open(filepath, 'r', encoding='utf-8') as file:
reader = csv.reader(file)
return [row for row in reader]
# Extract giver-receiver pairs from data
def extract_pairs(data: List[List[str]]) -> Set[Tuple[str, str]]:
pairs = set()
for record in data:
giver = record[0]
recipients = record[2:] # Recipients start from the third column
pairs.update((giver, recipient) for recipient in recipients)
return pairs
# Generalized function to check conflicts between years, including current draw as a list
def check_conflicts(csv_path: str, csv_prefix: str, years: List[int], current_year_draw: List[List[str]] = None) -> None:
# Load data and extract pairs for each specified year
pairs_by_year = {}
for year in years:
file_path = f"{csv_path}/{csv_prefix}_{year}.csv"
year_data = load_csv(file_path)
pairs_by_year[year] = extract_pairs(year_data)
# Add current years draw if provided
if current_year_draw:
pairs_by_year['current'] = extract_pairs(current_year_draw)
# Check for conflicts across all pairs of years
year_keys = list(pairs_by_year.keys())
for i, year1 in enumerate(year_keys):
for year2 in year_keys[i+1:]:
conflicts = pairs_by_year[year1].intersection(pairs_by_year[year2])
if conflicts:
print(f"Conflicts found between {year1} and {year2}:")
for conflict in conflicts:
print(conflict)
else:
print(f"No conflicts between {year1} and {year2}.")
if __name__ == "__main__":
# Example usage: adjust years_to_check to whichever history years you want to verify.
years_to_check = [2022, 2023, 2024]
# Current year's draw passed as a list
current_year_draw = [
["Alice", "alice@example.com", "Bob", "Charlie"],
["Bob", "bob@example.com", "Alice", "David"],
["Charlie", "charlie@example.com", "David", "Alice"]
]
check_conflicts(CSV_PATH, CSV_PREFIX, years_to_check, current_year_draw)
+9
View File
@@ -6,3 +6,12 @@ def get_current_time():
today = date.today()
current_time = time.strftime("%H:%M:%S", time.localtime())
return today.strftime("%d/%m/%Y"), current_time
def get_last_n_years(n) -> list:
last_n_years = []
last_n_years.append(date.today().year)
for i in range (1,n+1):
last_n_years.append(date.today().year - i)
return last_n_years
if __name__ == "__main__":
print(get_last_n_years(3))