Code

ce6b630be059c8682650aefa8f48e8f6cc1dbb12
[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                     # Sort the value in the same order used by
429                     # Multilink.from_raw.
430                     value.sort(lambda x, y: cmp(int(x),int(y)))
432             elif value == '':
433                 # other types should be None'd if there's no value
434                 value = None
435             else:
436                 # handle all other types
437                 try:
438                     if isinstance(proptype, hyperdb.String):
439                         if (hasattr(value, 'filename') and
440                                 value.filename is not None):
441                             # skip if the upload is empty
442                             if not value.filename:
443                                 continue
444                             # this String is actually a _file_
445                             # try to determine the file content-type
446                             fn = value.filename.split('\\')[-1]
447                             if propdef.has_key('name'):
448                                 props['name'] = fn
449                             # use this info as the type/filename properties
450                             if propdef.has_key('type'):
451                                 if hasattr(value, 'type') and value.type:
452                                     props['type'] = value.type
453                                 elif mimetypes.guess_type(fn)[0]:
454                                     props['type'] = mimetypes.guess_type(fn)[0]
455                                 else:
456                                     props['type'] = "application/octet-stream"
457                             # finally, read the content RAW
458                             value = value.value
459                         else:
460                             value = hyperdb.rawToHyperdb(self.db, cl,
461                                 nodeid, propname, value)
463                     else:
464                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
465                             propname, value)
466                 except hyperdb.HyperdbValueError, msg:
467                     raise FormError, msg
469             # register that we got this property
470             if isinstance(proptype, hyperdb.Multilink):
471                 if value != []:
472                     got_props[this][propname] = 1
473             elif value is not None:
474                 got_props[this][propname] = 1
476             # get the old value
477             if nodeid and not nodeid.startswith('-'):
478                 try:
479                     existing = cl.get(nodeid, propname)
480                 except KeyError:
481                     # this might be a new property for which there is
482                     # no existing value
483                     if not propdef.has_key(propname):
484                         raise
485                 except IndexError, message:
486                     raise FormError(str(message))
488                 # make sure the existing multilink is sorted.  We must
489                 # be sure to use the same sort order in all places,
490                 # since we want to compare values with "=" or "!=".
491                 # The canonical order (given in Multilink.from_raw) is
492                 # by the numeric value of the IDs.
493                 if isinstance(proptype, hyperdb.Multilink):
494                     existing.sort(lambda x, y: cmp(int(x),int(y)))
496                 # "missing" existing values may not be None
497                 if not existing:
498                     if isinstance(proptype, hyperdb.String):
499                         # some backends store "missing" Strings as empty strings
500                         if existing == self.db.BACKEND_MISSING_STRING:
501                             existing = None
502                     elif isinstance(proptype, hyperdb.Number):
503                         # some backends store "missing" Numbers as 0 :(
504                         if existing == self.db.BACKEND_MISSING_NUMBER:
505                             existing = None
506                     elif isinstance(proptype, hyperdb.Boolean):
507                         # likewise Booleans
508                         if existing == self.db.BACKEND_MISSING_BOOLEAN:
509                             existing = None
511                 # if changed, set it
512                 if value != existing:
513                     props[propname] = value
514             else:
515                 # don't bother setting empty/unset values
516                 if value is None:
517                     continue
518                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
519                     continue
520                 elif isinstance(proptype, hyperdb.String) and value == '':
521                     continue
523                 props[propname] = value
525         # check to see if we need to specially link a file to the note
526         if have_note and have_file:
527             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
529         # see if all the required properties have been supplied
530         s = []
531         for thing, required in all_required.items():
532             # register the values we got
533             got = got_props.get(thing, {})
534             for entry in required[:]:
535                 if got.has_key(entry):
536                     required.remove(entry)
538             # If a user doesn't have edit permission for a given property,
539             # but the property is already set in the database, we don't
540             # require a value.
541             if not (create or nodeid is None):
542                 for entry in required[:]:
543                     if not self.db.security.hasPermission('Edit',
544                                                           self.client.userid,
545                                                           self.classname,
546                                                           entry):
547                         cl = self.db.classes[self.classname]
548                         if cl.get(nodeid, entry) is not None:
549                             required.remove(entry)
551             # any required values not present?
552             if not required:
553                 continue
555             # tell the user to entry the values required
556             s.append(self.ngettext(
557                 'Required %(class)s property %(property)s not supplied',
558                 'Required %(class)s properties %(property)s not supplied',
559                 len(required)
560             ) % {
561                 'class': self._(thing[0]),
562                 'property': ', '.join(map(self.gettext, required))
563             })
564         if s:
565             raise FormError, '\n'.join(s)
567         # When creating a FileClass node, it should have a non-empty content
568         # property to be created. When editing a FileClass node, it should
569         # either have a non-empty content property or no property at all. In
570         # the latter case, nothing will change.
571         for (cn, id), props in all_props.items():
572             if id is not None and id.startswith('-') and not props:
573                 # new item (any class) with no content - ignore
574                 del all_props[(cn, id)]
575             elif isinstance(self.db.classes[cn], hyperdb.FileClass):
576                 if id is not None and id.startswith('-'):
577                     if not props.get('content', ''):
578                         del all_props[(cn, id)]
579                 elif props.has_key('content') and not props['content']:
580                     raise FormError, self._('File is empty')
581         return all_props, all_links
583     def extractFormList(self, value):
584         ''' Extract a list of values from the form value.
586             It may be one of:
587              [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
588              MiniFieldStorage('value,value,...')
589              MiniFieldStorage('value')
590         '''
591         # multiple values are OK
592         if isinstance(value, type([])):
593             # it's a list of MiniFieldStorages - join then into
594             values = ','.join([i.value.strip() for i in value])
595         else:
596             # it's a MiniFieldStorage, but may be a comma-separated list
597             # of values
598             values = value.value
600         value = [i.strip() for i in values.split(',')]
602         # filter out the empty bits
603         return filter(None, value)
605 # vim: set et sts=4 sw=4 :