arthexis 0.1.19__py3-none-any.whl → 0.1.20__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arthexis
3
- Version: 0.1.19
3
+ Version: 0.1.20
4
4
  Summary: Power & Energy Infrastructure
5
5
  Author-email: "Rafael J. Guillén-Osorio" <tecnologia@gelectriic.com>
6
6
  License-Expression: GPL-3.0-only
@@ -171,7 +171,7 @@ Arthexis Constellation ships in four node roles tailored to different deployment
171
171
  <td valign="top"><strong>Multi-Device Edge, Network &amp; Data Acquisition</strong><br />Features: AP Router, Celery Queue, NGINX Server, RFID Scanner</td>
172
172
  </tr>
173
173
  <tr>
174
- <td valign="top"><strong>Constellation</strong></td>
174
+ <td valign="top"><strong>Watchtower</strong></td>
175
175
  <td valign="top"><strong>Multi-User Cloud &amp; Orchestration</strong><br />Features: Celery Queue, NGINX Server</td>
176
176
  </tr>
177
177
  </tbody>
@@ -184,7 +184,7 @@ Arthexis Constellation ships in four node roles tailored to different deployment
184
184
  - **[Windows](https://en.wikipedia.org/wiki/Microsoft_Windows)**: open [PowerShell](https://learn.microsoft.com/powershell/) or [Git Bash](https://gitforwindows.org/) and run the same command.
185
185
 
186
186
  ### 2. Start and stop
187
- Terminal nodes can start directly with the scripts below without installing; Control, Satellite, and Constellation roles require installation first. Both approaches listen on [`http://localhost:8000/`](http://localhost:8000/) by default.
187
+ Terminal nodes can start directly with the scripts below without installing; Control, Satellite, and Watchtower roles require installation first. Both approaches listen on [`http://localhost:8000/`](http://localhost:8000/) by default.
188
188
 
189
189
  - **[VS Code](https://code.visualstudio.com/)**
190
190
  - Open the folder and go to the **Run and Debug** panel (`Ctrl+Shift+D`).
@@ -1,4 +1,4 @@
1
- arthexis-0.1.19.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
1
+ arthexis-0.1.20.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
2
2
  config/__init__.py,sha256=AwpOX7il-DAOmkdJ5dVfVJ3CWWebn1lHyQNmkw1EkDw,103
3
3
  config/active_app.py,sha256=KJqYh-o91nPQjVXPEdbiJHzsI6cN9IZsBZ9O3iZ6Hyc,373
4
4
  config/asgi.py,sha256=T-0QSbtieEWKPIDkEcEdd-q6qjK8ZCwwjCaISOBkWdM,1296
@@ -15,15 +15,15 @@ config/settings_helpers.py,sha256=0BdBciUHIkwsWa0vV_RKAd4wDuEzgE7G-42XYiES4YQ,31
15
15
  config/urls.py,sha256=zvU4FSMKPlXUrGDjUgJCRFQztWb78wo1urW2DQf8qdI,5463
16
16
  config/wsgi.py,sha256=zU_mKlya6hejQ21PxKacTui3dUWd4ca_-YJNSYAoMX0,433
17
17
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- core/admin.py,sha256=tVoWAdi4LzNaGQBOkvyIAmmALn3cXj-fU9rdYUPBYNg,145572
18
+ core/admin.py,sha256=3MhT1TA8QUAgmjlwBmLD6Z1nT19ZUrXLCBSchV1R6LE,151562
19
19
  core/admin_history.py,sha256=XZ4b0ryufIka-xcwboK3DzmOL-INSx5Y2fJO-aJdV70,1783
20
20
  core/admindocs.py,sha256=ycD0bJ_VE6rTGf9ebXTiKdYkD8Y8hD2oQ4HxxoBURCM,6756
21
21
  core/apps.py,sha256=L_UMYI72-5jTo6nt8mfCbgdLhlP32D-8k76EZw0QyAA,14348
22
22
  core/auto_upgrade.py,sha256=1EffHHFylgydWdZM_id6CppV0QqBtdNw7cwBYVdbNdk,1715
23
- core/backends.py,sha256=Mzv_0YYF3iNWYAaKAJHHk75X2im-Kihu1zsg8eBeW2c,10509
23
+ core/backends.py,sha256=okrNW5Z-zDhJB9Al529aTOHGsTLACeQ6v_hMl4cyfnA,10704
24
24
  core/changelog.py,sha256=SRn37i5N-qb-RYV4Gpu9fg7Kv8gu4TH8ZwEmDRgN-Vo,12594
25
25
  core/entity.py,sha256=o4VteOXePGEsIWJFZ3fpq3DZsdWr3hpQ9A6kFbKosSE,4844
26
- core/environment.py,sha256=JLcvxAwU3OTL8O6kzwcUCFNZ3T28KanHrU_4mDBFamU,1584
26
+ core/environment.py,sha256=DfmVn2HVwX0rru-BSak6n3R3ier0UiHCZUoNszjt6oA,10182
27
27
  core/fields.py,sha256=d-qGahdcv4SRcO4fwCJ6_-NnEAP5xW0k3kODdAAAHSA,5412
28
28
  core/form_fields.py,sha256=h2xT8sO8EWbznsiARkxukFk69yoW6mQwqpgonA-d6aA,2496
29
29
  core/github_helper.py,sha256=fkjoUPwOB19zbGuk39LNLJ5AbIVKFf3rNCtnu-JISIc,5733
@@ -34,79 +34,79 @@ core/liveupdate.py,sha256=22m0ueQ10-6b-9pQJHY0_5WRYA98fysXKEXOWzIr550,691
34
34
  core/log_paths.py,sha256=lxvgXPgJtVNZ-kYrqV8VFle4GFQrSxG-yRTglqvclmU,3318
35
35
  core/mailer.py,sha256=JpW0RnD9uZ4O-wvlqeW7CMw95IFeCSkdvbankJDwHq0,2886
36
36
  core/middleware.py,sha256=j19K9SX-Emkv7BDDtAacR9g6RWsxhKHwCc8w23JFvMM,3388
37
- core/models.py,sha256=QIZazU5lwTaNQyO68Fm0Jg2AXDAjvg_HsK_djnKIv_Q,127829
38
- core/notifications.py,sha256=LYktoKM5k4q7YYWAJuqdeKM-p0Q-3gXgfqdq71qLS68,3916
37
+ core/models.py,sha256=U4oyaIuh1VSvpK7xwyZek09hRzVtY75zYXh1at7-_Pk,131276
38
+ core/notifications.py,sha256=jNLSuSCrhb8x5cDu_APeDlkrmbMejufk5eJOhssAC4I,3917
39
39
  core/public_wifi.py,sha256=yydLgxOo9DmJJbM4X_23wGR3gxL3YzHno54v9GssuFA,7213
40
- core/reference_utils.py,sha256=jeox3V4cZNxzM2Jj31g_mdb3O55zy9S2iXAZu70R1Zc,3627
40
+ core/reference_utils.py,sha256=tffCoyE1w4_SmYzXVWOsW8aR_ZVVTSPzrGhBq8K2xzA,3631
41
41
  core/release.py,sha256=y5NRs0XwB7RQVvMEZoNWYjTBxuG68dOMizUXLRx7-x8,31561
42
42
  core/rfid_import_export.py,sha256=petyhPvL0WUpehc6uGUDUhjYQ9AVvc6O49zuhDs6YFw,3516
43
- core/sigil_builder.py,sha256=VLwbrrD7Zr3SHfIDYV-V7uv7LEGiIelCSkeGswHibuc,4843
43
+ core/sigil_builder.py,sha256=nMuhYlw3j3LosrK85Q0pYsMcfGWCmrmdnv8UG7GTq_o,4856
44
44
  core/sigil_context.py,sha256=GCzjfM6fcVvBtSbVNfmE6sx3HU8QnxnXrCIytnNpQzM,439
45
45
  core/sigil_resolver.py,sha256=rCsypuX-0oWNfKyM1T9ZLWHY0Ezwhtk4VmI0L3krnsE,11098
46
46
  core/system.py,sha256=RyBqooWezM0Li_KCRskchD5Lub0cdqBmrnU6ilC8MPE,39823
47
- core/tasks.py,sha256=Vjv3wQaK-eRs3osSTfVbtgzBoBQvTHXHbrOKxDY7V00,11617
47
+ core/tasks.py,sha256=d0MQP5fmn5pA2VCFGxDMEX0xppDIIh8IAzPfGdnk8J4,12340
48
48
  core/temp_passwords.py,sha256=FieUnIUeQHmA1DoXvfJ5U6-Ayv3oDz-hSln5s_vNbA4,5271
49
49
  core/test_system_info.py,sha256=IMPz21KEs6OC5YbL7YaIBdmJVLjRY6MgPuZpldJB5OI,6935
50
- core/tests.py,sha256=H0vPRcwcD3HNscRInbMGvdzs9ixvmkLh3d4YSEeQfEc,98679
50
+ core/tests.py,sha256=a78FZug8JPbvIMx1MgxmcYGyTpMr3WboTvqjnxI9evs,98715
51
51
  core/tests_liveupdate.py,sha256=IquU8ztk6zbzC1bQu3Nrr3RzGzuujtPwDkANJHbxg98,510
52
52
  core/urls.py,sha256=YPippON1MAP2KeZZ8jHpcLO6mvbnKn1q7fdMv5Vm9dY,425
53
53
  core/user_data.py,sha256=02CfvxayELWSWZJCxWpv1Yz7EGg08yEu5MM31Khsi0U,21083
54
- core/views.py,sha256=oH210iFack4Yvt4ug5DGM3mx24GhPGFhZOlzi4WMV_w,89298
54
+ core/views.py,sha256=U3hwNqJz_I9ybmmdJvLrljrynJaUzGMeHhsiaBJD4mk,90297
55
55
  core/widgets.py,sha256=vlR9PlFfZGlkHm5X2cqNXuEBZSj8gmWaR6MO1mMy6kg,6904
56
56
  core/workgroup_urls.py,sha256=XR9IqwsSBI8epW7_-hHhWFU9wsyJfZehHwNQBhCgmpM,407
57
57
  core/workgroup_views.py,sha256=vtumF3-8YaTD-K6nSd8eYvUyq3ftpvWSEwtcp5B-P6o,2889
58
58
  nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
- nodes/admin.py,sha256=QuBB7JMqUvu2io6ZoZVTdqXLOX0CaB-394yUuwiRqDM,62022
59
+ nodes/admin.py,sha256=20gUgL5clMr_hNugybDqYdhoZh-4cWB2dVp6lgnHo_I,66960
60
60
  nodes/apps.py,sha256=AxK-sh9JBJZwNOLjqw9omCQGUQWw-45VRdYH07XhVJU,2732
61
61
  nodes/backends.py,sha256=dmmbS0X2YIlCDz2KjoDf_L62dy--nuqZF1rEDoi2JHM,5921
62
62
  nodes/dns.py,sha256=D5smXD7Rkh6E4MdL6TBL2WY8GgJg7Rx9z88LZrcMbTw,7048
63
63
  nodes/feature_checks.py,sha256=27e4PCkZ8BGWnJCOwMcY2Bo9z7LoeZWiTZuISWGnrzk,3996
64
64
  nodes/lcd.py,sha256=iKA8Wmq85KZD52aTzAU8ZmS144_gbdGMOXcE8yuECps,5758
65
- nodes/models.py,sha256=ay2FE-M0I82SR3__BPdfrXAaOvBL4CKua9_xOOm3mKg,62593
65
+ nodes/models.py,sha256=S2Gc68Q2_8r8RO1ZKxASN5mEOj1q4UWtxdum5A-p0-4,71693
66
66
  nodes/reports.py,sha256=NRYh3Y0SlZFhx31Zh2K03yO12ZrpxEHEY6T-dODA6WE,12059
67
- nodes/rfid_sync.py,sha256=SP_BRUhgYMBH-zXrcM7uShgFSGYmmuIMb1OdcU1e-5U,6956
67
+ nodes/rfid_sync.py,sha256=oeblawcp6xeLApdIuhsJS83OAk58Eu7pVVmgpAc0Nt8,6953
68
68
  nodes/signals.py,sha256=PtOKdQfb08mV1LgSZvn7ZAcfOyy2c3Xkq4AOpBQyUdE,622
69
- nodes/tasks.py,sha256=ur59ebu9z02idmvy_IvUQt3eu9LWRyyNpkg2szvIHCQ,1522
70
- nodes/tests.py,sha256=w_vUQNatk2K-FIu1yd1I4iD95q68DQ0H9uDYx1Cl3gA,156975
71
- nodes/urls.py,sha256=OaPypFt5gcmVe87NDzt1WMiC3YwHezih4jV_pGkICcE,601
69
+ nodes/tasks.py,sha256=7m9pKO-iI6JDdfPQ-GWRGown4mdyKrcroOnhbiWN7dY,5246
70
+ nodes/tests.py,sha256=OS9yI940avVZNNE9tn0i-Tr-BnyHKiUFKz0O1adNsUQ,176567
71
+ nodes/urls.py,sha256=-o9_pLo6XHerKMQwL0TW80wm6wmtVZqyNWcUhpdq9vk,915
72
72
  nodes/utils.py,sha256=wt7UuSXGuq79A-g-B6EW3kK49QWJBb7zhhkw4pun4k8,4474
73
- nodes/views.py,sha256=qJnAW5lIO5Up6n3IiTutERHZJTU-eZ_3ChpuZBIZ3oU,22526
73
+ nodes/views.py,sha256=ZaUlTLQUaRbQsiLCfTuwigxXSpoeUqBkrs938KsjmjA,36962
74
74
  ocpp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
- ocpp/admin.py,sha256=2gnFJFvDDTJmmMuuPVf9ZEuKLLsc6Z7_HFYSlg6TlwE,35336
75
+ ocpp/admin.py,sha256=1X2g-ICBJyp2Queh8Bexu8vXex6qU9onWN5yruczZ0s,38318
76
76
  ocpp/apps.py,sha256=i3NqrmIamNEQBT33CIqh7HOSOPmJXCMKrZ-DUd3whqg,842
77
- ocpp/consumers.py,sha256=s51784IwUoWfLaMI_zfnCve7ouZIB5xAnlgwThb40Gw,67979
77
+ ocpp/consumers.py,sha256=wt9tauYSlpWnGbaGsJKIPt4D13HyloLIHwmlK8iqA0Q,71674
78
78
  ocpp/evcs.py,sha256=q1mZrCVSZxXTrtYsDqH6lkeEcJ6tfSC7p9YxkDmpSCw,28883
79
79
  ocpp/evcs_discovery.py,sha256=OmrzgaOHwveDRJs8AIhrM3apX8_k2PPXh_oYaYpNW3c,3876
80
- ocpp/models.py,sha256=6BG-maPpcalLFoEHcbpecuXqia9mQx2ZATOu2pvUaBU,31846
80
+ ocpp/models.py,sha256=wUGYSh382e_zYXq059GCaWqUCjQTTvYbbt0PdlONMFc,34411
81
81
  ocpp/reference_utils.py,sha256=_UR82GfE93kv4766mHyVIfdhhyYvrT59660r3H6W55M,1072
82
82
  ocpp/routing.py,sha256=3kQya-MdJ00778xDmX0esQLBP05P200V45asg-CGNoo,438
83
83
  ocpp/simulator.py,sha256=vnyd59QffT79AaPhmfM_jipni_nqfG57X5tXyx1rBoc,28016
84
84
  ocpp/status_display.py,sha256=YGFosd5HJETA0DcLdsjvx6EfhZSnI8Aa3cMnHG2WsBE,939
85
85
  ocpp/store.py,sha256=gLCSaP9KKF7li2ALlE3O3RW5eVJtoe-_YHfKhdf0VOM,18943
86
- ocpp/tasks.py,sha256=OxIaI4OSLz9AfwLexnXhiBILBimTs3gVrPd197Jguqg,5819
87
- ocpp/test_export_import.py,sha256=Zp6xUBlRq7XkdKjOs78BhkujNQdklxi4RLxU8c-udWY,4530
88
- ocpp/test_rfid.py,sha256=hMFQwYDPhwfTW2XdDT5q__gKrL8YPbv7DNNMdwzJ7BQ,39105
89
- ocpp/tests.py,sha256=Y9Z4hfeiqRRCFP8utsVadFTLcKBOwTVIQuDJK3dYqas,186492
90
- ocpp/transactions_io.py,sha256=YnxI-Tv5UFxv0JuFK3XpoqFYP8eRT8sMuDiqkiMHPtU,7387
91
- ocpp/urls.py,sha256=3T5O5DSwVk4PbhPx5p4D3UseCWvC5xV5HwJLSM6AfA8,1700
92
- ocpp/views.py,sha256=TRsfkm8VpygNPW7WIK_hQGX5a2ohrKnTcPfTM11WXVc,60141
86
+ ocpp/tasks.py,sha256=n2axf1Oo7brZtXRe-uCwt6K0f57ZlHUNvp6c5W0Gdzo,6035
87
+ ocpp/test_export_import.py,sha256=ouQbTCp4mxfqoK6gondlu3PPcyrT9jSbWAX5gqqgaNk,4561
88
+ ocpp/test_rfid.py,sha256=IhFSlvsI8A8D3S32sRE298nYfrmqxbv7GfVErtNU3DQ,39137
89
+ ocpp/tests.py,sha256=8Tzb_agTQdLdHnccAGCTfYbcRQHznz5DJI3Hb94mg5M,190065
90
+ ocpp/transactions_io.py,sha256=p2aUsKlCDYnZ4ZBrOM7pxXoW_w3Tbm-tvRFSjnR3x24,7738
91
+ ocpp/urls.py,sha256=5ZomUtznJe3kfs8E-DtVp12eFva5jUuJdpTEczIsQ5w,1730
92
+ ocpp/views.py,sha256=G2y2g55XJBJ0gzZPUqA4pplKfQIrO3-fjH5CkOYqDsw,63152
93
93
  pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
94
94
  pages/admin.py,sha256=E-jcxUI-89gFgqGI38HMSHbfDw1inpSUiRR83utJ0fc,32512
95
95
  pages/apps.py,sha256=o8gQP-VdZOk9LXIEo6IDmOSqX3TP8XypBvKGGWLoQ0k,351
96
96
  pages/checks.py,sha256=sM8_hUVM_HOIocvtTb2sY3AaSEvbTnOlO46UchGVd-0,1527
97
- pages/context_processors.py,sha256=JELk1V5R5yOVIm5h3JfikG1_V6QSUN3LDlKWF8p9zCE,5543
98
- pages/defaults.py,sha256=l36APPAZO4ub2A8Pp-lQGujKeOVYcyzU6t7-kOk8VoA,522
97
+ pages/context_processors.py,sha256=0Ie3_lPY6-j6f8eXtSzMlWpprMzY0tvX9fcOdpJr6Vs,5788
98
+ pages/defaults.py,sha256=3tjv3nFPxwpFu6poJ1Ez1MP92Q6ZvyRluftKHlU-zeI,522
99
99
  pages/forms.py,sha256=T0atqxdNds3IBP8N-9c5-ACf3iR9FzzmhzK4MOa24e8,7058
100
100
  pages/middleware.py,sha256=MYd5Nko4AnFg3orY6MuyvvNg_I6GCIf8mDW8znSOgvQ,7042
101
101
  pages/models.py,sha256=Ms_m_tzzstNghN_JOzyfwsbllDBIl_AKnYdTRTJthqM,22173
102
- pages/module_defaults.py,sha256=R8n6eQDjNRMpO-DW86OFGvyRarju5Bx7Fnb275R_z24,5411
102
+ pages/module_defaults.py,sha256=rCAY8aTyxYNL0M5zDr393rX-Gi-svXqKtuLXm0rILrQ,5444
103
103
  pages/site_config.py,sha256=f1Me0GFdHeGbIeyMlQNzD2e6hym59YHqbz92U_ppffY,4057
104
104
  pages/tasks.py,sha256=ivcba_3wSQ1-cku0oDplzw6vLeQ9hBq3R4TG-LmR5gs,1913
105
- pages/tests.py,sha256=5rRi1e_mSoS6VGykrz58VZTb428RJRR9zMi3-Pfg3FU,138871
106
- pages/urls.py,sha256=BbfEl6SbNfyFo2acA8Wze6aHV2kJR-0339FCIOi6cD4,1231
105
+ pages/tests.py,sha256=rgMYFiei8ONBNts4vJzukjaWN-WUGLxr6LFwtSHJQ_8,141550
106
+ pages/urls.py,sha256=rcK0zEJTSCurYhx77ZK_GVVMSHTS5ZJWoKTJkh1lj-M,1305
107
107
  pages/utils.py,sha256=CR4D1debgJLGgXsw9kap2ggpe7fIpSoWS_ivbgMNp2k,564
108
- pages/views.py,sha256=hthdoV352cZRru-Wi3Wtv0ZXOjOba7z7rIpHLlzfuVo,47665
109
- arthexis-0.1.19.dist-info/METADATA,sha256=ko-yFBbCoQEs9EUfs6BzAcFK5qfn_7uvRJcqbk6pgpQ,11757
110
- arthexis-0.1.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
111
- arthexis-0.1.19.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
112
- arthexis-0.1.19.dist-info/RECORD,,
108
+ pages/views.py,sha256=2ik28HW70a0S84K8hMQ8qL9yzVH1WggXT7S8G5ZaLgA,50940
109
+ arthexis-0.1.20.dist-info/METADATA,sha256=RLz9veHKDPM6UzcxnJgZcxj3ri6TuTPVLSK9QBn48mM,11751
110
+ arthexis-0.1.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
111
+ arthexis-0.1.20.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
112
+ arthexis-0.1.20.dist-info/RECORD,,
core/admin.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  from io import BytesIO
2
3
  import os
3
4
 
@@ -2909,7 +2910,7 @@ class CopyRFIDForm(forms.Form):
2909
2910
  normalized = (cleaned or "").strip().upper()
2910
2911
  if not normalized:
2911
2912
  raise forms.ValidationError(_("RFID value is required."))
2912
- if RFID.objects.filter(rfid=normalized).exists():
2913
+ if RFID.matching_queryset(normalized).exists():
2913
2914
  raise forms.ValidationError(
2914
2915
  _("An RFID with this value already exists.")
2915
2916
  )
@@ -2943,6 +2944,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2943
2944
  "print_card_labels",
2944
2945
  "print_release_form",
2945
2946
  "copy_rfids",
2947
+ "merge_rfids",
2946
2948
  "toggle_selected_user_data",
2947
2949
  "toggle_selected_released",
2948
2950
  "toggle_selected_allowed",
@@ -3177,6 +3179,145 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3177
3179
  context["media"] = self.media + form.media
3178
3180
  return TemplateResponse(request, "admin/core/rfid/copy.html", context)
3179
3181
 
3182
+ @admin.action(description=_("Merge RFID cards"))
3183
+ def merge_rfids(self, request, queryset):
3184
+ tags = list(queryset.prefetch_related("energy_accounts"))
3185
+ if len(tags) < 2:
3186
+ self.message_user(
3187
+ request,
3188
+ _("Select at least two RFIDs to merge."),
3189
+ level=messages.WARNING,
3190
+ )
3191
+ return None
3192
+
3193
+ normalized_map: dict[int, str] = {}
3194
+ groups: defaultdict[str, list[RFID]] = defaultdict(list)
3195
+ unmatched = 0
3196
+ for tag in tags:
3197
+ normalized = RFID.normalize_code(tag.rfid)
3198
+ normalized_map[tag.pk] = normalized
3199
+ if not normalized:
3200
+ unmatched += 1
3201
+ continue
3202
+ prefix = normalized[: RFID.MATCH_PREFIX_LENGTH]
3203
+ groups[prefix].append(tag)
3204
+
3205
+ merge_groups: list[list[RFID]] = []
3206
+ skipped = unmatched
3207
+ for prefix, group in groups.items():
3208
+ if len(group) < 2:
3209
+ skipped += len(group)
3210
+ continue
3211
+ group.sort(
3212
+ key=lambda item: (
3213
+ len(normalized_map.get(item.pk, "")),
3214
+ normalized_map.get(item.pk, ""),
3215
+ item.pk,
3216
+ )
3217
+ )
3218
+ merge_groups.append(group)
3219
+
3220
+ if not merge_groups:
3221
+ self.message_user(
3222
+ request,
3223
+ _("No matching RFIDs were found to merge."),
3224
+ level=messages.WARNING,
3225
+ )
3226
+ return None
3227
+
3228
+ merged_tags = 0
3229
+ merged_groups = 0
3230
+ conflicting_accounts = 0
3231
+ with transaction.atomic():
3232
+ for group in merge_groups:
3233
+ canonical = group[0]
3234
+ update_fields: set[str] = set()
3235
+ existing_account_ids = set(
3236
+ canonical.energy_accounts.values_list("pk", flat=True)
3237
+ )
3238
+ for tag in group[1:]:
3239
+ other_value = normalized_map.get(tag.pk, "")
3240
+ if canonical.adopt_rfid(other_value):
3241
+ update_fields.add("rfid")
3242
+ normalized_map[canonical.pk] = RFID.normalize_code(
3243
+ canonical.rfid
3244
+ )
3245
+ accounts = list(tag.energy_accounts.all())
3246
+ if accounts:
3247
+ transferable: list[EnergyAccount] = []
3248
+ for account in accounts:
3249
+ if existing_account_ids and account.pk not in existing_account_ids:
3250
+ conflicting_accounts += 1
3251
+ continue
3252
+ transferable.append(account)
3253
+ if transferable:
3254
+ canonical.energy_accounts.add(*transferable)
3255
+ existing_account_ids.update(
3256
+ account.pk for account in transferable
3257
+ )
3258
+ if tag.allowed and not canonical.allowed:
3259
+ canonical.allowed = True
3260
+ update_fields.add("allowed")
3261
+ if tag.released and not canonical.released:
3262
+ canonical.released = True
3263
+ update_fields.add("released")
3264
+ if tag.key_a_verified and not canonical.key_a_verified:
3265
+ canonical.key_a_verified = True
3266
+ update_fields.add("key_a_verified")
3267
+ if tag.key_b_verified and not canonical.key_b_verified:
3268
+ canonical.key_b_verified = True
3269
+ update_fields.add("key_b_verified")
3270
+ if tag.last_seen_on and (
3271
+ not canonical.last_seen_on
3272
+ or tag.last_seen_on > canonical.last_seen_on
3273
+ ):
3274
+ canonical.last_seen_on = tag.last_seen_on
3275
+ update_fields.add("last_seen_on")
3276
+ if not canonical.origin_node and tag.origin_node_id:
3277
+ canonical.origin_node = tag.origin_node
3278
+ update_fields.add("origin_node")
3279
+ merged_tags += 1
3280
+ tag.delete()
3281
+ if update_fields:
3282
+ canonical.save(update_fields=sorted(update_fields))
3283
+ merged_groups += 1
3284
+
3285
+ if merged_tags:
3286
+ self.message_user(
3287
+ request,
3288
+ ngettext(
3289
+ "Merged %(removed)d RFID into %(groups)d canonical record.",
3290
+ "Merged %(removed)d RFIDs into %(groups)d canonical records.",
3291
+ merged_tags,
3292
+ )
3293
+ % {"removed": merged_tags, "groups": merged_groups},
3294
+ level=messages.SUCCESS,
3295
+ )
3296
+
3297
+ if skipped:
3298
+ self.message_user(
3299
+ request,
3300
+ ngettext(
3301
+ "Skipped %(count)d RFID because it did not share the first %(length)d characters with another selection.",
3302
+ "Skipped %(count)d RFIDs because they did not share the first %(length)d characters with another selection.",
3303
+ skipped,
3304
+ )
3305
+ % {"count": skipped, "length": RFID.MATCH_PREFIX_LENGTH},
3306
+ level=messages.WARNING,
3307
+ )
3308
+
3309
+ if conflicting_accounts:
3310
+ self.message_user(
3311
+ request,
3312
+ ngettext(
3313
+ "Skipped %(count)d energy account because the RFID was already linked to a different account.",
3314
+ "Skipped %(count)d energy accounts because the RFID was already linked to a different account.",
3315
+ conflicting_accounts,
3316
+ )
3317
+ % {"count": conflicting_accounts},
3318
+ level=messages.WARNING,
3319
+ )
3320
+
3180
3321
  def _render_card_labels(
3181
3322
  self,
3182
3323
  request,
core/backends.py CHANGED
@@ -81,10 +81,16 @@ class RFIDBackend:
81
81
  if not rfid_value:
82
82
  return None
83
83
 
84
- tag = RFID.objects.filter(rfid=rfid_value).first()
85
- if not tag or not tag.allowed:
84
+ tag = RFID.matching_queryset(rfid_value).filter(allowed=True).first()
85
+ if not tag:
86
86
  return None
87
87
 
88
+ update_fields: list[str] = []
89
+ if tag.adopt_rfid(rfid_value):
90
+ update_fields.append("rfid")
91
+ if update_fields:
92
+ tag.save(update_fields=update_fields)
93
+
88
94
  command = (tag.external_command or "").strip()
89
95
  if command:
90
96
  env = os.environ.copy()
core/environment.py CHANGED
@@ -1,11 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ from pathlib import Path
4
8
 
9
+ from django import forms
5
10
  from django.conf import settings
6
11
  from django.contrib import admin
12
+ from django.core.exceptions import PermissionDenied
7
13
  from django.template.response import TemplateResponse
8
- from django.urls import path
14
+ from django.urls import path, reverse
9
15
  from django.utils.translation import gettext_lazy as _
10
16
 
11
17
 
@@ -15,23 +21,229 @@ def _get_django_settings():
15
21
  )
16
22
 
17
23
 
24
+ class NetworkSetupForm(forms.Form):
25
+ prompt_for_password = forms.BooleanField(
26
+ label=_("Prompt for new WiFi password"),
27
+ required=False,
28
+ help_text=_("Add --password to request a password even when one is already configured."),
29
+ )
30
+ access_point_name = forms.CharField(
31
+ label=_("Access point name"),
32
+ required=False,
33
+ max_length=32,
34
+ help_text=_("Use --ap to set the wlan0 access point name."),
35
+ )
36
+ skip_firewall_validation = forms.BooleanField(
37
+ label=_("Skip firewall validation"),
38
+ required=False,
39
+ help_text=_("Add --no-firewall to bypass firewall port checks."),
40
+ )
41
+ skip_access_point_configuration = forms.BooleanField(
42
+ label=_("Skip access point configuration"),
43
+ required=False,
44
+ help_text=_("Add --no-ap to leave the access point configuration unchanged."),
45
+ )
46
+ allow_unsafe_changes = forms.BooleanField(
47
+ label=_("Allow modifying the active internet connection"),
48
+ required=False,
49
+ help_text=_("Include --unsafe to allow changes that may interrupt connectivity."),
50
+ )
51
+ interactive = forms.BooleanField(
52
+ label=_("Prompt before each step"),
53
+ required=False,
54
+ help_text=_("Run the script with --interactive to confirm each action."),
55
+ )
56
+ install_watchdog = forms.BooleanField(
57
+ label=_("Install WiFi watchdog service"),
58
+ required=False,
59
+ initial=True,
60
+ help_text=_("Keep selected to retain the watchdog or clear to add --no-watchdog."),
61
+ )
62
+ vnc_validation = forms.ChoiceField(
63
+ label=_("VNC validation"),
64
+ choices=(
65
+ ("default", _("Use script default (skip validation)")),
66
+ ("require", _("Require that a VNC service is enabled (--vnc)")),
67
+ ),
68
+ initial="default",
69
+ required=True,
70
+ )
71
+ ethernet_subnet = forms.CharField(
72
+ label=_("Ethernet subnet"),
73
+ required=False,
74
+ help_text=_("Provide N or N/P (prefix 16 or 24) to supply --subnet."),
75
+ )
76
+ update_ap_password_only = forms.BooleanField(
77
+ label=_("Update access point password only"),
78
+ required=False,
79
+ help_text=_("Use --ap-set-password without running other setup steps."),
80
+ )
81
+
82
+ def clean_ethernet_subnet(self) -> str:
83
+ value = self.cleaned_data.get("ethernet_subnet", "")
84
+ if not value:
85
+ return ""
86
+ raw = value.strip()
87
+ match = re.fullmatch(r"(?P<subnet>\d{1,3})(?:/(?P<prefix>\d{1,2}))?", raw)
88
+ if not match:
89
+ raise forms.ValidationError(
90
+ _("Enter a subnet in the form N or N/P with prefix 16 or 24."),
91
+ )
92
+ subnet = int(match.group("subnet"))
93
+ if subnet < 0 or subnet > 254:
94
+ raise forms.ValidationError(
95
+ _("Subnet value must be between 0 and 254."),
96
+ )
97
+ prefix_value = match.group("prefix")
98
+ if prefix_value:
99
+ prefix = int(prefix_value)
100
+ if prefix not in {16, 24}:
101
+ raise forms.ValidationError(
102
+ _("Subnet prefix must be 16 or 24."),
103
+ )
104
+ return f"{subnet}/{prefix}"
105
+ return str(subnet)
106
+
107
+ def clean(self) -> dict:
108
+ cleaned_data = super().clean()
109
+ if cleaned_data.get("update_ap_password_only"):
110
+ other_flags = [
111
+ cleaned_data.get("prompt_for_password"),
112
+ bool(cleaned_data.get("access_point_name")),
113
+ cleaned_data.get("skip_firewall_validation"),
114
+ cleaned_data.get("skip_access_point_configuration"),
115
+ cleaned_data.get("allow_unsafe_changes"),
116
+ cleaned_data.get("interactive"),
117
+ bool(cleaned_data.get("ethernet_subnet")),
118
+ cleaned_data.get("vnc_validation") == "require",
119
+ not cleaned_data.get("install_watchdog", True),
120
+ ]
121
+ if any(other_flags):
122
+ raise forms.ValidationError(
123
+ _(
124
+ "Update access point password only cannot be combined with other network-setup options."
125
+ )
126
+ )
127
+ return cleaned_data
128
+
129
+ def build_command(self, script_path: Path) -> list[str]:
130
+ command = [str(script_path)]
131
+ data = self.cleaned_data
132
+ if data.get("update_ap_password_only"):
133
+ command.append("--ap-set-password")
134
+ return command
135
+ if data.get("prompt_for_password"):
136
+ command.append("--password")
137
+ access_point_name = data.get("access_point_name")
138
+ if access_point_name:
139
+ command.extend(["--ap", access_point_name])
140
+ if data.get("skip_firewall_validation"):
141
+ command.append("--no-firewall")
142
+ if data.get("skip_access_point_configuration"):
143
+ command.append("--no-ap")
144
+ if data.get("allow_unsafe_changes"):
145
+ command.append("--unsafe")
146
+ if data.get("interactive"):
147
+ command.append("--interactive")
148
+ if not data.get("install_watchdog"):
149
+ command.append("--no-watchdog")
150
+ if data.get("vnc_validation") == "require":
151
+ command.append("--vnc")
152
+ ethernet_subnet = data.get("ethernet_subnet")
153
+ if ethernet_subnet:
154
+ command.extend(["--subnet", ethernet_subnet])
155
+ return command
156
+
157
+
18
158
  def _environment_view(request):
19
159
  env_vars = sorted(os.environ.items())
20
160
  context = admin.site.each_context(request)
161
+ environment_tasks: list[dict[str, str]] = []
162
+ if request.user.is_superuser:
163
+ environment_tasks.append(
164
+ {
165
+ "name": _("Run network-setup"),
166
+ "description": _(
167
+ "Configure network services, stage managed NGINX sites, and review script output."
168
+ ),
169
+ "url": reverse("admin:environment-network-setup"),
170
+ }
171
+ )
21
172
  context.update(
22
173
  {
23
- "title": _("Environ"),
174
+ "title": _("Environment"),
24
175
  "env_vars": env_vars,
176
+ "environment_tasks": environment_tasks,
25
177
  }
26
178
  )
27
179
  return TemplateResponse(request, "admin/environment.html", context)
28
180
 
29
181
 
182
+ def _environment_network_setup_view(request):
183
+ if not request.user.is_superuser:
184
+ raise PermissionDenied
185
+
186
+ script_path = Path(settings.BASE_DIR) / "network-setup.sh"
187
+ command_result: dict[str, object] | None = None
188
+
189
+ if request.method == "POST":
190
+ form = NetworkSetupForm(request.POST)
191
+ if form.is_valid():
192
+ command = form.build_command(script_path)
193
+ if not script_path.exists():
194
+ form.add_error(None, _("The network-setup.sh script could not be found."))
195
+ else:
196
+ try:
197
+ completed = subprocess.run(
198
+ command,
199
+ capture_output=True,
200
+ text=True,
201
+ cwd=settings.BASE_DIR,
202
+ check=False,
203
+ )
204
+ except FileNotFoundError:
205
+ form.add_error(None, _("The network-setup.sh script could not be executed."))
206
+ except OSError as exc:
207
+ form.add_error(
208
+ None,
209
+ _("Unable to execute network-setup.sh: %(error)s")
210
+ % {"error": str(exc)},
211
+ )
212
+ else:
213
+ if hasattr(shlex, "join"):
214
+ command_display = shlex.join(command)
215
+ else:
216
+ command_display = " ".join(shlex.quote(part) for part in command)
217
+ command_result = {
218
+ "command": command_display,
219
+ "stdout": completed.stdout,
220
+ "stderr": completed.stderr,
221
+ "returncode": completed.returncode,
222
+ "succeeded": completed.returncode == 0,
223
+ }
224
+ else:
225
+ form = NetworkSetupForm()
226
+
227
+ context = admin.site.each_context(request)
228
+ context.update(
229
+ {
230
+ "title": _("Run network-setup"),
231
+ "form": form,
232
+ "command_result": command_result,
233
+ "task_description": _(
234
+ "Configure script flags, execute network-setup, and review the captured output."
235
+ ),
236
+ "back_url": reverse("admin:environment"),
237
+ }
238
+ )
239
+ return TemplateResponse(request, "admin/environment_network_setup.html", context)
240
+
241
+
30
242
  def _config_view(request):
31
243
  context = admin.site.each_context(request)
32
244
  context.update(
33
245
  {
34
- "title": _("Config"),
246
+ "title": _("Django Settings"),
35
247
  "django_settings": _get_django_settings(),
36
248
  }
37
249
  )
@@ -39,12 +251,17 @@ def _config_view(request):
39
251
 
40
252
 
41
253
  def patch_admin_environment_view() -> None:
42
- """Add custom admin view for environment information."""
254
+ """Register the Environment and Config admin views on the main admin site."""
43
255
  original_get_urls = admin.site.get_urls
44
256
 
45
257
  def get_urls():
46
258
  urls = original_get_urls()
47
259
  custom = [
260
+ path(
261
+ "environment/network-setup/",
262
+ admin.site.admin_view(_environment_network_setup_view),
263
+ name="environment-network-setup",
264
+ ),
48
265
  path(
49
266
  "environment/",
50
267
  admin.site.admin_view(_environment_view),