X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;ds=inline;f=roundup%2Fcgi_client.py;h=aeb5fd64e50b34e9f6c7c5197b2f0899be85bdba;hb=93d14d9ee2e0dbdb5c8971ace98c923bb5f155e7;hp=d35cfa552f15ec0aec4a3d204743856bdd8f33ce;hpb=711ef3707d86e46b24e68bc4e9524a9d5a5f9d3c;p=roundup.git diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py index d35cfa5..aeb5fd6 100644 --- a/roundup/cgi_client.py +++ b/roundup/cgi_client.py @@ -15,12 +15,17 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: cgi_client.py,v 1.33 2001-10-17 00:18:41 richard Exp $ +# $Id: cgi_client.py,v 1.111 2002-02-25 04:32:21 richard Exp $ + +__doc__ = """ +WWW request handler (also used in the stand-alone server). +""" import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes -import base64, Cookie, time +import binascii, Cookie, time, random import roundupdb, htmltemplate, date, hyperdb, password +from roundup.i18n import _ class Unauthorised(ValueError): pass @@ -41,81 +46,149 @@ class Client: modifications are attributed to the 'anonymous' user. ''' - def __init__(self, instance, out, env): + def __init__(self, instance, request, env, form=None): self.instance = instance - self.out = out + self.request = request self.env = env self.path = env['PATH_INFO'] self.split_path = self.path.split('/') + self.instance_path_name = env['INSTANCE_NAME'] + url = self.env['SCRIPT_NAME'] + '/' + machine = self.env['SERVER_NAME'] + port = self.env['SERVER_PORT'] + if port != '80': machine = machine + ':' + port + self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url, + None, None, None)) + if form is None: + self.form = cgi.FieldStorage(environ=env) + else: + self.form = form self.headers_done = 0 - self.form = cgi.FieldStorage(environ=env) - self.headers_done = 0 - self.debug = 0 + try: + self.debug = int(env.get("ROUNDUP_DEBUG", 0)) + except ValueError: + # someone gave us a non-int debug level, turn it off + self.debug = 0 def getuid(self): return self.db.user.lookup(self.user) def header(self, headers={'Content-Type':'text/html'}): + '''Put up the appropriate header. + ''' if not headers.has_key('Content-Type'): headers['Content-Type'] = 'text/html' + self.request.send_response(200) for entry in headers.items(): - self.out.write('%s: %s\n'%entry) - self.out.write('\n') + self.request.send_header(*entry) + self.request.end_headers() self.headers_done = 1 + if self.debug: + self.headers_sent = headers + + global_javascript = ''' + +''' def pagehead(self, title, message=None): - url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/') - machine = self.env['SERVER_NAME'] - port = self.env['SERVER_PORT'] - if port != '80': machine = machine + ':' + port - base = urlparse.urlunparse(('http', machine, url, None, None, None)) if message is not None: - message = '
%s
'%message + message = _('
%(message)s
')%locals() else: message = '' - style = open(os.path.join(self.TEMPLATES, 'style.css')).read() - if self.user is not None: + style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read() + user_name = self.user or '' + if self.user == 'admin': + admin_links = _(' | Class List' \ + ' | User List' \ + ' | Add User') + else: + admin_links = '' + if self.user not in (None, 'anonymous'): userid = self.db.user.lookup(self.user) - user_info = '(login: %s)'%(userid, self.user) + user_info = _(''' +My Issues | +My Details | Logout +''')%locals() + else: + user_info = _('Login') + if self.user is not None: + add_links = _(''' +| Add +Issue +''') else: - user_info = '' - self.write(''' -%s - + add_links = '' + global_javascript = self.global_javascript%self.__dict__ + self.write(_(''' +%(title)s + +%(global_javascript)s -%s +%(message)s - + + + + +
%s %s
%(title)s%(user_name)s
All +Issues +| Unassigned +Issues +%(add_links)s +%(admin_links)s%(user_info)s
-'''%(title, style, message, title, user_info)) +''')%locals()) def pagefoot(self): if self.debug: - self.write('
') - self.write('
Path
') + self.write(_('
Path
')) self.write('
%s
'%(', '.join(map(repr, self.split_path)))) keys = self.form.keys() keys.sort() if keys: - self.write('
Form entries
') + self.write(_('
Form entries
')) for k in self.form.keys(): - v = str(self.form[k].value) - self.write('
%s:%s
'%(k, cgi.escape(v))) + v = self.form.getvalue(k, "") + if type(v) is type([]): + # Multiple username fields specified + v = "|".join(v) + self.write('
%s=%s
'%(k, cgi.escape(v))) + keys = self.headers_sent.keys() + keys.sort() + self.write(_('
Sent these HTTP headers
')) + for k in keys: + v = self.headers_sent[k] + self.write('
%s=%s
'%(k, cgi.escape(v))) keys = self.env.keys() keys.sort() - self.write('
CGI environment
') + self.write(_('
CGI environment
')) for k in keys: v = self.env[k] - self.write('
%s:%s
'%(k, cgi.escape(v))) + self.write('
%s=%s
'%(k, cgi.escape(v))) self.write('
') self.write('') def write(self, content): if not self.headers_done: self.header() - self.out.write(content) + self.request.wfile.write(content) def index_arg(self, arg): ''' handle the args to index - they might be a list from the form @@ -129,7 +202,7 @@ class Client: return arg.value.split(',') return [] - def index_filterspec(self): + def index_filterspec(self, filter): ''' pull the index filter spec from the form Links and multilinks want to be lists - the rest are straight @@ -141,6 +214,7 @@ class Client: for key in self.form.keys(): if key[0] == ':': continue if not props.has_key(key): continue + if key not in filter: continue prop = props[key] value = self.form[key] if (isinstance(prop, hyperdb.Link) or @@ -156,33 +230,56 @@ class Client: filterspec[key] = value.value return filterspec + def customization_widget(self): + ''' The customization widget is visible by default. The widget + visibility is remembered by show_customization. Visibility + is not toggled if the action value is "Redisplay" + ''' + if not self.form.has_key('show_customization'): + visible = 1 + else: + visible = int(self.form['show_customization'].value) + if self.form.has_key('action'): + if self.form['action'].value != 'Redisplay': + visible = self.form['action'].value == '+' + + return visible + default_index_sort = ['-activity'] default_index_group = ['priority'] - default_index_filter = [] + default_index_filter = ['status'] default_index_columns = ['id','activity','title','status','assignedto'] default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']} def index(self): ''' put up an index ''' self.classname = 'issue' - if self.form.has_key(':sort'): sort = self.index_arg(':sort') - else: sort = self.default_index_sort - if self.form.has_key(':group'): group = self.index_arg(':group') - else: group = self.default_index_group - if self.form.has_key(':filter'): filter = self.index_arg(':filter') - else: filter = self.default_index_filter - if self.form.has_key(':columns'): columns = self.index_arg(':columns') - else: columns = self.default_index_columns - filterspec = self.index_filterspec() - if not filterspec: + # see if the web has supplied us with any customisation info + defaults = 1 + for key in ':sort', ':group', ':filter', ':columns': + if self.form.has_key(key): + defaults = 0 + break + if defaults: + # no info supplied - use the defaults + sort = self.default_index_sort + group = self.default_index_group + filter = self.default_index_filter + columns = self.default_index_columns filterspec = self.default_index_filterspec + else: + sort = self.index_arg(':sort') + group = self.index_arg(':group') + filter = self.index_arg(':filter') + columns = self.index_arg(':columns') + filterspec = self.index_filterspec(filter) return self.list(columns=columns, filter=filter, group=group, sort=sort, filterspec=filterspec) # XXX deviates from spec - loses the '+' (that's a reserved character # in URLS def list(self, sort=None, group=None, filter=None, columns=None, - filterspec=None): + filterspec=None, show_customization=None): ''' call the template index with the args :sort - sort by prop name, optionally preceeded with '-' @@ -196,17 +293,133 @@ class Client: ''' cn = self.classname - self.pagehead('Index of %s'%cn) + cl = self.db.classes[cn] + self.pagehead(_('%(instancename)s: Index of %(classname)s')%{ + 'classname': cn, 'instancename': self.instance.INSTANCE_NAME}) if sort is None: sort = self.index_arg(':sort') if group is None: group = self.index_arg(':group') if filter is None: filter = self.index_arg(':filter') if columns is None: columns = self.index_arg(':columns') - if filterspec is None: filterspec = self.index_filterspec() + if filterspec is None: filterspec = self.index_filterspec(filter) + if show_customization is None: + show_customization = self.customization_widget() - htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec, - filter, columns, sort, group) + index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn) + try: + index.render(filterspec, filter, columns, sort, group, + show_customization=show_customization) + except htmltemplate.MissingTemplateError: + self.basicClassEditPage() self.pagefoot() + def basicClassEditPage(self): + '''Display a basic edit page that allows simple editing of the + nodes of the current class + ''' + if self.user != 'admin': + raise Unauthorised + w = self.write + cn = self.classname + cl = self.db.classes[cn] + idlessprops = cl.getprops(protected=0).keys() + props = ['id'] + idlessprops + + + # get the CSV module + try: + import csv + except ImportError: + w(_('Sorry, you need the csv module to use this function.
\n' + 'Get it from: http://www.object-craft.com.au/projects/csv/')) + return + + # do the edit + if self.form.has_key('rows'): + rows = self.form['rows'].value.splitlines() + p = csv.parser() + found = {} + line = 0 + for row in rows: + line += 1 + values = p.parse(row) + # not a complete row, keep going + if not values: continue + + # extract the nodeid + nodeid, values = values[0], values[1:] + found[nodeid] = 1 + + # confirm correct weight + if len(idlessprops) != len(values): + w(_('Not enough values on line %(line)s'%{'line':line})) + return + + # extract the new values + d = {} + for name, value in zip(idlessprops, values): + d[name] = value.strip() + + # perform the edit + if cl.hasnode(nodeid): + # edit existing + cl.set(nodeid, **d) + else: + # new node + found[cl.create(**d)] = 1 + + # retire the removed entries + for nodeid in cl.list(): + if not found.has_key(nodeid): + cl.retire(nodeid) + + w(_('''

You may edit the contents of the + "%(classname)s" class using this form. The lines are full-featured + Comma-Separated-Value lines, so you may include commas and even + newlines by enclosing the values in double-quotes ("). Double + quotes themselves must be quoted by doubling ("").

+

Remove entries by deleting their line. Add + new entries by appending + them to the table - put an X in the id column.

''')%{'classname':cn}) + + l = [] + for name in props: + l.append(name) + w('') + w(', '.join(l) + '\n') + w('') + + w('
') + w('
')) + + def classhelp(self): + '''Display a table of class info + ''' + w = self.write + cn = self.form['classname'].value + cl = self.db.classes[cn] + props = self.form['properties'].value.split(',') + + w('') + w('') + for name in props: + w(''%name) + w('') + for nodeid in cl.list(): + w('') + for name in props: + value = cgi.escape(str(cl.get(nodeid, name))) + w(''%value) + w('') + w('
%s
%s
') + def shownode(self, message=None): ''' display an item ''' @@ -216,15 +429,27 @@ class Client: # possibly perform an edit keys = self.form.keys() num_re = re.compile('^\d+$') - if keys: + # don't try to set properties if the user has just logged in + if keys and not self.form.has_key('__login_name'): try: - props, changed = parsePropsFromForm(self.db, cl, self.form, - self.nodeid) - cl.set(self.nodeid, **props) - self._post_editnode(self.nodeid, changed) + props = parsePropsFromForm(self.db, cl, self.form, self.nodeid) + # make changes to the node + self._changenode(props) + # handle linked nodes + self._post_editnode(self.nodeid) # and some nice feedback for the user - message = '%s edited ok'%', '.join(changed) + if props: + message = _('%(changes)s edited ok')%{'changes': + ', '.join(props.keys())} + elif self.form.has_key('__note') and self.form['__note'].value: + message = _('note added') + elif (self.form.has_key('__file') and + self.form['__file'].filename): + message = _('file added') + else: + message = _('nothing changed') except: + self.db.rollback() s = StringIO.StringIO() traceback.print_exc(None, s) message = '
%s
'%cgi.escape(s.getvalue()) @@ -238,39 +463,168 @@ class Client: nodeid = self.nodeid # use the template to display the item - htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid) + item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, + self.classname) + item.render(nodeid) + self.pagefoot() showissue = shownode showmsg = shownode - def showuser(self, message=None): - ''' display an item + def _add_assignedto_to_nosy(self, props): + ''' add the assignedto value from the props to the nosy list ''' - if self.user in ('admin', self.db.user.get(self.nodeid, 'username')): - self.shownode(message) - else: - raise Unauthorised + if not props.has_key('assignedto'): + return + assignedto_id = props['assignedto'] + if not props.has_key('nosy'): + # load current nosy + if self.nodeid: + cl = self.db.classes[self.classname] + l = cl.get(self.nodeid, 'nosy') + if assignedto_id in l: + return + props['nosy'] = l + else: + props['nosy'] = [] + if assignedto_id not in props['nosy']: + props['nosy'].append(assignedto_id) - def showfile(self): - ''' display a file + def _changenode(self, props): + ''' change the node based on the contents of the form ''' - nodeid = self.nodeid - cl = self.db.file - type = cl.get(nodeid, 'type') - if type == 'message/rfc822': - type = 'text/plain' - self.header(headers={'Content-Type': type}) - self.write(cl.get(nodeid, 'content')) + cl = self.db.classes[self.classname] + # set status to chatting if 'unread' or 'resolved' + try: + # determine the id of 'unread','resolved' and 'chatting' + unread_id = self.db.status.lookup('unread') + resolved_id = self.db.status.lookup('resolved') + chatting_id = self.db.status.lookup('chatting') + current_status = cl.get(self.nodeid, 'status') + if props.has_key('status'): + new_status = props['status'] + else: + # apparently there's a chance that some browsers don't + # send status... + new_status = current_status + except KeyError: + pass + else: + if new_status == unread_id or (new_status == resolved_id + and current_status == resolved_id): + props['status'] = chatting_id + + self._add_assignedto_to_nosy(props) + + # create the message + message, files = self._handle_message() + if message: + props['messages'] = cl.get(self.nodeid, 'messages') + [message] + if files: + props['files'] = cl.get(self.nodeid, 'files') + files + + # make the changes + cl.set(self.nodeid, **props) def _createnode(self): ''' create a node based on the contents of the form ''' cl = self.db.classes[self.classname] - props, dummy = parsePropsFromForm(self.db, cl, self.form) + props = parsePropsFromForm(self.db, cl, self.form) + + # set status to 'unread' if not specified - a status of '- no + # selection -' doesn't make sense + if not props.has_key('status'): + try: + unread_id = self.db.status.lookup('unread') + except KeyError: + pass + else: + props['status'] = unread_id + + self._add_assignedto_to_nosy(props) + + # check for messages and files + message, files = self._handle_message() + if message: + props['messages'] = [message] + if files: + props['files'] = files + # create the node and return it's id return cl.create(**props) - def _post_editnode(self, nid, changes=None): - ''' do the linking and message sending part of the node creation + def _handle_message(self): + ''' generate an edit message + ''' + # handle file attachments + files = [] + if self.form.has_key('__file'): + file = self.form['__file'] + if file.filename: + filename = file.filename.split('\\')[-1] + mime_type = mimetypes.guess_type(filename)[0] + if not mime_type: + mime_type = "application/octet-stream" + # create the new file entry + files.append(self.db.file.create(type=mime_type, + name=filename, content=file.file.read())) + + # we don't want to do a message if none of the following is true... + cn = self.classname + cl = self.db.classes[self.classname] + props = cl.getprops() + note = None + # in a nutshell, don't do anything if there's no note or there's no + # NOSY + if self.form.has_key('__note'): + note = self.form['__note'].value + if not props.has_key('messages'): + return None, files + if not isinstance(props['messages'], hyperdb.Multilink): + return None, files + if not props['messages'].classname == 'msg': + return None, files + if not (self.form.has_key('nosy') or note): + return None, files + + # handle the note + if note: + if '\n' in note: + summary = re.split(r'\n\r?', note)[0] + else: + summary = note + m = ['%s\n'%note] + elif not files: + # don't generate a useless message + return None, files + + # handle the messageid + # TODO: handle inreplyto + messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(), + self.classname, self.instance.MAIL_DOMAIN) + + # now create the message, attaching the files + content = '\n'.join(m) + message_id = self.db.msg.create(author=self.getuid(), + recipients=[], date=date.Date('.'), summary=summary, + content=content, files=files, messageid=messageid) + + # update the messages property + return message_id, files + + def _post_editnode(self, nid): + '''Do the linking part of the node creation. + + If a form element has :link or :multilink appended to it, its + value specifies a node designator and the property on that node + to add _this_ node to as a link or multilink. + + This is typically used on, eg. the file upload page to indicated + which issue to link the file to. + + TODO: I suspect that this and newfile will go away now that + there's the ability to upload a file using the issue __file form + element! ''' cn = self.classname cl = self.db.classes[cn] @@ -296,66 +650,6 @@ class Client: link = self.db.classes[link] link.set(nodeid, **{property: nid}) - # generate an edit message - # don't bother if there's no messages or nosy list - props = cl.getprops() - note = None - if self.form.has_key('__note'): - note = self.form['__note'] - note = note.value - send = len(cl.get(nid, 'nosy', [])) or note - if (send and props.has_key('messages') and - isinstance(props['messages'], hyperdb.Multilink) and - props['messages'].classname == 'msg'): - - # handle the note - if note: - if '\n' in note: - summary = re.split(r'\n\r?', note)[0] - else: - summary = note - m = ['%s\n'%note] - else: - summary = 'This %s has been edited through the web.\n'%cn - m = [summary] - - first = 1 - for name, prop in props.items(): - if changes is not None and name not in changes: continue - if first: - m.append('\n-------') - first = 0 - value = cl.get(nid, name, None) - if isinstance(prop, hyperdb.Link): - link = self.db.classes[prop.classname] - key = link.labelprop(default_to_id=1) - if value is not None and key: - value = link.get(value, key) - else: - value = '-' - elif isinstance(prop, hyperdb.Multilink): - if value is None: value = [] - l = [] - link = self.db.classes[prop.classname] - key = link.labelprop(default_to_id=1) - for entry in value: - if key: - l.append(link.get(entry, key)) - else: - l.append(entry) - value = ', '.join(l) - m.append('%s: %s'%(name, value)) - - # now create the message - content = '\n'.join(m) - message_id = self.db.msg.create(author=self.getuid(), - recipients=[], date=date.Date('.'), summary=summary, - content=content) - messages = cl.get(nid, 'messages') - messages.append(message_id) - props = {'messages': messages} - cl.set(nid, **props) - def newnode(self, message=None): ''' Add a new node to the database. @@ -387,19 +681,69 @@ class Client: props = {} try: nid = self._createnode() + # handle linked nodes self._post_editnode(nid) # and some nice feedback for the user - message = '%s created ok'%cn + message = _('%(classname)s created ok')%{'classname': cn} + + # render the newly created issue + self.db.commit() + self.nodeid = nid + self.pagehead('%s: %s'%(self.classname.capitalize(), nid), + message) + item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, + self.classname) + item.render(nid) + self.pagefoot() + return except: + self.db.rollback() s = StringIO.StringIO() traceback.print_exc(None, s) message = '
%s
'%cgi.escape(s.getvalue()) - self.pagehead('New %s'%self.classname.capitalize(), message) - htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname, - self.form) + self.pagehead(_('New %(classname)s')%{'classname': + self.classname.capitalize()}, message) + + # call the template + newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES, + self.classname) + newitem.render(self.form) + self.pagefoot() newissue = newnode - newuser = newnode + + def newuser(self, message=None): + ''' Add a new user to the database. + + Don't do any of the message or file handling, just create the node. + ''' + cn = self.classname + cl = self.db.classes[cn] + + # possibly perform a create + keys = self.form.keys() + if [i for i in keys if i[0] != ':']: + try: + props = parsePropsFromForm(self.db, cl, self.form) + nid = cl.create(**props) + # handle linked nodes + self._post_editnode(nid) + # and some nice feedback for the user + message = _('%(classname)s created ok')%{'classname': cn} + except: + self.db.rollback() + s = StringIO.StringIO() + traceback.print_exc(None, s) + message = '
%s
'%cgi.escape(s.getvalue()) + self.pagehead(_('New %(classname)s')%{'classname': + self.classname.capitalize()}, message) + + # call the template + newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES, + self.classname) + newitem.render(self.form) + + self.pagefoot() def newfile(self, message=None): ''' Add a new file to the database. @@ -415,34 +759,111 @@ class Client: if [i for i in keys if i[0] != ':']: try: file = self.form['content'] - type = mimetypes.guess_type(file.filename)[0] - if not type: - type = "application/octet-stream" - self._post_editnode(cl.create(content=file.file.read(), - type=type, name=file.filename)) + mime_type = mimetypes.guess_type(file.filename)[0] + if not mime_type: + mime_type = "application/octet-stream" + # save the file + nid = cl.create(content=file.file.read(), type=mime_type, + name=file.filename) + # handle linked nodes + self._post_editnode(nid) # and some nice feedback for the user - message = '%s created ok'%cn + message = _('%(classname)s created ok')%{'classname': cn} except: + self.db.rollback() s = StringIO.StringIO() traceback.print_exc(None, s) message = '
%s
'%cgi.escape(s.getvalue()) - self.pagehead('New %s'%self.classname.capitalize(), message) - htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname, - self.form) + self.pagehead(_('New %(classname)s')%{'classname': + self.classname.capitalize()}, message) + newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES, + self.classname) + newitem.render(self.form) self.pagefoot() + def showuser(self, message=None): + '''Display a user page for editing. Make sure the user is allowed + to edit this node, and also check for password changes. + ''' + if self.user == 'anonymous': + raise Unauthorised + + user = self.db.user + + # get the username of the node being edited + node_user = user.get(self.nodeid, 'username') + + if self.user not in ('admin', node_user): + raise Unauthorised + + # + # perform any editing + # + keys = self.form.keys() + num_re = re.compile('^\d+$') + if keys: + try: + props = parsePropsFromForm(self.db, user, self.form, + self.nodeid) + set_cookie = 0 + if props.has_key('password'): + password = self.form['password'].value.strip() + if not password: + # no password was supplied - don't change it + del props['password'] + elif self.nodeid == self.getuid(): + # this is the logged-in user's password + set_cookie = password + user.set(self.nodeid, **props) + # and some feedback for the user + message = _('%(changes)s edited ok')%{'changes': + ', '.join(props.keys())} + except: + self.db.rollback() + s = StringIO.StringIO() + traceback.print_exc(None, s) + message = '
%s
'%cgi.escape(s.getvalue()) + else: + set_cookie = 0 + + # fix the cookie if the password has changed + if set_cookie: + self.set_cookie(self.user, set_cookie) + + # + # now the display + # + self.pagehead(_('User: %(user)s')%{'user': node_user}, message) + + # use the template to display the item + item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user') + item.render(self.nodeid) + self.pagefoot() + + def showfile(self): + ''' display a file + ''' + nodeid = self.nodeid + cl = self.db.file + mime_type = cl.get(nodeid, 'type') + if mime_type == 'message/rfc822': + mime_type = 'text/plain' + self.header(headers={'Content-Type': mime_type}) + self.write(cl.get(nodeid, 'content')) + def classes(self, message=None): ''' display a list of all the classes in the database ''' if self.user == 'admin': - self.pagehead('Table of classes', message) + self.pagehead(_('Table of classes'), message) classnames = self.db.classes.keys() classnames.sort() self.write('\n') for cn in classnames: cl = self.db.getclass(cn) - self.write(''%cn.capitalize()) + self.write(''%(cn, cn.capitalize())) for key, value in cl.properties.items(): if value is None: value = '' else: value = str(value) @@ -453,12 +874,15 @@ class Client: else: raise Unauthorised - def login(self, message=None): - self.pagehead('Login to roundup', message) - self.write(''' + def login(self, message=None, newuser_form=None, action='index'): + '''Display a login page. + ''' + self.pagehead(_('Login to roundup'), message) + self.write(_('''
%s
' + '%s
- + + @@ -466,55 +890,117 @@ class Client: - +''')%locals()) + if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny': + self.write('
Existing User Login
Login name:
Password:
') + self.pagefoot() + return + values = {'realname': '', 'organisation': '', 'address': '', + 'phone': '', 'username': '', 'password': '', 'confirm': '', + 'action': action, 'alternate_addresses': ''} + if newuser_form is not None: + for key in newuser_form.keys(): + values[key] = newuser_form[key].value + self.write(_('''

New User Registration marked items are optional... -

+ + Name: - + Organisation: - + E-Mail Address: - + +Alternate E-mail Addresses: + Phone: - + Preferred Login name: - + Password: - + Password Again: - +
-''') +''')%values) + self.pagefoot() def login_action(self, message=None): + '''Attempt to log a user in and set the cookie + + returns 0 if a page is generated as a result of this call, and + 1 if not (ie. the login is successful + ''' + if not self.form.has_key('__login_name'): + self.login(message=_('Username required')) + return 0 self.user = self.form['__login_name'].value - password = self.form['__login_password'].value + if self.form.has_key('__login_password'): + password = self.form['__login_password'].value + else: + password = '' # make sure the user exists try: uid = self.db.user.lookup(self.user) except KeyError: name = self.user self.make_user_anonymous() - return self.login(message='No such user "%s"'%name) + action = self.form['__destination_url'].value + self.login(message=_('No such user "%(name)s"')%locals(), + action=action) + return 0 # and that the password is correct pw = self.db.user.get(uid, 'password') - if password != self.db.user.get(uid, 'password'): + if password != pw: self.make_user_anonymous() - return self.login(message='Incorrect password') + action = self.form['__destination_url'].value + self.login(message=_('Incorrect password'), action=action) + return 0 + + self.set_cookie(self.user, password) + return 1 + + def newuser_action(self, message=None): + '''Attempt to create a new user based on the contents of the form + and then set the cookie. + return 1 on successful login + ''' + # re-open the database as "admin" + self.db = self.instance.open('admin') + + # TODO: pre-check the required fields and username key property + cl = self.db.user + try: + props = parsePropsFromForm(self.db, cl, self.form) + uid = cl.create(**props) + except ValueError, message: + action = self.form['__destination_url'].value + self.login(message, action=action) + return 0 + self.user = cl.get(uid, 'username') + password = cl.get(uid, 'password') + self.set_cookie(self.user, self.form['password'].value) + return 1 + + def set_cookie(self, user, password): # construct the cookie - uid = self.db.user.lookup(self.user) - user = base64.encodestring('%s:%s'%(self.user, password))[:-1] - path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], - '')) - self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)}) - return self.index() + user = binascii.b2a_base64('%s:%s'%(user, password)).strip() + if user[-1] == '=': + if user[-2] == '=': + user = user[:-2] + else: + user = user[:-1] + expire = Cookie._getdate(86400*365) + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'])) + self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % ( + user, expire, path)}) def make_user_anonymous(self): # make us anonymous if we can @@ -527,34 +1013,16 @@ class Client: def logout(self, message=None): self.make_user_anonymous() # construct the logout cookie - path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], - '')) now = Cookie._getdate() + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'])) self.header({'Set-Cookie': - 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)}) - return self.index() + 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, + path)}) + self.login() - def newuser_action(self, message=None): - ''' create a new user based on the contents of the form and then - set the cookie + def main(self): + '''Wrap the database accesses so we can close the database cleanly ''' - # TODO: pre-check the required fields and username key property - cl = self.db.classes['user'] - props, dummy = parsePropsFromForm(self.db, cl, self.form) - uid = cl.create(**props) - self.user = self.db.user.get(uid, 'username') - password = self.db.user.get(uid, 'password') - # construct the cookie - uid = self.db.user.lookup(self.user) - user = base64.encodestring('%s:%s'%(self.user, password))[:-1] - path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], - '')) - self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)}) - return self.index() - - def main(self, dre=re.compile(r'([^\d]+)(\d+)'), - nre=re.compile(r'new(\w+)')): - # determine the uid to use self.db = self.instance.open('admin') cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', '')) @@ -562,7 +1030,14 @@ class Client: if (cookie.has_key('roundup_user') and cookie['roundup_user'].value != 'deleted'): cookie = cookie['roundup_user'].value - user, password = base64.decodestring(cookie).split(':') + if len(cookie)%4: + cookie = cookie + '='*(4-len(cookie)%4) + try: + user, password = binascii.a2b_base64(cookie).split(':') + except (TypeError, binascii.Error, binascii.Incomplete): + # damaged cookie! + user, password = 'anonymous', '' + # make sure the user exists try: uid = self.db.user.lookup(user) @@ -577,69 +1052,127 @@ class Client: self.make_user_anonymous() else: self.user = user - self.db.close() # re-open the database for real, using the user self.db = self.instance.open(self.user) # now figure which function to call path = self.split_path + + # default action to index if the path has no information in it if not path or path[0] in ('', 'index'): - self.index() - elif len(path) == 1: - if path[0] == 'list_classes': - self.classes() - return - if path[0] == 'login': - self.login() - return - if path[0] == 'login_action': - self.login_action() - return - if path[0] == 'newuser_action': - self.newuser_action() - return - if path[0] == 'logout': - self.logout() + action = 'index' + else: + action = path[0] + + # Everthing ignores path[1:] + # - The file download link generator actually relies on this - it + # appends the name of the file to the URL so the download file name + # is correct, but doesn't actually use it. + + # everyone is allowed to try to log in + if action == 'login_action': + # try to login + if not self.login_action(): return - m = dre.match(path[0]) - if m: - self.classname = m.group(1) - self.nodeid = m.group(2) - try: - cl = self.db.classes[self.classname] - except KeyError: - raise NotFound - try: - cl.get(self.nodeid, 'id') - except IndexError: - raise NotFound - try: - func = getattr(self, 'show%s'%self.classname) - except AttributeError: - raise NotFound - func() + # figure the resulting page + action = self.form['__destination_url'].value + if not action: + action = 'index' + self.do_action(action) + return + + # allow anonymous people to register + if action == 'newuser_action': + # if we don't have a login and anonymous people aren't allowed to + # register, then spit up the login form + if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None: + if action == 'login': + self.login() # go to the index after login + else: + self.login(action=action) return - m = nre.match(path[0]) - if m: - self.classname = m.group(1) - try: - func = getattr(self, 'new%s'%self.classname) - except AttributeError: - raise NotFound - func() + # try to add the user + if not self.newuser_action(): return - self.classname = path[0] + # figure the resulting page + action = self.form['__destination_url'].value + if not action: + action = 'index' + + # no login or registration, make sure totally anonymous access is OK + elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None: + if action == 'login': + self.login() # go to the index after login + else: + self.login(action=action) + return + + # just a regular action + self.do_action(action) + + # commit all changes to the database + self.db.commit() + + def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'), + nre=re.compile(r'new(\w+)')): + '''Figure the user's action and do it. + ''' + # here be the "normal" functionality + if action == 'index': + self.index() + return + if action == 'list_classes': + self.classes() + return + if action == 'classhelp': + self.classhelp() + return + if action == 'login': + self.login() + return + if action == 'logout': + self.logout() + return + + # see if we're to display an existing node + m = dre.match(action) + if m: + self.classname = m.group(1) + self.nodeid = m.group(2) try: - self.db.getclass(self.classname) + cl = self.db.classes[self.classname] except KeyError: raise NotFound - self.list() - else: - raise 'ValueError', 'Path not understood' + try: + cl.get(self.nodeid, 'id') + except IndexError: + raise NotFound + try: + func = getattr(self, 'show%s'%self.classname) + except AttributeError: + raise NotFound + func() + return + + # see if we're to put up the new node page + m = nre.match(action) + if m: + self.classname = m.group(1) + try: + func = getattr(self, 'new%s'%self.classname) + except AttributeError: + raise NotFound + func() + return - def __del__(self): - self.db.close() + # otherwise, display the named class + self.classname = action + try: + self.db.getclass(self.classname) + except KeyError: + raise NotFound + self.list() class ExtendedClient(Client): @@ -653,72 +1186,68 @@ class ExtendedClient(Client): default_index_sort = ['-activity'] default_index_group = ['priority'] - default_index_filter = [] + default_index_filter = ['status'] default_index_columns = ['activity','status','title','assignedto'] default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']} def pagehead(self, title, message=None): - url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/') - machine = self.env['SERVER_NAME'] - port = self.env['SERVER_PORT'] - if port != '80': machine = machine + ':' + port - base = urlparse.urlunparse(('http', machine, url, None, None, None)) if message is not None: - message = '
%s
'%message + message = _('
%(message)s
')%locals() else: message = '' - style = open(os.path.join(self.TEMPLATES, 'style.css')).read() + style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read() user_name = self.user or '' if self.user == 'admin': - admin_links = ' |
Class List' + admin_links = _(' | Class List' \ + ' | User List' \ + ' | Add User') else: admin_links = '' if self.user not in (None, 'anonymous'): userid = self.db.user.lookup(self.user) - user_info = ''' -My Issues | -My Support | -My Details | Logout -'''%(userid, userid, userid) + user_info = _(''' +My Issues | +My Support | +My Details | Logout +''')%locals() else: - user_info = 'Login' + user_info = _('Login') if self.user is not None: - add_links = ''' + add_links = _(''' | Add Issue, Support, -User -''' +''') else: add_links = '' - self.write(''' -%s - + global_javascript = self.global_javascript%self.__dict__ + self.write(_(''' +%(title)s + +%(global_javascript)s -%s +%(message)s - - + + - +Issues, +Support +%(add_links)s +%(admin_links)s +
%s%s
%(title)s%(user_name)s
All -Issues, -Support +Issues, +Support | Unassigned -Issues, -Support -%s -%s%s%(user_info)s
-'''%(title, style, message, title, user_name, add_links, admin_links, - user_info)) +''')%locals()) def parsePropsFromForm(db, cl, form, nodeid=0): '''Pull properties for the given class out of the form. ''' props = {} - changed = [] keys = form.keys() num_re = re.compile('^\d+$') for key in keys: @@ -730,19 +1259,33 @@ def parsePropsFromForm(db, cl, form, nodeid=0): elif isinstance(proptype, hyperdb.Password): value = password.Password(form[key].value.strip()) elif isinstance(proptype, hyperdb.Date): - value = date.Date(form[key].value.strip()) + value = form[key].value.strip() + if value: + value = date.Date(form[key].value.strip()) + else: + value = None elif isinstance(proptype, hyperdb.Interval): - value = date.Interval(form[key].value.strip()) + value = form[key].value.strip() + if value: + value = date.Interval(form[key].value.strip()) + else: + value = None elif isinstance(proptype, hyperdb.Link): value = form[key].value.strip() - # handle key values - link = cl.properties[key].classname - if not num_re.match(value): - try: - value = db.classes[link].lookup(value) - except KeyError: - raise ValueError, 'property "%s": %s not a %s'%( - key, value, link) + # see if it's the "no selection" choice + if value == '-1': + # don't set this property + continue + else: + # handle key values + link = cl.properties[key].classname + if not num_re.match(value): + try: + value = db.classes[link].lookup(value) + except KeyError: + raise ValueError, _('property "%(propname)s": ' + '%(value)s not a %(classname)s')%{'propname':key, + 'value': value, 'classname': link} elif isinstance(proptype, hyperdb.Multilink): value = form[key] if type(value) != type([]): @@ -752,25 +1295,414 @@ def parsePropsFromForm(db, cl, form, nodeid=0): link = cl.properties[key].classname l = [] for entry in map(str, value): + if entry == '': continue if not num_re.match(entry): try: entry = db.classes[link].lookup(entry) except KeyError: - raise ValueError, \ - 'property "%s": "%s" not an entry of %s'%(key, - entry, link.capitalize()) + raise ValueError, _('property "%(propname)s": ' + '"%(value)s" not an entry of %(classname)s')%{ + 'propname':key, 'value': entry, 'classname': link} l.append(entry) l.sort() value = l - props[key] = value - # if changed, set it - if nodeid and value != cl.get(nodeid, key): - changed.append(key) + + # get the old value + if nodeid: + try: + existing = cl.get(nodeid, key) + except KeyError: + # this might be a new property for which there is no existing + # value + if not cl.properties.has_key(key): raise + + # if changed, set it + if value != existing: + props[key] = value + else: props[key] = value - return props, changed + return props # # $Log: not supported by cvs2svn $ +# Revision 1.110 2002/02/21 07:19:08 richard +# ... and label, width and height control for extra flavour! +# +# Revision 1.109 2002/02/21 07:08:19 richard +# oops +# +# Revision 1.108 2002/02/21 07:02:54 richard +# The correct var is "HTTP_HOST" +# +# Revision 1.107 2002/02/21 06:57:38 richard +# . Added popup help for classes using the classhelp html template function. +# - add +# to an item page, and it generates a link to a popup window which displays +# the id, name and description for the priority class. The description +# field won't exist in most installations, but it will be added to the +# default templates. +# +# Revision 1.106 2002/02/21 06:23:00 richard +# *** empty log message *** +# +# Revision 1.105 2002/02/20 05:52:10 richard +# better error handling +# +# Revision 1.104 2002/02/20 05:45:17 richard +# Use the csv module for generating the form entry so it's correct. +# [also noted the sf.net feature request id in the change log] +# +# Revision 1.103 2002/02/20 05:05:28 richard +# . Added simple editing for classes that don't define a templated interface. +# - access using the admin "class list" interface +# - limited to admin-only +# - requires the csv module from object-craft (url given if it's missing) +# +# Revision 1.102 2002/02/15 07:08:44 richard +# . Alternate email addresses are now available for users. See the MIGRATION +# file for info on how to activate the feature. +# +# Revision 1.101 2002/02/14 23:39:18 richard +# . All forms now have "double-submit" protection when Javascript is enabled +# on the client-side. +# +# Revision 1.100 2002/01/16 07:02:57 richard +# . lots of date/interval related changes: +# - more relaxed date format for input +# +# Revision 1.99 2002/01/16 03:02:42 richard +# #503793 ] changing assignedto resets nosy list +# +# Revision 1.98 2002/01/14 02:20:14 richard +# . changed all config accesses so they access either the instance or the +# config attriubute on the db. This means that all config is obtained from +# instance_config instead of the mish-mash of classes. This will make +# switching to a ConfigParser setup easier too, I hope. +# +# At a minimum, this makes migration a _little_ easier (a lot easier in the +# 0.5.0 switch, I hope!) +# +# Revision 1.97 2002/01/11 23:22:29 richard +# . #502437 ] rogue reactor and unittest +# in short, the nosy reactor was modifying the nosy list. That code had +# been there for a long time, and I suspsect it was there because we +# weren't generating the nosy list correctly in other places of the code. +# We're now doing that, so the nosy-modifying code can go away from the +# nosy reactor. +# +# Revision 1.96 2002/01/10 05:26:10 richard +# missed a parsePropsFromForm in last update +# +# Revision 1.95 2002/01/10 03:39:45 richard +# . fixed some problems with web editing and change detection +# +# Revision 1.94 2002/01/09 13:54:21 grubert +# _add_assignedto_to_nosy did set nosy to assignedto only, no adding. +# +# Revision 1.93 2002/01/08 11:57:12 richard +# crying out for real configuration handling... :( +# +# Revision 1.92 2002/01/08 04:12:05 richard +# Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822 +# +# Revision 1.91 2002/01/08 04:03:47 richard +# I mucked the intent of the code up. +# +# Revision 1.90 2002/01/08 03:56:55 richard +# Oops, missed this before the beta: +# . #495392 ] empty nosy -patch +# +# Revision 1.89 2002/01/07 20:24:45 richard +# *mutter* stupid cutnpaste +# +# Revision 1.88 2002/01/02 02:31:38 richard +# Sorry for the huge checkin message - I was only intending to implement #496356 +# but I found a number of places where things had been broken by transactions: +# . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename +# for _all_ roundup-generated smtp messages to be sent to. +# . the transaction cache had broken the roundupdb.Class set() reactors +# . newly-created author users in the mailgw weren't being committed to the db +# +# Stuff that made it into CHANGES.txt (ie. the stuff I was actually working +# on when I found that stuff :): +# . #496356 ] Use threading in messages +# . detectors were being registered multiple times +# . added tests for mailgw +# . much better attaching of erroneous messages in the mail gateway +# +# Revision 1.87 2001/12/23 23:18:49 richard +# We already had an admin-specific section of the web heading, no need to add +# another one :) +# +# Revision 1.86 2001/12/20 15:43:01 rochecompaan +# Features added: +# . Multilink properties are now displayed as comma separated values in +# a textbox +# . The add user link is now only visible to the admin user +# . Modified the mail gateway to reject submissions from unknown +# addresses if ANONYMOUS_ACCESS is denied +# +# Revision 1.85 2001/12/20 06:13:24 rochecompaan +# Bugs fixed: +# . Exception handling in hyperdb for strings-that-look-like numbers got +# lost somewhere +# . Internet Explorer submits full path for filename - we now strip away +# the path +# Features added: +# . Link and multilink properties are now displayed sorted in the cgi +# interface +# +# Revision 1.84 2001/12/18 15:30:30 rochecompaan +# Fixed bugs: +# . Fixed file creation and retrieval in same transaction in anydbm +# backend +# . Cgi interface now renders new issue after issue creation +# . Could not set issue status to resolved through cgi interface +# . Mail gateway was changing status back to 'chatting' if status was +# omitted as an argument +# +# Revision 1.83 2001/12/15 23:51:01 richard +# Tested the changes and fixed a few problems: +# . files are now attached to the issue as well as the message +# . newuser is a real method now since we don't want to do the message/file +# stuff for it +# . added some documentation +# The really big changes in the diff are a result of me moving some code +# around to keep like methods together a bit better. +# +# Revision 1.82 2001/12/15 19:24:39 rochecompaan +# . Modified cgi interface to change properties only once all changes are +# collected, files created and messages generated. +# . Moved generation of change note to nosyreactors. +# . We now check for changes to "assignedto" to ensure it's added to the +# nosy list. +# +# Revision 1.81 2001/12/12 23:55:00 richard +# Fixed some problems with user editing +# +# Revision 1.80 2001/12/12 23:27:14 richard +# Added a Zope frontend for roundup. +# +# Revision 1.79 2001/12/10 22:20:01 richard +# Enabled transaction support in the bsddb backend. It uses the anydbm code +# where possible, only replacing methods where the db is opened (it uses the +# btree opener specifically.) +# Also cleaned up some change note generation. +# Made the backends package work with pydoc too. +# +# Revision 1.78 2001/12/07 05:59:27 rochecompaan +# Fixed small bug that prevented adding issues through the web. +# +# Revision 1.77 2001/12/06 22:48:29 richard +# files multilink was being nuked in post_edit_node +# +# Revision 1.76 2001/12/05 14:26:44 rochecompaan +# Removed generation of change note from "sendmessage" in roundupdb.py. +# The change note is now generated when the message is created. +# +# Revision 1.75 2001/12/04 01:25:08 richard +# Added some rollbacks where we were catching exceptions that would otherwise +# have stopped committing. +# +# Revision 1.74 2001/12/02 05:06:16 richard +# . We now use weakrefs in the Classes to keep the database reference, so +# the close() method on the database is no longer needed. +# I bumped the minimum python requirement up to 2.1 accordingly. +# . #487480 ] roundup-server +# . #487476 ] INSTALL.txt +# +# I also cleaned up the change message / post-edit stuff in the cgi client. +# There's now a clearly marked "TODO: append the change note" where I believe +# the change note should be added there. The "changes" list will obviously +# have to be modified to be a dict of the changes, or somesuch. +# +# More testing needed. +# +# Revision 1.73 2001/12/01 07:17:50 richard +# . We now have basic transaction support! Information is only written to +# the database when the commit() method is called. Only the anydbm +# backend is modified in this way - neither of the bsddb backends have been. +# The mail, admin and cgi interfaces all use commit (except the admin tool +# doesn't have a commit command, so interactive users can't commit...) +# . Fixed login/registration forwarding the user to the right page (or not, +# on a failure) +# +# Revision 1.72 2001/11/30 20:47:58 rochecompaan +# Links in page header are now consistent with default sort order. +# +# Fixed bugs: +# - When login failed the list of issues were still rendered. +# - User was redirected to index page and not to his destination url +# if his first login attempt failed. +# +# Revision 1.71 2001/11/30 20:28:10 rochecompaan +# Property changes are now completely traceable, whether changes are +# made through the web or by email +# +# Revision 1.70 2001/11/30 00:06:29 richard +# Converted roundup/cgi_client.py to use _() +# Added the status file, I18N_PROGRESS.txt +# +# Revision 1.69 2001/11/29 23:19:51 richard +# Removed the "This issue has been edited through the web" when a valid +# change note is supplied. +# +# Revision 1.68 2001/11/29 04:57:23 richard +# a little comment +# +# Revision 1.67 2001/11/28 21:55:35 richard +# . login_action and newuser_action return values were being ignored +# . Woohoo! Found that bloody re-login bug that was killing the mail +# gateway. +# (also a minor cleanup in hyperdb) +# +# Revision 1.66 2001/11/27 03:00:50 richard +# couple of bugfixes from latest patch integration +# +# Revision 1.65 2001/11/26 23:00:53 richard +# This config stuff is getting to be a real mess... +# +# Revision 1.64 2001/11/26 22:56:35 richard +# typo +# +# Revision 1.63 2001/11/26 22:55:56 richard +# Feature: +# . Added INSTANCE_NAME to configuration - used in web and email to identify +# the instance. +# . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup +# signature info in e-mails. +# . Some more flexibility in the mail gateway and more error handling. +# . Login now takes you to the page you back to the were denied access to. +# +# Fixed: +# . Lots of bugs, thanks Roché and others on the devel mailing list! +# +# Revision 1.62 2001/11/24 00:45:42 jhermann +# typeof() instead of type(): avoid clash with database field(?) "type" +# +# Fixes this traceback: +# +# Traceback (most recent call last): +# File "roundup\cgi_client.py", line 535, in newnode +# self._post_editnode(nid) +# File "roundup\cgi_client.py", line 415, in _post_editnode +# if type(value) != type([]): value = [value] +# UnboundLocalError: local variable 'type' referenced before assignment +# +# Revision 1.61 2001/11/22 15:46:42 jhermann +# Added module docstrings to all modules. +# +# Revision 1.60 2001/11/21 22:57:28 jhermann +# Added dummy hooks for I18N and some preliminary (test) markup of +# translatable messages +# +# Revision 1.59 2001/11/21 03:21:13 richard +# oops +# +# Revision 1.58 2001/11/21 03:11:28 richard +# Better handling of new properties. +# +# Revision 1.57 2001/11/15 10:24:27 richard +# handle the case where there is no file attached +# +# Revision 1.56 2001/11/14 21:35:21 richard +# . users may attach files to issues (and support in ext) through the web now +# +# Revision 1.55 2001/11/07 02:34:06 jhermann +# Handling of damaged login cookies +# +# Revision 1.54 2001/11/07 01:16:12 richard +# Remove the '=' padding from cookie value so quoting isn't an issue. +# +# Revision 1.53 2001/11/06 23:22:05 jhermann +# More IE fixes: it does not like quotes around cookie values; in the +# hope this does not break anything for other browser; if it does, we +# need to check HTTP_USER_AGENT +# +# Revision 1.52 2001/11/06 23:11:22 jhermann +# Fixed debug output in page footer; added expiry date to the login cookie +# (expires 1 year in the future) to prevent probs with certain versions +# of IE +# +# Revision 1.51 2001/11/06 22:00:34 jhermann +# Get debug level from ROUNDUP_DEBUG env var +# +# Revision 1.50 2001/11/05 23:45:40 richard +# Fixed newuser_action so it sets the cookie with the unencrypted password. +# Also made it present nicer error messages (not tracebacks). +# +# Revision 1.49 2001/11/04 03:07:12 richard +# Fixed various cookie-related bugs: +# . bug #477685 ] base64.decodestring breaks +# . bug #477837 ] lynx does not like the cookie +# . bug #477892 ] Password edit doesn't fix login cookie +# Also closed a security hole - a logged-in user could edit another user's +# details. +# +# Revision 1.48 2001/11/03 01:30:18 richard +# Oops. uses pagefoot now. +# +# Revision 1.47 2001/11/03 01:29:28 richard +# Login page didn't have all close tags. +# +# Revision 1.46 2001/11/03 01:26:55 richard +# possibly fix truncated base64'ed user:pass +# +# Revision 1.45 2001/11/01 22:04:37 richard +# Started work on supporting a pop3-fetching server +# Fixed bugs: +# . bug #477104 ] HTML tag error in roundup-server +# . bug #477107 ] HTTP header problem +# +# Revision 1.44 2001/10/28 23:03:08 richard +# Added more useful header to the classic schema. +# +# Revision 1.43 2001/10/24 00:01:42 richard +# More fixes to lockout logic. +# +# Revision 1.42 2001/10/23 23:56:03 richard +# HTML typo +# +# Revision 1.41 2001/10/23 23:52:35 richard +# Fixed lock-out logic, thanks Roch'e for pointing out the problems. +# +# Revision 1.40 2001/10/23 23:06:39 richard +# Some cleanup. +# +# Revision 1.39 2001/10/23 01:00:18 richard +# Re-enabled login and registration access after lopping them off via +# disabling access for anonymous users. +# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed +# a couple of bugs while I was there. Probably introduced a couple, but +# things seem to work OK at the moment. +# +# Revision 1.38 2001/10/22 03:25:01 richard +# Added configuration for: +# . anonymous user access and registration (deny/allow) +# . filter "widget" location on index page (top, bottom, both) +# Updated some documentation. +# +# Revision 1.37 2001/10/21 07:26:35 richard +# feature #473127: Filenames. I modified the file.index and htmltemplate +# source so that the filename is used in the link and the creation +# information is displayed. +# +# Revision 1.36 2001/10/21 04:44:50 richard +# bug #473124: UI inconsistency with Link fields. +# This also prompted me to fix a fairly long-standing usability issue - +# that of being able to turn off certain filters. +# +# Revision 1.35 2001/10/21 00:17:54 richard +# CGI interface view customisation section may now be hidden (patch from +# Roch'e Compaan.) +# +# Revision 1.34 2001/10/20 11:58:48 richard +# Catch errors in login - no username or password supplied. +# Fixed editing of password (Password property type) thanks Roch'e Compaan. +# +# Revision 1.33 2001/10/17 00:18:41 richard +# Manually constructing cookie headers now. +# # Revision 1.32 2001/10/16 03:36:21 richard # CGI interface wasn't handling checkboxes at all. #