0e446d48c3416eba68af6f936306d1c5e1bed7cd
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.103 2002-02-20 05:05:28 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.
47 '''
49 def __init__(self, instance, request, env, form=None):
50 self.instance = instance
51 self.request = request
52 self.env = env
53 self.path = env['PATH_INFO']
54 self.split_path = self.path.split('/')
56 if form is None:
57 self.form = cgi.FieldStorage(environ=env)
58 else:
59 self.form = form
60 self.headers_done = 0
61 try:
62 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
63 except ValueError:
64 # someone gave us a non-int debug level, turn it off
65 self.debug = 0
67 def getuid(self):
68 return self.db.user.lookup(self.user)
70 def header(self, headers={'Content-Type':'text/html'}):
71 '''Put up the appropriate header.
72 '''
73 if not headers.has_key('Content-Type'):
74 headers['Content-Type'] = 'text/html'
75 self.request.send_response(200)
76 for entry in headers.items():
77 self.request.send_header(*entry)
78 self.request.end_headers()
79 self.headers_done = 1
80 if self.debug:
81 self.headers_sent = headers
83 single_submit_script = '''
84 <script language="javascript">
85 submitted = false;
86 function submit_once() {
87 if (submitted) {
88 alert("Your request is being processed.\\nPlease be patient.");
89 return 0;
90 }
91 submitted = true;
92 return 1;
93 }
94 </script>
95 '''
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.instance.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 single_submit_script = self.single_submit_script
131 self.write(_('''<html><head>
132 <title>%(title)s</title>
133 <style type="text/css">%(style)s</style>
134 </head>
135 %(single_submit_script)s
136 <body bgcolor=#ffffff>
137 %(message)s
138 <table width=100%% border=0 cellspacing=0 cellpadding=2>
139 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
140 <td align=right valign=bottom>%(user_name)s</td></tr>
141 <tr class="location-bar">
142 <td align=left>All
143 <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>
144 | Unassigned
145 <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>
146 %(add_links)s
147 %(admin_links)s</td>
148 <td align=right>%(user_info)s</td>
149 </table>
150 ''')%locals())
152 def pagefoot(self):
153 if self.debug:
154 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
155 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
156 keys = self.form.keys()
157 keys.sort()
158 if keys:
159 self.write(_('<dt><b>Form entries</b></dt>'))
160 for k in self.form.keys():
161 v = self.form.getvalue(k, "<empty>")
162 if type(v) is type([]):
163 # Multiple username fields specified
164 v = "|".join(v)
165 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
166 keys = self.headers_sent.keys()
167 keys.sort()
168 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
169 for k in keys:
170 v = self.headers_sent[k]
171 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
172 keys = self.env.keys()
173 keys.sort()
174 self.write(_('<dt><b>CGI environment</b></dt>'))
175 for k in keys:
176 v = self.env[k]
177 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
178 self.write('</dl></small>')
179 self.write('</body></html>')
181 def write(self, content):
182 if not self.headers_done:
183 self.header()
184 self.request.wfile.write(content)
186 def index_arg(self, arg):
187 ''' handle the args to index - they might be a list from the form
188 (ie. submitted from a form) or they might be a command-separated
189 single string (ie. manually constructed GET args)
190 '''
191 if self.form.has_key(arg):
192 arg = self.form[arg]
193 if type(arg) == type([]):
194 return [arg.value for arg in arg]
195 return arg.value.split(',')
196 return []
198 def index_filterspec(self, filter):
199 ''' pull the index filter spec from the form
201 Links and multilinks want to be lists - the rest are straight
202 strings.
203 '''
204 props = self.db.classes[self.classname].getprops()
205 # all the form args not starting with ':' are filters
206 filterspec = {}
207 for key in self.form.keys():
208 if key[0] == ':': continue
209 if not props.has_key(key): continue
210 if key not in filter: continue
211 prop = props[key]
212 value = self.form[key]
213 if (isinstance(prop, hyperdb.Link) or
214 isinstance(prop, hyperdb.Multilink)):
215 if type(value) == type([]):
216 value = [arg.value for arg in value]
217 else:
218 value = value.value.split(',')
219 l = filterspec.get(key, [])
220 l = l + value
221 filterspec[key] = l
222 else:
223 filterspec[key] = value.value
224 return filterspec
226 def customization_widget(self):
227 ''' The customization widget is visible by default. The widget
228 visibility is remembered by show_customization. Visibility
229 is not toggled if the action value is "Redisplay"
230 '''
231 if not self.form.has_key('show_customization'):
232 visible = 1
233 else:
234 visible = int(self.form['show_customization'].value)
235 if self.form.has_key('action'):
236 if self.form['action'].value != 'Redisplay':
237 visible = self.form['action'].value == '+'
239 return visible
241 default_index_sort = ['-activity']
242 default_index_group = ['priority']
243 default_index_filter = ['status']
244 default_index_columns = ['id','activity','title','status','assignedto']
245 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
246 def index(self):
247 ''' put up an index
248 '''
249 self.classname = 'issue'
250 # see if the web has supplied us with any customisation info
251 defaults = 1
252 for key in ':sort', ':group', ':filter', ':columns':
253 if self.form.has_key(key):
254 defaults = 0
255 break
256 if defaults:
257 # no info supplied - use the defaults
258 sort = self.default_index_sort
259 group = self.default_index_group
260 filter = self.default_index_filter
261 columns = self.default_index_columns
262 filterspec = self.default_index_filterspec
263 else:
264 sort = self.index_arg(':sort')
265 group = self.index_arg(':group')
266 filter = self.index_arg(':filter')
267 columns = self.index_arg(':columns')
268 filterspec = self.index_filterspec(filter)
269 return self.list(columns=columns, filter=filter, group=group,
270 sort=sort, filterspec=filterspec)
272 # XXX deviates from spec - loses the '+' (that's a reserved character
273 # in URLS
274 def list(self, sort=None, group=None, filter=None, columns=None,
275 filterspec=None, show_customization=None):
276 ''' call the template index with the args
278 :sort - sort by prop name, optionally preceeded with '-'
279 to give descending or nothing for ascending sorting.
280 :group - group by prop name, optionally preceeded with '-' or
281 to sort in descending or nothing for ascending order.
282 :filter - selects which props should be displayed in the filter
283 section. Default is all.
284 :columns - selects the columns that should be displayed.
285 Default is all.
287 '''
288 cn = self.classname
289 cl = self.db.classes[cn]
290 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
291 'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
292 if sort is None: sort = self.index_arg(':sort')
293 if group is None: group = self.index_arg(':group')
294 if filter is None: filter = self.index_arg(':filter')
295 if columns is None: columns = self.index_arg(':columns')
296 if filterspec is None: filterspec = self.index_filterspec(filter)
297 if show_customization is None:
298 show_customization = self.customization_widget()
300 index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
301 try:
302 index.render(filterspec, filter, columns, sort, group,
303 show_customization=show_customization)
304 except htmltemplate.MissingTemplateError:
305 self.basicClassEditPage()
306 self.pagefoot()
308 def basicClassEditPage(self):
309 '''Display a basic edit page that allows simple editing of the
310 nodes of the current class
311 '''
312 if self.user != 'admin':
313 raise Unauthorised
314 w = self.write
315 cn = self.classname
316 cl = self.db.classes[cn]
317 props = ['id'] + cl.getprops(protected=0).keys()
319 # get the CSV module
320 try:
321 import csv
322 except ImportError:
323 w(_('Sorry, you need the csv module to use this function.<br>\n'
324 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
325 return
327 # do the edit
328 if self.form.has_key('rows'):
329 rows = self.form['rows'].value.splitlines()
330 p = csv.parser()
331 idlessprops = props[1:]
332 found = {}
333 for row in rows:
334 values = p.parse(row)
335 # not a complete row, keep going
336 if not values: continue
338 # extract the nodeid
339 nodeid, values = values[0], values[1:]
340 found[nodeid] = 1
342 # extract the new values
343 d = {}
344 for name, value in zip(idlessprops, values):
345 d[name] = value.strip()
347 # perform the edit
348 if cl.hasnode(nodeid):
349 # edit existing
350 cl.set(nodeid, **d)
351 else:
352 # new node
353 found[cl.create(**d)] = 1
355 # retire the removed entries
356 for nodeid in cl.list():
357 if not found.has_key(nodeid):
358 cl.retire(nodeid)
360 w(_('''<p class="form-help">You may edit the contents of the
361 "%(classname)s" class using this form.</p>
362 <p class="form-help">Remove entries by deleting their line. Add
363 new entries by appending
364 them to the table - put an X in the id column.</p>''')%{'classname':cn})
366 l = []
367 for name in props:
368 l.append(name)
369 w('<tt>')
370 w(', '.join(l) + '\n')
371 w('</tt>')
373 w('<form onSubmit="return submit_once()" method="POST">')
374 w('<textarea name="rows" cols=80 rows=15>')
375 for nodeid in cl.list():
376 l = []
377 for name in props:
378 l.append(cgi.escape(str(cl.get(nodeid, name))))
379 w(', '.join(l) + '\n')
381 w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
383 def shownode(self, message=None):
384 ''' display an item
385 '''
386 cn = self.classname
387 cl = self.db.classes[cn]
389 # possibly perform an edit
390 keys = self.form.keys()
391 num_re = re.compile('^\d+$')
392 # don't try to set properties if the user has just logged in
393 if keys and not self.form.has_key('__login_name'):
394 try:
395 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
396 # make changes to the node
397 self._changenode(props)
398 # handle linked nodes
399 self._post_editnode(self.nodeid)
400 # and some nice feedback for the user
401 if props:
402 message = _('%(changes)s edited ok')%{'changes':
403 ', '.join(props.keys())}
404 elif self.form.has_key('__note') and self.form['__note'].value:
405 message = _('note added')
406 elif (self.form.has_key('__file') and
407 self.form['__file'].filename):
408 message = _('file added')
409 else:
410 message = _('nothing changed')
411 except:
412 self.db.rollback()
413 s = StringIO.StringIO()
414 traceback.print_exc(None, s)
415 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
417 # now the display
418 id = self.nodeid
419 if cl.getkey():
420 id = cl.get(id, cl.getkey())
421 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
423 nodeid = self.nodeid
425 # use the template to display the item
426 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
427 self.classname)
428 item.render(nodeid)
430 self.pagefoot()
431 showissue = shownode
432 showmsg = shownode
434 def _add_assignedto_to_nosy(self, props):
435 ''' add the assignedto value from the props to the nosy list
436 '''
437 if not props.has_key('assignedto'):
438 return
439 assignedto_id = props['assignedto']
440 if not props.has_key('nosy'):
441 # load current nosy
442 if self.nodeid:
443 cl = self.db.classes[self.classname]
444 l = cl.get(self.nodeid, 'nosy')
445 if assignedto_id in l:
446 return
447 props['nosy'] = l
448 else:
449 props['nosy'] = []
450 if assignedto_id not in props['nosy']:
451 props['nosy'].append(assignedto_id)
453 def _changenode(self, props):
454 ''' change the node based on the contents of the form
455 '''
456 cl = self.db.classes[self.classname]
457 # set status to chatting if 'unread' or 'resolved'
458 try:
459 # determine the id of 'unread','resolved' and 'chatting'
460 unread_id = self.db.status.lookup('unread')
461 resolved_id = self.db.status.lookup('resolved')
462 chatting_id = self.db.status.lookup('chatting')
463 current_status = cl.get(self.nodeid, 'status')
464 if props.has_key('status'):
465 new_status = props['status']
466 else:
467 # apparently there's a chance that some browsers don't
468 # send status...
469 new_status = current_status
470 except KeyError:
471 pass
472 else:
473 if new_status == unread_id or (new_status == resolved_id
474 and current_status == resolved_id):
475 props['status'] = chatting_id
477 self._add_assignedto_to_nosy(props)
479 # create the message
480 message, files = self._handle_message()
481 if message:
482 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
483 if files:
484 props['files'] = cl.get(self.nodeid, 'files') + files
486 # make the changes
487 cl.set(self.nodeid, **props)
489 def _createnode(self):
490 ''' create a node based on the contents of the form
491 '''
492 cl = self.db.classes[self.classname]
493 props = parsePropsFromForm(self.db, cl, self.form)
495 # set status to 'unread' if not specified - a status of '- no
496 # selection -' doesn't make sense
497 if not props.has_key('status'):
498 try:
499 unread_id = self.db.status.lookup('unread')
500 except KeyError:
501 pass
502 else:
503 props['status'] = unread_id
505 self._add_assignedto_to_nosy(props)
507 # check for messages and files
508 message, files = self._handle_message()
509 if message:
510 props['messages'] = [message]
511 if files:
512 props['files'] = files
513 # create the node and return it's id
514 return cl.create(**props)
516 def _handle_message(self):
517 ''' generate an edit message
518 '''
519 # handle file attachments
520 files = []
521 if self.form.has_key('__file'):
522 file = self.form['__file']
523 if file.filename:
524 filename = file.filename.split('\\')[-1]
525 mime_type = mimetypes.guess_type(filename)[0]
526 if not mime_type:
527 mime_type = "application/octet-stream"
528 # create the new file entry
529 files.append(self.db.file.create(type=mime_type,
530 name=filename, content=file.file.read()))
532 # we don't want to do a message if none of the following is true...
533 cn = self.classname
534 cl = self.db.classes[self.classname]
535 props = cl.getprops()
536 note = None
537 # in a nutshell, don't do anything if there's no note or there's no
538 # NOSY
539 if self.form.has_key('__note'):
540 note = self.form['__note'].value
541 if not props.has_key('messages'):
542 return None, files
543 if not isinstance(props['messages'], hyperdb.Multilink):
544 return None, files
545 if not props['messages'].classname == 'msg':
546 return None, files
547 if not (self.form.has_key('nosy') or note):
548 return None, files
550 # handle the note
551 if note:
552 if '\n' in note:
553 summary = re.split(r'\n\r?', note)[0]
554 else:
555 summary = note
556 m = ['%s\n'%note]
557 elif not files:
558 # don't generate a useless message
559 return None, files
561 # handle the messageid
562 # TODO: handle inreplyto
563 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
564 self.classname, self.instance.MAIL_DOMAIN)
566 # now create the message, attaching the files
567 content = '\n'.join(m)
568 message_id = self.db.msg.create(author=self.getuid(),
569 recipients=[], date=date.Date('.'), summary=summary,
570 content=content, files=files, messageid=messageid)
572 # update the messages property
573 return message_id, files
575 def _post_editnode(self, nid):
576 '''Do the linking part of the node creation.
578 If a form element has :link or :multilink appended to it, its
579 value specifies a node designator and the property on that node
580 to add _this_ node to as a link or multilink.
582 This is typically used on, eg. the file upload page to indicated
583 which issue to link the file to.
585 TODO: I suspect that this and newfile will go away now that
586 there's the ability to upload a file using the issue __file form
587 element!
588 '''
589 cn = self.classname
590 cl = self.db.classes[cn]
591 # link if necessary
592 keys = self.form.keys()
593 for key in keys:
594 if key == ':multilink':
595 value = self.form[key].value
596 if type(value) != type([]): value = [value]
597 for value in value:
598 designator, property = value.split(':')
599 link, nodeid = roundupdb.splitDesignator(designator)
600 link = self.db.classes[link]
601 value = link.get(nodeid, property)
602 value.append(nid)
603 link.set(nodeid, **{property: value})
604 elif key == ':link':
605 value = self.form[key].value
606 if type(value) != type([]): value = [value]
607 for value in value:
608 designator, property = value.split(':')
609 link, nodeid = roundupdb.splitDesignator(designator)
610 link = self.db.classes[link]
611 link.set(nodeid, **{property: nid})
613 def newnode(self, message=None):
614 ''' Add a new node to the database.
616 The form works in two modes: blank form and submission (that is,
617 the submission goes to the same URL). **Eventually this means that
618 the form will have previously entered information in it if
619 submission fails.
621 The new node will be created with the properties specified in the
622 form submission. For multilinks, multiple form entries are handled,
623 as are prop=value,value,value. You can't mix them though.
625 If the new node is to be referenced from somewhere else immediately
626 (ie. the new node is a file that is to be attached to a support
627 issue) then supply one of these arguments in addition to the usual
628 form entries:
629 :link=designator:property
630 :multilink=designator:property
631 ... which means that once the new node is created, the "property"
632 on the node given by "designator" should now reference the new
633 node's id. The node id will be appended to the multilink.
634 '''
635 cn = self.classname
636 cl = self.db.classes[cn]
638 # possibly perform a create
639 keys = self.form.keys()
640 if [i for i in keys if i[0] != ':']:
641 props = {}
642 try:
643 nid = self._createnode()
644 # handle linked nodes
645 self._post_editnode(nid)
646 # and some nice feedback for the user
647 message = _('%(classname)s created ok')%{'classname': cn}
649 # render the newly created issue
650 self.db.commit()
651 self.nodeid = nid
652 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
653 message)
654 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
655 self.classname)
656 item.render(nid)
657 self.pagefoot()
658 return
659 except:
660 self.db.rollback()
661 s = StringIO.StringIO()
662 traceback.print_exc(None, s)
663 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
664 self.pagehead(_('New %(classname)s')%{'classname':
665 self.classname.capitalize()}, message)
667 # call the template
668 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
669 self.classname)
670 newitem.render(self.form)
672 self.pagefoot()
673 newissue = newnode
675 def newuser(self, message=None):
676 ''' Add a new user to the database.
678 Don't do any of the message or file handling, just create the node.
679 '''
680 cn = self.classname
681 cl = self.db.classes[cn]
683 # possibly perform a create
684 keys = self.form.keys()
685 if [i for i in keys if i[0] != ':']:
686 try:
687 props = parsePropsFromForm(self.db, cl, self.form)
688 nid = cl.create(**props)
689 # handle linked nodes
690 self._post_editnode(nid)
691 # and some nice feedback for the user
692 message = _('%(classname)s created ok')%{'classname': cn}
693 except:
694 self.db.rollback()
695 s = StringIO.StringIO()
696 traceback.print_exc(None, s)
697 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
698 self.pagehead(_('New %(classname)s')%{'classname':
699 self.classname.capitalize()}, message)
701 # call the template
702 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
703 self.classname)
704 newitem.render(self.form)
706 self.pagefoot()
708 def newfile(self, message=None):
709 ''' Add a new file to the database.
711 This form works very much the same way as newnode - it just has a
712 file upload.
713 '''
714 cn = self.classname
715 cl = self.db.classes[cn]
717 # possibly perform a create
718 keys = self.form.keys()
719 if [i for i in keys if i[0] != ':']:
720 try:
721 file = self.form['content']
722 mime_type = mimetypes.guess_type(file.filename)[0]
723 if not mime_type:
724 mime_type = "application/octet-stream"
725 # save the file
726 nid = cl.create(content=file.file.read(), type=mime_type,
727 name=file.filename)
728 # handle linked nodes
729 self._post_editnode(nid)
730 # and some nice feedback for the user
731 message = _('%(classname)s created ok')%{'classname': cn}
732 except:
733 self.db.rollback()
734 s = StringIO.StringIO()
735 traceback.print_exc(None, s)
736 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
738 self.pagehead(_('New %(classname)s')%{'classname':
739 self.classname.capitalize()}, message)
740 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
741 self.classname)
742 newitem.render(self.form)
743 self.pagefoot()
745 def showuser(self, message=None):
746 '''Display a user page for editing. Make sure the user is allowed
747 to edit this node, and also check for password changes.
748 '''
749 if self.user == 'anonymous':
750 raise Unauthorised
752 user = self.db.user
754 # get the username of the node being edited
755 node_user = user.get(self.nodeid, 'username')
757 if self.user not in ('admin', node_user):
758 raise Unauthorised
760 #
761 # perform any editing
762 #
763 keys = self.form.keys()
764 num_re = re.compile('^\d+$')
765 if keys:
766 try:
767 props = parsePropsFromForm(self.db, user, self.form,
768 self.nodeid)
769 set_cookie = 0
770 if props.has_key('password'):
771 password = self.form['password'].value.strip()
772 if not password:
773 # no password was supplied - don't change it
774 del props['password']
775 elif self.nodeid == self.getuid():
776 # this is the logged-in user's password
777 set_cookie = password
778 user.set(self.nodeid, **props)
779 # and some feedback for the user
780 message = _('%(changes)s edited ok')%{'changes':
781 ', '.join(props.keys())}
782 except:
783 self.db.rollback()
784 s = StringIO.StringIO()
785 traceback.print_exc(None, s)
786 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
787 else:
788 set_cookie = 0
790 # fix the cookie if the password has changed
791 if set_cookie:
792 self.set_cookie(self.user, set_cookie)
794 #
795 # now the display
796 #
797 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
799 # use the template to display the item
800 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
801 item.render(self.nodeid)
802 self.pagefoot()
804 def showfile(self):
805 ''' display a file
806 '''
807 nodeid = self.nodeid
808 cl = self.db.file
809 mime_type = cl.get(nodeid, 'type')
810 if mime_type == 'message/rfc822':
811 mime_type = 'text/plain'
812 self.header(headers={'Content-Type': mime_type})
813 self.write(cl.get(nodeid, 'content'))
815 def classes(self, message=None):
816 ''' display a list of all the classes in the database
817 '''
818 if self.user == 'admin':
819 self.pagehead(_('Table of classes'), message)
820 classnames = self.db.classes.keys()
821 classnames.sort()
822 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
823 for cn in classnames:
824 cl = self.db.getclass(cn)
825 self.write('<tr class="list-header"><th colspan=2 align=left>'
826 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
827 for key, value in cl.properties.items():
828 if value is None: value = ''
829 else: value = str(value)
830 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
831 key, cgi.escape(value)))
832 self.write('</table>')
833 self.pagefoot()
834 else:
835 raise Unauthorised
837 def login(self, message=None, newuser_form=None, action='index'):
838 '''Display a login page.
839 '''
840 self.pagehead(_('Login to roundup'), message)
841 self.write(_('''
842 <table>
843 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
844 <form onSubmit="return submit_once()" action="login_action" method=POST>
845 <input type="hidden" name="__destination_url" value="%(action)s">
846 <tr><td align=right>Login name: </td>
847 <td><input name="__login_name"></td></tr>
848 <tr><td align=right>Password: </td>
849 <td><input type="password" name="__login_password"></td></tr>
850 <tr><td></td>
851 <td><input type="submit" value="Log In"></td></tr>
852 </form>
853 ''')%locals())
854 if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
855 self.write('</table>')
856 self.pagefoot()
857 return
858 values = {'realname': '', 'organisation': '', 'address': '',
859 'phone': '', 'username': '', 'password': '', 'confirm': '',
860 'action': action, 'alternate_addresses': ''}
861 if newuser_form is not None:
862 for key in newuser_form.keys():
863 values[key] = newuser_form[key].value
864 self.write(_('''
865 <p>
866 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
867 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
868 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
869 <input type="hidden" name="__destination_url" value="%(action)s">
870 <tr><td align=right><em>Name: </em></td>
871 <td><input name="realname" value="%(realname)s" size=40></td></tr>
872 <tr><td align=right><em>Organisation: </em></td>
873 <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
874 <tr><td align=right>E-Mail Address: </td>
875 <td><input name="address" value="%(address)s" size=40></td></tr>
876 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
877 <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
878 <tr><td align=right><em>Phone: </em></td>
879 <td><input name="phone" value="%(phone)s"></td></tr>
880 <tr><td align=right>Preferred Login name: </td>
881 <td><input name="username" value="%(username)s"></td></tr>
882 <tr><td align=right>Password: </td>
883 <td><input type="password" name="password" value="%(password)s"></td></tr>
884 <tr><td align=right>Password Again: </td>
885 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
886 <tr><td></td>
887 <td><input type="submit" value="Register"></td></tr>
888 </form>
889 </table>
890 ''')%values)
891 self.pagefoot()
893 def login_action(self, message=None):
894 '''Attempt to log a user in and set the cookie
896 returns 0 if a page is generated as a result of this call, and
897 1 if not (ie. the login is successful
898 '''
899 if not self.form.has_key('__login_name'):
900 self.login(message=_('Username required'))
901 return 0
902 self.user = self.form['__login_name'].value
903 if self.form.has_key('__login_password'):
904 password = self.form['__login_password'].value
905 else:
906 password = ''
907 # make sure the user exists
908 try:
909 uid = self.db.user.lookup(self.user)
910 except KeyError:
911 name = self.user
912 self.make_user_anonymous()
913 action = self.form['__destination_url'].value
914 self.login(message=_('No such user "%(name)s"')%locals(),
915 action=action)
916 return 0
918 # and that the password is correct
919 pw = self.db.user.get(uid, 'password')
920 if password != pw:
921 self.make_user_anonymous()
922 action = self.form['__destination_url'].value
923 self.login(message=_('Incorrect password'), action=action)
924 return 0
926 self.set_cookie(self.user, password)
927 return 1
929 def newuser_action(self, message=None):
930 '''Attempt to create a new user based on the contents of the form
931 and then set the cookie.
933 return 1 on successful login
934 '''
935 # re-open the database as "admin"
936 self.db = self.instance.open('admin')
938 # TODO: pre-check the required fields and username key property
939 cl = self.db.user
940 try:
941 props = parsePropsFromForm(self.db, cl, self.form)
942 uid = cl.create(**props)
943 except ValueError, message:
944 action = self.form['__destination_url'].value
945 self.login(message, action=action)
946 return 0
947 self.user = cl.get(uid, 'username')
948 password = cl.get(uid, 'password')
949 self.set_cookie(self.user, self.form['password'].value)
950 return 1
952 def set_cookie(self, user, password):
953 # construct the cookie
954 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
955 if user[-1] == '=':
956 if user[-2] == '=':
957 user = user[:-2]
958 else:
959 user = user[:-1]
960 expire = Cookie._getdate(86400*365)
961 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
962 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
963 user, expire, path)})
965 def make_user_anonymous(self):
966 # make us anonymous if we can
967 try:
968 self.db.user.lookup('anonymous')
969 self.user = 'anonymous'
970 except KeyError:
971 self.user = None
973 def logout(self, message=None):
974 self.make_user_anonymous()
975 # construct the logout cookie
976 now = Cookie._getdate()
977 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
978 self.header({'Set-Cookie':
979 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
980 path)})
981 self.login()
983 def main(self):
984 '''Wrap the database accesses so we can close the database cleanly
985 '''
986 # determine the uid to use
987 self.db = self.instance.open('admin')
988 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
989 user = 'anonymous'
990 if (cookie.has_key('roundup_user') and
991 cookie['roundup_user'].value != 'deleted'):
992 cookie = cookie['roundup_user'].value
993 if len(cookie)%4:
994 cookie = cookie + '='*(4-len(cookie)%4)
995 try:
996 user, password = binascii.a2b_base64(cookie).split(':')
997 except (TypeError, binascii.Error, binascii.Incomplete):
998 # damaged cookie!
999 user, password = 'anonymous', ''
1001 # make sure the user exists
1002 try:
1003 uid = self.db.user.lookup(user)
1004 # now validate the password
1005 if password != self.db.user.get(uid, 'password'):
1006 user = 'anonymous'
1007 except KeyError:
1008 user = 'anonymous'
1010 # make sure the anonymous user is valid if we're using it
1011 if user == 'anonymous':
1012 self.make_user_anonymous()
1013 else:
1014 self.user = user
1016 # re-open the database for real, using the user
1017 self.db = self.instance.open(self.user)
1019 # now figure which function to call
1020 path = self.split_path
1022 # default action to index if the path has no information in it
1023 if not path or path[0] in ('', 'index'):
1024 action = 'index'
1025 else:
1026 action = path[0]
1028 # Everthing ignores path[1:]
1029 # - The file download link generator actually relies on this - it
1030 # appends the name of the file to the URL so the download file name
1031 # is correct, but doesn't actually use it.
1033 # everyone is allowed to try to log in
1034 if action == 'login_action':
1035 # try to login
1036 if not self.login_action():
1037 return
1038 # figure the resulting page
1039 action = self.form['__destination_url'].value
1040 if not action:
1041 action = 'index'
1042 self.do_action(action)
1043 return
1045 # allow anonymous people to register
1046 if action == 'newuser_action':
1047 # if we don't have a login and anonymous people aren't allowed to
1048 # register, then spit up the login form
1049 if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1050 if action == 'login':
1051 self.login() # go to the index after login
1052 else:
1053 self.login(action=action)
1054 return
1055 # try to add the user
1056 if not self.newuser_action():
1057 return
1058 # figure the resulting page
1059 action = self.form['__destination_url'].value
1060 if not action:
1061 action = 'index'
1063 # no login or registration, make sure totally anonymous access is OK
1064 elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1065 if action == 'login':
1066 self.login() # go to the index after login
1067 else:
1068 self.login(action=action)
1069 return
1071 # just a regular action
1072 self.do_action(action)
1074 # commit all changes to the database
1075 self.db.commit()
1077 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1078 nre=re.compile(r'new(\w+)')):
1079 '''Figure the user's action and do it.
1080 '''
1081 # here be the "normal" functionality
1082 if action == 'index':
1083 self.index()
1084 return
1085 if action == 'list_classes':
1086 self.classes()
1087 return
1088 if action == 'login':
1089 self.login()
1090 return
1091 if action == 'logout':
1092 self.logout()
1093 return
1095 # see if we're to display an existing node
1096 m = dre.match(action)
1097 if m:
1098 self.classname = m.group(1)
1099 self.nodeid = m.group(2)
1100 try:
1101 cl = self.db.classes[self.classname]
1102 except KeyError:
1103 raise NotFound
1104 try:
1105 cl.get(self.nodeid, 'id')
1106 except IndexError:
1107 raise NotFound
1108 try:
1109 func = getattr(self, 'show%s'%self.classname)
1110 except AttributeError:
1111 raise NotFound
1112 func()
1113 return
1115 # see if we're to put up the new node page
1116 m = nre.match(action)
1117 if m:
1118 self.classname = m.group(1)
1119 try:
1120 func = getattr(self, 'new%s'%self.classname)
1121 except AttributeError:
1122 raise NotFound
1123 func()
1124 return
1126 # otherwise, display the named class
1127 self.classname = action
1128 try:
1129 self.db.getclass(self.classname)
1130 except KeyError:
1131 raise NotFound
1132 self.list()
1135 class ExtendedClient(Client):
1136 '''Includes pages and page heading information that relate to the
1137 extended schema.
1138 '''
1139 showsupport = Client.shownode
1140 showtimelog = Client.shownode
1141 newsupport = Client.newnode
1142 newtimelog = Client.newnode
1144 default_index_sort = ['-activity']
1145 default_index_group = ['priority']
1146 default_index_filter = ['status']
1147 default_index_columns = ['activity','status','title','assignedto']
1148 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1150 def pagehead(self, title, message=None):
1151 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1152 machine = self.env['SERVER_NAME']
1153 port = self.env['SERVER_PORT']
1154 if port != '80': machine = machine + ':' + port
1155 base = urlparse.urlunparse(('http', machine, url, None, None, None))
1156 if message is not None:
1157 message = _('<div class="system-msg">%(message)s</div>')%locals()
1158 else:
1159 message = ''
1160 style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
1161 user_name = self.user or ''
1162 if self.user == 'admin':
1163 admin_links = _(' | <a href="list_classes">Class List</a>' \
1164 ' | <a href="user">User List</a>' \
1165 ' | <a href="newuser">Add User</a>')
1166 else:
1167 admin_links = ''
1168 if self.user not in (None, 'anonymous'):
1169 userid = self.db.user.lookup(self.user)
1170 user_info = _('''
1171 <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> |
1172 <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> |
1173 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1174 ''')%locals()
1175 else:
1176 user_info = _('<a href="login">Login</a>')
1177 if self.user is not None:
1178 add_links = _('''
1179 | Add
1180 <a href="newissue">Issue</a>,
1181 <a href="newsupport">Support</a>,
1182 ''')
1183 else:
1184 add_links = ''
1185 single_submit_script = self.single_submit_script
1186 self.write(_('''<html><head>
1187 <title>%(title)s</title>
1188 <style type="text/css">%(style)s</style>
1189 </head>
1190 %(single_submit_script)s
1191 <body bgcolor=#ffffff>
1192 %(message)s
1193 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1194 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1195 <td align=right valign=bottom>%(user_name)s</td></tr>
1196 <tr class="location-bar">
1197 <td align=left>All
1198 <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>,
1199 <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>
1200 | Unassigned
1201 <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>,
1202 <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>
1203 %(add_links)s
1204 %(admin_links)s</td>
1205 <td align=right>%(user_info)s</td>
1206 </table>
1207 ''')%locals())
1209 def parsePropsFromForm(db, cl, form, nodeid=0):
1210 '''Pull properties for the given class out of the form.
1211 '''
1212 props = {}
1213 keys = form.keys()
1214 num_re = re.compile('^\d+$')
1215 for key in keys:
1216 if not cl.properties.has_key(key):
1217 continue
1218 proptype = cl.properties[key]
1219 if isinstance(proptype, hyperdb.String):
1220 value = form[key].value.strip()
1221 elif isinstance(proptype, hyperdb.Password):
1222 value = password.Password(form[key].value.strip())
1223 elif isinstance(proptype, hyperdb.Date):
1224 value = form[key].value.strip()
1225 if value:
1226 value = date.Date(form[key].value.strip())
1227 else:
1228 value = None
1229 elif isinstance(proptype, hyperdb.Interval):
1230 value = form[key].value.strip()
1231 if value:
1232 value = date.Interval(form[key].value.strip())
1233 else:
1234 value = None
1235 elif isinstance(proptype, hyperdb.Link):
1236 value = form[key].value.strip()
1237 # see if it's the "no selection" choice
1238 if value == '-1':
1239 # don't set this property
1240 continue
1241 else:
1242 # handle key values
1243 link = cl.properties[key].classname
1244 if not num_re.match(value):
1245 try:
1246 value = db.classes[link].lookup(value)
1247 except KeyError:
1248 raise ValueError, _('property "%(propname)s": '
1249 '%(value)s not a %(classname)s')%{'propname':key,
1250 'value': value, 'classname': link}
1251 elif isinstance(proptype, hyperdb.Multilink):
1252 value = form[key]
1253 if type(value) != type([]):
1254 value = [i.strip() for i in value.value.split(',')]
1255 else:
1256 value = [i.value.strip() for i in value]
1257 link = cl.properties[key].classname
1258 l = []
1259 for entry in map(str, value):
1260 if entry == '': continue
1261 if not num_re.match(entry):
1262 try:
1263 entry = db.classes[link].lookup(entry)
1264 except KeyError:
1265 raise ValueError, _('property "%(propname)s": '
1266 '"%(value)s" not an entry of %(classname)s')%{
1267 'propname':key, 'value': entry, 'classname': link}
1268 l.append(entry)
1269 l.sort()
1270 value = l
1272 # get the old value
1273 if nodeid:
1274 try:
1275 existing = cl.get(nodeid, key)
1276 except KeyError:
1277 # this might be a new property for which there is no existing
1278 # value
1279 if not cl.properties.has_key(key): raise
1281 # if changed, set it
1282 if value != existing:
1283 props[key] = value
1284 else:
1285 props[key] = value
1286 return props
1288 #
1289 # $Log: not supported by cvs2svn $
1290 # Revision 1.102 2002/02/15 07:08:44 richard
1291 # . Alternate email addresses are now available for users. See the MIGRATION
1292 # file for info on how to activate the feature.
1293 #
1294 # Revision 1.101 2002/02/14 23:39:18 richard
1295 # . All forms now have "double-submit" protection when Javascript is enabled
1296 # on the client-side.
1297 #
1298 # Revision 1.100 2002/01/16 07:02:57 richard
1299 # . lots of date/interval related changes:
1300 # - more relaxed date format for input
1301 #
1302 # Revision 1.99 2002/01/16 03:02:42 richard
1303 # #503793 ] changing assignedto resets nosy list
1304 #
1305 # Revision 1.98 2002/01/14 02:20:14 richard
1306 # . changed all config accesses so they access either the instance or the
1307 # config attriubute on the db. This means that all config is obtained from
1308 # instance_config instead of the mish-mash of classes. This will make
1309 # switching to a ConfigParser setup easier too, I hope.
1310 #
1311 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1312 # 0.5.0 switch, I hope!)
1313 #
1314 # Revision 1.97 2002/01/11 23:22:29 richard
1315 # . #502437 ] rogue reactor and unittest
1316 # in short, the nosy reactor was modifying the nosy list. That code had
1317 # been there for a long time, and I suspsect it was there because we
1318 # weren't generating the nosy list correctly in other places of the code.
1319 # We're now doing that, so the nosy-modifying code can go away from the
1320 # nosy reactor.
1321 #
1322 # Revision 1.96 2002/01/10 05:26:10 richard
1323 # missed a parsePropsFromForm in last update
1324 #
1325 # Revision 1.95 2002/01/10 03:39:45 richard
1326 # . fixed some problems with web editing and change detection
1327 #
1328 # Revision 1.94 2002/01/09 13:54:21 grubert
1329 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1330 #
1331 # Revision 1.93 2002/01/08 11:57:12 richard
1332 # crying out for real configuration handling... :(
1333 #
1334 # Revision 1.92 2002/01/08 04:12:05 richard
1335 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1336 #
1337 # Revision 1.91 2002/01/08 04:03:47 richard
1338 # I mucked the intent of the code up.
1339 #
1340 # Revision 1.90 2002/01/08 03:56:55 richard
1341 # Oops, missed this before the beta:
1342 # . #495392 ] empty nosy -patch
1343 #
1344 # Revision 1.89 2002/01/07 20:24:45 richard
1345 # *mutter* stupid cutnpaste
1346 #
1347 # Revision 1.88 2002/01/02 02:31:38 richard
1348 # Sorry for the huge checkin message - I was only intending to implement #496356
1349 # but I found a number of places where things had been broken by transactions:
1350 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1351 # for _all_ roundup-generated smtp messages to be sent to.
1352 # . the transaction cache had broken the roundupdb.Class set() reactors
1353 # . newly-created author users in the mailgw weren't being committed to the db
1354 #
1355 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1356 # on when I found that stuff :):
1357 # . #496356 ] Use threading in messages
1358 # . detectors were being registered multiple times
1359 # . added tests for mailgw
1360 # . much better attaching of erroneous messages in the mail gateway
1361 #
1362 # Revision 1.87 2001/12/23 23:18:49 richard
1363 # We already had an admin-specific section of the web heading, no need to add
1364 # another one :)
1365 #
1366 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1367 # Features added:
1368 # . Multilink properties are now displayed as comma separated values in
1369 # a textbox
1370 # . The add user link is now only visible to the admin user
1371 # . Modified the mail gateway to reject submissions from unknown
1372 # addresses if ANONYMOUS_ACCESS is denied
1373 #
1374 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1375 # Bugs fixed:
1376 # . Exception handling in hyperdb for strings-that-look-like numbers got
1377 # lost somewhere
1378 # . Internet Explorer submits full path for filename - we now strip away
1379 # the path
1380 # Features added:
1381 # . Link and multilink properties are now displayed sorted in the cgi
1382 # interface
1383 #
1384 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1385 # Fixed bugs:
1386 # . Fixed file creation and retrieval in same transaction in anydbm
1387 # backend
1388 # . Cgi interface now renders new issue after issue creation
1389 # . Could not set issue status to resolved through cgi interface
1390 # . Mail gateway was changing status back to 'chatting' if status was
1391 # omitted as an argument
1392 #
1393 # Revision 1.83 2001/12/15 23:51:01 richard
1394 # Tested the changes and fixed a few problems:
1395 # . files are now attached to the issue as well as the message
1396 # . newuser is a real method now since we don't want to do the message/file
1397 # stuff for it
1398 # . added some documentation
1399 # The really big changes in the diff are a result of me moving some code
1400 # around to keep like methods together a bit better.
1401 #
1402 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1403 # . Modified cgi interface to change properties only once all changes are
1404 # collected, files created and messages generated.
1405 # . Moved generation of change note to nosyreactors.
1406 # . We now check for changes to "assignedto" to ensure it's added to the
1407 # nosy list.
1408 #
1409 # Revision 1.81 2001/12/12 23:55:00 richard
1410 # Fixed some problems with user editing
1411 #
1412 # Revision 1.80 2001/12/12 23:27:14 richard
1413 # Added a Zope frontend for roundup.
1414 #
1415 # Revision 1.79 2001/12/10 22:20:01 richard
1416 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1417 # where possible, only replacing methods where the db is opened (it uses the
1418 # btree opener specifically.)
1419 # Also cleaned up some change note generation.
1420 # Made the backends package work with pydoc too.
1421 #
1422 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1423 # Fixed small bug that prevented adding issues through the web.
1424 #
1425 # Revision 1.77 2001/12/06 22:48:29 richard
1426 # files multilink was being nuked in post_edit_node
1427 #
1428 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1429 # Removed generation of change note from "sendmessage" in roundupdb.py.
1430 # The change note is now generated when the message is created.
1431 #
1432 # Revision 1.75 2001/12/04 01:25:08 richard
1433 # Added some rollbacks where we were catching exceptions that would otherwise
1434 # have stopped committing.
1435 #
1436 # Revision 1.74 2001/12/02 05:06:16 richard
1437 # . We now use weakrefs in the Classes to keep the database reference, so
1438 # the close() method on the database is no longer needed.
1439 # I bumped the minimum python requirement up to 2.1 accordingly.
1440 # . #487480 ] roundup-server
1441 # . #487476 ] INSTALL.txt
1442 #
1443 # I also cleaned up the change message / post-edit stuff in the cgi client.
1444 # There's now a clearly marked "TODO: append the change note" where I believe
1445 # the change note should be added there. The "changes" list will obviously
1446 # have to be modified to be a dict of the changes, or somesuch.
1447 #
1448 # More testing needed.
1449 #
1450 # Revision 1.73 2001/12/01 07:17:50 richard
1451 # . We now have basic transaction support! Information is only written to
1452 # the database when the commit() method is called. Only the anydbm
1453 # backend is modified in this way - neither of the bsddb backends have been.
1454 # The mail, admin and cgi interfaces all use commit (except the admin tool
1455 # doesn't have a commit command, so interactive users can't commit...)
1456 # . Fixed login/registration forwarding the user to the right page (or not,
1457 # on a failure)
1458 #
1459 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1460 # Links in page header are now consistent with default sort order.
1461 #
1462 # Fixed bugs:
1463 # - When login failed the list of issues were still rendered.
1464 # - User was redirected to index page and not to his destination url
1465 # if his first login attempt failed.
1466 #
1467 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1468 # Property changes are now completely traceable, whether changes are
1469 # made through the web or by email
1470 #
1471 # Revision 1.70 2001/11/30 00:06:29 richard
1472 # Converted roundup/cgi_client.py to use _()
1473 # Added the status file, I18N_PROGRESS.txt
1474 #
1475 # Revision 1.69 2001/11/29 23:19:51 richard
1476 # Removed the "This issue has been edited through the web" when a valid
1477 # change note is supplied.
1478 #
1479 # Revision 1.68 2001/11/29 04:57:23 richard
1480 # a little comment
1481 #
1482 # Revision 1.67 2001/11/28 21:55:35 richard
1483 # . login_action and newuser_action return values were being ignored
1484 # . Woohoo! Found that bloody re-login bug that was killing the mail
1485 # gateway.
1486 # (also a minor cleanup in hyperdb)
1487 #
1488 # Revision 1.66 2001/11/27 03:00:50 richard
1489 # couple of bugfixes from latest patch integration
1490 #
1491 # Revision 1.65 2001/11/26 23:00:53 richard
1492 # This config stuff is getting to be a real mess...
1493 #
1494 # Revision 1.64 2001/11/26 22:56:35 richard
1495 # typo
1496 #
1497 # Revision 1.63 2001/11/26 22:55:56 richard
1498 # Feature:
1499 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1500 # the instance.
1501 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1502 # signature info in e-mails.
1503 # . Some more flexibility in the mail gateway and more error handling.
1504 # . Login now takes you to the page you back to the were denied access to.
1505 #
1506 # Fixed:
1507 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1508 #
1509 # Revision 1.62 2001/11/24 00:45:42 jhermann
1510 # typeof() instead of type(): avoid clash with database field(?) "type"
1511 #
1512 # Fixes this traceback:
1513 #
1514 # Traceback (most recent call last):
1515 # File "roundup\cgi_client.py", line 535, in newnode
1516 # self._post_editnode(nid)
1517 # File "roundup\cgi_client.py", line 415, in _post_editnode
1518 # if type(value) != type([]): value = [value]
1519 # UnboundLocalError: local variable 'type' referenced before assignment
1520 #
1521 # Revision 1.61 2001/11/22 15:46:42 jhermann
1522 # Added module docstrings to all modules.
1523 #
1524 # Revision 1.60 2001/11/21 22:57:28 jhermann
1525 # Added dummy hooks for I18N and some preliminary (test) markup of
1526 # translatable messages
1527 #
1528 # Revision 1.59 2001/11/21 03:21:13 richard
1529 # oops
1530 #
1531 # Revision 1.58 2001/11/21 03:11:28 richard
1532 # Better handling of new properties.
1533 #
1534 # Revision 1.57 2001/11/15 10:24:27 richard
1535 # handle the case where there is no file attached
1536 #
1537 # Revision 1.56 2001/11/14 21:35:21 richard
1538 # . users may attach files to issues (and support in ext) through the web now
1539 #
1540 # Revision 1.55 2001/11/07 02:34:06 jhermann
1541 # Handling of damaged login cookies
1542 #
1543 # Revision 1.54 2001/11/07 01:16:12 richard
1544 # Remove the '=' padding from cookie value so quoting isn't an issue.
1545 #
1546 # Revision 1.53 2001/11/06 23:22:05 jhermann
1547 # More IE fixes: it does not like quotes around cookie values; in the
1548 # hope this does not break anything for other browser; if it does, we
1549 # need to check HTTP_USER_AGENT
1550 #
1551 # Revision 1.52 2001/11/06 23:11:22 jhermann
1552 # Fixed debug output in page footer; added expiry date to the login cookie
1553 # (expires 1 year in the future) to prevent probs with certain versions
1554 # of IE
1555 #
1556 # Revision 1.51 2001/11/06 22:00:34 jhermann
1557 # Get debug level from ROUNDUP_DEBUG env var
1558 #
1559 # Revision 1.50 2001/11/05 23:45:40 richard
1560 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1561 # Also made it present nicer error messages (not tracebacks).
1562 #
1563 # Revision 1.49 2001/11/04 03:07:12 richard
1564 # Fixed various cookie-related bugs:
1565 # . bug #477685 ] base64.decodestring breaks
1566 # . bug #477837 ] lynx does not like the cookie
1567 # . bug #477892 ] Password edit doesn't fix login cookie
1568 # Also closed a security hole - a logged-in user could edit another user's
1569 # details.
1570 #
1571 # Revision 1.48 2001/11/03 01:30:18 richard
1572 # Oops. uses pagefoot now.
1573 #
1574 # Revision 1.47 2001/11/03 01:29:28 richard
1575 # Login page didn't have all close tags.
1576 #
1577 # Revision 1.46 2001/11/03 01:26:55 richard
1578 # possibly fix truncated base64'ed user:pass
1579 #
1580 # Revision 1.45 2001/11/01 22:04:37 richard
1581 # Started work on supporting a pop3-fetching server
1582 # Fixed bugs:
1583 # . bug #477104 ] HTML tag error in roundup-server
1584 # . bug #477107 ] HTTP header problem
1585 #
1586 # Revision 1.44 2001/10/28 23:03:08 richard
1587 # Added more useful header to the classic schema.
1588 #
1589 # Revision 1.43 2001/10/24 00:01:42 richard
1590 # More fixes to lockout logic.
1591 #
1592 # Revision 1.42 2001/10/23 23:56:03 richard
1593 # HTML typo
1594 #
1595 # Revision 1.41 2001/10/23 23:52:35 richard
1596 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1597 #
1598 # Revision 1.40 2001/10/23 23:06:39 richard
1599 # Some cleanup.
1600 #
1601 # Revision 1.39 2001/10/23 01:00:18 richard
1602 # Re-enabled login and registration access after lopping them off via
1603 # disabling access for anonymous users.
1604 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1605 # a couple of bugs while I was there. Probably introduced a couple, but
1606 # things seem to work OK at the moment.
1607 #
1608 # Revision 1.38 2001/10/22 03:25:01 richard
1609 # Added configuration for:
1610 # . anonymous user access and registration (deny/allow)
1611 # . filter "widget" location on index page (top, bottom, both)
1612 # Updated some documentation.
1613 #
1614 # Revision 1.37 2001/10/21 07:26:35 richard
1615 # feature #473127: Filenames. I modified the file.index and htmltemplate
1616 # source so that the filename is used in the link and the creation
1617 # information is displayed.
1618 #
1619 # Revision 1.36 2001/10/21 04:44:50 richard
1620 # bug #473124: UI inconsistency with Link fields.
1621 # This also prompted me to fix a fairly long-standing usability issue -
1622 # that of being able to turn off certain filters.
1623 #
1624 # Revision 1.35 2001/10/21 00:17:54 richard
1625 # CGI interface view customisation section may now be hidden (patch from
1626 # Roch'e Compaan.)
1627 #
1628 # Revision 1.34 2001/10/20 11:58:48 richard
1629 # Catch errors in login - no username or password supplied.
1630 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1631 #
1632 # Revision 1.33 2001/10/17 00:18:41 richard
1633 # Manually constructing cookie headers now.
1634 #
1635 # Revision 1.32 2001/10/16 03:36:21 richard
1636 # CGI interface wasn't handling checkboxes at all.
1637 #
1638 # Revision 1.31 2001/10/14 10:55:00 richard
1639 # Handle empty strings in HTML template Link function
1640 #
1641 # Revision 1.30 2001/10/09 07:38:58 richard
1642 # Pushed the base code for the extended schema CGI interface back into the
1643 # code cgi_client module so that future updates will be less painful.
1644 # Also removed a debugging print statement from cgi_client.
1645 #
1646 # Revision 1.29 2001/10/09 07:25:59 richard
1647 # Added the Password property type. See "pydoc roundup.password" for
1648 # implementation details. Have updated some of the documentation too.
1649 #
1650 # Revision 1.28 2001/10/08 00:34:31 richard
1651 # Change message was stuffing up for multilinks with no key property.
1652 #
1653 # Revision 1.27 2001/10/05 02:23:24 richard
1654 # . roundup-admin create now prompts for property info if none is supplied
1655 # on the command-line.
1656 # . hyperdb Class getprops() method may now return only the mutable
1657 # properties.
1658 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1659 # now support anonymous user access (read-only, unless there's an
1660 # "anonymous" user, in which case write access is permitted). Login
1661 # handling has been moved into cgi_client.Client.main()
1662 # . The "extended" schema is now the default in roundup init.
1663 # . The schemas have had their page headings modified to cope with the new
1664 # login handling. Existing installations should copy the interfaces.py
1665 # file from the roundup lib directory to their instance home.
1666 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1667 # Ping - has been removed.
1668 # . Fixed a whole bunch of places in the CGI interface where we should have
1669 # been returning Not Found instead of throwing an exception.
1670 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1671 # an item now throws an exception.
1672 #
1673 # Revision 1.26 2001/09/12 08:31:42 richard
1674 # handle cases where mime type is not guessable
1675 #
1676 # Revision 1.25 2001/08/29 05:30:49 richard
1677 # change messages weren't being saved when there was no-one on the nosy list.
1678 #
1679 # Revision 1.24 2001/08/29 04:49:39 richard
1680 # didn't clean up fully after debugging :(
1681 #
1682 # Revision 1.23 2001/08/29 04:47:18 richard
1683 # Fixed CGI client change messages so they actually include the properties
1684 # changed (again).
1685 #
1686 # Revision 1.22 2001/08/17 00:08:10 richard
1687 # reverted back to sending messages always regardless of who is doing the web
1688 # edit. change notes weren't being saved. bleah. hackish.
1689 #
1690 # Revision 1.21 2001/08/15 23:43:18 richard
1691 # Fixed some isFooTypes that I missed.
1692 # Refactored some code in the CGI code.
1693 #
1694 # Revision 1.20 2001/08/12 06:32:36 richard
1695 # using isinstance(blah, Foo) now instead of isFooType
1696 #
1697 # Revision 1.19 2001/08/07 00:24:42 richard
1698 # stupid typo
1699 #
1700 # Revision 1.18 2001/08/07 00:15:51 richard
1701 # Added the copyright/license notice to (nearly) all files at request of
1702 # Bizar Software.
1703 #
1704 # Revision 1.17 2001/08/02 06:38:17 richard
1705 # Roundupdb now appends "mailing list" information to its messages which
1706 # include the e-mail address and web interface address. Templates may
1707 # override this in their db classes to include specific information (support
1708 # instructions, etc).
1709 #
1710 # Revision 1.16 2001/08/02 05:55:25 richard
1711 # Web edit messages aren't sent to the person who did the edit any more. No
1712 # message is generated if they are the only person on the nosy list.
1713 #
1714 # Revision 1.15 2001/08/02 00:34:10 richard
1715 # bleah syntax error
1716 #
1717 # Revision 1.14 2001/08/02 00:26:16 richard
1718 # Changed the order of the information in the message generated by web edits.
1719 #
1720 # Revision 1.13 2001/07/30 08:12:17 richard
1721 # Added time logging and file uploading to the templates.
1722 #
1723 # Revision 1.12 2001/07/30 06:26:31 richard
1724 # Added some documentation on how the newblah works.
1725 #
1726 # Revision 1.11 2001/07/30 06:17:45 richard
1727 # Features:
1728 # . Added ability for cgi newblah forms to indicate that the new node
1729 # should be linked somewhere.
1730 # Fixed:
1731 # . Fixed the agument handling for the roundup-admin find command.
1732 # . Fixed handling of summary when no note supplied for newblah. Again.
1733 # . Fixed detection of no form in htmltemplate Field display.
1734 #
1735 # Revision 1.10 2001/07/30 02:37:34 richard
1736 # Temporary measure until we have decent schema migration...
1737 #
1738 # Revision 1.9 2001/07/30 01:25:07 richard
1739 # Default implementation is now "classic" rather than "extended" as one would
1740 # expect.
1741 #
1742 # Revision 1.8 2001/07/29 08:27:40 richard
1743 # Fixed handling of passed-in values in form elements (ie. during a
1744 # drill-down)
1745 #
1746 # Revision 1.7 2001/07/29 07:01:39 richard
1747 # Added vim command to all source so that we don't get no steenkin' tabs :)
1748 #
1749 # Revision 1.6 2001/07/29 04:04:00 richard
1750 # Moved some code around allowing for subclassing to change behaviour.
1751 #
1752 # Revision 1.5 2001/07/28 08:16:52 richard
1753 # New issue form handles lack of note better now.
1754 #
1755 # Revision 1.4 2001/07/28 00:34:34 richard
1756 # Fixed some non-string node ids.
1757 #
1758 # Revision 1.3 2001/07/23 03:56:30 richard
1759 # oops, missed a config removal
1760 #
1761 # Revision 1.2 2001/07/22 12:09:32 richard
1762 # Final commit of Grande Splite
1763 #
1764 # Revision 1.1 2001/07/22 11:58:35 richard
1765 # More Grande Splite
1766 #
1767 #
1768 # vim: set filetype=python ts=4 sw=4 et si