source: peerreviewplugin/tags/0.12/3.1/codereview/docx_export.py

Last change on this file was 16451, checked in by Ryan J Ollos, 6 years ago

Fix indentation

File size: 18.6 KB
Line 
1# -*- coding: utf-8 -*-
2
3
4import datetime
5import io
6import os
7import zipfile
8from collections import defaultdict
9from lxml import etree as et
10from repo import get_repository_dict
11from trac.util.datefmt import format_date
12from trac.util.text import to_unicode, to_utf8
13from trac.versioncontrol.api import RepositoryManager
14from trac.versioncontrol.web_ui.util import get_existing_node
15from .model import PeerReviewModel, PeerReviewerModel, ReviewCommentModel, ReviewFileModel
16from peerReviewPerform import file_data_from_repo
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$'],
109                     ['Date', '$DATE$'],
110                     ['Followup from', '$FOLLOWUP$']
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'])
137                elif cell.text == u'$FOLLOWUP$':
138                    cell.text = str(rev_info['parent_id']) if rev_info['parent_id'] > 0  else '---'
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:
184        try:
185            row.cells[0].text = reviewers[0]['reviewer']
186            row.cells[1].text = reviewers[0]['status']
187        except IndexError:
188            pass
189        for idx, reviewer in enumerate(reviewers[1:]):
190            cells = table.add_row().cells
191            try:
192                cells[0].text = reviewer['reviewer']
193                cells[1].text = reviewer['status']
194            except IndexError:
195                pass
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
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
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
236def add_file_info_to_table(env, doc, review_id, file_info):
237    """Find or create file information table and add file info.
238
239    @param doc: python-docx Document object
240    @param review_id: id of this review
241    @param file_info: list of PeerReviewFile objects
242    @return: None
243    :param env:
244    """
245    def get_file_table(doc):
246        for table in doc.tables:
247            row = table.rows[-1]
248            if len(row.cells) > 1 and row.cells[1].text == u'$FILEPATH$':
249                return table
250
251        # Table not found, add it. This may be an empty template
252        cell_data = [['ID', 'Path', 'Hash', 'Revision', 'Comments', 'Status'],
253                     ['', '$FILEPATH$', '', '', '', '']
254                     ]
255        doc.add_heading(u'Files', level=1)
256        tbl = doc.add_table(len(cell_data), len(cell_data[0]))
257        for idx, data in enumerate(cell_data):
258            for i, val in enumerate(data):
259                tbl.rows[idx].cells[i].text = val
260        return tbl
261
262    def get_num_comments(comments):
263        """Count total number of comments for a file given a dict with comment information.
264
265        @param comments: dict with key: line number, value: comments for this line
266        @return: total number of comments
267        """
268        num = 0
269        for value in comments.items():
270            num += len(value)
271        return num
272
273    repodict = get_repository_dict(env)
274
275    table = get_file_table(doc)
276    if file_info and table:
277        cells = table.rows[-1].cells
278
279        for idx, item in enumerate(file_info):
280            try:
281                try:
282                    prefix = repodict[item['repo']]['url'].rstrip('/')
283                except KeyError:
284                    prefix = ''
285                if idx > 0:
286                    cells = table.add_row().cells
287                cells[0].text = str(item['file_id'])
288                cells[1].text = prefix + item['path']
289                cells[2].text = item['hash'] or ''
290                cells[3].text = item['revision']
291                cells[4].text = str(get_num_comments(item['comments']))
292                cells[5].text = item['status'] or ''
293            except IndexError:  # May happen if the template misses some table columns
294                pass
295
296def get_file_data(env, f_info):
297    """Get file content from repository.
298
299    @param env: Trac environment object
300    @param f_info: PeerReviewFile object
301    @return: file data for file specified by f_info
302    """
303    f_data = []
304    repos = RepositoryManager(env).get_repository(f_info['repo'] or '')
305    if not repos:
306        return f_data
307
308    rev = f_info['revision']
309    if rev:
310        rev = repos.normalize_rev(rev)
311    rev_or_latest = rev or repos.youngest_rev
312    node = get_existing_node(env, repos, f_info['path'], rev_or_latest)
313    return file_data_from_repo(node)
314
315
316def create_comment_tree(comments):
317    comms = {}
318    for c in comments:
319        comms[c['comment_id']] = c
320
321    all_keys = comms.keys()
322    for key in all_keys:
323        c = comms[key]
324        if c['parent_id'] != -1 and c['parent_id'] in comms and c['parent_id'] != c['comment_id']:
325            children_dict = comms[c['parent_id']]['children']
326            children_dict[c['comment_id']] = c
327
328    # Remove all comments without parent from root. These are still referenced in children dicts of some parent.
329    for key in all_keys:
330        c = comms[key]
331        if c['parent_id'] != -1:
332            del comms[key]
333
334    return comms
335
336
337def print_comment(par, comment, indent=0):
338    """Add the given comment to the docx file.
339
340    @param par: pythond-docx Paragraph. Comment will be inserted right before it.
341    @param comment: ReviewComment object
342    @param indent: number of tabs used for indenting
343    @return: None
344    """
345    header = u"ID: %s: \t%s,\tAutor: %s" % (comment['comment_id'], format_date(comment['created']),
346                                            comment['author'])
347    par.insert_paragraph_before(u"\t"*indent + header, style=u"Reviewcommentinfo")
348    par.insert_paragraph_before(u"\t"*indent + comment['comment'], style=u"Reviewcomment")
349    children = comment['children']
350    items = [c for id, c in children.items()]
351    items = sorted(items, key=lambda item: item['comment_id'])
352    for c in items:
353        print_comment(par, c, indent + 1)
354
355
356def add_file_data(env, doc, file_info):
357    """Add file content and associated comments to document.
358
359    The content of the given file is added to the document using the paragraph style 'Code'.
360    The position in the document is specified by the string $FILECONTENT$ which must be in the
361    template. If this string can't be found the data is appended to the end of the document.
362    Note that a header with style 'Heading 2' will be added with the file path.
363
364    @param env: Trac environment object
365    @param doc: python-docx Document object
366    @param file_info: PeerReviewFile object holding information about a file
367    @return: None
368    """
369    def get_fileinfo_paragraph(doc):
370        for par in doc.paragraphs:
371            if u"$FILECONTENT$" in par.text:
372                return par
373        par = doc.add_paragraph(u"$FILECONTENT$")
374        return par
375
376    par = get_fileinfo_paragraph(doc)
377    if par:
378        for item in file_info:
379            comments = item['comments']
380            par.insert_paragraph_before(item['path'], style=u"Heading 2")
381            f_data = get_file_data(env, item)
382            line_nr = 0
383            for line in f_data:
384                line_nr += 1
385                par.insert_paragraph_before("%s: %s" % (str(line_nr).rjust(4, ' '), to_unicode(line)),
386                                            style=u'Code')
387                if line_nr in comments:
388                    par.insert_paragraph_before()
389                    comm_tree = create_comment_tree(comments[line_nr])
390                    items = [c for id, c in comm_tree.items()]
391                    items = sorted(items, key=lambda item: item['comment_id'])
392                    for comment in items:
393                        print_comment(par, comment)
394                    par.insert_paragraph_before()
395
396        par.text = u""  # Overwrite marker text
397
398
399def set_custom_doc_properties(zin, review):
400    def set_element_txt(elm, txt):
401        e = dom.xpath("//p:property[@name='%s']" % elm,
402                      namespaces={'p': 'http://schemas.openxmlformats.org/officeDocument/2006/custom-properties'})
403        if e:
404            try:
405                e[0][0].text = txt
406            except IndexError:
407                pass
408
409    dom = et.fromstring(zin.read("docProps/custom.xml"))
410
411    set_element_txt(u'VersionDate', datetime.datetime.today().strftime("%d.%m.%Y"))
412    # Set document id
413    set_element_txt(u'Dokumentnummer', '0')
414    set_element_txt(u'MCNummer', review['project'])
415    set_element_txt(u'VersionMajor', u'1')
416    set_element_txt(u'VersionMinor', u'0')
417
418    return et.tostring(dom)
419
420
421def set_core_properties(doc, data):
422    from datetime import datetime
423
424    props = doc.core_properties
425    props.created = datetime.now()
426
427
428def set_core_doc_properties(zin, data):
429
430    review = data['review']
431
432    dom = et.fromstring(zin.read("docProps/core.xml"))
433
434    e = dom.find("{http://purl.org/dc/elements/1.1/}creator")
435    if e is not None:
436        e.text = review['owner']
437    e = dom.find("{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}lastModifiedBy")
438    if e is not None:
439        e.text = review['owner']
440
441    e = dom.find("{http://purl.org/dc/elements/1.1/}subject")
442    if e is not None:
443        e.text = data['subject']
444
445    e = dom.find("{http://purl.org/dc/elements/1.1/}title")
446    if e is not None:
447        e.text = data['title']
448
449    e = dom.find("{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}revision")
450    if e is not None:
451        e.text = '1'
452
453    return et.tostring(dom)
454
455
456def create_docx_for_review(env, data, template):
457    """
458
459    :param env: Trac environment object
460    :param data: dictionary with information about the review
461    :param template: path to docx template
462    :return: docx file data
463    """
464    def template_exists(tpath):
465        if not tpath:
466            return False
467        if os.path.isfile(tpath):
468            return True
469        return False
470
471    review_id = data['review_id']
472    if template and template_exists(template):
473        doc = Document(template)
474    else:
475        doc = Document()
476
477    ensure_paragraph_styles(doc)
478
479    add_review_info_to_table(env, doc, review_id)
480    add_reviewers_to_table(env, doc, review_id)
481
482    file_info = get_file_info(env, review_id)
483    add_file_info_to_table(env, doc, review_id, file_info)
484    add_file_data(env, doc, file_info)
485
486    set_core_properties(doc, data)
487    buff = io.BytesIO()
488    doc.save(buff)
489
490    # Change custom properties
491    out_buff = io.BytesIO()
492
493    zin = zipfile.ZipFile(buff, 'r')
494    with zipfile.ZipFile(out_buff, 'w') as zout:
495        for item in zin.infolist():
496            buf = zin.read(item.filename)
497            if item.filename == 'docProps/custom.xml':
498                zout.writestr("docProps/custom.xml",
499                              set_custom_doc_properties(zin, data['review']))
500            elif item.filename == 'docProps/core.xml':
501                zout.writestr("docProps/core.xml",
502                              set_core_doc_properties(zin, data))
503            else:
504                zout.writestr(item, buf)
505
506    return out_buff.getvalue()
Note: See TracBrowser for help on using the repository browser.