arthexis 0.1.17__py3-none-any.whl → 0.1.19__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.
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/METADATA +37 -10
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/RECORD +35 -34
- config/middleware.py +47 -1
- config/settings.py +2 -5
- config/urls.py +5 -0
- core/admin.py +1 -1
- core/models.py +31 -1
- core/system.py +125 -0
- core/tasks.py +0 -22
- core/tests.py +9 -0
- core/views.py +87 -19
- nodes/admin.py +1 -2
- nodes/models.py +18 -23
- nodes/tests.py +42 -34
- nodes/urls.py +0 -1
- nodes/views.py +2 -15
- ocpp/admin.py +23 -2
- ocpp/consumers.py +63 -19
- ocpp/models.py +7 -0
- ocpp/store.py +6 -4
- ocpp/test_rfid.py +70 -0
- ocpp/tests.py +107 -1
- ocpp/views.py +84 -10
- pages/admin.py +150 -15
- pages/apps.py +3 -0
- pages/context_processors.py +11 -0
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/site_config.py +137 -0
- pages/tests.py +352 -30
- pages/urls.py +2 -1
- pages/views.py +70 -23
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/WHEEL +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/top_level.txt +0 -0
core/views.py
CHANGED
|
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
|
|
|
43
43
|
PYPI_REQUEST_TIMEOUT = 10
|
|
44
44
|
|
|
45
45
|
from . import changelog as changelog_utils
|
|
46
|
+
from . import temp_passwords
|
|
46
47
|
from .models import OdooProfile, Product, EnergyAccount, PackageRelease, Todo
|
|
47
48
|
from .models import RFID
|
|
48
49
|
|
|
@@ -336,6 +337,35 @@ def odoo_quote_report(request):
|
|
|
336
337
|
return TemplateResponse(request, "admin/core/odoo_quote_report.html", context)
|
|
337
338
|
|
|
338
339
|
|
|
340
|
+
@staff_member_required
|
|
341
|
+
@require_GET
|
|
342
|
+
def request_temp_password(request):
|
|
343
|
+
"""Generate a temporary password for the authenticated staff member."""
|
|
344
|
+
|
|
345
|
+
user = request.user
|
|
346
|
+
username = user.get_username()
|
|
347
|
+
password = temp_passwords.generate_password()
|
|
348
|
+
entry = temp_passwords.store_temp_password(
|
|
349
|
+
username,
|
|
350
|
+
password,
|
|
351
|
+
allow_change=True,
|
|
352
|
+
)
|
|
353
|
+
context = {
|
|
354
|
+
**admin_site.each_context(request),
|
|
355
|
+
"title": _("Temporary password"),
|
|
356
|
+
"username": username,
|
|
357
|
+
"password": password,
|
|
358
|
+
"expires_at": timezone.localtime(entry.expires_at),
|
|
359
|
+
"allow_change": entry.allow_change,
|
|
360
|
+
"return_url": reverse("admin:password_change"),
|
|
361
|
+
}
|
|
362
|
+
return TemplateResponse(
|
|
363
|
+
request,
|
|
364
|
+
"admin/core/request_temp_password.html",
|
|
365
|
+
context,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
339
369
|
@require_GET
|
|
340
370
|
def version_info(request):
|
|
341
371
|
"""Return the running application version and Git revision."""
|
|
@@ -706,6 +736,34 @@ def _ensure_release_todo(
|
|
|
706
736
|
return todo, fixture_path
|
|
707
737
|
|
|
708
738
|
|
|
739
|
+
def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
|
|
740
|
+
"""Return ``True`` when ``todo`` should block the release workflow."""
|
|
741
|
+
|
|
742
|
+
request = (todo.request or "").strip()
|
|
743
|
+
release_name = (release.package.name or "").strip()
|
|
744
|
+
if not request or not release_name:
|
|
745
|
+
return True
|
|
746
|
+
|
|
747
|
+
prefix = f"create release {release_name.lower()} "
|
|
748
|
+
if not request.lower().startswith(prefix):
|
|
749
|
+
return True
|
|
750
|
+
|
|
751
|
+
release_version = (release.version or "").strip()
|
|
752
|
+
generated_version = (todo.generated_for_version or "").strip()
|
|
753
|
+
if not release_version or release_version != generated_version:
|
|
754
|
+
return True
|
|
755
|
+
|
|
756
|
+
generated_revision = (todo.generated_for_revision or "").strip()
|
|
757
|
+
release_revision = (release.revision or "").strip()
|
|
758
|
+
if generated_revision and release_revision and generated_revision != release_revision:
|
|
759
|
+
return True
|
|
760
|
+
|
|
761
|
+
if not todo.is_seed_data:
|
|
762
|
+
return True
|
|
763
|
+
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
|
|
709
767
|
def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
|
|
710
768
|
"""Ensure ``release`` matches the repository revision and version.
|
|
711
769
|
|
|
@@ -1855,26 +1913,37 @@ def release_progress(request, pk: int, action: str):
|
|
|
1855
1913
|
|
|
1856
1914
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
1857
1915
|
pending_items = list(pending_qs)
|
|
1858
|
-
|
|
1859
|
-
if
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1916
|
+
blocking_todos = [
|
|
1917
|
+
todo for todo in pending_items if _todo_blocks_publish(todo, release)
|
|
1918
|
+
]
|
|
1919
|
+
if not blocking_todos:
|
|
1920
|
+
ctx["todos_ack"] = True
|
|
1921
|
+
ctx["todos_ack_auto"] = True
|
|
1922
|
+
elif ack_todos_requested:
|
|
1923
|
+
failures = []
|
|
1924
|
+
for todo in blocking_todos:
|
|
1925
|
+
result = todo.check_on_done_condition()
|
|
1926
|
+
if not result.passed:
|
|
1927
|
+
failures.append((todo, result))
|
|
1928
|
+
if failures:
|
|
1929
|
+
ctx["todos_ack"] = False
|
|
1930
|
+
ctx.pop("todos_ack_auto", None)
|
|
1931
|
+
for todo, result in failures:
|
|
1932
|
+
messages.error(request, _format_condition_failure(todo, result))
|
|
1871
1933
|
else:
|
|
1872
1934
|
ctx["todos_ack"] = True
|
|
1935
|
+
ctx.pop("todos_ack_auto", None)
|
|
1936
|
+
else:
|
|
1937
|
+
if ctx.pop("todos_ack_auto", None):
|
|
1938
|
+
ctx["todos_ack"] = False
|
|
1939
|
+
else:
|
|
1940
|
+
ctx.setdefault("todos_ack", False)
|
|
1873
1941
|
|
|
1874
1942
|
if ctx.get("todos_ack"):
|
|
1875
1943
|
ctx.pop("todos_block_logged", None)
|
|
1876
|
-
|
|
1877
|
-
|
|
1944
|
+
ctx.pop("todos", None)
|
|
1945
|
+
ctx.pop("todos_required", None)
|
|
1946
|
+
else:
|
|
1878
1947
|
ctx["todos"] = [
|
|
1879
1948
|
{
|
|
1880
1949
|
"id": todo.pk,
|
|
@@ -1882,12 +1951,9 @@ def release_progress(request, pk: int, action: str):
|
|
|
1882
1951
|
"url": todo.url,
|
|
1883
1952
|
"request_details": todo.request_details,
|
|
1884
1953
|
}
|
|
1885
|
-
for todo in
|
|
1954
|
+
for todo in blocking_todos
|
|
1886
1955
|
]
|
|
1887
1956
|
ctx["todos_required"] = True
|
|
1888
|
-
else:
|
|
1889
|
-
ctx.pop("todos", None)
|
|
1890
|
-
ctx.pop("todos_required", None)
|
|
1891
1957
|
|
|
1892
1958
|
log_name = _release_log_name(release.package.name, release.version)
|
|
1893
1959
|
if ctx.get("log") != log_name:
|
|
@@ -1897,6 +1963,8 @@ def release_progress(request, pk: int, action: str):
|
|
|
1897
1963
|
"started": ctx.get("started", False),
|
|
1898
1964
|
}
|
|
1899
1965
|
step_count = 0
|
|
1966
|
+
if not blocking_todos:
|
|
1967
|
+
ctx["todos_ack"] = True
|
|
1900
1968
|
log_path = log_dir / log_name
|
|
1901
1969
|
ctx.setdefault("log", log_name)
|
|
1902
1970
|
ctx.setdefault("paused", False)
|
nodes/admin.py
CHANGED
|
@@ -1565,7 +1565,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1565
1565
|
search_fields = ("subject", "body")
|
|
1566
1566
|
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
1567
1567
|
ordering = ("-created",)
|
|
1568
|
-
readonly_fields = ("complete",
|
|
1568
|
+
readonly_fields = ("complete",)
|
|
1569
1569
|
actions = ["send_messages"]
|
|
1570
1570
|
fieldsets = (
|
|
1571
1571
|
(None, {"fields": ("subject", "body")}),
|
|
@@ -1590,7 +1590,6 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1590
1590
|
"node_origin",
|
|
1591
1591
|
"target_limit",
|
|
1592
1592
|
"propagated_to",
|
|
1593
|
-
"confirmed_peers",
|
|
1594
1593
|
"complete",
|
|
1595
1594
|
)
|
|
1596
1595
|
},
|
nodes/models.py
CHANGED
|
@@ -293,7 +293,13 @@ class Node(Entity):
|
|
|
293
293
|
@classmethod
|
|
294
294
|
def register_current(cls):
|
|
295
295
|
"""Create or update the :class:`Node` entry for this host."""
|
|
296
|
-
|
|
296
|
+
hostname_override = (
|
|
297
|
+
os.environ.get("NODE_HOSTNAME")
|
|
298
|
+
or os.environ.get("HOSTNAME")
|
|
299
|
+
or ""
|
|
300
|
+
)
|
|
301
|
+
hostname_override = hostname_override.strip()
|
|
302
|
+
hostname = hostname_override or socket.gethostname()
|
|
297
303
|
try:
|
|
298
304
|
address = socket.gethostbyname(hostname)
|
|
299
305
|
except OSError:
|
|
@@ -305,7 +311,11 @@ class Node(Entity):
|
|
|
305
311
|
rev_value = revision.get_revision()
|
|
306
312
|
installed_revision = rev_value if rev_value else ""
|
|
307
313
|
mac = cls.get_current_mac()
|
|
308
|
-
|
|
314
|
+
endpoint_override = os.environ.get("NODE_PUBLIC_ENDPOINT", "").strip()
|
|
315
|
+
slug_source = endpoint_override or hostname
|
|
316
|
+
slug = slugify(slug_source)
|
|
317
|
+
if not slug:
|
|
318
|
+
slug = cls._generate_unique_public_endpoint(hostname or mac)
|
|
309
319
|
node = cls.objects.filter(mac_address=mac).first()
|
|
310
320
|
if not node:
|
|
311
321
|
node = cls.objects.filter(public_endpoint=slug).first()
|
|
@@ -1410,7 +1420,6 @@ class NetMessage(Entity):
|
|
|
1410
1420
|
propagated_to = models.ManyToManyField(
|
|
1411
1421
|
Node, blank=True, related_name="received_net_messages"
|
|
1412
1422
|
)
|
|
1413
|
-
confirmed_peers = models.JSONField(default=dict, blank=True)
|
|
1414
1423
|
created = models.DateTimeField(auto_now_add=True)
|
|
1415
1424
|
complete = models.BooleanField(default=False, editable=False)
|
|
1416
1425
|
|
|
@@ -1640,10 +1649,7 @@ class NetMessage(Entity):
|
|
|
1640
1649
|
seen_list = seen.copy()
|
|
1641
1650
|
selected_ids = [str(n.uuid) for n in selected]
|
|
1642
1651
|
payload_seen = seen_list + selected_ids
|
|
1643
|
-
confirmed_peers = dict(self.confirmed_peers or {})
|
|
1644
|
-
|
|
1645
1652
|
for node in selected:
|
|
1646
|
-
now = timezone.now().isoformat()
|
|
1647
1653
|
payload = {
|
|
1648
1654
|
"uuid": str(self.uuid),
|
|
1649
1655
|
"subject": self.subject,
|
|
@@ -1679,33 +1685,22 @@ class NetMessage(Entity):
|
|
|
1679
1685
|
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
1680
1686
|
except Exception:
|
|
1681
1687
|
pass
|
|
1682
|
-
status_entry = {
|
|
1683
|
-
"status": "pending",
|
|
1684
|
-
"status_code": None,
|
|
1685
|
-
"updated": now,
|
|
1686
|
-
}
|
|
1687
1688
|
try:
|
|
1688
|
-
|
|
1689
|
+
requests.post(
|
|
1689
1690
|
f"http://{node.address}:{node.port}/nodes/net-message/",
|
|
1690
1691
|
data=payload_json,
|
|
1691
1692
|
headers=headers,
|
|
1692
1693
|
timeout=1,
|
|
1693
1694
|
)
|
|
1694
|
-
status_entry["status_code"] = getattr(response, "status_code", None)
|
|
1695
|
-
if getattr(response, "ok", False):
|
|
1696
|
-
status_entry["status"] = "acknowledged"
|
|
1697
|
-
else:
|
|
1698
|
-
status_entry["status"] = "failed"
|
|
1699
1695
|
except Exception:
|
|
1700
|
-
|
|
1696
|
+
logger.exception(
|
|
1697
|
+
"Failed to propagate NetMessage %s to node %s",
|
|
1698
|
+
self.pk,
|
|
1699
|
+
node.pk,
|
|
1700
|
+
)
|
|
1701
1701
|
self.propagated_to.add(node)
|
|
1702
|
-
confirmed_peers[str(node.uuid)] = status_entry
|
|
1703
1702
|
|
|
1704
1703
|
save_fields: list[str] = []
|
|
1705
|
-
if confirmed_peers != (self.confirmed_peers or {}):
|
|
1706
|
-
self.confirmed_peers = confirmed_peers
|
|
1707
|
-
save_fields.append("confirmed_peers")
|
|
1708
|
-
|
|
1709
1704
|
if total_known and self.propagated_to.count() >= total_known:
|
|
1710
1705
|
self.complete = True
|
|
1711
1706
|
save_fields.append("complete")
|
nodes/tests.py
CHANGED
|
@@ -223,6 +223,46 @@ class NodeGetLocalTests(TestCase):
|
|
|
223
223
|
node.refresh_from_db()
|
|
224
224
|
self.assertEqual(node.role.name, "Constellation")
|
|
225
225
|
|
|
226
|
+
def test_register_current_respects_node_hostname_env(self):
|
|
227
|
+
with TemporaryDirectory() as tmp:
|
|
228
|
+
base = Path(tmp)
|
|
229
|
+
with override_settings(BASE_DIR=base):
|
|
230
|
+
with (
|
|
231
|
+
patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
|
|
232
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
|
|
233
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
234
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
235
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
236
|
+
patch.object(Node, "ensure_keys"),
|
|
237
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
238
|
+
):
|
|
239
|
+
node, created = Node.register_current()
|
|
240
|
+
self.assertTrue(created)
|
|
241
|
+
self.assertEqual(node.hostname, "gway-002")
|
|
242
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
243
|
+
|
|
244
|
+
def test_register_current_respects_public_endpoint_env(self):
|
|
245
|
+
with TemporaryDirectory() as tmp:
|
|
246
|
+
base = Path(tmp)
|
|
247
|
+
with override_settings(BASE_DIR=base):
|
|
248
|
+
with (
|
|
249
|
+
patch.dict(
|
|
250
|
+
os.environ,
|
|
251
|
+
{"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
|
|
252
|
+
clear=False,
|
|
253
|
+
),
|
|
254
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
|
|
255
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
256
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
257
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
258
|
+
patch.object(Node, "ensure_keys"),
|
|
259
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
260
|
+
):
|
|
261
|
+
node, created = Node.register_current()
|
|
262
|
+
self.assertTrue(created)
|
|
263
|
+
self.assertEqual(node.hostname, "gway-alpha")
|
|
264
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
265
|
+
|
|
226
266
|
def test_register_and_list_node(self):
|
|
227
267
|
response = self.client.post(
|
|
228
268
|
reverse("register-node"),
|
|
@@ -2315,29 +2355,6 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2315
2355
|
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
2316
2356
|
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
2317
2357
|
|
|
2318
|
-
|
|
2319
|
-
class LastNetMessageViewTests(TestCase):
|
|
2320
|
-
def setUp(self):
|
|
2321
|
-
self.client = Client()
|
|
2322
|
-
NodeRole.objects.get_or_create(name="Terminal")
|
|
2323
|
-
|
|
2324
|
-
def test_returns_latest_message(self):
|
|
2325
|
-
NetMessage.objects.create(subject="old", body="msg1")
|
|
2326
|
-
latest = NetMessage.objects.create(subject="new", body="msg2")
|
|
2327
|
-
resp = self.client.get(reverse("last-net-message"))
|
|
2328
|
-
self.assertEqual(resp.status_code, 200)
|
|
2329
|
-
self.assertEqual(
|
|
2330
|
-
resp.json(),
|
|
2331
|
-
{
|
|
2332
|
-
"subject": "new",
|
|
2333
|
-
"body": "msg2",
|
|
2334
|
-
"admin_url": reverse(
|
|
2335
|
-
"admin:nodes_netmessage_change", args=[latest.pk]
|
|
2336
|
-
),
|
|
2337
|
-
},
|
|
2338
|
-
)
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
2358
|
class NetMessageReachTests(TestCase):
|
|
2342
2359
|
def setUp(self):
|
|
2343
2360
|
self.roles = {}
|
|
@@ -2597,13 +2614,6 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2597
2614
|
self.assertNotIn(sender_addr, targets)
|
|
2598
2615
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2599
2616
|
self.assertTrue(msg.complete)
|
|
2600
|
-
self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
|
|
2601
|
-
self.assertTrue(
|
|
2602
|
-
all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
|
|
2603
|
-
)
|
|
2604
|
-
self.assertTrue(
|
|
2605
|
-
all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
|
|
2606
|
-
)
|
|
2607
2617
|
|
|
2608
2618
|
@patch("requests.post")
|
|
2609
2619
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2689,10 +2699,8 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2689
2699
|
):
|
|
2690
2700
|
msg.propagate()
|
|
2691
2701
|
|
|
2692
|
-
self.
|
|
2693
|
-
self.assertTrue(
|
|
2694
|
-
all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
|
|
2695
|
-
)
|
|
2702
|
+
self.assertEqual(msg.propagated_to.count(), len(self.remotes))
|
|
2703
|
+
self.assertTrue(msg.complete)
|
|
2696
2704
|
|
|
2697
2705
|
|
|
2698
2706
|
class NetMessageSignatureTests(TestCase):
|
nodes/urls.py
CHANGED
|
@@ -8,7 +8,6 @@ urlpatterns = [
|
|
|
8
8
|
path("register/", views.register_node, name="register-node"),
|
|
9
9
|
path("screenshot/", views.capture, name="node-screenshot"),
|
|
10
10
|
path("net-message/", views.net_message, name="net-message"),
|
|
11
|
-
path("last-message/", views.last_net_message, name="last-net-message"),
|
|
12
11
|
path("rfid/export/", views.export_rfids, name="node-rfid-export"),
|
|
13
12
|
path("rfid/import/", views.import_rfids, name="node-rfid-import"),
|
|
14
13
|
path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
|
nodes/views.py
CHANGED
|
@@ -104,6 +104,8 @@ def _get_host_domain(request) -> str:
|
|
|
104
104
|
domain, _ = split_domain_port(host)
|
|
105
105
|
if not domain:
|
|
106
106
|
return ""
|
|
107
|
+
if domain.lower() == "localhost":
|
|
108
|
+
return ""
|
|
107
109
|
try:
|
|
108
110
|
ipaddress.ip_address(domain)
|
|
109
111
|
except ValueError:
|
|
@@ -666,18 +668,3 @@ def net_message(request):
|
|
|
666
668
|
msg.apply_attachments(attachments)
|
|
667
669
|
msg.propagate(seen=seen)
|
|
668
670
|
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def last_net_message(request):
|
|
672
|
-
"""Return the most recent :class:`NetMessage`."""
|
|
673
|
-
|
|
674
|
-
msg = NetMessage.objects.order_by("-created").first()
|
|
675
|
-
if not msg:
|
|
676
|
-
return JsonResponse({"subject": "", "body": "", "admin_url": ""})
|
|
677
|
-
return JsonResponse(
|
|
678
|
-
{
|
|
679
|
-
"subject": msg.subject,
|
|
680
|
-
"body": msg.body,
|
|
681
|
-
"admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
|
|
682
|
-
}
|
|
683
|
-
)
|
ocpp/admin.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import timedelta
|
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
9
|
+
from django.utils import formats, timezone, translation
|
|
10
10
|
from django.urls import path
|
|
11
11
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
12
|
from django.template.response import TemplateResponse
|
|
@@ -163,6 +163,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
163
163
|
"charger_id",
|
|
164
164
|
"display_name",
|
|
165
165
|
"connector_id",
|
|
166
|
+
"language",
|
|
166
167
|
"location",
|
|
167
168
|
"last_path",
|
|
168
169
|
"last_heartbeat",
|
|
@@ -719,7 +720,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
719
720
|
"ws_port",
|
|
720
721
|
"ws_url",
|
|
721
722
|
"interval",
|
|
722
|
-
"
|
|
723
|
+
"kw_max_display",
|
|
723
724
|
"running",
|
|
724
725
|
"log_link",
|
|
725
726
|
)
|
|
@@ -759,6 +760,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
759
760
|
|
|
760
761
|
log_type = "simulator"
|
|
761
762
|
|
|
763
|
+
@admin.display(description="kW Max", ordering="kw_max")
|
|
764
|
+
def kw_max_display(self, obj):
|
|
765
|
+
"""Display ``kw_max`` with a dot decimal separator for Spanish locales."""
|
|
766
|
+
|
|
767
|
+
language = translation.get_language() or ""
|
|
768
|
+
if language.startswith("es"):
|
|
769
|
+
return formats.number_format(
|
|
770
|
+
obj.kw_max,
|
|
771
|
+
decimal_pos=2,
|
|
772
|
+
use_l10n=False,
|
|
773
|
+
force_grouping=False,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
return formats.number_format(
|
|
777
|
+
obj.kw_max,
|
|
778
|
+
decimal_pos=2,
|
|
779
|
+
use_l10n=True,
|
|
780
|
+
force_grouping=False,
|
|
781
|
+
)
|
|
782
|
+
|
|
762
783
|
def save_model(self, request, obj, form, change):
|
|
763
784
|
previous_door_open = False
|
|
764
785
|
if change and obj.pk:
|
ocpp/consumers.py
CHANGED
|
@@ -5,6 +5,7 @@ from datetime import datetime
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import inspect
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
from urllib.parse import parse_qs
|
|
9
10
|
from django.utils import timezone
|
|
10
11
|
from core.models import EnergyAccount, Reference, RFID as CoreRFID
|
|
@@ -32,6 +33,9 @@ from .evcs_discovery import (
|
|
|
32
33
|
FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
35
39
|
# Query parameter keys that may contain the charge point serial. Keys are
|
|
36
40
|
# matched case-insensitively and trimmed before use.
|
|
37
41
|
SERIAL_QUERY_PARAM_NAMES = (
|
|
@@ -309,6 +313,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
309
313
|
|
|
310
314
|
return await database_sync_to_async(_ensure)()
|
|
311
315
|
|
|
316
|
+
def _log_unlinked_rfid(self, rfid: str) -> None:
|
|
317
|
+
"""Record a warning when an RFID is authorized without an account."""
|
|
318
|
+
|
|
319
|
+
message = (
|
|
320
|
+
f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
|
|
321
|
+
)
|
|
322
|
+
logger.warning(message)
|
|
323
|
+
store.add_log(
|
|
324
|
+
store.pending_key(self.charger_id),
|
|
325
|
+
message,
|
|
326
|
+
log_type="charger",
|
|
327
|
+
)
|
|
328
|
+
|
|
312
329
|
async def _assign_connector(self, connector: int | str | None) -> None:
|
|
313
330
|
"""Ensure ``self.charger`` matches the provided connector id."""
|
|
314
331
|
if connector in (None, "", "-"):
|
|
@@ -1395,13 +1412,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1395
1412
|
elif action == "Authorize":
|
|
1396
1413
|
id_tag = payload.get("idTag")
|
|
1397
1414
|
account = await self._get_account(id_tag)
|
|
1415
|
+
status = "Invalid"
|
|
1398
1416
|
if self.charger.require_rfid:
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1417
|
+
tag = None
|
|
1418
|
+
tag_created = False
|
|
1419
|
+
if id_tag:
|
|
1420
|
+
tag, tag_created = await database_sync_to_async(
|
|
1421
|
+
CoreRFID.register_scan
|
|
1422
|
+
)(id_tag)
|
|
1423
|
+
if account:
|
|
1424
|
+
if await database_sync_to_async(account.can_authorize)():
|
|
1425
|
+
status = "Accepted"
|
|
1426
|
+
elif (
|
|
1427
|
+
id_tag
|
|
1428
|
+
and tag
|
|
1429
|
+
and not tag_created
|
|
1430
|
+
and tag.allowed
|
|
1431
|
+
):
|
|
1432
|
+
status = "Accepted"
|
|
1433
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1405
1434
|
else:
|
|
1406
1435
|
await self._ensure_rfid_seen(id_tag)
|
|
1407
1436
|
status = "Accepted"
|
|
@@ -1475,23 +1504,38 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1475
1504
|
reply_payload = {}
|
|
1476
1505
|
elif action == "StartTransaction":
|
|
1477
1506
|
id_tag = payload.get("idTag")
|
|
1478
|
-
|
|
1507
|
+
tag = None
|
|
1508
|
+
tag_created = False
|
|
1479
1509
|
if id_tag:
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1510
|
+
tag, tag_created = await database_sync_to_async(
|
|
1511
|
+
CoreRFID.register_scan
|
|
1512
|
+
)(id_tag)
|
|
1513
|
+
account = await self._get_account(id_tag)
|
|
1514
|
+
if id_tag and not self.charger.require_rfid:
|
|
1515
|
+
seen_tag = await self._ensure_rfid_seen(id_tag)
|
|
1516
|
+
if seen_tag:
|
|
1517
|
+
tag = seen_tag
|
|
1486
1518
|
await self._assign_connector(payload.get("connectorId"))
|
|
1519
|
+
authorized = True
|
|
1520
|
+
authorized_via_tag = False
|
|
1487
1521
|
if self.charger.require_rfid:
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1522
|
+
if account is not None:
|
|
1523
|
+
authorized = await database_sync_to_async(
|
|
1524
|
+
account.can_authorize
|
|
1525
|
+
)()
|
|
1526
|
+
elif (
|
|
1527
|
+
id_tag
|
|
1528
|
+
and tag
|
|
1529
|
+
and not tag_created
|
|
1530
|
+
and getattr(tag, "allowed", False)
|
|
1531
|
+
):
|
|
1532
|
+
authorized = True
|
|
1533
|
+
authorized_via_tag = True
|
|
1534
|
+
else:
|
|
1535
|
+
authorized = False
|
|
1494
1536
|
if authorized:
|
|
1537
|
+
if authorized_via_tag and tag:
|
|
1538
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1495
1539
|
start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1496
1540
|
received_start = timezone.now()
|
|
1497
1541
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
ocpp/models.py
CHANGED
|
@@ -82,6 +82,13 @@ class Charger(Entity):
|
|
|
82
82
|
default=True,
|
|
83
83
|
help_text="Display this charger on the public status dashboard.",
|
|
84
84
|
)
|
|
85
|
+
language = models.CharField(
|
|
86
|
+
_("Language"),
|
|
87
|
+
max_length=12,
|
|
88
|
+
choices=settings.LANGUAGES,
|
|
89
|
+
default="es",
|
|
90
|
+
help_text=_("Preferred language for the public landing page."),
|
|
91
|
+
)
|
|
85
92
|
require_rfid = models.BooleanField(
|
|
86
93
|
_("Require RFID Authorization"),
|
|
87
94
|
default=False,
|
ocpp/store.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import re
|
|
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
|
|
|
427
427
|
def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
|
|
428
428
|
"""Append a timestamped log entry for the given id and log type."""
|
|
429
429
|
|
|
430
|
-
timestamp = datetime.
|
|
430
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
431
431
|
entry = f"{timestamp} {entry}"
|
|
432
432
|
|
|
433
433
|
store = logs[log_type]
|
|
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
|
|
|
454
454
|
|
|
455
455
|
history[cid] = {
|
|
456
456
|
"transaction": tx_id,
|
|
457
|
-
"start": datetime.
|
|
457
|
+
"start": datetime.now(timezone.utc),
|
|
458
458
|
"messages": [],
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
|
|
|
467
467
|
return
|
|
468
468
|
sess["messages"].append(
|
|
469
469
|
{
|
|
470
|
-
"timestamp": datetime.
|
|
470
|
+
"timestamp": datetime.now(timezone.utc)
|
|
471
|
+
.isoformat()
|
|
472
|
+
.replace("+00:00", "Z"),
|
|
471
473
|
"message": message,
|
|
472
474
|
}
|
|
473
475
|
)
|