Code

Make HTMLProperty report actual classname in repr().
[roundup.git] / roundup / cgi / templating.py
1 from __future__ import nested_scopes
3 """Implements the API used in the HTML templating for the web interface.
4 """
6 todo = """
7 - Most methods should have a "default" arg to supply a value
8   when none appears in the hyperdb or request.
9 - Multilink property additions: change_note and new_upload
10 - Add class.find() too
11 - NumberHTMLProperty should support numeric operations
12 - LinkHTMLProperty should handle comparisons to strings (cf. linked name)
13 - HTMLRequest.default(self, sort, group, filter, columns, **filterspec):
14   '''Set the request's view arguments to the given values when no
15      values are found in the CGI environment.
16   '''
17 - have menu() methods accept filtering arguments
18 """
20 __docformat__ = 'restructuredtext'
23 import sys, cgi, urllib, os, re, os.path, time, errno, mimetypes, csv
24 import calendar, textwrap
26 from roundup import hyperdb, date, support
27 from roundup import i18n
28 from roundup.i18n import _
30 try:
31     import cPickle as pickle
32 except ImportError:
33     import pickle
34 try:
35     import cStringIO as StringIO
36 except ImportError:
37     import StringIO
38 try:
39     from StructuredText.StructuredText import HTML as StructuredText
40 except ImportError:
41     try: # older version
42         import StructuredText
43     except ImportError:
44         StructuredText = None
45 try:
46     from docutils.core import publish_parts as ReStructuredText
47 except ImportError:
48     ReStructuredText = None
50 # bring in the templating support
51 from roundup.cgi.PageTemplates import PageTemplate, GlobalTranslationService
52 from roundup.cgi.PageTemplates.Expressions import getEngine
53 from roundup.cgi.TAL import TALInterpreter
54 from roundup.cgi import TranslationService, ZTUtils
56 ### i18n services
57 # this global translation service is not thread-safe.
58 # it is left here for backward compatibility
59 # until all Web UI translations are done via client.translator object
60 translationService = TranslationService.get_translation()
61 GlobalTranslationService.setGlobalTranslationService(translationService)
63 ### templating
65 class NoTemplate(Exception):
66     pass
68 class Unauthorised(Exception):
69     def __init__(self, action, klass, translator=None):
70         self.action = action
71         self.klass = klass
72         if translator:
73             self._ = translator.gettext
74         else:
75             self._ = TranslationService.get_translation().gettext
76     def __str__(self):
77         return self._('You are not allowed to %(action)s '
78             'items of class %(class)s') % {
79             'action': self.action, 'class': self.klass}
81 def find_template(dir, name, view):
82     """ Find a template in the nominated dir
83     """
84     # find the source
85     if view:
86         filename = '%s.%s'%(name, view)
87     else:
88         filename = name
90     # try old-style
91     src = os.path.join(dir, filename)
92     if os.path.exists(src):
93         return (src, filename)
95     # try with a .html or .xml extension (new-style)
96     for extension in '.html', '.xml':
97         f = filename + extension
98         src = os.path.join(dir, f)
99         if os.path.exists(src):
100             return (src, f)
102     # no view == no generic template is possible
103     if not view:
104         raise NoTemplate, 'Template file "%s" doesn\'t exist'%name
106     # try for a _generic template
107     generic = '_generic.%s'%view
108     src = os.path.join(dir, generic)
109     if os.path.exists(src):
110         return (src, generic)
112     # finally, try _generic.html
113     generic = generic + '.html'
114     src = os.path.join(dir, generic)
115     if os.path.exists(src):
116         return (src, generic)
118     raise NoTemplate('No template file exists for templating "%s" '
119         'with template "%s" (neither "%s" nor "%s")'%(name, view,
120         filename, generic))
122 class Templates:
123     templates = {}
125     def __init__(self, dir):
126         self.dir = dir
128     def precompileTemplates(self):
129         """ Go through a directory and precompile all the templates therein
130         """
131         for filename in os.listdir(self.dir):
132             # skip subdirs
133             if os.path.isdir(filename):
134                 continue
136             # skip files without ".html" or ".xml" extension - .css, .js etc.
137             for extension in '.html', '.xml':
138                 if filename.endswith(extension):
139                     break
140             else:
141                 continue
143             # remove extension
144             filename = filename[:-len(extension)]
146             # load the template
147             if '.' in filename:
148                 name, extension = filename.split('.', 1)
149                 self.get(name, extension)
150             else:
151                 self.get(filename, None)
153     def get(self, name, extension=None):
154         """ Interface to get a template, possibly loading a compiled template.
156             "name" and "extension" indicate the template we're after, which in
157             most cases will be "name.extension". If "extension" is None, then
158             we look for a template just called "name" with no extension.
160             If the file "name.extension" doesn't exist, we look for
161             "_generic.extension" as a fallback.
162         """
163         # default the name to "home"
164         if name is None:
165             name = 'home'
166         elif extension is None and '.' in name:
167             # split name
168             name, extension = name.split('.')
170         # find the source
171         src, filename = find_template(self.dir, name, extension)
173         # has it changed?
174         try:
175             stime = os.stat(src)[os.path.stat.ST_MTIME]
176         except os.error, error:
177             if error.errno != errno.ENOENT:
178                 raise
180         if self.templates.has_key(src) and \
181                 stime <= self.templates[src].mtime:
182             # compiled template is up to date
183             return self.templates[src]
185         # compile the template
186         pt = RoundupPageTemplate()
187         # use pt_edit so we can pass the content_type guess too
188         content_type = mimetypes.guess_type(filename)[0] or 'text/html'
189         pt.pt_edit(open(src).read(), content_type)
190         pt.id = filename
191         pt.mtime = stime
192         # Add it to the cache.  We cannot do this until the template
193         # is fully initialized, as we could otherwise have a race
194         # condition when running with multiple threads:
195         #
196         # 1. Thread A notices the template is not in the cache,
197         #    adds it, but has not yet set "mtime".
198         #
199         # 2. Thread B notices the template is in the cache, checks
200         #    "mtime" (above) and crashes.
201         #
202         # Since Python dictionary access is atomic, as long as we
203         # insert "pt" only after it is fully initialized, we avoid
204         # this race condition.  It's possible that two separate
205         # threads will both do the work of initializing the template,
206         # but the risk of wasted work is offset by avoiding a lock.
207         self.templates[src] = pt
208         return pt
210     def __getitem__(self, name):
211         name, extension = os.path.splitext(name)
212         if extension:
213             extension = extension[1:]
214         try:
215             return self.get(name, extension)
216         except NoTemplate, message:
217             raise KeyError, message
219 def context(client, template=None, classname=None, request=None):
220     """Return the rendering context dictionary
222     The dictionary includes following symbols:
224     *context*
225      this is one of three things:
227      1. None - we're viewing a "home" page
228      2. The current class of item being displayed. This is an HTMLClass
229         instance.
230      3. The current item from the database, if we're viewing a specific
231         item, as an HTMLItem instance.
233     *request*
234       Includes information about the current request, including:
236        - the url
237        - the current index information (``filterspec``, ``filter`` args,
238          ``properties``, etc) parsed out of the form.
239        - methods for easy filterspec link generation
240        - *user*, the current user node as an HTMLItem instance
241        - *form*, the current CGI form information as a FieldStorage
243     *config*
244       The current tracker config.
246     *db*
247       The current database, used to access arbitrary database items.
249     *utils*
250       This is a special class that has its base in the TemplatingUtils
251       class in this file. If the tracker interfaces module defines a
252       TemplatingUtils class then it is mixed in, overriding the methods
253       in the base class.
255     *templates*
256       Access to all the tracker templates by name.
257       Used mainly in *use-macro* commands.
259     *template*
260       Current rendering template.
262     *true*
263       Logical True value.
265     *false*
266       Logical False value.
268     *i18n*
269       Internationalization service, providing string translation
270       methods ``gettext`` and ``ngettext``.
272     """
273     # construct the TemplatingUtils class
274     utils = TemplatingUtils
275     if (hasattr(client.instance, 'interfaces') and
276             hasattr(client.instance.interfaces, 'TemplatingUtils')):
277         class utils(client.instance.interfaces.TemplatingUtils, utils):
278             pass
280     # if template, classname and/or request are not passed explicitely,
281     # compute form client
282     if template is None:
283         template = client.template
284     if classname is None:
285         classname = client.classname
286     if request is None:
287         request = HTMLRequest(client)
289     c = {
290          'context': None,
291          'options': {},
292          'nothing': None,
293          'request': request,
294          'db': HTMLDatabase(client),
295          'config': client.instance.config,
296          'tracker': client.instance,
297          'utils': utils(client),
298          'templates': client.instance.templates,
299          'template': template,
300          'true': 1,
301          'false': 0,
302          'i18n': client.translator
303     }
304     # add in the item if there is one
305     if client.nodeid:
306         c['context'] = HTMLItem(client, classname, client.nodeid,
307             anonymous=1)
308     elif client.db.classes.has_key(classname):
309         c['context'] = HTMLClass(client, classname, anonymous=1)
310     return c
312 class RoundupPageTemplate(PageTemplate.PageTemplate):
313     """A Roundup-specific PageTemplate.
315     Interrogate the client to set up Roundup-specific template variables
316     to be available.  See 'context' function for the list of variables.
318     """
320     # 06-jun-2004 [als] i am not sure if this method is used yet
321     def getContext(self, client, classname, request):
322         return context(client, self, classname, request)
324     def render(self, client, classname, request, **options):
325         """Render this Page Template"""
327         if not self._v_cooked:
328             self._cook()
330         __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
332         if self._v_errors:
333             raise PageTemplate.PTRuntimeError, \
334                 'Page Template %s has errors.'%self.id
336         # figure the context
337         c = context(client, self, classname, request)
338         c.update({'options': options})
340         # and go
341         output = StringIO.StringIO()
342         TALInterpreter.TALInterpreter(self._v_program, self.macros,
343             getEngine().getContext(c), output, tal=1, strictinsert=0)()
344         return output.getvalue()
346     def __repr__(self):
347         return '<Roundup PageTemplate %r>'%self.id
349 class HTMLDatabase:
350     """ Return HTMLClasses for valid class fetches
351     """
352     def __init__(self, client):
353         self._client = client
354         self._ = client._
355         self._db = client.db
357         # we want config to be exposed
358         self.config = client.db.config
360     def __getitem__(self, item, desre=re.compile(r'(?P<cl>[a-zA-Z_]+)(?P<id>[-\d]+)')):
361         # check to see if we're actually accessing an item
362         m = desre.match(item)
363         if m:
364             cl = m.group('cl')
365             self._client.db.getclass(cl)
366             return HTMLItem(self._client, cl, m.group('id'))
367         else:
368             self._client.db.getclass(item)
369             return HTMLClass(self._client, item)
371     def __getattr__(self, attr):
372         try:
373             return self[attr]
374         except KeyError:
375             raise AttributeError, attr
377     def classes(self):
378         l = self._client.db.classes.keys()
379         l.sort()
380         m = []
381         for item in l:
382             m.append(HTMLClass(self._client, item))
383         return m
385 num_re = re.compile('^-?\d+$')
387 def lookupIds(db, prop, ids, fail_ok=0, num_re=num_re, do_lookup=True):
388     """ "fail_ok" should be specified if we wish to pass through bad values
389         (most likely form values that we wish to represent back to the user)
390         "do_lookup" is there for preventing lookup by key-value (if we
391         know that the value passed *is* an id)
392     """
393     cl = db.getclass(prop.classname)
394     l = []
395     for entry in ids:
396         if do_lookup:
397             try:
398                 item = cl.lookup(entry)
399             except (TypeError, KeyError):
400                 pass
401             else:
402                 l.append(item)
403                 continue
404         # if fail_ok, ignore lookup error
405         # otherwise entry must be existing object id rather than key value
406         if fail_ok or num_re.match(entry):
407             l.append(entry)
408     return l
410 def lookupKeys(linkcl, key, ids, num_re=num_re):
411     """ Look up the "key" values for "ids" list - though some may already
412     be key values, not ids.
413     """
414     l = []
415     for entry in ids:
416         if num_re.match(entry):
417             label = linkcl.get(entry, key)
418             # fall back to designator if label is None
419             if label is None: label = '%s%s'%(linkcl.classname, entry)
420             l.append(label)
421         else:
422             l.append(entry)
423     return l
425 def _set_input_default_args(dic):
426     # 'text' is the default value anyway --
427     # but for CSS usage it should be present
428     dic.setdefault('type', 'text')
429     # useful e.g for HTML LABELs:
430     if not dic.has_key('id'):
431         try:
432             if dic['text'] in ('radio', 'checkbox'):
433                 dic['id'] = '%(name)s-%(value)s' % dic
434             else:
435                 dic['id'] = dic['name']
436         except KeyError:
437             pass
439 def cgi_escape_attrs(**attrs):
440     return ' '.join(['%s="%s"'%(k,cgi.escape(str(v), True))
441         for k,v in attrs.items()])
443 def input_html4(**attrs):
444     """Generate an 'input' (html4) element with given attributes"""
445     _set_input_default_args(attrs)
446     return '<input %s>'%cgi_escape_attrs(**attrs)
448 def input_xhtml(**attrs):
449     """Generate an 'input' (xhtml) element with given attributes"""
450     _set_input_default_args(attrs)
451     return '<input %s/>'%cgi_escape_attrs(**attrs)
453 class HTMLInputMixin:
454     """ requires a _client property """
455     def __init__(self):
456         html_version = 'html4'
457         if hasattr(self._client.instance.config, 'HTML_VERSION'):
458             html_version = self._client.instance.config.HTML_VERSION
459         if html_version == 'xhtml':
460             self.input = input_xhtml
461         else:
462             self.input = input_html4
463         # self._context is used for translations.
464         # will be initialized by the first call to .gettext()
465         self._context = None
467     def gettext(self, msgid):
468         """Return the localized translation of msgid"""
469         if self._context is None:
470             self._context = context(self._client)
471         return self._client.translator.translate(domain="roundup",
472             msgid=msgid, context=self._context)
474     _ = gettext
476 class HTMLPermissions:
478     def view_check(self):
479         """ Raise the Unauthorised exception if the user's not permitted to
480             view this class.
481         """
482         if not self.is_view_ok():
483             raise Unauthorised("view", self._classname,
484                 translator=self._client.translator)
486     def edit_check(self):
487         """ Raise the Unauthorised exception if the user's not permitted to
488             edit items of this class.
489         """
490         if not self.is_edit_ok():
491             raise Unauthorised("edit", self._classname,
492                 translator=self._client.translator)
494     def retire_check(self):
495         """ Raise the Unauthorised exception if the user's not permitted to
496             retire items of this class.
497         """
498         if not self.is_retire_ok():
499             raise Unauthorised("retire", self._classname,
500                 translator=self._client.translator)
503 class HTMLClass(HTMLInputMixin, HTMLPermissions):
504     """ Accesses through a class (either through *class* or *db.<classname>*)
505     """
506     def __init__(self, client, classname, anonymous=0):
507         self._client = client
508         self._ = client._
509         self._db = client.db
510         self._anonymous = anonymous
512         # we want classname to be exposed, but _classname gives a
513         # consistent API for extending Class/Item
514         self._classname = self.classname = classname
515         self._klass = self._db.getclass(self.classname)
516         self._props = self._klass.getprops()
518         HTMLInputMixin.__init__(self)
520     def is_edit_ok(self):
521         """ Is the user allowed to Create the current class?
522         """
523         perm = self._db.security.hasPermission
524         return perm('Web Access', self._client.userid) and perm('Create',
525             self._client.userid, self._classname)
527     def is_retire_ok(self):
528         """ Is the user allowed to retire items of the current class?
529         """
530         perm = self._db.security.hasPermission
531         return perm('Web Access', self._client.userid) and perm('Retire',
532             self._client.userid, self._classname)
534     def is_view_ok(self):
535         """ Is the user allowed to View the current class?
536         """
537         perm = self._db.security.hasPermission
538         return perm('Web Access', self._client.userid) and perm('View',
539             self._client.userid, self._classname)
541     def is_only_view_ok(self):
542         """ Is the user only allowed to View (ie. not Create) the current class?
543         """
544         return self.is_view_ok() and not self.is_edit_ok()
546     def __repr__(self):
547         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
549     def __getitem__(self, item):
550         """ return an HTMLProperty instance
551         """
553         # we don't exist
554         if item == 'id':
555             return None
557         # get the property
558         try:
559             prop = self._props[item]
560         except KeyError:
561             raise KeyError, 'No such property "%s" on %s'%(item, self.classname)
563         # look up the correct HTMLProperty class
564         form = self._client.form
565         for klass, htmlklass in propclasses:
566             if not isinstance(prop, klass):
567                 continue
568             if isinstance(prop, hyperdb.Multilink):
569                 value = []
570             else:
571                 value = None
572             return htmlklass(self._client, self._classname, None, prop, item,
573                 value, self._anonymous)
575         # no good
576         raise KeyError, item
578     def __getattr__(self, attr):
579         """ convenience access """
580         try:
581             return self[attr]
582         except KeyError:
583             raise AttributeError, attr
585     def designator(self):
586         """ Return this class' designator (classname) """
587         return self._classname
589     def getItem(self, itemid, num_re=num_re):
590         """ Get an item of this class by its item id.
591         """
592         # make sure we're looking at an itemid
593         if not isinstance(itemid, type(1)) and not num_re.match(itemid):
594             itemid = self._klass.lookup(itemid)
596         return HTMLItem(self._client, self.classname, itemid)
598     def properties(self, sort=1):
599         """ Return HTMLProperty for all of this class' properties.
600         """
601         l = []
602         for name, prop in self._props.items():
603             for klass, htmlklass in propclasses:
604                 if isinstance(prop, hyperdb.Multilink):
605                     value = []
606                 else:
607                     value = None
608                 if isinstance(prop, klass):
609                     l.append(htmlklass(self._client, self._classname, '',
610                         prop, name, value, self._anonymous))
611         if sort:
612             l.sort(lambda a,b:cmp(a._name, b._name))
613         return l
615     def list(self, sort_on=None):
616         """ List all items in this class.
617         """
618         # get the list and sort it nicely
619         l = self._klass.list()
620         sortfunc = make_sort_function(self._db, self._classname, sort_on)
621         l.sort(sortfunc)
623         # check perms
624         check = self._client.db.security.hasPermission
625         userid = self._client.userid
626         if not check('Web Access', userid):
627             return []
629         l = [HTMLItem(self._client, self._classname, id) for id in l
630             if check('View', userid, self._classname, itemid=id)]
632         return l
634     def csv(self):
635         """ Return the items of this class as a chunk of CSV text.
636         """
637         props = self.propnames()
638         s = StringIO.StringIO()
639         writer = csv.writer(s)
640         writer.writerow(props)
641         check = self._client.db.security.hasPermission
642         userid = self._client.userid
643         if not check('Web Access', userid):
644             return ''
645         for nodeid in self._klass.list():
646             l = []
647             for name in props:
648                 # check permission to view this property on this item
649                 if not check('View', userid, itemid=nodeid,
650                         classname=self._klass.classname, property=name):
651                     raise Unauthorised('view', self._klass.classname,
652                         translator=self._client.translator)
653                 value = self._klass.get(nodeid, name)
654                 if value is None:
655                     l.append('')
656                 elif isinstance(value, type([])):
657                     l.append(':'.join(map(str, value)))
658                 else:
659                     l.append(str(self._klass.get(nodeid, name)))
660             writer.writerow(l)
661         return s.getvalue()
663     def propnames(self):
664         """ Return the list of the names of the properties of this class.
665         """
666         idlessprops = self._klass.getprops(protected=0).keys()
667         idlessprops.sort()
668         return ['id'] + idlessprops
670     def filter(self, request=None, filterspec={}, sort=[], group=[]):
671         """ Return a list of items from this class, filtered and sorted
672             by the current requested filterspec/filter/sort/group args
674             "request" takes precedence over the other three arguments.
675         """
676         security = self._db.security
677         userid = self._client.userid
678         if request is not None:
679             # for a request we asume it has already been
680             # security-filtered
681             filterspec = request.filterspec
682             sort = request.sort
683             group = request.group
684         else:
685             cn = self.classname
686             filterspec = security.filterFilterspec(userid, cn, filterspec)
687             sort = security.filterSortspec(userid, cn, sort)
688             group = security.filterSortspec(userid, cn, group)
690         check = security.hasPermission
691         if not check('Web Access', userid):
692             return []
694         l = [HTMLItem(self._client, self.classname, id)
695              for id in self._klass.filter(None, filterspec, sort, group)
696              if check('View', userid, self.classname, itemid=id)]
697         return l
699     def classhelp(self, properties=None, label=''"(list)", width='500',
700             height='400', property='', form='itemSynopsis',
701             pagesize=50, inputtype="checkbox", sort=None, filter=None):
702         """Pop up a javascript window with class help
704         This generates a link to a popup window which displays the
705         properties indicated by "properties" of the class named by
706         "classname". The "properties" should be a comma-separated list
707         (eg. 'id,name,description'). Properties defaults to all the
708         properties of a class (excluding id, creator, created and
709         activity).
711         You may optionally override the label displayed, the width,
712         the height, the number of items per page and the field on which
713         the list is sorted (defaults to username if in the displayed
714         properties).
716         With the "filter" arg it is possible to specify a filter for
717         which items are supposed to be displayed. It has to be of
718         the format "<field>=<values>;<field>=<values>;...".
720         The popup window will be resizable and scrollable.
722         If the "property" arg is given, it's passed through to the
723         javascript help_window function.
725         You can use inputtype="radio" to display a radio box instead
726         of the default checkbox (useful for entering Link-properties)
728         If the "form" arg is given, it's passed through to the
729         javascript help_window function. - it's the name of the form
730         the "property" belongs to.
731         """
732         if properties is None:
733             properties = self._klass.getprops(protected=0).keys()
734             properties.sort()
735             properties = ','.join(properties)
736         if sort is None:
737             if 'username' in properties.split( ',' ):
738                 sort = 'username'
739             else:
740                 sort = self._klass.orderprop()
741         sort = '&amp;@sort=' + sort
742         if property:
743             property = '&amp;property=%s'%property
744         if form:
745             form = '&amp;form=%s'%form
746         if inputtype:
747             type= '&amp;type=%s'%inputtype
748         if filter:
749             filterprops = filter.split(';')
750             filtervalues = []
751             names = []
752             for x in filterprops:
753                 (name, values) = x.split('=')
754                 names.append(name)
755                 filtervalues.append('&amp;%s=%s' % (name, urllib.quote(values)))
756             filter = '&amp;@filter=%s%s' % (','.join(names), ''.join(filtervalues))
757         else:
758            filter = ''
759         help_url = "%s?@startwith=0&amp;@template=help&amp;"\
760                    "properties=%s%s%s%s%s&amp;@pagesize=%s%s" % \
761                    (self.classname, properties, property, form, type,
762                    sort, pagesize, filter)
763         onclick = "javascript:help_window('%s', '%s', '%s');return false;" % \
764                   (help_url, width, height)
765         return '<a class="classhelp" href="%s" onclick="%s">%s</a>' % \
766                (help_url, onclick, self._(label))
768     def submit(self, label=''"Submit New Entry", action="new"):
769         """ Generate a submit button (and action hidden element)
771         Generate nothing if we're not editable.
772         """
773         if not self.is_edit_ok():
774             return ''
776         return self.input(type="hidden", name="@action", value=action) + \
777             '\n' + \
778             self.input(type="submit", name="submit_button", value=self._(label))
780     def history(self):
781         if not self.is_view_ok():
782             return self._('[hidden]')
783         return self._('New node - no history')
785     def renderWith(self, name, **kwargs):
786         """ Render this class with the given template.
787         """
788         # create a new request and override the specified args
789         req = HTMLRequest(self._client)
790         req.classname = self.classname
791         req.update(kwargs)
793         # new template, using the specified classname and request
794         pt = self._client.instance.templates.get(self.classname, name)
796         # use our fabricated request
797         args = {
798             'ok_message': self._client.ok_message,
799             'error_message': self._client.error_message
800         }
801         return pt.render(self._client, self.classname, req, **args)
803 class _HTMLItem(HTMLInputMixin, HTMLPermissions):
804     """ Accesses through an *item*
805     """
806     def __init__(self, client, classname, nodeid, anonymous=0):
807         self._client = client
808         self._db = client.db
809         self._classname = classname
810         self._nodeid = nodeid
811         self._klass = self._db.getclass(classname)
812         self._props = self._klass.getprops()
814         # do we prefix the form items with the item's identification?
815         self._anonymous = anonymous
817         HTMLInputMixin.__init__(self)
819     def is_edit_ok(self):
820         """ Is the user allowed to Edit this item?
821         """
822         perm = self._db.security.hasPermission
823         return perm('Web Access', self._client.userid) and perm('Edit',
824             self._client.userid, self._classname, itemid=self._nodeid)
826     def is_retire_ok(self):
827         """ Is the user allowed to Reture this item?
828         """
829         perm = self._db.security.hasPermission
830         return perm('Web Access', self._client.userid) and perm('Retire',
831             self._client.userid, self._classname, itemid=self._nodeid)
833     def is_view_ok(self):
834         """ Is the user allowed to View this item?
835         """
836         perm = self._db.security.hasPermission
837         if perm('Web Access', self._client.userid) and perm('View',
838                 self._client.userid, self._classname, itemid=self._nodeid):
839             return 1
840         return self.is_edit_ok()
842     def is_only_view_ok(self):
843         """ Is the user only allowed to View (ie. not Edit) this item?
844         """
845         return self.is_view_ok() and not self.is_edit_ok()
847     def __repr__(self):
848         return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
849             self._nodeid)
851     def __getitem__(self, item):
852         """ return an HTMLProperty instance
853             this now can handle transitive lookups where item is of the
854             form x.y.z
855         """
856         if item == 'id':
857             return self._nodeid
859         items = item.split('.', 1)
860         has_rest = len(items) > 1
862         # get the property
863         prop = self._props[items[0]]
865         if has_rest and not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
866             raise KeyError, item
868         # get the value, handling missing values
869         value = None
870         if int(self._nodeid) > 0:
871             value = self._klass.get(self._nodeid, items[0], None)
872         if value is None:
873             if isinstance(prop, hyperdb.Multilink):
874                 value = []
876         # look up the correct HTMLProperty class
877         htmlprop = None
878         for klass, htmlklass in propclasses:
879             if isinstance(prop, klass):
880                 htmlprop = htmlklass(self._client, self._classname,
881                     self._nodeid, prop, items[0], value, self._anonymous)
882         if htmlprop is not None:
883             if has_rest:
884                 if isinstance(htmlprop, MultilinkHTMLProperty):
885                     return [h[items[1]] for h in htmlprop]
886                 return htmlprop[items[1]]
887             return htmlprop
889         raise KeyError, item
891     def __getattr__(self, attr):
892         """ convenience access to properties """
893         try:
894             return self[attr]
895         except KeyError:
896             raise AttributeError, attr
898     def designator(self):
899         """Return this item's designator (classname + id)."""
900         return '%s%s'%(self._classname, self._nodeid)
902     def is_retired(self):
903         """Is this item retired?"""
904         return self._klass.is_retired(self._nodeid)
906     def submit(self, label=''"Submit Changes", action="edit"):
907         """Generate a submit button.
909         Also sneak in the lastactivity and action hidden elements.
910         """
911         return self.input(type="hidden", name="@lastactivity",
912             value=self.activity.local(0)) + '\n' + \
913             self.input(type="hidden", name="@action", value=action) + '\n' + \
914             self.input(type="submit", name="submit_button", value=self._(label))
916     def journal(self, direction='descending'):
917         """ Return a list of HTMLJournalEntry instances.
918         """
919         # XXX do this
920         return []
922     def history(self, direction='descending', dre=re.compile('^\d+$'),
923             limit=None):
924         if not self.is_view_ok():
925             return self._('[hidden]')
927         # pre-load the history with the current state
928         current = {}
929         for prop_n in self._props.keys():
930             prop = self[prop_n]
931             if not isinstance(prop, HTMLProperty):
932                 continue
933             current[prop_n] = prop.plain(escape=1)
934             # make link if hrefable
935             if (self._props.has_key(prop_n) and
936                     isinstance(self._props[prop_n], hyperdb.Link)):
937                 classname = self._props[prop_n].classname
938                 try:
939                     template = find_template(self._db.config.TEMPLATES,
940                         classname, 'item')
941                     if template[1].startswith('_generic'):
942                         raise NoTemplate, 'not really...'
943                 except NoTemplate:
944                     pass
945                 else:
946                     id = self._klass.get(self._nodeid, prop_n, None)
947                     current[prop_n] = '<a href="%s%s">%s</a>'%(
948                         classname, id, current[prop_n])
950         # get the journal, sort and reverse
951         history = self._klass.history(self._nodeid)
952         history.sort()
953         history.reverse()
955         # restrict the volume
956         if limit:
957             history = history[:limit]
959         timezone = self._db.getUserTimezone()
960         l = []
961         comments = {}
962         for id, evt_date, user, action, args in history:
963             date_s = str(evt_date.local(timezone)).replace("."," ")
964             arg_s = ''
965             if action == 'link' and type(args) == type(()):
966                 if len(args) == 3:
967                     linkcl, linkid, key = args
968                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
969                         linkcl, linkid, key)
970                 else:
971                     arg_s = str(args)
973             elif action == 'unlink' and type(args) == type(()):
974                 if len(args) == 3:
975                     linkcl, linkid, key = args
976                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
977                         linkcl, linkid, key)
978                 else:
979                     arg_s = str(args)
981             elif type(args) == type({}):
982                 cell = []
983                 for k in args.keys():
984                     # try to get the relevant property and treat it
985                     # specially
986                     try:
987                         prop = self._props[k]
988                     except KeyError:
989                         prop = None
990                     if prop is None:
991                         # property no longer exists
992                         comments['no_exist'] = self._(
993                             "<em>The indicated property no longer exists</em>")
994                         cell.append(self._('<em>%s: %s</em>\n')
995                             % (self._(k), str(args[k])))
996                         continue
998                     if args[k] and (isinstance(prop, hyperdb.Multilink) or
999                             isinstance(prop, hyperdb.Link)):
1000                         # figure what the link class is
1001                         classname = prop.classname
1002                         try:
1003                             linkcl = self._db.getclass(classname)
1004                         except KeyError:
1005                             labelprop = None
1006                             comments[classname] = self._(
1007                                 "The linked class %(classname)s no longer exists"
1008                             ) % locals()
1009                         labelprop = linkcl.labelprop(1)
1010                         try:
1011                             template = find_template(self._db.config.TEMPLATES,
1012                                 classname, 'item')
1013                             if template[1].startswith('_generic'):
1014                                 raise NoTemplate, 'not really...'
1015                             hrefable = 1
1016                         except NoTemplate:
1017                             hrefable = 0
1019                     if isinstance(prop, hyperdb.Multilink) and args[k]:
1020                         ml = []
1021                         for linkid in args[k]:
1022                             if isinstance(linkid, type(())):
1023                                 sublabel = linkid[0] + ' '
1024                                 linkids = linkid[1]
1025                             else:
1026                                 sublabel = ''
1027                                 linkids = [linkid]
1028                             subml = []
1029                             for linkid in linkids:
1030                                 label = classname + linkid
1031                                 # if we have a label property, try to use it
1032                                 # TODO: test for node existence even when
1033                                 # there's no labelprop!
1034                                 try:
1035                                     if labelprop is not None and \
1036                                             labelprop != 'id':
1037                                         label = linkcl.get(linkid, labelprop)
1038                                         label = cgi.escape(label)
1039                                 except IndexError:
1040                                     comments['no_link'] = self._(
1041                                         "<strike>The linked node"
1042                                         " no longer exists</strike>")
1043                                     subml.append('<strike>%s</strike>'%label)
1044                                 else:
1045                                     if hrefable:
1046                                         subml.append('<a href="%s%s">%s</a>'%(
1047                                             classname, linkid, label))
1048                                     elif label is None:
1049                                         subml.append('%s%s'%(classname,
1050                                             linkid))
1051                                     else:
1052                                         subml.append(label)
1053                             ml.append(sublabel + ', '.join(subml))
1054                         cell.append('%s:\n  %s'%(self._(k), ', '.join(ml)))
1055                     elif isinstance(prop, hyperdb.Link) and args[k]:
1056                         label = classname + args[k]
1057                         # if we have a label property, try to use it
1058                         # TODO: test for node existence even when
1059                         # there's no labelprop!
1060                         if labelprop is not None and labelprop != 'id':
1061                             try:
1062                                 label = cgi.escape(linkcl.get(args[k],
1063                                     labelprop))
1064                             except IndexError:
1065                                 comments['no_link'] = self._(
1066                                     "<strike>The linked node"
1067                                     " no longer exists</strike>")
1068                                 cell.append(' <strike>%s</strike>,\n'%label)
1069                                 # "flag" this is done .... euwww
1070                                 label = None
1071                         if label is not None:
1072                             if hrefable:
1073                                 old = '<a href="%s%s">%s</a>'%(classname,
1074                                     args[k], label)
1075                             else:
1076                                 old = label;
1077                             cell.append('%s: %s' % (self._(k), old))
1078                             if current.has_key(k):
1079                                 cell[-1] += ' -> %s'%current[k]
1080                                 current[k] = old
1082                     elif isinstance(prop, hyperdb.Date) and args[k]:
1083                         if args[k] is None:
1084                             d = ''
1085                         else:
1086                             d = date.Date(args[k],
1087                                 translator=self._client).local(timezone)
1088                         cell.append('%s: %s'%(self._(k), str(d)))
1089                         if current.has_key(k):
1090                             cell[-1] += ' -> %s' % current[k]
1091                             current[k] = str(d)
1093                     elif isinstance(prop, hyperdb.Interval) and args[k]:
1094                         val = str(date.Interval(args[k],
1095                             translator=self._client))
1096                         cell.append('%s: %s'%(self._(k), val))
1097                         if current.has_key(k):
1098                             cell[-1] += ' -> %s'%current[k]
1099                             current[k] = val
1101                     elif isinstance(prop, hyperdb.String) and args[k]:
1102                         val = cgi.escape(args[k])
1103                         cell.append('%s: %s'%(self._(k), val))
1104                         if current.has_key(k):
1105                             cell[-1] += ' -> %s'%current[k]
1106                             current[k] = val
1108                     elif isinstance(prop, hyperdb.Boolean) and args[k] is not None:
1109                         val = args[k] and ''"Yes" or ''"No"
1110                         cell.append('%s: %s'%(self._(k), val))
1111                         if current.has_key(k):
1112                             cell[-1] += ' -> %s'%current[k]
1113                             current[k] = val
1115                     elif not args[k]:
1116                         if current.has_key(k):
1117                             cell.append('%s: %s'%(self._(k), current[k]))
1118                             current[k] = '(no value)'
1119                         else:
1120                             cell.append(self._('%s: (no value)')%self._(k))
1122                     else:
1123                         cell.append('%s: %s'%(self._(k), str(args[k])))
1124                         if current.has_key(k):
1125                             cell[-1] += ' -> %s'%current[k]
1126                             current[k] = str(args[k])
1128                 arg_s = '<br />'.join(cell)
1129             else:
1130                 # unkown event!!
1131                 comments['unknown'] = self._(
1132                     "<strong><em>This event is not handled"
1133                     " by the history display!</em></strong>")
1134                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
1135             date_s = date_s.replace(' ', '&nbsp;')
1136             # if the user's an itemid, figure the username (older journals
1137             # have the username)
1138             if dre.match(user):
1139                 user = self._db.user.get(user, 'username')
1140             l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
1141                 date_s, user, self._(action), arg_s))
1142         if comments:
1143             l.append(self._(
1144                 '<tr><td colspan=4><strong>Note:</strong></td></tr>'))
1145         for entry in comments.values():
1146             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
1148         if direction == 'ascending':
1149             l.reverse()
1151         l[0:0] = ['<table class="history">'
1152              '<tr><th colspan="4" class="header">',
1153              self._('History'),
1154              '</th></tr><tr>',
1155              self._('<th>Date</th>'),
1156              self._('<th>User</th>'),
1157              self._('<th>Action</th>'),
1158              self._('<th>Args</th>'),
1159             '</tr>']
1160         l.append('</table>')
1161         return '\n'.join(l)
1163     def renderQueryForm(self):
1164         """ Render this item, which is a query, as a search form.
1165         """
1166         # create a new request and override the specified args
1167         req = HTMLRequest(self._client)
1168         req.classname = self._klass.get(self._nodeid, 'klass')
1169         name = self._klass.get(self._nodeid, 'name')
1170         req.updateFromURL(self._klass.get(self._nodeid, 'url') +
1171             '&@queryname=%s'%urllib.quote(name))
1173         # new template, using the specified classname and request
1174         pt = self._client.instance.templates.get(req.classname, 'search')
1175         # The context for a search page should be the class, not any
1176         # node.
1177         self._client.nodeid = None
1179         # use our fabricated request
1180         return pt.render(self._client, req.classname, req)
1182     def download_url(self):
1183         """ Assume that this item is a FileClass and that it has a name
1184         and content. Construct a URL for the download of the content.
1185         """
1186         name = self._klass.get(self._nodeid, 'name')
1187         url = '%s%s/%s'%(self._classname, self._nodeid, name)
1188         return urllib.quote(url)
1190     def copy_url(self, exclude=("messages", "files")):
1191         """Construct a URL for creating a copy of this item
1193         "exclude" is an optional list of properties that should
1194         not be copied to the new object.  By default, this list
1195         includes "messages" and "files" properties.  Note that
1196         "id" property cannot be copied.
1198         """
1199         exclude = ("id", "activity", "actor", "creation", "creator") \
1200             + tuple(exclude)
1201         query = {
1202             "@template": "item",
1203             "@note": self._("Copy of %(class)s %(id)s") % {
1204                 "class": self._(self._classname), "id": self._nodeid},
1205         }
1206         for name in self._props.keys():
1207             if name not in exclude:
1208                 query[name] = self[name].plain()
1209         return self._classname + "?" + "&".join(
1210             ["%s=%s" % (key, urllib.quote(value))
1211                 for key, value in query.items()])
1213 class _HTMLUser(_HTMLItem):
1214     """Add ability to check for permissions on users.
1215     """
1216     _marker = []
1217     def hasPermission(self, permission, classname=_marker,
1218             property=None, itemid=None):
1219         """Determine if the user has the Permission.
1221         The class being tested defaults to the template's class, but may
1222         be overidden for this test by suppling an alternate classname.
1223         """
1224         if classname is self._marker:
1225             classname = self._client.classname
1226         return self._db.security.hasPermission(permission,
1227             self._nodeid, classname, property, itemid)
1229     def hasRole(self, *rolenames):
1230         """Determine whether the user has any role in rolenames."""
1231         return self._db.user.has_role(self._nodeid, *rolenames)
1233 def HTMLItem(client, classname, nodeid, anonymous=0):
1234     if classname == 'user':
1235         return _HTMLUser(client, classname, nodeid, anonymous)
1236     else:
1237         return _HTMLItem(client, classname, nodeid, anonymous)
1239 class HTMLProperty(HTMLInputMixin, HTMLPermissions):
1240     """ String, Number, Date, Interval HTMLProperty
1242         Has useful attributes:
1244          _name  the name of the property
1245          _value the value of the property if any
1247         A wrapper object which may be stringified for the plain() behaviour.
1248     """
1249     def __init__(self, client, classname, nodeid, prop, name, value,
1250             anonymous=0):
1251         self._client = client
1252         self._db = client.db
1253         self._ = client._
1254         self._classname = classname
1255         self._nodeid = nodeid
1256         self._prop = prop
1257         self._value = value
1258         self._anonymous = anonymous
1259         self._name = name
1260         if not anonymous:
1261             if nodeid:
1262                 self._formname = '%s%s@%s'%(classname, nodeid, name)
1263             else:
1264                 # This case occurs when creating a property for a
1265                 # non-anonymous class.
1266                 self._formname = '%s@%s'%(classname, name)
1267         else:
1268             self._formname = name
1270         # If no value is already present for this property, see if one
1271         # is specified in the current form.
1272         form = self._client.form
1273         if not self._value and form.has_key(self._formname):
1274             if isinstance(prop, hyperdb.Multilink):
1275                 value = lookupIds(self._db, prop,
1276                                   handleListCGIValue(form[self._formname]),
1277                                   fail_ok=1)
1278             elif isinstance(prop, hyperdb.Link):
1279                 value = form.getfirst(self._formname).strip()
1280                 if value:
1281                     value = lookupIds(self._db, prop, [value],
1282                                       fail_ok=1)[0]
1283                 else:
1284                     value = None
1285             else:
1286                 value = form.getfirst(self._formname).strip() or None
1287             self._value = value
1289         HTMLInputMixin.__init__(self)
1291     def __repr__(self):
1292         classname = self.__class__.__name__
1293         return '<%s(0x%x) %s %r %r>'%(classname, id(self), self._formname,
1294                                       self._prop, self._value)
1295     def __str__(self):
1296         return self.plain()
1297     def __cmp__(self, other):
1298         if isinstance(other, HTMLProperty):
1299             return cmp(self._value, other._value)
1300         return cmp(self._value, other)
1302     def __nonzero__(self):
1303         return not not self._value
1305     def isset(self):
1306         """Is my _value not None?"""
1307         return self._value is not None
1309     def is_edit_ok(self):
1310         """Should the user be allowed to use an edit form field for this
1311         property. Check "Create" for new items, or "Edit" for existing
1312         ones.
1313         """
1314         perm = self._db.security.hasPermission
1315         userid = self._client.userid
1316         if self._nodeid:
1317             if not perm('Web Access', userid):
1318                 return False
1319             return perm('Edit', userid, self._classname, self._name,
1320                 self._nodeid)
1321         return perm('Create', userid, self._classname, self._name) or \
1322             perm('Register', userid, self._classname, self._name)
1324     def is_view_ok(self):
1325         """ Is the user allowed to View the current class?
1326         """
1327         perm = self._db.security.hasPermission
1328         if perm('Web Access',  self._client.userid) and perm('View',
1329                 self._client.userid, self._classname, self._name, self._nodeid):
1330             return 1
1331         return self.is_edit_ok()
1333 class StringHTMLProperty(HTMLProperty):
1334     hyper_re = re.compile(r'''(
1335         (?P<url>
1336          (
1337           (ht|f)tp(s?)://                   # protocol
1338           ([\w]+(:\w+)?@)?                  # username/password
1339           ([\w\-]+)                         # hostname
1340           ((\.[\w-]+)+)?                    # .domain.etc
1341          |                                  # ... or ...
1342           ([\w]+(:\w+)?@)?                  # username/password
1343           www\.                             # "www."
1344           ([\w\-]+\.)+                      # hostname
1345           [\w]{2,5}                         # TLD
1346          )
1347          (:[\d]{1,5})?                     # port
1348          (/[\w\-$.+!*(),;:@&=?/~\\#%]*)?   # path etc.
1349         )|
1350         (?P<email>[-+=%/\w\.]+@[\w\.\-]+)|
1351         (?P<item>(?P<class>[A-Za-z_]+)(\s*)(?P<id>\d+))
1352     )''', re.X | re.I)
1353     protocol_re = re.compile('^(ht|f)tp(s?)://', re.I)
1357     def _hyper_repl(self, match):
1358         if match.group('url'):
1359             return self._hyper_repl_url(match, '<a href="%s">%s</a>%s')
1360         elif match.group('email'):
1361             return self._hyper_repl_email(match, '<a href="mailto:%s">%s</a>')
1362         elif len(match.group('id')) < 10:
1363             return self._hyper_repl_item(match,
1364                 '<a href="%(cls)s%(id)s">%(item)s</a>')
1365         else:
1366             # just return the matched text
1367             return match.group(0)
1369     def _hyper_repl_url(self, match, replacement):
1370         u = s = match.group('url')
1371         if not self.protocol_re.search(s):
1372             u = 'http://' + s
1373         end = ''
1374         if '&gt;' in s:
1375             # catch an escaped ">" in the URL
1376             pos = s.find('&gt;')
1377             end = s[pos:]
1378             u = s = s[:pos]
1379         if ')' in s and s.count('(') != s.count(')'):
1380             # don't include extraneous ')' in the link
1381             pos = s.rfind(')')
1382             end = s[pos:] + end
1383             u = s = s[:pos]
1384         return replacement % (u, s, end)
1386     def _hyper_repl_email(self, match, replacement):
1387         s = match.group('email')
1388         return replacement % (s, s)
1390     def _hyper_repl_item(self, match, replacement):
1391         item = match.group('item')
1392         cls = match.group('class').lower()
1393         id = match.group('id')
1394         try:
1395             # make sure cls is a valid tracker classname
1396             cl = self._db.getclass(cls)
1397             if not cl.hasnode(id):
1398                 return item
1399             return replacement % locals()
1400         except KeyError:
1401             return item
1404     def _hyper_repl_rst(self, match):
1405         if match.group('url'):
1406             s = match.group('url')
1407             return '`%s <%s>`_'%(s, s)
1408         elif match.group('email'):
1409             s = match.group('email')
1410             return '`%s <mailto:%s>`_'%(s, s)
1411         elif len(match.group('id')) < 10:
1412             return self._hyper_repl_item(match,'`%(item)s <%(cls)s%(id)s>`_')
1413         else:
1414             # just return the matched text
1415             return match.group(0)
1417     def hyperlinked(self):
1418         """ Render a "hyperlinked" version of the text """
1419         return self.plain(hyperlink=1)
1421     def plain(self, escape=0, hyperlink=0):
1422         """Render a "plain" representation of the property
1424         - "escape" turns on/off HTML quoting
1425         - "hyperlink" turns on/off in-text hyperlinking of URLs, email
1426           addresses and designators
1427         """
1428         if not self.is_view_ok():
1429             return self._('[hidden]')
1431         if self._value is None:
1432             return ''
1433         if escape:
1434             s = cgi.escape(str(self._value))
1435         else:
1436             s = str(self._value)
1437         if hyperlink:
1438             # no, we *must* escape this text
1439             if not escape:
1440                 s = cgi.escape(s)
1441             s = self.hyper_re.sub(self._hyper_repl, s)
1442         return s
1444     def wrapped(self, escape=1, hyperlink=1):
1445         """Render a "wrapped" representation of the property.
1447         We wrap long lines at 80 columns on the nearest whitespace. Lines
1448         with no whitespace are not broken to force wrapping.
1450         Note that unlike plain() we default wrapped() to have the escaping
1451         and hyperlinking turned on since that's the most common usage.
1453         - "escape" turns on/off HTML quoting
1454         - "hyperlink" turns on/off in-text hyperlinking of URLs, email
1455           addresses and designators
1456         """
1457         if not self.is_view_ok():
1458             return self._('[hidden]')
1460         if self._value is None:
1461             return ''
1462         s = support.wrap(str(self._value), width=80)
1463         if escape:
1464             s = cgi.escape(s)
1465         if hyperlink:
1466             # no, we *must* escape this text
1467             if not escape:
1468                 s = cgi.escape(s)
1469             s = self.hyper_re.sub(self._hyper_repl, s)
1470         return s
1472     def stext(self, escape=0, hyperlink=1):
1473         """ Render the value of the property as StructuredText.
1475             This requires the StructureText module to be installed separately.
1476         """
1477         if not self.is_view_ok():
1478             return self._('[hidden]')
1480         s = self.plain(escape=escape, hyperlink=hyperlink)
1481         if not StructuredText:
1482             return s
1483         return StructuredText(s,level=1,header=0)
1485     def rst(self, hyperlink=1):
1486         """ Render the value of the property as ReStructuredText.
1488             This requires docutils to be installed separately.
1489         """
1490         if not self.is_view_ok():
1491             return self._('[hidden]')
1493         if not ReStructuredText:
1494             return self.plain(escape=0, hyperlink=hyperlink)
1495         s = self.plain(escape=0, hyperlink=0)
1496         if hyperlink:
1497             s = self.hyper_re.sub(self._hyper_repl_rst, s)
1498         return ReStructuredText(s, writer_name="html")["html_body"].encode("utf-8",
1499             "replace")
1501     def field(self, **kwargs):
1502         """ Render the property as a field in HTML.
1504             If not editable, just display the value via plain().
1505         """
1506         if not self.is_edit_ok():
1507             return self.plain(escape=1)
1509         value = self._value
1510         if value is None:
1511             value = ''
1513         kwargs.setdefault("size", 30)
1514         kwargs.update({"name": self._formname, "value": value})
1515         return self.input(**kwargs)
1517     def multiline(self, escape=0, rows=5, cols=40, **kwargs):
1518         """ Render a multiline form edit field for the property.
1520             If not editable, just display the plain() value in a <pre> tag.
1521         """
1522         if not self.is_edit_ok():
1523             return '<pre>%s</pre>'%self.plain()
1525         if self._value is None:
1526             value = ''
1527         else:
1528             value = cgi.escape(str(self._value))
1530             value = '&quot;'.join(value.split('"'))
1531         name = self._formname
1532         passthrough_args = cgi_escape_attrs(**kwargs)
1533         return ('<textarea %(passthrough_args)s name="%(name)s" id="%(name)s"'
1534                 ' rows="%(rows)s" cols="%(cols)s">'
1535                  '%(value)s</textarea>') % locals()
1537     def email(self, escape=1):
1538         """ Render the value of the property as an obscured email address
1539         """
1540         if not self.is_view_ok():
1541             return self._('[hidden]')
1543         if self._value is None:
1544             value = ''
1545         else:
1546             value = str(self._value)
1547         split = value.split('@')
1548         if len(split) == 2:
1549             name, domain = split
1550             domain = ' '.join(domain.split('.')[:-1])
1551             name = name.replace('.', ' ')
1552             value = '%s at %s ...'%(name, domain)
1553         else:
1554             value = value.replace('.', ' ')
1555         if escape:
1556             value = cgi.escape(value)
1557         return value
1559 class PasswordHTMLProperty(HTMLProperty):
1560     def plain(self, escape=0):
1561         """ Render a "plain" representation of the property
1562         """
1563         if not self.is_view_ok():
1564             return self._('[hidden]')
1566         if self._value is None:
1567             return ''
1568         return self._('*encrypted*')
1570     def field(self, size=30, **kwargs):
1571         """ Render a form edit field for the property.
1573             If not editable, just display the value via plain().
1574         """
1575         if not self.is_edit_ok():
1576             return self.plain(escape=1)
1578         return self.input(type="password", name=self._formname, size=size,
1579                           **kwargs)
1581     def confirm(self, size=30):
1582         """ Render a second form edit field for the property, used for
1583             confirmation that the user typed the password correctly. Generates
1584             a field with name "@confirm@name".
1586             If not editable, display nothing.
1587         """
1588         if not self.is_edit_ok():
1589             return ''
1591         return self.input(type="password",
1592             name="@confirm@%s"%self._formname,
1593             id="%s-confirm"%self._formname,
1594             size=size)
1596 class NumberHTMLProperty(HTMLProperty):
1597     def plain(self, escape=0):
1598         """ Render a "plain" representation of the property
1599         """
1600         if not self.is_view_ok():
1601             return self._('[hidden]')
1603         if self._value is None:
1604             return ''
1606         return str(self._value)
1608     def field(self, size=30, **kwargs):
1609         """ Render a form edit field for the property.
1611             If not editable, just display the value via plain().
1612         """
1613         if not self.is_edit_ok():
1614             return self.plain(escape=1)
1616         value = self._value
1617         if value is None:
1618             value = ''
1620         return self.input(name=self._formname, value=value, size=size,
1621                           **kwargs)
1623     def __int__(self):
1624         """ Return an int of me
1625         """
1626         return int(self._value)
1628     def __float__(self):
1629         """ Return a float of me
1630         """
1631         return float(self._value)
1634 class BooleanHTMLProperty(HTMLProperty):
1635     def plain(self, escape=0):
1636         """ Render a "plain" representation of the property
1637         """
1638         if not self.is_view_ok():
1639             return self._('[hidden]')
1641         if self._value is None:
1642             return ''
1643         return self._value and self._("Yes") or self._("No")
1645     def field(self, **kwargs):
1646         """ Render a form edit field for the property
1648             If not editable, just display the value via plain().
1649         """
1650         if not self.is_edit_ok():
1651             return self.plain(escape=1)
1653         value = self._value
1654         if isinstance(value, str) or isinstance(value, unicode):
1655             value = value.strip().lower() in ('checked', 'yes', 'true',
1656                 'on', '1')
1658         checked = value and "checked" or ""
1659         if value:
1660             s = self.input(type="radio", name=self._formname, value="yes",
1661                 checked="checked", **kwargs)
1662             s += self._('Yes')
1663             s +=self.input(type="radio", name=self._formname,  value="no",
1664                            **kwargs)
1665             s += self._('No')
1666         else:
1667             s = self.input(type="radio", name=self._formname,  value="yes",
1668                            **kwargs)
1669             s += self._('Yes')
1670             s +=self.input(type="radio", name=self._formname, value="no",
1671                 checked="checked", **kwargs)
1672             s += self._('No')
1673         return s
1675 class DateHTMLProperty(HTMLProperty):
1677     _marker = []
1679     def __init__(self, client, classname, nodeid, prop, name, value,
1680             anonymous=0, offset=None):
1681         HTMLProperty.__init__(self, client, classname, nodeid, prop, name,
1682                 value, anonymous=anonymous)
1683         if self._value and not (isinstance(self._value, str) or
1684                 isinstance(self._value, unicode)):
1685             self._value.setTranslator(self._client.translator)
1686         self._offset = offset
1687         if self._offset is None :
1688             self._offset = self._prop.offset (self._db)
1690     def plain(self, escape=0):
1691         """ Render a "plain" representation of the property
1692         """
1693         if not self.is_view_ok():
1694             return self._('[hidden]')
1696         if self._value is None:
1697             return ''
1698         if self._offset is None:
1699             offset = self._db.getUserTimezone()
1700         else:
1701             offset = self._offset
1702         return str(self._value.local(offset))
1704     def now(self, str_interval=None):
1705         """ Return the current time.
1707             This is useful for defaulting a new value. Returns a
1708             DateHTMLProperty.
1709         """
1710         if not self.is_view_ok():
1711             return self._('[hidden]')
1713         ret = date.Date('.', translator=self._client)
1715         if isinstance(str_interval, basestring):
1716             sign = 1
1717             if str_interval[0] == '-':
1718                 sign = -1
1719                 str_interval = str_interval[1:]
1720             interval = date.Interval(str_interval, translator=self._client)
1721             if sign > 0:
1722                 ret = ret + interval
1723             else:
1724                 ret = ret - interval
1726         return DateHTMLProperty(self._client, self._classname, self._nodeid,
1727             self._prop, self._formname, ret)
1729     def field(self, size=30, default=None, format=_marker, popcal=True,
1730               **kwargs):
1731         """Render a form edit field for the property
1733         If not editable, just display the value via plain().
1735         If "popcal" then include the Javascript calendar editor.
1736         Default=yes.
1738         The format string is a standard python strftime format string.
1739         """
1740         if not self.is_edit_ok():
1741             if format is self._marker:
1742                 return self.plain(escape=1)
1743             else:
1744                 return self.pretty(format)
1746         value = self._value
1748         if value is None:
1749             if default is None:
1750                 raw_value = None
1751             else:
1752                 if isinstance(default, basestring):
1753                     raw_value = date.Date(default, translator=self._client)
1754                 elif isinstance(default, date.Date):
1755                     raw_value = default
1756                 elif isinstance(default, DateHTMLProperty):
1757                     raw_value = default._value
1758                 else:
1759                     raise ValueError, self._('default value for '
1760                         'DateHTMLProperty must be either DateHTMLProperty '
1761                         'or string date representation.')
1762         elif isinstance(value, str) or isinstance(value, unicode):
1763             # most likely erroneous input to be passed back to user
1764             if isinstance(value, unicode): value = value.encode('utf8')
1765             return self.input(name=self._formname, value=value, size=size,
1766                               **kwargs)
1767         else:
1768             raw_value = value
1770         if raw_value is None:
1771             value = ''
1772         elif isinstance(raw_value, str) or isinstance(raw_value, unicode):
1773             if format is self._marker:
1774                 value = raw_value
1775             else:
1776                 value = date.Date(raw_value).pretty(format)
1777         else:
1778             if self._offset is None :
1779                 offset = self._db.getUserTimezone()
1780             else :
1781                 offset = self._offset
1782             value = raw_value.local(offset)
1783             if format is not self._marker:
1784                 value = value.pretty(format)
1786         s = self.input(name=self._formname, value=value, size=size,
1787                        **kwargs)
1788         if popcal:
1789             s += self.popcal()
1790         return s
1792     def reldate(self, pretty=1):
1793         """ Render the interval between the date and now.
1795             If the "pretty" flag is true, then make the display pretty.
1796         """
1797         if not self.is_view_ok():
1798             return self._('[hidden]')
1800         if not self._value:
1801             return ''
1803         # figure the interval
1804         interval = self._value - date.Date('.', translator=self._client)
1805         if pretty:
1806             return interval.pretty()
1807         return str(interval)
1809     def pretty(self, format=_marker):
1810         """ Render the date in a pretty format (eg. month names, spaces).
1812             The format string is a standard python strftime format string.
1813             Note that if the day is zero, and appears at the start of the
1814             string, then it'll be stripped from the output. This is handy
1815             for the situation when a date only specifies a month and a year.
1816         """
1817         if not self.is_view_ok():
1818             return self._('[hidden]')
1820         if self._offset is None:
1821             offset = self._db.getUserTimezone()
1822         else:
1823             offset = self._offset
1825         if not self._value:
1826             return ''
1827         elif format is not self._marker:
1828             return self._value.local(offset).pretty(format)
1829         else:
1830             return self._value.local(offset).pretty()
1832     def local(self, offset):
1833         """ Return the date/time as a local (timezone offset) date/time.
1834         """
1835         if not self.is_view_ok():
1836             return self._('[hidden]')
1838         return DateHTMLProperty(self._client, self._classname, self._nodeid,
1839             self._prop, self._formname, self._value, offset=offset)
1841     def popcal(self, width=300, height=200, label="(cal)",
1842             form="itemSynopsis"):
1843         """Generate a link to a calendar pop-up window.
1845         item: HTMLProperty e.g.: context.deadline
1846         """
1847         if self.isset():
1848             date = "&date=%s"%self._value
1849         else :
1850             date = ""
1851         return ('<a class="classhelp" href="javascript:help_window('
1852             "'%s?@template=calendar&amp;property=%s&amp;form=%s%s', %d, %d)"
1853             '">%s</a>'%(self._classname, self._name, form, date, width,
1854             height, label))
1856 class IntervalHTMLProperty(HTMLProperty):
1857     def __init__(self, client, classname, nodeid, prop, name, value,
1858             anonymous=0):
1859         HTMLProperty.__init__(self, client, classname, nodeid, prop,
1860             name, value, anonymous)
1861         if self._value and not isinstance(self._value, (str, unicode)):
1862             self._value.setTranslator(self._client.translator)
1864     def plain(self, escape=0):
1865         """ Render a "plain" representation of the property
1866         """
1867         if not self.is_view_ok():
1868             return self._('[hidden]')
1870         if self._value is None:
1871             return ''
1872         return str(self._value)
1874     def pretty(self):
1875         """ Render the interval in a pretty format (eg. "yesterday")
1876         """
1877         if not self.is_view_ok():
1878             return self._('[hidden]')
1880         return self._value.pretty()
1882     def field(self, size=30, **kwargs):
1883         """ Render a form edit field for the property
1885             If not editable, just display the value via plain().
1886         """
1887         if not self.is_edit_ok():
1888             return self.plain(escape=1)
1890         value = self._value
1891         if value is None:
1892             value = ''
1894         return self.input(name=self._formname, value=value, size=size,
1895                           **kwargs)
1897 class LinkHTMLProperty(HTMLProperty):
1898     """ Link HTMLProperty
1899         Include the above as well as being able to access the class
1900         information. Stringifying the object itself results in the value
1901         from the item being displayed. Accessing attributes of this object
1902         result in the appropriate entry from the class being queried for the
1903         property accessed (so item/assignedto/name would look up the user
1904         entry identified by the assignedto property on item, and then the
1905         name property of that user)
1906     """
1907     def __init__(self, *args, **kw):
1908         HTMLProperty.__init__(self, *args, **kw)
1909         # if we're representing a form value, then the -1 from the form really
1910         # should be a None
1911         if str(self._value) == '-1':
1912             self._value = None
1914     def __getattr__(self, attr):
1915         """ return a new HTMLItem """
1916         if not self._value:
1917             # handle a special page templates lookup
1918             if attr == '__render_with_namespace__':
1919                 def nothing(*args, **kw):
1920                     return ''
1921                 return nothing
1922             msg = self._('Attempt to look up %(attr)s on a missing value')
1923             return MissingValue(msg%locals())
1924         i = HTMLItem(self._client, self._prop.classname, self._value)
1925         return getattr(i, attr)
1927     def plain(self, escape=0):
1928         """ Render a "plain" representation of the property
1929         """
1930         if not self.is_view_ok():
1931             return self._('[hidden]')
1933         if self._value is None:
1934             return ''
1935         linkcl = self._db.classes[self._prop.classname]
1936         k = linkcl.labelprop(1)
1937         if num_re.match(self._value):
1938             try:
1939                 value = str(linkcl.get(self._value, k))
1940             except IndexError:
1941                 value = self._value
1942         else :
1943             value = self._value
1944         if escape:
1945             value = cgi.escape(value)
1946         return value
1948     def field(self, showid=0, size=None, **kwargs):
1949         """ Render a form edit field for the property
1951             If not editable, just display the value via plain().
1952         """
1953         if not self.is_edit_ok():
1954             return self.plain(escape=1)
1956         # edit field
1957         linkcl = self._db.getclass(self._prop.classname)
1958         if self._value is None:
1959             value = ''
1960         else:
1961             k = linkcl.getkey()
1962             if k and num_re.match(self._value):
1963                 value = linkcl.get(self._value, k)
1964             else:
1965                 value = self._value
1966         return self.input(name=self._formname, value=value, size=size,
1967                           **kwargs)
1969     def menu(self, size=None, height=None, showid=0, additional=[], value=None,
1970              sort_on=None, html_kwargs = {}, **conditions):
1971         """ Render a form select list for this property
1973             "size" is used to limit the length of the list labels
1974             "height" is used to set the <select> tag's "size" attribute
1975             "showid" includes the item ids in the list labels
1976             "value" specifies which item is pre-selected
1977             "additional" lists properties which should be included in the
1978                 label
1979             "sort_on" indicates the property to sort the list on as
1980                 (direction, property) where direction is '+' or '-'. A
1981                 single string with the direction prepended may be used.
1982                 For example: ('-', 'order'), '+name'.
1984             The remaining keyword arguments are used as conditions for
1985             filtering the items in the list - they're passed as the
1986             "filterspec" argument to a Class.filter() call.
1988             If not editable, just display the value via plain().
1989         """
1990         if not self.is_edit_ok():
1991             return self.plain(escape=1)
1993         # Since None indicates the default, we need another way to
1994         # indicate "no selection".  We use -1 for this purpose, as
1995         # that is the value we use when submitting a form without the
1996         # value set.
1997         if value is None:
1998             value = self._value
1999         elif value == '-1':
2000             value = None
2002         linkcl = self._db.getclass(self._prop.classname)
2003         l = ['<select %s>'%cgi_escape_attrs(name = self._formname,
2004                                             **html_kwargs)]
2005         k = linkcl.labelprop(1)
2006         s = ''
2007         if value is None:
2008             s = 'selected="selected" '
2009         l.append(self._('<option %svalue="-1">- no selection -</option>')%s)
2011         if sort_on is not None:
2012             if not isinstance(sort_on, tuple):
2013                 if sort_on[0] in '+-':
2014                     sort_on = (sort_on[0], sort_on[1:])
2015                 else:
2016                     sort_on = ('+', sort_on)
2017         else:
2018             sort_on = ('+', linkcl.orderprop())
2020         options = [opt
2021             for opt in linkcl.filter(None, conditions, sort_on, (None, None))
2022             if self._db.security.hasPermission("View", self._client.userid,
2023                 linkcl.classname, itemid=opt)]
2025         # make sure we list the current value if it's retired
2026         if value and value not in options:
2027             options.insert(0, value)
2029         if additional:
2030             additional_fns = []
2031             props = linkcl.getprops()
2032             for propname in additional:
2033                 prop = props[propname]
2034                 if isinstance(prop, hyperdb.Link):
2035                     cl = self._db.getclass(prop.classname)
2036                     labelprop = cl.labelprop()
2037                     fn = lambda optionid: cl.get(linkcl.get(optionid,
2038                                                             propname),
2039                                                  labelprop)
2040                 else:
2041                     fn = lambda optionid: linkcl.get(optionid, propname)
2042             additional_fns.append(fn)
2044         for optionid in options:
2045             # get the option value, and if it's None use an empty string
2046             option = linkcl.get(optionid, k) or ''
2048             # figure if this option is selected
2049             s = ''
2050             if value in [optionid, option]:
2051                 s = 'selected="selected" '
2053             # figure the label
2054             if showid:
2055                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
2056             elif not option:
2057                 lab = '%s%s'%(self._prop.classname, optionid)
2058             else:
2059                 lab = option
2061             # truncate if it's too long
2062             if size is not None and len(lab) > size:
2063                 lab = lab[:size-3] + '...'
2064             if additional:
2065                 m = []
2066                 for fn in additional_fns:
2067                     m.append(str(fn(optionid)))
2068                 lab = lab + ' (%s)'%', '.join(m)
2070             # and generate
2071             lab = cgi.escape(self._(lab))
2072             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
2073         l.append('</select>')
2074         return '\n'.join(l)
2075 #    def checklist(self, ...)
2079 class MultilinkHTMLProperty(HTMLProperty):
2080     """ Multilink HTMLProperty
2082         Also be iterable, returning a wrapper object like the Link case for
2083         each entry in the multilink.
2084     """
2085     def __init__(self, *args, **kwargs):
2086         HTMLProperty.__init__(self, *args, **kwargs)
2087         if self._value:
2088             display_value = lookupIds(self._db, self._prop, self._value,
2089                 fail_ok=1, do_lookup=False)
2090             sortfun = make_sort_function(self._db, self._prop.classname)
2091             # sorting fails if the value contains
2092             # items not yet stored in the database
2093             # ignore these errors to preserve user input
2094             try:
2095                 display_value.sort(sortfun)
2096             except:
2097                 pass
2098             self._value = display_value
2100     def __len__(self):
2101         """ length of the multilink """
2102         return len(self._value)
2104     def __getattr__(self, attr):
2105         """ no extended attribute accesses make sense here """
2106         raise AttributeError, attr
2108     def viewableGenerator(self, values):
2109         """Used to iterate over only the View'able items in a class."""
2110         check = self._db.security.hasPermission
2111         userid = self._client.userid
2112         classname = self._prop.classname
2113         if check('Web Access', userid):
2114             for value in values:
2115                 if check('View', userid, classname, itemid=value):
2116                     yield HTMLItem(self._client, classname, value)
2118     def __iter__(self):
2119         """ iterate and return a new HTMLItem
2120         """
2121         return self.viewableGenerator(self._value)
2123     def reverse(self):
2124         """ return the list in reverse order
2125         """
2126         l = self._value[:]
2127         l.reverse()
2128         return self.viewableGenerator(l)
2130     def sorted(self, property):
2131         """ Return this multilink sorted by the given property """
2132         value = list(self.__iter__())
2133         value.sort(lambda a,b:cmp(a[property], b[property]))
2134         return value
2136     def __contains__(self, value):
2137         """ Support the "in" operator. We have to make sure the passed-in
2138             value is a string first, not a HTMLProperty.
2139         """
2140         return str(value) in self._value
2142     def isset(self):
2143         """Is my _value not []?"""
2144         return self._value != []
2146     def plain(self, escape=0):
2147         """ Render a "plain" representation of the property
2148         """
2149         if not self.is_view_ok():
2150             return self._('[hidden]')
2152         linkcl = self._db.classes[self._prop.classname]
2153         k = linkcl.labelprop(1)
2154         labels = []
2155         for v in self._value:
2156             if num_re.match(v):
2157                 try:
2158                     label = linkcl.get(v, k)
2159                 except IndexError:
2160                     label = None
2161                 # fall back to designator if label is None
2162                 if label is None: label = '%s%s'%(self._prop.classname, k)
2163             else:
2164                 label = v
2165             labels.append(label)
2166         value = ', '.join(labels)
2167         if escape:
2168             value = cgi.escape(value)
2169         return value
2171     def field(self, size=30, showid=0, **kwargs):
2172         """ Render a form edit field for the property
2174             If not editable, just display the value via plain().
2175         """
2176         if not self.is_edit_ok():
2177             return self.plain(escape=1)
2179         linkcl = self._db.getclass(self._prop.classname)
2181         if 'value' not in kwargs:
2182             value = self._value[:]
2183             # map the id to the label property
2184             if not linkcl.getkey():
2185                 showid=1
2186             if not showid:
2187                 k = linkcl.labelprop(1)
2188                 value = lookupKeys(linkcl, k, value)
2189             value = ','.join(value)
2190             kwargs["value"] = value
2192         return self.input(name=self._formname, size=size, **kwargs)
2194     def menu(self, size=None, height=None, showid=0, additional=[],
2195              value=None, sort_on=None, html_kwargs = {}, **conditions):
2196         """ Render a form <select> list for this property.
2198             "size" is used to limit the length of the list labels
2199             "height" is used to set the <select> tag's "size" attribute
2200             "showid" includes the item ids in the list labels
2201             "additional" lists properties which should be included in the
2202                 label
2203             "value" specifies which item is pre-selected
2204             "sort_on" indicates the property to sort the list on as
2205                 (direction, property) where direction is '+' or '-'. A
2206                 single string with the direction prepended may be used.
2207                 For example: ('-', 'order'), '+name'.
2209             The remaining keyword arguments are used as conditions for
2210             filtering the items in the list - they're passed as the
2211             "filterspec" argument to a Class.filter() call.
2213             If not editable, just display the value via plain().
2214         """
2215         if not self.is_edit_ok():
2216             return self.plain(escape=1)
2218         if value is None:
2219             value = self._value
2221         linkcl = self._db.getclass(self._prop.classname)
2223         if sort_on is not None:
2224             if not isinstance(sort_on, tuple):
2225                 if sort_on[0] in '+-':
2226                     sort_on = (sort_on[0], sort_on[1:])
2227                 else:
2228                     sort_on = ('+', sort_on)
2229         else:
2230             sort_on = ('+', linkcl.orderprop())
2232         options = [opt
2233             for opt in linkcl.filter(None, conditions, sort_on)
2234             if self._db.security.hasPermission("View", self._client.userid,
2235                 linkcl.classname, itemid=opt)]
2237         # make sure we list the current values if they're retired
2238         for val in value:
2239             if val not in options:
2240                 options.insert(0, val)
2242         if not height:
2243             height = len(options)
2244             if value:
2245                 # The "no selection" option.
2246                 height += 1
2247             height = min(height, 7)
2248         l = ['<select multiple %s>'%cgi_escape_attrs(name = self._formname,
2249                                                      size = height,
2250                                                      **html_kwargs)]
2251         k = linkcl.labelprop(1)
2253         if value:
2254             l.append('<option value="%s">- no selection -</option>'
2255                      % ','.join(['-' + v for v in value]))
2257         if additional:
2258             additional_fns = []
2259             props = linkcl.getprops()
2260             for propname in additional:
2261                 prop = props[propname]
2262                 if isinstance(prop, hyperdb.Link):
2263                     cl = self._db.getclass(prop.classname)
2264                     labelprop = cl.labelprop()
2265                     fn = lambda optionid: cl.get(linkcl.get(optionid,
2266                                                             propname),
2267                                                  labelprop)
2268                 else:
2269                     fn = lambda optionid: linkcl.get(optionid, propname)
2270             additional_fns.append(fn)
2272         for optionid in options:
2273             # get the option value, and if it's None use an empty string
2274             option = linkcl.get(optionid, k) or ''
2276             # figure if this option is selected
2277             s = ''
2278             if optionid in value or option in value:
2279                 s = 'selected="selected" '
2281             # figure the label
2282             if showid:
2283                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
2284             else:
2285                 lab = option
2286             # truncate if it's too long
2287             if size is not None and len(lab) > size:
2288                 lab = lab[:size-3] + '...'
2289             if additional:
2290                 m = []
2291                 for fn in additional_fns:
2292                     m.append(str(fn(optionid)))
2293                 lab = lab + ' (%s)'%', '.join(m)
2295             # and generate
2296             lab = cgi.escape(self._(lab))
2297             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
2298                 lab))
2299         l.append('</select>')
2300         return '\n'.join(l)
2303 # set the propclasses for HTMLItem
2304 propclasses = [
2305     (hyperdb.String, StringHTMLProperty),
2306     (hyperdb.Number, NumberHTMLProperty),
2307     (hyperdb.Boolean, BooleanHTMLProperty),
2308     (hyperdb.Date, DateHTMLProperty),
2309     (hyperdb.Interval, IntervalHTMLProperty),
2310     (hyperdb.Password, PasswordHTMLProperty),
2311     (hyperdb.Link, LinkHTMLProperty),
2312     (hyperdb.Multilink, MultilinkHTMLProperty),
2315 def register_propclass(prop, cls):
2316     for index,propclass in enumerate(propclasses):
2317         p, c = propclass
2318         if prop == p:
2319             propclasses[index] = (prop, cls)
2320             break
2321     else:
2322         propclasses.append((prop, cls))
2325 def make_sort_function(db, classname, sort_on=None):
2326     """Make a sort function for a given class.
2328     The list being sorted may contain mixed ids and labels.
2329     """
2330     linkcl = db.getclass(classname)
2331     if sort_on is None:
2332         sort_on = linkcl.orderprop()
2333     def sortfunc(a, b):
2334         if num_re.match(a):
2335             a = linkcl.get(a, sort_on)
2336         if num_re.match(b):
2337             b = linkcl.get(b, sort_on)
2338         return cmp(a, b)
2339     return sortfunc
2341 def handleListCGIValue(value):
2342     """ Value is either a single item or a list of items. Each item has a
2343         .value that we're actually interested in.
2344     """
2345     if isinstance(value, type([])):
2346         return [value.value for value in value]
2347     else:
2348         value = value.value.strip()
2349         if not value:
2350             return []
2351         return [v.strip() for v in value.split(',')]
2353 class HTMLRequest(HTMLInputMixin):
2354     """The *request*, holding the CGI form and environment.
2356     - "form" the CGI form as a cgi.FieldStorage
2357     - "env" the CGI environment variables
2358     - "base" the base URL for this instance
2359     - "user" a HTMLItem instance for this user
2360     - "language" as determined by the browser or config
2361     - "classname" the current classname (possibly None)
2362     - "template" the current template (suffix, also possibly None)
2364     Index args:
2366     - "columns" dictionary of the columns to display in an index page
2367     - "show" a convenience access to columns - request/show/colname will
2368       be true if the columns should be displayed, false otherwise
2369     - "sort" index sort column (direction, column name)
2370     - "group" index grouping property (direction, column name)
2371     - "filter" properties to filter the index on
2372     - "filterspec" values to filter the index on
2373     - "search_text" text to perform a full-text search on for an index
2374     """
2375     def __repr__(self):
2376         return '<HTMLRequest %r>'%self.__dict__
2378     def __init__(self, client):
2379         # _client is needed by HTMLInputMixin
2380         self._client = self.client = client
2382         # easier access vars
2383         self.form = client.form
2384         self.env = client.env
2385         self.base = client.base
2386         self.user = HTMLItem(client, 'user', client.userid)
2387         self.language = client.language
2389         # store the current class name and action
2390         self.classname = client.classname
2391         self.nodeid = client.nodeid
2392         self.template = client.template
2394         # the special char to use for special vars
2395         self.special_char = '@'
2397         HTMLInputMixin.__init__(self)
2399         self._post_init()
2401     def current_url(self):
2402         url = self.base
2403         if self.classname:
2404             url += self.classname
2405             if self.nodeid:
2406                 url += self.nodeid
2407         args = {}
2408         if self.template:
2409             args['@template'] = self.template
2410         return self.indexargs_url(url, args)
2412     def _parse_sort(self, var, name):
2413         """ Parse sort/group options. Append to var
2414         """
2415         fields = []
2416         dirs = []
2417         for special in '@:':
2418             idx = 0
2419             key = '%s%s%d'%(special, name, idx)
2420             while key in self.form:
2421                 self.special_char = special
2422                 fields.append(self.form.getfirst(key))
2423                 dirkey = '%s%sdir%d'%(special, name, idx)
2424                 if dirkey in self.form:
2425                     dirs.append(self.form.getfirst(dirkey))
2426                 else:
2427                     dirs.append(None)
2428                 idx += 1
2429                 key = '%s%s%d'%(special, name, idx)
2430             # backward compatible (and query) URL format
2431             key = special + name
2432             dirkey = key + 'dir'
2433             if key in self.form and not fields:
2434                 fields = handleListCGIValue(self.form[key])
2435                 if dirkey in self.form:
2436                     dirs.append(self.form.getfirst(dirkey))
2437             if fields: # only try other special char if nothing found
2438                 break
2439         for f, d in map(None, fields, dirs):
2440             if f.startswith('-'):
2441                 var.append(('-', f[1:]))
2442             elif d:
2443                 var.append(('-', f))
2444             else:
2445                 var.append(('+', f))
2447     def _post_init(self):
2448         """ Set attributes based on self.form
2449         """
2450         # extract the index display information from the form
2451         self.columns = []
2452         for name in ':columns @columns'.split():
2453             if self.form.has_key(name):
2454                 self.special_char = name[0]
2455                 self.columns = handleListCGIValue(self.form[name])
2456                 break
2457         self.show = support.TruthDict(self.columns)
2458         security = self._client.db.security
2459         userid = self._client.userid
2461         # sorting and grouping
2462         self.sort = []
2463         self.group = []
2464         self._parse_sort(self.sort, 'sort')
2465         self._parse_sort(self.group, 'group')
2466         self.sort = security.filterSortspec(userid, self.classname, self.sort)
2467         self.group = security.filterSortspec(userid, self.classname, self.group)
2469         # filtering
2470         self.filter = []
2471         for name in ':filter @filter'.split():
2472             if self.form.has_key(name):
2473                 self.special_char = name[0]
2474                 self.filter = handleListCGIValue(self.form[name])
2476         self.filterspec = {}
2477         db = self.client.db
2478         if self.classname is not None:
2479             cls = db.getclass (self.classname)
2480             for name in self.filter:
2481                 if not self.form.has_key(name):
2482                     continue
2483                 prop = cls.get_transitive_prop (name)
2484                 fv = self.form[name]
2485                 if (isinstance(prop, hyperdb.Link) or
2486                         isinstance(prop, hyperdb.Multilink)):
2487                     self.filterspec[name] = lookupIds(db, prop,
2488                         handleListCGIValue(fv))
2489                 else:
2490                     if isinstance(fv, type([])):
2491                         self.filterspec[name] = [v.value for v in fv]
2492                     elif name == 'id':
2493                         # special case "id" property
2494                         self.filterspec[name] = handleListCGIValue(fv)
2495                     else:
2496                         self.filterspec[name] = fv.value
2497         self.filterspec = security.filterFilterspec(userid, self.classname,
2498             self.filterspec)
2500         # full-text search argument
2501         self.search_text = None
2502         for name in ':search_text @search_text'.split():
2503             if self.form.has_key(name):
2504                 self.special_char = name[0]
2505                 self.search_text = self.form.getfirst(name)
2507         # pagination - size and start index
2508         # figure batch args
2509         self.pagesize = 50
2510         for name in ':pagesize @pagesize'.split():
2511             if self.form.has_key(name):
2512                 self.special_char = name[0]
2513                 try:
2514                     self.pagesize = int(self.form.getfirst(name))
2515                 except ValueError:
2516                     # not an integer - ignore
2517                     pass
2519         self.startwith = 0
2520         for name in ':startwith @startwith'.split():
2521             if self.form.has_key(name):
2522                 self.special_char = name[0]
2523                 try:
2524                     self.startwith = int(self.form.getfirst(name))
2525                 except ValueError:
2526                     # not an integer - ignore
2527                     pass
2529         # dispname
2530         if self.form.has_key('@dispname'):
2531             self.dispname = self.form.getfirst('@dispname')
2532         else:
2533             self.dispname = None
2535     def updateFromURL(self, url):
2536         """ Parse the URL for query args, and update my attributes using the
2537             values.
2538         """
2539         env = {'QUERY_STRING': url}
2540         self.form = cgi.FieldStorage(environ=env)
2542         self._post_init()
2544     def update(self, kwargs):
2545         """ Update my attributes using the keyword args
2546         """
2547         self.__dict__.update(kwargs)
2548         if kwargs.has_key('columns'):
2549             self.show = support.TruthDict(self.columns)
2551     def description(self):
2552         """ Return a description of the request - handle for the page title.
2553         """
2554         s = [self.client.db.config.TRACKER_NAME]
2555         if self.classname:
2556             if self.client.nodeid:
2557                 s.append('- %s%s'%(self.classname, self.client.nodeid))
2558             else:
2559                 if self.template == 'item':
2560                     s.append('- new %s'%self.classname)
2561                 elif self.template == 'index':
2562                     s.append('- %s index'%self.classname)
2563                 else:
2564                     s.append('- %s %s'%(self.classname, self.template))
2565         else:
2566             s.append('- home')
2567         return ' '.join(s)
2569     def __str__(self):
2570         d = {}
2571         d.update(self.__dict__)
2572         f = ''
2573         for k in self.form.keys():
2574             f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
2575         d['form'] = f
2576         e = ''
2577         for k,v in self.env.items():
2578             e += '\n     %r=%r'%(k, v)
2579         d['env'] = e
2580         return """
2581 form: %(form)s
2582 base: %(base)r
2583 classname: %(classname)r
2584 template: %(template)r
2585 columns: %(columns)r
2586 sort: %(sort)r
2587 group: %(group)r
2588 filter: %(filter)r
2589 search_text: %(search_text)r
2590 pagesize: %(pagesize)r
2591 startwith: %(startwith)r
2592 env: %(env)s
2593 """%d
2595     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
2596             filterspec=1, search_text=1):
2597         """ return the current index args as form elements """
2598         l = []
2599         sc = self.special_char
2600         def add(k, v):
2601             l.append(self.input(type="hidden", name=k, value=v))
2602         if columns and self.columns:
2603             add(sc+'columns', ','.join(self.columns))
2604         if sort:
2605             val = []
2606             for dir, attr in self.sort:
2607                 if dir == '-':
2608                     val.append('-'+attr)
2609                 else:
2610                     val.append(attr)
2611             add(sc+'sort', ','.join (val))
2612         if group:
2613             val = []
2614             for dir, attr in self.group:
2615                 if dir == '-':
2616                     val.append('-'+attr)
2617                 else:
2618                     val.append(attr)
2619             add(sc+'group', ','.join (val))
2620         if filter and self.filter:
2621             add(sc+'filter', ','.join(self.filter))
2622         if self.classname and filterspec:
2623             cls = self.client.db.getclass(self.classname)
2624             for k,v in self.filterspec.items():
2625                 if type(v) == type([]):
2626                     if isinstance(cls.get_transitive_prop(k), hyperdb.String):
2627                         add(k, ' '.join(v))
2628                     else:
2629                         add(k, ','.join(v))
2630                 else:
2631                     add(k, v)
2632         if search_text and self.search_text:
2633             add(sc+'search_text', self.search_text)
2634         add(sc+'pagesize', self.pagesize)
2635         add(sc+'startwith', self.startwith)
2636         return '\n'.join(l)
2638     def indexargs_url(self, url, args):
2639         """ Embed the current index args in a URL
2640         """
2641         q = urllib.quote
2642         sc = self.special_char
2643         l = ['%s=%s'%(k,v) for k,v in args.items()]
2645         # pull out the special values (prefixed by @ or :)
2646         specials = {}
2647         for key in args.keys():
2648             if key[0] in '@:':
2649                 specials[key[1:]] = args[key]
2651         # ok, now handle the specials we received in the request
2652         if self.columns and not specials.has_key('columns'):
2653             l.append(sc+'columns=%s'%(','.join(self.columns)))
2654         if self.sort and not specials.has_key('sort'):
2655             val = []
2656             for dir, attr in self.sort:
2657                 if dir == '-':
2658                     val.append('-'+attr)
2659                 else:
2660                     val.append(attr)
2661             l.append(sc+'sort=%s'%(','.join(val)))
2662         if self.group and not specials.has_key('group'):
2663             val = []
2664             for dir, attr in self.group:
2665                 if dir == '-':
2666                     val.append('-'+attr)
2667                 else:
2668                     val.append(attr)
2669             l.append(sc+'group=%s'%(','.join(val)))
2670         if self.filter and not specials.has_key('filter'):
2671             l.append(sc+'filter=%s'%(','.join(self.filter)))
2672         if self.search_text and not specials.has_key('search_text'):
2673             l.append(sc+'search_text=%s'%q(self.search_text))
2674         if not specials.has_key('pagesize'):
2675             l.append(sc+'pagesize=%s'%self.pagesize)
2676         if not specials.has_key('startwith'):
2677             l.append(sc+'startwith=%s'%self.startwith)
2679         # finally, the remainder of the filter args in the request
2680         if self.classname and self.filterspec:
2681             cls = self.client.db.getclass(self.classname)
2682             for k,v in self.filterspec.items():
2683                 if not args.has_key(k):
2684                     if type(v) == type([]):
2685                         prop = cls.get_transitive_prop(k)
2686                         if k != 'id' and isinstance(prop, hyperdb.String):
2687                             l.append('%s=%s'%(k, '%20'.join([q(i) for i in v])))
2688                         else:
2689                             l.append('%s=%s'%(k, ','.join([q(i) for i in v])))
2690                     else:
2691                         l.append('%s=%s'%(k, q(v)))
2692         return '%s?%s'%(url, '&'.join(l))
2693     indexargs_href = indexargs_url
2695     def base_javascript(self):
2696         return """
2697 <script type="text/javascript">
2698 submitted = false;
2699 function submit_once() {
2700     if (submitted) {
2701         alert("Your request is being processed.\\nPlease be patient.");
2702         event.returnValue = 0;    // work-around for IE
2703         return 0;
2704     }
2705     submitted = true;
2706     return 1;
2709 function help_window(helpurl, width, height) {
2710     HelpWin = window.open('%s' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
2712 </script>
2713 """%self.base
2715     def batch(self):
2716         """ Return a batch object for results from the "current search"
2717         """
2718         check = self._client.db.security.hasPermission
2719         userid = self._client.userid
2720         if not check('Web Access', userid):
2721             return Batch(self.client, [], self.pagesize, self.startwith,
2722                 classname=self.classname)
2724         filterspec = self.filterspec
2725         sort = self.sort
2726         group = self.group
2728         # get the list of ids we're batching over
2729         klass = self.client.db.getclass(self.classname)
2730         if self.search_text:
2731             matches = self.client.db.indexer.search(
2732                 [w.upper().encode("utf-8", "replace") for w in re.findall(
2733                     r'(?u)\b\w{2,25}\b',
2734                     unicode(self.search_text, "utf-8", "replace")
2735                 )], klass)
2736         else:
2737             matches = None
2739         # filter for visibility
2740         l = [id for id in klass.filter(matches, filterspec, sort, group)
2741             if check('View', userid, self.classname, itemid=id)]
2743         # return the batch object, using IDs only
2744         return Batch(self.client, l, self.pagesize, self.startwith,
2745             classname=self.classname)
2747 # extend the standard ZTUtils Batch object to remove dependency on
2748 # Acquisition and add a couple of useful methods
2749 class Batch(ZTUtils.Batch):
2750     """ Use me to turn a list of items, or item ids of a given class, into a
2751         series of batches.
2753         ========= ========================================================
2754         Parameter  Usage
2755         ========= ========================================================
2756         sequence  a list of HTMLItems or item ids
2757         classname if sequence is a list of ids, this is the class of item
2758         size      how big to make the sequence.
2759         start     where to start (0-indexed) in the sequence.
2760         end       where to end (0-indexed) in the sequence.
2761         orphan    if the next batch would contain less items than this
2762                   value, then it is combined with this batch
2763         overlap   the number of items shared between adjacent batches
2764         ========= ========================================================
2766         Attributes: Note that the "start" attribute, unlike the
2767         argument, is a 1-based index (I know, lame).  "first" is the
2768         0-based index.  "length" is the actual number of elements in
2769         the batch.
2771         "sequence_length" is the length of the original, unbatched, sequence.
2772     """
2773     def __init__(self, client, sequence, size, start, end=0, orphan=0,
2774             overlap=0, classname=None):
2775         self.client = client
2776         self.last_index = self.last_item = None
2777         self.current_item = None
2778         self.classname = classname
2779         self.sequence_length = len(sequence)
2780         ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
2781             overlap)
2783     # overwrite so we can late-instantiate the HTMLItem instance
2784     def __getitem__(self, index):
2785         if index < 0:
2786             if index + self.end < self.first: raise IndexError, index
2787             return self._sequence[index + self.end]
2789         if index >= self.length:
2790             raise IndexError, index
2792         # move the last_item along - but only if the fetched index changes
2793         # (for some reason, index 0 is fetched twice)
2794         if index != self.last_index:
2795             self.last_item = self.current_item
2796             self.last_index = index
2798         item = self._sequence[index + self.first]
2799         if self.classname:
2800             # map the item ids to instances
2801             item = HTMLItem(self.client, self.classname, item)
2802         self.current_item = item
2803         return item
2805     def propchanged(self, *properties):
2806         """ Detect if one of the properties marked as being a group
2807             property changed in the last iteration fetch
2808         """
2809         # we poke directly at the _value here since MissingValue can screw
2810         # us up and cause Nones to compare strangely
2811         if self.last_item is None:
2812             return 1
2813         for property in properties:
2814             if property == 'id' or isinstance (self.last_item[property], list):
2815                 if (str(self.last_item[property]) !=
2816                     str(self.current_item[property])):
2817                     return 1
2818             else:
2819                 if (self.last_item[property]._value !=
2820                     self.current_item[property]._value):
2821                     return 1
2822         return 0
2824     # override these 'cos we don't have access to acquisition
2825     def previous(self):
2826         if self.start == 1:
2827             return None
2828         return Batch(self.client, self._sequence, self._size,
2829             self.first - self._size + self.overlap, 0, self.orphan,
2830             self.overlap)
2832     def next(self):
2833         try:
2834             self._sequence[self.end]
2835         except IndexError:
2836             return None
2837         return Batch(self.client, self._sequence, self._size,
2838             self.end - self.overlap, 0, self.orphan, self.overlap)
2840 class TemplatingUtils:
2841     """ Utilities for templating
2842     """
2843     def __init__(self, client):
2844         self.client = client
2845     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
2846         return Batch(self.client, sequence, size, start, end, orphan,
2847             overlap)
2849     def url_quote(self, url):
2850         """URL-quote the supplied text."""
2851         return urllib.quote(url)
2853     def html_quote(self, html):
2854         """HTML-quote the supplied text."""
2855         return cgi.escape(html)
2857     def __getattr__(self, name):
2858         """Try the tracker's templating_utils."""
2859         if not hasattr(self.client.instance, 'templating_utils'):
2860             # backwards-compatibility
2861             raise AttributeError, name
2862         if not self.client.instance.templating_utils.has_key(name):
2863             raise AttributeError, name
2864         return self.client.instance.templating_utils[name]
2866     def html_calendar(self, request):
2867         """Generate a HTML calendar.
2869         `request`  the roundup.request object
2870                    - @template : name of the template
2871                    - form      : name of the form to store back the date
2872                    - property  : name of the property of the form to store
2873                                  back the date
2874                    - date      : current date
2875                    - display   : when browsing, specifies year and month
2877         html will simply be a table.
2878         """
2879         tz = request.client.db.getUserTimezone()
2880         current_date = date.Date(".").local(tz)
2881         date_str  = request.form.getfirst("date", current_date)
2882         display   = request.form.getfirst("display", date_str)
2883         template  = request.form.getfirst("@template", "calendar")
2884         form      = request.form.getfirst("form")
2885         property  = request.form.getfirst("property")
2886         curr_date = date.Date(date_str) # to highlight
2887         display   = date.Date(display)  # to show
2888         day       = display.day
2890         # for navigation
2891         date_prev_month = display + date.Interval("-1m")
2892         date_next_month = display + date.Interval("+1m")
2893         date_prev_year  = display + date.Interval("-1y")
2894         date_next_year  = display + date.Interval("+1y")
2896         res = []
2898         base_link = "%s?@template=%s&property=%s&form=%s&date=%s" % \
2899                     (request.classname, template, property, form, curr_date)
2901         # navigation
2902         # month
2903         res.append('<table class="calendar"><tr><td>')
2904         res.append(' <table width="100%" class="calendar_nav"><tr>')
2905         link = "&display=%s"%date_prev_month
2906         res.append('  <td><a href="%s&display=%s">&lt;</a></td>'%(base_link,
2907             date_prev_month))
2908         res.append('  <td>%s</td>'%calendar.month_name[display.month])
2909         res.append('  <td><a href="%s&display=%s">&gt;</a></td>'%(base_link,
2910             date_next_month))
2911         # spacer
2912         res.append('  <td width="100%"></td>')
2913         # year
2914         res.append('  <td><a href="%s&display=%s">&lt;</a></td>'%(base_link,
2915             date_prev_year))
2916         res.append('  <td>%s</td>'%display.year)
2917         res.append('  <td><a href="%s&display=%s">&gt;</a></td>'%(base_link,
2918             date_next_year))
2919         res.append(' </tr></table>')
2920         res.append(' </td></tr>')
2922         # the calendar
2923         res.append(' <tr><td><table class="calendar_display">')
2924         res.append('  <tr class="weekdays">')
2925         for day in calendar.weekheader(3).split():
2926             res.append('   <td>%s</td>'%day)
2927         res.append('  </tr>')
2928         for week in calendar.monthcalendar(display.year, display.month):
2929             res.append('  <tr>')
2930             for day in week:
2931                 link = "javascript:form[field].value = '%d-%02d-%02d'; " \
2932                       "window.close ();"%(display.year, display.month, day)
2933                 if (day == curr_date.day and display.month == curr_date.month
2934                         and display.year == curr_date.year):
2935                     # highlight
2936                     style = "today"
2937                 else :
2938                     style = ""
2939                 if day:
2940                     res.append('   <td class="%s"><a href="%s">%s</a></td>'%(
2941                         style, link, day))
2942                 else :
2943                     res.append('   <td></td>')
2944             res.append('  </tr>')
2945         res.append('</table></td></tr></table>')
2946         return "\n".join(res)
2948 class MissingValue:
2949     def __init__(self, description, **kwargs):
2950         self.__description = description
2951         for key, value in kwargs.items():
2952             self.__dict__[key] = value
2954     def __call__(self, *args, **kwargs): return MissingValue(self.__description)
2955     def __getattr__(self, name):
2956         # This allows assignments which assume all intermediate steps are Null
2957         # objects if they don't exist yet.
2958         #
2959         # For example (with just 'client' defined):
2960         #
2961         # client.db.config.TRACKER_WEB = 'BASE/'
2962         self.__dict__[name] = MissingValue(self.__description)
2963         return getattr(self, name)
2965     def __getitem__(self, key): return self
2966     def __nonzero__(self): return 0
2967     def __str__(self): return '[%s]'%self.__description
2968     def __repr__(self): return '<MissingValue 0x%x "%s">'%(id(self),
2969         self.__description)
2970     def gettext(self, str): return str
2971     _ = gettext
2973 # vim: set et sts=4 sw=4 :