Code

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