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