1#!/usr/bin/env python3
2
3"""Represent an HTML report being constructed by a report template."""
4
5import os
6import shutil
7import logging
8import collections
9from io import StringIO
10from datetime import datetime
11
12from django.template.loader import render_to_string
13
14from chart.common.path import Path
15from chart.project import settings
16from chart.common.exceptions import ConfigError
17from chart.common import traits
18from chart.common.prettyprint import Table
19from chart.common.xml import to_html
20from chart.common.xml import xml_to_str
21from chart.common.prettyprint import show_time
22from chart.products.scos2000.srdb_version_info import get_srdb_version
23
24
25# class Debugstringio(object):
26# """Report debugging help.
27# This version of StringIO logs it's contents to `logging.debug` as they are entered.
28# """
29
30# def __init__(self):
31# self.imp = StringIO()
32
33# def write(self, mess):
34# """Log a string and write it to the internal string object."""
35# logging.debug(mess)
36# #super(debugstringio, self).write(m)
37# self.imp.write(mess)
38
39# def getvalue(self):
40# """Return the internal string object."""
41# return self.imp.getvalue()
42
43
44class Document:
45 """An HTML report."""
46
47 def __init__(self):
48 # name of the starting point for this report i.e. report.html
49 self.report_filename = None
50
51 # content of html title element
52 self.title = None
53
54 # build the final html as a StringIO-like object
55 self.html = None
56 self.htmls = None
57
58 # document configuration options
59 self.config = {}
60
61 # list of heading text
62 self.headings = []
63
64 # list of subheadings within current heading
65 self.subheadings = []
66
67 # list of figures (titles only)
68 self.figures = []
69
70 # document application specific options
71 self.globals = {}
72
73 # during pre-html pass track current heading number
74 self.heading_cc = None
75
76 # during pre-html pass track subheading number within current heading
77 self.subheading_cc = None
78
79 # current figure number
80 self.figure_count = None
81
82 # list of all generated aux files. These are the only files that get included
83 # in the final .zip
84 self.aux_files = []
85
86 # Used to generate unique filenames for images. We map widget names or hashes
87 # against the current count of similar things
88 self.anon_counts = collections.defaultdict(int)
89
90 # list of image filenames used by the LoF widget
91 self.images = None
92
93 # set to the current theme module while we are being instantiated
94 self.theme = None
95
96 # if true, instructs some widgets to dump their underlying data set as a .csv file
97 # in the report directory
98 self.dump_values = None
99
100 # report template object being used to render us
101 self.template = None
102
103 def render_html(self, widgets):
104 """Render each of `widgets` to our `self.html`.
105 This is called by the digest algorithm which captures the output for HTML email.
106 """
107
108 for widget in widgets:
109 logging.info('Processing {widget}'.format(widget=widget))
110 self.html = StringIO()
111 widget.pre_html(self) # should be removed completely now we have 1-pass rendering
112 try:
113 widget.html(self)
114 except BaseException:
115 logging.error('Error rendering {elem}'.format(elem=xml_to_str(widget.elem)))
116 raise
117
118 self.htmls.append(self.html)
119 self.html = None
120
121 for widget in widgets:
122 widget.post_html(self)
123
124 def render(self, template, debug=False, dump_values=False, sid=None):
125 """Use `template` to instantiate our `html` member variable.
126
127 Args:
128 `template` (ReportTemplate): Report template to instantiate.
129 `debug` (bool): ?
130 `dump_values` (bool): For supported widgets, incorporate a .csv
131 file containing the underlying data used to render the widget
132
133 Returns:
134 Report top level HTML as a string.
135 """
136 self.template = template
137 self.dump_values = dump_values
138
139 # if debug is True:
140 # self.html = Debugstringio()
141
142 # else:
143 # self.html = StringIO()
144
145 self.htmls = []
146
147 # Count of the headings created so far (for heading numbering and ToC)
148 self.heading_cc = 0
149
150 # Count of subheadings withing the current heading
151 self.subheading_cc = 0
152
153 # Count of figures
154 self.figure_count = 0
155
156 # List of image filenames
157 self.images = []
158
159 self.theme = template.theme
160
161 # Copy over any static files required by the theme
162 theme_dir = Path(template.theme.__file__).parent
163 for filename in template.theme.embed:
164 # logging.debug('Embedding theme file {filename}'.format(filename=filename))
165 shutil.copy(os.path.join(str(theme_dir), str(filename)), '.')
166 self.aux_files.append(filename)
167
168 # temporary hack - sensing-start should be used, but many widgets
169 # still refer to sensing_start and this avoids breaking them
170 if 'sensing-start' in self.config:
171 self.config['sensing_start'] = self.config['sensing-start']
172
173 if 'sensing-stop' in self.config:
174 self.config['sensing_stop'] = self.config['sensing-stop']
175
176 # build the list of widgets to be rendered
177 widgets = template.widgets
178
179 # remove any that fail the <scid> condition
180 for i in range(len(widgets) - 1, -1, -1):
181 # logging.debug('Testing widget ' + str(widgets[i]))
182 if not widgets[i].cond.test(self.config):
183 logging.debug('Condition test deleting widget {w}'.format(w=widgets[i]))
184 del widgets[i]
185
186 # for w in widgets:
187 # logging.info('Preprocessing {widget}'.format(widget=w))
188 # if getattr(w, 'pre_html').__func__ != getattr(Widget, 'pre_html').__func__:
189 # logging.debug('Preprocessing ' + w.__class__.__name__)
190 # w.pre_html(self)
191
192 self.render_html(widgets)
193 self.htmls.insert(0, template.theme.preamble.format(
194 title=' <title>{title}</title>\n'.format(
195 title=self.title if self.title is not None else ''),
196 mathjax=template.theme.mathjax if template.enable_maths else '',
197 STATIC_URL=settings.STATIC_URL))
198
199 if template.theme.postscript:
200 # get the srdb version to include in the postscript
201 self.htmls.append(template.theme.postscript.format(
202 gentime=show_time(datetime.utcnow()),
203 template=template.name,
204 srdb=get_srdb_version(sid=self.config['sid'])))
205
206
207 def write(self, handle):
208 """After the document has been rendered, write the widgets contents to `handle`."""
209 for h in self.htmls:
210 if isinstance(h, str):
211 handle.write(h)
212 # handle.write(h.encode('latin-1', 'xmlcharrefreplace'))
213
214 else:
215 handle.write(h.getvalue())
216 # handle.write(h.getvalue().encode('latin-1', 'xmlcharrefreplace'))
217
218 def append_figure(self, # (too many args) pylint: disable=R0913
219 filename,
220 width=None,
221 height=None,
222 title=None,
223 zoom_filename=None,
224 zoom_width=None,
225 zoom_height=None,
226 live_url=None,
227 summary_info=None,
228 datapoint_info=None,
229 widget=None):
230 """Add a figure to a report.
231 Will also use jquery fancybox to make a zooming thumbnail effect.
232 Also can create a summary info subpage.
233
234 Args:
235 `filename` (str): Filename of normal image. Optionally, also the filename of
236 thumbnail image in which case browser resizing will be used
237 `width` (int): Width of normal image
238 `height` (int): Height of normal image
239 `title` (str): Figure title. If omitted, this figure does not appear in the LoF
240 `zoom_filename` (str): Zoomed image shown after clicking or in the PDF export.
241 If omitted there is not zooming effect
242 `zoom_width` (int): Width of big image (not actually needed with current lightbox)
243 `zoom_height` (int): Height of big image
244 `live_url` (str): Link to live view of this figure (appears in summary subpage)
245 `summary_info` (Table or str): Figure summary information
246 `datapoint_info` (Table or str): Statistics per datapoint
247 `widget` (Widget): Widget that created the class. We show the source XML
248 and an interpreted table of configs.
249
250 Returns:
251 Nothing. Output is appended to self.html and written to subpages.
252 """
253 # moved down here because of yet another insane AIX bug.
254 # on tcprimus PIL won't import but we are only doing ingestion/stats on tcprimus
255 # so we hide this import away
256 from PIL import Image
257
258 logging.info('Inserting {filename} zoom {zoom_filename}'.format(
259 filename=filename, zoom_filename=zoom_filename))
260
261 # if filename is None:
262 # raise ConfigError('No filename specified')
263
264 # We only place ourselves into the LoF if we have a title set
265 if title is not None:
266 self.figure_count += 1
267
268 # check that full size filename is unique
269 if filename in self.aux_files:
270 raise ConfigError('Duplicate filename in report: {f}'.format(f=filename))
271
272 # check thumbnail filename is unique
273 if zoom_filename in self.aux_files:
274 raise ConfigError('Duplicate thumbnail filename in report: {f}'.format(f=filename))
275
276 if width is None or height is None:
277 width, height = Image.open(str(filename)).size
278
279 # compute thumbnail dims if none supplied
280 # this is safer (if less efficient) since our plot code may not render
281 # the precise requested size
282 if zoom_filename is not None and (zoom_width is None or zoom_height is None):
283 zoom_width, zoom_height = Image.open(str(zoom_filename)).size
284
285 self.aux_files.append(filename)
286 if zoom_filename is not None and zoom_filename != filename:
287 self.aux_files.append(zoom_filename)
288
289 if summary_info is not None and isinstance(summary_info, Table):
290 summary_info = summary_info.to_html_str()
291
292 if datapoint_info is not None and isinstance(datapoint_info, Table):
293 datapoint_info = datapoint_info.to_html_str()
294
295 summary_filename = filename.with_suffix('.html')
296 with summary_filename.open('w') as handle:
297 handle.write(
298 render_to_string(
299 'reports/summary.html',
300 {'preamble': self.theme.preamble.format(title='<title>Summary info</title>',
301 mathjax='',
302 STATIC_URL=settings.STATIC_URL),
303 'title': title,
304 'filename': zoom_filename or filename,
305 'width': (zoom_width or width) // 3, # turn the normal image into a tiny thumbnail
306 'height': (zoom_height or height) // 3,
307 'live_url': live_url,
308 'summary_info': summary_info,
309 'datapoint_info': datapoint_info,
310 'config': widget.make_html_config() if widget is not None else None,
311 # 'csv': None,
312 'elem': to_html(widget.elem) if widget is not None else None,
313 }))
314
315 self.aux_files.append(summary_filename)
316
317 self.html.write(render_to_string(
318 'reports/figure.html',
319 {'title': title,
320 'figure_count': self.figure_count,
321 'filename': filename,
322 'width': width,
323 'height': height,
324 'zoom_filename': zoom_filename,
325 'livelink': live_url,
326 'summarylink': summary_filename}))