From c3887eace9f07e409f1e7516546b3cb18a9e7126 Mon Sep 17 00:00:00 2001
From: Vivek Santayana
Date: Fri, 1 Dec 2023 15:36:40 +0000
Subject: [PATCH] Created script.
---
.gitignore | 4 ++
README.md | 122 +++++++++++++++++++++++++++++++++++++++++++-
app.py | 79 ++++++++++++++++++++++++++++
config.py | 22 ++++++++
secret.bak.py | 2 +
templates/plain.txt | 15 ++++++
templates/rich.html | 45 ++++++++++++++++
7 files changed, 288 insertions(+), 1 deletion(-)
create mode 100644 app.py
create mode 100644 config.py
create mode 100644 secret.bak.py
create mode 100644 templates/plain.txt
create mode 100644 templates/rich.html
diff --git a/.gitignore b/.gitignore
index 5d381cc..205be35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
+secret.py
+data.json
+output.json
+
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/README.md b/README.md
index 7f3e7a0..514bc20 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,122 @@
-# secret-santa
+# README
+This is a Python script for running a secret Santa draw.
+
+This script:
+
+- Ingests participant data in JSON format,
+- Automates the randomised pairing of people to buy gifts for,
+- Includes information about preferences or restrictions for gifts that participants may have,
+- Automatically emails participants via GMail SMTP (requires external set-up), and
+- Creates an output file of gift pairings for troubleshooting later.
+
+The script only uses standard libraries that come bundled with Python distributions, and does not require installing any dependencies.
+
+## Set Up
+
+### GMail SMTP
+
+This app uses GMail as an SMTP provider.
+
+- This is because GMail accounts are free and easy to create,
+- The server is trusted by spam filters and is unlikely to be flagged as junk, and
+- The app can authenticate without using any external libraries.
+
+This means that there will be a few steps of external set up.
+
+- [ ] Create `secret.py` from a copy of `secret.py.bak`.
+- [ ] Log in to (or create) a GMail account (a standard, free account should suffice).
+- [ ] Enable two-factor authentication on the account.
+- [ ] Under the two-factor authentication section, generate an `App Password`.
+- [ ] Enter your GMail username and the generated app password in `secret.py`.
+
+`secret.py`
+
+```py
+USERNAME = '@gmail.com'
+PASSWORD = ''
+```
+
+You can get more information on [how to do this in the GMail documentation](https://support.google.com/mail/answer/185833?hl=en-GB).
+
+#### Using a Different SMTP Server
+
+You can set up a different SMTP provider by editing the server information in `config.py` and providing the appropriate username and password in `secret.py`.
+
+### Data
+
+The script ingests data as a JSON file.
+Create a file `data.json` (matching the file path in `config.py`) in the following format:
+
+`data.json`
+
+```json
+{
+ "name0": {
+ "email": "string",
+ "preference": "string"
+ },
+ "name1": {
+ "email": "string",
+ "preference": "string"
+ }
+}
+```
+
+> [!NOTE]
+> **Punctuation**: Comma after every entry, except the last entry.
+
+JSON syntax is very fragile and can break if not entered correctly.
+
+> [!TIP]
+> **Email Addresses**: If you have provided an `EMAIL_DOMAIN` value in `config.py`, then you do not need to add the full email address, and can just provide the username before the `@`.
+If you do provide a full email address for a participant in the data file, it will override the domain name given.
+
+### Configuration
+
+You can configure the app to send the draw for the secret Santa, including the greeting and the sign-off used, via the values in `config.py`.
+You can also edit the files in the `./templates` directory to customise the email message to include any additional instructions or text as needed.
+
+The most important configurations, however, are the credentials in `secret.py` and the data in `data.json`
+
+## Running the script
+
+Because this script should work using standard libraries in Python 3, you should be able to execute the script using whatever Python 3 interpreter you have.
+
+`PowerShell`
+
+```powershell
+> python app.py
+```
+
+`Bash`
+
+```bash
+$ python3 app.py
+```
+
+## The Algorithm
+
+The algorithm I used for the secret santa draw is a very elegant one, as [illustrated by Prof Hannah Fry](https://www.youtube.com/watch?v=GhnCj7Fvqt0).
+
+- Randomise the order of participants.
+- Generate a derangement from the randomised order by shifting the top name to the bottom of the list.
+- Match participants between the randomised list and derangement to pair who is buying for whom.
+
+This ensures that:
+
+- People know whom they are buying for, but do not know who else is buying for whom, and
+- Everybody has an equal chance of buying for everybody else.
+- The person running the draw also does not know who is buying for them (unless they peek behind the curtain, as it were).
+
+## Troubleshooting
+
+If you do need to see who is buying for whom, you can check the `output.json` file, or check the sent emails from the SMTP server.
+
+## Isn't This an Over-Engineered Solution for a Trivial Problem?
+
+Of course it is.
+
+## Why Not Use an Existing Site for This?
+
+Because where is the fun in that?
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..c4e67d1
--- /dev/null
+++ b/app.py
@@ -0,0 +1,79 @@
+import json, os, random, smtplib, ssl
+from email.message import EmailMessage
+from string import Template
+
+from config import Config as Config
+from secret import USERNAME, PASSWORD
+
+context = ssl.create_default_context()
+
+# Load data file
+data_path = os.path.normpath(Config.DATA)
+with open(data_path, 'r') as data_file:
+ data = json.loads(data_file.read())
+
+print('Parsed data file.')
+
+if not data: raise Exception('Error: data file is empty.')
+
+# Randomise participants.
+participants = list(data.keys())
+random.shuffle(participants)
+
+# Create a derangement in the permutation by moving the first participant to the bottom
+derangement = list(participants)
+derangement.append(derangement.pop(0))
+
+# Create a dictionary pairing participants and gift recipients
+buying = {}
+for index, participant in enumerate(participants):
+ buying[participant] = derangement[index]
+buying = {key: value for key, value in sorted(buying.items())}
+
+# Create output file
+output_path = os.path.normpath(Config.OUTPUT)
+with open(output_path, 'w') as output:
+ output.write(json.dumps(buying, indent=4))
+
+# Parse Templates
+plain_path = os.path.normpath('./templates/plain.txt')
+with open(plain_path, 'r') as plain_file:
+ plain_template = Template(plain_file.read())
+rich_path = os.path.normpath('./templates/rich.html')
+with open(rich_path, 'r') as rich_file:
+ rich_template = Template(rich_file.read())
+
+def construct_message(participant, gift_recipient):
+ message = EmailMessage()
+ message['From'] = USERNAME
+ if '@' in data[participant]['email']:
+ participant_email = data[participant]['email']
+ elif hasattr(Config,'EMAIL_DOMAIN') and Config.EMAIL_DOMAIN:
+ participant_email = f'{data[participant]["email"]}@{Config.EMAIL_DOMAIN}'
+ else:
+ raise Exception(f'Error: Invalid email address for participant {participant}.')
+ if hasattr(Config,'OVERRIDE_RECIPIENT') and Config.OVERRIDE_RECIPIENT and hasattr(Config,'RECIPIENT_EMAIL') and Config.RECIPIENT_EMAIL:
+ message['To'] = Config.RECIPIENT_EMAIL
+ else: message['To'] = participant_email
+ message['Subject'] = Config.SUBJECT
+ preference = 'None' if not data[gift_recipient].get('preference') else data[gift_recipient].get('preference')
+ template_substitutions = {
+ 'subject': Config.SUBJECT,
+ 'greeting': Config.GREETING,
+ 'participant': participant.title(),
+ 'gift_recipient': gift_recipient.title(),
+ 'preference': preference,
+ 'sender': Config.SENDER
+ }
+ body_html = rich_template.substitute(template_substitutions)
+ body_plain = plain_template.substitute(template_substitutions)
+ message.set_content(body_plain)
+ message.add_alternative(body_html, subtype='html')
+ return message
+
+messages = [construct_message(participant=participant, gift_recipient=gift_recipient) for participant, gift_recipient in buying.items()]
+
+with smtplib.SMTP_SSL(Config.SERVER, Config.PORT, context=context) as smtp:
+ smtp.login(user=USERNAME, password=PASSWORD)
+ for message in messages: smtp.send_message(message)
+ smtp.quit()
\ No newline at end of file
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..5265752
--- /dev/null
+++ b/config.py
@@ -0,0 +1,22 @@
+class Config(object):
+
+ # SMTP Configuration
+ SERVER = 'smtp.gmail.com' # Uses GMail SMTP by default
+ PORT = 465 # Default GMail SSL SMTP port
+
+ # Customisation
+ GROUP = '' # Group/Organisation Name
+ EMAIL_DOMAIN = 'bar.com' # Email domain for organisation
+ SUBJECT = f'{GROUP} Secret Santa Draw' if GROUP else 'Secret Santa Draw' # Email subject line
+ GREETING = 'Ho Ho Ho' # Email greeting
+ SENDER = 'The Secret Santa Elf' # Email sign-off
+
+ # App Configs
+ DATA = './data.json' # Data file
+ OUTPUT = './output.json' # Output file
+
+class Test(Config):
+
+ # Test Config
+ OVERRIDE_RECIPIENT = True # Override the recipient for all emails
+ RECIPIENT_EMAIL = '' # Test email address to receive all outgoing email
diff --git a/secret.bak.py b/secret.bak.py
new file mode 100644
index 0000000..c5a309d
--- /dev/null
+++ b/secret.bak.py
@@ -0,0 +1,2 @@
+USERNAME = '' # GMail Username
+PASSWORD = '' # 16-character 'App Password' (without spaces).
\ No newline at end of file
diff --git a/templates/plain.txt b/templates/plain.txt
new file mode 100644
index 0000000..2fc8865
--- /dev/null
+++ b/templates/plain.txt
@@ -0,0 +1,15 @@
+$subject
+
+$greeting $participant!
+
+You are buying a gift for $gift_recipient.
+
+That they have given the following preferences for their gift:
+
+$preference
+
+I hope you enjoy the secret Santa!
+
+$sender
+
+This is an automatically generated email. Please do not reply to this address as it will not be monitored.
\ No newline at end of file
diff --git a/templates/rich.html b/templates/rich.html
new file mode 100644
index 0000000..20b1a51
--- /dev/null
+++ b/templates/rich.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ $subject
+
+
+
+
+ $subject
+
+
+ $greeting $participant!
+
+
+ You are buying a gift for
+
+ $gift_recipient
+
+ .
+
+
+ That they have given the following preferences for their gift:
+
+
+ $preference
+
+
+
+
+ I hope you enjoy the secret Santa!
+
+
+ $sender
+
+
+
+ This is an automatically generated email.
+ Please do not reply to this address as it will not be monitored.
+
+
+
+
\ No newline at end of file