Code

7650cc8e78768f6f3c2a6b63991bef55a5223429
[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.20 2001-11-25 10:11:14 jhermann 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 binascii, 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'
235     # Overridden methods:
237     def __init__(self, db, classname, **properties):
238         """The newly-created class automatically includes the "messages",
239         "files", "nosy", and "superseder" properties.  If the 'properties'
240         dictionary attempts to specify any of these properties or a
241         "creation" or "activity" property, a ValueError is raised."""
242         if not properties.has_key('title'):
243             properties['title'] = hyperdb.String()
244         if not properties.has_key('messages'):
245             properties['messages'] = hyperdb.Multilink("msg")
246         if not properties.has_key('files'):
247             properties['files'] = hyperdb.Multilink("file")
248         if not properties.has_key('nosy'):
249             properties['nosy'] = hyperdb.Multilink("user")
250         if not properties.has_key('superseder'):
251             properties['superseder'] = hyperdb.Multilink(classname)
252         Class.__init__(self, db, classname, **properties)
254     # New methods:
256     def addmessage(self, nodeid, summary, text):
257         """Add a message to an issue's mail spool.
259         A new "msg" node is constructed using the current date, the user that
260         owns the database connection as the author, and the specified summary
261         text.
263         The "files" and "recipients" fields are left empty.
265         The given text is saved as the body of the message and the node is
266         appended to the "messages" field of the specified issue.
267         """
269     def sendmessage(self, nodeid, msgid):
270         """Send a message to the members of an issue's nosy list.
272         The message is sent only to users on the nosy list who are not
273         already on the "recipients" list for the message.
274         
275         These users are then added to the message's "recipients" list.
276         """
277         # figure the recipient ids
278         recipients = self.db.msg.get(msgid, 'recipients')
279         r = {}
280         for recipid in recipients:
281             r[recipid] = 1
282         rlen = len(recipients)
284         # figure the author's id, and indicate they've received the message
285         authid = self.db.msg.get(msgid, 'author')
287         # ... but duplicate the message to the author as long as it's not
288         # the anonymous user
289         if (self.MESSAGES_TO_AUTHOR == 'yes' and
290                 self.db.user.get(authid, 'username') != 'anonymous'):
291             if not r.has_key(authid):
292                 recipients.append(authid)
293         r[authid] = 1
295         # now figure the nosy people who weren't recipients
296         nosy = self.get(nodeid, 'nosy')
297         for nosyid in nosy:
298             # Don't send nosy mail to the anonymous user (that user
299             # shouldn't appear in the nosy list, but just in case they
300             # do...)
301             if self.db.user.get(nosyid, 'username') == 'anonymous': continue
302             if not r.has_key(nosyid):
303                 recipients.append(nosyid)
305         # no new recipients
306         if rlen == len(recipients):
307             return
309         # update the message's recipients list
310         self.db.msg.set(msgid, recipients=recipients)
312         # send an email to the people who missed out
313         sendto = [self.db.user.get(i, 'address') for i in recipients]
314         cn = self.classname
315         title = self.get(nodeid, 'title') or '%s message copy'%cn
316         # figure author information
317         authname = self.db.user.get(authid, 'realname')
318         if not authname:
319             authname = self.db.user.get(authid, 'username')
320         authaddr = self.db.user.get(authid, 'address')
321         if authaddr:
322             authaddr = '<%s> '%authaddr
323         else:
324             authaddr = ''
325         # make the message body
326         m = []
327         # add author information
328         m.append("%s %sadded the comment:"%(authname, authaddr))
329         m.append('')
330         # add the content
331         m.append(self.db.msg.get(msgid, 'content'))
332         # "list information" footer
333         m.append(self.email_footer(nodeid, msgid))
335         # get the files for this message
336         files = self.db.msg.get(msgid, 'files')
338         # create the message
339         message = cStringIO.StringIO()
340         writer = MimeWriter.MimeWriter(message)
341         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
342         writer.addheader('To', ', '.join(sendto))
343         writer.addheader('From', self.ISSUE_TRACKER_EMAIL)
344         writer.addheader('Reply-To:', self.ISSUE_TRACKER_EMAIL)
345         if files:
346             part = writer.startmultipartbody('mixed')
347             part = writer.nextpart()
348             body = part.startbody('text/plain')
349             body.write('\n'.join(m))
350             for fileid in files:
351                 name = self.db.file.get(fileid, 'name')
352                 type = self.db.file.get(fileid, 'type')
353                 content = self.db.file.get(fileid, 'content')
354                 part = writer.nextpart()
355                 if type == 'text/plain':
356                     part.addheader('Content-Disposition',
357                         'attachment;\n filename="%s"'%name)
358                     part.addheader('Content-Transfer-Encoding', '7bit')
359                     body = part.startbody('text/plain')
360                     body.write(content)
361                 else:
362                     type = mimetypes.guess_type(name)[0]
363                     part.addheader('Content-Disposition',
364                         'attachment;\n filename="%s"'%name)
365                     part.addheader('Content-Transfer-Encoding', 'base64')
366                     body = part.startbody(type)
367                 body.write(binascii.b2a_base64(content))
368             writer.lastpart()
369         else:
370             body = writer.startbody('text/plain')
371             body.write('\n'.join(m))
373         # now try to send the message
374         try:
375             smtp = smtplib.SMTP(self.MAILHOST)
376             smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, message.getvalue())
377         except socket.error, value:
378             raise MessageSendError, \
379                 "Couldn't send confirmation email: mailhost %s"%value
380         except smtplib.SMTPException, value:
381             raise MessageSendError, \
382                 "Couldn't send confirmation email: %s"%value
384     def email_footer(self, nodeid, msgid):
385         ''' Add a footer to the e-mail with some useful information
386         '''
387         web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
388         return '''%s
389 Roundup issue tracker
390 %s
391 %s
392 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
395 # $Log: not supported by cvs2svn $
396 # Revision 1.19  2001/11/22 15:46:42  jhermann
397 # Added module docstrings to all modules.
399 # Revision 1.18  2001/11/15 10:36:17  richard
400 #  . incorporated patch from Roch'e Compaan implementing attachments in nosy
401 #     e-mail
403 # Revision 1.17  2001/11/12 22:01:06  richard
404 # Fixed issues with nosy reaction and author copies.
406 # Revision 1.16  2001/10/30 00:54:45  richard
407 # Features:
408 #  . #467129 ] Lossage when username=e-mail-address
409 #  . #473123 ] Change message generation for author
410 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
412 # Revision 1.15  2001/10/23 01:00:18  richard
413 # Re-enabled login and registration access after lopping them off via
414 # disabling access for anonymous users.
415 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
416 # a couple of bugs while I was there. Probably introduced a couple, but
417 # things seem to work OK at the moment.
419 # Revision 1.14  2001/10/21 07:26:35  richard
420 # feature #473127: Filenames. I modified the file.index and htmltemplate
421 #  source so that the filename is used in the link and the creation
422 #  information is displayed.
424 # Revision 1.13  2001/10/21 00:45:15  richard
425 # Added author identification to e-mail messages from roundup.
427 # Revision 1.12  2001/10/04 02:16:15  richard
428 # Forgot to pass the protected flag down *sigh*.
430 # Revision 1.11  2001/10/04 02:12:42  richard
431 # Added nicer command-line item adding: passing no arguments will enter an
432 # interactive more which asks for each property in turn. While I was at it, I
433 # fixed an implementation problem WRT the spec - I wasn't raising a
434 # ValueError if the key property was missing from a create(). Also added a
435 # protected=boolean argument to getprops() so we can list only the mutable
436 # properties (defaults to yes, which lists the immutables).
438 # Revision 1.10  2001/08/07 00:24:42  richard
439 # stupid typo
441 # Revision 1.9  2001/08/07 00:15:51  richard
442 # Added the copyright/license notice to (nearly) all files at request of
443 # Bizar Software.
445 # Revision 1.8  2001/08/02 06:38:17  richard
446 # Roundupdb now appends "mailing list" information to its messages which
447 # include the e-mail address and web interface address. Templates may
448 # override this in their db classes to include specific information (support
449 # instructions, etc).
451 # Revision 1.7  2001/07/30 02:38:31  richard
452 # get() now has a default arg - for migration only.
454 # Revision 1.6  2001/07/30 00:05:54  richard
455 # Fixed IssueClass so that superseders links to its classname rather than
456 # hard-coded to "issue".
458 # Revision 1.5  2001/07/29 07:01:39  richard
459 # Added vim command to all source so that we don't get no steenkin' tabs :)
461 # Revision 1.4  2001/07/29 04:05:37  richard
462 # Added the fabricated property "id".
464 # Revision 1.3  2001/07/23 07:14:41  richard
465 # Moved the database backends off into backends.
467 # Revision 1.2  2001/07/22 12:09:32  richard
468 # Final commit of Grande Splite
470 # Revision 1.1  2001/07/22 11:58:35  richard
471 # More Grande Splite
474 # vim: set filetype=python ts=4 sw=4 et si