1#!/usr/bin/env python3
2
3"""Table widget."""
4
5import re
6from collections import OrderedDict
7from collections import namedtuple
8import logging
9
10from chart.events.db import find_events
11from chart.common.prettyprint import Table
12from chart.reports.widget import Widget
13from chart.reports.widget import WidgetOption
14from chart.reports.widget import WidgetOptionChoice
15from chart.common.util import coalesce
16from chart.events.event import Event
17
18logger = logging.getLogger()
19
20
21def render_with_template(obj, template) -> str:
22 """Apply Django `template` to `obj`."""
23 if isinstance(obj, Event):
24 # exclude_description stops the function from computing it's own {{description}}
25 # attribute, which can interfere with instance properties called description
26 return obj.render_with_template(template, exclude_description=True)
27
28 raise ValueError('Only Event rendering implemented')
29
30
31def get_attribute(obj, name) -> str:
32 """Retrieve data member `name` from object `obj` using a reasonable method."""
33 if isinstance(obj, Event):
34 return obj.render_with_template('{{{{{name}}}}}'.format(name=name),
35 exclude_description=True)
36
37 raise ValueError('Only Event rendering implemented')
38
39
40class TableWidget(Widget):
41 """Draw a table of events or other data.
42
43 A number of <columns> can be defined containing elements:
44
45 <name>
46 Allows a <renderer> defined inside a <source> to refer back to this column. Also used
47 as the table header text if <label> is omitted.
48 <label>
49 Text for the column header.
50 <description>
51 An optional column tooltip.
52 <template>
53 A Django template applied to each piece of data to render the text for this column. Can
54 be overridden using <render> elements inside individual <source> elements.
55 <sort>
56 Set to "ascending" to sort on this column.
57
58 And a number of data <sources> can be given with:
59
60 <event>
61 This element can be repeated to list all the event class names to be retrieved
62 <condition>
63 A template which if it evaluates to string "drop", this row is removed from the final output.
64 Or if evaluated to "keep", keep the row and stop processing.
65 <render>
66 One or more render conditions, each one containing a <column> element specifying the column
67 name to render and <template> for the template itself.
68 <keep>
69 Specify a different <template> response that means we keep the packet. Is a regular expression.
70 <drop>
71 Specify a different <template> response that means we keep the packet. Is a regular expression.
72
73 A template is a Django template. For events this can use items:
74
75 {{start}}
76 Start time
77 {{stop}
78 Stop time
79 {{description}}
80 Event class <description> text
81 {{classname}}
82 Event class name
83 {{duration}}
84 Length of the event
85 {{properties}}
86 Instance properties dictionary, i.e. {{properties[abc]}} to access individual properties
87 {{start_orbit}}
88 Orbit number at start of event, for LEO projects
89 {{stop_orbit}}
90 Orbit number at end of event, for LEO projects
91 {{latitude}}
92 Latitude at start of event, for LEO projects
93 {{longtude}}
94 Longitude at start of event, for LEO projects
95 """
96 # This list above shouldn't be needed, but the build in widgets gallery isn't displaying
97 # options for this widget properly
98
99 # Not implemented: If similar events occur immediately after each other they are squished into
100 # a single event.
101
102 name = 'table'
103
104 image = 'widgets/eventslist.png'
105 thumbnail = 'widgets/eventslist_sm.png'
106 # url = settings.CORE_WIKI_URL + '/EventsListWidget'
107
108 options = [
109 WidgetOption(name='title',
110 datatype=str,
111 description='Title for the table',
112 optional=True),
113 # WidgetOption(name='empty-message',
114 # datatype=str,
115 # default='No data found',
116 # description='Message to show instead of table if empty'),
117 # WidgetOption(name='squash',
118 # description='Set squash strategy for merging similar rows')
119 WidgetOption(
120 name='column',
121 multiple=True,
122 description='Define each single table column',
123 datatype=[
124 WidgetOption(
125 name='name',
126 datatype=str,
127 description=('Data source for this column, either directly from the data '
128 'source or from a column element below')),
129 WidgetOption(name='label',
130 datatype=str,
131 optional=True,
132 description='Display name for the column, if different to name'),
133 WidgetOption(name='template',
134 datatype=str,
135 optional=True,
136 description=('Optional default template for this column. Can be '
137 'overridden on a per-source basis by <template> elements '
138 'inside <render> elements inside individual <sources>')),
139 WidgetOption(
140 name='description',
141 datatype=str,
142 optional=True,
143 description='Additional description shown as tooltip (not implemented)'),
144 WidgetOption(name='sort',
145 optional=True,
146 datatype=str,
147 choices=[WidgetOptionChoice(name='ascending'),
148 WidgetOptionChoice(name='descending')]),
149 # WidgetOption(
150 # name='squash',
151 # datatype=bool,
152 # default=False,
153 # description=('Omit the entire row if the value of this column matches the '
154 # 'value in the previous row')),
155 ]),
156 WidgetOption(
157 name='source',
158 multiple=True,
159 description='Declare on input data source',
160 datatype=[
161 WidgetOption(name='event',
162 multiple=True,
163 datatype='eventclass'),
164 WidgetOption(name='field',
165 datatype='field',
166 multiple=True),
167 WidgetOption(
168 name='condition',
169 multiple=True,
170 description=('Django template. If it evaluates to "False" the '
171 'row is dropped'),
172 datatype=[
173 WidgetOption(
174 name='template',
175 datatype=str,
176 description=('Template to evaluate against this row to see if it '
177 'matches the keep or drop keywords')),
178 WidgetOption(
179 name='keep',
180 datatype=str,
181 default='keep',
182 description=('If <condition> matches this regular expression, keep the row')),
183 WidgetOption(
184 name='drop',
185 datatype=str,
186 default='drop',
187 description=('If <condition> matches this regular expression, drop the row')),
188 ]),
189 WidgetOption(name='render',
190 description='Optional template',
191 multiple=True,
192 datatype=[
193 WidgetOption(name='column',
194 datatype=str),
195 WidgetOption(name='template',
196 datatype=str),
197 ]),
198 ]),
199 ]
200
201 document_options = OrderedDict([
202 ('sid', {'type': 'string'}),
203 ('sensing-start', {'type': 'datetime'}),
204 ('sensing-stop', {'type': 'datetime'})])
205
206 def html(self, document):
207 """Render ourselves as HTML."""
208 dc = document.config
209 c = self.config
210 html = document.html
211
212 sid = dc['sid']
213 sensing_start = dc['sensing-start']
214 sensing_stop = dc['sensing-stop']
215
216 columns = c['column']
217
218 # For each source object (Event, database row, CSV line) record an entry consisting
219 # of `data` (the actual object) and `source` (the source dictionary that specified it)
220 RawItem = namedtuple('RawItem', 'data source')
221 raw_data = []
222
223 # Go through each source and assemble a big list of raw data (Event object, cursor rows,
224 # or others) into `raw_data`
225 drop_count = 0
226 for source in c['source']:
227 # logger.info('Processing source')
228 if 'event' in source:
229 logger.info('Looking for events')
230 events = list(find_events(sid=sid,
231 start_time=sensing_start,
232 stop_time=sensing_stop,
233 event_class=source['event']))
234 # logger.info('Found event' + str(events))
235 for e in events:
236 # Apply any filters
237 filtered = False
238 if 'condition' in source:
239 # We process each filter in turn until one matches
240 for cond in source['condition']:
241 if 'template' not in cond:
242 raise ValueError('Condition element lacks template element')
243
244 result = e.render_with_template(cond['template'])
245 if re.match(cond['keep'], result):
246 # If a condition template becomes KEEP, we stop processing and
247 # retain this row
248 break
249
250 if re.match(cond['drop'], result):
251 # If any evaluate to DROP we remove the line without checking
252 # further
253 filtered = True
254 break
255
256 # If any filter was triggered, remove this event
257 if filtered:
258 drop_count += 1
259 continue
260
261 raw_data.append(RawItem(data=e, source=source))
262
263 logging.info('Rendering {cc} items after dropping {dd}'.format(
264 cc=len(raw_data), dd=drop_count))
265
266 # Prepare the final result
267 table = Table(title=c['title'],
268 headings=[coalesce(s.get('label'), s.get('name')) for s in columns])
269
270 # For every piece of data, process it into a table row
271 for raw_item in raw_data:
272 row = []
273 # Compute a value for each column in the table by matching the column <name>
274 # attribute to whatever we can find in the source data
275 for column in columns:
276 cell = None
277 # First priority is if the <source> element had a <renderer> for this column
278 if 'render' in raw_item.source:
279 for r in raw_item.source['render']:
280 if r['column'] == column['name']:
281 cell = render_with_template(raw_item.data, r['template'])
282
283 # Next see if the <column> has a <template>
284 if cell is None and 'template' in column:
285 cell = render_with_template(raw_item.data, column['template'])
286
287 if cell is None:
288 # Otherwise see if the object has anything matching the column <name>
289 cell = get_attribute(raw_item.data, column['name'])
290
291 row.append(cell)
292
293 table.append(row)
294
295 # hack the row sorting.
296 # This doesn't implement any choice of ascending/descending,
297 # definitely not the full flexibility the options allow
298 # sort_columns = []
299 sort_column = None
300 for cc, column in enumerate(columns):
301 if 'sort' in column:
302 # sort_columns.append(cc)
303 sort_column = cc
304
305 if sort_column is not None:
306 # table.rows.sort(key=itemgetter(*sort_columns))
307 table.sort_alphabetically(column=sort_column)
308
309 table.write_html(html)