Mercurial > p > roundup > code
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup/cgi/form_parser.py Wed Feb 11 21:34:31 2004 +0000 @@ -0,0 +1,536 @@ +import re, mimetypes + +from roundup import hyperdb, date, password +from roundup.cgi.exceptions import FormError +from roundup.i18n import _ + +class FormParser: + # edit form variable handling (see unit tests) + FV_LABELS = r''' + ^( + (?P<note>[@:]note)| + (?P<file>[@:]file)| + ( + ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator + ((?P<required>[@:]required$)| # :required + ( + ( + (?P<add>[@:]add[@:])| # :add:<prop> + (?P<remove>[@:]remove[@:])| # :remove:<prop> + (?P<confirm>[@:]confirm[@:])| # :confirm:<prop> + (?P<link>[@:]link[@:])| # :link:<prop> + ([@:]) # just a separator + )? + (?P<propname>[^@:]+) # <prop> + ) + ) + ) + )$''' + + def __init__(self, client): + self.client = client + self.db = client.db + self.form = client.form + self.classname = client.classname + self.nodeid = client.nodeid + + def parse(self, num_re=re.compile('^\d+$')): + """ Item properties and their values are edited with html FORM + variables and their values. You can: + + - Change the value of some property of the current item. + - Create a new item of any class, and edit the new item's + properties, + - Attach newly created items to a multilink property of the + current item. + - Remove items from a multilink property of the current item. + - Specify that some properties are required for the edit + operation to be successful. + + In the following, <bracketed> values are variable, "@" may be + either ":" or "@", and other text "required" is fixed. + + Most properties are specified as form variables: + + <propname> + - property on the current context item + + <designator>"@"<propname> + - property on the indicated item (for editing related + information) + + Designators name a specific item of a class. + + <classname><N> + + Name an existing item of class <classname>. + + <classname>"-"<N> + + Name the <N>th new item of class <classname>. If the form + submission is successful, a new item of <classname> is + created. Within the submitted form, a particular + designator of this form always refers to the same new + item. + + Once we have determined the "propname", we look at it to see + if it's special: + + @required + The associated form value is a comma-separated list of + property names that must be specified when the form is + submitted for the edit operation to succeed. + + When the <designator> is missing, the properties are + for the current context item. When <designator> is + present, they are for the item specified by + <designator>. + + The "@required" specifier must come before any of the + properties it refers to are assigned in the form. + + @remove@<propname>=id(s) or @add@<propname>=id(s) + The "@add@" and "@remove@" edit actions apply only to + Multilink properties. The form value must be a + comma-separate list of keys for the class specified by + the simple form variable. The listed items are added + to (respectively, removed from) the specified + property. + + @link@<propname>=<designator> + If the edit action is "@link@", the simple form + variable must specify a Link or Multilink property. + The form value is a comma-separated list of + designators. The item corresponding to each + designator is linked to the property given by simple + form variable. These are collected up and returned in + all_links. + + None of the above (ie. just a simple form value) + The value of the form variable is converted + appropriately, depending on the type of the property. + + For a Link('klass') property, the form value is a + single key for 'klass', where the key field is + specified in dbinit.py. + + For a Multilink('klass') property, the form value is a + comma-separated list of keys for 'klass', where the + key field is specified in dbinit.py. + + Note that for simple-form-variables specifiying Link + and Multilink properties, the linked-to class must + have a key field. + + For a String() property specifying a filename, the + file named by the form value is uploaded. This means we + try to set additional properties "filename" and "type" (if + they are valid for the class). Otherwise, the property + is set to the form value. + + For Date(), Interval(), Boolean(), and Number() + properties, the form value is converted to the + appropriate + + Any of the form variables may be prefixed with a classname or + designator. + + Two special form values are supported for backwards + compatibility: + + @note + This is equivalent to:: + + @link@messages=msg-1 + msg-1@content=value + + except that in addition, the "author" and "date" + properties of "msg-1" are set to the userid of the + submitter, and the current time, respectively. + + @file + This is equivalent to:: + + @link@files=file-1 + file-1@content=value + + The String content value is handled as described above for + file uploads. + + If both the "@note" and "@file" form variables are + specified, the action:: + + @link@msg-1@files=file-1 + + is also performed. + + We also check that FileClass items have a "content" property with + actual content, otherwise we remove them from all_props before + returning. + + The return from this method is a dict of + (classname, id): properties + ... this dict _always_ has an entry for the current context, + even if it's empty (ie. a submission for an existing issue that + doesn't result in any changes would return {('issue','123'): {}}) + The id may be None, which indicates that an item should be + created. + """ + # some very useful variables + db = self.db + form = self.form + + if not hasattr(self, 'FV_SPECIAL'): + # generate the regexp for handling special form values + classes = '|'.join(db.classes.keys()) + # specials for parsePropsFromForm + # handle the various forms (see unit tests) + self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE) + self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes) + + # these indicate the default class / item + default_cn = self.classname + default_cl = self.db.classes[default_cn] + default_nodeid = self.nodeid + + # we'll store info about the individual class/item edit in these + all_required = {} # required props per class/item + all_props = {} # props to set per class/item + got_props = {} # props received per class/item + all_propdef = {} # note - only one entry per class + all_links = [] # as many as are required + + # we should always return something, even empty, for the context + all_props[(default_cn, default_nodeid)] = {} + + keys = form.keys() + timezone = db.getUserTimezone() + + # sentinels for the :note and :file props + have_note = have_file = 0 + + # extract the usable form labels from the form + matches = [] + for key in keys: + m = self.FV_SPECIAL.match(key) + if m: + matches.append((key, m.groupdict())) + + # now handle the matches + for key, d in matches: + if d['classname']: + # we got a designator + cn = d['classname'] + cl = self.db.classes[cn] + nodeid = d['id'] + propname = d['propname'] + elif d['note']: + # the special note field + cn = 'msg' + cl = self.db.classes[cn] + nodeid = '-1' + propname = 'content' + all_links.append((default_cn, default_nodeid, 'messages', + [('msg', '-1')])) + have_note = 1 + elif d['file']: + # the special file field + cn = 'file' + cl = self.db.classes[cn] + nodeid = '-1' + propname = 'content' + all_links.append((default_cn, default_nodeid, 'files', + [('file', '-1')])) + have_file = 1 + else: + # default + cn = default_cn + cl = default_cl + nodeid = default_nodeid + propname = d['propname'] + + # the thing this value relates to is... + this = (cn, nodeid) + + # get more info about the class, and the current set of + # form props for it + if not all_propdef.has_key(cn): + all_propdef[cn] = cl.getprops() + propdef = all_propdef[cn] + if not all_props.has_key(this): + all_props[this] = {} + props = all_props[this] + if not got_props.has_key(this): + got_props[this] = {} + + # is this a link command? + if d['link']: + value = [] + for entry in self.extractFormList(form[key]): + m = self.FV_DESIGNATOR.match(entry) + if not m: + raise FormError, \ + 'link "%s" value "%s" not a designator'%(key, entry) + value.append((m.group(1), m.group(2))) + + # make sure the link property is valid + if (not isinstance(propdef[propname], hyperdb.Multilink) and + not isinstance(propdef[propname], hyperdb.Link)): + raise FormError, '%s %s is not a link or '\ + 'multilink property'%(cn, propname) + + all_links.append((cn, nodeid, propname, value)) + continue + + # detect the special ":required" variable + if d['required']: + all_required[this] = self.extractFormList(form[key]) + continue + + # see if we're performing a special multilink action + mlaction = 'set' + if d['remove']: + mlaction = 'remove' + elif d['add']: + mlaction = 'add' + + # does the property exist? + if not propdef.has_key(propname): + if mlaction != 'set': + raise FormError, 'You have submitted a %s action for'\ + ' the property "%s" which doesn\'t exist'%(mlaction, + propname) + # the form element is probably just something we don't care + # about - ignore it + continue + proptype = propdef[propname] + + # Get the form value. This value may be a MiniFieldStorage or a list + # of MiniFieldStorages. + value = form[key] + + # handle unpacking of the MiniFieldStorage / list form value + if isinstance(proptype, hyperdb.Multilink): + value = self.extractFormList(value) + else: + # multiple values are not OK + if isinstance(value, type([])): + raise FormError, 'You have submitted more than one value'\ + ' for the %s property'%propname + # value might be a file upload... + if not hasattr(value, 'filename') or value.filename is None: + # nope, pull out the value and strip it + value = value.value.strip() + + # now that we have the props field, we need a teensy little + # extra bit of help for the old :note field... + if d['note'] and value: + props['author'] = self.db.getuid() + props['date'] = date.Date() + + # handle by type now + if isinstance(proptype, hyperdb.Password): + if not value: + # ignore empty password values + continue + for key, d in matches: + if d['confirm'] and d['propname'] == propname: + confirm = form[key] + break + else: + raise FormError, 'Password and confirmation text do '\ + 'not match' + if isinstance(confirm, type([])): + raise FormError, 'You have submitted more than one value'\ + ' for the %s property'%propname + if value != confirm.value: + raise FormError, 'Password and confirmation text do '\ + 'not match' + try: + value = password.Password(value) + except hyperdb.HyperdbValueError, msg: + raise FormError, msg + + elif isinstance(proptype, hyperdb.Multilink): + # convert input to list of ids + try: + l = hyperdb.rawToHyperdb(self.db, cl, nodeid, + propname, value) + except hyperdb.HyperdbValueError, msg: + raise FormError, msg + + # now use that list of ids to modify the multilink + if mlaction == 'set': + value = l + else: + # we're modifying the list - get the current list of ids + if props.has_key(propname): + existing = props[propname] + elif nodeid and not nodeid.startswith('-'): + existing = cl.get(nodeid, propname, []) + else: + existing = [] + + # now either remove or add + if mlaction == 'remove': + # remove - handle situation where the id isn't in + # the list + for entry in l: + try: + existing.remove(entry) + except ValueError: + raise FormError, _('property "%(propname)s": ' + '"%(value)s" not currently in list')%{ + 'propname': propname, 'value': entry} + else: + # add - easy, just don't dupe + for entry in l: + if entry not in existing: + existing.append(entry) + value = existing + value.sort() + + elif value == '': + # other types should be None'd if there's no value + value = None + else: + # handle all other types + try: + if isinstance(proptype, hyperdb.String): + if (hasattr(value, 'filename') and + value.filename is not None): + # skip if the upload is empty + if not value.filename: + continue + # this String is actually a _file_ + # try to determine the file content-type + fn = value.filename.split('\\')[-1] + if propdef.has_key('name'): + props['name'] = fn + # use this info as the type/filename properties + if propdef.has_key('type'): + if hasattr(value, 'type') and value.type: + props['type'] = value.type + elif mimetypes.guess_type(fn)[0]: + props['type'] = mimetypes.guess_type(fn)[0] + else: + props['type'] = "application/octet-stream" + # finally, read the content RAW + value = value.value + else: + value = hyperdb.rawToHyperdb(self.db, cl, + nodeid, propname, value) + + else: + value = hyperdb.rawToHyperdb(self.db, cl, nodeid, + propname, value) + except hyperdb.HyperdbValueError, msg: + raise FormError, msg + + # register that we got this property + if value: + got_props[this][propname] = 1 + + # get the old value + if nodeid and not nodeid.startswith('-'): + try: + existing = cl.get(nodeid, propname) + except KeyError: + # this might be a new property for which there is + # no existing value + if not propdef.has_key(propname): + raise + except IndexError, message: + raise FormError(str(message)) + + # make sure the existing multilink is sorted + if isinstance(proptype, hyperdb.Multilink): + existing.sort() + + # "missing" existing values may not be None + if not existing: + if isinstance(proptype, hyperdb.String) and not existing: + # some backends store "missing" Strings as empty strings + existing = None + elif isinstance(proptype, hyperdb.Number) and not existing: + # some backends store "missing" Numbers as 0 :( + existing = 0 + elif isinstance(proptype, hyperdb.Boolean) and not existing: + # likewise Booleans + existing = 0 + + # if changed, set it + if value != existing: + props[propname] = value + else: + # don't bother setting empty/unset values + if value is None: + continue + elif isinstance(proptype, hyperdb.Multilink) and value == []: + continue + elif isinstance(proptype, hyperdb.String) and value == '': + continue + + props[propname] = value + + # check to see if we need to specially link a file to the note + if have_note and have_file: + all_links.append(('msg', '-1', 'files', [('file', '-1')])) + + # see if all the required properties have been supplied + s = [] + for thing, required in all_required.items(): + # register the values we got + got = got_props.get(thing, {}) + for entry in required[:]: + if got.has_key(entry): + required.remove(entry) + + # any required values not present? + if not required: + continue + + # tell the user to entry the values required + if len(required) > 1: + p = 'properties' + else: + p = 'property' + s.append('Required %s %s %s not supplied'%(thing[0], p, + ', '.join(required))) + if s: + raise FormError, '\n'.join(s) + + # When creating a FileClass node, it should have a non-empty content + # property to be created. When editing a FileClass node, it should + # either have a non-empty content property or no property at all. In + # the latter case, nothing will change. + for (cn, id), props in all_props.items(): + if isinstance(self.db.classes[cn], hyperdb.FileClass): + if id == '-1': + if not props.get('content', ''): + del all_props[(cn, id)] + elif props.has_key('content') and not props['content']: + raise FormError, _('File is empty') + return all_props, all_links + + def extractFormList(self, value): + ''' Extract a list of values from the form value. + + It may be one of: + [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...] + MiniFieldStorage('value,value,...') + MiniFieldStorage('value') + ''' + # multiple values are OK + if isinstance(value, type([])): + # it's a list of MiniFieldStorages - join then into + values = ','.join([i.value.strip() for i in value]) + else: + # it's a MiniFieldStorage, but may be a comma-separated list + # of values + values = value.value + + value = [i.strip() for i in values.split(',')] + + # filter out the empty bits + return filter(None, value)
