NEMO-monitors 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nemo_monitors-1.0.0/LICENSE +6 -0
- nemo_monitors-1.0.0/NEMO_monitors/__init__.py +0 -0
- nemo_monitors-1.0.0/NEMO_monitors/admin.py +207 -0
- nemo_monitors-1.0.0/NEMO_monitors/alerts.py +36 -0
- nemo_monitors-1.0.0/NEMO_monitors/api.py +118 -0
- nemo_monitors-1.0.0/NEMO_monitors/apps.py +31 -0
- nemo_monitors-1.0.0/NEMO_monitors/control_limits.py +56 -0
- nemo_monitors-1.0.0/NEMO_monitors/customizations.py +10 -0
- nemo_monitors-1.0.0/NEMO_monitors/html_utils.py +35 -0
- nemo_monitors-1.0.0/NEMO_monitors/management/__init__.py +0 -0
- nemo_monitors-1.0.0/NEMO_monitors/management/commands/__init__.py +0 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0001_initial.py +232 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0002_monitorcolumn_control_limits.py +40 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0003_remove_monitor_data_unit_label.py +15 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0004_monitor_alert_column_recipients.py +51 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0005_monitoralertlog_acknowledgement.py +30 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/0006_remove_monitoralertlog_acknowledgement.py +19 -0
- nemo_monitors-1.0.0/NEMO_monitors/migrations/__init__.py +0 -0
- nemo_monitors-1.0.0/NEMO_monitors/models.py +479 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chart.js +13269 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chart.min.js +13 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chartjs-adapter-moment.js +7 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/daterangepicker.css +410 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/daterangepicker.js +1578 -0
- nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/monitors.css +64 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/alert_log_table.html +24 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_alert_config.html +138 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_alerts.html +1 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_data.html +1130 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_form.html +293 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors.html +56 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors_for_tool.html +239 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors_upload_hub.html +145 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/base/navbar.html +1 -0
- nemo_monitors-1.0.0/NEMO_monitors/templates/customizations/customizations_monitors.html +64 -0
- nemo_monitors-1.0.0/NEMO_monitors/tests/__init__.py +0 -0
- nemo_monitors-1.0.0/NEMO_monitors/tests/test_control_limits.py +54 -0
- nemo_monitors-1.0.0/NEMO_monitors/tests/test_html_utils.py +26 -0
- nemo_monitors-1.0.0/NEMO_monitors/tests/test_monitor_alerts.py +86 -0
- nemo_monitors-1.0.0/NEMO_monitors/tests/test_settings.py +87 -0
- nemo_monitors-1.0.0/NEMO_monitors/urls.py +70 -0
- nemo_monitors-1.0.0/NEMO_monitors/views.py +1246 -0
- nemo_monitors-1.0.0/NEMO_monitors.egg-info/PKG-INFO +239 -0
- nemo_monitors-1.0.0/NEMO_monitors.egg-info/SOURCES.txt +49 -0
- nemo_monitors-1.0.0/NEMO_monitors.egg-info/dependency_links.txt +1 -0
- nemo_monitors-1.0.0/NEMO_monitors.egg-info/requires.txt +12 -0
- nemo_monitors-1.0.0/NEMO_monitors.egg-info/top_level.txt +1 -0
- nemo_monitors-1.0.0/PKG-INFO +239 -0
- nemo_monitors-1.0.0/README.md +198 -0
- nemo_monitors-1.0.0/pyproject.toml +55 -0
- nemo_monitors-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# NIST Software Licensing Statement
|
|
2
|
+
NIST-developed software is provided by NIST as a public service. You may use, copy, and distribute copies of the software in any medium, provided that you keep intact this entire notice. You may improve, modify, and create derivative works of the software or any portion of the software, and you may copy and distribute such modifications or works. Modified works should carry a notice stating that you changed the software and should note the date and nature of any such change. Please explicitly acknowledge the National Institute of Standards and Technology as the source of the software.
|
|
3
|
+
|
|
4
|
+
NIST-developed software is expressly provided "AS IS." NIST MAKES NO WARRANTY OF ANY KIND, EXPRESS, IMPLIED, IN FACT, OR ARISING BY OPERATION OF LAW, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND DATA ACCURACY. NIST NEITHER REPRESENTS NOR WARRANTS THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT ANY DEFECTS WILL BE CORRECTED. NIST DOES NOT WARRANT OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF THE SOFTWARE OR THE RESULTS THEREOF, INCLUDING BUT NOT LIMITED TO THE CORRECTNESS, ACCURACY, RELIABILITY, OR USEFULNESS OF THE SOFTWARE.
|
|
5
|
+
|
|
6
|
+
You are solely responsible for determining the appropriateness of using and distributing the software and you assume all risks associated with its use, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and the unavailability or interruption of operation. This software is not intended to be used in any situation where a failure could cause risk of injury or damage to property. The software developed by NIST employees is not subject to copyright protection within the United States.
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.contrib import admin, messages
|
|
3
|
+
from django.contrib.admin import register
|
|
4
|
+
from django.contrib.admin.decorators import display
|
|
5
|
+
from django.urls import reverse
|
|
6
|
+
from django.utils.safestring import mark_safe
|
|
7
|
+
|
|
8
|
+
from NEMO_monitors.models import (
|
|
9
|
+
DEFAULT_DATA_ENTRY_FIELDS,
|
|
10
|
+
Monitor,
|
|
11
|
+
MonitorAlertEmail,
|
|
12
|
+
MonitorAlertLog,
|
|
13
|
+
MonitorColumn,
|
|
14
|
+
MonitorData,
|
|
15
|
+
)
|
|
16
|
+
from NEMO.typing import QuerySetType
|
|
17
|
+
from NEMO.utilities import new_model_copy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def duplicate_monitor_configuration(model_admin, request, queryset: QuerySetType[Monitor]):
|
|
21
|
+
for monitor in queryset:
|
|
22
|
+
original_name = monitor.name
|
|
23
|
+
new_name = "Copy of " + monitor.name
|
|
24
|
+
try:
|
|
25
|
+
existing = Monitor.objects.filter(name=new_name, tool=monitor.tool)
|
|
26
|
+
if existing.exists():
|
|
27
|
+
messages.error(
|
|
28
|
+
request,
|
|
29
|
+
mark_safe(
|
|
30
|
+
f'There is already a copy of {original_name} as '
|
|
31
|
+
f'<a href="{reverse("admin:monitors_monitor_change", args=[existing.first().id])}">{new_name}</a>. '
|
|
32
|
+
f'Change the copy\'s name and try again'
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
continue
|
|
36
|
+
new_monitor: Monitor = new_model_copy(monitor)
|
|
37
|
+
new_monitor.name = new_name
|
|
38
|
+
new_monitor.last_read = None
|
|
39
|
+
new_monitor.last_value = None
|
|
40
|
+
new_monitor.save()
|
|
41
|
+
messages.success(
|
|
42
|
+
request,
|
|
43
|
+
mark_safe(
|
|
44
|
+
f'A duplicate of {original_name} has been made as '
|
|
45
|
+
f'<a href="{reverse("admin:monitors_monitor_change", args=[new_monitor.id])}">{new_monitor.name}</a>'
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
except Exception as error:
|
|
49
|
+
messages.error(
|
|
50
|
+
request, f"{original_name} could not be duplicated because of the following error: {str(error)}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@admin.action(description="Hide selected monitors")
|
|
55
|
+
def hide_selected_monitors(model_admin, request, queryset: QuerySetType[Monitor]):
|
|
56
|
+
for monitor in queryset:
|
|
57
|
+
monitor.visible = False
|
|
58
|
+
monitor.save(update_fields=["visible"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@admin.action(description="Show selected monitors")
|
|
62
|
+
def show_selected_monitors(model_admin, request, queryset: QuerySetType[Monitor]):
|
|
63
|
+
for monitor in queryset:
|
|
64
|
+
monitor.visible = True
|
|
65
|
+
monitor.save(update_fields=["visible"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@admin.action(description="Disable selected alerts")
|
|
69
|
+
def disable_selected_alerts(model_admin, request, queryset: QuerySetType[MonitorAlertEmail]):
|
|
70
|
+
for monitor_alert in queryset:
|
|
71
|
+
monitor_alert.enabled = False
|
|
72
|
+
monitor_alert.save(update_fields=["enabled"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@admin.action(description="Enable selected alerts")
|
|
76
|
+
def enable_selected_alerts(model_admin, request, queryset: QuerySetType[MonitorAlertEmail]):
|
|
77
|
+
for monitor_alert in queryset:
|
|
78
|
+
monitor_alert.enabled = True
|
|
79
|
+
monitor_alert.save(update_fields=["enabled"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MonitorAdminForm(forms.ModelForm):
|
|
83
|
+
class Meta:
|
|
84
|
+
model = Monitor
|
|
85
|
+
exclude = ("last_read", "last_value")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MonitorColumnInline(admin.TabularInline):
|
|
89
|
+
model = MonitorColumn
|
|
90
|
+
fields = (
|
|
91
|
+
"name",
|
|
92
|
+
"data_type",
|
|
93
|
+
"unit_label",
|
|
94
|
+
"order",
|
|
95
|
+
"control_limits_mode",
|
|
96
|
+
"fixed_limit_min",
|
|
97
|
+
"fixed_limit_max",
|
|
98
|
+
)
|
|
99
|
+
ordering = ("order", "name")
|
|
100
|
+
verbose_name = "Data entry field"
|
|
101
|
+
verbose_name_plural = "Data entry fields"
|
|
102
|
+
|
|
103
|
+
def get_extra(self, request, obj=None, **kwargs):
|
|
104
|
+
if obj is None:
|
|
105
|
+
return len(DEFAULT_DATA_ENTRY_FIELDS)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
def get_formset(self, request, obj=None, **kwargs):
|
|
109
|
+
formset_class = super().get_formset(request, obj, **kwargs)
|
|
110
|
+
|
|
111
|
+
class MonitorColumnFormSet(formset_class):
|
|
112
|
+
def __init__(self, *args, **inner_kwargs):
|
|
113
|
+
super().__init__(*args, **inner_kwargs)
|
|
114
|
+
if obj is None and not self.data:
|
|
115
|
+
for index, (name, data_type) in enumerate(DEFAULT_DATA_ENTRY_FIELDS):
|
|
116
|
+
if index < len(self.forms):
|
|
117
|
+
self.forms[index].initial = {"name": name, "data_type": data_type, "order": index}
|
|
118
|
+
|
|
119
|
+
return MonitorColumnFormSet
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register(Monitor)
|
|
123
|
+
class MonitorAdmin(admin.ModelAdmin):
|
|
124
|
+
search_fields = ["name", "tool__name"]
|
|
125
|
+
form = MonitorAdminForm
|
|
126
|
+
inlines = [MonitorColumnInline]
|
|
127
|
+
list_display = (
|
|
128
|
+
"id",
|
|
129
|
+
"name",
|
|
130
|
+
"tool",
|
|
131
|
+
"visible",
|
|
132
|
+
"last_read",
|
|
133
|
+
"last_value",
|
|
134
|
+
)
|
|
135
|
+
list_filter = (
|
|
136
|
+
"visible",
|
|
137
|
+
("tool", admin.RelatedOnlyFieldListFilter),
|
|
138
|
+
)
|
|
139
|
+
actions = [duplicate_monitor_configuration, hide_selected_monitors, show_selected_monitors]
|
|
140
|
+
autocomplete_fields = ["tool"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@register(MonitorData)
|
|
144
|
+
class MonitorDataAdmin(admin.ModelAdmin):
|
|
145
|
+
list_display = (
|
|
146
|
+
"created_date",
|
|
147
|
+
"monitor",
|
|
148
|
+
"get_column_name",
|
|
149
|
+
"value",
|
|
150
|
+
"string_value",
|
|
151
|
+
"get_display_value",
|
|
152
|
+
"created_by",
|
|
153
|
+
"created_on",
|
|
154
|
+
"updated_by",
|
|
155
|
+
"updated_on",
|
|
156
|
+
)
|
|
157
|
+
date_hierarchy = "created_date"
|
|
158
|
+
list_filter = (
|
|
159
|
+
("monitor", admin.RelatedOnlyFieldListFilter),
|
|
160
|
+
("column", admin.RelatedOnlyFieldListFilter),
|
|
161
|
+
("created_by", admin.RelatedOnlyFieldListFilter),
|
|
162
|
+
)
|
|
163
|
+
autocomplete_fields = ["monitor", "created_by", "updated_by"]
|
|
164
|
+
readonly_fields = ["created_on", "updated_on"]
|
|
165
|
+
search_fields = ("monitor__name", "monitor__tool__name", "created_by__username", "notes")
|
|
166
|
+
|
|
167
|
+
@display(ordering="column__name", description="Data entry field")
|
|
168
|
+
def get_column_name(self, obj: MonitorData):
|
|
169
|
+
return obj.column.name if obj.column else ""
|
|
170
|
+
|
|
171
|
+
@display(description="Display value")
|
|
172
|
+
def get_display_value(self, obj: MonitorData):
|
|
173
|
+
return obj.display_value()
|
|
174
|
+
|
|
175
|
+
def save_model(self, request, obj: MonitorData, form, change):
|
|
176
|
+
if not change and not obj.created_by_id:
|
|
177
|
+
obj.created_by = request.user
|
|
178
|
+
obj.updated_by = request.user
|
|
179
|
+
super().save_model(request, obj, form, change)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@register(MonitorAlertEmail)
|
|
183
|
+
class MonitorAlertEmailAdmin(admin.ModelAdmin):
|
|
184
|
+
list_display = (
|
|
185
|
+
"monitor",
|
|
186
|
+
"column",
|
|
187
|
+
"enabled",
|
|
188
|
+
"trigger_condition",
|
|
189
|
+
"trigger_no_data",
|
|
190
|
+
"recipient_emails",
|
|
191
|
+
"triggered_on",
|
|
192
|
+
)
|
|
193
|
+
actions = [disable_selected_alerts, enable_selected_alerts]
|
|
194
|
+
autocomplete_fields = ["monitor"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@register(MonitorAlertLog)
|
|
198
|
+
class MonitorAlertLogAdmin(admin.ModelAdmin):
|
|
199
|
+
list_display = ["id", "time", "monitor", "column", "reset", "value"]
|
|
200
|
+
list_filter = [("monitor", admin.RelatedOnlyFieldListFilter), "value", "reset"]
|
|
201
|
+
date_hierarchy = "time"
|
|
202
|
+
|
|
203
|
+
def has_add_permission(self, request):
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def has_change_permission(self, request, obj=None):
|
|
207
|
+
return False
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
|
|
3
|
+
from NEMO_monitors.models import Monitor, MonitorAlert, MonitorData
|
|
4
|
+
|
|
5
|
+
alerts_logger = getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def process_alerts_for_monitor_data(monitor: Monitor, monitor_data: MonitorData = None):
|
|
9
|
+
"""Run every enabled alert for this monitor, optionally scoped to one data point."""
|
|
10
|
+
try:
|
|
11
|
+
monitor_alerts = []
|
|
12
|
+
for sub_class in MonitorAlert.__subclasses__():
|
|
13
|
+
monitor_alerts.extend(sub_class.objects.filter(enabled=True, monitor=monitor))
|
|
14
|
+
for alert in monitor_alerts:
|
|
15
|
+
if (
|
|
16
|
+
monitor_data is not None
|
|
17
|
+
and alert.column_id
|
|
18
|
+
and monitor_data.column_id != alert.column_id
|
|
19
|
+
):
|
|
20
|
+
continue
|
|
21
|
+
alert.process(monitor_data)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
alerts_logger.error(e)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def process_alerts(monitor: Monitor, monitor_data: MonitorData = None):
|
|
27
|
+
"""Backward-compatible alias for process_alerts_for_monitor_data."""
|
|
28
|
+
process_alerts_for_monitor_data(monitor, monitor_data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def process_alerts_after_bulk_create(monitor: Monitor, created_rows):
|
|
32
|
+
"""Process alerts after bulk_create (which does not fire post_save signals)."""
|
|
33
|
+
if not created_rows:
|
|
34
|
+
return
|
|
35
|
+
for row in created_rows:
|
|
36
|
+
process_alerts_for_monitor_data(monitor, row)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from NEMO.serializers import ModelSerializer
|
|
2
|
+
from NEMO.views.api import ModelViewSet, boolean_filters, datetime_filters, key_filters, number_filters, string_filters
|
|
3
|
+
from drf_excel.mixins import XLSXFileMixin
|
|
4
|
+
from rest_flex_fields.serializers import FlexFieldsSerializerMixin
|
|
5
|
+
from rest_framework.serializers import DateTimeField
|
|
6
|
+
from rest_framework.viewsets import ReadOnlyModelViewSet
|
|
7
|
+
|
|
8
|
+
from NEMO_monitors.models import (
|
|
9
|
+
Monitor,
|
|
10
|
+
MonitorAlertEmail,
|
|
11
|
+
MonitorAlertLog,
|
|
12
|
+
MonitorData,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MonitorSerializer(FlexFieldsSerializerMixin, ModelSerializer):
|
|
17
|
+
class Meta:
|
|
18
|
+
model = Monitor
|
|
19
|
+
fields = "__all__"
|
|
20
|
+
expandable_fields = {
|
|
21
|
+
"tool": "NEMO.serializers.ToolSerializer",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MonitorDataSerializer(FlexFieldsSerializerMixin, ModelSerializer):
|
|
26
|
+
created_date = DateTimeField()
|
|
27
|
+
created_on = DateTimeField(read_only=True)
|
|
28
|
+
updated_on = DateTimeField(read_only=True)
|
|
29
|
+
|
|
30
|
+
class Meta:
|
|
31
|
+
model = MonitorData
|
|
32
|
+
fields = "__all__"
|
|
33
|
+
read_only_fields = ("created_on", "updated_on")
|
|
34
|
+
expandable_fields = {
|
|
35
|
+
"monitor": "NEMO_monitors.api.MonitorSerializer",
|
|
36
|
+
"created_by": "NEMO.serializers.UserSerializer",
|
|
37
|
+
"updated_by": "NEMO.serializers.UserSerializer",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MonitorAlertEmailSerializer(FlexFieldsSerializerMixin, ModelSerializer):
|
|
42
|
+
class Meta:
|
|
43
|
+
model = MonitorAlertEmail
|
|
44
|
+
fields = "__all__"
|
|
45
|
+
expandable_fields = {
|
|
46
|
+
"monitor": "NEMO_monitors.api.MonitorSerializer",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MonitorAlertLogSerializer(FlexFieldsSerializerMixin, ModelSerializer):
|
|
51
|
+
class Meta:
|
|
52
|
+
model = MonitorAlertLog
|
|
53
|
+
fields = "__all__"
|
|
54
|
+
expandable_fields = {
|
|
55
|
+
"monitor": "NEMO_monitors.api.MonitorSerializer",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MonitorViewSet(ModelViewSet):
|
|
60
|
+
filename = "monitors"
|
|
61
|
+
queryset = Monitor.objects.all()
|
|
62
|
+
serializer_class = MonitorSerializer
|
|
63
|
+
filterset_fields = {
|
|
64
|
+
"id": key_filters,
|
|
65
|
+
"name": string_filters,
|
|
66
|
+
"visible": boolean_filters,
|
|
67
|
+
"tool": key_filters,
|
|
68
|
+
"last_read": datetime_filters,
|
|
69
|
+
"last_value": number_filters,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MonitorDataViewSet(ModelViewSet):
|
|
74
|
+
filename = "monitor_data"
|
|
75
|
+
queryset = MonitorData.objects.all()
|
|
76
|
+
serializer_class = MonitorDataSerializer
|
|
77
|
+
filterset_fields = {
|
|
78
|
+
"id": key_filters,
|
|
79
|
+
"monitor": key_filters,
|
|
80
|
+
"created_date": datetime_filters,
|
|
81
|
+
"created_on": datetime_filters,
|
|
82
|
+
"updated_on": datetime_filters,
|
|
83
|
+
"created_by": key_filters,
|
|
84
|
+
"updated_by": key_filters,
|
|
85
|
+
"value": number_filters,
|
|
86
|
+
"notes": string_filters,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MonitorAlertEmailViewSet(ModelViewSet):
|
|
91
|
+
filename = "monitor_alert_emails"
|
|
92
|
+
queryset = MonitorAlertEmail.objects.all()
|
|
93
|
+
serializer_class = MonitorAlertEmailSerializer
|
|
94
|
+
filterset_fields = {
|
|
95
|
+
"id": key_filters,
|
|
96
|
+
"enabled": boolean_filters,
|
|
97
|
+
"monitor": key_filters,
|
|
98
|
+
"trigger_no_data": boolean_filters,
|
|
99
|
+
"trigger_condition": string_filters,
|
|
100
|
+
"triggered_on": datetime_filters,
|
|
101
|
+
"column": key_filters,
|
|
102
|
+
"recipient_emails": string_filters,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MonitorAlertLogViewSet(XLSXFileMixin, ReadOnlyModelViewSet):
|
|
107
|
+
filename = "monitor_alert_logs"
|
|
108
|
+
queryset = MonitorAlertLog.objects.all()
|
|
109
|
+
serializer_class = MonitorAlertLogSerializer
|
|
110
|
+
filterset_fields = {
|
|
111
|
+
"id": key_filters,
|
|
112
|
+
"monitor": key_filters,
|
|
113
|
+
"time": datetime_filters,
|
|
114
|
+
"value": number_filters,
|
|
115
|
+
"reset": boolean_filters,
|
|
116
|
+
"condition": string_filters,
|
|
117
|
+
"no_data": boolean_filters,
|
|
118
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
# Wheel / PyPI name from pyproject.toml [project].name — NOT the same as AppConfig.name
|
|
6
|
+
DISTRIBUTION_NAME = "NEMO-monitors"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MonitorsConfig(AppConfig):
|
|
10
|
+
name = "NEMO_monitors"
|
|
11
|
+
label = "monitors"
|
|
12
|
+
verbose_name = "Monitors"
|
|
13
|
+
default_auto_field = "django.db.models.AutoField"
|
|
14
|
+
|
|
15
|
+
def ready(self):
|
|
16
|
+
try:
|
|
17
|
+
from NEMO.plugins.utils import check_extra_dependencies
|
|
18
|
+
except ImportError:
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
# get_extra_requires() looks up importlib.metadata.distribution(app_name).
|
|
22
|
+
# That expects the *installed distribution* name (hyphenated PyPI name), not the
|
|
23
|
+
# Python package path (NEMO_monitors). When running only from NEMO/plugins
|
|
24
|
+
# without pip installing the wheel into the active interpreter, the dist is absent
|
|
25
|
+
# — skip the check instead of crashing.
|
|
26
|
+
for dist_name in (DISTRIBUTION_NAME, "nemo-monitors", "NEMO-tool-monitors", "nemo-tool-monitors"):
|
|
27
|
+
try:
|
|
28
|
+
check_extra_dependencies(dist_name, ["NEMO", "NEMO-CE"])
|
|
29
|
+
return
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
continue
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from NEMO_monitors.models import CONTROL_LIMITS_FIXED, CONTROL_LIMITS_STATISTICAL
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _numeric_values(values: List[Optional[float]]) -> List[float]:
|
|
10
|
+
result: List[float] = []
|
|
11
|
+
for value in values:
|
|
12
|
+
if value is None:
|
|
13
|
+
continue
|
|
14
|
+
try:
|
|
15
|
+
result.append(float(value))
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
continue
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_control_limits(
|
|
22
|
+
values: List[Optional[float]],
|
|
23
|
+
mode: str,
|
|
24
|
+
fixed_min: Optional[float] = None,
|
|
25
|
+
fixed_max: Optional[float] = None,
|
|
26
|
+
) -> Optional[dict]:
|
|
27
|
+
"""
|
|
28
|
+
Compute control limit lines for chart display.
|
|
29
|
+
|
|
30
|
+
Statistical mode: centerline (mean), UCL/LCL at mean ± 3σ (sample stdev, n >= 2).
|
|
31
|
+
Fixed mode: LCL from fixed_min, UCL from fixed_max; no centerline.
|
|
32
|
+
Returns None when limits cannot be computed.
|
|
33
|
+
"""
|
|
34
|
+
if mode == CONTROL_LIMITS_STATISTICAL:
|
|
35
|
+
numeric = _numeric_values(values)
|
|
36
|
+
if len(numeric) < 2:
|
|
37
|
+
return None
|
|
38
|
+
centerline = statistics.mean(numeric)
|
|
39
|
+
sigma = statistics.stdev(numeric)
|
|
40
|
+
return {
|
|
41
|
+
"centerline": centerline,
|
|
42
|
+
"ucl": centerline + 3 * sigma,
|
|
43
|
+
"lcl": centerline - 3 * sigma,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if mode == CONTROL_LIMITS_FIXED:
|
|
47
|
+
if fixed_min is None and fixed_max is None:
|
|
48
|
+
return None
|
|
49
|
+
result = {}
|
|
50
|
+
if fixed_min is not None:
|
|
51
|
+
result["lcl"] = float(fixed_min)
|
|
52
|
+
if fixed_max is not None:
|
|
53
|
+
result["ucl"] = float(fixed_max)
|
|
54
|
+
return result or None
|
|
55
|
+
|
|
56
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from NEMO.decorators import customization
|
|
2
|
+
from NEMO.views.customization import CustomizationBase
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@customization(key="monitors", title="Monitor data")
|
|
6
|
+
class MonitorCustomization(CustomizationBase):
|
|
7
|
+
variables = {
|
|
8
|
+
"monitor_default_daterange": "last_year",
|
|
9
|
+
"monitor_default_refresh_rate": "0",
|
|
10
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
_HREF_ATTR_RE = re.compile(
|
|
5
|
+
r"""(<a\s[^>]*\bhref\s*=\s*)(['"])(.*?)\2""",
|
|
6
|
+
re.IGNORECASE | re.DOTALL,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_external_href(href: str) -> str:
|
|
11
|
+
"""Turn bare domains (e.g. google.com) into absolute https URLs."""
|
|
12
|
+
href = (href or "").strip()
|
|
13
|
+
if not href:
|
|
14
|
+
return href
|
|
15
|
+
lower = href.lower()
|
|
16
|
+
if lower.startswith(("#", "/", "mailto:", "tel:", "javascript:", "data:")):
|
|
17
|
+
return href
|
|
18
|
+
if lower.startswith("//") or lower.startswith("http://") or lower.startswith("https://"):
|
|
19
|
+
return href
|
|
20
|
+
host = href.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
|
|
21
|
+
if "." in host and not host.startswith("."):
|
|
22
|
+
return f"https://{href}"
|
|
23
|
+
return href
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_description_links(html: Optional[str]) -> str:
|
|
27
|
+
"""Fix anchor hrefs in monitor description HTML so bare domains are absolute."""
|
|
28
|
+
if not html:
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
def replace_href(match: re.Match) -> str:
|
|
32
|
+
prefix, quote, href = match.group(1), match.group(2), match.group(3)
|
|
33
|
+
return f"{prefix}{quote}{normalize_external_href(href)}{quote}"
|
|
34
|
+
|
|
35
|
+
return _HREF_ATTR_RE.sub(replace_href, html)
|
|
File without changes
|
|
File without changes
|