arthexis 0.1.18__py3-none-any.whl → 0.1.20__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.18.dist-info → arthexis-0.1.20.dist-info}/METADATA +39 -12
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/RECORD +44 -44
- config/settings.py +1 -5
- core/admin.py +142 -1
- core/backends.py +8 -2
- core/environment.py +221 -4
- core/models.py +124 -25
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/system.py +125 -0
- core/tasks.py +24 -23
- core/tests.py +1 -0
- core/views.py +105 -40
- nodes/admin.py +134 -3
- nodes/models.py +310 -69
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +573 -48
- nodes/urls.py +4 -1
- nodes/views.py +498 -106
- ocpp/admin.py +124 -5
- ocpp/consumers.py +106 -9
- ocpp/models.py +90 -1
- ocpp/store.py +6 -4
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +114 -10
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +166 -40
- pages/admin.py +63 -10
- pages/context_processors.py +26 -9
- pages/defaults.py +1 -1
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/module_defaults.py +5 -5
- pages/tests.py +280 -65
- pages/urls.py +3 -1
- pages/views.py +176 -29
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.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,229 @@ 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 N or N/P (prefix 16 or 24) 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(r"(?P<subnet>\d{1,3})(?:/(?P<prefix>\d{1,2}))?", raw)
|
|
88
|
+
if not match:
|
|
89
|
+
raise forms.ValidationError(
|
|
90
|
+
_("Enter a subnet in the form N or N/P with prefix 16 or 24."),
|
|
91
|
+
)
|
|
92
|
+
subnet = int(match.group("subnet"))
|
|
93
|
+
if subnet < 0 or subnet > 254:
|
|
94
|
+
raise forms.ValidationError(
|
|
95
|
+
_("Subnet value must be between 0 and 254."),
|
|
96
|
+
)
|
|
97
|
+
prefix_value = match.group("prefix")
|
|
98
|
+
if prefix_value:
|
|
99
|
+
prefix = int(prefix_value)
|
|
100
|
+
if prefix not in {16, 24}:
|
|
101
|
+
raise forms.ValidationError(
|
|
102
|
+
_("Subnet prefix must be 16 or 24."),
|
|
103
|
+
)
|
|
104
|
+
return f"{subnet}/{prefix}"
|
|
105
|
+
return str(subnet)
|
|
106
|
+
|
|
107
|
+
def clean(self) -> dict:
|
|
108
|
+
cleaned_data = super().clean()
|
|
109
|
+
if cleaned_data.get("update_ap_password_only"):
|
|
110
|
+
other_flags = [
|
|
111
|
+
cleaned_data.get("prompt_for_password"),
|
|
112
|
+
bool(cleaned_data.get("access_point_name")),
|
|
113
|
+
cleaned_data.get("skip_firewall_validation"),
|
|
114
|
+
cleaned_data.get("skip_access_point_configuration"),
|
|
115
|
+
cleaned_data.get("allow_unsafe_changes"),
|
|
116
|
+
cleaned_data.get("interactive"),
|
|
117
|
+
bool(cleaned_data.get("ethernet_subnet")),
|
|
118
|
+
cleaned_data.get("vnc_validation") == "require",
|
|
119
|
+
not cleaned_data.get("install_watchdog", True),
|
|
120
|
+
]
|
|
121
|
+
if any(other_flags):
|
|
122
|
+
raise forms.ValidationError(
|
|
123
|
+
_(
|
|
124
|
+
"Update access point password only cannot be combined with other network-setup options."
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return cleaned_data
|
|
128
|
+
|
|
129
|
+
def build_command(self, script_path: Path) -> list[str]:
|
|
130
|
+
command = [str(script_path)]
|
|
131
|
+
data = self.cleaned_data
|
|
132
|
+
if data.get("update_ap_password_only"):
|
|
133
|
+
command.append("--ap-set-password")
|
|
134
|
+
return command
|
|
135
|
+
if data.get("prompt_for_password"):
|
|
136
|
+
command.append("--password")
|
|
137
|
+
access_point_name = data.get("access_point_name")
|
|
138
|
+
if access_point_name:
|
|
139
|
+
command.extend(["--ap", access_point_name])
|
|
140
|
+
if data.get("skip_firewall_validation"):
|
|
141
|
+
command.append("--no-firewall")
|
|
142
|
+
if data.get("skip_access_point_configuration"):
|
|
143
|
+
command.append("--no-ap")
|
|
144
|
+
if data.get("allow_unsafe_changes"):
|
|
145
|
+
command.append("--unsafe")
|
|
146
|
+
if data.get("interactive"):
|
|
147
|
+
command.append("--interactive")
|
|
148
|
+
if not data.get("install_watchdog"):
|
|
149
|
+
command.append("--no-watchdog")
|
|
150
|
+
if data.get("vnc_validation") == "require":
|
|
151
|
+
command.append("--vnc")
|
|
152
|
+
ethernet_subnet = data.get("ethernet_subnet")
|
|
153
|
+
if ethernet_subnet:
|
|
154
|
+
command.extend(["--subnet", ethernet_subnet])
|
|
155
|
+
return command
|
|
156
|
+
|
|
157
|
+
|
|
18
158
|
def _environment_view(request):
|
|
19
159
|
env_vars = sorted(os.environ.items())
|
|
20
160
|
context = admin.site.each_context(request)
|
|
161
|
+
environment_tasks: list[dict[str, str]] = []
|
|
162
|
+
if request.user.is_superuser:
|
|
163
|
+
environment_tasks.append(
|
|
164
|
+
{
|
|
165
|
+
"name": _("Run network-setup"),
|
|
166
|
+
"description": _(
|
|
167
|
+
"Configure network services, stage managed NGINX sites, and review script output."
|
|
168
|
+
),
|
|
169
|
+
"url": reverse("admin:environment-network-setup"),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
21
172
|
context.update(
|
|
22
173
|
{
|
|
23
|
-
"title": _("
|
|
174
|
+
"title": _("Environment"),
|
|
24
175
|
"env_vars": env_vars,
|
|
176
|
+
"environment_tasks": environment_tasks,
|
|
25
177
|
}
|
|
26
178
|
)
|
|
27
179
|
return TemplateResponse(request, "admin/environment.html", context)
|
|
28
180
|
|
|
29
181
|
|
|
182
|
+
def _environment_network_setup_view(request):
|
|
183
|
+
if not request.user.is_superuser:
|
|
184
|
+
raise PermissionDenied
|
|
185
|
+
|
|
186
|
+
script_path = Path(settings.BASE_DIR) / "network-setup.sh"
|
|
187
|
+
command_result: dict[str, object] | None = None
|
|
188
|
+
|
|
189
|
+
if request.method == "POST":
|
|
190
|
+
form = NetworkSetupForm(request.POST)
|
|
191
|
+
if form.is_valid():
|
|
192
|
+
command = form.build_command(script_path)
|
|
193
|
+
if not script_path.exists():
|
|
194
|
+
form.add_error(None, _("The network-setup.sh script could not be found."))
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
completed = subprocess.run(
|
|
198
|
+
command,
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
cwd=settings.BASE_DIR,
|
|
202
|
+
check=False,
|
|
203
|
+
)
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
form.add_error(None, _("The network-setup.sh script could not be executed."))
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
form.add_error(
|
|
208
|
+
None,
|
|
209
|
+
_("Unable to execute network-setup.sh: %(error)s")
|
|
210
|
+
% {"error": str(exc)},
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
if hasattr(shlex, "join"):
|
|
214
|
+
command_display = shlex.join(command)
|
|
215
|
+
else:
|
|
216
|
+
command_display = " ".join(shlex.quote(part) for part in command)
|
|
217
|
+
command_result = {
|
|
218
|
+
"command": command_display,
|
|
219
|
+
"stdout": completed.stdout,
|
|
220
|
+
"stderr": completed.stderr,
|
|
221
|
+
"returncode": completed.returncode,
|
|
222
|
+
"succeeded": completed.returncode == 0,
|
|
223
|
+
}
|
|
224
|
+
else:
|
|
225
|
+
form = NetworkSetupForm()
|
|
226
|
+
|
|
227
|
+
context = admin.site.each_context(request)
|
|
228
|
+
context.update(
|
|
229
|
+
{
|
|
230
|
+
"title": _("Run network-setup"),
|
|
231
|
+
"form": form,
|
|
232
|
+
"command_result": command_result,
|
|
233
|
+
"task_description": _(
|
|
234
|
+
"Configure script flags, execute network-setup, and review the captured output."
|
|
235
|
+
),
|
|
236
|
+
"back_url": reverse("admin:environment"),
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
return TemplateResponse(request, "admin/environment_network_setup.html", context)
|
|
240
|
+
|
|
241
|
+
|
|
30
242
|
def _config_view(request):
|
|
31
243
|
context = admin.site.each_context(request)
|
|
32
244
|
context.update(
|
|
33
245
|
{
|
|
34
|
-
"title": _("
|
|
246
|
+
"title": _("Django Settings"),
|
|
35
247
|
"django_settings": _get_django_settings(),
|
|
36
248
|
}
|
|
37
249
|
)
|
|
@@ -39,12 +251,17 @@ def _config_view(request):
|
|
|
39
251
|
|
|
40
252
|
|
|
41
253
|
def patch_admin_environment_view() -> None:
|
|
42
|
-
"""
|
|
254
|
+
"""Register the Environment and Config admin views on the main admin site."""
|
|
43
255
|
original_get_urls = admin.site.get_urls
|
|
44
256
|
|
|
45
257
|
def get_urls():
|
|
46
258
|
urls = original_get_urls()
|
|
47
259
|
custom = [
|
|
260
|
+
path(
|
|
261
|
+
"environment/network-setup/",
|
|
262
|
+
admin.site.admin_view(_environment_network_setup_view),
|
|
263
|
+
name="environment-network-setup",
|
|
264
|
+
),
|
|
48
265
|
path(
|
|
49
266
|
"environment/",
|
|
50
267
|
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 _
|
|
@@ -1764,6 +1764,7 @@ class RFID(Entity):
|
|
|
1764
1764
|
"""RFID tag that may be assigned to one account."""
|
|
1765
1765
|
|
|
1766
1766
|
label_id = models.AutoField(primary_key=True, db_column="label_id")
|
|
1767
|
+
MATCH_PREFIX_LENGTH = 8
|
|
1767
1768
|
rfid = models.CharField(
|
|
1768
1769
|
max_length=255,
|
|
1769
1770
|
unique=True,
|
|
@@ -1939,6 +1940,108 @@ class RFID(Entity):
|
|
|
1939
1940
|
def __str__(self): # pragma: no cover - simple representation
|
|
1940
1941
|
return str(self.label_id)
|
|
1941
1942
|
|
|
1943
|
+
@classmethod
|
|
1944
|
+
def normalize_code(cls, value: str) -> str:
|
|
1945
|
+
"""Return ``value`` normalized for comparisons."""
|
|
1946
|
+
|
|
1947
|
+
return "".join((value or "").split()).upper()
|
|
1948
|
+
|
|
1949
|
+
def adopt_rfid(self, candidate: str) -> bool:
|
|
1950
|
+
"""Adopt ``candidate`` as the stored RFID if it is a better match."""
|
|
1951
|
+
|
|
1952
|
+
normalized = type(self).normalize_code(candidate)
|
|
1953
|
+
if not normalized:
|
|
1954
|
+
return False
|
|
1955
|
+
current = type(self).normalize_code(self.rfid)
|
|
1956
|
+
if current == normalized:
|
|
1957
|
+
return False
|
|
1958
|
+
if not current:
|
|
1959
|
+
self.rfid = normalized
|
|
1960
|
+
return True
|
|
1961
|
+
reversed_current = type(self).reverse_uid(current)
|
|
1962
|
+
if reversed_current and reversed_current == normalized:
|
|
1963
|
+
self.rfid = normalized
|
|
1964
|
+
return True
|
|
1965
|
+
if len(normalized) < len(current):
|
|
1966
|
+
self.rfid = normalized
|
|
1967
|
+
return True
|
|
1968
|
+
if len(normalized) == len(current) and normalized < current:
|
|
1969
|
+
self.rfid = normalized
|
|
1970
|
+
return True
|
|
1971
|
+
return False
|
|
1972
|
+
|
|
1973
|
+
@classmethod
|
|
1974
|
+
def matching_queryset(cls, value: str) -> models.QuerySet["RFID"]:
|
|
1975
|
+
"""Return RFID records matching ``value`` using prefix comparison."""
|
|
1976
|
+
|
|
1977
|
+
normalized = cls.normalize_code(value)
|
|
1978
|
+
if not normalized:
|
|
1979
|
+
return cls.objects.none()
|
|
1980
|
+
|
|
1981
|
+
conditions: list[Q] = []
|
|
1982
|
+
candidate = normalized
|
|
1983
|
+
if candidate:
|
|
1984
|
+
conditions.append(Q(rfid=candidate))
|
|
1985
|
+
alternate = cls.reverse_uid(candidate)
|
|
1986
|
+
if alternate and alternate != candidate:
|
|
1987
|
+
conditions.append(Q(rfid=alternate))
|
|
1988
|
+
|
|
1989
|
+
prefix_length = min(len(candidate), cls.MATCH_PREFIX_LENGTH)
|
|
1990
|
+
if prefix_length:
|
|
1991
|
+
prefix = candidate[:prefix_length]
|
|
1992
|
+
conditions.append(Q(rfid__startswith=prefix))
|
|
1993
|
+
if alternate and alternate != candidate:
|
|
1994
|
+
alt_prefix = alternate[:prefix_length]
|
|
1995
|
+
if alt_prefix:
|
|
1996
|
+
conditions.append(Q(rfid__startswith=alt_prefix))
|
|
1997
|
+
|
|
1998
|
+
query: Q | None = None
|
|
1999
|
+
for condition in conditions:
|
|
2000
|
+
query = condition if query is None else query | condition
|
|
2001
|
+
|
|
2002
|
+
if query is None:
|
|
2003
|
+
return cls.objects.none()
|
|
2004
|
+
|
|
2005
|
+
queryset = cls.objects.filter(query).distinct()
|
|
2006
|
+
return queryset.annotate(rfid_length=Length("rfid")).order_by(
|
|
2007
|
+
"rfid_length", "rfid", "pk"
|
|
2008
|
+
)
|
|
2009
|
+
|
|
2010
|
+
@classmethod
|
|
2011
|
+
def find_match(cls, value: str) -> "RFID | None":
|
|
2012
|
+
"""Return the best matching RFID for ``value`` if it exists."""
|
|
2013
|
+
|
|
2014
|
+
return cls.matching_queryset(value).first()
|
|
2015
|
+
|
|
2016
|
+
@classmethod
|
|
2017
|
+
def update_or_create_from_code(
|
|
2018
|
+
cls, value: str, defaults: dict[str, Any] | None = None
|
|
2019
|
+
) -> tuple["RFID", bool]:
|
|
2020
|
+
"""Update or create an RFID using relaxed matching rules."""
|
|
2021
|
+
|
|
2022
|
+
normalized = cls.normalize_code(value)
|
|
2023
|
+
if not normalized:
|
|
2024
|
+
raise ValueError("RFID value is required")
|
|
2025
|
+
|
|
2026
|
+
defaults_map = defaults.copy() if defaults else {}
|
|
2027
|
+
existing = cls.find_match(normalized)
|
|
2028
|
+
if existing:
|
|
2029
|
+
update_fields: set[str] = set()
|
|
2030
|
+
if existing.adopt_rfid(normalized):
|
|
2031
|
+
update_fields.add("rfid")
|
|
2032
|
+
for field_name, new_value in defaults_map.items():
|
|
2033
|
+
if getattr(existing, field_name) != new_value:
|
|
2034
|
+
setattr(existing, field_name, new_value)
|
|
2035
|
+
update_fields.add(field_name)
|
|
2036
|
+
if update_fields:
|
|
2037
|
+
existing.save(update_fields=sorted(update_fields))
|
|
2038
|
+
return existing, False
|
|
2039
|
+
|
|
2040
|
+
create_kwargs = defaults_map
|
|
2041
|
+
create_kwargs["rfid"] = normalized
|
|
2042
|
+
tag = cls.objects.create(**create_kwargs)
|
|
2043
|
+
return tag, True
|
|
2044
|
+
|
|
1942
2045
|
@classmethod
|
|
1943
2046
|
def normalize_endianness(cls, value: object) -> str:
|
|
1944
2047
|
"""Return a valid endianness value, defaulting to BIG."""
|
|
@@ -2033,25 +2136,12 @@ class RFID(Entity):
|
|
|
2033
2136
|
) -> tuple["RFID", bool]:
|
|
2034
2137
|
"""Return or create an RFID that was detected via scanning."""
|
|
2035
2138
|
|
|
2036
|
-
normalized =
|
|
2139
|
+
normalized = cls.normalize_code(rfid)
|
|
2037
2140
|
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()
|
|
2141
|
+
existing = cls.find_match(normalized)
|
|
2051
2142
|
if existing:
|
|
2052
2143
|
update_fields: list[str] = []
|
|
2053
|
-
if
|
|
2054
|
-
existing.rfid = normalized
|
|
2144
|
+
if existing.adopt_rfid(normalized):
|
|
2055
2145
|
update_fields.append("rfid")
|
|
2056
2146
|
if existing.endianness != desired_endianness:
|
|
2057
2147
|
existing.endianness = desired_endianness
|
|
@@ -2079,23 +2169,28 @@ class RFID(Entity):
|
|
|
2079
2169
|
tag = cls.objects.create(**create_kwargs)
|
|
2080
2170
|
cls._reset_label_sequence()
|
|
2081
2171
|
except IntegrityError:
|
|
2082
|
-
existing = cls.
|
|
2172
|
+
existing = cls.find_match(normalized)
|
|
2083
2173
|
if existing:
|
|
2084
2174
|
return existing, False
|
|
2085
2175
|
else:
|
|
2086
2176
|
return tag, True
|
|
2087
2177
|
raise IntegrityError("Unable to allocate label id for scanned RFID")
|
|
2088
2178
|
|
|
2089
|
-
@
|
|
2090
|
-
def get_account_by_rfid(value):
|
|
2179
|
+
@classmethod
|
|
2180
|
+
def get_account_by_rfid(cls, value):
|
|
2091
2181
|
"""Return the energy account associated with an RFID code if it exists."""
|
|
2092
2182
|
try:
|
|
2093
2183
|
EnergyAccount = apps.get_model("core", "EnergyAccount")
|
|
2094
2184
|
except LookupError: # pragma: no cover - energy accounts app optional
|
|
2095
2185
|
return None
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2186
|
+
matches = cls.matching_queryset(value).filter(allowed=True)
|
|
2187
|
+
if not matches.exists():
|
|
2188
|
+
return None
|
|
2189
|
+
return (
|
|
2190
|
+
EnergyAccount.objects.filter(rfids__in=matches)
|
|
2191
|
+
.distinct()
|
|
2192
|
+
.first()
|
|
2193
|
+
)
|
|
2099
2194
|
|
|
2100
2195
|
class Meta:
|
|
2101
2196
|
verbose_name = "RFID"
|
|
@@ -2795,7 +2890,10 @@ class ClientReport(Entity):
|
|
|
2795
2890
|
def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
|
|
2796
2891
|
from ocpp.models import Transaction
|
|
2797
2892
|
|
|
2798
|
-
qs = Transaction.objects.
|
|
2893
|
+
qs = Transaction.objects.filter(
|
|
2894
|
+
(Q(rfid__isnull=False) & ~Q(rfid=""))
|
|
2895
|
+
| (Q(vid__isnull=False) & ~Q(vid=""))
|
|
2896
|
+
)
|
|
2799
2897
|
if start_date:
|
|
2800
2898
|
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
2801
2899
|
|
|
@@ -2841,7 +2939,7 @@ class ClientReport(Entity):
|
|
|
2841
2939
|
subject = str(tag.label_id)
|
|
2842
2940
|
|
|
2843
2941
|
if subject is None:
|
|
2844
|
-
subject = tx.rfid
|
|
2942
|
+
subject = tx.rfid or tx.vid
|
|
2845
2943
|
|
|
2846
2944
|
start_value = tx.start_time
|
|
2847
2945
|
end_value = tx.stop_time
|
|
@@ -2853,6 +2951,7 @@ class ClientReport(Entity):
|
|
|
2853
2951
|
{
|
|
2854
2952
|
"subject": subject,
|
|
2855
2953
|
"rfid": tx.rfid,
|
|
2954
|
+
"vid": tx.vid,
|
|
2856
2955
|
"kw": energy,
|
|
2857
2956
|
"start": start_value,
|
|
2858
2957
|
"end": end_value,
|
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",
|