"""quixote.session

Contains various classes related to session management:
SessionCollection, GlobalSession, and ApplicationState.
"""

__revision__ = "$Id: session.py,v 1.13 2000/10/20 17:45:34 gward Exp $"

import sys, string
from time import time, localtime, strftime, strptime, mktime
from whrandom import randint

from quixote import config

def debug(*args):
    for arg in args: print arg,
    print

# This is used both to generate cookies and parse them (when we need
# to figure out how old they are)
TIME_FORMAT = "%Y%m%d:%H%M%S"


class SessionCollection:
    """SessionCollection acts as a dictionary of all sessions, mapping
    session ID strings to individual session objects.  Session objects
    are instances of some class, usually the GlobalSession class.

    Most applications can just use this class directly; sessions will
    be kept in memory-based dictionaries, and will be lost when the
    Quixote process dies.  Alternatively an application can subclass
    SessionCollection to implement specific behaviour, such as persistence.
    """

    cookie_name = config.COOKIE_NAME
    cookie_path = config.COOKIE_PATH
    
    def __init__ (self):
        self.sessions = {}

    # Methods to be overridden by subclasses
    
    def abort(self):
        "Abort all changes to sessions"
        pass

    def commit(self):
        "Commit all changes to sessions"
        pass

    def new_session(self, request, response, id):
        "Return a new instance of the GlobalSession class."
        # Subclasses can override this to return an instance of a different
        # class.
        return GlobalSession(request, response, id)

    
    # Hmmm, looks like a dictionary... smells like a dictionary...
    def keys(self):
        return self.sessions.keys()

    def values(self):
        return self.sessions.keys()

    def items(self):
        return self.sessions.items()

    def get(self, key):
        return self.sessions.get(key)

    def __getitem__(self, key):
        return self.sessions[key]

    def __delitem__ (self, key):
        del self.sessions[key]


    # and here's one real dictionaries *should* have ;-)
    def sorted_keys (self):
        k = self.sessions.keys()

        # Only call sort() if it's present; when using a ZODB BTree,
        # the object returned by keys() doesn't have a sort() method
        if hasattr(k, 'sort'): k.sort()

        return k


    # Now the real methods for real use

    def get_session (self, request, response):
        """Fetch or create a GlobalSession object for the current
           session, and return it.  Uses the "MX_session" cookie to
           get the ID of an existing session; sets a value for this
           cookie if it's not found or doesn't correspond to an
           existing session."""
        
        # XXX when/how should we clean up old sessions?

        id = request.cookies.get(self.cookie_name)
        if id == "" or id == "*del*":
            id = None
        if id is not None:
            session = self.sessions.get(id)
            
        # Hmmm.  This logic provokes slightly odd cookie behaviour in the
        # browser.  Could be worth rethinking it.
        if id is None or session is None:

            # Generate a "session ID", which is just the value of the
            # session cookie we are about to drop on the user
            while id is None or self.sessions.has_key (id):
                # IP address + date/time + random number
                id = ("%s:%s:%08X%08X" %
                      (request['REMOTE_ADDR'],
                       strftime(TIME_FORMAT, localtime(time())),
                       randint(0, sys.maxint-1),
                       randint(0, sys.maxint-1)))
                    
            # If we get here, it means this is the first we have seen of
            # this user in this session (or they've been rejecting our
            # cookies, which means we'll keep coming back here).  So, we
            # create a "global session" object -- this is the session
            # information that crosses application boundaries -- and
            # then drop a cookie on them, so they can get at this global
            # session object (and, more importantly, the bits of
            # application state it contains) later.  We also store the
            # session cookie in the *request*, in case multiple requests
            # are made for it (or one of its application state objects)
            # before we finish this request.  (After this request, the
            # user should be carrying around the cookie anyways.)

            session = self.new_session (request, response, id)
            self.sessions[id] = session
            self.set_session_cookie(request, response, id)

        session.access_time = time()
        return session

    # get_session ()


    def set_session_cookie (self, request, response, session_id):
        assert self.sessions.has_key(session_id), \
               "invalid session ID %s" % `session_id`
        response.setCookie(self.cookie_name, session_id,
                           domain = config.COOKIE_DOMAIN,
                           path = self.cookie_path)
        request.cookies[self.cookie_name] = session_id

    def revoke_session_cookie (self, request, response, session_id):
        assert self.sessions.has_key(session_id), \
               "invalid session ID" % `session_id`
        assert request.cookies[self.cookie_name] == session_id, \
               "not in session %s" % `session_id`
        response.setCookie(self.cookie_name, "*del*",
                           domain = config.COOKIE_DOMAIN,
                           path = self.cookie_path,
                           max_age = 0)
        del request.cookies[self.cookie_name]


    def has_session (self, request, must_exist=0):
        """Return true if the current request already has a cookie
           identifying a global session.  If 'must_exist' is true, the
           cookie must correspond to a currently existing session;
           otherwise (the default), we just check for the existence of
           the session cookie and don't inspect its content at all."""

        id = request.cookies.get(self.cookie_name)
        if id is None:
            return 0
        if must_exist:
            return self.sessions.has_key (id)
        else:
            return 1

    # has_session ()


    def get_app_state (self, request, response, app_id):
        """Fetch or create an application state object for the
           application identified by 'app_id' and the current
           session."""

        session = self.get_session (request, response)
        return session.get_app_state (app_id)


    def del_sessions (self, ip=None, age=None):
        """Delete a subset of open sessions selected according to the
        keyword arguments.  Default values select (and delete) no
        sessions.  Sessions are selected by combining non-None keyword
        args with "and": eg. if 'ip' and 'age' are non-None then any
        session matching *both* the supplied IP address and age (hours
        since session creation) is deleted.
        
        'ip' specifies an IP address (dotted-quad string); it must match
        the IP address of a session exactly.  'age' is the session's age
        (since creation) in hours; any session older than 'age' will
        pass the age test. Again, sessions must pass all specified tests
        (non-None arguments) to be selected and deleted."""
        
        goners = []
        for id in self.sessions.keys():
            (s_ip, s_date, s_time, rand) = string.split (id, ':')
            if ip is not None and ip != s_ip:
                continue
            if age is not None:
                datetime = strptime ("%s:%s" % (s_date, s_time), TIME_FORMAT)
                datetime = mktime (datetime)
            if (time() - datetime) <= float (age):
                continue

            # passed all the tests: we'll delete this sucker
            goners.append (id)

        return goners

    # del_session

# SessionCollection


class GlobalSession:

    """Holds session information that crosses application boundaries,
    and maintains a registry of application state objects where
    individual applications keep their particular session-state
    information.

    Instance attributes:
      id : string
        the session ID (generated by SessionCollection and used as the
        value of the session cookie)
      actual_user : any
        an object to identify the human being on the other end of the
        line.  It's up to you whether to store just a string in 'user',
        or some more complex data structure or object.
      user : any
        the user object used for access control checks.  Usually .user ==
        .actual_user, but for a situation where a user can act on
        behalf of another, you would leave .actual_user alone and
        set .user to another user object.
      creation_time : int
      access_time : int
        two ways of keeping track of the "age" of the whole session
        (without knowing anything about the age of individual
        application state objects).  Note that 'access_time' is
        maintained by the SessionCollection that owns this session.
      app_state : {string : ApplicationState}
        maps application IDs to application state objects (which should
        be instances of ApplicationState subclasses)
      app_creation_time : {string : int}
      app_access_time : {string : int}
        keep track of the creation and last-access time for each
        application state object in 'app_state'

    Feel free to access 'id', 'user', 'creation_time', and 'access_time'
    directly.  'id' and the '_time' attributes must never change;
    if 'user' or 'actual_user' need to change, use 'set_user()' or
    'set_actual_user()'.  All other attributes are private: do not
    access or modify them. 
    """


    # 'app_state_class' - registry of application state classes (so
    # we know how to create the objects that go in the per-instance
    # application state object registry)
    app_state_class = {}


    def __init__ (self, request, response, id):

        self.id = id
        self.user = self.actual_user = None
        self.creation_time = self.access_time = time()
        
        self.app_state = {}
        self.app_creation_time = {}
        self.app_access_time = {}

        debug ("GlobalSession: just created session %s, ctime %s" %
               (self.id,
                strftime ("%Y-%m-%d %H:%M:%S ",
                          localtime (self.creation_time))))
        #debug ("  URL0 = %s" % request.URL0)
        #debug ("  REFERER = %s" % request.HTTP_REFERER)

    # __init__ ()


    def __repr__ (self):
        return "<%s at %x: %s>" % (self.__class__.__name__, id(self), self.id)

    def __str__ (self):
        if self.actual_user is None:
            return "session %s (no user)" % self.id
        elif self.user == self.actual_user:
            return "session %s (user %s)" % (self.id, self.user)
        else:
            return "session %s (user %s working as %s)" % (self.id,
                                                         self.actual_user,
                                                         self.user)
    

    def dump (self, indent='', deep=1, header=1):
        time_fmt = "%Y-%m-%d %H:%M:%S"
        ctime = strftime (time_fmt, localtime (self.creation_time))
        atime = strftime (time_fmt, localtime (self.access_time))

        if header:
            lines = [indent + 'session %s:' % self.id]
            indent = indent + '  '
        else:
            lines = []

        lines.extend (
            [indent + 'user %s' % (self.user),
             indent + 'created %s, last accessed %s' % (ctime, atime)])

        if deep:
            apps = self.app_state.keys()
            if apps:
                lines.append (indent + 'application state objects:')
                apps.sort()
                indent = indent + '  '
                for app in apps:
                    ctime = self.app_creation_time.get(app)
                    if ctime is not None:
                        ctime = strftime (time_fmt, localtime (ctime))
                    atime = self.app_access_time.get(app)
                    if atime is not None:
                        atime = strftime (time_fmt, localtime (atime))

                    lines.append (indent +
                                  '%s: created %s, last accessed %s' %
                                  (app, ctime, atime))
                    app_state = self.app_state[app]
                    try:
                        st_lines = app_state.dump (indent + '  ', header=0)
                    except (AttributeError, NotImplementedError):
                        lines[-1] = lines[-1] + ": no 'dump' method"
                    else:
                        lines.extend (st_lines)

                # for app
            else:
                lines.append (indent + 'no application state objects')
        # if deep

        return lines
    
    # dump()
                            
            
    # -- Simple accessors and modifiers --------------------------------

    def set_actual_user (self, user):
        self.user = self.actual_user = user

    def set_user (self, user):
        self.user = user

    def get_creation_age (self):
        """Return the number of seconds since session was created."""
        return time() - self.creation_time

    def get_access_age (self):
        """Return the number of seconds since session was last accessed."""
        return time() - self.access_time


    # -- More involved stuff -------------------------------------------

    def get_app_state (self, app_id):

        state = self.app_state.get(app_id)
        if state is None:
            klass = self.app_state_class.get(app_id)
            if klass is None:
                raise RuntimeError, \
                      "no application state class has been registered " + \
                      "for application '%s'" % app_id

            state = klass ()
            # XXX restore this check (using mems.lib.base.isinstance?)!
            if 0 and not isinstance (state, ApplicationState):
                raise RuntimeError, \
                      ("class %s (for application '%s') " +
                       "not a subclass of ApplicationState") % (klass, app_id)

            self.app_state[app_id] = state
            self.app_creation_time[app_id] = \
                self.app_access_time[app_id] = time()

        else:
            self.app_access_time[app_id] = time()

        return state

    # get_app_state ()

# GlobalSession


class ApplicationState:
    """Defines the interface that application state classes must
       implement."""

    
    def __init__ (self):
        """Constructor takes no arguments, and just initializes all
           application session state structures."""

        raise NotImplementedError, \
              "ApplicationState should not be instantiated directly"

    def cleanup (self):
        """Destroy all application state data in memory without regard
           to persistence: the goal here is to free up memory, not to
           commit session data anywhere."""

        raise NotImplementedError, \
              "ApplicationState subclasses must implement 'cleanup()' method."

    def dump (self, indent='', header=1, deep=1):
        """Dump all relevant and interesting information in this application
           state object.  If 'header' is true (the default), the first line
           of the dump will identify the object, and the rest of the
           dump will be indented deeper than the header line.  Otherwise,
           the initial line is skipped and the whole dump is indented by
           'indent'.  If 'deep' is true, we'll dump contained objects
           that might be bulky or non-essential; otherwise, we'll skip
           dumping them."""

        raise NotImplementedError, \
              "ApplicationState subclasses must implement 'dump()' method."
        

# ApplicationState


def register_app_state_class (klass):
    try:
        app_id = klass.app_id
    except AttributeError:
        raise RuntimeError, \
              "application state class '%s' must define 'app_id' attribute" % \
              klass.__name__
    GlobalSession.app_state_class[app_id] = klass


#
# Pair of functions for getting and setting the singleton SessionCollection
# object.
# 

_session_coll = None

def get_session_collection ():
    global _session_coll
    if _session_coll is None:
        raise RuntimeError, "No session collection object has been set"
    
    return _session_coll

def set_session_collection (collection):
    global _session_coll
    if _session_coll is not None:
        raise RuntimeError, "Session collection object has already been set"
    _session_coll = collection
    
# These are the functions commonly used by callers.
def get_session (request, response):
    return get_session_collection().get_session (request, response)

def has_session (request=None, must_exist=0):
    return get_session_collection().has_session (request, must_exist)

def get_app_state (request, response, app_id):
    if app_id is None:
        raise RuntimeError, "app_id must be supplied"
    return get_session_collection().get_app_state (request, response, app_id)
