arthexis 0.1.11__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.

Files changed (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
ocpp/admin.py CHANGED
@@ -11,12 +11,16 @@ from django.urls import path
11
11
  from django.http import HttpResponse, HttpResponseRedirect
12
12
  from django.template.response import TemplateResponse
13
13
 
14
+ import uuid
15
+ from asgiref.sync import async_to_sync
16
+
14
17
  from .models import (
15
18
  Charger,
16
19
  Simulator,
17
20
  MeterValue,
18
21
  Transaction,
19
22
  Location,
23
+ DataTransferMessage,
20
24
  )
21
25
  from .simulator import ChargePointSimulator
22
26
  from . import store
@@ -24,6 +28,8 @@ from .transactions_io import (
24
28
  export_transactions,
25
29
  import_transactions as import_transactions_data,
26
30
  )
31
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
32
+ from core.admin import SaveBeforeChangeAction
27
33
  from core.user_data import EntityModelAdmin
28
34
 
29
35
 
@@ -108,6 +114,44 @@ class LocationAdmin(EntityModelAdmin):
108
114
  change_form_template = "admin/ocpp/location/change_form.html"
109
115
 
110
116
 
117
+ @admin.register(DataTransferMessage)
118
+ class DataTransferMessageAdmin(admin.ModelAdmin):
119
+ list_display = (
120
+ "charger",
121
+ "connector_id",
122
+ "direction",
123
+ "vendor_id",
124
+ "message_id",
125
+ "status",
126
+ "created_at",
127
+ "responded_at",
128
+ )
129
+ list_filter = ("direction", "status")
130
+ search_fields = (
131
+ "charger__charger_id",
132
+ "ocpp_message_id",
133
+ "vendor_id",
134
+ "message_id",
135
+ )
136
+ readonly_fields = (
137
+ "charger",
138
+ "connector_id",
139
+ "direction",
140
+ "ocpp_message_id",
141
+ "vendor_id",
142
+ "message_id",
143
+ "payload",
144
+ "status",
145
+ "response_data",
146
+ "error_code",
147
+ "error_description",
148
+ "error_details",
149
+ "responded_at",
150
+ "created_at",
151
+ "updated_at",
152
+ )
153
+
154
+
111
155
  @admin.register(Charger)
112
156
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
113
157
  fieldsets = (
@@ -122,6 +166,13 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
122
166
  "last_path",
123
167
  "last_heartbeat",
124
168
  "last_meter_values",
169
+ )
170
+ },
171
+ ),
172
+ (
173
+ "Firmware",
174
+ {
175
+ "fields": (
125
176
  "firmware_status",
126
177
  "firmware_status_info",
127
178
  "firmware_timestamp",
@@ -138,6 +189,20 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
138
189
  )
139
190
  },
140
191
  ),
192
+ (
193
+ "Availability",
194
+ {
195
+ "fields": (
196
+ "availability_state",
197
+ "availability_state_updated_at",
198
+ "availability_requested_state",
199
+ "availability_requested_at",
200
+ "availability_request_status",
201
+ "availability_request_status_at",
202
+ "availability_request_details",
203
+ )
204
+ },
205
+ ),
141
206
  (
142
207
  "Configuration",
143
208
  {"fields": ("public_display", "require_rfid")},
@@ -148,6 +213,13 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
148
213
  "fields": ("reference",),
149
214
  },
150
215
  ),
216
+ (
217
+ "Owner",
218
+ {
219
+ "fields": ("owner_users", "owner_groups"),
220
+ "classes": ("collapse",),
221
+ },
222
+ ),
151
223
  )
152
224
  readonly_fields = (
153
225
  "last_heartbeat",
@@ -155,16 +227,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
155
227
  "firmware_status",
156
228
  "firmware_status_info",
157
229
  "firmware_timestamp",
230
+ "availability_state",
231
+ "availability_state_updated_at",
232
+ "availability_requested_state",
233
+ "availability_requested_at",
234
+ "availability_request_status",
235
+ "availability_request_status_at",
236
+ "availability_request_details",
158
237
  )
159
238
  list_display = (
160
239
  "charger_id",
161
- "connector_id",
240
+ "connector_number",
162
241
  "location_name",
163
242
  "require_rfid_display",
164
243
  "public_display",
165
244
  "last_heartbeat",
166
- "firmware_status",
167
- "firmware_timestamp",
168
245
  "session_kw",
169
246
  "total_kw_display",
170
247
  "page_link",
@@ -172,7 +249,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
172
249
  "status_link",
173
250
  )
174
251
  search_fields = ("charger_id", "connector_id", "location__name")
175
- actions = ["purge_data", "delete_selected"]
252
+ filter_horizontal = ("owner_users", "owner_groups")
253
+ actions = [
254
+ "purge_data",
255
+ "fetch_cp_configuration",
256
+ "change_availability_operative",
257
+ "change_availability_inoperative",
258
+ "set_availability_state_operative",
259
+ "set_availability_state_inoperative",
260
+ "remote_stop_transaction",
261
+ "reset_chargers",
262
+ "delete_selected",
263
+ ]
176
264
 
177
265
  def get_view_on_site_url(self, obj=None):
178
266
  return obj.get_absolute_url() if obj else None
@@ -215,6 +303,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
215
303
  def get_log_identifier(self, obj):
216
304
  return store.identity_key(obj.charger_id, obj.connector_id)
217
305
 
306
+ def connector_number(self, obj):
307
+ return obj.connector_id if obj.connector_id is not None else ""
308
+
309
+ connector_number.short_description = "#"
310
+ connector_number.admin_order_field = "connector_id"
311
+
218
312
  def status_link(self, obj):
219
313
  from django.utils.html import format_html
220
314
  from django.urls import reverse
@@ -223,10 +317,36 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
223
317
  "charger-status-connector",
224
318
  args=[obj.charger_id, obj.connector_slug],
225
319
  )
226
- return format_html('<a href="{}" target="_blank">status</a>', url)
320
+ label = (obj.last_status or "status").strip() or "status"
321
+ status_key = label.lower()
322
+ error_code = (obj.last_error_code or "").strip().lower()
323
+ if (
324
+ self._has_active_session(obj)
325
+ and error_code in ERROR_OK_VALUES
326
+ and (status_key not in STATUS_BADGE_MAP or status_key == "available")
327
+ ):
328
+ label = STATUS_BADGE_MAP["charging"][0]
329
+ return format_html('<a href="{}" target="_blank">{}</a>', url, label)
227
330
 
228
331
  status_link.short_description = "Status"
229
332
 
333
+ def _has_active_session(self, charger: Charger) -> bool:
334
+ """Return whether ``charger`` currently has an active session."""
335
+
336
+ if store.get_transaction(charger.charger_id, charger.connector_id):
337
+ return True
338
+ if charger.connector_id is not None:
339
+ return False
340
+ sibling_connectors = (
341
+ Charger.objects.filter(charger_id=charger.charger_id)
342
+ .exclude(pk=charger.pk)
343
+ .values_list("connector_id", flat=True)
344
+ )
345
+ for connector_id in sibling_connectors:
346
+ if store.get_transaction(charger.charger_id, connector_id):
347
+ return True
348
+ return False
349
+
230
350
  def location_name(self, obj):
231
351
  return obj.location.name if obj.location else ""
232
352
 
@@ -239,6 +359,257 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
239
359
 
240
360
  purge_data.short_description = "Purge data"
241
361
 
362
+ @admin.action(description="Fetch CP configuration")
363
+ def fetch_cp_configuration(self, request, queryset):
364
+ fetched = 0
365
+ for charger in queryset:
366
+ connector_value = charger.connector_id
367
+ ws = store.get_connection(charger.charger_id, connector_value)
368
+ if ws is None:
369
+ self.message_user(
370
+ request,
371
+ f"{charger}: no active connection",
372
+ level=messages.ERROR,
373
+ )
374
+ continue
375
+ message_id = uuid.uuid4().hex
376
+ payload = {}
377
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
378
+ try:
379
+ async_to_sync(ws.send)(msg)
380
+ except Exception as exc: # pragma: no cover - network error
381
+ self.message_user(
382
+ request,
383
+ f"{charger}: failed to send GetConfiguration ({exc})",
384
+ level=messages.ERROR,
385
+ )
386
+ continue
387
+ log_key = store.identity_key(charger.charger_id, connector_value)
388
+ store.add_log(log_key, f"< {msg}", log_type="charger")
389
+ store.register_pending_call(
390
+ message_id,
391
+ {
392
+ "action": "GetConfiguration",
393
+ "charger_id": charger.charger_id,
394
+ "connector_id": connector_value,
395
+ "log_key": log_key,
396
+ "requested_at": timezone.now(),
397
+ },
398
+ )
399
+ store.schedule_call_timeout(
400
+ message_id,
401
+ timeout=5.0,
402
+ action="GetConfiguration",
403
+ log_key=log_key,
404
+ message=(
405
+ "GetConfiguration timed out: charger did not respond"
406
+ " (operation may not be supported)"
407
+ ),
408
+ )
409
+ fetched += 1
410
+ if fetched:
411
+ self.message_user(
412
+ request,
413
+ f"Requested configuration from {fetched} charger(s)",
414
+ )
415
+
416
+ def _dispatch_change_availability(self, request, queryset, availability_type: str):
417
+ sent = 0
418
+ for charger in queryset:
419
+ connector_value = charger.connector_id
420
+ ws = store.get_connection(charger.charger_id, connector_value)
421
+ if ws is None:
422
+ self.message_user(
423
+ request,
424
+ f"{charger}: no active connection",
425
+ level=messages.ERROR,
426
+ )
427
+ continue
428
+ connector_id = connector_value if connector_value is not None else 0
429
+ message_id = uuid.uuid4().hex
430
+ payload = {"connectorId": connector_id, "type": availability_type}
431
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
432
+ try:
433
+ async_to_sync(ws.send)(msg)
434
+ except Exception as exc: # pragma: no cover - network error
435
+ self.message_user(
436
+ request,
437
+ f"{charger}: failed to send ChangeAvailability ({exc})",
438
+ level=messages.ERROR,
439
+ )
440
+ continue
441
+ log_key = store.identity_key(charger.charger_id, connector_value)
442
+ store.add_log(log_key, f"< {msg}", log_type="charger")
443
+ timestamp = timezone.now()
444
+ store.register_pending_call(
445
+ message_id,
446
+ {
447
+ "action": "ChangeAvailability",
448
+ "charger_id": charger.charger_id,
449
+ "connector_id": connector_value,
450
+ "availability_type": availability_type,
451
+ "requested_at": timestamp,
452
+ },
453
+ )
454
+ updates = {
455
+ "availability_requested_state": availability_type,
456
+ "availability_requested_at": timestamp,
457
+ "availability_request_status": "",
458
+ "availability_request_status_at": None,
459
+ "availability_request_details": "",
460
+ }
461
+ Charger.objects.filter(pk=charger.pk).update(**updates)
462
+ for field, value in updates.items():
463
+ setattr(charger, field, value)
464
+ sent += 1
465
+ if sent:
466
+ self.message_user(
467
+ request,
468
+ f"Sent ChangeAvailability ({availability_type}) to {sent} charger(s)",
469
+ )
470
+
471
+ @admin.action(description="Set availability to Operative")
472
+ def change_availability_operative(self, request, queryset):
473
+ self._dispatch_change_availability(request, queryset, "Operative")
474
+
475
+ @admin.action(description="Set availability to Inoperative")
476
+ def change_availability_inoperative(self, request, queryset):
477
+ self._dispatch_change_availability(request, queryset, "Inoperative")
478
+
479
+ def _set_availability_state(
480
+ self, request, queryset, availability_state: str
481
+ ) -> None:
482
+ timestamp = timezone.now()
483
+ updated = 0
484
+ for charger in queryset:
485
+ updates = {
486
+ "availability_state": availability_state,
487
+ "availability_state_updated_at": timestamp,
488
+ }
489
+ Charger.objects.filter(pk=charger.pk).update(**updates)
490
+ for field, value in updates.items():
491
+ setattr(charger, field, value)
492
+ updated += 1
493
+ if updated:
494
+ self.message_user(
495
+ request,
496
+ f"Updated availability to {availability_state} for {updated} charger(s)",
497
+ )
498
+
499
+ @admin.action(description="Mark availability as Operative")
500
+ def set_availability_state_operative(self, request, queryset):
501
+ self._set_availability_state(request, queryset, "Operative")
502
+
503
+ @admin.action(description="Mark availability as Inoperative")
504
+ def set_availability_state_inoperative(self, request, queryset):
505
+ self._set_availability_state(request, queryset, "Inoperative")
506
+
507
+ @admin.action(description="Remote stop active transaction")
508
+ def remote_stop_transaction(self, request, queryset):
509
+ stopped = 0
510
+ for charger in queryset:
511
+ connector_value = charger.connector_id
512
+ ws = store.get_connection(charger.charger_id, connector_value)
513
+ if ws is None:
514
+ self.message_user(
515
+ request,
516
+ f"{charger}: no active connection",
517
+ level=messages.ERROR,
518
+ )
519
+ continue
520
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
521
+ if tx_obj is None:
522
+ self.message_user(
523
+ request,
524
+ f"{charger}: no active transaction",
525
+ level=messages.ERROR,
526
+ )
527
+ continue
528
+ message_id = uuid.uuid4().hex
529
+ payload = {"transactionId": tx_obj.pk}
530
+ msg = json.dumps([
531
+ 2,
532
+ message_id,
533
+ "RemoteStopTransaction",
534
+ payload,
535
+ ])
536
+ try:
537
+ async_to_sync(ws.send)(msg)
538
+ except Exception as exc: # pragma: no cover - network error
539
+ self.message_user(
540
+ request,
541
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
542
+ level=messages.ERROR,
543
+ )
544
+ continue
545
+ log_key = store.identity_key(charger.charger_id, connector_value)
546
+ store.add_log(log_key, f"< {msg}", log_type="charger")
547
+ store.register_pending_call(
548
+ message_id,
549
+ {
550
+ "action": "RemoteStopTransaction",
551
+ "charger_id": charger.charger_id,
552
+ "connector_id": connector_value,
553
+ "transaction_id": tx_obj.pk,
554
+ "log_key": log_key,
555
+ "requested_at": timezone.now(),
556
+ },
557
+ )
558
+ stopped += 1
559
+ if stopped:
560
+ self.message_user(
561
+ request,
562
+ f"Sent RemoteStopTransaction to {stopped} charger(s)",
563
+ )
564
+
565
+ @admin.action(description="Reset charger (soft)")
566
+ def reset_chargers(self, request, queryset):
567
+ reset = 0
568
+ for charger in queryset:
569
+ connector_value = charger.connector_id
570
+ ws = store.get_connection(charger.charger_id, connector_value)
571
+ if ws is None:
572
+ self.message_user(
573
+ request,
574
+ f"{charger}: no active connection",
575
+ level=messages.ERROR,
576
+ )
577
+ continue
578
+ message_id = uuid.uuid4().hex
579
+ msg = json.dumps([
580
+ 2,
581
+ message_id,
582
+ "Reset",
583
+ {"type": "Soft"},
584
+ ])
585
+ try:
586
+ async_to_sync(ws.send)(msg)
587
+ except Exception as exc: # pragma: no cover - network error
588
+ self.message_user(
589
+ request,
590
+ f"{charger}: failed to send Reset ({exc})",
591
+ level=messages.ERROR,
592
+ )
593
+ continue
594
+ log_key = store.identity_key(charger.charger_id, connector_value)
595
+ store.add_log(log_key, f"< {msg}", log_type="charger")
596
+ store.register_pending_call(
597
+ message_id,
598
+ {
599
+ "action": "Reset",
600
+ "charger_id": charger.charger_id,
601
+ "connector_id": connector_value,
602
+ "log_key": log_key,
603
+ "requested_at": timezone.now(),
604
+ },
605
+ )
606
+ reset += 1
607
+ if reset:
608
+ self.message_user(
609
+ request,
610
+ f"Sent Reset to {reset} charger(s)",
611
+ )
612
+
242
613
  def delete_queryset(self, request, queryset):
243
614
  for obj in queryset:
244
615
  obj.delete()
@@ -258,7 +629,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
258
629
 
259
630
 
260
631
  @admin.register(Simulator)
261
- class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
632
+ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
262
633
  list_display = (
263
634
  "name",
264
635
  "cp_path",
@@ -270,17 +641,39 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
270
641
  "running",
271
642
  "log_link",
272
643
  )
273
- fields = (
274
- "name",
275
- "cp_path",
276
- ("host", "ws_port"),
277
- "rfid",
278
- ("duration", "interval", "pre_charge_delay"),
279
- "kw_max",
280
- ("repeat", "door_open"),
281
- ("username", "password"),
644
+ fieldsets = (
645
+ (
646
+ None,
647
+ {
648
+ "fields": (
649
+ "name",
650
+ "cp_path",
651
+ ("host", "ws_port"),
652
+ "rfid",
653
+ ("duration", "interval", "pre_charge_delay"),
654
+ "kw_max",
655
+ ("repeat", "door_open"),
656
+ ("username", "password"),
657
+ )
658
+ },
659
+ ),
660
+ (
661
+ "Configuration",
662
+ {
663
+ "fields": (
664
+ "configuration_keys",
665
+ "configuration_unknown_keys",
666
+ ),
667
+ "classes": ("collapse",),
668
+ "description": (
669
+ "Provide JSON lists for configurationKey entries and "
670
+ "unknownKey values returned by GetConfiguration."
671
+ ),
672
+ },
673
+ ),
282
674
  )
283
675
  actions = ("start_simulator", "stop_simulator", "send_open_door")
676
+ change_actions = ["start_simulator_action", "stop_simulator_action"]
284
677
 
285
678
  log_type = "simulator"
286
679
 
@@ -382,11 +775,25 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
382
775
  if sim:
383
776
  await sim.stop()
384
777
 
385
- asyncio.get_event_loop().create_task(_stop(list(queryset)))
778
+ objs = list(queryset)
779
+ try:
780
+ loop = asyncio.get_running_loop()
781
+ except RuntimeError:
782
+ asyncio.run(_stop(objs))
783
+ else:
784
+ loop.create_task(_stop(objs))
386
785
  self.message_user(request, "Stopping simulators")
387
786
 
388
787
  stop_simulator.short_description = "Stop selected simulators"
389
788
 
789
+ def start_simulator_action(self, request, obj):
790
+ queryset = type(obj).objects.filter(pk=obj.pk)
791
+ self.start_simulator(request, queryset)
792
+
793
+ def stop_simulator_action(self, request, obj):
794
+ queryset = type(obj).objects.filter(pk=obj.pk)
795
+ self.stop_simulator(request, queryset)
796
+
390
797
  def log_link(self, obj):
391
798
  from django.utils.html import format_html
392
799
  from django.urls import reverse
@@ -431,7 +838,7 @@ class TransactionAdmin(EntityModelAdmin):
431
838
  "stop_time",
432
839
  "kw",
433
840
  )
434
- readonly_fields = ("kw",)
841
+ readonly_fields = ("kw", "received_start_time", "received_stop_time")
435
842
  list_filter = ("charger", "account")
436
843
  date_hierarchy = "start_time"
437
844
  inlines = [MeterValueInline]