Code

d0ef649bf6152e11e8c6e01d857ea9fc9e4c9c91
[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.17 2001-11-12 22:01:06 richard Exp $
20 import re, os, smtplib, socket
22 import hyperdb, date
24 class DesignatorError(ValueError):
25     pass
26 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
27     ''' Take a foo123 and return ('foo', 123)
28     '''
29     m = dre.match(designator)
30     if m is None:
31         raise DesignatorError, '"%s" not a node designator'%designator
32     return m.group(1), m.group(2)
35 class Database:
36     def getuid(self):
37         """Return the id of the "user" node associated with the user
38         that owns this connection to the hyperdatabase."""
39         return self.user.lookup(self.journaltag)
41     def uidFromAddress(self, address, create=1):
42         ''' address is from the rfc822 module, and therefore is (name, addr)
44             user is created if they don't exist in the db already
45         '''
46         (realname, address) = address
47         users = self.user.stringFind(address=address)
48         for dummy in range(2):
49             if len(users) > 1:
50                 # make sure we don't match the anonymous or admin user
51                 for user in users:
52                     if user == '1': continue
53                     if self.user.get(user, 'username') == 'anonymous': continue
54                     # first valid match will do
55                     return user
56                 # well, I guess we have no choice
57                 return user[0]
58             elif users:
59                 return users[0]
60             # try to match the username to the address (for local
61             # submissions where the address is empty)
62             users = self.user.stringFind(username=address)
64         # couldn't match address or username, so create a new user
65         return self.user.create(username=address, address=address,
66             realname=realname)
68 _marker = []
69 # XXX: added the 'creator' faked attribute
70 class Class(hyperdb.Class):
71     # Overridden methods:
72     def __init__(self, db, classname, **properties):
73         if (properties.has_key('creation') or properties.has_key('activity')
74                 or properties.has_key('creator')):
75             raise ValueError, '"creation", "activity" and "creator" are reserved'
76         hyperdb.Class.__init__(self, db, classname, **properties)
77         self.auditors = {'create': [], 'set': [], 'retire': []}
78         self.reactors = {'create': [], 'set': [], 'retire': []}
80     def create(self, **propvalues):
81         """These operations trigger detectors and can be vetoed.  Attempts
82         to modify the "creation" or "activity" properties cause a KeyError.
83         """
84         if propvalues.has_key('creation') or propvalues.has_key('activity'):
85             raise KeyError, '"creation" and "activity" are reserved'
86         for audit in self.auditors['create']:
87             audit(self.db, self, None, propvalues)
88         nodeid = hyperdb.Class.create(self, **propvalues)
89         for react in self.reactors['create']:
90             react(self.db, self, nodeid, None)
91         return nodeid
93     def set(self, nodeid, **propvalues):
94         """These operations trigger detectors and can be vetoed.  Attempts
95         to modify the "creation" or "activity" properties cause a KeyError.
96         """
97         if propvalues.has_key('creation') or propvalues.has_key('activity'):
98             raise KeyError, '"creation" and "activity" are reserved'
99         for audit in self.auditors['set']:
100             audit(self.db, self, nodeid, propvalues)
101         oldvalues = self.db.getnode(self.classname, nodeid)
102         hyperdb.Class.set(self, nodeid, **propvalues)
103         for react in self.reactors['set']:
104             react(self.db, self, nodeid, oldvalues)
106     def retire(self, nodeid):
107         """These operations trigger detectors and can be vetoed.  Attempts
108         to modify the "creation" or "activity" properties cause a KeyError.
109         """
110         for audit in self.auditors['retire']:
111             audit(self.db, self, nodeid, None)
112         hyperdb.Class.retire(self, nodeid)
113         for react in self.reactors['retire']:
114             react(self.db, self, nodeid, None)
116     def get(self, nodeid, propname, default=_marker):
117         """Attempts to get the "creation" or "activity" properties should
118         do the right thing.
119         """
120         if propname == 'creation':
121             journal = self.db.getjournal(self.classname, nodeid)
122             if journal:
123                 return self.db.getjournal(self.classname, nodeid)[0][1]
124             else:
125                 # on the strange chance that there's no journal
126                 return date.Date()
127         if propname == 'activity':
128             journal = self.db.getjournal(self.classname, nodeid)
129             if journal:
130                 return self.db.getjournal(self.classname, nodeid)[-1][1]
131             else:
132                 # on the strange chance that there's no journal
133                 return date.Date()
134         if propname == 'creator':
135             journal = self.db.getjournal(self.classname, nodeid)
136             if journal:
137                 name = self.db.getjournal(self.classname, nodeid)[0][2]
138             else:
139                 return None
140             return self.db.user.lookup(name)
141         if default is not _marker:
142             return hyperdb.Class.get(self, nodeid, propname, default)
143         else:
144             return hyperdb.Class.get(self, nodeid, propname)
146     def getprops(self, protected=1):
147         """In addition to the actual properties on the node, these
148         methods provide the "creation" and "activity" properties. If the
149         "protected" flag is true, we include protected properties - those
150         which may not be modified.
151         """
152         d = hyperdb.Class.getprops(self, protected=protected).copy()
153         if protected:
154             d['creation'] = hyperdb.Date()
155             d['activity'] = hyperdb.Date()
156             d['creator'] = hyperdb.Link("user")
157         return d
159     #
160     # Detector interface
161     #
162     def audit(self, event, detector):
163         """Register a detector
164         """
165         self.auditors[event].append(detector)
167     def react(self, event, detector):
168         """Register a detector
169         """
170         self.reactors[event].append(detector)
173 class FileClass(Class):
174     def create(self, **propvalues):
175         ''' snaffle the file propvalue and store in a file
176         '''
177         content = propvalues['content']
178         del propvalues['content']
179         newid = Class.create(self, **propvalues)
180         self.setcontent(self.classname, newid, content)
181         return newid
183     def filename(self, classname, nodeid):
184         # TODO: split into multiple files directories
185         return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
187     def setcontent(self, classname, nodeid, content):
188         ''' set the content file for this file
189         '''
190         open(self.filename(classname, nodeid), 'wb').write(content)
192     def getcontent(self, classname, nodeid):
193         ''' get the content file for this file
194         '''
195         return open(self.filename(classname, nodeid), 'rb').read()
197     def get(self, nodeid, propname, default=_marker):
198         ''' trap the content propname and get it from the file
199         '''
200         if propname == 'content':
201             return self.getcontent(self.classname, nodeid)
202         if default is not _marker:
203             return Class.get(self, nodeid, propname, default)
204         else:
205             return Class.get(self, nodeid, propname)
207     def getprops(self, protected=1):
208         ''' In addition to the actual properties on the node, these methods
209             provide the "content" property. If the "protected" flag is true,
210             we include protected properties - those which may not be
211             modified.
212         '''
213         d = Class.getprops(self, protected=protected).copy()
214         if protected:
215             d['content'] = hyperdb.String()
216         return d
218 class MessageSendError(RuntimeError):
219     pass
221 class DetectorError(RuntimeError):
222     pass
224 # XXX deviation from spec - was called ItemClass
225 class IssueClass(Class):
226     # configuration
227     MESSAGES_TO_AUTHOR = 'no'
229     # Overridden methods:
231     def __init__(self, db, classname, **properties):
232         """The newly-created class automatically includes the "messages",
233         "files", "nosy", and "superseder" properties.  If the 'properties'
234         dictionary attempts to specify any of these properties or a
235         "creation" or "activity" property, a ValueError is raised."""
236         if not properties.has_key('title'):
237             properties['title'] = hyperdb.String()
238         if not properties.has_key('messages'):
239             properties['messages'] = hyperdb.Multilink("msg")
240         if not properties.has_key('files'):
241             properties['files'] = hyperdb.Multilink("file")
242         if not properties.has_key('nosy'):
243             properties['nosy'] = hyperdb.Multilink("user")
244         if not properties.has_key('superseder'):
245             properties['superseder'] = hyperdb.Multilink(classname)
246         Class.__init__(self, db, classname, **properties)
248     # New methods:
250     def addmessage(self, nodeid, summary, text):
251         """Add a message to an issue's mail spool.
253         A new "msg" node is constructed using the current date, the user that
254         owns the database connection as the author, and the specified summary
255         text.
257         The "files" and "recipients" fields are left empty.
259         The given text is saved as the body of the message and the node is
260         appended to the "messages" field of the specified issue.
261         """
263     def sendmessage(self, nodeid, msgid):
264         """Send a message to the members of an issue's nosy list.
266         The message is sent only to users on the nosy list who are not
267         already on the "recipients" list for the message.
268         
269         These users are then added to the message's "recipients" list.
270         """
271         # figure the recipient ids
272         recipients = self.db.msg.get(msgid, 'recipients')
273         r = {}
274         for recipid in recipients:
275             r[recipid] = 1
276         rlen = len(recipients)
278         # figure the author's id, and indicate they've received the message
279         authid = self.db.msg.get(msgid, 'author')
281         # ... but duplicate the message to the author as long as it's not
282         # the anonymous user
283         if (self.MESSAGES_TO_AUTHOR == 'yes' and
284                 self.db.user.get(authid, 'username') != 'anonymous'):
285             if not r.has_key(authid):
286                 recipients.append(authid)
287         r[authid] = 1
289         # now figure the nosy people who weren't recipients
290         nosy = self.get(nodeid, 'nosy')
291         for nosyid in nosy:
292             # Don't send nosy mail to the anonymous user (that user
293             # shouldn't appear in the nosy list, but just in case they
294             # do...)
295             if self.db.user.get(nosyid, 'username') == 'anonymous': continue
296             if not r.has_key(nosyid):
297                 recipients.append(nosyid)
299         # no new recipients
300         if rlen == len(recipients):
301             return
303         # update the message's recipients list
304         self.db.msg.set(msgid, recipients=recipients)
306         # send an email to the people who missed out
307         sendto = [self.db.user.get(i, 'address') for i in recipients]
308         cn = self.classname
309         title = self.get(nodeid, 'title') or '%s message copy'%cn
310         # figure author information
311         authname = self.db.user.get(authid, 'realname')
312         if not authname:
313             authname = self.db.user.get(authid, 'username')
314         authaddr = self.db.user.get(authid, 'address')
315         if authaddr:
316             authaddr = '<%s> '%authaddr
317         else:
318             authaddr = ''
319         # TODO attachments
320         m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
321         m.append('To: %s'%', '.join(sendto))
322         m.append('From: %s'%self.ISSUE_TRACKER_EMAIL)
323         m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
324         m.append('')
325         # add author information
326         m.append("%s %sadded the comment:"%(authname, authaddr))
327         m.append('')
328         # add the content
329         m.append(self.db.msg.get(msgid, 'content'))
330         # "list information" footer
331         m.append(self.email_footer(nodeid, msgid))
332         try:
333             smtp = smtplib.SMTP(self.MAILHOST)
334             smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
335         except socket.error, value:
336             raise MessageSendError, \
337                 "Couldn't send confirmation email: mailhost %s"%value
338         except smtplib.SMTPException, value:
339             raise MessageSendError, \
340                  "Couldn't send confirmation email: %s"%value
342     def email_footer(self, nodeid, msgid):
343         ''' Add a footer to the e-mail with some useful information
344         '''
345         web = self.ISSUE_TRACKER_WEB
346         return '''%s
347 Roundup issue tracker
348 %s
349 %s
350 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
353 # $Log: not supported by cvs2svn $
354 # Revision 1.16  2001/10/30 00:54:45  richard
355 # Features:
356 #  . #467129 ] Lossage when username=e-mail-address
357 #  . #473123 ] Change message generation for author
358 #  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
360 # Revision 1.15  2001/10/23 01:00:18  richard
361 # Re-enabled login and registration access after lopping them off via
362 # disabling access for anonymous users.
363 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
364 # a couple of bugs while I was there. Probably introduced a couple, but
365 # things seem to work OK at the moment.
367 # Revision 1.14  2001/10/21 07:26:35  richard
368 # feature #473127: Filenames. I modified the file.index and htmltemplate
369 #  source so that the filename is used in the link and the creation
370 #  information is displayed.
372 # Revision 1.13  2001/10/21 00:45:15  richard
373 # Added author identification to e-mail messages from roundup.
375 # Revision 1.12  2001/10/04 02:16:15  richard
376 # Forgot to pass the protected flag down *sigh*.
378 # Revision 1.11  2001/10/04 02:12:42  richard
379 # Added nicer command-line item adding: passing no arguments will enter an
380 # interactive more which asks for each property in turn. While I was at it, I
381 # fixed an implementation problem WRT the spec - I wasn't raising a
382 # ValueError if the key property was missing from a create(). Also added a
383 # protected=boolean argument to getprops() so we can list only the mutable
384 # properties (defaults to yes, which lists the immutables).
386 # Revision 1.10  2001/08/07 00:24:42  richard
387 # stupid typo
389 # Revision 1.9  2001/08/07 00:15:51  richard
390 # Added the copyright/license notice to (nearly) all files at request of
391 # Bizar Software.
393 # Revision 1.8  2001/08/02 06:38:17  richard
394 # Roundupdb now appends "mailing list" information to its messages which
395 # include the e-mail address and web interface address. Templates may
396 # override this in their db classes to include specific information (support
397 # instructions, etc).
399 # Revision 1.7  2001/07/30 02:38:31  richard
400 # get() now has a default arg - for migration only.
402 # Revision 1.6  2001/07/30 00:05:54  richard
403 # Fixed IssueClass so that superseders links to its classname rather than
404 # hard-coded to "issue".
406 # Revision 1.5  2001/07/29 07:01:39  richard
407 # Added vim command to all source so that we don't get no steenkin' tabs :)
409 # Revision 1.4  2001/07/29 04:05:37  richard
410 # Added the fabricated property "id".
412 # Revision 1.3  2001/07/23 07:14:41  richard
413 # Moved the database backends off into backends.
415 # Revision 1.2  2001/07/22 12:09:32  richard
416 # Final commit of Grande Splite
418 # Revision 1.1  2001/07/22 11:58:35  richard
419 # More Grande Splite
422 # vim: set filetype=python ts=4 sw=4 et si