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