#!/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 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.2)
# Project  : Christmas 
# Package  : Fun scripts and programs
# Revision : $Id: secret-santa,v 1.6 2003/09/08 21:07:25 pronovic Exp $
# Purpose  : Provide Secret Santa assignments from a pool
#
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

# This file was created with a width of 132 characters, and NO tabs.

# 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"
__version__ = "$Revision: 1.6 $"


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

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


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

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


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

###################
# usage() function
###################

def usage(error=None):

   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 
   print " Copyright (c) 2002 Kenneth J. Pronovici."
   print " Distributed under the GNU GPL; see http://www.gnu.org/ for info."
   print ""


#####################
# version() function
#####################

def version():

   print ""
   print " This is secret-santa, version %s." % VERSION
   print " Use the --help option for usage information."
   print ""


######################
# cmd_line() function
######################
# See usage() for a brief explanation of arguments 

def cmd_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' ] )
   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

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


#########################
# read_config() function
#########################

def read_config(cfile):

   # 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) != []:

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

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

      j = 0
      config['group'][id]['conflicts'] = [ ]
      while Evaluate('//secret-santa/group/person[%d]/conflicts/id[%d]' % (i+1, j+1), dom.documentElement) != []:
         config['group'][id]['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);


####################
# assign() function
####################
# 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 (up to 'attempts' times) to
# see if we can make it work.  If it still doesn't work, the user can
# either modify configuration (to change conflicts) or increase the
# number of attempts (which might make a difference for larger groups).

def assign(config, attempts):

   # Look for a valid solution for some number of attempts
   count = 0
   for i in range(0, attempts):
      count += 1
      result = _assign(config)
      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.  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 id in result:
      log += "\"%s\" was assigned buddy \"%s\"\n" % (
             "%s <%s>" % (config['group'][id]['name'], config['group'][id]['email']),
             "%s <%s>" % (config['group'][result[id]]['name'], config['group'][result[id]]['email']))

   # Return the result
   return (result, log)

def _assign(config):

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

   # For each person, make an assignment
   for id 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(id)
      except: pass  # it's just not there, no big deal

      for conflict in config['group'][id]['conflicts']:
         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[id] = allowable[select]
      available.remove(allowable[select])

   # Return the result
   return assignments


####################
# notify() function
####################

def notify(config, assignments, orgonly):

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

      # This is their assignment
      buddy = assignments[id]

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

      # The body
      body  = "\n"
      body += "Hello, %s!\n" % config['group'][id]['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
      from_addr = "%s <%s>" % (config['exchange']['organizer']['name'], config['exchange']['organizer']['email'])
      if orgonly:
         to_addr = from_addr
      else:
         to_addr   = "%s <%s>" % (config['group'][id]['name'], config['group'][id]['email'])

      msg  = "From: %s\n" % from_addr
      msg += "To: %s\n" % to_addr
      msg += "Subject: %s\n" % subject
      msg += "\n"
      msg += body

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


###############
# main routine
###############

def main():

   # Command line arguments
   cfile     = ""
   attempts  = 0
   results   = False
   orgonly   = False
   noemail   = False

   # Check the version
   try:
      if map(int, [sys.version_info[0], sys.version_info[1]]) < [2, 2]:
         print "Python version 2.2 or greater required, sorry."
         sys.exit(1)
   except:
      # sys.version_info isn't available before 2.0
      print "Python version 2.2 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) = cmd_line()

   # Wrap execution in a big try block
   try:

      # Read the configuration file
      config = read_config(cfile)

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

      # 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()

