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, 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)