arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
ocpp/admin.py CHANGED
@@ -1,948 +1,948 @@
1
- from django.contrib import admin, messages
2
- from django import forms
3
-
4
- import asyncio
5
- from datetime import timedelta
6
- import json
7
-
8
- from django.shortcuts import redirect
9
- from django.utils import timezone
10
- from django.urls import path
11
- from django.http import HttpResponse, HttpResponseRedirect
12
- from django.template.response import TemplateResponse
13
-
14
- import uuid
15
- from asgiref.sync import async_to_sync
16
-
17
- from .models import (
18
- Charger,
19
- Simulator,
20
- MeterValue,
21
- Transaction,
22
- Location,
23
- DataTransferMessage,
24
- )
25
- from .simulator import ChargePointSimulator
26
- from . import store
27
- from .transactions_io import (
28
- export_transactions,
29
- import_transactions as import_transactions_data,
30
- )
31
- from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
32
- from core.admin import SaveBeforeChangeAction
33
- from core.user_data import EntityModelAdmin
34
-
35
-
36
- class LocationAdminForm(forms.ModelForm):
37
- class Meta:
38
- model = Location
39
- fields = "__all__"
40
-
41
- widgets = {
42
- "latitude": forms.NumberInput(attrs={"step": "any"}),
43
- "longitude": forms.NumberInput(attrs={"step": "any"}),
44
- }
45
-
46
- class Media:
47
- css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
48
- js = (
49
- "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
50
- "ocpp/charger_map.js",
51
- )
52
-
53
-
54
- class TransactionExportForm(forms.Form):
55
- start = forms.DateTimeField(required=False)
56
- end = forms.DateTimeField(required=False)
57
- chargers = forms.ModelMultipleChoiceField(
58
- queryset=Charger.objects.all(), required=False
59
- )
60
-
61
-
62
- class TransactionImportForm(forms.Form):
63
- file = forms.FileField()
64
-
65
-
66
- class LogViewAdminMixin:
67
- """Mixin providing an admin view to display charger or simulator logs."""
68
-
69
- log_type = "charger"
70
- log_template_name = "admin/ocpp/log_view.html"
71
-
72
- def get_log_identifier(self, obj): # pragma: no cover - mixin hook
73
- raise NotImplementedError
74
-
75
- def get_log_title(self, obj):
76
- return f"Log for {obj}"
77
-
78
- def get_urls(self):
79
- urls = super().get_urls()
80
- info = self.model._meta.app_label, self.model._meta.model_name
81
- custom = [
82
- path(
83
- "<path:object_id>/log/",
84
- self.admin_site.admin_view(self.log_view),
85
- name=f"{info[0]}_{info[1]}_log",
86
- ),
87
- ]
88
- return custom + urls
89
-
90
- def log_view(self, request, object_id):
91
- obj = self.get_object(request, object_id)
92
- if obj is None:
93
- self.message_user(request, "Log is not available.", messages.ERROR)
94
- return redirect("..")
95
- identifier = self.get_log_identifier(obj)
96
- log_entries = store.get_logs(identifier, log_type=self.log_type)
97
- log_file = store._file_path(identifier, log_type=self.log_type)
98
- context = {
99
- **self.admin_site.each_context(request),
100
- "opts": self.model._meta,
101
- "original": obj,
102
- "title": self.get_log_title(obj),
103
- "log_entries": log_entries,
104
- "log_file": str(log_file),
105
- "log_identifier": identifier,
106
- }
107
- return TemplateResponse(request, self.log_template_name, context)
108
-
109
-
110
- @admin.register(Location)
111
- class LocationAdmin(EntityModelAdmin):
112
- form = LocationAdminForm
113
- list_display = ("name", "latitude", "longitude")
114
- change_form_template = "admin/ocpp/location/change_form.html"
115
-
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
-
155
- @admin.register(Charger)
156
- class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
157
- fieldsets = (
158
- (
159
- "General",
160
- {
161
- "fields": (
162
- "charger_id",
163
- "display_name",
164
- "connector_id",
165
- "location",
166
- "last_path",
167
- "last_heartbeat",
168
- "last_meter_values",
169
- )
170
- },
171
- ),
172
- (
173
- "Firmware",
174
- {
175
- "fields": (
176
- "firmware_status",
177
- "firmware_status_info",
178
- "firmware_timestamp",
179
- )
180
- },
181
- ),
182
- (
183
- "Diagnostics",
184
- {
185
- "fields": (
186
- "diagnostics_status",
187
- "diagnostics_timestamp",
188
- "diagnostics_location",
189
- )
190
- },
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
- ),
206
- (
207
- "Configuration",
208
- {"fields": ("public_display", "require_rfid")},
209
- ),
210
- (
211
- "References",
212
- {
213
- "fields": ("reference",),
214
- },
215
- ),
216
- (
217
- "Owner",
218
- {
219
- "fields": ("owner_users", "owner_groups"),
220
- "classes": ("collapse",),
221
- },
222
- ),
223
- )
224
- readonly_fields = (
225
- "last_heartbeat",
226
- "last_meter_values",
227
- "firmware_status",
228
- "firmware_status_info",
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",
237
- )
238
- list_display = (
239
- "charger_id",
240
- "connector_number",
241
- "location_name",
242
- "require_rfid_display",
243
- "public_display",
244
- "last_heartbeat",
245
- "session_kw",
246
- "total_kw_display",
247
- "page_link",
248
- "log_link",
249
- "status_link",
250
- )
251
- search_fields = ("charger_id", "connector_id", "location__name")
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
- ]
264
-
265
- def get_view_on_site_url(self, obj=None):
266
- return obj.get_absolute_url() if obj else None
267
-
268
- def require_rfid_display(self, obj):
269
- return obj.require_rfid
270
-
271
- require_rfid_display.boolean = True
272
- require_rfid_display.short_description = "RFID Auth"
273
-
274
- def page_link(self, obj):
275
- from django.utils.html import format_html
276
-
277
- return format_html(
278
- '<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
279
- )
280
-
281
- page_link.short_description = "Landing"
282
-
283
- def qr_link(self, obj):
284
- from django.utils.html import format_html
285
-
286
- if obj.reference and obj.reference.image:
287
- return format_html(
288
- '<a href="{}" target="_blank">qr</a>', obj.reference.image.url
289
- )
290
- return ""
291
-
292
- qr_link.short_description = "QR Code"
293
-
294
- def log_link(self, obj):
295
- from django.utils.html import format_html
296
- from django.urls import reverse
297
-
298
- url = reverse("admin:ocpp_charger_log", args=[obj.pk])
299
- return format_html('<a href="{}" target="_blank">view</a>', url)
300
-
301
- log_link.short_description = "Log"
302
-
303
- def get_log_identifier(self, obj):
304
- return store.identity_key(obj.charger_id, obj.connector_id)
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
-
312
- def status_link(self, obj):
313
- from django.utils.html import format_html
314
- from django.urls import reverse
315
-
316
- url = reverse(
317
- "charger-status-connector",
318
- args=[obj.charger_id, obj.connector_slug],
319
- )
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)
330
-
331
- status_link.short_description = "Status"
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
-
350
- def location_name(self, obj):
351
- return obj.location.name if obj.location else ""
352
-
353
- location_name.short_description = "Location"
354
-
355
- def purge_data(self, request, queryset):
356
- for charger in queryset:
357
- charger.purge()
358
- self.message_user(request, "Data purged for selected chargers")
359
-
360
- purge_data.short_description = "Purge data"
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
-
613
- def delete_queryset(self, request, queryset):
614
- for obj in queryset:
615
- obj.delete()
616
-
617
- def total_kw_display(self, obj):
618
- return round(obj.total_kw, 2)
619
-
620
- total_kw_display.short_description = "Total kW"
621
-
622
- def session_kw(self, obj):
623
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
624
- if tx:
625
- return round(tx.kw, 2)
626
- return 0.0
627
-
628
- session_kw.short_description = "Session kW"
629
-
630
-
631
- @admin.register(Simulator)
632
- class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
633
- list_display = (
634
- "name",
635
- "cp_path",
636
- "host",
637
- "ws_port",
638
- "ws_url",
639
- "interval",
640
- "kw_max",
641
- "running",
642
- "log_link",
643
- )
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
- ),
674
- )
675
- actions = ("start_simulator", "stop_simulator", "send_open_door")
676
- change_actions = ["start_simulator_action", "stop_simulator_action"]
677
-
678
- log_type = "simulator"
679
-
680
- def save_model(self, request, obj, form, change):
681
- previous_door_open = False
682
- if change and obj.pk:
683
- previous_door_open = (
684
- type(obj)
685
- .objects.filter(pk=obj.pk)
686
- .values_list("door_open", flat=True)
687
- .first()
688
- or False
689
- )
690
- super().save_model(request, obj, form, change)
691
- if obj.door_open and not previous_door_open:
692
- triggered = self._queue_door_open(request, obj)
693
- if not triggered:
694
- type(obj).objects.filter(pk=obj.pk).update(door_open=False)
695
- obj.door_open = False
696
-
697
- def _queue_door_open(self, request, obj) -> bool:
698
- sim = store.simulators.get(obj.pk)
699
- if not sim:
700
- self.message_user(
701
- request,
702
- f"{obj.name}: simulator is not running",
703
- level=messages.ERROR,
704
- )
705
- return False
706
- type(obj).objects.filter(pk=obj.pk).update(door_open=True)
707
- obj.door_open = True
708
- store.add_log(
709
- obj.cp_path,
710
- "Door open event requested from admin",
711
- log_type="simulator",
712
- )
713
- if hasattr(sim, "trigger_door_open"):
714
- sim.trigger_door_open()
715
- else: # pragma: no cover - unexpected condition
716
- self.message_user(
717
- request,
718
- f"{obj.name}: simulator cannot send door open event",
719
- level=messages.ERROR,
720
- )
721
- type(obj).objects.filter(pk=obj.pk).update(door_open=False)
722
- obj.door_open = False
723
- return False
724
- type(obj).objects.filter(pk=obj.pk).update(door_open=False)
725
- obj.door_open = False
726
- self.message_user(
727
- request,
728
- f"{obj.name}: DoorOpen status notification sent",
729
- )
730
- return True
731
-
732
- def running(self, obj):
733
- return obj.pk in store.simulators
734
-
735
- running.boolean = True
736
-
737
- @admin.action(description="Send Open Door")
738
- def send_open_door(self, request, queryset):
739
- for obj in queryset:
740
- self._queue_door_open(request, obj)
741
-
742
- def start_simulator(self, request, queryset):
743
- from django.urls import reverse
744
- from django.utils.html import format_html
745
-
746
- for obj in queryset:
747
- if obj.pk in store.simulators:
748
- self.message_user(request, f"{obj.name}: already running")
749
- continue
750
- type(obj).objects.filter(pk=obj.pk).update(door_open=False)
751
- obj.door_open = False
752
- store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
753
- sim = ChargePointSimulator(obj.as_config())
754
- started, status, log_file = sim.start()
755
- if started:
756
- store.simulators[obj.pk] = sim
757
- log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
758
- self.message_user(
759
- request,
760
- format_html(
761
- '{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
762
- obj.name,
763
- status,
764
- log_file,
765
- log_url,
766
- ),
767
- )
768
-
769
- start_simulator.short_description = "Start selected simulators"
770
-
771
- def stop_simulator(self, request, queryset):
772
- async def _stop(objs):
773
- for obj in objs:
774
- sim = store.simulators.pop(obj.pk, None)
775
- if sim:
776
- await sim.stop()
777
-
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))
785
- self.message_user(request, "Stopping simulators")
786
-
787
- stop_simulator.short_description = "Stop selected simulators"
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
-
797
- def log_link(self, obj):
798
- from django.utils.html import format_html
799
- from django.urls import reverse
800
-
801
- url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
802
- return format_html('<a href="{}" target="_blank">view</a>', url)
803
-
804
- log_link.short_description = "Log"
805
-
806
- def get_log_identifier(self, obj):
807
- return obj.cp_path
808
-
809
-
810
- class MeterValueInline(admin.TabularInline):
811
- model = MeterValue
812
- extra = 0
813
- fields = (
814
- "timestamp",
815
- "context",
816
- "energy",
817
- "voltage",
818
- "current_import",
819
- "current_offered",
820
- "temperature",
821
- "soc",
822
- "connector_id",
823
- )
824
- readonly_fields = fields
825
- can_delete = False
826
-
827
-
828
- @admin.register(Transaction)
829
- class TransactionAdmin(EntityModelAdmin):
830
- change_list_template = "admin/ocpp/transaction/change_list.html"
831
- list_display = (
832
- "charger",
833
- "account",
834
- "rfid",
835
- "meter_start",
836
- "meter_stop",
837
- "start_time",
838
- "stop_time",
839
- "kw",
840
- )
841
- readonly_fields = ("kw", "received_start_time", "received_stop_time")
842
- list_filter = ("charger", "account")
843
- date_hierarchy = "start_time"
844
- inlines = [MeterValueInline]
845
-
846
- def get_urls(self):
847
- urls = super().get_urls()
848
- custom = [
849
- path(
850
- "export/",
851
- self.admin_site.admin_view(self.export_view),
852
- name="ocpp_transaction_export",
853
- ),
854
- path(
855
- "import/",
856
- self.admin_site.admin_view(self.import_view),
857
- name="ocpp_transaction_import",
858
- ),
859
- ]
860
- return custom + urls
861
-
862
- def export_view(self, request):
863
- if request.method == "POST":
864
- form = TransactionExportForm(request.POST)
865
- if form.is_valid():
866
- chargers = form.cleaned_data["chargers"]
867
- data = export_transactions(
868
- start=form.cleaned_data["start"],
869
- end=form.cleaned_data["end"],
870
- chargers=[c.charger_id for c in chargers] if chargers else None,
871
- )
872
- response = HttpResponse(
873
- json.dumps(data, indent=2, ensure_ascii=False),
874
- content_type="application/json",
875
- )
876
- response["Content-Disposition"] = (
877
- "attachment; filename=transactions.json"
878
- )
879
- return response
880
- else:
881
- form = TransactionExportForm()
882
- context = self.admin_site.each_context(request)
883
- context["form"] = form
884
- return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
885
-
886
- def import_view(self, request):
887
- if request.method == "POST":
888
- form = TransactionImportForm(request.POST, request.FILES)
889
- if form.is_valid():
890
- data = json.load(form.cleaned_data["file"])
891
- imported = import_transactions_data(data)
892
- self.message_user(request, f"Imported {imported} transactions")
893
- return HttpResponseRedirect("../")
894
- else:
895
- form = TransactionImportForm()
896
- context = self.admin_site.each_context(request)
897
- context["form"] = form
898
- return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
899
-
900
-
901
- class MeterValueDateFilter(admin.SimpleListFilter):
902
- title = "Timestamp"
903
- parameter_name = "timestamp_range"
904
-
905
- def lookups(self, request, model_admin):
906
- return [
907
- ("today", "Today"),
908
- ("7days", "Last 7 days"),
909
- ("30days", "Last 30 days"),
910
- ("older", "Older than 30 days"),
911
- ]
912
-
913
- def queryset(self, request, queryset):
914
- value = self.value()
915
- now = timezone.now()
916
- if value == "today":
917
- start = now.replace(hour=0, minute=0, second=0, microsecond=0)
918
- end = start + timedelta(days=1)
919
- return queryset.filter(timestamp__gte=start, timestamp__lt=end)
920
- if value == "7days":
921
- start = now - timedelta(days=7)
922
- return queryset.filter(timestamp__gte=start)
923
- if value == "30days":
924
- start = now - timedelta(days=30)
925
- return queryset.filter(timestamp__gte=start)
926
- if value == "older":
927
- cutoff = now - timedelta(days=30)
928
- return queryset.filter(timestamp__lt=cutoff)
929
- return queryset
930
-
931
-
932
- @admin.register(MeterValue)
933
- class MeterValueAdmin(EntityModelAdmin):
934
- list_display = (
935
- "charger",
936
- "timestamp",
937
- "context",
938
- "energy",
939
- "voltage",
940
- "current_import",
941
- "current_offered",
942
- "temperature",
943
- "soc",
944
- "connector_id",
945
- "transaction",
946
- )
947
- date_hierarchy = "timestamp"
948
- list_filter = ("charger", MeterValueDateFilter)
1
+ from django.contrib import admin, messages
2
+ from django import forms
3
+
4
+ import asyncio
5
+ from datetime import timedelta
6
+ import json
7
+
8
+ from django.shortcuts import redirect
9
+ from django.utils import timezone
10
+ from django.urls import path
11
+ from django.http import HttpResponse, HttpResponseRedirect
12
+ from django.template.response import TemplateResponse
13
+
14
+ import uuid
15
+ from asgiref.sync import async_to_sync
16
+
17
+ from .models import (
18
+ Charger,
19
+ Simulator,
20
+ MeterValue,
21
+ Transaction,
22
+ Location,
23
+ DataTransferMessage,
24
+ )
25
+ from .simulator import ChargePointSimulator
26
+ from . import store
27
+ from .transactions_io import (
28
+ export_transactions,
29
+ import_transactions as import_transactions_data,
30
+ )
31
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
32
+ from core.admin import SaveBeforeChangeAction
33
+ from core.user_data import EntityModelAdmin
34
+
35
+
36
+ class LocationAdminForm(forms.ModelForm):
37
+ class Meta:
38
+ model = Location
39
+ fields = "__all__"
40
+
41
+ widgets = {
42
+ "latitude": forms.NumberInput(attrs={"step": "any"}),
43
+ "longitude": forms.NumberInput(attrs={"step": "any"}),
44
+ }
45
+
46
+ class Media:
47
+ css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
48
+ js = (
49
+ "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
50
+ "ocpp/charger_map.js",
51
+ )
52
+
53
+
54
+ class TransactionExportForm(forms.Form):
55
+ start = forms.DateTimeField(required=False)
56
+ end = forms.DateTimeField(required=False)
57
+ chargers = forms.ModelMultipleChoiceField(
58
+ queryset=Charger.objects.all(), required=False
59
+ )
60
+
61
+
62
+ class TransactionImportForm(forms.Form):
63
+ file = forms.FileField()
64
+
65
+
66
+ class LogViewAdminMixin:
67
+ """Mixin providing an admin view to display charger or simulator logs."""
68
+
69
+ log_type = "charger"
70
+ log_template_name = "admin/ocpp/log_view.html"
71
+
72
+ def get_log_identifier(self, obj): # pragma: no cover - mixin hook
73
+ raise NotImplementedError
74
+
75
+ def get_log_title(self, obj):
76
+ return f"Log for {obj}"
77
+
78
+ def get_urls(self):
79
+ urls = super().get_urls()
80
+ info = self.model._meta.app_label, self.model._meta.model_name
81
+ custom = [
82
+ path(
83
+ "<path:object_id>/log/",
84
+ self.admin_site.admin_view(self.log_view),
85
+ name=f"{info[0]}_{info[1]}_log",
86
+ ),
87
+ ]
88
+ return custom + urls
89
+
90
+ def log_view(self, request, object_id):
91
+ obj = self.get_object(request, object_id)
92
+ if obj is None:
93
+ self.message_user(request, "Log is not available.", messages.ERROR)
94
+ return redirect("..")
95
+ identifier = self.get_log_identifier(obj)
96
+ log_entries = store.get_logs(identifier, log_type=self.log_type)
97
+ log_file = store._file_path(identifier, log_type=self.log_type)
98
+ context = {
99
+ **self.admin_site.each_context(request),
100
+ "opts": self.model._meta,
101
+ "original": obj,
102
+ "title": self.get_log_title(obj),
103
+ "log_entries": log_entries,
104
+ "log_file": str(log_file),
105
+ "log_identifier": identifier,
106
+ }
107
+ return TemplateResponse(request, self.log_template_name, context)
108
+
109
+
110
+ @admin.register(Location)
111
+ class LocationAdmin(EntityModelAdmin):
112
+ form = LocationAdminForm
113
+ list_display = ("name", "latitude", "longitude")
114
+ change_form_template = "admin/ocpp/location/change_form.html"
115
+
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
+
155
+ @admin.register(Charger)
156
+ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
157
+ fieldsets = (
158
+ (
159
+ "General",
160
+ {
161
+ "fields": (
162
+ "charger_id",
163
+ "display_name",
164
+ "connector_id",
165
+ "location",
166
+ "last_path",
167
+ "last_heartbeat",
168
+ "last_meter_values",
169
+ )
170
+ },
171
+ ),
172
+ (
173
+ "Firmware",
174
+ {
175
+ "fields": (
176
+ "firmware_status",
177
+ "firmware_status_info",
178
+ "firmware_timestamp",
179
+ )
180
+ },
181
+ ),
182
+ (
183
+ "Diagnostics",
184
+ {
185
+ "fields": (
186
+ "diagnostics_status",
187
+ "diagnostics_timestamp",
188
+ "diagnostics_location",
189
+ )
190
+ },
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
+ ),
206
+ (
207
+ "Configuration",
208
+ {"fields": ("public_display", "require_rfid")},
209
+ ),
210
+ (
211
+ "References",
212
+ {
213
+ "fields": ("reference",),
214
+ },
215
+ ),
216
+ (
217
+ "Owner",
218
+ {
219
+ "fields": ("owner_users", "owner_groups"),
220
+ "classes": ("collapse",),
221
+ },
222
+ ),
223
+ )
224
+ readonly_fields = (
225
+ "last_heartbeat",
226
+ "last_meter_values",
227
+ "firmware_status",
228
+ "firmware_status_info",
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",
237
+ )
238
+ list_display = (
239
+ "charger_id",
240
+ "connector_number",
241
+ "location_name",
242
+ "require_rfid_display",
243
+ "public_display",
244
+ "last_heartbeat",
245
+ "session_kw",
246
+ "total_kw_display",
247
+ "page_link",
248
+ "log_link",
249
+ "status_link",
250
+ )
251
+ search_fields = ("charger_id", "connector_id", "location__name")
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
+ ]
264
+
265
+ def get_view_on_site_url(self, obj=None):
266
+ return obj.get_absolute_url() if obj else None
267
+
268
+ def require_rfid_display(self, obj):
269
+ return obj.require_rfid
270
+
271
+ require_rfid_display.boolean = True
272
+ require_rfid_display.short_description = "RFID Auth"
273
+
274
+ def page_link(self, obj):
275
+ from django.utils.html import format_html
276
+
277
+ return format_html(
278
+ '<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
279
+ )
280
+
281
+ page_link.short_description = "Landing"
282
+
283
+ def qr_link(self, obj):
284
+ from django.utils.html import format_html
285
+
286
+ if obj.reference and obj.reference.image:
287
+ return format_html(
288
+ '<a href="{}" target="_blank">qr</a>', obj.reference.image.url
289
+ )
290
+ return ""
291
+
292
+ qr_link.short_description = "QR Code"
293
+
294
+ def log_link(self, obj):
295
+ from django.utils.html import format_html
296
+ from django.urls import reverse
297
+
298
+ url = reverse("admin:ocpp_charger_log", args=[obj.pk])
299
+ return format_html('<a href="{}" target="_blank">view</a>', url)
300
+
301
+ log_link.short_description = "Log"
302
+
303
+ def get_log_identifier(self, obj):
304
+ return store.identity_key(obj.charger_id, obj.connector_id)
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
+
312
+ def status_link(self, obj):
313
+ from django.utils.html import format_html
314
+ from django.urls import reverse
315
+
316
+ url = reverse(
317
+ "charger-status-connector",
318
+ args=[obj.charger_id, obj.connector_slug],
319
+ )
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)
330
+
331
+ status_link.short_description = "Status"
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
+
350
+ def location_name(self, obj):
351
+ return obj.location.name if obj.location else ""
352
+
353
+ location_name.short_description = "Location"
354
+
355
+ def purge_data(self, request, queryset):
356
+ for charger in queryset:
357
+ charger.purge()
358
+ self.message_user(request, "Data purged for selected chargers")
359
+
360
+ purge_data.short_description = "Purge data"
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
+
613
+ def delete_queryset(self, request, queryset):
614
+ for obj in queryset:
615
+ obj.delete()
616
+
617
+ def total_kw_display(self, obj):
618
+ return round(obj.total_kw, 2)
619
+
620
+ total_kw_display.short_description = "Total kW"
621
+
622
+ def session_kw(self, obj):
623
+ tx = store.get_transaction(obj.charger_id, obj.connector_id)
624
+ if tx:
625
+ return round(tx.kw, 2)
626
+ return 0.0
627
+
628
+ session_kw.short_description = "Session kW"
629
+
630
+
631
+ @admin.register(Simulator)
632
+ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
633
+ list_display = (
634
+ "name",
635
+ "cp_path",
636
+ "host",
637
+ "ws_port",
638
+ "ws_url",
639
+ "interval",
640
+ "kw_max",
641
+ "running",
642
+ "log_link",
643
+ )
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
+ ),
674
+ )
675
+ actions = ("start_simulator", "stop_simulator", "send_open_door")
676
+ change_actions = ["start_simulator_action", "stop_simulator_action"]
677
+
678
+ log_type = "simulator"
679
+
680
+ def save_model(self, request, obj, form, change):
681
+ previous_door_open = False
682
+ if change and obj.pk:
683
+ previous_door_open = (
684
+ type(obj)
685
+ .objects.filter(pk=obj.pk)
686
+ .values_list("door_open", flat=True)
687
+ .first()
688
+ or False
689
+ )
690
+ super().save_model(request, obj, form, change)
691
+ if obj.door_open and not previous_door_open:
692
+ triggered = self._queue_door_open(request, obj)
693
+ if not triggered:
694
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
695
+ obj.door_open = False
696
+
697
+ def _queue_door_open(self, request, obj) -> bool:
698
+ sim = store.simulators.get(obj.pk)
699
+ if not sim:
700
+ self.message_user(
701
+ request,
702
+ f"{obj.name}: simulator is not running",
703
+ level=messages.ERROR,
704
+ )
705
+ return False
706
+ type(obj).objects.filter(pk=obj.pk).update(door_open=True)
707
+ obj.door_open = True
708
+ store.add_log(
709
+ obj.cp_path,
710
+ "Door open event requested from admin",
711
+ log_type="simulator",
712
+ )
713
+ if hasattr(sim, "trigger_door_open"):
714
+ sim.trigger_door_open()
715
+ else: # pragma: no cover - unexpected condition
716
+ self.message_user(
717
+ request,
718
+ f"{obj.name}: simulator cannot send door open event",
719
+ level=messages.ERROR,
720
+ )
721
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
722
+ obj.door_open = False
723
+ return False
724
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
725
+ obj.door_open = False
726
+ self.message_user(
727
+ request,
728
+ f"{obj.name}: DoorOpen status notification sent",
729
+ )
730
+ return True
731
+
732
+ def running(self, obj):
733
+ return obj.pk in store.simulators
734
+
735
+ running.boolean = True
736
+
737
+ @admin.action(description="Send Open Door")
738
+ def send_open_door(self, request, queryset):
739
+ for obj in queryset:
740
+ self._queue_door_open(request, obj)
741
+
742
+ def start_simulator(self, request, queryset):
743
+ from django.urls import reverse
744
+ from django.utils.html import format_html
745
+
746
+ for obj in queryset:
747
+ if obj.pk in store.simulators:
748
+ self.message_user(request, f"{obj.name}: already running")
749
+ continue
750
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
751
+ obj.door_open = False
752
+ store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
753
+ sim = ChargePointSimulator(obj.as_config())
754
+ started, status, log_file = sim.start()
755
+ if started:
756
+ store.simulators[obj.pk] = sim
757
+ log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
758
+ self.message_user(
759
+ request,
760
+ format_html(
761
+ '{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
762
+ obj.name,
763
+ status,
764
+ log_file,
765
+ log_url,
766
+ ),
767
+ )
768
+
769
+ start_simulator.short_description = "Start selected simulators"
770
+
771
+ def stop_simulator(self, request, queryset):
772
+ async def _stop(objs):
773
+ for obj in objs:
774
+ sim = store.simulators.pop(obj.pk, None)
775
+ if sim:
776
+ await sim.stop()
777
+
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))
785
+ self.message_user(request, "Stopping simulators")
786
+
787
+ stop_simulator.short_description = "Stop selected simulators"
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
+
797
+ def log_link(self, obj):
798
+ from django.utils.html import format_html
799
+ from django.urls import reverse
800
+
801
+ url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
802
+ return format_html('<a href="{}" target="_blank">view</a>', url)
803
+
804
+ log_link.short_description = "Log"
805
+
806
+ def get_log_identifier(self, obj):
807
+ return obj.cp_path
808
+
809
+
810
+ class MeterValueInline(admin.TabularInline):
811
+ model = MeterValue
812
+ extra = 0
813
+ fields = (
814
+ "timestamp",
815
+ "context",
816
+ "energy",
817
+ "voltage",
818
+ "current_import",
819
+ "current_offered",
820
+ "temperature",
821
+ "soc",
822
+ "connector_id",
823
+ )
824
+ readonly_fields = fields
825
+ can_delete = False
826
+
827
+
828
+ @admin.register(Transaction)
829
+ class TransactionAdmin(EntityModelAdmin):
830
+ change_list_template = "admin/ocpp/transaction/change_list.html"
831
+ list_display = (
832
+ "charger",
833
+ "account",
834
+ "rfid",
835
+ "meter_start",
836
+ "meter_stop",
837
+ "start_time",
838
+ "stop_time",
839
+ "kw",
840
+ )
841
+ readonly_fields = ("kw", "received_start_time", "received_stop_time")
842
+ list_filter = ("charger", "account")
843
+ date_hierarchy = "start_time"
844
+ inlines = [MeterValueInline]
845
+
846
+ def get_urls(self):
847
+ urls = super().get_urls()
848
+ custom = [
849
+ path(
850
+ "export/",
851
+ self.admin_site.admin_view(self.export_view),
852
+ name="ocpp_transaction_export",
853
+ ),
854
+ path(
855
+ "import/",
856
+ self.admin_site.admin_view(self.import_view),
857
+ name="ocpp_transaction_import",
858
+ ),
859
+ ]
860
+ return custom + urls
861
+
862
+ def export_view(self, request):
863
+ if request.method == "POST":
864
+ form = TransactionExportForm(request.POST)
865
+ if form.is_valid():
866
+ chargers = form.cleaned_data["chargers"]
867
+ data = export_transactions(
868
+ start=form.cleaned_data["start"],
869
+ end=form.cleaned_data["end"],
870
+ chargers=[c.charger_id for c in chargers] if chargers else None,
871
+ )
872
+ response = HttpResponse(
873
+ json.dumps(data, indent=2, ensure_ascii=False),
874
+ content_type="application/json",
875
+ )
876
+ response["Content-Disposition"] = (
877
+ "attachment; filename=transactions.json"
878
+ )
879
+ return response
880
+ else:
881
+ form = TransactionExportForm()
882
+ context = self.admin_site.each_context(request)
883
+ context["form"] = form
884
+ return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
885
+
886
+ def import_view(self, request):
887
+ if request.method == "POST":
888
+ form = TransactionImportForm(request.POST, request.FILES)
889
+ if form.is_valid():
890
+ data = json.load(form.cleaned_data["file"])
891
+ imported = import_transactions_data(data)
892
+ self.message_user(request, f"Imported {imported} transactions")
893
+ return HttpResponseRedirect("../")
894
+ else:
895
+ form = TransactionImportForm()
896
+ context = self.admin_site.each_context(request)
897
+ context["form"] = form
898
+ return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
899
+
900
+
901
+ class MeterValueDateFilter(admin.SimpleListFilter):
902
+ title = "Timestamp"
903
+ parameter_name = "timestamp_range"
904
+
905
+ def lookups(self, request, model_admin):
906
+ return [
907
+ ("today", "Today"),
908
+ ("7days", "Last 7 days"),
909
+ ("30days", "Last 30 days"),
910
+ ("older", "Older than 30 days"),
911
+ ]
912
+
913
+ def queryset(self, request, queryset):
914
+ value = self.value()
915
+ now = timezone.now()
916
+ if value == "today":
917
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
918
+ end = start + timedelta(days=1)
919
+ return queryset.filter(timestamp__gte=start, timestamp__lt=end)
920
+ if value == "7days":
921
+ start = now - timedelta(days=7)
922
+ return queryset.filter(timestamp__gte=start)
923
+ if value == "30days":
924
+ start = now - timedelta(days=30)
925
+ return queryset.filter(timestamp__gte=start)
926
+ if value == "older":
927
+ cutoff = now - timedelta(days=30)
928
+ return queryset.filter(timestamp__lt=cutoff)
929
+ return queryset
930
+
931
+
932
+ @admin.register(MeterValue)
933
+ class MeterValueAdmin(EntityModelAdmin):
934
+ list_display = (
935
+ "charger",
936
+ "timestamp",
937
+ "context",
938
+ "energy",
939
+ "voltage",
940
+ "current_import",
941
+ "current_offered",
942
+ "temperature",
943
+ "soc",
944
+ "connector_id",
945
+ "transaction",
946
+ )
947
+ date_hierarchy = "timestamp"
948
+ list_filter = ("charger", MeterValueDateFilter)