Code

minor pre-release / test fixes
[roundup.git] / roundup / cgi / form_parser.py
1 import re, mimetypes
3 from roundup import hyperdb, date, password
4 from roundup.cgi.exceptions import FormError
5 from roundup.i18n import _
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         )$'''
29     
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       
37     def parse(self, create=0, num_re=re.compile('^\d+$')):
38         """ Item properties and their values are edited with html FORM
39             variables and their values. You can:
41             - Change the value of some property of the current item.
42             - Create a new item of any class, and edit the new item's
43               properties,
44             - Attach newly created items to a multilink property of the
45               current item.
46             - Remove items from a multilink property of the current item.
47             - Specify that some properties are required for the edit
48               operation to be successful.
50             In the following, <bracketed> values are variable, "@" may be
51             either ":" or "@", and other text "required" is fixed.
53             Most properties are specified as form variables:
55              <propname>
56               - property on the current context item
58              <designator>"@"<propname>
59               - property on the indicated item (for editing related
60                 information)
62             Designators name a specific item of a class.
64             <classname><N>
66                 Name an existing item of class <classname>.
68             <classname>"-"<N>
70                 Name the <N>th new item of class <classname>. If the form
71                 submission is successful, a new item of <classname> is
72                 created. Within the submitted form, a particular
73                 designator of this form always refers to the same new
74                 item.
76             Once we have determined the "propname", we look at it to see
77             if it's special:
79             @required
80                 The associated form value is a comma-separated list of
81                 property names that must be specified when the form is
82                 submitted for the edit operation to succeed.  
84                 When the <designator> is missing, the properties are
85                 for the current context item.  When <designator> is
86                 present, they are for the item specified by
87                 <designator>.
89                 The "@required" specifier must come before any of the
90                 properties it refers to are assigned in the form.
92             @remove@<propname>=id(s) or @add@<propname>=id(s)
93                 The "@add@" and "@remove@" edit actions apply only to
94                 Multilink properties.  The form value must be a
95                 comma-separate list of keys for the class specified by
96                 the simple form variable.  The listed items are added
97                 to (respectively, removed from) the specified
98                 property.
100             @link@<propname>=<designator>
101                 If the edit action is "@link@", the simple form
102                 variable must specify a Link or Multilink property.
103                 The form value is a comma-separated list of
104                 designators.  The item corresponding to each
105                 designator is linked to the property given by simple
106                 form variable.  These are collected up and returned in
107                 all_links.
109             None of the above (ie. just a simple form value)
110                 The value of the form variable is converted
111                 appropriately, depending on the type of the property.
113                 For a Link('klass') property, the form value is a
114                 single key for 'klass', where the key field is
115                 specified in dbinit.py.  
117                 For a Multilink('klass') property, the form value is a
118                 comma-separated list of keys for 'klass', where the
119                 key field is specified in dbinit.py.  
121                 Note that for simple-form-variables specifiying Link
122                 and Multilink properties, the linked-to class must
123                 have a key field.
125                 For a String() property specifying a filename, the
126                 file named by the form value is uploaded. This means we
127                 try to set additional properties "filename" and "type" (if
128                 they are valid for the class).  Otherwise, the property
129                 is set to the form value.
131                 For Date(), Interval(), Boolean(), and Number()
132                 properties, the form value is converted to the
133                 appropriate
135             Any of the form variables may be prefixed with a classname or
136             designator.
138             Two special form values are supported for backwards
139             compatibility:
141             @note
142                 This is equivalent to::
144                     @link@messages=msg-1
145                     msg-1@content=value
147                 except that in addition, the "author" and "date"
148                 properties of "msg-1" are set to the userid of the
149                 submitter, and the current time, respectively.
151             @file
152                 This is equivalent to::
154                     @link@files=file-1
155                     file-1@content=value
157                 The String content value is handled as described above for
158                 file uploads.
160             If both the "@note" and "@file" form variables are
161             specified, the action::
163                     @link@msg-1@files=file-1
165             is also performed.
167             We also check that FileClass items have a "content" property with
168             actual content, otherwise we remove them from all_props before
169             returning.
171             The return from this method is a dict of 
172                 (classname, id): properties
173             ... this dict _always_ has an entry for the current context,
174             even if it's empty (ie. a submission for an existing issue that
175             doesn't result in any changes would return {('issue','123'): {}})
176             The id may be None, which indicates that an item should be
177             created.
178         """
179         # some very useful variables
180         db = self.db
181         form = self.form
183         if not hasattr(self, 'FV_SPECIAL'):
184             # generate the regexp for handling special form values
185             classes = '|'.join(db.classes.keys())
186             # specials for parsePropsFromForm
187             # handle the various forms (see unit tests)
188             self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
189             self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
191         # these indicate the default class / item
192         default_cn = self.classname
193         default_cl = self.db.classes[default_cn]
194         default_nodeid = self.nodeid
196         # we'll store info about the individual class/item edit in these
197         all_required = {}       # required props per class/item
198         all_props = {}          # props to set per class/item
199         got_props = {}          # props received per class/item
200         all_propdef = {}        # note - only one entry per class
201         all_links = []          # as many as are required
203         # we should always return something, even empty, for the context
204         all_props[(default_cn, default_nodeid)] = {}
206         keys = form.keys()
207         timezone = db.getUserTimezone()
209         # sentinels for the :note and :file props
210         have_note = have_file = 0
212         # extract the usable form labels from the form
213         matches = []
214         for key in keys:
215             m = self.FV_SPECIAL.match(key)
216             if m:
217                 matches.append((key, m.groupdict()))
219         # now handle the matches
220         for key, d in matches:
221             if d['classname']:
222                 # we got a designator
223                 cn = d['classname']
224                 cl = self.db.classes[cn]
225                 nodeid = d['id']
226                 propname = d['propname']
227             elif d['note']:
228                 # the special note field
229                 cn = 'msg'
230                 cl = self.db.classes[cn]
231                 nodeid = '-1'
232                 propname = 'content'
233                 all_links.append((default_cn, default_nodeid, 'messages',
234                     [('msg', '-1')]))
235                 have_note = 1
236             elif d['file']:
237                 # the special file field
238                 cn = 'file'
239                 cl = self.db.classes[cn]
240                 nodeid = '-1'
241                 propname = 'content'
242                 all_links.append((default_cn, default_nodeid, 'files',
243                     [('file', '-1')]))
244                 have_file = 1
245             else:
246                 # default
247                 cn = default_cn
248                 cl = default_cl
249                 nodeid = default_nodeid
250                 propname = d['propname']
252             # the thing this value relates to is...
253             this = (cn, nodeid)
255             # skip implicit create if this isn't a create action
256             if not create and nodeid is None:
257                 continue
259             # get more info about the class, and the current set of
260             # form props for it
261             if not all_propdef.has_key(cn):
262                 all_propdef[cn] = cl.getprops()
263             propdef = all_propdef[cn]
264             if not all_props.has_key(this):
265                 all_props[this] = {}
266             props = all_props[this]
267             if not got_props.has_key(this):
268                 got_props[this] = {}
270             # is this a link command?
271             if d['link']:
272                 value = []
273                 for entry in self.extractFormList(form[key]):
274                     m = self.FV_DESIGNATOR.match(entry)
275                     if not m:
276                         raise FormError, \
277                             'link "%s" value "%s" not a designator'%(key, entry)
278                     value.append((m.group(1), m.group(2)))
280                 # make sure the link property is valid
281                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
282                         not isinstance(propdef[propname], hyperdb.Link)):
283                     raise FormError, '%s %s is not a link or '\
284                         'multilink property'%(cn, propname)
286                 all_links.append((cn, nodeid, propname, value))
287                 continue
289             # detect the special ":required" variable
290             if d['required']:
291                 all_required[this] = self.extractFormList(form[key])
292                 continue
294             # see if we're performing a special multilink action
295             mlaction = 'set'
296             if d['remove']:
297                 mlaction = 'remove'
298             elif d['add']:
299                 mlaction = 'add'
301             # does the property exist?
302             if not propdef.has_key(propname):
303                 if mlaction != 'set':
304                     raise FormError, 'You have submitted a %s action for'\
305                         ' the property "%s" which doesn\'t exist'%(mlaction,
306                         propname)
307                 # the form element is probably just something we don't care
308                 # about - ignore it
309                 continue
310             proptype = propdef[propname]
312             # Get the form value. This value may be a MiniFieldStorage or a list
313             # of MiniFieldStorages.
314             value = form[key]
316             # handle unpacking of the MiniFieldStorage / list form value
317             if isinstance(proptype, hyperdb.Multilink):
318                 value = self.extractFormList(value)
319             else:
320                 # multiple values are not OK
321                 if isinstance(value, type([])):
322                     raise FormError, 'You have submitted more than one value'\
323                         ' for the %s property'%propname
324                 # value might be a file upload...
325                 if not hasattr(value, 'filename') or value.filename is None:
326                     # nope, pull out the value and strip it
327                     value = value.value.strip()
329             # now that we have the props field, we need a teensy little
330             # extra bit of help for the old :note field...
331             if d['note'] and value:
332                 props['author'] = self.db.getuid()
333                 props['date'] = date.Date()
335             # handle by type now
336             if isinstance(proptype, hyperdb.Password):
337                 if not value:
338                     # ignore empty password values
339                     continue
340                 for key, d in matches:
341                     if d['confirm'] and d['propname'] == propname:
342                         confirm = form[key]
343                         break
344                 else:
345                     raise FormError, 'Password and confirmation text do '\
346                         'not match'
347                 if isinstance(confirm, type([])):
348                     raise FormError, 'You have submitted more than one value'\
349                         ' for the %s property'%propname
350                 if value != confirm.value:
351                     raise FormError, 'Password and confirmation text do '\
352                         'not match'
353                 try:
354                     value = password.Password(value)
355                 except hyperdb.HyperdbValueError, msg:
356                     raise FormError, msg
358             elif isinstance(proptype, hyperdb.Multilink):
359                 # convert input to list of ids
360                 try:
361                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
362                         propname, value)
363                 except hyperdb.HyperdbValueError, msg:
364                     raise FormError, msg
366                 # now use that list of ids to modify the multilink
367                 if mlaction == 'set':
368                     value = l
369                 else:
370                     # we're modifying the list - get the current list of ids
371                     if props.has_key(propname):
372                         existing = props[propname]
373                     elif nodeid and not nodeid.startswith('-'):
374                         existing = cl.get(nodeid, propname, [])
375                     else:
376                         existing = []
378                     # now either remove or add
379                     if mlaction == 'remove':
380                         # remove - handle situation where the id isn't in
381                         # the list
382                         for entry in l:
383                             try:
384                                 existing.remove(entry)
385                             except ValueError:
386                                 raise FormError, _('property "%(propname)s": '
387                                     '"%(value)s" not currently in list')%{
388                                     'propname': propname, 'value': entry}
389                     else:
390                         # add - easy, just don't dupe
391                         for entry in l:
392                             if entry not in existing:
393                                 existing.append(entry)
394                     value = existing
395                     value.sort()
397             elif value == '':
398                 # other types should be None'd if there's no value
399                 value = None
400             else:
401                 # handle all other types
402                 try:
403                     if isinstance(proptype, hyperdb.String):
404                         if (hasattr(value, 'filename') and
405                                 value.filename is not None):
406                             # skip if the upload is empty
407                             if not value.filename:
408                                 continue
409                             # this String is actually a _file_
410                             # try to determine the file content-type
411                             fn = value.filename.split('\\')[-1]
412                             if propdef.has_key('name'):
413                                 props['name'] = fn
414                             # use this info as the type/filename properties
415                             if propdef.has_key('type'):
416                                 if hasattr(value, 'type') and value.type:
417                                     props['type'] = value.type
418                                 elif mimetypes.guess_type(fn)[0]:
419                                     props['type'] = mimetypes.guess_type(fn)[0]
420                                 else:
421                                     props['type'] = "application/octet-stream"
422                             # finally, read the content RAW
423                             value = value.value
424                         else:
425                             value = hyperdb.rawToHyperdb(self.db, cl,
426                                 nodeid, propname, value)
428                     else:
429                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
430                             propname, value)
431                 except hyperdb.HyperdbValueError, msg:
432                     raise FormError, msg
434             # register that we got this property
435             if value:
436                 got_props[this][propname] = 1
438             # get the old value
439             if nodeid and not nodeid.startswith('-'):
440                 try:
441                     existing = cl.get(nodeid, propname)
442                 except KeyError:
443                     # this might be a new property for which there is
444                     # no existing value
445                     if not propdef.has_key(propname):
446                         raise
447                 except IndexError, message:
448                     raise FormError(str(message))
450                 # make sure the existing multilink is sorted
451                 if isinstance(proptype, hyperdb.Multilink):
452                     existing.sort()
454                 # "missing" existing values may not be None
455                 if not existing:
456                     if isinstance(proptype, hyperdb.String) and not existing:
457                         # some backends store "missing" Strings as empty strings
458                         existing = None
459                     elif isinstance(proptype, hyperdb.Number) and not existing:
460                         # some backends store "missing" Numbers as 0 :(
461                         existing = 0
462                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
463                         # likewise Booleans
464                         existing = 0
466                 # if changed, set it
467                 if value != existing:
468                     props[propname] = value
469             else:
470                 # don't bother setting empty/unset values
471                 if value is None:
472                     continue
473                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
474                     continue
475                 elif isinstance(proptype, hyperdb.String) and value == '':
476                     continue
478                 props[propname] = value
480         # check to see if we need to specially link a file to the note
481         if have_note and have_file:
482             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
484         # see if all the required properties have been supplied
485         s = []
486         for thing, required in all_required.items():
487             # register the values we got
488             got = got_props.get(thing, {})
489             for entry in required[:]:
490                 if got.has_key(entry):
491                     required.remove(entry)
493             # any required values not present?
494             if not required:
495                 continue
497             # tell the user to entry the values required
498             if len(required) > 1:
499                 p = 'properties'
500             else:
501                 p = 'property'
502             s.append('Required %s %s %s not supplied'%(thing[0], p,
503                 ', '.join(required)))
504         if s:
505             raise FormError, '\n'.join(s)
507         # When creating a FileClass node, it should have a non-empty content
508         # property to be created. When editing a FileClass node, it should
509         # either have a non-empty content property or no property at all. In
510         # the latter case, nothing will change.
511         for (cn, id), props in all_props.items():
512             if isinstance(self.db.classes[cn], hyperdb.FileClass):
513                 if id == '-1':
514                     if not props.get('content', ''):
515                         del all_props[(cn, id)]
516                 elif props.has_key('content') and not props['content']:
517                     raise FormError, _('File is empty')
518         return all_props, all_links
520     def extractFormList(self, value):
521         ''' Extract a list of values from the form value.
523             It may be one of:
524              [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
525              MiniFieldStorage('value,value,...')
526              MiniFieldStorage('value')
527         '''
528         # multiple values are OK
529         if isinstance(value, type([])):
530             # it's a list of MiniFieldStorages - join then into
531             values = ','.join([i.value.strip() for i in value])
532         else:
533             # it's a MiniFieldStorage, but may be a comma-separated list
534             # of values
535             values = value.value
537         value = [i.strip() for i in values.split(',')]
539         # filter out the empty bits
540         return filter(None, value)