arthexis 0.1.10__py3-none-any.whl → 0.1.12__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
ocpp/evcs_discovery.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from ipaddress import IPv4Address
6
+ from typing import Iterable, Sequence
7
+
8
+ DEFAULT_TOP_PORTS = 200
9
+ DEFAULT_CONSOLE_PORT = 8900
10
+ PORT_PREFERENCES: Sequence[int] = (
11
+ DEFAULT_CONSOLE_PORT,
12
+ 8443,
13
+ 9443,
14
+ 443,
15
+ 8080,
16
+ 8800,
17
+ 8000,
18
+ 8001,
19
+ 8880,
20
+ 80,
21
+ )
22
+ HTTPS_PORTS = {443, 8443, 9443}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ConsoleEndpoint:
27
+ host: str
28
+ port: int
29
+ secure: bool = False
30
+
31
+ @property
32
+ def url(self) -> str:
33
+ return build_console_url(self.host, self.port, self.secure)
34
+
35
+
36
+ def scan_open_ports(
37
+ host: str,
38
+ *,
39
+ nmap_path: str = "nmap",
40
+ full: bool = False,
41
+ top_ports: int = DEFAULT_TOP_PORTS,
42
+ ) -> list[int]:
43
+ """Return the list of open TCP ports discovered with nmap.
44
+
45
+ The function mirrors the behaviour of the ``evcs_discover`` shell script.
46
+ It uses nmap's grepable output so the caller can avoid touching the
47
+ filesystem and parse the results quickly.
48
+ """
49
+
50
+ port_args: list[str]
51
+ if full:
52
+ port_args = ["-p-"]
53
+ else:
54
+ if top_ports <= 0:
55
+ raise ValueError("top_ports must be greater than zero")
56
+ port_args = ["--top-ports", str(top_ports)]
57
+
58
+ args = [
59
+ nmap_path,
60
+ "-sS",
61
+ "-Pn",
62
+ "-n",
63
+ "-T4",
64
+ "--open",
65
+ *port_args,
66
+ host,
67
+ "-oG",
68
+ "-",
69
+ ]
70
+
71
+ try:
72
+ proc = subprocess.run(
73
+ args,
74
+ check=False,
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ except FileNotFoundError:
79
+ return []
80
+ except subprocess.SubprocessError:
81
+ return []
82
+
83
+ if proc.returncode != 0:
84
+ return []
85
+
86
+ return _parse_nmap_open_ports(proc.stdout)
87
+
88
+
89
+ def _parse_nmap_open_ports(output: str) -> list[int]:
90
+ ports: list[int] = []
91
+ for line in output.splitlines():
92
+ if "Ports:" not in line:
93
+ continue
94
+ try:
95
+ _, ports_section = line.split("Ports:", 1)
96
+ except ValueError:
97
+ continue
98
+ for entry in ports_section.split(","):
99
+ entry = entry.strip()
100
+ if not entry:
101
+ continue
102
+ parts = entry.split("/")
103
+ if len(parts) < 2:
104
+ continue
105
+ state = parts[1].strip().lower()
106
+ if state != "open":
107
+ continue
108
+ try:
109
+ port = int(parts[0])
110
+ except ValueError:
111
+ continue
112
+ if port not in ports:
113
+ ports.append(port)
114
+ return ports
115
+
116
+
117
+ def prioritise_ports(ports: Iterable[int]) -> list[int]:
118
+ """Order ports so the most likely console endpoints are tried first."""
119
+
120
+ unique = []
121
+ seen = set()
122
+ available = list(dict.fromkeys(ports))
123
+ for preferred in PORT_PREFERENCES:
124
+ if preferred in available and preferred not in seen:
125
+ unique.append(preferred)
126
+ seen.add(preferred)
127
+ for port in available:
128
+ if port not in seen:
129
+ unique.append(port)
130
+ seen.add(port)
131
+ return unique
132
+
133
+
134
+ def select_console_port(ports: Sequence[int]) -> ConsoleEndpoint | None:
135
+ """Pick the best console port from a list of open ports."""
136
+
137
+ if not ports:
138
+ return None
139
+ ordered = prioritise_ports(ports)
140
+ if not ordered:
141
+ return None
142
+ port = ordered[0]
143
+ secure = port in HTTPS_PORTS
144
+ return ConsoleEndpoint(host="", port=port, secure=secure)
145
+
146
+
147
+ def build_console_url(host: str, port: int, secure: bool) -> str:
148
+ scheme = "https" if secure else "http"
149
+ host_part = host
150
+ if ":" in host and not host.startswith("["):
151
+ host_part = f"[{host}]"
152
+ return f"{scheme}://{host_part}:{port}"
153
+
154
+
155
+ def normalise_host(host: str | IPv4Address) -> str:
156
+ if isinstance(host, IPv4Address):
157
+ return str(host)
158
+ return str(host)
ocpp/models.py CHANGED
@@ -1,9 +1,12 @@
1
+ import re
1
2
  import socket
2
3
  from decimal import Decimal, InvalidOperation
3
4
 
4
5
  from django.conf import settings
5
6
  from django.contrib.sites.models import Site
6
7
  from django.db import models
8
+ from django.db.models import Q
9
+ from django.core.exceptions import ValidationError
7
10
  from django.urls import reverse
8
11
  from django.utils.translation import gettext_lazy as _
9
12
 
@@ -17,7 +20,9 @@ from core.models import (
17
20
  ElectricVehicle as CoreElectricVehicle,
18
21
  Brand as CoreBrand,
19
22
  EVModel as CoreEVModel,
23
+ SecurityGroup,
20
24
  )
25
+ from .reference_utils import url_targets_local_loopback
21
26
 
22
27
 
23
28
  class Location(Entity):
@@ -42,6 +47,19 @@ class Location(Entity):
42
47
  class Charger(Entity):
43
48
  """Known charge point."""
44
49
 
50
+ _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
51
+
52
+ OPERATIVE_STATUSES = {
53
+ "Available",
54
+ "Preparing",
55
+ "Charging",
56
+ "SuspendedEV",
57
+ "SuspendedEVSE",
58
+ "Finishing",
59
+ "Reserved",
60
+ }
61
+ INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
62
+
45
63
  charger_id = models.CharField(
46
64
  _("Serial Number"),
47
65
  max_length=100,
@@ -60,7 +78,7 @@ class Charger(Entity):
60
78
  help_text="Optional connector identifier for multi-connector chargers.",
61
79
  )
62
80
  public_display = models.BooleanField(
63
- _("Show on Public Dashboard"),
81
+ _("Public"),
64
82
  default=True,
65
83
  help_text="Display this charger on the public status dashboard.",
66
84
  )
@@ -70,21 +88,21 @@ class Charger(Entity):
70
88
  help_text="Require a valid RFID before starting a charging session.",
71
89
  )
72
90
  firmware_status = models.CharField(
73
- _("Firmware Status"),
91
+ _("Status"),
74
92
  max_length=32,
75
93
  blank=True,
76
94
  default="",
77
95
  help_text="Latest firmware status reported by the charger.",
78
96
  )
79
97
  firmware_status_info = models.CharField(
80
- _("Firmware Status Details"),
98
+ _("Status Details"),
81
99
  max_length=255,
82
100
  blank=True,
83
101
  default="",
84
102
  help_text="Additional information supplied with the firmware status.",
85
103
  )
86
104
  firmware_timestamp = models.DateTimeField(
87
- _("Firmware Status Timestamp"),
105
+ _("Status Timestamp"),
88
106
  null=True,
89
107
  blank=True,
90
108
  help_text="When the charger reported the current firmware status.",
@@ -95,6 +113,58 @@ class Charger(Entity):
95
113
  last_error_code = models.CharField(max_length=64, blank=True)
96
114
  last_status_vendor_info = models.JSONField(null=True, blank=True)
97
115
  last_status_timestamp = models.DateTimeField(null=True, blank=True)
116
+ availability_state = models.CharField(
117
+ _("State"),
118
+ max_length=16,
119
+ blank=True,
120
+ default="",
121
+ help_text=(
122
+ "Current availability reported by the charger "
123
+ "(Operative/Inoperative)."
124
+ ),
125
+ )
126
+ availability_state_updated_at = models.DateTimeField(
127
+ _("State Updated At"),
128
+ null=True,
129
+ blank=True,
130
+ help_text="When the current availability state became effective.",
131
+ )
132
+ availability_requested_state = models.CharField(
133
+ _("Requested State"),
134
+ max_length=16,
135
+ blank=True,
136
+ default="",
137
+ help_text="Last availability state requested via ChangeAvailability.",
138
+ )
139
+ availability_requested_at = models.DateTimeField(
140
+ _("Requested At"),
141
+ null=True,
142
+ blank=True,
143
+ help_text="When the last ChangeAvailability request was sent.",
144
+ )
145
+ availability_request_status = models.CharField(
146
+ _("Request Status"),
147
+ max_length=16,
148
+ blank=True,
149
+ default="",
150
+ help_text=(
151
+ "Latest response status for ChangeAvailability "
152
+ "(Accepted/Rejected/Scheduled)."
153
+ ),
154
+ )
155
+ availability_request_status_at = models.DateTimeField(
156
+ _("Request Status At"),
157
+ null=True,
158
+ blank=True,
159
+ help_text="When the last ChangeAvailability response was received.",
160
+ )
161
+ availability_request_details = models.CharField(
162
+ _("Request Details"),
163
+ max_length=255,
164
+ blank=True,
165
+ default="",
166
+ help_text="Additional details from the last ChangeAvailability response.",
167
+ )
98
168
  temperature = models.DecimalField(
99
169
  max_digits=5, decimal_places=1, null=True, blank=True
100
170
  )
@@ -134,10 +204,60 @@ class Charger(Entity):
134
204
  blank=True,
135
205
  related_name="managed_chargers",
136
206
  )
207
+ owner_users = models.ManyToManyField(
208
+ settings.AUTH_USER_MODEL,
209
+ blank=True,
210
+ related_name="owned_chargers",
211
+ help_text=_("Users who can view this charge point."),
212
+ )
213
+ owner_groups = models.ManyToManyField(
214
+ SecurityGroup,
215
+ blank=True,
216
+ related_name="owned_chargers",
217
+ help_text=_("Security groups that can view this charge point."),
218
+ )
137
219
 
138
220
  def __str__(self) -> str: # pragma: no cover - simple representation
139
221
  return self.charger_id
140
222
 
223
+ @classmethod
224
+ def visible_for_user(cls, user):
225
+ """Return chargers marked for display that the user may view."""
226
+
227
+ qs = cls.objects.filter(public_display=True)
228
+ if getattr(user, "is_superuser", False):
229
+ return qs
230
+ if not getattr(user, "is_authenticated", False):
231
+ return qs.filter(
232
+ owner_users__isnull=True, owner_groups__isnull=True
233
+ ).distinct()
234
+ group_ids = list(user.groups.values_list("pk", flat=True))
235
+ visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
236
+ owner_users=user
237
+ )
238
+ if group_ids:
239
+ visibility |= Q(owner_groups__pk__in=group_ids)
240
+ return qs.filter(visibility).distinct()
241
+
242
+ def has_owner_scope(self) -> bool:
243
+ """Return ``True`` when owner restrictions are defined."""
244
+
245
+ return self.owner_users.exists() or self.owner_groups.exists()
246
+
247
+ def is_visible_to(self, user) -> bool:
248
+ """Return ``True`` when ``user`` may view this charger."""
249
+
250
+ if getattr(user, "is_superuser", False):
251
+ return True
252
+ if not self.has_owner_scope():
253
+ return True
254
+ if not getattr(user, "is_authenticated", False):
255
+ return False
256
+ if self.owner_users.filter(pk=user.pk).exists():
257
+ return True
258
+ user_group_ids = user.groups.values_list("pk", flat=True)
259
+ return self.owner_groups.filter(pk__in=user_group_ids).exists()
260
+
141
261
  class Meta:
142
262
  verbose_name = _("Charge Point")
143
263
  verbose_name_plural = _("Charge Points")
@@ -154,6 +274,39 @@ class Charger(Entity):
154
274
  ),
155
275
  ]
156
276
 
277
+
278
+ @classmethod
279
+ def normalize_serial(cls, value: str | None) -> str:
280
+ """Return ``value`` trimmed for consistent comparisons."""
281
+
282
+ if value is None:
283
+ return ""
284
+ return str(value).strip()
285
+
286
+ @classmethod
287
+ def is_placeholder_serial(cls, value: str | None) -> bool:
288
+ """Return ``True`` when ``value`` matches the placeholder pattern."""
289
+
290
+ normalized = cls.normalize_serial(value)
291
+ return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
292
+
293
+ @classmethod
294
+ def validate_serial(cls, value: str | None) -> str:
295
+ """Return a normalized serial number or raise ``ValidationError``."""
296
+
297
+ normalized = cls.normalize_serial(value)
298
+ if not normalized:
299
+ raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
300
+ if cls.is_placeholder_serial(normalized):
301
+ raise ValidationError(
302
+ {
303
+ "charger_id": _(
304
+ "Serial Number placeholder values such as <charger_id> are not allowed."
305
+ )
306
+ }
307
+ )
308
+ return normalized
309
+
157
310
  AGGREGATE_CONNECTOR_SLUG = "all"
158
311
 
159
312
  def identity_tuple(self) -> tuple[str, int | None]:
@@ -183,6 +336,19 @@ class Charger(Entity):
183
336
  except (TypeError, ValueError) as exc:
184
337
  raise ValueError(f"Invalid connector slug: {slug}") from exc
185
338
 
339
+ @classmethod
340
+ def availability_state_from_status(cls, status: str) -> str | None:
341
+ """Return the availability state implied by a status notification."""
342
+
343
+ normalized = (status or "").strip()
344
+ if not normalized:
345
+ return None
346
+ if normalized in cls.INOPERATIVE_STATUSES:
347
+ return "Inoperative"
348
+ if normalized in cls.OPERATIVE_STATUSES:
349
+ return "Operative"
350
+ return None
351
+
186
352
  @property
187
353
  def connector_slug(self) -> str:
188
354
  """Return the slug representing this charger's connector."""
@@ -251,7 +417,12 @@ class Charger(Entity):
251
417
  scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
252
418
  return f"{scheme}://{domain}{self.get_absolute_url()}"
253
419
 
420
+ def clean(self):
421
+ super().clean()
422
+ self.charger_id = type(self).validate_serial(self.charger_id)
423
+
254
424
  def save(self, *args, **kwargs):
425
+ self.charger_id = type(self).validate_serial(self.charger_id)
255
426
  update_fields = kwargs.get("update_fields")
256
427
  update_list = list(update_fields) if update_fields is not None else None
257
428
  if not self.manager_node_id:
@@ -279,6 +450,8 @@ class Charger(Entity):
279
450
  kwargs["update_fields"] = update_list
280
451
  super().save(*args, **kwargs)
281
452
  ref_value = self._full_url()
453
+ if url_targets_local_loopback(ref_value):
454
+ return
282
455
  if not self.reference or self.reference.value != ref_value:
283
456
  self.reference = Reference.objects.create(
284
457
  value=ref_value, alt_text=self.charger_id
@@ -612,6 +785,18 @@ class Simulator(Entity):
612
785
  default=False,
613
786
  help_text=_("Send a DoorOpen error StatusNotification when enabled."),
614
787
  )
788
+ configuration_keys = models.JSONField(
789
+ default=list,
790
+ blank=True,
791
+ help_text=_(
792
+ "List of configurationKey entries to return for GetConfiguration calls."
793
+ ),
794
+ )
795
+ configuration_unknown_keys = models.JSONField(
796
+ default=list,
797
+ blank=True,
798
+ help_text=_("Keys to include in the GetConfiguration unknownKey response."),
799
+ )
615
800
 
616
801
  def __str__(self) -> str: # pragma: no cover - simple representation
617
802
  return self.name
@@ -638,6 +823,8 @@ class Simulator(Entity):
638
823
  repeat=self.repeat,
639
824
  username=self.username or None,
640
825
  password=self.password or None,
826
+ configuration_keys=self.configuration_keys or [],
827
+ configuration_unknown_keys=self.configuration_unknown_keys or [],
641
828
  )
642
829
 
643
830
  @property
@@ -650,6 +837,52 @@ class Simulator(Entity):
650
837
  return f"ws://{self.host}/{path}"
651
838
 
652
839
 
840
+ class DataTransferMessage(models.Model):
841
+ """Persisted record of OCPP DataTransfer exchanges."""
842
+
843
+ DIRECTION_CP_TO_CSMS = "cp_to_csms"
844
+ DIRECTION_CSMS_TO_CP = "csms_to_cp"
845
+ DIRECTION_CHOICES = (
846
+ (DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
847
+ (DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
848
+ )
849
+
850
+ charger = models.ForeignKey(
851
+ "Charger",
852
+ on_delete=models.CASCADE,
853
+ related_name="data_transfer_messages",
854
+ )
855
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
856
+ direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
857
+ ocpp_message_id = models.CharField(max_length=64)
858
+ vendor_id = models.CharField(max_length=255, blank=True)
859
+ message_id = models.CharField(max_length=255, blank=True)
860
+ payload = models.JSONField(default=dict, blank=True)
861
+ status = models.CharField(max_length=64, blank=True)
862
+ response_data = models.JSONField(null=True, blank=True)
863
+ error_code = models.CharField(max_length=64, blank=True)
864
+ error_description = models.TextField(blank=True)
865
+ error_details = models.JSONField(null=True, blank=True)
866
+ responded_at = models.DateTimeField(null=True, blank=True)
867
+ created_at = models.DateTimeField(auto_now_add=True)
868
+ updated_at = models.DateTimeField(auto_now=True)
869
+
870
+ class Meta:
871
+ ordering = ["-created_at"]
872
+ indexes = [
873
+ models.Index(
874
+ fields=["ocpp_message_id"],
875
+ name="ocpp_datatr_ocpp_me_70d17f_idx",
876
+ ),
877
+ models.Index(
878
+ fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
879
+ ),
880
+ ]
881
+
882
+ def __str__(self) -> str: # pragma: no cover - simple representation
883
+ return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
884
+
885
+
653
886
  class RFID(CoreRFID):
654
887
  class Meta:
655
888
  proxy = True
@@ -0,0 +1,42 @@
1
+ """Helpers related to console Reference creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ def _normalize_host(host: str | None) -> str:
10
+ """Return a trimmed host string without surrounding brackets."""
11
+
12
+ if not host:
13
+ return ""
14
+ host = host.strip()
15
+ if host.startswith("[") and host.endswith("]"):
16
+ return host[1:-1]
17
+ return host
18
+
19
+
20
+ def host_is_local_loopback(host: str | None) -> bool:
21
+ """Return ``True`` when the host string points to 127.0.0.1."""
22
+
23
+ normalized = _normalize_host(host)
24
+ if not normalized:
25
+ return False
26
+ try:
27
+ return ipaddress.ip_address(normalized) == ipaddress.ip_address("127.0.0.1")
28
+ except ValueError:
29
+ return False
30
+
31
+
32
+ def url_targets_local_loopback(url: str | None) -> bool:
33
+ """Return ``True`` when the parsed URL host equals 127.0.0.1."""
34
+
35
+ if not url:
36
+ return False
37
+ parsed = urlparse(url)
38
+ return host_is_local_loopback(parsed.hostname)
39
+
40
+
41
+ __all__ = ["host_is_local_loopback", "url_targets_local_loopback"]
42
+