Code

1840147d725efa3c023ea3ba563196035b34c173
[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.11 2001-10-04 02:12:42 richard Exp $
20 import re, os, smtplib, socket
22 import hyperdb, date
24 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
25     ''' Take a foo123 and return ('foo', 123)
26     '''
27     m = dre.match(designator)
28     return m.group(1), m.group(2)
30 class Database:
31     def getuid(self):
32         """Return the id of the "user" node associated with the user
33         that owns this connection to the hyperdatabase."""
34         return self.user.lookup(self.journaltag)
36     def uidFromAddress(self, address, create=1):
37         ''' address is from the rfc822 module, and therefore is (name, addr)
39             user is created if they don't exist in the db already
40         '''
41         (realname, address) = address
42         users = self.user.stringFind(address=address)
43         if users: return users[0]
44         return self.user.create(username=address, address=address,
45             realname=realname)
47 _marker = []
48 # XXX: added the 'creator' faked attribute
49 class Class(hyperdb.Class):
50     # Overridden methods:
51     def __init__(self, db, classname, **properties):
52         hyperdb.Class.__init__(self, db, classname, **properties)
53         self.auditors = {'create': [], 'set': [], 'retire': []}
54         self.reactors = {'create': [], 'set': [], 'retire': []}
56     def create(self, **propvalues):
57         """These operations trigger detectors and can be vetoed.  Attempts
58         to modify the "creation" or "activity" properties cause a KeyError.
59         """
60         if propvalues.has_key('creation') or propvalues.has_key('activity'):
61             raise KeyError, '"creation" and "activity" are reserved'
62         for audit in self.auditors['create']:
63             audit(self.db, self, None, propvalues)
64         nodeid = hyperdb.Class.create(self, **propvalues)
65         for react in self.reactors['create']:
66             react(self.db, self, nodeid, None)
67         return nodeid
69     def set(self, nodeid, **propvalues):
70         """These operations trigger detectors and can be vetoed.  Attempts
71         to modify the "creation" or "activity" properties cause a KeyError.
72         """
73         if propvalues.has_key('creation') or propvalues.has_key('activity'):
74             raise KeyError, '"creation" and "activity" are reserved'
75         for audit in self.auditors['set']:
76             audit(self.db, self, nodeid, propvalues)
77         oldvalues = self.db.getnode(self.classname, nodeid)
78         hyperdb.Class.set(self, nodeid, **propvalues)
79         for react in self.reactors['set']:
80             react(self.db, self, nodeid, oldvalues)
82     def retire(self, nodeid):
83         """These operations trigger detectors and can be vetoed.  Attempts
84         to modify the "creation" or "activity" properties cause a KeyError.
85         """
86         for audit in self.auditors['retire']:
87             audit(self.db, self, nodeid, None)
88         hyperdb.Class.retire(self, nodeid)
89         for react in self.reactors['retire']:
90             react(self.db, self, nodeid, None)
92     def get(self, nodeid, propname, default=_marker):
93         """Attempts to get the "creation" or "activity" properties should
94         do the right thing.
95         """
96         if propname == 'creation':
97             journal = self.db.getjournal(self.classname, nodeid)
98             if journal:
99                 return self.db.getjournal(self.classname, nodeid)[0][1]
100             else:
101                 # on the strange chance that there's no journal
102                 return date.Date()
103         if propname == 'activity':
104             journal = self.db.getjournal(self.classname, nodeid)
105             if journal:
106                 return self.db.getjournal(self.classname, nodeid)[-1][1]
107             else:
108                 # on the strange chance that there's no journal
109                 return date.Date()
110         if propname == 'creator':
111             journal = self.db.getjournal(self.classname, nodeid)
112             if journal:
113                 name = self.db.getjournal(self.classname, nodeid)[0][2]
114             else:
115                 return None
116             return self.db.user.lookup(name)
117         if default is not _marker:
118             return hyperdb.Class.get(self, nodeid, propname, default)
119         else:
120             return hyperdb.Class.get(self, nodeid, propname)
122     def getprops(self, protected=1):
123         """In addition to the actual properties on the node, these
124         methods provide the "creation" and "activity" properties. If the
125         "protected" flag is true, we include protected properties - those
126         which may not be modified.
127         """
128         d = hyperdb.Class.getprops(self).copy()
129         if protected:
130             d['creation'] = hyperdb.Date()
131             d['activity'] = hyperdb.Date()
132             d['creator'] = hyperdb.Link("user")
133         return d
135     #
136     # Detector interface
137     #
138     def audit(self, event, detector):
139         """Register a detector
140         """
141         self.auditors[event].append(detector)
143     def react(self, event, detector):
144         """Register a detector
145         """
146         self.reactors[event].append(detector)
149 class FileClass(Class):
150     def create(self, **propvalues):
151         ''' snaffle the file propvalue and store in a file
152         '''
153         content = propvalues['content']
154         del propvalues['content']
155         newid = Class.create(self, **propvalues)
156         self.setcontent(self.classname, newid, content)
157         return newid
159     def filename(self, classname, nodeid):
160         # TODO: split into multiple files directories
161         return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
163     def setcontent(self, classname, nodeid, content):
164         ''' set the content file for this file
165         '''
166         open(self.filename(classname, nodeid), 'wb').write(content)
168     def getcontent(self, classname, nodeid):
169         ''' get the content file for this file
170         '''
171         return open(self.filename(classname, nodeid), 'rb').read()
173     def get(self, nodeid, propname, default=_marker):
174         ''' trap the content propname and get it from the file
175         '''
176         if propname == 'content':
177             return self.getcontent(self.classname, nodeid)
178         if default is not _marker:
179             return Class.get(self, nodeid, propname, default)
180         else:
181             return Class.get(self, nodeid, propname)
183     def getprops(self, protected=1):
184         ''' In addition to the actual properties on the node, these methods
185             provide the "content" property. If the "protected" flag is true,
186             we include protected properties - those which may not be
187             modified.
188         '''
189         d = Class.getprops(self).copy()
190         if protected:
191             d['content'] = hyperdb.String()
192         return d
194 # XXX deviation from spec - was called ItemClass
195 class IssueClass(Class):
196     # Overridden methods:
198     def __init__(self, db, classname, **properties):
199         """The newly-created class automatically includes the "messages",
200         "files", "nosy", and "superseder" properties.  If the 'properties'
201         dictionary attempts to specify any of these properties or a
202         "creation" or "activity" property, a ValueError is raised."""
203         if not properties.has_key('title'):
204             properties['title'] = hyperdb.String()
205         if not properties.has_key('messages'):
206             properties['messages'] = hyperdb.Multilink("msg")
207         if not properties.has_key('files'):
208             properties['files'] = hyperdb.Multilink("file")
209         if not properties.has_key('nosy'):
210             properties['nosy'] = hyperdb.Multilink("user")
211         if not properties.has_key('superseder'):
212             properties['superseder'] = hyperdb.Multilink(classname)
213         if (properties.has_key('creation') or properties.has_key('activity')
214                 or properties.has_key('creator')):
215             raise ValueError, '"creation", "activity" and "creator" are reserved'
216         Class.__init__(self, db, classname, **properties)
218     # New methods:
220     def addmessage(self, nodeid, summary, text):
221         """Add a message to an issue's mail spool.
223         A new "msg" node is constructed using the current date, the user that
224         owns the database connection as the author, and the specified summary
225         text.
227         The "files" and "recipients" fields are left empty.
229         The given text is saved as the body of the message and the node is
230         appended to the "messages" field of the specified issue.
231         """
233     def sendmessage(self, nodeid, msgid):
234         """Send a message to the members of an issue's nosy list.
236         The message is sent only to users on the nosy list who are not
237         already on the "recipients" list for the message.
238         
239         These users are then added to the message's "recipients" list.
240         """
241         # figure the recipient ids
242         recipients = self.db.msg.get(msgid, 'recipients')
243         r = {}
244         for recipid in recipients:
245             r[recipid] = 1
246         authid = self.db.msg.get(msgid, 'author')
247         r[authid] = 1
249         # now figure the nosy people who weren't recipients
250         sendto = []
251         nosy = self.get(nodeid, 'nosy')
252         for nosyid in nosy:
253             if not r.has_key(nosyid):
254                 sendto.append(nosyid)
255                 recipients.append(nosyid)
257         if sendto:
258             # update the message's recipients list
259             self.db.msg.set(msgid, recipients=recipients)
261             # send an email to the people who missed out
262             sendto = [self.db.user.get(i, 'address') for i in recipients]
263             cn = self.classname
264             title = self.get(nodeid, 'title') or '%s message copy'%cn
265             m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
266             m.append('To: %s'%', '.join(sendto))
267             m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
268             m.append('')
269             m.append(self.db.msg.get(msgid, 'content'))
270             m.append(self.email_footer(nodeid, msgid))
271             # TODO attachments
272             try:
273                 smtp = smtplib.SMTP(self.MAILHOST)
274                 smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
275             except socket.error, value:
276                 return "Couldn't send confirmation email: mailhost %s"%value
277             except smtplib.SMTPException, value:
278                 return "Couldn't send confirmation email: %s"%value
280     def email_footer(self, nodeid, msgid):
281         ''' Add a footer to the e-mail with some useful information
282         '''
283         web = self.ISSUE_TRACKER_WEB
284         return '''%s
285 Roundup issue tracker
286 %s
287 %s
288 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
291 # $Log: not supported by cvs2svn $
292 # Revision 1.10  2001/08/07 00:24:42  richard
293 # stupid typo
295 # Revision 1.9  2001/08/07 00:15:51  richard
296 # Added the copyright/license notice to (nearly) all files at request of
297 # Bizar Software.
299 # Revision 1.8  2001/08/02 06:38:17  richard
300 # Roundupdb now appends "mailing list" information to its messages which
301 # include the e-mail address and web interface address. Templates may
302 # override this in their db classes to include specific information (support
303 # instructions, etc).
305 # Revision 1.7  2001/07/30 02:38:31  richard
306 # get() now has a default arg - for migration only.
308 # Revision 1.6  2001/07/30 00:05:54  richard
309 # Fixed IssueClass so that superseders links to its classname rather than
310 # hard-coded to "issue".
312 # Revision 1.5  2001/07/29 07:01:39  richard
313 # Added vim command to all source so that we don't get no steenkin' tabs :)
315 # Revision 1.4  2001/07/29 04:05:37  richard
316 # Added the fabricated property "id".
318 # Revision 1.3  2001/07/23 07:14:41  richard
319 # Moved the database backends off into backends.
321 # Revision 1.2  2001/07/22 12:09:32  richard
322 # Final commit of Grande Splite
324 # Revision 1.1  2001/07/22 11:58:35  richard
325 # More Grande Splite
328 # vim: set filetype=python ts=4 sw=4 et si