Code

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