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