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 )$'''
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
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)