source: trachtmlnotificationplugin/0.12/trachtmlnotification/notification.py

Last change on this file was 18478, checked in by Jun Omae, 18 months ago

TracHtmlNotificationPlugin: make compatible with Python 3 (closes #14130)

File size: 11.5 KB
RevLine 
[13252]1# -*- coding: utf-8 -*-
2
[13253]3import os.path
[13252]4import re
[18478]5import sys
[17594]6from pkg_resources import parse_version, resource_filename
7
[13252]8try:
9    from babel.core import Locale
10except ImportError:
[17594]11    Locale = None
[13252]12
[17594]13from trac import __version__
[13252]14from trac.core import Component, implements
15from trac.attachment import AttachmentModule
16from trac.env import Environment
17from trac.notification import SmtpEmailSender, SendmailEmailSender
18from trac.resource import ResourceNotFound
[13660]19from trac.test import MockPerm
[13252]20from trac.ticket.model import Ticket
21from trac.ticket.web_ui import TicketModule
[13618]22from trac.timeline.web_ui import TimelineModule
[14384]23from trac.util.datefmt import get_timezone, localtz, to_utimestamp
[13253]24from trac.util.text import to_unicode
[13617]25from trac.util.translation import deactivate, make_activable, reactivate, tag_
[13660]26from trac.web.api import Request
[13252]27from trac.web.chrome import Chrome, ITemplateProvider
28from trac.web.main import FakeSession
29
[18478]30text_type = unicode if sys.version_info[0] == 2 else str
31
[14384]32try:
[17594]33    from trac.web.chrome import web_context
34except ImportError:
35    from trac.mimeview.api import Context
36    web_context = Context.from_request
37
38try:
[14384]39    from trac.notification.api import INotificationFormatter
40except ImportError:
41    INotificationFormatter = None
[13252]42
[14384]43
[17594]44_parsed_version = parse_version(__version__)
45if _parsed_version >= parse_version('1.4'):
46    _use_jinja2 = True
47elif _parsed_version >= parse_version('1.3'):
48    _use_jinja2 = hasattr(Chrome, 'jenv')
49else:
50    _use_jinja2 = False
51
52
53if _use_jinja2:
[18478]54    from trac.util.html import tag
[17594]55    _template_dir = resource_filename(__name__, 'templates/jinja2')
56else:
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
68if Locale:
69    def _parse_locale(lang):
70        try:
71            return Locale.parse(lang, sep='-')
72        except:
73            return Locale('en', 'US')
74else:
[17594]75    _parse_locale = lambda lang: None
[13252]76
77
78class 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]322if 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)
Note: See TracBrowser for help on using the repository browser.