Code

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