source: peerreviewplugin/trunk/codereview/docx_export.py

Last change on this file was 18284, checked in by Cinc-th, 2 years ago

PeerReviewPlugin: allow to review added, copied and moved files in changesets. Up to now you had to visit the review pages for such files, see also [18271] which introduced an explanatory message for those files.

Refs #14007

File size: 21.8 KB
RevLine 
[15463]1# -*- coding: utf-8 -*-
2
3
[15692]4import datetime
[15463]5import io
[15464]6import os
[15692]7import zipfile
[15463]8from collections import defaultdict
[15692]9from lxml import etree as et
[18249]10from .repo import get_repository_dict
[15463]11from trac.util.datefmt import format_date
[15465]12from trac.util.text import to_unicode, to_utf8
[15463]13from trac.versioncontrol.api import RepositoryManager
14from trac.versioncontrol.web_ui.util import get_existing_node
15from .model import PeerReviewModel, PeerReviewerModel, ReviewCommentModel, ReviewFileModel
[18284]16from .peerReviewPerform import file_lines_from_node
[15463]17
18try:
19    from docx import Document
20    from docx.enum.style import WD_STYLE_TYPE
21    from docx.shared import Pt, RGBColor
22    docx_support = True
23except ImportError:
24    docx_support = False
25
26__author__ = 'Cinc'
27__copyright__ = "Copyright 2016"
28__license__ = "BSD"
29
30
31def ensure_paragraph_styles(doc):
32    """Add paragraph styles to the given document if they are not already defined.
33
34    The user may use a template document for a report. Different parts of the added content
35    use different styles. If the template doesn't define them defaults for the styles are used
36    which are added here.
37
38    The following styles are used:
39    * 'Code': for listings of source files
40    * 'Reviewcomment': review comment text
41    * 'Reviewcommentinfo': info text for a comment. Author, date, id, ...
42    """
43    def have_paragraph_style(style_name):
44        styles = doc.styles
45        paragraph_styles = [s for s in styles if s.type == WD_STYLE_TYPE.PARAGRAPH]
46        for style in paragraph_styles:
47            if style.name == style_name:
48                return True
49        return False
50    def add_style(style_data):
51        style = doc.styles.add_style(style_data['name'], WD_STYLE_TYPE.PARAGRAPH)
52        style.base_style = doc.styles['Normal']
53
54        font_data = style_data['font']
55        if font_data:
56            font = style.font
57            font.name = font_data[0]
58            font.size = Pt(font_data[1])
59            if font_data[2]:
60                font.color.rgb = RGBColor(font_data[2][0], font_data[2][1], font_data[2][2])
61
62    styles = [{'name': u'Code', 'font': [u'Courier New', 8, []]},
63              {'name': u'Reviewcomment', 'font': [u'Arial', 10, []]},
64              {'name': u'Reviewcommentinfo', 'font': [u'Arial', 8, [0x80, 0x80, 0x80]]}]
65
66    for style_data in styles:
67        if not have_paragraph_style(style_data['name']):
68            add_style(style_data)
69
70
71def add_review_info_to_table(env, doc, review_id):
72    """Add info about the review to the given document.
73
74    @param env: Trac envireonment object
75    @param doc: python-docx document
76    @param review_id: id of the review
77
78    This function searches the document for a two column table with a marker field to add the information.
79    If the table can't be found a heading 'Review Info' is added to the end of the document followed by
80    a default table.
81
82    Table format:
83
84    |  Foo   |     Bar      |   Some header row
85    |  Name  | $REVIEWNAME$ |   <- Marker field
86    | Status |   $STATUS$   |
87    |  ....  |    $....$    |   More rows, see cell_data below
88
89    """
90    def get_review_info(env, review_id):
91        """Get a PeerReviewModel for the given review id and prepare some additional data used by the template"""
92        review = PeerReviewModel(env, review_id)
93        # review.html_notes = format_to_html(env, Context.from_request(req), review['notes'])
94        review.date = format_date(review['created'], 'iso8601')
95        return review
96
97    def get_review_info_table(doc):
98        for table in doc.tables:
99            for row in table.rows:
100                if len(row.cells) > 1 and row.cells[1].text == u'$REVIEWNAME$':
101                    return table
102
103        # Table not found, add it. This may be an empty template
104        cell_data = [['Name', '$REVIEWNAME$'],
105                     ['Status', '$STATUS$'],
106                     ['ID', '$ID$'],
107                     ['Project', '$PROJECT$'],
108                     ['Author', '$AUTHOR$'],
[15475]109                     ['Date', '$DATE$'],
110                     ['Followup from', '$FOLLOWUP$']
[15463]111                     ]
112        doc.add_heading('Review Info', level=1)
113        tbl = doc.add_table(len(cell_data), 2)
114        for idx, data in enumerate(cell_data):
115            tbl.rows[idx].cells[0].text = data[0]
116            tbl.rows[idx].cells[1].text = data[1]
117        return tbl
118
119    rev_info = get_review_info(env, review_id)
120    if rev_info:
121        table = get_review_info_table(doc)
122        if table:
123            for row in table.rows:
124                cell = row.cells[1]
125                if cell.text == u'$REVIEWNAME$':
126                    cell.text = rev_info['name']
127                elif cell.text == u'$STATUS$':
128                    cell.text = rev_info['status']
129                elif cell.text == u'$PROJECT$':
130                    cell.text = rev_info['project'] or ''
131                elif cell.text == u'$AUTHOR$':
132                    cell.text = rev_info['owner']
133                elif cell.text == u'$DATE$':
134                    cell.text = format_date(rev_info['created'], 'iso8601')
135                elif cell.text == u'$ID$':
136                    cell.text = str(rev_info['review_id'])
[15475]137                elif cell.text == u'$FOLLOWUP$':
138                    cell.text = str(rev_info['parent_id']) if rev_info['parent_id'] > 0  else '---'
[15463]139
140def add_reviewers_to_table(env, doc, review_id):
141    """Add reviewer names to a table in the document.
142
143    The table will be searched by looking for a table row with $REVIEWER$ in the first column.
144    The first reviewer will be inserted in the found column. For each subsequent reviewer a row is appended
145    to the table. This means the column in question must be in the last row of the table.
146
147    The table must have at least two columns:
148    | Reviewer | Status | Some more columns... |
149
150    """
151    def get_reviewer_table(doc):
152        """Find the table for inserting the reviewer names.
153
154        This table must have two columns and at least one row with a cell in column 1 holding
155        the text $REVIEWER$.
156        First reviewer name will be inserted there and the following appended starting with
157        the row/column holding the text. Thus the row in question must be the last one in the table.
158        """
159        for table in doc.tables:
160            row = table.rows[-1]
161            if row.cells[0].text == u'$REVIEWER$':
162                return table, row
163
164        # Table not found, add it. This may be an empty template
165        cell_data = [['Reviewer', 'Status'],
166                     ['$REVIEWER$', '']
167                     ]
168        doc.add_heading('Reviewer Info', level=1)
169        tbl = doc.add_table(len(cell_data), 2)
170        for idx, data in enumerate(cell_data):
171            tbl.rows[idx].cells[0].text = data[0]
172            tbl.rows[idx].cells[1].text = data[1]
173        return tbl, tbl.rows[1]
174
175    def get_reviewers(env, review_id):
176        rm = PeerReviewerModel(env)
177        rm.clear_props()
178        rm['review_id'] = review_id
179        return list(rm.list_matching_objects())
180
181    reviewers = get_reviewers(env, review_id)
182    table, row = get_reviewer_table(doc)
183    if table:
[15475]184        try:
185            row.cells[0].text = reviewers[0]['reviewer']
186            row.cells[1].text = reviewers[0]['status']
187        except IndexError:
188            pass
[15463]189        for idx, reviewer in enumerate(reviewers[1:]):
190            cells = table.add_row().cells
[15475]191            try:
192                cells[0].text = reviewer['reviewer']
193                cells[1].text = reviewer['status']
194            except IndexError:
195                pass
[15463]196
197def get_file_info(env, review_id):
198    """Get a list of objects holding file and comment information for a review.
199
200    Comment data is added to each of the files associated with the review.
201    * Each file has an attribute 'comments'.
202    * file_obj['comments'] holds a comment dict. Key: line number, value: ReviewCommentModel object
203
204    @param env: Trac environment object
205    @param review_id: review id
[15475]206    @return: list of ReviewFileModel objects. This one has an additional attribute 'comments' which holds
207             a dict with key: line number, value: list of comments for that line
[15463]208    """
209    def get_files_for_review_id(review_id):
210        """Get all files belonging to the given review id. Provide the number of comments if asked for."""
211        rfm = ReviewFileModel(env)
212        rfm.clear_props()
213        rfm['review_id'] = review_id
214        return list(rfm.list_matching_objects())
215
216    def get_comment_data_for_file(file_id):
217        rcm = ReviewCommentModel(env)
218        rcm.clear_props()
219        rcm['file_id'] = file_id
220
221        comments = list(rcm.list_matching_objects())
222
223        the_dict = defaultdict(list)  # key: line_num, value: list of comments
224        for comm in comments:
225            comm['children'] = {}
226            the_dict[comm['line_num']].append(comm)
227        return the_dict
228
229    items = get_files_for_review_id(review_id)
230    for review_file in items:
231        comments = get_comment_data_for_file(review_file['file_id'])
232        review_file['comments'] = comments
233    return items
234
235
[17414]236def add_file_info_to_table(env, doc, file_info):
[15463]237    """Find or create file information table and add file info.
238
239    @param doc: python-docx Document object
240    @param file_info: list of PeerReviewFile objects
241    @return: None
[15597]242    :param env:
[15463]243    """
244    def get_file_table(doc):
245        for table in doc.tables:
246            row = table.rows[-1]
[15464]247            if len(row.cells) > 1 and row.cells[1].text == u'$FILEPATH$':
[15463]248                return table
249
250        # Table not found, add it. This may be an empty template
[15475]251        cell_data = [['ID', 'Path', 'Hash', 'Revision', 'Comments', 'Status'],
252                     ['', '$FILEPATH$', '', '', '', '']
[15463]253                     ]
254        doc.add_heading(u'Files', level=1)
[15476]255        tbl = doc.add_table(len(cell_data), len(cell_data[0]))
[15463]256        for idx, data in enumerate(cell_data):
257            for i, val in enumerate(data):
258                tbl.rows[idx].cells[i].text = val
259        return tbl
260
[15475]261    def get_num_comments(comments):
262        """Count total number of comments for a file given a dict with comment information.
263
264        @param comments: dict with key: line number, value: comments for this line
265        @return: total number of comments
266        """
267        num = 0
268        for value in comments.items():
269            num += len(value)
270        return num
271
[15597]272    repodict = get_repository_dict(env)
273
[15463]274    table = get_file_table(doc)
275    if file_info and table:
276        cells = table.rows[-1].cells
277
[15597]278        for idx, item in enumerate(file_info):
[15475]279            try:
[15597]280                try:
281                    prefix = repodict[item['repo']]['url'].rstrip('/')
[15598]282                except KeyError:
[15597]283                    prefix = ''
284                if idx > 0:
285                    cells = table.add_row().cells
[15475]286                cells[0].text = str(item['file_id'])
[15597]287                cells[1].text = prefix + item['path']
[15475]288                cells[2].text = item['hash'] or ''
289                cells[3].text = item['revision']
290                cells[4].text = str(get_num_comments(item['comments']))
291                cells[5].text = item['status'] or ''
[15597]292            except IndexError:  # May happen if the template misses some table columns
[15475]293                pass
[15463]294
295def get_file_data(env, f_info):
296    """Get file content from repository.
297
298    @param env: Trac environment object
299    @param f_info: PeerReviewFile object
300    @return: file data for file specified by f_info
301    """
302    f_data = []
[15687]303    repos = RepositoryManager(env).get_repository(f_info['repo'] or '')
[15463]304    if not repos:
305        return f_data
306
307    rev = f_info['revision']
308    if rev:
309        rev = repos.normalize_rev(rev)
310    rev_or_latest = rev or repos.youngest_rev
311    node = get_existing_node(env, repos, f_info['path'], rev_or_latest)
[18284]312    return file_lines_from_node(node)
[15463]313
314
315def create_comment_tree(comments):
316    comms = {}
317    for c in comments:
318        comms[c['comment_id']] = c
319
320    all_keys = comms.keys()
321    for key in all_keys:
322        c = comms[key]
323        if c['parent_id'] != -1 and c['parent_id'] in comms and c['parent_id'] != c['comment_id']:
324            children_dict = comms[c['parent_id']]['children']
325            children_dict[c['comment_id']] = c
326
327    # Remove all comments without parent from root. These are still referenced in children dicts of some parent.
328    for key in all_keys:
329        c = comms[key]
330        if c['parent_id'] != -1:
331            del comms[key]
332
333    return comms
334
335
336def print_comment(par, comment, indent=0):
337    """Add the given comment to the docx file.
338
339    @param par: pythond-docx Paragraph. Comment will be inserted right before it.
340    @param comment: ReviewComment object
341    @param indent: number of tabs used for indenting
342    @return: None
343    """
[15695]344    header = u"ID: %s: \t%s,\tAutor: %s" % (comment['comment_id'], format_date(comment['created']),
345                                            comment['author'])
[15463]346    par.insert_paragraph_before(u"\t"*indent + header, style=u"Reviewcommentinfo")
347    par.insert_paragraph_before(u"\t"*indent + comment['comment'], style=u"Reviewcomment")
348    children = comment['children']
349    items = [c for id, c in children.items()]
350    items = sorted(items, key=lambda item: item['comment_id'])
351    for c in items:
352        print_comment(par, c, indent + 1)
353
354
355def add_file_data(env, doc, file_info):
356    """Add file content and associated comments to document.
357
358    The content of the given file is added to the document using the paragraph style 'Code'.
[15464]359    The position in the document is specified by the string $FILECONTENT$ which must be in the
[15463]360    template. If this string can't be found the data is appended to the end of the document.
361    Note that a header with style 'Heading 2' will be added with the file path.
362
363    @param env: Trac environment object
364    @param doc: python-docx Document object
365    @param file_info: PeerReviewFile object holding information about a file
366    @return: None
367    """
368    def get_fileinfo_paragraph(doc):
369        for par in doc.paragraphs:
[15464]370            if u"$FILECONTENT$" in par.text:
[15463]371                return par
[15464]372        par = doc.add_paragraph(u"$FILECONTENT$")
[15463]373        return par
374
375    par = get_fileinfo_paragraph(doc)
376    if par:
377        for item in file_info:
378            comments = item['comments']
379            par.insert_paragraph_before(item['path'], style=u"Heading 2")
380            f_data = get_file_data(env, item)
381            line_nr = 0
382            for line in f_data:
383                line_nr += 1
384                par.insert_paragraph_before("%s: %s" % (str(line_nr).rjust(4, ' '), to_unicode(line)),
385                                            style=u'Code')
386                if line_nr in comments:
387                    par.insert_paragraph_before()
388                    comm_tree = create_comment_tree(comments[line_nr])
389                    items = [c for id, c in comm_tree.items()]
390                    items = sorted(items, key=lambda item: item['comment_id'])
391                    for comment in items:
392                        print_comment(par, comment)
393                    par.insert_paragraph_before()
394
395        par.text = u""  # Overwrite marker text
396
397
[17414]398def set_custom_doc_properties(zin, review=None):
[15692]399    def set_element_txt(elm, txt):
400        e = dom.xpath("//p:property[@name='%s']" % elm,
401                      namespaces={'p': 'http://schemas.openxmlformats.org/officeDocument/2006/custom-properties'})
402        if e:
403            try:
404                e[0][0].text = txt
405            except IndexError:
406                pass
407
408    dom = et.fromstring(zin.read("docProps/custom.xml"))
409
410    set_element_txt(u'VersionDate', datetime.datetime.today().strftime("%d.%m.%Y"))
411    # Set document id
[15695]412    set_element_txt(u'Dokumentnummer', '0')
[17414]413    if review:
414        set_element_txt(u'MCNummer', review['project'])
[15692]415    set_element_txt(u'VersionMajor', u'1')
416    set_element_txt(u'VersionMinor', u'0')
417
418    return et.tostring(dom)
419
420
[17414]421def set_core_properties(doc):
[15465]422    from datetime import datetime
423
424    props = doc.core_properties
425    props.created = datetime.now()
426
427
[15695]428def set_core_doc_properties(zin, data):
429
430    dom = et.fromstring(zin.read("docProps/core.xml"))
431
432    e = dom.find("{http://purl.org/dc/elements/1.1/}creator")
433    if e is not None:
[17414]434        e.text = data['author']
[15695]435    e = dom.find("{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}lastModifiedBy")
436    if e is not None:
[17414]437        e.text = data['author']
[15695]438
439    e = dom.find("{http://purl.org/dc/elements/1.1/}subject")
440    if e is not None:
[17414]441        e.text = data.get('subject', '')
[15695]442
443    e = dom.find("{http://purl.org/dc/elements/1.1/}title")
444    if e is not None:
[17414]445        e.text = data.get('title', '')
[15695]446
447    e = dom.find("{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}revision")
448    if e is not None:
449        e.text = '1'
450
451    return et.tostring(dom)
452
453
[15465]454def create_docx_for_review(env, data, template):
[15691]455    """
456
457    :param env: Trac environment object
458    :param data: dictionary with information about the review
459    :param template: path to docx template
460    :return: docx file data
461    """
[15464]462    def template_exists(tpath):
463        if not tpath:
464            return False
465        if os.path.isfile(tpath):
466            return True
467        return False
[15463]468
[15465]469    review_id = data['review_id']
[15464]470    if template and template_exists(template):
471        doc = Document(template)
472    else:
473        doc = Document()
474
[15463]475    ensure_paragraph_styles(doc)
476
477    add_review_info_to_table(env, doc, review_id)
478    add_reviewers_to_table(env, doc, review_id)
479
480    file_info = get_file_info(env, review_id)
[17414]481    add_file_info_to_table(env, doc, file_info)
[15463]482    add_file_data(env, doc, file_info)
483
[17414]484    set_core_properties(doc)
[15463]485    buff = io.BytesIO()
486    doc.save(buff)
[15692]487
488    # Change custom properties
489    out_buff = io.BytesIO()
490
491    zin = zipfile.ZipFile(buff, 'r')
492    with zipfile.ZipFile(out_buff, 'w') as zout:
493        for item in zin.infolist():
494            buf = zin.read(item.filename)
495            if item.filename == 'docProps/custom.xml':
496                zout.writestr("docProps/custom.xml",
497                              set_custom_doc_properties(zin, data['review']))
[15695]498            elif item.filename == 'docProps/core.xml':
499                zout.writestr("docProps/core.xml",
500                              set_core_doc_properties(zin, data))
[15692]501            else:
502                zout.writestr(item, buf)
503
[17414]504    return out_buff.getvalue()
505
506
507def add_filelist_to_table(env, doc, file_info):
508    """Find or create file information table and add file info.
509
510    @param doc: python-docx Document object
511    @param file_info: list of files as a namedtuple
512    @return: None
513    :param env:
514    """
515    def get_file_table(doc):
516        for table in doc.tables:
517            row = table.rows[-1]
518            if len(row.cells) > 1 and row.cells[1].text == u'$FILEPATH$':
519                return table
520
521        # Table not found, add it. This may be an empty template
522        cell_data = [['ID', 'Path', 'Hash', 'Revision', 'Status'],
523                     ['', '$FILEPATH$', '', '', '']
524                     ]
525        doc.add_heading(u'Files', level=1)
526        tbl = doc.add_table(len(cell_data), len(cell_data[0]))
527        for idx, data in enumerate(cell_data):
528            for i, val in enumerate(data):
529                tbl.rows[idx].cells[i].text = val
530        return tbl
531
532    repodict = get_repository_dict(env)
533
534    table = get_file_table(doc)
535    if file_info and table:
536        cells = table.rows[-1].cells
537
538        for idx, (file, status) in enumerate(file_info):
539            try:
540                try:
541                    prefix = repodict[file.repo]['url'].rstrip('/')
542                except KeyError:
543                    prefix = ''
544                if idx > 0:
545                    cells = table.add_row().cells
546                cells[0].text = str(file.file_id)
547                cells[1].text = prefix + file.path
548                cells[2].text = file.hash or ''
549                cells[3].text = file.changerev
550                cells[4].text = status or ''
551            except IndexError:  # May happen if the template misses some table columns
552                pass
553
554
555def create_docx_for_filelist(env, data, template):
556    """Create a Word document from the given template holding the list of files.
557
558    :param env: Trac environment object
559    :param data: dictionary with information about the review
560    :param template: path to docx template
561    :return: docx file data
562    """
563    def template_exists(tpath):
564        if not tpath:
565            return False
566        if os.path.isfile(tpath):
567            return True
568        return False
569
570    if template and template_exists(template):
571        doc = Document(template)
572    else:
573        doc = Document()
574
575    ensure_paragraph_styles(doc)
576
577    #add_review_info_to_table(env, doc, review_id)
578    #add_reviewers_to_table(env, doc, review_id)
579    files = data['files']
580    add_filelist_to_table(env, doc, files)
581
582    set_core_properties(doc)
583    buff = io.BytesIO()
584    doc.save(buff)
585
586    # Change custom properties
587    out_buff = io.BytesIO()
588
589    zin = zipfile.ZipFile(buff, 'r')
590    with zipfile.ZipFile(out_buff, 'w') as zout:
591        for item in zin.infolist():
592            buf = zin.read(item.filename)
593            if item.filename == 'docProps/custom.xml':
594                zout.writestr("docProps/custom.xml",
595                              set_custom_doc_properties(zin))
596            elif item.filename == 'docProps/core.xml':
597                zout.writestr("docProps/core.xml",
598                              set_core_doc_properties(zin, data))
599            else:
600                zout.writestr(item, buf)
601
602    return out_buff.getvalue()
Note: See TracBrowser for help on using the repository browser.