Code

5f4c90a9322fe50e4ac3f9ca31fde2e10c90aeb6
[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.42 2002-01-21 09:55:14 rochecompaan 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.
280         
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         l = changed.items()
518         l.sort()
519         for propname, oldvalue in l:
520             prop = cl.properties[propname]
521             value = cl.get(nodeid, propname, None)
522             if isinstance(prop, hyperdb.Link):
523                 link = self.db.classes[prop.classname]
524                 key = link.labelprop(default_to_id=1)
525                 if key:
526                     if value:
527                         value = link.get(value, key)
528                     else:
529                         value = ''
530                     if oldvalue:
531                         oldvalue = link.get(oldvalue, key)
532                     else:
533                         oldvalue = ''
534                 change = '%s -> %s'%(oldvalue, value)
535             elif isinstance(prop, hyperdb.Multilink):
536                 change = ''
537                 if value is None: value = []
538                 if oldvalue is None: oldvalue = []
539                 l = []
540                 link = self.db.classes[prop.classname]
541                 key = link.labelprop(default_to_id=1)
542                 # check for additions
543                 for entry in value:
544                     if entry in oldvalue: continue
545                     if key:
546                         l.append(link.get(entry, key))
547                     else:
548                         l.append(entry)
549                 if l:
550                     change = '+%s'%(', '.join(l))
551                     l = []
552                 # check for removals
553                 for entry in oldvalue:
554                     if entry in value: continue
555                     if key:
556                         l.append(link.get(entry, key))
557                     else:
558                         l.append(entry)
559                 if l:
560                     change += ' -%s'%(', '.join(l))
561             else:
562                 change = '%s -> %s'%(oldvalue, value)
563             m.append('%s: %s'%(propname, change))
564         if m:
565             m.insert(0, '----------')
566             m.insert(0, '')
567         return '\n'.join(m)
570 # $Log: not supported by cvs2svn $
571 # Revision 1.41  2002/01/15 00:12:40  richard
572 # #503340 ] creating issue with [asignedto=p.ohly]
574 # Revision 1.40  2002/01/14 22:21:38  richard
575 # #503353 ] setting properties in initial email
577 # Revision 1.39  2002/01/14 02:20:15  richard
578 #  . changed all config accesses so they access either the instance or the
579 #    config attriubute on the db. This means that all config is obtained from
580 #    instance_config instead of the mish-mash of classes. This will make
581 #    switching to a ConfigParser setup easier too, I hope.
583 # At a minimum, this makes migration a _little_ easier (a lot easier in the
584 # 0.5.0 switch, I hope!)
586 # Revision 1.38  2002/01/10 05:57:45  richard
587 # namespace clobberation
589 # Revision 1.37  2002/01/08 04:12:05  richard
590 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
592 # Revision 1.36  2002/01/02 02:31:38  richard
593 # Sorry for the huge checkin message - I was only intending to implement #496356
594 # but I found a number of places where things had been broken by transactions:
595 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
596 #    for _all_ roundup-generated smtp messages to be sent to.
597 #  . the transaction cache had broken the roundupdb.Class set() reactors
598 #  . newly-created author users in the mailgw weren't being committed to the db
600 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
601 # on when I found that stuff :):
602 #  . #496356 ] Use threading in messages
603 #  . detectors were being registered multiple times
604 #  . added tests for mailgw
605 #  . much better attaching of erroneous messages in the mail gateway
607 # Revision 1.35  2001/12/20 15:43:01  rochecompaan
608 # Features added:
609 #  .  Multilink properties are now displayed as comma separated values in
610 #     a textbox
611 #  .  The add user link is now only visible to the admin user
612 #  .  Modified the mail gateway to reject submissions from unknown
613 #     addresses if ANONYMOUS_ACCESS is denied
615 # Revision 1.34  2001/12/17 03:52:48  richard
616 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
617 # storing more than one file per node - if a property name is supplied,
618 # the file is called designator.property.
619 # I decided not to migrate the existing files stored over to the new naming
620 # scheme - the FileClass just doesn't specify the property name.
622 # Revision 1.33  2001/12/16 10:53:37  richard
623 # take a copy of the node dict so that the subsequent set
624 # operation doesn't modify the oldvalues structure
626 # Revision 1.32  2001/12/15 23:48:35  richard
627 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
628 # actually sending mail :)
630 # Revision 1.31  2001/12/15 19:24:39  rochecompaan
631 #  . Modified cgi interface to change properties only once all changes are
632 #    collected, files created and messages generated.
633 #  . Moved generation of change note to nosyreactors.
634 #  . We now check for changes to "assignedto" to ensure it's added to the
635 #    nosy list.
637 # Revision 1.30  2001/12/12 21:47:45  richard
638 #  . Message author's name appears in From: instead of roundup instance name
639 #    (which still appears in the Reply-To:)
640 #  . envelope-from is now set to the roundup-admin and not roundup itself so
641 #    delivery reports aren't sent to roundup (thanks Patrick Ohly)
643 # Revision 1.29  2001/12/11 04:50:49  richard
644 # fixed the order of the blank line and '-------' line
646 # Revision 1.28  2001/12/10 22:20:01  richard
647 # Enabled transaction support in the bsddb backend. It uses the anydbm code
648 # where possible, only replacing methods where the db is opened (it uses the
649 # btree opener specifically.)
650 # Also cleaned up some change note generation.
651 # Made the backends package work with pydoc too.
653 # Revision 1.27  2001/12/10 21:02:53  richard
654 # only insert the -------- change note marker if there is a change note
656 # Revision 1.26  2001/12/05 14:26:44  rochecompaan
657 # Removed generation of change note from "sendmessage" in roundupdb.py.
658 # The change note is now generated when the message is created.
660 # Revision 1.25  2001/11/30 20:28:10  rochecompaan
661 # Property changes are now completely traceable, whether changes are
662 # made through the web or by email
664 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
665 # Property changes are now listed in emails generated by Roundup
667 # Revision 1.23  2001/11/27 03:17:13  richard
668 # oops
670 # Revision 1.22  2001/11/27 03:00:50  richard
671 # couple of bugfixes from latest patch integration
673 # Revision 1.21  2001/11/26 22:55:56  richard
674 # Feature:
675 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
676 #    the instance.
677 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
678 #    signature info in e-mails.
679 #  . Some more flexibility in the mail gateway and more error handling.
680 #  . Login now takes you to the page you back to the were denied access to.
682 # Fixed:
683 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
685 # Revision 1.20  2001/11/25 10:11:14  jhermann
686 # Typo fix
688 # Revision 1.19  2001/11/22 15:46:42  jhermann
689 # Added module docstrings to all modules.
691 # Revision 1.18  2001/11/15 10:36:17  richard
692 #  . incorporated patch from Roch'e Compaan implementing attachments in nosy
693 #     e-mail
695 # Revision 1.17  2001/11/12 22:01:06  richard
696 # Fixed issues with nosy reaction and author copies.
698 # Revision 1.16  2001/10/30 00:54:45  richard
699 # Features:
700 #  . #467129 ] Lossage when username=e-mail-address
701 #  . #473123 ] Change message generation for author
702 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
704 # Revision 1.15  2001/10/23 01:00:18  richard
705 # Re-enabled login and registration access after lopping them off via
706 # disabling access for anonymous users.
707 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
708 # a couple of bugs while I was there. Probably introduced a couple, but
709 # things seem to work OK at the moment.
711 # Revision 1.14  2001/10/21 07:26:35  richard
712 # feature #473127: Filenames. I modified the file.index and htmltemplate
713 #  source so that the filename is used in the link and the creation
714 #  information is displayed.
716 # Revision 1.13  2001/10/21 00:45:15  richard
717 # Added author identification to e-mail messages from roundup.
719 # Revision 1.12  2001/10/04 02:16:15  richard
720 # Forgot to pass the protected flag down *sigh*.
722 # Revision 1.11  2001/10/04 02:12:42  richard
723 # Added nicer command-line item adding: passing no arguments will enter an
724 # interactive more which asks for each property in turn. While I was at it, I
725 # fixed an implementation problem WRT the spec - I wasn't raising a
726 # ValueError if the key property was missing from a create(). Also added a
727 # protected=boolean argument to getprops() so we can list only the mutable
728 # properties (defaults to yes, which lists the immutables).
730 # Revision 1.10  2001/08/07 00:24:42  richard
731 # stupid typo
733 # Revision 1.9  2001/08/07 00:15:51  richard
734 # Added the copyright/license notice to (nearly) all files at request of
735 # Bizar Software.
737 # Revision 1.8  2001/08/02 06:38:17  richard
738 # Roundupdb now appends "mailing list" information to its messages which
739 # include the e-mail address and web interface address. Templates may
740 # override this in their db classes to include specific information (support
741 # instructions, etc).
743 # Revision 1.7  2001/07/30 02:38:31  richard
744 # get() now has a default arg - for migration only.
746 # Revision 1.6  2001/07/30 00:05:54  richard
747 # Fixed IssueClass so that superseders links to its classname rather than
748 # hard-coded to "issue".
750 # Revision 1.5  2001/07/29 07:01:39  richard
751 # Added vim command to all source so that we don't get no steenkin' tabs :)
753 # Revision 1.4  2001/07/29 04:05:37  richard
754 # Added the fabricated property "id".
756 # Revision 1.3  2001/07/23 07:14:41  richard
757 # Moved the database backends off into backends.
759 # Revision 1.2  2001/07/22 12:09:32  richard
760 # Final commit of Grande Splite
762 # Revision 1.1  2001/07/22 11:58:35  richard
763 # More Grande Splite
766 # vim: set filetype=python ts=4 sw=4 et si