Commits

Chris Miles committed 29a1c1b

Adding utility modules, widgets and fixing model. Basic functional tests now pass.

  • Participants
  • Parent commits 9c20da2
  • Branches rebuild_in_pylons

Comments (0)

Files changed (12)

chronr/chronr/config/routing.py

     map.connect('/error/{action}/{id}', controller='error')
 
     map.connect('/', controller='home', action='index')
+    map.connect('/about', controller='home', action='about')
+    map.connect('/event', controller='accounts', action='event')    # legacy
     map.connect('/accounts', controller='accounts', action='index')
     
     map.connect('/{controller}/{action}')

chronr/chronr/controllers/home.py

 import logging
 
-from pylons import request, response, session, tmpl_context as c
+from pylons import request, response, session, tmpl_context as c, url
 from pylons.controllers.util import abort, redirect_to
 
 from chronr.lib.base import BaseController, render
+from chronr.lib.widgets.event_widgets import EventSummary, group_events
+from chronr.model import Event
 
 log = logging.getLogger(__name__)
 
+event_summary_widget = EventSummary()
+
 class HomeController(BaseController):
     def index(self):
+        events = Event.get_next(20)
+        
+        c.event_summary_widget = event_summary_widget
+        c.grouped_events = group_events(events)
         return render('/home.mako')
     
+    def about(self):
+        return render('/about.mako')
+
+    def event(self):
+        # legacy
+        redirect_to(url('/events'))
+    
+

chronr/chronr/lib/odict.py

+# encoding: utf-8
+
+'''odict - based on http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/107747
+'''
+
+__author__ = 'Chris Miles'
+__copyright__ = '(c) Chris Miles 2008. All rights reserved.'
+__id__ = '$Id$'
+__url__ = '$URL$'
+
+
+# ---- Imports ----
+
+# - Python modules -
+from UserDict import UserDict
+
+
+# ---- Classes ----
+
+class odict(UserDict):
+    def __init__(self, dict = None):
+        self._keys = []
+        UserDict.__init__(self, dict)
+    
+    def __delitem__(self, key):
+        UserDict.__delitem__(self, key)
+        self._keys.remove(key)
+    
+    def __setitem__(self, key, item):
+        UserDict.__setitem__(self, key, item)
+        if key not in self._keys: self._keys.append(key)
+    
+    def clear(self):
+        UserDict.clear(self)
+        self._keys = []
+    
+    def copy(self):
+        dict = UserDict.copy(self)
+        dict._keys = self._keys[:]
+        return dict
+    
+    def items(self):
+        return zip(self._keys, self.values())
+    
+    def keys(self):
+        return self._keys
+    
+    def popitem(self):
+        try:
+            key = self._keys[-1]
+        except IndexError:
+            raise KeyError('dictionary is empty')
+        
+        val = self[key]
+        del self[key]
+        
+        return (key, val)
+    
+    def setdefault(self, key, failobj = None):
+        r = UserDict.setdefault(self, key, failobj)
+        if key not in self._keys:
+            self._keys.append(key)
+        return r
+    
+    def update(self, dict):
+        UserDict.update(self, dict)
+        for key in dict.keys():
+            if key not in self._keys: self._keys.append(key)
+    
+    def values(self):
+        return map(self.get, self._keys)
+    
+

chronr/chronr/lib/texttime.py

+# -*- coding: utf-8 -*-
+
+# Reference: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/498062
+
+from datetime import timedelta
+
+# Set this to the language you want to use.
+LANG = "en"
+
+# Singular and plural forms of time units in your language.
+unit_names = dict(sv = {"year" : ("år", "år"),
+                        "month" : ("månad", "månader"),
+                        "week" : ("vecka", "veckor"),
+                        "day" : ("dag", "dagar"),
+                        "hour" : ("timme", "timmar"),
+                        "minute" : ("minut", "minuter"),
+                        "second" : ("sekund", "sekunder")},
+                  en = {"year" : ("year", "years"),
+                        "month" : ("month", "months"),
+                        "week" : ("week", "weeks"),
+                        "day" : ("day", "days"),
+                        "hour" : ("hour", "hours"),
+                        "minute" : ("minute", "minutes"),
+                        "second" : ("second", "seconds")})
+                  
+num_repr = dict(sv = {1 : "en",
+                      2 : "två",
+                      3 : "tre",
+                      4 : "fyra",
+                      5 : "fem",
+                      6 : "sex",
+                      7 : "sju",
+                      8 : "åtta",
+                      9 : "nio",
+                      10 : "tio",
+                      11 : "elva",
+                      12 : "tolv"},
+                en = {1 : "one",
+                      2 : "two",
+                      3 : "three",
+                      4 : "four",
+                      5 : "five",
+                      6 : "six",
+                      7 : "seven",
+                      8 : "eight",
+                      9 : "nine",
+                      10 : "ten",
+                      11 : "eleven",
+                      12 : "twelve"})
+
+def amount_to_str(amount, unit_name):
+    # This is the Swedish hack. The Swedish language has two words for
+    # "one" - "en" and "ett". Sometimes "en" is used and other times
+    # "ett" is used. For the word "år," "ett" is used instead of "en."
+    # No doubt other languages contain similar weirdness.
+    if amount == 1 and unit_name == "year" and LANG == "sv":
+        return "ett"
+    if amount in num_repr[LANG]:
+        return num_repr[LANG][amount]
+    return str(amount)
+
+def seconds_in_units(seconds, parts=1):
+    """
+    TODO: this docstring needs to be updated...
+    
+    Returns a tuple containing the most appropriate unit for the
+    number of seconds supplied and the value in that units form.
+
+        >>> seconds_in_units(7700)
+        (2, 'hour')
+    """
+    unit_limits = [
+        ("year", 365 * 24 * 3600),
+        ("month", 30 * 24 * 3600),
+        ("week", 7 * 24 * 3600),
+        ("day", 24 * 3600),
+        ("hour", 3600),
+        ("minute", 60),
+        ("second", 1)
+    ]
+    result = []
+    for unit_name, limit in unit_limits:
+        if len(result) >= parts:
+            break
+        if seconds >= limit:
+            # amount = int(round(float(seconds) / limit))
+            amount = int(float(seconds) / limit)
+            seconds = seconds - (amount * limit)
+            result.append(
+                (amount, unit_name)
+            )
+        elif len(result) > 0:
+            break
+        
+    # else:
+    #     result.append(
+    #         (seconds, "second")
+    #     )
+    return result
+
+def stringify(td):
+    """
+    Converts a timedelta into a nicely readable string.
+    
+        >>> td = timedelta(days = 77, seconds = 5)
+        >>> print readable_timedelta(td)
+        two months
+    """
+    seconds = td.days * 3600 * 24 + td.seconds
+    
+    units = seconds_in_units(seconds, parts=2)
+    
+    # Localize it.
+    stringified = ""
+    for amount, unit_name in units:
+        i18n_amount = amount_to_str(amount, unit_name)
+        i18n_unit = unit_names[LANG][unit_name][1]
+        if amount == 1:
+            i18n_unit = unit_names[LANG][unit_name][0]
+        if stringified:
+            stringified += ' '
+        stringified += "%s %s" % (i18n_amount, i18n_unit)
+    
+    return stringified
+
+def test(td):
+    if td.days > 100:
+        fmt = "In %s, it's a long time. (%s)"
+    elif td.days > 4:
+        fmt = "I've only got %s to finish the project. (%s)"
+    elif td.days > 0:
+        fmt = "The party was %s ago. (%s)"
+    elif td.seconds > 3600:
+        fmt = "Something weird happened %s ago. (%s)"
+    elif td.seconds > 60:
+        fmt = "The train arrives in %s. (%s)"
+    else:
+        fmt = "%s passes fast. (%s)"
+    print fmt % (stringify(td), str(td))
+
+def main():
+    global LANG
+    LANG = "en"
+    test(timedelta(weeks = 7, days = 3))
+    test(timedelta(weeks = 1))
+    test(timedelta(days = 1000))
+    test(timedelta(days = 400))
+    test(timedelta(days = 4))
+    test(timedelta(seconds = 2000))
+    test(timedelta(seconds = 9888))
+    test(timedelta(seconds = 999888))
+    test(timedelta(seconds = 999))
+    test(timedelta(seconds = 99))
+    test(timedelta(seconds = 45))
+    test(timedelta(seconds = 3))
+
+if __name__ == "__main__":
+    main()

chronr/chronr/lib/widgets/__init__.py

Empty file added.

chronr/chronr/lib/widgets/event_widgets.py

+# encoding: utf-8
+
+'''event_widgets
+'''
+
+__author__ = 'Chris Miles'
+__copyright__ = '(c) Chris Miles 2008. All rights reserved.'
+
+
+# ---- Imports ----
+
+# - Python modules -
+from datetime import datetime, timedelta
+
+# try:
+#     import json     # Python 2.6+
+# except ImportError:
+#     import simplejson as json
+
+# - ToscaWidgets modules -
+from tw.api import Widget, WidgetsList
+import tw.forms
+
+# - Misc modules -
+import pytz
+
+# - Project modules -
+from chronr.lib.odict import odict
+from chronr.lib import texttime
+from chronr.model import Event, EVENT_TITLE_SIZE
+
+
+# ---- Constants ----
+
+MONTHS = {
+  1 : 'January',
+  2 : 'February',
+  3 : 'March',
+  4 : 'April',
+  5 : 'May',
+  6 : 'June',
+  7 : 'July',
+  8 : 'August',
+  9 : 'September',
+  10: 'October',
+  11: 'November',
+  12: 'December',
+}
+
+EVENT_FORM_TITLE_SIZE = 64
+EVENT_FORM_TAGS_SIZE = 64
+EVENT_FORM_DURATION_SIZE = 5
+EVENT_FORM_DESCRIPTION_ROWS = 6
+EVENT_FORM_DESCRIPTION_COLS = 63
+
+
+# ---- JSONify Rules ----
+
+# @jsonify.when("isinstance(obj, Event)") 
+# def jsonify_Event(ev): 
+#     return dict(
+#         description = ev.description,
+#         duedate = ev.duedate,
+#         id = ev.id,
+#         time_remaining = ev.time_remaining,
+#         timezone = ev.timezone,
+#         title = ev.title,
+#     )
+# 
+# # from chronr.odict import odict
+# @jsonify.when("isinstance(obj, odict)") 
+# def jsonify_events_odict(obj):
+#     # return dict(obj)
+#     return [{'group':k, 'events':v} for k,v in obj.items()]
+# 
+# @jsonify.when("isinstance(obj, widgets.Widget)") 
+# def jsonify_Event(obj): 
+#     return None
+
+
+
+# ---- Widgets ----
+
+class EventList(Widget):
+    template = "chronr.templates.event_list_widget"
+    params = ["events"]
+
+
+class EventSummary(Widget):
+    template = "chronr.templates.event_summary_widget"
+    params = ["events"]
+
+
+# --- Form Widgets ----
+
+class CreateEventFields(WidgetsList):
+    title = tw.forms.TextField('title',
+            label="Title",
+            help_text="The title of this event.",
+            attrs={'size': EVENT_FORM_TITLE_SIZE, 'maxlength': EVENT_TITLE_SIZE}
+    )
+    
+    duedate = tw.forms.CalendarDateTimePicker('duedate',
+            label="Due Date & Time",
+            help_text="When is this event due?"
+    )
+    
+    timezone = tw.forms.SingleSelectField('timezone',
+            label="Timezone",
+            help_text="Timezone the due date is relative to.",
+            options=[(tz,tz) for tz in pytz.common_timezones]
+    )
+    
+    allday = tw.forms.CheckBox('allday',
+            label="All day?",
+            help_text="Does this event cover the whole day (i.e. no specific time)?"
+    )
+    
+    duration = tw.forms.TextField('duration',
+            label="Duration",
+            help_text="Event duration in minutes (only if not an \"all day\" event).",
+            attrs={'size': EVENT_FORM_DURATION_SIZE}
+    )
+    
+    description = tw.forms.TextArea('description',
+            label="Description",
+            help_text="Detailed description of this event.",
+            attrs={'rows': EVENT_FORM_DESCRIPTION_ROWS, 'cols': EVENT_FORM_DESCRIPTION_COLS}
+    )
+    
+    private = tw.forms.CheckBox('private',
+            label="Private",
+            help_text="Is this event private (not visible to any other users)?"
+    )
+    
+    tags = tw.forms.TextField('tags',
+            label="Tags",
+            help_text="Separate tags by commas (example \"birthdays, friends\").",
+            attrs={'size': EVENT_FORM_TAGS_SIZE}
+    )
+
+
+class EditEventFields(WidgetsList):
+    id = tw.forms.HiddenField('id',
+    )
+    
+    title = tw.forms.TextField('title',
+            label="Title",
+            help_text="The title of this event.",
+            attrs={'size': EVENT_FORM_TITLE_SIZE, 'maxlength': EVENT_TITLE_SIZE}
+    )
+    
+    duedate = tw.forms.CalendarDateTimePicker('duedate',
+            label="Due Date & Time",
+            help_text="When is this event due?"
+    )
+    
+    timezone = tw.forms.SingleSelectField('timezone',
+            label="Timezone",
+            help_text="Timezone the due date is relative to.",
+            options=[(tz,tz) for tz in pytz.common_timezones]
+    )
+    
+    allday = tw.forms.CheckBox('allday',
+            label="All day?",
+            help_text="Does this event cover the whole day (i.e. no specific time)?"
+    )
+    
+    duration = tw.forms.TextField('duration',
+            label="Duration",
+            help_text="Event duration in minutes (only if not an \"all day\" event).",
+            attrs={'size': EVENT_FORM_DURATION_SIZE}
+    )
+    
+    description = tw.forms.TextArea('description',
+            label="Description",
+            help_text="Detailed description of this event.",
+            attrs={'rows': EVENT_FORM_DESCRIPTION_ROWS, 'cols': EVENT_FORM_DESCRIPTION_COLS}
+    )
+    
+    private = tw.forms.CheckBox('private',
+            label="Private",
+            help_text="Is this event private (not visible to any other users)?"
+    )
+    
+    tags = tw.forms.TextField('tags',
+            label="Tags",
+            help_text="Separate tags by commas (example \"birthdays, friends\").",
+            attrs={'size': EVENT_FORM_TAGS_SIZE}
+    )
+    
+    _method = tw.forms.HiddenField('_method')
+
+
+class DeleteEventFields(WidgetsList):
+    id = tw.forms.HiddenField('id')
+    
+    confirm = tw.forms.CheckBox('confirm',
+            label="Confirm",
+    )
+    
+    _method = tw.forms.HiddenField('_method')
+
+
+class EditEventTagsFields(WidgetsList):
+    tags = tw.forms.TextField('tags',
+            label="Tags",
+            help_text="Separate tags by commas (example \"birthdays, friends\")."
+    )
+
+
+class EventTableForm(tw.forms.TableForm):
+    # template = 'chronr.templates.event_tabletemplate'
+    pass
+
+
+# ---- Validators ----
+
+class CreateEventSchema(tw.forms.validators.Schema):    
+    title = tw.forms.validators.UnicodeString(not_empty=True, max=EVENT_TITLE_SIZE, strip=True)
+    duedate = tw.forms.validators.DateTimeConverter(not_empty=True)
+    timezone = tw.forms.validators.UnicodeString(not_empty=True, max=64, strip=True)
+    allday = tw.forms.validators.Bool()
+    duration = tw.forms.validators.Int()
+    description = tw.forms.validators.UnicodeString(strip=True)
+    private = tw.forms.validators.Bool()
+    tags = tw.forms.validators.UnicodeString(max=4096, strip=True)
+
+class EditEventSchema(tw.forms.validators.Schema):    
+    id = tw.forms.validators.Number(not_empty=True)
+    title = tw.forms.validators.UnicodeString(not_empty=True, max=EVENT_TITLE_SIZE, strip=True)
+    duedate = tw.forms.validators.DateTimeConverter(not_empty=True)
+    timezone = tw.forms.validators.UnicodeString(not_empty=True, max=64, strip=True)
+    allday = tw.forms.validators.Bool()
+    duration = tw.forms.validators.Int()
+    description = tw.forms.validators.UnicodeString(strip=True)
+    private = tw.forms.validators.Bool()
+    tags = tw.forms.validators.UnicodeString(max=4096, strip=True)
+    _method = tw.forms.validators.UnicodeString()
+
+class DeleteEventSchema(tw.forms.validators.Schema):    
+    id = tw.forms.validators.Number(not_empty=True)
+    confirm = tw.forms.validators.Bool()
+    _method = tw.forms.validators.UnicodeString()
+
+class EditEventTagsSchema(tw.forms.validators.Schema):    
+    tags = tw.forms.validators.UnicodeString(max=4096, strip=True)
+
+
+
+# ---- Form objects ----
+
+create_event_form = EventTableForm(
+    fields = CreateEventFields(),
+    validator = CreateEventSchema()
+)
+
+edit_event_form = EventTableForm(
+    fields = EditEventFields(),
+    validator = EditEventSchema()
+)
+
+edit_event_tags_form = EventTableForm(
+    fields = EditEventTagsFields(),
+    validator = EditEventTagsSchema()
+)
+
+delete_event_form = EventTableForm(
+    fields = DeleteEventFields(),
+    validator = DeleteEventSchema()
+)
+
+
+
+# ---- Functions ----
+
+def group_events(events):
+    now = datetime.utcnow()
+    
+    grouped_events = odict()
+    for event in events:
+        if event.duedate.date() == now.date():
+            grouped_events.setdefault('Today', []).append(event)
+        
+        elif event.duedate.date() == now.date() + timedelta(days=1):
+            grouped_events.setdefault('Tomorrow', []).append(event)
+        
+        elif (event.duedate - now).days < 7 and event.duedate.weekday() > now.weekday():
+            grouped_events.setdefault('This week', []).append(event)
+        
+        elif event.duedate.month == now.month and event.duedate.year == now.year:
+            grouped_events.setdefault('This month', []).append(event)
+        
+        elif event.duedate.year == now.year:
+            grouped_events.setdefault(MONTHS[event.duedate.month], []).append(event)
+        
+        else:
+            grouped_events.setdefault("%s %d" %(MONTHS[event.duedate.month], event.duedate.year), []).append(event)
+        
+        event.time_remaining = texttime.stringify(event.duedate - now)
+    
+    return grouped_events

chronr/chronr/model/__init__.py

 from colossusmodel import define_model
 from chronr.model import meta
 
+
+# ---- Constants ----
+
+EVENT_TITLE_SIZE = 255
+
+
+# ---- Globals ----
+
 log = logging.getLogger(__name__)
 
+
+# ---- Functions ----
+
 def init_model(engine, auto_schema_update=False):
     """Call me before using any of the tables or classes in the model"""
     ## Reflected tables must be defined and mapped here
     
     schema_mgr = SchemaManager()
     schema_mgr.register(1, upgrade=schema_version_1_upgrade, downgrade=schema_version_1_downgrade)
+    schema_mgr.register(2, upgrade=schema_version_2_upgrade, downgrade=schema_version_2_downgrade)
     
     schemabot = SchemaBot(schema_mgr, engine=engine)
     (model_version, current_db_version) = schemabot.version_check()
             raise Exception("SchemaBot reports that the model version (%d) does not match the DB schema version (%d). Please run 'paster setup-app config.ini' to update the database schema." %(model_version, current_db_version))
 
 
-## Non-reflected tables may be defined and mapped at module level
+# ---- Model ----
+
+# - Schema version 1 -
+
 user_table = sa.Table('user', meta.metadata,
     sa.Column('id', sa.Integer, primary_key=True),
     sa.Column('user_name', sa.Unicode(16), unique=True),
 schema_version_1_downgrade = [user_table]
 
 
+# - Schema version 2 -
+
+events_table = sa.Table('events', meta.metadata,
+    sa.Column('id', sa.Integer, primary_key=True),
+    sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id'), nullable=False),
+    sa.Column('title', sa.Unicode(EVENT_TITLE_SIZE), nullable=False),
+    sa.Column('duedate', sa.DateTime(timezone=False), nullable=False),    # date/time (UTC)
+    sa.Column('timezone', sa.Unicode(256)),
+    sa.Column('duration', sa.Integer),                    # minutes
+    sa.Column('allday', sa.Boolean),
+    sa.Column('description', sa.Unicode),
+    sa.Column('private', sa.Boolean),
+)
+
+following_table = sa.Table('following', meta.metadata,
+    sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id',
+        onupdate='CASCADE', ondelete='CASCADE')),
+    sa.Column('event_id', sa.Integer, sa.ForeignKey('events.id',
+        onupdate='CASCADE', ondelete='CASCADE'))
+)
+
+tags_table = sa.Table('tags', meta.metadata,
+    sa.Column('id', sa.Integer, primary_key=True),
+    sa.Column('name', sa.Unicode(32), index=True, nullable=False, unique=True),
+)
+
+# many_to_many(tags/events/user)
+tag_event_user_table = sa.Table('tag_event_user', meta.metadata,
+    sa.Column('tag_id', sa.Integer, sa.ForeignKey('tags.id')),
+    sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id')),
+    sa.Column('event_id', sa.Integer, sa.ForeignKey('events.id')),
+    sa.UniqueConstraint('tag_id', 'user_id', 'event_id'),
+)
+
+schema_version_2_upgrade = [events_table, following_table, tags_table, tag_event_user_table]
+schema_version_2_downgrade = [events_table, following_table, tags_table, tag_event_user_table]
+
+
 # ---- ORM Mapped Classes ----
 
 class User(object):
+    @classmethod
+    def by_email_address(cls, email):
+        """
+        A class method that can be used to search users
+        based on their email addresses since it is unique.
+        """
+        return cls.query.filter_by(email_address=email).first()
+    
+    @classmethod
+    def by_user_name(cls, username):
+        """
+        A class method that permits to search users
+        based on their user_name attribute.
+        """
+        return cls.query.filter_by(user_name=username).first()
+    
+    def isfollowing(self, event_id):
+        """Return True if user is following event_id.
+        """
+        if event_id in self.following:
+            return True
+        return False
+    
+    def num_following(self):
+        """Return the number of events the user is following."""
+        return len(self.following)
+    
+    @classmethod
+    def get_by_id(cls, user_id):
+        """Returns a User object representing the user
+        record for `user_id`.
+        """
+        query = meta.Session().query(User)
+        u = query.get(user_id)
+        return u
+    
     def validate_password(self, password):
         """The given password is hashed and compared against the one
         stored in the database.  Returns True if they are equal, else
         return self.password == hashed_password
     
 
+
+class Event(object):
+    """An event with a due date and various other useful info.
+    
+    ORM mapped to the events table.
+    
+    The core data type of the chronr app.
+    """
+    def add_tags_for_user(self, tags, user_id):
+        """Tag the event with all strings in the list provided by
+        `tags` for the user specified by `user_id`.
+        """
+        for tag in tags:
+            t = Tag.get_existing_or_new(tag)
+            u = User.get_by_id(user_id)
+            
+            # associate tag with the event
+            assoc = TagAssociation()
+            assoc.tag = t
+            assoc.user = u
+            self.tags.append(assoc)
+    
+    @property
+    def local_duedate(self):
+        """Return the due date in the local timezone (rather than UTC).
+        """
+        return utc_to_tz(self.duedate, self.timezone)
+    
+    @classmethod
+    def get_next(self, n=10, username=None, qfilter=None, following=False):
+        if following and username is None:
+            raise ValueError("username must be specified when following is True")
+            
+        query = meta.Session().query(Event)
+        
+        if following and username:
+            # Filter to events that are being followed by user
+            user = User.by_user_name(username)
+            if user is None:
+                raise ValueError("No such username '%s'" %username)
+            query = query.filter_by(followers=user)
+        
+        elif username is not None:
+            # Filter to events created by user
+            query = query.join('user').filter_by(user_name=username)
+        
+        if qfilter:
+            # Add custom query filter
+            query = query.filter(qfilter)
+        
+        query = query.filter(Event.duedate >= datetime.utcnow()).order_by(Event.duedate).limit(n)
+        return query
+    
+
+
+class Tag(object):
+    """A text string used to classify events.  Each tag for an event
+    is linked to the user who tagged it.
+    """
+    @classmethod
+    def get_by_name(cls, name):
+        """Return Tag object matching `name`.  If it doesn't
+        exist then None is returned
+        """
+        query = meta.Session().query(Tag)
+        try:
+            # Fetch existing tag, if it exists
+            t = query.filter(Tag.c.name == name).one()
+        except InvalidRequestError:
+            t = None
+        
+        return t
+    
+    @classmethod
+    def get_existing_or_new(cls, tag):
+        """Return Tag object matching `tag`.  If it doesn't
+        exist then create a new row in DB and return the
+        corresponding Tag object.
+        """
+        session = meta.Session()
+        query = session.query(Tag)
+        try:
+            # Fetch existing tag, if it exists
+            t = query.filter(Tag.c.name == tag).one()
+        except InvalidRequestError:
+            # Create new tag
+            t = Tag(name=tag)
+            session.save(t)
+            session.commit()
+        
+        return t
+    
+    @classmethod
+    def remove_for_user_and_event(cls, user_id, event_id):
+        query = meta.Session().query(Event)
+        event = query.get(event_id)
+        
+        if event is None:
+            raise ValueError
+        
+        for assoc in event.tags:
+            if assoc.user_id == user_id:
+                meta.Session().delete(assoc)
+    
+    @classmethod
+    def tags_for_event(cls, event_id):
+        """Return list of all Tag objects related to Event specified
+        by `event_id`.
+        """
+        query = meta.Session().query(Tag).filter(TagAssociation.c.tag_id == Tag.c.id).filter(TagAssociation.c.event_id == event_id)
+        return query.all()
+    
+    @classmethod
+    def tags_for_user(cls, user_id):
+        """Return list of all Tag objects related to User specified
+        by `user_id`.
+        """
+        query = meta.Session().query(Tag).filter(TagAssociation.c.tag_id == Tag.c.id).filter(TagAssociation.c.user_id == user_id)
+        return query.all()
+    
+
+
+class TagAssociation(object):
+    pass
+
+
+
 orm.mapper(User, user_table)
 
+orm.mapper(Event, events_table,
+    properties = dict(
+        tags = sa.orm.relation(TagAssociation, lazy=False, cascade="all, delete-orphan"),
+        user = sa.orm.relation(User, backref='events'),
+        followers = sa.orm.relation(User, secondary=following_table, backref='following')
+    ),
+)
+
+orm.mapper(Tag, tags_table,
+    # properties = dict(
+    #     # users = relation(User, secondary=tag_event_user_table, backref='tags'),
+    #     # events = relation(Event, secondary=tag_event_user_table, backref='tags'),
+    # )
+)
+
+# Associate an Event with a (Tag, User) pair.
+orm.mapper(TagAssociation, tag_event_user_table,
+    primary_key=[
+        tag_event_user_table.c.event_id,
+        tag_event_user_table.c.tag_id,
+        tag_event_user_table.c.user_id,
+    ],
+    properties = dict(
+        tag = sa.orm.relation(Tag, lazy=False),
+        user = sa.orm.relation(User, lazy=False, backref='tags'),
+    )
+)
+
+
 
 ## Classes for reflected tables may be defined here, but the table and
 ## mapping itself must be done in the init_model function

chronr/chronr/tests/functional/test_account.py

 class TestAccountController(TestController):
 
     def test_index(self):
-        response = self.app.get(url(controller='account', action='index'))
+        response = self.app.get(url(controller='account', action='login'))
         # Test response...

chronr/data/templates/account/login.mako.py

+from mako import runtime, filters, cache
+UNDEFINED = runtime.UNDEFINED
+__M_dict_builtin = dict
+__M_locals_builtin = locals
+_magic_number = 5
+_modified_time = 1248261681.599565
+_template_filename='/Users/chris/src/chronr-hg-rebuild_in_pylons/chronr/chronr/templates/account/login.mako'
+_template_uri='/account/login.mako'
+_template_cache=cache.Cache(__name__, _modified_time)
+_source_encoding='utf-8'
+from webhelpers.html import escape
+_exports = ['title']
+
+
+def _mako_get_namespace(context, name):
+    try:
+        return context.namespaces[(__name__, name)]
+    except KeyError:
+        _mako_generate_namespaces(context)
+        return context.namespaces[(__name__, name)]
+def _mako_generate_namespaces(context):
+    pass
+def _mako_inherit(template, context):
+    _mako_generate_namespaces(context)
+    return runtime._inherit_from(context, '/base/base-index.mako', _template_uri)
+def render_body(context,**pageargs):
+    context.caller_stack._push_frame()
+    try:
+        __M_locals = __M_dict_builtin(pageargs=pageargs)
+        __M_writer = context.writer()
+        # SOURCE LINE 1
+        __M_writer(u'\n\n')
+        # SOURCE LINE 3
+        __M_writer(u'\n\n<form action="/account/dologin" method="POST">\n  Username: <input type="text" name="login" value="" />\n  <br />\n  Password: <input type="password" name="password" value ="" />\n  <br />\n  <input type="submit" value="Login" />\n</form>\n')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
+def render_title(context):
+    context.caller_stack._push_frame()
+    try:
+        __M_writer = context.writer()
+        # SOURCE LINE 3
+        __M_writer(u'Login')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+

chronr/data/templates/base/base-index.mako.py

+from mako import runtime, filters, cache
+UNDEFINED = runtime.UNDEFINED
+__M_dict_builtin = dict
+__M_locals_builtin = locals
+_magic_number = 5
+_modified_time = 1248261675.0158801
+_template_filename='/Users/chris/src/chronr-hg-rebuild_in_pylons/chronr/chronr/templates/base/base-index.mako'
+_template_uri='/base/base-index.mako'
+_template_cache=cache.Cache(__name__, _modified_time)
+_source_encoding='utf-8'
+from webhelpers.html import escape
+_exports = ['head', 'rightcontent']
+
+
+def render_body(context,**pageargs):
+    context.caller_stack._push_frame()
+    try:
+        __M_locals = __M_dict_builtin(pageargs=pageargs)
+        url = context.get('url', UNDEFINED)
+        h = context.get('h', UNDEFINED)
+        self = context.get('self', UNDEFINED)
+        next = context.get('next', UNDEFINED)
+        __M_writer = context.writer()
+        # SOURCE LINE 2
+        __M_writer(u'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"\n "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n<head>\n  <title>chronr - ')
+        # SOURCE LINE 7
+        __M_writer(escape(self.title()))
+        __M_writer(u'</title>\n  <link href="/css/style.css" type="text/css" rel="stylesheet" />\n\n  ')
+        # SOURCE LINE 10
+        __M_writer(escape(self.head()))
+        __M_writer(u'\n</head>\n\n<body>\n  <div id="header">\n    <a href="')
+        # SOURCE LINE 15
+        __M_writer(escape(url('/')))
+        __M_writer(u'"><div id="header_logo"></div></a>\n    <div id="user_login">\n')
+        # SOURCE LINE 17
+        if h.user():
+            # SOURCE LINE 18
+            __M_writer(u'        ')
+            __M_writer(escape(h.user().display_name or h.user().user_name))
+            __M_writer(u'.\n        <a href="/account/logout">Logout</a> \n')
+            # SOURCE LINE 20
+        else:
+            # SOURCE LINE 21
+            __M_writer(u'      <a href="/account/login?came_from=')
+            __M_writer(escape(h.url_for()))
+            __M_writer(u'">Login</a>\n')
+        # SOURCE LINE 23
+        __M_writer(u'    </div>  <!-- id="user_login" -->\n  </div>  <!-- id="header" -->\n  \n  <div id="nav_strip">\n    <ul>\n      <li> <a href="/nav1">Nav Item 1</a> </li>\n      <li> <a href="/nav2">Nav Item 2</a> </li>\n    </ul>\n  </div>  <!-- id="nav_strip" -->\n  \n  <div id="maincontent">\n  ')
+        # SOURCE LINE 34
+        __M_writer(escape(next.body()))
+        __M_writer(u'\n  </div>  <!-- id="maincontent" -->\n\n  <div id="rightcontent">\n    ')
+        # SOURCE LINE 38
+        __M_writer(escape(self.rightcontent()))
+        __M_writer(u'\n  </div>    <!-- id="rightcontent" -->\n\n  <div id="footer">\n    &copy; Copyright <a href="#">You</a> 2009.\n  </div>  <!-- id="footer" -->\n</body>\n\n')
+        # SOURCE LINE 46
+        __M_writer(u'\n')
+        # SOURCE LINE 47
+        __M_writer(u'\n')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
+def render_head(context):
+    context.caller_stack._push_frame()
+    try:
+        __M_writer = context.writer()
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
+def render_rightcontent(context):
+    context.caller_stack._push_frame()
+    try:
+        __M_writer = context.writer()
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+

chronr/data/templates/home.mako.py

+from mako import runtime, filters, cache
+UNDEFINED = runtime.UNDEFINED
+__M_dict_builtin = dict
+__M_locals_builtin = locals
+_magic_number = 5
+_modified_time = 1248261674.9838619
+_template_filename='/Users/chris/src/chronr-hg-rebuild_in_pylons/chronr/chronr/templates/home.mako'
+_template_uri='/home.mako'
+_template_cache=cache.Cache(__name__, _modified_time)
+_source_encoding='utf-8'
+from webhelpers.html import escape
+_exports = ['rightcontent', 'title']
+
+
+def _mako_get_namespace(context, name):
+    try:
+        return context.namespaces[(__name__, name)]
+    except KeyError:
+        _mako_generate_namespaces(context)
+        return context.namespaces[(__name__, name)]
+def _mako_generate_namespaces(context):
+    pass
+def _mako_inherit(template, context):
+    _mako_generate_namespaces(context)
+    return runtime._inherit_from(context, '/base/base-index.mako', _template_uri)
+def render_body(context,**pageargs):
+    context.caller_stack._push_frame()
+    try:
+        __M_locals = __M_dict_builtin(pageargs=pageargs)
+        __M_writer = context.writer()
+        # SOURCE LINE 1
+        __M_writer(u'\n\n')
+        # SOURCE LINE 3
+        __M_writer(u'\n\nWelcome to chronr.\n\n')
+        # SOURCE LINE 7
+        __M_writer(u'\n')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
+def render_rightcontent(context):
+    context.caller_stack._push_frame()
+    try:
+        __M_writer = context.writer()
+        # SOURCE LINE 7
+        __M_writer(u'Right Hand Content.')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
+def render_title(context):
+    context.caller_stack._push_frame()
+    try:
+        __M_writer = context.writer()
+        # SOURCE LINE 3
+        __M_writer(u'Home')
+        return ''
+    finally:
+        context.caller_stack._pop_frame()
+
+
         "repoze.who-friendlyform",
         "ToscaWidgets",
         "tw.forms",
+        "pytz",
     ],
     setup_requires=["PasteScript>=1.6.3"],
     packages=find_packages(exclude=['ez_setup']),