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: roundupdb.py,v 1.41 2002-01-15 00:12:40 richard Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, copy, time, random
25 import mimetools, MimeWriter, cStringIO
26 import base64, mimetypes
28 import hyperdb, date
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 class DesignatorError(ValueError):
35 pass
36 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
37 ''' Take a foo123 and return ('foo', 123)
38 '''
39 m = dre.match(designator)
40 if m is None:
41 raise DesignatorError, '"%s" not a node designator'%designator
42 return m.group(1), m.group(2)
45 class Database:
46 def getuid(self):
47 """Return the id of the "user" node associated with the user
48 that owns this connection to the hyperdatabase."""
49 return self.user.lookup(self.journaltag)
51 def uidFromAddress(self, address, create=1):
52 ''' address is from the rfc822 module, and therefore is (name, addr)
54 user is created if they don't exist in the db already
55 '''
56 (realname, address) = address
57 users = self.user.stringFind(address=address)
58 for dummy in range(2):
59 if len(users) > 1:
60 # make sure we don't match the anonymous or admin user
61 for user in users:
62 if user == '1': continue
63 if self.user.get(user, 'username') == 'anonymous': continue
64 # first valid match will do
65 return user
66 # well, I guess we have no choice
67 return user[0]
68 elif users:
69 return users[0]
70 # try to match the username to the address (for local
71 # submissions where the address is empty)
72 users = self.user.stringFind(username=address)
74 # couldn't match address or username, so create a new user
75 if create:
76 print 'CREATING USER', address
77 return self.user.create(username=address, address=address,
78 realname=realname)
79 else:
80 return 0
82 _marker = []
83 # XXX: added the 'creator' faked attribute
84 class Class(hyperdb.Class):
85 # Overridden methods:
86 def __init__(self, db, classname, **properties):
87 if (properties.has_key('creation') or properties.has_key('activity')
88 or properties.has_key('creator')):
89 raise ValueError, '"creation", "activity" and "creator" are reserved'
90 hyperdb.Class.__init__(self, db, classname, **properties)
91 self.auditors = {'create': [], 'set': [], 'retire': []}
92 self.reactors = {'create': [], 'set': [], 'retire': []}
94 def create(self, **propvalues):
95 """These operations trigger detectors and can be vetoed. Attempts
96 to modify the "creation" or "activity" properties cause a KeyError.
97 """
98 if propvalues.has_key('creation') or propvalues.has_key('activity'):
99 raise KeyError, '"creation" and "activity" are reserved'
100 for audit in self.auditors['create']:
101 audit(self.db, self, None, propvalues)
102 nodeid = hyperdb.Class.create(self, **propvalues)
103 for react in self.reactors['create']:
104 react(self.db, self, nodeid, None)
105 return nodeid
107 def set(self, nodeid, **propvalues):
108 """These operations trigger detectors and can be vetoed. Attempts
109 to modify the "creation" or "activity" properties cause a KeyError.
110 """
111 if propvalues.has_key('creation') or propvalues.has_key('activity'):
112 raise KeyError, '"creation" and "activity" are reserved'
113 for audit in self.auditors['set']:
114 audit(self.db, self, nodeid, propvalues)
115 # Take a copy of the node dict so that the subsequent set
116 # operation doesn't modify the oldvalues structure.
117 try:
118 # try not using the cache initially
119 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
120 cache=0))
121 except IndexError:
122 # this will be needed if somone does a create() and set()
123 # with no intervening commit()
124 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
125 hyperdb.Class.set(self, nodeid, **propvalues)
126 for react in self.reactors['set']:
127 react(self.db, self, nodeid, oldvalues)
129 def retire(self, nodeid):
130 """These operations trigger detectors and can be vetoed. Attempts
131 to modify the "creation" or "activity" properties cause a KeyError.
132 """
133 for audit in self.auditors['retire']:
134 audit(self.db, self, nodeid, None)
135 hyperdb.Class.retire(self, nodeid)
136 for react in self.reactors['retire']:
137 react(self.db, self, nodeid, None)
139 def get(self, nodeid, propname, default=_marker, cache=1):
140 """Attempts to get the "creation" or "activity" properties should
141 do the right thing.
142 """
143 if propname == 'creation':
144 journal = self.db.getjournal(self.classname, nodeid)
145 if journal:
146 return self.db.getjournal(self.classname, nodeid)[0][1]
147 else:
148 # on the strange chance that there's no journal
149 return date.Date()
150 if propname == 'activity':
151 journal = self.db.getjournal(self.classname, nodeid)
152 if journal:
153 return self.db.getjournal(self.classname, nodeid)[-1][1]
154 else:
155 # on the strange chance that there's no journal
156 return date.Date()
157 if propname == 'creator':
158 journal = self.db.getjournal(self.classname, nodeid)
159 if journal:
160 name = self.db.getjournal(self.classname, nodeid)[0][2]
161 else:
162 return None
163 return self.db.user.lookup(name)
164 if default is not _marker:
165 return hyperdb.Class.get(self, nodeid, propname, default,
166 cache=cache)
167 else:
168 return hyperdb.Class.get(self, nodeid, propname, cache=cache)
170 def getprops(self, protected=1):
171 """In addition to the actual properties on the node, these
172 methods provide the "creation" and "activity" properties. If the
173 "protected" flag is true, we include protected properties - those
174 which may not be modified.
175 """
176 d = hyperdb.Class.getprops(self, protected=protected).copy()
177 if protected:
178 d['creation'] = hyperdb.Date()
179 d['activity'] = hyperdb.Date()
180 d['creator'] = hyperdb.Link("user")
181 return d
183 #
184 # Detector interface
185 #
186 def audit(self, event, detector):
187 """Register a detector
188 """
189 l = self.auditors[event]
190 if detector not in l:
191 self.auditors[event].append(detector)
193 def react(self, event, detector):
194 """Register a detector
195 """
196 l = self.reactors[event]
197 if detector not in l:
198 self.reactors[event].append(detector)
201 class FileClass(Class):
202 def create(self, **propvalues):
203 ''' snaffle the file propvalue and store in a file
204 '''
205 content = propvalues['content']
206 del propvalues['content']
207 newid = Class.create(self, **propvalues)
208 self.db.storefile(self.classname, newid, None, content)
209 return newid
211 def get(self, nodeid, propname, default=_marker, cache=1):
212 ''' trap the content propname and get it from the file
213 '''
214 if propname == 'content':
215 return self.db.getfile(self.classname, nodeid, None)
216 if default is not _marker:
217 return Class.get(self, nodeid, propname, default, cache=cache)
218 else:
219 return Class.get(self, nodeid, propname, cache=cache)
221 def getprops(self, protected=1):
222 ''' In addition to the actual properties on the node, these methods
223 provide the "content" property. If the "protected" flag is true,
224 we include protected properties - those which may not be
225 modified.
226 '''
227 d = Class.getprops(self, protected=protected).copy()
228 if protected:
229 d['content'] = hyperdb.String()
230 return d
232 class MessageSendError(RuntimeError):
233 pass
235 class DetectorError(RuntimeError):
236 pass
238 # XXX deviation from spec - was called ItemClass
239 class IssueClass(Class):
241 # Overridden methods:
243 def __init__(self, db, classname, **properties):
244 """The newly-created class automatically includes the "messages",
245 "files", "nosy", and "superseder" properties. If the 'properties'
246 dictionary attempts to specify any of these properties or a
247 "creation" or "activity" property, a ValueError is raised."""
248 if not properties.has_key('title'):
249 properties['title'] = hyperdb.String()
250 if not properties.has_key('messages'):
251 properties['messages'] = hyperdb.Multilink("msg")
252 if not properties.has_key('files'):
253 properties['files'] = hyperdb.Multilink("file")
254 if not properties.has_key('nosy'):
255 properties['nosy'] = hyperdb.Multilink("user")
256 if not properties.has_key('superseder'):
257 properties['superseder'] = hyperdb.Multilink(classname)
258 Class.__init__(self, db, classname, **properties)
260 # New methods:
262 def addmessage(self, nodeid, summary, text):
263 """Add a message to an issue's mail spool.
265 A new "msg" node is constructed using the current date, the user that
266 owns the database connection as the author, and the specified summary
267 text.
269 The "files" and "recipients" fields are left empty.
271 The given text is saved as the body of the message and the node is
272 appended to the "messages" field of the specified issue.
273 """
275 def sendmessage(self, nodeid, msgid, change_note):
276 """Send a message to the members of an issue's nosy list.
278 The message is sent only to users on the nosy list who are not
279 already on the "recipients" list for the message.
281 These users are then added to the message's "recipients" list.
282 """
283 users = self.db.user
284 messages = self.db.msg
285 files = self.db.file
287 # figure the recipient ids
288 sendto = []
289 r = {}
290 recipients = messages.get(msgid, 'recipients')
291 for recipid in messages.get(msgid, 'recipients'):
292 r[recipid] = 1
294 # figure the author's id, and indicate they've received the message
295 authid = messages.get(msgid, 'author')
297 # get the current nosy list, we'll need it
298 nosy = self.get(nodeid, 'nosy')
300 # possibly send the message to the author, as long as they aren't
301 # anonymous
302 if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
303 users.get(authid, 'username') != 'anonymous'):
304 sendto.append(authid)
305 r[authid] = 1
307 # now figure the nosy people who weren't recipients
308 for nosyid in nosy:
309 # Don't send nosy mail to the anonymous user (that user
310 # shouldn't appear in the nosy list, but just in case they
311 # do...)
312 if users.get(nosyid, 'username') == 'anonymous':
313 continue
314 # make sure they haven't seen the message already
315 if not r.has_key(nosyid):
316 # send it to them
317 sendto.append(nosyid)
318 recipients.append(nosyid)
320 # no new recipients
321 if not sendto:
322 return
324 # determine the messageid and inreplyto of the message
325 inreplyto = messages.get(msgid, 'inreplyto')
326 messageid = messages.get(msgid, 'messageid')
327 if not messageid:
328 # this is an old message that didn't get a messageid, so
329 # create one
330 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
331 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
332 messages.set(msgid, messageid=messageid)
334 # update the message's recipients list
335 messages.set(msgid, recipients=recipients)
337 # send an email to the people who missed out
338 sendto = [users.get(i, 'address') for i in sendto]
339 cn = self.classname
340 title = self.get(nodeid, 'title') or '%s message copy'%cn
341 # figure author information
342 authname = users.get(authid, 'realname')
343 if not authname:
344 authname = users.get(authid, 'username')
345 authaddr = users.get(authid, 'address')
346 if authaddr:
347 authaddr = ' <%s>'%authaddr
348 else:
349 authaddr = ''
351 # make the message body
352 m = ['']
354 # put in roundup's signature
355 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
356 m.append(self.email_signature(nodeid, msgid))
358 # add author information
359 if len(self.get(nodeid,'messages')) == 1:
360 m.append("New submission from %s%s:"%(authname, authaddr))
361 else:
362 m.append("%s%s added the comment:"%(authname, authaddr))
363 m.append('')
365 # add the content
366 m.append(messages.get(msgid, 'content'))
368 # add the change note
369 if change_note:
370 m.append(change_note)
372 # put in roundup's signature
373 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
374 m.append(self.email_signature(nodeid, msgid))
376 # get the files for this message
377 message_files = messages.get(msgid, 'files')
379 # create the message
380 message = cStringIO.StringIO()
381 writer = MimeWriter.MimeWriter(message)
382 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
383 writer.addheader('To', ', '.join(sendto))
384 writer.addheader('From', '%s <%s>'%(authname,
385 self.db.config.ISSUE_TRACKER_EMAIL))
386 writer.addheader('Reply-To', '%s <%s>'%(self.db.config.INSTANCE_NAME,
387 self.db.config.ISSUE_TRACKER_EMAIL))
388 writer.addheader('MIME-Version', '1.0')
389 if messageid:
390 writer.addheader('Message-Id', messageid)
391 if inreplyto:
392 writer.addheader('In-Reply-To', inreplyto)
394 # attach files
395 if message_files:
396 part = writer.startmultipartbody('mixed')
397 part = writer.nextpart()
398 body = part.startbody('text/plain')
399 body.write('\n'.join(m))
400 for fileid in message_files:
401 name = files.get(fileid, 'name')
402 mime_type = files.get(fileid, 'type')
403 content = files.get(fileid, 'content')
404 part = writer.nextpart()
405 if mime_type == 'text/plain':
406 part.addheader('Content-Disposition',
407 'attachment;\n filename="%s"'%name)
408 part.addheader('Content-Transfer-Encoding', '7bit')
409 body = part.startbody('text/plain')
410 body.write(content)
411 else:
412 # some other type, so encode it
413 if not mime_type:
414 # this should have been done when the file was saved
415 mime_type = mimetypes.guess_type(name)[0]
416 if mime_type is None:
417 mime_type = 'application/octet-stream'
418 part.addheader('Content-Disposition',
419 'attachment;\n filename="%s"'%name)
420 part.addheader('Content-Transfer-Encoding', 'base64')
421 body = part.startbody(mime_type)
422 body.write(base64.encodestring(content))
423 writer.lastpart()
424 else:
425 body = writer.startbody('text/plain')
426 body.write('\n'.join(m))
428 # now try to send the message
429 if SENDMAILDEBUG:
430 open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
431 self.db.config.ADMIN_EMAIL,', '.join(sendto),message.getvalue()))
432 else:
433 try:
434 # send the message as admin so bounces are sent there
435 # instead of to roundup
436 smtp = smtplib.SMTP(self.db.config.MAILHOST)
437 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
438 message.getvalue())
439 except socket.error, value:
440 raise MessageSendError, \
441 "Couldn't send confirmation email: mailhost %s"%value
442 except smtplib.SMTPException, value:
443 raise MessageSendError, \
444 "Couldn't send confirmation email: %s"%value
446 def email_signature(self, nodeid, msgid):
447 ''' Add a signature to the e-mail with some useful information
448 '''
449 web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid
450 email = '"%s" <%s>'%(self.db.config.INSTANCE_NAME,
451 self.db.config.ISSUE_TRACKER_EMAIL)
452 line = '_' * max(len(web), len(email))
453 return '%s\n%s\n%s\n%s'%(line, email, web, line)
455 def generateCreateNote(self, nodeid):
456 """Generate a create note that lists initial property values
457 """
458 cn = self.classname
459 cl = self.db.classes[cn]
460 props = cl.getprops(protected=0)
462 # list the values
463 m = []
464 l = props.items()
465 l.sort()
466 for propname, prop in l:
467 value = cl.get(nodeid, propname, None)
468 # skip boring entries
469 if not value:
470 continue
471 if isinstance(prop, hyperdb.Link):
472 link = self.db.classes[prop.classname]
473 if value:
474 key = link.labelprop(default_to_id=1)
475 if key:
476 value = link.get(value, key)
477 else:
478 value = ''
479 elif isinstance(prop, hyperdb.Multilink):
480 if value is None: value = []
481 l = []
482 link = self.db.classes[prop.classname]
483 key = link.labelprop(default_to_id=1)
484 if key:
485 value = [link.get(entry, key) for entry in value]
486 value = ', '.join(value)
487 m.append('%s: %s'%(propname, value))
488 m.insert(0, '----------')
489 m.insert(0, '')
490 return '\n'.join(m)
492 def generateChangeNote(self, nodeid, oldvalues):
493 """Generate a change note that lists property changes
494 """
495 cn = self.classname
496 cl = self.db.classes[cn]
497 changed = {}
498 props = cl.getprops(protected=0)
500 # determine what changed
501 for key in oldvalues.keys():
502 if key in ['files','messages']: continue
503 new_value = cl.get(nodeid, key)
504 # the old value might be non existent
505 try:
506 old_value = oldvalues[key]
507 if type(new_value) is type([]):
508 new_value.sort()
509 old_value.sort()
510 if new_value != old_value:
511 changed[key] = old_value
512 except:
513 changed[key] = new_value
515 # list the changes
516 m = []
517 for propname, oldvalue in changed.items():
518 prop = cl.properties[propname]
519 value = cl.get(nodeid, propname, None)
520 if isinstance(prop, hyperdb.Link):
521 link = self.db.classes[prop.classname]
522 key = link.labelprop(default_to_id=1)
523 if key:
524 if value:
525 value = link.get(value, key)
526 else:
527 value = ''
528 if oldvalue:
529 oldvalue = link.get(oldvalue, key)
530 else:
531 oldvalue = ''
532 change = '%s -> %s'%(oldvalue, value)
533 elif isinstance(prop, hyperdb.Multilink):
534 change = ''
535 if value is None: value = []
536 if oldvalue is None: oldvalue = []
537 l = []
538 link = self.db.classes[prop.classname]
539 key = link.labelprop(default_to_id=1)
540 # check for additions
541 for entry in value:
542 if entry in oldvalue: continue
543 if key:
544 l.append(link.get(entry, key))
545 else:
546 l.append(entry)
547 if l:
548 change = '+%s'%(', '.join(l))
549 l = []
550 # check for removals
551 for entry in oldvalue:
552 if entry in value: continue
553 if key:
554 l.append(link.get(entry, key))
555 else:
556 l.append(entry)
557 if l:
558 change += ' -%s'%(', '.join(l))
559 else:
560 change = '%s -> %s'%(oldvalue, value)
561 m.append('%s: %s'%(propname, change))
562 if m:
563 m.insert(0, '----------')
564 m.insert(0, '')
565 return '\n'.join(m)
567 #
568 # $Log: not supported by cvs2svn $
569 # Revision 1.40 2002/01/14 22:21:38 richard
570 # #503353 ] setting properties in initial email
571 #
572 # Revision 1.39 2002/01/14 02:20:15 richard
573 # . changed all config accesses so they access either the instance or the
574 # config attriubute on the db. This means that all config is obtained from
575 # instance_config instead of the mish-mash of classes. This will make
576 # switching to a ConfigParser setup easier too, I hope.
577 #
578 # At a minimum, this makes migration a _little_ easier (a lot easier in the
579 # 0.5.0 switch, I hope!)
580 #
581 # Revision 1.38 2002/01/10 05:57:45 richard
582 # namespace clobberation
583 #
584 # Revision 1.37 2002/01/08 04:12:05 richard
585 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
586 #
587 # Revision 1.36 2002/01/02 02:31:38 richard
588 # Sorry for the huge checkin message - I was only intending to implement #496356
589 # but I found a number of places where things had been broken by transactions:
590 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
591 # for _all_ roundup-generated smtp messages to be sent to.
592 # . the transaction cache had broken the roundupdb.Class set() reactors
593 # . newly-created author users in the mailgw weren't being committed to the db
594 #
595 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
596 # on when I found that stuff :):
597 # . #496356 ] Use threading in messages
598 # . detectors were being registered multiple times
599 # . added tests for mailgw
600 # . much better attaching of erroneous messages in the mail gateway
601 #
602 # Revision 1.35 2001/12/20 15:43:01 rochecompaan
603 # Features added:
604 # . Multilink properties are now displayed as comma separated values in
605 # a textbox
606 # . The add user link is now only visible to the admin user
607 # . Modified the mail gateway to reject submissions from unknown
608 # addresses if ANONYMOUS_ACCESS is denied
609 #
610 # Revision 1.34 2001/12/17 03:52:48 richard
611 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
612 # storing more than one file per node - if a property name is supplied,
613 # the file is called designator.property.
614 # I decided not to migrate the existing files stored over to the new naming
615 # scheme - the FileClass just doesn't specify the property name.
616 #
617 # Revision 1.33 2001/12/16 10:53:37 richard
618 # take a copy of the node dict so that the subsequent set
619 # operation doesn't modify the oldvalues structure
620 #
621 # Revision 1.32 2001/12/15 23:48:35 richard
622 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
623 # actually sending mail :)
624 #
625 # Revision 1.31 2001/12/15 19:24:39 rochecompaan
626 # . Modified cgi interface to change properties only once all changes are
627 # collected, files created and messages generated.
628 # . Moved generation of change note to nosyreactors.
629 # . We now check for changes to "assignedto" to ensure it's added to the
630 # nosy list.
631 #
632 # Revision 1.30 2001/12/12 21:47:45 richard
633 # . Message author's name appears in From: instead of roundup instance name
634 # (which still appears in the Reply-To:)
635 # . envelope-from is now set to the roundup-admin and not roundup itself so
636 # delivery reports aren't sent to roundup (thanks Patrick Ohly)
637 #
638 # Revision 1.29 2001/12/11 04:50:49 richard
639 # fixed the order of the blank line and '-------' line
640 #
641 # Revision 1.28 2001/12/10 22:20:01 richard
642 # Enabled transaction support in the bsddb backend. It uses the anydbm code
643 # where possible, only replacing methods where the db is opened (it uses the
644 # btree opener specifically.)
645 # Also cleaned up some change note generation.
646 # Made the backends package work with pydoc too.
647 #
648 # Revision 1.27 2001/12/10 21:02:53 richard
649 # only insert the -------- change note marker if there is a change note
650 #
651 # Revision 1.26 2001/12/05 14:26:44 rochecompaan
652 # Removed generation of change note from "sendmessage" in roundupdb.py.
653 # The change note is now generated when the message is created.
654 #
655 # Revision 1.25 2001/11/30 20:28:10 rochecompaan
656 # Property changes are now completely traceable, whether changes are
657 # made through the web or by email
658 #
659 # Revision 1.24 2001/11/30 11:29:04 rochecompaan
660 # Property changes are now listed in emails generated by Roundup
661 #
662 # Revision 1.23 2001/11/27 03:17:13 richard
663 # oops
664 #
665 # Revision 1.22 2001/11/27 03:00:50 richard
666 # couple of bugfixes from latest patch integration
667 #
668 # Revision 1.21 2001/11/26 22:55:56 richard
669 # Feature:
670 # . Added INSTANCE_NAME to configuration - used in web and email to identify
671 # the instance.
672 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
673 # signature info in e-mails.
674 # . Some more flexibility in the mail gateway and more error handling.
675 # . Login now takes you to the page you back to the were denied access to.
676 #
677 # Fixed:
678 # . Lots of bugs, thanks Roché and others on the devel mailing list!
679 #
680 # Revision 1.20 2001/11/25 10:11:14 jhermann
681 # Typo fix
682 #
683 # Revision 1.19 2001/11/22 15:46:42 jhermann
684 # Added module docstrings to all modules.
685 #
686 # Revision 1.18 2001/11/15 10:36:17 richard
687 # . incorporated patch from Roch'e Compaan implementing attachments in nosy
688 # e-mail
689 #
690 # Revision 1.17 2001/11/12 22:01:06 richard
691 # Fixed issues with nosy reaction and author copies.
692 #
693 # Revision 1.16 2001/10/30 00:54:45 richard
694 # Features:
695 # . #467129 ] Lossage when username=e-mail-address
696 # . #473123 ] Change message generation for author
697 # . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
698 #
699 # Revision 1.15 2001/10/23 01:00:18 richard
700 # Re-enabled login and registration access after lopping them off via
701 # disabling access for anonymous users.
702 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
703 # a couple of bugs while I was there. Probably introduced a couple, but
704 # things seem to work OK at the moment.
705 #
706 # Revision 1.14 2001/10/21 07:26:35 richard
707 # feature #473127: Filenames. I modified the file.index and htmltemplate
708 # source so that the filename is used in the link and the creation
709 # information is displayed.
710 #
711 # Revision 1.13 2001/10/21 00:45:15 richard
712 # Added author identification to e-mail messages from roundup.
713 #
714 # Revision 1.12 2001/10/04 02:16:15 richard
715 # Forgot to pass the protected flag down *sigh*.
716 #
717 # Revision 1.11 2001/10/04 02:12:42 richard
718 # Added nicer command-line item adding: passing no arguments will enter an
719 # interactive more which asks for each property in turn. While I was at it, I
720 # fixed an implementation problem WRT the spec - I wasn't raising a
721 # ValueError if the key property was missing from a create(). Also added a
722 # protected=boolean argument to getprops() so we can list only the mutable
723 # properties (defaults to yes, which lists the immutables).
724 #
725 # Revision 1.10 2001/08/07 00:24:42 richard
726 # stupid typo
727 #
728 # Revision 1.9 2001/08/07 00:15:51 richard
729 # Added the copyright/license notice to (nearly) all files at request of
730 # Bizar Software.
731 #
732 # Revision 1.8 2001/08/02 06:38:17 richard
733 # Roundupdb now appends "mailing list" information to its messages which
734 # include the e-mail address and web interface address. Templates may
735 # override this in their db classes to include specific information (support
736 # instructions, etc).
737 #
738 # Revision 1.7 2001/07/30 02:38:31 richard
739 # get() now has a default arg - for migration only.
740 #
741 # Revision 1.6 2001/07/30 00:05:54 richard
742 # Fixed IssueClass so that superseders links to its classname rather than
743 # hard-coded to "issue".
744 #
745 # Revision 1.5 2001/07/29 07:01:39 richard
746 # Added vim command to all source so that we don't get no steenkin' tabs :)
747 #
748 # Revision 1.4 2001/07/29 04:05:37 richard
749 # Added the fabricated property "id".
750 #
751 # Revision 1.3 2001/07/23 07:14:41 richard
752 # Moved the database backends off into backends.
753 #
754 # Revision 1.2 2001/07/22 12:09:32 richard
755 # Final commit of Grande Splite
756 #
757 # Revision 1.1 2001/07/22 11:58:35 richard
758 # More Grande Splite
759 #
760 #
761 # vim: set filetype=python ts=4 sw=4 et si