A user manager for our site boils down to writing an editor for the PrincipalFolder
we used as the basis of our IAuthenticatorPlugin
. Such an editor should be able to add or remove users, or change the details associated with the principal, for example, change the roles associated with a given principal.
The code for this may be found in users.py.
Let us start with the interface for the editor component we are building. We know that the PrincipalFolder implements the IInternalPrincipalContainer
interface, which is a container for items which conform to IInternalPrincipal
.
We may envisage our editor as implementing ILayout
, being the general layout of this site, and defining a content area and header area which we can fill in with the guts of our editor. The editor we might define as a simple list followed by a search box:
class IUsers(Interface):
""" A user management interface
"""
users = List(title=u'Accounts', required=True,
value_type=Object(title=u"User", schema=IAccount))
search = TextLine(title=u'Search:', required=False, default=u'')
def nItems(self):
''' Returns the number of items in the complete search result '''
def fromItem(self, setPos=None):
''' Optionally sets and returns the position from which to display items '''
We see that the list in the interface uses a value type of IAccount
, which we define below:
class IAccount(Interface):
login = TextLine(title=u'Login: ',
description=u'A login/user Name')
title = TextLine(title=u"Title: ",
description=u"Provides a title for the principal.",
required=False)
password = Password(title=u"Password: ",
description=u"The password for the principal.",
required=False)
roles = HSet(title=u'Roles: ',
value_type=Choice(title=u'Role', vocabulary=u'gfn.AccountRoles',
description=u'The kind role the user plays',
default='gfn.Visitor'), default=set())
The only non-standard or odd thing about this is the HSet
type. This type does not appear in zope.schema, although it otherwise behaves like a schema.Set:
class HSet(Set):
''' Marker class for a horizontal set
'''
Now, when rendering a schema.Set element, zope.formlib does a queryMultiAdapter((Set, SimpleVocabulary, IBrowserRequest), IInputWidget)
to determine the type of widget to render. Knowing this, we can override default behaviour and define our own widget rather simply by implementing an adapter:
from zope.formlib.itemswidgets import MultiCheckBoxWidget
from zope.formlib.interfaces import IInputWidget
from zope.publisher.browser import IBrowserRequest
from zope.schema.vocabulary import SimpleVocabulary
class HSetVocabularyWidget(grok.MultiAdapter):
''' Left to it's own devices, this a set displays a dropdown selection box with
multi-select capability (control-LMB to choose multiple values.) Rather than this,
we would like the widget for Set fields to be multiple check boxes, one for each
value in the set. This MultiAdapter overrides the original widget with the new one.
'''
grok.adapts(HSet, SimpleVocabulary, IBrowserRequest)
grok.provides(IInputWidget)
def __new__(cls, context, vocab, request):
w=MultiCheckBoxWidget(context,vocab,request)
w.orientation = 'horizontal'
return w
This lets us change the way a schema.Set is rendered, and possibly even add a bit of styling to it.
We define our Account
model, which implements an IAccount
, by declaring the attributes found in IAccount
, and giving them values. We also provide a few helper methods for populating the IAccount.roles
appropriately for the given principal:
class Account(grok.Model):
grok.implements(IAccount)
login = u''
password = u''
title = u''
roles = set('gfn.Visitor')
def rolesFromAccount(self):
''' Populate the managed roles for this principal from self.roles
'''
roleMgr = IPrincipalRoleManager(grok.getSite())
if self.login == 'admin':
self.roles.add('gfn.Administrator')
for rid, _setting in roleMgr.getRolesForPrincipal('gfn.'+self.login):
roleMgr.unsetRoleForPrincipal(rid, 'gfn.'+self.login)
for role in self.roles:
roleMgr.assignRoleToPrincipal(role, 'gfn.'+self.login)
def accountFromRoles(self, login):
''' Populate self.roles by querying the role manager
'''
roleMgr = IPrincipalRoleManager(grok.getSite())
for rid, setting in roleMgr.getRolesForPrincipal('gfn.'+login):
if setting.getName() == 'Allow':
self.roles.add(rid)
def __init__(self, user=None):
self.user = user
if user:
self.login = user.login
self.password = user.password
self.title = user.title
self.roles = set()
self.accountFromRoles(self.login)
Of course, we don't start with an IAccount
, instead we have an IInternalPrincipal
. So to make an IAccount
from an IInternalPrincipal
, we need an adapter:
class InternalPrincipalAccount(grok.Adapter):
grok.context(IInternalPrincipal)
grok.implements(IAccount)
def __new__(cls, principal):
return Account(principal)
The Users()
model, which implements the IUsers
interface, initialises itself from a IInternalPrincipalContainer
, keeping a reference to the container and working directly on that. Users()
also keeps a list of Account
objects which mirror the container items, but only for a subset of the container items as specified by BATCH_SIZE (10 items).
The do_search()
method populates this short list of items, which are the only ones displayed in the editor.
class Users(grok.Model):
""" The list of users matching the _search filter is cached in _accounts. We limit
the number of cached users to BATCH_SIZE, also the number of users actually
displayed at any time. The page offset is counted from <pos>.
The principals folder for the site looks somewhat like a dict, and is passed
as an argument when instantiating this object. We keep a reference in
<principals>.
The users property setter takes a look at the cached list and compares the new
list. From this it determines the set of deleted, inserted or changed items,
allowing us to update several principals details at once.
"""
grok.implements(ILayout, IUsers)
title = u'User Management'
principals = {}
pos = 0
_accounts = []
_search = None
def __init__(self, principals):
self.principals = principals
self.do_search()
def do_search(self):
p = self.principals
gen = p.search({'search':self.search or ''}, start=self.pos, batch_size=BATCH_SIZE)
self._accounts = [IAccount(p[p.principalInfo(i).login]) for i in gen]
The 'search' attribute is implemented in the Users()
class as a property, which mirrors the _search attribute:
@property
def search(self):
return self._search
@search.setter
def search(self, value):
if self._search != value:
self._search = value
self.do_search()
This approach makes it easy to detect when the search specification has changed.
The users
attribute is also implemented as a property. The read
property simply returns the self._accounts
attribute, but the users.setter
is where the magic is. The incoming list of accounts is compared to the self._accounts
, and we use set arithmetic to determine the set of added, deleted or altered records. For the deleted set, we then delete the corresponding container items. For the new elements we add appropriate InternalPrincipal
items to the container. For the changed items, we alter the record in the container directly.
@property
def users(self):
return self._accounts
@users.setter
def users(self, values):
vdict = {account.login:account for account in values}
a = set([account.login for account in self._accounts])
v = set([account.login for account in values])
deleted = a.difference(v)
added = v.difference(a)
updated = a.intersection(v)
p = self.principals
for user in deleted:
del p[user]
for user in added:
account = vdict[user]
if account.login:
account = vdict[user]
p[user] = InternalPrincipal(login=account.login, title=account.title, password=account.password)
account.rolesFromAccount()
for user in updated:
u = p[user]
account = vdict[user]
if account.login and u.login != account.login: u.login=account.login
u.title=account.title or ''
if account.password: u.password=account.password
account.rolesFromAccount()
self.do_search()
When all the changes have been made, we repeat the last search to update our internal list of 10 (BATCH_SIZE) elements.
nItems
is expected to hold the total length of our internal list, and fromItem is the offset into our result set from which we start displaying items. nItems
is again a property, but fromItems()
is a method.
@property
def nItems(self):
''' Returns the number of items in the complete search result '''
return len(list(self.principals.search({'search':self._search or ''})))
def fromItem(self, setPos=None):
''' Optionally sets and returns the position from which to display items '''
if setPos: self.pos = setPos
return self.pos
To make a Users()
instance from a IInternalPrincipalContainer
, we need an adapter:
class PrincipalUsers(grok.Adapter):
""" Adapts our PrincipalFolder to a user management interface
"""
grok.context(IInternalPrincipalContainer)
grok.implements(IUsers)
def __new__(cls, principals):
return Users(principals)
This adapter means that even though we have traversed to an intance of IInternalPrincipalContainer
, our content viewlet which is based upon a Users
model will work just fine. When we redirect to the editor, we determine the new context for our editor by using context=IUser(oldContext)
. Isn't ZCA magic just great?
...and to render the editor in the content area, we need a viewlet:
class EditPrincipals(grok.Viewlet):
""" Renders the user management interface within the Content Area
"""
grok.context(Users)
grok.require(gfn.Administering)
grok.viewletmanager(Content)
with a page template to match:
<div tal:content="structure context/@@editprincipalform" />
The above template simply renders the EditPrincipalForm
form inside the content area.
Now for the display. We use grok.formlib to generate an entirely automatic form for the IUsers
interface. Since the IUsers.users
field is a schema.List
of schema.Object
, we use the formlib.ListSequenceWidget
as a custom widget which holds formlib.ObjectWidget
items. The way to do this is to use a formlib.widget.CustomWidgetFactory
:
from zope.formlib.widget import CustomWidgetFactory
from zope.formlib.objectwidget import ObjectWidget
from zope.formlib.sequencewidget import ListSequenceWidget
def AccountWidget():
''' An ObjectWidget produces a 'sub-form' for an object, in this case an Account. If our
data field happens to be a list of accounts, we can wrap the account widget in a
list sequence widget. The CustomWidgetFactory produces widgets by calling the
appropriate factory with the configured arguments.
'''
# Show accounts in the list as object widgets (sub-forms)
ow = CustomWidgetFactory(ObjectWidget, Account)
return CustomWidgetFactory(ListSequenceWidget, subwidget=ow)
Now the new AccountWidget
can be set inside our automatic formlib form as follows:
class EditPrincipalForm(grok.EditForm):
''' A form that allows creation, editing and deletion of principals. '''
grok.context(Users) # view available as URL: 'appname/editprincipal'
grok.require(gfn.Administering) # Permission requirement
form_fields = grok.Fields(IUsers) # Present a search form
form_fields['users'].custom_widget = AccountWidget() # The current list of principals
...
We then add a bunch of actions to the form, rendered as buttons. These navigate through the virtual list of items, search the container or apply changes to the list:
@grok.action(u"Search")
def search(self, **data):
self.context.search = data['search']
@grok.action(u"Apply")
def apply(self, **data):
self.applyData(self.context, **data)
@grok.action(u"First Page")
def firstPage(self, **data):
self.context.fromItem(0)
@grok.action(u"Next Page")
def nextPage(self, **data):
if self.context.fromItem() + BATCH_SIZE < self.context.nItems():
self.context.fromItem(self.context.fromItem()+BATCH_SIZE)
@grok.action(u"Prev Page")
def prevPage(self, **data):
if self.context.fromItem() - BATCH_SIZE >= 0:
self.context.fromItem(self.context.fromItem()-BATCH_SIZE)
else:
self.context.fromItem(0)
@grok.action(u"Last Page")
def lastPage(self, **data):
n = self.context.fromItem() / BATCH_SIZE
if n % BATCH_SIZE == 0: n -= 1
if n < 0: n = 0
self.context.fromItem(n * BATCH_SIZE)
To make the form look good, we use a bit of css, as defined in the update()
method of the form:
def update(self):
rc.tabular.need()
The tabularform.css included by the rc.tabular.need()
looks like this:
form { width:100% }
form > table {
width: 100%;
-webkit-border-top-left-radius: 20px;
-khtml-border-top-left-radius: 20px;
-moz-border-top-left-radius: 20px;
-ms-border-top-left-radius: 20px;
-o-border-top-left-radius: 20px;
border-top-left-radius: 20px;
}
form > table fieldset {color:blue; border:none}
form > table fieldset > legend {display:none}
form > table.form-fields {border:1px solid blue}
form > table.form-fields td.fieldname{display:none}
form > table.form-fields tr:first-child td.label span{display:none}
form > table.form-fields li:nth-child(even) {background:#cecefe}
form > table.form-fields tr:nth-child(even) {background:#cecefe}
form > table.form-fields div.row {float:left}
form > table.form-fields div.row input {border:none; border-bottom:1px dashed black; background:lightgreen}
form > table.form-fields div.row div {float:left}
form > table.form-fields div.row label {margin-left:5px; margin-right:2px}
form > table.listing input {background:none}
The form renders automaticallly as:

The rest of the editor is just plugging in the navigation buttons and so forth.
class BackButtonMenuEntry(MenuItem):
''' A menu item for articles with parent articles. IOW NoobsArticle
'''
grok.context(Users)
title = u'Back to Main'
link = u'..'
mclass = 'nav buttons'
class UsersButtonMenuEntry(MenuItem):
''' A menu item for articles with parent articles. IOW NoobsArticle
'''
grok.context(ISiteRoot)
grok.require(gfn.Administering)
grok.order(-4)
title = u'Manage Users'
link = u'/users'
mclass = 'nav buttons'
In order to link the /users URL to the site, we add the following to the grok4noobs.Grok4Noobs
class:
grok.traversable('users')
def users(self):
sm = self.getSiteManager()
if 'users' in sm:
return IUsers(sm['users'])