Commits (5)
...@@ -90,13 +90,14 @@ class AlertAdminInline(admin.StackedInline): ...@@ -90,13 +90,14 @@ class AlertAdminInline(admin.StackedInline):
'''Used in the Sensor Admin to enter alerts. '''Used in the Sensor Admin to enter alerts.
''' '''
model = AlertCondition model = AlertCondition
template = "admin/stacked_alerts.html"
extra = 0 extra = 0
filter_horizontal = ('recipients',) filter_horizontal = ('recipients',)
fieldsets = ( fieldsets = (
(None, {'fields': ( ('active', 'sensor'), (None, {'fields': ( ('active', 'sensor'),
('condition', 'test_value', 'read_count'), ('condition', 'test_value', 'read_count'),
('only_if_bldg', 'only_if_bldg_mode'), ('only_if_bldg', 'only_if_bldg_mode','only_if_bldg_status'),
('alert_message',), ('alert_message',),
('priority', 'wait_before_next'), ('priority', 'wait_before_next'),
('recipients',) ('recipients',)
...@@ -243,6 +244,7 @@ class MultiBuildingChartAdmin(admin.ModelAdmin): ...@@ -243,6 +244,7 @@ class MultiBuildingChartAdmin(admin.ModelAdmin):
@admin.register(AlertRecipient) @admin.register(AlertRecipient)
class AlertRecipientAdmin(admin.ModelAdmin): class AlertRecipientAdmin(admin.ModelAdmin):
change_form_template = 'admin/AlertRecipient_change_form.html'
list_display = ('name', 'active' ) list_display = ('name', 'active' )
list_editable= ('active',) list_editable= ('active',)
fields = ( fields = (
......
...@@ -6,6 +6,7 @@ from datetime import datetime ...@@ -6,6 +6,7 @@ from datetime import datetime
import pytz, calendar, time, math import pytz, calendar, time, math
from dateutil import parser from dateutil import parser
import numpy as np import numpy as np
import pandas as pd
from django.conf import settings from django.conf import settings
...@@ -94,7 +95,7 @@ def histogram_from_series(pandas_series): ...@@ -94,7 +95,7 @@ def histogram_from_series(pandas_series):
# to 4 significant figures # to 4 significant figures
return list(zip(avg_bins, cts)) return list(zip(avg_bins, cts))
def resample_timeseries(pandas_dataframe, averaging_hours, drop_na=True): def resample_timeseries(pandas_dataframe, averaging_hours, use_rolling_averaging=False, drop_na=True):
''' '''
Returns a new pandas dataframe that is resampled at the specified "averaging_hours" Returns a new pandas dataframe that is resampled at the specified "averaging_hours"
interval. If the 'averaging_hours' parameter is fractional, the averaging time interval. If the 'averaging_hours' parameter is fractional, the averaging time
...@@ -117,7 +118,20 @@ def resample_timeseries(pandas_dataframe, averaging_hours, drop_na=True): ...@@ -117,7 +118,20 @@ def resample_timeseries(pandas_dataframe, averaging_hours, drop_na=True):
} }
params = interval_lookup.get(averaging_hours, {'rule':str(int(averaging_hours * 60)) + 'min', 'loffset':str(int(averaging_hours * 30)) + 'min'}) params = interval_lookup.get(averaging_hours, {'rule':str(int(averaging_hours * 60)) + 'min', 'loffset':str(int(averaging_hours * 30)) + 'min'})
new_df = pandas_dataframe.resample(rule=params['rule'], loffset=params['loffset'],label='left').mean() if not use_rolling_averaging:
new_df = pandas_dataframe.resample(rule=params['rule'], loffset=params['loffset'],label='left').mean()
else:
# resample to consistent interval
original_interval = pandas_dataframe.index.to_series().diff().quantile(.05)
new_df = pandas_dataframe.resample(rule=original_interval).median()
# apply the rolling averaging
new_df = new_df.rolling(int(pd.Timedelta(hours=averaging_hours) / original_interval),center=True,min_periods=1).mean()
# downsample the result if there are more than 1000 values
if len(new_df) > 1000:
new_df = new_df.resample(rule=(pandas_dataframe.index[-1] - pandas_dataframe.index[0]) / 1000).mean()
if drop_na: if drop_na:
new_df = new_df.dropna() new_df = new_df.dropna()
......
...@@ -116,7 +116,7 @@ def decode_boat_lt2(data: bytes) -> Dict[str, Any]: ...@@ -116,7 +116,7 @@ def decode_boat_lt2(data: bytes) -> Dict[str, Any]:
# ------- Shore Power # ------- Shore Power
shoreV = int16(0) / 1000. # voltage from wall wart in Volts shoreV = int16(0) / 1000. # voltage from wall wart in Volts
res['shorePower'] = 1 if shoreV > 4.3 and shoreV < 5.5 else 0 res['shorePower'] = 1 if shoreV > 4.3 and shoreV < 5.75 else 0
# ---- Battery voltage # ---- Battery voltage
batV = int16(2) / 1000. batV = int16(2) / 1000.
......
# Generated by Django 2.2.4 on 2021-04-02 22:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bmsapp', '0032_auto_20190926_1206'),
]
operations = [
migrations.AddField(
model_name='alertcondition',
name='only_if_bldg_status',
field=models.CharField(blank=True, choices=[('Occupied', 'Occupied'), ('Unoccupied', 'Unoccupied')], max_length=15, null=True, verbose_name='and this status'),
),
]
...@@ -8,6 +8,7 @@ from django.core.mail import send_mail ...@@ -8,6 +8,7 @@ from django.core.mail import send_mail
import requests import requests
import bmsapp.data_util import bmsapp.data_util
import bmsapp.formatters import bmsapp.formatters
import bmsapp.schedule
from . import sms_gateways from . import sms_gateways
import yaml import yaml
...@@ -409,6 +410,21 @@ class Building(models.Model): ...@@ -409,6 +410,21 @@ class Building(models.Model):
return props return props
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'
class Meta: class Meta:
ordering = ['title'] ordering = ['title']
...@@ -716,6 +732,7 @@ class AlertCondition(models.Model): ...@@ -716,6 +732,7 @@ class AlertCondition(models.Model):
# fields to qualify the condition test according to building mode # fields to qualify the condition test according to building mode
only_if_bldg = models.ForeignKey(Building, models.SET_NULL, verbose_name='But only if building', blank=True, null=True) 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) only_if_bldg_mode = models.ForeignKey(BuildingMode, models.SET_NULL, verbose_name='is in this mode', blank=True, null=True)
only_if_bldg_status = models.CharField(verbose_name='and this status', max_length=15, blank=True, null=True, choices=(('Occupied','Occupied'),('Unoccupied','Unoccupied')))
# alert message. If left blank a message will be created from other field values. # alert message. If left blank a message will be created from other field values.
alert_message = models.TextField(max_length=400, blank=True, alert_message = models.TextField(max_length=400, blank=True,
...@@ -751,12 +768,49 @@ class AlertCondition(models.Model): ...@@ -751,12 +768,49 @@ class AlertCondition(models.Model):
return '%s %s %s, %s in %s mode' % \ 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) (self.sensor.title, self.condition, self.test_value, self.only_if_bldg, self.only_if_bldg_mode)
def check_condition(self, reading_db): 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):
'''This method checks to see if the alert condition is in effect, and if so, '''This method checks to see if the alert condition is in effect, and if so,
returns a (subject, message) tuple describing the alert. If the condition is 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 not in effect, None is returned. 'reading_db' is a sensor reading database, an
instance ofbmsapp.readingdb.bmsdata.BMSdata. If the alert condition is not active, instance ofbmsapp.readingdb.bmsdata.BMSdata. If an 'override_value' is
None is returned. 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.
''' '''
if not self.active: if not self.active:
...@@ -771,7 +825,10 @@ class AlertCondition(models.Model): ...@@ -771,7 +825,10 @@ class AlertCondition(models.Model):
# get the most current reading for the sensor (last_read), and # get the most current reading for the sensor (last_read), and
# also fill out a list of all the recent readings that need to # also fill out a list of all the recent readings that need to
# be evaluated to determine if the alert condition is true (last_reads). # be evaluated to determine if the alert condition is true (last_reads).
if self.read_count==1: 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:
last_read = self.sensor.last_read(reading_db) # will be None if no readings last_read = self.sensor.last_read(reading_db) # will be None if no readings
last_reads = [last_read] if last_read else [] last_reads = [last_read] if last_read else []
else: else:
...@@ -814,10 +871,13 @@ class AlertCondition(models.Model): ...@@ -814,10 +871,13 @@ class AlertCondition(models.Model):
# Loop through the requested number of last readings, testing whether # Loop through the requested number of last readings, testing whether
# the alert conditions are satisfied for all the readings. # the alert conditions are satisfied for all the readings.
# First see if there was a building mode test requested and test it. # First see if there was a building mode test requested and test it.
if self.only_if_bldg is not None and self.only_if_bldg_mode is not None: if self.only_if_bldg is not None:
if self.only_if_bldg.current_mode != self.only_if_bldg_mode: if self.only_if_bldg_mode is not None and (self.only_if_bldg.current_mode != self.only_if_bldg_mode):
# Failed building mode test # Failed building mode test
return None return None
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
# Alert condition must be true for each of the requested readings # Alert condition must be true for each of the requested readings
for read in last_reads: for read in last_reads:
......
...@@ -56,6 +56,15 @@ class BMSdata: ...@@ -56,6 +56,15 @@ class BMSdata:
self.conn.commit() self.conn.commit()
self.sensor_ids.add('_last_raw') self.sensor_ids.add('_last_raw')
# Check to see if the table that stores alert log records exists.
# If not, make it. Make the value field a
# real instead of integer in case this table is needed for non counter
# sensors in the future.
if '_alert_log' not in self.sensor_ids:
self.cursor.execute("CREATE TABLE [_alert_log] (id varchar(50), ts integer, message varchar(255))")
self.conn.commit()
self.sensor_ids.add('_alert_log')
# because SQLite has case insensitive table names, make a sensor ID set with lower-case names # because SQLite has case insensitive table names, make a sensor ID set with lower-case names
self.sensor_ids_lower = {tbl.lower() for tbl in self.sensor_ids} self.sensor_ids_lower = {tbl.lower() for tbl in self.sensor_ids}
...@@ -335,6 +344,14 @@ class BMSdata: ...@@ -335,6 +344,14 @@ class BMSdata:
id_list = [sens_id for sens_id in self.sensor_ids if sens_id[0]!='_'] id_list = [sens_id for sens_id in self.sensor_ids if sens_id[0]!='_']
return sorted(id_list) return sorted(id_list)
def log_alert(self, sensor_id, message):
"""Stores a log of an alert nofification
"""
ts = int(time.time())
self.cursor.execute("INSERT INTO [_alert_log] (id, ts, message) VALUES (?, ?, ?)", (sensor_id, ts, message))
self.conn.commit()
def backup_db(self, days_to_retain): def backup_db(self, days_to_retain):
"""Backs up the database and compresses the backup. Deletes old backup """Backs up the database and compresses the backup. Deletes old backup
files that were created more than 'days_to_retain' ago. files that were created more than 'days_to_retain' ago.
......
...@@ -10,7 +10,7 @@ class TimeSeries(basechart.BaseChart): ...@@ -10,7 +10,7 @@ class TimeSeries(basechart.BaseChart):
""" """
# see BaseChart for definition of these constants # see BaseChart for definition of these constants
CTRLS = 'refresh, ctrl_sensor, ctrl_avg, ctrl_occupied, time_period_group, get_embed_link' CTRLS = 'refresh, ctrl_sensor, ctrl_avg, ctrl_use_rolling_averaging, ctrl_occupied, time_period_group, get_embed_link'
MULTI_SENSOR = 1 MULTI_SENSOR = 1
def result(self): def result(self):
...@@ -28,8 +28,15 @@ class TimeSeries(basechart.BaseChart): ...@@ -28,8 +28,15 @@ class TimeSeries(basechart.BaseChart):
# get the requested averaging interval in hours # get the requested averaging interval in hours
if 'averaging_time' in self.request_params: if 'averaging_time' in self.request_params:
averaging_hours = float(self.request_params['averaging_time']) averaging_hours = float(self.request_params['averaging_time'])
if 'use_rolling_averaging' in self.request_params:
use_rolling_averaging = True
else:
use_rolling_averaging = False
else: else:
averaging_hours = 0 averaging_hours = 0
use_rolling_averaging = False
# determine the start time for selecting records and loop through the selected # determine the start time for selecting records and loop through the selected
# records to get the needed dataset # records to get the needed dataset
...@@ -51,7 +58,7 @@ class TimeSeries(basechart.BaseChart): ...@@ -51,7 +58,7 @@ class TimeSeries(basechart.BaseChart):
if not df.empty: if not df.empty:
# perform average (if requested) # perform average (if requested)
if averaging_hours: if averaging_hours:
df = bmsapp.data_util.resample_timeseries(df,averaging_hours) df = bmsapp.data_util.resample_timeseries(df,averaging_hours,use_rolling_averaging)
# create lists for plotly # create lists for plotly
if np.absolute(df.val.values).max() < 10000: if np.absolute(df.val.values).max() < 10000:
......
...@@ -10,7 +10,7 @@ class Schedule: ...@@ -10,7 +10,7 @@ class Schedule:
""" This class represents an occupied/unoccupied schedule for a facility. """ This class represents an occupied/unoccupied schedule for a facility.
""" """
def __init__(self, schedule_text, tz_name): def __init__(self, schedule_text, tz_name = 'US/Alaska'):
""" Constructs a Schedule object. """ Constructs a Schedule object.
'schedule_text' is a string that describes the occupied periods 'schedule_text' is a string that describes the occupied periods
......
...@@ -26,29 +26,6 @@ def run(): ...@@ -26,29 +26,6 @@ def run():
total_true_alerts = 0 total_true_alerts = 0
for condx in AlertCondition.objects.all(): for condx in AlertCondition.objects.all():
total_true_alerts += condx.handle(reading_db, logger)
# if the wait time has not been satisfied, don't notify
if time.time() < (condx.last_notified + condx.wait_before_next * 3600.0):
continue
try:
subject_msg = condx.check_condition(reading_db)
if subject_msg:
total_true_alerts += 1
subject, msg = subject_msg
msg_count = 0 # tracks # of successful messages sent
for recip in condx.recipients.all():
try:
msg_count += recip.notify(subject, msg, condx.priority)
except:
logger.exception('Error notifying recipient %s of an alert.' % recip)
if msg_count:
# at least one message was sent so update the field tracking the timestamp
# of the last notification for this condition.
condx.last_notified = time.time()
condx.save()
except:
logger.exception('Error processing alert %s')
return total_true_alerts return total_true_alerts
...@@ -108,7 +108,7 @@ process_chart_change = -> ...@@ -108,7 +108,7 @@ process_chart_change = ->
# start by hiding all input controls # start by hiding all input controls
set_visibility(['refresh', 'ctrl_sensor', 'ctrl_avg', 'ctrl_avg_export', set_visibility(['refresh', 'ctrl_sensor', 'ctrl_avg', 'ctrl_avg_export',
'ctrl_normalize', 'ctrl_occupied', 'xy_controls', 'time_period_group', 'ctrl_normalize', 'ctrl_occupied', 'xy_controls', 'time_period_group',
'download_many', 'get_embed_link'], false) 'download_many', 'get_embed_link','ctrl_use_rolling_averaging'], false)
# get the chart option control that is selected. Then use the data # get the chart option control that is selected. Then use the data
# attributes of that option element to configure the user interface. # attributes of that option element to configure the user interface.
...@@ -320,7 +320,7 @@ $ -> ...@@ -320,7 +320,7 @@ $ ->
$("#select_chart").change process_chart_change $("#select_chart").change process_chart_change
# Set up change handlers for inputs. # Set up change handlers for inputs.
ctrls = ['averaging_time', 'averaging_time_export', 'normalize', 'show_occupied', ctrls = ['averaging_time', 'averaging_time_export', 'normalize', 'use_rolling_averaging', 'show_occupied',
'select_sensor', 'select_sensor_x', 'select_sensor_y', 'averaging_time_xy', 'div_date', 'select_sensor', 'select_sensor_x', 'select_sensor_y', 'averaging_time_xy', 'div_date',
'start_date', 'end_date'] 'start_date', 'end_date']
$("##{ctrl}").change inputs_changed for ctrl in ctrls $("##{ctrl}").change inputs_changed for ctrl in ctrls
......
...@@ -101,7 +101,7 @@ ...@@ -101,7 +101,7 @@
process_chart_change = function () { process_chart_change = function () {
var multi, selected_chart_option, sensor_val, single, vis_ctrls; var multi, selected_chart_option, sensor_val, single, vis_ctrls;
set_visibility(['refresh', 'ctrl_sensor', 'ctrl_avg', 'ctrl_avg_export', 'ctrl_normalize', 'ctrl_occupied', 'xy_controls', 'time_period_group', 'download_many', 'get_embed_link'], false); set_visibility(['refresh', 'ctrl_sensor', 'ctrl_avg', 'ctrl_avg_export', 'ctrl_normalize', 'ctrl_use_rolling_averaging', 'ctrl_occupied', 'xy_controls', 'time_period_group', 'download_many', 'get_embed_link'], false);
selected_chart_option = $("#select_chart").find("option:selected"); selected_chart_option = $("#select_chart").find("option:selected");
vis_ctrls = selected_chart_option.data("ctrls").split(","); vis_ctrls = selected_chart_option.data("ctrls").split(",");
set_visibility(vis_ctrls, true); set_visibility(vis_ctrls, true);
...@@ -299,7 +299,7 @@ ...@@ -299,7 +299,7 @@
$("#select_group").change(update_bldg_list); $("#select_group").change(update_bldg_list);
$("#select_bldg").change(update_chart_sensor_lists); $("#select_bldg").change(update_chart_sensor_lists);
$("#select_chart").change(process_chart_change); $("#select_chart").change(process_chart_change);
ctrls = ['averaging_time', 'averaging_time_export', 'normalize', 'show_occupied', 'select_sensor', 'select_sensor_x', 'select_sensor_y', 'averaging_time_xy', 'div_date', 'start_date', 'end_date']; ctrls = ['averaging_time', 'averaging_time_export', 'normalize', 'use_rolling_averaging', 'show_occupied', 'select_sensor', 'select_sensor_x', 'select_sensor_y', 'averaging_time_xy', 'div_date', 'start_date', 'end_date'];
for (i = 0, len = ctrls.length; i < len; i++) { for (i = 0, len = ctrls.length; i < len; i++) {
ctrl = ctrls[i]; ctrl = ctrls[i];
$("#" + ctrl).change(inputs_changed); $("#" + ctrl).change(inputs_changed);
......
{% extends "admin/change_form.html" %}
{% block after_related_objects %}
<fieldset class="module aligned">
<div class="form-row">
<div>
<label for="btn_trigger_alert">Testing:</label>
<button type="button" id="btn_trigger_alert" onclick="testAlert({{ original.id }});">Send A Test Notification</button>
<div class="help">You should 'Save' any changes first.</div>
</div>
</div>
</fieldset>
<script
src="https://code.jquery.com/jquery-3.4.0.min.js"
integrity="sha256-BJeo0qm959uMBGb65z40ejJYGSgR7REI4+CW1fNKwOg="
crossorigin="anonymous"></script>
<script>
var cookies = document.cookie
.split(';')
.map(v => v.split('='))
.reduce((acc, v) => {
acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
return acc;
}, {});
function testAlert(recipientID) {
$.post( {
url: "/test-alert-notifications/",
data: {'recipient': recipientID},
headers: {'X-CSRFToken': cookies['csrftoken']}
})
.done(function( data ) {
alert( data )})
.fail(function (jqXHR, textStatus, errorThrown) {
alert( jqXHR.responseText )});
}
</script>
{% endblock %}
\ No newline at end of file
{% load i18n admin_urls static %}
<div class="js-inline-admin-formset inline-group"
id="{{ inline_admin_formset.formset.prefix }}-group"
data-inline-type="stacked"
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
<fieldset class="module {{ inline_admin_formset.classes }}">
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
{{ inline_admin_formset.formset.management_form }}
{{ inline_admin_formset.formset.non_form_errors }}
{% for inline_admin_form in inline_admin_formset %}<div class="inline-related{% if inline_admin_form.original or inline_admin_form.show_url %} has_original{% endif %}{% if forloop.last and inline_admin_formset.has_add_permission %} empty-form last-related{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
<h3><b>{{ inline_admin_formset.opts.verbose_name|capfirst }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %} <a href="{% url inline_admin_form.model_admin.opts|admin_urlname:'change' inline_admin_form.original.pk|admin_urlquote %}" class="{% if inline_admin_formset.has_change_permission %}inlinechangelink{% else %}inlineviewlink{% endif %}">{% if inline_admin_formset.has_change_permission %}{% trans "Change" %}{% else %}{% trans "View" %}{% endif %}</a>{% endif %}
{% else %}#{{ forloop.counter }}{% endif %}</span>
{% if inline_admin_form.show_url %}<a href="{{ inline_admin_form.absolute_url }}">{% trans "View on site" %}</a>{% endif %}
{% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
</h3>
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
{% for fieldset in inline_admin_form %}
{% include "admin/includes/fieldset.html" %}
{% endfor %}
{% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
{% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
{% comment "Alternate Implementation - Replace the above with this include statement" %}
{% include "admin\edit_inline\stacked.html" %}
{% endcomment %}
<fieldset class="module aligned">
<div class="form-row">
<div>
<label for="btn_test_alert_value">Test a Value:</label>
<input type="number" name="alertcondition_test_value" value="0" step="any" id="alertcondition_test_value">
<button type="button" id="btn_test_alert_value" onclick="testAlertValue(this.form);">Check Value</button>
<div class="help">You should 'Save' any changes first.</div>
</div>
</div>
</fieldset>
</div>{% endfor %}
</fieldset>
</div>
<script
src="https://code.jquery.com/jquery-3.4.0.min.js"
integrity="sha256-BJeo0qm959uMBGb65z40ejJYGSgR7REI4+CW1fNKwOg="
crossorigin="anonymous"></script>
<script>
var cookies = document.cookie
.split(';')
.map(v => v.split('='))
.reduce((acc, v) => {
acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
return acc;
}, {});
function testAlertValue(currentForm) {
var formData = new FormData(currentForm);
$.post( {
url: "/test-alert-value/",
data: {'sensor': formData.get('alertcondition_set-0-sensor'),
'test_value': formData.get('alertcondition_test_value')},
headers: {'X-CSRFToken': cookies['csrftoken']}
})
.done(function( data ) {
alert( data )})
.fail(function (jqXHR, textStatus, errorThrown) {
alert( jqXHR.responseText )});
}
</script>
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
<li><a href="{{ bmsapp_nav_link_base_url }}admin/">System Administration: Configure BMON System</a></li> <li><a href="{{ bmsapp_nav_link_base_url }}admin/">System Administration: Configure BMON System</a></li>
<li><a href="{{ bmsapp_nav_link_base_url }}unassigned-sensors/">Unassigned Sensors: View and Delete</a></li> <li><a href="{{ bmsapp_nav_link_base_url }}unassigned-sensors/">Unassigned Sensors: View and Delete</a></li>
<li><a href="{{ bmsapp_nav_link_base_url }}sensor-data-utilities/">Sensor Data Utilities (Eliminate Bad Data, Merge Sensors)</a></li> <li><a href="{{ bmsapp_nav_link_base_url }}sensor-data-utilities/">Sensor Data Utilities (Eliminate Bad Data, Merge Sensors)</a></li>
<li><a href="{{ bmsapp_nav_link_base_url }}alert-log/">Alert Logs: View and Export</a></li>
<li><a href="{{ bmsapp_nav_link_base_url }}ecobee-auth/">Authorize Access to an Ecobee Thermostat Account</a></li> <li><a href="{{ bmsapp_nav_link_base_url }}ecobee-auth/">Authorize Access to an Ecobee Thermostat Account</a></li>
</ul> </ul>
......
{% extends "bmsapp/base.html" %}
{% block pagetitle %}Alert Logs{% endblock %}
{% block title %}Alert notifications that have been sent{% endblock %}
{% block content %}
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/v/bs4/jszip-2.5.0/dt-1.10.24/b-1.7.0/b-html5-1.7.0/sl-1.3.3/datatables.min.css"/>
<script type="text/javascript" src="https://cdn.datatables.net/v/bs4/jszip-2.5.0/dt-1.10.24/b-1.7.0/b-html5-1.7.0/sl-1.3.3/datatables.min.js"></script>
<script>
var cookies = document.cookie
.split(';')
.map(v => v.split('='))
.reduce((acc, v) => {
acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
return acc;
}, {});
$(function () {
$.fn.dataTable.Buttons.defaults.dom.button.className = 'btn';
var alert_table = $('#Alerts').DataTable({
paging: false,
scrollY: '80vh',
scrollCollapse: true,
columnDefs: [ {
orderable: false,
className: 'select-checkbox',
targets: 0
} ],
dom: 'Bfrtip',
buttons: [
{
text: 'Select all',
className: 'btn-secondary',
action: function (e, dt, node, config) {
dt.rows().deselect();
dt.rows( { search: 'applied' } ).select();
}
},
'selectNone',
'excel'
],
select: {
style: 'multi',
selector: 'td:first-child'
},
order: [[ 1, 'asc' ]]
});
});
</script>
<div style="background-color: white; width: fit-content; padding: 5px;">
<table id="Alerts" class="table table-sm table-striped">
<thead><tr>
<th>&nbsp;&nbsp;&nbsp;&nbsp;</th>
<th scope="col" class="text-center">Sensor ID</th>
<th scope="col" class="text-center">Timestamp</th>
<th scope="col" class="text-center">Message</th>
</tr></thead>
<tbody style="background-color: white">
{% for alert in alert_list %}
<tr id="{{ alert.id }}">
<td></td>
<td class="bmon-sensor-id">{{ alert.id }}</td>
<td>{{ alert.when }}</td>
<td>{{ alert.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
...@@ -91,6 +91,13 @@ ...@@ -91,6 +91,13 @@
Shade Occupied Periods Shade Occupied Periods
</label> </label>
</div> </div>
<div id="ctrl_use_rolling_averaging" class="form-check mb-4 ml-3">
<input class="form-check-input" type="checkbox" value="" id="use_rolling_averaging"
name="use_rolling_averaging">
<label class="form-check-label" for="use_rolling_averaging">
Use Rolling Averaging
</label>
</div>
<div id="ctrl_normalize" class="form-check mb-4 ml-3"> <div id="ctrl_normalize" class="form-check mb-4 ml-3">
<input class="form-check-input" type="checkbox" value="" id="normalize" <input class="form-check-input" type="checkbox" value="" id="normalize"
name="normalize"> name="normalize">
......
...@@ -40,6 +40,7 @@ urlpatterns = [ ...@@ -40,6 +40,7 @@ urlpatterns = [
views.show_video, name='show-video'), views.show_video, name='show-video'),
re_path(r'^make-store-key/$', views.make_store_key), re_path(r'^make-store-key/$', views.make_store_key),
re_path(r'^ecobee-auth/$', views.ecobee_auth), re_path(r'^ecobee-auth/$', views.ecobee_auth),
re_path(r'^unassigned-sensors/$', views.unassigned_sensors), re_path(r'^unassigned-sensors/$', views.unassigned_sensors),
re_path(r'^unassigned-sensors/delete_ids/$', re_path(r'^unassigned-sensors/delete_ids/$',
views.delete_unassigned_sensor_ids), views.delete_unassigned_sensor_ids),
...@@ -48,6 +49,10 @@ urlpatterns = [ ...@@ -48,6 +49,10 @@ urlpatterns = [
re_path(r'^merge-sensors/$', views.merge_sensors), re_path(r'^merge-sensors/$', views.merge_sensors),
re_path(r'^delete-sensor-values/$', views.delete_sensor_values), re_path(r'^delete-sensor-values/$', views.delete_sensor_values),
re_path(r'^test-alert-notifications/$', views.test_alert_notifications),
re_path(r'^test-alert-value/$', views.test_alert_value),
re_path(r'^alert-log/$', views.alert_log),
# Views related to the API, version 1 # Views related to the API, version 1
re_path(r'^api/v1/version/$', views_api_v1.api_version), re_path(r'^api/v1/version/$', views_api_v1.api_version),
re_path(r'^api/v1/readings/(.+)/$', views_api_v1.sensor_readings), re_path(r'^api/v1/readings/(.+)/$', views_api_v1.sensor_readings),
......
...@@ -782,6 +782,20 @@ def delete_unassigned_sensor_ids(request): ...@@ -782,6 +782,20 @@ def delete_unassigned_sensor_ids(request):
return HttpResponse(f'Deleted {len(row_ids)} unassigned sensors') return HttpResponse(f'Deleted {len(row_ids)} unassigned sensors')
@login_required(login_url='../admin/login/')
def alert_log(request):
"""Shows a log of alerts that have been issued.
"""
db = bmsdata.BMSdata()
db.cursor.execute('SELECT * FROM [_alert_log]')
alert_list = [{**x,'when':time.strftime('%Y-%m-%d %M:%S',time.localtime(x['ts']))} for x in [dict(r) for r in db.cursor.fetchall()]]
# time.strftime('%Y-%m-%dT%M:%S',time.localtime(alert_list[0]['ts']))
ctx = base_context()
ctx.update({'alert_list': alert_list})
return render_to_response('bmsapp/alert-log.html', ctx)
@login_required(login_url='../admin/login/') @login_required(login_url='../admin/login/')
def backup_reading_db(request): def backup_reading_db(request):
...@@ -790,6 +804,32 @@ def backup_reading_db(request): ...@@ -790,6 +804,32 @@ def backup_reading_db(request):
bmsapp.scripts.backup_readingdb.run() bmsapp.scripts.backup_readingdb.run()
return HttpResponse('Sensor Reading Backup Complete!') return HttpResponse('Sensor Reading Backup Complete!')
def test_alert_notifications(request):
"""Send test notifications
"""
try:
recipient = request.POST.get('recipient')
recip = models.AlertRecipient.objects.filter(id=recipient)[0]
result = recip.notify(subject='BMON Test Alert', message='This is a test of a BMON notification that was sent manually by an administrator.', pushover_priority='0')
if result:
return HttpResponse(f'The test notifications have been sent.')
else:
return HttpResponse(f'Failed to send any notifications!',status=500)
except Exception as e:
return HttpResponse(e,status=500)
def test_alert_value(request):
"""Test a value to see if it triggers an alert
"""
sensor_id = request.POST.get('sensor')
test_value = request.POST.get('test_value')
alert = models.AlertCondition.objects.filter(sensor=sensor_id)[0]
result = alert.check_condition(reading_db=None, override_value=test_value)
if result:
subject, msg = result
return HttpResponse(f'The value triggered an Alert:\n{subject}\n{msg}')
else:
return HttpResponse(f'The value did not trigger an Alert')
def wildcard(request, template_name): def wildcard(request, template_name):
''' '''
......