Code

Fixed name/address display problem introduced by an earlier change.
[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.59 2002-06-18 03:55:25 dman13 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     def create(self, **propvalues):
231         ''' snaffle the file propvalue and store in a file
232         '''
233         content = propvalues['content']
234         del propvalues['content']
235         newid = Class.create(self, **propvalues)
236         self.db.storefile(self.classname, newid, None, content)
237         return newid
239     def get(self, nodeid, propname, default=_marker, cache=1):
240         ''' trap the content propname and get it from the file
241         '''
243         poss_msg = 'Possibly a access right configuration problem.'
244         if propname == 'content':
245             try:
246                 return self.db.getfile(self.classname, nodeid, None)
247             except IOError, (strerror):
248                 # BUG: by catching this we donot see an error in the log.
249                 return 'ERROR reading file: %s%s\n%s\n%s'%(
250                         self.classname, nodeid, poss_msg, strerror)
251         if default is not _marker:
252             return Class.get(self, nodeid, propname, default, cache=cache)
253         else:
254             return Class.get(self, nodeid, propname, cache=cache)
256     def getprops(self, protected=1):
257         ''' In addition to the actual properties on the node, these methods
258             provide the "content" property. If the "protected" flag is true,
259             we include protected properties - those which may not be
260             modified.
261         '''
262         d = Class.getprops(self, protected=protected).copy()
263         if protected:
264             d['content'] = hyperdb.String()
265         return d
267 class MessageSendError(RuntimeError):
268     pass
270 class DetectorError(RuntimeError):
271     pass
273 # XXX deviation from spec - was called ItemClass
274 class IssueClass(Class):
276     # Overridden methods:
278     def __init__(self, db, classname, **properties):
279         """The newly-created class automatically includes the "messages",
280         "files", "nosy", and "superseder" properties.  If the 'properties'
281         dictionary attempts to specify any of these properties or a
282         "creation" or "activity" property, a ValueError is raised."""
283         if not properties.has_key('title'):
284             properties['title'] = hyperdb.String()
285         if not properties.has_key('messages'):
286             properties['messages'] = hyperdb.Multilink("msg")
287         if not properties.has_key('files'):
288             properties['files'] = hyperdb.Multilink("file")
289         if not properties.has_key('nosy'):
290             properties['nosy'] = hyperdb.Multilink("user")
291         if not properties.has_key('superseder'):
292             properties['superseder'] = hyperdb.Multilink(classname)
293         Class.__init__(self, db, classname, **properties)
295     # New methods:
297     def addmessage(self, nodeid, summary, text):
298         """Add a message to an issue's mail spool.
300         A new "msg" node is constructed using the current date, the user that
301         owns the database connection as the author, and the specified summary
302         text.
304         The "files" and "recipients" fields are left empty.
306         The given text is saved as the body of the message and the node is
307         appended to the "messages" field of the specified issue.
308         """
310     def nosymessage(self, nodeid, msgid, oldvalues):
311         """Send a message to the members of an issue's nosy list.
313         The message is sent only to users on the nosy list who are not
314         already on the "recipients" list for the message.
315         
316         These users are then added to the message's "recipients" list.
317         """
318         users = self.db.user
319         messages = self.db.msg
321         # figure the recipient ids
322         sendto = []
323         r = {}
324         recipients = messages.get(msgid, 'recipients')
325         for recipid in messages.get(msgid, 'recipients'):
326             r[recipid] = 1
328         # figure the author's id, and indicate they've received the message
329         authid = messages.get(msgid, 'author')
331         # possibly send the message to the author, as long as they aren't
332         # anonymous
333         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
334                 users.get(authid, 'username') != 'anonymous'):
335             sendto.append(authid)
336         r[authid] = 1
338         # now figure the nosy people who weren't recipients
339         nosy = self.get(nodeid, 'nosy')
340         for nosyid in nosy:
341             # Don't send nosy mail to the anonymous user (that user
342             # shouldn't appear in the nosy list, but just in case they
343             # do...)
344             if users.get(nosyid, 'username') == 'anonymous':
345                 continue
346             # make sure they haven't seen the message already
347             if not r.has_key(nosyid):
348                 # send it to them
349                 sendto.append(nosyid)
350                 recipients.append(nosyid)
352         # generate a change note
353         if oldvalues:
354             note = self.generateChangeNote(nodeid, oldvalues)
355         else:
356             note = self.generateCreateNote(nodeid)
358         # we have new recipients
359         if sendto:
360             # map userids to addresses
361             sendto = [users.get(i, 'address') for i in sendto]
363             # update the message's recipients list
364             messages.set(msgid, recipients=recipients)
366             # send the message
367             self.send_message(nodeid, msgid, note, sendto)
369     # XXX backwards compatibility - don't remove
370     sendmessage = nosymessage
372     def send_message(self, nodeid, msgid, note, sendto):
373         '''Actually send the nominated message from this node to the sendto
374            recipients, with the note appended.
375         '''
376         users = self.db.user
377         messages = self.db.msg
378         files = self.db.file
380         # determine the messageid and inreplyto of the message
381         inreplyto = messages.get(msgid, 'inreplyto')
382         messageid = messages.get(msgid, 'messageid')
384         # make up a messageid if there isn't one (web edit)
385         if not messageid:
386             # this is an old message that didn't get a messageid, so
387             # create one
388             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
389                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
390             messages.set(msgid, messageid=messageid)
392         # send an email to the people who missed out
393         cn = self.classname
394         title = self.get(nodeid, 'title') or '%s message copy'%cn
395         # figure author information
396         authid = messages.get(msgid, 'author')
397         authname = users.get(authid, 'realname')
398         if not authname:
399             authname = users.get(authid, 'username')
400         authaddr = users.get(authid, 'address')
401         if authaddr:
402             authaddr = " <%s>" % straddr( ('',authaddr) )
403         else:
404             authaddr = ''
406         # make the message body
407         m = ['']
409         # put in roundup's signature
410         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
411             m.append(self.email_signature(nodeid, msgid))
413         # add author information
414         if len(self.get(nodeid,'messages')) == 1:
415             m.append("New submission from %s%s:"%(authname, authaddr))
416         else:
417             m.append("%s%s added the comment:"%(authname, authaddr))
418         m.append('')
420         # add the content
421         m.append(messages.get(msgid, 'content'))
423         # add the change note
424         if note:
425             m.append(note)
427         # put in roundup's signature
428         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
429             m.append(self.email_signature(nodeid, msgid))
431         # encode the content as quoted-printable
432         content = cStringIO.StringIO('\n'.join(m))
433         content_encoded = cStringIO.StringIO()
434         quopri.encode(content, content_encoded, 0)
435         content_encoded = content_encoded.getvalue()
437         # get the files for this message
438         message_files = messages.get(msgid, 'files')
440         # make sure the To line is always the same (for testing mostly)
441         sendto.sort()
443         # create the message
444         message = cStringIO.StringIO()
445         writer = MimeWriter.MimeWriter(message)
446         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
447         writer.addheader('To', ', '.join(sendto))
448         writer.addheader('From', straddr(
449                               (authname, self.db.config.ISSUE_TRACKER_EMAIL) ) )
450         writer.addheader('Reply-To', straddr( 
451                                         (self.db.config.INSTANCE_NAME,
452                                          self.db.config.ISSUE_TRACKER_EMAIL) ) )
453         writer.addheader('MIME-Version', '1.0')
454         if messageid:
455             writer.addheader('Message-Id', messageid)
456         if inreplyto:
457             writer.addheader('In-Reply-To', inreplyto)
459         # add a uniquely Roundup header to help filtering
460         writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
462         # attach files
463         if message_files:
464             part = writer.startmultipartbody('mixed')
465             part = writer.nextpart()
466             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
467             body = part.startbody('text/plain')
468             body.write(content_encoded)
469             for fileid in message_files:
470                 name = files.get(fileid, 'name')
471                 mime_type = files.get(fileid, 'type')
472                 content = files.get(fileid, 'content')
473                 part = writer.nextpart()
474                 if mime_type == 'text/plain':
475                     part.addheader('Content-Disposition',
476                         'attachment;\n filename="%s"'%name)
477                     part.addheader('Content-Transfer-Encoding', '7bit')
478                     body = part.startbody('text/plain')
479                     body.write(content)
480                 else:
481                     # some other type, so encode it
482                     if not mime_type:
483                         # this should have been done when the file was saved
484                         mime_type = mimetypes.guess_type(name)[0]
485                     if mime_type is None:
486                         mime_type = 'application/octet-stream'
487                     part.addheader('Content-Disposition',
488                         'attachment;\n filename="%s"'%name)
489                     part.addheader('Content-Transfer-Encoding', 'base64')
490                     body = part.startbody(mime_type)
491                     body.write(base64.encodestring(content))
492             writer.lastpart()
493         else:
494             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
495             body = writer.startbody('text/plain')
496             body.write(content_encoded)
498         # now try to send the message
499         if SENDMAILDEBUG:
500             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
501                 self.db.config.ADMIN_EMAIL,
502                 ', '.join(sendto),message.getvalue()))
503         else:
504             try:
505                 # send the message as admin so bounces are sent there
506                 # instead of to roundup
507                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
508                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
509                     message.getvalue())
510             except socket.error, value:
511                 raise MessageSendError, \
512                     "Couldn't send confirmation email: mailhost %s"%value
513             except smtplib.SMTPException, value:
514                 raise MessageSendError, \
515                     "Couldn't send confirmation email: %s"%value
517     def email_signature(self, nodeid, msgid):
518         ''' Add a signature to the e-mail with some useful information
519         '''
521         # simplistic check to see if the url is valid,
522         # then append a trailing slash if it is missing
523         base = self.db.config.ISSUE_TRACKER_WEB 
524         if not isinstance( base , type('') ) or not base.startswith( "http://" ) :
525             base = "Configuration Error: ISSUE_TRACKER_WEB isn't a fully-qualified URL"
526         elif base[-1] != '/' :
527             base += '/'
528         web = base + 'issue'+ nodeid
530         # ensure the email address is properly quoted
531         email = straddr( (self.db.config.INSTANCE_NAME ,
532                           self.db.config.ISSUE_TRACKER_EMAIL) )
534         line = '_' * max(len(web), len(email))
535         return '%s\n%s\n%s\n%s'%(line, email, web, line)
538     def generateCreateNote(self, nodeid):
539         """Generate a create note that lists initial property values
540         """
541         cn = self.classname
542         cl = self.db.classes[cn]
543         props = cl.getprops(protected=0)
545         # list the values
546         m = []
547         l = props.items()
548         l.sort()
549         for propname, prop in l:
550             value = cl.get(nodeid, propname, None)
551             # skip boring entries
552             if not value:
553                 continue
554             if isinstance(prop, hyperdb.Link):
555                 link = self.db.classes[prop.classname]
556                 if value:
557                     key = link.labelprop(default_to_id=1)
558                     if key:
559                         value = link.get(value, key)
560                 else:
561                     value = ''
562             elif isinstance(prop, hyperdb.Multilink):
563                 if value is None: value = []
564                 l = []
565                 link = self.db.classes[prop.classname]
566                 key = link.labelprop(default_to_id=1)
567                 if key:
568                     value = [link.get(entry, key) for entry in value]
569                 value.sort()
570                 value = ', '.join(value)
571             m.append('%s: %s'%(propname, value))
572         m.insert(0, '----------')
573         m.insert(0, '')
574         return '\n'.join(m)
576     def generateChangeNote(self, nodeid, oldvalues):
577         """Generate a change note that lists property changes
578         """
580         if __debug__ :
581             if not isinstance( oldvalues , type({}) ) :
582                 raise TypeError(
583                         "'oldvalues' must be dict-like, not %s."
584                         % str(type(oldvalues)) )
586         cn = self.classname
587         cl = self.db.classes[cn]
588         changed = {}
589         props = cl.getprops(protected=0)
591         # determine what changed
592         for key in oldvalues.keys():
593             if key in ['files','messages']: continue
594             new_value = cl.get(nodeid, key)
595             # the old value might be non existent
596             try:
597                 old_value = oldvalues[key]
598                 if type(new_value) is type([]):
599                     new_value.sort()
600                     old_value.sort()
601                 if new_value != old_value:
602                     changed[key] = old_value
603             except:
604                 changed[key] = new_value
606         # list the changes
607         m = []
608         l = changed.items()
609         l.sort()
610         for propname, oldvalue in l:
611             prop = props[propname]
612             value = cl.get(nodeid, propname, None)
613             if isinstance(prop, hyperdb.Link):
614                 link = self.db.classes[prop.classname]
615                 key = link.labelprop(default_to_id=1)
616                 if key:
617                     if value:
618                         value = link.get(value, key)
619                     else:
620                         value = ''
621                     if oldvalue:
622                         oldvalue = link.get(oldvalue, key)
623                     else:
624                         oldvalue = ''
625                 change = '%s -> %s'%(oldvalue, value)
626             elif isinstance(prop, hyperdb.Multilink):
627                 change = ''
628                 if value is None: value = []
629                 if oldvalue is None: oldvalue = []
630                 l = []
631                 link = self.db.classes[prop.classname]
632                 key = link.labelprop(default_to_id=1)
633                 # check for additions
634                 for entry in value:
635                     if entry in oldvalue: continue
636                     if key:
637                         l.append(link.get(entry, key))
638                     else:
639                         l.append(entry)
640                 if l:
641                     change = '+%s'%(', '.join(l))
642                     l = []
643                 # check for removals
644                 for entry in oldvalue:
645                     if entry in value: continue
646                     if key:
647                         l.append(link.get(entry, key))
648                     else:
649                         l.append(entry)
650                 if l:
651                     change += ' -%s'%(', '.join(l))
652             else:
653                 change = '%s -> %s'%(oldvalue, value)
654             m.append('%s: %s'%(propname, change))
655         if m:
656             m.insert(0, '----------')
657             m.insert(0, '')
658         return '\n'.join(m)
661 # $Log: not supported by cvs2svn $
662 # Revision 1.58  2002/06/16 01:05:15  dman13
663 # Removed temporary workaround -- it seems it was a bug in the
664 # nosyreaction detector in the 0.4.1 extended template and has already
665 # been fixed in CVS.  We'll see.
667 # Revision 1.57  2002/06/15 15:49:29  dman13
668 # Use 'email' instead of 'rfc822', if available.
669 # Don't use isinstance() on a string (not allowed in python 2.1).
670 # Return an error message instead of crashing if 'oldvalues' isn't a
671 #     dict (in generateChangeNote).
673 # Revision 1.56  2002/06/14 03:54:21  dman13
674 # #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it
676 # use the rfc822 module to ensure that every (oddball) email address and
677 # real-name is properly quoted
679 # Revision 1.55  2002/06/11 04:58:07  richard
680 # detabbing
682 # Revision 1.54  2002/05/29 01:16:17  richard
683 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
684 # though.
686 # . #541941 ] changing multilink properties by mail
687 # . #526730 ] search for messages capability
688 # . #505180 ] split MailGW.handle_Message
689 #   - also changed cgi client since it was duplicating the functionality
690 # . build htmlbase if tests are run using CVS checkout (removed note from
691 #   installation.txt)
692 # . don't create an empty message on email issue creation if the email is empty
694 # Revision 1.53  2002/05/25 07:16:24  rochecompaan
695 # Merged search_indexing-branch with HEAD
697 # Revision 1.52  2002/05/15 03:27:16  richard
698 #  . fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
699 #    (thanks dman)
700 #  . fixed some sorting issues that were breaking some unit tests under py2.2
701 #  . mailgw test output dir was confusing the init test (but only on 2.2 *shrug*)
703 # fixed bug in the init unit test that meant only the bsddb test ran if it
704 # could (it clobbered the anydbm test)
706 # Revision 1.51  2002/04/08 03:46:42  richard
707 # make it work
709 # Revision 1.50  2002/04/08 03:40:31  richard
710 #  . added a "detectors" directory for people to put their useful auditors and
711 #    reactors in. Note - the roundupdb.IssueClass.sendmessage method has been
712 #    split and renamed "nosymessage" specifically for things like the nosy
713 #    reactor, and "send_message" which just sends the message.
715 # The initial detector is one that we'll be using here at ekit - it bounces new
716 # issue messages to a team address.
718 # Revision 1.49.2.1  2002/04/19 19:54:42  rochecompaan
719 # cgi_client.py
720 #     removed search link for the time being
721 #     moved rendering of matches to htmltemplate
722 # hyperdb.py
723 #     filtering of nodes on full text search incorporated in filter method
724 # roundupdb.py
725 #     added paramater to call of filter method
726 # roundup_indexer.py
727 #     added search method to RoundupIndexer class
729 # Revision 1.49  2002/03/19 06:41:49  richard
730 # Faster, easier, less mess ;)
732 # Revision 1.48  2002/03/18 18:32:00  rochecompaan
733 # All messages sent to the nosy list are now encoded as quoted-printable.
735 # Revision 1.47  2002/02/27 03:16:02  richard
736 # Fixed a couple of dodgy bits found by pychekcer.
738 # Revision 1.46  2002/02/25 14:22:59  grubert
739 #  . roundup db: catch only IOError in getfile.
741 # Revision 1.44  2002/02/15 07:08:44  richard
742 #  . Alternate email addresses are now available for users. See the MIGRATION
743 #    file for info on how to activate the feature.
745 # Revision 1.43  2002/02/14 22:33:15  richard
746 #  . Added a uniquely Roundup header to email, "X-Roundup-Name"
748 # Revision 1.42  2002/01/21 09:55:14  rochecompaan
749 # Properties in change note are now sorted
751 # Revision 1.41  2002/01/15 00:12:40  richard
752 # #503340 ] creating issue with [asignedto=p.ohly]
754 # Revision 1.40  2002/01/14 22:21:38  richard
755 # #503353 ] setting properties in initial email
757 # Revision 1.39  2002/01/14 02:20:15  richard
758 #  . changed all config accesses so they access either the instance or the
759 #    config attriubute on the db. This means that all config is obtained from
760 #    instance_config instead of the mish-mash of classes. This will make
761 #    switching to a ConfigParser setup easier too, I hope.
763 # At a minimum, this makes migration a _little_ easier (a lot easier in the
764 # 0.5.0 switch, I hope!)
766 # Revision 1.38  2002/01/10 05:57:45  richard
767 # namespace clobberation
769 # Revision 1.37  2002/01/08 04:12:05  richard
770 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
772 # Revision 1.36  2002/01/02 02:31:38  richard
773 # Sorry for the huge checkin message - I was only intending to implement #496356
774 # but I found a number of places where things had been broken by transactions:
775 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
776 #    for _all_ roundup-generated smtp messages to be sent to.
777 #  . the transaction cache had broken the roundupdb.Class set() reactors
778 #  . newly-created author users in the mailgw weren't being committed to the db
780 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
781 # on when I found that stuff :):
782 #  . #496356 ] Use threading in messages
783 #  . detectors were being registered multiple times
784 #  . added tests for mailgw
785 #  . much better attaching of erroneous messages in the mail gateway
787 # Revision 1.35  2001/12/20 15:43:01  rochecompaan
788 # Features added:
789 #  .  Multilink properties are now displayed as comma separated values in
790 #     a textbox
791 #  .  The add user link is now only visible to the admin user
792 #  .  Modified the mail gateway to reject submissions from unknown
793 #     addresses if ANONYMOUS_ACCESS is denied
795 # Revision 1.34  2001/12/17 03:52:48  richard
796 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
797 # storing more than one file per node - if a property name is supplied,
798 # the file is called designator.property.
799 # I decided not to migrate the existing files stored over to the new naming
800 # scheme - the FileClass just doesn't specify the property name.
802 # Revision 1.33  2001/12/16 10:53:37  richard
803 # take a copy of the node dict so that the subsequent set
804 # operation doesn't modify the oldvalues structure
806 # Revision 1.32  2001/12/15 23:48:35  richard
807 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
808 # actually sending mail :)
810 # Revision 1.31  2001/12/15 19:24:39  rochecompaan
811 #  . Modified cgi interface to change properties only once all changes are
812 #    collected, files created and messages generated.
813 #  . Moved generation of change note to nosyreactors.
814 #  . We now check for changes to "assignedto" to ensure it's added to the
815 #    nosy list.
817 # Revision 1.30  2001/12/12 21:47:45  richard
818 #  . Message author's name appears in From: instead of roundup instance name
819 #    (which still appears in the Reply-To:)
820 #  . envelope-from is now set to the roundup-admin and not roundup itself so
821 #    delivery reports aren't sent to roundup (thanks Patrick Ohly)
823 # Revision 1.29  2001/12/11 04:50:49  richard
824 # fixed the order of the blank line and '-------' line
826 # Revision 1.28  2001/12/10 22:20:01  richard
827 # Enabled transaction support in the bsddb backend. It uses the anydbm code
828 # where possible, only replacing methods where the db is opened (it uses the
829 # btree opener specifically.)
830 # Also cleaned up some change note generation.
831 # Made the backends package work with pydoc too.
833 # Revision 1.27  2001/12/10 21:02:53  richard
834 # only insert the -------- change note marker if there is a change note
836 # Revision 1.26  2001/12/05 14:26:44  rochecompaan
837 # Removed generation of change note from "sendmessage" in roundupdb.py.
838 # The change note is now generated when the message is created.
840 # Revision 1.25  2001/11/30 20:28:10  rochecompaan
841 # Property changes are now completely traceable, whether changes are
842 # made through the web or by email
844 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
845 # Property changes are now listed in emails generated by Roundup
847 # Revision 1.23  2001/11/27 03:17:13  richard
848 # oops
850 # Revision 1.22  2001/11/27 03:00:50  richard
851 # couple of bugfixes from latest patch integration
853 # Revision 1.21  2001/11/26 22:55:56  richard
854 # Feature:
855 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
856 #    the instance.
857 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
858 #    signature info in e-mails.
859 #  . Some more flexibility in the mail gateway and more error handling.
860 #  . Login now takes you to the page you back to the were denied access to.
862 # Fixed:
863 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
865 # Revision 1.20  2001/11/25 10:11:14  jhermann
866 # Typo fix
868 # Revision 1.19  2001/11/22 15:46:42  jhermann
869 # Added module docstrings to all modules.
871 # Revision 1.18  2001/11/15 10:36:17  richard
872 #  . incorporated patch from Roch'e Compaan implementing attachments in nosy
873 #     e-mail
875 # Revision 1.17  2001/11/12 22:01:06  richard
876 # Fixed issues with nosy reaction and author copies.
878 # Revision 1.16  2001/10/30 00:54:45  richard
879 # Features:
880 #  . #467129 ] Lossage when username=e-mail-address
881 #  . #473123 ] Change message generation for author
882 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
884 # Revision 1.15  2001/10/23 01:00:18  richard
885 # Re-enabled login and registration access after lopping them off via
886 # disabling access for anonymous users.
887 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
888 # a couple of bugs while I was there. Probably introduced a couple, but
889 # things seem to work OK at the moment.
891 # Revision 1.14  2001/10/21 07:26:35  richard
892 # feature #473127: Filenames. I modified the file.index and htmltemplate
893 #  source so that the filename is used in the link and the creation
894 #  information is displayed.
896 # Revision 1.13  2001/10/21 00:45:15  richard
897 # Added author identification to e-mail messages from roundup.
899 # Revision 1.12  2001/10/04 02:16:15  richard
900 # Forgot to pass the protected flag down *sigh*.
902 # Revision 1.11  2001/10/04 02:12:42  richard
903 # Added nicer command-line item adding: passing no arguments will enter an
904 # interactive more which asks for each property in turn. While I was at it, I
905 # fixed an implementation problem WRT the spec - I wasn't raising a
906 # ValueError if the key property was missing from a create(). Also added a
907 # protected=boolean argument to getprops() so we can list only the mutable
908 # properties (defaults to yes, which lists the immutables).
910 # Revision 1.10  2001/08/07 00:24:42  richard
911 # stupid typo
913 # Revision 1.9  2001/08/07 00:15:51  richard
914 # Added the copyright/license notice to (nearly) all files at request of
915 # Bizar Software.
917 # Revision 1.8  2001/08/02 06:38:17  richard
918 # Roundupdb now appends "mailing list" information to its messages which
919 # include the e-mail address and web interface address. Templates may
920 # override this in their db classes to include specific information (support
921 # instructions, etc).
923 # Revision 1.7  2001/07/30 02:38:31  richard
924 # get() now has a default arg - for migration only.
926 # Revision 1.6  2001/07/30 00:05:54  richard
927 # Fixed IssueClass so that superseders links to its classname rather than
928 # hard-coded to "issue".
930 # Revision 1.5  2001/07/29 07:01:39  richard
931 # Added vim command to all source so that we don't get no steenkin' tabs :)
933 # Revision 1.4  2001/07/29 04:05:37  richard
934 # Added the fabricated property "id".
936 # Revision 1.3  2001/07/23 07:14:41  richard
937 # Moved the database backends off into backends.
939 # Revision 1.2  2001/07/22 12:09:32  richard
940 # Final commit of Grande Splite
942 # Revision 1.1  2001/07/22 11:58:35  richard
943 # More Grande Splite
946 # vim: set filetype=python ts=4 sw=4 et si