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.
Files changed (51) hide show
  1. nemo_monitors-1.0.0/LICENSE +6 -0
  2. nemo_monitors-1.0.0/NEMO_monitors/__init__.py +0 -0
  3. nemo_monitors-1.0.0/NEMO_monitors/admin.py +207 -0
  4. nemo_monitors-1.0.0/NEMO_monitors/alerts.py +36 -0
  5. nemo_monitors-1.0.0/NEMO_monitors/api.py +118 -0
  6. nemo_monitors-1.0.0/NEMO_monitors/apps.py +31 -0
  7. nemo_monitors-1.0.0/NEMO_monitors/control_limits.py +56 -0
  8. nemo_monitors-1.0.0/NEMO_monitors/customizations.py +10 -0
  9. nemo_monitors-1.0.0/NEMO_monitors/html_utils.py +35 -0
  10. nemo_monitors-1.0.0/NEMO_monitors/management/__init__.py +0 -0
  11. nemo_monitors-1.0.0/NEMO_monitors/management/commands/__init__.py +0 -0
  12. nemo_monitors-1.0.0/NEMO_monitors/migrations/0001_initial.py +232 -0
  13. nemo_monitors-1.0.0/NEMO_monitors/migrations/0002_monitorcolumn_control_limits.py +40 -0
  14. nemo_monitors-1.0.0/NEMO_monitors/migrations/0003_remove_monitor_data_unit_label.py +15 -0
  15. nemo_monitors-1.0.0/NEMO_monitors/migrations/0004_monitor_alert_column_recipients.py +51 -0
  16. nemo_monitors-1.0.0/NEMO_monitors/migrations/0005_monitoralertlog_acknowledgement.py +30 -0
  17. nemo_monitors-1.0.0/NEMO_monitors/migrations/0006_remove_monitoralertlog_acknowledgement.py +19 -0
  18. nemo_monitors-1.0.0/NEMO_monitors/migrations/__init__.py +0 -0
  19. nemo_monitors-1.0.0/NEMO_monitors/models.py +479 -0
  20. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chart.js +13269 -0
  21. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chart.min.js +13 -0
  22. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/chartjs-adapter-moment.js +7 -0
  23. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/daterangepicker.css +410 -0
  24. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/daterangepicker.js +1578 -0
  25. nemo_monitors-1.0.0/NEMO_monitors/static/NEMO_monitors/monitors.css +64 -0
  26. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/alert_log_table.html +24 -0
  27. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_alert_config.html +138 -0
  28. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_alerts.html +1 -0
  29. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_data.html +1130 -0
  30. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitor_form.html +293 -0
  31. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors.html +56 -0
  32. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors_for_tool.html +239 -0
  33. nemo_monitors-1.0.0/NEMO_monitors/templates/NEMO_monitors/monitors_upload_hub.html +145 -0
  34. nemo_monitors-1.0.0/NEMO_monitors/templates/base/navbar.html +1 -0
  35. nemo_monitors-1.0.0/NEMO_monitors/templates/customizations/customizations_monitors.html +64 -0
  36. nemo_monitors-1.0.0/NEMO_monitors/tests/__init__.py +0 -0
  37. nemo_monitors-1.0.0/NEMO_monitors/tests/test_control_limits.py +54 -0
  38. nemo_monitors-1.0.0/NEMO_monitors/tests/test_html_utils.py +26 -0
  39. nemo_monitors-1.0.0/NEMO_monitors/tests/test_monitor_alerts.py +86 -0
  40. nemo_monitors-1.0.0/NEMO_monitors/tests/test_settings.py +87 -0
  41. nemo_monitors-1.0.0/NEMO_monitors/urls.py +70 -0
  42. nemo_monitors-1.0.0/NEMO_monitors/views.py +1246 -0
  43. nemo_monitors-1.0.0/NEMO_monitors.egg-info/PKG-INFO +239 -0
  44. nemo_monitors-1.0.0/NEMO_monitors.egg-info/SOURCES.txt +49 -0
  45. nemo_monitors-1.0.0/NEMO_monitors.egg-info/dependency_links.txt +1 -0
  46. nemo_monitors-1.0.0/NEMO_monitors.egg-info/requires.txt +12 -0
  47. nemo_monitors-1.0.0/NEMO_monitors.egg-info/top_level.txt +1 -0
  48. nemo_monitors-1.0.0/PKG-INFO +239 -0
  49. nemo_monitors-1.0.0/README.md +198 -0
  50. nemo_monitors-1.0.0/pyproject.toml +55 -0
  51. 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)