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