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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.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 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 current_participants: This year's participant list.
:param history_data: Historical draw data to avoid repeats. :param history_data: Historical draw data to avoid repeats.
:param draws_per_person: Number of people each participant should give gifts to. :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. :return: The new draw results.
""" """
participants = [p[0] for p in current_participants] # Get participant names 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 new_draw = [] # Store new draw results
for attempt in range(max_attempts): for attempt in range(max_attempts):
print(attempt)
new_draw.clear() new_draw.clear()
already_drawn.clear() already_drawn.clear()
success = True # Track if draw is successful in this attempt 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 # 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:]} 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 = set(participants) - {giver} - previous_recipients
available_participants -= {receiver for receiver in participants if (giver, receiver) in exclusions}
new_recipients = [] new_recipients = []
# Ensure there are enough available participants for the draw # 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...") print(f"Attempt {attempt + 1} failed, retrying...")
# If all attempts fail, raise an error # 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 # Configuration is loaded from environment variables (see .env.example),
SMTP_SERVER = "smtp.example.com" # so no secrets need to live in this file or in version control.
SMTP_PORT = 25 import os
SENDER_EMAIL = "santa@example.com" from pathlib import Path
CSV_PATH = r"path\to\your\csv\files" # Path to the CSV files
CSV_PREFIX = r"secret_santa_DB" # Prefix for CSV files from dotenv import load_dotenv
HISTORY_YEARS = 2 # Number of past years to consider in the draw
DRAW_PER_PERSON = 2 # Number of recipients per person 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 content
EMAIL_SUBJECT = "Secret Santa {year} Draw" EMAIL_SUBJECT = os.environ.get("EMAIL_SUBJECT", "Secret Santa {year} Draw")
EMAIL_BODY = """ EMAIL_BODY = os.environ.get("EMAIL_BODY", """
Hello {name}, Hello {name},
You have been chosen to give gifts to: {draws}. 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! Merry Christmas!
This email was sent automatically, please do not reply. This email was sent automatically, please do not reply.
""" """)
+18
View File
@@ -45,6 +45,24 @@ def load_participants():
return participants_data 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): def save_csv(data, year):
""" """
Save the new draw results to a CSV file named with the current 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 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 draw import draw_names
from emailer import send_email from emailer import send_email
from utils import get_current_time from utils import get_current_time
@@ -17,22 +17,21 @@ def send_all_emails(new_draw):
send_email(receiver_email, subject, message) send_email(receiver_email, subject, message)
print(f"Email sent to {name} ({receiver_email})") 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__": if __name__ == "__main__":
try: try:
# Load data from historical CSVs and this year's participants new_draw = run_draw()
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)
print(f"Process completed at {get_current_time()[1]} on {get_current_time()[0]}") print(f"Process completed at {get_current_time()[1]} on {get_current_time()[0]}")
except Exception as e: 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() today = date.today()
current_time = time.strftime("%H:%M:%S", time.localtime()) current_time = time.strftime("%H:%M:%S", time.localtime())
return today.strftime("%d/%m/%Y"), current_time 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))