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:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Specify filepatterns you want to assign special attributes.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
"""
|
||||
""")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 year’s 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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user