1#!/usr/bin/env python3
2
3"""Event class and support utilities."""
4
5import json
6import logging
7import operator
8from datetime import datetime
9from datetime import timedelta
10from fnmatch import fnmatch
11import textwrap
12
13from django.urls import reverse
14from django.template import Template
15from django.template import Context
16
17from chart.common.xml import Element
18from chart.common.xml import SubElement
19from chart.db.connection import db_connect
20from chart.common import traits
21from chart.common.traits import Choices
22from chart.common.prettyprint import show_obj
23from chart.common.xml import datetime_to_xml
24from chart.common.xml import xml_to_datetime
25from chart.common.xml import parsechildstr
26from chart.common.xml import timedelta_to_xml
27from chart.events.eventclass import EventClass
28from chart.events.exceptions import InvalidEvent
29from chart.events.exceptions import NoSuchEventClass
30from chart.project import SID
31from chart.project import settings
32from chart.common.prettyprint import Table
33from chart.common.prettyprint import show_time_ms
34from chart.common.prettyprint import show_time_us
35from chart.products.pus.packetdef import PacketDomain
36from chart.products.pus.packetdef import PacketDef
37from chart.events.eventclass import EventRenderer
38from chart.common.util import nvl
39
40db_conn = db_connect('EVENTS')
41
42logger = logging.getLogger()
43
44ELEM_EVENT_CLASSNAME = 'event-classname'
45ELEM_GEN_TIME = 'gen-time'
46ELEM_START_TIME = 'start-time'
47ELEM_STOP_TIME = 'stop-time'
48ELEM_PROPERTIES = 'properties'
49
50# Special value for rendered telecommand values which could not be or were not calibrated
51UNCALIBRATED = '-'
52
53# Manually break up long parameter values in rendered telecommands
54TABLE_BREAK_CHARS = 50
55
56# User Id Blocked Parameter indicator
57BLOCKED = 'Blocked'
58
59def show_event_time(obj):
60 """Show timestamp `obj` to either 3 or 6 decimal places depending on project settings."""
61 if settings.EVENT_TIMESTAMP_ACCURACY == 'us':
62 return show_time_us(obj)
63
64 return show_time_ms(obj)
65
66
67def include_event_check(properties, inst_properties):
68 """Check to see if event matches include critera, used for filtering in the Eventviewer.
69
70 This is called by the build event functions to interpret the Search boxes at bottom of
71 Eventviewer table.
72
73 Args:
74 `properties`: (list of tuples): Filter results by property match, (name, value, op).
75 `inst_properties`: (dict): Event properties to check
76
77 Supported operators, as defined in eventviewer2/views.py:
78 '!=' op = 'ne'
79 '>=' op = 'ge'
80 '>' op = 'gt'
81 '<=' op = 'le'
82 '<' op = 'lt'
83 'eq' op = 'eq'
84 '=' op = 'eq'
85 'in' op = 'contains'
86 'IN' op = 'contains'
87 op = 'eq' # assume as default.
88 """
89 accept = None
90
91 if properties is not None:
92 for name, search_value, op in properties:
93 search_value = search_value.replace('\n', '')
94 if name not in inst_properties:
95 # accept = False
96 break
97
98 # apply operator
99 if op is operator.eq:
100 accept = fnmatch(str(inst_properties[name]).upper(), search_value.upper())
101
102 elif op is operator.contains or op is operator_contains_not:
103 # order of op arguments switched for contains / contains_not operators
104 accept = op(search_value.upper(), str(inst_properties[name]).upper())
105
106 else:
107 accept = op(str(inst_properties[name]).upper(), search_value.upper())
108
109 if not accept:
110 # do not bother with anymore checks
111 break
112
113 else:
114 accept = True
115
116 return accept
117
118
119def interpret_filter_clause(clause):
120 """Interpret filter clause as typed in by user into the filter field.
121
122 Return a tuple of
123 (property_value, comparison_operator)
124
125 Raises EventViewerException when failed to make sense out of filter clause.
126 """
127 if clause.startswith('!='):
128 start = 2
129 op = getattr(operator, 'ne')
130
131 elif clause.startswith('>='):
132 start = 2
133 op = getattr(operator, 'ge')
134
135 elif clause.startswith('>'):
136 start = 1
137 op = getattr(operator, 'gt')
138
139 elif clause.startswith('<='):
140 start = 2
141 op = getattr(operator, 'le')
142
143 elif clause.startswith('<'):
144 start = 1
145 op = getattr(operator, 'lt')
146
147 elif clause.startswith('eq'):
148 start = 2
149 op = getattr(operator, 'eq')
150
151 elif clause.startswith('='):
152 start = 1
153 op = getattr(operator, 'eq')
154
155 elif clause.startswith('in') or clause.startswith('IN'):
156 start = 3
157 op = getattr(operator, 'contains')
158
159 elif clause.startswith('ni') or clause.startswith('NI'):
160 start = 3
161 # operation does not exist in operators, use custom implementation
162 op = operator_contains_not
163
164 else:
165 start = 0
166 op = getattr(operator, 'eq')
167
168 return clause[start:], op
169
170
171def operator_contains_not(a, b):
172 """Custom implementation of 'contains not' since this operation
173 doesn't exist in built-in operators"""
174 return not b in a
175
176
177class DescribedString:
178 """A special string for passing to Django templates.
179
180 It has it's own value plus a description field so the template can include:
181
182 "The string is {{a}} which means {{a.description}}".
183 """
184
185 def __init__(self, value, description):
186 self.value = value
187 self.description = description
188
189 def __str__(self):
190 return str(self.value)
191
192
193def event_to_json_id(event):
194 """Create a browser-safe string to be stored in column 0 of the event viewer table.
195
196 It must contain all information needed later to re-create this Event for the single
197 event popup dialog."""
198 if event.event_id is None:
199 # Probably a non-EVENT table Event using the multitable support
200 # Timeseries table rows don't have a nice unique ID
201 result = {'time': datetime_to_xml(event.start_time, include_us=True),
202 'cls': event.event_classname,
203 'sid': event.sid.name,
204 'extra': event.multitable_extra_params}
205
206 else:
207 # Standard Events from the EVENTS table have a unique ID
208 result = event.event_id
209
210 return json.dumps(result)
211
212
213def json_id_to_event(id_str, user=None):
214 """Create an Event instance from a browser-friendly json string locator."""
215 # logger.debug('json_id_to_event {id}'.format(id=id_str))
216 id_obj = json.loads(id_str)
217 # Avoid circular import
218 from chart.events.db import find_single_event
219 if isinstance(id_obj, int):
220 # Retrieval from EVENTS table using unique ID
221 return find_single_event(event_id=id_obj, user=user)
222
223 else:
224 # Retrieval from general timeseries table using any unique extra parameters
225 # were previously inserted by the multitable retrieval function
226 return find_single_event(start_time_eq=xml_to_datetime(id_obj['time']),
227 event_class=id_obj['cls'],
228 sid=SID(id_obj['sid']),
229 multitable_extra_params=id_obj['extra'],
230 user=user)
231
232
233class Event:
234 """A class representing an event.
235
236 Instances may be constructed from a database ID, a database query,
237 an XML file or manually.
238 Instances may be written into the database or to an XML file.
239 """
240 def __init__(self,
241 event_classname,
242 scid=None,
243 gen_method=None,
244 start_time=None,
245 gen_time=None,
246 stop_time=None,
247 event_id=None,
248 instance_properties=None,
249 instance_properties_elem=None,
250 multitable_extra_params=None,
251 sid=None):
252 # logger.debug('Create event start {strt} gen {gen}'.format(strt=start_time, gen=gen_time))
253 if sid is None:
254 sid = SID(scid)
255
256 self.sid = sid
257 self.event_id = event_id
258 self.event_classname = event_classname
259 self.gen_method = gen_method
260 self.multitable_extra_params = multitable_extra_params
261 if gen_time is not None:
262 self.gen_time = gen_time
263
264 else:
265 self.gen_time = datetime.utcnow()
266
267 self.start_time = start_time
268 self.stop_time = stop_time
269
270 if instance_properties is None:
271 self.instance_properties = {}
272
273 else:
274 self.instance_properties = instance_properties
275
276 if instance_properties_elem is not None:
277 self.set_instance_properties(instance_properties_elem)
278
279 self._geoloc = None
280
281 def __str__(self):
282 # below is bad is causes a weird unicode error with hktm events
283 # for m02 on 2014-03-22
284 # return 'Start {start} {stop} {desc}'.format(
285 # start=show_obj(self.start_time),
286 # stop='stop {stop} '.format(
287 # stop=show_obj(self.stop_time)) if self.stop_time is not None else '',
288 # desc=self.description())
289 return 'Event<{cls}>({start},{stop})'.format(
290 cls=self.event_class.name, start=self.start_time, stop=self.stop_time)
291
292 def property_pairs(self):
293 """Return a list of property-value pairs.
294
295 Values are returned as strings irrespective of their type.
296 Outputs are intended for display only and exceptionally long strings are truncated.
297 """
298 result = []
299 for prop_name in self.event_class.instance_properties.keys():
300 v = self.instance_properties.get(prop_name)
301 if v is None or v == '':
302 continue
303
304 prop_str = show_obj(v)
305
306 # sometimes the 'PARAM_DATA' or 'PAYLOAD' fields can be very long, in case
307 # of reports etc, and swamp the Eventviewer Table
308 if len(prop_str) > 80:
309 prop_str = prop_str[:70] + ' ...'
310
311 result.append((prop_name, prop_str))
312
313 return result
314
315 @property
316 def instrument(self):
317 """Return events which are associated with a specific instrument if possible.
318
319 Possible return values:
320 * ADCS
321 * AMSU-A1
322 * AMSU-A2
323 * ASCAT
324 * AVHRR
325 * GOME
326 * GRAS
327 * HIRS
328 * IASI
329 * MHS
330 * NIU
331 * SARP
332 * SARR
333 * SEM
334 * None.
335 """
336 return self.event_class.instrument
337
338 def check(self):
339 """Validate this instance."""
340 # Note the algorithm name check assumes that only one activity and only one
341 # algorithm can raise a particular event - not guaranteed to always be true
342
343 if self.start_time is None:
344 raise InvalidEvent(self, 'Start time must be specified')
345
346 try:
347 self.event_class
348 except NoSuchEventClass:
349 raise InvalidEvent(
350 self,
351 'Undefined event class {cls}'.format(cls=self.event_classname))
352
353 if self.stop_time is not None and self.start_time > self.stop_time:
354 raise InvalidEvent(
355 self,
356 'Event failed check - start time is later than stop_time {start}'.format(
357 start=self.start_time))
358
359 for p, v in self.event_class.instance_properties.items():
360 if p not in self.instance_properties and 'optional' not in v:
361 raise InvalidEvent(
362 self,
363 'Required property {prop} is missing from event {event}'.format(
364 prop=p, event=self))
365
366 # Check property types
367 for k, v in self.instance_properties.items():
368 definition = self.event_class.instance_properties.get(k)
369
370 if definition is None:
371 raise InvalidEvent(self, 'Unknown property {name}'.format(name=k))
372
373 if v is None or (isinstance(v, str) and
374 len(v) == 0 and
375 definition.get('optional', False)):
376 # allow optional properties to be omitted
377 continue
378
379 ptype = definition['type']
380 if ptype in ('int', 'uint'):
381 try:
382 int(v)
383 except ValueError:
384 raise InvalidEvent(
385 self,
386 'Bad type of instance property {name} ({value})'.format(
387 name=k, value=v))
388
389 elif ptype == 'datetime' and not isinstance(v, datetime):
390 raise InvalidEvent(
391 self,
392 'Bad type of instance property {name}'.format(name=k))
393
394 elif ptype == 'duration' and not isinstance(v, timedelta):
395 raise InvalidEvent(
396 self,
397 'Bad type of instance property {name}'.format(name=k))
398
399 elif ptype == 'string' and not isinstance(v, str):
400 raise InvalidEvent(
401 self,
402 'Type mismatch for instance property {name}, expected {exp} '
403 'found "{val}" type {act}'.format(name=k,
404 exp=ptype,
405 val=v,
406 act=type(v)))
407 # logger.error('Bad type of instance property ' + prop_name)
408
409 # if any instance properties have <choices> defined, make sure the values in
410 # this event match the allowed values
411 choices = definition.get('choices')
412 if choices is not None:
413 # logger.debug('Looking for selection {val} of choices {choices}'.format(
414 # val=v, choices=', '.join(c.get('name') for c in choices)))
415
416 # event class choice "name" elements give the text for an enumerated value.
417 # "value" elements give the allowed values
418 enum_found = False
419 for c in choices:
420 if v == c.get('value') or ('value' not in c and v == c.get('name')):
421 enum_found = True
422 elif 'use-colour' in c:
423 # has associated parameter, to get colour coding from
424 enum_found = True
425
426 elif v == c.get('name'):
427 # Value previously replaced by valid choice name
428 enum_found = True
429
430 if not enum_found:
431 raise InvalidEvent(
432 self,
433 'Event {cls} has unknown value "{act}" for property {name} '
434 '(expecting one of {choices})'.format(
435 cls=self.event_classname,
436 act=v,
437 name=k,
438 choices=', '.join(c.get('name') for c in choices)))
439
440 def xml_properties(self):
441 """Return our instance properties as an XML element."""
442 props_elem = Element('properties')
443 for pname, pvalue in self.event_class.instance_properties.items():
444 if pname not in self.instance_properties:
445 continue
446
447 prop_elem = SubElement(props_elem, 'property')
448 SubElement(prop_elem, 'name').text = pname
449 instance_value = SubElement(prop_elem, 'value')
450
451 prop_value = self.instance_properties[pname]
452
453 if prop_value is None:
454 instance_value.text = ''
455
456 elif pvalue['type'] == 'datetime':
457 instance_value.text = datetime_to_xml(prop_value, include_us=True)
458
459 elif pvalue['type'] == 'duration':
460 instance_value.text = timedelta_to_xml(prop_value)
461
462 elif pvalue['type'] == 'json':
463 instance_value.text = json.dumps(prop_value)
464 # logger.debug('json encoded to {j} type {t}'.format(
465 # j=instance_value.text, t=type(instance_value.text)))
466
467 elif isinstance(prop_value, str):
468 instance_value.text = prop_value
469
470 else:
471 # elif isinstance(prop_value, str):
472 # Not certain why this was needed...
473 # if isinstance(prop_value, str):
474 # instance_value.text = prop_value.decode(
475 # 'latin-1', 'xmlcharrefreplace')
476
477 # else:
478 try:
479 instance_value.text = str(prop_value)
480 except UnicodeEncodeError:
481 # print('Bad string len ' + str(len(prop_value)))
482 # for cc,i in enumerate(prop_value):
483 # a = prop_value.encode('utf-8')
484 # print(a)
485 # instance_value.text = unicode(prop_value).encode(
486 # 'latin-1', 'xmlcharrefreplace')
487 instance_value.text = prop_value.decode('latin')
488 # instance_value.text = prop_value.encode('utf-8')
489 # this fails running anomaly_message_events
490 except ValueError:
491 # print('Cannot insert ')
492 # print(prop_value)
493 # raise
494 # watch out for bad APT-ANOMALY event on 2013-01-28
495 # also APT-ANOMALY on 2011-08-06
496 try:
497 instance_value.text = str(prop_value)
498 except ValueError:
499 instance_value.text = str(prop_value, 'latin')
500
501 # except ValueError:
502 # try:
503 # instance_value.text = unicode(prop_value).encode('latin-1',
504 # 'xmlcharrefreplace')
505 # except ValueError:
506 # print type(prop_value)
507 # print len(prop_value)
508 # print prop_value
509 # raise
510 # pass
511
512 # else:
513 # InvalidEvent(self, 'Bad property encoding in event id=', self.event_id)
514
515 return props_elem
516
517 def to_xml(self, parent_elem):
518 """Append this instance to an XML file."""
519 event_elem = SubElement(parent_elem, 'event')
520
521 SubElement(event_elem, ELEM_EVENT_CLASSNAME).text = self.event_classname
522 self.sid.to_xml(event_elem)
523 if self.gen_method is not None:
524 SubElement(event_elem, 'gen-method').text = self.gen_method
525
526 SubElement(event_elem, 'gen-time').text = datetime_to_xml(self.gen_time)
527 SubElement(event_elem, 'start-time').text = datetime_to_xml(self.start_time,
528 include_us=True)
529 if self.stop_time is not None:
530 SubElement(event_elem, 'stop-time').text = datetime_to_xml(self.stop_time,
531 include_us=True)
532
533 if len(self.instance_properties) > 0:
534 event_elem.append(self.xml_properties())
535
536 # SR EUM/EPS/NCR/16603.7
537 # fix for missing latitude/longitude in XML export
538 # need to access 'event' element to add lat/long in eventviewer/views.py (xml_export)
539 return event_elem
540
541 @staticmethod
542 def build_from_xml(elem):
543 """Create a single instance of Event from an 'event' node in an XML file."""
544 # logger.debug('Loading event')
545 sid = SID.from_xml(elem.elem)
546 res = Event(event_classname=elem.parse_str(ELEM_EVENT_CLASSNAME),
547 gen_time=elem.parse_datetime(ELEM_GEN_TIME, None),
548 start_time=elem.parse_datetime(ELEM_START_TIME),
549 stop_time=elem.parse_datetime(ELEM_STOP_TIME, None),
550 sid=sid)
551
552 properties_elem = elem.find(ELEM_PROPERTIES)
553 if properties_elem is not None:
554 res.set_instance_properties(properties_elem.elem)
555
556 try:
557 res.check()
558 except InvalidEvent as e:
559 e.elem = elem
560 raise
561
562 return res
563
564 def set_instance_properties(self, elem):
565 """Set instance properties from xml <properties> with correct typing."""
566 self.instance_properties = {}
567 if elem is None:
568 return
569
570 # SubElement(elem, 'operator').text = 'test-operator'
571 # SubElement(elem, 'comment').text = 'comment'
572
573 for prop_elem in elem.findall('property'):
574 # we allow properties in format:
575 #
576 # <property>
577 # <name>key</name>
578 # <value>val</value>
579 # </property>
580 #
581 # or
582 #
583 # <key>val</key>
584 #
585
586 def get_property_type(name):
587 """Find the datatype defined for property `name`."""
588 try:
589 return self.event_class.instance_properties[name]['type']
590 except KeyError:
591 raise InvalidEvent(
592 self,
593 'Unknown property "{name}" in type {cls}'.format(
594 name=name,
595 cls=self.event_classname),
596 elem=elem)
597
598 # if prop_elem.tag == 'property':
599 name = parsechildstr(prop_elem, 'name')
600 try:
601 value = traits.from_xml_child(get_property_type(name),
602 prop_elem,
603 'value',
604 allow_null=True)
605 except ValueError as e:
606 raise InvalidEvent(
607 event=self,
608 message='While reading property {prop}: {message}'.format(
609 prop=name,
610 message=e),
611 elem=prop_elem)
612
613 # print('name {n} {nt} value {v} {vt}'.format(n=name, nt=type(name),
614 # v=value, vt=type(value)))
615
616 # This causes "Unknown property 'operator' error in event viewer
617 # else:
618 # name = prop_elem.tag
619 # if name == 'outage': # ?
620 # continue
621 # value = traits.from_xml(get_property_type(name),
622 # prop_elem)
623
624 # Is this needed? check() does this
625 if (value is None and not
626 self.event_class.instance_properties[name].get('optional', False)):
627
628 raise InvalidEvent(
629 self, 'Missing value for required property {p} in event {e}'.format(
630 p=name, e=self.event_classname))
631
632 self.instance_properties[name] = value
633
634 @property
635 def geoloc(self):
636 """Geolocation getter."""
637 # geoloc imports numpy which really slows down a small command line tool
638 from chart.plots.geoloc import Geoloc
639 from chart.plots.geoloc import CannotGeolocate
640 if self._geoloc is None:
641 try:
642 locator = Geoloc(self.sid,
643 self.start_time,
644 self.start_time)
645 if locator is None:
646 return None
647
648 geo_start_lat_lon = locator.lat_lon(self.start_time)
649 if self.stop_time is not None:
650 geo_stop_lat_lon = Geoloc(self.sid,
651 self.stop_time,
652 self.stop_time).lat_lon(self.stop_time)
653 else:
654 geo_stop_lat_lon = (None, None)
655
656 self._geoloc = {
657 'start_lat': geo_start_lat_lon[0],
658 'start_lon': geo_start_lat_lon[1],
659 'stop_lat': geo_stop_lat_lon[0],
660 'stop_lon': geo_stop_lat_lon[1]}
661
662 except CannotGeolocate:
663 pass
664
665 return self._geoloc
666
667 @property
668 def class_properties(self):
669 """Return a list of class properties for this instance."""
670 return self.event_class.class_properties
671
672 def duration(self):
673 """Compute the duration of this instance as a timedelta object."""
674 if self.stop_time is None:
675 return timedelta(0, 0, 0)
676
677 return self.stop_time - self.start_time
678
679 def description(self, usage=None, html=False): # (unused arg) pylint: disable=W0613
680 """Return text description of this instance by instantiating the class <template>.
681
682 Set `usage` to 'email' to get a longer description more suitable for email text.
683 If needed we could add a <email-template> attribute especially for this case.
684 Other types of `usage` like 'brief' or 'graph-label' could be added.
685 """
686 template = self.event_class.template
687 if template is None:
688 return '{cls} {desc}. {attrs}'.format(
689 cls=self.event_classname,
690 desc=self.event_class.description,
691 attrs=', '.join('{k}: {v}'.format(
692 k=k, v=v) for k, v in self.instance_properties.items()))
693
694 # avoid the infinite loop which could happen if render_with_template called back to here
695 return self.render_with_template(template, exclude_description=True, html=html)
696
697 def render_with_template(self,
698 template,
699 exclude_description=False,
700 html=False):
701 """Apply the Django `template` to ourselves.
702
703 The template can access these items in its context:
704
705 - classname
706 - start_orbit, stop_orbit
707 - start_time, stop_time
708 - Our instance properties
709
710 This is used by the EventsList template as well as here.
711 If `html` is set then translate newlines to <br>.
712 If `html` is not set then convert XML glyphs (<, >) to ascii.
713
714 Normally {{description}} is expanded to a very verbose string with class name,
715 class description and instance parameters. If `use_class_description` is True
716 then {{description}} will simply expand to the event class <description> element.
717
718 TODO: Check if exclude_description if actually useful
719 """
720 # geoloc imports numpy which really slows down a small command line tool
721 from chart.plots.geoloc import Geoloc
722
723 # set up some default properties first - some operator events have 'duration'
724 # properties which can override this
725 context = Context({'settings': settings,
726 'classname': self.event_class.name,
727 'description': self.event_class.description,
728 'sid': self.sid,
729 'start_time': self.start_time,
730 'stop_time': self.stop_time,
731 'duration': self.duration,
732 'properties': {}})
733
734 # Allow the <template> to include instance property values as just {{prop}}
735 # where 'prop' is an instance property name.
736 # If the property has choices define, and the value is one of the defined choices,
737 # and the choice has a description, the template can also include {{prop.description}}.
738 for k, v in self.instance_properties.items():
739 # Django has no way to access dictionary values where the key contains a minus
740 # So we change to underscore and hope no-one is has used that as a property name
741 if 'choices' in self.event_class.instance_properties[k]:
742 # this instance property has choices defined. However there is no guarantee
743 # the actual instance property value is one of the defined values
744 desc = None
745 for choice in self.event_class.instance_properties[k]['choices']:
746 if choice['name'] == v:
747 desc = choice.get('description')
748
749 v = DescribedString(v, desc)
750
751 if '-' in k:
752 k = k.replace('-', '_')
753
754 context['properties'][k] = v
755
756 # If we're breaking {{p}} anyway lets break {{instance_properties.p}} too
757 # context['instance_properties'] = context['properties']
758
759 # add class properties too but only update instance properties with class
760 # properties if they are empty
761 for k, v in self.class_properties.items():
762 # check if the current key exists in the instance properties
763 # if it does then check if the key is empty - if so use class properties to update
764 # if its not empty it will retain its value from instance properties
765 # if the key doesn exist update it from class properties
766 if k in context:
767 if not context[k]:
768 context[k] = v
769
770 else:
771 context[k] = v
772
773 # Only compute description if it might be used
774 if not exclude_description and 'description' in template:
775 context['description'] = self.description()
776
777 # This is expensive to compute so check if the template looks like it actually
778 # uses orbits first
779 if 'start_orbit' in template:
780 context['start_orbit'] = self.sid.orbit.find_orbit(self.start_time)
781
782 if 'stop_orbit' in template:
783 context['stop_orbit'] = self.sid.orbit.find_orbit(self.stop_time)
784
785 # if the event template contains the words 'latitude' or 'longitude' in and
786 # context, we compute the geolocation.
787 # There's no easy way to check the template in advance to see exactly what fields
788 # are needed.
789 # We could probably implement some sort of lazy evaluation for template properties
790 # if it's really a problem.
791 if 'latitude' in template or 'longitude' in template:
792 loc = Geoloc(self.sid, self.start_time, self.start_time)
793 lat, lon = loc.lat_lon(self.start_time)
794 context['latitude'] = round(lat, 2)
795 context['longitude'] = round(lon, 2)
796
797 # duplicate the {{properties.x}} entries as {{x}} which is needed for
798 # EPS reports - shouldn't be used in new reports
799 for k, v in context['properties'].items():
800 context[k] = v
801
802 result = Template(template).render(context)
803 if html:
804 # for HTML output replace newlines with <br>.
805 # but first trim leading and trailing spaces.
806 # Empty lines are striped out.
807 # Later on the Table class will substitute <hr class='novis'> for
808 # empty cells
809 result = '<br>'.join(
810 line.strip() for line in result.split('\n') if len(line.strip()) > 0)
811
812 else:
813 # strip XML glyphs.
814 # Cannot find a built in function for this. pypi as a namedentities
815 # project that might be better.
816 result = result.replace('<', '<').replace('>', '>').replace('"', '"')
817
818 return result
819
820 @property
821 def event_class(self):
822 """Return the EventClass object this Event belongs to."""
823 return EventClass(self.event_classname)
824
825 def as_table(self):
826 """Render ourselves as a Table. """
827 t = Table()
828
829 def header(text):
830 """Use a CSS class to highlight parameter names."""
831 return {'text': text, 'cssclass': 'evt-header'}
832
833 # Standard header fields
834 t.append((header('Class'),
835 '<a title="Event ID {eid}" href=\'{url}\' target=\'_blank\'>{cls}</a>'.format(
836 eid=self.event_id,
837 cls=self.event_classname,
838 url=reverse('events:single',
839 kwargs={'eventclass': self.event_classname}))))
840 t.append((header('Source'), self.sid.name))
841 t.append((header('Start'), show_event_time(self.start_time)))
842 if self.stop_time is not None:
843 t.append((header('Stop'), show_event_time(self.stop_time)))
844
845 if self.geoloc is not None:
846 if self.stop_time is None:
847 t.append((header('Location'),
848 ('Lat {0.geoloc[start_lat]:+.2f} Lon {0.geoloc[start_lon]:+.2f}'.format(
849 self))))
850
851 else:
852 t.append((header('Start location'),
853 ('Lat {0.geoloc[start_lat]:+.2f} Lon {0.geoloc[start_lon]:+.2f}'.format(
854 self))))
855 t.append((header('Stop location'),
856 ('Lat {0.geoloc[stop_lat]:+.2f} Lon {0.geoloc[stop_lon]:+.2f}'.format(
857 self))))
858
859 # If a special renderer is configured for this packet, look up a PUS PacketDef first
860 renderer = self.event_class.renderer
861 packetdef = None
862 if renderer is EventRenderer.TELECOMMAND:
863 # It would be better to use something other than the property name
864 # to identify which property gives the packetdef
865 if 'Name' in self.instance_properties:
866 packetdef = PacketDef.find_one(domain=PacketDomain.TC,
867 name=self.instance_properties['Name'],
868 sid=self.sid)
869 else:
870 packetdef = PacketDef.find_one(domain=PacketDomain.TC,
871 name=self.instance_properties['name'],
872 sid=self.sid)
873
874 elif renderer is EventRenderer.TELEMETRY:
875 if 'SPID' in self.instance_properties:
876 packetdef = PacketDef.find_one(domain=PacketDomain.TM,
877 spid=self.instance_properties['SPID'],
878 sid=self.sid)
879 else:
880 packetdef = PacketDef.find_one(domain=PacketDomain.TM,
881 spid=self.instance_properties['spid'],
882 sid=self.sid)
883 assert packetdef is not None
884
885 elif renderer is EventRenderer.OOL_EVENT:
886 if 'spid' in self.instance_properties:
887 packetdef = PacketDef.find_one(domain=PacketDomain.EV,
888 name=self.instance_properties['spid'],
889 sid=self.sid)
890
891 # Basic table of all instance properties
892 prop_types = self.event_class.instance_properties
893 for prop_name, prop_type in prop_types.items():
894 if prop_name not in self.instance_properties:
895 continue
896
897 value = self.instance_properties[prop_name]
898 # Allow special renderers to be configured
899 if renderer in (EventRenderer.TELECOMMAND,
900 EventRenderer.TELEMETRY,
901 EventRenderer.OOL_EVENT) and prop_type['type'] == 'json':
902 if len(value) == 0:
903 t.append((header(prop_name), 'No parameters'))
904
905 elif prop_name.upper() == 'HEX_DATA':
906 t.append((header(prop_name), self.render_json(value)))
907
908 else:
909 t.append((header(prop_name), self.render_telecommand(packetdef, value)))
910
911 elif prop_type['type'] == 'json':
912 # the instance_properties function sends us json values unmodified as a dict
913 if isinstance(value, list):
914 for v in value:
915 t.append((header(prop_name), self.render_json(v)))
916 elif value is None:
917 t.append((header(prop_name), value))
918 else:
919 t.append((header(prop_name), self.render_json(value)))
920
921 else:
922 # Allow individual cells to be coloured, and apply choice value as well
923 colour = None
924 cal_val = None
925 associated_param = None
926
927 if 'choices' in prop_type:
928 for c in prop_type['choices']:
929 if 'use-colour' in c.keys() and c['use-colour'] is not None:
930 raw_val = value
931 cal_val = UNCALIBRATED
932
933 # get colour from an associated parameter
934 associated_param = c['use-colour']
935 for ac in prop_types[associated_param]['choices']:
936 if self.instance_properties[associated_param] in [ac['name'],
937 ac['value']]:
938 if 'colour' in ac:
939 colour = ac['colour']
940
941 break
942
943 elif value in [c['name'], c['value']]:
944 cal_val = c['name']
945 raw_val = c['value']
946 if 'colour' in c:
947 colour = c['colour']
948
949 break
950
951 if colour is not None:
952 t.append((header(prop_name), {'text': '{raw}:{cal}'.format(
953 raw=raw_val, cal=cal_val), 'bgcolour': colour}))
954
955 else:
956 if len(str(value)) > TABLE_BREAK_CHARS:
957 value = textwrap.fill(str(value), TABLE_BREAK_CHARS)
958 if cal_val is None:
959 t.append((header(prop_name), value))
960 else:
961 t.append((header(prop_name), {'text': '{raw}\t:\t{cal}'.format(
962 raw=raw_val, cal=cal_val)}))
963
964 return t
965
966 def render_telecommand(self, packetdef, params):
967 """Take `params` and render as a Table, given they come from `packetdef`."""
968 # This function should be reused for non-telecommand parameters
969 # it just needs the right PacketDef
970
971 if len(params) == 0:
972 # if there are no parameters just return text string
973 return 'No parameters'
974
975 result = Table(headings=('Param', 'Raw', 'Cal', 'Units', 'Description'))
976
977 def htmlise_array(values):
978 """If `values` is an array, expand it to a nice comma separated list.
979
980 We could handle multidimensional arrays also by adding <br> for line breaks.
981 """
982 if isinstance(values, list):
983 result = ', '.join(show_obj(v) for v in values)
984
985 else:
986 result = show_obj(values)
987
988 # now if length of htmlise_value greater than 30(TBD) characters break over serval lines,
989 # required for long continuous byte strings - with non continuous may need to manually
990 # shrink popup width
991 if len(result) > TABLE_BREAK_CHARS:
992 result = textwrap.fill(result, TABLE_BREAK_CHARS)
993
994 return result
995
996 # Drop duplicate names
997 # This is because some packets store arrays of parameters by naming the parameter
998 # multiple times, but we store it as a single array
999 done = set()
1000
1001 json_params = db_conn.as_json(params)
1002
1003 # Walk through the parameters in the PacketDef so we display them in the order
1004 # defined there, and show any missing ones
1005 for param_info in packetdef.params:
1006 if param_info.field_info is None:
1007 # spacer ?
1008 continue
1009
1010 field_info = param_info.field_info
1011 param_name = field_info.name
1012
1013 # drop duplicates
1014 if param_name in done:
1015 continue
1016
1017 done.add(param_name)
1018
1019 # Look up the actual values of this parameter in this event
1020 param_value = json_params.get(param_name)
1021
1022 def markup_cells(values):
1023 """For each raw value in `values` mark it up with calibrated values and unit.
1024
1025 It could be a scalar or multidimensional array.
1026 """
1027 if isinstance(values, list):
1028 raw_values = []
1029 cal_values = []
1030 for i in values:
1031 raw_value, cal_value = markup_cells(i)
1032 raw_values.append(raw_value)
1033 cal_values.append(nvl(cal_value))
1034
1035 return raw_values, cal_values
1036
1037 # Assume the stored value is the raw value
1038 raw_value = values
1039
1040 # check for None, can be if empty array...
1041 if raw_value is None:
1042 return UNCALIBRATED, UNCALIBRATED
1043
1044 elif raw_value is BLOCKED:
1045 # value blocked from user
1046 return BLOCKED, BLOCKED
1047
1048 # Look for a "calibration" function
1049 # i.e. either an actual calibration, of a choices list
1050 if field_info.choices is not None:
1051 # it has a choices element. We leave raw_value as the literal
1052 # value as read, but set cal_value to the name of the choice
1053 cal_value = field_info.choices.get_name(raw_value)
1054
1055 elif field_info.choice_name is not None:
1056 # load choices, if not done already
1057 choices = Choices(choice_id=field_info.choice_name)
1058 field_info.choices = choices.load_xml(sid=self.sid)
1059 cal_value = field_info.choices.get_name(raw_value)
1060
1061 elif field_info.cal[self.sid] is not None:
1062 # no choices element so test for a calibration function
1063 # Manually calibrate each value
1064 cal_value = field_info.cal_value(self.sid, raw_value)
1065 # In the tooltip, show the raw value and name the cal function
1066 raw_value = '{raw} ({calfunc})'.format(raw=raw_value,
1067 calfunc=field_info.calibration_name)
1068
1069 else:
1070 # TBD what to set calvalue if the same as raw value ? raw_value
1071 cal_value = ' {uncal} '.format(uncal=UNCALIBRATED)
1072
1073 # Display calibrated value in the main value column.
1074 # Use raw value as the tooltip
1075 # Append unit if present to the calibrated value display
1076 return raw_value, nvl(cal_value)
1077
1078 # Show parameter names in the first column with parameter descriptions
1079 # as a tooltip,
1080 # and render the values into a second column
1081 rawvalues, calvalues = markup_cells(param_value)
1082
1083 result.append(({'text': param_name,
1084 'tooltip': field_info.description},
1085 htmlise_array(rawvalues),
1086 htmlise_array(calvalues),
1087 nvl(field_info.unit),
1088 nvl(field_info.description)))
1089
1090 return result
1091
1092 def render_json(self, params):
1093 """Take `params` and render as a Table, given they come from `packetdef`."""
1094 result = Table(headings=('Param', 'Value'))
1095
1096 for k, v in params.items():
1097 if isinstance(v, dict):
1098 # We could make this nicer by calling ourselves to insert another Table into
1099 # the cell
1100 result.append((k, str(v)))
1101 # result.append((k, self.render_json(v)))
1102
1103 elif isinstance(v, list):
1104 # This could generate an html list for better outputs
1105 result.append((k, str(v)))
1106 # result.append((k, ', '.join(str(vv) for vv in v)))
1107
1108 else:
1109 if len(str(v)) > TABLE_BREAK_CHARS:
1110 v = textwrap.fill(v, TABLE_BREAK_CHARS)
1111
1112 result.append((k, v))
1113
1114 return result