Tags: , , ,

Part of my tangeant using zope.interface was to avoid remaking list/edit admin pages for my SQLObject objects. Very little object-specific was left, and so I've now made an EntityPage that entity list/edit pages can inherit from.

The pattern I'm using is that you can manage your objects of a certain type under /admin/people. When you go to that URI, you'll get a list of the people. When you click on one of those, you'll go to /admin/people/1 or /admin/people/nbm, depending on what you use for your path names.

from samoosa.webware import WebwarePage
from samoosa.webware.templates import *
from samoosa.user import User

from FunFormKit.Form import *
from FunFormKit.Field import *

# Can at least pretend to be portable...
try:
    from sqlobject import SQLObject
except:
    SQLObject = None

class EntityPage(WebwarePage, FormServlet):
    contentTemplate = ListContentTemplate
    userTemplate = EditContentTemplate
    handleExtraPathInfo = True

    # Class being edited/listed
    _samoosa_class = None # Person
    # How to look up the object by path
    _samoosa_lookup = None # Person.byShortName
    # Whether we are editing or not
    _samoosa_edit = True
    # How to sort results
    _samoosa_sort = 'id' # 'display_name'
    # Anything else about the object to show in list is here...
    _samoosa_extra = None # 'shortName'
    # Are we the admin or public version?
    _samoosa_adminView = False

    def __init__(self):
        formDef = self.createForm()
        FormServlet.__init__(self, formDef)
        WebwarePage.__init__(self)

    def createForm(self):
        fields = self._samoosa_class.ffkFields()
        fields.append(SubmitButton('submit', description='Save'))

        formDef = FormDefinition('',  # the form action
            fields, name='form')

        return formDef

    def filterData(self, data):
        return data

    def content_template(self):
        username = self.session().value('authenticated_user_admin', None)
        admin = False

        if username:
            admin = User.byUsername(username).admin

        if self._samoosa_adminView:
            path = self._samoosa_class._samoosa_adminView
        else:
            path = self._samoosa_class._samoosa_publicView

        if not self.request().extraURLPath() or self.request().extraURLPath() == "/":
            mySearchList = {
                'admin': admin,
                'path': path,
            }
            return self.contentTemplate(searchList=[mySearchList, self])

        pathName = self.request().extraURLPath()
        while pathName[0] == '/':
            pathName = pathName[1:]
        while pathName[-1] == '/':
            pathName = pathName[:-1]

        try:
            item = self._samoosa_lookup(pathName)
        except:
            return self.sendRedirectAndEnd(self._samoosa_class._samoosa_publicView)

        submitted, data = self.processForm()
        if str(data) == data:
            data = {}

        data = self.filterData(data)

        if submitted:
            item.set(**data)

        newPath = item._samoosa_getPathName()
        if pathName != newPath:
            return self.sendRedirectAndEnd('%s/%s' % (path, newPath))

        defaults = {}
        for column in item._columns:
            name = column.kw['name']
            value = getattr(item, name)
            if value is not None and isinstance(value, SQLObject):
                value = value.id
            defaults[name] = value

        df = self.renderableForm(defaults = defaults)
        dl = df.htFormTable()

        mySearchList = {
            'item': item,
            'dl': dl,
            'admin': admin,
            'path': path,
        }
        return self.userTemplate(searchList=[mySearchList, self])

This allows you to write a pretty simple context page:

from samoosa.webware import EntityPage
from samoosa.person import Person

class people(EntityPage):
    _samoosa_class = Person
    _samoosa_lookup = Person.byShortName
    _samoosa_edit = True
    _samoosa_singular = 'person'
    _samoosa_plural = 'people'
    _samoosa_sort = 'display_name'
    _samoosa_extra = 'shortName'

    require_login = True
    require_admin = True

Actually, that's before I remove some OBEd attributes.

The Person class now has a bit more intelligence:

from sitesqlobject import *
from sql import connection

class Person(SiteSQLObject):
    _connection = connection
    shortName = StringCol(length=9, alternateID=True)
    displayName = StringCol(length=100, alternateID=True)
    webpageUrl = StringCol(length=255, default = None)
    blogUrl = StringCol(length=255, default = None)
    tagLine = StringCol(length=255, default = None)
    summary = StringCol(default = None)

    # Samoosa-specific hackery
    _samoosa_columnsInOrder = ['shortName', 'displayName', 'webpageUrl', 'blogUrl', 'tagLine', 'summary']

    _samoosa_displayName = 'displayName'
    _samoosa_pathName = 'shortName'
    _samoosa_lookup = 'byShortName'

    _samoosa_publicView = '/people'
    _samoosa_adminView = '/admin/people'
    _samoosa_createPath = '/admin/newPerson'

    _samoosa_singular = "person"
    _samoosa_plural = "people"

Person.createTable(ifNotExists = True)
if Person.select().count() == 0:
    p = Person(shortName="nbm", displayName="Neil Blakey-Milner",
        webpageUrl="http://mithrandr.moria.org/",
        blogUrl="http://mithrandr.moria.org/")

I've expanded SiteSQLObject a bit to simplify and future-proof some stuff:

from sqlobject import *
from sql import connection as __connection__

from zope.interface.adapter import AdapterRegistry
from zope.interface import classImplements, implements, Interface

registry = AdapterRegistry()

class SiteSQLObject(SQLObject):
    _samoosa_columnsInOrder = []
    _samoosa_displayName = 'id'
    _samoosa_pathName = 'id'
    _samoosa_publicView = None
    _samoosa_adminView = None
    _samoosa_lookup = 'get'

    def ffkFields(cls):
        ret = []
        columnDict = {}
        for col in cls._columns:
            columnDict[col.kw['name']] = col

        for colName in cls._samoosa_columnsInOrder:
            col = columnDict[colName]
            col.form = cls
            display = registry.queryAdapter(col, IColumnDisplay, '')
            ret.append(display.getField())
        return ret

    ffkFields = classmethod(ffkFields)

    def _samoosa_getDisplayName(self):
        return getattr(self, self._samoosa_displayName)

    def _samoosa_getPathName(self):
        return getattr(self, self._samoosa_pathName)

    def _samoosa_getHTMLDisplayName(self):
        if not self._samoosa_publicView:
            return self._samoosa_getDisplayName()
        path = '%s/%s' % (self._samoosa_publicView, self._samoosa_getPathName())
        return '<a href="%s">%s</a>' % (path, self._samoosa_getDisplayName())

    def _samoosa_pathnameLookup(self):
        return getattr(self, self._samoosa_lookup)

class ICol(Interface): pass
class IBoolCol(Interface): pass
class IStringCol(Interface): pass
class IFKCol(Interface): pass
class IColumnDisplay(Interface): pass

from FunFormKit.Form import *
from FunFormKit.Field import *

class ColumnDisplay:
    implements(IColumnDisplay)

    def __init__(self, context):
        print "DefaultColumnDisplay: %s" % (context)
        self.context = context

class DefaultColumnDisplay(ColumnDisplay):
    def getField(self):
        return TextField(self.context.kw['name'], size=20, maxLength=100)

class BoolColumnDisplay(ColumnDisplay):
    def getField(self):
        return CheckboxField(self.context.kw['name'])

class StringColumnDisplay(ColumnDisplay):
    def getField(self):
        length = self.context.kw.get('length', None)
        if not length:
            return TextareaField(self.context.kw['name'], rows=10, cols=40, wrap="SOFT")
        size = 20
        if length < size:
            size = length
        return TextField(self.context.kw['name'], size=size, maxLength=length)

class FKColumnDisplay(ColumnDisplay):
    def getField(self):
        clsname = self.context.kw['foreignKey']
        cls = getattr(self.context.form, "_SO_class_%s" % (clsname))
        print dir(cls)
        selections = []
        for o in cls.select():
            selections.append((o.id, o._samoosa_getDisplayName()))
        return SelectField(self.context.kw['name'], selections = selections)

classImplements(Col, ICol)
classImplements(BoolCol, IBoolCol)
classImplements(StringCol, IStringCol)
classImplements(ForeignKey, IFKCol)
registry.register([ICol], IColumnDisplay, '', DefaultColumnDisplay)
registry.register([IBoolCol], IColumnDisplay, '', BoolColumnDisplay)
registry.register([IStringCol], IColumnDisplay, '', StringColumnDisplay)
registry.register([IFKCol], IColumnDisplay, '', FKColumnDisplay)

(Notice I had to put an attribute linking a column to its table, so that I could get access to _SO_class_Person for foreign keys. But now it works a charm showing the options and updating.)

The display is handled by two Cheetah Template templates, one for the list of items, and another for displaying/editing a single item (although it's pretty dumb since FunFormKit actually does the form).

#if $admin
<a href="$_samoosa_class._samoosa_createPath">Add a new $_samoosa_class._samoosa_singular</a>
#end if

<ul>
#for p in $_samoosa_class.select(orderBy=$_samoosa_sort)
#set $itempath = $p._samoosa_getPathName
#set $display = $p._samoosa_getDisplayName
#if $_samoosa_extra:
#set $extra = getattr($p, $_samoosa_extra)
#else
#set $extra = None
#end if
<li><a href="$path/$itempath">$display</a>
#if $extra:
($extra)
#end if
</li>
#end for
</ul>

#if $admin
<a href="$_samoosa_class._samoosa_createPath">Add a new $_samoosa_class._samoosa_singular</a>
#end if

(Ick, all those _samoosa_*, but I want to keep the custom stuff pretty obvious.)

#if $item
#set $display = $item._samoosa_getHTMLDisplayName
<h2>$display</h2>

<h3>Details</h3>
#end if

$dl

All in all, this has made adding/editing data a lot easier than using phpMyAdmin, adding new entities is arbitrary, and ForeignKeys are dealt with intelligently.

Again, I hate going behind SQLObject's back so much. Perhaps I should be using _SO_columnDict and the SOCol-based objects - although these aren't exported from the module. But for now, what I've got will do.

1 old-style comments

  1. Neil Blakey-MilnerJanuary 21, 2005 at 09:30 PM.

    Since I forgot to use Trackback, I should mention that ljb wrote a response and showed off what Ruby on Rails can do. I then replied, explaining why this looks complex in comparison.
blog comments powered by Disqus