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