My girlfriend’s family does a variation of secret santa for Christmas every year instead of the more traditional American “gift armageddon” strategy. I’m a fan because it encourages gift creativity and thoughtfulness while eliminating the crazy holiday consumerism that is prevalent here.

Every year the selection of giver-receiver pairs must be made and this can be a challenge because there are strict rules:

  • everyone gives once, everyone receives once
  • you can’t give to your partner
  • you can’t give a gift to someone who is giving a gift to you
  • it should be a secret until gift-giving time

Last year the selections were made by my girlfriend’s sister and Christmas was absolutely delightful. But then at some point afterward I opened my big fat mouth and said that a computer program should do the selections. The response was more or less “make it so.” Here’s my Pythonic attempt. For the web I’ve blanked out the PARTNERS map and my SMTP information. The rest is as I ran it.

My “Solution”

#!/usr/bin/python
""" Solve the complex issue of secret santa """
import random
import smtplib
import email.mime.text
import time
    
    
class TwoWayDict(dict):
    """ dict subclass that provides a weak transparent reverse key search """
    
    def __getitem__(self, search_key):
        """
        Call dict's __getitem__, if that fails iterate through key value
        pairs until we find a value matching the search_key
        """
        try:
            return super(TwoWayDict, self).__getitem__(search_key)
        except KeyError:
            for key, value in super(TwoWayDict, self).iteritems():
                if value == search_key:
                    return key
            raise
    
    def __contains__(self, search_key):
        """ Membership test based on our janky new __getitem__ method """
        try:
            self.__getitem__(search_key)
        except KeyError:
            return False
    
        return True
    
    def all(self):
        """ Return a list of all keys and values """
        return self.keys() + self.values()
    
    
def quick_mail(from_addr, to_addr, subject, content,
               smtp_host, smtp_port=25, smtp_user=None, smtp_pass=None):
    """ Send an SMTP email message """
    
    if isinstance(to_addr, basestring):
        to_addr = [to_addr]
    
    # Create a text/plain message
    message = email.mime.text.MIMEText(content)
    message['From'] = from_addr
    message['To'] = ", ".join(to_addr)
    message['Subject'] = subject
    
    message_date = time.ctime(time.time())
    message.set_unixfrom("From {} {}".format(from_addr, message_date))
    
    server = smtplib.SMTP(smtp_host, smtp_port)
    
    if smtp_user and smtp_pass:
        server.starttls()
        server.login(smtp_user, smtp_pass)
    
    server.sendmail(from_addr, to_addr, message.as_string() + "\n")
    server.quit()
    
    
def choose_pairs(partners):
    """
    Return a dictionary of giver:receiver pairs following STRICT RULES
    """
    people = partners.all()
    choices = TwoWayDict()
    
    for giver in people:
        exclusions = list()
    
        # you can't give to yourself
        exclusions.append(giver)
    
        # you can't give to your partner
        exclusions.append(partners[giver])
    
        # you can't give to someone who is already getting a gift
        exclusions.extend(choices.values())
    
        # you can't give a gift to someone who is giving a gift to you
        if giver in choices:
            exclusions.append(choices[giver])
    
        possible_receivers = set(people) - set(exclusions)
        receiver = random.choice(list(possible_receivers))
        choices[giver] = receiver
    
    return choices
    
    
if __name__ == "__main__":
    PARTNERS = TwoWayDict({
        'K... <k...@example.com>': 'P... <p...@example.com>',
        'R... <r...@example.com>': 'J... <j..@example.com>',
        'F... <f...@example.com>': 'A... <a...@example.com>',
        'F... <f...@example.com>': 'B... <b...@example.com>'
    })

    
    SMTP_HOST = 'smtp.example.com'
    SMTP_PORT = 587
    SMTP_USER = 'nobody@example.com'
    SMTP_PASS = 'somereallygreatpassword'
    SMTP_FROM = 'No Body <nobody@example.com>'
    
    while True:
        try:
            CHOICES = choose_pairs(PARTNERS)
        except IndexError:
            # using random dice-throws, somtimes the only option left for the
            # final giver is their partner, which is NOT ALLOWED, so if that
            # happens, we just throw everything out and start over
            pass
        else:
            break
    
    for GIVER, RECEIVER in CHOICES.items():
        MESSAGE = ("Hello {}. A computer has determined that this year you "
                   "will be giving a Christmas gift to {}. Nobody knows this "
                   "but you. Good luck!").format(GIVER.split()[0], RECEIVER)
    
        quick_mail(SMTP_FROM, GIVER, "Secret Santa Calculation", MESSAGE,
                   SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS)