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
28 import logging
30 from rfc2822 import encode_header
32 from roundup import password, date, hyperdb
33 from roundup.i18n import _
35 # MessageSendError is imported for backwards compatibility
36 from roundup.mailer import Mailer, straddr, MessageSendError
38 class Database:
40 # remember the journal uid for the current journaltag so that:
41 # a. we don't have to look it up every time we need it, and
42 # b. if the journaltag disappears during a transaction, we don't barf
43 # (eg. the current user edits their username)
44 journal_uid = None
45 def getuid(self):
46 """Return the id of the "user" node associated with the user
47 that owns this connection to the hyperdatabase."""
48 if self.journaltag is None:
49 return None
50 elif self.journaltag == 'admin':
51 # admin user may not exist, but always has ID 1
52 return '1'
53 else:
54 if (self.journal_uid is None or self.journal_uid[0] !=
55 self.journaltag):
56 uid = self.user.lookup(self.journaltag)
57 self.journal_uid = (self.journaltag, uid)
58 return self.journal_uid[1]
60 def setCurrentUser(self, username):
61 """Set the user that is responsible for current database
62 activities.
63 """
64 self.journaltag = username
66 def isCurrentUser(self, username):
67 """Check if a given username equals the already active user.
68 """
69 return self.journaltag == username
71 def getUserTimezone(self):
72 """Return user timezone defined in 'timezone' property of user class.
73 If no such property exists return 0
74 """
75 userid = self.getuid()
76 timezone = None
77 try:
78 tz = self.user.get(userid, 'timezone')
79 date.get_timezone(tz)
80 timezone = tz
81 except KeyError:
82 pass
83 # If there is no class 'user' or current user doesn't have timezone
84 # property or that property is not set assume he/she lives in
85 # the timezone set in the tracker config.
86 if timezone is None:
87 timezone = self.config['TIMEZONE']
88 return timezone
90 def confirm_registration(self, otk):
91 props = self.getOTKManager().getall(otk)
92 for propname, proptype in self.user.getprops().items():
93 value = props.get(propname, None)
94 if value is None:
95 pass
96 elif isinstance(proptype, hyperdb.Date):
97 props[propname] = date.Date(value)
98 elif isinstance(proptype, hyperdb.Interval):
99 props[propname] = date.Interval(value)
100 elif isinstance(proptype, hyperdb.Password):
101 props[propname] = password.Password()
102 props[propname].unpack(value)
104 # tag new user creation with 'admin'
105 self.journaltag = 'admin'
107 # create the new user
108 cl = self.user
110 props['roles'] = self.config.NEW_WEB_USER_ROLES
111 userid = cl.create(**props)
112 # clear the props from the otk database
113 self.getOTKManager().destroy(otk)
114 self.commit()
116 return userid
119 def log_debug(self, msg, *args, **kwargs):
120 """Log a message with level DEBUG."""
122 logger = self.get_logger()
123 logger.debug(msg, *args, **kwargs)
125 def log_info(self, msg, *args, **kwargs):
126 """Log a message with level INFO."""
128 logger = self.get_logger()
129 logger.info(msg, *args, **kwargs)
131 def get_logger(self):
132 """Return the logger for this database."""
134 # Because getting a logger requires acquiring a lock, we want
135 # to do it only once.
136 if not hasattr(self, '__logger'):
137 self.__logger = logging.getLogger('hyperdb')
139 return self.__logger
142 class DetectorError(RuntimeError):
143 """ Raised by detectors that want to indicate that something's amiss
144 """
145 pass
147 # deviation from spec - was called IssueClass
148 class IssueClass:
149 """This class is intended to be mixed-in with a hyperdb backend
150 implementation. The backend should provide a mechanism that
151 enforces the title, messages, files, nosy and superseder
152 properties:
154 - title = hyperdb.String(indexme='yes')
155 - messages = hyperdb.Multilink("msg")
156 - files = hyperdb.Multilink("file")
157 - nosy = hyperdb.Multilink("user")
158 - superseder = hyperdb.Multilink(classname)
159 """
161 # The tuple below does not affect the class definition.
162 # It just lists all names of all issue properties
163 # marked for message extraction tool.
164 #
165 # XXX is there better way to get property names into message catalog??
166 #
167 # Note that this list also includes properties
168 # defined in the classic template:
169 # assignedto, keyword, priority, status.
170 (
171 ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
172 ''"assignedto", ''"keyword", ''"priority", ''"status",
173 # following properties are common for all hyperdb classes
174 # they are listed here to keep things in one place
175 ''"actor", ''"activity", ''"creator", ''"creation",
176 )
178 # New methods:
179 def addmessage(self, nodeid, summary, text):
180 """Add a message to an issue's mail spool.
182 A new "msg" node is constructed using the current date, the user that
183 owns the database connection as the author, and the specified summary
184 text.
186 The "files" and "recipients" fields are left empty.
188 The given text is saved as the body of the message and the node is
189 appended to the "messages" field of the specified issue.
190 """
192 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
193 from_address=None, cc=[], bcc=[]):
194 """Send a message to the members of an issue's nosy list.
196 The message is sent only to users on the nosy list who are not
197 already on the "recipients" list for the message.
199 These users are then added to the message's "recipients" list.
201 If 'msgid' is None, the message gets sent only to the nosy
202 list, and it's called a 'System Message'.
204 The "cc" argument indicates additional recipients to send the
205 message to that may not be specified in the message's recipients
206 list.
208 The "bcc" argument also indicates additional recipients to send the
209 message to that may not be specified in the message's recipients
210 list. These recipients will not be included in the To: or Cc:
211 address lists.
212 """
213 if msgid:
214 authid = self.db.msg.get(msgid, 'author')
215 recipients = self.db.msg.get(msgid, 'recipients', [])
216 else:
217 # "system message"
218 authid = None
219 recipients = []
221 sendto = []
222 bcc_sendto = []
223 seen_message = {}
224 for recipient in recipients:
225 seen_message[recipient] = 1
227 def add_recipient(userid, to):
228 # make sure they have an address
229 address = self.db.user.get(userid, 'address')
230 if address:
231 to.append(address)
232 recipients.append(userid)
234 def good_recipient(userid):
235 # Make sure we don't send mail to either the anonymous
236 # user or a user who has already seen the message.
237 return (userid and
238 (self.db.user.get(userid, 'username') != 'anonymous') and
239 not seen_message.has_key(userid))
241 # possibly send the message to the author, as long as they aren't
242 # anonymous
243 if (good_recipient(authid) and
244 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
245 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
246 add_recipient(authid, sendto)
248 if authid:
249 seen_message[authid] = 1
251 # now deal with the nosy and cc people who weren't recipients.
252 for userid in cc + self.get(nodeid, whichnosy):
253 if good_recipient(userid):
254 add_recipient(userid, sendto)
256 # now deal with bcc people.
257 for userid in bcc:
258 if good_recipient(userid):
259 add_recipient(userid, bcc_sendto)
261 if oldvalues:
262 note = self.generateChangeNote(nodeid, oldvalues)
263 else:
264 note = self.generateCreateNote(nodeid)
266 # If we have new recipients, update the message's recipients
267 # and send the mail.
268 if sendto or bcc_sendto:
269 if msgid is not None:
270 self.db.msg.set(msgid, recipients=recipients)
271 self.send_message(nodeid, msgid, note, sendto, from_address,
272 bcc_sendto)
274 # backwards compatibility - don't remove
275 sendmessage = nosymessage
277 def send_message(self, nodeid, msgid, note, sendto, from_address=None,
278 bcc_sendto=[]):
279 '''Actually send the nominated message from this node to the sendto
280 recipients, with the note appended.
281 '''
282 users = self.db.user
283 messages = self.db.msg
284 files = self.db.file
286 if msgid is None:
287 inreplyto = None
288 messageid = None
289 else:
290 inreplyto = messages.get(msgid, 'inreplyto')
291 messageid = messages.get(msgid, 'messageid')
293 # make up a messageid if there isn't one (web edit)
294 if not messageid:
295 # this is an old message that didn't get a messageid, so
296 # create one
297 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
298 self.classname, nodeid,
299 self.db.config.MAIL_DOMAIN)
300 if msgid is not None:
301 messages.set(msgid, messageid=messageid)
303 # compose title
304 cn = self.classname
305 title = self.get(nodeid, 'title') or '%s message copy'%cn
307 # figure author information
308 if msgid:
309 authid = messages.get(msgid, 'author')
310 else:
311 authid = self.db.getuid()
312 authname = users.get(authid, 'realname')
313 if not authname:
314 authname = users.get(authid, 'username', '')
315 authaddr = users.get(authid, 'address', '')
317 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
318 authaddr = " <%s>" % straddr( ('',authaddr) )
319 elif authaddr:
320 authaddr = ""
322 # make the message body
323 m = ['']
325 # put in roundup's signature
326 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
327 m.append(self.email_signature(nodeid, msgid))
329 # add author information
330 if authid and self.db.config.MAIL_ADD_AUTHORINFO:
331 if msgid and len(self.get(nodeid, 'messages')) == 1:
332 m.append(_("New submission from %(authname)s%(authaddr)s:")
333 % locals())
334 elif msgid:
335 m.append(_("%(authname)s%(authaddr)s added the comment:")
336 % locals())
337 else:
338 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
339 m.append('')
341 # add the content
342 if msgid is not None:
343 m.append(messages.get(msgid, 'content', ''))
345 # get the files for this message
346 message_files = []
347 if msgid :
348 for fileid in messages.get(msgid, 'files') :
349 # check the attachment size
350 filename = self.db.filename('file', fileid, None)
351 filesize = os.path.getsize(filename)
352 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
353 message_files.append(fileid)
354 else:
355 base = self.db.config.TRACKER_WEB
356 link = "".join((base, files.classname, fileid))
357 filename = files.get(fileid, 'name')
358 m.append(_("File '%(filename)s' not attached - "
359 "you can download it from %(link)s.") % locals())
361 # add the change note
362 if note:
363 m.append(note)
365 # put in roundup's signature
366 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
367 m.append(self.email_signature(nodeid, msgid))
369 # encode the content as quoted-printable
370 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
371 m = '\n'.join(m)
372 if charset != 'utf-8':
373 m = unicode(m, 'utf-8').encode(charset)
374 content = cStringIO.StringIO(m)
375 content_encoded = cStringIO.StringIO()
376 quopri.encode(content, content_encoded, 0)
377 content_encoded = content_encoded.getvalue()
379 # make sure the To line is always the same (for testing mostly)
380 sendto.sort()
382 # make sure we have a from address
383 if from_address is None:
384 from_address = self.db.config.TRACKER_EMAIL
386 # additional bit for after the From: "name"
387 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
388 if from_tag:
389 from_tag = ' ' + from_tag
391 subject = '[%s%s] %s'%(cn, nodeid, title)
392 author = (authname + from_tag, from_address)
394 # send an individual message per recipient?
395 if self.db.config.NOSY_EMAIL_SENDING != 'single':
396 sendto = [[address] for address in sendto]
397 else:
398 sendto = [sendto]
400 # now send one or more messages
401 # TODO: I believe we have to create a new message each time as we
402 # can't fiddle the recipients in the message ... worth testing
403 # and/or fixing some day
404 first = True
405 for sendto in sendto:
406 # create the message
407 mailer = Mailer(self.db.config)
408 message, writer = mailer.get_standard_message(sendto, subject,
409 author)
411 # set reply-to to the tracker
412 tracker_name = self.db.config.TRACKER_NAME
413 if charset != 'utf-8':
414 tracker = unicode(tracker_name, 'utf-8').encode(charset)
415 tracker_name = encode_header(tracker_name, charset)
416 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
418 # message ids
419 if messageid:
420 writer.addheader('Message-Id', messageid)
421 if inreplyto:
422 writer.addheader('In-Reply-To', inreplyto)
424 # Generate a header for each link or multilink to
425 # a class that has a name attribute
426 for propname, prop in self.getprops().items():
427 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
428 continue
429 cl = self.db.getclass(prop.classname)
430 if not 'name' in cl.getprops():
431 continue
432 if isinstance(prop, hyperdb.Link):
433 value = self.get(nodeid, propname)
434 if value is None:
435 continue
436 values = [value]
437 else:
438 values = self.get(nodeid, propname)
439 if not values:
440 continue
441 values = [cl.get(v, 'name') for v in values]
442 values = ', '.join(values)
443 writer.addheader("X-Roundup-%s-%s" % (self.classname, propname),
444 values)
445 if not inreplyto:
446 # Default the reply to the first message
447 msgs = self.get(nodeid, 'messages')
448 # Assume messages are sorted by increasing message number here
449 # If the issue is just being created, and the submitter didn't
450 # provide a message, then msgs will be empty.
451 if msgs and msgs[0] != nodeid:
452 inreplyto = messages.get(msgs[0], 'messageid')
453 if inreplyto:
454 writer.addheader('In-Reply-To', inreplyto)
456 # attach files
457 if message_files:
458 part = writer.startmultipartbody('mixed')
459 part = writer.nextpart()
460 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
461 body = part.startbody('text/plain; charset=%s'%charset)
462 body.write(content_encoded)
463 for fileid in message_files:
464 name = files.get(fileid, 'name')
465 mime_type = files.get(fileid, 'type')
466 content = files.get(fileid, 'content')
467 part = writer.nextpart()
468 if mime_type == 'text/plain':
469 part.addheader('Content-Disposition',
470 'attachment;\n filename="%s"'%name)
471 try:
472 content.decode('ascii')
473 except UnicodeError:
474 # the content cannot be 7bit-encoded.
475 # use quoted printable
476 part.addheader('Content-Transfer-Encoding',
477 'quoted-printable')
478 body = part.startbody('text/plain')
479 body.write(quopri.encodestring(content))
480 else:
481 part.addheader('Content-Transfer-Encoding', '7bit')
482 body = part.startbody('text/plain')
483 body.write(content)
484 else:
485 # some other type, so encode it
486 if not mime_type:
487 # this should have been done when the file was saved
488 mime_type = mimetypes.guess_type(name)[0]
489 if mime_type is None:
490 mime_type = 'application/octet-stream'
491 part.addheader('Content-Disposition',
492 'attachment;\n filename="%s"'%name)
493 part.addheader('Content-Transfer-Encoding', 'base64')
494 body = part.startbody(mime_type)
495 body.write(base64.encodestring(content))
496 writer.lastpart()
497 else:
498 writer.addheader('Content-Transfer-Encoding',
499 'quoted-printable')
500 body = writer.startbody('text/plain; charset=%s'%charset)
501 body.write(content_encoded)
503 if first:
504 mailer.smtp_send(sendto + bcc_sendto, message)
505 else:
506 mailer.smtp_send(sendto, message)
507 first = False
509 def email_signature(self, nodeid, msgid):
510 ''' Add a signature to the e-mail with some useful information
511 '''
512 # simplistic check to see if the url is valid,
513 # then append a trailing slash if it is missing
514 base = self.db.config.TRACKER_WEB
515 if (not isinstance(base , type('')) or
516 not (base.startswith('http://') or base.startswith('https://'))):
517 web = "Configuration Error: TRACKER_WEB isn't a " \
518 "fully-qualified URL"
519 else:
520 if not base.endswith('/'):
521 base = base + '/'
522 web = base + self.classname + nodeid
524 # ensure the email address is properly quoted
525 email = straddr((self.db.config.TRACKER_NAME,
526 self.db.config.TRACKER_EMAIL))
528 line = '_' * max(len(web)+2, len(email))
529 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
532 def generateCreateNote(self, nodeid):
533 """Generate a create note that lists initial property values
534 """
535 cn = self.classname
536 cl = self.db.classes[cn]
537 props = cl.getprops(protected=0)
539 # list the values
540 m = []
541 prop_items = props.items()
542 prop_items.sort()
543 for propname, prop in prop_items:
544 value = cl.get(nodeid, propname, None)
545 # skip boring entries
546 if not value:
547 continue
548 if isinstance(prop, hyperdb.Link):
549 link = self.db.classes[prop.classname]
550 if value:
551 key = link.labelprop(default_to_id=1)
552 if key:
553 value = link.get(value, key)
554 else:
555 value = ''
556 elif isinstance(prop, hyperdb.Multilink):
557 if value is None: value = []
558 l = []
559 link = self.db.classes[prop.classname]
560 key = link.labelprop(default_to_id=1)
561 if key:
562 value = [link.get(entry, key) for entry in value]
563 value.sort()
564 value = ', '.join(value)
565 else:
566 value = str(value)
567 if '\n' in value:
568 value = '\n'+self.indentChangeNoteValue(value)
569 m.append('%s: %s'%(propname, value))
570 m.insert(0, '----------')
571 m.insert(0, '')
572 return '\n'.join(m)
574 def generateChangeNote(self, nodeid, oldvalues):
575 """Generate a change note that lists property changes
576 """
577 if not isinstance(oldvalues, type({})):
578 raise TypeError("'oldvalues' must be dict-like, not %s."%
579 type(oldvalues))
581 cn = self.classname
582 cl = self.db.classes[cn]
583 changed = {}
584 props = cl.getprops(protected=0)
586 # determine what changed
587 for key in oldvalues.keys():
588 if key in ['files','messages']:
589 continue
590 if key in ('actor', 'activity', 'creator', 'creation'):
591 continue
592 # not all keys from oldvalues might be available in database
593 # this happens when property was deleted
594 try:
595 new_value = cl.get(nodeid, key)
596 except KeyError:
597 continue
598 # the old value might be non existent
599 # this happens when property was added
600 try:
601 old_value = oldvalues[key]
602 if type(new_value) is type([]):
603 new_value.sort()
604 old_value.sort()
605 if new_value != old_value:
606 changed[key] = old_value
607 except:
608 changed[key] = new_value
610 # list the changes
611 m = []
612 changed_items = changed.items()
613 changed_items.sort()
614 for propname, oldvalue in changed_items:
615 prop = props[propname]
616 value = cl.get(nodeid, propname, None)
617 if isinstance(prop, hyperdb.Link):
618 link = self.db.classes[prop.classname]
619 key = link.labelprop(default_to_id=1)
620 if key:
621 if value:
622 value = link.get(value, key)
623 else:
624 value = ''
625 if oldvalue:
626 oldvalue = link.get(oldvalue, key)
627 else:
628 oldvalue = ''
629 change = '%s -> %s'%(oldvalue, value)
630 elif isinstance(prop, hyperdb.Multilink):
631 change = ''
632 if value is None: value = []
633 if oldvalue is None: oldvalue = []
634 l = []
635 link = self.db.classes[prop.classname]
636 key = link.labelprop(default_to_id=1)
637 # check for additions
638 for entry in value:
639 if entry in oldvalue: continue
640 if key:
641 l.append(link.get(entry, key))
642 else:
643 l.append(entry)
644 if l:
645 l.sort()
646 change = '+%s'%(', '.join(l))
647 l = []
648 # check for removals
649 for entry in oldvalue:
650 if entry in value: continue
651 if key:
652 l.append(link.get(entry, key))
653 else:
654 l.append(entry)
655 if l:
656 l.sort()
657 change += ' -%s'%(', '.join(l))
658 else:
659 change = '%s -> %s'%(oldvalue, value)
660 if '\n' in change:
661 value = self.indentChangeNoteValue(str(value))
662 oldvalue = self.indentChangeNoteValue(str(oldvalue))
663 change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
664 "new": value, "old": oldvalue}
665 m.append('%s: %s'%(propname, change))
666 if m:
667 m.insert(0, '----------')
668 m.insert(0, '')
669 return '\n'.join(m)
671 def indentChangeNoteValue(self, text):
672 lines = text.rstrip('\n').split('\n')
673 lines = [ ' '+line for line in lines ]
674 return '\n'.join(lines)
676 # vim: set filetype=python sts=4 sw=4 et si :