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>
This commit is contained in:
2026-07-20 14:16:12 +02:00
parent 23c4276afb
commit 9e2f761544
10 changed files with 152 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
+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))