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.
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": _("Environ"),
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": _("Config"),
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
- """Add custom admin view for environment information."""
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 = "".join((rfid or "").split()).upper()
2139
+ normalized = cls.normalize_code(rfid)
2037
2140
  desired_endianness = cls.normalize_endianness(endianness)
2038
- alternate = None
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 normalized and existing.rfid != normalized:
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.objects.filter(rfid=normalized).first()
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
- @staticmethod
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
- return EnergyAccount.objects.filter(
2097
- rfids__rfid=value.upper(), rfids__allowed=True
2098
- ).first()
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.exclude(rfid="")
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 fallback to logging quietly.
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 = False
74
- if required_roles and node_role_id and node_role_id in required_roles:
75
- allowed = True
76
- elif (
77
- required_features
78
- and node_active_feature_ids
79
- and node_active_feature_ids.intersection(required_features)
80
- ):
81
- allowed = True
82
- elif required_sites and site_id and site_id in required_sites:
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": _("Environ"),
43
+ "label": _("Environment"),
44
44
  },
45
45
  {
46
46
  "prefix": "CONF",
47
47
  "url": reverse("admin:config"),
48
- "label": _("Config"),
48
+ "label": _("Django Settings"),
49
49
  },
50
50
  {
51
51
  "prefix": "SYS",