Code

03bde0d1d3c486942c016a466f872e3b0a2fcd0f
[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.60 2002-07-09 03:02:52 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 MimeWriter, cStringIO
26 import base64, quopri, mimetypes
27 # if available, use the 'email' module, otherwise fallback to 'rfc822'
28 try :
29     from email.Utils import dump_address_pair as straddr
30 except ImportError :
31     from rfc822 import dump_address_pair as straddr
33 import hyperdb, date
35 # set to indicate to roundup not to actually _send_ email
36 # this var must contain a file to write the mail to
37 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
39 class DesignatorError(ValueError):
40     pass
41 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
42     ''' Take a foo123 and return ('foo', 123)
43     '''
44     m = dre.match(designator)
45     if m is None:
46         raise DesignatorError, '"%s" not a node designator'%designator
47     return m.group(1), m.group(2)
50 def extractUserFromList(userClass, users):
51     '''Given a list of users, try to extract the first non-anonymous user
52        and return that user, otherwise return None
53     '''
54     if len(users) > 1:
55         # make sure we don't match the anonymous or admin user
56         for user in users:
57             if user == '1': continue
58             if userClass.get(user, 'username') == 'anonymous': continue
59             # first valid match will do
60             return user
61         # well, I guess we have no choice
62         return user[0]
63     elif users:
64         return users[0]
65     return None
67 class Database:
68     def getuid(self):
69         """Return the id of the "user" node associated with the user
70         that owns this connection to the hyperdatabase."""
71         return self.user.lookup(self.journaltag)
73     def uidFromAddress(self, address, create=1):
74         ''' address is from the rfc822 module, and therefore is (name, addr)
76             user is created if they don't exist in the db already
77         '''
78         (realname, address) = address
80         # try a straight match of the address
81         user = extractUserFromList(self.user,
82             self.user.stringFind(address=address))
83         if user is not None: return user
85         # try the user alternate addresses if possible
86         props = self.user.getprops()
87         if props.has_key('alternate_addresses'):
88             users = self.user.filter(None, {'alternate_addresses': address},
89                 [], [])
90             user = extractUserFromList(self.user, users)
91             if user is not None: return user
93         # try to match the username to the address (for local
94         # submissions where the address is empty)
95         user = extractUserFromList(self.user,
96             self.user.stringFind(username=address))
98         # couldn't match address or username, so create a new user
99         if create:
100             return self.user.create(username=address, address=address,
101                 realname=realname)
102         else:
103             return 0
105 _marker = []
106 # XXX: added the 'creator' faked attribute
107 class Class(hyperdb.Class):
108     # Overridden methods:
109     def __init__(self, db, classname, **properties):
110         if (properties.has_key('creation') or properties.has_key('activity')
111                 or properties.has_key('creator')):
112             raise ValueError, '"creation", "activity" and "creator" are reserved'
113         hyperdb.Class.__init__(self, db, classname, **properties)
114         self.auditors = {'create': [], 'set': [], 'retire': []}
115         self.reactors = {'create': [], 'set': [], 'retire': []}
117     def create(self, **propvalues):
118         """These operations trigger detectors and can be vetoed.  Attempts
119         to modify the "creation" or "activity" properties cause a KeyError.
120         """
121         if propvalues.has_key('creation') or propvalues.has_key('activity'):
122             raise KeyError, '"creation" and "activity" are reserved'
123         self.fireAuditors('create', None, propvalues)
124         nodeid = hyperdb.Class.create(self, **propvalues)
125         self.fireReactors('create', nodeid, None)
126         return nodeid
128     def set(self, nodeid, **propvalues):
129         """These operations trigger detectors and can be vetoed.  Attempts
130         to modify the "creation" or "activity" properties cause a KeyError.
131         """
132         if propvalues.has_key('creation') or propvalues.has_key('activity'):
133             raise KeyError, '"creation" and "activity" are reserved'
134         self.fireAuditors('set', nodeid, propvalues)
135         # Take a copy of the node dict so that the subsequent set
136         # operation doesn't modify the oldvalues structure.
137         try:
138             # try not using the cache initially
139             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
140                 cache=0))
141         except IndexError:
142             # this will be needed if somone does a create() and set()
143             # with no intervening commit()
144             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
145         hyperdb.Class.set(self, nodeid, **propvalues)
146         self.fireReactors('set', nodeid, oldvalues)
148     def retire(self, nodeid):
149         """These operations trigger detectors and can be vetoed.  Attempts
150         to modify the "creation" or "activity" properties cause a KeyError.
151         """
152         self.fireAuditors('retire', nodeid, None)
153         hyperdb.Class.retire(self, nodeid)
154         self.fireReactors('retire', nodeid, None)
156     def get(self, nodeid, propname, default=_marker, cache=1):
157         """Attempts to get the "creation" or "activity" properties should
158         do the right thing.
159         """
160         if propname == 'creation':
161             journal = self.db.getjournal(self.classname, nodeid)
162             if journal:
163                 return self.db.getjournal(self.classname, nodeid)[0][1]
164             else:
165                 # on the strange chance that there's no journal
166                 return date.Date()
167         if propname == 'activity':
168             journal = self.db.getjournal(self.classname, nodeid)
169             if journal:
170                 return self.db.getjournal(self.classname, nodeid)[-1][1]
171             else:
172                 # on the strange chance that there's no journal
173                 return date.Date()
174         if propname == 'creator':
175             journal = self.db.getjournal(self.classname, nodeid)
176             if journal:
177                 name = self.db.getjournal(self.classname, nodeid)[0][2]
178             else:
179                 return None
180             return self.db.user.lookup(name)
181         if default is not _marker:
182             return hyperdb.Class.get(self, nodeid, propname, default,
183                 cache=cache)
184         else:
185             return hyperdb.Class.get(self, nodeid, propname, cache=cache)
187     def getprops(self, protected=1):
188         """In addition to the actual properties on the node, these
189         methods provide the "creation" and "activity" properties. If the
190         "protected" flag is true, we include protected properties - those
191         which may not be modified.
192         """
193         d = hyperdb.Class.getprops(self, protected=protected).copy()
194         if protected:
195             d['creation'] = hyperdb.Date()
196             d['activity'] = hyperdb.Date()
197             d['creator'] = hyperdb.Link("user")
198         return d
200     #
201     # Detector interface
202     #
203     def audit(self, event, detector):
204         """Register a detector
205         """
206         l = self.auditors[event]
207         if detector not in l:
208             self.auditors[event].append(detector)
210     def fireAuditors(self, action, nodeid, newvalues):
211         """Fire all registered auditors.
212         """
213         for audit in self.auditors[action]:
214             audit(self.db, self, nodeid, newvalues)
216     def react(self, event, detector):
217         """Register a detector
218         """
219         l = self.reactors[event]
220         if detector not in l:
221             self.reactors[event].append(detector)
223     def fireReactors(self, action, nodeid, oldvalues):
224         """Fire all registered reactors.
225         """
226         for react in self.reactors[action]:
227             react(self.db, self, nodeid, oldvalues)
229 class FileClass(Class):
230     '''This class defines a large chunk of data. To support this, it has a
231        mandatory String property "content" which is typically saved off
232        externally to the hyperdb.
234        The default MIME type of this data is defined by the
235        "default_mime_type" class attribute, which may be overridden by each
236        node if the class defines a "type" String property.
237     '''
238     default_mime_type = 'text/plain'
240     def create(self, **propvalues):
241         ''' snaffle the file propvalue and store in a file
242         '''
243         content = propvalues['content']
244         del propvalues['content']
245         newid = Class.create(self, **propvalues)
246         self.db.storefile(self.classname, newid, None, content)
247         return newid
249     def get(self, nodeid, propname, default=_marker, cache=1):
250         ''' trap the content propname and get it from the file
251         '''
253         poss_msg = 'Possibly a access right configuration problem.'
254         if propname == 'content':
255             try:
256                 return self.db.getfile(self.classname, nodeid, None)
257             except IOError, (strerror):
258                 # BUG: by catching this we donot see an error in the log.
259                 return 'ERROR reading file: %s%s\n%s\n%s'%(
260                         self.classname, nodeid, poss_msg, strerror)
261         if default is not _marker:
262             return Class.get(self, nodeid, propname, default, cache=cache)
263         else:
264             return Class.get(self, nodeid, propname, cache=cache)
266     def getprops(self, protected=1):
267         ''' In addition to the actual properties on the node, these methods
268             provide the "content" property. If the "protected" flag is true,
269             we include protected properties - those which may not be
270             modified.
271         '''
272         d = Class.getprops(self, protected=protected).copy()
273         if protected:
274             d['content'] = hyperdb.String()
275         return d
277     def index(self, nodeid):
278         ''' Index the node in the search index.
280             We want to index the content in addition to the normal String
281             property indexing.
282         '''
283         # perform normal indexing
284         Class.index(self, nodeid)
286         # get the content to index
287         content = self.get(nodeid, 'content')
289         # figure the mime type
290         if self.properties.has_key('type'):
291             mime_type = self.get(nodeid, 'type')
292         else:
293             mime_type = self.default_mime_type
295         # and index!
296         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
297             mime_type)
299 class MessageSendError(RuntimeError):
300     pass
302 class DetectorError(RuntimeError):
303     pass
305 # XXX deviation from spec - was called ItemClass
306 class IssueClass(Class):
308     # Overridden methods:
310     def __init__(self, db, classname, **properties):
311         """The newly-created class automatically includes the "messages",
312         "files", "nosy", and "superseder" properties.  If the 'properties'
313         dictionary attempts to specify any of these properties or a
314         "creation" or "activity" property, a ValueError is raised."""
315         if not properties.has_key('title'):
316             properties['title'] = hyperdb.String()
317         if not properties.has_key('messages'):
318             properties['messages'] = hyperdb.Multilink("msg")
319         if not properties.has_key('files'):
320             properties['files'] = hyperdb.Multilink("file")
321         if not properties.has_key('nosy'):
322             properties['nosy'] = hyperdb.Multilink("user")
323         if not properties.has_key('superseder'):
324             properties['superseder'] = hyperdb.Multilink(classname)
325         Class.__init__(self, db, classname, **properties)
327     # New methods:
329     def addmessage(self, nodeid, summary, text):
330         """Add a message to an issue's mail spool.
332         A new "msg" node is constructed using the current date, the user that
333         owns the database connection as the author, and the specified summary
334         text.
336         The "files" and "recipients" fields are left empty.
338         The given text is saved as the body of the message and the node is
339         appended to the "messages" field of the specified issue.
340         """
342     def nosymessage(self, nodeid, msgid, oldvalues):
343         """Send a message to the members of an issue's nosy list.
345         The message is sent only to users on the nosy list who are not
346         already on the "recipients" list for the message.
347         
348         These users are then added to the message's "recipients" list.
349         """
350         users = self.db.user
351         messages = self.db.msg
353         # figure the recipient ids
354         sendto = []
355         r = {}
356         recipients = messages.get(msgid, 'recipients')
357         for recipid in messages.get(msgid, 'recipients'):
358             r[recipid] = 1
360         # figure the author's id, and indicate they've received the message
361         authid = messages.get(msgid, 'author')
363         # possibly send the message to the author, as long as they aren't
364         # anonymous
365         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
366                 users.get(authid, 'username') != 'anonymous'):
367             sendto.append(authid)
368         r[authid] = 1
370         # now figure the nosy people who weren't recipients
371         nosy = self.get(nodeid, 'nosy')
372         for nosyid in nosy:
373             # Don't send nosy mail to the anonymous user (that user
374             # shouldn't appear in the nosy list, but just in case they
375             # do...)
376             if users.get(nosyid, 'username') == 'anonymous':
377                 continue
378             # make sure they haven't seen the message already
379             if not r.has_key(nosyid):
380                 # send it to them
381                 sendto.append(nosyid)
382                 recipients.append(nosyid)
384         # generate a change note
385         if oldvalues:
386             note = self.generateChangeNote(nodeid, oldvalues)
387         else:
388             note = self.generateCreateNote(nodeid)
390         # we have new recipients
391         if sendto:
392             # map userids to addresses
393             sendto = [users.get(i, 'address') for i in sendto]
395             # update the message's recipients list
396             messages.set(msgid, recipients=recipients)
398             # send the message
399             self.send_message(nodeid, msgid, note, sendto)
401     # XXX backwards compatibility - don't remove
402     sendmessage = nosymessage
404     def send_message(self, nodeid, msgid, note, sendto):
405         '''Actually send the nominated message from this node to the sendto
406            recipients, with the note appended.
407         '''
408         users = self.db.user
409         messages = self.db.msg
410         files = self.db.file
412         # determine the messageid and inreplyto of the message
413         inreplyto = messages.get(msgid, 'inreplyto')
414         messageid = messages.get(msgid, 'messageid')
416         # make up a messageid if there isn't one (web edit)
417         if not messageid:
418             # this is an old message that didn't get a messageid, so
419             # create one
420             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
421                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
422             messages.set(msgid, messageid=messageid)
424         # send an email to the people who missed out
425         cn = self.classname
426         title = self.get(nodeid, 'title') or '%s message copy'%cn
427         # figure author information
428         authid = messages.get(msgid, 'author')
429         authname = users.get(authid, 'realname')
430         if not authname:
431             authname = users.get(authid, 'username')
432         authaddr = users.get(authid, 'address')
433         if authaddr:
434             authaddr = " <%s>" % straddr( ('',authaddr) )
435         else:
436             authaddr = ''
438         # make the message body
439         m = ['']
441         # put in roundup's signature
442         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
443             m.append(self.email_signature(nodeid, msgid))
445         # add author information
446         if len(self.get(nodeid,'messages')) == 1:
447             m.append("New submission from %s%s:"%(authname, authaddr))
448         else:
449             m.append("%s%s added the comment:"%(authname, authaddr))
450         m.append('')
452         # add the content
453         m.append(messages.get(msgid, 'content'))
455         # add the change note
456         if note:
457             m.append(note)
459         # put in roundup's signature
460         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
461             m.append(self.email_signature(nodeid, msgid))
463         # encode the content as quoted-printable
464         content = cStringIO.StringIO('\n'.join(m))
465         content_encoded = cStringIO.StringIO()
466         quopri.encode(content, content_encoded, 0)
467         content_encoded = content_encoded.getvalue()
469         # get the files for this message
470         message_files = messages.get(msgid, 'files')
472         # make sure the To line is always the same (for testing mostly)
473         sendto.sort()
475         # create the message
476         message = cStringIO.StringIO()
477         writer = MimeWriter.MimeWriter(message)
478         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
479         writer.addheader('To', ', '.join(sendto))
480         writer.addheader('From', straddr(
481                               (authname, self.db.config.ISSUE_TRACKER_EMAIL) ) )
482         writer.addheader('Reply-To', straddr( 
483                                         (self.db.config.INSTANCE_NAME,
484                                          self.db.config.ISSUE_TRACKER_EMAIL) ) )
485         writer.addheader('MIME-Version', '1.0')
486         if messageid:
487             writer.addheader('Message-Id', messageid)
488         if inreplyto:
489             writer.addheader('In-Reply-To', inreplyto)
491         # add a uniquely Roundup header to help filtering
492         writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
494         # attach files
495         if message_files:
496             part = writer.startmultipartbody('mixed')
497             part = writer.nextpart()
498             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
499             body = part.startbody('text/plain')
500             body.write(content_encoded)
501             for fileid in message_files:
502                 name = files.get(fileid, 'name')
503                 mime_type = files.get(fileid, 'type')
504                 content = files.get(fileid, 'content')
505                 part = writer.nextpart()
506                 if mime_type == 'text/plain':
507                     part.addheader('Content-Disposition',
508                         'attachment;\n filename="%s"'%name)
509                     part.addheader('Content-Transfer-Encoding', '7bit')
510                     body = part.startbody('text/plain')
511                     body.write(content)
512                 else:
513                     # some other type, so encode it
514                     if not mime_type:
515                         # this should have been done when the file was saved
516                         mime_type = mimetypes.guess_type(name)[0]
517                     if mime_type is None:
518                         mime_type = 'application/octet-stream'
519                     part.addheader('Content-Disposition',
520                         'attachment;\n filename="%s"'%name)
521                     part.addheader('Content-Transfer-Encoding', 'base64')
522                     body = part.startbody(mime_type)
523                     body.write(base64.encodestring(content))
524             writer.lastpart()
525         else:
526             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
527             body = writer.startbody('text/plain')
528             body.write(content_encoded)
530         # now try to send the message
531         if SENDMAILDEBUG:
532             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
533                 self.db.config.ADMIN_EMAIL,
534                 ', '.join(sendto),message.getvalue()))
535         else:
536             try:
537                 # send the message as admin so bounces are sent there
538                 # instead of to roundup
539                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
540                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
541                     message.getvalue())
542             except socket.error, value:
543                 raise MessageSendError, \
544                     "Couldn't send confirmation email: mailhost %s"%value
545             except smtplib.SMTPException, value:
546                 raise MessageSendError, \
547                     "Couldn't send confirmation email: %s"%value
549     def email_signature(self, nodeid, msgid):
550         ''' Add a signature to the e-mail with some useful information
551         '''
553         # simplistic check to see if the url is valid,
554         # then append a trailing slash if it is missing
555         base = self.db.config.ISSUE_TRACKER_WEB 
556         if not isinstance( base , type('') ) or not base.startswith( "http://" ) :
557             base = "Configuration Error: ISSUE_TRACKER_WEB isn't a fully-qualified URL"
558         elif base[-1] != '/' :
559             base += '/'
560         web = base + 'issue'+ nodeid
562         # ensure the email address is properly quoted
563         email = straddr( (self.db.config.INSTANCE_NAME ,
564                           self.db.config.ISSUE_TRACKER_EMAIL) )
566         line = '_' * max(len(web), len(email))
567         return '%s\n%s\n%s\n%s'%(line, email, web, line)
570     def generateCreateNote(self, nodeid):
571         """Generate a create note that lists initial property values
572         """
573         cn = self.classname
574         cl = self.db.classes[cn]
575         props = cl.getprops(protected=0)
577         # list the values
578         m = []
579         l = props.items()
580         l.sort()
581         for propname, prop in l:
582             value = cl.get(nodeid, propname, None)
583             # skip boring entries
584             if not value:
585                 continue
586             if isinstance(prop, hyperdb.Link):
587                 link = self.db.classes[prop.classname]
588                 if value:
589                     key = link.labelprop(default_to_id=1)
590                     if key:
591                         value = link.get(value, key)
592                 else:
593                     value = ''
594             elif isinstance(prop, hyperdb.Multilink):
595                 if value is None: value = []
596                 l = []
597                 link = self.db.classes[prop.classname]
598                 key = link.labelprop(default_to_id=1)
599                 if key:
600                     value = [link.get(entry, key) for entry in value]
601                 value.sort()
602                 value = ', '.join(value)
603             m.append('%s: %s'%(propname, value))
604         m.insert(0, '----------')
605         m.insert(0, '')
606         return '\n'.join(m)
608     def generateChangeNote(self, nodeid, oldvalues):
609         """Generate a change note that lists property changes
610         """
612         if __debug__ :
613             if not isinstance( oldvalues , type({}) ) :
614                 raise TypeError(
615                         "'oldvalues' must be dict-like, not %s."
616                         % str(type(oldvalues)) )
618         cn = self.classname
619         cl = self.db.classes[cn]
620         changed = {}
621         props = cl.getprops(protected=0)
623         # determine what changed
624         for key in oldvalues.keys():
625             if key in ['files','messages']: continue
626             new_value = cl.get(nodeid, key)
627             # the old value might be non existent
628             try:
629                 old_value = oldvalues[key]
630                 if type(new_value) is type([]):
631                     new_value.sort()
632                     old_value.sort()
633                 if new_value != old_value:
634                     changed[key] = old_value
635             except:
636                 changed[key] = new_value
638         # list the changes
639         m = []
640         l = changed.items()
641         l.sort()
642         for propname, oldvalue in l:
643             prop = props[propname]
644             value = cl.get(nodeid, propname, None)
645             if isinstance(prop, hyperdb.Link):
646                 link = self.db.classes[prop.classname]
647                 key = link.labelprop(default_to_id=1)
648                 if key:
649                     if value:
650                         value = link.get(value, key)
651                     else:
652                         value = ''
653                     if oldvalue:
654                         oldvalue = link.get(oldvalue, key)
655                     else:
656                         oldvalue = ''
657                 change = '%s -> %s'%(oldvalue, value)
658             elif isinstance(prop, hyperdb.Multilink):
659                 change = ''
660                 if value is None: value = []
661                 if oldvalue is None: oldvalue = []
662                 l = []
663                 link = self.db.classes[prop.classname]
664                 key = link.labelprop(default_to_id=1)
665                 # check for additions
666                 for entry in value:
667                     if entry in oldvalue: continue
668                     if key:
669                         l.append(link.get(entry, key))
670                     else:
671                         l.append(entry)
672                 if l:
673                     change = '+%s'%(', '.join(l))
674                     l = []
675                 # check for removals
676                 for entry in oldvalue:
677                     if entry in value: continue
678                     if key:
679                         l.append(link.get(entry, key))
680                     else:
681                         l.append(entry)
682                 if l:
683                     change += ' -%s'%(', '.join(l))
684             else:
685                 change = '%s -> %s'%(oldvalue, value)
686             m.append('%s: %s'%(propname, change))
687         if m:
688             m.insert(0, '----------')
689             m.insert(0, '')
690         return '\n'.join(m)
693 # $Log: not supported by cvs2svn $
694 # Revision 1.59  2002/06/18 03:55:25  dman13
695 # Fixed name/address display problem introduced by an earlier change.
696 # (instead of "name<addr>" display "name <addr>")
698 # Revision 1.58  2002/06/16 01:05:15  dman13
699 # Removed temporary workaround -- it seems it was a bug in the
700 # nosyreaction detector in the 0.4.1 extended template and has already
701 # been fixed in CVS.  We'll see.
703 # Revision 1.57  2002/06/15 15:49:29  dman13
704 # Use 'email' instead of 'rfc822', if available.
705 # Don't use isinstance() on a string (not allowed in python 2.1).
706 # Return an error message instead of crashing if 'oldvalues' isn't a
707 #     dict (in generateChangeNote).
709 # Revision 1.56  2002/06/14 03:54:21  dman13
710 # #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it
712 # use the rfc822 module to ensure that every (oddball) email address and
713 # real-name is properly quoted
715 # Revision 1.55  2002/06/11 04:58:07  richard
716 # detabbing
718 # Revision 1.54  2002/05/29 01:16:17  richard
719 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
720 # though.
722 # . #541941 ] changing multilink properties by mail
723 # . #526730 ] search for messages capability
724 # . #505180 ] split MailGW.handle_Message
725 #   - also changed cgi client since it was duplicating the functionality
726 # . build htmlbase if tests are run using CVS checkout (removed note from
727 #   installation.txt)
728 # . don't create an empty message on email issue creation if the email is empty
730 # Revision 1.53  2002/05/25 07:16:24  rochecompaan
731 # Merged search_indexing-branch with HEAD
733 # Revision 1.52  2002/05/15 03:27:16  richard
734 #  . fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
735 #    (thanks dman)
736 #  . fixed some sorting issues that were breaking some unit tests under py2.2
737 #  . mailgw test output dir was confusing the init test (but only on 2.2 *shrug*)
739 # fixed bug in the init unit test that meant only the bsddb test ran if it
740 # could (it clobbered the anydbm test)
742 # Revision 1.51  2002/04/08 03:46:42  richard
743 # make it work
745 # Revision 1.50  2002/04/08 03:40:31  richard
746 #  . added a "detectors" directory for people to put their useful auditors and
747 #    reactors in. Note - the roundupdb.IssueClass.sendmessage method has been
748 #    split and renamed "nosymessage" specifically for things like the nosy
749 #    reactor, and "send_message" which just sends the message.
751 # The initial detector is one that we'll be using here at ekit - it bounces new
752 # issue messages to a team address.
754 # Revision 1.49.2.1  2002/04/19 19:54:42  rochecompaan
755 # cgi_client.py
756 #     removed search link for the time being
757 #     moved rendering of matches to htmltemplate
758 # hyperdb.py
759 #     filtering of nodes on full text search incorporated in filter method
760 # roundupdb.py
761 #     added paramater to call of filter method
762 # roundup_indexer.py
763 #     added search method to RoundupIndexer class
765 # Revision 1.49  2002/03/19 06:41:49  richard
766 # Faster, easier, less mess ;)
768 # Revision 1.48  2002/03/18 18:32:00  rochecompaan
769 # All messages sent to the nosy list are now encoded as quoted-printable.
771 # Revision 1.47  2002/02/27 03:16:02  richard
772 # Fixed a couple of dodgy bits found by pychekcer.
774 # Revision 1.46  2002/02/25 14:22:59  grubert
775 #  . roundup db: catch only IOError in getfile.
777 # Revision 1.44  2002/02/15 07:08:44  richard
778 #  . Alternate email addresses are now available for users. See the MIGRATION
779 #    file for info on how to activate the feature.
781 # Revision 1.43  2002/02/14 22:33:15  richard
782 #  . Added a uniquely Roundup header to email, "X-Roundup-Name"
784 # Revision 1.42  2002/01/21 09:55:14  rochecompaan
785 # Properties in change note are now sorted
787 # Revision 1.41  2002/01/15 00:12:40  richard
788 # #503340 ] creating issue with [asignedto=p.ohly]
790 # Revision 1.40  2002/01/14 22:21:38  richard
791 # #503353 ] setting properties in initial email
793 # Revision 1.39  2002/01/14 02:20:15  richard
794 #  . changed all config accesses so they access either the instance or the
795 #    config attriubute on the db. This means that all config is obtained from
796 #    instance_config instead of the mish-mash of classes. This will make
797 #    switching to a ConfigParser setup easier too, I hope.
799 # At a minimum, this makes migration a _little_ easier (a lot easier in the
800 # 0.5.0 switch, I hope!)
802 # Revision 1.38  2002/01/10 05:57:45  richard
803 # namespace clobberation
805 # Revision 1.37  2002/01/08 04:12:05  richard
806 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
808 # Revision 1.36  2002/01/02 02:31:38  richard
809 # Sorry for the huge checkin message - I was only intending to implement #496356
810 # but I found a number of places where things had been broken by transactions:
811 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
812 #    for _all_ roundup-generated smtp messages to be sent to.
813 #  . the transaction cache had broken the roundupdb.Class set() reactors
814 #  . newly-created author users in the mailgw weren't being committed to the db
816 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
817 # on when I found that stuff :):
818 #  . #496356 ] Use threading in messages
819 #  . detectors were being registered multiple times
820 #  . added tests for mailgw
821 #  . much better attaching of erroneous messages in the mail gateway
823 # Revision 1.35  2001/12/20 15:43:01  rochecompaan
824 # Features added:
825 #  .  Multilink properties are now displayed as comma separated values in
826 #     a textbox
827 #  .  The add user link is now only visible to the admin user
828 #  .  Modified the mail gateway to reject submissions from unknown
829 #     addresses if ANONYMOUS_ACCESS is denied
831 # Revision 1.34  2001/12/17 03:52:48  richard
832 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
833 # storing more than one file per node - if a property name is supplied,
834 # the file is called designator.property.
835 # I decided not to migrate the existing files stored over to the new naming
836 # scheme - the FileClass just doesn't specify the property name.
838 # Revision 1.33  2001/12/16 10:53:37  richard
839 # take a copy of the node dict so that the subsequent set
840 # operation doesn't modify the oldvalues structure
842 # Revision 1.32  2001/12/15 23:48:35  richard
843 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
844 # actually sending mail :)
846 # Revision 1.31  2001/12/15 19:24:39  rochecompaan
847 #  . Modified cgi interface to change properties only once all changes are
848 #    collected, files created and messages generated.
849 #  . Moved generation of change note to nosyreactors.
850 #  . We now check for changes to "assignedto" to ensure it's added to the
851 #    nosy list.
853 # Revision 1.30  2001/12/12 21:47:45  richard
854 #  . Message author's name appears in From: instead of roundup instance name
855 #    (which still appears in the Reply-To:)
856 #  . envelope-from is now set to the roundup-admin and not roundup itself so
857 #    delivery reports aren't sent to roundup (thanks Patrick Ohly)
859 # Revision 1.29  2001/12/11 04:50:49  richard
860 # fixed the order of the blank line and '-------' line
862 # Revision 1.28  2001/12/10 22:20:01  richard
863 # Enabled transaction support in the bsddb backend. It uses the anydbm code
864 # where possible, only replacing methods where the db is opened (it uses the
865 # btree opener specifically.)
866 # Also cleaned up some change note generation.
867 # Made the backends package work with pydoc too.
869 # Revision 1.27  2001/12/10 21:02:53  richard
870 # only insert the -------- change note marker if there is a change note
872 # Revision 1.26  2001/12/05 14:26:44  rochecompaan
873 # Removed generation of change note from "sendmessage" in roundupdb.py.
874 # The change note is now generated when the message is created.
876 # Revision 1.25  2001/11/30 20:28:10  rochecompaan
877 # Property changes are now completely traceable, whether changes are
878 # made through the web or by email
880 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
881 # Property changes are now listed in emails generated by Roundup
883 # Revision 1.23  2001/11/27 03:17:13  richard
884 # oops
886 # Revision 1.22  2001/11/27 03:00:50  richard
887 # couple of bugfixes from latest patch integration
889 # Revision 1.21  2001/11/26 22:55:56  richard
890 # Feature:
891 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
892 #    the instance.
893 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
894 #    signature info in e-mails.
895 #  . Some more flexibility in the mail gateway and more error handling.
896 #  . Login now takes you to the page you back to the were denied access to.
898 # Fixed:
899 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
901 # Revision 1.20  2001/11/25 10:11:14  jhermann
902 # Typo fix
904 # Revision 1.19  2001/11/22 15:46:42  jhermann
905 # Added module docstrings to all modules.
907 # Revision 1.18  2001/11/15 10:36:17  richard
908 #  . incorporated patch from Roch'e Compaan implementing attachments in nosy
909 #     e-mail
911 # Revision 1.17  2001/11/12 22:01:06  richard
912 # Fixed issues with nosy reaction and author copies.
914 # Revision 1.16  2001/10/30 00:54:45  richard
915 # Features:
916 #  . #467129 ] Lossage when username=e-mail-address
917 #  . #473123 ] Change message generation for author
918 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
920 # Revision 1.15  2001/10/23 01:00:18  richard
921 # Re-enabled login and registration access after lopping them off via
922 # disabling access for anonymous users.
923 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
924 # a couple of bugs while I was there. Probably introduced a couple, but
925 # things seem to work OK at the moment.
927 # Revision 1.14  2001/10/21 07:26:35  richard
928 # feature #473127: Filenames. I modified the file.index and htmltemplate
929 #  source so that the filename is used in the link and the creation
930 #  information is displayed.
932 # Revision 1.13  2001/10/21 00:45:15  richard
933 # Added author identification to e-mail messages from roundup.
935 # Revision 1.12  2001/10/04 02:16:15  richard
936 # Forgot to pass the protected flag down *sigh*.
938 # Revision 1.11  2001/10/04 02:12:42  richard
939 # Added nicer command-line item adding: passing no arguments will enter an
940 # interactive more which asks for each property in turn. While I was at it, I
941 # fixed an implementation problem WRT the spec - I wasn't raising a
942 # ValueError if the key property was missing from a create(). Also added a
943 # protected=boolean argument to getprops() so we can list only the mutable
944 # properties (defaults to yes, which lists the immutables).
946 # Revision 1.10  2001/08/07 00:24:42  richard
947 # stupid typo
949 # Revision 1.9  2001/08/07 00:15:51  richard
950 # Added the copyright/license notice to (nearly) all files at request of
951 # Bizar Software.
953 # Revision 1.8  2001/08/02 06:38:17  richard
954 # Roundupdb now appends "mailing list" information to its messages which
955 # include the e-mail address and web interface address. Templates may
956 # override this in their db classes to include specific information (support
957 # instructions, etc).
959 # Revision 1.7  2001/07/30 02:38:31  richard
960 # get() now has a default arg - for migration only.
962 # Revision 1.6  2001/07/30 00:05:54  richard
963 # Fixed IssueClass so that superseders links to its classname rather than
964 # hard-coded to "issue".
966 # Revision 1.5  2001/07/29 07:01:39  richard
967 # Added vim command to all source so that we don't get no steenkin' tabs :)
969 # Revision 1.4  2001/07/29 04:05:37  richard
970 # Added the fabricated property "id".
972 # Revision 1.3  2001/07/23 07:14:41  richard
973 # Moved the database backends off into backends.
975 # Revision 1.2  2001/07/22 12:09:32  richard
976 # Final commit of Grande Splite
978 # Revision 1.1  2001/07/22 11:58:35  richard
979 # More Grande Splite
982 # vim: set filetype=python ts=4 sw=4 et si