Code

. incorporated patch from Roch'e Compaan implementing attachments in nosy
[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.18 2001-11-15 10:36:17 richard Exp $
20 import re, os, smtplib, socket
21 import mimetools, MimeWriter, cStringIO
22 import binascii, mimetypes
24 import hyperdb, date
26 class DesignatorError(ValueError):
27     pass
28 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
29     ''' Take a foo123 and return ('foo', 123)
30     '''
31     m = dre.match(designator)
32     if m is None:
33         raise DesignatorError, '"%s" not a node designator'%designator
34     return m.group(1), m.group(2)
37 class Database:
38     def getuid(self):
39         """Return the id of the "user" node associated with the user
40         that owns this connection to the hyperdatabase."""
41         return self.user.lookup(self.journaltag)
43     def uidFromAddress(self, address, create=1):
44         ''' address is from the rfc822 module, and therefore is (name, addr)
46             user is created if they don't exist in the db already
47         '''
48         (realname, address) = address
49         users = self.user.stringFind(address=address)
50         for dummy in range(2):
51             if len(users) > 1:
52                 # make sure we don't match the anonymous or admin user
53                 for user in users:
54                     if user == '1': continue
55                     if self.user.get(user, 'username') == 'anonymous': continue
56                     # first valid match will do
57                     return user
58                 # well, I guess we have no choice
59                 return user[0]
60             elif users:
61                 return users[0]
62             # try to match the username to the address (for local
63             # submissions where the address is empty)
64             users = self.user.stringFind(username=address)
66         # couldn't match address or username, so create a new user
67         return self.user.create(username=address, address=address,
68             realname=realname)
70 _marker = []
71 # XXX: added the 'creator' faked attribute
72 class Class(hyperdb.Class):
73     # Overridden methods:
74     def __init__(self, db, classname, **properties):
75         if (properties.has_key('creation') or properties.has_key('activity')
76                 or properties.has_key('creator')):
77             raise ValueError, '"creation", "activity" and "creator" are reserved'
78         hyperdb.Class.__init__(self, db, classname, **properties)
79         self.auditors = {'create': [], 'set': [], 'retire': []}
80         self.reactors = {'create': [], 'set': [], 'retire': []}
82     def create(self, **propvalues):
83         """These operations trigger detectors and can be vetoed.  Attempts
84         to modify the "creation" or "activity" properties cause a KeyError.
85         """
86         if propvalues.has_key('creation') or propvalues.has_key('activity'):
87             raise KeyError, '"creation" and "activity" are reserved'
88         for audit in self.auditors['create']:
89             audit(self.db, self, None, propvalues)
90         nodeid = hyperdb.Class.create(self, **propvalues)
91         for react in self.reactors['create']:
92             react(self.db, self, nodeid, None)
93         return nodeid
95     def set(self, nodeid, **propvalues):
96         """These operations trigger detectors and can be vetoed.  Attempts
97         to modify the "creation" or "activity" properties cause a KeyError.
98         """
99         if propvalues.has_key('creation') or propvalues.has_key('activity'):
100             raise KeyError, '"creation" and "activity" are reserved'
101         for audit in self.auditors['set']:
102             audit(self.db, self, nodeid, propvalues)
103         oldvalues = self.db.getnode(self.classname, nodeid)
104         hyperdb.Class.set(self, nodeid, **propvalues)
105         for react in self.reactors['set']:
106             react(self.db, self, nodeid, oldvalues)
108     def retire(self, nodeid):
109         """These operations trigger detectors and can be vetoed.  Attempts
110         to modify the "creation" or "activity" properties cause a KeyError.
111         """
112         for audit in self.auditors['retire']:
113             audit(self.db, self, nodeid, None)
114         hyperdb.Class.retire(self, nodeid)
115         for react in self.reactors['retire']:
116             react(self.db, self, nodeid, None)
118     def get(self, nodeid, propname, default=_marker):
119         """Attempts to get the "creation" or "activity" properties should
120         do the right thing.
121         """
122         if propname == 'creation':
123             journal = self.db.getjournal(self.classname, nodeid)
124             if journal:
125                 return self.db.getjournal(self.classname, nodeid)[0][1]
126             else:
127                 # on the strange chance that there's no journal
128                 return date.Date()
129         if propname == 'activity':
130             journal = self.db.getjournal(self.classname, nodeid)
131             if journal:
132                 return self.db.getjournal(self.classname, nodeid)[-1][1]
133             else:
134                 # on the strange chance that there's no journal
135                 return date.Date()
136         if propname == 'creator':
137             journal = self.db.getjournal(self.classname, nodeid)
138             if journal:
139                 name = self.db.getjournal(self.classname, nodeid)[0][2]
140             else:
141                 return None
142             return self.db.user.lookup(name)
143         if default is not _marker:
144             return hyperdb.Class.get(self, nodeid, propname, default)
145         else:
146             return hyperdb.Class.get(self, nodeid, propname)
148     def getprops(self, protected=1):
149         """In addition to the actual properties on the node, these
150         methods provide the "creation" and "activity" properties. If the
151         "protected" flag is true, we include protected properties - those
152         which may not be modified.
153         """
154         d = hyperdb.Class.getprops(self, protected=protected).copy()
155         if protected:
156             d['creation'] = hyperdb.Date()
157             d['activity'] = hyperdb.Date()
158             d['creator'] = hyperdb.Link("user")
159         return d
161     #
162     # Detector interface
163     #
164     def audit(self, event, detector):
165         """Register a detector
166         """
167         self.auditors[event].append(detector)
169     def react(self, event, detector):
170         """Register a detector
171         """
172         self.reactors[event].append(detector)
175 class FileClass(Class):
176     def create(self, **propvalues):
177         ''' snaffle the file propvalue and store in a file
178         '''
179         content = propvalues['content']
180         del propvalues['content']
181         newid = Class.create(self, **propvalues)
182         self.setcontent(self.classname, newid, content)
183         return newid
185     def filename(self, classname, nodeid):
186         # TODO: split into multiple files directories
187         return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
189     def setcontent(self, classname, nodeid, content):
190         ''' set the content file for this file
191         '''
192         open(self.filename(classname, nodeid), 'wb').write(content)
194     def getcontent(self, classname, nodeid):
195         ''' get the content file for this file
196         '''
197         return open(self.filename(classname, nodeid), 'rb').read()
199     def get(self, nodeid, propname, default=_marker):
200         ''' trap the content propname and get it from the file
201         '''
202         if propname == 'content':
203             return self.getcontent(self.classname, nodeid)
204         if default is not _marker:
205             return Class.get(self, nodeid, propname, default)
206         else:
207             return Class.get(self, nodeid, propname)
209     def getprops(self, protected=1):
210         ''' In addition to the actual properties on the node, these methods
211             provide the "content" property. If the "protected" flag is true,
212             we include protected properties - those which may not be
213             modified.
214         '''
215         d = Class.getprops(self, protected=protected).copy()
216         if protected:
217             d['content'] = hyperdb.String()
218         return d
220 class MessageSendError(RuntimeError):
221     pass
223 class DetectorError(RuntimeError):
224     pass
226 # XXX deviation from spec - was called ItemClass
227 class IssueClass(Class):
228     # configuration
229     MESSAGES_TO_AUTHOR = 'no'
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):
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.
270         
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         # ... but duplicate the message to the author as long as it's not
284         # the anonymous user
285         if (self.MESSAGES_TO_AUTHOR == 'yes' and
286                 self.db.user.get(authid, 'username') != 'anonymous'):
287             if not r.has_key(authid):
288                 recipients.append(authid)
289         r[authid] = 1
291         # now figure the nosy people who weren't recipients
292         nosy = self.get(nodeid, 'nosy')
293         for nosyid in nosy:
294             # Don't send nosy mail to the anonymous user (that user
295             # shouldn't appear in the nosy list, but just in case they
296             # do...)
297             if self.db.user.get(nosyid, 'username') == 'anonymous': continue
298             if not r.has_key(nosyid):
299                 recipients.append(nosyid)
301         # no new recipients
302         if rlen == len(recipients):
303             return
305         # update the message's recipients list
306         self.db.msg.set(msgid, recipients=recipients)
308         # send an email to the people who missed out
309         sendto = [self.db.user.get(i, 'address') for i in recipients]
310         cn = self.classname
311         title = self.get(nodeid, 'title') or '%s message copy'%cn
312         # figure author information
313         authname = self.db.user.get(authid, 'realname')
314         if not authname:
315             authname = self.db.user.get(authid, 'username')
316         authaddr = self.db.user.get(authid, 'address')
317         if authaddr:
318             authaddr = '<%s> '%authaddr
319         else:
320             authaddr = ''
321         # make the message body
322         m = []
323         # add author information
324         m.append("%s %sadded the comment:"%(authname, authaddr))
325         m.append('')
326         # add the content
327         m.append(self.db.msg.get(msgid, 'content'))
328         # "list information" footer
329         m.append(self.email_footer(nodeid, msgid))
331         # get the files for this message
332         files = self.db.msg.get(msgid, 'files')
334         # create the message
335         message = cStringIO.StringIO()
336         writer = MimeWriter.MimeWriter(message)
337         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
338         writer.addheader('To', ', '.join(sendto))
339         writer.addheader('Form', self.ISSUE_TRACKER_EMAIL)
340         writer.addheader('Reply-To:', self.ISSUE_TRACKER_EMAIL)
341         if files:
342             part = writer.startmultipartbody('mixed')
343             part = writer.nextpart()
344             body = part.startbody('text/plain')
345             body.write('\n'.join(m))
346             for fileid in files:
347                 name = self.db.file.get(fileid, 'name')
348                 type = self.db.file.get(fileid, 'type')
349                 content = self.db.file.get(fileid, 'content')
350                 part = writer.nextpart()
351                 if type == 'text/plain':
352                     part.addheader('Content-Disposition',
353                         'attachment;\n filename="%s"'%name)
354                     part.addheader('Content-Transfer-Encoding', '7bit')
355                     body = part.startbody('text/plain')
356                     body.write(content)
357                 else:
358                     type = mimetypes.guess_type(name)[0]
359                     part.addheader('Content-Disposition',
360                         'attachment;\n filename="%s"'%name)
361                     part.addheader('Content-Transfer-Encoding', 'base64')
362                     body = part.startbody(type)
363                 body.write(binascii.b2a_base64(content))
364             writer.lastpart()
365         else:
366             body = writer.startbody('text/plain')
367             body.write('\n'.join(m))
369         # now try to send the message
370         try:
371             smtp = smtplib.SMTP(self.MAILHOST)
372             smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, message.getvalue())
373         except socket.error, value:
374             raise MessageSendError, \
375                 "Couldn't send confirmation email: mailhost %s"%value
376         except smtplib.SMTPException, value:
377             raise MessageSendError, \
378                 "Couldn't send confirmation email: %s"%value
380     def email_footer(self, nodeid, msgid):
381         ''' Add a footer to the e-mail with some useful information
382         '''
383         web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
384         return '''%s
385 Roundup issue tracker
386 %s
387 %s
388 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
391 # $Log: not supported by cvs2svn $
392 # Revision 1.17  2001/11/12 22:01:06  richard
393 # Fixed issues with nosy reaction and author copies.
395 # Revision 1.16  2001/10/30 00:54:45  richard
396 # Features:
397 #  . #467129 ] Lossage when username=e-mail-address
398 #  . #473123 ] Change message generation for author
399 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
401 # Revision 1.15  2001/10/23 01:00:18  richard
402 # Re-enabled login and registration access after lopping them off via
403 # disabling access for anonymous users.
404 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
405 # a couple of bugs while I was there. Probably introduced a couple, but
406 # things seem to work OK at the moment.
408 # Revision 1.14  2001/10/21 07:26:35  richard
409 # feature #473127: Filenames. I modified the file.index and htmltemplate
410 #  source so that the filename is used in the link and the creation
411 #  information is displayed.
413 # Revision 1.13  2001/10/21 00:45:15  richard
414 # Added author identification to e-mail messages from roundup.
416 # Revision 1.12  2001/10/04 02:16:15  richard
417 # Forgot to pass the protected flag down *sigh*.
419 # Revision 1.11  2001/10/04 02:12:42  richard
420 # Added nicer command-line item adding: passing no arguments will enter an
421 # interactive more which asks for each property in turn. While I was at it, I
422 # fixed an implementation problem WRT the spec - I wasn't raising a
423 # ValueError if the key property was missing from a create(). Also added a
424 # protected=boolean argument to getprops() so we can list only the mutable
425 # properties (defaults to yes, which lists the immutables).
427 # Revision 1.10  2001/08/07 00:24:42  richard
428 # stupid typo
430 # Revision 1.9  2001/08/07 00:15:51  richard
431 # Added the copyright/license notice to (nearly) all files at request of
432 # Bizar Software.
434 # Revision 1.8  2001/08/02 06:38:17  richard
435 # Roundupdb now appends "mailing list" information to its messages which
436 # include the e-mail address and web interface address. Templates may
437 # override this in their db classes to include specific information (support
438 # instructions, etc).
440 # Revision 1.7  2001/07/30 02:38:31  richard
441 # get() now has a default arg - for migration only.
443 # Revision 1.6  2001/07/30 00:05:54  richard
444 # Fixed IssueClass so that superseders links to its classname rather than
445 # hard-coded to "issue".
447 # Revision 1.5  2001/07/29 07:01:39  richard
448 # Added vim command to all source so that we don't get no steenkin' tabs :)
450 # Revision 1.4  2001/07/29 04:05:37  richard
451 # Added the fabricated property "id".
453 # Revision 1.3  2001/07/23 07:14:41  richard
454 # Moved the database backends off into backends.
456 # Revision 1.2  2001/07/22 12:09:32  richard
457 # Final commit of Grande Splite
459 # Revision 1.1  2001/07/22 11:58:35  richard
460 # More Grande Splite
463 # vim: set filetype=python ts=4 sw=4 et si