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