Code

f47da62d77a86f9021a51876b66140cf4897a613
[roundup.git] / roundup / cgi_client.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: cgi_client.py,v 1.83 2001-12-15 23:51:01 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 class Client:
37     '''
38     A note about login
39     ------------------
41     If the user has no login cookie, then they are anonymous. There
42     are two levels of anonymous use. If there is no 'anonymous' user, there
43     is no login at all and the database is opened in read-only mode. If the
44     'anonymous' user exists, the user is logged in using that user (though
45     there is no cookie). This allows them to modify the database, and all
46     modifications are attributed to the 'anonymous' user.
49     Customisation
50     -------------
51       FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52       ANONYMOUS_ACCESS - one of 'deny', 'allow'
53       ANONYMOUS_REGISTER - one of 'deny', 'allow'
55     from the roundup class:
56       INSTANCE_NAME - defaults to 'Roundup issue tracker'
58     '''
59     FILTER_POSITION = 'bottom'       # one of 'top', 'bottom', 'top and bottom'
60     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
61     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
63     def __init__(self, instance, request, env, form=None):
64         self.instance = instance
65         self.request = request
66         self.env = env
67         self.path = env['PATH_INFO']
68         self.split_path = self.path.split('/')
70         if form is None:
71             self.form = cgi.FieldStorage(environ=env)
72         else:
73             self.form = form
74         self.headers_done = 0
75         try:
76             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
77         except ValueError:
78             # someone gave us a non-int debug level, turn it off
79             self.debug = 0
81     def getuid(self):
82         return self.db.user.lookup(self.user)
84     def header(self, headers={'Content-Type':'text/html'}):
85         '''Put up the appropriate header.
86         '''
87         if not headers.has_key('Content-Type'):
88             headers['Content-Type'] = 'text/html'
89         self.request.send_response(200)
90         for entry in headers.items():
91             self.request.send_header(*entry)
92         self.request.end_headers()
93         self.headers_done = 1
94         if self.debug:
95             self.headers_sent = headers
97     def pagehead(self, title, message=None):
98         url = self.env['SCRIPT_NAME'] + '/'
99         machine = self.env['SERVER_NAME']
100         port = self.env['SERVER_PORT']
101         if port != '80': machine = machine + ':' + port
102         base = urlparse.urlunparse(('http', machine, url, None, None, None))
103         if message is not None:
104             message = _('<div class="system-msg">%(message)s</div>')%locals()
105         else:
106             message = ''
107         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
108         user_name = self.user or ''
109         if self.user == 'admin':
110             admin_links = _(' | <a href="list_classes">Class List</a>' \
111                           ' | <a href="user">User List</a>')
112         else:
113             admin_links = ''
114         if self.user not in (None, 'anonymous'):
115             userid = self.db.user.lookup(self.user)
116             user_info = _('''
117 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
118 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
119 ''')%locals()
120         else:
121             user_info = _('<a href="login">Login</a>')
122         if self.user is not None:
123             add_links = _('''
124 | Add
125 <a href="newissue">Issue</a>,
126 <a href="newuser">User</a>
127 ''')
128         else:
129             add_links = ''
130         self.write(_('''<html><head>
131 <title>%(title)s</title>
132 <style type="text/css">%(style)s</style>
133 </head>
134 <body bgcolor=#ffffff>
135 %(message)s
136 <table width=100%% border=0 cellspacing=0 cellpadding=2>
137 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
138 <td align=right valign=bottom>%(user_name)s</td></tr>
139 <tr class="location-bar">
140 <td align=left>All
141 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
142 | Unassigned
143 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
144 %(add_links)s
145 %(admin_links)s</td>
146 <td align=right>%(user_info)s</td>
147 </table>
148 ''')%locals())
150     def pagefoot(self):
151         if self.debug:
152             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
153             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
154             keys = self.form.keys()
155             keys.sort()
156             if keys:
157                 self.write(_('<dt><b>Form entries</b></dt>'))
158                 for k in self.form.keys():
159                     v = self.form.getvalue(k, "<empty>")
160                     if type(v) is type([]):
161                         # Multiple username fields specified
162                         v = "|".join(v)
163                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
164             keys = self.headers_sent.keys()
165             keys.sort()
166             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
167             for k in keys:
168                 v = self.headers_sent[k]
169                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
170             keys = self.env.keys()
171             keys.sort()
172             self.write(_('<dt><b>CGI environment</b></dt>'))
173             for k in keys:
174                 v = self.env[k]
175                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
176             self.write('</dl></small>')
177         self.write('</body></html>')
179     def write(self, content):
180         if not self.headers_done:
181             self.header()
182         self.request.wfile.write(content)
184     def index_arg(self, arg):
185         ''' handle the args to index - they might be a list from the form
186             (ie. submitted from a form) or they might be a command-separated
187             single string (ie. manually constructed GET args)
188         '''
189         if self.form.has_key(arg):
190             arg =  self.form[arg]
191             if type(arg) == type([]):
192                 return [arg.value for arg in arg]
193             return arg.value.split(',')
194         return []
196     def index_filterspec(self, filter):
197         ''' pull the index filter spec from the form
199         Links and multilinks want to be lists - the rest are straight
200         strings.
201         '''
202         props = self.db.classes[self.classname].getprops()
203         # all the form args not starting with ':' are filters
204         filterspec = {}
205         for key in self.form.keys():
206             if key[0] == ':': continue
207             if not props.has_key(key): continue
208             if key not in filter: continue
209             prop = props[key]
210             value = self.form[key]
211             if (isinstance(prop, hyperdb.Link) or
212                     isinstance(prop, hyperdb.Multilink)):
213                 if type(value) == type([]):
214                     value = [arg.value for arg in value]
215                 else:
216                     value = value.value.split(',')
217                 l = filterspec.get(key, [])
218                 l = l + value
219                 filterspec[key] = l
220             else:
221                 filterspec[key] = value.value
222         return filterspec
224     def customization_widget(self):
225         ''' The customization widget is visible by default. The widget
226             visibility is remembered by show_customization.  Visibility
227             is not toggled if the action value is "Redisplay"
228         '''
229         if not self.form.has_key('show_customization'):
230             visible = 1
231         else:
232             visible = int(self.form['show_customization'].value)
233             if self.form.has_key('action'):
234                 if self.form['action'].value != 'Redisplay':
235                     visible = self.form['action'].value == '+'
236             
237         return visible
239     default_index_sort = ['-activity']
240     default_index_group = ['priority']
241     default_index_filter = ['status']
242     default_index_columns = ['id','activity','title','status','assignedto']
243     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
244     def index(self):
245         ''' put up an index
246         '''
247         self.classname = 'issue'
248         # see if the web has supplied us with any customisation info
249         defaults = 1
250         for key in ':sort', ':group', ':filter', ':columns':
251             if self.form.has_key(key):
252                 defaults = 0
253                 break
254         if defaults:
255             # no info supplied - use the defaults
256             sort = self.default_index_sort
257             group = self.default_index_group
258             filter = self.default_index_filter
259             columns = self.default_index_columns
260             filterspec = self.default_index_filterspec
261         else:
262             sort = self.index_arg(':sort')
263             group = self.index_arg(':group')
264             filter = self.index_arg(':filter')
265             columns = self.index_arg(':columns')
266             filterspec = self.index_filterspec(filter)
267         return self.list(columns=columns, filter=filter, group=group,
268             sort=sort, filterspec=filterspec)
270     # XXX deviates from spec - loses the '+' (that's a reserved character
271     # in URLS
272     def list(self, sort=None, group=None, filter=None, columns=None,
273             filterspec=None, show_customization=None):
274         ''' call the template index with the args
276             :sort    - sort by prop name, optionally preceeded with '-'
277                      to give descending or nothing for ascending sorting.
278             :group   - group by prop name, optionally preceeded with '-' or
279                      to sort in descending or nothing for ascending order.
280             :filter  - selects which props should be displayed in the filter
281                      section. Default is all.
282             :columns - selects the columns that should be displayed.
283                      Default is all.
285         '''
286         cn = self.classname
287         cl = self.db.classes[cn]
288         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
289             'classname': cn, 'instancename': self.INSTANCE_NAME})
290         if sort is None: sort = self.index_arg(':sort')
291         if group is None: group = self.index_arg(':group')
292         if filter is None: filter = self.index_arg(':filter')
293         if columns is None: columns = self.index_arg(':columns')
294         if filterspec is None: filterspec = self.index_filterspec(filter)
295         if show_customization is None:
296             show_customization = self.customization_widget()
298         index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
299         index.render(filterspec, filter, columns, sort, group,
300             show_customization=show_customization)
301         self.pagefoot()
303     def shownode(self, message=None):
304         ''' display an item
305         '''
306         cn = self.classname
307         cl = self.db.classes[cn]
309         # possibly perform an edit
310         keys = self.form.keys()
311         num_re = re.compile('^\d+$')
312         # don't try to set properties if the user has just logged in
313         if keys and not self.form.has_key('__login_name'):
314             try:
315                 props, changed = parsePropsFromForm(self.db, cl, self.form,
316                     self.nodeid)
317                 # make changes to the node
318                 self._changenode(props)
319                 # handle linked nodes 
320                 self._post_editnode(self.nodeid)
321                 # and some nice feedback for the user
322                 if changed:
323                     message = _('%(changes)s edited ok')%{'changes':
324                         ', '.join(changed.keys())}
325                 elif self.form.has_key('__note') and self.form['__note'].value:
326                     message = _('note added')
327                 elif self.form.has_key('__file'):
328                     message = _('file added')
329                 else:
330                     message = _('nothing changed')
331             except:
332                 self.db.rollback()
333                 s = StringIO.StringIO()
334                 traceback.print_exc(None, s)
335                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
337         # now the display
338         id = self.nodeid
339         if cl.getkey():
340             id = cl.get(id, cl.getkey())
341         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
343         nodeid = self.nodeid
345         # use the template to display the item
346         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
347         item.render(nodeid)
349         self.pagefoot()
350     showissue = shownode
351     showmsg = shownode
353     def _changenode(self, props):
354         ''' change the node based on the contents of the form
355         '''
356         cl = self.db.classes[self.classname]
357         # set status to chatting if 'unread' or 'resolved'
358         try:
359             # determine the id of 'unread','resolved' and 'chatting'
360             unread_id = self.db.status.lookup('unread')
361             resolved_id = self.db.status.lookup('resolved')
362             chatting_id = self.db.status.lookup('chatting')
363         except KeyError:
364             pass
365         else:
366             if (props['status'] == unread_id or props['status'] == resolved_id):
367                 props['status'] = chatting_id
368         # add assignedto to the nosy list
369         if props.has_key('assignedto'):
370             assignedto_id = props['assignedto']
371             if assignedto_id not in props['nosy']:
372                 props['nosy'].append(assignedto_id)
373         # create the message
374         message, files = self._handle_message()
375         if message:
376             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
377         if files:
378             props['files'] = cl.get(self.nodeid, 'files') + files
379         # make the changes
380         cl.set(self.nodeid, **props)
382     def _createnode(self):
383         ''' create a node based on the contents of the form
384         '''
385         cl = self.db.classes[self.classname]
386         props, dummy = parsePropsFromForm(self.db, cl, self.form)
388         # set status to 'unread' if not specified - a status of '- no
389         # selection -' doesn't make sense
390         if not props.has_key('status'):
391             try:
392                 unread_id = self.db.status.lookup('unread')
393             except KeyError:
394                 pass
395             else:
396                 props['status'] = unread_id
397         # add assignedto to the nosy list
398         if props.has_key('assignedto'):
399             assignedto_id = props['assignedto']
400             if props.has_key('nosy') and assignedto_id not in props['nosy']:
401                 props['nosy'].append(assignedto_id)
402             else:
403                 props['nosy'] = [assignedto_id]
404         # check for messages and files
405         message, files = self._handle_message()
406         if message:
407             props['messages'] = [message]
408         if files:
409             props['files'] = files
410         # create the node and return it's id
411         return cl.create(**props)
413     def _handle_message(self):
414         ''' generate and edit message
415         '''
416         # handle file attachments 
417         files = []
418         if self.form.has_key('__file'):
419             file = self.form['__file']
420             if file.filename:
421                 mime_type = mimetypes.guess_type(file.filename)[0]
422                 if not mime_type:
423                     mime_type = "application/octet-stream"
424                 # create the new file entry
425                 files.append(self.db.file.create(type=mime_type,
426                     name=file.filename, content=file.file.read()))
428         # we don't want to do a message if none of the following is true...
429         cn = self.classname
430         cl = self.db.classes[self.classname]
431         props = cl.getprops()
432         note = None
433         # in a nutshell, don't do anything if there's no note or there's no
434         # nosy
435         if self.form.has_key('__note'):
436             note = self.form['__note'].value
437         if not props.has_key('messages'):
438             return None, files
439         if not isinstance(props['messages'], hyperdb.Multilink):
440             return None, files
441         if not props['messages'].classname == 'msg':
442             return None, files
443         if not (self.form.has_key('nosy') or note):
444             return None, files
446         # handle the note
447         if note:
448             if '\n' in note:
449                 summary = re.split(r'\n\r?', note)[0]
450             else:
451                 summary = note
452             m = ['%s\n'%note]
453         elif not files:
454             # don't generate a useless message
455             return None, files
457         # now create the message, attaching the files
458         content = '\n'.join(m)
459         message_id = self.db.msg.create(author=self.getuid(),
460             recipients=[], date=date.Date('.'), summary=summary,
461             content=content, files=files)
463         # update the messages property
464         return message_id, files
466     def _post_editnode(self, nid):
467         '''Do the linking part of the node creation.
469            If a form element has :link or :multilink appended to it, its
470            value specifies a node designator and the property on that node
471            to add _this_ node to as a link or multilink.
473            This is typically used on, eg. the file upload page to indicated
474            which issue to link the file to.
476            TODO: I suspect that this and newfile will go away now that
477            there's the ability to upload a file using the issue __file form
478            element!
479         '''
480         cn = self.classname
481         cl = self.db.classes[cn]
482         # link if necessary
483         keys = self.form.keys()
484         for key in keys:
485             if key == ':multilink':
486                 value = self.form[key].value
487                 if type(value) != type([]): value = [value]
488                 for value in value:
489                     designator, property = value.split(':')
490                     link, nodeid = roundupdb.splitDesignator(designator)
491                     link = self.db.classes[link]
492                     value = link.get(nodeid, property)
493                     value.append(nid)
494                     link.set(nodeid, **{property: value})
495             elif key == ':link':
496                 value = self.form[key].value
497                 if type(value) != type([]): value = [value]
498                 for value in value:
499                     designator, property = value.split(':')
500                     link, nodeid = roundupdb.splitDesignator(designator)
501                     link = self.db.classes[link]
502                     link.set(nodeid, **{property: nid})
504     def newnode(self, message=None):
505         ''' Add a new node to the database.
506         
507         The form works in two modes: blank form and submission (that is,
508         the submission goes to the same URL). **Eventually this means that
509         the form will have previously entered information in it if
510         submission fails.
512         The new node will be created with the properties specified in the
513         form submission. For multilinks, multiple form entries are handled,
514         as are prop=value,value,value. You can't mix them though.
516         If the new node is to be referenced from somewhere else immediately
517         (ie. the new node is a file that is to be attached to a support
518         issue) then supply one of these arguments in addition to the usual
519         form entries:
520             :link=designator:property
521             :multilink=designator:property
522         ... which means that once the new node is created, the "property"
523         on the node given by "designator" should now reference the new
524         node's id. The node id will be appended to the multilink.
525         '''
526         cn = self.classname
527         cl = self.db.classes[cn]
529         # possibly perform a create
530         keys = self.form.keys()
531         if [i for i in keys if i[0] != ':']:
532             props = {}
533             try:
534                 nid = self._createnode()
535                 # handle linked nodes 
536                 self._post_editnode(nid)
537                 # and some nice feedback for the user
538                 message = _('%(classname)s created ok')%{'classname': cn}
539             except:
540                 self.db.rollback()
541                 s = StringIO.StringIO()
542                 traceback.print_exc(None, s)
543                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
544         self.pagehead(_('New %(classname)s')%{'classname':
545              self.classname.capitalize()}, message)
547         # call the template
548         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
549             self.classname)
550         newitem.render(self.form)
552         self.pagefoot()
553     newissue = newnode
555     def newuser(self, message=None):
556         ''' Add a new user to the database.
558             Don't do any of the message or file handling, just create the node.
559         '''
560         cn = self.classname
561         cl = self.db.classes[cn]
563         # possibly perform a create
564         keys = self.form.keys()
565         if [i for i in keys if i[0] != ':']:
566             try:
567                 props, dummy = parsePropsFromForm(self.db, cl, self.form)
568                 nid = cl.create(**props)
569                 # handle linked nodes 
570                 self._post_editnode(nid)
571                 # and some nice feedback for the user
572                 message = _('%(classname)s created ok')%{'classname': cn}
573             except:
574                 self.db.rollback()
575                 s = StringIO.StringIO()
576                 traceback.print_exc(None, s)
577                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
578         self.pagehead(_('New %(classname)s')%{'classname':
579              self.classname.capitalize()}, message)
581         # call the template
582         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
583             self.classname)
584         newitem.render(self.form)
586         self.pagefoot()
588     def newfile(self, message=None):
589         ''' Add a new file to the database.
590         
591         This form works very much the same way as newnode - it just has a
592         file upload.
593         '''
594         cn = self.classname
595         cl = self.db.classes[cn]
597         # possibly perform a create
598         keys = self.form.keys()
599         if [i for i in keys if i[0] != ':']:
600             try:
601                 file = self.form['content']
602                 mime_type = mimetypes.guess_type(file.filename)[0]
603                 if not mime_type:
604                     mime_type = "application/octet-stream"
605                 # save the file
606                 nid = cl.create(content=file.file.read(), type=mime_type,
607                     name=file.filename)
608                 # handle linked nodes
609                 self._post_editnode(nid)
610                 # and some nice feedback for the user
611                 message = _('%(classname)s created ok')%{'classname': cn}
612             except:
613                 self.db.rollback()
614                 s = StringIO.StringIO()
615                 traceback.print_exc(None, s)
616                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
618         self.pagehead(_('New %(classname)s')%{'classname':
619              self.classname.capitalize()}, message)
620         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
621             self.classname)
622         newitem.render(self.form)
623         self.pagefoot()
625     def showuser(self, message=None):
626         '''Display a user page for editing. Make sure the user is allowed
627             to edit this node, and also check for password changes.
628         '''
629         if self.user == 'anonymous':
630             raise Unauthorised
632         user = self.db.user
634         # get the username of the node being edited
635         node_user = user.get(self.nodeid, 'username')
637         if self.user not in ('admin', node_user):
638             raise Unauthorised
640         #
641         # perform any editing
642         #
643         keys = self.form.keys()
644         num_re = re.compile('^\d+$')
645         if keys:
646             try:
647                 props, changed = parsePropsFromForm(self.db, user, self.form,
648                     self.nodeid)
649                 set_cookie = 0
650                 if self.nodeid == self.getuid() and changed.has_key('password'):
651                     password = self.form['password'].value.strip()
652                     if password:
653                         set_cookie = password
654                     else:
655                         # no password was supplied - don't change it
656                         del props['password']
657                         del changed['password']
658                 user.set(self.nodeid, **props)
659                 # and some feedback for the user
660                 message = _('%(changes)s edited ok')%{'changes':
661                     ', '.join(changed.keys())}
662             except:
663                 self.db.rollback()
664                 s = StringIO.StringIO()
665                 traceback.print_exc(None, s)
666                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
667         else:
668             set_cookie = 0
670         # fix the cookie if the password has changed
671         if set_cookie:
672             self.set_cookie(self.user, set_cookie)
674         #
675         # now the display
676         #
677         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
679         # use the template to display the item
680         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
681         item.render(self.nodeid)
682         self.pagefoot()
684     def showfile(self):
685         ''' display a file
686         '''
687         nodeid = self.nodeid
688         cl = self.db.file
689         mime_type = cl.get(nodeid, 'type')
690         if mime_type == 'message/rfc822':
691             mime_type = 'text/plain'
692         self.header(headers={'Content-Type': mime_type})
693         self.write(cl.get(nodeid, 'content'))
695     def classes(self, message=None):
696         ''' display a list of all the classes in the database
697         '''
698         if self.user == 'admin':
699             self.pagehead(_('Table of classes'), message)
700             classnames = self.db.classes.keys()
701             classnames.sort()
702             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
703             for cn in classnames:
704                 cl = self.db.getclass(cn)
705                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
706                 for key, value in cl.properties.items():
707                     if value is None: value = ''
708                     else: value = str(value)
709                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
710                         key, cgi.escape(value)))
711             self.write('</table>')
712             self.pagefoot()
713         else:
714             raise Unauthorised
716     def login(self, message=None, newuser_form=None, action='index'):
717         '''Display a login page.
718         '''
719         self.pagehead(_('Login to roundup'), message)
720         self.write(_('''
721 <table>
722 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
723 <form action="login_action" method=POST>
724 <input type="hidden" name="__destination_url" value="%(action)s">
725 <tr><td align=right>Login name: </td>
726     <td><input name="__login_name"></td></tr>
727 <tr><td align=right>Password: </td>
728     <td><input type="password" name="__login_password"></td></tr>
729 <tr><td></td>
730     <td><input type="submit" value="Log In"></td></tr>
731 </form>
732 ''')%locals())
733         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
734             self.write('</table>')
735             self.pagefoot()
736             return
737         values = {'realname': '', 'organisation': '', 'address': '',
738             'phone': '', 'username': '', 'password': '', 'confirm': '',
739             'action': action}
740         if newuser_form is not None:
741             for key in newuser_form.keys():
742                 values[key] = newuser_form[key].value
743         self.write(_('''
744 <p>
745 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
746 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
747 <form action="newuser_action" method=POST>
748 <input type="hidden" name="__destination_url" value="%(action)s">
749 <tr><td align=right><em>Name: </em></td>
750     <td><input name="realname" value="%(realname)s"></td></tr>
751 <tr><td align=right><em>Organisation: </em></td>
752     <td><input name="organisation" value="%(organisation)s"></td></tr>
753 <tr><td align=right>E-Mail Address: </td>
754     <td><input name="address" value="%(address)s"></td></tr>
755 <tr><td align=right><em>Phone: </em></td>
756     <td><input name="phone" value="%(phone)s"></td></tr>
757 <tr><td align=right>Preferred Login name: </td>
758     <td><input name="username" value="%(username)s"></td></tr>
759 <tr><td align=right>Password: </td>
760     <td><input type="password" name="password" value="%(password)s"></td></tr>
761 <tr><td align=right>Password Again: </td>
762     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
763 <tr><td></td>
764     <td><input type="submit" value="Register"></td></tr>
765 </form>
766 </table>
767 ''')%values)
768         self.pagefoot()
770     def login_action(self, message=None):
771         '''Attempt to log a user in and set the cookie
773         returns 0 if a page is generated as a result of this call, and
774         1 if not (ie. the login is successful
775         '''
776         if not self.form.has_key('__login_name'):
777             self.login(message=_('Username required'))
778             return 0
779         self.user = self.form['__login_name'].value
780         if self.form.has_key('__login_password'):
781             password = self.form['__login_password'].value
782         else:
783             password = ''
784         # make sure the user exists
785         try:
786             uid = self.db.user.lookup(self.user)
787         except KeyError:
788             name = self.user
789             self.make_user_anonymous()
790             action = self.form['__destination_url'].value
791             self.login(message=_('No such user "%(name)s"')%locals(),
792                 action=action)
793             return 0
795         # and that the password is correct
796         pw = self.db.user.get(uid, 'password')
797         if password != pw:
798             self.make_user_anonymous()
799             action = self.form['__destination_url'].value
800             self.login(message=_('Incorrect password'), action=action)
801             return 0
803         self.set_cookie(self.user, password)
804         return 1
806     def newuser_action(self, message=None):
807         '''Attempt to create a new user based on the contents of the form
808         and then set the cookie.
810         return 1 on successful login
811         '''
812         # re-open the database as "admin"
813         self.db = self.instance.open('admin')
815         # TODO: pre-check the required fields and username key property
816         cl = self.db.user
817         try:
818             props, dummy = parsePropsFromForm(self.db, cl, self.form)
819             uid = cl.create(**props)
820         except ValueError, message:
821             action = self.form['__destination_url'].value
822             self.login(message, action=action)
823             return 0
824         self.user = cl.get(uid, 'username')
825         password = cl.get(uid, 'password')
826         self.set_cookie(self.user, self.form['password'].value)
827         return 1
829     def set_cookie(self, user, password):
830         # construct the cookie
831         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
832         if user[-1] == '=':
833           if user[-2] == '=':
834             user = user[:-2]
835           else:
836             user = user[:-1]
837         expire = Cookie._getdate(86400*365)
838         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
839         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
840             user, expire, path)})
842     def make_user_anonymous(self):
843         # make us anonymous if we can
844         try:
845             self.db.user.lookup('anonymous')
846             self.user = 'anonymous'
847         except KeyError:
848             self.user = None
850     def logout(self, message=None):
851         self.make_user_anonymous()
852         # construct the logout cookie
853         now = Cookie._getdate()
854         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
855         self.header({'Set-Cookie':
856             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
857             path)})
858         self.login()
861     def main(self):
862         '''Wrap the database accesses so we can close the database cleanly
863         '''
864         # determine the uid to use
865         self.db = self.instance.open('admin')
866         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
867         user = 'anonymous'
868         if (cookie.has_key('roundup_user') and
869                 cookie['roundup_user'].value != 'deleted'):
870             cookie = cookie['roundup_user'].value
871             if len(cookie)%4:
872               cookie = cookie + '='*(4-len(cookie)%4)
873             try:
874                 user, password = binascii.a2b_base64(cookie).split(':')
875             except (TypeError, binascii.Error, binascii.Incomplete):
876                 # damaged cookie!
877                 user, password = 'anonymous', ''
879             # make sure the user exists
880             try:
881                 uid = self.db.user.lookup(user)
882                 # now validate the password
883                 if password != self.db.user.get(uid, 'password'):
884                     user = 'anonymous'
885             except KeyError:
886                 user = 'anonymous'
888         # make sure the anonymous user is valid if we're using it
889         if user == 'anonymous':
890             self.make_user_anonymous()
891         else:
892             self.user = user
894         # re-open the database for real, using the user
895         self.db = self.instance.open(self.user)
897         # now figure which function to call
898         path = self.split_path
900         # default action to index if the path has no information in it
901         if not path or path[0] in ('', 'index'):
902             action = 'index'
903         else:
904             action = path[0]
906         # Everthing ignores path[1:]
907         #  - The file download link generator actually relies on this - it
908         #    appends the name of the file to the URL so the download file name
909         #    is correct, but doesn't actually use it.
911         # everyone is allowed to try to log in
912         if action == 'login_action':
913             # try to login
914             if not self.login_action():
915                 return
916             # figure the resulting page
917             action = self.form['__destination_url'].value
918             if not action:
919                 action = 'index'
920             self.do_action(action)
921             return
923         # allow anonymous people to register
924         if action == 'newuser_action':
925             # if we don't have a login and anonymous people aren't allowed to
926             # register, then spit up the login form
927             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
928                 if action == 'login':
929                     self.login()         # go to the index after login
930                 else:
931                     self.login(action=action)
932                 return
933             # try to add the user
934             if not self.newuser_action():
935                 return
936             # figure the resulting page
937             action = self.form['__destination_url'].value
938             if not action:
939                 action = 'index'
941         # no login or registration, make sure totally anonymous access is OK
942         elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
943             if action == 'login':
944                 self.login()             # go to the index after login
945             else:
946                 self.login(action=action)
947             return
949         # just a regular action
950         self.do_action(action)
952         # commit all changes to the database
953         self.db.commit()
955     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
956             nre=re.compile(r'new(\w+)')):
957         '''Figure the user's action and do it.
958         '''
959         # here be the "normal" functionality
960         if action == 'index':
961             self.index()
962             return
963         if action == 'list_classes':
964             self.classes()
965             return
966         if action == 'login':
967             self.login()
968             return
969         if action == 'logout':
970             self.logout()
971             return
972         m = dre.match(action)
973         if m:
974             self.classname = m.group(1)
975             self.nodeid = m.group(2)
976             try:
977                 cl = self.db.classes[self.classname]
978             except KeyError:
979                 raise NotFound
980             try:
981                 cl.get(self.nodeid, 'id')
982             except IndexError:
983                 raise NotFound
984             try:
985                 func = getattr(self, 'show%s'%self.classname)
986             except AttributeError:
987                 raise NotFound
988             func()
989             return
990         m = nre.match(action)
991         if m:
992             self.classname = m.group(1)
993             try:
994                 func = getattr(self, 'new%s'%self.classname)
995             except AttributeError:
996                 raise NotFound
997             func()
998             return
999         self.classname = action
1000         try:
1001             self.db.getclass(self.classname)
1002         except KeyError:
1003             raise NotFound
1004         self.list()
1007 class ExtendedClient(Client): 
1008     '''Includes pages and page heading information that relate to the
1009        extended schema.
1010     ''' 
1011     showsupport = Client.shownode
1012     showtimelog = Client.shownode
1013     newsupport = Client.newnode
1014     newtimelog = Client.newnode
1016     default_index_sort = ['-activity']
1017     default_index_group = ['priority']
1018     default_index_filter = ['status']
1019     default_index_columns = ['activity','status','title','assignedto']
1020     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1022     def pagehead(self, title, message=None):
1023         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1024         machine = self.env['SERVER_NAME']
1025         port = self.env['SERVER_PORT']
1026         if port != '80': machine = machine + ':' + port
1027         base = urlparse.urlunparse(('http', machine, url, None, None, None))
1028         if message is not None:
1029             message = _('<div class="system-msg">%(message)s</div>')%locals()
1030         else:
1031             message = ''
1032         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
1033         user_name = self.user or ''
1034         if self.user == 'admin':
1035             admin_links = _(' | <a href="list_classes">Class List</a>' \
1036                           ' | <a href="user">User List</a>')
1037         else:
1038             admin_links = ''
1039         if self.user not in (None, 'anonymous'):
1040             userid = self.db.user.lookup(self.user)
1041             user_info = _('''
1042 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
1043 <a href="support?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
1044 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1045 ''')%locals()
1046         else:
1047             user_info = _('<a href="login">Login</a>')
1048         if self.user is not None:
1049             add_links = _('''
1050 | Add
1051 <a href="newissue">Issue</a>,
1052 <a href="newsupport">Support</a>,
1053 <a href="newuser">User</a>
1054 ''')
1055         else:
1056             add_links = ''
1057         self.write(_('''<html><head>
1058 <title>%(title)s</title>
1059 <style type="text/css">%(style)s</style>
1060 </head>
1061 <body bgcolor=#ffffff>
1062 %(message)s
1063 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1064 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1065 <td align=right valign=bottom>%(user_name)s</td></tr>
1066 <tr class="location-bar">
1067 <td align=left>All
1068 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
1069 <a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
1070 | Unassigned
1071 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
1072 <a href="support?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
1073 %(add_links)s
1074 %(admin_links)s</td>
1075 <td align=right>%(user_info)s</td>
1076 </table>
1077 ''')%locals())
1079 def parsePropsFromForm(db, cl, form, nodeid=0):
1080     '''Pull properties for the given class out of the form.
1081     '''
1082     props = {}
1083     changed = {}
1084     keys = form.keys()
1085     num_re = re.compile('^\d+$')
1086     for key in keys:
1087         if not cl.properties.has_key(key):
1088             continue
1089         proptype = cl.properties[key]
1090         if isinstance(proptype, hyperdb.String):
1091             value = form[key].value.strip()
1092         elif isinstance(proptype, hyperdb.Password):
1093             value = password.Password(form[key].value.strip())
1094         elif isinstance(proptype, hyperdb.Date):
1095             value = date.Date(form[key].value.strip())
1096         elif isinstance(proptype, hyperdb.Interval):
1097             value = date.Interval(form[key].value.strip())
1098         elif isinstance(proptype, hyperdb.Link):
1099             value = form[key].value.strip()
1100             # see if it's the "no selection" choice
1101             if value == '-1':
1102                 # don't set this property
1103                 continue
1104             else:
1105                 # handle key values
1106                 link = cl.properties[key].classname
1107                 if not num_re.match(value):
1108                     try:
1109                         value = db.classes[link].lookup(value)
1110                     except KeyError:
1111                         raise ValueError, _('property "%(propname)s": '
1112                             '%(value)s not a %(classname)s')%{'propname':key, 
1113                             'value': value, 'classname': link}
1114         elif isinstance(proptype, hyperdb.Multilink):
1115             value = form[key]
1116             if type(value) != type([]):
1117                 value = [i.strip() for i in value.value.split(',')]
1118             else:
1119                 value = [i.value.strip() for i in value]
1120             link = cl.properties[key].classname
1121             l = []
1122             for entry in map(str, value):
1123                 if entry == '': continue
1124                 if not num_re.match(entry):
1125                     try:
1126                         entry = db.classes[link].lookup(entry)
1127                     except KeyError:
1128                         raise ValueError, _('property "%(propname)s": '
1129                             '"%(value)s" not an entry of %(classname)s')%{
1130                             'propname':key, 'value': entry, 'classname': link}
1131                 l.append(entry)
1132             l.sort()
1133             value = l
1134         props[key] = value
1136         # get the old value
1137         if nodeid:
1138             try:
1139                 existing = cl.get(nodeid, key)
1140             except KeyError:
1141                 # this might be a new property for which there is no existing
1142                 # value
1143                 if not cl.properties.has_key(key): raise
1145         # if changed, set it
1146         if nodeid and value != existing:
1147             changed[key] = value
1148             props[key] = value
1149     return props, changed
1152 # $Log: not supported by cvs2svn $
1153 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1154 #  . Modified cgi interface to change properties only once all changes are
1155 #    collected, files created and messages generated.
1156 #  . Moved generation of change note to nosyreactors.
1157 #  . We now check for changes to "assignedto" to ensure it's added to the
1158 #    nosy list.
1160 # Revision 1.81  2001/12/12 23:55:00  richard
1161 # Fixed some problems with user editing
1163 # Revision 1.80  2001/12/12 23:27:14  richard
1164 # Added a Zope frontend for roundup.
1166 # Revision 1.79  2001/12/10 22:20:01  richard
1167 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1168 # where possible, only replacing methods where the db is opened (it uses the
1169 # btree opener specifically.)
1170 # Also cleaned up some change note generation.
1171 # Made the backends package work with pydoc too.
1173 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1174 # Fixed small bug that prevented adding issues through the web.
1176 # Revision 1.77  2001/12/06 22:48:29  richard
1177 # files multilink was being nuked in post_edit_node
1179 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1180 # Removed generation of change note from "sendmessage" in roundupdb.py.
1181 # The change note is now generated when the message is created.
1183 # Revision 1.75  2001/12/04 01:25:08  richard
1184 # Added some rollbacks where we were catching exceptions that would otherwise
1185 # have stopped committing.
1187 # Revision 1.74  2001/12/02 05:06:16  richard
1188 # . We now use weakrefs in the Classes to keep the database reference, so
1189 #   the close() method on the database is no longer needed.
1190 #   I bumped the minimum python requirement up to 2.1 accordingly.
1191 # . #487480 ] roundup-server
1192 # . #487476 ] INSTALL.txt
1194 # I also cleaned up the change message / post-edit stuff in the cgi client.
1195 # There's now a clearly marked "TODO: append the change note" where I believe
1196 # the change note should be added there. The "changes" list will obviously
1197 # have to be modified to be a dict of the changes, or somesuch.
1199 # More testing needed.
1201 # Revision 1.73  2001/12/01 07:17:50  richard
1202 # . We now have basic transaction support! Information is only written to
1203 #   the database when the commit() method is called. Only the anydbm
1204 #   backend is modified in this way - neither of the bsddb backends have been.
1205 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1206 #   doesn't have a commit command, so interactive users can't commit...)
1207 # . Fixed login/registration forwarding the user to the right page (or not,
1208 #   on a failure)
1210 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1211 # Links in page header are now consistent with default sort order.
1213 # Fixed bugs:
1214 #     - When login failed the list of issues were still rendered.
1215 #     - User was redirected to index page and not to his destination url
1216 #       if his first login attempt failed.
1218 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1219 # Property changes are now completely traceable, whether changes are
1220 # made through the web or by email
1222 # Revision 1.70  2001/11/30 00:06:29  richard
1223 # Converted roundup/cgi_client.py to use _()
1224 # Added the status file, I18N_PROGRESS.txt
1226 # Revision 1.69  2001/11/29 23:19:51  richard
1227 # Removed the "This issue has been edited through the web" when a valid
1228 # change note is supplied.
1230 # Revision 1.68  2001/11/29 04:57:23  richard
1231 # a little comment
1233 # Revision 1.67  2001/11/28 21:55:35  richard
1234 #  . login_action and newuser_action return values were being ignored
1235 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1236 #    gateway.
1237 #  (also a minor cleanup in hyperdb)
1239 # Revision 1.66  2001/11/27 03:00:50  richard
1240 # couple of bugfixes from latest patch integration
1242 # Revision 1.65  2001/11/26 23:00:53  richard
1243 # This config stuff is getting to be a real mess...
1245 # Revision 1.64  2001/11/26 22:56:35  richard
1246 # typo
1248 # Revision 1.63  2001/11/26 22:55:56  richard
1249 # Feature:
1250 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1251 #    the instance.
1252 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1253 #    signature info in e-mails.
1254 #  . Some more flexibility in the mail gateway and more error handling.
1255 #  . Login now takes you to the page you back to the were denied access to.
1257 # Fixed:
1258 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1260 # Revision 1.62  2001/11/24 00:45:42  jhermann
1261 # typeof() instead of type(): avoid clash with database field(?) "type"
1263 # Fixes this traceback:
1265 # Traceback (most recent call last):
1266 #   File "roundup\cgi_client.py", line 535, in newnode
1267 #     self._post_editnode(nid)
1268 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1269 #     if type(value) != type([]): value = [value]
1270 # UnboundLocalError: local variable 'type' referenced before assignment
1272 # Revision 1.61  2001/11/22 15:46:42  jhermann
1273 # Added module docstrings to all modules.
1275 # Revision 1.60  2001/11/21 22:57:28  jhermann
1276 # Added dummy hooks for I18N and some preliminary (test) markup of
1277 # translatable messages
1279 # Revision 1.59  2001/11/21 03:21:13  richard
1280 # oops
1282 # Revision 1.58  2001/11/21 03:11:28  richard
1283 # Better handling of new properties.
1285 # Revision 1.57  2001/11/15 10:24:27  richard
1286 # handle the case where there is no file attached
1288 # Revision 1.56  2001/11/14 21:35:21  richard
1289 #  . users may attach files to issues (and support in ext) through the web now
1291 # Revision 1.55  2001/11/07 02:34:06  jhermann
1292 # Handling of damaged login cookies
1294 # Revision 1.54  2001/11/07 01:16:12  richard
1295 # Remove the '=' padding from cookie value so quoting isn't an issue.
1297 # Revision 1.53  2001/11/06 23:22:05  jhermann
1298 # More IE fixes: it does not like quotes around cookie values; in the
1299 # hope this does not break anything for other browser; if it does, we
1300 # need to check HTTP_USER_AGENT
1302 # Revision 1.52  2001/11/06 23:11:22  jhermann
1303 # Fixed debug output in page footer; added expiry date to the login cookie
1304 # (expires 1 year in the future) to prevent probs with certain versions
1305 # of IE
1307 # Revision 1.51  2001/11/06 22:00:34  jhermann
1308 # Get debug level from ROUNDUP_DEBUG env var
1310 # Revision 1.50  2001/11/05 23:45:40  richard
1311 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1312 # Also made it present nicer error messages (not tracebacks).
1314 # Revision 1.49  2001/11/04 03:07:12  richard
1315 # Fixed various cookie-related bugs:
1316 #  . bug #477685 ] base64.decodestring breaks
1317 #  . bug #477837 ] lynx does not like the cookie
1318 #  . bug #477892 ] Password edit doesn't fix login cookie
1319 # Also closed a security hole - a logged-in user could edit another user's
1320 # details.
1322 # Revision 1.48  2001/11/03 01:30:18  richard
1323 # Oops. uses pagefoot now.
1325 # Revision 1.47  2001/11/03 01:29:28  richard
1326 # Login page didn't have all close tags.
1328 # Revision 1.46  2001/11/03 01:26:55  richard
1329 # possibly fix truncated base64'ed user:pass
1331 # Revision 1.45  2001/11/01 22:04:37  richard
1332 # Started work on supporting a pop3-fetching server
1333 # Fixed bugs:
1334 #  . bug #477104 ] HTML tag error in roundup-server
1335 #  . bug #477107 ] HTTP header problem
1337 # Revision 1.44  2001/10/28 23:03:08  richard
1338 # Added more useful header to the classic schema.
1340 # Revision 1.43  2001/10/24 00:01:42  richard
1341 # More fixes to lockout logic.
1343 # Revision 1.42  2001/10/23 23:56:03  richard
1344 # HTML typo
1346 # Revision 1.41  2001/10/23 23:52:35  richard
1347 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1349 # Revision 1.40  2001/10/23 23:06:39  richard
1350 # Some cleanup.
1352 # Revision 1.39  2001/10/23 01:00:18  richard
1353 # Re-enabled login and registration access after lopping them off via
1354 # disabling access for anonymous users.
1355 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1356 # a couple of bugs while I was there. Probably introduced a couple, but
1357 # things seem to work OK at the moment.
1359 # Revision 1.38  2001/10/22 03:25:01  richard
1360 # Added configuration for:
1361 #  . anonymous user access and registration (deny/allow)
1362 #  . filter "widget" location on index page (top, bottom, both)
1363 # Updated some documentation.
1365 # Revision 1.37  2001/10/21 07:26:35  richard
1366 # feature #473127: Filenames. I modified the file.index and htmltemplate
1367 #  source so that the filename is used in the link and the creation
1368 #  information is displayed.
1370 # Revision 1.36  2001/10/21 04:44:50  richard
1371 # bug #473124: UI inconsistency with Link fields.
1372 #    This also prompted me to fix a fairly long-standing usability issue -
1373 #    that of being able to turn off certain filters.
1375 # Revision 1.35  2001/10/21 00:17:54  richard
1376 # CGI interface view customisation section may now be hidden (patch from
1377 #  Roch'e Compaan.)
1379 # Revision 1.34  2001/10/20 11:58:48  richard
1380 # Catch errors in login - no username or password supplied.
1381 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1383 # Revision 1.33  2001/10/17 00:18:41  richard
1384 # Manually constructing cookie headers now.
1386 # Revision 1.32  2001/10/16 03:36:21  richard
1387 # CGI interface wasn't handling checkboxes at all.
1389 # Revision 1.31  2001/10/14 10:55:00  richard
1390 # Handle empty strings in HTML template Link function
1392 # Revision 1.30  2001/10/09 07:38:58  richard
1393 # Pushed the base code for the extended schema CGI interface back into the
1394 # code cgi_client module so that future updates will be less painful.
1395 # Also removed a debugging print statement from cgi_client.
1397 # Revision 1.29  2001/10/09 07:25:59  richard
1398 # Added the Password property type. See "pydoc roundup.password" for
1399 # implementation details. Have updated some of the documentation too.
1401 # Revision 1.28  2001/10/08 00:34:31  richard
1402 # Change message was stuffing up for multilinks with no key property.
1404 # Revision 1.27  2001/10/05 02:23:24  richard
1405 #  . roundup-admin create now prompts for property info if none is supplied
1406 #    on the command-line.
1407 #  . hyperdb Class getprops() method may now return only the mutable
1408 #    properties.
1409 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1410 #    now support anonymous user access (read-only, unless there's an
1411 #    "anonymous" user, in which case write access is permitted). Login
1412 #    handling has been moved into cgi_client.Client.main()
1413 #  . The "extended" schema is now the default in roundup init.
1414 #  . The schemas have had their page headings modified to cope with the new
1415 #    login handling. Existing installations should copy the interfaces.py
1416 #    file from the roundup lib directory to their instance home.
1417 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1418 #    Ping - has been removed.
1419 #  . Fixed a whole bunch of places in the CGI interface where we should have
1420 #    been returning Not Found instead of throwing an exception.
1421 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1422 #    an item now throws an exception.
1424 # Revision 1.26  2001/09/12 08:31:42  richard
1425 # handle cases where mime type is not guessable
1427 # Revision 1.25  2001/08/29 05:30:49  richard
1428 # change messages weren't being saved when there was no-one on the nosy list.
1430 # Revision 1.24  2001/08/29 04:49:39  richard
1431 # didn't clean up fully after debugging :(
1433 # Revision 1.23  2001/08/29 04:47:18  richard
1434 # Fixed CGI client change messages so they actually include the properties
1435 # changed (again).
1437 # Revision 1.22  2001/08/17 00:08:10  richard
1438 # reverted back to sending messages always regardless of who is doing the web
1439 # edit. change notes weren't being saved. bleah. hackish.
1441 # Revision 1.21  2001/08/15 23:43:18  richard
1442 # Fixed some isFooTypes that I missed.
1443 # Refactored some code in the CGI code.
1445 # Revision 1.20  2001/08/12 06:32:36  richard
1446 # using isinstance(blah, Foo) now instead of isFooType
1448 # Revision 1.19  2001/08/07 00:24:42  richard
1449 # stupid typo
1451 # Revision 1.18  2001/08/07 00:15:51  richard
1452 # Added the copyright/license notice to (nearly) all files at request of
1453 # Bizar Software.
1455 # Revision 1.17  2001/08/02 06:38:17  richard
1456 # Roundupdb now appends "mailing list" information to its messages which
1457 # include the e-mail address and web interface address. Templates may
1458 # override this in their db classes to include specific information (support
1459 # instructions, etc).
1461 # Revision 1.16  2001/08/02 05:55:25  richard
1462 # Web edit messages aren't sent to the person who did the edit any more. No
1463 # message is generated if they are the only person on the nosy list.
1465 # Revision 1.15  2001/08/02 00:34:10  richard
1466 # bleah syntax error
1468 # Revision 1.14  2001/08/02 00:26:16  richard
1469 # Changed the order of the information in the message generated by web edits.
1471 # Revision 1.13  2001/07/30 08:12:17  richard
1472 # Added time logging and file uploading to the templates.
1474 # Revision 1.12  2001/07/30 06:26:31  richard
1475 # Added some documentation on how the newblah works.
1477 # Revision 1.11  2001/07/30 06:17:45  richard
1478 # Features:
1479 #  . Added ability for cgi newblah forms to indicate that the new node
1480 #    should be linked somewhere.
1481 # Fixed:
1482 #  . Fixed the agument handling for the roundup-admin find command.
1483 #  . Fixed handling of summary when no note supplied for newblah. Again.
1484 #  . Fixed detection of no form in htmltemplate Field display.
1486 # Revision 1.10  2001/07/30 02:37:34  richard
1487 # Temporary measure until we have decent schema migration...
1489 # Revision 1.9  2001/07/30 01:25:07  richard
1490 # Default implementation is now "classic" rather than "extended" as one would
1491 # expect.
1493 # Revision 1.8  2001/07/29 08:27:40  richard
1494 # Fixed handling of passed-in values in form elements (ie. during a
1495 # drill-down)
1497 # Revision 1.7  2001/07/29 07:01:39  richard
1498 # Added vim command to all source so that we don't get no steenkin' tabs :)
1500 # Revision 1.6  2001/07/29 04:04:00  richard
1501 # Moved some code around allowing for subclassing to change behaviour.
1503 # Revision 1.5  2001/07/28 08:16:52  richard
1504 # New issue form handles lack of note better now.
1506 # Revision 1.4  2001/07/28 00:34:34  richard
1507 # Fixed some non-string node ids.
1509 # Revision 1.3  2001/07/23 03:56:30  richard
1510 # oops, missed a config removal
1512 # Revision 1.2  2001/07/22 12:09:32  richard
1513 # Final commit of Grande Splite
1515 # Revision 1.1  2001/07/22 11:58:35  richard
1516 # More Grande Splite
1519 # vim: set filetype=python ts=4 sw=4 et si