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