arthexis 0.1.12__py3-none-any.whl → 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arthexis
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.13
|
|
4
4
|
Summary: Django-based MESH system
|
|
5
5
|
Author-email: "Rafael J. Guillén-Osorio" <tecnologia@gelectriic.com>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -115,7 +115,7 @@ Dynamic: license-file
|
|
|
115
115
|
|
|
116
116
|
# Arthexis Constellation
|
|
117
117
|
|
|
118
|
-
[](https://github.com/arthexis/arthexis/actions/workflows/coverage.yml) [](docs/development/ocpp-user-manual.md)
|
|
118
|
+
[](https://github.com/arthexis/arthexis/actions/workflows/coverage.yml) [](https://github.com/arthexis/arthexis/blob/main/docs/development/ocpp-user-manual.md)
|
|
119
119
|
|
|
120
120
|
## Purpose
|
|
121
121
|
|
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
arthexis-0.1.
|
|
1
|
+
arthexis-0.1.13.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
2
2
|
config/__init__.py,sha256=8_b7rx_-Xcuzu3Z7mSR94q3PAhjyYqLFQi3IOEz6hcI,108
|
|
3
3
|
config/active_app.py,sha256=MET_G7oHL7GkoSo3VkkMzymM-PwsSZazMLZxpgjFLTo,388
|
|
4
|
-
config/asgi.py,sha256=
|
|
4
|
+
config/asgi.py,sha256=P7q9K5M-GTdmQuVEkSDPm89yS93-x7WkwZ6pq1EfoZ4,1339
|
|
5
5
|
config/auth_app.py,sha256=2NkC_iYQxnpbv0gYxW4xp5DgQtdkVLpa-JzAF-638ZE,205
|
|
6
|
-
config/celery.py,sha256=
|
|
6
|
+
config/celery.py,sha256=6BnonLD51aG2tFtjNheYQDKmoEqzhmNLtjcOvgOrvlU,851
|
|
7
7
|
config/context_processors.py,sha256=bjLSqbz7Qw6knPosIc4KNFEl5HsJHOe23htoNsul40E,2404
|
|
8
8
|
config/horologia_app.py,sha256=u1hTYcEmIqh82Gt5YNPvR5ta2MnVatELvD9ByFrCH1A,194
|
|
9
9
|
config/loadenv.py,sha256=bhFbHTbRJSkSwrFk3UInKEKQ8ZY-poatOGi7rC57YAI,298
|
|
10
10
|
config/logging.py,sha256=334jADN4dM5GNHaCWlYPOKYa5BhfxbsuejH_QDALG6g,1793
|
|
11
11
|
config/middleware.py,sha256=EvraDumepnKwCDswHGXb1mK7vud_dEEoZ4eh0IQ7fhQ,744
|
|
12
12
|
config/offline.py,sha256=mhQjCUzdOwSzZ6oLgPDJR48xaPIDzOi34ARUEz43seE,1431
|
|
13
|
-
config/settings.py,sha256=
|
|
13
|
+
config/settings.py,sha256=sdxnJ_F0sp7qrG6vgObdgDem8mVirPBfMumzoTcMCVQ,21782
|
|
14
|
+
config/settings_helpers.py,sha256=D5w1GdfO8hjWoIlnDBPLpWgfd-NQQuKNttSrIeBSMek,3236
|
|
14
15
|
config/urls.py,sha256=MmbES50lTHyMZ6risgXAGfevncN7j4HC74jR4PX_5xY,5228
|
|
15
16
|
config/wsgi.py,sha256=Fu-ONO2SgYeU6rhmy909P-uLX-n8ALJQObdm9MHPS-k,450
|
|
16
17
|
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
core/admin.py,sha256=
|
|
18
|
+
core/admin.py,sha256=sR772Bf28isQvnHRv5vjVi0um4yTzggWMRcjrfA6rI8,103412
|
|
18
19
|
core/admin_history.py,sha256=NIDWkosJoHMaucBvUjq8VmmL-0e8ngJ4l4XA89d4jwQ,1833
|
|
19
20
|
core/admindocs.py,sha256=GufdugiNEG87xGSDYVq4CBMhGRubsQCzgz-FqDIqzpM,5367
|
|
20
21
|
core/apps.py,sha256=Kyv7vnYPBWDg21uYyUo1aOki0Rb5zC3_3LRJTgEHE3s,11236
|
|
21
|
-
core/auto_upgrade.py,sha256=
|
|
22
|
+
core/auto_upgrade.py,sha256=UASqknQ8YOPAjMzdyP_f5smzbnw04DjGwqHf-tMmt4I,1772
|
|
22
23
|
core/backends.py,sha256=VsZZwskII6QLnxP6Ff593V7o9lqXXfN2_bIfZXrvjyI,8222
|
|
23
24
|
core/entity.py,sha256=dkPywTVk981fV8bOEoZw-1SMrdh8T0jVAUZnRxg3dDg,4505
|
|
24
25
|
core/environment.py,sha256=egNX3vP9xRJt0dqxNZFXi25ZMhe3IZH3Vjne1_k7hxo,1645
|
|
25
26
|
core/fields.py,sha256=4nJ_tngso8NHs6HBl8nn5PWvbcOiuAgkoM6hixrFm64,5580
|
|
27
|
+
core/form_fields.py,sha256=vRqHi8yjvDEUhpdwQth9Os4hKMxBcKX4UhcgL6zawW8,2571
|
|
26
28
|
core/github_helper.py,sha256=k9LmxL0Ec5MzFwZsbMpWMMJ-5tGMe-5yRoI8V0sbFsw,682
|
|
27
29
|
core/github_issues.py,sha256=LKqt-Ilx5TRYnx2LXttc54Nvuh3_1VRP0u_M3whe09c,4963
|
|
28
30
|
core/lcd_screen.py,sha256=mkKtIJjHGDLaV4t80L-JScsZXbLhlYUDknAcvV9Ijr0,2651
|
|
@@ -30,11 +32,11 @@ core/liveupdate.py,sha256=kTgbE2gnU3PPIV-88Bw2swSl1aGp6stSdBYqBFbLvx0,716
|
|
|
30
32
|
core/log_paths.py,sha256=6UXYk6QIUmRO3ecFaFH3dgJ_pf4C_wUN6b0JqUhLVBY,3045
|
|
31
33
|
core/mailer.py,sha256=OF3UgrTVs2St60tQG3ORV7_N6AWK6EtuB04KQXDop_Q,2829
|
|
32
34
|
core/middleware.py,sha256=a4XL0pld4YiG-vanHrzYbJNHv64s75lvmG9inoG1ln0,3479
|
|
33
|
-
core/models.py,sha256=
|
|
35
|
+
core/models.py,sha256=sa06Axm1p7W7EcYqrX0mWemwxhYIhWWjkby-qV-sLbk,96345
|
|
34
36
|
core/notifications.py,sha256=YtNDGxNveZ6t3tlMXJ7wIaZZTWIZfOKy6vN9mXkeYnA,4021
|
|
35
37
|
core/public_wifi.py,sha256=A08IPJqcdgUKSWbktyqsV4ol8C0uxDxZy1s1ECuPdBE,6526
|
|
36
38
|
core/reference_utils.py,sha256=2AQxtUkEbArieu1FMz3rTTHKPCDTJhHp_7bhzQCkN3U,3735
|
|
37
|
-
core/release.py,sha256=
|
|
39
|
+
core/release.py,sha256=jZtTNyw3zezUJ6gd9Y7oo4fkyvDasTrkFGGMAufbsH0,11888
|
|
38
40
|
core/sigil_builder.py,sha256=63KSTh7AohG0szr_amBov0zoZuTE8bWqmgsEfPsZHCg,4992
|
|
39
41
|
core/sigil_context.py,sha256=8xrGiB2L1dFfSTrVLsFPLKfkhRwCXXZ0-EXvZdPeTMU,459
|
|
40
42
|
core/sigil_resolver.py,sha256=PAGwF3ugsqN85tKyIvT0YCDCM2MU_Mct2mCJz8XY1s4,11413
|
|
@@ -42,61 +44,62 @@ core/system.py,sha256=iH3-Ymonn_wVAvTmg2fJBI81F0nDCcrSJsAwIlD4sR0,15212
|
|
|
42
44
|
core/tasks.py,sha256=9njUdJUZNtu23XfWhSxk5lX1kKuvckX0qamxlxTHFxA,12328
|
|
43
45
|
core/temp_passwords.py,sha256=Kp4C-y1Hh4GeV1vSOCw3ZyIjcRMts186lUgtEkd3rxY,5452
|
|
44
46
|
core/test_system_info.py,sha256=sHCuo-qyw0BKacbGXFhTWxkuPgywdMGiQ_6YK022n-E,4803
|
|
45
|
-
core/tests.py,sha256=
|
|
47
|
+
core/tests.py,sha256=75znkttcCZ9RCIND1c5JnNfJIU1YrJRtjEj678QcC80,62011
|
|
46
48
|
core/tests_liveupdate.py,sha256=D1o2gPopnK7wDCeDQlJ-tfitWh4umZQFRxSTCFY-puc,527
|
|
47
49
|
core/urls.py,sha256=x-LNCxCgrLINdBsJTUUcAuMS5EK5Sh1ybvsVuUnJfLw,436
|
|
48
50
|
core/user_data.py,sha256=kOzcWJcR-d05E7QYoNzKqQSm9bnKJvtG6zq0zRf-tDI,21371
|
|
49
|
-
core/views.py,sha256=
|
|
50
|
-
core/widgets.py,sha256=
|
|
51
|
+
core/views.py,sha256=LoyNaISnwWUBVpr4zE4FN5c_KjiO3c2rtx_onE2RgIg,50361
|
|
52
|
+
core/widgets.py,sha256=B1E7iM1LFATHCK92UqcHV_oUua3Eun5uWibmjc9f7_Q,2845
|
|
51
53
|
core/workgroup_urls.py,sha256=2bC8mOMkxIj04WsNis0Td6AmmJFr6z27Ol5epIvhO28,424
|
|
52
54
|
core/workgroup_views.py,sha256=pFJ4PIRN3WWpRyombpWDKKteYQYoI7lu7ddSEKojD7I,2983
|
|
53
55
|
nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
56
|
nodes/actions.py,sha256=HHnwByTBc3guOORvrKOuvUFID-_BpBq6OENE_PDgk9s,2335
|
|
55
|
-
nodes/admin.py,sha256=
|
|
56
|
-
nodes/apps.py,sha256=
|
|
57
|
+
nodes/admin.py,sha256=_I9BBMuciFuFd8oLzJZ7y9iB7zzeyjbO0AQ64SgfL2M,42272
|
|
58
|
+
nodes/apps.py,sha256=mKeub2f8tukiXbz8cA1xVJM6hnlgce_TGITiQEOqL9A,2716
|
|
57
59
|
nodes/backends.py,sha256=-g7PIbGtIn_3kGoi2w_mJ6zVjvUKTRRWhHABPnnf29g,6081
|
|
58
60
|
nodes/dns.py,sha256=X8PML9rPR7i6NwCSqqxnlO1qpHHRo8uA-XePDTQZYrY,7251
|
|
59
61
|
nodes/feature_checks.py,sha256=ZK9PufvF2pFWHUm4khg8lfHVNncA6zrMBxu-WZEw7-E,4129
|
|
60
62
|
nodes/lcd.py,sha256=7MqS3jV_De2ysSmTtXQfYTWaxdfbmm6YfNdb_7qIVZc,5923
|
|
61
|
-
nodes/models.py,sha256=
|
|
63
|
+
nodes/models.py,sha256=s_GdZkWyWySQauVdSytLY9HeqraX80_5Vz-br5bO_GY,55899
|
|
62
64
|
nodes/reports.py,sha256=pQwwTa5E-JhUwQIbKKQvgD_Cw9556CH0ieRgRjBMnMQ,12470
|
|
63
65
|
nodes/tasks.py,sha256=jorbN4h0PqWMBRMFdkGgJtvG8GPFwGr5Hxlm5In3EeY,1568
|
|
64
|
-
nodes/tests.py,sha256=
|
|
65
|
-
nodes/urls.py,sha256=
|
|
66
|
+
nodes/tests.py,sha256=sxXeaaNCE6sn_CQYe7PDrhp0q0yhMRQyOy2blodnbH8,124914
|
|
67
|
+
nodes/urls.py,sha256=30rYTwQGOnilucXDf8nf95debxn6aytMQHFo8MRbd2A,620
|
|
66
68
|
nodes/utils.py,sha256=h3qVmCBBLyrh06FmDldprxfDQG7dgLw82Tf5PrU-5Qc,4120
|
|
67
|
-
nodes/views.py,sha256=
|
|
69
|
+
nodes/views.py,sha256=trgJaCJ4QZ6I0eWXxzRqqN9WXMfugcPdTyKLiCHa2ZI,21466
|
|
68
70
|
ocpp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
69
|
-
ocpp/admin.py,sha256=
|
|
71
|
+
ocpp/admin.py,sha256=xXAuC8c5fWCxgUiCnL5RWEEks7fmPLVTkWoqoiP4a-0,32397
|
|
70
72
|
ocpp/apps.py,sha256=mCZ5Z0ei7z7c62luIcIhbEuwL1N4czQFSToOkGvRmms,867
|
|
71
|
-
ocpp/consumers.py,sha256=
|
|
72
|
-
ocpp/evcs.py,sha256=
|
|
73
|
+
ocpp/consumers.py,sha256=NA5IG--kOER2bDkLsa3pygiEtXSuiBXCYU-9nXS5zrg,62584
|
|
74
|
+
ocpp/evcs.py,sha256=KrkujSKkhzDJ64_btXQ4Vu7TIZfH_0UHs7Eptny73wg,29727
|
|
73
75
|
ocpp/evcs_discovery.py,sha256=47pWgqX1_JOT5BGSrIdEyuoPTGWN4b6rNjJr8kWeQCg,4034
|
|
74
|
-
ocpp/models.py,sha256=
|
|
76
|
+
ocpp/models.py,sha256=fhOxKqulTRlvHQ36b5KT5CDnVS9YlTorrQahUQC4gh8,32318
|
|
75
77
|
ocpp/reference_utils.py,sha256=sTgbXfmz00f23LBBkpO-sBGoJf1qaEshHeSofLReYCc,1114
|
|
76
|
-
ocpp/routing.py,sha256=
|
|
77
|
-
ocpp/simulator.py,sha256=
|
|
78
|
+
ocpp/routing.py,sha256=8hhP8vqAJm279_0MU6P183h61N98cL20BZap4ZrYRT8,449
|
|
79
|
+
ocpp/simulator.py,sha256=aQ4u5OLIuicCpV3SMCS6pFI-cU45heO0NqJkvbXyH7o,28761
|
|
80
|
+
ocpp/status_display.py,sha256=eR4qNeGhLVt6FO-sRY4kz9eGLtnCtbxDjeR0etZBHk8,965
|
|
78
81
|
ocpp/store.py,sha256=OjGWT3WkpoahGpUwn_ZpIOsxgOnryoXXSwPCvFsLkzM,17410
|
|
79
82
|
ocpp/tasks.py,sha256=cOcJBshckFKs8GnACvmYZUBG116amtLRAzEP-JNqlZ0,905
|
|
80
83
|
ocpp/test_export_import.py,sha256=TK1__E4K5WrLYPIx-1iJWoIhuRCnOtXc2cYEpGigd3U,4660
|
|
81
84
|
ocpp/test_rfid.py,sha256=hX8VZ2HRyBGjtQLClZ1gy28dOj25RT2r9eK3l9XkmJg,25877
|
|
82
|
-
ocpp/tests.py,sha256=
|
|
83
|
-
ocpp/transactions_io.py,sha256=
|
|
85
|
+
ocpp/tests.py,sha256=5xH8zY2vD4QJyOsMc1LMDrXXhKVrzFTOVfjRNNxfyLM,163083
|
|
86
|
+
ocpp/transactions_io.py,sha256=emiPuIepqOE-IdRE2SA7REJAmF3Yur17m7gzdK4S_wE,7576
|
|
84
87
|
ocpp/urls.py,sha256=jl6AQLtmLMvrNXP3dQKgPe9Kvaul4oaYd80DskpzVBc,1750
|
|
85
|
-
ocpp/views.py,sha256=
|
|
88
|
+
ocpp/views.py,sha256=OI3NM1oauWIHL_3Czt_Srn322SnVH_wqdcoNwXpidpw,49268
|
|
86
89
|
pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
87
|
-
pages/admin.py,sha256=
|
|
90
|
+
pages/admin.py,sha256=KLjrVI4zzENiZA4cDi5jJ79DNnpgfg3jIVRcP5X5umQ,16978
|
|
88
91
|
pages/apps.py,sha256=mfCxegmnRqKcszyEwpQhZpW9JWOuEYdVereU_w49BXg,298
|
|
89
92
|
pages/checks.py,sha256=an-MlMCIG-FSKmdgOhYV0VOoB_wDQD7dxKD03Hjrzts,1567
|
|
90
93
|
pages/context_processors.py,sha256=4lkk2mTzmsOKhDhQanWXuU2bbwow0h-1Zn5GWOrL2z4,4132
|
|
91
94
|
pages/defaults.py,sha256=cOhb5p_SneIsK6Qfn9SV1fyq7L3gDheWYo1xp7SplF8,535
|
|
92
|
-
pages/forms.py,sha256=
|
|
95
|
+
pages/forms.py,sha256=63_243YfT8sAW3hrdyNgYhVxlXTAUZeshaLWUlreR1c,7256
|
|
93
96
|
pages/middleware.py,sha256=fUdAscLa6h2EqJTFUjhVijfSf82gBfm6XUZTVygWAnk,5227
|
|
94
97
|
pages/models.py,sha256=O4wtjD8jkZlguxEQFcvijSPM55KBjRBdhzvg0FHnxt4,14577
|
|
95
|
-
pages/tests.py,sha256=
|
|
98
|
+
pages/tests.py,sha256=T4akrxZv2NPK3X9mguipW7DUn4F7OyKG6DDlRqpJEpI,88701
|
|
96
99
|
pages/urls.py,sha256=iwe4hvcr_ko-n4h24t6t2882eZp2qyLXeEYkrIahoGU,1067
|
|
97
100
|
pages/utils.py,sha256=7kik1W0Gk6SFxYGhg6shfI2W9Xdcv1sCpkRCQ883a88,311
|
|
98
|
-
pages/views.py,sha256=
|
|
99
|
-
arthexis-0.1.
|
|
100
|
-
arthexis-0.1.
|
|
101
|
-
arthexis-0.1.
|
|
102
|
-
arthexis-0.1.
|
|
101
|
+
pages/views.py,sha256=DSzsvHGOVavKU_gDIweHRWQeHiQoe0czkeigZ2a4ge0,43260
|
|
102
|
+
arthexis-0.1.13.dist-info/METADATA,sha256=PhW-6uJCu_HAvfQ7saLHifGvvCK2JRO3BSGDapjTat8,10184
|
|
103
|
+
arthexis-0.1.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
104
|
+
arthexis-0.1.13.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
|
|
105
|
+
arthexis-0.1.13.dist-info/RECORD,,
|
config/asgi.py
CHANGED
|
@@ -9,21 +9,35 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
from config.loadenv import loadenv
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, MutableMapping
|
|
12
13
|
from channels.auth import AuthMiddlewareStack
|
|
13
14
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
14
15
|
from django.core.asgi import get_asgi_application
|
|
15
16
|
import ocpp.routing
|
|
16
17
|
|
|
18
|
+
from core.mcp.asgi import application as mcp_application
|
|
19
|
+
from core.mcp.asgi import is_mcp_scope
|
|
20
|
+
|
|
17
21
|
loadenv()
|
|
18
22
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
19
23
|
|
|
20
24
|
django_asgi_app = get_asgi_application()
|
|
21
25
|
|
|
26
|
+
Scope = MutableMapping[str, Any]
|
|
27
|
+
Receive = Callable[[], Awaitable[Dict[str, Any]]]
|
|
28
|
+
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
|
29
|
+
|
|
22
30
|
websocket_patterns = ocpp.routing.websocket_urlpatterns
|
|
23
31
|
|
|
32
|
+
async def http_application(scope: Scope, receive: Receive, send: Send) -> None:
|
|
33
|
+
if is_mcp_scope(scope):
|
|
34
|
+
await mcp_application(scope, receive, send)
|
|
35
|
+
else:
|
|
36
|
+
await django_asgi_app(scope, receive, send)
|
|
37
|
+
|
|
24
38
|
application = ProtocolTypeRouter(
|
|
25
39
|
{
|
|
26
|
-
"http":
|
|
40
|
+
"http": http_application,
|
|
27
41
|
"websocket": AuthMiddlewareStack(URLRouter(websocket_patterns)),
|
|
28
42
|
}
|
|
29
43
|
)
|
config/celery.py
CHANGED
|
@@ -9,7 +9,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
|
9
9
|
|
|
10
10
|
# When running on production-oriented nodes, avoid Celery debug mode.
|
|
11
11
|
NODE_ROLE = os.environ.get("NODE_ROLE", "")
|
|
12
|
-
|
|
12
|
+
PRODUCTION_ROLES = {
|
|
13
|
+
"constellation",
|
|
14
|
+
"satellite",
|
|
15
|
+
"control",
|
|
16
|
+
"terminal",
|
|
17
|
+
"gateway",
|
|
18
|
+
}
|
|
19
|
+
if NODE_ROLE.lower() in PRODUCTION_ROLES:
|
|
13
20
|
for var in ["CELERY_TRACE_APP", "CELERY_DEBUG"]:
|
|
14
21
|
os.environ.pop(var, None)
|
|
15
22
|
os.environ.setdefault("CELERY_LOG_LEVEL", "INFO")
|
config/settings.py
CHANGED
|
@@ -22,61 +22,24 @@ from celery.schedules import crontab
|
|
|
22
22
|
from django.http import request as http_request
|
|
23
23
|
from django.http.request import split_domain_port
|
|
24
24
|
from django.middleware.csrf import CsrfViewMiddleware
|
|
25
|
-
from django.core.exceptions import DisallowedHost
|
|
25
|
+
from django.core.exceptions import DisallowedHost, ImproperlyConfigured
|
|
26
26
|
from django.contrib.sites import shortcuts as sites_shortcuts
|
|
27
27
|
from django.contrib.sites.requests import RequestSite
|
|
28
|
-
from django.core.management.utils import get_random_secret_key
|
|
29
28
|
from urllib.parse import urlsplit
|
|
30
29
|
import django.utils.encoding as encoding
|
|
31
30
|
|
|
31
|
+
from config.settings_helpers import (
|
|
32
|
+
extract_ip_from_host,
|
|
33
|
+
install_validate_host_with_subnets,
|
|
34
|
+
load_secret_key,
|
|
35
|
+
strip_ipv6_brackets,
|
|
36
|
+
)
|
|
37
|
+
|
|
32
38
|
if not hasattr(encoding, "force_text"): # pragma: no cover - Django>=5 compatibility
|
|
33
39
|
from django.utils.encoding import force_str
|
|
34
40
|
|
|
35
41
|
encoding.force_text = force_str
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
_original_validate_host = http_request.validate_host
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _strip_ipv6_brackets(host: str) -> str:
|
|
43
|
-
if host.startswith("[") and host.endswith("]"):
|
|
44
|
-
return host[1:-1]
|
|
45
|
-
return host
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _extract_ip_from_host(host: str):
|
|
49
|
-
"""Return an :mod:`ipaddress` object for ``host`` when possible."""
|
|
50
|
-
|
|
51
|
-
candidate = _strip_ipv6_brackets(host)
|
|
52
|
-
try:
|
|
53
|
-
return ipaddress.ip_address(candidate)
|
|
54
|
-
except ValueError:
|
|
55
|
-
domain, _port = split_domain_port(host)
|
|
56
|
-
if domain and domain != host:
|
|
57
|
-
candidate = _strip_ipv6_brackets(domain)
|
|
58
|
-
try:
|
|
59
|
-
return ipaddress.ip_address(candidate)
|
|
60
|
-
except ValueError:
|
|
61
|
-
return None
|
|
62
|
-
return None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _validate_host_with_subnets(host, allowed_hosts):
|
|
66
|
-
ip = _extract_ip_from_host(host)
|
|
67
|
-
if ip is None:
|
|
68
|
-
return _original_validate_host(host, allowed_hosts)
|
|
69
|
-
for pattern in allowed_hosts:
|
|
70
|
-
try:
|
|
71
|
-
network = ipaddress.ip_network(pattern)
|
|
72
|
-
except ValueError:
|
|
73
|
-
continue
|
|
74
|
-
if ip in network:
|
|
75
|
-
return True
|
|
76
|
-
return _original_validate_host(host, allowed_hosts)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
http_request.validate_host = _validate_host_with_subnets
|
|
42
|
+
install_validate_host_with_subnets()
|
|
80
43
|
|
|
81
44
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
82
45
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
@@ -96,29 +59,7 @@ with contextlib.suppress(FileNotFoundError):
|
|
|
96
59
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
|
97
60
|
|
|
98
61
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _load_secret_key() -> str:
|
|
102
|
-
for env_var in ("DJANGO_SECRET_KEY", "SECRET_KEY"):
|
|
103
|
-
value = os.environ.get(env_var)
|
|
104
|
-
if value:
|
|
105
|
-
return value
|
|
106
|
-
|
|
107
|
-
secret_file = BASE_DIR / "locks" / "django-secret.key"
|
|
108
|
-
with contextlib.suppress(OSError):
|
|
109
|
-
stored_key = secret_file.read_text(encoding="utf-8").strip()
|
|
110
|
-
if stored_key:
|
|
111
|
-
return stored_key
|
|
112
|
-
|
|
113
|
-
generated_key = get_random_secret_key()
|
|
114
|
-
with contextlib.suppress(OSError):
|
|
115
|
-
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
-
secret_file.write_text(generated_key, encoding="utf-8")
|
|
117
|
-
|
|
118
|
-
return generated_key
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
SECRET_KEY = _load_secret_key()
|
|
62
|
+
SECRET_KEY = load_secret_key(BASE_DIR)
|
|
122
63
|
|
|
123
64
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
124
65
|
|
|
@@ -258,7 +199,7 @@ def _normalize_origin_tuple(scheme: str | None, host: str) -> tuple[str, str, st
|
|
|
258
199
|
if not scheme or scheme.lower() not in {"http", "https"}:
|
|
259
200
|
return None
|
|
260
201
|
domain, port = split_domain_port(host)
|
|
261
|
-
normalized_host =
|
|
202
|
+
normalized_host = strip_ipv6_brackets(domain.strip().lower())
|
|
262
203
|
if not normalized_host:
|
|
263
204
|
return None
|
|
264
205
|
normalized_port = port.strip() if isinstance(port, str) else port
|
|
@@ -325,13 +266,13 @@ def _origin_verified_with_subnets(self, request):
|
|
|
325
266
|
if normalized_origin is None:
|
|
326
267
|
return _original_origin_verified(self, request)
|
|
327
268
|
|
|
328
|
-
origin_ip =
|
|
269
|
+
origin_ip = extract_ip_from_host(normalized_origin[1])
|
|
329
270
|
|
|
330
271
|
for candidate in _candidate_origin_tuples(request, allowed_hosts):
|
|
331
272
|
if candidate == normalized_origin:
|
|
332
273
|
return True
|
|
333
274
|
|
|
334
|
-
candidate_ip =
|
|
275
|
+
candidate_ip = extract_ip_from_host(candidate[1])
|
|
335
276
|
if origin_ip and candidate_ip:
|
|
336
277
|
for pattern in allowed_hosts:
|
|
337
278
|
try:
|
|
@@ -367,13 +308,13 @@ def _check_referer_with_forwarded(self, request):
|
|
|
367
308
|
return _original_check_referer(self, request)
|
|
368
309
|
|
|
369
310
|
allowed_hosts = _get_allowed_hosts()
|
|
370
|
-
referer_ip =
|
|
311
|
+
referer_ip = extract_ip_from_host(normalized_referer[1])
|
|
371
312
|
|
|
372
313
|
for candidate in _candidate_origin_tuples(request, allowed_hosts):
|
|
373
314
|
if candidate == normalized_referer:
|
|
374
315
|
return
|
|
375
316
|
|
|
376
|
-
candidate_ip =
|
|
317
|
+
candidate_ip = extract_ip_from_host(candidate[1])
|
|
377
318
|
if referer_ip and candidate_ip:
|
|
378
319
|
for pattern in allowed_hosts:
|
|
379
320
|
try:
|
|
@@ -524,6 +465,7 @@ MCP_SIGIL_SERVER = {
|
|
|
524
465
|
"required_scopes": ["sigils:read"],
|
|
525
466
|
"issuer_url": os.environ.get("MCP_SIGIL_ISSUER_URL"),
|
|
526
467
|
"resource_server_url": os.environ.get("MCP_SIGIL_RESOURCE_URL"),
|
|
468
|
+
"mount_path": os.environ.get("MCP_SIGIL_MOUNT_PATH"),
|
|
527
469
|
}
|
|
528
470
|
|
|
529
471
|
|
|
@@ -544,8 +486,16 @@ OTP_TOTP_ISSUER = os.environ.get("OTP_TOTP_ISSUER", "Arthexis")
|
|
|
544
486
|
# Database
|
|
545
487
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
546
488
|
|
|
489
|
+
FORCED_DB_BACKEND = os.environ.get("ARTHEXIS_FORCE_DB_BACKEND", "").strip().lower()
|
|
490
|
+
if FORCED_DB_BACKEND and FORCED_DB_BACKEND not in {"sqlite", "postgres"}:
|
|
491
|
+
raise ImproperlyConfigured(
|
|
492
|
+
"ARTHEXIS_FORCE_DB_BACKEND must be 'sqlite' or 'postgres' when defined."
|
|
493
|
+
)
|
|
494
|
+
|
|
547
495
|
|
|
548
496
|
def _postgres_available() -> bool:
|
|
497
|
+
if FORCED_DB_BACKEND == "sqlite":
|
|
498
|
+
return False
|
|
549
499
|
try:
|
|
550
500
|
import psycopg
|
|
551
501
|
except Exception:
|
|
@@ -566,7 +516,15 @@ def _postgres_available() -> bool:
|
|
|
566
516
|
return False
|
|
567
517
|
|
|
568
518
|
|
|
569
|
-
if
|
|
519
|
+
if FORCED_DB_BACKEND == "postgres":
|
|
520
|
+
_use_postgres = True
|
|
521
|
+
elif FORCED_DB_BACKEND == "sqlite":
|
|
522
|
+
_use_postgres = False
|
|
523
|
+
else:
|
|
524
|
+
_use_postgres = _postgres_available()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if _use_postgres:
|
|
570
528
|
DATABASES = {
|
|
571
529
|
"default": {
|
|
572
530
|
"ENGINE": "django.db.backends.postgresql",
|
|
@@ -582,10 +540,16 @@ if _postgres_available():
|
|
|
582
540
|
}
|
|
583
541
|
}
|
|
584
542
|
else:
|
|
543
|
+
_sqlite_override = os.environ.get("ARTHEXIS_SQLITE_PATH")
|
|
544
|
+
if _sqlite_override:
|
|
545
|
+
SQLITE_DB_PATH = Path(_sqlite_override)
|
|
546
|
+
else:
|
|
547
|
+
SQLITE_DB_PATH = BASE_DIR / "db.sqlite3"
|
|
548
|
+
|
|
585
549
|
DATABASES = {
|
|
586
550
|
"default": {
|
|
587
551
|
"ENGINE": "django.db.backends.sqlite3",
|
|
588
|
-
"NAME":
|
|
552
|
+
"NAME": SQLITE_DB_PATH,
|
|
589
553
|
"OPTIONS": {"timeout": 60},
|
|
590
554
|
"TEST": {"NAME": BASE_DIR / "test_db.sqlite3"},
|
|
591
555
|
}
|
|
@@ -625,6 +589,8 @@ LANGUAGES = [
|
|
|
625
589
|
|
|
626
590
|
LOCALE_PATHS = [BASE_DIR / "locale"]
|
|
627
591
|
|
|
592
|
+
FORMAT_MODULE_PATH = ["config.formats"]
|
|
593
|
+
|
|
628
594
|
TIME_ZONE = "America/Monterrey"
|
|
629
595
|
|
|
630
596
|
USE_I18N = True
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Utility helpers shared by :mod:`config.settings` and related tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping, MutableMapping
|
|
10
|
+
|
|
11
|
+
from django.core.management.utils import get_random_secret_key
|
|
12
|
+
from django.http import request as http_request
|
|
13
|
+
from django.http.request import split_domain_port
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"extract_ip_from_host",
|
|
18
|
+
"install_validate_host_with_subnets",
|
|
19
|
+
"load_secret_key",
|
|
20
|
+
"strip_ipv6_brackets",
|
|
21
|
+
"validate_host_with_subnets",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def strip_ipv6_brackets(host: str) -> str:
|
|
26
|
+
"""Return ``host`` without IPv6 URL literal brackets."""
|
|
27
|
+
|
|
28
|
+
if host.startswith("[") and host.endswith("]"):
|
|
29
|
+
return host[1:-1]
|
|
30
|
+
return host
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_ip_from_host(host: str):
|
|
34
|
+
"""Return an :mod:`ipaddress` object for ``host`` when possible."""
|
|
35
|
+
|
|
36
|
+
candidate = strip_ipv6_brackets(host)
|
|
37
|
+
try:
|
|
38
|
+
return ipaddress.ip_address(candidate)
|
|
39
|
+
except ValueError:
|
|
40
|
+
domain, _port = split_domain_port(host)
|
|
41
|
+
if domain and domain != host:
|
|
42
|
+
candidate = strip_ipv6_brackets(domain)
|
|
43
|
+
try:
|
|
44
|
+
return ipaddress.ip_address(candidate)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return None
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_host_with_subnets(host, allowed_hosts, original_validate=None):
|
|
51
|
+
"""Extend Django's host validation to honor subnet CIDR notation."""
|
|
52
|
+
|
|
53
|
+
if original_validate is None:
|
|
54
|
+
original_validate = http_request.validate_host
|
|
55
|
+
|
|
56
|
+
ip = extract_ip_from_host(host)
|
|
57
|
+
if ip is None:
|
|
58
|
+
return original_validate(host, allowed_hosts)
|
|
59
|
+
|
|
60
|
+
for pattern in allowed_hosts:
|
|
61
|
+
try:
|
|
62
|
+
network = ipaddress.ip_network(pattern)
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
if ip in network:
|
|
66
|
+
return True
|
|
67
|
+
return original_validate(host, allowed_hosts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def install_validate_host_with_subnets() -> None:
|
|
71
|
+
"""Monkeypatch Django's host validator to recognize subnet patterns."""
|
|
72
|
+
|
|
73
|
+
original_validate = http_request.validate_host
|
|
74
|
+
|
|
75
|
+
def _patched(host, allowed_hosts):
|
|
76
|
+
return validate_host_with_subnets(host, allowed_hosts, original_validate)
|
|
77
|
+
|
|
78
|
+
http_request.validate_host = _patched
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_secret_key(
|
|
82
|
+
base_dir: Path,
|
|
83
|
+
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
|
|
84
|
+
secret_file: Path | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Load the Django secret key from the environment or a persisted file."""
|
|
87
|
+
|
|
88
|
+
if env is None:
|
|
89
|
+
env = os.environ
|
|
90
|
+
|
|
91
|
+
for env_var in ("DJANGO_SECRET_KEY", "SECRET_KEY"):
|
|
92
|
+
value = env.get(env_var)
|
|
93
|
+
if value:
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
if secret_file is None:
|
|
97
|
+
secret_file = base_dir / "locks" / "django-secret.key"
|
|
98
|
+
|
|
99
|
+
with contextlib.suppress(OSError):
|
|
100
|
+
stored_key = secret_file.read_text(encoding="utf-8").strip()
|
|
101
|
+
if stored_key:
|
|
102
|
+
return stored_key
|
|
103
|
+
|
|
104
|
+
generated_key = get_random_secret_key()
|
|
105
|
+
with contextlib.suppress(OSError):
|
|
106
|
+
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
secret_file.write_text(generated_key, encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
return generated_key
|
core/admin.py
CHANGED
|
@@ -16,6 +16,7 @@ from django.contrib.auth.admin import (
|
|
|
16
16
|
GroupAdmin as DjangoGroupAdmin,
|
|
17
17
|
UserAdmin as DjangoUserAdmin,
|
|
18
18
|
)
|
|
19
|
+
import logging
|
|
19
20
|
from import_export import resources, fields
|
|
20
21
|
from import_export.admin import ImportExportModelAdmin
|
|
21
22
|
from import_export.widgets import ForeignKeyWidget
|
|
@@ -34,6 +35,7 @@ import calendar
|
|
|
34
35
|
import re
|
|
35
36
|
from django_object_actions import DjangoObjectActions
|
|
36
37
|
from ocpp.models import Transaction
|
|
38
|
+
from ocpp.rfid.utils import build_mode_toggle
|
|
37
39
|
from nodes.models import EmailOutbox
|
|
38
40
|
from .models import (
|
|
39
41
|
User,
|
|
@@ -78,6 +80,8 @@ from .widgets import OdooProductWidget
|
|
|
78
80
|
from .mcp import process as mcp_process
|
|
79
81
|
from .mcp.server import resolve_base_urls
|
|
80
82
|
|
|
83
|
+
logger = logging.getLogger(__name__)
|
|
84
|
+
|
|
81
85
|
|
|
82
86
|
admin.site.unregister(Group)
|
|
83
87
|
|
|
@@ -1998,6 +2002,13 @@ class ProductAdmin(EntityModelAdmin):
|
|
|
1998
2002
|
try:
|
|
1999
2003
|
results = self._search_odoo_products(profile, form)
|
|
2000
2004
|
except Exception:
|
|
2005
|
+
logger.exception(
|
|
2006
|
+
"Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
|
|
2007
|
+
getattr(getattr(request, "user", None), "pk", None),
|
|
2008
|
+
getattr(profile, "pk", None),
|
|
2009
|
+
getattr(profile, "host", None),
|
|
2010
|
+
getattr(profile, "database", None),
|
|
2011
|
+
)
|
|
2001
2012
|
form.add_error(None, _("Unable to fetch products from Odoo."))
|
|
2002
2013
|
results = []
|
|
2003
2014
|
else:
|
|
@@ -2302,15 +2313,13 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2302
2313
|
change_list_template = "admin/core/rfid/change_list.html"
|
|
2303
2314
|
resource_class = RFIDResource
|
|
2304
2315
|
list_display = (
|
|
2305
|
-
"
|
|
2316
|
+
"label",
|
|
2306
2317
|
"rfid",
|
|
2307
2318
|
"custom_label",
|
|
2308
2319
|
"color",
|
|
2309
2320
|
"kind",
|
|
2310
2321
|
"released",
|
|
2311
|
-
"energy_accounts_display",
|
|
2312
2322
|
"allowed",
|
|
2313
|
-
"added_on",
|
|
2314
2323
|
"last_seen_on",
|
|
2315
2324
|
)
|
|
2316
2325
|
list_filter = ("color", "released", "allowed")
|
|
@@ -2321,10 +2330,11 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2321
2330
|
readonly_fields = ("added_on", "last_seen_on")
|
|
2322
2331
|
form = RFIDForm
|
|
2323
2332
|
|
|
2324
|
-
def
|
|
2325
|
-
return
|
|
2333
|
+
def label(self, obj):
|
|
2334
|
+
return obj.label_id
|
|
2326
2335
|
|
|
2327
|
-
|
|
2336
|
+
label.admin_order_field = "label_id"
|
|
2337
|
+
label.short_description = "Label"
|
|
2328
2338
|
|
|
2329
2339
|
def scan_rfids(self, request, queryset):
|
|
2330
2340
|
return redirect("admin:core_rfid_scan")
|
|
@@ -2359,16 +2369,43 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
|
|
|
2359
2369
|
|
|
2360
2370
|
def scan_view(self, request):
|
|
2361
2371
|
context = self.admin_site.each_context(request)
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2372
|
+
table_mode, toggle_url, toggle_label = build_mode_toggle(request)
|
|
2373
|
+
public_view_url = reverse("rfid-reader")
|
|
2374
|
+
if table_mode:
|
|
2375
|
+
public_view_url = f"{public_view_url}?mode=table"
|
|
2376
|
+
context.update(
|
|
2377
|
+
{
|
|
2378
|
+
"scan_url": reverse("admin:core_rfid_scan_next"),
|
|
2379
|
+
"admin_change_url_template": reverse(
|
|
2380
|
+
"admin:core_rfid_change", args=[0]
|
|
2381
|
+
),
|
|
2382
|
+
"title": _("Scan RFIDs"),
|
|
2383
|
+
"opts": self.model._meta,
|
|
2384
|
+
"table_mode": table_mode,
|
|
2385
|
+
"toggle_url": toggle_url,
|
|
2386
|
+
"toggle_label": toggle_label,
|
|
2387
|
+
"public_view_url": public_view_url,
|
|
2388
|
+
}
|
|
2365
2389
|
)
|
|
2390
|
+
context["title"] = _("Scan RFIDs")
|
|
2391
|
+
context["opts"] = self.model._meta
|
|
2392
|
+
context["show_release_info"] = True
|
|
2366
2393
|
return render(request, "admin/core/rfid/scan.html", context)
|
|
2367
2394
|
|
|
2368
2395
|
def scan_next(self, request):
|
|
2369
2396
|
from ocpp.rfid.scanner import scan_sources
|
|
2397
|
+
from ocpp.rfid.reader import validate_rfid_value
|
|
2370
2398
|
|
|
2371
|
-
|
|
2399
|
+
if request.method == "POST":
|
|
2400
|
+
try:
|
|
2401
|
+
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
2402
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
2403
|
+
return JsonResponse({"error": "Invalid JSON payload"}, status=400)
|
|
2404
|
+
rfid = payload.get("rfid") or payload.get("value")
|
|
2405
|
+
kind = payload.get("kind")
|
|
2406
|
+
result = validate_rfid_value(rfid, kind=kind)
|
|
2407
|
+
else:
|
|
2408
|
+
result = scan_sources(request)
|
|
2372
2409
|
status = 500 if result.get("error") else 200
|
|
2373
2410
|
return JsonResponse(result, status=status)
|
|
2374
2411
|
|
core/auto_upgrade.py
CHANGED
|
@@ -39,8 +39,8 @@ def ensure_auto_upgrade_periodic_task(
|
|
|
39
39
|
except Exception:
|
|
40
40
|
return
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
interval_minutes = 5
|
|
42
|
+
_mode = mode_file.read_text().strip() or "version"
|
|
43
|
+
interval_minutes = 5
|
|
44
44
|
|
|
45
45
|
try:
|
|
46
46
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|