Code

Move out parts of client.py to new modules:
[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, 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             # get more info about the class, and the current set of
256             # form props for it
257             if not all_propdef.has_key(cn):
258                 all_propdef[cn] = cl.getprops()
259             propdef = all_propdef[cn]
260             if not all_props.has_key(this):
261                 all_props[this] = {}
262             props = all_props[this]
263             if not got_props.has_key(this):
264                 got_props[this] = {}
266             # is this a link command?
267             if d['link']:
268                 value = []
269                 for entry in self.extractFormList(form[key]):
270                     m = self.FV_DESIGNATOR.match(entry)
271                     if not m:
272                         raise FormError, \
273                             'link "%s" value "%s" not a designator'%(key, entry)
274                     value.append((m.group(1), m.group(2)))
276                 # make sure the link property is valid
277                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
278                         not isinstance(propdef[propname], hyperdb.Link)):
279                     raise FormError, '%s %s is not a link or '\
280                         'multilink property'%(cn, propname)
282                 all_links.append((cn, nodeid, propname, value))
283                 continue
285             # detect the special ":required" variable
286             if d['required']:
287                 all_required[this] = self.extractFormList(form[key])
288                 continue
290             # see if we're performing a special multilink action
291             mlaction = 'set'
292             if d['remove']:
293                 mlaction = 'remove'
294             elif d['add']:
295                 mlaction = 'add'
297             # does the property exist?
298             if not propdef.has_key(propname):
299                 if mlaction != 'set':
300                     raise FormError, 'You have submitted a %s action for'\
301                         ' the property "%s" which doesn\'t exist'%(mlaction,
302                         propname)
303                 # the form element is probably just something we don't care
304                 # about - ignore it
305                 continue
306             proptype = propdef[propname]
308             # Get the form value. This value may be a MiniFieldStorage or a list
309             # of MiniFieldStorages.
310             value = form[key]
312             # handle unpacking of the MiniFieldStorage / list form value
313             if isinstance(proptype, hyperdb.Multilink):
314                 value = self.extractFormList(value)
315             else:
316                 # multiple values are not OK
317                 if isinstance(value, type([])):
318                     raise FormError, 'You have submitted more than one value'\
319                         ' for the %s property'%propname
320                 # value might be a file upload...
321                 if not hasattr(value, 'filename') or value.filename is None:
322                     # nope, pull out the value and strip it
323                     value = value.value.strip()
325             # now that we have the props field, we need a teensy little
326             # extra bit of help for the old :note field...
327             if d['note'] and value:
328                 props['author'] = self.db.getuid()
329                 props['date'] = date.Date()
331             # handle by type now
332             if isinstance(proptype, hyperdb.Password):
333                 if not value:
334                     # ignore empty password values
335                     continue
336                 for key, d in matches:
337                     if d['confirm'] and d['propname'] == propname:
338                         confirm = form[key]
339                         break
340                 else:
341                     raise FormError, 'Password and confirmation text do '\
342                         'not match'
343                 if isinstance(confirm, type([])):
344                     raise FormError, 'You have submitted more than one value'\
345                         ' for the %s property'%propname
346                 if value != confirm.value:
347                     raise FormError, 'Password and confirmation text do '\
348                         'not match'
349                 try:
350                     value = password.Password(value)
351                 except hyperdb.HyperdbValueError, msg:
352                     raise FormError, msg
354             elif isinstance(proptype, hyperdb.Multilink):
355                 # convert input to list of ids
356                 try:
357                     l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
358                         propname, value)
359                 except hyperdb.HyperdbValueError, msg:
360                     raise FormError, msg
362                 # now use that list of ids to modify the multilink
363                 if mlaction == 'set':
364                     value = l
365                 else:
366                     # we're modifying the list - get the current list of ids
367                     if props.has_key(propname):
368                         existing = props[propname]
369                     elif nodeid and not nodeid.startswith('-'):
370                         existing = cl.get(nodeid, propname, [])
371                     else:
372                         existing = []
374                     # now either remove or add
375                     if mlaction == 'remove':
376                         # remove - handle situation where the id isn't in
377                         # the list
378                         for entry in l:
379                             try:
380                                 existing.remove(entry)
381                             except ValueError:
382                                 raise FormError, _('property "%(propname)s": '
383                                     '"%(value)s" not currently in list')%{
384                                     'propname': propname, 'value': entry}
385                     else:
386                         # add - easy, just don't dupe
387                         for entry in l:
388                             if entry not in existing:
389                                 existing.append(entry)
390                     value = existing
391                     value.sort()
393             elif value == '':
394                 # other types should be None'd if there's no value
395                 value = None
396             else:
397                 # handle all other types
398                 try:
399                     if isinstance(proptype, hyperdb.String):
400                         if (hasattr(value, 'filename') and
401                                 value.filename is not None):
402                             # skip if the upload is empty
403                             if not value.filename:
404                                 continue
405                             # this String is actually a _file_
406                             # try to determine the file content-type
407                             fn = value.filename.split('\\')[-1]
408                             if propdef.has_key('name'):
409                                 props['name'] = fn
410                             # use this info as the type/filename properties
411                             if propdef.has_key('type'):
412                                 if hasattr(value, 'type') and value.type:
413                                     props['type'] = value.type
414                                 elif mimetypes.guess_type(fn)[0]:
415                                     props['type'] = mimetypes.guess_type(fn)[0]
416                                 else:
417                                     props['type'] = "application/octet-stream"
418                             # finally, read the content RAW
419                             value = value.value
420                         else:
421                             value = hyperdb.rawToHyperdb(self.db, cl,
422                                 nodeid, propname, value)
424                     else:
425                         value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
426                             propname, value)
427                 except hyperdb.HyperdbValueError, msg:
428                     raise FormError, msg
430             # register that we got this property
431             if value:
432                 got_props[this][propname] = 1
434             # get the old value
435             if nodeid and not nodeid.startswith('-'):
436                 try:
437                     existing = cl.get(nodeid, propname)
438                 except KeyError:
439                     # this might be a new property for which there is
440                     # no existing value
441                     if not propdef.has_key(propname):
442                         raise
443                 except IndexError, message:
444                     raise FormError(str(message))
446                 # make sure the existing multilink is sorted
447                 if isinstance(proptype, hyperdb.Multilink):
448                     existing.sort()
450                 # "missing" existing values may not be None
451                 if not existing:
452                     if isinstance(proptype, hyperdb.String) and not existing:
453                         # some backends store "missing" Strings as empty strings
454                         existing = None
455                     elif isinstance(proptype, hyperdb.Number) and not existing:
456                         # some backends store "missing" Numbers as 0 :(
457                         existing = 0
458                     elif isinstance(proptype, hyperdb.Boolean) and not existing:
459                         # likewise Booleans
460                         existing = 0
462                 # if changed, set it
463                 if value != existing:
464                     props[propname] = value
465             else:
466                 # don't bother setting empty/unset values
467                 if value is None:
468                     continue
469                 elif isinstance(proptype, hyperdb.Multilink) and value == []:
470                     continue
471                 elif isinstance(proptype, hyperdb.String) and value == '':
472                     continue
474                 props[propname] = value
476         # check to see if we need to specially link a file to the note
477         if have_note and have_file:
478             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
480         # see if all the required properties have been supplied
481         s = []
482         for thing, required in all_required.items():
483             # register the values we got
484             got = got_props.get(thing, {})
485             for entry in required[:]:
486                 if got.has_key(entry):
487                     required.remove(entry)
489             # any required values not present?
490             if not required:
491                 continue
493             # tell the user to entry the values required
494             if len(required) > 1:
495                 p = 'properties'
496             else:
497                 p = 'property'
498             s.append('Required %s %s %s not supplied'%(thing[0], p,
499                 ', '.join(required)))
500         if s:
501             raise FormError, '\n'.join(s)
503         # When creating a FileClass node, it should have a non-empty content
504         # property to be created. When editing a FileClass node, it should
505         # either have a non-empty content property or no property at all. In
506         # the latter case, nothing will change.
507         for (cn, id), props in all_props.items():
508             if isinstance(self.db.classes[cn], hyperdb.FileClass):
509                 if id == '-1':
510                     if not props.get('content', ''):
511                         del all_props[(cn, id)]
512                 elif props.has_key('content') and not props['content']:
513                     raise FormError, _('File is empty')
514         return all_props, all_links
516     def extractFormList(self, value):
517         ''' Extract a list of values from the form value.
519             It may be one of:
520              [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
521              MiniFieldStorage('value,value,...')
522              MiniFieldStorage('value')
523         '''
524         # multiple values are OK
525         if isinstance(value, type([])):
526             # it's a list of MiniFieldStorages - join then into
527             values = ','.join([i.value.strip() for i in value])
528         else:
529             # it's a MiniFieldStorage, but may be a comma-separated list
530             # of values
531             values = value.value
533         value = [i.strip() for i in values.split(',')]
535         # filter out the empty bits
536         return filter(None, value)