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