Code

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