Code

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