arthexis 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/utils.py CHANGED
@@ -14,8 +14,12 @@ SCREENSHOT_DIR = settings.LOG_DIR / "screenshots"
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- def capture_screenshot(url: str) -> Path:
18
- """Capture a screenshot of ``url`` and save it to :data:`SCREENSHOT_DIR`."""
17
+ def capture_screenshot(url: str, cookies=None) -> Path:
18
+ """Capture a screenshot of ``url`` and save it to :data:`SCREENSHOT_DIR`.
19
+
20
+ ``cookies`` can be an iterable of Selenium cookie mappings which will be
21
+ applied after the initial navigation and before the screenshot is taken.
22
+ """
19
23
  options = Options()
20
24
  options.add_argument("-headless")
21
25
  try:
@@ -27,6 +31,13 @@ def capture_screenshot(url: str) -> Path:
27
31
  browser.get(url)
28
32
  except WebDriverException as exc:
29
33
  logger.error("Failed to load %s: %s", url, exc)
34
+ if cookies:
35
+ for cookie in cookies:
36
+ try:
37
+ browser.add_cookie(cookie)
38
+ except WebDriverException as exc:
39
+ logger.error("Failed to apply cookie for %s: %s", url, exc)
40
+ browser.get(url)
30
41
  if not browser.save_screenshot(str(filename)):
31
42
  raise RuntimeError("Screenshot capture failed")
32
43
  return filename
nodes/views.py CHANGED
@@ -1,34 +1,112 @@
1
- import json
2
1
  import base64
2
+ import ipaddress
3
+ import json
4
+ import socket
3
5
 
4
6
  from django.http import JsonResponse
7
+ from django.http.request import split_domain_port
5
8
  from django.views.decorators.csrf import csrf_exempt
6
9
  from django.shortcuts import get_object_or_404
7
10
  from django.conf import settings
8
11
  from pathlib import Path
12
+ from django.utils.cache import patch_vary_headers
9
13
 
10
14
  from utils.api import api_login_required
11
15
 
12
16
  from cryptography.hazmat.primitives import serialization, hashes
13
17
  from cryptography.hazmat.primitives.asymmetric import padding
14
18
 
15
- from .models import Node, NetMessage, NodeRole
19
+ from .models import Node, NetMessage, NodeRole, node_information_updated
16
20
  from .utils import capture_screenshot, save_screenshot
17
21
 
18
22
 
23
+ def _get_client_ip(request):
24
+ """Return the client IP from the request headers."""
25
+
26
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
27
+ if forwarded_for:
28
+ for value in forwarded_for.split(","):
29
+ candidate = value.strip()
30
+ if candidate:
31
+ return candidate
32
+ return request.META.get("REMOTE_ADDR", "")
33
+
34
+
35
+ def _get_route_address(remote_ip: str, port: int) -> str:
36
+ """Return the local address used to reach ``remote_ip``."""
37
+
38
+ if not remote_ip:
39
+ return ""
40
+ try:
41
+ parsed = ipaddress.ip_address(remote_ip)
42
+ except ValueError:
43
+ return ""
44
+
45
+ try:
46
+ target_port = int(port)
47
+ except (TypeError, ValueError):
48
+ target_port = 1
49
+ if target_port <= 0 or target_port > 65535:
50
+ target_port = 1
51
+
52
+ family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
53
+ try:
54
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
55
+ if family == socket.AF_INET6:
56
+ sock.connect((remote_ip, target_port, 0, 0))
57
+ else:
58
+ sock.connect((remote_ip, target_port))
59
+ return sock.getsockname()[0]
60
+ except OSError:
61
+ return ""
62
+
63
+
64
+ def _get_host_ip(request) -> str:
65
+ """Return the IP address from the host header if available."""
66
+
67
+ try:
68
+ host = request.get_host()
69
+ except Exception: # pragma: no cover - defensive
70
+ return ""
71
+ if not host:
72
+ return ""
73
+ domain, _ = split_domain_port(host)
74
+ if not domain:
75
+ return ""
76
+ try:
77
+ ipaddress.ip_address(domain)
78
+ except ValueError:
79
+ return ""
80
+ return domain
81
+
82
+
83
+ def _get_advertised_address(request, node) -> str:
84
+ """Return the best address for the client to reach this node."""
85
+
86
+ client_ip = _get_client_ip(request)
87
+ route_address = _get_route_address(client_ip, node.port)
88
+ if route_address:
89
+ return route_address
90
+ host_ip = _get_host_ip(request)
91
+ if host_ip:
92
+ return host_ip
93
+ return node.address
94
+
95
+
19
96
  @api_login_required
20
97
  def node_list(request):
21
98
  """Return a JSON list of all known nodes."""
22
99
 
23
- nodes = list(
24
- Node.objects.values(
25
- "hostname",
26
- "address",
27
- "port",
28
- "last_seen",
29
- "has_lcd_screen",
30
- )
31
- )
100
+ nodes = [
101
+ {
102
+ "hostname": node.hostname,
103
+ "address": node.address,
104
+ "port": node.port,
105
+ "last_seen": node.last_seen,
106
+ "features": list(node.features.values_list("slug", flat=True)),
107
+ }
108
+ for node in Node.objects.prefetch_related("features")
109
+ ]
32
110
  return JsonResponse({"nodes": nodes})
33
111
 
34
112
 
@@ -41,13 +119,14 @@ def node_info(request):
41
119
  node, _ = Node.register_current()
42
120
 
43
121
  token = request.GET.get("token", "")
122
+ address = _get_advertised_address(request, node)
44
123
  data = {
45
124
  "hostname": node.hostname,
46
- "address": node.address,
125
+ "address": address,
47
126
  "port": node.port,
48
127
  "mac_address": node.mac_address,
49
128
  "public_key": node.public_key,
50
- "has_lcd_screen": node.has_lcd_screen,
129
+ "features": list(node.features.values_list("slug", flat=True)),
51
130
  }
52
131
 
53
132
  if token:
@@ -74,19 +153,48 @@ def node_info(request):
74
153
  return response
75
154
 
76
155
 
156
+ def _add_cors_headers(request, response):
157
+ origin = request.headers.get("Origin")
158
+ if origin:
159
+ response["Access-Control-Allow-Origin"] = origin
160
+ response["Access-Control-Allow-Credentials"] = "true"
161
+ allow_headers = request.headers.get(
162
+ "Access-Control-Request-Headers", "Content-Type"
163
+ )
164
+ response["Access-Control-Allow-Headers"] = allow_headers
165
+ response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
166
+ patch_vary_headers(response, ["Origin"])
167
+ return response
168
+
169
+
77
170
  @csrf_exempt
78
- @api_login_required
79
171
  def register_node(request):
80
172
  """Register or update a node from POSTed JSON data."""
81
173
 
174
+ if request.method == "OPTIONS":
175
+ response = JsonResponse({"detail": "ok"})
176
+ return _add_cors_headers(request, response)
177
+
82
178
  if request.method != "POST":
83
- return JsonResponse({"detail": "POST required"}, status=400)
179
+ response = JsonResponse({"detail": "POST required"}, status=400)
180
+ return _add_cors_headers(request, response)
84
181
 
85
182
  try:
86
183
  data = json.loads(request.body.decode())
87
184
  except json.JSONDecodeError:
88
185
  data = request.POST
89
186
 
187
+ if hasattr(data, "getlist"):
188
+ raw_features = data.getlist("features")
189
+ if not raw_features:
190
+ features = None
191
+ elif len(raw_features) == 1:
192
+ features = raw_features[0]
193
+ else:
194
+ features = raw_features
195
+ else:
196
+ features = data.get("features")
197
+
90
198
  hostname = data.get("hostname")
91
199
  address = data.get("address")
92
200
  port = data.get("port", 8000)
@@ -94,12 +202,14 @@ def register_node(request):
94
202
  public_key = data.get("public_key")
95
203
  token = data.get("token")
96
204
  signature = data.get("signature")
97
- has_lcd_screen = data.get("has_lcd_screen")
205
+ installed_version = data.get("installed_version")
206
+ installed_revision = data.get("installed_revision")
98
207
 
99
208
  if not hostname or not address or not mac_address:
100
- return JsonResponse(
209
+ response = JsonResponse(
101
210
  {"detail": "hostname, address and mac_address required"}, status=400
102
211
  )
212
+ return _add_cors_headers(request, response)
103
213
 
104
214
  verified = False
105
215
  if public_key and token and signature:
@@ -113,23 +223,33 @@ def register_node(request):
113
223
  )
114
224
  verified = True
115
225
  except Exception:
116
- return JsonResponse({"detail": "invalid signature"}, status=403)
226
+ response = JsonResponse({"detail": "invalid signature"}, status=403)
227
+ return _add_cors_headers(request, response)
228
+
229
+ if not verified and not request.user.is_authenticated:
230
+ response = JsonResponse({"detail": "authentication required"}, status=401)
231
+ return _add_cors_headers(request, response)
117
232
 
118
233
  mac_address = mac_address.lower()
119
234
  defaults = {
120
235
  "hostname": hostname,
121
236
  "address": address,
122
237
  "port": port,
123
- "has_lcd_screen": bool(has_lcd_screen),
124
238
  }
125
239
  if verified:
126
240
  defaults["public_key"] = public_key
241
+ if installed_version is not None:
242
+ defaults["installed_version"] = str(installed_version)[:20]
243
+ if installed_revision is not None:
244
+ defaults["installed_revision"] = str(installed_revision)[:40]
127
245
 
128
246
  node, created = Node.objects.get_or_create(
129
247
  mac_address=mac_address,
130
248
  defaults=defaults,
131
249
  )
132
250
  if not created:
251
+ previous_version = (node.installed_version or "").strip()
252
+ previous_revision = (node.installed_revision or "").strip()
133
253
  node.hostname = hostname
134
254
  node.address = address
135
255
  node.port = port
@@ -137,15 +257,58 @@ def register_node(request):
137
257
  if verified:
138
258
  node.public_key = public_key
139
259
  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")
260
+ if installed_version is not None:
261
+ node.installed_version = str(installed_version)[:20]
262
+ if "installed_version" not in update_fields:
263
+ update_fields.append("installed_version")
264
+ if installed_revision is not None:
265
+ node.installed_revision = str(installed_revision)[:40]
266
+ if "installed_revision" not in update_fields:
267
+ update_fields.append("installed_revision")
143
268
  node.save(update_fields=update_fields)
144
- return JsonResponse(
269
+ current_version = (node.installed_version or "").strip()
270
+ current_revision = (node.installed_revision or "").strip()
271
+ node_information_updated.send(
272
+ sender=Node,
273
+ node=node,
274
+ previous_version=previous_version,
275
+ previous_revision=previous_revision,
276
+ current_version=current_version,
277
+ current_revision=current_revision,
278
+ request=request,
279
+ )
280
+ if features is not None and (verified or request.user.is_authenticated):
281
+ if isinstance(features, (str, bytes)):
282
+ feature_list = [features]
283
+ else:
284
+ feature_list = list(features)
285
+ node.update_manual_features(feature_list)
286
+ response = JsonResponse(
145
287
  {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
146
288
  )
289
+ return _add_cors_headers(request, response)
290
+
291
+ if features is not None and (verified or request.user.is_authenticated):
292
+ if isinstance(features, (str, bytes)):
293
+ feature_list = [features]
294
+ else:
295
+ feature_list = list(features)
296
+ node.update_manual_features(feature_list)
297
+
298
+ current_version = (node.installed_version or "").strip()
299
+ current_revision = (node.installed_revision or "").strip()
300
+ node_information_updated.send(
301
+ sender=Node,
302
+ node=node,
303
+ previous_version="",
304
+ previous_revision="",
305
+ current_version=current_version,
306
+ current_revision=current_revision,
307
+ request=request,
308
+ )
147
309
 
148
- return JsonResponse({"id": node.id})
310
+ response = JsonResponse({"id": node.id})
311
+ return _add_cors_headers(request, response)
149
312
 
150
313
 
151
314
  @api_login_required
@@ -172,9 +335,7 @@ def public_node_endpoint(request, endpoint):
172
335
  - ``POST`` broadcasts the request body as a :class:`NetMessage`.
173
336
  """
174
337
 
175
- node = get_object_or_404(
176
- Node, public_endpoint=endpoint, enable_public_api=True
177
- )
338
+ node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
178
339
 
179
340
  if request.method == "GET":
180
341
  data = {
@@ -183,6 +344,7 @@ def public_node_endpoint(request, endpoint):
183
344
  "port": node.port,
184
345
  "badge_color": node.badge_color,
185
346
  "last_seen": node.last_seen,
347
+ "features": list(node.features.values_list("slug", flat=True)),
186
348
  }
187
349
  return JsonResponse(data)
188
350
 
@@ -234,11 +396,22 @@ def net_message(request):
234
396
  if reach_name:
235
397
  reach_role = NodeRole.objects.filter(name=reach_name).first()
236
398
  seen = data.get("seen", [])
399
+ origin_id = data.get("origin")
400
+ origin_node = None
401
+ if origin_id:
402
+ origin_node = Node.objects.filter(uuid=origin_id).first()
403
+ if not origin_node:
404
+ origin_node = node
237
405
  if not msg_uuid:
238
406
  return JsonResponse({"detail": "uuid required"}, status=400)
239
407
  msg, created = NetMessage.objects.get_or_create(
240
408
  uuid=msg_uuid,
241
- defaults={"subject": subject[:64], "body": body[:256], "reach": reach_role},
409
+ defaults={
410
+ "subject": subject[:64],
411
+ "body": body[:256],
412
+ "reach": reach_role,
413
+ "node_origin": origin_node,
414
+ },
242
415
  )
243
416
  if not created:
244
417
  msg.subject = subject[:64]
@@ -247,6 +420,9 @@ def net_message(request):
247
420
  if reach_role and msg.reach_id != reach_role.id:
248
421
  msg.reach = reach_role
249
422
  update_fields.append("reach")
423
+ if msg.node_origin_id is None and origin_node:
424
+ msg.node_origin = origin_node
425
+ update_fields.append("node_origin")
250
426
  msg.save(update_fields=update_fields)
251
427
  msg.propagate(seen=seen)
252
428
  return JsonResponse({"status": "propagated", "complete": msg.complete})