Coverage for postrfp/buyer/api/endpoints/reports/msword.py: 100%
97 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1import re
2import logging
3from pathlib import Path
4from itertools import tee
6from docx import Document
7from sqlalchemy.orm import subqueryload, Session
9from postrfp import conf
10from postrfp.authorisation import perms
11from postrfp.model.questionnaire.answering import Answer
12from postrfp.shared import fetch
13from postrfp.shared.decorators import http
14from postrfp.buyer.api import authorise
15from postrfp.model import QuestionInstance, QElement, Project, User, Issue, Section
16from postrfp.shared.response import XAccelTempResponse, XAccelResponse
17from postrfp.shared.constants import MimeTypes
20TEMPLATE_DOCX = "assets/q-template.docx"
21log = logging.getLogger(__name__)
24def is_comments_label(label_text):
25 if label_text is None:
26 return False
27 patt = r"(comments|qualifications)"
28 match = re.search(patt, label_text, re.IGNORECASE) is not None
29 return match and len(label_text) < 50
32def render_paragraph_question(doc, question, answers):
33 elements, following = tee(question.question_def.elements)
34 next(following, None)
36 for element in elements:
37 next_el = next(following, None)
38 if is_comments_label(element.label):
39 if next_el.id not in answers:
40 # Don't print comments / quals label or answer
41 # if Not Answered
42 next(elements, None)
43 next(following, None)
44 continue
45 if answers and element.is_answerable:
46 if element.id in answers:
47 answer = answers[element.id].answer
48 else:
49 answer = "Not Answered"
50 doc.add_paragraph(answer, style="Answer")
51 else:
52 doc.add_paragraph(element.label)
55def render_table_question(doc, question, answers):
56 """
57 TODO - doesn't handle multi column tables
58 """
59 col_count = question.question_def.column_count
60 table = doc.add_table(rows=1, cols=col_count)
61 current_row = -1
62 row_cells = None
63 for el in question.question_def.elements:
64 if el.row > current_row:
65 row_cells = table.add_row().cells
66 cell = row_cells[el.col - 1]
67 if el.colspan > 1:
68 try:
69 # fix broken colspans
70 right_col_index = (el.col - 1) + el.colspan
71 if right_col_index > (col_count - 1):
72 right_col_index = col_count - 1
73 other_cell = row_cells[right_col_index]
74 cell.merge(other_cell)
75 except IndexError: # pragma: no cover
76 log.warning(
77 "Fix colspan failed for question instance ID %s", question.id
78 )
79 if answers and el.is_answerable:
80 if el.id in answers:
81 answer = answers[el.id].answer
82 else:
83 answer = "- Not Answered -"
84 cell.add_paragraph(answer, style="Answer")
85 else:
86 cell.add_paragraph(el.label)
87 current_row = el.row
90def _el_answer_lookup(project: Project, issue: Issue, user: User, session):
91 authorise.check(
92 user,
93 perms.ISSUE_VIEW_ANSWERS,
94 project=project,
95 issue=issue,
96 section_id=project.section_id,
97 )
99 answer_query = (
100 session.query(Answer.element_id, Answer.answer)
101 .join(QElement)
102 .join(QuestionInstance)
103 .filter(Answer.issue == issue, QuestionInstance.project == project)
104 )
106 return {a.element_id: a for a in answer_query}
109@http
110def get_project_report_msword(
111 session: Session, user: User, project_id: int, q_issue_id: int | None = None
112) -> XAccelResponse:
113 """
114 Generate an MS Word report for the quesionnaire associated with the given Project ID.
115 If an issue ID is provided the answers are populated for that Issue.
116 """
117 project = fetch.project(session, project_id)
118 authorise.check(user, perms.PROJECT_ACCESS, project=project, deny_restricted=True)
120 sections = (
121 fetch.sections(project, user).options(subqueryload(Section.questions)).all()
122 )
124 if q_issue_id is not None:
125 issue = project.get_issue(q_issue_id)
126 answers = _el_answer_lookup(project, issue, user, session)
127 fname = "{0}-{1}.docx".format(project.title[:20], issue.respondent.name[:15])
128 else:
129 answers = {}
130 fname = "{0}.docx".format(project.title[:25])
132 doc_path = Path(__file__).parent.joinpath(TEMPLATE_DOCX)
133 doc = Document(docx=str(doc_path))
135 for section in sections:
136 if section.is_top_level:
137 doc.add_heading(sections[0].title, 0)
138 else:
139 heading_text = "{} {}".format(section.number, section.title)
140 level = section.number.count(".") + 1
141 doc.add_heading(heading_text, level)
142 for question in section.questions:
143 q_title = "{} {}".format(question.number, question.title)
144 doc.add_paragraph(q_title, style="QuestionTitle")
145 if question.question_def.is_tabular():
146 render_table_question(doc, question, answers)
147 else:
148 render_paragraph_question(doc, question, answers)
150 cache_file_name, cache_file_path = conf.CONF.random_cache_file_path()
152 cache_dir = Path(cache_file_path)
153 cache_dir.parent.mkdir(parents=True, exist_ok=True)
154 with open(cache_file_path, "wb+") as fp:
155 doc.save(fp)
157 return XAccelTempResponse(cache_file_name, fname, content_type=MimeTypes.DOCX)