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