Code

bd1933eefc5a52282fa78c658de1dcd595eb46a4
[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.36 2002-01-02 02:31:38 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):
240     # configuration
241     MESSAGES_TO_AUTHOR = 'no'
242     INSTANCE_NAME = 'Roundup issue tracker'
243     EMAIL_SIGNATURE_POSITION = 'bottom'
245     # Overridden methods:
247     def __init__(self, db, classname, **properties):
248         """The newly-created class automatically includes the "messages",
249         "files", "nosy", and "superseder" properties.  If the 'properties'
250         dictionary attempts to specify any of these properties or a
251         "creation" or "activity" property, a ValueError is raised."""
252         if not properties.has_key('title'):
253             properties['title'] = hyperdb.String()
254         if not properties.has_key('messages'):
255             properties['messages'] = hyperdb.Multilink("msg")
256         if not properties.has_key('files'):
257             properties['files'] = hyperdb.Multilink("file")
258         if not properties.has_key('nosy'):
259             properties['nosy'] = hyperdb.Multilink("user")
260         if not properties.has_key('superseder'):
261             properties['superseder'] = hyperdb.Multilink(classname)
262         Class.__init__(self, db, classname, **properties)
264     # New methods:
266     def addmessage(self, nodeid, summary, text):
267         """Add a message to an issue's mail spool.
269         A new "msg" node is constructed using the current date, the user that
270         owns the database connection as the author, and the specified summary
271         text.
273         The "files" and "recipients" fields are left empty.
275         The given text is saved as the body of the message and the node is
276         appended to the "messages" field of the specified issue.
277         """
279     def sendmessage(self, nodeid, msgid, change_note):
280         """Send a message to the members of an issue's nosy list.
282         The message is sent only to users on the nosy list who are not
283         already on the "recipients" list for the message.
284         
285         These users are then added to the message's "recipients" list.
286         """
287         users = self.db.user
288         messages = self.db.msg
289         files = self.db.file
291         # figure the recipient ids
292         sendto = []
293         r = {}
294         recipients = messages.get(msgid, 'recipients')
295         for recipid in messages.get(msgid, 'recipients'):
296             r[recipid] = 1
298         # figure the author's id, and indicate they've received the message
299         authid = messages.get(msgid, 'author')
301         # get the current nosy list, we'll need it
302         nosy = self.get(nodeid, 'nosy')
304         # possibly send the message to the author, as long as they aren't
305         # anonymous
306         if (self.MESSAGES_TO_AUTHOR == 'yes' and
307                 users.get(authid, 'username') != 'anonymous'):
308             sendto.append(authid)
309         r[authid] = 1
311         # now figure the nosy people who weren't recipients
312         for nosyid in nosy:
313             # Don't send nosy mail to the anonymous user (that user
314             # shouldn't appear in the nosy list, but just in case they
315             # do...)
316             if users.get(nosyid, 'username') == 'anonymous':
317                 continue
318             # make sure they haven't seen the message already
319             if not r.has_key(nosyid):
320                 # send it to them
321                 sendto.append(nosyid)
322                 recipients.append(nosyid)
324         # no new recipients
325         if not sendto:
326             return
328         # determine the messageid and inreplyto of the message
329         inreplyto = messages.get(msgid, 'inreplyto')
330         messageid = messages.get(msgid, 'messageid')
331         if not messageid:
332             # this is an old message that didn't get a messageid, so
333             # create one
334             messageid = "%s.%s.%s%s-%s"%(time.time(), random.random(),
335                 self.classname, nodeid, self.MAIL_DOMAIN)
336             messages.set(msgid, messageid=messageid)
338         # update the message's recipients list
339         messages.set(msgid, recipients=recipients)
341         # send an email to the people who missed out
342         sendto = [users.get(i, 'address') for i in sendto]
343         cn = self.classname
344         title = self.get(nodeid, 'title') or '%s message copy'%cn
345         # figure author information
346         authname = users.get(authid, 'realname')
347         if not authname:
348             authname = users.get(authid, 'username')
349         authaddr = users.get(authid, 'address')
350         if authaddr:
351             authaddr = ' <%s>'%authaddr
352         else:
353             authaddr = ''
355         # make the message body
356         m = ['']
358         # put in roundup's signature
359         if self.EMAIL_SIGNATURE_POSITION == 'top':
360             m.append(self.email_signature(nodeid, msgid))
362         # add author information
363         if len(self.get(nodeid,'messages')) == 1:
364             m.append("New submission from %s%s:"%(authname, authaddr))
365         else:
366             m.append("%s%s added the comment:"%(authname, authaddr))
367         m.append('')
369         # add the content
370         m.append(messages.get(msgid, 'content'))
372         # add the change note
373         if change_note:
374             m.append(change_note)
376         # put in roundup's signature
377         if self.EMAIL_SIGNATURE_POSITION == 'bottom':
378             m.append(self.email_signature(nodeid, msgid))
380         # get the files for this message
381         files = messages.get(msgid, 'files')
383         # create the message
384         message = cStringIO.StringIO()
385         writer = MimeWriter.MimeWriter(message)
386         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
387         writer.addheader('To', ', '.join(sendto))
388         writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
389         writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
390             self.ISSUE_TRACKER_EMAIL))
391         writer.addheader('MIME-Version', '1.0')
392         if messageid:
393             writer.addheader('Message-Id', messageid)
394         if inreplyto:
395             writer.addheader('In-Reply-To', inreplyto)
397         # attach files
398         if files:
399             part = writer.startmultipartbody('mixed')
400             part = writer.nextpart()
401             body = part.startbody('text/plain')
402             body.write('\n'.join(m))
403             for fileid in files:
404                 name = files.get(fileid, 'name')
405                 mime_type = files.get(fileid, 'type')
406                 content = files.get(fileid, 'content')
407                 part = writer.nextpart()
408                 if mime_type == 'text/plain':
409                     part.addheader('Content-Disposition',
410                         'attachment;\n filename="%s"'%name)
411                     part.addheader('Content-Transfer-Encoding', '7bit')
412                     body = part.startbody('text/plain')
413                     body.write(content)
414                 else:
415                     # some other type, so encode it
416                     if not mime_type:
417                         # this should have been done when the file was saved
418                         mime_type = mimetypes.guess_type(name)[0]
419                     if mime_type is None:
420                         mime_type = 'application/octet-stream'
421                     part.addheader('Content-Disposition',
422                         'attachment;\n filename="%s"'%name)
423                     part.addheader('Content-Transfer-Encoding', 'base64')
424                     body = part.startbody(mime_type)
425                     body.write(base64.encodestring(content))
426             writer.lastpart()
427         else:
428             body = writer.startbody('text/plain')
429             body.write('\n'.join(m))
431         # now try to send the message
432         if SENDMAILDEBUG:
433             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
434                 self.ADMIN_EMAIL, ', '.join(sendto), message.getvalue()))
435         else:
436             try:
437                 # send the message as admin so bounces are sent there
438                 # instead of to roundup
439                 smtp = smtplib.SMTP(self.MAILHOST)
440                 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
441             except socket.error, value:
442                 raise MessageSendError, \
443                     "Couldn't send confirmation email: mailhost %s"%value
444             except smtplib.SMTPException, value:
445                 raise MessageSendError, \
446                     "Couldn't send confirmation email: %s"%value
448     def email_signature(self, nodeid, msgid):
449         ''' Add a signature to the e-mail with some useful information
450         '''
451         web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
452         email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
453         line = '_' * max(len(web), len(email))
454         return '%s\n%s\n%s\n%s'%(line, email, web, line)
456     def generateChangeNote(self, nodeid, oldvalues):
457         """Generate a change note that lists property changes
458         """
459         cn = self.classname
460         cl = self.db.classes[cn]
461         changed = {}
462         props = cl.getprops(protected=0)
464         # determine what changed
465         for key in oldvalues.keys():
466             if key in ['files','messages']: continue
467             new_value = cl.get(nodeid, key)
468             # the old value might be non existent
469             try:
470                 old_value = oldvalues[key]
471                 if type(new_value) is type([]):
472                     new_value.sort()
473                     old_value.sort()
474                 if new_value != old_value:
475                     changed[key] = old_value
476             except:
477                 changed[key] = new_value
479         # list the changes
480         m = []
481         for propname, oldvalue in changed.items():
482             prop = cl.properties[propname]
483             value = cl.get(nodeid, propname, None)
484             if isinstance(prop, hyperdb.Link):
485                 link = self.db.classes[prop.classname]
486                 key = link.labelprop(default_to_id=1)
487                 if key:
488                     if value:
489                         value = link.get(value, key)
490                     else:
491                         value = ''
492                     if oldvalue:
493                         oldvalue = link.get(oldvalue, key)
494                     else:
495                         oldvalue = ''
496                 change = '%s -> %s'%(oldvalue, value)
497             elif isinstance(prop, hyperdb.Multilink):
498                 change = ''
499                 if value is None: value = []
500                 if oldvalue is None: oldvalue = []
501                 l = []
502                 link = self.db.classes[prop.classname]
503                 key = link.labelprop(default_to_id=1)
504                 # check for additions
505                 for entry in value:
506                     if entry in oldvalue: continue
507                     if key:
508                         l.append(link.get(entry, key))
509                     else:
510                         l.append(entry)
511                 if l:
512                     change = '+%s'%(', '.join(l))
513                     l = []
514                 # check for removals
515                 for entry in oldvalue:
516                     if entry in value: continue
517                     if key:
518                         l.append(link.get(entry, key))
519                     else:
520                         l.append(entry)
521                 if l:
522                     change += ' -%s'%(', '.join(l))
523             else:
524                 change = '%s -> %s'%(oldvalue, value)
525             m.append('%s: %s'%(propname, change))
526         if m:
527             m.insert(0, '----------')
528             m.insert(0, '')
529         return '\n'.join(m)
532 # $Log: not supported by cvs2svn $
533 # Revision 1.35  2001/12/20 15:43:01  rochecompaan
534 # Features added:
535 #  .  Multilink properties are now displayed as comma separated values in
536 #     a textbox
537 #  .  The add user link is now only visible to the admin user
538 #  .  Modified the mail gateway to reject submissions from unknown
539 #     addresses if ANONYMOUS_ACCESS is denied
541 # Revision 1.34  2001/12/17 03:52:48  richard
542 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
543 # storing more than one file per node - if a property name is supplied,
544 # the file is called designator.property.
545 # I decided not to migrate the existing files stored over to the new naming
546 # scheme - the FileClass just doesn't specify the property name.
548 # Revision 1.33  2001/12/16 10:53:37  richard
549 # take a copy of the node dict so that the subsequent set
550 # operation doesn't modify the oldvalues structure
552 # Revision 1.32  2001/12/15 23:48:35  richard
553 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
554 # actually sending mail :)
556 # Revision 1.31  2001/12/15 19:24:39  rochecompaan
557 #  . Modified cgi interface to change properties only once all changes are
558 #    collected, files created and messages generated.
559 #  . Moved generation of change note to nosyreactors.
560 #  . We now check for changes to "assignedto" to ensure it's added to the
561 #    nosy list.
563 # Revision 1.30  2001/12/12 21:47:45  richard
564 #  . Message author's name appears in From: instead of roundup instance name
565 #    (which still appears in the Reply-To:)
566 #  . envelope-from is now set to the roundup-admin and not roundup itself so
567 #    delivery reports aren't sent to roundup (thanks Patrick Ohly)
569 # Revision 1.29  2001/12/11 04:50:49  richard
570 # fixed the order of the blank line and '-------' line
572 # Revision 1.28  2001/12/10 22:20:01  richard
573 # Enabled transaction support in the bsddb backend. It uses the anydbm code
574 # where possible, only replacing methods where the db is opened (it uses the
575 # btree opener specifically.)
576 # Also cleaned up some change note generation.
577 # Made the backends package work with pydoc too.
579 # Revision 1.27  2001/12/10 21:02:53  richard
580 # only insert the -------- change note marker if there is a change note
582 # Revision 1.26  2001/12/05 14:26:44  rochecompaan
583 # Removed generation of change note from "sendmessage" in roundupdb.py.
584 # The change note is now generated when the message is created.
586 # Revision 1.25  2001/11/30 20:28:10  rochecompaan
587 # Property changes are now completely traceable, whether changes are
588 # made through the web or by email
590 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
591 # Property changes are now listed in emails generated by Roundup
593 # Revision 1.23  2001/11/27 03:17:13  richard
594 # oops
596 # Revision 1.22  2001/11/27 03:00:50  richard
597 # couple of bugfixes from latest patch integration
599 # Revision 1.21  2001/11/26 22:55:56  richard
600 # Feature:
601 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
602 #    the instance.
603 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
604 #    signature info in e-mails.
605 #  . Some more flexibility in the mail gateway and more error handling.
606 #  . Login now takes you to the page you back to the were denied access to.
608 # Fixed:
609 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
611 # Revision 1.20  2001/11/25 10:11:14  jhermann
612 # Typo fix
614 # Revision 1.19  2001/11/22 15:46:42  jhermann
615 # Added module docstrings to all modules.
617 # Revision 1.18  2001/11/15 10:36:17  richard
618 #  . incorporated patch from Roch'e Compaan implementing attachments in nosy
619 #     e-mail
621 # Revision 1.17  2001/11/12 22:01:06  richard
622 # Fixed issues with nosy reaction and author copies.
624 # Revision 1.16  2001/10/30 00:54:45  richard
625 # Features:
626 #  . #467129 ] Lossage when username=e-mail-address
627 #  . #473123 ] Change message generation for author
628 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
630 # Revision 1.15  2001/10/23 01:00:18  richard
631 # Re-enabled login and registration access after lopping them off via
632 # disabling access for anonymous users.
633 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
634 # a couple of bugs while I was there. Probably introduced a couple, but
635 # things seem to work OK at the moment.
637 # Revision 1.14  2001/10/21 07:26:35  richard
638 # feature #473127: Filenames. I modified the file.index and htmltemplate
639 #  source so that the filename is used in the link and the creation
640 #  information is displayed.
642 # Revision 1.13  2001/10/21 00:45:15  richard
643 # Added author identification to e-mail messages from roundup.
645 # Revision 1.12  2001/10/04 02:16:15  richard
646 # Forgot to pass the protected flag down *sigh*.
648 # Revision 1.11  2001/10/04 02:12:42  richard
649 # Added nicer command-line item adding: passing no arguments will enter an
650 # interactive more which asks for each property in turn. While I was at it, I
651 # fixed an implementation problem WRT the spec - I wasn't raising a
652 # ValueError if the key property was missing from a create(). Also added a
653 # protected=boolean argument to getprops() so we can list only the mutable
654 # properties (defaults to yes, which lists the immutables).
656 # Revision 1.10  2001/08/07 00:24:42  richard
657 # stupid typo
659 # Revision 1.9  2001/08/07 00:15:51  richard
660 # Added the copyright/license notice to (nearly) all files at request of
661 # Bizar Software.
663 # Revision 1.8  2001/08/02 06:38:17  richard
664 # Roundupdb now appends "mailing list" information to its messages which
665 # include the e-mail address and web interface address. Templates may
666 # override this in their db classes to include specific information (support
667 # instructions, etc).
669 # Revision 1.7  2001/07/30 02:38:31  richard
670 # get() now has a default arg - for migration only.
672 # Revision 1.6  2001/07/30 00:05:54  richard
673 # Fixed IssueClass so that superseders links to its classname rather than
674 # hard-coded to "issue".
676 # Revision 1.5  2001/07/29 07:01:39  richard
677 # Added vim command to all source so that we don't get no steenkin' tabs :)
679 # Revision 1.4  2001/07/29 04:05:37  richard
680 # Added the fabricated property "id".
682 # Revision 1.3  2001/07/23 07:14:41  richard
683 # Moved the database backends off into backends.
685 # Revision 1.2  2001/07/22 12:09:32  richard
686 # Final commit of Grande Splite
688 # Revision 1.1  2001/07/22 11:58:35  richard
689 # More Grande Splite
692 # vim: set filetype=python ts=4 sw=4 et si