Code

f57139ed49d33716f6779915d051773325dc5a58
[roundup.git] / roundup / roundupdb.py
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.
281         
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)
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 :)
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.
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)
526 # Revision 1.29  2001/12/11 04:50:49  richard
527 # fixed the order of the blank line and '-------' line
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.
536 # Revision 1.27  2001/12/10 21:02:53  richard
537 # only insert the -------- change note marker if there is a change note
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.
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
547 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
548 # Property changes are now listed in emails generated by Roundup
550 # Revision 1.23  2001/11/27 03:17:13  richard
551 # oops
553 # Revision 1.22  2001/11/27 03:00:50  richard
554 # couple of bugfixes from latest patch integration
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.
565 # Fixed:
566 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
568 # Revision 1.20  2001/11/25 10:11:14  jhermann
569 # Typo fix
571 # Revision 1.19  2001/11/22 15:46:42  jhermann
572 # Added module docstrings to all modules.
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
578 # Revision 1.17  2001/11/12 22:01:06  richard
579 # Fixed issues with nosy reaction and author copies.
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.
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.
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.
599 # Revision 1.13  2001/10/21 00:45:15  richard
600 # Added author identification to e-mail messages from roundup.
602 # Revision 1.12  2001/10/04 02:16:15  richard
603 # Forgot to pass the protected flag down *sigh*.
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).
613 # Revision 1.10  2001/08/07 00:24:42  richard
614 # stupid typo
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.
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).
626 # Revision 1.7  2001/07/30 02:38:31  richard
627 # get() now has a default arg - for migration only.
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".
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 :)
636 # Revision 1.4  2001/07/29 04:05:37  richard
637 # Added the fabricated property "id".
639 # Revision 1.3  2001/07/23 07:14:41  richard
640 # Moved the database backends off into backends.
642 # Revision 1.2  2001/07/22 12:09:32  richard
643 # Final commit of Grande Splite
645 # Revision 1.1  2001/07/22 11:58:35  richard
646 # More Grande Splite
649 # vim: set filetype=python ts=4 sw=4 et si