Mercurial > p > roundup > code
comparison roundup/roundupdb.py @ 1356:83f33642d220 maint-0.5
[[Metadata associated with this commit was garbled during conversion from CVS
to Subversion.]]
| author | Richard Jones <richard@users.sourceforge.net> |
|---|---|
| date | Thu, 09 Jan 2003 22:59:22 +0000 |
| parents | |
| children | 0b30d6f0ec24 |
comparison
equal
deleted
inserted
replaced
| 1242:3d0158c8c32b | 1356:83f33642d220 |
|---|---|
| 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.75 2002-12-11 01:52:20 richard Exp $ | |
| 19 | |
| 20 __doc__ = """ | |
| 21 Extending hyperdb with types specific to issue-tracking. | |
| 22 """ | |
| 23 | |
| 24 import re, os, smtplib, socket, time, random | |
| 25 import MimeWriter, cStringIO | |
| 26 import base64, quopri, mimetypes | |
| 27 # if available, use the 'email' module, otherwise fallback to 'rfc822' | |
| 28 try : | |
| 29 from email.Utils import formataddr as straddr | |
| 30 except ImportError : | |
| 31 # code taken from the email package 2.4.3 | |
| 32 def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'), | |
| 33 escapesre = re.compile(r'[][\()"]')): | |
| 34 name, address = pair | |
| 35 if name: | |
| 36 quotes = '' | |
| 37 if specialsre.search(name): | |
| 38 quotes = '"' | |
| 39 name = escapesre.sub(r'\\\g<0>', name) | |
| 40 return '%s%s%s <%s>' % (quotes, name, quotes, address) | |
| 41 return address | |
| 42 | |
| 43 import hyperdb | |
| 44 | |
| 45 # set to indicate to roundup not to actually _send_ email | |
| 46 # this var must contain a file to write the mail to | |
| 47 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') | |
| 48 | |
| 49 class Database: | |
| 50 def getuid(self): | |
| 51 """Return the id of the "user" node associated with the user | |
| 52 that owns this connection to the hyperdatabase.""" | |
| 53 return self.user.lookup(self.journaltag) | |
| 54 | |
| 55 class MessageSendError(RuntimeError): | |
| 56 pass | |
| 57 | |
| 58 class DetectorError(RuntimeError): | |
| 59 ''' Raised by detectors that want to indicate that something's amiss | |
| 60 ''' | |
| 61 pass | |
| 62 | |
| 63 # deviation from spec - was called IssueClass | |
| 64 class IssueClass: | |
| 65 """ This class is intended to be mixed-in with a hyperdb backend | |
| 66 implementation. The backend should provide a mechanism that | |
| 67 enforces the title, messages, files, nosy and superseder | |
| 68 properties: | |
| 69 properties['title'] = hyperdb.String(indexme='yes') | |
| 70 properties['messages'] = hyperdb.Multilink("msg") | |
| 71 properties['files'] = hyperdb.Multilink("file") | |
| 72 properties['nosy'] = hyperdb.Multilink("user") | |
| 73 properties['superseder'] = hyperdb.Multilink(classname) | |
| 74 """ | |
| 75 | |
| 76 # New methods: | |
| 77 def addmessage(self, nodeid, summary, text): | |
| 78 """Add a message to an issue's mail spool. | |
| 79 | |
| 80 A new "msg" node is constructed using the current date, the user that | |
| 81 owns the database connection as the author, and the specified summary | |
| 82 text. | |
| 83 | |
| 84 The "files" and "recipients" fields are left empty. | |
| 85 | |
| 86 The given text is saved as the body of the message and the node is | |
| 87 appended to the "messages" field of the specified issue. | |
| 88 """ | |
| 89 | |
| 90 def nosymessage(self, nodeid, msgid, oldvalues): | |
| 91 """Send a message to the members of an issue's nosy list. | |
| 92 | |
| 93 The message is sent only to users on the nosy list who are not | |
| 94 already on the "recipients" list for the message. | |
| 95 | |
| 96 These users are then added to the message's "recipients" list. | |
| 97 """ | |
| 98 users = self.db.user | |
| 99 messages = self.db.msg | |
| 100 | |
| 101 # figure the recipient ids | |
| 102 sendto = [] | |
| 103 r = {} | |
| 104 recipients = messages.get(msgid, 'recipients') | |
| 105 for recipid in messages.get(msgid, 'recipients'): | |
| 106 r[recipid] = 1 | |
| 107 | |
| 108 # figure the author's id, and indicate they've received the message | |
| 109 authid = messages.get(msgid, 'author') | |
| 110 | |
| 111 # possibly send the message to the author, as long as they aren't | |
| 112 # anonymous | |
| 113 if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and | |
| 114 users.get(authid, 'username') != 'anonymous'): | |
| 115 sendto.append(authid) | |
| 116 r[authid] = 1 | |
| 117 | |
| 118 # now figure the nosy people who weren't recipients | |
| 119 nosy = self.get(nodeid, 'nosy') | |
| 120 for nosyid in nosy: | |
| 121 # Don't send nosy mail to the anonymous user (that user | |
| 122 # shouldn't appear in the nosy list, but just in case they | |
| 123 # do...) | |
| 124 if users.get(nosyid, 'username') == 'anonymous': | |
| 125 continue | |
| 126 # make sure they haven't seen the message already | |
| 127 if not r.has_key(nosyid): | |
| 128 # send it to them | |
| 129 sendto.append(nosyid) | |
| 130 recipients.append(nosyid) | |
| 131 | |
| 132 # generate a change note | |
| 133 if oldvalues: | |
| 134 note = self.generateChangeNote(nodeid, oldvalues) | |
| 135 else: | |
| 136 note = self.generateCreateNote(nodeid) | |
| 137 | |
| 138 # we have new recipients | |
| 139 if sendto: | |
| 140 # map userids to addresses | |
| 141 sendto = [users.get(i, 'address') for i in sendto] | |
| 142 | |
| 143 # update the message's recipients list | |
| 144 messages.set(msgid, recipients=recipients) | |
| 145 | |
| 146 # send the message | |
| 147 self.send_message(nodeid, msgid, note, sendto) | |
| 148 | |
| 149 # backwards compatibility - don't remove | |
| 150 sendmessage = nosymessage | |
| 151 | |
| 152 def send_message(self, nodeid, msgid, note, sendto): | |
| 153 '''Actually send the nominated message from this node to the sendto | |
| 154 recipients, with the note appended. | |
| 155 ''' | |
| 156 users = self.db.user | |
| 157 messages = self.db.msg | |
| 158 files = self.db.file | |
| 159 | |
| 160 # determine the messageid and inreplyto of the message | |
| 161 inreplyto = messages.get(msgid, 'inreplyto') | |
| 162 messageid = messages.get(msgid, 'messageid') | |
| 163 | |
| 164 # make up a messageid if there isn't one (web edit) | |
| 165 if not messageid: | |
| 166 # this is an old message that didn't get a messageid, so | |
| 167 # create one | |
| 168 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(), | |
| 169 self.classname, nodeid, self.db.config.MAIL_DOMAIN) | |
| 170 messages.set(msgid, messageid=messageid) | |
| 171 | |
| 172 # send an email to the people who missed out | |
| 173 cn = self.classname | |
| 174 title = self.get(nodeid, 'title') or '%s message copy'%cn | |
| 175 # figure author information | |
| 176 authid = messages.get(msgid, 'author') | |
| 177 authname = users.get(authid, 'realname') | |
| 178 if not authname: | |
| 179 authname = users.get(authid, 'username') | |
| 180 authaddr = users.get(authid, 'address') | |
| 181 if authaddr: | |
| 182 authaddr = " <%s>" % straddr( ('',authaddr) ) | |
| 183 else: | |
| 184 authaddr = '' | |
| 185 | |
| 186 # make the message body | |
| 187 m = [''] | |
| 188 | |
| 189 # put in roundup's signature | |
| 190 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top': | |
| 191 m.append(self.email_signature(nodeid, msgid)) | |
| 192 | |
| 193 # add author information | |
| 194 if len(self.get(nodeid,'messages')) == 1: | |
| 195 m.append("New submission from %s%s:"%(authname, authaddr)) | |
| 196 else: | |
| 197 m.append("%s%s added the comment:"%(authname, authaddr)) | |
| 198 m.append('') | |
| 199 | |
| 200 # add the content | |
| 201 m.append(messages.get(msgid, 'content')) | |
| 202 | |
| 203 # add the change note | |
| 204 if note: | |
| 205 m.append(note) | |
| 206 | |
| 207 # put in roundup's signature | |
| 208 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom': | |
| 209 m.append(self.email_signature(nodeid, msgid)) | |
| 210 | |
| 211 # encode the content as quoted-printable | |
| 212 content = cStringIO.StringIO('\n'.join(m)) | |
| 213 content_encoded = cStringIO.StringIO() | |
| 214 quopri.encode(content, content_encoded, 0) | |
| 215 content_encoded = content_encoded.getvalue() | |
| 216 | |
| 217 # get the files for this message | |
| 218 message_files = messages.get(msgid, 'files') | |
| 219 | |
| 220 # make sure the To line is always the same (for testing mostly) | |
| 221 sendto.sort() | |
| 222 | |
| 223 # create the message | |
| 224 message = cStringIO.StringIO() | |
| 225 writer = MimeWriter.MimeWriter(message) | |
| 226 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title)) | |
| 227 writer.addheader('To', ', '.join(sendto)) | |
| 228 writer.addheader('From', straddr( | |
| 229 (authname, self.db.config.TRACKER_EMAIL) ) ) | |
| 230 writer.addheader('Reply-To', straddr( | |
| 231 (self.db.config.TRACKER_NAME, | |
| 232 self.db.config.TRACKER_EMAIL) ) ) | |
| 233 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000", | |
| 234 time.gmtime())) | |
| 235 writer.addheader('MIME-Version', '1.0') | |
| 236 if messageid: | |
| 237 writer.addheader('Message-Id', messageid) | |
| 238 if inreplyto: | |
| 239 writer.addheader('In-Reply-To', inreplyto) | |
| 240 | |
| 241 # add a uniquely Roundup header to help filtering | |
| 242 writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME) | |
| 243 | |
| 244 # avoid email loops | |
| 245 writer.addheader('X-Roundup-Loop', 'hello') | |
| 246 | |
| 247 # attach files | |
| 248 if message_files: | |
| 249 part = writer.startmultipartbody('mixed') | |
| 250 part = writer.nextpart() | |
| 251 part.addheader('Content-Transfer-Encoding', 'quoted-printable') | |
| 252 body = part.startbody('text/plain') | |
| 253 body.write(content_encoded) | |
| 254 for fileid in message_files: | |
| 255 name = files.get(fileid, 'name') | |
| 256 mime_type = files.get(fileid, 'type') | |
| 257 content = files.get(fileid, 'content') | |
| 258 part = writer.nextpart() | |
| 259 if mime_type == 'text/plain': | |
| 260 part.addheader('Content-Disposition', | |
| 261 'attachment;\n filename="%s"'%name) | |
| 262 part.addheader('Content-Transfer-Encoding', '7bit') | |
| 263 body = part.startbody('text/plain') | |
| 264 body.write(content) | |
| 265 else: | |
| 266 # some other type, so encode it | |
| 267 if not mime_type: | |
| 268 # this should have been done when the file was saved | |
| 269 mime_type = mimetypes.guess_type(name)[0] | |
| 270 if mime_type is None: | |
| 271 mime_type = 'application/octet-stream' | |
| 272 part.addheader('Content-Disposition', | |
| 273 'attachment;\n filename="%s"'%name) | |
| 274 part.addheader('Content-Transfer-Encoding', 'base64') | |
| 275 body = part.startbody(mime_type) | |
| 276 body.write(base64.encodestring(content)) | |
| 277 writer.lastpart() | |
| 278 else: | |
| 279 writer.addheader('Content-Transfer-Encoding', 'quoted-printable') | |
| 280 body = writer.startbody('text/plain') | |
| 281 body.write(content_encoded) | |
| 282 | |
| 283 # now try to send the message | |
| 284 if SENDMAILDEBUG: | |
| 285 open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%( | |
| 286 self.db.config.ADMIN_EMAIL, | |
| 287 ', '.join(sendto),message.getvalue())) | |
| 288 else: | |
| 289 try: | |
| 290 # send the message as admin so bounces are sent there | |
| 291 # instead of to roundup | |
| 292 smtp = smtplib.SMTP(self.db.config.MAILHOST) | |
| 293 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto, | |
| 294 message.getvalue()) | |
| 295 except socket.error, value: | |
| 296 raise MessageSendError, \ | |
| 297 "Couldn't send confirmation email: mailhost %s"%value | |
| 298 except smtplib.SMTPException, value: | |
| 299 raise MessageSendError, \ | |
| 300 "Couldn't send confirmation email: %s"%value | |
| 301 | |
| 302 def email_signature(self, nodeid, msgid): | |
| 303 ''' Add a signature to the e-mail with some useful information | |
| 304 ''' | |
| 305 # simplistic check to see if the url is valid, | |
| 306 # then append a trailing slash if it is missing | |
| 307 base = self.db.config.TRACKER_WEB | |
| 308 if (not isinstance(base , type('')) or | |
| 309 not (base.startswith('http://') or base.startswith('https://'))): | |
| 310 base = "Configuration Error: TRACKER_WEB isn't a " \ | |
| 311 "fully-qualified URL" | |
| 312 elif base[-1] != '/' : | |
| 313 base += '/' | |
| 314 web = base + self.classname + nodeid | |
| 315 | |
| 316 # ensure the email address is properly quoted | |
| 317 email = straddr((self.db.config.TRACKER_NAME, | |
| 318 self.db.config.TRACKER_EMAIL)) | |
| 319 | |
| 320 line = '_' * max(len(web), len(email)) | |
| 321 return '%s\n%s\n%s\n%s'%(line, email, web, line) | |
| 322 | |
| 323 | |
| 324 def generateCreateNote(self, nodeid): | |
| 325 """Generate a create note that lists initial property values | |
| 326 """ | |
| 327 cn = self.classname | |
| 328 cl = self.db.classes[cn] | |
| 329 props = cl.getprops(protected=0) | |
| 330 | |
| 331 # list the values | |
| 332 m = [] | |
| 333 l = props.items() | |
| 334 l.sort() | |
| 335 for propname, prop in l: | |
| 336 value = cl.get(nodeid, propname, None) | |
| 337 # skip boring entries | |
| 338 if not value: | |
| 339 continue | |
| 340 if isinstance(prop, hyperdb.Link): | |
| 341 link = self.db.classes[prop.classname] | |
| 342 if value: | |
| 343 key = link.labelprop(default_to_id=1) | |
| 344 if key: | |
| 345 value = link.get(value, key) | |
| 346 else: | |
| 347 value = '' | |
| 348 elif isinstance(prop, hyperdb.Multilink): | |
| 349 if value is None: value = [] | |
| 350 l = [] | |
| 351 link = self.db.classes[prop.classname] | |
| 352 key = link.labelprop(default_to_id=1) | |
| 353 if key: | |
| 354 value = [link.get(entry, key) for entry in value] | |
| 355 value.sort() | |
| 356 value = ', '.join(value) | |
| 357 m.append('%s: %s'%(propname, value)) | |
| 358 m.insert(0, '----------') | |
| 359 m.insert(0, '') | |
| 360 return '\n'.join(m) | |
| 361 | |
| 362 def generateChangeNote(self, nodeid, oldvalues): | |
| 363 """Generate a change note that lists property changes | |
| 364 """ | |
| 365 if __debug__ : | |
| 366 if not isinstance(oldvalues, type({})) : | |
| 367 raise TypeError("'oldvalues' must be dict-like, not %s."% | |
| 368 type(oldvalues)) | |
| 369 | |
| 370 cn = self.classname | |
| 371 cl = self.db.classes[cn] | |
| 372 changed = {} | |
| 373 props = cl.getprops(protected=0) | |
| 374 | |
| 375 # determine what changed | |
| 376 for key in oldvalues.keys(): | |
| 377 if key in ['files','messages']: | |
| 378 continue | |
| 379 if key in ('activity', 'creator', 'creation'): | |
| 380 continue | |
| 381 new_value = cl.get(nodeid, key) | |
| 382 # the old value might be non existent | |
| 383 try: | |
| 384 old_value = oldvalues[key] | |
| 385 if type(new_value) is type([]): | |
| 386 new_value.sort() | |
| 387 old_value.sort() | |
| 388 if new_value != old_value: | |
| 389 changed[key] = old_value | |
| 390 except: | |
| 391 changed[key] = new_value | |
| 392 | |
| 393 # list the changes | |
| 394 m = [] | |
| 395 l = changed.items() | |
| 396 l.sort() | |
| 397 for propname, oldvalue in l: | |
| 398 prop = props[propname] | |
| 399 value = cl.get(nodeid, propname, None) | |
| 400 if isinstance(prop, hyperdb.Link): | |
| 401 link = self.db.classes[prop.classname] | |
| 402 key = link.labelprop(default_to_id=1) | |
| 403 if key: | |
| 404 if value: | |
| 405 value = link.get(value, key) | |
| 406 else: | |
| 407 value = '' | |
| 408 if oldvalue: | |
| 409 oldvalue = link.get(oldvalue, key) | |
| 410 else: | |
| 411 oldvalue = '' | |
| 412 change = '%s -> %s'%(oldvalue, value) | |
| 413 elif isinstance(prop, hyperdb.Multilink): | |
| 414 change = '' | |
| 415 if value is None: value = [] | |
| 416 if oldvalue is None: oldvalue = [] | |
| 417 l = [] | |
| 418 link = self.db.classes[prop.classname] | |
| 419 key = link.labelprop(default_to_id=1) | |
| 420 # check for additions | |
| 421 for entry in value: | |
| 422 if entry in oldvalue: continue | |
| 423 if key: | |
| 424 l.append(link.get(entry, key)) | |
| 425 else: | |
| 426 l.append(entry) | |
| 427 if l: | |
| 428 l.sort() | |
| 429 change = '+%s'%(', '.join(l)) | |
| 430 l = [] | |
| 431 # check for removals | |
| 432 for entry in oldvalue: | |
| 433 if entry in value: continue | |
| 434 if key: | |
| 435 l.append(link.get(entry, key)) | |
| 436 else: | |
| 437 l.append(entry) | |
| 438 if l: | |
| 439 l.sort() | |
| 440 change += ' -%s'%(', '.join(l)) | |
| 441 else: | |
| 442 change = '%s -> %s'%(oldvalue, value) | |
| 443 m.append('%s: %s'%(propname, change)) | |
| 444 if m: | |
| 445 m.insert(0, '----------') | |
| 446 m.insert(0, '') | |
| 447 return '\n'.join(m) | |
| 448 | |
| 449 # vim: set filetype=python ts=4 sw=4 et si |
