source: exceldownloadplugin/0.12/tracexceldownload/ticket.py @ 16204

Last change on this file since 16204 was 16204, checked in by Jun Omae, 7 years ago

ExcelDownloadPlugin: support *.xlsx format using openpyxl

File size: 20.4 KB
Line 
1# -*- coding: utf-8 -*-
2
3import inspect
4import re
5import types
6from datetime import datetime
7from itertools import chain, groupby
8
9from trac.core import Component, implements
10from trac.env import Environment
11from trac.mimeview.api import Context, IContentConverter
12from trac.resource import Resource, get_resource_url
13from trac.ticket.api import TicketSystem
14from trac.ticket.model import Ticket
15from trac.ticket.query import Query
16from trac.ticket.web_ui import TicketModule
17from trac.util import Ranges
18from trac.util.text import empty, unicode_urlencode
19from trac.web.api import IRequestFilter, RequestDone
20from trac.web.chrome import Chrome, add_link
21try:
22    from trac.util.datefmt import from_utimestamp
23except ImportError:
24    from datetime import timedelta
25    from trac.util.datefmt import utc
26    _epoc = datetime(1970, 1, 1, tzinfo=utc)
27    from_utimestamp = lambda ts: _epoc + timedelta(seconds=ts or 0)
28
29from tracexceldownload.api import (get_excel_format, get_excel_mimetype,
30                                   get_workbook_writer)
31from tracexceldownload.translation import _, dgettext, dngettext
32
33
34if hasattr(Environment, 'get_read_db'):
35    _get_db = lambda env: env.get_read_db()
36else:
37    _get_db = lambda env: env.get_db_cnx()
38
39
40def _tkt_id_conditions(column, tkt_ids):
41    ranges = Ranges()
42    ranges.appendrange(','.join(map(str, sorted(tkt_ids))))
43    condition = []
44    tkt_ids = []
45    for a, b in ranges.pairs:
46        if a == b:
47            tkt_ids.append(a)
48        elif a + 1 == b:
49            tkt_ids.extend((a, b))
50        else:
51            condition.append('%s BETWEEN %d AND %d' % (column, a, b))
52    if tkt_ids:
53        condition.append('%s IN (%s)' % (column, ','.join(map(str, tkt_ids))))
54    return ' OR '.join(condition)
55
56
57class BulkFetchTicket(Ticket):
58
59    @classmethod
60    def select(cls, env, tkt_ids):
61        if not tkt_ids:
62            return {}
63
64        db = _get_db(env)
65        fields = TicketSystem(env).get_ticket_fields()
66        std_fields = [f['name'] for f in fields if not f.get('custom')]
67        time_fields = [f['name'] for f in fields if f['type'] == 'time']
68        custom_fields = set(f['name'] for f in fields if f.get('custom'))
69        cursor = db.cursor()
70        tickets = {}
71
72        cursor.execute('SELECT %s,id FROM ticket WHERE %s' %
73                       (','.join(std_fields),
74                        _tkt_id_conditions('id', tkt_ids)))
75        for row in cursor:
76            id = row[-1]
77            values = {}
78            for idx, field in enumerate(std_fields):
79                value = row[idx]
80                if field in time_fields:
81                    value = from_utimestamp(value)
82                elif value is None:
83                    value = empty
84                values[field] = value
85            tickets[id] = (values, [])  # values, changelog
86
87        cursor.execute('SELECT ticket,name,value FROM ticket_custom '
88                       'WHERE %s ORDER BY ticket' %
89                       _tkt_id_conditions('ticket', tkt_ids))
90        for id, rows in groupby(cursor, lambda row: row[0]):
91            if id not in tickets:
92                continue
93            values = {}
94            for id, name, value in rows:
95                if name in custom_fields:
96                    if value is None:
97                        value = empty
98                    values[name] = value
99            tickets[id][0].update(values)
100
101        cursor.execute('SELECT ticket,time,author,field,oldvalue,newvalue '
102                       'FROM ticket_change WHERE %s ORDER BY ticket,time' %
103                       _tkt_id_conditions('ticket', tkt_ids))
104        for id, rows in groupby(cursor, lambda row: row[0]):
105            if id not in tickets:
106                continue
107            tickets[id][1].extend(
108                    (from_utimestamp(t), author, field, oldvalue or '',
109                     newvalue or '', 1)
110                    for id, t, author, field, oldvalue, newvalue in rows)
111
112        return dict((id, cls(env, id, values=values, changelog=changelog,
113                             fields=fields, time_fields=time_fields))
114                    for id, (values, changelog) in tickets.iteritems())
115
116    def __init__(self, env, tkt_id=None, db=None, version=None, values=None,
117                 changelog=None, fields=None, time_fields=None):
118        self.env = env
119        if tkt_id is not None:
120            tkt_id = int(tkt_id)
121        self.fields = fields
122        self.time_fields = time_fields
123        self.id = tkt_id
124        self.version = version
125        self._values = values
126        self.values = values.copy()
127        self._changelog = changelog
128        self._old = {}
129
130    @property
131    def resource(self):
132        return Resource('ticket', self.id, self.version)
133
134    def _fetch_ticket(self, tkt_id, db=None):
135        self.values = self._values.copy()
136
137    def get_changelog(self, when=None, db=None):
138        return self._changelog[:]
139
140
141class ExcelTicketModule(Component):
142
143    implements(IContentConverter)
144
145    def get_supported_conversions(self):
146        format = get_excel_format(self.env)
147        mimetype = get_excel_mimetype(format)
148        yield ('excel', _("Excel"), format,
149               'trac.ticket.Query', mimetype, 8)
150        yield ('excel-history', _("Excel including history"), format,
151               'trac.ticket.Query', mimetype, 8)
152        yield ('excel-history', _("Excel including history"), format,
153               'trac.ticket.Ticket', mimetype, 8)
154
155    def convert_content(self, req, mimetype, content, key):
156        if key == 'excel':
157            return self._convert_query(req, content)
158        if key == 'excel-history':
159            kwargs = {}
160            if isinstance(content, Ticket):
161                content = Query.from_string(self.env, 'id=%d' % content.id)
162                kwargs['sheet_query'] = False
163                kwargs['sheet_history'] = True
164            else:
165                kwargs['sheet_query'] = True
166                kwargs['sheet_history'] = True
167            return self._convert_query(req, content, **kwargs)
168
169    def _convert_query(self, req, query, sheet_query=True,
170                       sheet_history=False):
171        book = get_workbook_writer(self.env, req)
172
173        # no paginator
174        query.max = 0
175        query.has_more_pages = False
176        query.offset = 0
177        db = _get_db(self.env)
178
179        # extract all fields except custom fields
180        custom_fields = [f['name'] for f in query.fields if f.get('custom')]
181        cols = ['id']
182        cols.extend(f['name'] for f in query.fields
183                              if f['name'] not in custom_fields)
184        cols.extend(name for name in ('time', 'changetime')
185                         if name not in cols)
186        query.cols = cols
187
188        # prevent "SELECT COUNT(*)" query
189        saved_count_prop = query._count
190        try:
191            query._count = types.MethodType(lambda self, sql, args, db=None: 0,
192                                            query, query.__class__)
193            if 'db' in inspect.getargspec(query.execute)[0]:
194                tickets = query.execute(req, db)
195            else:
196                tickets = query.execute(req)
197            query.num_items = len(tickets)
198        finally:
199            query._count = saved_count_prop
200
201        # add custom fields to avoid error to join many tables
202        self._fill_custom_fields(tickets, query.fields, custom_fields, db)
203
204        context = Context.from_request(req, 'query', absurls=True)
205        cols.extend([name for name in custom_fields if name not in cols])
206        data = query.template_data(context, tickets)
207
208        if sheet_query:
209            self._create_sheet_query(req, context, data, book)
210        if sheet_history:
211            self._create_sheet_history(req, context, data, book)
212        return book.dumps(), book.mimetype
213
214    def _fill_custom_fields(self, tickets, fields, custom_fields, db):
215        if not tickets or not custom_fields:
216            return
217        fields = dict((f['name'], f) for f in fields)
218        tickets = dict((int(ticket['id']), ticket) for ticket in tickets)
219        query = "SELECT ticket,name,value " \
220                "FROM ticket_custom WHERE %s ORDER BY ticket" % \
221                _tkt_id_conditions('ticket', tickets)
222
223        cursor = db.cursor()
224        cursor.execute(query)
225        for id, name, value in cursor:
226            if id not in tickets:
227                continue
228            f = fields.get(name)
229            if f and f['type'] == 'checkbox':
230                try:
231                    value = bool(int(value))
232                except (TypeError, ValueError):
233                    value = False
234            tickets[id][name] = value
235
236    def _create_sheet_query(self, req, context, data, book):
237        def write_headers(writer, query):
238            writer.write_row([(
239                u'%s (%s)' % (dgettext('messages', 'Custom Query'),
240                              dngettext('messages', '%(num)s match',
241                                        '%(num)s matches', query.num_items)),
242                'header', -1, -1)])
243
244        query = data['query']
245        groups = data['groups']
246        fields = data['fields']
247        headers = data['headers']
248
249        sheet_count = 1
250        sheet_name = dgettext("messages", "Custom Query")
251        writer = book.create_sheet(sheet_name)
252        write_headers(writer, query)
253
254        for groupname, results in groups:
255            results = [result for result in results
256                              if 'TICKET_VIEW' in req.perm(
257                                 context('ticket', result['id']).resource)]
258            if not results:
259                continue
260
261            if writer.row_idx + len(results) + 3 > writer.MAX_ROWS:
262                sheet_count += 1
263                writer = book.create_sheet('%s (%d)' % (sheet_name,
264                                                        sheet_count))
265                write_headers(writer, query)
266
267            if groupname:
268                writer.move_row()
269                cell = fields[query.group]['label'] + ' '
270                if query.group in ('owner', 'reporter'):
271                    cell += Chrome(self.env).format_author(req, groupname)
272                else:
273                    cell += groupname
274                cell += ' (%s)' % dngettext('messages', '%(num)s match',
275                                            '%(num)s matches', len(results))
276                writer.write_row([(cell, 'header2', -1, -1)])
277
278            writer.write_row((header['label'], 'thead', None, None)
279                             for idx, header in enumerate(headers))
280
281            for result in results:
282                ticket_context = context('ticket', result['id'])
283                cells = []
284                for idx, header in enumerate(headers):
285                    name = header['name']
286                    value, style, width, line = self._get_cell_data(
287                        name, result.get(name), req, ticket_context, writer)
288                    cells.append((value, style, width, line))
289                writer.write_row(cells)
290
291        writer.set_col_widths()
292
293    def _create_sheet_history(self, req, context, data, book):
294        def write_headers(writer, headers):
295            writer.write_row((header['label'], 'thead', None, None)
296                             for idx, header in enumerate(headers))
297
298        groups = data['groups']
299        headers = [header for header in data['headers']
300                   if header['name'] not in ('id', 'time', 'changetime')]
301        headers[0:0] = [
302            {'name': 'id', 'label': dgettext("messages", "Ticket")},
303            {'name': 'time', 'label': dgettext("messages", "Time")},
304            {'name': 'author', 'label': dgettext("messages", "Author")},
305            {'name': 'comment', 'label': dgettext("messages", "Comment")},
306        ]
307
308        sheet_name = dgettext("messages", "Change History")
309        sheet_count = 1
310        writer = book.create_sheet(sheet_name)
311        write_headers(writer, headers)
312
313        tkt_ids = [result['id']
314                   for result in chain(*[results for groupname, results
315                                                 in groups])]
316        tickets = BulkFetchTicket.select(self.env, tkt_ids)
317
318        mod = TicketModule(self.env)
319        for result in chain(*[results for groupname, results in groups]):
320            id = result['id']
321            ticket = tickets[id]
322            ticket_context = context('ticket', id)
323            if 'TICKET_VIEW' not in req.perm(ticket_context.resource):
324                continue
325            values = ticket.values.copy()
326            changes = []
327
328            for change in mod.grouped_changelog_entries(ticket, None):
329                if change['permanent']:
330                    changes.append(change)
331            for change in reversed(changes):
332                change['values'] = values
333                values = values.copy()
334                for name, field in change['fields'].iteritems():
335                    if name in values:
336                        values[name] = field['old']
337            changes[0:0] = [{'date': ticket.time_created, 'fields': {},
338                             'values': values, 'cnum': None,
339                             'comment': '', 'author': ticket['reporter']}]
340
341            if writer.row_idx + len(changes) >= writer.MAX_ROWS:
342                sheet_count += 1
343                writer = book.create_sheet('%s (%d)' % (sheet_name,
344                                                        sheet_count))
345                write_headers(writer, headers)
346
347            for change in changes:
348                cells = []
349                for idx, header in enumerate(headers):
350                    name = header['name']
351                    if name == 'id':
352                        value = id
353                    elif name == 'time':
354                        value = change.get('date', '')
355                    elif name == 'comment':
356                        value = change.get('comment', '')
357                    elif name == 'author':
358                        value = change.get('author', '')
359                        value = Chrome(self.env).format_author(req, value)
360                    else:
361                        value = change['values'].get(name, '')
362                    value, style, width, line = \
363                            self._get_cell_data(name, value, req,
364                                                ticket_context, writer)
365                    if name in change['fields']:
366                        style = '%s:change' % style
367                    cells.append((value, style, width, line))
368                writer.write_row(cells)
369
370        writer.set_col_widths()
371
372    def _get_cell_data(self, name, value, req, context, writer):
373        if name == 'id':
374            url = self.env.abs_href.ticket(value)
375            value = '#%d' % value
376            width = len(value)
377            return value, 'id', width, 1
378
379        if isinstance(value, datetime):
380            return value, '[datetime]', None, None
381
382        if value and name in ('reporter', 'owner'):
383            value = Chrome(self.env).format_author(req, value)
384            return value, name, None, None
385
386        if name == 'cc':
387            value = Chrome(self.env).format_emails(context, value)
388            return value, name, None, None
389
390        if name == 'milestone':
391            if value:
392                url = self.env.abs_href.milestone(value)
393                width, line = writer.get_metrics(value)
394                return value, name, width, line
395            else:
396                return '', name, None, None
397
398        return value, name, None, None
399
400
401class ExcelReportModule(Component):
402
403    implements(IRequestFilter)
404
405    _PATH_INFO_MATCH = re.compile(r'/report/[0-9]+').match
406
407    def pre_process_request(self, req, handler):
408        if self._PATH_INFO_MATCH(req.path_info) \
409                and req.args.get('format') in ('xlsx', 'xls') \
410                and handler.__class__.__name__ == 'ReportModule':
411            req.args['max'] = 0
412        return handler
413
414    def post_process_request(self, req, template, data, content_type):
415        if template == 'report_view.html' and req.args.get('id'):
416            format = req.args.getfirst('format')
417            if format in ('xlsx', 'xls'):
418                resource = Resource('report', req.args['id'])
419                data['context'] = Context.from_request(req, resource,
420                                                       absurls=True)
421                self._convert_report(format, req, data)
422            elif not format:
423                self._add_alternate_links(req)
424        return template, data, content_type
425
426    def _convert_report(self, format, req, data):
427        book = get_workbook_writer(self.env, req)
428        writer = book.create_sheet(dgettext('messages', 'Report'))
429
430        writer.write_row([(
431            '%s (%s)' % (data['title'],
432                         dngettext('messages', '%(num)s match',
433                                   '%(num)s matches', data['numrows'])),
434            'header', -1, -1)])
435
436        for value_for_group, row_group in data['row_groups']:
437            writer.move_row()
438
439            if value_for_group and len(row_group):
440                writer.write_row([(
441                    '%s (%s)' % (value_for_group,
442                                 dngettext('messages', '%(num)s match',
443                                           '%(num)s matches', len(row_group))),
444                    'header2', -1, -1)])
445            for header_group in data['header_groups']:
446                writer.write_row([
447                    (header['title'], 'thead', None, None)
448                    for header in header_group
449                    if not header['hidden']])
450
451            for row in row_group:
452                for cell_group in row['cell_groups']:
453                    cells = []
454                    for cell in cell_group:
455                        cell_header = cell['header']
456                        if cell_header['hidden']:
457                            continue
458                        col = cell_header['col'].strip('_').lower()
459                        value, style, width, line = \
460                            self._get_cell_data(req, col, cell, row, writer)
461                        cells.append((value, style, width, line))
462                    writer.write_row(cells)
463
464        writer.set_col_widths()
465
466        content = book.dumps()
467        req.send_response(200)
468        req.send_header('Content-Type', book.mimetype)
469        req.send_header('Content-Length', len(content))
470        req.send_header('Content-Disposition',
471                        'filename=report_%s.%s' % (req.args['id'], format))
472        req.end_headers()
473        req.write(content)
474        raise RequestDone
475
476    def _get_cell_data(self, req, col, cell, row, writer):
477        value = cell['value']
478
479        if col == 'report':
480            url = self.env.abs_href.report(value)
481            width, line = writer.get_metrics(value)
482            return value, col, width, line
483
484        if col in ('ticket', 'id'):
485            id_value = cell['value']
486            value = '#%s' % id_value
487            url = get_resource_url(self.env, row['resource'], self.env.abs_href)
488            width = len(value)
489            return id_value, 'id', width, 1
490
491        if col == 'milestone':
492            url = self.env.abs_href.milestone(value)
493            width, line = writer.get_metrics(value)
494            return value, col, width, line
495
496        if col == 'time':
497            if isinstance(value, basestring) and value.isdigit():
498                value = from_utimestamp(long(value))
499                return value, '[time]', None, None
500        elif col in ('date', 'created', 'modified'):
501            if isinstance(value, basestring) and value.isdigit():
502                value = from_utimestamp(long(value))
503                return value, '[date]', None, None
504        elif col == 'datetime':
505            if isinstance(value, basestring) and value.isdigit():
506                value = from_utimestamp(long(value))
507                return value, '[datetime]', None, None
508
509        width, line = writer.get_metrics(value)
510        return value, col, width, line
511
512    def _add_alternate_links(self, req):
513        params = {}
514        for arg in req.args.keys():
515            if not arg.isupper():
516                continue
517            params[arg] = req.args.get(arg)
518        if 'USER' not in params:
519            params['USER'] = req.authname
520        if 'sort' in req.args:
521            params['sort'] = req.args['sort']
522        if 'asc' in req.args:
523            params['asc'] = req.args['asc']
524        href = ''
525        if params:
526            href = '&' + unicode_urlencode(params)
527        format = get_excel_format(self.env)
528        mimetype = get_excel_mimetype(format)
529        add_link(req, 'alternate', '?format=' + format + href,
530                 _("Excel"), mimetype)
Note: See TracBrowser for help on using the repository browser.