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