Any content management system will inevitably have to think about having different content types. Common content types include pages, attachments, calendars, events, and blog articles. Why make the distinction between different things that all appear as "pages" to the user? Because, of course, different content types must support different features and respond to different actions. For example, an event content type must have a date in order to show up on a calendar, and a calendar content type might support an iCalendar feed of its events.

Likewise, content types will all share similar features and actions. They all have a URL and a title. And if we take the common CMS approach of making a site as a hierarchy of objects, they all have a parent object and child objects. While sites might not be inherently hierarchical (URLs are just identifiers!), it's quite natural to create them this way—for example, if we move a page, we'd expect its entire tree of child pages to move with it.

One of the first things a web developer does when starting a project is to model its content types. Read any MVC (or, ahem, MTV) web framework tutorial and there will be a Wiki model, or a Blog model, or a TodoList model—all content types. In this article I'll be talking about what it currently looks like to model a content type in Pagoda. Since Pagoda is based on TurboGears, our goal is to make building your app alongside Pagoda no different than building your app with TurboGears, and so far I think we've done a pretty good job. (And by the way, since we're using SQLAlchemy, this part of Pagoda is TG2 future-proof.)

So, if it's supposed to be the same as just using TurboGears, why do I have to show you anything? The answer is that while you don't have to design your model with Pagoda in mind (existing apps will coexist just fine), doing so will make your model easily localizable and revisionable! That's a pretty big benefit in the world of content management. We'll be able to restore old content records from any point in their history, and make changes to locale-independent fields for all translations at once.

So, on to the code. I'll be modeling a simple Event content type. First, here's how you might do it with some plain old TurboGears and SQLAlchemy.


from sqlalchemy import *
from sqlalchemy.ext.assignmapper import assign_mapper
from turbogears.database import metadata, session
from datetime import datetime

event_table = Table('event', metadata,
    Column('event_id', Integer, primary_key=True),
    Column('start_date', DateTime, nullable=False, default=datetime.now),
    Column('end_date', DateTime, nullable=True),
    Column('title', Unicode(200), nullable=False),
    Column('url_slug', String(75), nullable=False),
    Column('description', TEXT, nullable=False, default=""),
    Column('show_in_calendar', Boolean, nullable=False, default=True)
)

class Event(object):
    def move_to_date(self, new_date):
        self.start_date = new_date
        if self.end_date:
           time_delta = new_date - self.start_date
           self.end_date += time_delta

assign_mapper(session.context, Event, event_table)

So, a pretty standard model with minimal event features. (One thing might not be obvious—the url_slug field is the short Latin-1 name of the event we'll show in the URL). Using the mapped Event class to use the model looks like this...


# Make an event.
bday_party = Event(start_date=datetime(2007, 10, 30, 19, 30),
    title="Brian turns twenty-three!", url_slug="brian_turns_23",
    description="The party will take place in my underwater hideout.",
    show_in_calendar=True)

# End at midnight!
bday_party.end_date = datetime(2007, 10, 31)

# Write it!
session.flush()

Now, how would it change with support for translations, revisions, and having parent and child objects? The first step is to split the table up into locale-dependent and locale-independent tables. If you read about our localizable revision model you'll see that this is how we support independently revisioned translations while avoiding data duplication. Here's what the two tables look like that will replace event_table:


event_generic_table = Table('event_generic', metadata,
    Column('event_id', Integer, primary_key=True),
    Column('start_date', DateTime, nullable=False, default=datetime.now),
    Column('end_date', DateTime, nullable=True),
    Column('url_slug', String(75), nullable=False),
    Column('show_in_calendar', Boolean, nullable=False, default=True)
)

event_localized_table = Table('event_localized', metadata,
    Column('event_id', Integer, primary_key=True),
    Column('title', Unicode(200), nullable=False),
    Column('description', TEXT, nullable=False, default="")
)

Since we want to support translations for the fields in event_localized_table, let's also add a locale field in order to tell the translations apart. locale will be a short identifier like "en-US", "fr", or "jp".


event_localized_table = Table('event_localized', metadata,
    Column('event_id', Integer, primary_key=True),
    Column('title', Unicode(200), nullable=False),
    Column('description', TEXT, nullable=False, default=""),
    Column('locale', String(25), nullable=False)
)

The next step is to point both tables at Pagoda's revision table in order to support revisions. Since each event record points to a unique revision record, our primary key is now redundant, and can be changed to the revision's ID:


from pagoda.models import Revision

event_generic_table = Table('event_generic', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('start_date', DateTime, nullable=False, default=datetime.now),
    Column('end_date', DateTime, nullable=True),
    Column('url_slug', String(75), nullable=False),
    Column('show_in_calendar', Boolean, nullable=False, default=True)
)

event_localized_table = Table('event_localized', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('title', Unicode(200), nullable=False),
    Column('description', TEXT, nullable=False, default=""),
    Column('locale', String(25), nullable=False)
)

A column type of None here will cause SQLAlchemy to use the column type of the foreign key—almost always what you want. There's one more change to make. Since Pagoda helps manage your site's content hierarchy, it already has a table to hold the URL of every object on the site. So we can get rid of the url_slug field—Pagoda will include its own when we tell it about this content type. Our final tables:


from pagoda.models import Revision

event_generic_table = Table('event_generic', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('start_date', DateTime, nullable=False, default=datetime.now),
    Column('end_date', DateTime, nullable=True),
    Column('show_in_calendar', Boolean, nullable=False, default=True)
)

event_localized_table = Table('event_localized', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('title', Unicode(200), nullable=False),
    Column('description', TEXT, nullable=False, default=""),
    Column('locale', String(25), nullable=False)
)

Just a few more small changes! Since we have two different tables, and are now adding some more tables (like Revision) into the mix, we need to join them somehow for SQLAlchemy to map against the resulting join. Pagoda has a function called revisioned_table that will perform the necessary joins. Just tell it about your two tables and give it an alias:


from pagoda.models import Revision, revisioned_table

...

event_table = revisioned_table('event', event_generic_table, event_localized_table)

event_table is now a Selectable according to SQLAlchemy. Let's map against it! Pagoda uses a mapper extension to help with querying and modifying revisioned records. You can add pagoda.models.RevisionableMapperExtension to the mapper yourself, or you can use our helper called revision_mapper to do it. revision_mapper is a small wrapper around assign_mapper that makes sure the mapper extension is there, and gives the mapped class methods some more helpful docstrings.


from pagoda.models import Revision, revisioned_table, revision_mapper

...

revision_mapper(session.context, Event, event_table)

Last change! Since Event is now revisioned, it would be nice to have some helpful methods for dealing with revisions, like querying for the latest published revision or creating a new revision based on a previous revision. Pagoda has a base class for your mapped class that will give it a few such methods. Just subclass your mapped class from Revision:


class Event(Revision):
    def move_to_date(self, new_date):
        self.start_date = new_date
        if self.end_date:
           time_delta = new_date - self.start_date
           self.end_date += time_delta

And that's all it takes to support revisions. Event works just like before, except it now has some more methods and fields. A url column came from Pagoda's Node table, content_id and content_type came from Pagoda's Content table, and Revision's columns came along too. Note that no columns were added to either Event table—these additional fields came from joins. Using it looks much the same as before:


bday_party = Event(start_date=datetime(2007, 10, 30, 19, 30),
    title="Brian turns twenty-three!", url="brian_turns_23",
    description="The party will take place in my underwater hideout.",
    show_in_calendar=True, locale='en', content_type='event',
    revision_author="brian")

revised_bday_party = bday_party.new_revision(title="Brian gets older")
revised_bday_party.publish()

session.flush()

# revised_bday_party is now "active" - the latest published revision

calendar_events = Event.select_active_by(show_in_calendar=True)

from datetime import datetime, timedelta
yesterday = datetime.today() - timedelta(days=1)
events_as_they_were_yesterday = Event.filter_snapshot(
    yesterday
).select_by(show_in_calendar=True)

Here's the final code. It's just a couple more lines than the original model at the beginning of this article:


from sqlalchemy import *
from sqlalchemy.ext.assignmapper import assign_mapper
from turbogears.database import metadata, session
from datetime import datetime
from pagoda.models import Revision, revisioned_table, revision_mapper

event_generic_table = Table('event_generic', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('start_date', DateTime, nullable=False, default=datetime.now),
    Column('end_date', DateTime, nullable=True),
    Column('show_in_calendar', Boolean, nullable=False, default=True)
)

event_localized_table = Table('event_localized', metadata,
    Column('revision_id', None, primary_key=True,
        ForeignKey(Revision.c.revision_id)),
    Column('title', Unicode(200), nullable=False),
    Column('description', TEXT, nullable=False, default=""),
    Column('locale', String(25), nullable=False)
)

event_table = revisioned_table('event', event_generic_table, event_localized_table)

class Event(Revision):
    def move_to_date(self, new_date):
        self.start_date = new_date
        if self.end_date:
           time_delta = new_date - self.start_date
           self.end_date += time_delta

revision_mapper(session.context, Event, event_table)

So, hopefully those changes to the original Event weren't too jarring. Sure we could make many of those changes automatically, but we're trying to avoid magic in favor of small helpers, each extending the model The SQLAlchemy Way. If you think all this is too much work, let us know! We want this to be fun to hack on for everyone, not just us.

Next time I'll talk about content type controllers.