f57139ed49d33716f6779915d051773325dc5a58
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.33 2001-12-16 10:53:37 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.setcontent(self.classname, newid, content)
192 return newid
194 def filename(self, classname, nodeid):
195 # TODO: split into multiple files directories
196 return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
198 def setcontent(self, classname, nodeid, content):
199 ''' set the content file for this file
200 '''
201 open(self.filename(classname, nodeid), 'wb').write(content)
203 def getcontent(self, classname, nodeid):
204 ''' get the content file for this file
205 '''
206 return open(self.filename(classname, nodeid), 'rb').read()
208 def get(self, nodeid, propname, default=_marker):
209 ''' trap the content propname and get it from the file
210 '''
211 if propname == 'content':
212 return self.getcontent(self.classname, nodeid)
213 if default is not _marker:
214 return Class.get(self, nodeid, propname, default)
215 else:
216 return Class.get(self, nodeid, propname)
218 def getprops(self, protected=1):
219 ''' In addition to the actual properties on the node, these methods
220 provide the "content" property. If the "protected" flag is true,
221 we include protected properties - those which may not be
222 modified.
223 '''
224 d = Class.getprops(self, protected=protected).copy()
225 if protected:
226 d['content'] = hyperdb.String()
227 return d
229 class MessageSendError(RuntimeError):
230 pass
232 class DetectorError(RuntimeError):
233 pass
235 # XXX deviation from spec - was called ItemClass
236 class IssueClass(Class):
237 # configuration
238 MESSAGES_TO_AUTHOR = 'no'
239 INSTANCE_NAME = 'Roundup issue tracker'
240 EMAIL_SIGNATURE_POSITION = 'bottom'
242 # Overridden methods:
244 def __init__(self, db, classname, **properties):
245 """The newly-created class automatically includes the "messages",
246 "files", "nosy", and "superseder" properties. If the 'properties'
247 dictionary attempts to specify any of these properties or a
248 "creation" or "activity" property, a ValueError is raised."""
249 if not properties.has_key('title'):
250 properties['title'] = hyperdb.String()
251 if not properties.has_key('messages'):
252 properties['messages'] = hyperdb.Multilink("msg")
253 if not properties.has_key('files'):
254 properties['files'] = hyperdb.Multilink("file")
255 if not properties.has_key('nosy'):
256 properties['nosy'] = hyperdb.Multilink("user")
257 if not properties.has_key('superseder'):
258 properties['superseder'] = hyperdb.Multilink(classname)
259 Class.__init__(self, db, classname, **properties)
261 # New methods:
263 def addmessage(self, nodeid, summary, text):
264 """Add a message to an issue's mail spool.
266 A new "msg" node is constructed using the current date, the user that
267 owns the database connection as the author, and the specified summary
268 text.
270 The "files" and "recipients" fields are left empty.
272 The given text is saved as the body of the message and the node is
273 appended to the "messages" field of the specified issue.
274 """
276 def sendmessage(self, nodeid, msgid, change_note):
277 """Send a message to the members of an issue's nosy list.
279 The message is sent only to users on the nosy list who are not
280 already on the "recipients" list for the message.
282 These users are then added to the message's "recipients" list.
283 """
284 # figure the recipient ids
285 recipients = self.db.msg.get(msgid, 'recipients')
286 r = {}
287 for recipid in recipients:
288 r[recipid] = 1
289 rlen = len(recipients)
291 # figure the author's id, and indicate they've received the message
292 authid = self.db.msg.get(msgid, 'author')
294 # get the current nosy list, we'll need it
295 nosy = self.get(nodeid, 'nosy')
297 # ... but duplicate the message to the author as long as it's not
298 # the anonymous user
299 if (self.MESSAGES_TO_AUTHOR == 'yes' and
300 self.db.user.get(authid, 'username') != 'anonymous'):
301 if not r.has_key(authid):
302 recipients.append(authid)
303 r[authid] = 1
305 # now figure the nosy people who weren't recipients
306 for nosyid in nosy:
307 # Don't send nosy mail to the anonymous user (that user
308 # shouldn't appear in the nosy list, but just in case they
309 # do...)
310 if self.db.user.get(nosyid, 'username') == 'anonymous': continue
311 if not r.has_key(nosyid):
312 recipients.append(nosyid)
314 # no new recipients
315 if rlen == len(recipients):
316 return
318 # update the message's recipients list
319 self.db.msg.set(msgid, recipients=recipients)
321 # send an email to the people who missed out
322 sendto = [self.db.user.get(i, 'address') for i in recipients]
323 cn = self.classname
324 title = self.get(nodeid, 'title') or '%s message copy'%cn
325 # figure author information
326 authname = self.db.user.get(authid, 'realname')
327 if not authname:
328 authname = self.db.user.get(authid, 'username')
329 authaddr = self.db.user.get(authid, 'address')
330 if authaddr:
331 authaddr = ' <%s>'%authaddr
332 else:
333 authaddr = ''
335 # make the message body
336 m = ['']
338 # put in roundup's signature
339 if self.EMAIL_SIGNATURE_POSITION == 'top':
340 m.append(self.email_signature(nodeid, msgid))
342 # add author information
343 if len(self.get(nodeid,'messages')) == 1:
344 m.append("New submission from %s%s:"%(authname, authaddr))
345 else:
346 m.append("%s%s added the comment:"%(authname, authaddr))
347 m.append('')
349 # add the content
350 m.append(self.db.msg.get(msgid, 'content'))
352 # add the change note
353 if change_note:
354 m.append(change_note)
356 # put in roundup's signature
357 if self.EMAIL_SIGNATURE_POSITION == 'bottom':
358 m.append(self.email_signature(nodeid, msgid))
360 # get the files for this message
361 files = self.db.msg.get(msgid, 'files')
363 # create the message
364 message = cStringIO.StringIO()
365 writer = MimeWriter.MimeWriter(message)
366 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
367 writer.addheader('To', ', '.join(sendto))
368 writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
369 writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
370 self.ISSUE_TRACKER_EMAIL))
371 writer.addheader('MIME-Version', '1.0')
373 # attach files
374 if files:
375 part = writer.startmultipartbody('mixed')
376 part = writer.nextpart()
377 body = part.startbody('text/plain')
378 body.write('\n'.join(m))
379 for fileid in files:
380 name = self.db.file.get(fileid, 'name')
381 mime_type = self.db.file.get(fileid, 'type')
382 content = self.db.file.get(fileid, 'content')
383 part = writer.nextpart()
384 if mime_type == 'text/plain':
385 part.addheader('Content-Disposition',
386 'attachment;\n filename="%s"'%name)
387 part.addheader('Content-Transfer-Encoding', '7bit')
388 body = part.startbody('text/plain')
389 body.write(content)
390 else:
391 # some other type, so encode it
392 if not mime_type:
393 # this should have been done when the file was saved
394 mime_type = mimetypes.guess_type(name)[0]
395 if mime_type is None:
396 mime_type = 'application/octet-stream'
397 part.addheader('Content-Disposition',
398 'attachment;\n filename="%s"'%name)
399 part.addheader('Content-Transfer-Encoding', 'base64')
400 body = part.startbody(mime_type)
401 body.write(base64.encodestring(content))
402 writer.lastpart()
403 else:
404 body = writer.startbody('text/plain')
405 body.write('\n'.join(m))
407 # now try to send the message
408 try:
409 if ROUNDUPDBSENDMAILDEBUG:
410 print 'From: %s\nTo: %s\n%s\n=-=-=-=-=-=-=-='%(
411 self.ADMIN_EMAIL, sendto, message.getvalue())
412 else:
413 smtp = smtplib.SMTP(self.MAILHOST)
414 # send the message as admin so bounces are sent there instead
415 # of to roundup
416 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
417 except socket.error, value:
418 raise MessageSendError, \
419 "Couldn't send confirmation email: mailhost %s"%value
420 except smtplib.SMTPException, value:
421 raise MessageSendError, \
422 "Couldn't send confirmation email: %s"%value
424 def email_signature(self, nodeid, msgid):
425 ''' Add a signature to the e-mail with some useful information
426 '''
427 web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
428 email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
429 line = '_' * max(len(web), len(email))
430 return '%s\n%s\n%s\n%s'%(line, email, web, line)
432 def generateChangeNote(self, nodeid, oldvalues):
433 """Generate a change note that lists property changes
434 """
435 cn = self.classname
436 cl = self.db.classes[cn]
437 changed = {}
438 props = cl.getprops(protected=0)
440 # determine what changed
441 for key in oldvalues.keys():
442 if key in ['files','messages']: continue
443 new_value = cl.get(nodeid, key)
444 # the old value might be non existent
445 try:
446 old_value = oldvalues[key]
447 if type(new_value) is type([]):
448 new_value.sort()
449 old_value.sort()
450 if new_value != old_value:
451 changed[key] = old_value
452 except:
453 changed[key] = new_value
455 # list the changes
456 m = []
457 for propname, oldvalue in changed.items():
458 prop = cl.properties[propname]
459 value = cl.get(nodeid, propname, None)
460 if isinstance(prop, hyperdb.Link):
461 link = self.db.classes[prop.classname]
462 key = link.labelprop(default_to_id=1)
463 if key:
464 if value:
465 value = link.get(value, key)
466 else:
467 value = ''
468 if oldvalue:
469 oldvalue = link.get(oldvalue, key)
470 else:
471 oldvalue = ''
472 change = '%s -> %s'%(oldvalue, value)
473 elif isinstance(prop, hyperdb.Multilink):
474 change = ''
475 if value is None: value = []
476 if oldvalue is None: oldvalue = []
477 l = []
478 link = self.db.classes[prop.classname]
479 key = link.labelprop(default_to_id=1)
480 # check for additions
481 for entry in value:
482 if entry in oldvalue: continue
483 if key:
484 l.append(link.get(entry, key))
485 else:
486 l.append(entry)
487 if l:
488 change = '+%s'%(', '.join(l))
489 l = []
490 # check for removals
491 for entry in oldvalue:
492 if entry in value: continue
493 if key:
494 l.append(link.get(entry, key))
495 else:
496 l.append(entry)
497 if l:
498 change += ' -%s'%(', '.join(l))
499 else:
500 change = '%s -> %s'%(oldvalue, value)
501 m.append('%s: %s'%(propname, change))
502 if m:
503 m.insert(0, '----------')
504 m.insert(0, '')
505 return '\n'.join(m)
507 #
508 # $Log: not supported by cvs2svn $
509 # Revision 1.32 2001/12/15 23:48:35 richard
510 # Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
511 # actually sending mail :)
512 #
513 # Revision 1.31 2001/12/15 19:24:39 rochecompaan
514 # . Modified cgi interface to change properties only once all changes are
515 # collected, files created and messages generated.
516 # . Moved generation of change note to nosyreactors.
517 # . We now check for changes to "assignedto" to ensure it's added to the
518 # nosy list.
519 #
520 # Revision 1.30 2001/12/12 21:47:45 richard
521 # . Message author's name appears in From: instead of roundup instance name
522 # (which still appears in the Reply-To:)
523 # . envelope-from is now set to the roundup-admin and not roundup itself so
524 # delivery reports aren't sent to roundup (thanks Patrick Ohly)
525 #
526 # Revision 1.29 2001/12/11 04:50:49 richard
527 # fixed the order of the blank line and '-------' line
528 #
529 # Revision 1.28 2001/12/10 22:20:01 richard
530 # Enabled transaction support in the bsddb backend. It uses the anydbm code
531 # where possible, only replacing methods where the db is opened (it uses the
532 # btree opener specifically.)
533 # Also cleaned up some change note generation.
534 # Made the backends package work with pydoc too.
535 #
536 # Revision 1.27 2001/12/10 21:02:53 richard
537 # only insert the -------- change note marker if there is a change note
538 #
539 # Revision 1.26 2001/12/05 14:26:44 rochecompaan
540 # Removed generation of change note from "sendmessage" in roundupdb.py.
541 # The change note is now generated when the message is created.
542 #
543 # Revision 1.25 2001/11/30 20:28:10 rochecompaan
544 # Property changes are now completely traceable, whether changes are
545 # made through the web or by email
546 #
547 # Revision 1.24 2001/11/30 11:29:04 rochecompaan
548 # Property changes are now listed in emails generated by Roundup
549 #
550 # Revision 1.23 2001/11/27 03:17:13 richard
551 # oops
552 #
553 # Revision 1.22 2001/11/27 03:00:50 richard
554 # couple of bugfixes from latest patch integration
555 #
556 # Revision 1.21 2001/11/26 22:55:56 richard
557 # Feature:
558 # . Added INSTANCE_NAME to configuration - used in web and email to identify
559 # the instance.
560 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
561 # signature info in e-mails.
562 # . Some more flexibility in the mail gateway and more error handling.
563 # . Login now takes you to the page you back to the were denied access to.
564 #
565 # Fixed:
566 # . Lots of bugs, thanks Roché and others on the devel mailing list!
567 #
568 # Revision 1.20 2001/11/25 10:11:14 jhermann
569 # Typo fix
570 #
571 # Revision 1.19 2001/11/22 15:46:42 jhermann
572 # Added module docstrings to all modules.
573 #
574 # Revision 1.18 2001/11/15 10:36:17 richard
575 # . incorporated patch from Roch'e Compaan implementing attachments in nosy
576 # e-mail
577 #
578 # Revision 1.17 2001/11/12 22:01:06 richard
579 # Fixed issues with nosy reaction and author copies.
580 #
581 # Revision 1.16 2001/10/30 00:54:45 richard
582 # Features:
583 # . #467129 ] Lossage when username=e-mail-address
584 # . #473123 ] Change message generation for author
585 # . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
586 #
587 # Revision 1.15 2001/10/23 01:00:18 richard
588 # Re-enabled login and registration access after lopping them off via
589 # disabling access for anonymous users.
590 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
591 # a couple of bugs while I was there. Probably introduced a couple, but
592 # things seem to work OK at the moment.
593 #
594 # Revision 1.14 2001/10/21 07:26:35 richard
595 # feature #473127: Filenames. I modified the file.index and htmltemplate
596 # source so that the filename is used in the link and the creation
597 # information is displayed.
598 #
599 # Revision 1.13 2001/10/21 00:45:15 richard
600 # Added author identification to e-mail messages from roundup.
601 #
602 # Revision 1.12 2001/10/04 02:16:15 richard
603 # Forgot to pass the protected flag down *sigh*.
604 #
605 # Revision 1.11 2001/10/04 02:12:42 richard
606 # Added nicer command-line item adding: passing no arguments will enter an
607 # interactive more which asks for each property in turn. While I was at it, I
608 # fixed an implementation problem WRT the spec - I wasn't raising a
609 # ValueError if the key property was missing from a create(). Also added a
610 # protected=boolean argument to getprops() so we can list only the mutable
611 # properties (defaults to yes, which lists the immutables).
612 #
613 # Revision 1.10 2001/08/07 00:24:42 richard
614 # stupid typo
615 #
616 # Revision 1.9 2001/08/07 00:15:51 richard
617 # Added the copyright/license notice to (nearly) all files at request of
618 # Bizar Software.
619 #
620 # Revision 1.8 2001/08/02 06:38:17 richard
621 # Roundupdb now appends "mailing list" information to its messages which
622 # include the e-mail address and web interface address. Templates may
623 # override this in their db classes to include specific information (support
624 # instructions, etc).
625 #
626 # Revision 1.7 2001/07/30 02:38:31 richard
627 # get() now has a default arg - for migration only.
628 #
629 # Revision 1.6 2001/07/30 00:05:54 richard
630 # Fixed IssueClass so that superseders links to its classname rather than
631 # hard-coded to "issue".
632 #
633 # Revision 1.5 2001/07/29 07:01:39 richard
634 # Added vim command to all source so that we don't get no steenkin' tabs :)
635 #
636 # Revision 1.4 2001/07/29 04:05:37 richard
637 # Added the fabricated property "id".
638 #
639 # Revision 1.3 2001/07/23 07:14:41 richard
640 # Moved the database backends off into backends.
641 #
642 # Revision 1.2 2001/07/22 12:09:32 richard
643 # Final commit of Grande Splite
644 #
645 # Revision 1.1 2001/07/22 11:58:35 richard
646 # More Grande Splite
647 #
648 #
649 # vim: set filetype=python ts=4 sw=4 et si