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