models.py 39.7 KB
Newer Older
Alan Mitchell's avatar
Alan Mitchell committed
1
import time
2 3
import json
import logging
Alan Mitchell's avatar
Alan Mitchell committed
4
from django.db import models
5
from django.core.validators import RegexValidator
6
from django.conf import settings
7 8
from django.core.mail import send_mail
import requests
9
import bmsapp.data_util
10
import bmsapp.formatters
11
import bmsapp.schedule
12
from . import sms_gateways
13
import yaml
14

Alan Mitchell's avatar
Alan Mitchell committed
15

16 17 18
# Make a logger for this module
_logger = logging.getLogger('bms.' + __name__)

Alan Mitchell's avatar
Alan Mitchell committed
19 20 21 22 23 24 25 26
# Models for the BMS App

class SensorGroup(models.Model):
    '''
    A functional group that the sensor belongs to, e.g. "Weather, Snowmelt System".
    '''

    # the diplay name of the Sensor Group.
27
    title = models.CharField(max_length=80, unique=True)
Alan Mitchell's avatar
Alan Mitchell committed
28 29 30 31

    # A number that determines the order that Sensor Groups will be presented to the user.
    sort_order = models.IntegerField(default=999)

Alan Mitchell's avatar
Alan Mitchell committed
32
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
        return self.title

    class Meta:
        ordering = ['sort_order']


class Unit(models.Model):
    '''
    A physical unit, e.g. "deg F", "gpm", for a sensor value.
    '''

    # the display name of the unit
    label = models.CharField("Unit Label", max_length=20, unique=True)

    # the type of physical quantity being measured, e.g. temperature, fluid flow, air flow, power
    measure_type = models.CharField("Measurement Type", max_length=30)

Alan Mitchell's avatar
Alan Mitchell committed
50
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
51 52 53 54 55 56 57 58 59 60 61 62
        return '%s: %s' % (self.measure_type, self.label)

    class Meta:
        ordering = ['measure_type', 'label']


class Sensor(models.Model):
    '''
    One sensor or a calculated field
    '''

    # sensor ID, either from Monnit network or a user-entered ID for a calculated value
Alan Mitchell's avatar
Alan Mitchell committed
63
    sensor_id = models.CharField("Sensor ID, or Calculated Field ID", max_length=80, unique=True)
Alan Mitchell's avatar
Alan Mitchell committed
64 65

    # descriptive title for the sensor, shown to users
66
    title = models.CharField(max_length = 80)
Alan Mitchell's avatar
Alan Mitchell committed
67 68

    # the units for the sensor values
69 70
    # Does not allow no value.
    unit =  models.ForeignKey(Unit, models.SET_DEFAULT, default=1)
Alan Mitchell's avatar
Alan Mitchell committed
71

72
    # Adds in a notes field to the Current sensors page.
73
    notes = models.TextField("Please enter descriptive notes about the sensor.", default="No sensor notes available.")
74
    
Alan Mitchell's avatar
Alan Mitchell committed
75
    # if True, this field is a calculated field and is not directly created from a sensor.
76
    is_calculated = models.BooleanField("Calculated Field", default=False)
Alan Mitchell's avatar
Alan Mitchell committed
77 78 79

    # the name of the transform function to scale the value, if any, for a standard sensor field, or the 
    # calculation function (required) for a calculated field.  transform functions must be located in the 
80 81
    # "transforms.py" module in the "calcs" package and calculated field functions must be properly 
    # referenced in the "calc_readings.py" script in the "scripts" directory.
82
    tran_calc_function = models.CharField("Transform or Calculated Field Function Name", max_length=80, blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
83 84

    # the function parameters, if any, for the transform or calculation function above.  parameters are 
85 86
    # entered in YAML format.
    function_parameters = models.TextField("Function Parameters in YAML form", blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
87 88 89 90 91

    # Calculation order.  If this particular calculated field depends on the completion of other calculated fields
    # first, make sure the calculation_order for this field is higher than the fields it depends on.
    calculation_order = models.IntegerField(default=0)

92 93 94 95
    # the name of the formatting function for the value, if any.
    # Formatting functions must be located in the "formatters.py" module.
    formatting_function = models.CharField("Formatting Function Name", max_length=50, blank=True)

96 97 98 99 100
    # Additional Descriptive Properties to include in the key_properties function.
    # These are given higer priority over the other fields so can be used to override 
    # other field values.
    other_properties = models.TextField("Additional Properties to include when exporting data. YAML form, e.g. room: telecom closet", help_text="One property per line.  Name of Property, a colon, a space, and then the property value.", blank=True)

Alan Mitchell's avatar
Alan Mitchell committed
101
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
102 103 104 105 106
        return self.sensor_id + ": " + self.title

    class Meta:
        ordering = ['sensor_id']

107 108 109 110 111 112
    def last_read(self, reading_db, read_count=1):
        '''If 'read_count' is 1, returns the last reading from the sensor as a dictionary
        with 'ts' and 'val' keys. Returns None if there have been no readings.
        If 'read_count' is more than 1, returns a list 'read_count' readings (if available),
        each reading as a dictionary.
        'reading_db' is a sensor reading database, an instance of bmsapp.readingdb.bmsdata.BMSdata.
113
        '''
114
        return reading_db.last_read(self.sensor_id, read_count)
115

116 117 118 119 120 121 122 123 124 125
    def format_func(self):
        '''Returns a function suitable for formatting a value from this sensor.
        If 'formatting_function' is present, that function is looked up and returned,
        otherwise a generic formatting function that displays 3 significant digits is
        returned.
        '''
        return getattr(bmsapp.formatters,
                       self.formatting_function.strip(),
                       bmsapp.data_util.formatCurVal)

126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
    def is_active(self, reading_db):
        '''Returns True if the sensor has last posted within the sensor
        activity interval specified in the settings file.  'reading_db' is a sensor reading
        database, an instance of bmsapp.readingdb.bmsdata.BMSdata.
        '''
        last_read = self.last_read(reading_db)
        if last_read is not None:
            # get inactivity setting from settings file
            inactivity_hrs = getattr(settings, 'BMSAPP_SENSOR_INACTIVITY', 2.0)
            last_post_hrs = (time.time() - last_read['ts']) / 3600.0
            return (last_post_hrs <= inactivity_hrs)
        else:
            # no readings in database for this sensor
            return False

141
    def alerts(self, reading_db):
Alan Mitchell's avatar
Alan Mitchell committed
142 143
        '''Returns a list of alert (subject, message) tuples that are currently effective.  
        List will be empty if no alerts are occurring.
144 145 146
        '''
        alerts = []
        for alert_condx in AlertCondition.objects.filter(sensor__pk=self.pk):
Alan Mitchell's avatar
Alan Mitchell committed
147 148 149
            subject_msg = alert_condx.check_condition(reading_db)
            if subject_msg:
                alerts.append(subject_msg)
150 151
        return alerts

152 153 154 155
    def key_properties(self):
        """Returns a dictionary of important properties associated with this building.  Not all properties are 
        included.  One use is to include when exporting data.
        """
156 157
        # adding a 'sensor' qualifier to title because these props are often combined
        # with sensor properties that also have a title.
158
        props = {'sensor_id': self.sensor_id,
159
                 'sensor_title': self.title,
160 161
                 'unit': self.unit.label}
        try:
162
            props.update( yaml.load(self.other_properties, Loader=yaml.FullLoader) )
163 164 165 166 167 168
        except:
            # ignore errors
            pass
        
        return props

Alan Mitchell's avatar
Alan Mitchell committed
169

170 171 172 173 174 175 176
class BuildingMode(models.Model):
    '''A state or mode that the building could be in, such as Winter, Summer, Vacant.
    '''

    # name of the mode
    name = models.CharField("Mode Name", max_length=50)

Alan Mitchell's avatar
Alan Mitchell committed
177
    def __str__(self):
178 179
        return self.name

180 181 182
    class Meta:
        ordering = ['name']

183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
class ElectricRate(models.Model):
    """Defines an electric rate schedule.
    """
    # Name of the Rate Schedule
    title = models.CharField(max_length=80, unique=True)

    # Determines the order that the Rate Schedule will be presented in the UI
    sort_order = models.IntegerField(default=999)

    # Monthly fixed customer charge.
    customer_charge = models.FloatField(
        'Monthly Fixed Customer Charge, $/month', 
        default=0.0,
        help_text='This charge is assessed every month, irrespective of usage.'
    )

    # Energy rate, 1st block
    rate1 = models.FloatField(
        'Rate for first block of usage, $/kWh',
        default=0.0
    )

    # 1st Block size, kWh
    block1 = models.FloatField(
        '1st Block size in kWh, leave blank to apply to all kWh',
        blank=True,
        null=True,
        help_text='Leave this entry blank if there are no usage blocks and the first rate applies to all kWh.'
    )

    # Energy rate, 2nd block
    rate2 = models.FloatField(
        'Rate for second block of usage, if present, $/kWh',
        blank=True,
        null=True,
        help_text='Only used if there is a second usage block.'
    )

    # Demand Charge
    demand_charge = models.FloatField(
        'Demand Charge for maximum 15 minute power, $/kW/mo',
        default=0.0,
        help_text='Generally not used except for Large Commercial rate structures.'
    )

    def __str__(self):
        return self.title

    class Meta:
        ordering = ['sort_order', 'title']


class FuelRate(models.Model):
    """Defines a fuel rate.
    """
    # Name of the Rate Schedule
    title = models.CharField(max_length=80, unique=True)

    # Determines the order that the Rate Schedule will be presented in the UI
    sort_order = models.IntegerField(default=999)

    # Monthly fixed customer charge.
    customer_charge = models.FloatField(
        'Monthly Fixed Customer Charge, $/month', 
        default=0.0,
        help_text='This charge is assessed every month, irrespective of usage.'
    )

    # Fuel price expressed in $/MMBtu
    rate = models.FloatField(
        'Fuel Price expressed in $/MMBtu',
        default=0.0
    )

    def __str__(self):
        return self.title

    class Meta:
        ordering = ['sort_order', 'title']

263

Alan Mitchell's avatar
Alan Mitchell committed
264 265 266 267 268 269
class Building(models.Model):
    '''
    A building that contains sensors.
    '''

    # name of the building displayed to users
270
    title = models.CharField(max_length=80, unique=True)
Alan Mitchell's avatar
Alan Mitchell committed
271

272 273 274 275 276 277
    # Latitude of building
    latitude = models.FloatField(default=62.0)

    # Longitude of building
    longitude = models.FloatField(default=-161.0)

278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296
    # Fields related to the Occupied Schedule of the Facility

    # The timezone, from the Olson timezone database, where the facility
    # is located.
    timezone = models.CharField("Time Zone of Facility, from tz database", 
        max_length=50, default='US/Alaska')

    # Occupied schedule for building.  No entry means continually occupied.
    schedule = models.TextField("Occupied Schedule of Facility (e.g. M-F: 8a-5p)", blank=True)

    # Current mode that building is in
    current_mode = models.ForeignKey(BuildingMode, models.SET_NULL, verbose_name='Current Operating Mode', blank=True, null=True)

    # Descriptive Text that shows at the bottom of the Current Values and Dashboard reports.
    report_footer = models.TextField(verbose_name='Additional Building Documentation', help_text='Use <a href="http://markdowntutorial.com/"> markdown syntax </a> to add links, pictures, etc.  Note that you <b>must</b> include the url prefix (e.g. <i>http://</i>) in your links.', blank=True, default='')

    # Timeline annotations
    timeline_annotations = models.TextField("Annotations for events in the building's timeline (e.g. Boiler Replaced: 1/1/2017)", help_text="One annotation per line. Use a colon between the annotation and the date/time.", blank=True)

297 298 299 300 301 302 303 304 305
    # Floor area of building
    floor_area = models.FloatField('Floor area of Building in square feet', null=True, blank=True)

    # Building Type
    SINGLE_RES = 'S-RES'
    MULTI_RES = 'M-RES'
    OFFICE = 'OFFIC'
    SCHOOL = 'SCH'
    RETAIL = 'RET'
306
    RESTAURANT = 'RES'
307 308 309 310 311 312 313 314 315 316
    WAREHOUSE = 'WARE'
    INDUSTRIAL = 'INDUS'
    OTHER = 'OTHER'

    BUILDING_TYPES = [
        (SINGLE_RES, 'Single-Family Residential'),
        (MULTI_RES, 'Multi-Family Residential'),
        (OFFICE, 'Office'),
        (SCHOOL, 'School'),
        (RETAIL, 'Retail'),
317
        (RESTAURANT, 'Restaurant'),
318 319 320 321 322 323 324 325 326 327 328 329
        (WAREHOUSE, 'Warehouse'),
        (INDUSTRIAL, 'Industrial'),
        (OTHER, 'Other')
    ]
    building_type = models.CharField(
        'Building Type',
        max_length=5,
        choices=BUILDING_TYPES,
        null=True,
        blank=True
    )

330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
    # Electric Rate for Building
    electric_rate = models.ForeignKey(
        ElectricRate, 
        on_delete=models.SET_NULL, 
        verbose_name='Electric Rate Schedule for Building', 
        blank=True, null=True
    )

    # Fuel Rate for Building
    fuel_rate = models.ForeignKey(
        FuelRate, 
        on_delete=models.SET_NULL, 
        verbose_name='Primary Fuel Price for Building', 
        blank=True, null=True
    )

346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384
    # Building outdoor temperature sensor
    outdoor_temp = models.CharField(
        'Outdoor Temperature Sensor ID',
        max_length=80,
        blank=True
    )

    # Building Electric Power Sensors
    electric_ids = models.TextField(
        'Building Electric Power Sensor IDs, one per line',
        blank=True,
        help_text='Sensors will be added together to determine total electric use.'
    )

    # Building Fuel Use Sensors
    fuel_ids = models.TextField(
        'Building Fuel Use Sensor IDs, one per line',
        blank=True,
        help_text='Sensors will be added together to determine total fuel use.'
    )

    # Building Indoor Temperature Sensors
    indoor_temps = models.TextField(
        'Indoor Temperature Sensor IDs, one per line',
        blank=True
    )

    # Building Indoor Light Sensors
    indoor_lights = models.TextField(
        'Indoor Light Sensor IDs, one per line',
        blank=True
    )

    # CO2 Sensors
    co2_sensors = models.TextField(
        'CO2 Sensor IDs, one per line',
        blank=True
    )

385 386 387 388 389
    # Additional Descriptive Properties to include in the key_properties function.
    # These are given higer priority over the other fields so can be used to override 
    # other field values.
    other_properties = models.TextField("Additional Properties to include when exporting data. YAML form, e.g. age: 23", help_text="One property per line.  Name of Property, a colon, a space, and then the property value.", blank=True)

Alan Mitchell's avatar
Alan Mitchell committed
390
    # the sensors and calculated values associated with this building
391
    sensors = models.ManyToManyField(Sensor, through='BldgToSensor', blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
392

Alan Mitchell's avatar
Alan Mitchell committed
393
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
394 395
        return self.title

396 397 398 399
    def key_properties(self):
        """Returns a dictionary of important properties associated with this building.  Not all properties are 
        included.  One use is to include when exporting data.
        """
400 401 402
        # adding a 'building' qualifier to title because these props are often combined
        # with sensor properties that also have a title.
        props = {'building_title': self.title,
403 404 405
                 'latitude': self.latitude,
                 'longitude': self.longitude}
        try:
406
            props.update( yaml.load(self.other_properties, Loader=yaml.FullLoader) )
407 408 409 410 411 412
        except:
            # ignore errors
            pass
        
        return props

413 414 415 416 417 418 419 420 421 422 423 424 425 426 427
    def current_status(self, ts = time.time()):
        """Returns the occupied/unoccupied status of a building at a given time
        """
        if self.schedule:
            if self.timezone:
                scheduleObj = bmsapp.schedule.Schedule(self.schedule, self.timezone)
            else:
                scheduleObj = bmsapp.schedule.Schedule(self.schedule, 'US/Alaska')
            if scheduleObj.is_occupied(ts):
                return 'Occupied'
            else:
                return 'Unoccupied'
        else:
            return 'Occupied'

Alan Mitchell's avatar
Alan Mitchell committed
428 429 430 431
    class Meta:
        ordering = ['title']


432 433 434 435 436 437
class BuildingGroup(models.Model):
    """Defines a group of buildings that the user can select to filter the 
    buildings shown in the user interface.
    """
    
    # Name of the building group
438
    title = models.CharField(max_length=80, unique=True)
439 440 441 442 443 444 445
    
    # Determines the order that the building group will be presented in the UI
    sort_order = models.IntegerField(default=999)
    
    # The buildings that are present in this group
    buildings = models.ManyToManyField(Building)

Alan Mitchell's avatar
Alan Mitchell committed
446
    def __str__(self):
447 448 449 450 451 452
        return self.title

    class Meta:
        ordering = ['sort_order', 'title']
        

Alan Mitchell's avatar
Alan Mitchell committed
453 454 455 456 457 458 459 460 461
class BldgToSensor(models.Model):
    '''
    Links buildings to sensors and supplies additional information about what Sensor Group the sensor is 
    in and what order the sensor should be listed within the group.  The Bldg-to-Sensor link is set up as 
    Many-to-Many beacause weather station sensors or calculated values can be used by multiple different 
    buildings.
    '''

    # The building and sensor that are linked
462 463
    building = models.ForeignKey(Building, models.CASCADE)
    sensor = models.ForeignKey(Sensor, models.CASCADE)
Alan Mitchell's avatar
Alan Mitchell committed
464 465

    # For this building, the sensor group that the sensor should be classified in.
466 467
    # The default value is for the "Miscellaneous" Sensor Group.
    sensor_group = models.ForeignKey(SensorGroup, models.SET_DEFAULT, default=8)
Alan Mitchell's avatar
Alan Mitchell committed
468 469 470 471

    # Within the sensor group, this field determines the sort order of this sensor.
    sort_order = models.IntegerField(default=999)

Alan Mitchell's avatar
Alan Mitchell committed
472
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
473 474 475 476 477
        return self.building.title + ": " + self.sensor.title

    class Meta:
        ordering = ('building__title', 'sensor_group__sort_order', 'sort_order')

478

Alan Mitchell's avatar
Alan Mitchell committed
479 480 481 482 483
class DashboardItem(models.Model):
    """An item on the Dashboard display for a building.
    """

    # The building this Dashboard item is for.
484
    building = models.ForeignKey(Building, models.CASCADE)
Alan Mitchell's avatar
Alan Mitchell committed
485

486
    # The Widget type to be used for display of this sensor on the Dashboard.
Ian Moore's avatar
Ian Moore committed
487
    GRAPH = 'graph'
488 489
    GAUGE = 'gauge'
    LED = 'LED'
Alan Mitchell's avatar
Alan Mitchell committed
490
    LABEL = 'label'
491
    NOT_CURRENT = 'stale'      # data is not current. Don't include as a User choice.
492
    DISPLAY_WIDGET_CHOICES = (
Ian Moore's avatar
Ian Moore committed
493 494
        (GRAPH, 'Graph'),
        (GAUGE, 'Gauge'),
495
        (LED, 'Red/Green LED'),
Alan Mitchell's avatar
Alan Mitchell committed
496
        (LABEL, 'Label'),
497
    )
Alan Mitchell's avatar
Alan Mitchell committed
498 499
    widget_type = models.CharField(max_length=15,
                                   choices=DISPLAY_WIDGET_CHOICES,
Ian Moore's avatar
Ian Moore committed
500
                                   default=GRAPH)
501 502 503

    # The row number on the Dashboard that this item will appear in.  Numbering can start
    # at any value and skip values; only the order matters.
Alan Mitchell's avatar
Alan Mitchell committed
504
    row_number = models.IntegerField(default=1)
505 506 507

    # The column number on the Dashboard that this item will appear in.  Numbering can
    # start at any value and can skip value; only the order matters.
Alan Mitchell's avatar
Alan Mitchell committed
508 509 510
    column_number = models.IntegerField(default=1)

    # The sensor, if any, used in this Dashboard item
511
    sensor = models.ForeignKey(BldgToSensor, models.CASCADE, null=True, blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
512 513 514 515

    # Title, mostly used for Label widgets, but also overrides default title on
    # other widgets
    title = models.CharField("Widget Title (can be blank)", max_length=50, null=True, blank=True)
516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538

    # The range of normal values
    minimum_normal_value = models.FloatField(default=0.0)
    maximum_normal_value = models.FloatField(default=100.0)

    # The total range of values shown on the Widget.  If blank,
    # default axis values will be calculated.
    minimum_axis_value = models.FloatField(null=True, blank=True)
    maximum_axis_value = models.FloatField(null=True, blank=True)

    def get_axis_range(self):
        """Returns the total range of values to show on the widget, using
        default values if none are provided by the user above.  A tuple of
        (min_axis_value, max_axis_value) is returned.
        """
        # Calculate the amount to extend the range beyond min and max normal
        # if no axis values are provided.  Base this on the range of normal
        # values.
        axis_extension = 0.60 * (self.maximum_normal_value - self.minimum_normal_value)
        min_val = self.minimum_axis_value if self.minimum_axis_value is not None else self.minimum_normal_value - axis_extension
        max_val = self.maximum_axis_value if self.maximum_axis_value is not None else self.maximum_normal_value + axis_extension
        return (min_val, max_val)

Alan Mitchell's avatar
Alan Mitchell committed
539
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
540
        return self.widget_type + ": " + (self.sensor.sensor.title if self.sensor else self.title)
Alan Mitchell's avatar
Alan Mitchell committed
541 542

    class Meta:
Alan Mitchell's avatar
Alan Mitchell committed
543
        ordering = ('row_number', 'column_number', 'sensor__sort_order')
Alan Mitchell's avatar
Alan Mitchell committed
544

545

Alan Mitchell's avatar
Alan Mitchell committed
546 547 548 549 550 551 552 553
class MultiBuildingChart(models.Model):
    '''
    One particular chart that utilizes data from a group of buildings
    '''

    # descriptive title of the Chart
    title = models.CharField(max_length=60, unique=True)

554 555 556 557 558 559
    MULTI_CHART_CHOICES = (
        ('currentvalues_multi.CurrentValuesMulti', 'Current Sensor Values'),
        ('normalizedbyddbyft2.NormalizedByDDbyFt2', 'Energy / Degree-Day / ft2'),
        ('normalizedbyft2.NormalizedByFt2', 'Energy / ft2'),
    )

Alan Mitchell's avatar
Alan Mitchell committed
560
    # the type of chart
561 562 563 564
    chart_class = models.CharField("Type of Chart", 
                                    max_length=60,
                                    null=True,
                                    choices=MULTI_CHART_CHOICES)
Alan Mitchell's avatar
Alan Mitchell committed
565 566 567

    # the general parameters for this chart, if any.  These are parameters that are
    # *not* associated with a particular building.  The parameters are
568 569
    # entered in YAML format.
    parameters = models.TextField("General Chart Parameters in YAML Form", blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
570 571 572 573

    # determines order of Chart displayed in Admin interface
    sort_order = models.IntegerField(default=999)

Alan Mitchell's avatar
Alan Mitchell committed
574
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
575 576 577 578 579 580 581 582 583 584 585 586
        return self.title

    class Meta:
        ordering = ['sort_order']


class ChartBuildingInfo(models.Model):
    '''
    Information about a building that is used in an MultiBuildingChart.
    '''
    
    # the associated chart
587
    chart = models.ForeignKey(MultiBuildingChart, models.CASCADE)
Alan Mitchell's avatar
Alan Mitchell committed
588 589

    # the building participating in the Chart
590
    building = models.ForeignKey(Building, models.CASCADE)
Alan Mitchell's avatar
Alan Mitchell committed
591 592

    # the parameters for this chart associated with this building, if any.
593 594
    # The parameters are entered in YAML format.
    parameters = models.TextField("Chart Parameters in YAML Form", blank=True)
Alan Mitchell's avatar
Alan Mitchell committed
595 596 597 598

    # determines the order that this building appears in the chart
    sort_order = models.IntegerField(default=999)

Alan Mitchell's avatar
Alan Mitchell committed
599
    def __str__(self):
Alan Mitchell's avatar
Alan Mitchell committed
600 601 602 603 604
        return self.chart.title + ": " + self.building.title

    class Meta:
        ordering = ['sort_order']

605 606 607 608 609 610 611

class AlertRecipient(models.Model):
    '''A person or entity that is sent a notification when an Alert condition
    occurs.
    '''

    # If False, no alerts will be sent to this person
612
    active = models.BooleanField(default=True)
613 614 615 616 617 618 619 620 621 622 623

    # Name of recipient 
    name = models.CharField(max_length=50)

    # Email notification fields
    notify_email = models.BooleanField("Send Email?", default=True)
    email_address = models.EmailField(max_length=100, blank=True)

    # Cell Phone Text Message notification fields
    notify_cell = models.BooleanField("Send Text Message?", default=True)
    phone_regex = RegexValidator(regex=r'^\d{10}$', message="Phone number must be entered as a 10 digit number, including area code, no spaces, dashes or parens.")
624
    cell_number = models.CharField("10 digit Cell number", max_length=10, validators=[phone_regex], blank=True)
625 626 627 628 629 630 631
    cell_sms_gateway = models.CharField('Cell Phone Carrier', max_length=60, choices=sms_gateways.GATEWAYS, blank=True)

    # Pushover mobile app notification fields
    notify_pushover = models.BooleanField("Send Pushover Notification?", default=True)
    pushover_regex = RegexValidator(regex=r'^\w{30}$', message="Pushover ID should be exactly 30 characters long.")
    pushover_id = models.CharField('Pushover ID', validators=[pushover_regex], max_length=30, blank=True)

Alan Mitchell's avatar
Alan Mitchell committed
632
    def __str__(self):
633 634
        return self.name

635 636 637
    class Meta:
        ordering = ['name']

638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694
    def notify(self, subject, message, pushover_priority):
        '''If this recipient is active, sends a message to the recipient via each
         of the enabled communication means.  'subject' is the subject line of the
         message, and 'message' is the message.  For the Pushover notification
         service, 'pushover_priority' gives the priority string for the message
         (e.g. '0', '1', etc.)
         Retuns the number of successful messages sent.
        '''
        if not self.active:
            return 0

        msgs_sent = 0     # tracks successful messages sent.

        email_addrs = []
        if self.notify_email:
            email_addrs.append(self.email_address)
        if self.notify_cell:
            email_addrs.append('%s@%s' % (self.cell_number, self.cell_sms_gateway))

        if email_addrs:
            # The FROM email address
            from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', '')
            if from_email:
                try:
                    if send_mail(subject, message, from_email, email_addrs):
                        msgs_sent += len(email_addrs)
                except:
                    _logger.exception('Error sending mail to alert recipients.')
            else:
                _logger.exception('No From Email address configured in Settings file.')

        if self.notify_pushover:
            # Get the Pushover API key out of the settings file, setting it to None
            # if it is not present in the file.
            pushover_api_key = getattr(settings, 'BMSAPP_PUSHOVER_APP_TOKEN', None)
            if pushover_api_key:
                url = 'https://api.pushover.net/1/messages.json'
                payload = {'token': pushover_api_key,
                    'user': self.pushover_id,
                    'priority': pushover_priority,
                    'title': subject,
                    'message': message}
                if pushover_priority=='2':
                    # emergency priority requires a retry and expire parameter
                    payload['retry'] = 300
                    payload['expire'] = 7200
                resp = json.loads(requests.post(url, data=payload).text)
                if resp['status'] != 0:
                    msgs_sent += 1
                else:
                    _logger.exception(', '.join(resp['errors']))

            else:
                _logger.exception('No Pushover API Token Key configured in Settings file.')

        return msgs_sent

695 696 697 698 699 700

class AlertCondition(models.Model):
    '''A sensor condition that should trigger an Alert to be sent to AlertRecipient's.
    '''

    # If False, this condition will not be evaluated
701
    active = models.BooleanField(default=True, help_text='Uncheck the box to Disable the alert.')
702 703

    # the sensor this condition applies to
704
    sensor = models.ForeignKey(Sensor, models.CASCADE)
705 706

    CONDITION_CHOICES = (
707 708 709 710
        ('>', 'greater than'),
        ('>=', 'greater than or equal to'),
        ('<', 'less than'),
        ('<=', 'less than or equal to'),
711 712 713 714 715
        ('==', 'equal to'),
        ('!=', 'not equal to'),
        ('inactive', 'inactive'),
    )
    # conditional type to evaluate for the sensor value
716
    condition = models.CharField('Notify when the Sensor value is', max_length=20, default='>', choices=CONDITION_CHOICES)
717 718

    # the value to test the current sensor value against
719
    test_value = models.FloatField(verbose_name='this value', blank=True, null=True)
720

721 722 723 724 725 726 727 728 729 730 731
    # the number of readings that have to qualify before alerting
    READ_COUNT_CHOICES = (
        (1, '1 time'),
        (2, '2 times'),
        (3, '3 times'),
        (4, '4 times'),
        (5, '5 times')
    )
    read_count = models.PositiveSmallIntegerField('Number of times Condition must occur before alerting',
                                                  default=1, choices=READ_COUNT_CHOICES)

732
    # fields to qualify the condition test according to building mode
733 734
    only_if_bldg = models.ForeignKey(Building, models.SET_NULL, verbose_name='But only if building', blank=True, null=True)
    only_if_bldg_mode = models.ForeignKey(BuildingMode, models.SET_NULL, verbose_name='is in this mode', blank=True, null=True)
735
    only_if_bldg_status = models.CharField(verbose_name='and this status', max_length=15, blank=True, null=True, choices=(('Occupied','Occupied'),('Unoccupied','Unoccupied')))
736 737

    # alert message.  If left blank a message will be created from other field values.
738
    alert_message = models.TextField(max_length=400, blank=True,
739
        help_text='If left blank, a message will be created automatically.')
740 741 742 743 744

    # priority of the alert.  These numbers correspond to priority levels in Pushover.
    PRIORITY_LOW = '-1'
    PRIORITY_NORMAL = '0'
    PRIORITY_HIGH = '1'
745 746
    PRIORITY_EMERGENCY = '2'
    ALERT_PRIORITY_CHOICES = (
747 748 749
        (PRIORITY_LOW, 'Low'), 
        (PRIORITY_NORMAL, 'Normal'),
        (PRIORITY_HIGH, 'High'),
750
        (PRIORITY_EMERGENCY, 'Emergency')
751
    )
752 753 754
    priority = models.CharField('Priority of this Alert Situation', max_length=5, 
        choices=ALERT_PRIORITY_CHOICES,
        default=PRIORITY_NORMAL)
755

756 757 758
    # determines delay before notifying again about this condition.  Expressed in hours.
    wait_before_next = models.FloatField('Hours to Wait before Notifying Again', default=4.0)

759
    # the recipients who should receive this alert
760
    recipients = models.ManyToManyField(AlertRecipient, verbose_name='Who should be notified?', blank=True)
761 762 763 764

    # when the last notification of this alert condition was sent out, Unix timestamp.
    # This is filled out when alert conditions are evaluated and is not accessible in the Admin
    # interface.
765
    last_notified = models.FloatField(default=0.0)
766

Alan Mitchell's avatar
Alan Mitchell committed
767
    def __str__(self):
768 769
        return '%s %s %s, %s in %s mode' % \
            (self.sensor.title, self.condition, self.test_value, self.only_if_bldg, self.only_if_bldg_mode)
770

AlaskaMapScience's avatar
AlaskaMapScience committed
771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807
    def handle(self, reading_db, logger, testing = False):
        '''This method handles an alert condition, notifying recipients if needed.
        If 'testing' is set to True, a message will always be sent. The number of alerts (0 or 1)
        is returned.
        '''
        alert_count = 0
        try:
            if testing:
                subject_msg = (f"Test Alert: {self.sensor.title} Sensor","This is just a test.")
            elif time.time() < (self.last_notified + self.wait_before_next * 3600.0):
                # if the wait time has not been satisfied, don't notify
                subject_msg =  None
            else:
                subject_msg = self.check_condition(reading_db)
            if subject_msg:
                alert_count = 1
                subject, msg = subject_msg
                msg_count = 0  # tracks # of successful messages sent
                for recip in self.recipients.all():
                    try:
                        msg_count += recip.notify(subject, msg, self.priority)
                    except:
                        logger.exception('Error notifying recipient %s of an alert.' % recip)
                if msg_count and (not testing):
                    # at least one message was sent so update the field tracking the timestamp
                    # of the last notification for this condition.
                    self.last_notified = time.time()
                    self.save()
                    # store a log of the alert
                    reading_db.log_alert(self.sensor.sensor_id,msg)

        except:
            logger.exception('Error processing alert %s')

        return alert_count

    def check_condition(self, reading_db, override_value = None):
808
        '''This method checks to see if the alert condition is in effect, and if so,
Alan Mitchell's avatar
Alan Mitchell committed
809 810
        returns a (subject, message) tuple describing the alert.  If the condition is 
        not in effect, None is returned.  'reading_db' is a sensor reading database, an 
AlaskaMapScience's avatar
AlaskaMapScience committed
811 812 813
        instance ofbmsapp.readingdb.bmsdata.BMSdata.  If an 'override_value' is
        specified, that value will be tested instead of the last values in the reading
        database. If the alert condition is not active, None is returned.
814 815 816 817 818 819 820 821 822 823 824
        '''

        if not self.active:
            return None   # condition not active

        # Make a description of the sensor that includes the building(s) it is
        # associated with.
        bldgs = [btos.building.title for btos in BldgToSensor.objects.filter(sensor__pk=self.sensor.pk)]
        bldgs_str = ', '.join(bldgs)
        sensor_desc = '%s sensor in %s' % (self.sensor.title, bldgs_str)

825 826
        # get the most current reading for the sensor (last_read), and
        # also fill out a list of all the recent readings that need to
Alan Mitchell's avatar
Alan Mitchell committed
827
        # be evaluated to determine if the alert condition is true (last_reads).
AlaskaMapScience's avatar
AlaskaMapScience committed
828 829 830 831
        if not override_value is None:
            last_read = {'ts': int(time.time()), 'val': float(override_value)}
            last_reads = [last_read]
        elif self.read_count==1:
832 833 834
            last_read = self.sensor.last_read(reading_db)  # will be None if no readings
            last_reads = [last_read] if last_read else []
        else:
Alan Mitchell's avatar
Alan Mitchell committed
835
            # last_read() method returns a list when read_count is > 1.
836 837
            last_reads = self.sensor.last_read(reading_db, self.read_count)
            last_read = last_reads[0] if len(last_reads) else None
838

Alan Mitchell's avatar
Alan Mitchell committed
839 840 841
        # start the subject
        subject = '%s Priority Alert: ' % choice_text(self.priority, AlertCondition.ALERT_PRIORITY_CHOICES)

842 843
        # if the condition test is for an inactive sensor, do that test now.
        # Do not consider the building mode test for this test.
844 845 846 847
        if self.condition=='inactive':
            if self.sensor.is_active(reading_db):
                # Sensor is active, no alert
                return None
848
            else:
849 850 851 852 853 854
                # Sensor is inactive
                if last_read:
                    msg = 'The last reading from the %s was %.1f hours ago.' % \
                        ( sensor_desc, (time.time() - last_read['ts'])/3600.0 )
                else:
                    msg = 'The %s has never posted a reading.' % sensor_desc
855 856 857 858 859 860 861 862

                # Check for user-entered message, & use it ahead of standard message.
                if self.alert_message.strip():
                    user_msg = self.alert_message.strip()
                    if user_msg[-1] != '.':
                        user_msg += '.'
                    msg = '%s %s' % (user_msg, msg)

863 864
                subject += '%s Inactive' % sensor_desc
                return subject, msg
865

866 867 868
        # If there are not enough readings for this sensor, return as the
        # alert condition is not satisfied.
        if len(last_reads) < self.read_count:
869 870
            return None

871 872 873
        # Loop through the requested number of last readings, testing whether
        # the alert conditions are satisfied for all the readings.
        # First see if there was a building mode test requested and test it.
874 875
        if self.only_if_bldg is not None:
            if self.only_if_bldg_mode is not None and (self.only_if_bldg.current_mode != self.only_if_bldg_mode):
876 877
                # Failed building mode test
                return None
878 879 880
            if self.only_if_bldg_status is not None and (self.only_if_bldg.current_status(last_reads[0]['ts']) != self.only_if_bldg_status):
                # Failed building status test
                return None
881 882 883 884 885 886 887

        # Alert condition must be true for each of the requested readings
        for read in last_reads:
            # Evaluate the numeric test condition
            if not eval( '%s %s %s' % (read['val'], self.condition, self.test_value) ):
                # Failed value test
                return None
888

889 890 891 892 893 894 895 896 897 898 899 900 901
        # All of the alert conditions were satisfied,
        # return a subject and message.

        # Get a formatting function for sensor values
        formatter = self.sensor.format_func()
        condition_text = choice_text(self.condition, AlertCondition.CONDITION_CHOICES)
        subject += '%s is %s %s' % (sensor_desc, condition_text, formatter(self.test_value))
        if self.alert_message.strip():
            msg = self.alert_message.strip()
            if msg[-1] != '.':
                msg += '.'
            msg = '%s The current sensor reading is %s %s.' % \
                (msg, formatter(last_read['val']), self.sensor.unit.label)
902
        else:
903 904 905 906 907 908 909 910 911 912 913
            msg = 'The %s has a current reading of %s %s, which is %s %s %s.' % \
                (
                sensor_desc,
                formatter(last_read['val']),
                self.sensor.unit.label,
                condition_text,
                formatter(self.test_value),
                self.sensor.unit.label
                )

        return subject, msg
914 915 916 917 918

    def wait_satisfied(self):
        '''Returns True if there has been enough wait between the last notification
        for this condition and now.
        '''
919 920 921
        return (time.time() >= self.last_notified + self.wait_before_next * 3600.0)


922
class PeriodicScript(models.Model):
Alan Mitchell's avatar
Alan Mitchell committed
923
    """Describes a script that should be run on a periodic basis,
924 925
    often for the purposes of collecting sensor readings to store in the
    reading database.
Alan Mitchell's avatar
Alan Mitchell committed
926
    """
927 928 929 930

    # Name of the script file
    script_file_name = models.CharField('File name of script', max_length=50, blank=False)

931 932 933
    # Optional Description
    description = models.CharField('Optional Description', max_length=80, blank=True)

934 935 936 937
    # How often the script should be run, in units of seconds.
    # Use defined choices; choices must be a multiple of 5 minutes, as that is
    # how frequently the main cron procedure runs.
    PERIOD_CHOICES = (
938
        (0, 'Disabled'),
939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957
        (300, '5 min'),
        (600, '10 min'),
        (900, '15 min'),
        (1800, '30 min'),
        (3600, '1 hr'),
        (7200, '2 hr'),
        (14400, '4 hr'),
        (21600, '6 hr'),
        (43200, '12 hr'),
        (86400, '24 hr')
    )
    period = models.IntegerField('How often should script run', default=3600, choices=PERIOD_CHOICES)

    # Parameters for the script, if any, given in YAML form.
    script_parameters = models.TextField("Script Parameters in YAML form", blank=True)

    # Results of the script saved and passed to the next invocation of the script.
    script_results = models.TextField('Script results in YAML form', blank=True)

958 959 960 961
    # Results of the Script that are *not* displayed in the Admin interface.  Useful
    # for storing authorization tokens or other credentials.
    hidden_script_results = models.TextField('Hidden Script results in YAML form', blank=True)

Alan Mitchell's avatar
Alan Mitchell committed
962
    def __str__(self):
963 964
        return '%s -- %s' % (self.script_file_name, self.script_parameters.replace('\n', ', '))

Ian Moore's avatar
Ian Moore committed
965 966 967 968 969 970 971 972
class CustomReport(models.Model):
    """Defines a custom report with text and widgets defined by the user.
    """
    
    # Name of the group that the report is listed under
    group = models.CharField(max_length=80)

    # Name of the report
973
    title = models.CharField(max_length=80)
Ian Moore's avatar
Ian Moore committed
974 975 976 977 978 979 980
    
    # Determines the order within the group that the report will be presented in the UI
    sort_order = models.IntegerField(default=999)
    
    # Markdown text that defines what will appear in the report
    markdown_text = models.TextField(verbose_name='Report Content (in markdown):', help_text='Use <a href="http://markdowntutorial.com/">markdown syntax</a> to add links, pictures, etc.  Note that you <b>must</b> include the url prefix (e.g. <i>http://</i>) in any external links.', blank=True)    

Alan Mitchell's avatar
Alan Mitchell committed
981
    def __str__(self):
Ian Moore's avatar
Ian Moore committed
982 983 984 985 986
        return self.title

    class Meta:
        ordering = ['group','sort_order', 'title']
        
987

988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
class Organization(models.Model):
    """Defines an Organization that the user can use to filter the 
    buildings and building groups shown in the user interface.
    """
    
    # Name of the Organization
    title = models.CharField(max_length=80, unique=True)
    
    # Determines the order that the Organization will be presented in the UI
    sort_order = models.IntegerField(default=999)
    
    # The buildings that are present in this Organization
    buildings = models.ManyToManyField(Building)
    
    # The building groups that are present in this Organization
    building_groups = models.ManyToManyField(BuildingGroup, blank=True)

    # Multi-Building Charts associated with this Organization
    multi_charts = models.ManyToManyField(MultiBuildingChart, blank=True)

    # Custom Reports associated with this Organization
    custom_reports = models.ManyToManyField(CustomReport, blank=True)

Alan Mitchell's avatar
Alan Mitchell committed
1011
    def __str__(self):
1012 1013 1014 1015 1016 1017
        return self.title

    class Meta:
        ordering = ['sort_order', 'title']


1018
def choice_text(val, choices):
Alan Mitchell's avatar
Alan Mitchell committed
1019
    """Returns the display text associated with the choice value 'val'
1020 1021 1022 1023
    from a list of Django character field choices 'choices'.  The 'choices'
    list is a list of two-element tuples, the first item being the stored
    value and the second item being the displayed value.  Returns None if 
    val is not found inthe choice list.
Alan Mitchell's avatar
Alan Mitchell committed
1024
    """
1025 1026 1027 1028
    for choice in choices:
        if choice[0]==val:
            return choice[1]
    return None