Code

svn repository setup
[roundup.git] / roundup / cgi / form_parser.py
1 import re, mimetypes
3 from roundup import hyperdb, date, password
4 from roundup.cgi import templating
5 from roundup.cgi.exceptions import FormError
7 class FormParser:
8     # edit form variable handling (see unit tests)
9     FV_LABELS = r'''
10        ^(
11          (?P<note>[@:]note)|
12          (?P<file>[@:]file)|
13          (
14           ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
15           ((?P<required>[@:]required$)|       # :required
16            (
17             (
18              (?P<add>[@:]add[@:])|            # :add:<prop>
19              (?P<remove>[@:]remove[@:])|      # :remove:<prop>
20              (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
21              (?P<link>[@:]link[@:])|          # :link:<prop>
22              ([@:])                           # just a separator
23             )?
24             (?P<propname>[^@:]+)             # <prop>
25            )
26           )
27          )
28         )$'''
30     def __init__(self, client):
31         self.client = client
32         self.db = client.db
33         self.form = client.form
34         self.classname = client.classname
35         self.nodeid = client.nodeid
36         try:
37             self._ = self.gettext = client.gettext
38             self.ngettext = client.ngettext
39         except AttributeError:
40             _translator = templating.translationService
41             self._ = self.gettext = _translator.gettext
42             self.ngettext = _translator.ngettext
44     def parse(self, create=0, num_re=re.compile('^\d+$')):
45         """ Item properties and their values are edited with html FORM
46             variables and their values. You can:
48             - Change the value of some property of the current item.
49             - Create a new item of any class, and edit the new item's
50               properties,
51             - Attach newly created items to a multilink property of the
52               current item.
53             - Remove items from a multilink property of the current item.
54             - Specify that some properties are required for the edit
55               operation to be successful.
57             In the following, <bracketed> values are variable, "@" may be
58             either ":" or "@", and other text "required" is fixed.
60             Most properties are specified as form variables:
62              <propname>
63               - property on the current context item
65              <designator>"@"<propname>
66               - property on the indicated item (for editing related
67                 information)
69             Designators name a specific item of a class.
71             <classname><N>
73                 Name an existing item of class <classname>.
75             <classname>"-"<N>
77                 Name the <N>th new item of class <classname>. If the form
78                 submission is successful, a new item of <classname> is
79                 created. Within the submitted form, a particular
80                 designator of this form always refers to the same new
81                 item.
83             Once we have determined the "propname", we look at it to see
84             if it's special:
86             @required
87                 The associated form value is a comma-separated list of
88                 property names that must be specified when the form is
89                 submitted for the edit operation to succeed.
91                 When the <designator> is missing, the properties are
92                 for the current context item.  When <designator> is
93                 present, they are for the item specified by
94                 <designator>.
96                 The "@required" specifier must come before any of the
97                 properties it refers to are assigned in the form.
99             @remove@<propname>=id(s) or @add@<propname>=id(s)
100                 The "@add@" and "@remove@" edit actions apply only to
101                 Multilink properties.  The form value must be a
102                 comma-separate list of keys for the class specified by
103                 the simple form variable.  The listed items are added
104                 to (respectively, removed from) the specified
105                 property.
107             @link@<propname>=<designator>
108                 If the edit action is "@link@", the simple form
109                 variable must specify a Link or Multilink property.
110                 The form value is a comma-separated list of
111                 designators.  The item corresponding to each
112                 designator is linked to the property given by simple
113                 form variable.  These are collected up and returned in
114                 all_links.
116             None of the above (ie. just a simple form value)
117                 The value of the form variable is converted
118                 appropriately, depending on the type of the property.
120                 For a Link('klass') property, the form value is a
121                 single key for 'klass', where the key field is
122                 specified in dbinit.py.
124                 For a Multilink('klass') property, the form value is a
125                 comma-separated list of keys for 'klass', where the
126                 key field is specified in dbinit.py.
128                 Note that for simple-form-variables specifiying Link
129                 and Multilink properties, the linked-to class must
130                 have a key field.
132                 For a String() property specifying a filename, the
133                 file named by the form value is uploaded. This means we
134                 try to set additional properties "filename" and "type" (if
135                 they are valid for the class).  Otherwise, the property
136                 is set to the form value.
138                 For Date(), Interval(), Boolean(), and Number()
139                 properties, the form value is converted to the
140                 appropriate
142             Any of the form variables may be prefixed with a classname or
143             designator.
145             Two special form values are supported for backwards
146             compatibility:
148             @note
149                 This is equivalent to::
151                     @link@messages=msg-1
152                     msg-1@content=value
154                 except that in addition, the "author" and "date"
155                 properties of "msg-1" are set to the userid of the
156                 submitter, and the current time, respectively.
158             @file
159                 This is equivalent to::
161                     @link@files=file-1
162                     file-1@content=value
164                 The String content value is handled as described above for
165                 file uploads.
167             If both the "@note" and "@file" form variables are
168             specified, the action::
170                     @link@msg-1@files=file-1
172             is also performed.
174             We also check that FileClass items have a "content" property with
175             actual content, otherwise we remove them from all_props before
176             returning.
178             The return from this method is a dict of
179                 (classname, id): properties
180             ... this dict _always_ has an entry for the current context,
181             even if it's empty (ie. a submission for an existing issue that
182             doesn't result in any changes would return {('issue','123'): {}})
183             The id may be None, which indicates that an item should be
184             created.
185         """
186         # some very useful variables
187         db = self.db
188         form = self.form
190         if not hasattr(self, 'FV_SPECIAL'):
191             # generate the regexp for handling special form values
192             classes = '|'.join(db.classes.keys())
193             # specials for parsePropsFromForm
194             # handle the various forms (see unit tests)
195             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
196             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
198         # these indicate the default class / item
199         default_cn = self.classname
200         default_cl = self.db.classes[default_cn]
201         default_nodeid = self.nodeid
203         # we'll store info about the individual class/item edit in these
204         all_required = {}       # required props per class/item
205         all_props = {}          # props to set per class/item
206         got_props = {}          # props received per class/item
207         all_propdef = {}        # note - only one entry per class
208         all_links = []          # as many as are required
210         # we should always return something, even empty, for the context
211         all_props[(default_cn, default_nodeid)] = {}
213         keys = form.keys()
214         timezone = db.getUserTimezone()
216         # sentinels for the :note and :file props
217         have_note = have_file = 0
219         # extract the usable form labels from the form
220         matches = []
221         for key in keys:
222             m = self.FV_SPECIAL.match(key)
223             if m:
224                 matches.append((key, m.groupdict()))
226         # now handle the matches
227         for key, d in matches:
228             if d['classname']:
229                 # we got a designator
230                 cn = d['classname']
231                 cl = self.db.classes[cn]
232                 nodeid = d['id']
233                 propname = d['propname']
234             elif d['note']:
235                 # the special note field
236                 cn = 'msg'
237                 cl = self.db.classes[cn]
238                 nodeid = '-1'
239                 propname = 'content'
240                 all_links.append((default_cn, default_nodeid, 'messages',
241                     [('msg', '-1')]))
242                 have_note = 1
243             elif d['file']:
244                 # the special file field
245                 cn = 'file'
246                 cl = self.db.classes[cn]
247                 nodeid = '-1'
248                 propname = 'content'
249                 all_links.append((default_cn, default_nodeid, 'files',
250                     [('file', '-1')]))
251                 have_file = 1
252             else:
253                 # default
254                 cn = default_cn
255                 cl = default_cl
256                 nodeid = default_nodeid
257                 propname = d['propname']
259             # the thing this value relates to is...
260             this = (cn, nodeid)
262             # skip implicit create if this isn't a create action
263             if not create and nodeid is None:
264                 continue
266             # get more info about the class, and the current set of
267             # form props for it
268             if not all_propdef.has_key(cn):
269                 all_propdef[cn] = cl.getprops()
270             propdef = all_propdef[cn]
271             if not all_props.has_key(this):
272                 all_props[this] = {}
273             props = all_props[this]
274             if not got_props.has_key(this):
275                 got_props[this] = {}
277             # is this a link command?
278             if d['link']:
279                 value = []
280                 for entry in self.extractFormList(form[key]):
281                     m = self.FV_DESIGNATOR.match(entry)
282                     if not m:
283                         raise FormError, self._('link "%(key)s" '
284                             'value "%(entry)s" not a designator') % locals()
285                     value.append((m.group(1), m.group(2)))
287                     # get details of linked class
288                     lcn = m.group(1)
289                     lcl = self.db.classes[lcn]
290                     lnodeid = m.group(2)
291                     if not all_propdef.has_key(lcn):
292                         all_propdef[lcn] = lcl.getprops()
293                     if not all_props.has_key((lcn, lnodeid)):
294                         all_props[(lcn, lnodeid)] = {}
295                     if not got_props.has_key((lcn, lnodeid)):
296                         got_props[(lcn, lnodeid)] = {}
298                 # make sure the link property is valid
299                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
300                         not isinstance(propdef[propname], hyperdb.Link)):
301                     raise FormError, self._('%(class)s %(property)s '
302                         'is not a link or multilink property') % {
303                         'class':cn, 'property':propname}
305                 all_links.append((cn, nodeid, propname, value))
306                 continue
308             # detect the special ":required" variable
309             if d['required']:
310                 for entry in self.extractFormList(form[key]):
311                     m = self.FV_SPECIAL.match(entry)
312                     if not m:
313                         raise FormError, self._('The form action claims to '
314                             'require property "%(property)s" '
315                             'which doesn\'t exist') % {
316                             'property':propname}
317                     if m.group('classname'):
318                         this = (m.group('classname'), m.group('id'))
319                         entry = m.group('propname')
320                     if not all_required.has_key(this):
321                         all_required[this] = []
322                     all_required[this].append(entry)
323                 continue
325             # see if we're performing a special multilink action
326             mlaction = 'set'
327             if d['remove']:
328                 mlaction = 'remove'
329             elif d['add']:
330                 mlaction = 'add'
332             # does the property exist?
333             if not propdef.has_key(propname):
334                 if mlaction != 'set':
335                     raise FormError, self._('You have submitted a %(action)s '
336                         'action for the property "%(property)s" '
337                         'which doesn\'t exist') % {
338                         'action': mlaction, 'property':propname}
339                 # the form element is probably just something we don't care
340                 # about - ignore it
341                 continue
342             proptype = propdef[propname]
344             # Get the form value. This value may be a MiniFieldStorage
345             # or a list of MiniFieldStorages.
346             value = form[key]
348             # handle unpacking of the MiniFieldStorage / list form value
349             if isinstance(proptype, hyperdb.Multilink):
350                 value = self.extractFormList(value)
351             else:
352                 # multiple values are not OK
353                 if isinstance(value, type([])):
354                     raise FormError, self._('You have submitted more than one '
355                         'value for the %s property') % propname
356                 # value might be a file upload...
357                 if not hasattr(value, 'filename') or value.filename is None:
358                     # nope, pull out the value and strip it
359                     value = value.value.strip()
361             # now that we have the props field, we need a teensy little
362             # extra bit of help for the old :note field...
363             if d['note'] and value:
364                 props['author'] = self.db.getuid()
365                 props['date'] = date.Date()
367             # handle by type now
368             if isinstance(proptype, hyperdb.Password):
369                 if not value:
370                     # ignore empty password values
371                     continue
372                 for key, d in matches:
373                     if d['confirm'] and d['propname'] == propname:
374                         confirm = form[key]
375                         break
376                 else:
377                     raise FormError, self._('Password and confirmation text '
378                         'do not match')
379                 if isinstance(confirm, type([])):
380                     raise FormError, self._('You have submitted more than one '
381                         'value for the %s property') % propname
382                 if value != confirm.value:
383                     raise FormError, self._('Password and confirmation text '
384                         'do not match')
385                 try:
386                     value = password.Password(value)
387                 except hyperdb.HyperdbValueError, msg:
388                     raise FormError, msg
390             elif isinstance(proptype, hyperdb.Multilink):
391                 # convert input to list of ids
392                 try:
393                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
394                         propname, value)
395                 except hyperdb.HyperdbValueError, msg:
396                     raise FormError, msg
398                 # now use that list of ids to modify the multilink
399                 if mlaction == 'set':
400                     value = l
401                 else:
402                     # we're modifying the list - get the current list of ids
403                     if props.has_key(propname):
404                         existing = props[propname]
405                     elif nodeid and not nodeid.startswith('-'):
406                         existing = cl.get(nodeid, propname, [])
407                     else:
408                         existing = []
410                     # now either remove or add
411                     if mlaction == 'remove':
412                         # remove - handle situation where the id isn't in
413                         # the list
414                         for entry in l:
415                             try:
416                                 existing.remove(entry)
417                             except ValueError:
418                                 raise FormError, self._('property '
419                                     '"%(propname)s": "%(value)s" '
420                                     'not currently in list') % {
421                                     'propname': propname, 'value': entry}
422                     else:
423                         # add - easy, just don't dupe
424                         for entry in l:
425                             if entry not in existing:
426                                 existing.append(entry)
427                     value = existing
428                     value.sort()
430             elif value == '':
431                 # other types should be None'd if there's no value
432                 value = None
433             else:
434                 # handle all other types
435                 try:
436                     if isinstance(proptype, hyperdb.String):
437                         if (hasattr(value, 'filename') and
438                                 value.filename is not None):
439                             # skip if the upload is empty
440                             if not value.filename:
441                                 continue
442                             # this String is actually a _file_
443                             # try to determine the file content-type
444                             fn = value.filename.split('\\')[-1]
445                             if propdef.has_key('name'):
446                                 props['name'] = fn
447                             # use this info as the type/filename properties
448                             if propdef.has_key('type'):
449                                 if hasattr(value, 'type') and value.type:
450                                     props['type'] = value.type
451                                 elif mimetypes.guess_type(fn)[0]:
452                                     props['type'] = mimetypes.guess_type(fn)[0]
453                                 else:
454                                     props['type'] = "application/octet-stream"
455                             # finally, read the content RAW
456                             value = value.value
457                         else:
458                             value = hyperdb.rawToHyperdb(self.db, cl,
459                                 nodeid, propname, value)
461                     else:
462                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
463                             propname, value)
464                 except hyperdb.HyperdbValueError, msg:
465                     raise FormError, msg
467             # register that we got this property
468             if isinstance(proptype, hyperdb.Multilink):
469                 if value != []:
470                     got_props[this][propname] = 1
471             elif value is not None:
472                 got_props[this][propname] = 1
474             # get the old value
475             if nodeid and not nodeid.startswith('-'):
476                 try:
477                     existing = cl.get(nodeid, propname)
478                 except KeyError:
479                     # this might be a new property for which there is
480                     # no existing value
481                     if not propdef.has_key(propname):
482                         raise
483                 except IndexError, message:
484                     raise FormError(str(message))
486                 # make sure the existing multilink is sorted
487                 if isinstance(proptype, hyperdb.Multilink):
488                     existing.sort()
490                 # "missing" existing values may not be None
491                 if not existing:
492                     if isinstance(proptype, hyperdb.String):
493                         # some backends store "missing" Strings as empty strings
494                         if existing == self.db.BACKEND_MISSING_STRING:
495                             existing = None
496                     elif isinstance(proptype, hyperdb.Number):
497                         # some backends store "missing" Numbers as 0 :(
498                         if existing == self.db.BACKEND_MISSING_NUMBER:
499                             existing = None
500                     elif isinstance(proptype, hyperdb.Boolean):
501                         # likewise Booleans
502                         if existing == self.db.BACKEND_MISSING_BOOLEAN:
503                             existing = None
505                 # if changed, set it
506                 if value != existing:
507                     props[propname] = value
508             else:
509                 # don't bother setting empty/unset values
510                 if value is None:
511                     continue
512                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
513                     continue
514                 elif isinstance(proptype, hyperdb.String) and value == '':
515                     continue
517                 props[propname] = value
519         # check to see if we need to specially link a file to the note
520         if have_note and have_file:
521             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
523         # see if all the required properties have been supplied
524         s = []
525         for thing, required in all_required.items():
526             # register the values we got
527             got = got_props.get(thing, {})
528             for entry in required[:]:
529                 if got.has_key(entry):
530                     required.remove(entry)
532             # If a user doesn't have edit permission for a given property,
533             # but the property is already set in the database, we don't
534             # require a value.
535             if not (create or nodeid is None):
536                 for entry in required[:]:
537                     if not self.db.security.hasPermission('Edit',
538                                                           self.client.userid,
539                                                           self.classname,
540                                                           entry):
541                         cl = self.db.classes[self.classname]
542                         if cl.get(nodeid, entry) is not None:
543                             required.remove(entry)
545             # any required values not present?
546             if not required:
547                 continue
549             # tell the user to entry the values required
550             s.append(self.ngettext(
551                 'Required %(class)s property %(property)s not supplied',
552                 'Required %(class)s properties %(property)s not supplied',
553                 len(required)
554             ) % {
555                 'class': self._(thing[0]),
556                 'property': ', '.join(map(self.gettext, required))
557             })
558         if s:
559             raise FormError, '\n'.join(s)
561         # When creating a FileClass node, it should have a non-empty content
562         # property to be created. When editing a FileClass node, it should
563         # either have a non-empty content property or no property at all. In
564         # the latter case, nothing will change.
565         for (cn, id), props in all_props.items():
566             if id and id.startswith('-') and not props:
567                 # new item (any class) with no content - ignore
568                 del all_props[(cn, id)]
569             elif isinstance(self.db.classes[cn], hyperdb.FileClass):
570                 if id and id.startswith('-'):
571                     if not props.get('content', ''):
572                         del all_props[(cn, id)]
573                 elif props.has_key('content') and not props['content']:
574                     raise FormError, self._('File is empty')
575         return all_props, all_links
577     def extractFormList(self, value):
578         ''' Extract a list of values from the form value.
580             It may be one of:
581              [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
582              MiniFieldStorage('value,value,...')
583              MiniFieldStorage('value')
584         '''
585         # multiple values are OK
586         if isinstance(value, type([])):
587             # it's a list of MiniFieldStorages - join then into
588             values = ','.join([i.value.strip() for i in value])
589         else:
590             # it's a MiniFieldStorage, but may be a comma-separated list
591             # of values
592             values = value.value
594         value = [i.strip() for i in values.split(',')]
596         # filter out the empty bits
597         return filter(None, value)
599 # vim: set et sts=4 sw=4 :