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)