arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
nodes/urls.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
urlpatterns = [
|
|
6
|
+
path("info/", views.node_info, name="node-info"),
|
|
7
|
+
path("list/", views.node_list, name="node-list"),
|
|
8
|
+
path("register/", views.register_node, name="register-node"),
|
|
9
|
+
path("screenshot/", views.capture, name="node-screenshot"),
|
|
10
|
+
path("net-message/", views.net_message, name="net-message"),
|
|
11
|
+
path("last-message/", views.last_net_message, name="last-net-message"),
|
|
12
|
+
path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
|
|
13
|
+
]
|
nodes/utils.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from selenium import webdriver
|
|
8
|
+
from selenium.webdriver.firefox.options import Options
|
|
9
|
+
from selenium.common.exceptions import WebDriverException
|
|
10
|
+
|
|
11
|
+
from .models import ContentSample
|
|
12
|
+
|
|
13
|
+
SCREENSHOT_DIR = settings.LOG_DIR / "screenshots"
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def capture_screenshot(url: str) -> Path:
|
|
18
|
+
"""Capture a screenshot of ``url`` and save it to :data:`SCREENSHOT_DIR`."""
|
|
19
|
+
options = Options()
|
|
20
|
+
options.add_argument("-headless")
|
|
21
|
+
try:
|
|
22
|
+
with webdriver.Firefox(options=options) as browser:
|
|
23
|
+
browser.set_window_size(1280, 720)
|
|
24
|
+
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
filename = SCREENSHOT_DIR / f"{datetime.utcnow():%Y%m%d%H%M%S}.png"
|
|
26
|
+
try:
|
|
27
|
+
browser.get(url)
|
|
28
|
+
except WebDriverException as exc:
|
|
29
|
+
logger.error("Failed to load %s: %s", url, exc)
|
|
30
|
+
if not browser.save_screenshot(str(filename)):
|
|
31
|
+
raise RuntimeError("Screenshot capture failed")
|
|
32
|
+
return filename
|
|
33
|
+
except WebDriverException as exc:
|
|
34
|
+
logger.error("Failed to capture screenshot from %s: %s", url, exc)
|
|
35
|
+
raise RuntimeError(f"Screenshot capture failed: {exc}") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_screenshot(path: Path, node=None, method: str = "", transaction_uuid=None):
|
|
39
|
+
"""Save screenshot file info if not already recorded.
|
|
40
|
+
|
|
41
|
+
Returns the created :class:`ContentSample` or ``None`` if duplicate.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
original = path
|
|
45
|
+
if not path.is_absolute():
|
|
46
|
+
path = settings.LOG_DIR / path
|
|
47
|
+
with path.open("rb") as fh:
|
|
48
|
+
digest = hashlib.sha256(fh.read()).hexdigest()
|
|
49
|
+
if ContentSample.objects.filter(hash=digest).exists():
|
|
50
|
+
logger.info("Duplicate screenshot content; record not created")
|
|
51
|
+
return None
|
|
52
|
+
stored_path = (original if not original.is_absolute() else path).as_posix()
|
|
53
|
+
data = {
|
|
54
|
+
"node": node,
|
|
55
|
+
"path": stored_path,
|
|
56
|
+
"method": method,
|
|
57
|
+
"hash": digest,
|
|
58
|
+
"kind": ContentSample.IMAGE,
|
|
59
|
+
}
|
|
60
|
+
if transaction_uuid is not None:
|
|
61
|
+
data["transaction_uuid"] = transaction_uuid
|
|
62
|
+
return ContentSample.objects.create(**data)
|
nodes/views.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import base64
|
|
3
|
+
|
|
4
|
+
from django.http import JsonResponse
|
|
5
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
6
|
+
from django.shortcuts import get_object_or_404
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from utils.api import api_login_required
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
14
|
+
|
|
15
|
+
from .models import Node, NetMessage, NodeRole
|
|
16
|
+
from .utils import capture_screenshot, save_screenshot
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@api_login_required
|
|
20
|
+
def node_list(request):
|
|
21
|
+
"""Return a JSON list of all known nodes."""
|
|
22
|
+
|
|
23
|
+
nodes = list(
|
|
24
|
+
Node.objects.values(
|
|
25
|
+
"hostname",
|
|
26
|
+
"address",
|
|
27
|
+
"port",
|
|
28
|
+
"last_seen",
|
|
29
|
+
"has_lcd_screen",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return JsonResponse({"nodes": nodes})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@csrf_exempt
|
|
36
|
+
def node_info(request):
|
|
37
|
+
"""Return information about the local node and sign ``token`` if provided."""
|
|
38
|
+
|
|
39
|
+
node = Node.get_local()
|
|
40
|
+
if node is None:
|
|
41
|
+
node, _ = Node.register_current()
|
|
42
|
+
|
|
43
|
+
token = request.GET.get("token", "")
|
|
44
|
+
data = {
|
|
45
|
+
"hostname": node.hostname,
|
|
46
|
+
"address": node.address,
|
|
47
|
+
"port": node.port,
|
|
48
|
+
"mac_address": node.mac_address,
|
|
49
|
+
"public_key": node.public_key,
|
|
50
|
+
"has_lcd_screen": node.has_lcd_screen,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if token:
|
|
54
|
+
try:
|
|
55
|
+
priv_path = (
|
|
56
|
+
Path(node.base_path or settings.BASE_DIR)
|
|
57
|
+
/ "security"
|
|
58
|
+
/ f"{node.public_endpoint}"
|
|
59
|
+
)
|
|
60
|
+
private_key = serialization.load_pem_private_key(
|
|
61
|
+
priv_path.read_bytes(), password=None
|
|
62
|
+
)
|
|
63
|
+
signature = private_key.sign(
|
|
64
|
+
token.encode(),
|
|
65
|
+
padding.PKCS1v15(),
|
|
66
|
+
hashes.SHA256(),
|
|
67
|
+
)
|
|
68
|
+
data["token_signature"] = base64.b64encode(signature).decode()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
response = JsonResponse(data)
|
|
73
|
+
response["Access-Control-Allow-Origin"] = "*"
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@csrf_exempt
|
|
78
|
+
@api_login_required
|
|
79
|
+
def register_node(request):
|
|
80
|
+
"""Register or update a node from POSTed JSON data."""
|
|
81
|
+
|
|
82
|
+
if request.method != "POST":
|
|
83
|
+
return JsonResponse({"detail": "POST required"}, status=400)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(request.body.decode())
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
data = request.POST
|
|
89
|
+
|
|
90
|
+
hostname = data.get("hostname")
|
|
91
|
+
address = data.get("address")
|
|
92
|
+
port = data.get("port", 8000)
|
|
93
|
+
mac_address = data.get("mac_address")
|
|
94
|
+
public_key = data.get("public_key")
|
|
95
|
+
token = data.get("token")
|
|
96
|
+
signature = data.get("signature")
|
|
97
|
+
has_lcd_screen = data.get("has_lcd_screen")
|
|
98
|
+
|
|
99
|
+
if not hostname or not address or not mac_address:
|
|
100
|
+
return JsonResponse(
|
|
101
|
+
{"detail": "hostname, address and mac_address required"}, status=400
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
verified = False
|
|
105
|
+
if public_key and token and signature:
|
|
106
|
+
try:
|
|
107
|
+
pub = serialization.load_pem_public_key(public_key.encode())
|
|
108
|
+
pub.verify(
|
|
109
|
+
base64.b64decode(signature),
|
|
110
|
+
token.encode(),
|
|
111
|
+
padding.PKCS1v15(),
|
|
112
|
+
hashes.SHA256(),
|
|
113
|
+
)
|
|
114
|
+
verified = True
|
|
115
|
+
except Exception:
|
|
116
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
117
|
+
|
|
118
|
+
mac_address = mac_address.lower()
|
|
119
|
+
defaults = {
|
|
120
|
+
"hostname": hostname,
|
|
121
|
+
"address": address,
|
|
122
|
+
"port": port,
|
|
123
|
+
"has_lcd_screen": bool(has_lcd_screen),
|
|
124
|
+
}
|
|
125
|
+
if verified:
|
|
126
|
+
defaults["public_key"] = public_key
|
|
127
|
+
|
|
128
|
+
node, created = Node.objects.get_or_create(
|
|
129
|
+
mac_address=mac_address,
|
|
130
|
+
defaults=defaults,
|
|
131
|
+
)
|
|
132
|
+
if not created:
|
|
133
|
+
node.hostname = hostname
|
|
134
|
+
node.address = address
|
|
135
|
+
node.port = port
|
|
136
|
+
update_fields = ["hostname", "address", "port"]
|
|
137
|
+
if verified:
|
|
138
|
+
node.public_key = public_key
|
|
139
|
+
update_fields.append("public_key")
|
|
140
|
+
if has_lcd_screen is not None:
|
|
141
|
+
node.has_lcd_screen = bool(has_lcd_screen)
|
|
142
|
+
update_fields.append("has_lcd_screen")
|
|
143
|
+
node.save(update_fields=update_fields)
|
|
144
|
+
return JsonResponse(
|
|
145
|
+
{"id": node.id, "detail": f"Node already exists (id: {node.id})"}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return JsonResponse({"id": node.id})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@api_login_required
|
|
152
|
+
def capture(request):
|
|
153
|
+
"""Capture a screenshot of the site's root URL and record it."""
|
|
154
|
+
|
|
155
|
+
url = request.build_absolute_uri("/")
|
|
156
|
+
try:
|
|
157
|
+
path = capture_screenshot(url)
|
|
158
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
159
|
+
return JsonResponse({"detail": str(exc)}, status=500)
|
|
160
|
+
node = Node.get_local()
|
|
161
|
+
screenshot = save_screenshot(path, node=node, method=request.method)
|
|
162
|
+
node_id = screenshot.node.id if screenshot and screenshot.node else None
|
|
163
|
+
return JsonResponse({"screenshot": str(path), "node": node_id})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@csrf_exempt
|
|
167
|
+
@api_login_required
|
|
168
|
+
def public_node_endpoint(request, endpoint):
|
|
169
|
+
"""Public API endpoint for a node.
|
|
170
|
+
|
|
171
|
+
- ``GET`` returns information about the node.
|
|
172
|
+
- ``POST`` broadcasts the request body as a :class:`NetMessage`.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
node = get_object_or_404(
|
|
176
|
+
Node, public_endpoint=endpoint, enable_public_api=True
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if request.method == "GET":
|
|
180
|
+
data = {
|
|
181
|
+
"hostname": node.hostname,
|
|
182
|
+
"address": node.address,
|
|
183
|
+
"port": node.port,
|
|
184
|
+
"badge_color": node.badge_color,
|
|
185
|
+
"last_seen": node.last_seen,
|
|
186
|
+
}
|
|
187
|
+
return JsonResponse(data)
|
|
188
|
+
|
|
189
|
+
if request.method == "POST":
|
|
190
|
+
NetMessage.broadcast(
|
|
191
|
+
subject=request.method,
|
|
192
|
+
body=request.body.decode("utf-8") if request.body else "",
|
|
193
|
+
seen=[str(node.uuid)],
|
|
194
|
+
)
|
|
195
|
+
return JsonResponse({"status": "stored"})
|
|
196
|
+
|
|
197
|
+
return JsonResponse({"detail": "Method not allowed"}, status=405)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@csrf_exempt
|
|
201
|
+
def net_message(request):
|
|
202
|
+
"""Receive a network message and continue propagation."""
|
|
203
|
+
|
|
204
|
+
if request.method != "POST":
|
|
205
|
+
return JsonResponse({"detail": "POST required"}, status=400)
|
|
206
|
+
try:
|
|
207
|
+
data = json.loads(request.body.decode())
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
210
|
+
|
|
211
|
+
signature = request.headers.get("X-Signature")
|
|
212
|
+
sender_id = data.get("sender")
|
|
213
|
+
if not signature or not sender_id:
|
|
214
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
215
|
+
node = Node.objects.filter(uuid=sender_id).first()
|
|
216
|
+
if not node or not node.public_key:
|
|
217
|
+
return JsonResponse({"detail": "unknown sender"}, status=403)
|
|
218
|
+
try:
|
|
219
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
220
|
+
public_key.verify(
|
|
221
|
+
base64.b64decode(signature),
|
|
222
|
+
request.body,
|
|
223
|
+
padding.PKCS1v15(),
|
|
224
|
+
hashes.SHA256(),
|
|
225
|
+
)
|
|
226
|
+
except Exception:
|
|
227
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
228
|
+
|
|
229
|
+
msg_uuid = data.get("uuid")
|
|
230
|
+
subject = data.get("subject", "")
|
|
231
|
+
body = data.get("body", "")
|
|
232
|
+
reach_name = data.get("reach")
|
|
233
|
+
reach_role = None
|
|
234
|
+
if reach_name:
|
|
235
|
+
reach_role = NodeRole.objects.filter(name=reach_name).first()
|
|
236
|
+
seen = data.get("seen", [])
|
|
237
|
+
if not msg_uuid:
|
|
238
|
+
return JsonResponse({"detail": "uuid required"}, status=400)
|
|
239
|
+
msg, created = NetMessage.objects.get_or_create(
|
|
240
|
+
uuid=msg_uuid,
|
|
241
|
+
defaults={"subject": subject[:64], "body": body[:256], "reach": reach_role},
|
|
242
|
+
)
|
|
243
|
+
if not created:
|
|
244
|
+
msg.subject = subject[:64]
|
|
245
|
+
msg.body = body[:256]
|
|
246
|
+
update_fields = ["subject", "body"]
|
|
247
|
+
if reach_role and msg.reach_id != reach_role.id:
|
|
248
|
+
msg.reach = reach_role
|
|
249
|
+
update_fields.append("reach")
|
|
250
|
+
msg.save(update_fields=update_fields)
|
|
251
|
+
msg.propagate(seen=seen)
|
|
252
|
+
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@api_login_required
|
|
256
|
+
def last_net_message(request):
|
|
257
|
+
"""Return the most recent :class:`NetMessage`."""
|
|
258
|
+
|
|
259
|
+
msg = NetMessage.objects.order_by("-created").first()
|
|
260
|
+
if not msg:
|
|
261
|
+
return JsonResponse({"subject": "", "body": ""})
|
|
262
|
+
return JsonResponse({"subject": msg.subject, "body": msg.body})
|
ocpp/__init__.py
ADDED
|
File without changes
|