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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.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
|
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
@@ -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.
|
||||||
"""
|
""")
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
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))
|
||||||
Reference in New Issue
Block a user