#!/usr/bin/env python
# -*- coding: iso-8859-1 -*-
# vim: set ft=python ts=3 sw=3 expandtab:
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
#              C E D A R
#          S O L U T I O N S       "Software done right."
#           S O F T W A R E
#
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
# Copyright (c) 2002-2003,2005,2010 Kenneth J. Pronovici.
# All rights reserved.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License,
# Version 2, as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# Copies of the GNU General Public License are available from
# the Free Software Foundation website, http://www.gnu.org/.
#
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
# Author   : Kenneth J. Pronovici <pronovic@ieee.org>
# Language : Python (>= 2.5)
# Project  : Secret Santa
# Revision : $Id: secret-santa 1289 2010-12-02 03:08:10Z pronovic $
# Purpose  : Provide Secret Santa assignments from a pool
#
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

# Note that right now, I must BY HAND synchronize the version number
# between setup.py, Makefile and this file!

######################
# Pydoc documentation
######################

"""
Provide Secret Santa assignment from a pool.

A "Secret Santa" exchange is one where a group of a people get together
and each exchange gifts with one other person from that group.  The
exchange assignments are supposed to be secret, so that no one knows
ahead of time who they will get their gift from.  Usually, there's a
small ($5, $10, $15) limit on the size of the gift.  Another name for
this type of exchange is a "grab bag" exchange.

Every Secret Santa exchange has several elements:

   * A group of people
   * A "theme" for the exchange
   * A maximum cost for each gift
   * A date/time that the exchange will take place

We sometimes place limits on the way the exchange is generated.  For
instance, a person should not be assigned themselves as a partner, and
we may sometimes not want to group two other particular people together
(say, husband and wife at a big party).

This script will generate an exchange and then notify the group
partipants of their assignment via email.
"""

__author__  = "Kenneth J. Pronovici"


########################################################################
# Imported modules
########################################################################

import sys
try:
   import getopt
   import time
   from xml.dom.ext.reader import PyExpat
   from xml.xpath import Evaluate
   from random import randint
   import smtplib
except ImportError, ie:
   print "Failed to import modules: %s" % ie
   print "Please try setting the PYTHONPATH environment variable."
   sys.exit(1)


#######################################################################
# Script-wide configuration and constants
#######################################################################

VERSION          = 1.4
DEFAULT_ATTEMPTS = 10       # number of attempts we'll make at a successful assignment


#######################################################################
# Functions
#######################################################################

def usage(error=None):
   """Generate usage message."""
   if error is not None:
      print ""
      print " [Error - %s.]" % error
   print ""
   print " Usage: secret-santa [options] config-file"
   print ""
   print " The following switches are accepted:"
   print ""
   print "   -h, --help         Display this usage/help listing"
   print "   -V, --version      Display version information"
   print "   -a, --attempts     Number of attempts to take (default: %d)" % DEFAULT_ATTEMPTS
   print "   -r, --results      Dump results to standard output"
   print "   -o, --orgonly      Generate email to organizer only"
   print "   -n, --noemail      Do not generate any email messages"
   print "   -c, --autoconflict Generate automatic conflicts between givers"
   print 
   print " Copyright (c) 2002-2003,2005,2010 Kenneth J. Pronovici."
   print " Distributed under the GNU GPL; see http://www.gnu.org/ for info."
   print ""

def version():
   """Generate version message."""
   print ""
   print " This is secret-santa, version %s." % VERSION
   print " Use the --help option for usage information."
   print ""


def commandLine():
   """Parse the command-line."""

   # Variables
   switches = { }

   # First, get the options and arguments
   try:
      opts, args = getopt.getopt(sys.argv[1:], 
                                 "hVa:ron", 
                                 [ 'help', "version", 'attempts=', 'results', 'orgonly', 'noemail', 'autoconflict', ] )
   except getopt.GetoptError:
      usage(error="Incorrect arguments supplied")
      sys.exit(2)

   # Put the switches in a hash for easy use (we don't allow multiple
   # occurrences of the same switch, so this is safe)
   for o, a in opts:
      switches[o] = a

   # Print help if needed
   if switches.has_key('-h') or switches.has_key('--help'):
      usage()
      sys.exit(0)

   # Print version if requested
   if switches.has_key('-V') or switches.has_key('--version'):
      version()
      sys.exit(0)

   # Set the config file (it should be the only remaining argument)
   if len(args) != 1:
      usage(error="config-file must be supplied")
      sys.exit(2)
   cfile = args[0]

   # Set the attempts value
   if switches.has_key('-a') or switches.has_key('--attempts'):
      if switches.has_key('-a'):
         attempts = int(switches['-a'])
      else:
         attempts = int(switches['--attempts'])
   else:
      attempts = DEFAULT_ATTEMPTS

   # Set the results flag
   if switches.has_key('-r') or switches.has_key('--results'):
      results = True
   else:
      results = False

   # Set the orgonly flag
   if switches.has_key('-o') or switches.has_key('--orgonly'):
      orgonly = True
   else:
      orgonly = False

   # Set the noemail flag
   if switches.has_key('-n') or switches.has_key('--noemail'):
      noemail = True
   else:
      noemail = False

   # Set the autoconflict flag
   if switches.has_key('-c') or switches.has_key('--autoconflict'):
      autoconflict = True
   else:
      autoconflict = False

   # Return configuration elements
   return (cfile, attempts, results, orgonly, noemail, autoconflict)

def readConfig(cfile):

   """Read XML configuration."""

   # Initialize the DOM tree
   dom = PyExpat.Reader().fromString(open(cfile, "r").read())

   # Initialize the config structure
   config = { }

   # Get the exchange section of configuration
   config['exchange'] = { }

   config['exchange']['name'] = Evaluate('string(//secret-santa/exchange/name)', dom.documentElement)

   config['exchange']['organizer'] = { }
   config['exchange']['organizer']['name']   = Evaluate('string(//secret-santa/exchange/organizer/name)', dom.documentElement)
   config['exchange']['organizer']['email']  = Evaluate('string(//secret-santa/exchange/organizer/email)', dom.documentElement)
   config['exchange']['organizer']['phone']  = Evaluate('string(//secret-santa/exchange/organizer/phone)', dom.documentElement)

   config['exchange']['date-time'] = Evaluate('string(//secret-santa/exchange/date-time)', dom.documentElement)
   config['exchange']['theme']     = Evaluate('string(//secret-santa/exchange/theme)', dom.documentElement)
   config['exchange']['cost']      = Evaluate('string(//secret-santa/exchange/cost)', dom.documentElement)
   config['exchange']['greeting']  = Evaluate('string(//secret-santa/exchange/greeting)', dom.documentElement)

   # Get the group section of configuration
   i = 0
   config['group'] = { }
   while Evaluate('//secret-santa/group/person[%d]' % (i+1), dom.documentElement) != []:

      personId = Evaluate('string(//secret-santa/group/person[%d]/id)' % (i+1), dom.documentElement)
      config['group'][personId] = { }

      config['group'][personId]['id'] = personId
      config['group'][personId]['id'] = Evaluate('string(//secret-santa/group/person[%d]/id)' % (i+1), dom.documentElement)
      config['group'][personId]['name'] = Evaluate('string(//secret-santa/group/person[%d]/name)' % (i+1), dom.documentElement)
      config['group'][personId]['nickname'] = Evaluate('string(//secret-santa/group/person[%d]/nickname)' % (i+1), dom.documentElement)
      config['group'][personId]['email'] = Evaluate('string(//secret-santa/group/person[%d]/email)' % (i+1), dom.documentElement)

      j = 0
      config['group'][personId]['conflicts'] = [ ]
      while Evaluate('//secret-santa/group/person[%d]/conflicts/id[%d]' % (i+1, j+1), dom.documentElement) != []:
         config['group'][personId]['conflicts'].append(Evaluate('string(//secret-santa/group/person[%d]/conflicts/id[%d])' % 
                                                      (i+1, j+1), dom.documentElement))
         j += 1

      i += 1

   # Return the parsed configuration
   return(config)

def assign(config, attempts, autoconflict):

   """
   Make Secret Santa assigments, handling multiple attempts if needed.

   It's possible that for a given pass, we might not be able to build a
   valid exchange.  This might happen, for instance, if we get to the
   point where the only people left in the available list all conflict
   with the person we're trying to make an assignment for.

   Since we're generating assigments pseudo-randomly, this isn't a permanent
   failure.  We'll just try again to see if we can make it work, up to the
   indicated maximum number of attempts.  If it still doesn't work, the user
   can either modify configuration (to change conflicts), increase the number
   of attempts (which might make a difference for larger groups), or disable
   auto-conflicts (if they are enabled).

   @param config: Configuration
   @param attempts: Maximum number of attempts
   @param autoconflict: Whether to enable automatic conflict detection

   @return: Assignments by participant id
   """

   # Look for a valid solution for some number of attempts
   count = 0
   for i in range(0, attempts):
      count = i + 1
      result = _assign(config, autoconflict)
      if result is not None:
         break

   # Raise an exception if we can't find a solution
   if result is None:
      raise Exception("Unable to find a solution in %d attempts.\n"
                      "Conflicts may be too limiting.\n"
                      "You may wish to increase the number of attempts using the --attempts flag." % attempts)

   # Generate a log based on the results
   log  = "\n"
   log += "Log for secret-santa run at %s.\n" % time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime())
   log += "\n"
   log += "This set of assignments was generated after %d attempt(s).\n" % count
   log += "\n"
   for personId in result:
      log += "\"%s\" was assigned buddy \"%s\"\n" % (
             "%s <%s>" % (config['group'][personId]['name'], config['group'][personId]['email']),
             "%s <%s>" % (config['group'][result[personId]]['name'], config['group'][result[personId]]['email']))

   # Return the result
   return (result, log)

def _assign(config, autoconflict):

   """
   Make Secret Santa assignments, for a single attempt.
   @param config: Configuration
   @param autoconflict: Whether to enable automatic conflict detection
   @return: Assignments by participant id
   """

   # Initialize variables
   assignments = { }
   available = config['group'].keys()

   # For each person, make an assignment
   for personId in config['group'].keys():

      # Trim the available list to a list of allowable matches, 
      # i.e. not self, not any listed in conflicts list.
      allowable = available[:]
      try:
         allowable.remove(personId)
      except: pass  # it's just not there, no big deal

      for conflict in config['group'][personId]['conflicts']:
         try:
            allowable.remove(conflict)
         except: pass  # it's just not there, no big deal

      # Set up automatic conflicts, so people don't get assigned each other
      if autoconflict:
         for conflict in assignments.keys():
            if assignments[conflict] == personId:
               try:
                  allowable.remove(conflict)
               except: pass # it's just not there, no big deal

      # If there are no allowable choices, the caller should retry
      if allowable == [ ]:
         return None

      # Select someone out of the allowable list
      select = randint(0, len(allowable)-1)
      assignments[personId] = allowable[select]
      available.remove(allowable[select])

   # Return the result
   return assignments


def notify(config, assignments, orgonly):

   """
   Send notification emails.
   @param config: Configuration
   @param assigments: Assignments, as from assign()
   @param orgonly: Whether emails should go to organizer only
   """

   # For each person, generate an email
   for personId in config['group'].keys():

      # This is their assignment
      buddy = assignments[personId]

      # The subject (note, NO ending \n)
      subject = "%s assignment (to be read by '%s' only)" % (config['exchange']['name'], config['group'][personId]['nickname'])

      # The body
      body  = "\n"
      body += "Hello, %s!\n" % config['group'][personId]['name']
      body += "\n"
      body += "This email was auto-generated by the secret-santa script\n"
      body += "on behalf of %s, the organizer.  If you need to,\n" % (config['exchange']['organizer']['name'])
      body += "you may contact the organizer:\n"
      body += "\n"
      body += "   Phone: %s\n" % config['exchange']['organizer']['phone']
      body += "   Email: %s\n" % config['exchange']['organizer']['email']
      body += "\n"
      body += "This email contains your assignment for the %s\n" % config['exchange']['name']
      body += "\"Secret Santa\" exchange.  No one else knows what your\n"
      body += "assignment is.  You should keep your assignment a secret.\n"
      body += "\n"
      body += "Your \"Secret Santa\" buddy is:   \t%s (%s)" % (config['group'][buddy]['name'], config['group'][buddy]['nickname'])
      body += "\n"
      body += "The theme of this exchange is:  \t%s\n" % config['exchange']['theme']
      body += "The exchange will take place on:\t%s\n" % config['exchange']['date-time']
      body += "The cost of your gift should be:\t%s\n" % config['exchange']['cost']
      body += "\n"

      if len(config['exchange']['greeting']) > 0:
         body += "The organizer has also provided this additional information:\n"
         body += "\n"
         body += config['exchange']['greeting']
         body += "\n"

      body += "\n"
      body += "\n"
      body += "\n"

      # Now, generate the email message itself
      fromAddr = "%s <%s>" % (config['exchange']['organizer']['name'], config['exchange']['organizer']['email'])
      if orgonly:
         toAddr = fromAddr
      else:
         toAddr   = "%s <%s>" % (config['group'][personId]['name'], config['group'][personId]['email'])

      msg  = "From: %s\n" % fromAddr
      msg += "To: %s\n" % toAddr
      msg += "Subject: %s\n" % subject
      msg += "\n"
      msg += body

      # And, send the email address
      fromAddr = config['exchange']['organizer']['email']
      if orgonly:
         toAddr = fromAddr
      else:
         toAddr = config['group'][personId]['email']
      s = smtplib.SMTP()
      s.connect()
      s.sendmail(fromAddr, [toAddr], msg)
      s.close()

def main():

   """Main routine."""

   # Check the version
   try:
      if map(int, [sys.version_info[0], sys.version_info[1]]) < [2, 5]:
         print "Python version 2.5 or greater required, sorry."
         sys.exit(1)
   except:
      # sys.version_info isn't available before 2.0
      print "Python version 2.5 or greater required, sorry."
      sys.exit(1)

   # Parse the command-line.
   # This function call might cause the script to exit.
   (cfile, attempts, results, orgonly, noemail, autoconflict) = commandLine()

   # Wrap execution in a big try block
   try:

      # Read the configuration file
      config = readConfig(cfile)

      # Generate the assignments
      (assignments, log) = assign(config, attempts, autoconflict)

      # Send assignments notification
      if not noemail:
         notify(config, assignments, orgonly)

      # Dump results to stdout
      if results:
         print log

      # Print a terminating message
      print "Execution complete."

   except Exception, e:
   
      # Display the error message
      print "\nAn error occurred during processing: %s" % e

   # Return
   return


########################################################################
# Module entry point
########################################################################

if __name__ == '__main__':
   main()

