3478bd54dcb9403a235ec9ac41ecf7b257fb0f98
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.37 2002-01-08 04:12:05 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):
240 # configuration
241 MESSAGES_TO_AUTHOR = 'no'
242 INSTANCE_NAME = 'Roundup issue tracker'
243 EMAIL_SIGNATURE_POSITION = 'bottom'
245 # Overridden methods:
247 def __init__(self, db, classname, **properties):
248 """The newly-created class automatically includes the "messages",
249 "files", "nosy", and "superseder" properties. If the 'properties'
250 dictionary attempts to specify any of these properties or a
251 "creation" or "activity" property, a ValueError is raised."""
252 if not properties.has_key('title'):
253 properties['title'] = hyperdb.String()
254 if not properties.has_key('messages'):
255 properties['messages'] = hyperdb.Multilink("msg")
256 if not properties.has_key('files'):
257 properties['files'] = hyperdb.Multilink("file")
258 if not properties.has_key('nosy'):
259 properties['nosy'] = hyperdb.Multilink("user")
260 if not properties.has_key('superseder'):
261 properties['superseder'] = hyperdb.Multilink(classname)
262 Class.__init__(self, db, classname, **properties)
264 # New methods:
266 def addmessage(self, nodeid, summary, text):
267 """Add a message to an issue's mail spool.
269 A new "msg" node is constructed using the current date, the user that
270 owns the database connection as the author, and the specified summary
271 text.
273 The "files" and "recipients" fields are left empty.
275 The given text is saved as the body of the message and the node is
276 appended to the "messages" field of the specified issue.
277 """
279 def sendmessage(self, nodeid, msgid, change_note):
280 """Send a message to the members of an issue's nosy list.
282 The message is sent only to users on the nosy list who are not
283 already on the "recipients" list for the message.
285 These users are then added to the message's "recipients" list.
286 """
287 users = self.db.user
288 messages = self.db.msg
289 files = self.db.file
291 # figure the recipient ids
292 sendto = []
293 r = {}
294 recipients = messages.get(msgid, 'recipients')
295 for recipid in messages.get(msgid, 'recipients'):
296 r[recipid] = 1
298 # figure the author's id, and indicate they've received the message
299 authid = messages.get(msgid, 'author')
301 # get the current nosy list, we'll need it
302 nosy = self.get(nodeid, 'nosy')
304 # possibly send the message to the author, as long as they aren't
305 # anonymous
306 if (self.MESSAGES_TO_AUTHOR == 'yes' and
307 users.get(authid, 'username') != 'anonymous'):
308 sendto.append(authid)
309 r[authid] = 1
311 # now figure the nosy people who weren't recipients
312 for nosyid in nosy:
313 # Don't send nosy mail to the anonymous user (that user
314 # shouldn't appear in the nosy list, but just in case they
315 # do...)
316 if users.get(nosyid, 'username') == 'anonymous':
317 continue
318 # make sure they haven't seen the message already
319 if not r.has_key(nosyid):
320 # send it to them
321 sendto.append(nosyid)
322 recipients.append(nosyid)
324 # no new recipients
325 if not sendto:
326 return
328 # determine the messageid and inreplyto of the message
329 inreplyto = messages.get(msgid, 'inreplyto')
330 messageid = messages.get(msgid, 'messageid')
331 if not messageid:
332 # this is an old message that didn't get a messageid, so
333 # create one
334 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
335 self.classname, nodeid, self.MAIL_DOMAIN)
336 messages.set(msgid, messageid=messageid)
338 # update the message's recipients list
339 messages.set(msgid, recipients=recipients)
341 # send an email to the people who missed out
342 sendto = [users.get(i, 'address') for i in sendto]
343 cn = self.classname
344 title = self.get(nodeid, 'title') or '%s message copy'%cn
345 # figure author information
346 authname = users.get(authid, 'realname')
347 if not authname:
348 authname = users.get(authid, 'username')
349 authaddr = users.get(authid, 'address')
350 if authaddr:
351 authaddr = ' <%s>'%authaddr
352 else:
353 authaddr = ''
355 # make the message body
356 m = ['']
358 # put in roundup's signature
359 if self.EMAIL_SIGNATURE_POSITION == 'top':
360 m.append(self.email_signature(nodeid, msgid))
362 # add author information
363 if len(self.get(nodeid,'messages')) == 1:
364 m.append("New submission from %s%s:"%(authname, authaddr))
365 else:
366 m.append("%s%s added the comment:"%(authname, authaddr))
367 m.append('')
369 # add the content
370 m.append(messages.get(msgid, 'content'))
372 # add the change note
373 if change_note:
374 m.append(change_note)
376 # put in roundup's signature
377 if self.EMAIL_SIGNATURE_POSITION == 'bottom':
378 m.append(self.email_signature(nodeid, msgid))
380 # get the files for this message
381 files = messages.get(msgid, 'files')
383 # create the message
384 message = cStringIO.StringIO()
385 writer = MimeWriter.MimeWriter(message)
386 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
387 writer.addheader('To', ', '.join(sendto))
388 writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
389 writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
390 self.ISSUE_TRACKER_EMAIL))
391 writer.addheader('MIME-Version', '1.0')
392 if messageid:
393 writer.addheader('Message-Id', messageid)
394 if inreplyto:
395 writer.addheader('In-Reply-To', inreplyto)
397 # attach files
398 if files:
399 part = writer.startmultipartbody('mixed')
400 part = writer.nextpart()
401 body = part.startbody('text/plain')
402 body.write('\n'.join(m))
403 for fileid in files:
404 name = files.get(fileid, 'name')
405 mime_type = files.get(fileid, 'type')
406 content = files.get(fileid, 'content')
407 part = writer.nextpart()
408 if mime_type == 'text/plain':
409 part.addheader('Content-Disposition',
410 'attachment;\n filename="%s"'%name)
411 part.addheader('Content-Transfer-Encoding', '7bit')
412 body = part.startbody('text/plain')
413 body.write(content)
414 else:
415 # some other type, so encode it
416 if not mime_type:
417 # this should have been done when the file was saved
418 mime_type = mimetypes.guess_type(name)[0]
419 if mime_type is None:
420 mime_type = 'application/octet-stream'
421 part.addheader('Content-Disposition',
422 'attachment;\n filename="%s"'%name)
423 part.addheader('Content-Transfer-Encoding', 'base64')
424 body = part.startbody(mime_type)
425 body.write(base64.encodestring(content))
426 writer.lastpart()
427 else:
428 body = writer.startbody('text/plain')
429 body.write('\n'.join(m))
431 # now try to send the message
432 if SENDMAILDEBUG:
433 open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
434 self.ADMIN_EMAIL, ', '.join(sendto), message.getvalue()))
435 else:
436 try:
437 # send the message as admin so bounces are sent there
438 # instead of to roundup
439 smtp = smtplib.SMTP(self.MAILHOST)
440 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
441 except socket.error, value:
442 raise MessageSendError, \
443 "Couldn't send confirmation email: mailhost %s"%value
444 except smtplib.SMTPException, value:
445 raise MessageSendError, \
446 "Couldn't send confirmation email: %s"%value
448 def email_signature(self, nodeid, msgid):
449 ''' Add a signature to the e-mail with some useful information
450 '''
451 web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
452 email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
453 line = '_' * max(len(web), len(email))
454 return '%s\n%s\n%s\n%s'%(line, email, web, line)
456 def generateChangeNote(self, nodeid, oldvalues):
457 """Generate a change note that lists property changes
458 """
459 cn = self.classname
460 cl = self.db.classes[cn]
461 changed = {}
462 props = cl.getprops(protected=0)
464 # determine what changed
465 for key in oldvalues.keys():
466 if key in ['files','messages']: continue
467 new_value = cl.get(nodeid, key)
468 # the old value might be non existent
469 try:
470 old_value = oldvalues[key]
471 if type(new_value) is type([]):
472 new_value.sort()
473 old_value.sort()
474 if new_value != old_value:
475 changed[key] = old_value
476 except:
477 changed[key] = new_value
479 # list the changes
480 m = []
481 for propname, oldvalue in changed.items():
482 prop = cl.properties[propname]
483 value = cl.get(nodeid, propname, None)
484 if isinstance(prop, hyperdb.Link):
485 link = self.db.classes[prop.classname]
486 key = link.labelprop(default_to_id=1)
487 if key:
488 if value:
489 value = link.get(value, key)
490 else:
491 value = ''
492 if oldvalue:
493 oldvalue = link.get(oldvalue, key)
494 else:
495 oldvalue = ''
496 change = '%s -> %s'%(oldvalue, value)
497 elif isinstance(prop, hyperdb.Multilink):
498 change = ''
499 if value is None: value = []
500 if oldvalue is None: oldvalue = []
501 l = []
502 link = self.db.classes[prop.classname]
503 key = link.labelprop(default_to_id=1)
504 # check for additions
505 for entry in value:
506 if entry in oldvalue: continue
507 if key:
508 l.append(link.get(entry, key))
509 else:
510 l.append(entry)
511 if l:
512 change = '+%s'%(', '.join(l))
513 l = []
514 # check for removals
515 for entry in oldvalue:
516 if entry in value: continue
517 if key:
518 l.append(link.get(entry, key))
519 else:
520 l.append(entry)
521 if l:
522 change += ' -%s'%(', '.join(l))
523 else:
524 change = '%s -> %s'%(oldvalue, value)
525 m.append('%s: %s'%(propname, change))
526 if m:
527 m.insert(0, '----------')
528 m.insert(0, '')
529 return '\n'.join(m)
531 #
532 # $Log: not supported by cvs2svn $
533 # Revision 1.36 2002/01/02 02:31:38 richard
534 # Sorry for the huge checkin message - I was only intending to implement #496356
535 # but I found a number of places where things had been broken by transactions:
536 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
537 # for _all_ roundup-generated smtp messages to be sent to.
538 # . the transaction cache had broken the roundupdb.Class set() reactors
539 # . newly-created author users in the mailgw weren't being committed to the db
540 #
541 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
542 # on when I found that stuff :):
543 # . #496356 ] Use threading in messages
544 # . detectors were being registered multiple times
545 # . added tests for mailgw
546 # . much better attaching of erroneous messages in the mail gateway
547 #
548 # Revision 1.35 2001/12/20 15:43:01 rochecompaan
549 # Features added:
550 # . Multilink properties are now displayed as comma separated values in
551 # a textbox
552 # . The add user link is now only visible to the admin user
553 # . Modified the mail gateway to reject submissions from unknown
554 # addresses if ANONYMOUS_ACCESS is denied
555 #
556 # Revision 1.34 2001/12/17 03:52:48 richard
557 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
558 # storing more than one file per node - if a property name is supplied,
559 # the file is called designator.property.
560 # I decided not to migrate the existing files stored over to the new naming
561 # scheme - the FileClass just doesn't specify the property name.
562 #
563 # Revision 1.33 2001/12/16 10:53:37 richard
564 # take a copy of the node dict so that the subsequent set
565 # operation doesn't modify the oldvalues structure
566 #
567 # Revision 1.32 2001/12/15 23:48:35 richard
568 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
569 # actually sending mail :)
570 #
571 # Revision 1.31 2001/12/15 19:24:39 rochecompaan
572 # . Modified cgi interface to change properties only once all changes are
573 # collected, files created and messages generated.
574 # . Moved generation of change note to nosyreactors.
575 # . We now check for changes to "assignedto" to ensure it's added to the
576 # nosy list.
577 #
578 # Revision 1.30 2001/12/12 21:47:45 richard
579 # . Message author's name appears in From: instead of roundup instance name
580 # (which still appears in the Reply-To:)
581 # . envelope-from is now set to the roundup-admin and not roundup itself so
582 # delivery reports aren't sent to roundup (thanks Patrick Ohly)
583 #
584 # Revision 1.29 2001/12/11 04:50:49 richard
585 # fixed the order of the blank line and '-------' line
586 #
587 # Revision 1.28 2001/12/10 22:20:01 richard
588 # Enabled transaction support in the bsddb backend. It uses the anydbm code
589 # where possible, only replacing methods where the db is opened (it uses the
590 # btree opener specifically.)
591 # Also cleaned up some change note generation.
592 # Made the backends package work with pydoc too.
593 #
594 # Revision 1.27 2001/12/10 21:02:53 richard
595 # only insert the -------- change note marker if there is a change note
596 #
597 # Revision 1.26 2001/12/05 14:26:44 rochecompaan
598 # Removed generation of change note from "sendmessage" in roundupdb.py.
599 # The change note is now generated when the message is created.
600 #
601 # Revision 1.25 2001/11/30 20:28:10 rochecompaan
602 # Property changes are now completely traceable, whether changes are
603 # made through the web or by email
604 #
605 # Revision 1.24 2001/11/30 11:29:04 rochecompaan
606 # Property changes are now listed in emails generated by Roundup
607 #
608 # Revision 1.23 2001/11/27 03:17:13 richard
609 # oops
610 #
611 # Revision 1.22 2001/11/27 03:00:50 richard
612 # couple of bugfixes from latest patch integration
613 #
614 # Revision 1.21 2001/11/26 22:55:56 richard
615 # Feature:
616 # . Added INSTANCE_NAME to configuration - used in web and email to identify
617 # the instance.
618 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
619 # signature info in e-mails.
620 # . Some more flexibility in the mail gateway and more error handling.
621 # . Login now takes you to the page you back to the were denied access to.
622 #
623 # Fixed:
624 # . Lots of bugs, thanks Roché and others on the devel mailing list!
625 #
626 # Revision 1.20 2001/11/25 10:11:14 jhermann
627 # Typo fix
628 #
629 # Revision 1.19 2001/11/22 15:46:42 jhermann
630 # Added module docstrings to all modules.
631 #
632 # Revision 1.18 2001/11/15 10:36:17 richard
633 # . incorporated patch from Roch'e Compaan implementing attachments in nosy
634 # e-mail
635 #
636 # Revision 1.17 2001/11/12 22:01:06 richard
637 # Fixed issues with nosy reaction and author copies.
638 #
639 # Revision 1.16 2001/10/30 00:54:45 richard
640 # Features:
641 # . #467129 ] Lossage when username=e-mail-address
642 # . #473123 ] Change message generation for author
643 # . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
644 #
645 # Revision 1.15 2001/10/23 01:00:18 richard
646 # Re-enabled login and registration access after lopping them off via
647 # disabling access for anonymous users.
648 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
649 # a couple of bugs while I was there. Probably introduced a couple, but
650 # things seem to work OK at the moment.
651 #
652 # Revision 1.14 2001/10/21 07:26:35 richard
653 # feature #473127: Filenames. I modified the file.index and htmltemplate
654 # source so that the filename is used in the link and the creation
655 # information is displayed.
656 #
657 # Revision 1.13 2001/10/21 00:45:15 richard
658 # Added author identification to e-mail messages from roundup.
659 #
660 # Revision 1.12 2001/10/04 02:16:15 richard
661 # Forgot to pass the protected flag down *sigh*.
662 #
663 # Revision 1.11 2001/10/04 02:12:42 richard
664 # Added nicer command-line item adding: passing no arguments will enter an
665 # interactive more which asks for each property in turn. While I was at it, I
666 # fixed an implementation problem WRT the spec - I wasn't raising a
667 # ValueError if the key property was missing from a create(). Also added a
668 # protected=boolean argument to getprops() so we can list only the mutable
669 # properties (defaults to yes, which lists the immutables).
670 #
671 # Revision 1.10 2001/08/07 00:24:42 richard
672 # stupid typo
673 #
674 # Revision 1.9 2001/08/07 00:15:51 richard
675 # Added the copyright/license notice to (nearly) all files at request of
676 # Bizar Software.
677 #
678 # Revision 1.8 2001/08/02 06:38:17 richard
679 # Roundupdb now appends "mailing list" information to its messages which
680 # include the e-mail address and web interface address. Templates may
681 # override this in their db classes to include specific information (support
682 # instructions, etc).
683 #
684 # Revision 1.7 2001/07/30 02:38:31 richard
685 # get() now has a default arg - for migration only.
686 #
687 # Revision 1.6 2001/07/30 00:05:54 richard
688 # Fixed IssueClass so that superseders links to its classname rather than
689 # hard-coded to "issue".
690 #
691 # Revision 1.5 2001/07/29 07:01:39 richard
692 # Added vim command to all source so that we don't get no steenkin' tabs :)
693 #
694 # Revision 1.4 2001/07/29 04:05:37 richard
695 # Added the fabricated property "id".
696 #
697 # Revision 1.3 2001/07/23 07:14:41 richard
698 # Moved the database backends off into backends.
699 #
700 # Revision 1.2 2001/07/22 12:09:32 richard
701 # Final commit of Grande Splite
702 #
703 # Revision 1.1 2001/07/22 11:58:35 richard
704 # More Grande Splite
705 #
706 #
707 # vim: set filetype=python ts=4 sw=4 et si