Mercurial > p > roundup > code
comparison roundup/cgi/form_parser.py @ 2004:1782fe36e7b8
Move out parts of client.py to new modules:
* actions.py - the xxxAction and xxxPermission functions refactored
into Action classes
* exceptions.py - all exceptions
* form_parser.py - parsePropsFromForm & extractFormList in a FormParser class
Also added some new tests for the Actions.
| author | Johannes Gijsbers <jlgijsbers@users.sourceforge.net> |
|---|---|
| date | Wed, 11 Feb 2004 21:34:31 +0000 |
| parents | |
| children | 1b11ffd8015e |
comparison
equal
deleted
inserted
replaced
| 2003:a291bf753037 | 2004:1782fe36e7b8 |
|---|---|
| 1 import re, mimetypes | |
| 2 | |
| 3 from roundup import hyperdb, date, password | |
| 4 from roundup.cgi.exceptions import FormError | |
| 5 from roundup.i18n import _ | |
| 6 | |
| 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: | |
| 40 | |
| 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. | |
| 49 | |
| 50 In the following, <bracketed> values are variable, "@" may be | |
| 51 either ":" or "@", and other text "required" is fixed. | |
| 52 | |
| 53 Most properties are specified as form variables: | |
| 54 | |
| 55 <propname> | |
| 56 - property on the current context item | |
| 57 | |
| 58 <designator>"@"<propname> | |
| 59 - property on the indicated item (for editing related | |
| 60 information) | |
| 61 | |
| 62 Designators name a specific item of a class. | |
| 63 | |
| 64 <classname><N> | |
| 65 | |
| 66 Name an existing item of class <classname>. | |
| 67 | |
| 68 <classname>"-"<N> | |
| 69 | |
| 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. | |
| 75 | |
| 76 Once we have determined the "propname", we look at it to see | |
| 77 if it's special: | |
| 78 | |
| 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. | |
| 83 | |
| 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>. | |
| 88 | |
| 89 The "@required" specifier must come before any of the | |
| 90 properties it refers to are assigned in the form. | |
| 91 | |
| 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. | |
| 99 | |
| 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. | |
| 108 | |
| 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. | |
| 112 | |
| 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. | |
| 116 | |
| 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. | |
| 120 | |
| 121 Note that for simple-form-variables specifiying Link | |
| 122 and Multilink properties, the linked-to class must | |
| 123 have a key field. | |
| 124 | |
| 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. | |
| 130 | |
| 131 For Date(), Interval(), Boolean(), and Number() | |
| 132 properties, the form value is converted to the | |
| 133 appropriate | |
| 134 | |
| 135 Any of the form variables may be prefixed with a classname or | |
| 136 designator. | |
| 137 | |
| 138 Two special form values are supported for backwards | |
| 139 compatibility: | |
| 140 | |
| 141 @note | |
| 142 This is equivalent to:: | |
| 143 | |
| 144 @link@messages=msg-1 | |
| 145 msg-1@content=value | |
| 146 | |
| 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. | |
| 150 | |
| 151 @file | |
| 152 This is equivalent to:: | |
| 153 | |
| 154 @link@files=file-1 | |
| 155 file-1@content=value | |
| 156 | |
| 157 The String content value is handled as described above for | |
| 158 file uploads. | |
| 159 | |
| 160 If both the "@note" and "@file" form variables are | |
| 161 specified, the action:: | |
| 162 | |
| 163 @link@msg-1@files=file-1 | |
| 164 | |
| 165 is also performed. | |
| 166 | |
| 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. | |
| 170 | |
| 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 | |
| 182 | |
| 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) | |
| 190 | |
| 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 | |
| 195 | |
| 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 | |
| 202 | |
| 203 # we should always return something, even empty, for the context | |
| 204 all_props[(default_cn, default_nodeid)] = {} | |
| 205 | |
| 206 keys = form.keys() | |
| 207 timezone = db.getUserTimezone() | |
| 208 | |
| 209 # sentinels for the :note and :file props | |
| 210 have_note = have_file = 0 | |
| 211 | |
| 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())) | |
| 218 | |
| 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'] | |
| 251 | |
| 252 # the thing this value relates to is... | |
| 253 this = (cn, nodeid) | |
| 254 | |
| 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] = {} | |
| 265 | |
| 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))) | |
| 275 | |
| 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) | |
| 281 | |
| 282 all_links.append((cn, nodeid, propname, value)) | |
| 283 continue | |
| 284 | |
| 285 # detect the special ":required" variable | |
| 286 if d['required']: | |
| 287 all_required[this] = self.extractFormList(form[key]) | |
| 288 continue | |
| 289 | |
| 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' | |
| 296 | |
| 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] | |
| 307 | |
| 308 # Get the form value. This value may be a MiniFieldStorage or a list | |
| 309 # of MiniFieldStorages. | |
| 310 value = form[key] | |
| 311 | |
| 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() | |
| 324 | |
| 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() | |
| 330 | |
| 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 | |
| 353 | |
| 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 | |
| 361 | |
| 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 = [] | |
| 373 | |
| 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() | |
| 392 | |
| 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) | |
| 423 | |
| 424 else: | |
| 425 value = hyperdb.rawToHyperdb(self.db, cl, nodeid, | |
| 426 propname, value) | |
| 427 except hyperdb.HyperdbValueError, msg: | |
| 428 raise FormError, msg | |
| 429 | |
| 430 # register that we got this property | |
| 431 if value: | |
| 432 got_props[this][propname] = 1 | |
| 433 | |
| 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)) | |
| 445 | |
| 446 # make sure the existing multilink is sorted | |
| 447 if isinstance(proptype, hyperdb.Multilink): | |
| 448 existing.sort() | |
| 449 | |
| 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 | |
| 461 | |
| 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 | |
| 473 | |
| 474 props[propname] = value | |
| 475 | |
| 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')])) | |
| 479 | |
| 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) | |
| 488 | |
| 489 # any required values not present? | |
| 490 if not required: | |
| 491 continue | |
| 492 | |
| 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) | |
| 502 | |
| 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 | |
| 515 | |
| 516 def extractFormList(self, value): | |
| 517 ''' Extract a list of values from the form value. | |
| 518 | |
| 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 | |
| 532 | |
| 533 value = [i.strip() for i in values.split(',')] | |
| 534 | |
| 535 # filter out the empty bits | |
| 536 return filter(None, value) |
