arthexis 0.1.19__py3-none-any.whl → 0.1.21__py3-none-any.whl
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.
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/METADATA +5 -6
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/RECORD +42 -44
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +143 -234
- core/apps.py +0 -6
- core/backends.py +8 -2
- core/environment.py +240 -4
- core/models.py +132 -102
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +2 -7
- core/views.py +70 -132
- nodes/admin.py +162 -7
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +581 -15
- nodes/urls.py +4 -0
- nodes/views.py +560 -96
- ocpp/admin.py +144 -4
- ocpp/consumers.py +106 -9
- ocpp/models.py +131 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +183 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +186 -31
- pages/context_processors.py +15 -21
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -79
- pages/urls.py +1 -1
- pages/views.py +108 -13
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/top_level.txt +0 -0
core/environment.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
4
8
|
|
|
9
|
+
from django import forms
|
|
5
10
|
from django.conf import settings
|
|
6
11
|
from django.contrib import admin
|
|
12
|
+
from django.core.exceptions import PermissionDenied
|
|
7
13
|
from django.template.response import TemplateResponse
|
|
8
|
-
from django.urls import path
|
|
14
|
+
from django.urls import path, reverse
|
|
9
15
|
from django.utils.translation import gettext_lazy as _
|
|
10
16
|
|
|
11
17
|
|
|
@@ -15,23 +21,248 @@ def _get_django_settings():
|
|
|
15
21
|
)
|
|
16
22
|
|
|
17
23
|
|
|
24
|
+
class NetworkSetupForm(forms.Form):
|
|
25
|
+
prompt_for_password = forms.BooleanField(
|
|
26
|
+
label=_("Prompt for new WiFi password"),
|
|
27
|
+
required=False,
|
|
28
|
+
help_text=_("Add --password to request a password even when one is already configured."),
|
|
29
|
+
)
|
|
30
|
+
access_point_name = forms.CharField(
|
|
31
|
+
label=_("Access point name"),
|
|
32
|
+
required=False,
|
|
33
|
+
max_length=32,
|
|
34
|
+
help_text=_("Use --ap to set the wlan0 access point name."),
|
|
35
|
+
)
|
|
36
|
+
skip_firewall_validation = forms.BooleanField(
|
|
37
|
+
label=_("Skip firewall validation"),
|
|
38
|
+
required=False,
|
|
39
|
+
help_text=_("Add --no-firewall to bypass firewall port checks."),
|
|
40
|
+
)
|
|
41
|
+
skip_access_point_configuration = forms.BooleanField(
|
|
42
|
+
label=_("Skip access point configuration"),
|
|
43
|
+
required=False,
|
|
44
|
+
help_text=_("Add --no-ap to leave the access point configuration unchanged."),
|
|
45
|
+
)
|
|
46
|
+
allow_unsafe_changes = forms.BooleanField(
|
|
47
|
+
label=_("Allow modifying the active internet connection"),
|
|
48
|
+
required=False,
|
|
49
|
+
help_text=_("Include --unsafe to allow changes that may interrupt connectivity."),
|
|
50
|
+
)
|
|
51
|
+
interactive = forms.BooleanField(
|
|
52
|
+
label=_("Prompt before each step"),
|
|
53
|
+
required=False,
|
|
54
|
+
help_text=_("Run the script with --interactive to confirm each action."),
|
|
55
|
+
)
|
|
56
|
+
install_watchdog = forms.BooleanField(
|
|
57
|
+
label=_("Install WiFi watchdog service"),
|
|
58
|
+
required=False,
|
|
59
|
+
initial=True,
|
|
60
|
+
help_text=_("Keep selected to retain the watchdog or clear to add --no-watchdog."),
|
|
61
|
+
)
|
|
62
|
+
vnc_validation = forms.ChoiceField(
|
|
63
|
+
label=_("VNC validation"),
|
|
64
|
+
choices=(
|
|
65
|
+
("default", _("Use script default (skip validation)")),
|
|
66
|
+
("require", _("Require that a VNC service is enabled (--vnc)")),
|
|
67
|
+
),
|
|
68
|
+
initial="default",
|
|
69
|
+
required=True,
|
|
70
|
+
)
|
|
71
|
+
ethernet_subnet = forms.CharField(
|
|
72
|
+
label=_("Ethernet subnet"),
|
|
73
|
+
required=False,
|
|
74
|
+
help_text=_("Provide Z, Z/P (prefix 16 or 24), X.Y.Z, or X.Y.Z/P to supply --subnet."),
|
|
75
|
+
)
|
|
76
|
+
update_ap_password_only = forms.BooleanField(
|
|
77
|
+
label=_("Update access point password only"),
|
|
78
|
+
required=False,
|
|
79
|
+
help_text=_("Use --ap-set-password without running other setup steps."),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def clean_ethernet_subnet(self) -> str:
|
|
83
|
+
value = self.cleaned_data.get("ethernet_subnet", "")
|
|
84
|
+
if not value:
|
|
85
|
+
return ""
|
|
86
|
+
raw = value.strip()
|
|
87
|
+
match = re.fullmatch(
|
|
88
|
+
r"(?P<first>\d{1,3})(?:\.(?P<second>\d{1,3})\.(?P<third>\d{1,3}))?(?:/(?P<prefix>\d{1,2}))?",
|
|
89
|
+
raw,
|
|
90
|
+
)
|
|
91
|
+
if not match:
|
|
92
|
+
raise forms.ValidationError(
|
|
93
|
+
_("Enter a subnet in the form Z, Z/P, X.Y.Z, or X.Y.Z/P with prefix 16 or 24."),
|
|
94
|
+
)
|
|
95
|
+
first_octet = int(match.group("first"))
|
|
96
|
+
second = match.group("second")
|
|
97
|
+
third = match.group("third")
|
|
98
|
+
if second is not None and third is not None:
|
|
99
|
+
octets = [first_octet, int(second), int(third)]
|
|
100
|
+
for octet in octets:
|
|
101
|
+
if octet < 0 or octet > 255:
|
|
102
|
+
raise forms.ValidationError(
|
|
103
|
+
_("Subnet octets must be between 0 and 255."),
|
|
104
|
+
)
|
|
105
|
+
if octets[2] > 254:
|
|
106
|
+
raise forms.ValidationError(
|
|
107
|
+
_("The third subnet octet must be between 0 and 254."),
|
|
108
|
+
)
|
|
109
|
+
subnet_value = ".".join(str(octet) for octet in octets)
|
|
110
|
+
else:
|
|
111
|
+
if first_octet < 0 or first_octet > 254:
|
|
112
|
+
raise forms.ValidationError(
|
|
113
|
+
_("Subnet value must be between 0 and 254."),
|
|
114
|
+
)
|
|
115
|
+
subnet_value = str(first_octet)
|
|
116
|
+
prefix_value = match.group("prefix")
|
|
117
|
+
if prefix_value:
|
|
118
|
+
prefix = int(prefix_value)
|
|
119
|
+
if prefix not in {16, 24}:
|
|
120
|
+
raise forms.ValidationError(
|
|
121
|
+
_("Subnet prefix must be 16 or 24."),
|
|
122
|
+
)
|
|
123
|
+
return f"{subnet_value}/{prefix}"
|
|
124
|
+
return subnet_value
|
|
125
|
+
|
|
126
|
+
def clean(self) -> dict:
|
|
127
|
+
cleaned_data = super().clean()
|
|
128
|
+
if cleaned_data.get("update_ap_password_only"):
|
|
129
|
+
other_flags = [
|
|
130
|
+
cleaned_data.get("prompt_for_password"),
|
|
131
|
+
bool(cleaned_data.get("access_point_name")),
|
|
132
|
+
cleaned_data.get("skip_firewall_validation"),
|
|
133
|
+
cleaned_data.get("skip_access_point_configuration"),
|
|
134
|
+
cleaned_data.get("allow_unsafe_changes"),
|
|
135
|
+
cleaned_data.get("interactive"),
|
|
136
|
+
bool(cleaned_data.get("ethernet_subnet")),
|
|
137
|
+
cleaned_data.get("vnc_validation") == "require",
|
|
138
|
+
not cleaned_data.get("install_watchdog", True),
|
|
139
|
+
]
|
|
140
|
+
if any(other_flags):
|
|
141
|
+
raise forms.ValidationError(
|
|
142
|
+
_(
|
|
143
|
+
"Update access point password only cannot be combined with other network-setup options."
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return cleaned_data
|
|
147
|
+
|
|
148
|
+
def build_command(self, script_path: Path) -> list[str]:
|
|
149
|
+
command = [str(script_path)]
|
|
150
|
+
data = self.cleaned_data
|
|
151
|
+
if data.get("update_ap_password_only"):
|
|
152
|
+
command.append("--ap-set-password")
|
|
153
|
+
return command
|
|
154
|
+
if data.get("prompt_for_password"):
|
|
155
|
+
command.append("--password")
|
|
156
|
+
access_point_name = data.get("access_point_name")
|
|
157
|
+
if access_point_name:
|
|
158
|
+
command.extend(["--ap", access_point_name])
|
|
159
|
+
if data.get("skip_firewall_validation"):
|
|
160
|
+
command.append("--no-firewall")
|
|
161
|
+
if data.get("skip_access_point_configuration"):
|
|
162
|
+
command.append("--no-ap")
|
|
163
|
+
if data.get("allow_unsafe_changes"):
|
|
164
|
+
command.append("--unsafe")
|
|
165
|
+
if data.get("interactive"):
|
|
166
|
+
command.append("--interactive")
|
|
167
|
+
if not data.get("install_watchdog"):
|
|
168
|
+
command.append("--no-watchdog")
|
|
169
|
+
if data.get("vnc_validation") == "require":
|
|
170
|
+
command.append("--vnc")
|
|
171
|
+
ethernet_subnet = data.get("ethernet_subnet")
|
|
172
|
+
if ethernet_subnet:
|
|
173
|
+
command.extend(["--subnet", ethernet_subnet])
|
|
174
|
+
return command
|
|
175
|
+
|
|
176
|
+
|
|
18
177
|
def _environment_view(request):
|
|
19
178
|
env_vars = sorted(os.environ.items())
|
|
20
179
|
context = admin.site.each_context(request)
|
|
180
|
+
environment_tasks: list[dict[str, str]] = []
|
|
181
|
+
if request.user.is_superuser:
|
|
182
|
+
environment_tasks.append(
|
|
183
|
+
{
|
|
184
|
+
"name": _("Run network-setup"),
|
|
185
|
+
"description": _(
|
|
186
|
+
"Configure network services, stage managed NGINX sites, and review script output."
|
|
187
|
+
),
|
|
188
|
+
"url": reverse("admin:environment-network-setup"),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
21
191
|
context.update(
|
|
22
192
|
{
|
|
23
|
-
"title": _("
|
|
193
|
+
"title": _("Environment"),
|
|
24
194
|
"env_vars": env_vars,
|
|
195
|
+
"environment_tasks": environment_tasks,
|
|
25
196
|
}
|
|
26
197
|
)
|
|
27
198
|
return TemplateResponse(request, "admin/environment.html", context)
|
|
28
199
|
|
|
29
200
|
|
|
201
|
+
def _environment_network_setup_view(request):
|
|
202
|
+
if not request.user.is_superuser:
|
|
203
|
+
raise PermissionDenied
|
|
204
|
+
|
|
205
|
+
script_path = Path(settings.BASE_DIR) / "network-setup.sh"
|
|
206
|
+
command_result: dict[str, object] | None = None
|
|
207
|
+
|
|
208
|
+
if request.method == "POST":
|
|
209
|
+
form = NetworkSetupForm(request.POST)
|
|
210
|
+
if form.is_valid():
|
|
211
|
+
command = form.build_command(script_path)
|
|
212
|
+
if not script_path.exists():
|
|
213
|
+
form.add_error(None, _("The network-setup.sh script could not be found."))
|
|
214
|
+
else:
|
|
215
|
+
try:
|
|
216
|
+
completed = subprocess.run(
|
|
217
|
+
command,
|
|
218
|
+
capture_output=True,
|
|
219
|
+
text=True,
|
|
220
|
+
cwd=settings.BASE_DIR,
|
|
221
|
+
check=False,
|
|
222
|
+
)
|
|
223
|
+
except FileNotFoundError:
|
|
224
|
+
form.add_error(None, _("The network-setup.sh script could not be executed."))
|
|
225
|
+
except OSError as exc:
|
|
226
|
+
form.add_error(
|
|
227
|
+
None,
|
|
228
|
+
_("Unable to execute network-setup.sh: %(error)s")
|
|
229
|
+
% {"error": str(exc)},
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
if hasattr(shlex, "join"):
|
|
233
|
+
command_display = shlex.join(command)
|
|
234
|
+
else:
|
|
235
|
+
command_display = " ".join(shlex.quote(part) for part in command)
|
|
236
|
+
command_result = {
|
|
237
|
+
"command": command_display,
|
|
238
|
+
"stdout": completed.stdout,
|
|
239
|
+
"stderr": completed.stderr,
|
|
240
|
+
"returncode": completed.returncode,
|
|
241
|
+
"succeeded": completed.returncode == 0,
|
|
242
|
+
}
|
|
243
|
+
else:
|
|
244
|
+
form = NetworkSetupForm()
|
|
245
|
+
|
|
246
|
+
context = admin.site.each_context(request)
|
|
247
|
+
context.update(
|
|
248
|
+
{
|
|
249
|
+
"title": _("Run network-setup"),
|
|
250
|
+
"form": form,
|
|
251
|
+
"command_result": command_result,
|
|
252
|
+
"task_description": _(
|
|
253
|
+
"Configure script flags, execute network-setup, and review the captured output."
|
|
254
|
+
),
|
|
255
|
+
"back_url": reverse("admin:environment"),
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
return TemplateResponse(request, "admin/environment_network_setup.html", context)
|
|
259
|
+
|
|
260
|
+
|
|
30
261
|
def _config_view(request):
|
|
31
262
|
context = admin.site.each_context(request)
|
|
32
263
|
context.update(
|
|
33
264
|
{
|
|
34
|
-
"title": _("
|
|
265
|
+
"title": _("Django Settings"),
|
|
35
266
|
"django_settings": _get_django_settings(),
|
|
36
267
|
}
|
|
37
268
|
)
|
|
@@ -39,12 +270,17 @@ def _config_view(request):
|
|
|
39
270
|
|
|
40
271
|
|
|
41
272
|
def patch_admin_environment_view() -> None:
|
|
42
|
-
"""
|
|
273
|
+
"""Register the Environment and Config admin views on the main admin site."""
|
|
43
274
|
original_get_urls = admin.site.get_urls
|
|
44
275
|
|
|
45
276
|
def get_urls():
|
|
46
277
|
urls = original_get_urls()
|
|
47
278
|
custom = [
|
|
279
|
+
path(
|
|
280
|
+
"environment/network-setup/",
|
|
281
|
+
admin.site.admin_view(_environment_network_setup_view),
|
|
282
|
+
name="environment-network-setup",
|
|
283
|
+
),
|
|
48
284
|
path(
|
|
49
285
|
"environment/",
|
|
50
286
|
admin.site.admin_view(_environment_view),
|
core/models.py
CHANGED
|
@@ -5,7 +5,7 @@ from django.contrib.auth.models import (
|
|
|
5
5
|
)
|
|
6
6
|
from django.db import DatabaseError, IntegrityError, connections, models, transaction
|
|
7
7
|
from django.db.models import Q
|
|
8
|
-
from django.db.models.functions import Lower
|
|
8
|
+
from django.db.models.functions import Lower, Length
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from django.contrib.auth import get_user_model
|
|
11
11
|
from django.utils.translation import gettext_lazy as _
|
|
@@ -17,12 +17,12 @@ from django.dispatch import receiver
|
|
|
17
17
|
from django.views.decorators.debug import sensitive_variables
|
|
18
18
|
from datetime import time as datetime_time, timedelta
|
|
19
19
|
import logging
|
|
20
|
+
import json
|
|
20
21
|
from django.contrib.contenttypes.models import ContentType
|
|
21
22
|
import hashlib
|
|
22
23
|
import hmac
|
|
23
24
|
import os
|
|
24
25
|
import subprocess
|
|
25
|
-
import secrets
|
|
26
26
|
import re
|
|
27
27
|
from io import BytesIO
|
|
28
28
|
from django.core.files.base import ContentFile
|
|
@@ -518,18 +518,10 @@ class User(Entity, AbstractUser):
|
|
|
518
518
|
def odoo_profile(self):
|
|
519
519
|
return self._direct_profile("OdooProfile")
|
|
520
520
|
|
|
521
|
-
@property
|
|
522
|
-
def assistant_profile(self):
|
|
523
|
-
return self._direct_profile("AssistantProfile")
|
|
524
|
-
|
|
525
521
|
@property
|
|
526
522
|
def social_profile(self):
|
|
527
523
|
return self._direct_profile("SocialProfile")
|
|
528
524
|
|
|
529
|
-
@property
|
|
530
|
-
def chat_profile(self):
|
|
531
|
-
return self.assistant_profile
|
|
532
|
-
|
|
533
525
|
|
|
534
526
|
class UserPhoneNumber(Entity):
|
|
535
527
|
"""Store phone numbers associated with a user."""
|
|
@@ -1764,6 +1756,7 @@ class RFID(Entity):
|
|
|
1764
1756
|
"""RFID tag that may be assigned to one account."""
|
|
1765
1757
|
|
|
1766
1758
|
label_id = models.AutoField(primary_key=True, db_column="label_id")
|
|
1759
|
+
MATCH_PREFIX_LENGTH = 8
|
|
1767
1760
|
rfid = models.CharField(
|
|
1768
1761
|
max_length=255,
|
|
1769
1762
|
unique=True,
|
|
@@ -1939,6 +1932,108 @@ class RFID(Entity):
|
|
|
1939
1932
|
def __str__(self): # pragma: no cover - simple representation
|
|
1940
1933
|
return str(self.label_id)
|
|
1941
1934
|
|
|
1935
|
+
@classmethod
|
|
1936
|
+
def normalize_code(cls, value: str) -> str:
|
|
1937
|
+
"""Return ``value`` normalized for comparisons."""
|
|
1938
|
+
|
|
1939
|
+
return "".join((value or "").split()).upper()
|
|
1940
|
+
|
|
1941
|
+
def adopt_rfid(self, candidate: str) -> bool:
|
|
1942
|
+
"""Adopt ``candidate`` as the stored RFID if it is a better match."""
|
|
1943
|
+
|
|
1944
|
+
normalized = type(self).normalize_code(candidate)
|
|
1945
|
+
if not normalized:
|
|
1946
|
+
return False
|
|
1947
|
+
current = type(self).normalize_code(self.rfid)
|
|
1948
|
+
if current == normalized:
|
|
1949
|
+
return False
|
|
1950
|
+
if not current:
|
|
1951
|
+
self.rfid = normalized
|
|
1952
|
+
return True
|
|
1953
|
+
reversed_current = type(self).reverse_uid(current)
|
|
1954
|
+
if reversed_current and reversed_current == normalized:
|
|
1955
|
+
self.rfid = normalized
|
|
1956
|
+
return True
|
|
1957
|
+
if len(normalized) < len(current):
|
|
1958
|
+
self.rfid = normalized
|
|
1959
|
+
return True
|
|
1960
|
+
if len(normalized) == len(current) and normalized < current:
|
|
1961
|
+
self.rfid = normalized
|
|
1962
|
+
return True
|
|
1963
|
+
return False
|
|
1964
|
+
|
|
1965
|
+
@classmethod
|
|
1966
|
+
def matching_queryset(cls, value: str) -> models.QuerySet["RFID"]:
|
|
1967
|
+
"""Return RFID records matching ``value`` using prefix comparison."""
|
|
1968
|
+
|
|
1969
|
+
normalized = cls.normalize_code(value)
|
|
1970
|
+
if not normalized:
|
|
1971
|
+
return cls.objects.none()
|
|
1972
|
+
|
|
1973
|
+
conditions: list[Q] = []
|
|
1974
|
+
candidate = normalized
|
|
1975
|
+
if candidate:
|
|
1976
|
+
conditions.append(Q(rfid=candidate))
|
|
1977
|
+
alternate = cls.reverse_uid(candidate)
|
|
1978
|
+
if alternate and alternate != candidate:
|
|
1979
|
+
conditions.append(Q(rfid=alternate))
|
|
1980
|
+
|
|
1981
|
+
prefix_length = min(len(candidate), cls.MATCH_PREFIX_LENGTH)
|
|
1982
|
+
if prefix_length:
|
|
1983
|
+
prefix = candidate[:prefix_length]
|
|
1984
|
+
conditions.append(Q(rfid__startswith=prefix))
|
|
1985
|
+
if alternate and alternate != candidate:
|
|
1986
|
+
alt_prefix = alternate[:prefix_length]
|
|
1987
|
+
if alt_prefix:
|
|
1988
|
+
conditions.append(Q(rfid__startswith=alt_prefix))
|
|
1989
|
+
|
|
1990
|
+
query: Q | None = None
|
|
1991
|
+
for condition in conditions:
|
|
1992
|
+
query = condition if query is None else query | condition
|
|
1993
|
+
|
|
1994
|
+
if query is None:
|
|
1995
|
+
return cls.objects.none()
|
|
1996
|
+
|
|
1997
|
+
queryset = cls.objects.filter(query).distinct()
|
|
1998
|
+
return queryset.annotate(rfid_length=Length("rfid")).order_by(
|
|
1999
|
+
"rfid_length", "rfid", "pk"
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
@classmethod
|
|
2003
|
+
def find_match(cls, value: str) -> "RFID | None":
|
|
2004
|
+
"""Return the best matching RFID for ``value`` if it exists."""
|
|
2005
|
+
|
|
2006
|
+
return cls.matching_queryset(value).first()
|
|
2007
|
+
|
|
2008
|
+
@classmethod
|
|
2009
|
+
def update_or_create_from_code(
|
|
2010
|
+
cls, value: str, defaults: dict[str, Any] | None = None
|
|
2011
|
+
) -> tuple["RFID", bool]:
|
|
2012
|
+
"""Update or create an RFID using relaxed matching rules."""
|
|
2013
|
+
|
|
2014
|
+
normalized = cls.normalize_code(value)
|
|
2015
|
+
if not normalized:
|
|
2016
|
+
raise ValueError("RFID value is required")
|
|
2017
|
+
|
|
2018
|
+
defaults_map = defaults.copy() if defaults else {}
|
|
2019
|
+
existing = cls.find_match(normalized)
|
|
2020
|
+
if existing:
|
|
2021
|
+
update_fields: set[str] = set()
|
|
2022
|
+
if existing.adopt_rfid(normalized):
|
|
2023
|
+
update_fields.add("rfid")
|
|
2024
|
+
for field_name, new_value in defaults_map.items():
|
|
2025
|
+
if getattr(existing, field_name) != new_value:
|
|
2026
|
+
setattr(existing, field_name, new_value)
|
|
2027
|
+
update_fields.add(field_name)
|
|
2028
|
+
if update_fields:
|
|
2029
|
+
existing.save(update_fields=sorted(update_fields))
|
|
2030
|
+
return existing, False
|
|
2031
|
+
|
|
2032
|
+
create_kwargs = defaults_map
|
|
2033
|
+
create_kwargs["rfid"] = normalized
|
|
2034
|
+
tag = cls.objects.create(**create_kwargs)
|
|
2035
|
+
return tag, True
|
|
2036
|
+
|
|
1942
2037
|
@classmethod
|
|
1943
2038
|
def normalize_endianness(cls, value: object) -> str:
|
|
1944
2039
|
"""Return a valid endianness value, defaulting to BIG."""
|
|
@@ -2033,25 +2128,12 @@ class RFID(Entity):
|
|
|
2033
2128
|
) -> tuple["RFID", bool]:
|
|
2034
2129
|
"""Return or create an RFID that was detected via scanning."""
|
|
2035
2130
|
|
|
2036
|
-
normalized =
|
|
2131
|
+
normalized = cls.normalize_code(rfid)
|
|
2037
2132
|
desired_endianness = cls.normalize_endianness(endianness)
|
|
2038
|
-
|
|
2039
|
-
if normalized and len(normalized) % 2 == 0:
|
|
2040
|
-
bytes_list = [normalized[i : i + 2] for i in range(0, len(normalized), 2)]
|
|
2041
|
-
bytes_list.reverse()
|
|
2042
|
-
alternate_candidate = "".join(bytes_list)
|
|
2043
|
-
if alternate_candidate != normalized:
|
|
2044
|
-
alternate = alternate_candidate
|
|
2045
|
-
|
|
2046
|
-
existing = None
|
|
2047
|
-
if normalized:
|
|
2048
|
-
existing = cls.objects.filter(rfid=normalized).first()
|
|
2049
|
-
if not existing and alternate:
|
|
2050
|
-
existing = cls.objects.filter(rfid=alternate).first()
|
|
2133
|
+
existing = cls.find_match(normalized)
|
|
2051
2134
|
if existing:
|
|
2052
2135
|
update_fields: list[str] = []
|
|
2053
|
-
if
|
|
2054
|
-
existing.rfid = normalized
|
|
2136
|
+
if existing.adopt_rfid(normalized):
|
|
2055
2137
|
update_fields.append("rfid")
|
|
2056
2138
|
if existing.endianness != desired_endianness:
|
|
2057
2139
|
existing.endianness = desired_endianness
|
|
@@ -2079,23 +2161,28 @@ class RFID(Entity):
|
|
|
2079
2161
|
tag = cls.objects.create(**create_kwargs)
|
|
2080
2162
|
cls._reset_label_sequence()
|
|
2081
2163
|
except IntegrityError:
|
|
2082
|
-
existing = cls.
|
|
2164
|
+
existing = cls.find_match(normalized)
|
|
2083
2165
|
if existing:
|
|
2084
2166
|
return existing, False
|
|
2085
2167
|
else:
|
|
2086
2168
|
return tag, True
|
|
2087
2169
|
raise IntegrityError("Unable to allocate label id for scanned RFID")
|
|
2088
2170
|
|
|
2089
|
-
@
|
|
2090
|
-
def get_account_by_rfid(value):
|
|
2171
|
+
@classmethod
|
|
2172
|
+
def get_account_by_rfid(cls, value):
|
|
2091
2173
|
"""Return the energy account associated with an RFID code if it exists."""
|
|
2092
2174
|
try:
|
|
2093
2175
|
EnergyAccount = apps.get_model("core", "EnergyAccount")
|
|
2094
2176
|
except LookupError: # pragma: no cover - energy accounts app optional
|
|
2095
2177
|
return None
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2178
|
+
matches = cls.matching_queryset(value).filter(allowed=True)
|
|
2179
|
+
if not matches.exists():
|
|
2180
|
+
return None
|
|
2181
|
+
return (
|
|
2182
|
+
EnergyAccount.objects.filter(rfids__in=matches)
|
|
2183
|
+
.distinct()
|
|
2184
|
+
.first()
|
|
2185
|
+
)
|
|
2099
2186
|
|
|
2100
2187
|
class Meta:
|
|
2101
2188
|
verbose_name = "RFID"
|
|
@@ -2795,7 +2882,10 @@ class ClientReport(Entity):
|
|
|
2795
2882
|
def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
|
|
2796
2883
|
from ocpp.models import Transaction
|
|
2797
2884
|
|
|
2798
|
-
qs = Transaction.objects.
|
|
2885
|
+
qs = Transaction.objects.filter(
|
|
2886
|
+
(Q(rfid__isnull=False) & ~Q(rfid=""))
|
|
2887
|
+
| (Q(vid__isnull=False) & ~Q(vid=""))
|
|
2888
|
+
)
|
|
2799
2889
|
if start_date:
|
|
2800
2890
|
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
2801
2891
|
|
|
@@ -2841,7 +2931,7 @@ class ClientReport(Entity):
|
|
|
2841
2931
|
subject = str(tag.label_id)
|
|
2842
2932
|
|
|
2843
2933
|
if subject is None:
|
|
2844
|
-
subject = tx.rfid
|
|
2934
|
+
subject = tx.rfid or tx.vid
|
|
2845
2935
|
|
|
2846
2936
|
start_value = tx.start_time
|
|
2847
2937
|
end_value = tx.stop_time
|
|
@@ -2853,6 +2943,7 @@ class ClientReport(Entity):
|
|
|
2853
2943
|
{
|
|
2854
2944
|
"subject": subject,
|
|
2855
2945
|
"rfid": tx.rfid,
|
|
2946
|
+
"vid": tx.vid,
|
|
2856
2947
|
"kw": energy,
|
|
2857
2948
|
"start": start_value,
|
|
2858
2949
|
"end": end_value,
|
|
@@ -3324,7 +3415,13 @@ class PackageRelease(Entity):
|
|
|
3324
3415
|
for release in cls.objects.all():
|
|
3325
3416
|
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
3326
3417
|
path = base / name
|
|
3327
|
-
data = serializers.serialize(
|
|
3418
|
+
data = serializers.serialize(
|
|
3419
|
+
"json",
|
|
3420
|
+
[release],
|
|
3421
|
+
use_natural_foreign_keys=True,
|
|
3422
|
+
use_natural_primary_keys=True,
|
|
3423
|
+
)
|
|
3424
|
+
data = json.dumps(json.loads(data), indent=2) + "\n"
|
|
3328
3425
|
expected.add(name)
|
|
3329
3426
|
try:
|
|
3330
3427
|
current = path.read_text(encoding="utf-8")
|
|
@@ -3604,73 +3701,6 @@ def _rfid_unique_energy_account(
|
|
|
3604
3701
|
"RFID tags may only be assigned to one energy account."
|
|
3605
3702
|
)
|
|
3606
3703
|
|
|
3607
|
-
|
|
3608
|
-
def hash_key(key: str) -> str:
|
|
3609
|
-
"""Return a SHA-256 hash for ``key``."""
|
|
3610
|
-
|
|
3611
|
-
return hashlib.sha256(key.encode()).hexdigest()
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
class AssistantProfile(Profile):
|
|
3615
|
-
"""Stores a hashed user key used by the assistant for authentication.
|
|
3616
|
-
|
|
3617
|
-
The plain-text ``user_key`` is generated server-side and shown only once.
|
|
3618
|
-
Users must supply this key in the ``Authorization: Bearer <user_key>``
|
|
3619
|
-
header when requesting protected endpoints. Only the hash is stored.
|
|
3620
|
-
"""
|
|
3621
|
-
|
|
3622
|
-
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
3623
|
-
profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
|
|
3624
|
-
assistant_name = models.CharField(max_length=100, default="Assistant")
|
|
3625
|
-
user_key_hash = models.CharField(max_length=64, unique=True)
|
|
3626
|
-
scopes = models.JSONField(default=list, blank=True)
|
|
3627
|
-
created_at = models.DateTimeField(auto_now_add=True)
|
|
3628
|
-
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
3629
|
-
is_active = models.BooleanField(default=True)
|
|
3630
|
-
|
|
3631
|
-
class Meta:
|
|
3632
|
-
db_table = "workgroup_assistantprofile"
|
|
3633
|
-
verbose_name = "Assistant Profile"
|
|
3634
|
-
verbose_name_plural = "Assistant Profiles"
|
|
3635
|
-
constraints = [
|
|
3636
|
-
models.CheckConstraint(
|
|
3637
|
-
check=(
|
|
3638
|
-
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
3639
|
-
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
3640
|
-
),
|
|
3641
|
-
name="assistantprofile_requires_owner",
|
|
3642
|
-
)
|
|
3643
|
-
]
|
|
3644
|
-
|
|
3645
|
-
@classmethod
|
|
3646
|
-
def issue_key(cls, user) -> tuple["AssistantProfile", str]:
|
|
3647
|
-
"""Create or update a profile and return it with a new plain key."""
|
|
3648
|
-
|
|
3649
|
-
key = secrets.token_hex(32)
|
|
3650
|
-
key_hash = hash_key(key)
|
|
3651
|
-
if user is None:
|
|
3652
|
-
raise ValueError("Assistant profiles require a user instance")
|
|
3653
|
-
|
|
3654
|
-
profile, _ = cls.objects.update_or_create(
|
|
3655
|
-
user=user,
|
|
3656
|
-
defaults={
|
|
3657
|
-
"user_key_hash": key_hash,
|
|
3658
|
-
"last_used_at": None,
|
|
3659
|
-
"is_active": True,
|
|
3660
|
-
},
|
|
3661
|
-
)
|
|
3662
|
-
return profile, key
|
|
3663
|
-
|
|
3664
|
-
def touch(self) -> None:
|
|
3665
|
-
"""Record that the key was used."""
|
|
3666
|
-
|
|
3667
|
-
self.last_used_at = timezone.now()
|
|
3668
|
-
self.save(update_fields=["last_used_at"])
|
|
3669
|
-
|
|
3670
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
3671
|
-
return self.assistant_name or "AssistantProfile"
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
3704
|
def validate_relative_url(value: str) -> None:
|
|
3675
3705
|
if not value:
|
|
3676
3706
|
return
|
core/notifications.py
CHANGED
|
@@ -39,7 +39,7 @@ class NotificationManager:
|
|
|
39
39
|
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
40
40
|
# ``plyer`` is only available on Windows and can fail when used in
|
|
41
41
|
# a non-interactive environment (e.g. service or CI).
|
|
42
|
-
# Any failure will
|
|
42
|
+
# Any failure will fall back to logging quietly.
|
|
43
43
|
|
|
44
44
|
def _write_lock_file(self, subject: str, body: str) -> None:
|
|
45
45
|
self.lock_file.write_text(f"{subject}\n{body}\n", encoding="utf-8")
|
core/reference_utils.py
CHANGED
|
@@ -70,17 +70,16 @@ def filter_visible_references(
|
|
|
70
70
|
required_sites = {current_site.pk for current_site in ref.sites.all()}
|
|
71
71
|
|
|
72
72
|
if required_roles or required_features or required_sites:
|
|
73
|
-
allowed =
|
|
74
|
-
if required_roles
|
|
75
|
-
allowed =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
allowed = True
|
|
73
|
+
allowed = True
|
|
74
|
+
if required_roles:
|
|
75
|
+
allowed = bool(node_role_id and node_role_id in required_roles)
|
|
76
|
+
if allowed and required_features:
|
|
77
|
+
allowed = bool(
|
|
78
|
+
node_active_feature_ids
|
|
79
|
+
and node_active_feature_ids.intersection(required_features)
|
|
80
|
+
)
|
|
81
|
+
if allowed and required_sites:
|
|
82
|
+
allowed = bool(site_id and site_id in required_sites)
|
|
84
83
|
|
|
85
84
|
if not allowed:
|
|
86
85
|
continue
|
core/sigil_builder.py
CHANGED
|
@@ -40,12 +40,12 @@ def _sigil_builder_view(request):
|
|
|
40
40
|
{
|
|
41
41
|
"prefix": "ENV",
|
|
42
42
|
"url": reverse("admin:environment"),
|
|
43
|
-
"label": _("
|
|
43
|
+
"label": _("Environment"),
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"prefix": "CONF",
|
|
47
47
|
"url": reverse("admin:config"),
|
|
48
|
-
"label": _("
|
|
48
|
+
"label": _("Django Settings"),
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"prefix": "SYS",
|