# XXX Hacked to work under Quixote

"""quixote.session

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

__revision__ = "$Id: session.py,v 1.1 2000/05/11 19:26:44 akuchlin Exp $"

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

import config

def debug(*args):
    pass


# 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"

# This is it, the dictionary of all sessions: maps session ID strings to
# individual session objects.  Session objects are instances of some class.

class SessionCollection:

    cookie_name = config.COOKIE_NAME
    cookie_path = config.COOKIE_PATH
    

    def __init__ (self):
        self.sessions = {}

    # 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()
        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 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:%s" % (request['REMOTE_ADDR'],
                                   strftime (TIME_FORMAT, localtime(time())),
                                   randint (1000000, 9999999))
                    
            # 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.sessions[id] = GlobalSession (request, response, id)
            response.setCookie(self.cookie_name, id,
                               domain = config.COOKIE_DOMAIN,
                               path = self.cookie_path)
            request.cookies[self.cookie_name] = id

        session.access_time = time()
        return session
                
    # get_session ()


    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,
       as well as maintaining a registry of application state objects
       where individual applications keep their particular session-state
       information."""


    # '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):

        # id is the session ID (generated by SessionCollection and used
        # as the value of the session cookie)
        self.id = id

        # 'user_id' and 'group' identify the human being on the other end
        # of the line (and what hat they're wearing today); 'user_id'
        # non-None implies the user is authenticated under that ID.
        self.user_id = None
        self.group = None

        # 'creation_time' and 'access_time' are two ways of keeping
        # track of the "age" of the whole session (without knowing
        # anything about the age of individual application state
        # objects)
        self.creation_time = self.access_time = time()

        # 'app_state' maps application IDs to application state objects
        # (which should be instances of ApplicationState subclasses)
        self.app_state = {}

        # 'app_creation_time' and 'app_access_time' keep track of the
        # creation and last-access time for each application state
        # object in '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 __str__ (self):
        return "%s (user %s in group %s)" % (self.id, self.user_id, self.group)
    

    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, group %s' % (self.user_id, self.group),
             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()
                            
            
    def set_user_id (self, user_id):
        self.user_id = user_id

    def set_group (self, group):
        self.group = group
        

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


SESSION_COLL = None

def get_session_collection ():
    global SESSION_COLL
    if SESSION_COLL is None:
        SESSION_COLL = SessionCollection ()
    
    return SESSION_COLL
