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.
- {arthexis-0.1.17.dist-info → arthexis-0.1.18.dist-info}/METADATA +1 -1
- {arthexis-0.1.17.dist-info → arthexis-0.1.18.dist-info}/RECORD +20 -19
- config/middleware.py +47 -1
- config/settings.py +1 -0
- config/urls.py +5 -0
- core/admin.py +1 -1
- core/models.py +31 -1
- core/tests.py +9 -0
- core/views.py +55 -18
- ocpp/consumers.py +63 -19
- ocpp/test_rfid.py +70 -0
- ocpp/tests.py +93 -0
- ocpp/views.py +23 -2
- pages/admin.py +87 -5
- pages/apps.py +3 -0
- pages/site_config.py +137 -0
- pages/tests.py +180 -1
- {arthexis-0.1.17.dist-info → arthexis-0.1.18.dist-info}/WHEEL +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.18.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.18.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
arthexis-0.1.
|
|
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=
|
|
11
|
+
config/middleware.py,sha256=zF8Cma0n5G8NNdh2LVeNJi7Hgl1G4mF9msRE2eRi1RU,2328
|
|
12
12
|
config/offline.py,sha256=X-yDcyoI4C44Y27lpkUwszY_09GwwFfazEsthKJpQ70,1382
|
|
13
|
-
config/settings.py,sha256=
|
|
13
|
+
config/settings.py,sha256=fkLL3nbh01KqTVS9M7QH19i3HOuvVD6OTEvApy56Y4w,21569
|
|
14
14
|
config/settings_helpers.py,sha256=0BdBciUHIkwsWa0vV_RKAd4wDuEzgE7G-42XYiES4YQ,3127
|
|
15
|
-
config/urls.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
89
|
-
ocpp/tests.py,sha256=
|
|
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=
|
|
92
|
+
ocpp/views.py,sha256=PYuSUclq9IZrKrS4iHP2EJ_-alRcLgXDXabmmenhda0,57970
|
|
93
93
|
pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
94
|
-
pages/admin.py,sha256=
|
|
95
|
-
pages/apps.py,sha256=
|
|
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=
|
|
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.
|
|
109
|
-
arthexis-0.1.
|
|
110
|
-
arthexis-0.1.
|
|
111
|
-
arthexis-0.1.
|
|
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
|
-
|
|
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
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
if
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1507
|
+
tag = None
|
|
1508
|
+
tag_created = False
|
|
1479
1509
|
if id_tag:
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
714
|
-
% {"story": story, "error": exc},
|
|
796
|
+
message,
|
|
715
797
|
messages.ERROR,
|
|
716
798
|
)
|
|
717
799
|
continue
|
pages/apps.py
CHANGED
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|