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.
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)
352 #
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.
359 #
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.
366 #
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.
371 #
372 # Revision 1.13 2001/10/21 00:45:15 richard
373 # Added author identification to e-mail messages from roundup.
374 #
375 # Revision 1.12 2001/10/04 02:16:15 richard
376 # Forgot to pass the protected flag down *sigh*.
377 #
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).
385 #
386 # Revision 1.10 2001/08/07 00:24:42 richard
387 # stupid typo
388 #
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.
392 #
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).
398 #
399 # Revision 1.7 2001/07/30 02:38:31 richard
400 # get() now has a default arg - for migration only.
401 #
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".
405 #
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 :)
408 #
409 # Revision 1.4 2001/07/29 04:05:37 richard
410 # Added the fabricated property "id".
411 #
412 # Revision 1.3 2001/07/23 07:14:41 richard
413 # Moved the database backends off into backends.
414 #
415 # Revision 1.2 2001/07/22 12:09:32 richard
416 # Final commit of Grande Splite
417 #
418 # Revision 1.1 2001/07/22 11:58:35 richard
419 # More Grande Splite
420 #
421 #
422 # vim: set filetype=python ts=4 sw=4 et si