arthexis 0.1.17__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.17
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.17.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,14 +8,14 @@ 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=WFywFlWRTkEqDksWYAOd6DdSpHLEu2ETgLeeSGruwrA,21516
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=tEMop2REJ-P4o73ZWKZQuWm29zF3S-dWKu26wUDfvi4,145556
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
@@ -34,7 +34,7 @@ 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=Vl_Z_tLo93w2ZDgObFFIfLSzid3KXraByghMmJeCfNk,126647
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
@@ -47,11 +47,11 @@ core/system.py,sha256=tqx8-4kyViMGKI3EAaxztrbyes4TSTPQ9YsIKzdVs6c,35731
47
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=ojHab5JtcHuVDc1zXK_QVms1cID1XXjuRcHmPcqVqD0,98368
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=Gt2J51RyIOsR2gzl2q3ChPbbIVDzrscty3yIGRW07P8,87141
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
@@ -74,7 +74,7 @@ nodes/views.py,sha256=TyW7exkVaR-o2_XkJXSi9jQ_BygXOE2cQFs4xlI20Xc,22905
74
74
  ocpp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
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=0Zczbg1x_5vhIV5TITHeaUkNONMdx20pD4St7Zc-ghM,36524
89
- ocpp/tests.py,sha256=BfOapD5vWrmA43Q4WI7or2lP2pmcBdrU5_YCK31JgHE,182621
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=wntIO3LHFoPAg40SFGMoRPAiA3xyDKPwNgIsCBSEOcI,57164
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=i1HLp8rNm63WuqFf9YgAEGWYowFC7SOyyg_7j17_buQ,126102
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.17.dist-info/METADATA,sha256=rvPpFn4fwAZGm0JJ6Esu1r1A80uFC-tWCYhJOj0UNd8,9998
109
- arthexis-0.1.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
110
- arthexis-0.1.17.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
111
- arthexis-0.1.17.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",
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
@@ -2947,7 +2947,7 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2947
2947
  "toggle_selected_released",
2948
2948
  "toggle_selected_allowed",
2949
2949
  ]
2950
- readonly_fields = ("added_on", "last_seen_on")
2950
+ readonly_fields = ("added_on", "last_seen_on", "reversed_uid")
2951
2951
  form = RFIDForm
2952
2952
 
2953
2953
  def get_import_resource_kwargs(self, request, form=None, **kwargs):
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,
@@ -1906,7 +1914,16 @@ class RFID(Entity):
1906
1914
  if self.key_b and old["key_b"] != self.key_b.upper():
1907
1915
  self.key_b_verified = False
1908
1916
  if self.rfid:
1909
- 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)
1910
1927
  if self.key_a:
1911
1928
  self.key_a = self.key_a.upper()
1912
1929
  if self.key_b:
@@ -1933,6 +1950,19 @@ class RFID(Entity):
1933
1950
  return candidate
1934
1951
  return cls.BIG_ENDIAN
1935
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
+
1936
1966
  @classmethod
1937
1967
  def next_scan_label(
1938
1968
  cls, *, step: int | None = None, start: int | None = None
core/tests.py CHANGED
@@ -550,6 +550,15 @@ class RFIDValidationTests(TestCase):
550
550
  tag = RFID.objects.create(rfid="DEADBEEF10")
551
551
  self.assertEqual(tag.rfid, "DEADBEEF10")
552
552
 
553
+ def test_reversed_uid_updates_with_rfid(self):
554
+ tag = RFID.objects.create(rfid="A1B2C3D4")
555
+ self.assertEqual(tag.reversed_uid, "D4C3B2A1")
556
+
557
+ tag.rfid = "112233"
558
+ tag.save(update_fields=["rfid"])
559
+ tag.refresh_from_db()
560
+ self.assertEqual(tag.reversed_uid, "332211")
561
+
553
562
  def test_find_user_by_rfid(self):
554
563
  user = User.objects.create_user(username="finder", password="pwd")
555
564
  acc = EnergyAccount.objects.create(user=user, name="FINDER")
core/views.py CHANGED
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
43
43
  PYPI_REQUEST_TIMEOUT = 10
44
44
 
45
45
  from . import changelog as changelog_utils
46
+ from . import temp_passwords
46
47
  from .models import OdooProfile, Product, EnergyAccount, PackageRelease, Todo
47
48
  from .models import RFID
48
49
 
@@ -336,6 +337,35 @@ def odoo_quote_report(request):
336
337
  return TemplateResponse(request, "admin/core/odoo_quote_report.html", context)
337
338
 
338
339
 
340
+ @staff_member_required
341
+ @require_GET
342
+ def request_temp_password(request):
343
+ """Generate a temporary password for the authenticated staff member."""
344
+
345
+ user = request.user
346
+ username = user.get_username()
347
+ password = temp_passwords.generate_password()
348
+ entry = temp_passwords.store_temp_password(
349
+ username,
350
+ password,
351
+ allow_change=True,
352
+ )
353
+ context = {
354
+ **admin_site.each_context(request),
355
+ "title": _("Temporary password"),
356
+ "username": username,
357
+ "password": password,
358
+ "expires_at": timezone.localtime(entry.expires_at),
359
+ "allow_change": entry.allow_change,
360
+ "return_url": reverse("admin:password_change"),
361
+ }
362
+ return TemplateResponse(
363
+ request,
364
+ "admin/core/request_temp_password.html",
365
+ context,
366
+ )
367
+
368
+
339
369
  @require_GET
340
370
  def version_info(request):
341
371
  """Return the running application version and Git revision."""
@@ -1855,26 +1885,34 @@ def release_progress(request, pk: int, action: str):
1855
1885
 
1856
1886
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
1857
1887
  pending_items = list(pending_qs)
1858
- if ack_todos_requested:
1859
- if pending_items:
1860
- failures = []
1861
- for todo in pending_items:
1862
- result = todo.check_on_done_condition()
1863
- if not result.passed:
1864
- failures.append((todo, result))
1865
- if failures:
1866
- ctx.pop("todos_ack", None)
1867
- for todo, result in failures:
1868
- messages.error(request, _format_condition_failure(todo, result))
1869
- else:
1870
- ctx["todos_ack"] = True
1888
+ if not pending_items:
1889
+ ctx["todos_ack"] = True
1890
+ ctx["todos_ack_auto"] = True
1891
+ elif ack_todos_requested:
1892
+ failures = []
1893
+ for todo in pending_items:
1894
+ result = todo.check_on_done_condition()
1895
+ if not result.passed:
1896
+ failures.append((todo, result))
1897
+ if failures:
1898
+ ctx["todos_ack"] = False
1899
+ ctx.pop("todos_ack_auto", None)
1900
+ for todo, result in failures:
1901
+ messages.error(request, _format_condition_failure(todo, result))
1871
1902
  else:
1872
1903
  ctx["todos_ack"] = True
1904
+ ctx.pop("todos_ack_auto", None)
1905
+ else:
1906
+ if ctx.pop("todos_ack_auto", None):
1907
+ ctx["todos_ack"] = False
1908
+ else:
1909
+ ctx.setdefault("todos_ack", False)
1873
1910
 
1874
1911
  if ctx.get("todos_ack"):
1875
1912
  ctx.pop("todos_block_logged", None)
1876
-
1877
- if not ctx.get("todos_ack"):
1913
+ ctx.pop("todos", None)
1914
+ ctx.pop("todos_required", None)
1915
+ else:
1878
1916
  ctx["todos"] = [
1879
1917
  {
1880
1918
  "id": todo.pk,
@@ -1885,9 +1923,6 @@ def release_progress(request, pk: int, action: str):
1885
1923
  for todo in pending_items
1886
1924
  ]
1887
1925
  ctx["todos_required"] = True
1888
- else:
1889
- ctx.pop("todos", None)
1890
- ctx.pop("todos_required", None)
1891
1926
 
1892
1927
  log_name = _release_log_name(release.package.name, release.version)
1893
1928
  if ctx.get("log") != log_name:
@@ -1897,6 +1932,8 @@ def release_progress(request, pk: int, action: str):
1897
1932
  "started": ctx.get("started", False),
1898
1933
  }
1899
1934
  step_count = 0
1935
+ if not pending_items:
1936
+ ctx["todos_ack"] = True
1900
1937
  log_path = log_dir / log_name
1901
1938
  ctx.setdefault("log", log_name)
1902
1939
  ctx.setdefault("paused", False)
ocpp/consumers.py CHANGED
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  import asyncio
6
6
  import inspect
7
7
  import json
8
+ import logging
8
9
  from urllib.parse import parse_qs
9
10
  from django.utils import timezone
10
11
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
@@ -32,6 +33,9 @@ from .evcs_discovery import (
32
33
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
34
 
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
38
+
35
39
  # Query parameter keys that may contain the charge point serial. Keys are
36
40
  # matched case-insensitively and trimmed before use.
37
41
  SERIAL_QUERY_PARAM_NAMES = (
@@ -309,6 +313,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
313
 
310
314
  return await database_sync_to_async(_ensure)()
311
315
 
316
+ def _log_unlinked_rfid(self, rfid: str) -> None:
317
+ """Record a warning when an RFID is authorized without an account."""
318
+
319
+ message = (
320
+ f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
321
+ )
322
+ logger.warning(message)
323
+ store.add_log(
324
+ store.pending_key(self.charger_id),
325
+ message,
326
+ log_type="charger",
327
+ )
328
+
312
329
  async def _assign_connector(self, connector: int | str | None) -> None:
313
330
  """Ensure ``self.charger`` matches the provided connector id."""
314
331
  if connector in (None, "", "-"):
@@ -1395,13 +1412,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1395
1412
  elif action == "Authorize":
1396
1413
  id_tag = payload.get("idTag")
1397
1414
  account = await self._get_account(id_tag)
1415
+ status = "Invalid"
1398
1416
  if self.charger.require_rfid:
1399
- status = (
1400
- "Accepted"
1401
- if account
1402
- and await database_sync_to_async(account.can_authorize)()
1403
- else "Invalid"
1404
- )
1417
+ tag = None
1418
+ tag_created = False
1419
+ if id_tag:
1420
+ tag, tag_created = await database_sync_to_async(
1421
+ CoreRFID.register_scan
1422
+ )(id_tag)
1423
+ if account:
1424
+ if await database_sync_to_async(account.can_authorize)():
1425
+ status = "Accepted"
1426
+ elif (
1427
+ id_tag
1428
+ and tag
1429
+ and not tag_created
1430
+ and tag.allowed
1431
+ ):
1432
+ status = "Accepted"
1433
+ self._log_unlinked_rfid(tag.rfid)
1405
1434
  else:
1406
1435
  await self._ensure_rfid_seen(id_tag)
1407
1436
  status = "Accepted"
@@ -1475,23 +1504,38 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1475
1504
  reply_payload = {}
1476
1505
  elif action == "StartTransaction":
1477
1506
  id_tag = payload.get("idTag")
1478
- account = await self._get_account(id_tag)
1507
+ tag = None
1508
+ tag_created = False
1479
1509
  if id_tag:
1480
- if self.charger.require_rfid:
1481
- await database_sync_to_async(CoreRFID.register_scan)(
1482
- id_tag.upper()
1483
- )
1484
- else:
1485
- await self._ensure_rfid_seen(id_tag)
1510
+ tag, tag_created = await database_sync_to_async(
1511
+ CoreRFID.register_scan
1512
+ )(id_tag)
1513
+ account = await self._get_account(id_tag)
1514
+ if id_tag and not self.charger.require_rfid:
1515
+ seen_tag = await self._ensure_rfid_seen(id_tag)
1516
+ if seen_tag:
1517
+ tag = seen_tag
1486
1518
  await self._assign_connector(payload.get("connectorId"))
1519
+ authorized = True
1520
+ authorized_via_tag = False
1487
1521
  if self.charger.require_rfid:
1488
- authorized = (
1489
- account is not None
1490
- and await database_sync_to_async(account.can_authorize)()
1491
- )
1492
- else:
1493
- authorized = True
1522
+ if account is not None:
1523
+ authorized = await database_sync_to_async(
1524
+ account.can_authorize
1525
+ )()
1526
+ elif (
1527
+ id_tag
1528
+ and tag
1529
+ and not tag_created
1530
+ and getattr(tag, "allowed", False)
1531
+ ):
1532
+ authorized = True
1533
+ authorized_via_tag = True
1534
+ else:
1535
+ authorized = False
1494
1536
  if authorized:
1537
+ if authorized_via_tag and tag:
1538
+ self._log_unlinked_rfid(tag.rfid)
1495
1539
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1496
1540
  received_start = timezone.now()
1497
1541
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
ocpp/test_rfid.py CHANGED
@@ -519,6 +519,76 @@ class ValidateRfidValueTests(SimpleTestCase):
519
519
  mock_popen.assert_not_called()
520
520
  self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
521
521
 
522
+ @patch("ocpp.rfid.reader.timezone.now")
523
+ @patch("ocpp.rfid.reader.notify_async")
524
+ @patch("ocpp.rfid.reader.subprocess.Popen")
525
+ @patch("ocpp.rfid.reader.subprocess.run")
526
+ @patch("ocpp.rfid.reader.RFID.register_scan")
527
+ def test_external_command_strips_trailing_percent_tokens(
528
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
529
+ ):
530
+ mock_now.return_value = timezone.now()
531
+ tag = MagicMock()
532
+ tag.pk = 3
533
+ tag.label_id = 3
534
+ tag.allowed = True
535
+ tag.external_command = "echo weird"
536
+ tag.color = "Y"
537
+ tag.released = False
538
+ tag.reference = None
539
+ tag.kind = RFID.CLASSIC
540
+ tag.endianness = RFID.BIG_ENDIAN
541
+ mock_register.return_value = (tag, False)
542
+ mock_run.return_value = types.SimpleNamespace(
543
+ returncode=0,
544
+ stdout="first %\nsecond 50%\r\nthird % %\n",
545
+ stderr="oops %\n",
546
+ )
547
+
548
+ result = validate_rfid_value("abc3")
549
+
550
+ output = result.get("command_output")
551
+ self.assertIsNotNone(output)
552
+ self.assertEqual(
553
+ output.get("stdout"), "first\nsecond 50%\r\nthird\n"
554
+ )
555
+ self.assertEqual(output.get("stderr"), "oops\n")
556
+ self.assertEqual(output.get("returncode"), 0)
557
+ self.assertEqual(output.get("error"), "")
558
+ mock_popen.assert_not_called()
559
+
560
+ @patch("ocpp.rfid.reader.timezone.now")
561
+ @patch("ocpp.rfid.reader.notify_async")
562
+ @patch("ocpp.rfid.reader.subprocess.Popen")
563
+ @patch("ocpp.rfid.reader.subprocess.run")
564
+ @patch("ocpp.rfid.reader.RFID.register_scan")
565
+ def test_external_command_error_strips_trailing_percent_tokens(
566
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
567
+ ):
568
+ mock_now.return_value = timezone.now()
569
+ tag = MagicMock()
570
+ tag.pk = 4
571
+ tag.label_id = 4
572
+ tag.allowed = True
573
+ tag.external_command = "echo boom"
574
+ tag.color = "R"
575
+ tag.released = False
576
+ tag.reference = None
577
+ tag.kind = RFID.CLASSIC
578
+ tag.endianness = RFID.BIG_ENDIAN
579
+ mock_register.return_value = (tag, False)
580
+ mock_run.side_effect = RuntimeError("bad % %")
581
+
582
+ result = validate_rfid_value("abcd")
583
+
584
+ output = result.get("command_output")
585
+ self.assertIsInstance(output, dict)
586
+ self.assertEqual(output.get("stdout"), "")
587
+ self.assertEqual(output.get("stderr"), "")
588
+ self.assertEqual(output.get("error"), "bad")
589
+ self.assertFalse(result["allowed"])
590
+ mock_popen.assert_not_called()
591
+
522
592
  @patch("ocpp.rfid.reader.timezone.now")
523
593
  @patch("ocpp.rfid.reader.notify_async")
524
594
  @patch("ocpp.rfid.reader.subprocess.Popen")
ocpp/tests.py CHANGED
@@ -2949,6 +2949,44 @@ class SimulatorAdminTests(TransactionTestCase):
2949
2949
 
2950
2950
  await communicator.disconnect()
2951
2951
 
2952
+ async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
2953
+ charger_id = "AUTHWARN"
2954
+ tag_value = "WARN01"
2955
+ await database_sync_to_async(Charger.objects.create)(
2956
+ charger_id=charger_id, require_rfid=True
2957
+ )
2958
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
2959
+
2960
+ pending_key = store.pending_key(charger_id)
2961
+ store.clear_log(pending_key, log_type="charger")
2962
+
2963
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
2964
+ connected, _ = await communicator.connect()
2965
+ self.assertTrue(connected)
2966
+
2967
+ message_id = "auth-unlinked"
2968
+ await communicator.send_json_to(
2969
+ [2, message_id, "Authorize", {"idTag": tag_value}]
2970
+ )
2971
+ response = await communicator.receive_json_from()
2972
+ self.assertEqual(response[0], 3)
2973
+ self.assertEqual(response[1], message_id)
2974
+ self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
2975
+
2976
+ log_entries = store.get_logs(pending_key, log_type="charger")
2977
+ self.assertTrue(
2978
+ any(
2979
+ "Authorized RFID" in entry
2980
+ and tag_value in entry
2981
+ and charger_id in entry
2982
+ for entry in log_entries
2983
+ ),
2984
+ log_entries,
2985
+ )
2986
+
2987
+ await communicator.disconnect()
2988
+ store.clear_log(pending_key, log_type="charger")
2989
+
2952
2990
  async def test_authorize_without_requirement_records_rfid(self):
2953
2991
  await database_sync_to_async(Charger.objects.create)(
2954
2992
  charger_id="AUTHOPT", require_rfid=False
@@ -3041,6 +3079,61 @@ class SimulatorAdminTests(TransactionTestCase):
3041
3079
  )
3042
3080
  self.assertEqual(tx.account_id, user.energy_account.id)
3043
3081
 
3082
+ async def test_start_transaction_allows_allowed_tag_without_account(self):
3083
+ charger_id = "STARTWARN"
3084
+ tag_value = "WARN02"
3085
+ await database_sync_to_async(Charger.objects.create)(
3086
+ charger_id=charger_id, require_rfid=True
3087
+ )
3088
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
3089
+
3090
+ pending_key = store.pending_key(charger_id)
3091
+ store.clear_log(pending_key, log_type="charger")
3092
+
3093
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
3094
+ connected, _ = await communicator.connect()
3095
+ self.assertTrue(connected)
3096
+
3097
+ start_payload = {
3098
+ "meterStart": 5,
3099
+ "idTag": tag_value,
3100
+ "connectorId": 1,
3101
+ }
3102
+ await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
3103
+ response = await communicator.receive_json_from()
3104
+ self.assertEqual(response[0], 3)
3105
+ self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
3106
+ tx_id = response[2]["transactionId"]
3107
+
3108
+ tx = await database_sync_to_async(Transaction.objects.get)(
3109
+ pk=tx_id, charger__charger_id=charger_id
3110
+ )
3111
+ self.assertIsNone(tx.account_id)
3112
+
3113
+ log_entries = store.get_logs(pending_key, log_type="charger")
3114
+ self.assertTrue(
3115
+ any(
3116
+ "Authorized RFID" in entry
3117
+ and tag_value in entry
3118
+ and charger_id in entry
3119
+ for entry in log_entries
3120
+ ),
3121
+ log_entries,
3122
+ )
3123
+
3124
+ await communicator.send_json_to(
3125
+ [
3126
+ 2,
3127
+ "stop-1",
3128
+ "StopTransaction",
3129
+ {"transactionId": tx_id, "meterStop": 6},
3130
+ ]
3131
+ )
3132
+ await communicator.receive_json_from()
3133
+
3134
+ await communicator.disconnect()
3135
+ store.clear_log(pending_key, log_type="charger")
3136
+
3044
3137
  async def test_status_fields_updated(self):
3045
3138
  communicator = WebsocketCommunicator(application, "/STAT/")
3046
3139
  connected, _ = await communicator.connect()
ocpp/views.py CHANGED
@@ -1209,17 +1209,38 @@ def charger_log_page(request, cid, connector=None):
1209
1209
  charger_id=cid
1210
1210
  )
1211
1211
  target_id = cid
1212
- log = store.get_logs(target_id, log_type=log_type)
1212
+ limit_options = [
1213
+ {"value": "10", "label": "10"},
1214
+ {"value": "20", "label": "20"},
1215
+ {"value": "40", "label": "40"},
1216
+ {"value": "100", "label": "100"},
1217
+ {"value": "all", "label": gettext("All")},
1218
+ ]
1219
+ allowed_values = [item["value"] for item in limit_options]
1220
+ limit_choice = request.GET.get("limit", "20")
1221
+ if limit_choice not in allowed_values:
1222
+ limit_choice = "20"
1223
+
1224
+ log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
1225
+ if limit_choice != "all":
1226
+ try:
1227
+ limit_value = int(limit_choice)
1228
+ except (TypeError, ValueError):
1229
+ limit_value = 20
1230
+ limit_choice = "20"
1231
+ log_entries = log_entries[-limit_value:]
1213
1232
  return render(
1214
1233
  request,
1215
1234
  "ocpp/charger_logs.html",
1216
1235
  {
1217
1236
  "charger": charger,
1218
- "log": log,
1237
+ "log": log_entries,
1219
1238
  "log_type": log_type,
1220
1239
  "connector_slug": connector_slug,
1221
1240
  "connector_links": connector_links,
1222
1241
  "status_url": status_url,
1242
+ "log_limit_options": limit_options,
1243
+ "log_limit_index": allowed_values.index(limit_choice),
1223
1244
  },
1224
1245
  )
1225
1246
 
pages/admin.py CHANGED
@@ -6,12 +6,13 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
6
6
  from django.contrib.sites.models import Site
7
7
  from django import forms
8
8
  from django.shortcuts import redirect, render, get_object_or_404
9
- from django.urls import path, reverse
9
+ from django.urls import NoReverseMatch, path, reverse
10
10
  from django.utils.html import format_html
11
11
  from django.template.response import TemplateResponse
12
12
  from django.http import JsonResponse
13
13
  from django.utils import timezone
14
14
  from django.db.models import Count
15
+ from django.core.exceptions import FieldError
15
16
  from django.db.models.functions import TruncDate
16
17
  from datetime import datetime, time, timedelta
17
18
  import ipaddress
@@ -25,6 +26,7 @@ from nodes.utils import capture_screenshot, save_screenshot
25
26
 
26
27
  from .forms import UserManualAdminForm
27
28
  from .module_defaults import reload_default_modules as restore_default_modules
29
+ from .site_config import ensure_site_fields
28
30
  from .utils import landing_leads_supported
29
31
 
30
32
  from .models import (
@@ -41,6 +43,7 @@ from .models import (
41
43
  UserStory,
42
44
  )
43
45
  from django.contrib.contenttypes.models import ContentType
46
+ from core.models import ReleaseManager
44
47
  from core.user_data import EntityModelAdmin
45
48
 
46
49
 
@@ -73,12 +76,47 @@ class SiteForm(forms.ModelForm):
73
76
  fields = "__all__"
74
77
 
75
78
 
79
+ ensure_site_fields()
80
+
81
+
82
+ class _BooleanAttributeListFilter(admin.SimpleListFilter):
83
+ """Filter helper for boolean attributes on :class:`~django.contrib.sites.models.Site`."""
84
+
85
+ field_name: str
86
+
87
+ def lookups(self, request, model_admin): # pragma: no cover - admin UI
88
+ return (("1", _("Yes")), ("0", _("No")))
89
+
90
+ def queryset(self, request, queryset):
91
+ value = self.value()
92
+ if value not in {"0", "1"}:
93
+ return queryset
94
+ expected = value == "1"
95
+ try:
96
+ return queryset.filter(**{self.field_name: expected})
97
+ except FieldError: # pragma: no cover - defensive when fields missing
98
+ return queryset
99
+
100
+
101
+ class ManagedSiteListFilter(_BooleanAttributeListFilter):
102
+ title = _("Managed by local NGINX")
103
+ parameter_name = "managed"
104
+ field_name = "managed"
105
+
106
+
107
+ class RequireHttpsListFilter(_BooleanAttributeListFilter):
108
+ title = _("Require HTTPS")
109
+ parameter_name = "require_https"
110
+ field_name = "require_https"
111
+
112
+
76
113
  class SiteAdmin(DjangoSiteAdmin):
77
114
  form = SiteForm
78
115
  inlines = [SiteBadgeInline]
79
116
  change_list_template = "admin/sites/site/change_list.html"
80
- fields = ("domain", "name")
81
- list_display = ("domain", "name")
117
+ fields = ("domain", "name", "managed", "require_https")
118
+ list_display = ("domain", "name", "managed", "require_https")
119
+ list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
82
120
  actions = ["capture_screenshot"]
83
121
 
84
122
  @admin.action(description="Capture screenshot")
@@ -110,6 +148,27 @@ class SiteAdmin(DjangoSiteAdmin):
110
148
  messages.INFO,
111
149
  )
112
150
 
151
+ def save_model(self, request, obj, form, change):
152
+ super().save_model(request, obj, form, change)
153
+ if {"managed", "require_https"} & set(form.changed_data or []):
154
+ self.message_user(
155
+ request,
156
+ _(
157
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
158
+ ),
159
+ messages.INFO,
160
+ )
161
+
162
+ def delete_model(self, request, obj):
163
+ super().delete_model(request, obj)
164
+ self.message_user(
165
+ request,
166
+ _(
167
+ "Managed NGINX configuration staged. Run network-setup.sh to apply changes."
168
+ ),
169
+ messages.INFO,
170
+ )
171
+
113
172
  def _reload_site_fixtures(self, request):
114
173
  fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
115
174
  fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
@@ -708,10 +767,33 @@ class UserStoryAdmin(EntityModelAdmin):
708
767
  issue_url = story.create_github_issue()
709
768
  except Exception as exc: # pragma: no cover - network/runtime errors
710
769
  logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
770
+ message = _("Unable to create a GitHub issue for %(story)s: %(error)s") % {
771
+ "story": story,
772
+ "error": exc,
773
+ }
774
+
775
+ if (
776
+ isinstance(exc, RuntimeError)
777
+ and "GitHub token is not configured" in str(exc)
778
+ ):
779
+ try:
780
+ opts = ReleaseManager._meta
781
+ config_url = reverse(
782
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
783
+ )
784
+ except NoReverseMatch: # pragma: no cover - defensive guard
785
+ config_url = None
786
+ if config_url:
787
+ message = format_html(
788
+ "{} <a href=\"{}\">{}</a>",
789
+ message,
790
+ config_url,
791
+ _("Configure GitHub credentials."),
792
+ )
793
+
711
794
  self.message_user(
712
795
  request,
713
- _("Unable to create a GitHub issue for %(story)s: %(error)s")
714
- % {"story": story, "error": exc},
796
+ message,
715
797
  messages.ERROR,
716
798
  )
717
799
  continue
pages/apps.py CHANGED
@@ -8,3 +8,6 @@ class PagesConfig(AppConfig):
8
8
 
9
9
  def ready(self): # pragma: no cover - import for side effects
10
10
  from . import checks # noqa: F401
11
+ from . import site_config
12
+
13
+ site_config.ready()
pages/site_config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Customizations for :mod:`django.contrib.sites`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from django.apps import apps
10
+ from django.conf import settings
11
+ from django.contrib.sites.models import Site
12
+ from django.db import DatabaseError, models
13
+ from django.db.models.signals import post_delete, post_migrate, post_save
14
+ from django.dispatch import receiver
15
+ from django.utils.translation import gettext_lazy as _
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ _FIELD_DEFINITIONS: tuple[tuple[str, models.Field], ...] = (
22
+ (
23
+ "managed",
24
+ models.BooleanField(
25
+ default=False,
26
+ db_default=False,
27
+ verbose_name=_("Managed by local NGINX"),
28
+ help_text=_(
29
+ "Include this site when staging the local NGINX configuration."
30
+ ),
31
+ ),
32
+ ),
33
+ (
34
+ "require_https",
35
+ models.BooleanField(
36
+ default=False,
37
+ db_default=False,
38
+ verbose_name=_("Require HTTPS"),
39
+ help_text=_(
40
+ "Redirect HTTP traffic to HTTPS when the staged NGINX configuration is applied."
41
+ ),
42
+ ),
43
+ ),
44
+ )
45
+
46
+
47
+ def _sites_config_path() -> Path:
48
+ return Path(settings.BASE_DIR) / "scripts" / "generated" / "nginx-sites.json"
49
+
50
+
51
+ def _ensure_directories(path: Path) -> bool:
52
+ try:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ except OSError as exc: # pragma: no cover - filesystem errors
55
+ logger.warning("Unable to create directory for %s: %s", path, exc)
56
+ return False
57
+ return True
58
+
59
+
60
+ def update_local_nginx_scripts() -> None:
61
+ """Serialize managed site configuration for the network setup script."""
62
+
63
+ SiteModel = apps.get_model("sites", "Site")
64
+ data: list[dict[str, object]] = []
65
+ seen_domains: set[str] = set()
66
+
67
+ try:
68
+ sites = list(
69
+ SiteModel.objects.filter(managed=True)
70
+ .only("domain", "require_https")
71
+ .order_by("domain")
72
+ )
73
+ except DatabaseError: # pragma: no cover - database not ready
74
+ return
75
+
76
+ for site in sites:
77
+ domain = (site.domain or "").strip()
78
+ if not domain:
79
+ continue
80
+ if domain.lower() in seen_domains:
81
+ continue
82
+ seen_domains.add(domain.lower())
83
+ data.append({"domain": domain, "require_https": bool(site.require_https)})
84
+
85
+ output_path = _sites_config_path()
86
+ if not _ensure_directories(output_path):
87
+ return
88
+
89
+ if data:
90
+ try:
91
+ output_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
92
+ except OSError as exc: # pragma: no cover - filesystem errors
93
+ logger.warning("Failed to write managed site configuration: %s", exc)
94
+ else:
95
+ try:
96
+ output_path.unlink()
97
+ except FileNotFoundError:
98
+ pass
99
+ except OSError as exc: # pragma: no cover - filesystem errors
100
+ logger.warning("Failed to remove managed site configuration: %s", exc)
101
+
102
+
103
+ def _install_fields() -> None:
104
+ for name, field in _FIELD_DEFINITIONS:
105
+ if hasattr(Site, name):
106
+ continue
107
+ Site.add_to_class(name, field.clone())
108
+
109
+
110
+ def ensure_site_fields() -> None:
111
+ """Ensure the custom ``Site`` fields are installed."""
112
+
113
+ _install_fields()
114
+
115
+
116
+ @receiver(post_save, sender=Site, dispatch_uid="pages_site_save_update_nginx")
117
+ def _site_saved(sender, **kwargs) -> None: # pragma: no cover - signal wrapper
118
+ update_local_nginx_scripts()
119
+
120
+
121
+ @receiver(post_delete, sender=Site, dispatch_uid="pages_site_delete_update_nginx")
122
+ def _site_deleted(sender, **kwargs) -> None: # pragma: no cover - signal wrapper
123
+ update_local_nginx_scripts()
124
+
125
+
126
+ def _run_post_migrate_update(**kwargs) -> None: # pragma: no cover - signal wrapper
127
+ update_local_nginx_scripts()
128
+
129
+
130
+ def ready() -> None:
131
+ """Apply customizations and connect signal handlers."""
132
+
133
+ ensure_site_fields()
134
+ post_migrate.connect(
135
+ _run_post_migrate_update,
136
+ dispatch_uid="pages_site_post_migrate_update",
137
+ )
pages/tests.py CHANGED
@@ -13,13 +13,14 @@ from django.templatetags.static import static
13
13
  from urllib.parse import quote
14
14
  from django.contrib.auth import get_user_model
15
15
  from django.contrib.sites.models import Site
16
- from django.contrib import admin
16
+ from django.contrib import admin, messages
17
17
  from django.contrib.messages.storage.fallback import FallbackStorage
18
18
  from django.core.exceptions import DisallowedHost
19
19
  from django.core.cache import cache
20
20
  from django.db import connection
21
21
  import socket
22
22
  from django.db import connection
23
+ from pages import site_config
23
24
  from pages.models import (
24
25
  Application,
25
26
  Landing,
@@ -47,6 +48,7 @@ from pages.screenshot_specs import (
47
48
  )
48
49
  from pages.context_processors import nav_links
49
50
  from django.apps import apps as django_apps
51
+ from config.middleware import SiteHttpsRedirectMiddleware
50
52
  from core import mailer
51
53
  from core.admin import ProfileAdminMixin
52
54
  from core.models import (
@@ -60,8 +62,10 @@ from core.models import (
60
62
  Todo,
61
63
  TOTPDeviceSettings,
62
64
  )
65
+ from ocpp.models import Charger
63
66
  from django.core.files.uploadedfile import SimpleUploadedFile
64
67
  import base64
68
+ import json
65
69
  import tempfile
66
70
  import shutil
67
71
  from io import StringIO
@@ -72,6 +76,7 @@ from types import SimpleNamespace
72
76
  from django.core.management import call_command
73
77
  import re
74
78
  from django.contrib.contenttypes.models import ContentType
79
+ from django.http import HttpResponse
75
80
  from datetime import (
76
81
  date,
77
82
  datetime,
@@ -1172,6 +1177,125 @@ class AdminModelStatusTests(TestCase):
1172
1177
  self.assertContains(resp, 'class="model-status missing"', count=1)
1173
1178
 
1174
1179
 
1180
+ class _FakeQuerySet(list):
1181
+ def only(self, *args, **kwargs):
1182
+ return self
1183
+
1184
+ def order_by(self, *args, **kwargs):
1185
+ return self
1186
+
1187
+
1188
+ class SiteConfigurationStagingTests(SimpleTestCase):
1189
+ def setUp(self):
1190
+ self.tmpdir = tempfile.mkdtemp()
1191
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1192
+ self.config_path = Path(self.tmpdir) / "nginx-sites.json"
1193
+ self._path_patcher = patch(
1194
+ "pages.site_config._sites_config_path", side_effect=lambda: self.config_path
1195
+ )
1196
+ self._path_patcher.start()
1197
+ self.addCleanup(self._path_patcher.stop)
1198
+ self._model_patcher = patch("pages.site_config.apps.get_model")
1199
+ self.mock_get_model = self._model_patcher.start()
1200
+ self.addCleanup(self._model_patcher.stop)
1201
+
1202
+ def _read_config(self):
1203
+ if not self.config_path.exists():
1204
+ return None
1205
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
1206
+
1207
+ def _set_sites(self, sites):
1208
+ queryset = _FakeQuerySet(sites)
1209
+
1210
+ class _Manager:
1211
+ @staticmethod
1212
+ def filter(**kwargs):
1213
+ return queryset
1214
+
1215
+ self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
1216
+
1217
+ def test_managed_site_persists_configuration(self):
1218
+ self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
1219
+ site_config.update_local_nginx_scripts()
1220
+ config = self._read_config()
1221
+ self.assertEqual(
1222
+ config,
1223
+ [
1224
+ {
1225
+ "domain": "example.com",
1226
+ "require_https": True,
1227
+ }
1228
+ ],
1229
+ )
1230
+
1231
+ def test_disabling_managed_site_removes_entry(self):
1232
+ primary = SimpleNamespace(domain="primary.test", require_https=False)
1233
+ secondary = SimpleNamespace(domain="secondary.test", require_https=False)
1234
+ self._set_sites([primary, secondary])
1235
+ site_config.update_local_nginx_scripts()
1236
+ config = self._read_config()
1237
+ self.assertEqual(
1238
+ [entry["domain"] for entry in config],
1239
+ ["primary.test", "secondary.test"],
1240
+ )
1241
+
1242
+ self._set_sites([secondary])
1243
+ site_config.update_local_nginx_scripts()
1244
+ config = self._read_config()
1245
+ self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
1246
+
1247
+ self._set_sites([])
1248
+ site_config.update_local_nginx_scripts()
1249
+ self.assertIsNone(self._read_config())
1250
+
1251
+ def test_require_https_toggle_updates_configuration(self):
1252
+ site = SimpleNamespace(domain="secure.example", require_https=False)
1253
+ self._set_sites([site])
1254
+ site_config.update_local_nginx_scripts()
1255
+ config = self._read_config()
1256
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
1257
+
1258
+ site.require_https = True
1259
+ self._set_sites([site])
1260
+ site_config.update_local_nginx_scripts()
1261
+ config = self._read_config()
1262
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
1263
+
1264
+
1265
+ class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
1266
+ def setUp(self):
1267
+ self.factory = RequestFactory()
1268
+ self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
1269
+ self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
1270
+
1271
+ def test_http_request_redirects_to_https(self):
1272
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1273
+ request.site = self.secure_site
1274
+ response = self.middleware(request)
1275
+ self.assertEqual(response.status_code, 301)
1276
+ self.assertTrue(response["Location"].startswith("https://secure.test"))
1277
+
1278
+ def test_secure_request_not_redirected(self):
1279
+ request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
1280
+ request.site = self.secure_site
1281
+ response = self.middleware(request)
1282
+ self.assertEqual(response.status_code, 200)
1283
+
1284
+ def test_forwarded_proto_respected(self):
1285
+ request = self.factory.get(
1286
+ "/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
1287
+ )
1288
+ request.site = self.secure_site
1289
+ response = self.middleware(request)
1290
+ self.assertEqual(response.status_code, 200)
1291
+
1292
+ self.secure_site.require_https = False
1293
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1294
+ request.site = self.secure_site
1295
+ response = self.middleware(request)
1296
+ self.assertEqual(response.status_code, 200)
1297
+
1298
+
1175
1299
  class SiteAdminRegisterCurrentTests(TestCase):
1176
1300
  def setUp(self):
1177
1301
  self.client = Client()
@@ -2422,6 +2546,27 @@ class FavoriteTests(TestCase):
2422
2546
  self.assertContains(resp, f'title="{badge_label}"')
2423
2547
  self.assertContains(resp, f'aria-label="{badge_label}"')
2424
2548
 
2549
+ def test_dashboard_shows_charge_point_availability_badge(self):
2550
+ Charger.objects.create(charger_id="CP-001", last_status="Available")
2551
+ Charger.objects.create(
2552
+ charger_id="CP-001", connector_id=1, last_status="Available"
2553
+ )
2554
+ Charger.objects.create(
2555
+ charger_id="CP-002", connector_id=1, last_status="Unavailable"
2556
+ )
2557
+
2558
+ resp = self.client.get(reverse("admin:index"))
2559
+
2560
+ expected = "2 / 1"
2561
+ badge_label = gettext(
2562
+ "%(total)s chargers reporting Available status, including %(with_cp)s with a CP number"
2563
+ ) % {"total": 2, "with_cp": 1}
2564
+
2565
+ self.assertContains(resp, expected)
2566
+ self.assertContains(resp, 'class="charger-availability-badge"')
2567
+ self.assertContains(resp, f'title="{badge_label}"')
2568
+ self.assertContains(resp, f'aria-label="{badge_label}"')
2569
+
2425
2570
  def test_nav_sidebar_hides_dashboard_badges(self):
2426
2571
  InviteLead.objects.create(email="open@example.com")
2427
2572
  RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
@@ -3159,6 +3304,40 @@ class UserStoryAdminActionTests(TestCase):
3159
3304
 
3160
3305
  mock_create_issue.assert_not_called()
3161
3306
 
3307
+ def test_create_github_issues_action_links_to_credentials_when_missing(self):
3308
+ request = self._build_request()
3309
+ queryset = UserStory.objects.filter(pk=self.story.pk)
3310
+
3311
+ mock_url = "/admin/core/releasemanager/"
3312
+ with (
3313
+ patch(
3314
+ "pages.admin.reverse", return_value=mock_url
3315
+ ) as mock_reverse,
3316
+ patch.object(
3317
+ UserStory,
3318
+ "create_github_issue",
3319
+ side_effect=RuntimeError("GitHub token is not configured"),
3320
+ ),
3321
+ ):
3322
+ self.admin.create_github_issues(request, queryset)
3323
+
3324
+ messages_list = list(request._messages)
3325
+ self.assertTrue(messages_list)
3326
+
3327
+ opts = ReleaseManager._meta
3328
+ mock_reverse.assert_called_once_with(
3329
+ f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
3330
+ )
3331
+ self.assertTrue(
3332
+ any(mock_url in message.message for message in messages_list),
3333
+ )
3334
+ self.assertTrue(
3335
+ any("Configure GitHub credentials" in message.message for message in messages_list),
3336
+ )
3337
+ self.assertTrue(
3338
+ any(message.level == messages.ERROR for message in messages_list),
3339
+ )
3340
+
3162
3341
 
3163
3342
  class ClientReportLiveUpdateTests(TestCase):
3164
3343
  def setUp(self):