arthexis 0.1.12__py3-none-any.whl → 0.1.13__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.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -34,6 +34,7 @@ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, o
|
|
|
34
34
|
from django.urls import reverse
|
|
35
35
|
from django.contrib.auth import get_user_model
|
|
36
36
|
from django.contrib import admin
|
|
37
|
+
from django.contrib.auth.models import Permission
|
|
37
38
|
from django.contrib.sites.models import Site
|
|
38
39
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
39
40
|
from django.conf import settings
|
|
@@ -60,7 +61,7 @@ from .backends import OutboxEmailBackend
|
|
|
60
61
|
from .tasks import capture_node_screenshot, sample_clipboard
|
|
61
62
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
62
63
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
63
|
-
from core.models import PackageRelease, SecurityGroup
|
|
64
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
class NodeBadgeColorTests(TestCase):
|
|
@@ -122,6 +123,32 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
class NodeGetLocalTests(TestCase):
|
|
126
|
+
def test_normalize_relation_handles_various_inputs(self):
|
|
127
|
+
self.assertEqual(
|
|
128
|
+
Node.normalize_relation(Node.Relation.UPSTREAM),
|
|
129
|
+
Node.Relation.UPSTREAM,
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(
|
|
132
|
+
Node.normalize_relation(None),
|
|
133
|
+
Node.Relation.PEER,
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(
|
|
136
|
+
Node.normalize_relation("Upstream"),
|
|
137
|
+
Node.Relation.UPSTREAM,
|
|
138
|
+
)
|
|
139
|
+
self.assertEqual(
|
|
140
|
+
Node.normalize_relation("DOWNSTREAM"),
|
|
141
|
+
Node.Relation.DOWNSTREAM,
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(
|
|
144
|
+
Node.normalize_relation("peer"),
|
|
145
|
+
Node.Relation.PEER,
|
|
146
|
+
)
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
Node.normalize_relation("unexpected"),
|
|
149
|
+
Node.Relation.PEER,
|
|
150
|
+
)
|
|
151
|
+
|
|
125
152
|
def test_register_current_does_not_create_release(self):
|
|
126
153
|
node = None
|
|
127
154
|
created = False
|
|
@@ -245,6 +272,41 @@ class NodeGetLocalTests(TestCase):
|
|
|
245
272
|
hostnames = {n["hostname"] for n in data["nodes"]}
|
|
246
273
|
self.assertEqual(hostnames, {"dup", "local2"})
|
|
247
274
|
|
|
275
|
+
def test_register_node_generates_unique_public_endpoint(self):
|
|
276
|
+
url = reverse("register-node")
|
|
277
|
+
User = get_user_model()
|
|
278
|
+
user = User.objects.create_user(username="registrar", password="pwd")
|
|
279
|
+
self.client.force_login(user)
|
|
280
|
+
first = self.client.post(
|
|
281
|
+
url,
|
|
282
|
+
data={
|
|
283
|
+
"hostname": "duplicate-host",
|
|
284
|
+
"address": "10.0.0.10",
|
|
285
|
+
"port": 8080,
|
|
286
|
+
"mac_address": "00:11:22:33:aa:bb",
|
|
287
|
+
},
|
|
288
|
+
content_type="application/json",
|
|
289
|
+
)
|
|
290
|
+
self.assertEqual(first.status_code, 200)
|
|
291
|
+
node_one = Node.objects.get(mac_address="00:11:22:33:aa:bb")
|
|
292
|
+
self.assertEqual(node_one.public_endpoint, "duplicate-host")
|
|
293
|
+
|
|
294
|
+
second = self.client.post(
|
|
295
|
+
url,
|
|
296
|
+
data={
|
|
297
|
+
"hostname": "duplicate-host",
|
|
298
|
+
"address": "10.0.0.11",
|
|
299
|
+
"port": 8081,
|
|
300
|
+
"mac_address": "00:11:22:33:aa:cc",
|
|
301
|
+
},
|
|
302
|
+
content_type="application/json",
|
|
303
|
+
)
|
|
304
|
+
self.assertEqual(second.status_code, 200)
|
|
305
|
+
node_two = Node.objects.get(mac_address="00:11:22:33:aa:cc")
|
|
306
|
+
|
|
307
|
+
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
308
|
+
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
309
|
+
|
|
248
310
|
def test_register_node_feature_toggle(self):
|
|
249
311
|
NodeFeature.objects.get_or_create(
|
|
250
312
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -341,6 +403,48 @@ class NodeGetLocalTests(TestCase):
|
|
|
341
403
|
self.assertEqual(subject, "UP friend")
|
|
342
404
|
self.assertEqual(body, "v2.0.0 r123456")
|
|
343
405
|
|
|
406
|
+
def test_register_node_marks_nonrelease_version(self):
|
|
407
|
+
package = Package.objects.create(name="pkg-node", is_active=False)
|
|
408
|
+
PackageRelease.objects.create(
|
|
409
|
+
package=package,
|
|
410
|
+
version="2.0.0",
|
|
411
|
+
revision="0" * 40,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
node = Node.objects.create(
|
|
415
|
+
hostname="friend",
|
|
416
|
+
address="10.1.1.5",
|
|
417
|
+
port=8123,
|
|
418
|
+
mac_address="aa:bb:cc:dd:ee:11",
|
|
419
|
+
installed_version="1.0.0",
|
|
420
|
+
installed_revision="rev-old",
|
|
421
|
+
)
|
|
422
|
+
user = get_user_model().objects.create_user(
|
|
423
|
+
username="node-registrar", password="pwd"
|
|
424
|
+
)
|
|
425
|
+
self.client.force_login(user)
|
|
426
|
+
url = reverse("register-node")
|
|
427
|
+
payload = {
|
|
428
|
+
"hostname": "friend",
|
|
429
|
+
"address": "10.1.1.5",
|
|
430
|
+
"port": 8123,
|
|
431
|
+
"mac_address": "aa:bb:cc:dd:ee:11",
|
|
432
|
+
"installed_version": "2.0.0",
|
|
433
|
+
"installed_revision": "1" * 40,
|
|
434
|
+
}
|
|
435
|
+
with patch("nodes.models.notify_async") as mock_notify:
|
|
436
|
+
response = self.client.post(
|
|
437
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
438
|
+
)
|
|
439
|
+
self.assertEqual(response.status_code, 200)
|
|
440
|
+
node.refresh_from_db()
|
|
441
|
+
self.assertEqual(node.installed_version, "2.0.0")
|
|
442
|
+
self.assertEqual(node.installed_revision, "1" * 40)
|
|
443
|
+
mock_notify.assert_called_once()
|
|
444
|
+
subject, body = mock_notify.call_args[0]
|
|
445
|
+
self.assertEqual(subject, "UP friend")
|
|
446
|
+
self.assertEqual(body, "v2.0.0+ r111111")
|
|
447
|
+
|
|
344
448
|
def test_register_node_update_without_version_change_still_notifies(self):
|
|
345
449
|
node = Node.objects.create(
|
|
346
450
|
hostname="friend",
|
|
@@ -534,6 +638,64 @@ class NodeGetLocalTests(TestCase):
|
|
|
534
638
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
535
639
|
|
|
536
640
|
|
|
641
|
+
class RegisterVisitorNodeMessageTests(TestCase):
|
|
642
|
+
def setUp(self):
|
|
643
|
+
self.client = Client()
|
|
644
|
+
User = get_user_model()
|
|
645
|
+
self.user = User.objects.create_user(username="visitor", password="pwd")
|
|
646
|
+
self.client.force_login(self.user)
|
|
647
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
648
|
+
self.visitor = Node.objects.create(
|
|
649
|
+
hostname="visitor-node",
|
|
650
|
+
address="10.0.0.100",
|
|
651
|
+
port=8000,
|
|
652
|
+
mac_address="00:10:20:30:40:50",
|
|
653
|
+
role=self.role,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def test_register_node_emits_join_message_when_upstream_added(self):
|
|
657
|
+
payload = {
|
|
658
|
+
"hostname": "host-node",
|
|
659
|
+
"address": "10.0.0.10",
|
|
660
|
+
"port": 8100,
|
|
661
|
+
"mac_address": "aa:bb:cc:dd:ee:01",
|
|
662
|
+
"current_relation": "Upstream",
|
|
663
|
+
}
|
|
664
|
+
with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
|
|
665
|
+
NetMessage, "broadcast"
|
|
666
|
+
) as mock_broadcast:
|
|
667
|
+
response = self.client.post(
|
|
668
|
+
reverse("register-node"),
|
|
669
|
+
data=json.dumps(payload),
|
|
670
|
+
content_type="application/json",
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
self.assertEqual(response.status_code, 200)
|
|
674
|
+
mock_broadcast.assert_called_once_with(
|
|
675
|
+
subject="NODE visitor-node", body="JOINS host-node"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def test_register_node_skips_message_when_not_upstream(self):
|
|
679
|
+
payload = {
|
|
680
|
+
"hostname": "remote-node",
|
|
681
|
+
"address": "10.0.0.11",
|
|
682
|
+
"port": 8101,
|
|
683
|
+
"mac_address": "aa:bb:cc:dd:ee:02",
|
|
684
|
+
"current_relation": "Downstream",
|
|
685
|
+
}
|
|
686
|
+
with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
|
|
687
|
+
NetMessage, "broadcast"
|
|
688
|
+
) as mock_broadcast:
|
|
689
|
+
response = self.client.post(
|
|
690
|
+
reverse("register-node"),
|
|
691
|
+
data=json.dumps(payload),
|
|
692
|
+
content_type="application/json",
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
self.assertEqual(response.status_code, 200)
|
|
696
|
+
mock_broadcast.assert_not_called()
|
|
697
|
+
|
|
698
|
+
|
|
537
699
|
class NodeRegisterCurrentTests(TestCase):
|
|
538
700
|
def setUp(self):
|
|
539
701
|
User = get_user_model()
|
|
@@ -845,6 +1007,17 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
845
1007
|
mac_address="00:11:22:33:44:cc",
|
|
846
1008
|
public_key=public_key,
|
|
847
1009
|
)
|
|
1010
|
+
target_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1011
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1012
|
+
slug="net-message", defaults={"display": "Net Message"}
|
|
1013
|
+
)
|
|
1014
|
+
target = Node.objects.create(
|
|
1015
|
+
hostname="target",
|
|
1016
|
+
address="10.0.0.2",
|
|
1017
|
+
port=8001,
|
|
1018
|
+
mac_address="00:11:22:33:44:dd",
|
|
1019
|
+
role=target_role,
|
|
1020
|
+
)
|
|
848
1021
|
msg_id = str(uuid.uuid4())
|
|
849
1022
|
payload = {
|
|
850
1023
|
"uuid": msg_id,
|
|
@@ -853,6 +1026,12 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
853
1026
|
"seen": [],
|
|
854
1027
|
"sender": str(sender.uuid),
|
|
855
1028
|
"origin": str(sender.uuid),
|
|
1029
|
+
"filter_node": str(target.uuid),
|
|
1030
|
+
"filter_node_feature": feature.slug,
|
|
1031
|
+
"filter_node_role": target_role.name,
|
|
1032
|
+
"filter_current_relation": Node.Relation.UPSTREAM,
|
|
1033
|
+
"filter_installed_version": "1.0.0",
|
|
1034
|
+
"filter_installed_revision": "rev123",
|
|
856
1035
|
}
|
|
857
1036
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
858
1037
|
signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
@@ -866,6 +1045,12 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
866
1045
|
self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
|
|
867
1046
|
message = NetMessage.objects.get(uuid=msg_id)
|
|
868
1047
|
self.assertEqual(message.node_origin, sender)
|
|
1048
|
+
self.assertEqual(message.filter_node, target)
|
|
1049
|
+
self.assertEqual(message.filter_node_feature, feature)
|
|
1050
|
+
self.assertEqual(message.filter_node_role, target_role)
|
|
1051
|
+
self.assertEqual(message.filter_current_relation, Node.Relation.UPSTREAM)
|
|
1052
|
+
self.assertEqual(message.filter_installed_version, "1.0.0")
|
|
1053
|
+
self.assertEqual(message.filter_installed_revision, "rev123")
|
|
869
1054
|
|
|
870
1055
|
def test_clipboard_polling_creates_task(self):
|
|
871
1056
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -944,6 +1129,18 @@ class NodeAdminTests(TestCase):
|
|
|
944
1129
|
action_url = reverse("admin:core_rfid_scan")
|
|
945
1130
|
self.assertContains(response, f'href="{action_url}"')
|
|
946
1131
|
|
|
1132
|
+
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1133
|
+
node = self._create_local_node()
|
|
1134
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1135
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1136
|
+
)
|
|
1137
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1138
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
1139
|
+
snapshot_url = reverse("admin:nodes_nodefeature_take_snapshot")
|
|
1140
|
+
stream_url = reverse("admin:nodes_nodefeature_view_stream")
|
|
1141
|
+
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1142
|
+
self.assertContains(response, f'href="{stream_url}"')
|
|
1143
|
+
|
|
947
1144
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
948
1145
|
self._create_local_node()
|
|
949
1146
|
NodeFeature.objects.get_or_create(
|
|
@@ -1010,6 +1207,34 @@ class NodeAdminTests(TestCase):
|
|
|
1010
1207
|
)
|
|
1011
1208
|
self.assertIn(node.public_key.strip(), resp.content.decode())
|
|
1012
1209
|
|
|
1210
|
+
def test_register_current_requires_superuser(self):
|
|
1211
|
+
User = get_user_model()
|
|
1212
|
+
staff = User.objects.create_user(
|
|
1213
|
+
username="staff", password="pass", is_staff=True
|
|
1214
|
+
)
|
|
1215
|
+
permission = Permission.objects.get(codename="view_node")
|
|
1216
|
+
staff.user_permissions.add(permission)
|
|
1217
|
+
self.client.force_login(staff)
|
|
1218
|
+
|
|
1219
|
+
response = self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1220
|
+
|
|
1221
|
+
self.assertEqual(response.status_code, 403)
|
|
1222
|
+
|
|
1223
|
+
def test_register_current_link_hidden_for_non_superusers(self):
|
|
1224
|
+
User = get_user_model()
|
|
1225
|
+
staff = User.objects.create_user(
|
|
1226
|
+
username="linkstaff", password="pass", is_staff=True
|
|
1227
|
+
)
|
|
1228
|
+
permission = Permission.objects.get(codename="view_node")
|
|
1229
|
+
staff.user_permissions.add(permission)
|
|
1230
|
+
self.client.force_login(staff)
|
|
1231
|
+
|
|
1232
|
+
response = self.client.get(reverse("admin:nodes_node_changelist"))
|
|
1233
|
+
|
|
1234
|
+
self.assertNotContains(
|
|
1235
|
+
response, reverse("admin:nodes_node_register_current")
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1013
1238
|
@patch("nodes.admin.capture_screenshot")
|
|
1014
1239
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1015
1240
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1241,6 +1466,195 @@ class NodeAdminTests(TestCase):
|
|
|
1241
1466
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1242
1467
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1243
1468
|
|
|
1469
|
+
def test_view_stream_requires_enabled_feature(self):
|
|
1470
|
+
self._create_local_node()
|
|
1471
|
+
NodeFeature.objects.get_or_create(
|
|
1472
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1473
|
+
)
|
|
1474
|
+
response = self.client.get(
|
|
1475
|
+
reverse("admin:nodes_nodefeature_view_stream"), follow=True
|
|
1476
|
+
)
|
|
1477
|
+
self.assertEqual(response.status_code, 200)
|
|
1478
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1479
|
+
self.assertEqual(response.wsgi_request.path, changelist_url)
|
|
1480
|
+
self.assertContains(
|
|
1481
|
+
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
def test_view_stream_renders_when_feature_enabled(self):
|
|
1485
|
+
node = self._create_local_node()
|
|
1486
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1487
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1488
|
+
)
|
|
1489
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1490
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_view_stream"))
|
|
1491
|
+
self.assertEqual(response.status_code, 200)
|
|
1492
|
+
response.render()
|
|
1493
|
+
expected_stream = "http://testserver:8554/"
|
|
1494
|
+
self.assertEqual(response.context_data["stream_url"], expected_stream)
|
|
1495
|
+
self.assertContains(response, expected_stream)
|
|
1496
|
+
self.assertContains(response, "camera-stream__frame")
|
|
1497
|
+
|
|
1498
|
+
def test_view_stream_uses_configured_stream_url(self):
|
|
1499
|
+
node = self._create_local_node()
|
|
1500
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1501
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1502
|
+
)
|
|
1503
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1504
|
+
configured_stream = "https://camera.local/stream"
|
|
1505
|
+
with self.settings(RPI_CAMERA_STREAM_URL=configured_stream):
|
|
1506
|
+
response = self.client.get(
|
|
1507
|
+
reverse("admin:nodes_nodefeature_view_stream")
|
|
1508
|
+
)
|
|
1509
|
+
self.assertEqual(response.status_code, 200)
|
|
1510
|
+
response.render()
|
|
1511
|
+
self.assertEqual(response.context_data["stream_url"], configured_stream)
|
|
1512
|
+
self.assertContains(response, configured_stream)
|
|
1513
|
+
|
|
1514
|
+
@patch("nodes.admin.requests.post")
|
|
1515
|
+
def test_fetch_rfids_action_fetches_and_imports(self, mock_post):
|
|
1516
|
+
local = self._create_local_node()
|
|
1517
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
1518
|
+
private_bytes = key.private_bytes(
|
|
1519
|
+
encoding=serialization.Encoding.PEM,
|
|
1520
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
1521
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
1522
|
+
)
|
|
1523
|
+
public_bytes = key.public_key().public_bytes(
|
|
1524
|
+
encoding=serialization.Encoding.PEM,
|
|
1525
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
1526
|
+
)
|
|
1527
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
1528
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
1529
|
+
(security_dir / f"{local.public_endpoint}").write_bytes(private_bytes)
|
|
1530
|
+
(security_dir / f"{local.public_endpoint}.pub").write_bytes(public_bytes)
|
|
1531
|
+
local.public_key = public_bytes.decode()
|
|
1532
|
+
local.save(update_fields=["public_key"])
|
|
1533
|
+
|
|
1534
|
+
remote = Node.objects.create(
|
|
1535
|
+
hostname="remote",
|
|
1536
|
+
address="127.0.0.2",
|
|
1537
|
+
port=8010,
|
|
1538
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
mock_response = MagicMock()
|
|
1542
|
+
mock_response.status_code = 200
|
|
1543
|
+
mock_response.json.return_value = {
|
|
1544
|
+
"rfids": [
|
|
1545
|
+
{
|
|
1546
|
+
"rfid": "abc123",
|
|
1547
|
+
"custom_label": "Remote tag",
|
|
1548
|
+
"key_a": "A1B2C3D4E5F6",
|
|
1549
|
+
"key_b": "FFFFFFFFFFFF",
|
|
1550
|
+
"data": ["sector"],
|
|
1551
|
+
"key_a_verified": True,
|
|
1552
|
+
"key_b_verified": False,
|
|
1553
|
+
"allowed": True,
|
|
1554
|
+
"color": RFID.BLACK,
|
|
1555
|
+
"kind": RFID.CLASSIC,
|
|
1556
|
+
"released": False,
|
|
1557
|
+
"last_seen_on": None,
|
|
1558
|
+
}
|
|
1559
|
+
]
|
|
1560
|
+
}
|
|
1561
|
+
mock_response.text = ""
|
|
1562
|
+
mock_post.return_value = mock_response
|
|
1563
|
+
|
|
1564
|
+
response = self.client.post(
|
|
1565
|
+
reverse("admin:nodes_node_changelist"),
|
|
1566
|
+
{"action": "fetch_rfids", "_selected_action": [str(remote.pk)]},
|
|
1567
|
+
follow=True,
|
|
1568
|
+
)
|
|
1569
|
+
self.assertEqual(response.status_code, 200)
|
|
1570
|
+
self.assertTrue(RFID.objects.filter(rfid="ABC123").exists())
|
|
1571
|
+
tag = RFID.objects.get(rfid="ABC123")
|
|
1572
|
+
self.assertEqual(tag.custom_label, "Remote tag")
|
|
1573
|
+
self.assertEqual(tag.origin_node, remote)
|
|
1574
|
+
self.assertEqual(tag.data, ["sector"])
|
|
1575
|
+
|
|
1576
|
+
self.assertTrue(mock_post.called)
|
|
1577
|
+
call_kwargs = mock_post.call_args.kwargs
|
|
1578
|
+
payload = call_kwargs["data"]
|
|
1579
|
+
headers = call_kwargs["headers"]
|
|
1580
|
+
signature = base64.b64decode(headers["X-Signature"])
|
|
1581
|
+
key.public_key().verify(
|
|
1582
|
+
signature,
|
|
1583
|
+
payload.encode(),
|
|
1584
|
+
padding.PKCS1v15(),
|
|
1585
|
+
hashes.SHA256(),
|
|
1586
|
+
)
|
|
1587
|
+
self.assertContains(response, "Fetched RFIDs from 1 node(s)")
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
class RFIDExportViewTests(TestCase):
|
|
1591
|
+
def setUp(self):
|
|
1592
|
+
self.client = Client()
|
|
1593
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
1594
|
+
self.local_node = Node.objects.create(
|
|
1595
|
+
hostname="local",
|
|
1596
|
+
address="127.0.0.1",
|
|
1597
|
+
port=8000,
|
|
1598
|
+
mac_address=Node.get_current_mac(),
|
|
1599
|
+
)
|
|
1600
|
+
self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
1601
|
+
self.remote_public = (
|
|
1602
|
+
self.remote_key.public_key()
|
|
1603
|
+
.public_bytes(
|
|
1604
|
+
encoding=serialization.Encoding.PEM,
|
|
1605
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
1606
|
+
)
|
|
1607
|
+
.decode()
|
|
1608
|
+
)
|
|
1609
|
+
self.remote_node = Node.objects.create(
|
|
1610
|
+
hostname="remote",
|
|
1611
|
+
address="10.0.0.2",
|
|
1612
|
+
port=8100,
|
|
1613
|
+
mac_address="00:11:22:33:44:55",
|
|
1614
|
+
public_key=self.remote_public,
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
def _sign_payload(self, payload):
|
|
1618
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1619
|
+
signature = self.remote_key.sign(
|
|
1620
|
+
payload_json.encode(),
|
|
1621
|
+
padding.PKCS1v15(),
|
|
1622
|
+
hashes.SHA256(),
|
|
1623
|
+
)
|
|
1624
|
+
return payload_json, base64.b64encode(signature).decode()
|
|
1625
|
+
|
|
1626
|
+
def test_export_requires_signature(self):
|
|
1627
|
+
payload_json = json.dumps(
|
|
1628
|
+
{"requester": str(self.remote_node.uuid)},
|
|
1629
|
+
separators=(",", ":"),
|
|
1630
|
+
sort_keys=True,
|
|
1631
|
+
)
|
|
1632
|
+
response = self.client.post(
|
|
1633
|
+
reverse("node-rfid-export"),
|
|
1634
|
+
data=payload_json,
|
|
1635
|
+
content_type="application/json",
|
|
1636
|
+
)
|
|
1637
|
+
self.assertEqual(response.status_code, 403)
|
|
1638
|
+
|
|
1639
|
+
def test_export_returns_serialized_data(self):
|
|
1640
|
+
RFID.objects.create(rfid="ABCDEF")
|
|
1641
|
+
payload_json, signature = self._sign_payload(
|
|
1642
|
+
{"requester": str(self.remote_node.uuid)}
|
|
1643
|
+
)
|
|
1644
|
+
response = self.client.post(
|
|
1645
|
+
reverse("node-rfid-export"),
|
|
1646
|
+
data=payload_json,
|
|
1647
|
+
content_type="application/json",
|
|
1648
|
+
HTTP_X_SIGNATURE=signature,
|
|
1649
|
+
)
|
|
1650
|
+
self.assertEqual(response.status_code, 200)
|
|
1651
|
+
body = response.json()
|
|
1652
|
+
self.assertIn("rfids", body)
|
|
1653
|
+
self.assertEqual(len(body["rfids"]), 1)
|
|
1654
|
+
tag_data = body["rfids"][0]
|
|
1655
|
+
self.assertEqual(tag_data["rfid"], "ABCDEF")
|
|
1656
|
+
self.assertIn("custom_label", tag_data)
|
|
1657
|
+
|
|
1244
1658
|
|
|
1245
1659
|
class NetMessageAdminTests(TransactionTestCase):
|
|
1246
1660
|
reset_sequences = True
|
|
@@ -1273,6 +1687,27 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
1273
1687
|
self.assertEqual(response.status_code, 302)
|
|
1274
1688
|
mock_propagate.assert_called_once()
|
|
1275
1689
|
|
|
1690
|
+
def test_reply_action_prefills_initial_data(self):
|
|
1691
|
+
role = NodeRole.objects.get(name="Terminal")
|
|
1692
|
+
node = Node.objects.create(
|
|
1693
|
+
hostname="remote",
|
|
1694
|
+
address="10.0.0.10",
|
|
1695
|
+
port=8100,
|
|
1696
|
+
mac_address="00:11:22:33:44:55",
|
|
1697
|
+
role=role,
|
|
1698
|
+
)
|
|
1699
|
+
original = NetMessage.objects.create(
|
|
1700
|
+
subject="Ping",
|
|
1701
|
+
body="Hello",
|
|
1702
|
+
node_origin=node,
|
|
1703
|
+
)
|
|
1704
|
+
url = f"{reverse('admin:nodes_netmessage_add')}?reply_to={original.pk}"
|
|
1705
|
+
response = self.client.get(url)
|
|
1706
|
+
self.assertEqual(response.status_code, 200)
|
|
1707
|
+
form = response.context_data["adminform"].form
|
|
1708
|
+
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
1709
|
+
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
1710
|
+
|
|
1276
1711
|
|
|
1277
1712
|
class LastNetMessageViewTests(TestCase):
|
|
1278
1713
|
def setUp(self):
|
|
@@ -1281,10 +1716,19 @@ class LastNetMessageViewTests(TestCase):
|
|
|
1281
1716
|
|
|
1282
1717
|
def test_returns_latest_message(self):
|
|
1283
1718
|
NetMessage.objects.create(subject="old", body="msg1")
|
|
1284
|
-
NetMessage.objects.create(subject="new", body="msg2")
|
|
1719
|
+
latest = NetMessage.objects.create(subject="new", body="msg2")
|
|
1285
1720
|
resp = self.client.get(reverse("last-net-message"))
|
|
1286
1721
|
self.assertEqual(resp.status_code, 200)
|
|
1287
|
-
self.assertEqual(
|
|
1722
|
+
self.assertEqual(
|
|
1723
|
+
resp.json(),
|
|
1724
|
+
{
|
|
1725
|
+
"subject": "new",
|
|
1726
|
+
"body": "msg2",
|
|
1727
|
+
"admin_url": reverse(
|
|
1728
|
+
"admin:nodes_netmessage_change", args=[latest.pk]
|
|
1729
|
+
),
|
|
1730
|
+
},
|
|
1731
|
+
)
|
|
1288
1732
|
|
|
1289
1733
|
|
|
1290
1734
|
class NetMessageReachTests(TestCase):
|
|
@@ -1345,8 +1789,10 @@ class NetMessageReachTests(TestCase):
|
|
|
1345
1789
|
with patch.object(Node, "get_local", return_value=None):
|
|
1346
1790
|
msg.propagate()
|
|
1347
1791
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
1348
|
-
self.assertEqual(
|
|
1349
|
-
|
|
1792
|
+
self.assertEqual(
|
|
1793
|
+
roles, {"Constellation", "Satellite", "Control", "Terminal"}
|
|
1794
|
+
)
|
|
1795
|
+
self.assertEqual(mock_post.call_count, 4)
|
|
1350
1796
|
|
|
1351
1797
|
@patch("requests.post")
|
|
1352
1798
|
def test_default_reach_not_limited_to_terminal(self, mock_post):
|
|
@@ -1357,7 +1803,105 @@ class NetMessageReachTests(TestCase):
|
|
|
1357
1803
|
msg.propagate()
|
|
1358
1804
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
1359
1805
|
self.assertIn("Control", roles)
|
|
1360
|
-
self.assertEqual(mock_post.call_count,
|
|
1806
|
+
self.assertEqual(mock_post.call_count, 4)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
class NetMessageFilterTests(TestCase):
|
|
1810
|
+
def setUp(self):
|
|
1811
|
+
self.terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1812
|
+
self.control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1813
|
+
self.nodes = {
|
|
1814
|
+
"terminal": Node.objects.create(
|
|
1815
|
+
hostname="terminal-filter",
|
|
1816
|
+
address="10.20.0.1",
|
|
1817
|
+
port=8020,
|
|
1818
|
+
mac_address="00:11:22:33:55:01",
|
|
1819
|
+
role=self.terminal_role,
|
|
1820
|
+
),
|
|
1821
|
+
"control": Node.objects.create(
|
|
1822
|
+
hostname="control-filter",
|
|
1823
|
+
address="10.20.0.2",
|
|
1824
|
+
port=8021,
|
|
1825
|
+
mac_address="00:11:22:33:55:02",
|
|
1826
|
+
role=self.control_role,
|
|
1827
|
+
),
|
|
1828
|
+
}
|
|
1829
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
1830
|
+
slug="filter-test", defaults={"display": "Filter Test"}
|
|
1831
|
+
)
|
|
1832
|
+
NodeFeatureAssignment.objects.get_or_create(
|
|
1833
|
+
node=self.nodes["control"], feature=self.feature
|
|
1834
|
+
)
|
|
1835
|
+
self.nodes["control"].current_relation = Node.Relation.UPSTREAM
|
|
1836
|
+
self.nodes["control"].installed_version = "1.2.3"
|
|
1837
|
+
self.nodes["control"].installed_revision = "abc123"
|
|
1838
|
+
self.nodes["control"].save(
|
|
1839
|
+
update_fields=[
|
|
1840
|
+
"current_relation",
|
|
1841
|
+
"installed_version",
|
|
1842
|
+
"installed_revision",
|
|
1843
|
+
]
|
|
1844
|
+
)
|
|
1845
|
+
|
|
1846
|
+
@patch("requests.post")
|
|
1847
|
+
def test_filter_node_limits_targets(self, mock_post):
|
|
1848
|
+
msg = NetMessage.objects.create(
|
|
1849
|
+
subject="s", body="b", filter_node=self.nodes["control"]
|
|
1850
|
+
)
|
|
1851
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
1852
|
+
msg.propagate()
|
|
1853
|
+
self.assertEqual(
|
|
1854
|
+
list(msg.propagated_to.values_list("pk", flat=True)),
|
|
1855
|
+
[self.nodes["control"].pk],
|
|
1856
|
+
)
|
|
1857
|
+
mock_post.assert_called_once()
|
|
1858
|
+
|
|
1859
|
+
@patch("requests.post")
|
|
1860
|
+
def test_filter_fields_limit_queryset(self, mock_post):
|
|
1861
|
+
msg = NetMessage.objects.create(
|
|
1862
|
+
subject="s",
|
|
1863
|
+
body="b",
|
|
1864
|
+
filter_node_feature=self.feature,
|
|
1865
|
+
filter_node_role=self.control_role,
|
|
1866
|
+
filter_current_relation=Node.Relation.UPSTREAM,
|
|
1867
|
+
filter_installed_version="1.2.3",
|
|
1868
|
+
filter_installed_revision="abc123",
|
|
1869
|
+
)
|
|
1870
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
1871
|
+
msg.propagate()
|
|
1872
|
+
self.assertEqual(
|
|
1873
|
+
list(msg.propagated_to.values_list("pk", flat=True)),
|
|
1874
|
+
[self.nodes["control"].pk],
|
|
1875
|
+
)
|
|
1876
|
+
mock_post.assert_called_once()
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
class NetMessageBroadcastStringReachTests(TestCase):
|
|
1880
|
+
def test_broadcast_uses_role_lookup_for_string_reach(self):
|
|
1881
|
+
role = NodeRole.objects.create(name="Terminal")
|
|
1882
|
+
local = Node.objects.create(
|
|
1883
|
+
hostname="terminal-local",
|
|
1884
|
+
address="10.10.0.1",
|
|
1885
|
+
port=8010,
|
|
1886
|
+
mac_address="00:aa:bb:cc:dd:ff",
|
|
1887
|
+
role=role,
|
|
1888
|
+
public_endpoint="terminal-local",
|
|
1889
|
+
)
|
|
1890
|
+
seen = ["existing"]
|
|
1891
|
+
|
|
1892
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
1893
|
+
NetMessage, "propagate"
|
|
1894
|
+
) as mock_propagate:
|
|
1895
|
+
msg = NetMessage.broadcast(
|
|
1896
|
+
"Subject", "Body", reach="Terminal", seen=seen
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
self.assertEqual(msg.reach, role)
|
|
1900
|
+
self.assertEqual(msg.node_origin, local)
|
|
1901
|
+
mock_propagate.assert_called_once()
|
|
1902
|
+
called_args = mock_propagate.call_args
|
|
1903
|
+
self.assertIn("seen", called_args.kwargs)
|
|
1904
|
+
self.assertIs(called_args.kwargs["seen"], seen)
|
|
1361
1905
|
|
|
1362
1906
|
|
|
1363
1907
|
class NetMessagePropagationTests(TestCase):
|
|
@@ -1412,6 +1956,29 @@ class NetMessagePropagationTests(TestCase):
|
|
|
1412
1956
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
1413
1957
|
self.assertTrue(msg.complete)
|
|
1414
1958
|
|
|
1959
|
+
@patch("requests.post")
|
|
1960
|
+
@patch("core.notifications.notify", return_value=False)
|
|
1961
|
+
def test_propagate_defaults_to_six_when_available(
|
|
1962
|
+
self, mock_notify, mock_post
|
|
1963
|
+
):
|
|
1964
|
+
for idx in range(6, 12):
|
|
1965
|
+
self.remotes.append(
|
|
1966
|
+
Node.objects.create(
|
|
1967
|
+
hostname=f"n{idx}",
|
|
1968
|
+
address=f"10.0.0.{idx}",
|
|
1969
|
+
port=8000 + idx,
|
|
1970
|
+
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
1971
|
+
role=self.role,
|
|
1972
|
+
public_endpoint=f"n{idx}",
|
|
1973
|
+
)
|
|
1974
|
+
)
|
|
1975
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
1976
|
+
with patch.object(Node, "get_local", return_value=self.local):
|
|
1977
|
+
msg.propagate()
|
|
1978
|
+
self.assertEqual(mock_post.call_count, 6)
|
|
1979
|
+
self.assertEqual(msg.propagated_to.count(), 6)
|
|
1980
|
+
self.assertFalse(msg.complete)
|
|
1981
|
+
|
|
1415
1982
|
@patch("requests.post")
|
|
1416
1983
|
@patch("core.notifications.notify", return_value=True)
|
|
1417
1984
|
def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
|
|
@@ -1545,6 +2112,36 @@ class StartupNotificationTests(TestCase):
|
|
|
1545
2112
|
self.assertEqual(kwargs["subject"], "host:9000")
|
|
1546
2113
|
self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
|
|
1547
2114
|
|
|
2115
|
+
def test_startup_notification_marks_nonrelease_version(self):
|
|
2116
|
+
from nodes.apps import _startup_notification
|
|
2117
|
+
|
|
2118
|
+
package = Package.objects.create(name="pkg-start", is_active=False)
|
|
2119
|
+
PackageRelease.objects.create(
|
|
2120
|
+
package=package,
|
|
2121
|
+
version="1.2.3",
|
|
2122
|
+
revision="0" * 40,
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
with TemporaryDirectory() as tmp:
|
|
2126
|
+
tmp_path = Path(tmp)
|
|
2127
|
+
(tmp_path / "VERSION").write_text("1.2.3")
|
|
2128
|
+
with self.settings(BASE_DIR=tmp_path):
|
|
2129
|
+
with patch(
|
|
2130
|
+
"nodes.apps.revision.get_revision", return_value="1" * 40
|
|
2131
|
+
):
|
|
2132
|
+
with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
|
|
2133
|
+
with patch(
|
|
2134
|
+
"nodes.apps.socket.gethostname", return_value="host"
|
|
2135
|
+
):
|
|
2136
|
+
with patch.dict(os.environ, {"PORT": "9000"}):
|
|
2137
|
+
_startup_notification()
|
|
2138
|
+
time.sleep(0.1)
|
|
2139
|
+
|
|
2140
|
+
mock_broadcast.assert_called_once()
|
|
2141
|
+
_, kwargs = mock_broadcast.call_args
|
|
2142
|
+
self.assertEqual(kwargs["subject"], "host:9000")
|
|
2143
|
+
self.assertEqual(kwargs["body"], "1.2.3+ r111111")
|
|
2144
|
+
|
|
1548
2145
|
|
|
1549
2146
|
class StartupHandlerTests(TestCase):
|
|
1550
2147
|
def test_handler_logs_db_errors(self):
|
|
@@ -2001,24 +2598,39 @@ class NodeFeatureTests(TestCase):
|
|
|
2001
2598
|
feature = NodeFeature.objects.create(
|
|
2002
2599
|
slug="rfid-scanner", display="RFID Scanner"
|
|
2003
2600
|
)
|
|
2004
|
-
|
|
2005
|
-
self.
|
|
2601
|
+
actions = feature.get_default_actions()
|
|
2602
|
+
self.assertEqual(len(actions), 1)
|
|
2603
|
+
action = actions[0]
|
|
2006
2604
|
self.assertEqual(action.label, "Scan RFIDs")
|
|
2007
2605
|
self.assertEqual(action.url_name, "admin:core_rfid_scan")
|
|
2606
|
+
self.assertEqual(feature.get_default_action(), action)
|
|
2008
2607
|
|
|
2009
2608
|
def test_celery_feature_default_action(self):
|
|
2010
2609
|
feature = NodeFeature.objects.create(
|
|
2011
2610
|
slug="celery-queue", display="Celery Queue"
|
|
2012
2611
|
)
|
|
2013
|
-
|
|
2014
|
-
self.
|
|
2612
|
+
actions = feature.get_default_actions()
|
|
2613
|
+
self.assertEqual(len(actions), 1)
|
|
2614
|
+
action = actions[0]
|
|
2015
2615
|
self.assertEqual(action.label, "Celery Report")
|
|
2016
2616
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
2617
|
+
self.assertEqual(feature.get_default_action(), action)
|
|
2618
|
+
|
|
2619
|
+
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
2620
|
+
feature = NodeFeature.objects.create(
|
|
2621
|
+
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
2622
|
+
)
|
|
2623
|
+
actions = feature.get_default_actions()
|
|
2624
|
+
self.assertEqual(len(actions), 2)
|
|
2625
|
+
labels = {action.label for action in actions}
|
|
2626
|
+
self.assertIn("Take a Snapshot", labels)
|
|
2627
|
+
self.assertIn("View stream", labels)
|
|
2017
2628
|
|
|
2018
2629
|
def test_default_action_missing_when_unconfigured(self):
|
|
2019
2630
|
feature = NodeFeature.objects.create(
|
|
2020
2631
|
slug="custom-feature", display="Custom Feature"
|
|
2021
2632
|
)
|
|
2633
|
+
self.assertEqual(feature.get_default_actions(), ())
|
|
2022
2634
|
self.assertIsNone(feature.get_default_action())
|
|
2023
2635
|
|
|
2024
2636
|
def test_lcd_screen_enabled(self):
|
|
@@ -2035,6 +2647,13 @@ class NodeFeatureTests(TestCase):
|
|
|
2035
2647
|
):
|
|
2036
2648
|
self.assertFalse(feature.is_enabled)
|
|
2037
2649
|
|
|
2650
|
+
def test_feature_disabled_when_local_node_missing(self):
|
|
2651
|
+
feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
|
|
2652
|
+
with patch("nodes.models.Node.get_local", return_value=None):
|
|
2653
|
+
with patch("core.notifications.supports_gui_toast") as mock_toast:
|
|
2654
|
+
self.assertFalse(feature.is_enabled)
|
|
2655
|
+
mock_toast.assert_not_called()
|
|
2656
|
+
|
|
2038
2657
|
def test_rfid_scanner_lock(self):
|
|
2039
2658
|
feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
|
|
2040
2659
|
feature.roles.add(self.role)
|