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 from anypy.email_ import FeedParser
36 from roundup import password, date, hyperdb
37 from roundup.i18n import _
39 # MessageSendError is imported for backwards compatibility
40 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
41 nice_sender_header
43 class Database:
45 # remember the journal uid for the current journaltag so that:
46 # a. we don't have to look it up every time we need it, and
47 # b. if the journaltag disappears during a transaction, we don't barf
48 # (eg. the current user edits their username)
49 journal_uid = None
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 if self.journaltag is None:
54 return None
55 elif self.journaltag == 'admin':
56 # admin user may not exist, but always has ID 1
57 return '1'
58 else:
59 if (self.journal_uid is None or self.journal_uid[0] !=
60 self.journaltag):
61 uid = self.user.lookup(self.journaltag)
62 self.journal_uid = (self.journaltag, uid)
63 return self.journal_uid[1]
65 def setCurrentUser(self, username):
66 """Set the user that is responsible for current database
67 activities.
68 """
69 self.journaltag = username
71 def isCurrentUser(self, username):
72 """Check if a given username equals the already active user.
73 """
74 return self.journaltag == username
76 def getUserTimezone(self):
77 """Return user timezone defined in 'timezone' property of user class.
78 If no such property exists return 0
79 """
80 userid = self.getuid()
81 timezone = None
82 try:
83 tz = self.user.get(userid, 'timezone')
84 date.get_timezone(tz)
85 timezone = tz
86 except KeyError:
87 pass
88 # If there is no class 'user' or current user doesn't have timezone
89 # property or that property is not set assume he/she lives in
90 # the timezone set in the tracker config.
91 if timezone is None:
92 timezone = self.config['TIMEZONE']
93 return timezone
95 def confirm_registration(self, otk):
96 props = self.getOTKManager().getall(otk)
97 for propname, proptype in self.user.getprops().items():
98 value = props.get(propname, None)
99 if value is None:
100 pass
101 elif isinstance(proptype, hyperdb.Date):
102 props[propname] = date.Date(value)
103 elif isinstance(proptype, hyperdb.Interval):
104 props[propname] = date.Interval(value)
105 elif isinstance(proptype, hyperdb.Password):
106 props[propname] = password.Password(encrypted=value)
108 # tag new user creation with 'admin'
109 self.journaltag = 'admin'
111 # create the new user
112 cl = self.user
114 props['roles'] = self.config.NEW_WEB_USER_ROLES
115 userid = cl.create(**props)
116 # clear the props from the otk database
117 self.getOTKManager().destroy(otk)
118 self.commit()
120 return userid
123 def log_debug(self, msg, *args, **kwargs):
124 """Log a message with level DEBUG."""
126 logger = self.get_logger()
127 logger.debug(msg, *args, **kwargs)
129 def log_info(self, msg, *args, **kwargs):
130 """Log a message with level INFO."""
132 logger = self.get_logger()
133 logger.info(msg, *args, **kwargs)
135 def get_logger(self):
136 """Return the logger for this database."""
138 # Because getting a logger requires acquiring a lock, we want
139 # to do it only once.
140 if not hasattr(self, '__logger'):
141 self.__logger = logging.getLogger('roundup.hyperdb')
143 return self.__logger
146 class DetectorError(RuntimeError):
147 """ Raised by detectors that want to indicate that something's amiss
148 """
149 pass
151 # deviation from spec - was called IssueClass
152 class IssueClass:
153 """This class is intended to be mixed-in with a hyperdb backend
154 implementation. The backend should provide a mechanism that
155 enforces the title, messages, files, nosy and superseder
156 properties:
158 - title = hyperdb.String(indexme='yes')
159 - messages = hyperdb.Multilink("msg")
160 - files = hyperdb.Multilink("file")
161 - nosy = hyperdb.Multilink("user")
162 - superseder = hyperdb.Multilink(classname)
163 """
165 # The tuple below does not affect the class definition.
166 # It just lists all names of all issue properties
167 # marked for message extraction tool.
168 #
169 # XXX is there better way to get property names into message catalog??
170 #
171 # Note that this list also includes properties
172 # defined in the classic template:
173 # assignedto, keyword, priority, status.
174 (
175 ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
176 ''"assignedto", ''"keyword", ''"priority", ''"status",
177 # following properties are common for all hyperdb classes
178 # they are listed here to keep things in one place
179 ''"actor", ''"activity", ''"creator", ''"creation",
180 )
182 # New methods:
183 def addmessage(self, issueid, summary, text):
184 """Add a message to an issue's mail spool.
186 A new "msg" node is constructed using the current date, the user that
187 owns the database connection as the author, and the specified summary
188 text.
190 The "files" and "recipients" fields are left empty.
192 The given text is saved as the body of the message and the node is
193 appended to the "messages" field of the specified issue.
194 """
196 def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
197 from_address=None, cc=[], bcc=[], cc_emails = [], bcc_emails = []):
198 """Send a message to the members of an issue's nosy list.
200 The message is sent only to users on the nosy list who are not
201 already on the "recipients" list for the message.
203 These users are then added to the message's "recipients" list.
205 If 'msgid' is None, the message gets sent only to the nosy
206 list, and it's called a 'System Message'.
208 The "cc" argument indicates additional recipients to send the
209 message to that may not be specified in the message's recipients
210 list.
212 The "bcc" argument also indicates additional recipients to send the
213 message to that may not be specified in the message's recipients
214 list. These recipients will not be included in the To: or Cc:
215 address lists.
217 The cc_emails and bcc_emails arguments take a list of additional
218 recipient email addresses (just the mail address not roundup users)
219 this can be useful for sending to additional email addresses which are no
220 roundup users. These arguments are currently not used by roundups
221 nosyreaction but can be used by customized (nosy-)reactors.
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)
276 sendto.extend (cc_emails)
278 # now deal with bcc people.
279 for userid in bcc:
280 if good_recipient(userid):
281 add_recipient(userid, bcc_sendto)
282 bcc_sendto.extend (bcc_emails)
284 if oldvalues:
285 note = self.generateChangeNote(issueid, oldvalues)
286 else:
287 note = self.generateCreateNote(issueid)
289 # If we have new recipients, update the message's recipients
290 # and send the mail.
291 if sendto or bcc_sendto:
292 if msgid is not None:
293 self.db.msg.set(msgid, recipients=recipients)
294 self.send_message(issueid, msgid, note, sendto, from_address,
295 bcc_sendto)
297 # backwards compatibility - don't remove
298 sendmessage = nosymessage
300 def send_message(self, issueid, msgid, note, sendto, from_address=None,
301 bcc_sendto=[]):
302 '''Actually send the nominated message from this issue to the sendto
303 recipients, with the note appended.
304 '''
305 users = self.db.user
306 messages = self.db.msg
307 files = self.db.file
309 if msgid is None:
310 inreplyto = None
311 messageid = None
312 else:
313 inreplyto = messages.get(msgid, 'inreplyto')
314 messageid = messages.get(msgid, 'messageid')
316 # make up a messageid if there isn't one (web edit)
317 if not messageid:
318 # this is an old message that didn't get a messageid, so
319 # create one
320 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
321 self.classname, issueid,
322 self.db.config.MAIL_DOMAIN)
323 if msgid is not None:
324 messages.set(msgid, messageid=messageid)
326 # compose title
327 cn = self.classname
328 title = self.get(issueid, 'title') or '%s message copy'%cn
330 # figure author information
331 if msgid:
332 authid = messages.get(msgid, 'author')
333 else:
334 authid = self.db.getuid()
335 authname = users.get(authid, 'realname')
336 if not authname:
337 authname = users.get(authid, 'username', '')
338 authaddr = users.get(authid, 'address', '')
340 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
341 authaddr = " <%s>" % formataddr( ('',authaddr) )
342 elif authaddr:
343 authaddr = ""
345 # make the message body
346 m = ['']
348 # put in roundup's signature
349 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
350 m.append(self.email_signature(issueid, msgid))
352 # add author information
353 if authid and self.db.config.MAIL_ADD_AUTHORINFO:
354 if msgid and len(self.get(issueid, 'messages')) == 1:
355 m.append(_("New submission from %(authname)s%(authaddr)s:")
356 % locals())
357 elif msgid:
358 m.append(_("%(authname)s%(authaddr)s added the comment:")
359 % locals())
360 else:
361 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
362 m.append('')
364 # add the content
365 if msgid is not None:
366 m.append(messages.get(msgid, 'content', ''))
368 # get the files for this message
369 message_files = []
370 if msgid :
371 for fileid in messages.get(msgid, 'files') :
372 # check the attachment size
373 filesize = self.db.filesize('file', fileid, None)
374 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
375 message_files.append(fileid)
376 else:
377 base = self.db.config.TRACKER_WEB
378 link = "".join((base, files.classname, fileid))
379 filename = files.get(fileid, 'name')
380 m.append(_("File '%(filename)s' not attached - "
381 "you can download it from %(link)s.") % locals())
383 # add the change note
384 if note:
385 m.append(note)
387 # put in roundup's signature
388 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
389 m.append(self.email_signature(issueid, msgid))
391 # figure the encoding
392 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
394 # construct the content and convert to unicode object
395 body = unicode('\n'.join(m), 'utf-8').encode(charset)
397 # make sure the To line is always the same (for testing mostly)
398 sendto.sort()
400 # make sure we have a from address
401 if from_address is None:
402 from_address = self.db.config.TRACKER_EMAIL
404 # additional bit for after the From: "name"
405 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
406 if from_tag:
407 from_tag = ' ' + from_tag
409 subject = '[%s%s] %s'%(cn, issueid, title)
410 author = (authname + from_tag, from_address)
412 # send an individual message per recipient?
413 if self.db.config.NOSY_EMAIL_SENDING != 'single':
414 sendto = [[address] for address in sendto]
415 else:
416 sendto = [sendto]
418 # tracker sender info
419 tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
420 tracker_name = nice_sender_header(tracker_name, from_address,
421 charset)
423 # now send one or more messages
424 # TODO: I believe we have to create a new message each time as we
425 # can't fiddle the recipients in the message ... worth testing
426 # and/or fixing some day
427 first = True
428 for sendto in sendto:
429 # create the message
430 mailer = Mailer(self.db.config)
432 message = mailer.get_standard_message(sendto, subject, author,
433 multipart=message_files)
435 # set reply-to to the tracker
436 message['Reply-To'] = tracker_name
438 # message ids
439 if messageid:
440 message['Message-Id'] = messageid
441 if inreplyto:
442 message['In-Reply-To'] = inreplyto
444 # Generate a header for each link or multilink to
445 # a class that has a name attribute
446 for propname, prop in self.getprops().items():
447 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
448 continue
449 cl = self.db.getclass(prop.classname)
450 if not 'name' in cl.getprops():
451 continue
452 if isinstance(prop, hyperdb.Link):
453 value = self.get(issueid, propname)
454 if value is None:
455 continue
456 values = [value]
457 else:
458 values = self.get(issueid, propname)
459 if not values:
460 continue
461 values = [cl.get(v, 'name') for v in values]
462 values = ', '.join(values)
463 header = "X-Roundup-%s-%s"%(self.classname, propname)
464 try:
465 message[header] = values.encode('ascii')
466 except UnicodeError:
467 message[header] = Header(values, charset)
469 if not inreplyto:
470 # Default the reply to the first message
471 msgs = self.get(issueid, 'messages')
472 # Assume messages are sorted by increasing message number here
473 # If the issue is just being created, and the submitter didn't
474 # provide a message, then msgs will be empty.
475 if msgs and msgs[0] != msgid:
476 inreplyto = messages.get(msgs[0], 'messageid')
477 if inreplyto:
478 message['In-Reply-To'] = inreplyto
480 # attach files
481 if message_files:
482 # first up the text as a part
483 part = MIMEText(body)
484 part.set_charset(charset)
485 encode_quopri(part)
486 message.attach(part)
488 for fileid in message_files:
489 name = files.get(fileid, 'name')
490 mime_type = files.get(fileid, 'type')
491 content = files.get(fileid, 'content')
492 if mime_type == 'text/plain':
493 try:
494 content.decode('ascii')
495 except UnicodeError:
496 # the content cannot be 7bit-encoded.
497 # use quoted printable
498 # XXX stuffed if we know the charset though :(
499 part = MIMEText(content)
500 encode_quopri(part)
501 else:
502 part = MIMEText(content)
503 part['Content-Transfer-Encoding'] = '7bit'
504 elif mime_type == 'message/rfc822':
505 main, sub = mime_type.split('/')
506 p = FeedParser()
507 p.feed(content)
508 part = MIMEBase(main, sub)
509 part.set_payload([p.close()])
510 else:
511 # some other type, so encode it
512 if not mime_type:
513 # this should have been done when the file was saved
514 mime_type = mimetypes.guess_type(name)[0]
515 if mime_type is None:
516 mime_type = 'application/octet-stream'
517 main, sub = mime_type.split('/')
518 part = MIMEBase(main, sub)
519 part.set_payload(content)
520 Encoders.encode_base64(part)
521 cd = 'Content-Disposition'
522 part[cd] = 'attachment;\n filename="%s"'%name
523 message.attach(part)
525 else:
526 message.set_payload(body)
527 encode_quopri(message)
529 if first:
530 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
531 else:
532 mailer.smtp_send(sendto, message.as_string())
533 first = False
535 def email_signature(self, issueid, msgid):
536 ''' Add a signature to the e-mail with some useful information
537 '''
538 # simplistic check to see if the url is valid,
539 # then append a trailing slash if it is missing
540 base = self.db.config.TRACKER_WEB
541 if (not isinstance(base , type('')) or
542 not (base.startswith('http://') or base.startswith('https://'))):
543 web = "Configuration Error: TRACKER_WEB isn't a " \
544 "fully-qualified URL"
545 else:
546 if not base.endswith('/'):
547 base = base + '/'
548 web = base + self.classname + issueid
550 # ensure the email address is properly quoted
551 email = formataddr((self.db.config.TRACKER_NAME,
552 self.db.config.TRACKER_EMAIL))
554 line = '_' * max(len(web)+2, len(email))
555 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
558 def generateCreateNote(self, issueid):
559 """Generate a create note that lists initial property values
560 """
561 cn = self.classname
562 cl = self.db.classes[cn]
563 props = cl.getprops(protected=0)
565 # list the values
566 m = []
567 prop_items = props.items()
568 prop_items.sort()
569 for propname, prop in prop_items:
570 value = cl.get(issueid, propname, None)
571 # skip boring entries
572 if not value:
573 continue
574 if isinstance(prop, hyperdb.Link):
575 link = self.db.classes[prop.classname]
576 if value:
577 key = link.labelprop(default_to_id=1)
578 if key:
579 value = link.get(value, key)
580 else:
581 value = ''
582 elif isinstance(prop, hyperdb.Multilink):
583 if value is None: value = []
584 l = []
585 link = self.db.classes[prop.classname]
586 key = link.labelprop(default_to_id=1)
587 if key:
588 value = [link.get(entry, key) for entry in value]
589 value.sort()
590 value = ', '.join(value)
591 else:
592 value = str(value)
593 if '\n' in value:
594 value = '\n'+self.indentChangeNoteValue(value)
595 m.append('%s: %s'%(propname, value))
596 m.insert(0, '----------')
597 m.insert(0, '')
598 return '\n'.join(m)
600 def generateChangeNote(self, issueid, oldvalues):
601 """Generate a change note that lists property changes
602 """
603 if not isinstance(oldvalues, type({})):
604 raise TypeError("'oldvalues' must be dict-like, not %s."%
605 type(oldvalues))
607 cn = self.classname
608 cl = self.db.classes[cn]
609 changed = {}
610 props = cl.getprops(protected=0)
612 # determine what changed
613 for key in oldvalues.keys():
614 if key in ['files','messages']:
615 continue
616 if key in ('actor', 'activity', 'creator', 'creation'):
617 continue
618 # not all keys from oldvalues might be available in database
619 # this happens when property was deleted
620 try:
621 new_value = cl.get(issueid, key)
622 except KeyError:
623 continue
624 # the old value might be non existent
625 # this happens when property was added
626 try:
627 old_value = oldvalues[key]
628 if type(new_value) is type([]):
629 new_value.sort()
630 old_value.sort()
631 if new_value != old_value:
632 changed[key] = old_value
633 except:
634 changed[key] = new_value
636 # list the changes
637 m = []
638 changed_items = changed.items()
639 changed_items.sort()
640 for propname, oldvalue in changed_items:
641 prop = props[propname]
642 value = cl.get(issueid, propname, None)
643 if isinstance(prop, hyperdb.Link):
644 link = self.db.classes[prop.classname]
645 key = link.labelprop(default_to_id=1)
646 if key:
647 if value:
648 value = link.get(value, key)
649 else:
650 value = ''
651 if oldvalue:
652 oldvalue = link.get(oldvalue, key)
653 else:
654 oldvalue = ''
655 change = '%s -> %s'%(oldvalue, value)
656 elif isinstance(prop, hyperdb.Multilink):
657 change = ''
658 if value is None: value = []
659 if oldvalue is None: oldvalue = []
660 l = []
661 link = self.db.classes[prop.classname]
662 key = link.labelprop(default_to_id=1)
663 # check for additions
664 for entry in value:
665 if entry in oldvalue: continue
666 if key:
667 l.append(link.get(entry, key))
668 else:
669 l.append(entry)
670 if l:
671 l.sort()
672 change = '+%s'%(', '.join(l))
673 l = []
674 # check for removals
675 for entry in oldvalue:
676 if entry in value: continue
677 if key:
678 l.append(link.get(entry, key))
679 else:
680 l.append(entry)
681 if l:
682 l.sort()
683 change += ' -%s'%(', '.join(l))
684 else:
685 change = '%s -> %s'%(oldvalue, value)
686 if '\n' in change:
687 value = self.indentChangeNoteValue(str(value))
688 oldvalue = self.indentChangeNoteValue(str(oldvalue))
689 change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
690 "new": value, "old": oldvalue}
691 m.append('%s: %s'%(propname, change))
692 if m:
693 m.insert(0, '----------')
694 m.insert(0, '')
695 return '\n'.join(m)
697 def indentChangeNoteValue(self, text):
698 lines = text.rstrip('\n').split('\n')
699 lines = [ ' '+line for line in lines ]
700 return '\n'.join(lines)
702 # vim: set filetype=python sts=4 sw=4 et si :