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 (&lt;, &gt;) 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('&lt;', '<').replace('&gt;', '>').replace('&quot;', '"')
 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