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