Code

*mutter* stupid cutnpaste
[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.89 2002-01-07 20:24:45 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, random
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                           ' | <a href="newuser">Add User</a>')
113         else:
114             admin_links = ''
115         if self.user not in (None, 'anonymous'):
116             userid = self.db.user.lookup(self.user)
117             user_info = _('''
118 <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> |
119 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
120 ''')%locals()
121         else:
122             user_info = _('<a href="login">Login</a>')
123         if self.user is not None:
124             add_links = _('''
125 | Add
126 <a href="newissue">Issue</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             current_status = cl.get(self.nodeid, 'status')
364         except KeyError:
365             pass
366         else:
367             if (props['status'] == unread_id or props['status'] == resolved_id and current_status == resolved_id):
368                 props['status'] = chatting_id
369         # add assignedto to the nosy list
370         if props.has_key('assignedto'):
371             assignedto_id = props['assignedto']
372             if assignedto_id not in props['nosy']:
373                 props['nosy'].append(assignedto_id)
374         # create the message
375         message, files = self._handle_message()
376         if message:
377             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
378         if files:
379             props['files'] = cl.get(self.nodeid, 'files') + files
380         # make the changes
381         cl.set(self.nodeid, **props)
383     def _createnode(self):
384         ''' create a node based on the contents of the form
385         '''
386         cl = self.db.classes[self.classname]
387         props, dummy = parsePropsFromForm(self.db, cl, self.form)
389         # set status to 'unread' if not specified - a status of '- no
390         # selection -' doesn't make sense
391         if not props.has_key('status'):
392             try:
393                 unread_id = self.db.status.lookup('unread')
394             except KeyError:
395                 pass
396             else:
397                 props['status'] = unread_id
398         # add assignedto to the nosy list
399         if props.has_key('assignedto'):
400             assignedto_id = props['assignedto']
401             if props.has_key('nosy') and assignedto_id not in props['nosy']:
402                 props['nosy'].append(assignedto_id)
403             else:
404                 props['nosy'] = [assignedto_id]
405         # check for messages and files
406         message, files = self._handle_message()
407         if message:
408             props['messages'] = [message]
409         if files:
410             props['files'] = files
411         # create the node and return it's id
412         return cl.create(**props)
414     def _handle_message(self):
415         ''' generate and edit message
416         '''
417         # handle file attachments 
418         files = []
419         if self.form.has_key('__file'):
420             file = self.form['__file']
421             if file.filename:
422                 filename = file.filename.split('\\')[-1]
423                 mime_type = mimetypes.guess_type(filename)[0]
424                 if not mime_type:
425                     mime_type = "application/octet-stream"
426                 # create the new file entry
427                 files.append(self.db.file.create(type=mime_type,
428                     name=filename, content=file.file.read()))
430         # we don't want to do a message if none of the following is true...
431         cn = self.classname
432         cl = self.db.classes[self.classname]
433         props = cl.getprops()
434         note = None
435         # in a nutshell, don't do anything if there's no note or there's no
436         # NOSY
437         if self.form.has_key('__note'):
438             note = self.form['__note'].value
439         if not props.has_key('messages'):
440             return None, files
441         if not isinstance(props['messages'], hyperdb.Multilink):
442             return None, files
443         if not props['messages'].classname == 'msg':
444             return None, files
445         if not (self.form.has_key('nosy') or note):
446             return None, files
448         # handle the note
449         if note:
450             if '\n' in note:
451                 summary = re.split(r'\n\r?', note)[0]
452             else:
453                 summary = note
454             m = ['%s\n'%note]
455         elif not files:
456             # don't generate a useless message
457             return None, files
459         # handle the messageid
460         # TODO: handle inreplyto
461         messageid = "%s.%s.%s-%s"%(time.time(), random.random(),
462             self.classname, self.MAIL_DOMAIN)
464         # now create the message, attaching the files
465         content = '\n'.join(m)
466         message_id = self.db.msg.create(author=self.getuid(),
467             recipients=[], date=date.Date('.'), summary=summary,
468             content=content, files=files, messageid=messageid)
470         # update the messages property
471         return message_id, files
473     def _post_editnode(self, nid):
474         '''Do the linking part of the node creation.
476            If a form element has :link or :multilink appended to it, its
477            value specifies a node designator and the property on that node
478            to add _this_ node to as a link or multilink.
480            This is typically used on, eg. the file upload page to indicated
481            which issue to link the file to.
483            TODO: I suspect that this and newfile will go away now that
484            there's the ability to upload a file using the issue __file form
485            element!
486         '''
487         cn = self.classname
488         cl = self.db.classes[cn]
489         # link if necessary
490         keys = self.form.keys()
491         for key in keys:
492             if key == ':multilink':
493                 value = self.form[key].value
494                 if type(value) != type([]): value = [value]
495                 for value in value:
496                     designator, property = value.split(':')
497                     link, nodeid = roundupdb.splitDesignator(designator)
498                     link = self.db.classes[link]
499                     value = link.get(nodeid, property)
500                     value.append(nid)
501                     link.set(nodeid, **{property: value})
502             elif key == ':link':
503                 value = self.form[key].value
504                 if type(value) != type([]): value = [value]
505                 for value in value:
506                     designator, property = value.split(':')
507                     link, nodeid = roundupdb.splitDesignator(designator)
508                     link = self.db.classes[link]
509                     link.set(nodeid, **{property: nid})
511     def newnode(self, message=None):
512         ''' Add a new node to the database.
513         
514         The form works in two modes: blank form and submission (that is,
515         the submission goes to the same URL). **Eventually this means that
516         the form will have previously entered information in it if
517         submission fails.
519         The new node will be created with the properties specified in the
520         form submission. For multilinks, multiple form entries are handled,
521         as are prop=value,value,value. You can't mix them though.
523         If the new node is to be referenced from somewhere else immediately
524         (ie. the new node is a file that is to be attached to a support
525         issue) then supply one of these arguments in addition to the usual
526         form entries:
527             :link=designator:property
528             :multilink=designator:property
529         ... which means that once the new node is created, the "property"
530         on the node given by "designator" should now reference the new
531         node's id. The node id will be appended to the multilink.
532         '''
533         cn = self.classname
534         cl = self.db.classes[cn]
536         # possibly perform a create
537         keys = self.form.keys()
538         if [i for i in keys if i[0] != ':']:
539             props = {}
540             try:
541                 nid = self._createnode()
542                 # handle linked nodes 
543                 self._post_editnode(nid)
544                 # and some nice feedback for the user
545                 message = _('%(classname)s created ok')%{'classname': cn}
547                 # render the newly created issue
548                 self.db.commit()
549                 self.nodeid = nid
550                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
551                     message)
552                 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 
553                     self.classname)
554                 item.render(nid)
555                 self.pagefoot()
556                 return
557             except:
558                 self.db.rollback()
559                 s = StringIO.StringIO()
560                 traceback.print_exc(None, s)
561                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
562         self.pagehead(_('New %(classname)s')%{'classname':
563             self.classname.capitalize()}, message)
565         # call the template
566         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
567             self.classname)
568         newitem.render(self.form)
570         self.pagefoot()
571     newissue = newnode
573     def newuser(self, message=None):
574         ''' Add a new user to the database.
576             Don't do any of the message or file handling, just create the node.
577         '''
578         cn = self.classname
579         cl = self.db.classes[cn]
581         # possibly perform a create
582         keys = self.form.keys()
583         if [i for i in keys if i[0] != ':']:
584             try:
585                 props, dummy = parsePropsFromForm(self.db, cl, self.form)
586                 nid = cl.create(**props)
587                 # handle linked nodes 
588                 self._post_editnode(nid)
589                 # and some nice feedback for the user
590                 message = _('%(classname)s created ok')%{'classname': cn}
591             except:
592                 self.db.rollback()
593                 s = StringIO.StringIO()
594                 traceback.print_exc(None, s)
595                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
596         self.pagehead(_('New %(classname)s')%{'classname':
597              self.classname.capitalize()}, message)
599         # call the template
600         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
601             self.classname)
602         newitem.render(self.form)
604         self.pagefoot()
606     def newfile(self, message=None):
607         ''' Add a new file to the database.
608         
609         This form works very much the same way as newnode - it just has a
610         file upload.
611         '''
612         cn = self.classname
613         cl = self.db.classes[cn]
615         # possibly perform a create
616         keys = self.form.keys()
617         if [i for i in keys if i[0] != ':']:
618             try:
619                 file = self.form['content']
620                 mime_type = mimetypes.guess_type(file.filename)[0]
621                 if not mime_type:
622                     mime_type = "application/octet-stream"
623                 # save the file
624                 nid = cl.create(content=file.file.read(), type=mime_type,
625                     name=file.filename)
626                 # handle linked nodes
627                 self._post_editnode(nid)
628                 # and some nice feedback for the user
629                 message = _('%(classname)s created ok')%{'classname': cn}
630             except:
631                 self.db.rollback()
632                 s = StringIO.StringIO()
633                 traceback.print_exc(None, s)
634                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
636         self.pagehead(_('New %(classname)s')%{'classname':
637              self.classname.capitalize()}, message)
638         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
639             self.classname)
640         newitem.render(self.form)
641         self.pagefoot()
643     def showuser(self, message=None):
644         '''Display a user page for editing. Make sure the user is allowed
645             to edit this node, and also check for password changes.
646         '''
647         if self.user == 'anonymous':
648             raise Unauthorised
650         user = self.db.user
652         # get the username of the node being edited
653         node_user = user.get(self.nodeid, 'username')
655         if self.user not in ('admin', node_user):
656             raise Unauthorised
658         #
659         # perform any editing
660         #
661         keys = self.form.keys()
662         num_re = re.compile('^\d+$')
663         if keys:
664             try:
665                 props, changed = parsePropsFromForm(self.db, user, self.form,
666                     self.nodeid)
667                 set_cookie = 0
668                 if self.nodeid == self.getuid() and changed.has_key('password'):
669                     password = self.form['password'].value.strip()
670                     if password:
671                         set_cookie = password
672                     else:
673                         # no password was supplied - don't change it
674                         del props['password']
675                         del changed['password']
676                 user.set(self.nodeid, **props)
677                 # and some feedback for the user
678                 message = _('%(changes)s edited ok')%{'changes':
679                     ', '.join(changed.keys())}
680             except:
681                 self.db.rollback()
682                 s = StringIO.StringIO()
683                 traceback.print_exc(None, s)
684                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
685         else:
686             set_cookie = 0
688         # fix the cookie if the password has changed
689         if set_cookie:
690             self.set_cookie(self.user, set_cookie)
692         #
693         # now the display
694         #
695         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
697         # use the template to display the item
698         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
699         item.render(self.nodeid)
700         self.pagefoot()
702     def showfile(self):
703         ''' display a file
704         '''
705         nodeid = self.nodeid
706         cl = self.db.file
707         mime_type = cl.get(nodeid, 'type')
708         if mime_type == 'message/rfc822':
709             mime_type = 'text/plain'
710         self.header(headers={'Content-Type': mime_type})
711         self.write(cl.get(nodeid, 'content'))
713     def classes(self, message=None):
714         ''' display a list of all the classes in the database
715         '''
716         if self.user == 'admin':
717             self.pagehead(_('Table of classes'), message)
718             classnames = self.db.classes.keys()
719             classnames.sort()
720             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
721             for cn in classnames:
722                 cl = self.db.getclass(cn)
723                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
724                 for key, value in cl.properties.items():
725                     if value is None: value = ''
726                     else: value = str(value)
727                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
728                         key, cgi.escape(value)))
729             self.write('</table>')
730             self.pagefoot()
731         else:
732             raise Unauthorised
734     def login(self, message=None, newuser_form=None, action='index'):
735         '''Display a login page.
736         '''
737         self.pagehead(_('Login to roundup'), message)
738         self.write(_('''
739 <table>
740 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
741 <form action="login_action" method=POST>
742 <input type="hidden" name="__destination_url" value="%(action)s">
743 <tr><td align=right>Login name: </td>
744     <td><input name="__login_name"></td></tr>
745 <tr><td align=right>Password: </td>
746     <td><input type="password" name="__login_password"></td></tr>
747 <tr><td></td>
748     <td><input type="submit" value="Log In"></td></tr>
749 </form>
750 ''')%locals())
751         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
752             self.write('</table>')
753             self.pagefoot()
754             return
755         values = {'realname': '', 'organisation': '', 'address': '',
756             'phone': '', 'username': '', 'password': '', 'confirm': '',
757             'action': action}
758         if newuser_form is not None:
759             for key in newuser_form.keys():
760                 values[key] = newuser_form[key].value
761         self.write(_('''
762 <p>
763 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
764 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
765 <form action="newuser_action" method=POST>
766 <input type="hidden" name="__destination_url" value="%(action)s">
767 <tr><td align=right><em>Name: </em></td>
768     <td><input name="realname" value="%(realname)s"></td></tr>
769 <tr><td align=right><em>Organisation: </em></td>
770     <td><input name="organisation" value="%(organisation)s"></td></tr>
771 <tr><td align=right>E-Mail Address: </td>
772     <td><input name="address" value="%(address)s"></td></tr>
773 <tr><td align=right><em>Phone: </em></td>
774     <td><input name="phone" value="%(phone)s"></td></tr>
775 <tr><td align=right>Preferred Login name: </td>
776     <td><input name="username" value="%(username)s"></td></tr>
777 <tr><td align=right>Password: </td>
778     <td><input type="password" name="password" value="%(password)s"></td></tr>
779 <tr><td align=right>Password Again: </td>
780     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
781 <tr><td></td>
782     <td><input type="submit" value="Register"></td></tr>
783 </form>
784 </table>
785 ''')%values)
786         self.pagefoot()
788     def login_action(self, message=None):
789         '''Attempt to log a user in and set the cookie
791         returns 0 if a page is generated as a result of this call, and
792         1 if not (ie. the login is successful
793         '''
794         if not self.form.has_key('__login_name'):
795             self.login(message=_('Username required'))
796             return 0
797         self.user = self.form['__login_name'].value
798         if self.form.has_key('__login_password'):
799             password = self.form['__login_password'].value
800         else:
801             password = ''
802         # make sure the user exists
803         try:
804             uid = self.db.user.lookup(self.user)
805         except KeyError:
806             name = self.user
807             self.make_user_anonymous()
808             action = self.form['__destination_url'].value
809             self.login(message=_('No such user "%(name)s"')%locals(),
810                 action=action)
811             return 0
813         # and that the password is correct
814         pw = self.db.user.get(uid, 'password')
815         if password != pw:
816             self.make_user_anonymous()
817             action = self.form['__destination_url'].value
818             self.login(message=_('Incorrect password'), action=action)
819             return 0
821         self.set_cookie(self.user, password)
822         return 1
824     def newuser_action(self, message=None):
825         '''Attempt to create a new user based on the contents of the form
826         and then set the cookie.
828         return 1 on successful login
829         '''
830         # re-open the database as "admin"
831         self.db = self.instance.open('admin')
833         # TODO: pre-check the required fields and username key property
834         cl = self.db.user
835         try:
836             props, dummy = parsePropsFromForm(self.db, cl, self.form)
837             uid = cl.create(**props)
838         except ValueError, message:
839             action = self.form['__destination_url'].value
840             self.login(message, action=action)
841             return 0
842         self.user = cl.get(uid, 'username')
843         password = cl.get(uid, 'password')
844         self.set_cookie(self.user, self.form['password'].value)
845         return 1
847     def set_cookie(self, user, password):
848         # construct the cookie
849         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
850         if user[-1] == '=':
851           if user[-2] == '=':
852             user = user[:-2]
853           else:
854             user = user[:-1]
855         expire = Cookie._getdate(86400*365)
856         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
857         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
858             user, expire, path)})
860     def make_user_anonymous(self):
861         # make us anonymous if we can
862         try:
863             self.db.user.lookup('anonymous')
864             self.user = 'anonymous'
865         except KeyError:
866             self.user = None
868     def logout(self, message=None):
869         self.make_user_anonymous()
870         # construct the logout cookie
871         now = Cookie._getdate()
872         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
873         self.header({'Set-Cookie':
874             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
875             path)})
876         self.login()
879     def main(self):
880         '''Wrap the database accesses so we can close the database cleanly
881         '''
882         # determine the uid to use
883         self.db = self.instance.open('admin')
884         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
885         user = 'anonymous'
886         if (cookie.has_key('roundup_user') and
887                 cookie['roundup_user'].value != 'deleted'):
888             cookie = cookie['roundup_user'].value
889             if len(cookie)%4:
890               cookie = cookie + '='*(4-len(cookie)%4)
891             try:
892                 user, password = binascii.a2b_base64(cookie).split(':')
893             except (TypeError, binascii.Error, binascii.Incomplete):
894                 # damaged cookie!
895                 user, password = 'anonymous', ''
897             # make sure the user exists
898             try:
899                 uid = self.db.user.lookup(user)
900                 # now validate the password
901                 if password != self.db.user.get(uid, 'password'):
902                     user = 'anonymous'
903             except KeyError:
904                 user = 'anonymous'
906         # make sure the anonymous user is valid if we're using it
907         if user == 'anonymous':
908             self.make_user_anonymous()
909         else:
910             self.user = user
912         # re-open the database for real, using the user
913         self.db = self.instance.open(self.user)
915         # now figure which function to call
916         path = self.split_path
918         # default action to index if the path has no information in it
919         if not path or path[0] in ('', 'index'):
920             action = 'index'
921         else:
922             action = path[0]
924         # Everthing ignores path[1:]
925         #  - The file download link generator actually relies on this - it
926         #    appends the name of the file to the URL so the download file name
927         #    is correct, but doesn't actually use it.
929         # everyone is allowed to try to log in
930         if action == 'login_action':
931             # try to login
932             if not self.login_action():
933                 return
934             # figure the resulting page
935             action = self.form['__destination_url'].value
936             if not action:
937                 action = 'index'
938             self.do_action(action)
939             return
941         # allow anonymous people to register
942         if action == 'newuser_action':
943             # if we don't have a login and anonymous people aren't allowed to
944             # register, then spit up the login form
945             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
946                 if action == 'login':
947                     self.login()         # go to the index after login
948                 else:
949                     self.login(action=action)
950                 return
951             # try to add the user
952             if not self.newuser_action():
953                 return
954             # figure the resulting page
955             action = self.form['__destination_url'].value
956             if not action:
957                 action = 'index'
959         # no login or registration, make sure totally anonymous access is OK
960         elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
961             if action == 'login':
962                 self.login()             # go to the index after login
963             else:
964                 self.login(action=action)
965             return
967         # just a regular action
968         self.do_action(action)
970         # commit all changes to the database
971         self.db.commit()
973     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
974             nre=re.compile(r'new(\w+)')):
975         '''Figure the user's action and do it.
976         '''
977         # here be the "normal" functionality
978         if action == 'index':
979             self.index()
980             return
981         if action == 'list_classes':
982             self.classes()
983             return
984         if action == 'login':
985             self.login()
986             return
987         if action == 'logout':
988             self.logout()
989             return
990         m = dre.match(action)
991         if m:
992             self.classname = m.group(1)
993             self.nodeid = m.group(2)
994             try:
995                 cl = self.db.classes[self.classname]
996             except KeyError:
997                 raise NotFound
998             try:
999                 cl.get(self.nodeid, 'id')
1000             except IndexError:
1001                 raise NotFound
1002             try:
1003                 func = getattr(self, 'show%s'%self.classname)
1004             except AttributeError:
1005                 raise NotFound
1006             func()
1007             return
1008         m = nre.match(action)
1009         if m:
1010             self.classname = m.group(1)
1011             try:
1012                 func = getattr(self, 'new%s'%self.classname)
1013             except AttributeError:
1014                 raise NotFound
1015             func()
1016             return
1017         self.classname = action
1018         try:
1019             self.db.getclass(self.classname)
1020         except KeyError:
1021             raise NotFound
1022         self.list()
1025 class ExtendedClient(Client): 
1026     '''Includes pages and page heading information that relate to the
1027        extended schema.
1028     ''' 
1029     showsupport = Client.shownode
1030     showtimelog = Client.shownode
1031     newsupport = Client.newnode
1032     newtimelog = Client.newnode
1034     default_index_sort = ['-activity']
1035     default_index_group = ['priority']
1036     default_index_filter = ['status']
1037     default_index_columns = ['activity','status','title','assignedto']
1038     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1040     def pagehead(self, title, message=None):
1041         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1042         machine = self.env['SERVER_NAME']
1043         port = self.env['SERVER_PORT']
1044         if port != '80': machine = machine + ':' + port
1045         base = urlparse.urlunparse(('http', machine, url, None, None, None))
1046         if message is not None:
1047             message = _('<div class="system-msg">%(message)s</div>')%locals()
1048         else:
1049             message = ''
1050         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
1051         user_name = self.user or ''
1052         if self.user == 'admin':
1053             admin_links = _(' | <a href="list_classes">Class List</a>' \
1054                           ' | <a href="user">User List</a>' \
1055                           ' | <a href="newuser">Add User</a>')
1056         else:
1057             admin_links = ''
1058         if self.user not in (None, 'anonymous'):
1059             userid = self.db.user.lookup(self.user)
1060             user_info = _('''
1061 <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> |
1062 <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> |
1063 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1064 ''')%locals()
1065         else:
1066             user_info = _('<a href="login">Login</a>')
1067         if self.user is not None:
1068             add_links = _('''
1069 | Add
1070 <a href="newissue">Issue</a>,
1071 <a href="newsupport">Support</a>,
1072 ''')
1073         else:
1074             add_links = ''
1075         self.write(_('''<html><head>
1076 <title>%(title)s</title>
1077 <style type="text/css">%(style)s</style>
1078 </head>
1079 <body bgcolor=#ffffff>
1080 %(message)s
1081 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1082 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1083 <td align=right valign=bottom>%(user_name)s</td></tr>
1084 <tr class="location-bar">
1085 <td align=left>All
1086 <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>,
1087 <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>
1088 | Unassigned
1089 <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>,
1090 <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>
1091 %(add_links)s
1092 %(admin_links)s</td>
1093 <td align=right>%(user_info)s</td>
1094 </table>
1095 ''')%locals())
1097 def parsePropsFromForm(db, cl, form, nodeid=0):
1098     '''Pull properties for the given class out of the form.
1099     '''
1100     props = {}
1101     changed = {}
1102     keys = form.keys()
1103     num_re = re.compile('^\d+$')
1104     for key in keys:
1105         if not cl.properties.has_key(key):
1106             continue
1107         proptype = cl.properties[key]
1108         if isinstance(proptype, hyperdb.String):
1109             value = form[key].value.strip()
1110         elif isinstance(proptype, hyperdb.Password):
1111             value = password.Password(form[key].value.strip())
1112         elif isinstance(proptype, hyperdb.Date):
1113             value = date.Date(form[key].value.strip())
1114         elif isinstance(proptype, hyperdb.Interval):
1115             value = date.Interval(form[key].value.strip())
1116         elif isinstance(proptype, hyperdb.Link):
1117             value = form[key].value.strip()
1118             # see if it's the "no selection" choice
1119             if value == '-1':
1120                 # don't set this property
1121                 continue
1122             else:
1123                 # handle key values
1124                 link = cl.properties[key].classname
1125                 if not num_re.match(value):
1126                     try:
1127                         value = db.classes[link].lookup(value)
1128                     except KeyError:
1129                         raise ValueError, _('property "%(propname)s": '
1130                             '%(value)s not a %(classname)s')%{'propname':key, 
1131                             'value': value, 'classname': link}
1132         elif isinstance(proptype, hyperdb.Multilink):
1133             value = form[key]
1134             if type(value) != type([]):
1135                 value = [i.strip() for i in value.value.split(',')]
1136             else:
1137                 value = [i.value.strip() for i in value]
1138             link = cl.properties[key].classname
1139             l = []
1140             for entry in map(str, value):
1141                 if entry == '': continue
1142                 if not num_re.match(entry):
1143                     try:
1144                         entry = db.classes[link].lookup(entry)
1145                     except KeyError:
1146                         raise ValueError, _('property "%(propname)s": '
1147                             '"%(value)s" not an entry of %(classname)s')%{
1148                             'propname':key, 'value': entry, 'classname': link}
1149                 l.append(entry)
1150             l.sort()
1151             value = l
1152         props[key] = value
1154         # get the old value
1155         if nodeid:
1156             try:
1157                 existing = cl.get(nodeid, key)
1158             except KeyError:
1159                 # this might be a new property for which there is no existing
1160                 # value
1161                 if not cl.properties.has_key(key): raise
1163         # if changed, set it
1164         if nodeid and value != existing:
1165             changed[key] = value
1166             props[key] = value
1167     return props, changed
1170 # $Log: not supported by cvs2svn $
1171 # Revision 1.88  2002/01/02 02:31:38  richard
1172 # Sorry for the huge checkin message - I was only intending to implement #496356
1173 # but I found a number of places where things had been broken by transactions:
1174 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1175 #    for _all_ roundup-generated smtp messages to be sent to.
1176 #  . the transaction cache had broken the roundupdb.Class set() reactors
1177 #  . newly-created author users in the mailgw weren't being committed to the db
1179 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1180 # on when I found that stuff :):
1181 #  . #496356 ] Use threading in messages
1182 #  . detectors were being registered multiple times
1183 #  . added tests for mailgw
1184 #  . much better attaching of erroneous messages in the mail gateway
1186 # Revision 1.87  2001/12/23 23:18:49  richard
1187 # We already had an admin-specific section of the web heading, no need to add
1188 # another one :)
1190 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1191 # Features added:
1192 #  .  Multilink properties are now displayed as comma separated values in
1193 #     a textbox
1194 #  .  The add user link is now only visible to the admin user
1195 #  .  Modified the mail gateway to reject submissions from unknown
1196 #     addresses if ANONYMOUS_ACCESS is denied
1198 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1199 # Bugs fixed:
1200 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1201 #     lost somewhere
1202 #   . Internet Explorer submits full path for filename - we now strip away
1203 #     the path
1204 # Features added:
1205 #   . Link and multilink properties are now displayed sorted in the cgi
1206 #     interface
1208 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1209 # Fixed bugs:
1210 #  .  Fixed file creation and retrieval in same transaction in anydbm
1211 #     backend
1212 #  .  Cgi interface now renders new issue after issue creation
1213 #  .  Could not set issue status to resolved through cgi interface
1214 #  .  Mail gateway was changing status back to 'chatting' if status was
1215 #     omitted as an argument
1217 # Revision 1.83  2001/12/15 23:51:01  richard
1218 # Tested the changes and fixed a few problems:
1219 #  . files are now attached to the issue as well as the message
1220 #  . newuser is a real method now since we don't want to do the message/file
1221 #    stuff for it
1222 #  . added some documentation
1223 # The really big changes in the diff are a result of me moving some code
1224 # around to keep like methods together a bit better.
1226 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1227 #  . Modified cgi interface to change properties only once all changes are
1228 #    collected, files created and messages generated.
1229 #  . Moved generation of change note to nosyreactors.
1230 #  . We now check for changes to "assignedto" to ensure it's added to the
1231 #    nosy list.
1233 # Revision 1.81  2001/12/12 23:55:00  richard
1234 # Fixed some problems with user editing
1236 # Revision 1.80  2001/12/12 23:27:14  richard
1237 # Added a Zope frontend for roundup.
1239 # Revision 1.79  2001/12/10 22:20:01  richard
1240 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1241 # where possible, only replacing methods where the db is opened (it uses the
1242 # btree opener specifically.)
1243 # Also cleaned up some change note generation.
1244 # Made the backends package work with pydoc too.
1246 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1247 # Fixed small bug that prevented adding issues through the web.
1249 # Revision 1.77  2001/12/06 22:48:29  richard
1250 # files multilink was being nuked in post_edit_node
1252 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1253 # Removed generation of change note from "sendmessage" in roundupdb.py.
1254 # The change note is now generated when the message is created.
1256 # Revision 1.75  2001/12/04 01:25:08  richard
1257 # Added some rollbacks where we were catching exceptions that would otherwise
1258 # have stopped committing.
1260 # Revision 1.74  2001/12/02 05:06:16  richard
1261 # . We now use weakrefs in the Classes to keep the database reference, so
1262 #   the close() method on the database is no longer needed.
1263 #   I bumped the minimum python requirement up to 2.1 accordingly.
1264 # . #487480 ] roundup-server
1265 # . #487476 ] INSTALL.txt
1267 # I also cleaned up the change message / post-edit stuff in the cgi client.
1268 # There's now a clearly marked "TODO: append the change note" where I believe
1269 # the change note should be added there. The "changes" list will obviously
1270 # have to be modified to be a dict of the changes, or somesuch.
1272 # More testing needed.
1274 # Revision 1.73  2001/12/01 07:17:50  richard
1275 # . We now have basic transaction support! Information is only written to
1276 #   the database when the commit() method is called. Only the anydbm
1277 #   backend is modified in this way - neither of the bsddb backends have been.
1278 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1279 #   doesn't have a commit command, so interactive users can't commit...)
1280 # . Fixed login/registration forwarding the user to the right page (or not,
1281 #   on a failure)
1283 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1284 # Links in page header are now consistent with default sort order.
1286 # Fixed bugs:
1287 #     - When login failed the list of issues were still rendered.
1288 #     - User was redirected to index page and not to his destination url
1289 #       if his first login attempt failed.
1291 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1292 # Property changes are now completely traceable, whether changes are
1293 # made through the web or by email
1295 # Revision 1.70  2001/11/30 00:06:29  richard
1296 # Converted roundup/cgi_client.py to use _()
1297 # Added the status file, I18N_PROGRESS.txt
1299 # Revision 1.69  2001/11/29 23:19:51  richard
1300 # Removed the "This issue has been edited through the web" when a valid
1301 # change note is supplied.
1303 # Revision 1.68  2001/11/29 04:57:23  richard
1304 # a little comment
1306 # Revision 1.67  2001/11/28 21:55:35  richard
1307 #  . login_action and newuser_action return values were being ignored
1308 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1309 #    gateway.
1310 #  (also a minor cleanup in hyperdb)
1312 # Revision 1.66  2001/11/27 03:00:50  richard
1313 # couple of bugfixes from latest patch integration
1315 # Revision 1.65  2001/11/26 23:00:53  richard
1316 # This config stuff is getting to be a real mess...
1318 # Revision 1.64  2001/11/26 22:56:35  richard
1319 # typo
1321 # Revision 1.63  2001/11/26 22:55:56  richard
1322 # Feature:
1323 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1324 #    the instance.
1325 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1326 #    signature info in e-mails.
1327 #  . Some more flexibility in the mail gateway and more error handling.
1328 #  . Login now takes you to the page you back to the were denied access to.
1330 # Fixed:
1331 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1333 # Revision 1.62  2001/11/24 00:45:42  jhermann
1334 # typeof() instead of type(): avoid clash with database field(?) "type"
1336 # Fixes this traceback:
1338 # Traceback (most recent call last):
1339 #   File "roundup\cgi_client.py", line 535, in newnode
1340 #     self._post_editnode(nid)
1341 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1342 #     if type(value) != type([]): value = [value]
1343 # UnboundLocalError: local variable 'type' referenced before assignment
1345 # Revision 1.61  2001/11/22 15:46:42  jhermann
1346 # Added module docstrings to all modules.
1348 # Revision 1.60  2001/11/21 22:57:28  jhermann
1349 # Added dummy hooks for I18N and some preliminary (test) markup of
1350 # translatable messages
1352 # Revision 1.59  2001/11/21 03:21:13  richard
1353 # oops
1355 # Revision 1.58  2001/11/21 03:11:28  richard
1356 # Better handling of new properties.
1358 # Revision 1.57  2001/11/15 10:24:27  richard
1359 # handle the case where there is no file attached
1361 # Revision 1.56  2001/11/14 21:35:21  richard
1362 #  . users may attach files to issues (and support in ext) through the web now
1364 # Revision 1.55  2001/11/07 02:34:06  jhermann
1365 # Handling of damaged login cookies
1367 # Revision 1.54  2001/11/07 01:16:12  richard
1368 # Remove the '=' padding from cookie value so quoting isn't an issue.
1370 # Revision 1.53  2001/11/06 23:22:05  jhermann
1371 # More IE fixes: it does not like quotes around cookie values; in the
1372 # hope this does not break anything for other browser; if it does, we
1373 # need to check HTTP_USER_AGENT
1375 # Revision 1.52  2001/11/06 23:11:22  jhermann
1376 # Fixed debug output in page footer; added expiry date to the login cookie
1377 # (expires 1 year in the future) to prevent probs with certain versions
1378 # of IE
1380 # Revision 1.51  2001/11/06 22:00:34  jhermann
1381 # Get debug level from ROUNDUP_DEBUG env var
1383 # Revision 1.50  2001/11/05 23:45:40  richard
1384 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1385 # Also made it present nicer error messages (not tracebacks).
1387 # Revision 1.49  2001/11/04 03:07:12  richard
1388 # Fixed various cookie-related bugs:
1389 #  . bug #477685 ] base64.decodestring breaks
1390 #  . bug #477837 ] lynx does not like the cookie
1391 #  . bug #477892 ] Password edit doesn't fix login cookie
1392 # Also closed a security hole - a logged-in user could edit another user's
1393 # details.
1395 # Revision 1.48  2001/11/03 01:30:18  richard
1396 # Oops. uses pagefoot now.
1398 # Revision 1.47  2001/11/03 01:29:28  richard
1399 # Login page didn't have all close tags.
1401 # Revision 1.46  2001/11/03 01:26:55  richard
1402 # possibly fix truncated base64'ed user:pass
1404 # Revision 1.45  2001/11/01 22:04:37  richard
1405 # Started work on supporting a pop3-fetching server
1406 # Fixed bugs:
1407 #  . bug #477104 ] HTML tag error in roundup-server
1408 #  . bug #477107 ] HTTP header problem
1410 # Revision 1.44  2001/10/28 23:03:08  richard
1411 # Added more useful header to the classic schema.
1413 # Revision 1.43  2001/10/24 00:01:42  richard
1414 # More fixes to lockout logic.
1416 # Revision 1.42  2001/10/23 23:56:03  richard
1417 # HTML typo
1419 # Revision 1.41  2001/10/23 23:52:35  richard
1420 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1422 # Revision 1.40  2001/10/23 23:06:39  richard
1423 # Some cleanup.
1425 # Revision 1.39  2001/10/23 01:00:18  richard
1426 # Re-enabled login and registration access after lopping them off via
1427 # disabling access for anonymous users.
1428 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1429 # a couple of bugs while I was there. Probably introduced a couple, but
1430 # things seem to work OK at the moment.
1432 # Revision 1.38  2001/10/22 03:25:01  richard
1433 # Added configuration for:
1434 #  . anonymous user access and registration (deny/allow)
1435 #  . filter "widget" location on index page (top, bottom, both)
1436 # Updated some documentation.
1438 # Revision 1.37  2001/10/21 07:26:35  richard
1439 # feature #473127: Filenames. I modified the file.index and htmltemplate
1440 #  source so that the filename is used in the link and the creation
1441 #  information is displayed.
1443 # Revision 1.36  2001/10/21 04:44:50  richard
1444 # bug #473124: UI inconsistency with Link fields.
1445 #    This also prompted me to fix a fairly long-standing usability issue -
1446 #    that of being able to turn off certain filters.
1448 # Revision 1.35  2001/10/21 00:17:54  richard
1449 # CGI interface view customisation section may now be hidden (patch from
1450 #  Roch'e Compaan.)
1452 # Revision 1.34  2001/10/20 11:58:48  richard
1453 # Catch errors in login - no username or password supplied.
1454 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1456 # Revision 1.33  2001/10/17 00:18:41  richard
1457 # Manually constructing cookie headers now.
1459 # Revision 1.32  2001/10/16 03:36:21  richard
1460 # CGI interface wasn't handling checkboxes at all.
1462 # Revision 1.31  2001/10/14 10:55:00  richard
1463 # Handle empty strings in HTML template Link function
1465 # Revision 1.30  2001/10/09 07:38:58  richard
1466 # Pushed the base code for the extended schema CGI interface back into the
1467 # code cgi_client module so that future updates will be less painful.
1468 # Also removed a debugging print statement from cgi_client.
1470 # Revision 1.29  2001/10/09 07:25:59  richard
1471 # Added the Password property type. See "pydoc roundup.password" for
1472 # implementation details. Have updated some of the documentation too.
1474 # Revision 1.28  2001/10/08 00:34:31  richard
1475 # Change message was stuffing up for multilinks with no key property.
1477 # Revision 1.27  2001/10/05 02:23:24  richard
1478 #  . roundup-admin create now prompts for property info if none is supplied
1479 #    on the command-line.
1480 #  . hyperdb Class getprops() method may now return only the mutable
1481 #    properties.
1482 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1483 #    now support anonymous user access (read-only, unless there's an
1484 #    "anonymous" user, in which case write access is permitted). Login
1485 #    handling has been moved into cgi_client.Client.main()
1486 #  . The "extended" schema is now the default in roundup init.
1487 #  . The schemas have had their page headings modified to cope with the new
1488 #    login handling. Existing installations should copy the interfaces.py
1489 #    file from the roundup lib directory to their instance home.
1490 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1491 #    Ping - has been removed.
1492 #  . Fixed a whole bunch of places in the CGI interface where we should have
1493 #    been returning Not Found instead of throwing an exception.
1494 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1495 #    an item now throws an exception.
1497 # Revision 1.26  2001/09/12 08:31:42  richard
1498 # handle cases where mime type is not guessable
1500 # Revision 1.25  2001/08/29 05:30:49  richard
1501 # change messages weren't being saved when there was no-one on the nosy list.
1503 # Revision 1.24  2001/08/29 04:49:39  richard
1504 # didn't clean up fully after debugging :(
1506 # Revision 1.23  2001/08/29 04:47:18  richard
1507 # Fixed CGI client change messages so they actually include the properties
1508 # changed (again).
1510 # Revision 1.22  2001/08/17 00:08:10  richard
1511 # reverted back to sending messages always regardless of who is doing the web
1512 # edit. change notes weren't being saved. bleah. hackish.
1514 # Revision 1.21  2001/08/15 23:43:18  richard
1515 # Fixed some isFooTypes that I missed.
1516 # Refactored some code in the CGI code.
1518 # Revision 1.20  2001/08/12 06:32:36  richard
1519 # using isinstance(blah, Foo) now instead of isFooType
1521 # Revision 1.19  2001/08/07 00:24:42  richard
1522 # stupid typo
1524 # Revision 1.18  2001/08/07 00:15:51  richard
1525 # Added the copyright/license notice to (nearly) all files at request of
1526 # Bizar Software.
1528 # Revision 1.17  2001/08/02 06:38:17  richard
1529 # Roundupdb now appends "mailing list" information to its messages which
1530 # include the e-mail address and web interface address. Templates may
1531 # override this in their db classes to include specific information (support
1532 # instructions, etc).
1534 # Revision 1.16  2001/08/02 05:55:25  richard
1535 # Web edit messages aren't sent to the person who did the edit any more. No
1536 # message is generated if they are the only person on the nosy list.
1538 # Revision 1.15  2001/08/02 00:34:10  richard
1539 # bleah syntax error
1541 # Revision 1.14  2001/08/02 00:26:16  richard
1542 # Changed the order of the information in the message generated by web edits.
1544 # Revision 1.13  2001/07/30 08:12:17  richard
1545 # Added time logging and file uploading to the templates.
1547 # Revision 1.12  2001/07/30 06:26:31  richard
1548 # Added some documentation on how the newblah works.
1550 # Revision 1.11  2001/07/30 06:17:45  richard
1551 # Features:
1552 #  . Added ability for cgi newblah forms to indicate that the new node
1553 #    should be linked somewhere.
1554 # Fixed:
1555 #  . Fixed the agument handling for the roundup-admin find command.
1556 #  . Fixed handling of summary when no note supplied for newblah. Again.
1557 #  . Fixed detection of no form in htmltemplate Field display.
1559 # Revision 1.10  2001/07/30 02:37:34  richard
1560 # Temporary measure until we have decent schema migration...
1562 # Revision 1.9  2001/07/30 01:25:07  richard
1563 # Default implementation is now "classic" rather than "extended" as one would
1564 # expect.
1566 # Revision 1.8  2001/07/29 08:27:40  richard
1567 # Fixed handling of passed-in values in form elements (ie. during a
1568 # drill-down)
1570 # Revision 1.7  2001/07/29 07:01:39  richard
1571 # Added vim command to all source so that we don't get no steenkin' tabs :)
1573 # Revision 1.6  2001/07/29 04:04:00  richard
1574 # Moved some code around allowing for subclassing to change behaviour.
1576 # Revision 1.5  2001/07/28 08:16:52  richard
1577 # New issue form handles lack of note better now.
1579 # Revision 1.4  2001/07/28 00:34:34  richard
1580 # Fixed some non-string node ids.
1582 # Revision 1.3  2001/07/23 03:56:30  richard
1583 # oops, missed a config removal
1585 # Revision 1.2  2001/07/22 12:09:32  richard
1586 # Final commit of Grande Splite
1588 # Revision 1.1  2001/07/22 11:58:35  richard
1589 # More Grande Splite
1592 # vim: set filetype=python ts=4 sw=4 et si