From 9e2f7615446655fc9a62b6a60490c71cda1ea530 Mon Sep 17 00:00:00 2001 From: jeanGaston Date: Mon, 20 Jul 2026 14:16:12 +0200 Subject: [PATCH] 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 --- .env.example | 18 +++++++++++++++ .gitattributes | 2 ++ .gitignore | 3 ++- requirements.txt | 5 +++++ src/draw.py | 13 ++++++----- src/env.py | 30 ++++++++++++++++--------- src/file_io.py | 18 +++++++++++++++ src/main.py | 29 ++++++++++++------------ src/test_draws.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils.py | 9 ++++++++ 10 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 requirements.txt create mode 100644 src/test_draws.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..12e4379 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5823272 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Specify filepatterns you want to assign special attributes. + diff --git a/.gitignore b/.gitignore index 1aa3ba8..9d1b693 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8035b24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +python-dotenv>=1.0 +fastapi>=0.111 +uvicorn[standard]>=0.30 +jinja2>=3.1 +python-multipart>=0.0.9 diff --git a/src/draw.py b/src/draw.py index 9b452a0..1946b6e 100644 --- a/src/draw.py +++ b/src/draw.py @@ -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.") \ No newline at end of file diff --git a/src/env.py b/src/env.py index 78dafe8..e6f87e7 100644 --- a/src/env.py +++ b/src/env.py @@ -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. -""" +""") diff --git a/src/file_io.py b/src/file_io.py index b8455b8..089989d 100644 --- a/src/file_io.py +++ b/src/file_io.py @@ -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. diff --git a/src/main.py b/src/main.py index 7394670..8f3d78d 100644 --- a/src/main.py +++ b/src/main.py @@ -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: diff --git a/src/test_draws.py b/src/test_draws.py new file mode 100644 index 0000000..b0d9d32 --- /dev/null +++ b/src/test_draws.py @@ -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) diff --git a/src/utils.py b/src/utils.py index 1aca254..15ab28a 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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)) \ No newline at end of file