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