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.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
from django.
|
|
11
|
-
from django.
|
|
12
|
-
from django.
|
|
13
|
-
|
|
14
|
-
from .
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
from
|
|
23
|
-
from .
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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)
|