Code

099c1c61532614eded43bda2ecdbe181191cc16b
[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.15 2001-10-23 01:00:18 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)
34 class Database:
35     def getuid(self):
36         """Return the id of the "user" node associated with the user
37         that owns this connection to the hyperdatabase."""
38         return self.user.lookup(self.journaltag)
40     def uidFromAddress(self, address, create=1):
41         ''' address is from the rfc822 module, and therefore is (name, addr)
43             user is created if they don't exist in the db already
44         '''
45         (realname, address) = address
46         users = self.user.stringFind(address=address)
47         if users: return users[0]
48         return self.user.create(username=address, address=address,
49             realname=realname)
51 _marker = []
52 # XXX: added the 'creator' faked attribute
53 class Class(hyperdb.Class):
54     # Overridden methods:
55     def __init__(self, db, classname, **properties):
56         if (properties.has_key('creation') or properties.has_key('activity')
57                 or properties.has_key('creator')):
58             raise ValueError, '"creation", "activity" and "creator" are reserved'
59         hyperdb.Class.__init__(self, db, classname, **properties)
60         self.auditors = {'create': [], 'set': [], 'retire': []}
61         self.reactors = {'create': [], 'set': [], 'retire': []}
63     def create(self, **propvalues):
64         """These operations trigger detectors and can be vetoed.  Attempts
65         to modify the "creation" or "activity" properties cause a KeyError.
66         """
67         if propvalues.has_key('creation') or propvalues.has_key('activity'):
68             raise KeyError, '"creation" and "activity" are reserved'
69         for audit in self.auditors['create']:
70             audit(self.db, self, None, propvalues)
71         nodeid = hyperdb.Class.create(self, **propvalues)
72         for react in self.reactors['create']:
73             react(self.db, self, nodeid, None)
74         return nodeid
76     def set(self, nodeid, **propvalues):
77         """These operations trigger detectors and can be vetoed.  Attempts
78         to modify the "creation" or "activity" properties cause a KeyError.
79         """
80         if propvalues.has_key('creation') or propvalues.has_key('activity'):
81             raise KeyError, '"creation" and "activity" are reserved'
82         for audit in self.auditors['set']:
83             audit(self.db, self, nodeid, propvalues)
84         oldvalues = self.db.getnode(self.classname, nodeid)
85         hyperdb.Class.set(self, nodeid, **propvalues)
86         for react in self.reactors['set']:
87             react(self.db, self, nodeid, oldvalues)
89     def retire(self, nodeid):
90         """These operations trigger detectors and can be vetoed.  Attempts
91         to modify the "creation" or "activity" properties cause a KeyError.
92         """
93         for audit in self.auditors['retire']:
94             audit(self.db, self, nodeid, None)
95         hyperdb.Class.retire(self, nodeid)
96         for react in self.reactors['retire']:
97             react(self.db, self, nodeid, None)
99     def get(self, nodeid, propname, default=_marker):
100         """Attempts to get the "creation" or "activity" properties should
101         do the right thing.
102         """
103         if propname == 'creation':
104             journal = self.db.getjournal(self.classname, nodeid)
105             if journal:
106                 return self.db.getjournal(self.classname, nodeid)[0][1]
107             else:
108                 # on the strange chance that there's no journal
109                 return date.Date()
110         if propname == 'activity':
111             journal = self.db.getjournal(self.classname, nodeid)
112             if journal:
113                 return self.db.getjournal(self.classname, nodeid)[-1][1]
114             else:
115                 # on the strange chance that there's no journal
116                 return date.Date()
117         if propname == 'creator':
118             journal = self.db.getjournal(self.classname, nodeid)
119             if journal:
120                 name = self.db.getjournal(self.classname, nodeid)[0][2]
121             else:
122                 return None
123             return self.db.user.lookup(name)
124         if default is not _marker:
125             return hyperdb.Class.get(self, nodeid, propname, default)
126         else:
127             return hyperdb.Class.get(self, nodeid, propname)
129     def getprops(self, protected=1):
130         """In addition to the actual properties on the node, these
131         methods provide the "creation" and "activity" properties. If the
132         "protected" flag is true, we include protected properties - those
133         which may not be modified.
134         """
135         d = hyperdb.Class.getprops(self, protected=protected).copy()
136         if protected:
137             d['creation'] = hyperdb.Date()
138             d['activity'] = hyperdb.Date()
139             d['creator'] = hyperdb.Link("user")
140         return d
142     #
143     # Detector interface
144     #
145     def audit(self, event, detector):
146         """Register a detector
147         """
148         self.auditors[event].append(detector)
150     def react(self, event, detector):
151         """Register a detector
152         """
153         self.reactors[event].append(detector)
156 class FileClass(Class):
157     def create(self, **propvalues):
158         ''' snaffle the file propvalue and store in a file
159         '''
160         content = propvalues['content']
161         del propvalues['content']
162         newid = Class.create(self, **propvalues)
163         self.setcontent(self.classname, newid, content)
164         return newid
166     def filename(self, classname, nodeid):
167         # TODO: split into multiple files directories
168         return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
170     def setcontent(self, classname, nodeid, content):
171         ''' set the content file for this file
172         '''
173         open(self.filename(classname, nodeid), 'wb').write(content)
175     def getcontent(self, classname, nodeid):
176         ''' get the content file for this file
177         '''
178         return open(self.filename(classname, nodeid), 'rb').read()
180     def get(self, nodeid, propname, default=_marker):
181         ''' trap the content propname and get it from the file
182         '''
183         if propname == 'content':
184             return self.getcontent(self.classname, nodeid)
185         if default is not _marker:
186             return Class.get(self, nodeid, propname, default)
187         else:
188             return Class.get(self, nodeid, propname)
190     def getprops(self, protected=1):
191         ''' In addition to the actual properties on the node, these methods
192             provide the "content" property. If the "protected" flag is true,
193             we include protected properties - those which may not be
194             modified.
195         '''
196         d = Class.getprops(self, protected=protected).copy()
197         if protected:
198             d['content'] = hyperdb.String()
199         return d
201 # XXX deviation from spec - was called ItemClass
202 class IssueClass(Class):
203     # Overridden methods:
205     def __init__(self, db, classname, **properties):
206         """The newly-created class automatically includes the "messages",
207         "files", "nosy", and "superseder" properties.  If the 'properties'
208         dictionary attempts to specify any of these properties or a
209         "creation" or "activity" property, a ValueError is raised."""
210         if not properties.has_key('title'):
211             properties['title'] = hyperdb.String()
212         if not properties.has_key('messages'):
213             properties['messages'] = hyperdb.Multilink("msg")
214         if not properties.has_key('files'):
215             properties['files'] = hyperdb.Multilink("file")
216         if not properties.has_key('nosy'):
217             properties['nosy'] = hyperdb.Multilink("user")
218         if not properties.has_key('superseder'):
219             properties['superseder'] = hyperdb.Multilink(classname)
220         Class.__init__(self, db, classname, **properties)
222     # New methods:
224     def addmessage(self, nodeid, summary, text):
225         """Add a message to an issue's mail spool.
227         A new "msg" node is constructed using the current date, the user that
228         owns the database connection as the author, and the specified summary
229         text.
231         The "files" and "recipients" fields are left empty.
233         The given text is saved as the body of the message and the node is
234         appended to the "messages" field of the specified issue.
235         """
237     def sendmessage(self, nodeid, msgid):
238         """Send a message to the members of an issue's nosy list.
240         The message is sent only to users on the nosy list who are not
241         already on the "recipients" list for the message.
242         
243         These users are then added to the message's "recipients" list.
244         """
245         # figure the recipient ids
246         recipients = self.db.msg.get(msgid, 'recipients')
247         r = {}
248         for recipid in recipients:
249             r[recipid] = 1
250         authid = self.db.msg.get(msgid, 'author')
251         r[authid] = 1
253         # now figure the nosy people who weren't recipients
254         sendto = []
255         nosy = self.get(nodeid, 'nosy')
256         for nosyid in nosy:
257             if not r.has_key(nosyid):
258                 sendto.append(nosyid)
259                 recipients.append(nosyid)
261         if sendto:
262             # update the message's recipients list
263             self.db.msg.set(msgid, recipients=recipients)
265             # send an email to the people who missed out
266             sendto = [self.db.user.get(i, 'address') for i in recipients]
267             cn = self.classname
268             title = self.get(nodeid, 'title') or '%s message copy'%cn
269             # figure author information
270             authname = self.db.user.get(authid, 'realname')
271             if not authname:
272                 authname = self.db.user.get(authid, 'username')
273             authaddr = self.db.user.get(authid, 'address')
274             if authaddr:
275                 authaddr = '<%s> '%authaddr
276             else:
277                 authaddr = ''
278             # TODO attachments
279             m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
280             m.append('To: %s'%', '.join(sendto))
281             m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
282             m.append('')
283             # add author information
284             m.append("%s %sadded the comment:"%(authname, authaddr))
285             m.append('')
286             # add the content
287             m.append(self.db.msg.get(msgid, 'content'))
288             # "list information" footer
289             m.append(self.email_footer(nodeid, msgid))
290             try:
291                 smtp = smtplib.SMTP(self.MAILHOST)
292                 smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
293             except socket.error, value:
294                 return "Couldn't send confirmation email: mailhost %s"%value
295             except smtplib.SMTPException, value:
296                 return "Couldn't send confirmation email: %s"%value
298     def email_footer(self, nodeid, msgid):
299         ''' Add a footer to the e-mail with some useful information
300         '''
301         web = self.ISSUE_TRACKER_WEB
302         return '''%s
303 Roundup issue tracker
304 %s
305 %s
306 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
309 # $Log: not supported by cvs2svn $
310 # Revision 1.14  2001/10/21 07:26:35  richard
311 # feature #473127: Filenames. I modified the file.index and htmltemplate
312 #  source so that the filename is used in the link and the creation
313 #  information is displayed.
315 # Revision 1.13  2001/10/21 00:45:15  richard
316 # Added author identification to e-mail messages from roundup.
318 # Revision 1.12  2001/10/04 02:16:15  richard
319 # Forgot to pass the protected flag down *sigh*.
321 # Revision 1.11  2001/10/04 02:12:42  richard
322 # Added nicer command-line item adding: passing no arguments will enter an
323 # interactive more which asks for each property in turn. While I was at it, I
324 # fixed an implementation problem WRT the spec - I wasn't raising a
325 # ValueError if the key property was missing from a create(). Also added a
326 # protected=boolean argument to getprops() so we can list only the mutable
327 # properties (defaults to yes, which lists the immutables).
329 # Revision 1.10  2001/08/07 00:24:42  richard
330 # stupid typo
332 # Revision 1.9  2001/08/07 00:15:51  richard
333 # Added the copyright/license notice to (nearly) all files at request of
334 # Bizar Software.
336 # Revision 1.8  2001/08/02 06:38:17  richard
337 # Roundupdb now appends "mailing list" information to its messages which
338 # include the e-mail address and web interface address. Templates may
339 # override this in their db classes to include specific information (support
340 # instructions, etc).
342 # Revision 1.7  2001/07/30 02:38:31  richard
343 # get() now has a default arg - for migration only.
345 # Revision 1.6  2001/07/30 00:05:54  richard
346 # Fixed IssueClass so that superseders links to its classname rather than
347 # hard-coded to "issue".
349 # Revision 1.5  2001/07/29 07:01:39  richard
350 # Added vim command to all source so that we don't get no steenkin' tabs :)
352 # Revision 1.4  2001/07/29 04:05:37  richard
353 # Added the fabricated property "id".
355 # Revision 1.3  2001/07/23 07:14:41  richard
356 # Moved the database backends off into backends.
358 # Revision 1.2  2001/07/22 12:09:32  richard
359 # Final commit of Grande Splite
361 # Revision 1.1  2001/07/22 11:58:35  richard
362 # More Grande Splite
365 # vim: set filetype=python ts=4 sw=4 et si