1 from __future__ import nested_scopes
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
19 # $Id: roundupdb.py,v 1.139 2008-08-07 06:31:16 richard Exp $
21 """Extending hyperdb with types specific to issue-tracking.
22 """
23 __docformat__ = 'restructuredtext'
25 import re, os, smtplib, socket, time, random
26 import cStringIO, base64, quopri, mimetypes
27 import os.path
29 from rfc2822 import encode_header
31 from roundup import password, date, hyperdb
32 from roundup.i18n import _
34 # MessageSendError is imported for backwards compatibility
35 from roundup.mailer import Mailer, straddr, MessageSendError
37 class Database:
39 # remember the journal uid for the current journaltag so that:
40 # a. we don't have to look it up every time we need it, and
41 # b. if the journaltag disappears during a transaction, we don't barf
42 # (eg. the current user edits their username)
43 journal_uid = None
44 def getuid(self):
45 """Return the id of the "user" node associated with the user
46 that owns this connection to the hyperdatabase."""
47 if self.journaltag is None:
48 return None
49 elif self.journaltag == 'admin':
50 # admin user may not exist, but always has ID 1
51 return '1'
52 else:
53 if (self.journal_uid is None or self.journal_uid[0] !=
54 self.journaltag):
55 uid = self.user.lookup(self.journaltag)
56 self.journal_uid = (self.journaltag, uid)
57 return self.journal_uid[1]
59 def setCurrentUser(self, username):
60 """Set the user that is responsible for current database
61 activities.
62 """
63 self.journaltag = username
65 def isCurrentUser(self, username):
66 """Check if a given username equals the already active user.
67 """
68 return self.journaltag == username
70 def getUserTimezone(self):
71 """Return user timezone defined in 'timezone' property of user class.
72 If no such property exists return 0
73 """
74 userid = self.getuid()
75 timezone = None
76 try:
77 tz = self.user.get(userid, 'timezone')
78 date.get_timezone(tz)
79 timezone = tz
80 except KeyError:
81 pass
82 # If there is no class 'user' or current user doesn't have timezone
83 # property or that property is not set assume he/she lives in
84 # the timezone set in the tracker config.
85 if timezone is None:
86 timezone = self.config['TIMEZONE']
87 return timezone
89 def confirm_registration(self, otk):
90 props = self.getOTKManager().getall(otk)
91 for propname, proptype in self.user.getprops().items():
92 value = props.get(propname, None)
93 if value is None:
94 pass
95 elif isinstance(proptype, hyperdb.Date):
96 props[propname] = date.Date(value)
97 elif isinstance(proptype, hyperdb.Interval):
98 props[propname] = date.Interval(value)
99 elif isinstance(proptype, hyperdb.Password):
100 props[propname] = password.Password()
101 props[propname].unpack(value)
103 # tag new user creation with 'admin'
104 self.journaltag = 'admin'
106 # create the new user
107 cl = self.user
109 props['roles'] = self.config.NEW_WEB_USER_ROLES
110 userid = cl.create(**props)
111 # clear the props from the otk database
112 self.getOTKManager().destroy(otk)
113 self.commit()
115 return userid
118 class DetectorError(RuntimeError):
119 """ Raised by detectors that want to indicate that something's amiss
120 """
121 pass
123 # deviation from spec - was called IssueClass
124 class IssueClass:
125 """This class is intended to be mixed-in with a hyperdb backend
126 implementation. The backend should provide a mechanism that
127 enforces the title, messages, files, nosy and superseder
128 properties:
130 - title = hyperdb.String(indexme='yes')
131 - messages = hyperdb.Multilink("msg")
132 - files = hyperdb.Multilink("file")
133 - nosy = hyperdb.Multilink("user")
134 - superseder = hyperdb.Multilink(classname)
135 """
137 # The tuple below does not affect the class definition.
138 # It just lists all names of all issue properties
139 # marked for message extraction tool.
140 #
141 # XXX is there better way to get property names into message catalog??
142 #
143 # Note that this list also includes properties
144 # defined in the classic template:
145 # assignedto, keyword, priority, status.
146 (
147 ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
148 ''"assignedto", ''"keyword", ''"priority", ''"status",
149 # following properties are common for all hyperdb classes
150 # they are listed here to keep things in one place
151 ''"actor", ''"activity", ''"creator", ''"creation",
152 )
154 # New methods:
155 def addmessage(self, nodeid, summary, text):
156 """Add a message to an issue's mail spool.
158 A new "msg" node is constructed using the current date, the user that
159 owns the database connection as the author, and the specified summary
160 text.
162 The "files" and "recipients" fields are left empty.
164 The given text is saved as the body of the message and the node is
165 appended to the "messages" field of the specified issue.
166 """
168 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
169 from_address=None, cc=[], bcc=[]):
170 """Send a message to the members of an issue's nosy list.
172 The message is sent only to users on the nosy list who are not
173 already on the "recipients" list for the message.
175 These users are then added to the message's "recipients" list.
177 If 'msgid' is None, the message gets sent only to the nosy
178 list, and it's called a 'System Message'.
180 The "cc" argument indicates additional recipients to send the
181 message to that may not be specified in the message's recipients
182 list.
184 The "bcc" argument also indicates additional recipients to send the
185 message to that may not be specified in the message's recipients
186 list. These recipients will not be included in the To: or Cc:
187 address lists.
188 """
189 if msgid:
190 authid = self.db.msg.get(msgid, 'author')
191 recipients = self.db.msg.get(msgid, 'recipients', [])
192 else:
193 # "system message"
194 authid = None
195 recipients = []
197 sendto = []
198 bcc_sendto = []
199 seen_message = {}
200 for recipient in recipients:
201 seen_message[recipient] = 1
203 def add_recipient(userid, to):
204 # make sure they have an address
205 address = self.db.user.get(userid, 'address')
206 if address:
207 to.append(address)
208 recipients.append(userid)
210 def good_recipient(userid):
211 # Make sure we don't send mail to either the anonymous
212 # user or a user who has already seen the message.
213 return (userid and
214 (self.db.user.get(userid, 'username') != 'anonymous') and
215 not seen_message.has_key(userid))
217 # possibly send the message to the author, as long as they aren't
218 # anonymous
219 if (good_recipient(authid) and
220 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
221 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
222 add_recipient(authid, sendto)
224 if authid:
225 seen_message[authid] = 1
227 # now deal with the nosy and cc people who weren't recipients.
228 for userid in cc + self.get(nodeid, whichnosy):
229 if good_recipient(userid):
230 add_recipient(userid, sendto)
232 # now deal with bcc people.
233 for userid in bcc:
234 if good_recipient(userid):
235 add_recipient(userid, bcc_sendto)
237 if oldvalues:
238 note = self.generateChangeNote(nodeid, oldvalues)
239 else:
240 note = self.generateCreateNote(nodeid)
242 # If we have new recipients, update the message's recipients
243 # and send the mail.
244 if sendto or bcc_sendto:
245 if msgid is not None:
246 self.db.msg.set(msgid, recipients=recipients)
247 self.send_message(nodeid, msgid, note, sendto, from_address,
248 bcc_sendto)
250 # backwards compatibility - don't remove
251 sendmessage = nosymessage
253 def send_message(self, nodeid, msgid, note, sendto, from_address=None,
254 bcc_sendto=[]):
255 '''Actually send the nominated message from this node to the sendto
256 recipients, with the note appended.
257 '''
258 users = self.db.user
259 messages = self.db.msg
260 files = self.db.file
262 if msgid is None:
263 inreplyto = None
264 messageid = None
265 else:
266 inreplyto = messages.get(msgid, 'inreplyto')
267 messageid = messages.get(msgid, 'messageid')
269 # make up a messageid if there isn't one (web edit)
270 if not messageid:
271 # this is an old message that didn't get a messageid, so
272 # create one
273 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
274 self.classname, nodeid,
275 self.db.config.MAIL_DOMAIN)
276 if msgid is not None:
277 messages.set(msgid, messageid=messageid)
279 # compose title
280 cn = self.classname
281 title = self.get(nodeid, 'title') or '%s message copy'%cn
283 # figure author information
284 if msgid:
285 authid = messages.get(msgid, 'author')
286 else:
287 authid = self.db.getuid()
288 authname = users.get(authid, 'realname')
289 if not authname:
290 authname = users.get(authid, 'username', '')
291 authaddr = users.get(authid, 'address', '')
293 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
294 authaddr = " <%s>" % straddr( ('',authaddr) )
295 elif authaddr:
296 authaddr = ""
298 # make the message body
299 m = ['']
301 # put in roundup's signature
302 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
303 m.append(self.email_signature(nodeid, msgid))
305 # add author information
306 if authid and self.db.config.MAIL_ADD_AUTHORINFO:
307 if msgid and len(self.get(nodeid, 'messages')) == 1:
308 m.append(_("New submission from %(authname)s%(authaddr)s:")
309 % locals())
310 elif msgid:
311 m.append(_("%(authname)s%(authaddr)s added the comment:")
312 % locals())
313 else:
314 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
315 m.append('')
317 # add the content
318 if msgid is not None:
319 m.append(messages.get(msgid, 'content', ''))
321 # get the files for this message
322 message_files = []
323 if msgid :
324 for fileid in messages.get(msgid, 'files') :
325 # check the attachment size
326 filename = self.db.filename('file', fileid, None)
327 filesize = os.path.getsize(filename)
328 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
329 message_files.append(fileid)
330 else:
331 base = self.db.config.TRACKER_WEB
332 link = "".join((base, files.classname, fileid))
333 filename = files.get(fileid, 'name')
334 m.append(_("File '%(filename)s' not attached - "
335 "you can download it from %(link)s.") % locals())
337 # add the change note
338 if note:
339 m.append(note)
341 # put in roundup's signature
342 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
343 m.append(self.email_signature(nodeid, msgid))
345 # encode the content as quoted-printable
346 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
347 m = '\n'.join(m)
348 if charset != 'utf-8':
349 m = unicode(m, 'utf-8').encode(charset)
350 content = cStringIO.StringIO(m)
351 content_encoded = cStringIO.StringIO()
352 quopri.encode(content, content_encoded, 0)
353 content_encoded = content_encoded.getvalue()
355 # make sure the To line is always the same (for testing mostly)
356 sendto.sort()
358 # make sure we have a from address
359 if from_address is None:
360 from_address = self.db.config.TRACKER_EMAIL
362 # additional bit for after the From: "name"
363 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
364 if from_tag:
365 from_tag = ' ' + from_tag
367 subject = '[%s%s] %s'%(cn, nodeid, title)
368 author = (authname + from_tag, from_address)
370 # send an individual message per recipient?
371 if self.db.config.NOSY_EMAIL_SENDING != 'single':
372 sendto = [[address] for address in sendto]
373 else:
374 sendto = [sendto]
376 # now send one or more messages
377 # TODO: I believe we have to create a new message each time as we
378 # can't fiddle the recipients in the message ... worth testing
379 # and/or fixing some day
380 first = True
381 for sendto in sendto:
382 # create the message
383 mailer = Mailer(self.db.config)
384 message, writer = mailer.get_standard_message(sendto, subject,
385 author)
387 # set reply-to to the tracker
388 tracker_name = self.db.config.TRACKER_NAME
389 if charset != 'utf-8':
390 tracker = unicode(tracker_name, 'utf-8').encode(charset)
391 tracker_name = encode_header(tracker_name, charset)
392 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
394 # message ids
395 if messageid:
396 writer.addheader('Message-Id', messageid)
397 if inreplyto:
398 writer.addheader('In-Reply-To', inreplyto)
400 # Generate a header for each link or multilink to
401 # a class that has a name attribute
402 for propname, prop in self.getprops().items():
403 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
404 continue
405 cl = self.db.getclass(prop.classname)
406 if not 'name' in cl.getprops():
407 continue
408 if isinstance(prop, hyperdb.Link):
409 value = self.get(nodeid, propname)
410 if value is None:
411 continue
412 values = [value]
413 else:
414 values = self.get(nodeid, propname)
415 if not values:
416 continue
417 values = [cl.get(v, 'name') for v in values]
418 values = ', '.join(values)
419 writer.addheader("X-Roundup-%s-%s" % (self.classname, propname),
420 values)
421 if not inreplyto:
422 # Default the reply to the first message
423 msgs = self.get(nodeid, 'messages')
424 # Assume messages are sorted by increasing message number here
425 # If the issue is just being created, and the submitter didn't
426 # provide a message, then msgs will be empty.
427 if msgs and msgs[0] != nodeid:
428 inreplyto = messages.get(msgs[0], 'messageid')
429 if inreplyto:
430 writer.addheader('In-Reply-To', inreplyto)
432 # attach files
433 if message_files:
434 part = writer.startmultipartbody('mixed')
435 part = writer.nextpart()
436 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
437 body = part.startbody('text/plain; charset=%s'%charset)
438 body.write(content_encoded)
439 for fileid in message_files:
440 name = files.get(fileid, 'name')
441 mime_type = files.get(fileid, 'type')
442 content = files.get(fileid, 'content')
443 part = writer.nextpart()
444 if mime_type == 'text/plain':
445 part.addheader('Content-Disposition',
446 'attachment;\n filename="%s"'%name)
447 try:
448 content.decode('ascii')
449 except UnicodeError:
450 # the content cannot be 7bit-encoded.
451 # use quoted printable
452 part.addheader('Content-Transfer-Encoding',
453 'quoted-printable')
454 body = part.startbody('text/plain')
455 body.write(quopri.encodestring(content))
456 else:
457 part.addheader('Content-Transfer-Encoding', '7bit')
458 body = part.startbody('text/plain')
459 body.write(content)
460 else:
461 # some other type, so encode it
462 if not mime_type:
463 # this should have been done when the file was saved
464 mime_type = mimetypes.guess_type(name)[0]
465 if mime_type is None:
466 mime_type = 'application/octet-stream'
467 part.addheader('Content-Disposition',
468 'attachment;\n filename="%s"'%name)
469 part.addheader('Content-Transfer-Encoding', 'base64')
470 body = part.startbody(mime_type)
471 body.write(base64.encodestring(content))
472 writer.lastpart()
473 else:
474 writer.addheader('Content-Transfer-Encoding',
475 'quoted-printable')
476 body = writer.startbody('text/plain; charset=%s'%charset)
477 body.write(content_encoded)
479 if first:
480 mailer.smtp_send(sendto + bcc_sendto, message)
481 else:
482 mailer.smtp_send(sendto, message)
483 first = False
485 def email_signature(self, nodeid, msgid):
486 ''' Add a signature to the e-mail with some useful information
487 '''
488 # simplistic check to see if the url is valid,
489 # then append a trailing slash if it is missing
490 base = self.db.config.TRACKER_WEB
491 if (not isinstance(base , type('')) or
492 not (base.startswith('http://') or base.startswith('https://'))):
493 web = "Configuration Error: TRACKER_WEB isn't a " \
494 "fully-qualified URL"
495 else:
496 if not base.endswith('/'):
497 base = base + '/'
498 web = base + self.classname + nodeid
500 # ensure the email address is properly quoted
501 email = straddr((self.db.config.TRACKER_NAME,
502 self.db.config.TRACKER_EMAIL))
504 line = '_' * max(len(web)+2, len(email))
505 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
508 def generateCreateNote(self, nodeid):
509 """Generate a create note that lists initial property values
510 """
511 cn = self.classname
512 cl = self.db.classes[cn]
513 props = cl.getprops(protected=0)
515 # list the values
516 m = []
517 prop_items = props.items()
518 prop_items.sort()
519 for propname, prop in prop_items:
520 value = cl.get(nodeid, propname, None)
521 # skip boring entries
522 if not value:
523 continue
524 if isinstance(prop, hyperdb.Link):
525 link = self.db.classes[prop.classname]
526 if value:
527 key = link.labelprop(default_to_id=1)
528 if key:
529 value = link.get(value, key)
530 else:
531 value = ''
532 elif isinstance(prop, hyperdb.Multilink):
533 if value is None: value = []
534 l = []
535 link = self.db.classes[prop.classname]
536 key = link.labelprop(default_to_id=1)
537 if key:
538 value = [link.get(entry, key) for entry in value]
539 value.sort()
540 value = ', '.join(value)
541 else:
542 value = str(value)
543 if '\n' in value:
544 value = '\n'+self.indentChangeNoteValue(value)
545 m.append('%s: %s'%(propname, value))
546 m.insert(0, '----------')
547 m.insert(0, '')
548 return '\n'.join(m)
550 def generateChangeNote(self, nodeid, oldvalues):
551 """Generate a change note that lists property changes
552 """
553 if not isinstance(oldvalues, type({})):
554 raise TypeError("'oldvalues' must be dict-like, not %s."%
555 type(oldvalues))
557 cn = self.classname
558 cl = self.db.classes[cn]
559 changed = {}
560 props = cl.getprops(protected=0)
562 # determine what changed
563 for key in oldvalues.keys():
564 if key in ['files','messages']:
565 continue
566 if key in ('actor', 'activity', 'creator', 'creation'):
567 continue
568 # not all keys from oldvalues might be available in database
569 # this happens when property was deleted
570 try:
571 new_value = cl.get(nodeid, key)
572 except KeyError:
573 continue
574 # the old value might be non existent
575 # this happens when property was added
576 try:
577 old_value = oldvalues[key]
578 if type(new_value) is type([]):
579 new_value.sort()
580 old_value.sort()
581 if new_value != old_value:
582 changed[key] = old_value
583 except:
584 changed[key] = new_value
586 # list the changes
587 m = []
588 changed_items = changed.items()
589 changed_items.sort()
590 for propname, oldvalue in changed_items:
591 prop = props[propname]
592 value = cl.get(nodeid, propname, None)
593 if isinstance(prop, hyperdb.Link):
594 link = self.db.classes[prop.classname]
595 key = link.labelprop(default_to_id=1)
596 if key:
597 if value:
598 value = link.get(value, key)
599 else:
600 value = ''
601 if oldvalue:
602 oldvalue = link.get(oldvalue, key)
603 else:
604 oldvalue = ''
605 change = '%s -> %s'%(oldvalue, value)
606 elif isinstance(prop, hyperdb.Multilink):
607 change = ''
608 if value is None: value = []
609 if oldvalue is None: oldvalue = []
610 l = []
611 link = self.db.classes[prop.classname]
612 key = link.labelprop(default_to_id=1)
613 # check for additions
614 for entry in value:
615 if entry in oldvalue: continue
616 if key:
617 l.append(link.get(entry, key))
618 else:
619 l.append(entry)
620 if l:
621 l.sort()
622 change = '+%s'%(', '.join(l))
623 l = []
624 # check for removals
625 for entry in oldvalue:
626 if entry in value: continue
627 if key:
628 l.append(link.get(entry, key))
629 else:
630 l.append(entry)
631 if l:
632 l.sort()
633 change += ' -%s'%(', '.join(l))
634 else:
635 change = '%s -> %s'%(oldvalue, value)
636 if '\n' in change:
637 value = self.indentChangeNoteValue(str(value))
638 oldvalue = self.indentChangeNoteValue(str(oldvalue))
639 change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
640 "new": value, "old": oldvalue}
641 m.append('%s: %s'%(propname, change))
642 if m:
643 m.insert(0, '----------')
644 m.insert(0, '')
645 return '\n'.join(m)
647 def indentChangeNoteValue(self, text):
648 lines = text.rstrip('\n').split('\n')
649 lines = [ ' '+line for line in lines ]
650 return '\n'.join(lines)
652 # vim: set filetype=python sts=4 sw=4 et si :