| [13252] | 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | |
|---|
| [13253] | 3 | import os.path |
|---|
| [13252] | 4 | import re |
|---|
| [18478] | 5 | import sys |
|---|
| [17594] | 6 | from pkg_resources import parse_version, resource_filename |
|---|
| 7 | |
|---|
| [13252] | 8 | try: |
|---|
| 9 | from babel.core import Locale |
|---|
| 10 | except ImportError: |
|---|
| [17594] | 11 | Locale = None |
|---|
| [13252] | 12 | |
|---|
| [17594] | 13 | from trac import __version__ |
|---|
| [13252] | 14 | from trac.core import Component, implements |
|---|
| 15 | from trac.attachment import AttachmentModule |
|---|
| 16 | from trac.env import Environment |
|---|
| 17 | from trac.notification import SmtpEmailSender, SendmailEmailSender |
|---|
| 18 | from trac.resource import ResourceNotFound |
|---|
| [13660] | 19 | from trac.test import MockPerm |
|---|
| [13252] | 20 | from trac.ticket.model import Ticket |
|---|
| 21 | from trac.ticket.web_ui import TicketModule |
|---|
| [13618] | 22 | from trac.timeline.web_ui import TimelineModule |
|---|
| [14384] | 23 | from trac.util.datefmt import get_timezone, localtz, to_utimestamp |
|---|
| [13253] | 24 | from trac.util.text import to_unicode |
|---|
| [13617] | 25 | from trac.util.translation import deactivate, make_activable, reactivate, tag_ |
|---|
| [13660] | 26 | from trac.web.api import Request |
|---|
| [13252] | 27 | from trac.web.chrome import Chrome, ITemplateProvider |
|---|
| 28 | from trac.web.main import FakeSession |
|---|
| 29 | |
|---|
| [18478] | 30 | text_type = unicode if sys.version_info[0] == 2 else str |
|---|
| 31 | |
|---|
| [14384] | 32 | try: |
|---|
| [17594] | 33 | from trac.web.chrome import web_context |
|---|
| 34 | except ImportError: |
|---|
| 35 | from trac.mimeview.api import Context |
|---|
| 36 | web_context = Context.from_request |
|---|
| 37 | |
|---|
| 38 | try: |
|---|
| [14384] | 39 | from trac.notification.api import INotificationFormatter |
|---|
| 40 | except ImportError: |
|---|
| 41 | INotificationFormatter = None |
|---|
| [13252] | 42 | |
|---|
| [14384] | 43 | |
|---|
| [17594] | 44 | _parsed_version = parse_version(__version__) |
|---|
| 45 | if _parsed_version >= parse_version('1.4'): |
|---|
| 46 | _use_jinja2 = True |
|---|
| 47 | elif _parsed_version >= parse_version('1.3'): |
|---|
| 48 | _use_jinja2 = hasattr(Chrome, 'jenv') |
|---|
| 49 | else: |
|---|
| 50 | _use_jinja2 = False |
|---|
| 51 | |
|---|
| 52 | |
|---|
| 53 | if _use_jinja2: |
|---|
| [18478] | 54 | from trac.util.html import tag |
|---|
| [17594] | 55 | _template_dir = resource_filename(__name__, 'templates/jinja2') |
|---|
| 56 | else: |
|---|
| 57 | _template_dir = resource_filename(__name__, 'templates/genshi') |
|---|
| [18478] | 58 | try: |
|---|
| 59 | from trac.util.html import tag |
|---|
| 60 | except ImportError: |
|---|
| 61 | from genshi.builder import tag |
|---|
| [17594] | 62 | |
|---|
| 63 | |
|---|
| [13252] | 64 | _TICKET_URI_RE = re.compile(r'/ticket/(?P<tktid>[0-9]+)' |
|---|
| 65 | r'(?:#comment:(?P<cnum>[0-9]+))?\Z') |
|---|
| 66 | |
|---|
| 67 | |
|---|
| 68 | if Locale: |
|---|
| 69 | def _parse_locale(lang): |
|---|
| 70 | try: |
|---|
| 71 | return Locale.parse(lang, sep='-') |
|---|
| 72 | except: |
|---|
| 73 | return Locale('en', 'US') |
|---|
| 74 | else: |
|---|
| [17594] | 75 | _parse_locale = lambda lang: None |
|---|
| [13252] | 76 | |
|---|
| 77 | |
|---|
| 78 | class HtmlNotificationModule(Component): |
|---|
| 79 | |
|---|
| [14384] | 80 | if INotificationFormatter: |
|---|
| 81 | implements(INotificationFormatter, ITemplateProvider) |
|---|
| 82 | else: |
|---|
| 83 | implements(ITemplateProvider) |
|---|
| [13252] | 84 | |
|---|
| [14384] | 85 | # INotificationFormatter methods |
|---|
| 86 | |
|---|
| 87 | def get_supported_styles(self, transport): |
|---|
| 88 | yield 'text/html', 'ticket' |
|---|
| 89 | |
|---|
| 90 | def format(self, transport, style, event): |
|---|
| 91 | if style != 'text/html' or event.realm != 'ticket': |
|---|
| 92 | return |
|---|
| 93 | chrome = Chrome(self.env) |
|---|
| 94 | req = self._create_request() |
|---|
| 95 | ticket = event.target |
|---|
| 96 | cnum = None |
|---|
| 97 | if event.time: |
|---|
| [17594] | 98 | rows = self._db_query("""\ |
|---|
| [14384] | 99 | SELECT field, oldvalue FROM ticket_change |
|---|
| 100 | WHERE ticket=%s AND time=%s AND field='comment' |
|---|
| 101 | """, (ticket.id, to_utimestamp(event.time))) |
|---|
| [17594] | 102 | for field, oldvalue in rows: |
|---|
| [14384] | 103 | if oldvalue: |
|---|
| 104 | cnum = int(oldvalue.rsplit('.', 1)[-1]) |
|---|
| 105 | break |
|---|
| 106 | link = self.env.abs_href.ticket(ticket.id) |
|---|
| 107 | if cnum is not None: |
|---|
| 108 | link += '#comment:%d' % cnum |
|---|
| 109 | |
|---|
| 110 | try: |
|---|
| 111 | tx = deactivate() |
|---|
| 112 | try: |
|---|
| 113 | make_activable(lambda: req.locale, self.env.path) |
|---|
| 114 | content = self._create_html_body(chrome, req, ticket, cnum, |
|---|
| 115 | link) |
|---|
| 116 | finally: |
|---|
| 117 | reactivate(tx) |
|---|
| 118 | except: |
|---|
| [18478] | 119 | self.log.warning('Caught exception while generating html part', |
|---|
| 120 | exc_info=True) |
|---|
| [14384] | 121 | raise |
|---|
| [18478] | 122 | if isinstance(content, text_type): |
|---|
| [14384] | 123 | # avoid UnicodeEncodeError from MIMEText() |
|---|
| 124 | content = content.encode('utf-8') |
|---|
| 125 | return content |
|---|
| 126 | |
|---|
| 127 | # ITemplateProvider methods |
|---|
| 128 | |
|---|
| [13252] | 129 | def get_htdocs_dirs(self): |
|---|
| 130 | return () |
|---|
| 131 | |
|---|
| 132 | def get_templates_dirs(self): |
|---|
| [17594] | 133 | return [_template_dir] |
|---|
| [13252] | 134 | |
|---|
| [14384] | 135 | # public methods |
|---|
| 136 | |
|---|
| [13660] | 137 | def substitute_message(self, message, ignore_exc=True): |
|---|
| [13252] | 138 | try: |
|---|
| 139 | chrome = Chrome(self.env) |
|---|
| [13660] | 140 | req = self._create_request() |
|---|
| [14384] | 141 | tx = deactivate() |
|---|
| [13252] | 142 | try: |
|---|
| 143 | make_activable(lambda: req.locale, self.env.path) |
|---|
| 144 | return self._substitute_message(chrome, req, message) |
|---|
| 145 | finally: |
|---|
| [14384] | 146 | reactivate(tx) |
|---|
| [13252] | 147 | except: |
|---|
| [18478] | 148 | self.log.warning('Caught exception while substituting message', |
|---|
| 149 | exc_info=True) |
|---|
| [13660] | 150 | if ignore_exc: |
|---|
| 151 | return message |
|---|
| 152 | raise |
|---|
| [13252] | 153 | |
|---|
| [14384] | 154 | # private methods |
|---|
| 155 | |
|---|
| [17594] | 156 | if hasattr(Environment, 'db_query'): |
|---|
| 157 | def _db_query(self, query, args=()): |
|---|
| 158 | return self.env.db_query(query, args) |
|---|
| 159 | else: |
|---|
| 160 | def _db_query(self, query, args=()): |
|---|
| 161 | db = self.env.get_read_db() |
|---|
| 162 | cursor = db.cursor() |
|---|
| 163 | cursor.execute(query, args) |
|---|
| 164 | return list(cursor) |
|---|
| 165 | |
|---|
| [13660] | 166 | def _create_request(self): |
|---|
| [18478] | 167 | lang = self.config.get('trac', 'default_language') |
|---|
| 168 | locale = _parse_locale(lang) if lang else None |
|---|
| [13252] | 169 | tzname = self.config.get('trac', 'default_timezone') |
|---|
| [13660] | 170 | tz = get_timezone(tzname) or localtz |
|---|
| [17594] | 171 | base_url = self.env.abs_href() |
|---|
| 172 | if ':' in base_url: |
|---|
| 173 | url_scheme = base_url.split(':', 1)[0] |
|---|
| 174 | else: |
|---|
| 175 | url_scheme = 'http' |
|---|
| [13660] | 176 | environ = {'REQUEST_METHOD': 'POST', 'REMOTE_ADDR': '127.0.0.1', |
|---|
| 177 | 'SERVER_NAME': 'localhost', 'SERVER_PORT': '80', |
|---|
| [17594] | 178 | 'wsgi.url_scheme': url_scheme, 'trac.base_url': base_url} |
|---|
| [18478] | 179 | if locale: |
|---|
| 180 | environ['HTTP_ACCEPT_LANGUAGE'] = str(locale).replace('_', '-') |
|---|
| [16119] | 181 | session = FakeSession() |
|---|
| 182 | session['dateinfo'] = 'absolute' |
|---|
| [13660] | 183 | req = Request(environ, lambda *args, **kwargs: None) |
|---|
| 184 | req.arg_list = () |
|---|
| 185 | req.args = {} |
|---|
| 186 | req.authname = 'anonymous' |
|---|
| [17594] | 187 | req.form_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' |
|---|
| [16119] | 188 | req.session = session |
|---|
| [13660] | 189 | req.perm = MockPerm() |
|---|
| 190 | req.href = req.abs_href |
|---|
| 191 | req.locale = locale |
|---|
| 192 | req.lc_time = locale |
|---|
| 193 | req.tz = tz |
|---|
| 194 | req.chrome = {'notices': [], 'warnings': []} |
|---|
| 195 | return req |
|---|
| [13252] | 196 | |
|---|
| 197 | def _substitute_message(self, chrome, req, message): |
|---|
| [18478] | 198 | from email import message_from_string |
|---|
| 199 | from email.mime.multipart import MIMEMultipart |
|---|
| 200 | from email.mime.text import MIMEText |
|---|
| 201 | |
|---|
| 202 | parsed = message_from_string(message) |
|---|
| [13252] | 203 | link = parsed.get('X-Trac-Ticket-URL') |
|---|
| 204 | if not link: |
|---|
| 205 | return message |
|---|
| 206 | match = _TICKET_URI_RE.search(link) |
|---|
| 207 | if not match: |
|---|
| 208 | return message |
|---|
| 209 | tktid = match.group('tktid') |
|---|
| 210 | cnum = match.group('cnum') |
|---|
| 211 | if cnum is not None: |
|---|
| 212 | cnum = int(cnum) |
|---|
| 213 | |
|---|
| 214 | try: |
|---|
| 215 | ticket = Ticket(self.env, tktid) |
|---|
| 216 | except ResourceNotFound: |
|---|
| 217 | return message |
|---|
| 218 | |
|---|
| 219 | container = MIMEMultipart('alternative') |
|---|
| 220 | for header, value in parsed.items(): |
|---|
| 221 | lower = header.lower() |
|---|
| 222 | if lower in ('content-type', 'content-transfer-encoding'): |
|---|
| 223 | continue |
|---|
| 224 | if lower != 'mime-version': |
|---|
| 225 | container[header] = value |
|---|
| 226 | del parsed[header] |
|---|
| 227 | container.attach(parsed) |
|---|
| 228 | |
|---|
| 229 | html = self._create_html_body(chrome, req, ticket, cnum, link) |
|---|
| 230 | part = MIMEText(html.encode('utf-8'), 'html') |
|---|
| 231 | self._set_charset(part) |
|---|
| 232 | container.attach(part) |
|---|
| 233 | |
|---|
| 234 | return container.as_string() |
|---|
| 235 | |
|---|
| 236 | def _create_html_body(self, chrome, req, ticket, cnum, link): |
|---|
| 237 | tktmod = TicketModule(self.env) |
|---|
| 238 | attmod = AttachmentModule(self.env) |
|---|
| 239 | data = tktmod._prepare_data(req, ticket) |
|---|
| 240 | tktmod._insert_ticket_data(req, ticket, data, req.authname, {}) |
|---|
| 241 | data['ticket']['link'] = link |
|---|
| 242 | changes = data.get('changes') |
|---|
| 243 | if cnum is None: |
|---|
| 244 | changes = [] |
|---|
| 245 | else: |
|---|
| 246 | changes = [change for change in (changes or []) |
|---|
| 247 | if change.get('cnum') == cnum] |
|---|
| 248 | data['changes'] = changes |
|---|
| [17594] | 249 | context = web_context(req, ticket.resource, absurls=True) |
|---|
| [13616] | 250 | alist = attmod.attachment_data(context) |
|---|
| 251 | alist['can_create'] = False |
|---|
| [17594] | 252 | data.update({'can_append': False, |
|---|
| 253 | 'show_editor': False, |
|---|
| 254 | 'start_time': ticket['changetime'], |
|---|
| 255 | 'context': context, |
|---|
| 256 | 'alist': alist, |
|---|
| 257 | 'styles': self._get_styles(chrome), |
|---|
| 258 | 'link': tag.a(link, href=link), |
|---|
| 259 | 'tag_': tag_}) |
|---|
| [13618] | 260 | template = 'htmlnotification_ticket.html' |
|---|
| 261 | # use pretty_dateinfo in TimelineModule |
|---|
| 262 | TimelineModule(self.env).post_process_request(req, template, data, |
|---|
| 263 | None) |
|---|
| [17594] | 264 | if _use_jinja2: |
|---|
| 265 | return chrome.render_template(req, template, data, |
|---|
| 266 | {'iterable': False}) |
|---|
| 267 | else: |
|---|
| [18478] | 268 | return text_type(chrome.render_template(req, template, data, |
|---|
| 269 | fragment=True)) |
|---|
| [13252] | 270 | |
|---|
| [13253] | 271 | def _get_styles(self, chrome): |
|---|
| 272 | for provider in chrome.template_providers: |
|---|
| [18478] | 273 | for prefix, dir_ in provider.get_htdocs_dirs(): |
|---|
| [13253] | 274 | if prefix != 'common': |
|---|
| 275 | continue |
|---|
| [18478] | 276 | comm_re = re.compile(r'/\*[^*]*\*+(?:[^/*][^*]*\*+)*/', re.S) |
|---|
| [13253] | 277 | url_re = re.compile(r'\burl\([^\]]*\)') |
|---|
| 278 | buf = ['#content > hr { display: none }'] |
|---|
| 279 | for name in ('trac.css', 'ticket.css'): |
|---|
| [18478] | 280 | filename = os.path.join(dir_, 'css', name) |
|---|
| 281 | f = open(filename, 'rb') |
|---|
| [13253] | 282 | try: |
|---|
| [18478] | 283 | content = to_unicode(f.read()) |
|---|
| [13253] | 284 | finally: |
|---|
| 285 | f.close() |
|---|
| [18478] | 286 | content = comm_re.sub('\n', content) |
|---|
| 287 | for line in content.splitlines(): |
|---|
| 288 | line = line.rstrip() |
|---|
| 289 | if not line: |
|---|
| 290 | continue |
|---|
| 291 | if line.startswith(('@import', '@charset')): |
|---|
| 292 | continue |
|---|
| 293 | buf.append(url_re.sub('none', line)) |
|---|
| [13253] | 294 | return ('/*<![CDATA[*/\n' + |
|---|
| 295 | '\n'.join(buf).replace(']]>', ']]]]><![CDATA[>') + |
|---|
| 296 | '\n/*]]>*/') |
|---|
| 297 | return '' |
|---|
| [13252] | 298 | |
|---|
| 299 | def _set_charset(self, mime): |
|---|
| [18478] | 300 | from email.charset import Charset, QP, BASE64, SHORTEST |
|---|
| [13252] | 301 | |
|---|
| [18478] | 302 | encoding = self.config.get('notification', 'mime_encoding').lower() |
|---|
| [13252] | 303 | charset = Charset() |
|---|
| 304 | charset.input_charset = 'utf-8' |
|---|
| 305 | charset.output_charset = 'utf-8' |
|---|
| 306 | charset.input_codec = 'utf-8' |
|---|
| 307 | charset.output_codec = 'utf-8' |
|---|
| [18478] | 308 | if encoding == 'base64': |
|---|
| [13252] | 309 | charset.header_encoding = BASE64 |
|---|
| 310 | charset.body_encoding = BASE64 |
|---|
| [18478] | 311 | elif encoding in ('qp', 'quoted-printable'): |
|---|
| [13252] | 312 | charset.header_encoding = QP |
|---|
| 313 | charset.body_encoding = QP |
|---|
| [18478] | 314 | elif encoding == 'none': |
|---|
| [13252] | 315 | charset.header_encoding = SHORTEST |
|---|
| 316 | charset.body_encoding = None |
|---|
| 317 | |
|---|
| 318 | del mime['Content-Transfer-Encoding'] |
|---|
| 319 | mime.set_charset(charset) |
|---|
| 320 | |
|---|
| 321 | |
|---|
| [17594] | 322 | if not INotificationFormatter: |
|---|
| [13252] | 323 | |
|---|
| [17594] | 324 | class HtmlNotificationSmtpEmailSender(SmtpEmailSender): |
|---|
| 325 | |
|---|
| 326 | def send(self, from_addr, recipients, message): |
|---|
| [14384] | 327 | mod = HtmlNotificationModule(self.env) |
|---|
| 328 | message = mod.substitute_message(message) |
|---|
| [17594] | 329 | SmtpEmailSender.send(self, from_addr, recipients, message) |
|---|
| [13252] | 330 | |
|---|
| 331 | |
|---|
| [17594] | 332 | class HtmlNotificationSendmailEmailSender(SendmailEmailSender): |
|---|
| [13252] | 333 | |
|---|
| [17594] | 334 | def send(self, from_addr, recipients, message): |
|---|
| [14384] | 335 | mod = HtmlNotificationModule(self.env) |
|---|
| 336 | message = mod.substitute_message(message) |
|---|
| [17594] | 337 | SendmailEmailSender.send(self, from_addr, recipients, message) |
|---|