arthexis 0.1.21__py3-none-any.whl → 0.1.22__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.21.dist-info → arthexis-0.1.22.dist-info}/METADATA +8 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/RECORD +31 -31
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +139 -19
- core/environment.py +2 -239
- core/models.py +419 -2
- core/system.py +76 -0
- core/tests.py +152 -8
- core/views.py +35 -1
- nodes/admin.py +148 -38
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +214 -1
- nodes/views.py +1 -0
- ocpp/admin.py +20 -1
- ocpp/consumers.py +1 -0
- ocpp/models.py +23 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +227 -2
- ocpp/views.py +281 -3
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -22
- pages/urls.py +5 -0
- pages/views.py +264 -11
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
core/tests.py
CHANGED
|
@@ -1969,7 +1969,7 @@ class PackageReleaseAdminActionTests(TestCase):
|
|
|
1969
1969
|
|
|
1970
1970
|
@mock.patch("core.admin.PackageRelease.dump_fixture")
|
|
1971
1971
|
@mock.patch("core.admin.requests.get")
|
|
1972
|
-
def
|
|
1972
|
+
def test_refresh_from_pypi_reports_missing_releases(self, mock_get, dump):
|
|
1973
1973
|
mock_get.return_value.raise_for_status.return_value = None
|
|
1974
1974
|
mock_get.return_value.json.return_value = {
|
|
1975
1975
|
"releases": {
|
|
@@ -1982,13 +1982,17 @@ class PackageReleaseAdminActionTests(TestCase):
|
|
|
1982
1982
|
}
|
|
1983
1983
|
}
|
|
1984
1984
|
self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1985
|
+
self.assertFalse(
|
|
1986
|
+
PackageRelease.objects.filter(version="1.1.0").exists()
|
|
1987
|
+
)
|
|
1988
|
+
dump.assert_not_called()
|
|
1989
|
+
self.assertIn(
|
|
1990
|
+
(
|
|
1991
|
+
"Manual creation required for 1 release: 1.1.0",
|
|
1992
|
+
messages.WARNING,
|
|
1993
|
+
),
|
|
1994
|
+
self.messages,
|
|
1990
1995
|
)
|
|
1991
|
-
dump.assert_called_once()
|
|
1992
1996
|
|
|
1993
1997
|
@mock.patch("core.admin.PackageRelease.dump_fixture")
|
|
1994
1998
|
@mock.patch("core.admin.requests.get")
|
|
@@ -2212,13 +2216,97 @@ class TodoDoneTests(TestCase):
|
|
|
2212
2216
|
User.objects.create_superuser("admin", "admin@example.com", "pw")
|
|
2213
2217
|
self.client.force_login(User.objects.get(username="admin"))
|
|
2214
2218
|
|
|
2215
|
-
|
|
2219
|
+
@mock.patch("core.models.revision_utils.get_revision", return_value="rev123")
|
|
2220
|
+
def test_mark_done_sets_timestamp(self, _get_revision):
|
|
2216
2221
|
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
2217
2222
|
resp = self.client.post(reverse("todo-done", args=[todo.pk]))
|
|
2218
2223
|
self.assertRedirects(resp, reverse("admin:index"))
|
|
2219
2224
|
todo.refresh_from_db()
|
|
2220
2225
|
self.assertIsNotNone(todo.done_on)
|
|
2221
2226
|
self.assertFalse(todo.is_deleted)
|
|
2227
|
+
self.assertIsNone(todo.done_node)
|
|
2228
|
+
version_path = Path(settings.BASE_DIR) / "VERSION"
|
|
2229
|
+
expected_version = ""
|
|
2230
|
+
if version_path.exists():
|
|
2231
|
+
expected_version = version_path.read_text(encoding="utf-8").strip()
|
|
2232
|
+
self.assertEqual(todo.done_version, expected_version)
|
|
2233
|
+
self.assertEqual(todo.done_revision, "rev123")
|
|
2234
|
+
self.assertEqual(todo.done_username, "admin")
|
|
2235
|
+
|
|
2236
|
+
def test_mark_done_updates_seed_fixture(self):
|
|
2237
|
+
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
2238
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2239
|
+
base = Path(tmp)
|
|
2240
|
+
fixture_dir = base / "core" / "fixtures"
|
|
2241
|
+
fixture_dir.mkdir(parents=True)
|
|
2242
|
+
fixture_path = fixture_dir / "todo__task.json"
|
|
2243
|
+
fixture_path.write_text(
|
|
2244
|
+
json.dumps(
|
|
2245
|
+
[
|
|
2246
|
+
{
|
|
2247
|
+
"model": "core.todo",
|
|
2248
|
+
"fields": {
|
|
2249
|
+
"request": "Task",
|
|
2250
|
+
"url": "",
|
|
2251
|
+
"request_details": "",
|
|
2252
|
+
},
|
|
2253
|
+
}
|
|
2254
|
+
],
|
|
2255
|
+
indent=2,
|
|
2256
|
+
)
|
|
2257
|
+
+ "\n",
|
|
2258
|
+
encoding="utf-8",
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
with override_settings(BASE_DIR=base):
|
|
2262
|
+
with mock.patch(
|
|
2263
|
+
"core.models.revision_utils.get_revision", return_value="rev456"
|
|
2264
|
+
):
|
|
2265
|
+
resp = self.client.post(reverse("todo-done", args=[todo.pk]))
|
|
2266
|
+
|
|
2267
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2268
|
+
data = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
2269
|
+
self.assertEqual(len(data), 1)
|
|
2270
|
+
fields = data[0]["fields"]
|
|
2271
|
+
self.assertIn("done_on", fields)
|
|
2272
|
+
self.assertTrue(fields["done_on"])
|
|
2273
|
+
self.assertFalse(fields.get("is_deleted", False))
|
|
2274
|
+
self.assertIn("done_version", fields)
|
|
2275
|
+
self.assertEqual(fields.get("done_revision"), "rev456")
|
|
2276
|
+
self.assertEqual(fields.get("done_username"), "admin")
|
|
2277
|
+
|
|
2278
|
+
def test_soft_delete_updates_seed_fixture(self):
|
|
2279
|
+
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
2280
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2281
|
+
base = Path(tmp)
|
|
2282
|
+
fixture_dir = base / "core" / "fixtures"
|
|
2283
|
+
fixture_dir.mkdir(parents=True)
|
|
2284
|
+
fixture_path = fixture_dir / "todo__task.json"
|
|
2285
|
+
fixture_path.write_text(
|
|
2286
|
+
json.dumps(
|
|
2287
|
+
[
|
|
2288
|
+
{
|
|
2289
|
+
"model": "core.todo",
|
|
2290
|
+
"fields": {
|
|
2291
|
+
"request": "Task",
|
|
2292
|
+
"url": "",
|
|
2293
|
+
"request_details": "",
|
|
2294
|
+
},
|
|
2295
|
+
}
|
|
2296
|
+
],
|
|
2297
|
+
indent=2,
|
|
2298
|
+
)
|
|
2299
|
+
+ "\n",
|
|
2300
|
+
encoding="utf-8",
|
|
2301
|
+
)
|
|
2302
|
+
|
|
2303
|
+
with override_settings(BASE_DIR=base):
|
|
2304
|
+
todo.delete()
|
|
2305
|
+
|
|
2306
|
+
data = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
2307
|
+
self.assertEqual(len(data), 1)
|
|
2308
|
+
fields = data[0]["fields"]
|
|
2309
|
+
self.assertTrue(fields.get("is_deleted"))
|
|
2222
2310
|
|
|
2223
2311
|
def test_mark_done_missing_task_refreshes(self):
|
|
2224
2312
|
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
@@ -2324,6 +2412,61 @@ class TodoDoneTests(TestCase):
|
|
|
2324
2412
|
self.assertTrue(todo.is_seed_data)
|
|
2325
2413
|
|
|
2326
2414
|
|
|
2415
|
+
class TodoDeleteTests(TestCase):
|
|
2416
|
+
def setUp(self):
|
|
2417
|
+
self.client = Client()
|
|
2418
|
+
User.objects.create_superuser("admin", "admin@example.com", "pw")
|
|
2419
|
+
self.client.force_login(User.objects.get(username="admin"))
|
|
2420
|
+
|
|
2421
|
+
def test_delete_marks_task_deleted(self):
|
|
2422
|
+
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
2423
|
+
resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
|
|
2424
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2425
|
+
todo.refresh_from_db()
|
|
2426
|
+
self.assertTrue(todo.is_deleted)
|
|
2427
|
+
self.assertIsNone(todo.done_on)
|
|
2428
|
+
|
|
2429
|
+
def test_delete_updates_seed_fixture(self):
|
|
2430
|
+
todo = Todo.objects.create(request="Task", is_seed_data=True)
|
|
2431
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2432
|
+
base = Path(tmp)
|
|
2433
|
+
fixture_dir = base / "core" / "fixtures"
|
|
2434
|
+
fixture_dir.mkdir(parents=True)
|
|
2435
|
+
fixture_path = fixture_dir / "todo__task.json"
|
|
2436
|
+
fixture_path.write_text(
|
|
2437
|
+
json.dumps(
|
|
2438
|
+
[
|
|
2439
|
+
{
|
|
2440
|
+
"model": "core.todo",
|
|
2441
|
+
"fields": {
|
|
2442
|
+
"request": "Task",
|
|
2443
|
+
"url": "",
|
|
2444
|
+
"request_details": "",
|
|
2445
|
+
},
|
|
2446
|
+
}
|
|
2447
|
+
],
|
|
2448
|
+
indent=2,
|
|
2449
|
+
)
|
|
2450
|
+
+ "\n",
|
|
2451
|
+
encoding="utf-8",
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
with override_settings(BASE_DIR=base):
|
|
2455
|
+
resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
|
|
2456
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2457
|
+
data = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
2458
|
+
self.assertEqual(len(data), 1)
|
|
2459
|
+
fields = data[0]["fields"]
|
|
2460
|
+
self.assertTrue(fields.get("is_deleted"))
|
|
2461
|
+
|
|
2462
|
+
def test_delete_missing_task_redirects(self):
|
|
2463
|
+
todo = Todo.objects.create(request="Task")
|
|
2464
|
+
todo.is_deleted = True
|
|
2465
|
+
todo.save(update_fields=["is_deleted"])
|
|
2466
|
+
resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
|
|
2467
|
+
self.assertRedirects(resp, reverse("admin:index"))
|
|
2468
|
+
|
|
2469
|
+
|
|
2327
2470
|
class TodoFocusViewTests(TestCase):
|
|
2328
2471
|
def setUp(self):
|
|
2329
2472
|
self.client = Client()
|
|
@@ -2341,6 +2484,7 @@ class TodoFocusViewTests(TestCase):
|
|
|
2341
2484
|
self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
|
|
2342
2485
|
self.assertContains(resp, f'src="{todo.url}"')
|
|
2343
2486
|
self.assertContains(resp, "Done")
|
|
2487
|
+
self.assertContains(resp, "Delete")
|
|
2344
2488
|
self.assertContains(resp, "Back")
|
|
2345
2489
|
self.assertContains(resp, "Take Snapshot")
|
|
2346
2490
|
snapshot_url = reverse("todo-snapshot", args=[todo.pk])
|
core/views.py
CHANGED
|
@@ -365,6 +365,7 @@ def request_temp_password(request):
|
|
|
365
365
|
)
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
@staff_member_required
|
|
368
369
|
@require_GET
|
|
369
370
|
def version_info(request):
|
|
370
371
|
"""Return the running application version and Git revision."""
|
|
@@ -495,6 +496,16 @@ def _sync_with_origin_main(log_path: Path) -> None:
|
|
|
495
496
|
if stderr:
|
|
496
497
|
_append_log(log_path, "git errors:\n" + stderr)
|
|
497
498
|
|
|
499
|
+
status = subprocess.run(
|
|
500
|
+
["git", "status"], capture_output=True, text=True, check=False
|
|
501
|
+
)
|
|
502
|
+
status_output = (status.stdout or "").strip()
|
|
503
|
+
status_errors = (status.stderr or "").strip()
|
|
504
|
+
if status_output:
|
|
505
|
+
_append_log(log_path, "git status:\n" + status_output)
|
|
506
|
+
if status_errors:
|
|
507
|
+
_append_log(log_path, "git status errors:\n" + status_errors)
|
|
508
|
+
|
|
498
509
|
branch = _current_branch() or "(detached HEAD)"
|
|
499
510
|
instructions = [
|
|
500
511
|
"Manual intervention required to finish syncing with origin/main.",
|
|
@@ -2360,6 +2371,7 @@ def todo_focus(request, pk: int):
|
|
|
2360
2371
|
"focus_auth": focus_auth,
|
|
2361
2372
|
"next_url": _get_return_url(request),
|
|
2362
2373
|
"done_url": reverse("todo-done", args=[todo.pk]),
|
|
2374
|
+
"delete_url": reverse("todo-delete", args=[todo.pk]),
|
|
2363
2375
|
"snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
|
|
2364
2376
|
}
|
|
2365
2377
|
return render(request, "core/todo_focus.html", context)
|
|
@@ -2378,7 +2390,29 @@ def todo_done(request, pk: int):
|
|
|
2378
2390
|
messages.error(request, _format_condition_failure(todo, result))
|
|
2379
2391
|
return redirect(redirect_to)
|
|
2380
2392
|
todo.done_on = timezone.now()
|
|
2381
|
-
todo.
|
|
2393
|
+
todo.populate_done_metadata(request.user)
|
|
2394
|
+
todo.save(
|
|
2395
|
+
update_fields=[
|
|
2396
|
+
"done_on",
|
|
2397
|
+
"done_node",
|
|
2398
|
+
"done_version",
|
|
2399
|
+
"done_revision",
|
|
2400
|
+
"done_username",
|
|
2401
|
+
]
|
|
2402
|
+
)
|
|
2403
|
+
return redirect(redirect_to)
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
@staff_member_required
|
|
2407
|
+
@require_POST
|
|
2408
|
+
def todo_delete(request, pk: int):
|
|
2409
|
+
redirect_to = reverse("admin:index")
|
|
2410
|
+
try:
|
|
2411
|
+
todo = Todo.objects.get(pk=pk, is_deleted=False)
|
|
2412
|
+
except Todo.DoesNotExist:
|
|
2413
|
+
return redirect(redirect_to)
|
|
2414
|
+
todo.is_deleted = True
|
|
2415
|
+
todo.save(update_fields=["is_deleted"])
|
|
2382
2416
|
return redirect(redirect_to)
|
|
2383
2417
|
|
|
2384
2418
|
|
nodes/admin.py
CHANGED
|
@@ -18,8 +18,10 @@ from django.utils.dateparse import parse_datetime
|
|
|
18
18
|
from django.utils.html import format_html, format_html_join
|
|
19
19
|
from django.utils.translation import gettext_lazy as _
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
22
23
|
import base64
|
|
24
|
+
import ipaddress
|
|
23
25
|
import json
|
|
24
26
|
import subprocess
|
|
25
27
|
import uuid
|
|
@@ -85,7 +87,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
85
87
|
|
|
86
88
|
class DeployDNSRecordsForm(forms.Form):
|
|
87
89
|
manager = forms.ModelChoiceField(
|
|
88
|
-
label="Node
|
|
90
|
+
label="Node Profile",
|
|
89
91
|
queryset=NodeManager.objects.none(),
|
|
90
92
|
help_text="Credentials used to authenticate with the DNS provider.",
|
|
91
93
|
)
|
|
@@ -234,6 +236,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
234
236
|
"role",
|
|
235
237
|
"relation",
|
|
236
238
|
"last_seen",
|
|
239
|
+
"visit_link",
|
|
237
240
|
"proxy_link",
|
|
238
241
|
)
|
|
239
242
|
search_fields = ("hostname", "address", "mac_address")
|
|
@@ -284,7 +287,6 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
284
287
|
"register_visitor",
|
|
285
288
|
"run_task",
|
|
286
289
|
"take_screenshots",
|
|
287
|
-
"fetch_rfids_from_selected",
|
|
288
290
|
"import_rfids_from_selected",
|
|
289
291
|
"export_rfids_to_selected",
|
|
290
292
|
]
|
|
@@ -304,6 +306,49 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
304
306
|
return ""
|
|
305
307
|
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
306
308
|
|
|
309
|
+
@admin.display(description=_("Visit"))
|
|
310
|
+
def visit_link(self, obj):
|
|
311
|
+
if not obj:
|
|
312
|
+
return ""
|
|
313
|
+
if obj.is_local:
|
|
314
|
+
try:
|
|
315
|
+
url = reverse("admin:index")
|
|
316
|
+
except NoReverseMatch:
|
|
317
|
+
return ""
|
|
318
|
+
return format_html(
|
|
319
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
320
|
+
url,
|
|
321
|
+
_("Visit"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
host_values: list[str] = []
|
|
325
|
+
for attr in ("hostname", "address", "public_endpoint"):
|
|
326
|
+
value = getattr(obj, attr, "") or ""
|
|
327
|
+
cleaned = value.strip()
|
|
328
|
+
if cleaned and cleaned not in host_values:
|
|
329
|
+
host_values.append(cleaned)
|
|
330
|
+
|
|
331
|
+
remote_url = ""
|
|
332
|
+
for host in host_values:
|
|
333
|
+
temp_node = SimpleNamespace(
|
|
334
|
+
public_endpoint=host,
|
|
335
|
+
address="",
|
|
336
|
+
hostname="",
|
|
337
|
+
port=obj.port,
|
|
338
|
+
)
|
|
339
|
+
remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
|
|
340
|
+
if remote_url:
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
if not remote_url:
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
return format_html(
|
|
347
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
348
|
+
remote_url,
|
|
349
|
+
_("Visit"),
|
|
350
|
+
)
|
|
351
|
+
|
|
307
352
|
def get_urls(self):
|
|
308
353
|
urls = super().get_urls()
|
|
309
354
|
custom = [
|
|
@@ -598,6 +643,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
598
643
|
setattr(node, field, value)
|
|
599
644
|
changed.append(field)
|
|
600
645
|
|
|
646
|
+
role_value = payload.get("role") or payload.get("role_name")
|
|
647
|
+
if role_value is not None:
|
|
648
|
+
role_name = str(role_value).strip()
|
|
649
|
+
if role_name:
|
|
650
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
651
|
+
else:
|
|
652
|
+
desired_role = None
|
|
653
|
+
if desired_role and node.role_id != desired_role.id:
|
|
654
|
+
node.role = desired_role
|
|
655
|
+
changed.append("role")
|
|
656
|
+
|
|
601
657
|
node.last_seen = timezone.now()
|
|
602
658
|
if "last_seen" not in changed:
|
|
603
659
|
changed.append("last_seen")
|
|
@@ -677,40 +733,96 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
677
733
|
return {"ok": False, "message": last_error or "Unable to reach remote node."}
|
|
678
734
|
|
|
679
735
|
def _iter_remote_urls(self, node, path):
|
|
680
|
-
host_candidates = []
|
|
736
|
+
host_candidates: list[str] = []
|
|
681
737
|
for attr in ("public_endpoint", "address", "hostname"):
|
|
682
738
|
value = getattr(node, attr, "") or ""
|
|
683
|
-
|
|
684
|
-
if
|
|
685
|
-
host_candidates.append(
|
|
739
|
+
cleaned = value.strip()
|
|
740
|
+
if cleaned and cleaned not in host_candidates:
|
|
741
|
+
host_candidates.append(cleaned)
|
|
686
742
|
|
|
687
|
-
|
|
743
|
+
default_port = node.port or 8000
|
|
688
744
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
689
|
-
seen = set()
|
|
745
|
+
seen: set[str] = set()
|
|
690
746
|
|
|
691
747
|
for host in host_candidates:
|
|
748
|
+
base_path = ""
|
|
692
749
|
formatted_host = host
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
f"
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
750
|
+
port_override: int | None = None
|
|
751
|
+
|
|
752
|
+
if "://" in host:
|
|
753
|
+
parsed = urlparse(host)
|
|
754
|
+
netloc = parsed.netloc or parsed.path
|
|
755
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
756
|
+
combined_path = (
|
|
757
|
+
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
758
|
+
)
|
|
759
|
+
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
760
|
+
if primary not in seen:
|
|
761
|
+
seen.add(primary)
|
|
762
|
+
yield primary
|
|
763
|
+
if parsed.scheme == "https":
|
|
764
|
+
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
765
|
+
if fallback not in seen:
|
|
766
|
+
seen.add(fallback)
|
|
767
|
+
yield fallback
|
|
768
|
+
elif parsed.scheme == "http":
|
|
769
|
+
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
770
|
+
if alternate not in seen:
|
|
771
|
+
seen.add(alternate)
|
|
772
|
+
yield alternate
|
|
773
|
+
continue
|
|
712
774
|
|
|
713
|
-
|
|
775
|
+
if host.startswith("[") and "]" in host:
|
|
776
|
+
end = host.index("]")
|
|
777
|
+
core_host = host[1:end]
|
|
778
|
+
remainder = host[end + 1 :]
|
|
779
|
+
if remainder.startswith(":"):
|
|
780
|
+
remainder = remainder[1:]
|
|
781
|
+
port_part, sep, path_tail = remainder.partition("/")
|
|
782
|
+
if port_part:
|
|
783
|
+
try:
|
|
784
|
+
port_override = int(port_part)
|
|
785
|
+
except ValueError:
|
|
786
|
+
port_override = None
|
|
787
|
+
if sep:
|
|
788
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
789
|
+
elif "/" in remainder:
|
|
790
|
+
_, _, path_tail = remainder.partition("/")
|
|
791
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
792
|
+
formatted_host = f"[{core_host}]"
|
|
793
|
+
else:
|
|
794
|
+
if "/" in host:
|
|
795
|
+
host_only, _, path_tail = host.partition("/")
|
|
796
|
+
formatted_host = host_only or host
|
|
797
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
798
|
+
try:
|
|
799
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
800
|
+
except ValueError:
|
|
801
|
+
parts = formatted_host.rsplit(":", 1)
|
|
802
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
803
|
+
formatted_host = parts[0]
|
|
804
|
+
port_override = int(parts[1])
|
|
805
|
+
try:
|
|
806
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
807
|
+
except ValueError:
|
|
808
|
+
ip_obj = None
|
|
809
|
+
else:
|
|
810
|
+
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
811
|
+
formatted_host = f"[{formatted_host}]"
|
|
812
|
+
|
|
813
|
+
effective_port = port_override if port_override is not None else default_port
|
|
814
|
+
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
815
|
+
|
|
816
|
+
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
817
|
+
base = f"{scheme}://{formatted_host}"
|
|
818
|
+
if effective_port and (
|
|
819
|
+
port_override is not None or effective_port != scheme_default_port
|
|
820
|
+
):
|
|
821
|
+
explicit = f"{base}:{effective_port}{combined_path}"
|
|
822
|
+
if explicit not in seen:
|
|
823
|
+
seen.add(explicit)
|
|
824
|
+
yield explicit
|
|
825
|
+
candidate = f"{base}{combined_path}"
|
|
714
826
|
if candidate not in seen:
|
|
715
827
|
seen.add(candidate)
|
|
716
828
|
yield candidate
|
|
@@ -999,17 +1111,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
999
1111
|
result["status"] = self._status_from_result(result)
|
|
1000
1112
|
return result
|
|
1001
1113
|
|
|
1002
|
-
def
|
|
1114
|
+
def _run_rfid_import(self, request, queryset):
|
|
1003
1115
|
nodes = list(queryset)
|
|
1004
1116
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
1005
1117
|
if error:
|
|
1006
1118
|
results = [self._skip_result(node, error) for node in nodes]
|
|
1007
|
-
return self._render_rfid_sync(
|
|
1119
|
+
return self._render_rfid_sync(
|
|
1120
|
+
request, "import", results, setup_error=error
|
|
1121
|
+
)
|
|
1008
1122
|
|
|
1009
1123
|
if not nodes:
|
|
1010
1124
|
return self._render_rfid_sync(
|
|
1011
1125
|
request,
|
|
1012
|
-
|
|
1126
|
+
"import",
|
|
1013
1127
|
[],
|
|
1014
1128
|
setup_error=_("No nodes selected."),
|
|
1015
1129
|
)
|
|
@@ -1032,15 +1146,11 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1032
1146
|
continue
|
|
1033
1147
|
results.append(self._process_import_from_node(node, payload, headers))
|
|
1034
1148
|
|
|
1035
|
-
return self._render_rfid_sync(request,
|
|
1036
|
-
|
|
1037
|
-
@admin.action(description=_("Fetch RFIDs from selected"))
|
|
1038
|
-
def fetch_rfids_from_selected(self, request, queryset):
|
|
1039
|
-
return self._run_rfid_fetch(request, queryset, operation="fetch")
|
|
1149
|
+
return self._render_rfid_sync(request, "import", results)
|
|
1040
1150
|
|
|
1041
1151
|
@admin.action(description=_("Import RFIDs from selected"))
|
|
1042
1152
|
def import_rfids_from_selected(self, request, queryset):
|
|
1043
|
-
return self.
|
|
1153
|
+
return self._run_rfid_import(request, queryset)
|
|
1044
1154
|
|
|
1045
1155
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
1046
1156
|
def export_rfids_to_selected(self, request, queryset):
|
nodes/apps.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import socket
|
|
4
|
+
import sys
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
6
7
|
from pathlib import Path
|
|
@@ -66,6 +67,10 @@ def _startup_notification() -> None:
|
|
|
66
67
|
def _trigger_startup_notification(**_: object) -> None:
|
|
67
68
|
"""Attempt to send the startup notification in the background."""
|
|
68
69
|
|
|
70
|
+
if _is_running_migration_command():
|
|
71
|
+
logger.debug("Startup notification skipped: running migration command")
|
|
72
|
+
return
|
|
73
|
+
|
|
69
74
|
try:
|
|
70
75
|
connections["default"].ensure_connection()
|
|
71
76
|
except OperationalError:
|
|
@@ -74,6 +79,12 @@ def _trigger_startup_notification(**_: object) -> None:
|
|
|
74
79
|
_startup_notification()
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
def _is_running_migration_command() -> bool:
|
|
83
|
+
"""Return ``True`` when Django's ``migrate`` command is executing."""
|
|
84
|
+
|
|
85
|
+
return len(sys.argv) > 1 and sys.argv[1] == "migrate"
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
class NodesConfig(AppConfig):
|
|
78
89
|
default_auto_field = "django.db.models.BigAutoField"
|
|
79
90
|
name = "nodes"
|
nodes/models.py
CHANGED
|
@@ -16,7 +16,7 @@ import base64
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.text import slugify
|
|
18
18
|
from django.conf import settings
|
|
19
|
-
from datetime import timedelta
|
|
19
|
+
from datetime import datetime, timedelta, timezone as datetime_timezone
|
|
20
20
|
import uuid
|
|
21
21
|
import os
|
|
22
22
|
import shutil
|
|
@@ -481,7 +481,24 @@ class Node(Entity):
|
|
|
481
481
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
482
482
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
483
483
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
484
|
-
|
|
484
|
+
regenerate = not priv_path.exists() or not pub_path.exists()
|
|
485
|
+
if not regenerate:
|
|
486
|
+
key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
|
|
487
|
+
if key_max_age is not None:
|
|
488
|
+
try:
|
|
489
|
+
priv_mtime = datetime.fromtimestamp(
|
|
490
|
+
priv_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
491
|
+
)
|
|
492
|
+
pub_mtime = datetime.fromtimestamp(
|
|
493
|
+
pub_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
494
|
+
)
|
|
495
|
+
except OSError:
|
|
496
|
+
regenerate = True
|
|
497
|
+
else:
|
|
498
|
+
cutoff = timezone.now() - key_max_age
|
|
499
|
+
if priv_mtime < cutoff or pub_mtime < cutoff:
|
|
500
|
+
regenerate = True
|
|
501
|
+
if regenerate:
|
|
485
502
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
486
503
|
private_bytes = private_key.private_bytes(
|
|
487
504
|
encoding=serialization.Encoding.PEM,
|
|
@@ -494,8 +511,10 @@ class Node(Entity):
|
|
|
494
511
|
)
|
|
495
512
|
priv_path.write_bytes(private_bytes)
|
|
496
513
|
pub_path.write_bytes(public_bytes)
|
|
497
|
-
|
|
498
|
-
self.
|
|
514
|
+
public_text = public_bytes.decode()
|
|
515
|
+
if self.public_key != public_text:
|
|
516
|
+
self.public_key = public_text
|
|
517
|
+
self.save(update_fields=["public_key"])
|
|
499
518
|
elif not self.public_key:
|
|
500
519
|
self.public_key = pub_path.read_text()
|
|
501
520
|
self.save(update_fields=["public_key"])
|
|
@@ -1088,8 +1107,8 @@ class NodeManager(Profile):
|
|
|
1088
1107
|
)
|
|
1089
1108
|
|
|
1090
1109
|
class Meta:
|
|
1091
|
-
verbose_name = "Node
|
|
1092
|
-
verbose_name_plural = "Node
|
|
1110
|
+
verbose_name = "Node Profile"
|
|
1111
|
+
verbose_name_plural = "Node Profiles"
|
|
1093
1112
|
|
|
1094
1113
|
def __str__(self) -> str:
|
|
1095
1114
|
owner = self.owner_display()
|
|
@@ -1542,6 +1561,7 @@ class NetMessage(Entity):
|
|
|
1542
1561
|
payload = attachments if attachments is not None else self.attachments or []
|
|
1543
1562
|
if not payload:
|
|
1544
1563
|
return
|
|
1564
|
+
|
|
1545
1565
|
try:
|
|
1546
1566
|
objects = list(
|
|
1547
1567
|
serializers.deserialize(
|