arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -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 +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/admin.py CHANGED
@@ -1,481 +1,1775 @@
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
- from .models import (
15
- Charger,
16
- Simulator,
17
- MeterValue,
18
- Transaction,
19
- Location,
20
- )
21
- from .simulator import ChargePointSimulator
22
- from . import store
23
- from .transactions_io import (
24
- export_transactions,
25
- import_transactions as import_transactions_data,
26
- )
27
- from core.user_data import EntityModelAdmin
28
-
29
-
30
- class LocationAdminForm(forms.ModelForm):
31
- class Meta:
32
- model = Location
33
- fields = "__all__"
34
-
35
- widgets = {
36
- "latitude": forms.NumberInput(attrs={"step": "any"}),
37
- "longitude": forms.NumberInput(attrs={"step": "any"}),
38
- }
39
-
40
- class Media:
41
- css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
42
- js = (
43
- "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
44
- "ocpp/charger_map.js",
45
- )
46
-
47
-
48
- class TransactionExportForm(forms.Form):
49
- start = forms.DateTimeField(required=False)
50
- end = forms.DateTimeField(required=False)
51
- chargers = forms.ModelMultipleChoiceField(
52
- queryset=Charger.objects.all(), required=False
53
- )
54
-
55
-
56
- class TransactionImportForm(forms.Form):
57
- file = forms.FileField()
58
-
59
-
60
- class LogViewAdminMixin:
61
- """Mixin providing an admin view to display charger or simulator logs."""
62
-
63
- log_type = "charger"
64
- log_template_name = "admin/ocpp/log_view.html"
65
-
66
- def get_log_identifier(self, obj): # pragma: no cover - mixin hook
67
- raise NotImplementedError
68
-
69
- def get_log_title(self, obj):
70
- return f"Log for {obj}"
71
-
72
- def get_urls(self):
73
- urls = super().get_urls()
74
- info = self.model._meta.app_label, self.model._meta.model_name
75
- custom = [
76
- path(
77
- "<path:object_id>/log/",
78
- self.admin_site.admin_view(self.log_view),
79
- name=f"{info[0]}_{info[1]}_log",
80
- ),
81
- ]
82
- return custom + urls
83
-
84
- def log_view(self, request, object_id):
85
- obj = self.get_object(request, object_id)
86
- if obj is None:
87
- self.message_user(request, "Log is not available.", messages.ERROR)
88
- return redirect("..")
89
- identifier = self.get_log_identifier(obj)
90
- log_entries = store.get_logs(identifier, log_type=self.log_type)
91
- log_file = store._file_path(identifier, log_type=self.log_type)
92
- context = {
93
- **self.admin_site.each_context(request),
94
- "opts": self.model._meta,
95
- "original": obj,
96
- "title": self.get_log_title(obj),
97
- "log_entries": log_entries,
98
- "log_file": str(log_file),
99
- "log_identifier": identifier,
100
- }
101
- return TemplateResponse(request, self.log_template_name, context)
102
-
103
-
104
- @admin.register(Location)
105
- class LocationAdmin(EntityModelAdmin):
106
- form = LocationAdminForm
107
- list_display = ("name", "latitude", "longitude")
108
- change_form_template = "admin/ocpp/location/change_form.html"
109
-
110
-
111
- @admin.register(Charger)
112
- class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
113
- fieldsets = (
114
- (
115
- "General",
116
- {
117
- "fields": (
118
- "charger_id",
119
- "display_name",
120
- "connector_id",
121
- "location",
122
- "last_path",
123
- "last_heartbeat",
124
- "last_meter_values",
125
- "firmware_status",
126
- "firmware_status_info",
127
- "firmware_timestamp",
128
- )
129
- },
130
- ),
131
- (
132
- "Diagnostics",
133
- {
134
- "fields": (
135
- "diagnostics_status",
136
- "diagnostics_timestamp",
137
- "diagnostics_location",
138
- )
139
- },
140
- ),
141
- (
142
- "Configuration",
143
- {"fields": ("require_rfid",)},
144
- ),
145
- (
146
- "References",
147
- {
148
- "fields": ("reference",),
149
- },
150
- ),
151
- )
152
- readonly_fields = (
153
- "last_heartbeat",
154
- "last_meter_values",
155
- "firmware_status",
156
- "firmware_status_info",
157
- "firmware_timestamp",
158
- )
159
- list_display = (
160
- "charger_id",
161
- "connector_id",
162
- "location_name",
163
- "require_rfid_display",
164
- "last_heartbeat",
165
- "firmware_status",
166
- "firmware_timestamp",
167
- "session_kw",
168
- "total_kw_display",
169
- "page_link",
170
- "log_link",
171
- "status_link",
172
- )
173
- search_fields = ("charger_id", "connector_id", "location__name")
174
- actions = ["purge_data", "delete_selected"]
175
-
176
- def get_view_on_site_url(self, obj=None):
177
- return obj.get_absolute_url() if obj else None
178
-
179
- def require_rfid_display(self, obj):
180
- return obj.require_rfid
181
-
182
- require_rfid_display.boolean = True
183
- require_rfid_display.short_description = "RFID Auth"
184
-
185
- def page_link(self, obj):
186
- from django.utils.html import format_html
187
-
188
- return format_html(
189
- '<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
190
- )
191
-
192
- page_link.short_description = "Landing"
193
-
194
- def qr_link(self, obj):
195
- from django.utils.html import format_html
196
-
197
- if obj.reference and obj.reference.image:
198
- return format_html(
199
- '<a href="{}" target="_blank">qr</a>', obj.reference.image.url
200
- )
201
- return ""
202
-
203
- qr_link.short_description = "QR Code"
204
-
205
- def log_link(self, obj):
206
- from django.utils.html import format_html
207
- from django.urls import reverse
208
-
209
- url = reverse("admin:ocpp_charger_log", args=[obj.pk])
210
- return format_html('<a href="{}" target="_blank">view</a>', url)
211
-
212
- log_link.short_description = "Log"
213
-
214
- def get_log_identifier(self, obj):
215
- return store.identity_key(obj.charger_id, obj.connector_id)
216
-
217
- def status_link(self, obj):
218
- from django.utils.html import format_html
219
- from django.urls import reverse
220
-
221
- url = reverse(
222
- "charger-status-connector",
223
- args=[obj.charger_id, obj.connector_slug],
224
- )
225
- return format_html('<a href="{}" target="_blank">status</a>', url)
226
-
227
- status_link.short_description = "Status"
228
-
229
- def location_name(self, obj):
230
- return obj.location.name if obj.location else ""
231
-
232
- location_name.short_description = "Location"
233
-
234
- def purge_data(self, request, queryset):
235
- for charger in queryset:
236
- charger.purge()
237
- self.message_user(request, "Data purged for selected chargers")
238
-
239
- purge_data.short_description = "Purge data"
240
-
241
- def delete_queryset(self, request, queryset):
242
- for obj in queryset:
243
- obj.delete()
244
-
245
- def total_kw_display(self, obj):
246
- return round(obj.total_kw, 2)
247
-
248
- total_kw_display.short_description = "Total kW"
249
-
250
- def session_kw(self, obj):
251
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
252
- if tx:
253
- return round(tx.kw, 2)
254
- return 0.0
255
-
256
- session_kw.short_description = "Session kW"
257
-
258
-
259
- @admin.register(Simulator)
260
- class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
261
- list_display = (
262
- "name",
263
- "cp_path",
264
- "host",
265
- "ws_port",
266
- "ws_url",
267
- "interval",
268
- "kw_max",
269
- "running",
270
- "log_link",
271
- )
272
- fields = (
273
- "name",
274
- "cp_path",
275
- ("host", "ws_port"),
276
- "rfid",
277
- ("duration", "interval", "pre_charge_delay"),
278
- "kw_max",
279
- "repeat",
280
- ("username", "password"),
281
- )
282
- actions = ("start_simulator", "stop_simulator")
283
-
284
- log_type = "simulator"
285
-
286
- def running(self, obj):
287
- return obj.pk in store.simulators
288
-
289
- running.boolean = True
290
-
291
- def start_simulator(self, request, queryset):
292
- from django.urls import reverse
293
- from django.utils.html import format_html
294
-
295
- for obj in queryset:
296
- if obj.pk in store.simulators:
297
- self.message_user(request, f"{obj.name}: already running")
298
- continue
299
- store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
300
- sim = ChargePointSimulator(obj.as_config())
301
- started, status, log_file = sim.start()
302
- if started:
303
- store.simulators[obj.pk] = sim
304
- log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
305
- self.message_user(
306
- request,
307
- format_html(
308
- '{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
309
- obj.name,
310
- status,
311
- log_file,
312
- log_url,
313
- ),
314
- )
315
-
316
- start_simulator.short_description = "Start selected simulators"
317
-
318
- def stop_simulator(self, request, queryset):
319
- async def _stop(objs):
320
- for obj in objs:
321
- sim = store.simulators.pop(obj.pk, None)
322
- if sim:
323
- await sim.stop()
324
-
325
- asyncio.get_event_loop().create_task(_stop(list(queryset)))
326
- self.message_user(request, "Stopping simulators")
327
-
328
- stop_simulator.short_description = "Stop selected simulators"
329
-
330
- def log_link(self, obj):
331
- from django.utils.html import format_html
332
- from django.urls import reverse
333
-
334
- url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
335
- return format_html('<a href="{}" target="_blank">view</a>', url)
336
-
337
- log_link.short_description = "Log"
338
-
339
- def get_log_identifier(self, obj):
340
- return obj.cp_path
341
-
342
-
343
- class MeterValueInline(admin.TabularInline):
344
- model = MeterValue
345
- extra = 0
346
- fields = (
347
- "timestamp",
348
- "context",
349
- "energy",
350
- "voltage",
351
- "current_import",
352
- "current_offered",
353
- "temperature",
354
- "soc",
355
- "connector_id",
356
- )
357
- readonly_fields = fields
358
- can_delete = False
359
-
360
-
361
- @admin.register(Transaction)
362
- class TransactionAdmin(EntityModelAdmin):
363
- change_list_template = "admin/ocpp/transaction/change_list.html"
364
- list_display = (
365
- "charger",
366
- "account",
367
- "rfid",
368
- "meter_start",
369
- "meter_stop",
370
- "start_time",
371
- "stop_time",
372
- "kw",
373
- )
374
- readonly_fields = ("kw",)
375
- list_filter = ("charger", "account")
376
- date_hierarchy = "start_time"
377
- inlines = [MeterValueInline]
378
-
379
- def get_urls(self):
380
- urls = super().get_urls()
381
- custom = [
382
- path(
383
- "export/",
384
- self.admin_site.admin_view(self.export_view),
385
- name="ocpp_transaction_export",
386
- ),
387
- path(
388
- "import/",
389
- self.admin_site.admin_view(self.import_view),
390
- name="ocpp_transaction_import",
391
- ),
392
- ]
393
- return custom + urls
394
-
395
- def export_view(self, request):
396
- if request.method == "POST":
397
- form = TransactionExportForm(request.POST)
398
- if form.is_valid():
399
- chargers = form.cleaned_data["chargers"]
400
- data = export_transactions(
401
- start=form.cleaned_data["start"],
402
- end=form.cleaned_data["end"],
403
- chargers=[c.charger_id for c in chargers] if chargers else None,
404
- )
405
- response = HttpResponse(
406
- json.dumps(data, indent=2, ensure_ascii=False),
407
- content_type="application/json",
408
- )
409
- response["Content-Disposition"] = (
410
- "attachment; filename=transactions.json"
411
- )
412
- return response
413
- else:
414
- form = TransactionExportForm()
415
- context = self.admin_site.each_context(request)
416
- context["form"] = form
417
- return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
418
-
419
- def import_view(self, request):
420
- if request.method == "POST":
421
- form = TransactionImportForm(request.POST, request.FILES)
422
- if form.is_valid():
423
- data = json.load(form.cleaned_data["file"])
424
- imported = import_transactions_data(data)
425
- self.message_user(request, f"Imported {imported} transactions")
426
- return HttpResponseRedirect("../")
427
- else:
428
- form = TransactionImportForm()
429
- context = self.admin_site.each_context(request)
430
- context["form"] = form
431
- return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
432
-
433
-
434
- class MeterValueDateFilter(admin.SimpleListFilter):
435
- title = "Timestamp"
436
- parameter_name = "timestamp_range"
437
-
438
- def lookups(self, request, model_admin):
439
- return [
440
- ("today", "Today"),
441
- ("7days", "Last 7 days"),
442
- ("30days", "Last 30 days"),
443
- ("older", "Older than 30 days"),
444
- ]
445
-
446
- def queryset(self, request, queryset):
447
- value = self.value()
448
- now = timezone.now()
449
- if value == "today":
450
- start = now.replace(hour=0, minute=0, second=0, microsecond=0)
451
- end = start + timedelta(days=1)
452
- return queryset.filter(timestamp__gte=start, timestamp__lt=end)
453
- if value == "7days":
454
- start = now - timedelta(days=7)
455
- return queryset.filter(timestamp__gte=start)
456
- if value == "30days":
457
- start = now - timedelta(days=30)
458
- return queryset.filter(timestamp__gte=start)
459
- if value == "older":
460
- cutoff = now - timedelta(days=30)
461
- return queryset.filter(timestamp__lt=cutoff)
462
- return queryset
463
-
464
-
465
- @admin.register(MeterValue)
466
- class MeterValueAdmin(EntityModelAdmin):
467
- list_display = (
468
- "charger",
469
- "timestamp",
470
- "context",
471
- "energy",
472
- "voltage",
473
- "current_import",
474
- "current_offered",
475
- "temperature",
476
- "soc",
477
- "connector_id",
478
- "transaction",
479
- )
480
- date_hierarchy = "timestamp"
481
- list_filter = ("charger", MeterValueDateFilter)
1
+ from django.contrib import admin, messages
2
+ from django import forms
3
+
4
+ import asyncio
5
+ import base64
6
+ from datetime import datetime, time, timedelta
7
+ import json
8
+ from typing import Any
9
+
10
+ from django.shortcuts import redirect
11
+ from django.utils import formats, timezone, translation
12
+ from django.utils.translation import gettext_lazy as _
13
+ from django.utils.dateparse import parse_datetime
14
+ from django.utils.html import format_html
15
+ from django.urls import path
16
+ from django.http import HttpResponse, HttpResponseRedirect
17
+ from django.template.response import TemplateResponse
18
+
19
+ import uuid
20
+ from asgiref.sync import async_to_sync
21
+ import requests
22
+ from requests import RequestException
23
+ from cryptography.hazmat.primitives import hashes
24
+ from cryptography.hazmat.primitives.asymmetric import padding
25
+ from django.db import transaction
26
+ from django.core.exceptions import ValidationError
27
+
28
+ from .models import (
29
+ Charger,
30
+ ChargerConfiguration,
31
+ Simulator,
32
+ MeterValue,
33
+ Transaction,
34
+ Location,
35
+ DataTransferMessage,
36
+ CPReservation,
37
+ )
38
+ from .simulator import ChargePointSimulator
39
+ from . import store
40
+ from .transactions_io import (
41
+ export_transactions,
42
+ import_transactions as import_transactions_data,
43
+ )
44
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
45
+ from .views import _charger_state, _live_sessions
46
+ from core.admin import SaveBeforeChangeAction
47
+ from core.user_data import EntityModelAdmin
48
+ from nodes.models import Node
49
+
50
+
51
+ class LocationAdminForm(forms.ModelForm):
52
+ class Meta:
53
+ model = Location
54
+ fields = "__all__"
55
+
56
+ widgets = {
57
+ "latitude": forms.NumberInput(attrs={"step": "any"}),
58
+ "longitude": forms.NumberInput(attrs={"step": "any"}),
59
+ }
60
+
61
+ class Media:
62
+ css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
63
+ js = (
64
+ "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
65
+ "ocpp/charger_map.js",
66
+ )
67
+
68
+
69
+ class TransactionExportForm(forms.Form):
70
+ start = forms.DateTimeField(required=False)
71
+ end = forms.DateTimeField(required=False)
72
+ chargers = forms.ModelMultipleChoiceField(
73
+ queryset=Charger.objects.all(), required=False
74
+ )
75
+
76
+
77
+ class TransactionImportForm(forms.Form):
78
+ file = forms.FileField()
79
+
80
+
81
+ class CPReservationForm(forms.ModelForm):
82
+ class Meta:
83
+ model = CPReservation
84
+ fields = [
85
+ "location",
86
+ "account",
87
+ "rfid",
88
+ "id_tag",
89
+ "start_time",
90
+ "duration_minutes",
91
+ ]
92
+
93
+ def clean(self):
94
+ cleaned = super().clean()
95
+ instance = self.instance
96
+ for field in self.Meta.fields:
97
+ if field in cleaned:
98
+ setattr(instance, field, cleaned[field])
99
+ try:
100
+ instance.allocate_connector(force=bool(instance.pk))
101
+ except ValidationError as exc:
102
+ if exc.message_dict:
103
+ for field, errors in exc.message_dict.items():
104
+ for error in errors:
105
+ self.add_error(field, error)
106
+ raise forms.ValidationError(
107
+ _("Unable to allocate a connector for the selected time window.")
108
+ )
109
+ raise forms.ValidationError(exc.messages or [str(exc)])
110
+ if not instance.id_tag_value:
111
+ message = _("Select an RFID or provide an idTag for the reservation.")
112
+ self.add_error("id_tag", message)
113
+ self.add_error("rfid", message)
114
+ raise forms.ValidationError(message)
115
+ return cleaned
116
+
117
+
118
+ class LogViewAdminMixin:
119
+ """Mixin providing an admin view to display charger or simulator logs."""
120
+
121
+ log_type = "charger"
122
+ log_template_name = "admin/ocpp/log_view.html"
123
+
124
+ def get_log_identifier(self, obj): # pragma: no cover - mixin hook
125
+ raise NotImplementedError
126
+
127
+ def get_log_title(self, obj):
128
+ return f"Log for {obj}"
129
+
130
+ def get_urls(self):
131
+ urls = super().get_urls()
132
+ info = self.model._meta.app_label, self.model._meta.model_name
133
+ custom = [
134
+ path(
135
+ "<path:object_id>/log/",
136
+ self.admin_site.admin_view(self.log_view),
137
+ name=f"{info[0]}_{info[1]}_log",
138
+ ),
139
+ ]
140
+ return custom + urls
141
+
142
+ def log_view(self, request, object_id):
143
+ obj = self.get_object(request, object_id)
144
+ if obj is None:
145
+ self.message_user(request, "Log is not available.", messages.ERROR)
146
+ return redirect("..")
147
+ identifier = self.get_log_identifier(obj)
148
+ log_entries = store.get_logs(identifier, log_type=self.log_type)
149
+ log_file = store._file_path(identifier, log_type=self.log_type)
150
+ context = {
151
+ **self.admin_site.each_context(request),
152
+ "opts": self.model._meta,
153
+ "original": obj,
154
+ "title": self.get_log_title(obj),
155
+ "log_entries": log_entries,
156
+ "log_file": str(log_file),
157
+ "log_identifier": identifier,
158
+ }
159
+ return TemplateResponse(request, self.log_template_name, context)
160
+
161
+
162
+ @admin.register(ChargerConfiguration)
163
+ class ChargerConfigurationAdmin(admin.ModelAdmin):
164
+ list_display = (
165
+ "charger_identifier",
166
+ "connector_display",
167
+ "origin_display",
168
+ "created_at",
169
+ )
170
+ list_filter = ("connector_id",)
171
+ search_fields = ("charger_identifier",)
172
+ readonly_fields = (
173
+ "charger_identifier",
174
+ "connector_id",
175
+ "origin_display",
176
+ "evcs_snapshot_at",
177
+ "created_at",
178
+ "updated_at",
179
+ "linked_chargers",
180
+ "configuration_keys_display",
181
+ "unknown_keys_display",
182
+ "raw_payload_display",
183
+ )
184
+ fieldsets = (
185
+ (
186
+ None,
187
+ {
188
+ "fields": (
189
+ "charger_identifier",
190
+ "connector_id",
191
+ "origin_display",
192
+ "evcs_snapshot_at",
193
+ "linked_chargers",
194
+ "created_at",
195
+ "updated_at",
196
+ )
197
+ },
198
+ ),
199
+ (
200
+ "Payload",
201
+ {
202
+ "fields": (
203
+ "configuration_keys_display",
204
+ "unknown_keys_display",
205
+ "raw_payload_display",
206
+ )
207
+ },
208
+ ),
209
+ )
210
+
211
+ @admin.display(description="Connector")
212
+ def connector_display(self, obj):
213
+ if obj.connector_id is None:
214
+ return "All"
215
+ return obj.connector_id
216
+
217
+ @admin.display(description="Linked charge points")
218
+ def linked_chargers(self, obj):
219
+ if obj.pk is None:
220
+ return ""
221
+ linked = [charger.identity_slug() for charger in obj.chargers.all()]
222
+ if not linked:
223
+ return "-"
224
+ return ", ".join(sorted(linked))
225
+
226
+ def _render_json(self, data):
227
+ from django.utils.html import format_html
228
+
229
+ if not data:
230
+ return "-"
231
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
232
+ return format_html("<pre>{}</pre>", formatted)
233
+
234
+ @admin.display(description="configurationKey")
235
+ def configuration_keys_display(self, obj):
236
+ return self._render_json(obj.configuration_keys)
237
+
238
+ @admin.display(description="unknownKey")
239
+ def unknown_keys_display(self, obj):
240
+ return self._render_json(obj.unknown_keys)
241
+
242
+ @admin.display(description="Raw payload")
243
+ def raw_payload_display(self, obj):
244
+ return self._render_json(obj.raw_payload)
245
+
246
+ @admin.display(description="Origin")
247
+ def origin_display(self, obj):
248
+ if obj.evcs_snapshot_at:
249
+ return "EVCS"
250
+ return "Local"
251
+
252
+ def save_model(self, request, obj, form, change):
253
+ obj.evcs_snapshot_at = None
254
+ super().save_model(request, obj, form, change)
255
+
256
+
257
+ @admin.register(Location)
258
+ class LocationAdmin(EntityModelAdmin):
259
+ form = LocationAdminForm
260
+ list_display = ("name", "zone", "contract_type", "latitude", "longitude")
261
+ change_form_template = "admin/ocpp/location/change_form.html"
262
+ search_fields = ("name",)
263
+
264
+
265
+ @admin.register(DataTransferMessage)
266
+ class DataTransferMessageAdmin(admin.ModelAdmin):
267
+ list_display = (
268
+ "charger",
269
+ "connector_id",
270
+ "direction",
271
+ "vendor_id",
272
+ "message_id",
273
+ "status",
274
+ "created_at",
275
+ "responded_at",
276
+ )
277
+ list_filter = ("direction", "status")
278
+ search_fields = (
279
+ "charger__charger_id",
280
+ "ocpp_message_id",
281
+ "vendor_id",
282
+ "message_id",
283
+ )
284
+ readonly_fields = (
285
+ "charger",
286
+ "connector_id",
287
+ "direction",
288
+ "ocpp_message_id",
289
+ "vendor_id",
290
+ "message_id",
291
+ "payload",
292
+ "status",
293
+ "response_data",
294
+ "error_code",
295
+ "error_description",
296
+ "error_details",
297
+ "responded_at",
298
+ "created_at",
299
+ "updated_at",
300
+ )
301
+
302
+
303
+ @admin.register(CPReservation)
304
+ class CPReservationAdmin(EntityModelAdmin):
305
+ form = CPReservationForm
306
+ list_display = (
307
+ "location",
308
+ "connector_side_display",
309
+ "start_time",
310
+ "end_time_display",
311
+ "account",
312
+ "id_tag_display",
313
+ "evcs_status",
314
+ "evcs_confirmed",
315
+ )
316
+ list_filter = ("location", "evcs_confirmed")
317
+ search_fields = (
318
+ "location__name",
319
+ "connector__charger_id",
320
+ "connector__display_name",
321
+ "account__name",
322
+ "id_tag",
323
+ "rfid__rfid",
324
+ )
325
+ date_hierarchy = "start_time"
326
+ ordering = ("-start_time",)
327
+ autocomplete_fields = ("location", "account", "rfid")
328
+ readonly_fields = (
329
+ "connector_identity",
330
+ "connector_side_display",
331
+ "evcs_status",
332
+ "evcs_error",
333
+ "evcs_confirmed",
334
+ "evcs_confirmed_at",
335
+ "ocpp_message_id",
336
+ "created_on",
337
+ "updated_on",
338
+ )
339
+ fieldsets = (
340
+ (
341
+ None,
342
+ {
343
+ "fields": (
344
+ "location",
345
+ "account",
346
+ "rfid",
347
+ "id_tag",
348
+ "start_time",
349
+ "duration_minutes",
350
+ )
351
+ },
352
+ ),
353
+ (
354
+ _("Assigned connector"),
355
+ {"fields": ("connector_identity", "connector_side_display")},
356
+ ),
357
+ (
358
+ _("EVCS response"),
359
+ {
360
+ "fields": (
361
+ "evcs_confirmed",
362
+ "evcs_status",
363
+ "evcs_confirmed_at",
364
+ "evcs_error",
365
+ "ocpp_message_id",
366
+ )
367
+ },
368
+ ),
369
+ (
370
+ _("Metadata"),
371
+ {"fields": ("created_on", "updated_on")},
372
+ ),
373
+ )
374
+
375
+ def save_model(self, request, obj, form, change):
376
+ trigger_fields = {
377
+ "start_time",
378
+ "duration_minutes",
379
+ "location",
380
+ "id_tag",
381
+ "rfid",
382
+ "account",
383
+ }
384
+ changed_data = set(getattr(form, "changed_data", []))
385
+ should_send = not change or bool(trigger_fields.intersection(changed_data))
386
+ with transaction.atomic():
387
+ super().save_model(request, obj, form, change)
388
+ if should_send:
389
+ try:
390
+ obj.send_reservation_request()
391
+ except ValidationError as exc:
392
+ raise ValidationError(exc.message_dict or exc.messages or str(exc))
393
+ else:
394
+ self.message_user(
395
+ request,
396
+ _("Reservation request sent to %(connector)s.")
397
+ % {"connector": self.connector_identity(obj)},
398
+ messages.SUCCESS,
399
+ )
400
+
401
+ @admin.display(description=_("Connector"), ordering="connector__connector_id")
402
+ def connector_side_display(self, obj):
403
+ return obj.connector_label or "-"
404
+
405
+ @admin.display(description=_("Connector identity"))
406
+ def connector_identity(self, obj):
407
+ if obj.connector_id:
408
+ return obj.connector.identity_slug()
409
+ return "-"
410
+
411
+ @admin.display(description=_("End time"))
412
+ def end_time_display(self, obj):
413
+ try:
414
+ value = timezone.localtime(obj.end_time)
415
+ except Exception:
416
+ value = obj.end_time
417
+ if not value:
418
+ return "-"
419
+ return formats.date_format(value, "DATETIME_FORMAT")
420
+
421
+ @admin.display(description=_("Id tag"))
422
+ def id_tag_display(self, obj):
423
+ value = obj.id_tag_value
424
+ return value or "-"
425
+
426
+
427
+ @admin.register(Charger)
428
+ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
+ _REMOTE_DATETIME_FIELDS = {
430
+ "availability_state_updated_at",
431
+ "availability_requested_at",
432
+ "availability_request_status_at",
433
+ "last_online_at",
434
+ }
435
+
436
+ fieldsets = (
437
+ (
438
+ "General",
439
+ {
440
+ "fields": (
441
+ "charger_id",
442
+ "display_name",
443
+ "connector_id",
444
+ "language",
445
+ "location",
446
+ "last_path",
447
+ "last_heartbeat",
448
+ "last_meter_values",
449
+ )
450
+ },
451
+ ),
452
+ (
453
+ "Firmware",
454
+ {
455
+ "fields": (
456
+ "firmware_status",
457
+ "firmware_status_info",
458
+ "firmware_timestamp",
459
+ )
460
+ },
461
+ ),
462
+ (
463
+ "Diagnostics",
464
+ {
465
+ "fields": (
466
+ "diagnostics_status",
467
+ "diagnostics_timestamp",
468
+ "diagnostics_location",
469
+ )
470
+ },
471
+ ),
472
+ (
473
+ "Availability",
474
+ {
475
+ "fields": (
476
+ "availability_state",
477
+ "availability_state_updated_at",
478
+ "availability_requested_state",
479
+ "availability_requested_at",
480
+ "availability_request_status",
481
+ "availability_request_status_at",
482
+ "availability_request_details",
483
+ )
484
+ },
485
+ ),
486
+ (
487
+ "Configuration",
488
+ {"fields": ("public_display", "require_rfid", "configuration")},
489
+ ),
490
+ (
491
+ "Network",
492
+ {
493
+ "fields": (
494
+ "node_origin",
495
+ "manager_node",
496
+ "forwarded_to",
497
+ "forwarding_watermark",
498
+ "allow_remote",
499
+ "export_transactions",
500
+ "last_online_at",
501
+ )
502
+ },
503
+ ),
504
+ (
505
+ "References",
506
+ {
507
+ "fields": ("reference",),
508
+ },
509
+ ),
510
+ (
511
+ "Owner",
512
+ {
513
+ "fields": ("owner_users", "owner_groups"),
514
+ "classes": ("collapse",),
515
+ },
516
+ ),
517
+ )
518
+ readonly_fields = (
519
+ "last_heartbeat",
520
+ "last_meter_values",
521
+ "firmware_status",
522
+ "firmware_status_info",
523
+ "firmware_timestamp",
524
+ "availability_state",
525
+ "availability_state_updated_at",
526
+ "availability_requested_state",
527
+ "availability_requested_at",
528
+ "availability_request_status",
529
+ "availability_request_status_at",
530
+ "availability_request_details",
531
+ "configuration",
532
+ "forwarded_to",
533
+ "forwarding_watermark",
534
+ "last_online_at",
535
+ )
536
+ list_display = (
537
+ "display_name_with_fallback",
538
+ "connector_number",
539
+ "charger_name_display",
540
+ "local_indicator",
541
+ "require_rfid_display",
542
+ "public_display",
543
+ "last_heartbeat",
544
+ "today_kw",
545
+ "total_kw_display",
546
+ "page_link",
547
+ "log_link",
548
+ "status_link",
549
+ )
550
+ search_fields = ("charger_id", "connector_id", "location__name")
551
+ filter_horizontal = ("owner_users", "owner_groups")
552
+ actions = [
553
+ "purge_data",
554
+ "fetch_cp_configuration",
555
+ "toggle_rfid_authentication",
556
+ "recheck_charger_status",
557
+ "change_availability_operative",
558
+ "change_availability_inoperative",
559
+ "set_availability_state_operative",
560
+ "set_availability_state_inoperative",
561
+ "remote_stop_transaction",
562
+ "reset_chargers",
563
+ "delete_selected",
564
+ ]
565
+
566
+ def _prepare_remote_credentials(self, request):
567
+ local = Node.get_local()
568
+ if not local or not local.uuid:
569
+ self.message_user(
570
+ request,
571
+ "Local node is not registered; remote actions are unavailable.",
572
+ level=messages.ERROR,
573
+ )
574
+ return None, None
575
+ private_key = local.get_private_key()
576
+ if private_key is None:
577
+ self.message_user(
578
+ request,
579
+ "Local node private key is unavailable; remote actions are disabled.",
580
+ level=messages.ERROR,
581
+ )
582
+ return None, None
583
+ return local, private_key
584
+
585
+ def _call_remote_action(
586
+ self,
587
+ request,
588
+ local_node: Node,
589
+ private_key,
590
+ charger: Charger,
591
+ action: str,
592
+ extra: dict[str, Any] | None = None,
593
+ ) -> tuple[bool, dict[str, Any]]:
594
+ if not charger.node_origin:
595
+ self.message_user(
596
+ request,
597
+ f"{charger}: remote node information is missing.",
598
+ level=messages.ERROR,
599
+ )
600
+ return False, {}
601
+ origin = charger.node_origin
602
+ if not origin.port:
603
+ self.message_user(
604
+ request,
605
+ f"{charger}: remote node port is not configured.",
606
+ level=messages.ERROR,
607
+ )
608
+ return False, {}
609
+
610
+ if not origin.get_remote_host_candidates():
611
+ self.message_user(
612
+ request,
613
+ f"{charger}: remote node connection details are incomplete.",
614
+ level=messages.ERROR,
615
+ )
616
+ return False, {}
617
+
618
+ payload: dict[str, Any] = {
619
+ "requester": str(local_node.uuid),
620
+ "requester_mac": local_node.mac_address,
621
+ "requester_public_key": local_node.public_key,
622
+ "charger_id": charger.charger_id,
623
+ "connector_id": charger.connector_id,
624
+ "action": action,
625
+ }
626
+ if extra:
627
+ payload.update(extra)
628
+
629
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
630
+ headers = {"Content-Type": "application/json"}
631
+ try:
632
+ signature = private_key.sign(
633
+ payload_json.encode(),
634
+ padding.PKCS1v15(),
635
+ hashes.SHA256(),
636
+ )
637
+ headers["X-Signature"] = base64.b64encode(signature).decode()
638
+ except Exception:
639
+ self.message_user(
640
+ request,
641
+ "Unable to sign remote action payload; remote action aborted.",
642
+ level=messages.ERROR,
643
+ )
644
+ return False, {}
645
+
646
+ url = next(
647
+ origin.iter_remote_urls("/nodes/network/chargers/action/"),
648
+ "",
649
+ )
650
+ if not url:
651
+ self.message_user(
652
+ request,
653
+ f"{charger}: no reachable hosts were reported for the remote node.",
654
+ level=messages.ERROR,
655
+ )
656
+ return False, {}
657
+ try:
658
+ response = requests.post(url, data=payload_json, headers=headers, timeout=5)
659
+ except RequestException as exc:
660
+ self.message_user(
661
+ request,
662
+ f"{charger}: failed to contact remote node ({exc}).",
663
+ level=messages.ERROR,
664
+ )
665
+ return False, {}
666
+
667
+ try:
668
+ data = response.json()
669
+ except ValueError:
670
+ self.message_user(
671
+ request,
672
+ f"{charger}: invalid response from remote node.",
673
+ level=messages.ERROR,
674
+ )
675
+ return False, {}
676
+
677
+ if response.status_code != 200 or data.get("status") != "ok":
678
+ detail = data.get("detail") if isinstance(data, dict) else None
679
+ if not detail:
680
+ detail = response.text or "Remote node rejected the request."
681
+ self.message_user(
682
+ request,
683
+ f"{charger}: {detail}",
684
+ level=messages.ERROR,
685
+ )
686
+ return False, {}
687
+
688
+ updates = data.get("updates", {}) if isinstance(data, dict) else {}
689
+ if not isinstance(updates, dict):
690
+ updates = {}
691
+ return True, updates
692
+
693
+ def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
694
+ if not updates:
695
+ return
696
+
697
+ applied: dict[str, Any] = {}
698
+ for field, value in updates.items():
699
+ if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
700
+ parsed = parse_datetime(value)
701
+ if parsed and timezone.is_naive(parsed):
702
+ parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
703
+ applied[field] = parsed
704
+ else:
705
+ applied[field] = value
706
+
707
+ Charger.objects.filter(pk=charger.pk).update(**applied)
708
+ for field, value in applied.items():
709
+ setattr(charger, field, value)
710
+
711
+ def get_readonly_fields(self, request, obj=None):
712
+ readonly = list(super().get_readonly_fields(request, obj))
713
+ if obj and not obj.is_local:
714
+ for field in ("allow_remote", "export_transactions"):
715
+ if field not in readonly:
716
+ readonly.append(field)
717
+ return tuple(readonly)
718
+
719
+ def get_view_on_site_url(self, obj=None):
720
+ return obj.get_absolute_url() if obj else None
721
+
722
+ def require_rfid_display(self, obj):
723
+ return obj.require_rfid
724
+
725
+ require_rfid_display.boolean = True
726
+ require_rfid_display.short_description = "RFID Auth"
727
+
728
+ def page_link(self, obj):
729
+ from django.utils.html import format_html
730
+
731
+ return format_html(
732
+ '<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
733
+ )
734
+
735
+ page_link.short_description = "Landing"
736
+
737
+ def qr_link(self, obj):
738
+ from django.utils.html import format_html
739
+
740
+ if obj.reference and obj.reference.image:
741
+ return format_html(
742
+ '<a href="{}" target="_blank">qr</a>', obj.reference.image.url
743
+ )
744
+ return ""
745
+
746
+ qr_link.short_description = "QR Code"
747
+
748
+ def log_link(self, obj):
749
+ from django.utils.html import format_html
750
+ from django.urls import reverse
751
+
752
+ url = reverse("admin:ocpp_charger_log", args=[obj.pk])
753
+ return format_html('<a href="{}" target="_blank">view</a>', url)
754
+
755
+ log_link.short_description = "Log"
756
+
757
+ def get_log_identifier(self, obj):
758
+ return store.identity_key(obj.charger_id, obj.connector_id)
759
+
760
+ def connector_number(self, obj):
761
+ return obj.connector_id if obj.connector_id is not None else ""
762
+
763
+ connector_number.short_description = "#"
764
+ connector_number.admin_order_field = "connector_id"
765
+
766
+ def status_link(self, obj):
767
+ from django.utils.html import format_html
768
+ from django.urls import reverse
769
+
770
+ url = reverse(
771
+ "charger-status-connector",
772
+ args=[obj.charger_id, obj.connector_slug],
773
+ )
774
+ tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
775
+ state, _ = _charger_state(
776
+ obj,
777
+ tx_obj
778
+ if obj.connector_id is not None
779
+ else (_live_sessions(obj) or None),
780
+ )
781
+ return format_html('<a href="{}" target="_blank">{}</a>', url, state)
782
+
783
+ status_link.short_description = "Status"
784
+
785
+ def _has_active_session(self, charger: Charger) -> bool:
786
+ """Return whether ``charger`` currently has an active session."""
787
+
788
+ if store.get_transaction(charger.charger_id, charger.connector_id):
789
+ return True
790
+ if charger.connector_id is not None:
791
+ return False
792
+ sibling_connectors = (
793
+ Charger.objects.filter(charger_id=charger.charger_id)
794
+ .exclude(pk=charger.pk)
795
+ .values_list("connector_id", flat=True)
796
+ )
797
+ for connector_id in sibling_connectors:
798
+ if store.get_transaction(charger.charger_id, connector_id):
799
+ return True
800
+ return False
801
+
802
+ @admin.display(description="Display Name", ordering="display_name")
803
+ def display_name_with_fallback(self, obj):
804
+ return self._charger_display_name(obj)
805
+
806
+ @admin.display(description="Charger", ordering="display_name")
807
+ def charger_name_display(self, obj):
808
+ return self._charger_display_name(obj)
809
+
810
+ def _charger_display_name(self, obj):
811
+ if obj.display_name:
812
+ return obj.display_name
813
+ if obj.location:
814
+ return obj.location.name
815
+ return obj.charger_id
816
+
817
+ @admin.display(boolean=True, description="Local")
818
+ def local_indicator(self, obj):
819
+ return obj.is_local
820
+
821
+ def location_name(self, obj):
822
+ return obj.location.name if obj.location else ""
823
+
824
+ location_name.short_description = "Location"
825
+
826
+ def purge_data(self, request, queryset):
827
+ for charger in queryset:
828
+ charger.purge()
829
+ self.message_user(request, "Data purged for selected chargers")
830
+
831
+ purge_data.short_description = "Purge data"
832
+
833
+ @admin.action(description="Re-check Charger Status")
834
+ def recheck_charger_status(self, request, queryset):
835
+ requested = 0
836
+ for charger in queryset:
837
+ connector_value = charger.connector_id
838
+ ws = store.get_connection(charger.charger_id, connector_value)
839
+ if ws is None:
840
+ self.message_user(
841
+ request,
842
+ f"{charger}: no active connection",
843
+ level=messages.ERROR,
844
+ )
845
+ continue
846
+ payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
847
+ trigger_connector: int | None = None
848
+ if connector_value is not None:
849
+ payload["connectorId"] = connector_value
850
+ trigger_connector = connector_value
851
+ message_id = uuid.uuid4().hex
852
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
853
+ try:
854
+ async_to_sync(ws.send)(msg)
855
+ except Exception as exc: # pragma: no cover - network error
856
+ self.message_user(
857
+ request,
858
+ f"{charger}: failed to send TriggerMessage ({exc})",
859
+ level=messages.ERROR,
860
+ )
861
+ continue
862
+ log_key = store.identity_key(charger.charger_id, connector_value)
863
+ store.add_log(log_key, f"< {msg}", log_type="charger")
864
+ store.register_pending_call(
865
+ message_id,
866
+ {
867
+ "action": "TriggerMessage",
868
+ "charger_id": charger.charger_id,
869
+ "connector_id": connector_value,
870
+ "log_key": log_key,
871
+ "trigger_target": "StatusNotification",
872
+ "trigger_connector": trigger_connector,
873
+ "requested_at": timezone.now(),
874
+ },
875
+ )
876
+ store.schedule_call_timeout(
877
+ message_id,
878
+ timeout=5.0,
879
+ action="TriggerMessage",
880
+ log_key=log_key,
881
+ message="TriggerMessage StatusNotification timed out",
882
+ )
883
+ requested += 1
884
+ if requested:
885
+ self.message_user(
886
+ request,
887
+ f"Requested status update from {requested} charger(s)",
888
+ )
889
+
890
+ @admin.action(description="Fetch CP configuration")
891
+ def fetch_cp_configuration(self, request, queryset):
892
+ fetched = 0
893
+ local_node = None
894
+ private_key = None
895
+ remote_unavailable = False
896
+ for charger in queryset:
897
+ if charger.is_local:
898
+ connector_value = charger.connector_id
899
+ ws = store.get_connection(charger.charger_id, connector_value)
900
+ if ws is None:
901
+ self.message_user(
902
+ request,
903
+ f"{charger}: no active connection",
904
+ level=messages.ERROR,
905
+ )
906
+ continue
907
+ message_id = uuid.uuid4().hex
908
+ payload = {}
909
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
910
+ try:
911
+ async_to_sync(ws.send)(msg)
912
+ except Exception as exc: # pragma: no cover - network error
913
+ self.message_user(
914
+ request,
915
+ f"{charger}: failed to send GetConfiguration ({exc})",
916
+ level=messages.ERROR,
917
+ )
918
+ continue
919
+ log_key = store.identity_key(charger.charger_id, connector_value)
920
+ store.add_log(log_key, f"< {msg}", log_type="charger")
921
+ store.register_pending_call(
922
+ message_id,
923
+ {
924
+ "action": "GetConfiguration",
925
+ "charger_id": charger.charger_id,
926
+ "connector_id": connector_value,
927
+ "log_key": log_key,
928
+ "requested_at": timezone.now(),
929
+ },
930
+ )
931
+ store.schedule_call_timeout(
932
+ message_id,
933
+ timeout=5.0,
934
+ action="GetConfiguration",
935
+ log_key=log_key,
936
+ message=(
937
+ "GetConfiguration timed out: charger did not respond"
938
+ " (operation may not be supported)"
939
+ ),
940
+ )
941
+ fetched += 1
942
+ continue
943
+
944
+ if not charger.allow_remote:
945
+ self.message_user(
946
+ request,
947
+ f"{charger}: remote administration is disabled.",
948
+ level=messages.ERROR,
949
+ )
950
+ continue
951
+ if remote_unavailable:
952
+ continue
953
+ if local_node is None:
954
+ local_node, private_key = self._prepare_remote_credentials(request)
955
+ if not local_node or not private_key:
956
+ remote_unavailable = True
957
+ continue
958
+ success, updates = self._call_remote_action(
959
+ request,
960
+ local_node,
961
+ private_key,
962
+ charger,
963
+ "get-configuration",
964
+ )
965
+ if success:
966
+ self._apply_remote_updates(charger, updates)
967
+ fetched += 1
968
+
969
+ if fetched:
970
+ self.message_user(
971
+ request,
972
+ f"Requested configuration from {fetched} charger(s)",
973
+ )
974
+
975
+ @admin.action(description="Toggle RFID Authentication")
976
+ def toggle_rfid_authentication(self, request, queryset):
977
+ enabled = 0
978
+ disabled = 0
979
+ local_node = None
980
+ private_key = None
981
+ remote_unavailable = False
982
+ for charger in queryset:
983
+ new_value = not charger.require_rfid
984
+ if charger.is_local:
985
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
986
+ charger.require_rfid = new_value
987
+ if new_value:
988
+ enabled += 1
989
+ else:
990
+ disabled += 1
991
+ continue
992
+
993
+ if not charger.allow_remote:
994
+ self.message_user(
995
+ request,
996
+ f"{charger}: remote administration is disabled.",
997
+ level=messages.ERROR,
998
+ )
999
+ continue
1000
+ if remote_unavailable:
1001
+ continue
1002
+ if local_node is None:
1003
+ local_node, private_key = self._prepare_remote_credentials(request)
1004
+ if not local_node or not private_key:
1005
+ remote_unavailable = True
1006
+ continue
1007
+ success, updates = self._call_remote_action(
1008
+ request,
1009
+ local_node,
1010
+ private_key,
1011
+ charger,
1012
+ "toggle-rfid",
1013
+ {"enable": new_value},
1014
+ )
1015
+ if success:
1016
+ self._apply_remote_updates(charger, updates)
1017
+ if charger.require_rfid:
1018
+ enabled += 1
1019
+ else:
1020
+ disabled += 1
1021
+
1022
+ if enabled or disabled:
1023
+ changes = []
1024
+ if enabled:
1025
+ changes.append(f"enabled for {enabled} charger(s)")
1026
+ if disabled:
1027
+ changes.append(f"disabled for {disabled} charger(s)")
1028
+ summary = "; ".join(changes)
1029
+ self.message_user(
1030
+ request,
1031
+ f"Updated RFID authentication: {summary}",
1032
+ )
1033
+
1034
+ def _dispatch_change_availability(self, request, queryset, availability_type: str):
1035
+ sent = 0
1036
+ local_node = None
1037
+ private_key = None
1038
+ remote_unavailable = False
1039
+ for charger in queryset:
1040
+ if charger.is_local:
1041
+ connector_value = charger.connector_id
1042
+ ws = store.get_connection(charger.charger_id, connector_value)
1043
+ if ws is None:
1044
+ self.message_user(
1045
+ request,
1046
+ f"{charger}: no active connection",
1047
+ level=messages.ERROR,
1048
+ )
1049
+ continue
1050
+ connector_id = connector_value if connector_value is not None else 0
1051
+ message_id = uuid.uuid4().hex
1052
+ payload = {"connectorId": connector_id, "type": availability_type}
1053
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1054
+ try:
1055
+ async_to_sync(ws.send)(msg)
1056
+ except Exception as exc: # pragma: no cover - network error
1057
+ self.message_user(
1058
+ request,
1059
+ f"{charger}: failed to send ChangeAvailability ({exc})",
1060
+ level=messages.ERROR,
1061
+ )
1062
+ continue
1063
+ log_key = store.identity_key(charger.charger_id, connector_value)
1064
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1065
+ timestamp = timezone.now()
1066
+ store.register_pending_call(
1067
+ message_id,
1068
+ {
1069
+ "action": "ChangeAvailability",
1070
+ "charger_id": charger.charger_id,
1071
+ "connector_id": connector_value,
1072
+ "availability_type": availability_type,
1073
+ "requested_at": timestamp,
1074
+ },
1075
+ )
1076
+ updates = {
1077
+ "availability_requested_state": availability_type,
1078
+ "availability_requested_at": timestamp,
1079
+ "availability_request_status": "",
1080
+ "availability_request_status_at": None,
1081
+ "availability_request_details": "",
1082
+ }
1083
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1084
+ for field, value in updates.items():
1085
+ setattr(charger, field, value)
1086
+ sent += 1
1087
+ continue
1088
+
1089
+ if not charger.allow_remote:
1090
+ self.message_user(
1091
+ request,
1092
+ f"{charger}: remote administration is disabled.",
1093
+ level=messages.ERROR,
1094
+ )
1095
+ continue
1096
+ if remote_unavailable:
1097
+ continue
1098
+ if local_node is None:
1099
+ local_node, private_key = self._prepare_remote_credentials(request)
1100
+ if not local_node or not private_key:
1101
+ remote_unavailable = True
1102
+ continue
1103
+ success, updates = self._call_remote_action(
1104
+ request,
1105
+ local_node,
1106
+ private_key,
1107
+ charger,
1108
+ "change-availability",
1109
+ {"availability_type": availability_type},
1110
+ )
1111
+ if success:
1112
+ self._apply_remote_updates(charger, updates)
1113
+ sent += 1
1114
+
1115
+ if sent:
1116
+ self.message_user(
1117
+ request,
1118
+ f"Sent ChangeAvailability ({availability_type}) to {sent} charger(s)",
1119
+ )
1120
+
1121
+ @admin.action(description="Set availability to Operative")
1122
+ def change_availability_operative(self, request, queryset):
1123
+ self._dispatch_change_availability(request, queryset, "Operative")
1124
+
1125
+ @admin.action(description="Set availability to Inoperative")
1126
+ def change_availability_inoperative(self, request, queryset):
1127
+ self._dispatch_change_availability(request, queryset, "Inoperative")
1128
+
1129
+ def _set_availability_state(
1130
+ self, request, queryset, availability_state: str
1131
+ ) -> None:
1132
+ updated = 0
1133
+ local_node = None
1134
+ private_key = None
1135
+ remote_unavailable = False
1136
+ for charger in queryset:
1137
+ if charger.is_local:
1138
+ timestamp = timezone.now()
1139
+ updates = {
1140
+ "availability_state": availability_state,
1141
+ "availability_state_updated_at": timestamp,
1142
+ }
1143
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1144
+ for field, value in updates.items():
1145
+ setattr(charger, field, value)
1146
+ updated += 1
1147
+ continue
1148
+
1149
+ if not charger.allow_remote:
1150
+ self.message_user(
1151
+ request,
1152
+ f"{charger}: remote administration is disabled.",
1153
+ level=messages.ERROR,
1154
+ )
1155
+ continue
1156
+ if remote_unavailable:
1157
+ continue
1158
+ if local_node is None:
1159
+ local_node, private_key = self._prepare_remote_credentials(request)
1160
+ if not local_node or not private_key:
1161
+ remote_unavailable = True
1162
+ continue
1163
+ success, updates = self._call_remote_action(
1164
+ request,
1165
+ local_node,
1166
+ private_key,
1167
+ charger,
1168
+ "set-availability-state",
1169
+ {"availability_state": availability_state},
1170
+ )
1171
+ if success:
1172
+ self._apply_remote_updates(charger, updates)
1173
+ updated += 1
1174
+
1175
+ if updated:
1176
+ self.message_user(
1177
+ request,
1178
+ f"Updated availability to {availability_state} for {updated} charger(s)",
1179
+ )
1180
+
1181
+ @admin.action(description="Mark availability as Operative")
1182
+ def set_availability_state_operative(self, request, queryset):
1183
+ self._set_availability_state(request, queryset, "Operative")
1184
+
1185
+ @admin.action(description="Mark availability as Inoperative")
1186
+ def set_availability_state_inoperative(self, request, queryset):
1187
+ self._set_availability_state(request, queryset, "Inoperative")
1188
+
1189
+ @admin.action(description="Remote stop active transaction")
1190
+ def remote_stop_transaction(self, request, queryset):
1191
+ stopped = 0
1192
+ local_node = None
1193
+ private_key = None
1194
+ remote_unavailable = False
1195
+ for charger in queryset:
1196
+ if charger.is_local:
1197
+ connector_value = charger.connector_id
1198
+ ws = store.get_connection(charger.charger_id, connector_value)
1199
+ if ws is None:
1200
+ self.message_user(
1201
+ request,
1202
+ f"{charger}: no active connection",
1203
+ level=messages.ERROR,
1204
+ )
1205
+ continue
1206
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1207
+ if tx_obj is None:
1208
+ self.message_user(
1209
+ request,
1210
+ f"{charger}: no active transaction",
1211
+ level=messages.ERROR,
1212
+ )
1213
+ continue
1214
+ message_id = uuid.uuid4().hex
1215
+ payload = {"transactionId": tx_obj.pk}
1216
+ msg = json.dumps([
1217
+ 2,
1218
+ message_id,
1219
+ "RemoteStopTransaction",
1220
+ payload,
1221
+ ])
1222
+ try:
1223
+ async_to_sync(ws.send)(msg)
1224
+ except Exception as exc: # pragma: no cover - network error
1225
+ self.message_user(
1226
+ request,
1227
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
1228
+ level=messages.ERROR,
1229
+ )
1230
+ continue
1231
+ log_key = store.identity_key(charger.charger_id, connector_value)
1232
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1233
+ store.register_pending_call(
1234
+ message_id,
1235
+ {
1236
+ "action": "RemoteStopTransaction",
1237
+ "charger_id": charger.charger_id,
1238
+ "connector_id": connector_value,
1239
+ "transaction_id": tx_obj.pk,
1240
+ "log_key": log_key,
1241
+ "requested_at": timezone.now(),
1242
+ },
1243
+ )
1244
+ stopped += 1
1245
+ continue
1246
+
1247
+ if not charger.allow_remote:
1248
+ self.message_user(
1249
+ request,
1250
+ f"{charger}: remote administration is disabled.",
1251
+ level=messages.ERROR,
1252
+ )
1253
+ continue
1254
+ if remote_unavailable:
1255
+ continue
1256
+ if local_node is None:
1257
+ local_node, private_key = self._prepare_remote_credentials(request)
1258
+ if not local_node or not private_key:
1259
+ remote_unavailable = True
1260
+ continue
1261
+ success, updates = self._call_remote_action(
1262
+ request,
1263
+ local_node,
1264
+ private_key,
1265
+ charger,
1266
+ "remote-stop",
1267
+ )
1268
+ if success:
1269
+ self._apply_remote_updates(charger, updates)
1270
+ stopped += 1
1271
+
1272
+ if stopped:
1273
+ self.message_user(
1274
+ request,
1275
+ f"Sent RemoteStopTransaction to {stopped} charger(s)",
1276
+ )
1277
+
1278
+ @admin.action(description="Reset charger (soft)")
1279
+ def reset_chargers(self, request, queryset):
1280
+ reset = 0
1281
+ local_node = None
1282
+ private_key = None
1283
+ remote_unavailable = False
1284
+ for charger in queryset:
1285
+ if charger.is_local:
1286
+ connector_value = charger.connector_id
1287
+ ws = store.get_connection(charger.charger_id, connector_value)
1288
+ if ws is None:
1289
+ self.message_user(
1290
+ request,
1291
+ f"{charger}: no active connection",
1292
+ level=messages.ERROR,
1293
+ )
1294
+ continue
1295
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1296
+ if tx_obj is not None:
1297
+ self.message_user(
1298
+ request,
1299
+ (
1300
+ f"{charger}: reset skipped because a session is active; "
1301
+ "stop the session first."
1302
+ ),
1303
+ level=messages.WARNING,
1304
+ )
1305
+ continue
1306
+ message_id = uuid.uuid4().hex
1307
+ msg = json.dumps([
1308
+ 2,
1309
+ message_id,
1310
+ "Reset",
1311
+ {"type": "Soft"},
1312
+ ])
1313
+ try:
1314
+ async_to_sync(ws.send)(msg)
1315
+ except Exception as exc: # pragma: no cover - network error
1316
+ self.message_user(
1317
+ request,
1318
+ f"{charger}: failed to send Reset ({exc})",
1319
+ level=messages.ERROR,
1320
+ )
1321
+ continue
1322
+ log_key = store.identity_key(charger.charger_id, connector_value)
1323
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1324
+ store.register_pending_call(
1325
+ message_id,
1326
+ {
1327
+ "action": "Reset",
1328
+ "charger_id": charger.charger_id,
1329
+ "connector_id": connector_value,
1330
+ "log_key": log_key,
1331
+ "requested_at": timezone.now(),
1332
+ },
1333
+ )
1334
+ store.schedule_call_timeout(
1335
+ message_id,
1336
+ timeout=5.0,
1337
+ action="Reset",
1338
+ log_key=log_key,
1339
+ message="Reset timed out: charger did not respond",
1340
+ )
1341
+ reset += 1
1342
+ continue
1343
+
1344
+ if not charger.allow_remote:
1345
+ self.message_user(
1346
+ request,
1347
+ f"{charger}: remote administration is disabled.",
1348
+ level=messages.ERROR,
1349
+ )
1350
+ continue
1351
+ if remote_unavailable:
1352
+ continue
1353
+ if local_node is None:
1354
+ local_node, private_key = self._prepare_remote_credentials(request)
1355
+ if not local_node or not private_key:
1356
+ remote_unavailable = True
1357
+ continue
1358
+ success, updates = self._call_remote_action(
1359
+ request,
1360
+ local_node,
1361
+ private_key,
1362
+ charger,
1363
+ "reset",
1364
+ {"reset_type": "Soft"},
1365
+ )
1366
+ if success:
1367
+ self._apply_remote_updates(charger, updates)
1368
+ reset += 1
1369
+
1370
+ if reset:
1371
+ self.message_user(
1372
+ request,
1373
+ f"Sent Reset to {reset} charger(s)",
1374
+ )
1375
+
1376
+ def delete_queryset(self, request, queryset):
1377
+ for obj in queryset:
1378
+ obj.delete()
1379
+
1380
+ def total_kw_display(self, obj):
1381
+ return round(obj.total_kw, 2)
1382
+
1383
+ total_kw_display.short_description = "Total kW"
1384
+
1385
+ def today_kw(self, obj):
1386
+ start, end = self._today_range()
1387
+ return round(obj.total_kw_for_range(start, end), 2)
1388
+
1389
+ today_kw.short_description = "Today kW"
1390
+
1391
+ def changelist_view(self, request, extra_context=None):
1392
+ response = super().changelist_view(request, extra_context=extra_context)
1393
+ if hasattr(response, "context_data"):
1394
+ cl = response.context_data.get("cl")
1395
+ if cl is not None:
1396
+ response.context_data.update(
1397
+ self._charger_quick_stats_context(cl.queryset)
1398
+ )
1399
+ return response
1400
+
1401
+ def _charger_quick_stats_context(self, queryset):
1402
+ chargers = list(queryset)
1403
+ stats = {"total_kw": 0.0, "today_kw": 0.0}
1404
+ if not chargers:
1405
+ return {"charger_quick_stats": stats}
1406
+
1407
+ parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
1408
+ start, end = self._today_range()
1409
+
1410
+ for charger in chargers:
1411
+ include_totals = True
1412
+ if charger.connector_id is not None and charger.charger_id in parent_ids:
1413
+ include_totals = False
1414
+ if include_totals:
1415
+ stats["total_kw"] += charger.total_kw
1416
+ stats["today_kw"] += charger.total_kw_for_range(start, end)
1417
+
1418
+ stats = {key: round(value, 2) for key, value in stats.items()}
1419
+ return {"charger_quick_stats": stats}
1420
+
1421
+ def _today_range(self):
1422
+ today = timezone.localdate()
1423
+ start = datetime.combine(today, time.min)
1424
+ if timezone.is_naive(start):
1425
+ start = timezone.make_aware(start, timezone.get_current_timezone())
1426
+ end = start + timedelta(days=1)
1427
+ return start, end
1428
+
1429
+
1430
+ @admin.register(Simulator)
1431
+ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
1432
+ list_display = (
1433
+ "name",
1434
+ "cp_path",
1435
+ "host",
1436
+ "ws_port",
1437
+ "ws_url",
1438
+ "interval",
1439
+ "kw_max_display",
1440
+ "running",
1441
+ "log_link",
1442
+ )
1443
+ fieldsets = (
1444
+ (
1445
+ None,
1446
+ {
1447
+ "fields": (
1448
+ "name",
1449
+ "cp_path",
1450
+ ("host", "ws_port"),
1451
+ "rfid",
1452
+ ("duration", "interval", "pre_charge_delay"),
1453
+ "kw_max",
1454
+ ("repeat", "door_open"),
1455
+ ("username", "password"),
1456
+ )
1457
+ },
1458
+ ),
1459
+ (
1460
+ "Configuration",
1461
+ {
1462
+ "fields": (
1463
+ "configuration_keys",
1464
+ "configuration_unknown_keys",
1465
+ ),
1466
+ "classes": ("collapse",),
1467
+ "description": (
1468
+ "Provide JSON lists for configurationKey entries and "
1469
+ "unknownKey values returned by GetConfiguration."
1470
+ ),
1471
+ },
1472
+ ),
1473
+ )
1474
+ actions = ("start_simulator", "stop_simulator", "send_open_door")
1475
+ change_actions = ["start_simulator_action", "stop_simulator_action"]
1476
+
1477
+ log_type = "simulator"
1478
+
1479
+ @admin.display(description="kW Max", ordering="kw_max")
1480
+ def kw_max_display(self, obj):
1481
+ """Display ``kw_max`` with a dot decimal separator for Spanish locales."""
1482
+
1483
+ language = translation.get_language() or ""
1484
+ if language.startswith("es"):
1485
+ return formats.number_format(
1486
+ obj.kw_max,
1487
+ decimal_pos=2,
1488
+ use_l10n=False,
1489
+ force_grouping=False,
1490
+ )
1491
+
1492
+ return formats.number_format(
1493
+ obj.kw_max,
1494
+ decimal_pos=2,
1495
+ use_l10n=True,
1496
+ force_grouping=False,
1497
+ )
1498
+
1499
+ def save_model(self, request, obj, form, change):
1500
+ previous_door_open = False
1501
+ if change and obj.pk:
1502
+ previous_door_open = (
1503
+ type(obj)
1504
+ .objects.filter(pk=obj.pk)
1505
+ .values_list("door_open", flat=True)
1506
+ .first()
1507
+ or False
1508
+ )
1509
+ super().save_model(request, obj, form, change)
1510
+ if obj.door_open and not previous_door_open:
1511
+ triggered = self._queue_door_open(request, obj)
1512
+ if not triggered:
1513
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
1514
+ obj.door_open = False
1515
+
1516
+ def _queue_door_open(self, request, obj) -> bool:
1517
+ sim = store.simulators.get(obj.pk)
1518
+ if not sim:
1519
+ self.message_user(
1520
+ request,
1521
+ f"{obj.name}: simulator is not running",
1522
+ level=messages.ERROR,
1523
+ )
1524
+ return False
1525
+ type(obj).objects.filter(pk=obj.pk).update(door_open=True)
1526
+ obj.door_open = True
1527
+ store.add_log(
1528
+ obj.cp_path,
1529
+ "Door open event requested from admin",
1530
+ log_type="simulator",
1531
+ )
1532
+ if hasattr(sim, "trigger_door_open"):
1533
+ sim.trigger_door_open()
1534
+ else: # pragma: no cover - unexpected condition
1535
+ self.message_user(
1536
+ request,
1537
+ f"{obj.name}: simulator cannot send door open event",
1538
+ level=messages.ERROR,
1539
+ )
1540
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
1541
+ obj.door_open = False
1542
+ return False
1543
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
1544
+ obj.door_open = False
1545
+ self.message_user(
1546
+ request,
1547
+ f"{obj.name}: DoorOpen status notification sent",
1548
+ )
1549
+ return True
1550
+
1551
+ def running(self, obj):
1552
+ return obj.pk in store.simulators
1553
+
1554
+ running.boolean = True
1555
+
1556
+ @admin.action(description="Send Open Door")
1557
+ def send_open_door(self, request, queryset):
1558
+ for obj in queryset:
1559
+ self._queue_door_open(request, obj)
1560
+
1561
+ def start_simulator(self, request, queryset):
1562
+ from django.urls import reverse
1563
+ from django.utils.html import format_html
1564
+
1565
+ for obj in queryset:
1566
+ if obj.pk in store.simulators:
1567
+ self.message_user(request, f"{obj.name}: already running")
1568
+ continue
1569
+ type(obj).objects.filter(pk=obj.pk).update(door_open=False)
1570
+ obj.door_open = False
1571
+ store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
1572
+ sim = ChargePointSimulator(obj.as_config())
1573
+ started, status, log_file = sim.start()
1574
+ if started:
1575
+ store.simulators[obj.pk] = sim
1576
+ log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
1577
+ self.message_user(
1578
+ request,
1579
+ format_html(
1580
+ '{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
1581
+ obj.name,
1582
+ status,
1583
+ log_file,
1584
+ log_url,
1585
+ ),
1586
+ )
1587
+
1588
+ start_simulator.short_description = "Start selected simulators"
1589
+
1590
+ def stop_simulator(self, request, queryset):
1591
+ async def _stop(objs):
1592
+ for obj in objs:
1593
+ sim = store.simulators.pop(obj.pk, None)
1594
+ if sim:
1595
+ await sim.stop()
1596
+
1597
+ objs = list(queryset)
1598
+ try:
1599
+ loop = asyncio.get_running_loop()
1600
+ except RuntimeError:
1601
+ asyncio.run(_stop(objs))
1602
+ else:
1603
+ loop.create_task(_stop(objs))
1604
+ self.message_user(request, "Stopping simulators")
1605
+
1606
+ stop_simulator.short_description = "Stop selected simulators"
1607
+
1608
+ def start_simulator_action(self, request, obj):
1609
+ queryset = type(obj).objects.filter(pk=obj.pk)
1610
+ self.start_simulator(request, queryset)
1611
+
1612
+ def stop_simulator_action(self, request, obj):
1613
+ queryset = type(obj).objects.filter(pk=obj.pk)
1614
+ self.stop_simulator(request, queryset)
1615
+
1616
+ def log_link(self, obj):
1617
+ from django.utils.html import format_html
1618
+ from django.urls import reverse
1619
+
1620
+ url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
1621
+ return format_html('<a href="{}" target="_blank">view</a>', url)
1622
+
1623
+ log_link.short_description = "Log"
1624
+
1625
+ def get_log_identifier(self, obj):
1626
+ return obj.cp_path
1627
+
1628
+
1629
+ class MeterValueInline(admin.TabularInline):
1630
+ model = MeterValue
1631
+ extra = 0
1632
+ fields = (
1633
+ "timestamp",
1634
+ "context",
1635
+ "energy",
1636
+ "voltage",
1637
+ "current_import",
1638
+ "current_offered",
1639
+ "temperature",
1640
+ "soc",
1641
+ "connector_id",
1642
+ )
1643
+ readonly_fields = fields
1644
+ can_delete = False
1645
+
1646
+
1647
+ @admin.register(Transaction)
1648
+ class TransactionAdmin(EntityModelAdmin):
1649
+ change_list_template = "admin/ocpp/transaction/change_list.html"
1650
+ list_display = (
1651
+ "charger",
1652
+ "connector_number",
1653
+ "account",
1654
+ "rfid",
1655
+ "vid",
1656
+ "meter_start",
1657
+ "meter_stop",
1658
+ "start_time",
1659
+ "stop_time",
1660
+ "kw",
1661
+ )
1662
+ readonly_fields = ("kw", "received_start_time", "received_stop_time")
1663
+ list_filter = ("charger", "account")
1664
+ date_hierarchy = "start_time"
1665
+ inlines = [MeterValueInline]
1666
+
1667
+ def connector_number(self, obj):
1668
+ return obj.connector_id or ""
1669
+
1670
+ connector_number.short_description = "#"
1671
+ connector_number.admin_order_field = "connector_id"
1672
+
1673
+ def get_urls(self):
1674
+ urls = super().get_urls()
1675
+ custom = [
1676
+ path(
1677
+ "export/",
1678
+ self.admin_site.admin_view(self.export_view),
1679
+ name="ocpp_transaction_export",
1680
+ ),
1681
+ path(
1682
+ "import/",
1683
+ self.admin_site.admin_view(self.import_view),
1684
+ name="ocpp_transaction_import",
1685
+ ),
1686
+ ]
1687
+ return custom + urls
1688
+
1689
+ def export_view(self, request):
1690
+ if request.method == "POST":
1691
+ form = TransactionExportForm(request.POST)
1692
+ if form.is_valid():
1693
+ chargers = form.cleaned_data["chargers"]
1694
+ data = export_transactions(
1695
+ start=form.cleaned_data["start"],
1696
+ end=form.cleaned_data["end"],
1697
+ chargers=[c.charger_id for c in chargers] if chargers else None,
1698
+ )
1699
+ response = HttpResponse(
1700
+ json.dumps(data, indent=2, ensure_ascii=False),
1701
+ content_type="application/json",
1702
+ )
1703
+ response["Content-Disposition"] = (
1704
+ "attachment; filename=transactions.json"
1705
+ )
1706
+ return response
1707
+ else:
1708
+ form = TransactionExportForm()
1709
+ context = self.admin_site.each_context(request)
1710
+ context["form"] = form
1711
+ return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
1712
+
1713
+ def import_view(self, request):
1714
+ if request.method == "POST":
1715
+ form = TransactionImportForm(request.POST, request.FILES)
1716
+ if form.is_valid():
1717
+ data = json.load(form.cleaned_data["file"])
1718
+ imported = import_transactions_data(data)
1719
+ self.message_user(request, f"Imported {imported} transactions")
1720
+ return HttpResponseRedirect("../")
1721
+ else:
1722
+ form = TransactionImportForm()
1723
+ context = self.admin_site.each_context(request)
1724
+ context["form"] = form
1725
+ return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
1726
+
1727
+
1728
+ class MeterValueDateFilter(admin.SimpleListFilter):
1729
+ title = "Timestamp"
1730
+ parameter_name = "timestamp_range"
1731
+
1732
+ def lookups(self, request, model_admin):
1733
+ return [
1734
+ ("today", "Today"),
1735
+ ("7days", "Last 7 days"),
1736
+ ("30days", "Last 30 days"),
1737
+ ("older", "Older than 30 days"),
1738
+ ]
1739
+
1740
+ def queryset(self, request, queryset):
1741
+ value = self.value()
1742
+ now = timezone.now()
1743
+ if value == "today":
1744
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
1745
+ end = start + timedelta(days=1)
1746
+ return queryset.filter(timestamp__gte=start, timestamp__lt=end)
1747
+ if value == "7days":
1748
+ start = now - timedelta(days=7)
1749
+ return queryset.filter(timestamp__gte=start)
1750
+ if value == "30days":
1751
+ start = now - timedelta(days=30)
1752
+ return queryset.filter(timestamp__gte=start)
1753
+ if value == "older":
1754
+ cutoff = now - timedelta(days=30)
1755
+ return queryset.filter(timestamp__lt=cutoff)
1756
+ return queryset
1757
+
1758
+
1759
+ @admin.register(MeterValue)
1760
+ class MeterValueAdmin(EntityModelAdmin):
1761
+ list_display = (
1762
+ "charger",
1763
+ "timestamp",
1764
+ "context",
1765
+ "energy",
1766
+ "voltage",
1767
+ "current_import",
1768
+ "current_offered",
1769
+ "temperature",
1770
+ "soc",
1771
+ "connector_id",
1772
+ "transaction",
1773
+ )
1774
+ date_hierarchy = "timestamp"
1775
+ list_filter = ("charger", MeterValueDateFilter)