arthexis 0.1.16__py3-none-any.whl → 0.1.18__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arthexis
3
- Version: 0.1.16
3
+ Version: 0.1.18
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
@@ -1,4 +1,4 @@
1
- arthexis-0.1.16.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
1
+ arthexis-0.1.18.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
@@ -8,20 +8,20 @@ config/context_processors.py,sha256=p74ocuzPRFI9vKSeIaJ42Vu0V2GtGph1t-2DkRo4NMw,
8
8
  config/horologia_app.py,sha256=puO_hObEYcLvE7PqcY_sGv1thnxJ018YKHKZWqNXha4,187
9
9
  config/loadenv.py,sha256=CjXx-wBaTt1wixub4GJ5CMSMFqtiK5JURc7cPXpqO7s,287
10
10
  config/logging.py,sha256=1cIbPgRshHuMKnVEEH0jKpRAlJSpewvLFbYDz7sCBG4,2104
11
- config/middleware.py,sha256=mDU5tye8H4WCjpJqocwd0vmrzoVEYwdz9WTP4Hcr6dI,719
11
+ config/middleware.py,sha256=zF8Cma0n5G8NNdh2LVeNJi7Hgl1G4mF9msRE2eRi1RU,2328
12
12
  config/offline.py,sha256=X-yDcyoI4C44Y27lpkUwszY_09GwwFfazEsthKJpQ70,1382
13
- config/settings.py,sha256=2ANaLD4_Vq3E84rDA2ulqK_DT_hu89Zj4ED5FVEjPBA,21427
13
+ config/settings.py,sha256=fkLL3nbh01KqTVS9M7QH19i3HOuvVD6OTEvApy56Y4w,21569
14
14
  config/settings_helpers.py,sha256=0BdBciUHIkwsWa0vV_RKAd4wDuEzgE7G-42XYiES4YQ,3127
15
- config/urls.py,sha256=lXl2KKsbIehjOW0W6FHAsxkZJ-3DAo37f2ICb1dvvz8,5320
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=c3Z5UE3cBXtJR6pQcLQYR0BWe6W7SBNvR0W9lG8b2ZU,143397
18
+ core/admin.py,sha256=tVoWAdi4LzNaGQBOkvyIAmmALn3cXj-fU9rdYUPBYNg,145572
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=y12jggdsn90bqBDW8L0kGy2lS8-P8Ym2PmPJ8AiPjYc,10343
24
- core/changelog.py,sha256=grMvuEektkymwvkC1ubXFZF2JFopPybT82k4rUIlfmo,10840
23
+ core/backends.py,sha256=Mzv_0YYF3iNWYAaKAJHHk75X2im-Kihu1zsg8eBeW2c,10509
24
+ core/changelog.py,sha256=SRn37i5N-qb-RYV4Gpu9fg7Kv8gu4TH8ZwEmDRgN-Vo,12594
25
25
  core/entity.py,sha256=o4VteOXePGEsIWJFZ3fpq3DZsdWr3hpQ9A6kFbKosSE,4844
26
26
  core/environment.py,sha256=JLcvxAwU3OTL8O6kzwcUCFNZ3T28KanHrU_4mDBFamU,1584
27
27
  core/fields.py,sha256=d-qGahdcv4SRcO4fwCJ6_-NnEAP5xW0k3kODdAAAHSA,5412
@@ -34,24 +34,24 @@ 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=cVfk96ujtn7Re7YiHm8CU1Q_URrDjJdsEwi2-3vWyWE,124680
37
+ core/models.py,sha256=QIZazU5lwTaNQyO68Fm0Jg2AXDAjvg_HsK_djnKIv_Q,127829
38
38
  core/notifications.py,sha256=LYktoKM5k4q7YYWAJuqdeKM-p0Q-3gXgfqdq71qLS68,3916
39
39
  core/public_wifi.py,sha256=yydLgxOo9DmJJbM4X_23wGR3gxL3YzHno54v9GssuFA,7213
40
40
  core/reference_utils.py,sha256=jeox3V4cZNxzM2Jj31g_mdb3O55zy9S2iXAZu70R1Zc,3627
41
- core/release.py,sha256=tcRddwl0_TugcmlGlTB_7gPzICfAK2wjGtdWLVg_eaE,29756
41
+ core/release.py,sha256=y5NRs0XwB7RQVvMEZoNWYjTBxuG68dOMizUXLRx7-x8,31561
42
42
  core/rfid_import_export.py,sha256=petyhPvL0WUpehc6uGUDUhjYQ9AVvc6O49zuhDs6YFw,3516
43
43
  core/sigil_builder.py,sha256=VLwbrrD7Zr3SHfIDYV-V7uv7LEGiIelCSkeGswHibuc,4843
44
44
  core/sigil_context.py,sha256=GCzjfM6fcVvBtSbVNfmE6sx3HU8QnxnXrCIytnNpQzM,439
45
45
  core/sigil_resolver.py,sha256=rCsypuX-0oWNfKyM1T9ZLWHY0Ezwhtk4VmI0L3krnsE,11098
46
- core/system.py,sha256=KRIvgEr0XwzdWuvLXmOsK43WRjPoDEuOvPn2nhVz0s4,35704
47
- core/tasks.py,sha256=PiZ5qKngXP8Q3rVEn_l9zCQ-9tx4Z8v7-t0l5fHnMvA,12535
46
+ core/system.py,sha256=tqx8-4kyViMGKI3EAaxztrbyes4TSTPQ9YsIKzdVs6c,35731
47
+ core/tasks.py,sha256=MtijKTtRHUEsTP4nVJFYx5B8Ls8EXmtzpBuq8FU5b9s,12302
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=J1ttP5u3UdU242NBoYIfbnpFVzJsmLNI671ChgQQuik,93782
50
+ core/tests.py,sha256=H0vPRcwcD3HNscRInbMGvdzs9ixvmkLh3d4YSEeQfEc,98679
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=N47qQFwSSm0gCKRf2oKJIrYVESt0BIIqnTNh4wMlf6s,85237
54
+ core/views.py,sha256=BTnbaGgSWz4lX7VKVttVIJGWyg7oHnYMMEQTOX44Zhc,88240
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
@@ -72,9 +72,9 @@ nodes/urls.py,sha256=HmAxj6sr6nMf0lii_1UX7sNBJUcrkaiKm3R9ofUWhvM,677
72
72
  nodes/utils.py,sha256=wt7UuSXGuq79A-g-B6EW3kK49QWJBb7zhhkw4pun4k8,4474
73
73
  nodes/views.py,sha256=TyW7exkVaR-o2_XkJXSi9jQ_BygXOE2cQFs4xlI20Xc,22905
74
74
  ocpp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
- ocpp/admin.py,sha256=9rmECU832lpD7Hcb4_6pkLmQ1D_y9Q6Xl9n8pa3ux8c,31449
75
+ ocpp/admin.py,sha256=gMxHkT5KSp4kPWJcDJ1Y65VqgrwFTZl8Y516FO8oi3g,34658
76
76
  ocpp/apps.py,sha256=i3NqrmIamNEQBT33CIqh7HOSOPmJXCMKrZ-DUd3whqg,842
77
- ocpp/consumers.py,sha256=LgplrJQOfs8CKCtmBcRQLcDVB4Tz7YZpb3I7r2lAorQ,66352
77
+ ocpp/consumers.py,sha256=s51784IwUoWfLaMI_zfnCve7ouZIB5xAnlgwThb40Gw,67979
78
78
  ocpp/evcs.py,sha256=q1mZrCVSZxXTrtYsDqH6lkeEcJ6tfSC7p9YxkDmpSCw,28883
79
79
  ocpp/evcs_discovery.py,sha256=OmrzgaOHwveDRJs8AIhrM3apX8_k2PPXh_oYaYpNW3c,3876
80
80
  ocpp/models.py,sha256=QjEaygY7Tl47Q6z2uxP6ftUn4JeD8-JQX2fcwrCaEEg,31631
@@ -85,14 +85,14 @@ ocpp/status_display.py,sha256=YGFosd5HJETA0DcLdsjvx6EfhZSnI8Aa3cMnHG2WsBE,939
85
85
  ocpp/store.py,sha256=rHrP2Iq2ycMFbal1UEJVXb7r4gDtI5yifaE3nT0tjJw,18855
86
86
  ocpp/tasks.py,sha256=OxIaI4OSLz9AfwLexnXhiBILBimTs3gVrPd197Jguqg,5819
87
87
  ocpp/test_export_import.py,sha256=Zp6xUBlRq7XkdKjOs78BhkujNQdklxi4RLxU8c-udWY,4530
88
- ocpp/test_rfid.py,sha256=1DeIfE9diIOV8kJoVH_5HYLOpv6GWQt7_SbZfFlpBZw,34690
89
- ocpp/tests.py,sha256=2pa6Vw5aMe-ak3LthHyo5jJmibD8I_BpFOapBwWkEdo,177608
88
+ ocpp/test_rfid.py,sha256=hMFQwYDPhwfTW2XdDT5q__gKrL8YPbv7DNNMdwzJ7BQ,39105
89
+ ocpp/tests.py,sha256=gFOMB3ioTa7dxwoA9pYwpP2cDu2GvhBsJeMKT7XyzAw,185987
90
90
  ocpp/transactions_io.py,sha256=YnxI-Tv5UFxv0JuFK3XpoqFYP8eRT8sMuDiqkiMHPtU,7387
91
91
  ocpp/urls.py,sha256=3T5O5DSwVk4PbhPx5p4D3UseCWvC5xV5HwJLSM6AfA8,1700
92
- ocpp/views.py,sha256=LE2mqB5FTno4SYzBWabu9g95o77Ojo2uFtTG6K5W9F0,56311
92
+ ocpp/views.py,sha256=PYuSUclq9IZrKrS4iHP2EJ_-alRcLgXDXabmmenhda0,57970
93
93
  pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
94
- pages/admin.py,sha256=f2IYr-nGg9FmafQfDmIRrv01UuXh4mdhFJbnw-ytzHU,27459
95
- pages/apps.py,sha256=AzUNXQX0yRUX5jus-5EDReDb0nOEY8DBgYaM970u6Io,288
94
+ pages/admin.py,sha256=25vuZVdOJKMnTAnI1BUIyDhO8R-BuaAu8Wj56BNxyJ0,30349
95
+ pages/apps.py,sha256=o8gQP-VdZOk9LXIEo6IDmOSqX3TP8XypBvKGGWLoQ0k,351
96
96
  pages/checks.py,sha256=sM8_hUVM_HOIocvtTb2sY3AaSEvbTnOlO46UchGVd-0,1527
97
97
  pages/context_processors.py,sha256=oINGTI0owXz60FV-XFEjnTkY2xlSDE-W6X1TK8IK800,5072
98
98
  pages/defaults.py,sha256=l36APPAZO4ub2A8Pp-lQGujKeOVYcyzU6t7-kOk8VoA,522
@@ -100,12 +100,13 @@ pages/forms.py,sha256=T0atqxdNds3IBP8N-9c5-ACf3iR9FzzmhzK4MOa24e8,7058
100
100
  pages/middleware.py,sha256=6PMLiyuHAHbfLeHwwQxIVy2fJ32ramEO9SHAN05Set4,6967
101
101
  pages/models.py,sha256=Sp8e2VB5a7yg4eSUlz_VcsSlAuDVap26xBKYYggxmLM,20952
102
102
  pages/module_defaults.py,sha256=R8n6eQDjNRMpO-DW86OFGvyRarju5Bx7Fnb275R_z24,5411
103
+ pages/site_config.py,sha256=f1Me0GFdHeGbIeyMlQNzD2e6hym59YHqbz92U_ppffY,4057
103
104
  pages/tasks.py,sha256=ivcba_3wSQ1-cku0oDplzw6vLeQ9hBq3R4TG-LmR5gs,1913
104
- pages/tests.py,sha256=-4EAtsfW3rmAVOCHaq6X_2rqLj0QEXkvJ5Lr3fY3QRw,125124
105
+ pages/tests.py,sha256=Lg3Jq_hOyF9KjiTeXr_AabFybW9KR3rvP5g_caomhGs,132912
105
106
  pages/urls.py,sha256=Ne6yYJxgUAMieDpppJ149E-yh-oVi92fARiRPe-n4-s,1166
106
107
  pages/utils.py,sha256=CR4D1debgJLGgXsw9kap2ggpe7fIpSoWS_ivbgMNp2k,564
107
108
  pages/views.py,sha256=Ye7qGlO7IwkZO0oR1SzCpkEDTtGCJPmDJT-x6QQ8vaQ,45848
108
- arthexis-0.1.16.dist-info/METADATA,sha256=hQmESfUjXmKX-Sp0jIX99El92v5Q691UVjNobBUflWg,9998
109
- arthexis-0.1.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
110
- arthexis-0.1.16.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
111
- arthexis-0.1.16.dist-info/RECORD,,
109
+ arthexis-0.1.18.dist-info/METADATA,sha256=C6OI88vHzosjqQvb7waV2pcPy0O5RbkOpdDBM-yOPBI,9998
110
+ arthexis-0.1.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
111
+ arthexis-0.1.18.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
112
+ arthexis-0.1.18.dist-info/RECORD,,
config/middleware.py CHANGED
@@ -1,6 +1,8 @@
1
- from utils.sites import get_site
2
1
  import socket
2
+ from django.core.exceptions import DisallowedHost
3
+ from django.http import HttpResponsePermanentRedirect
3
4
  from nodes.models import Node
5
+ from utils.sites import get_site
4
6
 
5
7
  from .active_app import set_active_app
6
8
 
@@ -17,9 +19,53 @@ class ActiveAppMiddleware:
17
19
  role_name = node.role.name if node and node.role else "Terminal"
18
20
  active = site.name or role_name
19
21
  set_active_app(active)
22
+ request.site = site
20
23
  request.active_app = active
21
24
  try:
22
25
  response = self.get_response(request)
23
26
  finally:
24
27
  set_active_app(socket.gethostname())
25
28
  return response
29
+
30
+
31
+ def _is_https_request(request) -> bool:
32
+ if request.is_secure():
33
+ return True
34
+
35
+ forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO", "")
36
+ if forwarded_proto:
37
+ candidate = forwarded_proto.split(",")[0].strip().lower()
38
+ if candidate == "https":
39
+ return True
40
+
41
+ forwarded_header = request.META.get("HTTP_FORWARDED", "")
42
+ for forwarded_part in forwarded_header.split(","):
43
+ for element in forwarded_part.split(";"):
44
+ key, _, value = element.partition("=")
45
+ if key.strip().lower() == "proto" and value.strip().strip('"').lower() == "https":
46
+ return True
47
+
48
+ return False
49
+
50
+
51
+ class SiteHttpsRedirectMiddleware:
52
+ """Redirect HTTP traffic to HTTPS for sites that require it."""
53
+
54
+ def __init__(self, get_response):
55
+ self.get_response = get_response
56
+
57
+ def __call__(self, request):
58
+ site = getattr(request, "site", None)
59
+ if site is None:
60
+ site = get_site(request)
61
+ request.site = site
62
+
63
+ if getattr(site, "require_https", False) and not _is_https_request(request):
64
+ try:
65
+ host = request.get_host()
66
+ except DisallowedHost: # pragma: no cover - defensive guard
67
+ host = request.META.get("HTTP_HOST", "")
68
+ redirect_url = f"https://{host}{request.get_full_path()}"
69
+ return HttpResponsePermanentRedirect(redirect_url)
70
+
71
+ return self.get_response(request)
config/settings.py CHANGED
@@ -390,6 +390,7 @@ MIDDLEWARE = [
390
390
  "whitenoise.middleware.WhiteNoiseMiddleware",
391
391
  "django.contrib.sessions.middleware.SessionMiddleware",
392
392
  "config.middleware.ActiveAppMiddleware",
393
+ "config.middleware.SiteHttpsRedirectMiddleware",
393
394
  "django.middleware.locale.LocaleMiddleware",
394
395
  "django.middleware.common.CommonMiddleware",
395
396
  "django.middleware.csrf.CsrfViewMiddleware",
@@ -480,6 +481,9 @@ AUTHENTICATION_BACKENDS = [
480
481
  "core.backends.RFIDBackend",
481
482
  ]
482
483
 
484
+ # Use the custom login view for all authentication redirects.
485
+ LOGIN_URL = "pages:login"
486
+
483
487
  # Issuer name used when generating otpauth URLs for authenticator apps.
484
488
  OTP_TOTP_ISSUER = os.environ.get("OTP_TOTP_ISSUER", "Arthexis")
485
489
 
config/urls.py CHANGED
@@ -149,6 +149,11 @@ urlpatterns = [
149
149
  core_views.odoo_quote_report,
150
150
  name="odoo-quote-report",
151
151
  ),
152
+ path(
153
+ "admin/request-temp-password/",
154
+ core_views.request_temp_password,
155
+ name="admin-request-temp-password",
156
+ ),
152
157
  path("admin/", admin.site.urls),
153
158
  path("i18n/setlang/", csrf_exempt(set_language), name="set_language"),
154
159
  path("api/", include("core.workgroup_urls")),
core/admin.py CHANGED
@@ -1309,11 +1309,11 @@ class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
1309
1309
  widget=forms.PasswordInput(render_value=True),
1310
1310
  help_text="Provide a plain key to create or rotate credentials.",
1311
1311
  )
1312
- profile_fields = ("user_key", "scopes", "is_active")
1312
+ profile_fields = ("assistant_name", "user_key", "scopes", "is_active")
1313
1313
 
1314
1314
  class Meta:
1315
1315
  model = AssistantProfile
1316
- fields = ("scopes", "is_active")
1316
+ fields = ("assistant_name", "scopes", "is_active")
1317
1317
 
1318
1318
  def __init__(self, *args, **kwargs):
1319
1319
  super().__init__(*args, **kwargs)
@@ -1475,7 +1475,7 @@ PROFILE_INLINE_CONFIG = {
1475
1475
  },
1476
1476
  AssistantProfile: {
1477
1477
  "form": AssistantProfileInlineForm,
1478
- "fields": ("user_key", "scopes", "is_active"),
1478
+ "fields": ("assistant_name", "user_key", "scopes", "is_active"),
1479
1479
  "readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
1480
1480
  "template": "admin/edit_inline/profile_stacked.html",
1481
1481
  },
@@ -2016,7 +2016,7 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
2016
2016
  class AssistantProfileAdmin(
2017
2017
  ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2018
2018
  ):
2019
- list_display = ("owner", "created_at", "last_used_at", "is_active")
2019
+ list_display = ("assistant_name", "owner", "created_at", "last_used_at", "is_active")
2020
2020
  readonly_fields = ("user_key_hash", "created_at", "last_used_at")
2021
2021
 
2022
2022
  change_form_template = "admin/workgroupassistantprofile_change_form.html"
@@ -2028,7 +2028,15 @@ class AssistantProfileAdmin(
2028
2028
  ("Credentials", {"fields": ("user_key_hash",)}),
2029
2029
  (
2030
2030
  "Configuration",
2031
- {"fields": ("scopes", "is_active", "created_at", "last_used_at")},
2031
+ {
2032
+ "fields": (
2033
+ "assistant_name",
2034
+ "scopes",
2035
+ "is_active",
2036
+ "created_at",
2037
+ "last_used_at",
2038
+ )
2039
+ },
2032
2040
  ),
2033
2041
  )
2034
2042
 
@@ -2835,6 +2843,7 @@ class RFIDResource(resources.ModelResource):
2835
2843
  "post_auth_command",
2836
2844
  "allowed",
2837
2845
  "color",
2846
+ "endianness",
2838
2847
  "kind",
2839
2848
  "released",
2840
2849
  "last_seen_on",
@@ -2849,6 +2858,7 @@ class RFIDResource(resources.ModelResource):
2849
2858
  "post_auth_command",
2850
2859
  "allowed",
2851
2860
  "color",
2861
+ "endianness",
2852
2862
  "kind",
2853
2863
  "released",
2854
2864
  "last_seen_on",
@@ -2919,11 +2929,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2919
2929
  "user_data_flag",
2920
2930
  "color",
2921
2931
  "kind",
2932
+ "endianness",
2922
2933
  "released",
2923
2934
  "allowed",
2924
2935
  "last_seen_on",
2925
2936
  )
2926
- list_filter = ("color", "released", "allowed")
2937
+ list_filter = ("color", "endianness", "released", "allowed")
2927
2938
  search_fields = ("label_id", "rfid", "custom_label")
2928
2939
  autocomplete_fields = ["energy_accounts"]
2929
2940
  raw_id_fields = ["reference"]
@@ -2933,8 +2944,10 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2933
2944
  "print_release_form",
2934
2945
  "copy_rfids",
2935
2946
  "toggle_selected_user_data",
2947
+ "toggle_selected_released",
2948
+ "toggle_selected_allowed",
2936
2949
  ]
2937
- readonly_fields = ("added_on", "last_seen_on")
2950
+ readonly_fields = ("added_on", "last_seen_on", "reversed_uid")
2938
2951
  form = RFIDForm
2939
2952
 
2940
2953
  def get_import_resource_kwargs(self, request, form=None, **kwargs):
@@ -3030,6 +3043,50 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3030
3043
  level=messages.WARNING,
3031
3044
  )
3032
3045
 
3046
+ @admin.action(description=_("Toggle Released flag"))
3047
+ def toggle_selected_released(self, request, queryset):
3048
+ manager = getattr(self.model, "all_objects", self.model.objects)
3049
+ toggled = 0
3050
+ for tag in queryset:
3051
+ new_state = not tag.released
3052
+ manager.filter(pk=tag.pk).update(released=new_state)
3053
+ tag.released = new_state
3054
+ toggled += 1
3055
+
3056
+ if toggled:
3057
+ self.message_user(
3058
+ request,
3059
+ ngettext(
3060
+ "Toggled released flag for %(count)d RFID.",
3061
+ "Toggled released flag for %(count)d RFIDs.",
3062
+ toggled,
3063
+ )
3064
+ % {"count": toggled},
3065
+ level=messages.SUCCESS,
3066
+ )
3067
+
3068
+ @admin.action(description=_("Toggle Allowed flag"))
3069
+ def toggle_selected_allowed(self, request, queryset):
3070
+ manager = getattr(self.model, "all_objects", self.model.objects)
3071
+ toggled = 0
3072
+ for tag in queryset:
3073
+ new_state = not tag.allowed
3074
+ manager.filter(pk=tag.pk).update(allowed=new_state)
3075
+ tag.allowed = new_state
3076
+ toggled += 1
3077
+
3078
+ if toggled:
3079
+ self.message_user(
3080
+ request,
3081
+ ngettext(
3082
+ "Toggled allowed flag for %(count)d RFID.",
3083
+ "Toggled allowed flag for %(count)d RFIDs.",
3084
+ toggled,
3085
+ )
3086
+ % {"count": toggled},
3087
+ level=messages.SUCCESS,
3088
+ )
3089
+
3033
3090
  @admin.action(description=_("Copy RFID"))
3034
3091
  def copy_rfids(self, request, queryset):
3035
3092
  if queryset.count() != 1:
@@ -3497,6 +3554,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3497
3554
  context["title"] = _("Scan RFIDs")
3498
3555
  context["opts"] = self.model._meta
3499
3556
  context["show_release_info"] = True
3557
+ context["default_endianness"] = RFID.BIG_ENDIAN
3500
3558
  return render(request, "admin/core/rfid/scan.html", context)
3501
3559
 
3502
3560
  def scan_next(self, request):
@@ -3510,9 +3568,11 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3510
3568
  return JsonResponse({"error": "Invalid JSON payload"}, status=400)
3511
3569
  rfid = payload.get("rfid") or payload.get("value")
3512
3570
  kind = payload.get("kind")
3513
- result = validate_rfid_value(rfid, kind=kind)
3571
+ endianness = payload.get("endianness")
3572
+ result = validate_rfid_value(rfid, kind=kind, endianness=endianness)
3514
3573
  else:
3515
- result = scan_sources(request)
3574
+ endianness = request.GET.get("endianness")
3575
+ result = scan_sources(request, endianness=endianness)
3516
3576
  status = 500 if result.get("error") else 200
3517
3577
  return JsonResponse(result, status=status)
3518
3578
 
core/backends.py CHANGED
@@ -90,6 +90,7 @@ class RFIDBackend:
90
90
  env = os.environ.copy()
91
91
  env["RFID_VALUE"] = rfid_value
92
92
  env["RFID_LABEL_ID"] = str(tag.pk)
93
+ env["RFID_ENDIANNESS"] = getattr(tag, "endianness", RFID.BIG_ENDIAN)
93
94
  try:
94
95
  completed = subprocess.run(
95
96
  command,
@@ -117,6 +118,7 @@ class RFIDBackend:
117
118
  env = os.environ.copy()
118
119
  env["RFID_VALUE"] = rfid_value
119
120
  env["RFID_LABEL_ID"] = str(tag.pk)
121
+ env["RFID_ENDIANNESS"] = getattr(tag, "endianness", RFID.BIG_ENDIAN)
120
122
  with contextlib.suppress(Exception):
121
123
  subprocess.Popen(
122
124
  post_command,
core/changelog.py CHANGED
@@ -154,9 +154,53 @@ def _parse_sections(text: str) -> List[ChangelogSection]:
154
154
  return sections
155
155
 
156
156
 
157
+ def _latest_release_version(previous_text: str) -> Optional[str]:
158
+ for section in _parse_sections(previous_text):
159
+ if section.version:
160
+ return section.version
161
+ return None
162
+
163
+
164
+ def _find_release_commit(version: str) -> Optional[str]:
165
+ normalized = version.lstrip("v")
166
+ search_terms = [
167
+ f"Release v{normalized}",
168
+ f"Release {normalized}",
169
+ f"pre-release commit v{normalized}",
170
+ f"pre-release commit {normalized}",
171
+ ]
172
+ for term in search_terms:
173
+ proc = subprocess.run(
174
+ [
175
+ "git",
176
+ "log",
177
+ "--max-count=1",
178
+ "--format=%H",
179
+ "--fixed-strings",
180
+ f"--grep={term}",
181
+ ],
182
+ capture_output=True,
183
+ text=True,
184
+ check=False,
185
+ )
186
+ sha = proc.stdout.strip()
187
+ if sha:
188
+ return sha.splitlines()[0]
189
+ return None
190
+
191
+
192
+ def _resolve_release_commit_from_text(previous_text: str) -> Optional[str]:
193
+ version = _latest_release_version(previous_text)
194
+ if not version:
195
+ return None
196
+ return _find_release_commit(version)
197
+
198
+
157
199
  def _merge_sections(
158
200
  new_sections: Iterable[ChangelogSection],
159
201
  old_sections: Iterable[ChangelogSection],
202
+ *,
203
+ reopen_latest: bool = False,
160
204
  ) -> List[ChangelogSection]:
161
205
  merged = list(new_sections)
162
206
  old_sections_list = list(old_sections)
@@ -199,7 +243,8 @@ def _merge_sections(
199
243
  existing = version_to_section.get(old.version)
200
244
  if existing is None:
201
245
  if (
202
- first_release_version
246
+ reopen_latest
247
+ and first_release_version
203
248
  and old.version == first_release_version
204
249
  and not reopened_latest_version
205
250
  and unreleased_section is not None
@@ -274,29 +319,45 @@ def _resolve_start_tag(explicit: str | None = None) -> Optional[str]:
274
319
  return None
275
320
 
276
321
 
277
- def determine_range_spec(start_tag: str | None = None) -> str:
322
+ def determine_range_spec(
323
+ start_tag: str | None = None, *, previous_text: str | None = None
324
+ ) -> str:
278
325
  """Return the git range specification to build the changelog."""
279
326
 
280
327
  resolved = _resolve_start_tag(start_tag)
281
328
  if resolved:
282
329
  return f"{resolved}..HEAD"
330
+
331
+ if previous_text:
332
+ release_commit = _resolve_release_commit_from_text(previous_text)
333
+ if release_commit:
334
+ return f"{release_commit}..HEAD"
335
+
283
336
  return "HEAD"
284
337
 
285
338
 
286
339
  def collect_sections(
287
- *, range_spec: str = "HEAD", previous_text: str | None = None
340
+ *,
341
+ range_spec: str = "HEAD",
342
+ previous_text: str | None = None,
343
+ reopen_latest: bool = False,
288
344
  ) -> List[ChangelogSection]:
289
345
  """Return changelog sections for *range_spec*.
290
346
 
291
347
  When ``previous_text`` is provided, sections not regenerated in the current run
292
- are appended so long as they can be parsed from the existing changelog.
348
+ are appended so long as they can be parsed from the existing changelog. Set
349
+ ``reopen_latest`` to ``True`` when the caller intends to move the most recent
350
+ release notes back into the ``Unreleased`` section (for example, when
351
+ preparing a release retry before a new tag is created).
293
352
  """
294
353
 
295
354
  commits = _read_commits(range_spec)
296
355
  sections = _sections_from_commits(commits)
297
356
  if previous_text:
298
357
  old_sections = _parse_sections(previous_text)
299
- sections = _merge_sections(sections, old_sections)
358
+ sections = _merge_sections(
359
+ sections, old_sections, reopen_latest=reopen_latest
360
+ )
300
361
  return sections
301
362
 
302
363
 
core/models.py CHANGED
@@ -1775,6 +1775,14 @@ class RFID(Entity):
1775
1775
  )
1776
1776
  ],
1777
1777
  )
1778
+ reversed_uid = models.CharField(
1779
+ max_length=255,
1780
+ default="",
1781
+ blank=True,
1782
+ editable=False,
1783
+ verbose_name="Reversed UID",
1784
+ help_text="UID value stored with opposite endianness for reference.",
1785
+ )
1778
1786
  custom_label = models.CharField(
1779
1787
  max_length=32,
1780
1788
  blank=True,
@@ -1851,6 +1859,17 @@ class RFID(Entity):
1851
1859
  choices=KIND_CHOICES,
1852
1860
  default=CLASSIC,
1853
1861
  )
1862
+ BIG_ENDIAN = "BIG"
1863
+ LITTLE_ENDIAN = "LITTLE"
1864
+ ENDIANNESS_CHOICES = [
1865
+ (BIG_ENDIAN, _("Big endian")),
1866
+ (LITTLE_ENDIAN, _("Little endian")),
1867
+ ]
1868
+ endianness = models.CharField(
1869
+ max_length=6,
1870
+ choices=ENDIANNESS_CHOICES,
1871
+ default=BIG_ENDIAN,
1872
+ )
1854
1873
  reference = models.ForeignKey(
1855
1874
  "Reference",
1856
1875
  null=True,
@@ -1895,13 +1914,24 @@ class RFID(Entity):
1895
1914
  if self.key_b and old["key_b"] != self.key_b.upper():
1896
1915
  self.key_b_verified = False
1897
1916
  if self.rfid:
1898
- self.rfid = self.rfid.upper()
1917
+ normalized_rfid = self.rfid.upper()
1918
+ self.rfid = normalized_rfid
1919
+ reversed_uid = self.reverse_uid(normalized_rfid)
1920
+ if reversed_uid != self.reversed_uid:
1921
+ self.reversed_uid = reversed_uid
1922
+ if update_fields:
1923
+ fields = set(update_fields)
1924
+ if "reversed_uid" not in fields:
1925
+ fields.add("reversed_uid")
1926
+ kwargs["update_fields"] = tuple(fields)
1899
1927
  if self.key_a:
1900
1928
  self.key_a = self.key_a.upper()
1901
1929
  if self.key_b:
1902
1930
  self.key_b = self.key_b.upper()
1903
1931
  if self.kind:
1904
1932
  self.kind = self.kind.upper()
1933
+ if self.endianness:
1934
+ self.endianness = self.normalize_endianness(self.endianness)
1905
1935
  super().save(*args, **kwargs)
1906
1936
  if not self.allowed:
1907
1937
  self.energy_accounts.clear()
@@ -1909,6 +1939,30 @@ class RFID(Entity):
1909
1939
  def __str__(self): # pragma: no cover - simple representation
1910
1940
  return str(self.label_id)
1911
1941
 
1942
+ @classmethod
1943
+ def normalize_endianness(cls, value: object) -> str:
1944
+ """Return a valid endianness value, defaulting to BIG."""
1945
+
1946
+ if isinstance(value, str):
1947
+ candidate = value.strip().upper()
1948
+ valid = {choice[0] for choice in cls.ENDIANNESS_CHOICES}
1949
+ if candidate in valid:
1950
+ return candidate
1951
+ return cls.BIG_ENDIAN
1952
+
1953
+ @staticmethod
1954
+ def reverse_uid(value: str) -> str:
1955
+ """Return ``value`` with reversed byte order for reference storage."""
1956
+
1957
+ normalized = "".join((value or "").split()).upper()
1958
+ if not normalized:
1959
+ return ""
1960
+ if len(normalized) % 2 != 0:
1961
+ return normalized[::-1]
1962
+ bytes_list = [normalized[index : index + 2] for index in range(0, len(normalized), 2)]
1963
+ bytes_list.reverse()
1964
+ return "".join(bytes_list)
1965
+
1912
1966
  @classmethod
1913
1967
  def next_scan_label(
1914
1968
  cls, *, step: int | None = None, start: int | None = None
@@ -1971,13 +2025,39 @@ class RFID(Entity):
1971
2025
 
1972
2026
  @classmethod
1973
2027
  def register_scan(
1974
- cls, rfid: str, *, kind: str | None = None
2028
+ cls,
2029
+ rfid: str,
2030
+ *,
2031
+ kind: str | None = None,
2032
+ endianness: str | None = None,
1975
2033
  ) -> tuple["RFID", bool]:
1976
2034
  """Return or create an RFID that was detected via scanning."""
1977
2035
 
1978
- normalized = (rfid or "").upper()
1979
- existing = cls.objects.filter(rfid=normalized).first()
2036
+ normalized = "".join((rfid or "").split()).upper()
2037
+ desired_endianness = cls.normalize_endianness(endianness)
2038
+ alternate = None
2039
+ if normalized and len(normalized) % 2 == 0:
2040
+ bytes_list = [normalized[i : i + 2] for i in range(0, len(normalized), 2)]
2041
+ bytes_list.reverse()
2042
+ alternate_candidate = "".join(bytes_list)
2043
+ if alternate_candidate != normalized:
2044
+ alternate = alternate_candidate
2045
+
2046
+ existing = None
2047
+ if normalized:
2048
+ existing = cls.objects.filter(rfid=normalized).first()
2049
+ if not existing and alternate:
2050
+ existing = cls.objects.filter(rfid=alternate).first()
1980
2051
  if existing:
2052
+ update_fields: list[str] = []
2053
+ if normalized and existing.rfid != normalized:
2054
+ existing.rfid = normalized
2055
+ update_fields.append("rfid")
2056
+ if existing.endianness != desired_endianness:
2057
+ existing.endianness = desired_endianness
2058
+ update_fields.append("endianness")
2059
+ if update_fields:
2060
+ existing.save(update_fields=update_fields)
1981
2061
  return existing, False
1982
2062
 
1983
2063
  attempts = 0
@@ -1990,6 +2070,7 @@ class RFID(Entity):
1990
2070
  "rfid": normalized,
1991
2071
  "allowed": True,
1992
2072
  "released": False,
2073
+ "endianness": desired_endianness,
1993
2074
  }
1994
2075
  if kind:
1995
2076
  create_kwargs["kind"] = kind
@@ -3539,7 +3620,8 @@ class AssistantProfile(Profile):
3539
3620
  """
3540
3621
 
3541
3622
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
3542
- profile_fields = ("user_key_hash", "scopes", "is_active")
3623
+ profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
3624
+ assistant_name = models.CharField(max_length=100, default="Assistant")
3543
3625
  user_key_hash = models.CharField(max_length=64, unique=True)
3544
3626
  scopes = models.JSONField(default=list, blank=True)
3545
3627
  created_at = models.DateTimeField(auto_now_add=True)
@@ -3586,8 +3668,7 @@ class AssistantProfile(Profile):
3586
3668
  self.save(update_fields=["last_used_at"])
3587
3669
 
3588
3670
  def __str__(self) -> str: # pragma: no cover - simple representation
3589
- owner = self.owner_display()
3590
- return f"AssistantProfile for {owner}" if owner else "AssistantProfile"
3671
+ return self.assistant_name or "AssistantProfile"
3591
3672
 
3592
3673
 
3593
3674
  def validate_relative_url(value: str) -> None: