603e608ed35f13f0d2a1733f149f1a9172c1f4b2
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.35 2001-12-20 15:43:01 rochecompaan 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 if create:
75 return self.user.create(username=address, address=address,
76 realname=realname)
77 else:
78 return 0
80 _marker = []
81 # XXX: added the 'creator' faked attribute
82 class Class(hyperdb.Class):
83 # Overridden methods:
84 def __init__(self, db, classname, **properties):
85 if (properties.has_key('creation') or properties.has_key('activity')
86 or properties.has_key('creator')):
87 raise ValueError, '"creation", "activity" and "creator" are reserved'
88 hyperdb.Class.__init__(self, db, classname, **properties)
89 self.auditors = {'create': [], 'set': [], 'retire': []}
90 self.reactors = {'create': [], 'set': [], 'retire': []}
92 def create(self, **propvalues):
93 """These operations trigger detectors and can be vetoed. Attempts
94 to modify the "creation" or "activity" properties cause a KeyError.
95 """
96 if propvalues.has_key('creation') or propvalues.has_key('activity'):
97 raise KeyError, '"creation" and "activity" are reserved'
98 for audit in self.auditors['create']:
99 audit(self.db, self, None, propvalues)
100 nodeid = hyperdb.Class.create(self, **propvalues)
101 for react in self.reactors['create']:
102 react(self.db, self, nodeid, None)
103 return nodeid
105 def set(self, nodeid, **propvalues):
106 """These operations trigger detectors and can be vetoed. Attempts
107 to modify the "creation" or "activity" properties cause a KeyError.
108 """
109 if propvalues.has_key('creation') or propvalues.has_key('activity'):
110 raise KeyError, '"creation" and "activity" are reserved'
111 for audit in self.auditors['set']:
112 audit(self.db, self, nodeid, propvalues)
113 # take a copy of the node dict so that the subsequent set
114 # operation doesn't modify the oldvalues structure
115 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
116 hyperdb.Class.set(self, nodeid, **propvalues)
117 for react in self.reactors['set']:
118 react(self.db, self, nodeid, oldvalues)
120 def retire(self, nodeid):
121 """These operations trigger detectors and can be vetoed. Attempts
122 to modify the "creation" or "activity" properties cause a KeyError.
123 """
124 for audit in self.auditors['retire']:
125 audit(self.db, self, nodeid, None)
126 hyperdb.Class.retire(self, nodeid)
127 for react in self.reactors['retire']:
128 react(self.db, self, nodeid, None)
130 def get(self, nodeid, propname, default=_marker):
131 """Attempts to get the "creation" or "activity" properties should
132 do the right thing.
133 """
134 if propname == 'creation':
135 journal = self.db.getjournal(self.classname, nodeid)
136 if journal:
137 return self.db.getjournal(self.classname, nodeid)[0][1]
138 else:
139 # on the strange chance that there's no journal
140 return date.Date()
141 if propname == 'activity':
142 journal = self.db.getjournal(self.classname, nodeid)
143 if journal:
144 return self.db.getjournal(self.classname, nodeid)[-1][1]
145 else:
146 # on the strange chance that there's no journal
147 return date.Date()
148 if propname == 'creator':
149 journal = self.db.getjournal(self.classname, nodeid)
150 if journal:
151 name = self.db.getjournal(self.classname, nodeid)[0][2]
152 else:
153 return None
154 return self.db.user.lookup(name)
155 if default is not _marker:
156 return hyperdb.Class.get(self, nodeid, propname, default)
157 else:
158 return hyperdb.Class.get(self, nodeid, propname)
160 def getprops(self, protected=1):
161 """In addition to the actual properties on the node, these
162 methods provide the "creation" and "activity" properties. If the
163 "protected" flag is true, we include protected properties - those
164 which may not be modified.
165 """
166 d = hyperdb.Class.getprops(self, protected=protected).copy()
167 if protected:
168 d['creation'] = hyperdb.Date()
169 d['activity'] = hyperdb.Date()
170 d['creator'] = hyperdb.Link("user")
171 return d
173 #
174 # Detector interface
175 #
176 def audit(self, event, detector):
177 """Register a detector
178 """
179 self.auditors[event].append(detector)
181 def react(self, event, detector):
182 """Register a detector
183 """
184 self.reactors[event].append(detector)
187 class FileClass(Class):
188 def create(self, **propvalues):
189 ''' snaffle the file propvalue and store in a file
190 '''
191 content = propvalues['content']
192 del propvalues['content']
193 newid = Class.create(self, **propvalues)
194 self.db.storefile(self.classname, newid, None, content)
195 return newid
197 def get(self, nodeid, propname, default=_marker):
198 ''' trap the content propname and get it from the file
199 '''
200 if propname == 'content':
201 return self.db.getfile(self.classname, nodeid, None)
202 if default is not _marker:
203 return Class.get(self, nodeid, propname, default)
204 else:
205 return Class.get(self, nodeid, propname)
207 def getprops(self, protected=1):
208 ''' In addition to the actual properties on the node, these methods
209 provide the "content" property. If the "protected" flag is true,
210 we include protected properties - those which may not be
211 modified.
212 '''
213 d = Class.getprops(self, protected=protected).copy()
214 if protected:
215 d['content'] = hyperdb.String()
216 return d
218 class MessageSendError(RuntimeError):
219 pass
221 class DetectorError(RuntimeError):
222 pass
224 # XXX deviation from spec - was called ItemClass
225 class IssueClass(Class):
226 # configuration
227 MESSAGES_TO_AUTHOR = 'no'
228 INSTANCE_NAME = 'Roundup issue tracker'
229 EMAIL_SIGNATURE_POSITION = 'bottom'
231 # Overridden methods:
233 def __init__(self, db, classname, **properties):
234 """The newly-created class automatically includes the "messages",
235 "files", "nosy", and "superseder" properties. If the 'properties'
236 dictionary attempts to specify any of these properties or a
237 "creation" or "activity" property, a ValueError is raised."""
238 if not properties.has_key('title'):
239 properties['title'] = hyperdb.String()
240 if not properties.has_key('messages'):
241 properties['messages'] = hyperdb.Multilink("msg")
242 if not properties.has_key('files'):
243 properties['files'] = hyperdb.Multilink("file")
244 if not properties.has_key('nosy'):
245 properties['nosy'] = hyperdb.Multilink("user")
246 if not properties.has_key('superseder'):
247 properties['superseder'] = hyperdb.Multilink(classname)
248 Class.__init__(self, db, classname, **properties)
250 # New methods:
252 def addmessage(self, nodeid, summary, text):
253 """Add a message to an issue's mail spool.
255 A new "msg" node is constructed using the current date, the user that
256 owns the database connection as the author, and the specified summary
257 text.
259 The "files" and "recipients" fields are left empty.
261 The given text is saved as the body of the message and the node is
262 appended to the "messages" field of the specified issue.
263 """
265 def sendmessage(self, nodeid, msgid, change_note):
266 """Send a message to the members of an issue's nosy list.
268 The message is sent only to users on the nosy list who are not
269 already on the "recipients" list for the message.
271 These users are then added to the message's "recipients" list.
272 """
273 # figure the recipient ids
274 recipients = self.db.msg.get(msgid, 'recipients')
275 r = {}
276 for recipid in recipients:
277 r[recipid] = 1
278 rlen = len(recipients)
280 # figure the author's id, and indicate they've received the message
281 authid = self.db.msg.get(msgid, 'author')
283 # get the current nosy list, we'll need it
284 nosy = self.get(nodeid, 'nosy')
286 # ... but duplicate the message to the author as long as it's not
287 # the anonymous user
288 if (self.MESSAGES_TO_AUTHOR == 'yes' and
289 self.db.user.get(authid, 'username') != 'anonymous'):
290 if not r.has_key(authid):
291 recipients.append(authid)
292 r[authid] = 1
294 # now figure the nosy people who weren't recipients
295 for nosyid in nosy:
296 # Don't send nosy mail to the anonymous user (that user
297 # shouldn't appear in the nosy list, but just in case they
298 # do...)
299 if self.db.user.get(nosyid, 'username') == 'anonymous': continue
300 if not r.has_key(nosyid):
301 recipients.append(nosyid)
303 # no new recipients
304 if rlen == len(recipients):
305 return
307 # update the message's recipients list
308 self.db.msg.set(msgid, recipients=recipients)
310 # send an email to the people who missed out
311 sendto = [self.db.user.get(i, 'address') for i in recipients]
312 cn = self.classname
313 title = self.get(nodeid, 'title') or '%s message copy'%cn
314 # figure author information
315 authname = self.db.user.get(authid, 'realname')
316 if not authname:
317 authname = self.db.user.get(authid, 'username')
318 authaddr = self.db.user.get(authid, 'address')
319 if authaddr:
320 authaddr = ' <%s>'%authaddr
321 else:
322 authaddr = ''
324 # make the message body
325 m = ['']
327 # put in roundup's signature
328 if self.EMAIL_SIGNATURE_POSITION == 'top':
329 m.append(self.email_signature(nodeid, msgid))
331 # add author information
332 if len(self.get(nodeid,'messages')) == 1:
333 m.append("New submission from %s%s:"%(authname, authaddr))
334 else:
335 m.append("%s%s added the comment:"%(authname, authaddr))
336 m.append('')
338 # add the content
339 m.append(self.db.msg.get(msgid, 'content'))
341 # add the change note
342 if change_note:
343 m.append(change_note)
345 # put in roundup's signature
346 if self.EMAIL_SIGNATURE_POSITION == 'bottom':
347 m.append(self.email_signature(nodeid, msgid))
349 # get the files for this message
350 files = self.db.msg.get(msgid, 'files')
352 # create the message
353 message = cStringIO.StringIO()
354 writer = MimeWriter.MimeWriter(message)
355 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
356 writer.addheader('To', ', '.join(sendto))
357 writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
358 writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
359 self.ISSUE_TRACKER_EMAIL))
360 writer.addheader('MIME-Version', '1.0')
362 # attach files
363 if files:
364 part = writer.startmultipartbody('mixed')
365 part = writer.nextpart()
366 body = part.startbody('text/plain')
367 body.write('\n'.join(m))
368 for fileid in files:
369 name = self.db.file.get(fileid, 'name')
370 mime_type = self.db.file.get(fileid, 'type')
371 content = self.db.file.get(fileid, 'content')
372 part = writer.nextpart()
373 if mime_type == 'text/plain':
374 part.addheader('Content-Disposition',
375 'attachment;\n filename="%s"'%name)
376 part.addheader('Content-Transfer-Encoding', '7bit')
377 body = part.startbody('text/plain')
378 body.write(content)
379 else:
380 # some other type, so encode it
381 if not mime_type:
382 # this should have been done when the file was saved
383 mime_type = mimetypes.guess_type(name)[0]
384 if mime_type is None:
385 mime_type = 'application/octet-stream'
386 part.addheader('Content-Disposition',
387 'attachment;\n filename="%s"'%name)
388 part.addheader('Content-Transfer-Encoding', 'base64')
389 body = part.startbody(mime_type)
390 body.write(base64.encodestring(content))
391 writer.lastpart()
392 else:
393 body = writer.startbody('text/plain')
394 body.write('\n'.join(m))
396 # now try to send the message
397 try:
398 if ROUNDUPDBSENDMAILDEBUG:
399 print 'From: %s\nTo: %s\n%s\n=-=-=-=-=-=-=-='%(
400 self.ADMIN_EMAIL, sendto, message.getvalue())
401 else:
402 smtp = smtplib.SMTP(self.MAILHOST)
403 # send the message as admin so bounces are sent there instead
404 # of to roundup
405 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
406 except socket.error, value:
407 raise MessageSendError, \
408 "Couldn't send confirmation email: mailhost %s"%value
409 except smtplib.SMTPException, value:
410 raise MessageSendError, \
411 "Couldn't send confirmation email: %s"%value
413 def email_signature(self, nodeid, msgid):
414 ''' Add a signature to the e-mail with some useful information
415 '''
416 web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
417 email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
418 line = '_' * max(len(web), len(email))
419 return '%s\n%s\n%s\n%s'%(line, email, web, line)
421 def generateChangeNote(self, nodeid, oldvalues):
422 """Generate a change note that lists property changes
423 """
424 cn = self.classname
425 cl = self.db.classes[cn]
426 changed = {}
427 props = cl.getprops(protected=0)
429 # determine what changed
430 for key in oldvalues.keys():
431 if key in ['files','messages']: continue
432 new_value = cl.get(nodeid, key)
433 # the old value might be non existent
434 try:
435 old_value = oldvalues[key]
436 if type(new_value) is type([]):
437 new_value.sort()
438 old_value.sort()
439 if new_value != old_value:
440 changed[key] = old_value
441 except:
442 changed[key] = new_value
444 # list the changes
445 m = []
446 for propname, oldvalue in changed.items():
447 prop = cl.properties[propname]
448 value = cl.get(nodeid, propname, None)
449 if isinstance(prop, hyperdb.Link):
450 link = self.db.classes[prop.classname]
451 key = link.labelprop(default_to_id=1)
452 if key:
453 if value:
454 value = link.get(value, key)
455 else:
456 value = ''
457 if oldvalue:
458 oldvalue = link.get(oldvalue, key)
459 else:
460 oldvalue = ''
461 change = '%s -> %s'%(oldvalue, value)
462 elif isinstance(prop, hyperdb.Multilink):
463 change = ''
464 if value is None: value = []
465 if oldvalue is None: oldvalue = []
466 l = []
467 link = self.db.classes[prop.classname]
468 key = link.labelprop(default_to_id=1)
469 # check for additions
470 for entry in value:
471 if entry in oldvalue: continue
472 if key:
473 l.append(link.get(entry, key))
474 else:
475 l.append(entry)
476 if l:
477 change = '+%s'%(', '.join(l))
478 l = []
479 # check for removals
480 for entry in oldvalue:
481 if entry in value: continue
482 if key:
483 l.append(link.get(entry, key))
484 else:
485 l.append(entry)
486 if l:
487 change += ' -%s'%(', '.join(l))
488 else:
489 change = '%s -> %s'%(oldvalue, value)
490 m.append('%s: %s'%(propname, change))
491 if m:
492 m.insert(0, '----------')
493 m.insert(0, '')
494 return '\n'.join(m)
496 #
497 # $Log: not supported by cvs2svn $
498 # Revision 1.34 2001/12/17 03:52:48 richard
499 # Implemented file store rollback. As a bonus, the hyperdb is now capable of
500 # storing more than one file per node - if a property name is supplied,
501 # the file is called designator.property.
502 # I decided not to migrate the existing files stored over to the new naming
503 # scheme - the FileClass just doesn't specify the property name.
504 #
505 # Revision 1.33 2001/12/16 10:53:37 richard
506 # take a copy of the node dict so that the subsequent set
507 # operation doesn't modify the oldvalues structure
508 #
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