arthexis 0.1.12__py3-none-any.whl → 0.1.14__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.12.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
- arthexis-0.1.14.dist-info/RECORD +109 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -716
- config/settings_helpers.py +109 -0
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3771 -2772
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +133 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +100 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3609 -2672
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +721 -350
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +752 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2095 -1511
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2175 -1382
- core/widgets.py +213 -51
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -898
- nodes/apps.py +87 -70
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1737 -1416
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3810 -2497
- nodes/urls.py +15 -13
- nodes/utils.py +121 -105
- nodes/views.py +683 -451
- ocpp/admin.py +948 -804
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1342
- ocpp/evcs.py +844 -931
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -915
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -724
- ocpp/status_display.py +26 -0
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -3485
- ocpp/transactions_io.py +189 -179
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1151
- pages/admin.py +708 -536
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -169
- pages/middleware.py +205 -153
- pages/models.py +607 -426
- pages/tests.py +2612 -2083
- pages/urls.py +25 -25
- pages/utils.py +12 -12
- pages/views.py +1165 -1120
- arthexis-0.1.12.dist-info/RECORD +0 -102
- nodes/actions.py +0 -70
- {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
core/system.py
CHANGED
|
@@ -1,493 +1,752 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from django.
|
|
16
|
-
from django.
|
|
17
|
-
from django.
|
|
18
|
-
from django.
|
|
19
|
-
from django.utils
|
|
20
|
-
from django.utils.
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from contextlib import closing
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import Callable, Iterable, Optional
|
|
14
|
+
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.contrib import admin
|
|
17
|
+
from django.template.response import TemplateResponse
|
|
18
|
+
from django.urls import path
|
|
19
|
+
from django.utils import timezone
|
|
20
|
+
from django.utils.formats import date_format
|
|
21
|
+
from django.utils.translation import gettext_lazy as _
|
|
22
|
+
|
|
23
|
+
from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
|
|
24
|
+
from utils import revision
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
AUTO_UPGRADE_LOCK_NAME = "auto_upgrade.lck"
|
|
28
|
+
AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
|
|
29
|
+
AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _auto_upgrade_mode_file(base_dir: Path) -> Path:
|
|
33
|
+
return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _auto_upgrade_skip_file(base_dir: Path) -> Path:
|
|
37
|
+
return base_dir / "locks" / AUTO_UPGRADE_SKIP_LOCK_NAME
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _auto_upgrade_log_file(base_dir: Path) -> Path:
|
|
41
|
+
return base_dir / "logs" / AUTO_UPGRADE_LOG_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SystemField:
|
|
46
|
+
"""Metadata describing a single entry on the system admin page."""
|
|
47
|
+
|
|
48
|
+
label: str
|
|
49
|
+
sigil_key: str
|
|
50
|
+
value: object
|
|
51
|
+
field_type: str = "text"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def sigil(self) -> str:
|
|
55
|
+
return f"SYS.{self.sigil_key}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_RUNSERVER_PORT_PATTERN = re.compile(r":(\d{2,5})(?:\D|$)")
|
|
59
|
+
_RUNSERVER_PORT_FLAG_PATTERN = re.compile(r"--port(?:=|\s+)(\d{2,5})", re.IGNORECASE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _format_timestamp(dt: datetime | None) -> str:
|
|
63
|
+
"""Return ``dt`` formatted using the active ``DATETIME_FORMAT``."""
|
|
64
|
+
|
|
65
|
+
if dt is None:
|
|
66
|
+
return ""
|
|
67
|
+
try:
|
|
68
|
+
localized = timezone.localtime(dt)
|
|
69
|
+
except Exception:
|
|
70
|
+
localized = dt
|
|
71
|
+
return date_format(localized, "DATETIME_FORMAT")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _auto_upgrade_next_check() -> str:
|
|
75
|
+
"""Return the human-readable timestamp for the next auto-upgrade check."""
|
|
76
|
+
|
|
77
|
+
try: # pragma: no cover - optional dependency failures
|
|
78
|
+
from django_celery_beat.models import PeriodicTask
|
|
79
|
+
except Exception:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
task = (
|
|
84
|
+
PeriodicTask.objects.select_related(
|
|
85
|
+
"interval", "crontab", "solar", "clocked"
|
|
86
|
+
)
|
|
87
|
+
.only("enabled", "last_run_at", "start_time", "name")
|
|
88
|
+
.get(name=AUTO_UPGRADE_TASK_NAME)
|
|
89
|
+
)
|
|
90
|
+
except PeriodicTask.DoesNotExist:
|
|
91
|
+
return ""
|
|
92
|
+
except Exception: # pragma: no cover - database unavailable
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
if not task.enabled:
|
|
96
|
+
return str(_("Disabled"))
|
|
97
|
+
|
|
98
|
+
schedule = task.schedule
|
|
99
|
+
if schedule is None:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
now = schedule.maybe_make_aware(schedule.now())
|
|
103
|
+
|
|
104
|
+
start_time = task.start_time
|
|
105
|
+
if start_time is not None:
|
|
106
|
+
try:
|
|
107
|
+
candidate_start = schedule.maybe_make_aware(start_time)
|
|
108
|
+
except Exception:
|
|
109
|
+
candidate_start = (
|
|
110
|
+
timezone.make_aware(start_time)
|
|
111
|
+
if timezone.is_naive(start_time)
|
|
112
|
+
else start_time
|
|
113
|
+
)
|
|
114
|
+
if candidate_start and candidate_start > now:
|
|
115
|
+
return _format_timestamp(candidate_start)
|
|
116
|
+
|
|
117
|
+
last_run_at = task.last_run_at
|
|
118
|
+
if last_run_at is not None:
|
|
119
|
+
try:
|
|
120
|
+
reference = schedule.maybe_make_aware(last_run_at)
|
|
121
|
+
except Exception:
|
|
122
|
+
reference = (
|
|
123
|
+
timezone.make_aware(last_run_at)
|
|
124
|
+
if timezone.is_naive(last_run_at)
|
|
125
|
+
else last_run_at
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
reference = now
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
remaining = schedule.remaining_estimate(reference)
|
|
132
|
+
except Exception:
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
next_run = now + remaining
|
|
136
|
+
return _format_timestamp(next_run)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_auto_upgrade_mode(base_dir: Path) -> dict[str, object]:
|
|
140
|
+
"""Return metadata describing the configured auto-upgrade mode."""
|
|
141
|
+
|
|
142
|
+
mode_file = _auto_upgrade_mode_file(base_dir)
|
|
143
|
+
info: dict[str, object] = {
|
|
144
|
+
"mode": "version",
|
|
145
|
+
"enabled": False,
|
|
146
|
+
"lock_exists": mode_file.exists(),
|
|
147
|
+
"read_error": False,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if not info["lock_exists"]:
|
|
151
|
+
return info
|
|
152
|
+
|
|
153
|
+
info["enabled"] = True
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
raw_value = mode_file.read_text(encoding="utf-8").strip()
|
|
157
|
+
except OSError:
|
|
158
|
+
info["read_error"] = True
|
|
159
|
+
return info
|
|
160
|
+
|
|
161
|
+
mode = raw_value or "version"
|
|
162
|
+
info["mode"] = mode
|
|
163
|
+
info["enabled"] = True
|
|
164
|
+
return info
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_auto_upgrade_skip_revisions(base_dir: Path) -> list[str]:
|
|
168
|
+
"""Return a sorted list of revisions blocked from auto-upgrade."""
|
|
169
|
+
|
|
170
|
+
skip_file = _auto_upgrade_skip_file(base_dir)
|
|
171
|
+
try:
|
|
172
|
+
lines = skip_file.read_text(encoding="utf-8").splitlines()
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
return []
|
|
175
|
+
except OSError:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
revisions = {line.strip() for line in lines if line.strip()}
|
|
179
|
+
return sorted(revisions)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_log_timestamp(value: str) -> datetime | None:
|
|
183
|
+
"""Return a ``datetime`` parsed from ``value`` if it appears ISO formatted."""
|
|
184
|
+
|
|
185
|
+
if not value:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
candidate = value.strip()
|
|
189
|
+
if not candidate:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
if candidate[-1] in {"Z", "z"}:
|
|
193
|
+
candidate = f"{candidate[:-1]}+00:00"
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
return datetime.fromisoformat(candidate)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_auto_upgrade_log_entries(
|
|
202
|
+
base_dir: Path, *, limit: int = 25
|
|
203
|
+
) -> dict[str, object]:
|
|
204
|
+
"""Return the most recent auto-upgrade log entries."""
|
|
205
|
+
|
|
206
|
+
log_file = _auto_upgrade_log_file(base_dir)
|
|
207
|
+
result: dict[str, object] = {
|
|
208
|
+
"path": log_file,
|
|
209
|
+
"entries": [],
|
|
210
|
+
"error": "",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
with log_file.open("r", encoding="utf-8") as handle:
|
|
215
|
+
lines = deque((line.rstrip("\n") for line in handle), maxlen=limit)
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
return result
|
|
218
|
+
except OSError:
|
|
219
|
+
result["error"] = str(
|
|
220
|
+
_("The auto-upgrade log could not be read."))
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
entries: list[dict[str, str]] = []
|
|
224
|
+
for raw_line in lines:
|
|
225
|
+
line = raw_line.strip()
|
|
226
|
+
if not line:
|
|
227
|
+
continue
|
|
228
|
+
timestamp_str, _, message = line.partition(" ")
|
|
229
|
+
message = message.strip()
|
|
230
|
+
timestamp = _parse_log_timestamp(timestamp_str)
|
|
231
|
+
if not message:
|
|
232
|
+
message = timestamp_str
|
|
233
|
+
if timestamp is not None:
|
|
234
|
+
timestamp_display = _format_timestamp(timestamp)
|
|
235
|
+
else:
|
|
236
|
+
timestamp_display = timestamp_str
|
|
237
|
+
entries.append({
|
|
238
|
+
"timestamp": timestamp_display,
|
|
239
|
+
"message": message,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
result["entries"] = entries
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_auto_upgrade_periodic_task():
|
|
247
|
+
"""Return the configured auto-upgrade periodic task, if available."""
|
|
248
|
+
|
|
249
|
+
try: # pragma: no cover - optional dependency failures
|
|
250
|
+
from django_celery_beat.models import PeriodicTask
|
|
251
|
+
except Exception:
|
|
252
|
+
return None, False, str(_("django-celery-beat is not installed or configured."))
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
task = (
|
|
256
|
+
PeriodicTask.objects.select_related(
|
|
257
|
+
"interval", "crontab", "solar", "clocked"
|
|
258
|
+
)
|
|
259
|
+
.only(
|
|
260
|
+
"enabled",
|
|
261
|
+
"last_run_at",
|
|
262
|
+
"start_time",
|
|
263
|
+
"one_off",
|
|
264
|
+
"total_run_count",
|
|
265
|
+
"queue",
|
|
266
|
+
"expires",
|
|
267
|
+
"task",
|
|
268
|
+
"name",
|
|
269
|
+
"description",
|
|
270
|
+
)
|
|
271
|
+
.get(name=AUTO_UPGRADE_TASK_NAME)
|
|
272
|
+
)
|
|
273
|
+
except PeriodicTask.DoesNotExist:
|
|
274
|
+
return None, True, ""
|
|
275
|
+
except Exception:
|
|
276
|
+
return None, False, str(_("Auto-upgrade schedule could not be loaded."))
|
|
277
|
+
|
|
278
|
+
return task, True, ""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _load_auto_upgrade_schedule() -> dict[str, object]:
|
|
282
|
+
"""Return normalized auto-upgrade scheduling metadata."""
|
|
283
|
+
|
|
284
|
+
task, available, error = _get_auto_upgrade_periodic_task()
|
|
285
|
+
info: dict[str, object] = {
|
|
286
|
+
"available": available,
|
|
287
|
+
"configured": bool(task),
|
|
288
|
+
"enabled": getattr(task, "enabled", False) if task else False,
|
|
289
|
+
"one_off": getattr(task, "one_off", False) if task else False,
|
|
290
|
+
"queue": getattr(task, "queue", "") or "",
|
|
291
|
+
"schedule": "",
|
|
292
|
+
"start_time": "",
|
|
293
|
+
"last_run_at": "",
|
|
294
|
+
"next_run": "",
|
|
295
|
+
"total_run_count": 0,
|
|
296
|
+
"description": getattr(task, "description", "") or "",
|
|
297
|
+
"expires": "",
|
|
298
|
+
"task": getattr(task, "task", "") or "",
|
|
299
|
+
"name": getattr(task, "name", AUTO_UPGRADE_TASK_NAME) or AUTO_UPGRADE_TASK_NAME,
|
|
300
|
+
"error": error,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if not task:
|
|
304
|
+
return info
|
|
305
|
+
|
|
306
|
+
info["start_time"] = _format_timestamp(getattr(task, "start_time", None))
|
|
307
|
+
info["last_run_at"] = _format_timestamp(getattr(task, "last_run_at", None))
|
|
308
|
+
info["expires"] = _format_timestamp(getattr(task, "expires", None))
|
|
309
|
+
try:
|
|
310
|
+
run_count = int(getattr(task, "total_run_count", 0) or 0)
|
|
311
|
+
except (TypeError, ValueError):
|
|
312
|
+
run_count = 0
|
|
313
|
+
info["total_run_count"] = run_count
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
schedule_obj = task.schedule
|
|
317
|
+
except Exception: # pragma: no cover - schedule property may raise
|
|
318
|
+
schedule_obj = None
|
|
319
|
+
|
|
320
|
+
if schedule_obj is not None:
|
|
321
|
+
try:
|
|
322
|
+
info["schedule"] = str(schedule_obj)
|
|
323
|
+
except Exception: # pragma: no cover - schedule string conversion failed
|
|
324
|
+
info["schedule"] = ""
|
|
325
|
+
|
|
326
|
+
info["next_run"] = _auto_upgrade_next_check()
|
|
327
|
+
return info
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _build_auto_upgrade_report(*, limit: int = 25) -> dict[str, object]:
|
|
331
|
+
"""Assemble the composite auto-upgrade report for the admin view."""
|
|
332
|
+
|
|
333
|
+
base_dir = Path(settings.BASE_DIR)
|
|
334
|
+
mode_info = _read_auto_upgrade_mode(base_dir)
|
|
335
|
+
log_info = _load_auto_upgrade_log_entries(base_dir, limit=limit)
|
|
336
|
+
skip_revisions = _load_auto_upgrade_skip_revisions(base_dir)
|
|
337
|
+
schedule_info = _load_auto_upgrade_schedule()
|
|
338
|
+
|
|
339
|
+
mode_value = str(mode_info.get("mode", "version"))
|
|
340
|
+
is_latest = mode_value.lower() == "latest"
|
|
341
|
+
|
|
342
|
+
settings_info = {
|
|
343
|
+
"enabled": bool(mode_info.get("enabled", False)),
|
|
344
|
+
"mode": mode_value,
|
|
345
|
+
"is_latest": is_latest,
|
|
346
|
+
"lock_exists": bool(mode_info.get("lock_exists", False)),
|
|
347
|
+
"read_error": bool(mode_info.get("read_error", False)),
|
|
348
|
+
"mode_file": str(_auto_upgrade_mode_file(base_dir)),
|
|
349
|
+
"skip_revisions": skip_revisions,
|
|
350
|
+
"task_name": AUTO_UPGRADE_TASK_NAME,
|
|
351
|
+
"task_path": AUTO_UPGRADE_TASK_PATH,
|
|
352
|
+
"log_path": str(log_info.get("path")),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"settings": settings_info,
|
|
357
|
+
"schedule": schedule_info,
|
|
358
|
+
"log_entries": log_info.get("entries", []),
|
|
359
|
+
"log_error": str(log_info.get("error", "")),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _resolve_auto_upgrade_namespace(key: str) -> str | None:
|
|
364
|
+
"""Resolve sigils within the legacy ``AUTO-UPGRADE`` namespace."""
|
|
365
|
+
|
|
366
|
+
normalized = key.replace("-", "_").upper()
|
|
367
|
+
if normalized == "NEXT_CHECK":
|
|
368
|
+
return _auto_upgrade_next_check()
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_SYSTEM_SIGIL_NAMESPACES: dict[str, Callable[[str], Optional[str]]] = {
|
|
373
|
+
"AUTO_UPGRADE": _resolve_auto_upgrade_namespace,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def resolve_system_namespace_value(key: str) -> str | None:
|
|
378
|
+
"""Resolve dot-notation sigils mapped to dynamic ``SYS`` namespaces."""
|
|
379
|
+
|
|
380
|
+
if not key:
|
|
381
|
+
return None
|
|
382
|
+
normalized_key = key.replace("-", "_").upper()
|
|
383
|
+
if normalized_key == "NEXT_VER_CHECK":
|
|
384
|
+
return _auto_upgrade_next_check()
|
|
385
|
+
namespace, _, remainder = key.partition(".")
|
|
386
|
+
if not remainder:
|
|
387
|
+
return None
|
|
388
|
+
normalized = namespace.replace("-", "_").upper()
|
|
389
|
+
handler = _SYSTEM_SIGIL_NAMESPACES.get(normalized)
|
|
390
|
+
if not handler:
|
|
391
|
+
return None
|
|
392
|
+
return handler(remainder)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _database_configurations() -> list[dict[str, str]]:
|
|
396
|
+
"""Return a normalized list of configured database connections."""
|
|
397
|
+
|
|
398
|
+
databases: list[dict[str, str]] = []
|
|
399
|
+
for alias, config in settings.DATABASES.items():
|
|
400
|
+
engine = config.get("ENGINE", "")
|
|
401
|
+
name = config.get("NAME", "")
|
|
402
|
+
if engine is None:
|
|
403
|
+
engine = ""
|
|
404
|
+
if name is None:
|
|
405
|
+
name = ""
|
|
406
|
+
databases.append({
|
|
407
|
+
"alias": alias,
|
|
408
|
+
"engine": str(engine),
|
|
409
|
+
"name": str(name),
|
|
410
|
+
})
|
|
411
|
+
databases.sort(key=lambda entry: entry["alias"].lower())
|
|
412
|
+
return databases
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_system_fields(info: dict[str, object]) -> list[SystemField]:
|
|
416
|
+
"""Convert gathered system information into renderable rows."""
|
|
417
|
+
|
|
418
|
+
fields: list[SystemField] = []
|
|
419
|
+
|
|
420
|
+
def add_field(label: str, key: str, value: object, *, field_type: str = "text", visible: bool = True) -> None:
|
|
421
|
+
if not visible:
|
|
422
|
+
return
|
|
423
|
+
fields.append(SystemField(label=label, sigil_key=key, value=value, field_type=field_type))
|
|
424
|
+
|
|
425
|
+
add_field(_("Suite installed"), "INSTALLED", info.get("installed", False), field_type="boolean")
|
|
426
|
+
add_field(_("Revision"), "REVISION", info.get("revision", ""))
|
|
427
|
+
|
|
428
|
+
service_value = info.get("service") or _("not installed")
|
|
429
|
+
add_field(_("Service"), "SERVICE", service_value)
|
|
430
|
+
|
|
431
|
+
nginx_mode = info.get("mode", "")
|
|
432
|
+
port = info.get("port", "")
|
|
433
|
+
nginx_display = f"{nginx_mode} ({port})" if port else nginx_mode
|
|
434
|
+
add_field(_("Nginx mode"), "NGINX_MODE", nginx_display)
|
|
435
|
+
|
|
436
|
+
add_field(_("Node role"), "NODE_ROLE", info.get("role", ""))
|
|
437
|
+
add_field(
|
|
438
|
+
_("Display mode"),
|
|
439
|
+
"DISPLAY_MODE",
|
|
440
|
+
info.get("screen_mode", ""),
|
|
441
|
+
visible=bool(info.get("screen_mode")),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
add_field(_("Features"), "FEATURES", info.get("features", []), field_type="features")
|
|
445
|
+
add_field(_("Running"), "RUNNING", info.get("running", False), field_type="boolean")
|
|
446
|
+
add_field(
|
|
447
|
+
_("Service status"),
|
|
448
|
+
"SERVICE_STATUS",
|
|
449
|
+
info.get("service_status", ""),
|
|
450
|
+
visible=bool(info.get("service")),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
add_field(_("Hostname"), "HOSTNAME", info.get("hostname", ""))
|
|
454
|
+
|
|
455
|
+
ip_addresses: Iterable[str] = info.get("ip_addresses", []) # type: ignore[assignment]
|
|
456
|
+
add_field(_("IP addresses"), "IP_ADDRESSES", " ".join(ip_addresses))
|
|
457
|
+
|
|
458
|
+
add_field(
|
|
459
|
+
_("Databases"),
|
|
460
|
+
"DATABASES",
|
|
461
|
+
info.get("databases", []),
|
|
462
|
+
field_type="databases",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
add_field(
|
|
466
|
+
_("Next version check"),
|
|
467
|
+
"NEXT-VER-CHECK",
|
|
468
|
+
info.get("auto_upgrade_next_check", ""),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return fields
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _export_field_value(field: SystemField) -> str:
|
|
475
|
+
"""Serialize a ``SystemField`` value for sigil resolution."""
|
|
476
|
+
|
|
477
|
+
if field.field_type in {"features", "databases"}:
|
|
478
|
+
return json.dumps(field.value)
|
|
479
|
+
if field.field_type == "boolean":
|
|
480
|
+
return "True" if field.value else "False"
|
|
481
|
+
if field.value is None:
|
|
482
|
+
return ""
|
|
483
|
+
return str(field.value)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def get_system_sigil_values() -> dict[str, str]:
|
|
487
|
+
"""Expose system information in a format suitable for sigil lookups."""
|
|
488
|
+
|
|
489
|
+
info = _gather_info()
|
|
490
|
+
values: dict[str, str] = {}
|
|
491
|
+
for field in _build_system_fields(info):
|
|
492
|
+
exported = _export_field_value(field)
|
|
493
|
+
raw_key = (field.sigil_key or "").strip()
|
|
494
|
+
if not raw_key:
|
|
495
|
+
continue
|
|
496
|
+
variants = {
|
|
497
|
+
raw_key.upper(),
|
|
498
|
+
raw_key.replace("-", "_").upper(),
|
|
499
|
+
}
|
|
500
|
+
for variant in variants:
|
|
501
|
+
values[variant] = exported
|
|
502
|
+
return values
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _parse_runserver_port(command_line: str) -> int | None:
|
|
506
|
+
"""Extract the HTTP port from a runserver command line."""
|
|
507
|
+
|
|
508
|
+
for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
|
|
509
|
+
match = pattern.search(command_line)
|
|
510
|
+
if match:
|
|
511
|
+
try:
|
|
512
|
+
return int(match.group(1))
|
|
513
|
+
except ValueError:
|
|
514
|
+
continue
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _detect_runserver_process() -> tuple[bool, int | None]:
|
|
519
|
+
"""Return whether the dev server is running and the port if available."""
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
result = subprocess.run(
|
|
523
|
+
["pgrep", "-af", "manage.py runserver"],
|
|
524
|
+
capture_output=True,
|
|
525
|
+
text=True,
|
|
526
|
+
check=False,
|
|
527
|
+
)
|
|
528
|
+
except FileNotFoundError:
|
|
529
|
+
return False, None
|
|
530
|
+
except Exception:
|
|
531
|
+
return False, None
|
|
532
|
+
|
|
533
|
+
if result.returncode != 0:
|
|
534
|
+
return False, None
|
|
535
|
+
|
|
536
|
+
output = result.stdout.strip()
|
|
537
|
+
if not output:
|
|
538
|
+
return False, None
|
|
539
|
+
|
|
540
|
+
port = None
|
|
541
|
+
for line in output.splitlines():
|
|
542
|
+
port = _parse_runserver_port(line)
|
|
543
|
+
if port is not None:
|
|
544
|
+
break
|
|
545
|
+
|
|
546
|
+
if port is None:
|
|
547
|
+
port = 8000
|
|
548
|
+
|
|
549
|
+
return True, port
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
|
|
553
|
+
"""Attempt to connect to localhost on the provided ports."""
|
|
554
|
+
|
|
555
|
+
for port in candidates:
|
|
556
|
+
try:
|
|
557
|
+
with closing(socket.create_connection(("localhost", port), timeout=0.25)):
|
|
558
|
+
return True, port
|
|
559
|
+
except OSError:
|
|
560
|
+
continue
|
|
561
|
+
return False, None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _port_candidates(default_port: int) -> list[int]:
|
|
565
|
+
"""Return a prioritized list of ports to probe for the HTTP service."""
|
|
566
|
+
|
|
567
|
+
candidates = [default_port]
|
|
568
|
+
for port in (8000, 8888):
|
|
569
|
+
if port not in candidates:
|
|
570
|
+
candidates.append(port)
|
|
571
|
+
return candidates
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _gather_info() -> dict:
|
|
575
|
+
"""Collect basic system information similar to status.sh."""
|
|
576
|
+
base_dir = Path(settings.BASE_DIR)
|
|
577
|
+
lock_dir = base_dir / "locks"
|
|
578
|
+
info: dict[str, object] = {}
|
|
579
|
+
|
|
580
|
+
info["installed"] = (base_dir / ".venv").exists()
|
|
581
|
+
info["revision"] = revision.get_revision()
|
|
582
|
+
|
|
583
|
+
service_file = lock_dir / "service.lck"
|
|
584
|
+
info["service"] = service_file.read_text().strip() if service_file.exists() else ""
|
|
585
|
+
|
|
586
|
+
mode_file = lock_dir / "nginx_mode.lck"
|
|
587
|
+
mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
|
|
588
|
+
info["mode"] = mode
|
|
589
|
+
default_port = 8000 if mode == "public" else 8888
|
|
590
|
+
detected_port: int | None = None
|
|
591
|
+
|
|
592
|
+
screen_file = lock_dir / "screen_mode.lck"
|
|
593
|
+
info["screen_mode"] = (
|
|
594
|
+
screen_file.read_text().strip() if screen_file.exists() else ""
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Use settings.NODE_ROLE as the single source of truth for the node role.
|
|
598
|
+
info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
|
|
599
|
+
|
|
600
|
+
features: list[dict[str, object]] = []
|
|
601
|
+
try:
|
|
602
|
+
from nodes.models import Node, NodeFeature
|
|
603
|
+
except Exception:
|
|
604
|
+
info["features"] = features
|
|
605
|
+
else:
|
|
606
|
+
feature_map: dict[str, dict[str, object]] = {}
|
|
607
|
+
|
|
608
|
+
def _add_feature(feature: NodeFeature, flag: str) -> None:
|
|
609
|
+
slug = getattr(feature, "slug", "") or ""
|
|
610
|
+
if not slug:
|
|
611
|
+
return
|
|
612
|
+
display = (getattr(feature, "display", "") or "").strip()
|
|
613
|
+
normalized = display or slug.replace("-", " ").title()
|
|
614
|
+
entry = feature_map.setdefault(
|
|
615
|
+
slug,
|
|
616
|
+
{
|
|
617
|
+
"slug": slug,
|
|
618
|
+
"display": normalized,
|
|
619
|
+
"expected": False,
|
|
620
|
+
"actual": False,
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
if display:
|
|
624
|
+
entry["display"] = display
|
|
625
|
+
entry[flag] = True
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
expected_features = (
|
|
629
|
+
NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
|
|
630
|
+
)
|
|
631
|
+
except Exception:
|
|
632
|
+
expected_features = []
|
|
633
|
+
try:
|
|
634
|
+
for feature in expected_features:
|
|
635
|
+
_add_feature(feature, "expected")
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
local_node = Node.get_local()
|
|
641
|
+
except Exception:
|
|
642
|
+
local_node = None
|
|
643
|
+
|
|
644
|
+
actual_features = []
|
|
645
|
+
if local_node:
|
|
646
|
+
try:
|
|
647
|
+
actual_features = list(local_node.features.only("slug", "display"))
|
|
648
|
+
except Exception:
|
|
649
|
+
actual_features = []
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
for feature in actual_features:
|
|
653
|
+
_add_feature(feature, "actual")
|
|
654
|
+
except Exception:
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
features = sorted(
|
|
658
|
+
feature_map.values(),
|
|
659
|
+
key=lambda item: str(item.get("display", "")).lower(),
|
|
660
|
+
)
|
|
661
|
+
info["features"] = features
|
|
662
|
+
|
|
663
|
+
running = False
|
|
664
|
+
service_status = ""
|
|
665
|
+
service = info["service"]
|
|
666
|
+
if service and shutil.which("systemctl"):
|
|
667
|
+
try:
|
|
668
|
+
result = subprocess.run(
|
|
669
|
+
["systemctl", "is-active", str(service)],
|
|
670
|
+
capture_output=True,
|
|
671
|
+
text=True,
|
|
672
|
+
check=False,
|
|
673
|
+
)
|
|
674
|
+
service_status = result.stdout.strip()
|
|
675
|
+
running = service_status == "active"
|
|
676
|
+
except Exception:
|
|
677
|
+
pass
|
|
678
|
+
else:
|
|
679
|
+
process_running, process_port = _detect_runserver_process()
|
|
680
|
+
if process_running:
|
|
681
|
+
running = True
|
|
682
|
+
detected_port = process_port
|
|
683
|
+
|
|
684
|
+
if not running or detected_port is None:
|
|
685
|
+
probe_running, probe_port = _probe_ports(_port_candidates(default_port))
|
|
686
|
+
if probe_running:
|
|
687
|
+
running = True
|
|
688
|
+
if detected_port is None:
|
|
689
|
+
detected_port = probe_port
|
|
690
|
+
|
|
691
|
+
info["running"] = running
|
|
692
|
+
info["port"] = detected_port if detected_port is not None else default_port
|
|
693
|
+
info["service_status"] = service_status
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
hostname = socket.gethostname()
|
|
697
|
+
ip_list = socket.gethostbyname_ex(hostname)[2]
|
|
698
|
+
except Exception:
|
|
699
|
+
hostname = ""
|
|
700
|
+
ip_list = []
|
|
701
|
+
info["hostname"] = hostname
|
|
702
|
+
info["ip_addresses"] = ip_list
|
|
703
|
+
|
|
704
|
+
info["databases"] = _database_configurations()
|
|
705
|
+
info["auto_upgrade_next_check"] = _auto_upgrade_next_check()
|
|
706
|
+
|
|
707
|
+
return info
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _system_view(request):
|
|
711
|
+
info = _gather_info()
|
|
712
|
+
|
|
713
|
+
context = admin.site.each_context(request)
|
|
714
|
+
context.update(
|
|
715
|
+
{
|
|
716
|
+
"title": _("System"),
|
|
717
|
+
"info": info,
|
|
718
|
+
"system_fields": _build_system_fields(info),
|
|
719
|
+
}
|
|
720
|
+
)
|
|
721
|
+
return TemplateResponse(request, "admin/system.html", context)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _system_upgrade_report_view(request):
|
|
725
|
+
context = admin.site.each_context(request)
|
|
726
|
+
context.update(
|
|
727
|
+
{
|
|
728
|
+
"title": _("Upgrade Report"),
|
|
729
|
+
"auto_upgrade_report": _build_auto_upgrade_report(),
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
return TemplateResponse(request, "admin/system_upgrade_report.html", context)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def patch_admin_system_view() -> None:
|
|
736
|
+
"""Add custom admin view for system information."""
|
|
737
|
+
original_get_urls = admin.site.get_urls
|
|
738
|
+
|
|
739
|
+
def get_urls():
|
|
740
|
+
urls = original_get_urls()
|
|
741
|
+
custom = [
|
|
742
|
+
path("system/", admin.site.admin_view(_system_view), name="system"),
|
|
743
|
+
path(
|
|
744
|
+
"system/upgrade-report/",
|
|
745
|
+
admin.site.admin_view(_system_upgrade_report_view),
|
|
746
|
+
name="system-upgrade-report",
|
|
747
|
+
),
|
|
748
|
+
]
|
|
749
|
+
return custom + urls
|
|
750
|
+
|
|
751
|
+
admin.site.get_urls = get_urls
|
|
752
|
+
|