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.34 2001-12-17 03:52:48 richard Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, copy
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 ROUNDUPDBSENDMAILDEBUG = os.environ.get('ROUNDUPDBSENDMAILDEBUG', '')
33 class DesignatorError(ValueError):
34 pass
35 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
36 ''' Take a foo123 and return ('foo', 123)
37 '''
38 m = dre.match(designator)
39 if m is None:
40 raise DesignatorError, '"%s" not a node designator'%designator
41 return m.group(1), m.group(2)
44 class Database:
45 def getuid(self):
46 """Return the id of the "user" node associated with the user
47 that owns this connection to the hyperdatabase."""
48 return self.user.lookup(self.journaltag)
50 def uidFromAddress(self, address, create=1):
51 ''' address is from the rfc822 module, and therefore is (name, addr)
53 user is created if they don't exist in the db already
54 '''
55 (realname, address) = address
56 users = self.user.stringFind(address=address)
57 for dummy in range(2):
58 if len(users) > 1:
59 # make sure we don't match the anonymous or admin user
60 for user in users:
61 if user == '1': continue
62 if self.user.get(user, 'username') == 'anonymous': continue
63 # first valid match will do
64 return user
65 # well, I guess we have no choice
66 return user[0]
67 elif users:
68 return users[0]
69 # try to match the username to the address (for local
70 # submissions where the address is empty)
71 users = self.user.stringFind(username=address)
73 # couldn't match address or username, so create a new user
74 return self.user.create(username=address, address=address,
75 realname=realname)
77 _marker = []
78 # XXX: added the 'creator' faked attribute
79 class Class(hyperdb.Class):
80 # Overridden methods:
81 def __init__(self, db, classname, **properties):
82 if (properties.has_key('creation') or properties.has_key('activity')
83 or properties.has_key('creator')):
84 raise ValueError, '"creation", "activity" and "creator" are reserved'
85 hyperdb.Class.__init__(self, db, classname, **properties)
86 self.auditors = {'create': [], 'set': [], 'retire': []}
87 self.reactors = {'create': [], 'set': [], 'retire': []}
89 def create(self, **propvalues):
90 """These operations trigger detectors and can be vetoed. Attempts
91 to modify the "creation" or "activity" properties cause a KeyError.
92 """
93 if propvalues.has_key('creation') or propvalues.has_key('activity'):
94 raise KeyError, '"creation" and "activity" are reserved'
95 for audit in self.auditors['create']:
96 audit(self.db, self, None, propvalues)
97 nodeid = hyperdb.Class.create(self, **propvalues)
98 for react in self.reactors['create']:
99 react(self.db, self, nodeid, None)
100 return nodeid
102 def set(self, nodeid, **propvalues):
103 """These operations trigger detectors and can be vetoed. Attempts
104 to modify the "creation" or "activity" properties cause a KeyError.
105 """
106 if propvalues.has_key('creation') or propvalues.has_key('activity'):
107 raise KeyError, '"creation" and "activity" are reserved'
108 for audit in self.auditors['set']:
109 audit(self.db, self, nodeid, propvalues)
110 # take a copy of the node dict so that the subsequent set
111 # operation doesn't modify the oldvalues structure
112 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
113 hyperdb.Class.set(self, nodeid, **propvalues)
114 for react in self.reactors['set']:
115 react(self.db, self, nodeid, oldvalues)
117 def retire(self, nodeid):
118 """These operations trigger detectors and can be vetoed. Attempts
119 to modify the "creation" or "activity" properties cause a KeyError.
120 """
121 for audit in self.auditors['retire']:
122 audit(self.db, self, nodeid, None)
123 hyperdb.Class.retire(self, nodeid)
124 for react in self.reactors['retire']:
125 react(self.db, self, nodeid, None)
127 def get(self, nodeid, propname, default=_marker):
128 """Attempts to get the "creation" or "activity" properties should
129 do the right thing.
130 """
131 if propname == 'creation':
132 journal = self.db.getjournal(self.classname, nodeid)
133 if journal:
134 return self.db.getjournal(self.classname, nodeid)[0][1]
135 else:
136 # on the strange chance that there's no journal
137 return date.Date()
138 if propname == 'activity':
139 journal = self.db.getjournal(self.classname, nodeid)
140 if journal:
141 return self.db.getjournal(self.classname, nodeid)[-1][1]
142 else:
143 # on the strange chance that there's no journal
144 return date.Date()
145 if propname == 'creator':
146 journal = self.db.getjournal(self.classname, nodeid)
147 if journal:
148 name = self.db.getjournal(self.classname, nodeid)[0][2]
149 else:
150 return None
151 return self.db.user.lookup(name)
152 if default is not _marker:
153 return hyperdb.Class.get(self, nodeid, propname, default)
154 else:
155 return hyperdb.Class.get(self, nodeid, propname)
157 def getprops(self, protected=1):
158 """In addition to the actual properties on the node, these
159 methods provide the "creation" and "activity" properties. If the
160 "protected" flag is true, we include protected properties - those
161 which may not be modified.
162 """
163 d = hyperdb.Class.getprops(self, protected=protected).copy()
164 if protected:
165 d['creation'] = hyperdb.Date()
166 d['activity'] = hyperdb.Date()
167 d['creator'] = hyperdb.Link("user")
168 return d
170 #
171 # Detector interface
172 #
173 def audit(self, event, detector):
174 """Register a detector
175 """
176 self.auditors[event].append(detector)
178 def react(self, event, detector):
179 """Register a detector
180 """
181 self.reactors[event].append(detector)
184 class FileClass(Class):
185 def create(self, **propvalues):
186 ''' snaffle the file propvalue and store in a file
187 '''
188 content = propvalues['content']
189 del propvalues['content']
190 newid = Class.create(self, **propvalues)
191 self.db.storefile(self.classname, newid, None, content)
192 return newid
194 def get(self, nodeid, propname, default=_marker):
195 ''' trap the content propname and get it from the file
196 '''
197 if propname == 'content':
198 return self.db.getfile(self.classname, nodeid, None)
199 if default is not _marker:
200 return Class.get(self, nodeid, propname, default)
201 else:
202 return Class.get(self, nodeid, propname)
204 def getprops(self, protected=1):
205 ''' In addition to the actual properties on the node, these methods
206 provide the "content" property. If the "protected" flag is true,
207 we include protected properties - those which may not be
208 modified.
209 '''
210 d = Class.getprops(self, protected=protected).copy()
211 if protected:
212 d['content'] = hyperdb.String()
213 return d
215 class MessageSendError(RuntimeError):
216 pass
218 class DetectorError(RuntimeError):
219 pass
221 # XXX deviation from spec - was called ItemClass
222 class IssueClass(Class):
223 # configuration
224 MESSAGES_TO_AUTHOR = 'no'
225 INSTANCE_NAME = 'Roundup issue tracker'
226 EMAIL_SIGNATURE_POSITION = 'bottom'
228 # Overridden methods:
230 def __init__(self, db, classname, **properties):
231 """The newly-created class automatically includes the "messages",
232 "files", "nosy", and "superseder" properties. If the 'properties'
233 dictionary attempts to specify any of these properties or a
234 "creation" or "activity" property, a ValueError is raised."""
235 if not properties.has_key('title'):
236 properties['title'] = hyperdb.String()
237 if not properties.has_key('messages'):
238 properties['messages'] = hyperdb.Multilink("msg")
239 if not properties.has_key('files'):
240 properties['files'] = hyperdb.Multilink("file")
241 if not properties.has_key('nosy'):
242 properties['nosy'] = hyperdb.Multilink("user")
243 if not properties.has_key('superseder'):
244 properties['superseder'] = hyperdb.Multilink(classname)
245 Class.__init__(self, db, classname, **properties)
247 # New methods:
249 def addmessage(self, nodeid, summary, text):
250 """Add a message to an issue's mail spool.
252 A new "msg" node is constructed using the current date, the user that
253 owns the database connection as the author, and the specified summary
254 text.
256 The "files" and "recipients" fields are left empty.
258 The given text is saved as the body of the message and the node is
259 appended to the "messages" field of the specified issue.
260 """
262 def sendmessage(self, nodeid, msgid, change_note):
263 """Send a message to the members of an issue's nosy list.
265 The message is sent only to users on the nosy list who are not
266 already on the "recipients" list for the message.
268 These users are then added to the message's "recipients" list.
269 """
270 # figure the recipient ids
271 recipients = self.db.msg.get(msgid, 'recipients')
272 r = {}
273 for recipid in recipients:
274 r[recipid] = 1
275 rlen = len(recipients)
277 # figure the author's id, and indicate they've received the message
278 authid = self.db.msg.get(msgid, 'author')
280 # get the current nosy list, we'll need it
281 nosy = self.get(nodeid, 'nosy')
283 # ... but duplicate the message to the author as long as it's not
284 # the anonymous user
285 if (self.MESSAGES_TO_AUTHOR == 'yes' and
286 self.db.user.get(authid, 'username') != 'anonymous'):
287 if not r.has_key(authid):
288 recipients.append(authid)
289 r[authid] = 1
291 # now figure the nosy people who weren't recipients
292 for nosyid in nosy:
293 # Don't send nosy mail to the anonymous user (that user
294 # shouldn't appear in the nosy list, but just in case they
295 # do...)
296 if self.db.user.get(nosyid, 'username') == 'anonymous': continue
297 if not r.has_key(nosyid):
298 recipients.append(nosyid)
300 # no new recipients
301 if rlen == len(recipients):
302 return
304 # update the message's recipients list
305 self.db.msg.set(msgid, recipients=recipients)
307 # send an email to the people who missed out
308 sendto = [self.db.user.get(i, 'address') for i in recipients]
309 cn = self.classname
310 title = self.get(nodeid, 'title') or '%s message copy'%cn
311 # figure author information
312 authname = self.db.user.get(authid, 'realname')
313 if not authname:
314 authname = self.db.user.get(authid, 'username')
315 authaddr = self.db.user.get(authid, 'address')
316 if authaddr:
317 authaddr = ' <%s>'%authaddr
318 else:
319 authaddr = ''
321 # make the message body
322 m = ['']
324 # put in roundup's signature
325 if self.EMAIL_SIGNATURE_POSITION == 'top':
326 m.append(self.email_signature(nodeid, msgid))
328 # add author information
329 if len(self.get(nodeid,'messages')) == 1:
330 m.append("New submission from %s%s:"%(authname, authaddr))
331 else:
332 m.append("%s%s added the comment:"%(authname, authaddr))
333 m.append('')
335 # add the content
336 m.append(self.db.msg.get(msgid, 'content'))
338 # add the change note
339 if change_note:
340 m.append(change_note)
342 # put in roundup's signature
343 if self.EMAIL_SIGNATURE_POSITION == 'bottom':
344 m.append(self.email_signature(nodeid, msgid))
346 # get the files for this message
347 files = self.db.msg.get(msgid, 'files')
349 # create the message
350 message = cStringIO.StringIO()
351 writer = MimeWriter.MimeWriter(message)
352 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
353 writer.addheader('To', ', '.join(sendto))
354 writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
355 writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
356 self.ISSUE_TRACKER_EMAIL))
357 writer.addheader('MIME-Version', '1.0')
359 # attach files
360 if files:
361 part = writer.startmultipartbody('mixed')
362 part = writer.nextpart()
363 body = part.startbody('text/plain')
364 body.write('\n'.join(m))
365 for fileid in files:
366 name = self.db.file.get(fileid, 'name')
367 mime_type = self.db.file.get(fileid, 'type')
368 content = self.db.file.get(fileid, 'content')
369 part = writer.nextpart()
370 if mime_type == 'text/plain':
371 part.addheader('Content-Disposition',
372 'attachment;\n filename="%s"'%name)
373 part.addheader('Content-Transfer-Encoding', '7bit')
374 body = part.startbody('text/plain')
375 body.write(content)
376 else:
377 # some other type, so encode it
378 if not mime_type:
379 # this should have been done when the file was saved
380 mime_type = mimetypes.guess_type(name)[0]
381 if mime_type is None:
382 mime_type = 'application/octet-stream'
383 part.addheader('Content-Disposition',
384 'attachment;\n filename="%s"'%name)
385 part.addheader('Content-Transfer-Encoding', 'base64')
386 body = part.startbody(mime_type)
387 body.write(base64.encodestring(content))
388 writer.lastpart()
389 else:
390 body = writer.startbody('text/plain')
391 body.write('\n'.join(m))
393 # now try to send the message
394 try:
395 if ROUNDUPDBSENDMAILDEBUG:
396 print 'From: %s\nTo: %s\n%s\n=-=-=-=-=-=-=-='%(
397 self.ADMIN_EMAIL, sendto, message.getvalue())
398 else:
399 smtp = smtplib.SMTP(self.MAILHOST)
400 # send the message as admin so bounces are sent there instead
401 # of to roundup
402 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
403 except socket.error, value:
404 raise MessageSendError, \
405 "Couldn't send confirmation email: mailhost %s"%value
406 except smtplib.SMTPException, value:
407 raise MessageSendError, \
408 "Couldn't send confirmation email: %s"%value
410 def email_signature(self, nodeid, msgid):
411 ''' Add a signature to the e-mail with some useful information
412 '''
413 web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
414 email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
415 line = '_' * max(len(web), len(email))
416 return '%s\n%s\n%s\n%s'%(line, email, web, line)
418 def generateChangeNote(self, nodeid, oldvalues):
419 """Generate a change note that lists property changes
420 """
421 cn = self.classname
422 cl = self.db.classes[cn]
423 changed = {}
424 props = cl.getprops(protected=0)
426 # determine what changed
427 for key in oldvalues.keys():
428 if key in ['files','messages']: continue
429 new_value = cl.get(nodeid, key)
430 # the old value might be non existent
431 try:
432 old_value = oldvalues[key]
433 if type(new_value) is type([]):
434 new_value.sort()
435 old_value.sort()
436 if new_value != old_value:
437 changed[key] = old_value
438 except:
439 changed[key] = new_value
441 # list the changes
442 m = []
443 for propname, oldvalue in changed.items():
444 prop = cl.properties[propname]
445 value = cl.get(nodeid, propname, None)
446 if isinstance(prop, hyperdb.Link):
447 link = self.db.classes[prop.classname]
448 key = link.labelprop(default_to_id=1)
449 if key:
450 if value:
451 value = link.get(value, key)
452 else:
453 value = ''
454 if oldvalue:
455 oldvalue = link.get(oldvalue, key)
456 else:
457 oldvalue = ''
458 change = '%s -> %s'%(oldvalue, value)
459 elif isinstance(prop, hyperdb.Multilink):
460 change = ''
461 if value is None: value = []
462 if oldvalue is None: oldvalue = []
463 l = []
464 link = self.db.classes[prop.classname]
465 key = link.labelprop(default_to_id=1)
466 # check for additions
467 for entry in value:
468 if entry in oldvalue: continue
469 if key:
470 l.append(link.get(entry, key))
471 else:
472 l.append(entry)
473 if l:
474 change = '+%s'%(', '.join(l))
475 l = []
476 # check for removals
477 for entry in oldvalue:
478 if entry in value: continue
479 if key:
480 l.append(link.get(entry, key))
481 else:
482 l.append(entry)
483 if l:
484 change += ' -%s'%(', '.join(l))
485 else:
486 change = '%s -> %s'%(oldvalue, value)
487 m.append('%s: %s'%(propname, change))
488 if m:
489 m.insert(0, '----------')
490 m.insert(0, '')
491 return '\n'.join(m)
493 #
494 # $Log: not supported by cvs2svn $
495 # Revision 1.33 2001/12/16 10:53:37 richard
496 # take a copy of the node dict so that the subsequent set
497 # operation doesn't modify the oldvalues structure
498 #
499 # Revision 1.32 2001/12/15 23:48:35 richard
500 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
501 # actually sending mail :)
502 #
503 # Revision 1.31 2001/12/15 19:24:39 rochecompaan
504 # . Modified cgi interface to change properties only once all changes are
505 # collected, files created and messages generated.
506 # . Moved generation of change note to nosyreactors.
507 # . We now check for changes to "assignedto" to ensure it's added to the
508 # nosy list.
509 #
510 # Revision 1.30 2001/12/12 21:47:45 richard
511 # . Message author's name appears in From: instead of roundup instance name
512 # (which still appears in the Reply-To:)
513 # . envelope-from is now set to the roundup-admin and not roundup itself so
514 # delivery reports aren't sent to roundup (thanks Patrick Ohly)
515 #
516 # Revision 1.29 2001/12/11 04:50:49 richard
517 # fixed the order of the blank line and '-------' line
518 #
519 # Revision 1.28 2001/12/10 22:20:01 richard
520 # Enabled transaction support in the bsddb backend. It uses the anydbm code
521 # where possible, only replacing methods where the db is opened (it uses the
522 # btree opener specifically.)
523 # Also cleaned up some change note generation.
524 # Made the backends package work with pydoc too.
525 #
526 # Revision 1.27 2001/12/10 21:02:53 richard
527 # only insert the -------- change note marker if there is a change note
528 #
529 # Revision 1.26 2001/12/05 14:26:44 rochecompaan
530 # Removed generation of change note from "sendmessage" in roundupdb.py.
531 # The change note is now generated when the message is created.
532 #
533 # Revision 1.25 2001/11/30 20:28:10 rochecompaan
534 # Property changes are now completely traceable, whether changes are
535 # made through the web or by email
536 #
537 # Revision 1.24 2001/11/30 11:29:04 rochecompaan
538 # Property changes are now listed in emails generated by Roundup
539 #
540 # Revision 1.23 2001/11/27 03:17:13 richard
541 # oops
542 #
543 # Revision 1.22 2001/11/27 03:00:50 richard
544 # couple of bugfixes from latest patch integration
545 #
546 # Revision 1.21 2001/11/26 22:55:56 richard
547 # Feature:
548 # . Added INSTANCE_NAME to configuration - used in web and email to identify
549 # the instance.
550 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
551 # signature info in e-mails.
552 # . Some more flexibility in the mail gateway and more error handling.
553 # . Login now takes you to the page you back to the were denied access to.
554 #
555 # Fixed:
556 # . Lots of bugs, thanks Roché and others on the devel mailing list!
557 #
558 # Revision 1.20 2001/11/25 10:11:14 jhermann
559 # Typo fix
560 #
561 # Revision 1.19 2001/11/22 15:46:42 jhermann
562 # Added module docstrings to all modules.
563 #
564 # Revision 1.18 2001/11/15 10:36:17 richard
565 # . incorporated patch from Roch'e Compaan implementing attachments in nosy
566 # e-mail
567 #
568 # Revision 1.17 2001/11/12 22:01:06 richard
569 # Fixed issues with nosy reaction and author copies.
570 #
571 # Revision 1.16 2001/10/30 00:54:45 richard
572 # Features:
573 # . #467129 ] Lossage when username=e-mail-address
574 # . #473123 ] Change message generation for author
575 # . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
576 #
577 # Revision 1.15 2001/10/23 01:00:18 richard
578 # Re-enabled login and registration access after lopping them off via
579 # disabling access for anonymous users.
580 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
581 # a couple of bugs while I was there. Probably introduced a couple, but
582 # things seem to work OK at the moment.
583 #
584 # Revision 1.14 2001/10/21 07:26:35 richard
585 # feature #473127: Filenames. I modified the file.index and htmltemplate
586 # source so that the filename is used in the link and the creation
587 # information is displayed.
588 #
589 # Revision 1.13 2001/10/21 00:45:15 richard
590 # Added author identification to e-mail messages from roundup.
591 #
592 # Revision 1.12 2001/10/04 02:16:15 richard
593 # Forgot to pass the protected flag down *sigh*.
594 #
595 # Revision 1.11 2001/10/04 02:12:42 richard
596 # Added nicer command-line item adding: passing no arguments will enter an
597 # interactive more which asks for each property in turn. While I was at it, I
598 # fixed an implementation problem WRT the spec - I wasn't raising a
599 # ValueError if the key property was missing from a create(). Also added a
600 # protected=boolean argument to getprops() so we can list only the mutable
601 # properties (defaults to yes, which lists the immutables).
602 #
603 # Revision 1.10 2001/08/07 00:24:42 richard
604 # stupid typo
605 #
606 # Revision 1.9 2001/08/07 00:15:51 richard
607 # Added the copyright/license notice to (nearly) all files at request of
608 # Bizar Software.
609 #
610 # Revision 1.8 2001/08/02 06:38:17 richard
611 # Roundupdb now appends "mailing list" information to its messages which
612 # include the e-mail address and web interface address. Templates may
613 # override this in their db classes to include specific information (support
614 # instructions, etc).
615 #
616 # Revision 1.7 2001/07/30 02:38:31 richard
617 # get() now has a default arg - for migration only.
618 #
619 # Revision 1.6 2001/07/30 00:05:54 richard
620 # Fixed IssueClass so that superseders links to its classname rather than
621 # hard-coded to "issue".
622 #
623 # Revision 1.5 2001/07/29 07:01:39 richard
624 # Added vim command to all source so that we don't get no steenkin' tabs :)
625 #
626 # Revision 1.4 2001/07/29 04:05:37 richard
627 # Added the fabricated property "id".
628 #
629 # Revision 1.3 2001/07/23 07:14:41 richard
630 # Moved the database backends off into backends.
631 #
632 # Revision 1.2 2001/07/22 12:09:32 richard
633 # Final commit of Grande Splite
634 #
635 # Revision 1.1 2001/07/22 11:58:35 richard
636 # More Grande Splite
637 #
638 #
639 # vim: set filetype=python ts=4 sw=4 et si