Compare commits
2 Commits
23c4276afb
...
4c5a926227
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5a926227 | |||
| 9e2f761544 |
@@ -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,67 @@
|
||||
# Agent instructions for this repository
|
||||
|
||||
This file guides both human contributors and AI coding agents working in this
|
||||
repo. Follow it unless the user explicitly asks for something different.
|
||||
|
||||
## Branching model
|
||||
|
||||
- `main` — always deployable. Only receives finished, working changes.
|
||||
- `dev` — integration branch for in-progress work (new features, larger
|
||||
refactors, anything not yet proven). Merge to `main` only once it works.
|
||||
- Feature branches (optional): `feature/<short-name>` or `fix/<short-name>`
|
||||
cut from `dev` for anything that touches multiple files or takes more than
|
||||
one sitting. Small, obvious fixes can go straight to `dev`.
|
||||
|
||||
Rebase feature branches on `dev` before opening a PR/merge if `dev` has moved
|
||||
on; don't merge `dev` into `main` with unresolved conflicts.
|
||||
|
||||
## Commit practices
|
||||
|
||||
- Commit early and often — one logical change per commit. Don't bundle an
|
||||
unrelated refactor with a bug fix.
|
||||
- Write commit messages in the imperative mood ("Add exclusion support", not
|
||||
"Added" or "Adding"), with a short summary line (~50-70 chars) and, if
|
||||
needed, a blank line followed by bullet points explaining *why*.
|
||||
- Never commit secrets: SMTP credentials, real participant CSV paths, API
|
||||
keys. Real configuration lives in a local `.env` file (gitignored) —
|
||||
`env.py` only reads `os.environ`/`.env` and must never hardcode real values.
|
||||
If you add a new secret, add its placeholder to `.env.example` too.
|
||||
- Before committing, check `git status` and `git diff` for anything
|
||||
unexpected (stray debug prints, personal file paths, credentials).
|
||||
- Prefer creating new commits over amending, unless explicitly asked to amend
|
||||
and the previous commit hasn't been pushed/shared.
|
||||
- Don't use `--no-verify`, force-push, or rewrite shared history without
|
||||
explicit user approval.
|
||||
- Run whatever tests/scripts are relevant before committing (`src/test_draws.py`
|
||||
for conflict checks; manually exercise `main.run_draw()` or the web GUI for
|
||||
behavior changes — there's no CI yet).
|
||||
|
||||
## Code style
|
||||
|
||||
- Keep the existing flat-module layout in `src/` (`draw.py`, `file_io.py`,
|
||||
`emailer.py`, `env.py`, `main.py`, `utils.py`); they import each other with
|
||||
plain `from x import y`, so `src/` must stay on `sys.path` for anything that
|
||||
imports them (see `webapp/app.py` for how the web GUI does this).
|
||||
- No comments explaining *what* code does — only *why*, when it's non-obvious
|
||||
(e.g. a workaround, an invariant, a subtle constraint).
|
||||
- Don't add abstractions, config flags, or error handling for cases that
|
||||
can't happen. Keep changes scoped to what was asked.
|
||||
- Type hints are used inconsistently today (see `utils.py` vs `file_io.py`);
|
||||
match the style of the file you're editing rather than mixing conventions.
|
||||
|
||||
## Secrets & data
|
||||
|
||||
- `.env` (real secrets, gitignored) vs `.env.example` (committed template) —
|
||||
keep them in sync whenever a new config key is added.
|
||||
- Participant CSV data lives outside the repo (`CSV_PATH`), never commit
|
||||
sample data containing real names/emails.
|
||||
- The web GUI can trigger real emails to real people via `run_draw()` — treat
|
||||
"run draw" as a destructive/one-way action (it also overwrites this year's
|
||||
CSV) and confirm with the user before wiring up anything that calls it
|
||||
automatically (e.g. a cron/scheduled container).
|
||||
|
||||
## Deployment
|
||||
|
||||
- `Dockerfile` builds the web GUI service; `docker-compose.yml` wires it up
|
||||
with a volume for `CSV_PATH` and an `.env` file for config. See the
|
||||
README's Docker section for usage.
|
||||
@@ -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