arthexis 0.1.17__py3-none-any.whl → 0.1.19__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.19.dist-info}/METADATA +37 -10
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/RECORD +35 -34
- config/middleware.py +47 -1
- config/settings.py +2 -5
- config/urls.py +5 -0
- core/admin.py +1 -1
- core/models.py +31 -1
- core/system.py +125 -0
- core/tasks.py +0 -22
- core/tests.py +9 -0
- core/views.py +87 -19
- nodes/admin.py +1 -2
- nodes/models.py +18 -23
- nodes/tests.py +42 -34
- nodes/urls.py +0 -1
- nodes/views.py +2 -15
- ocpp/admin.py +23 -2
- ocpp/consumers.py +63 -19
- ocpp/models.py +7 -0
- ocpp/store.py +6 -4
- ocpp/test_rfid.py +70 -0
- ocpp/tests.py +107 -1
- ocpp/views.py +84 -10
- pages/admin.py +150 -15
- pages/apps.py +3 -0
- pages/context_processors.py +11 -0
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/site_config.py +137 -0
- pages/tests.py +352 -30
- pages/urls.py +2 -1
- pages/views.py +70 -23
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/WHEEL +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.17.dist-info → arthexis-0.1.19.dist-info}/top_level.txt +0 -0
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
|
@@ -1852,6 +1852,16 @@ class ChargerLandingTests(TestCase):
|
|
|
1852
1852
|
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1853
1853
|
self.assertContains(response, status_url)
|
|
1854
1854
|
|
|
1855
|
+
def test_charger_page_respects_language_configuration(self):
|
|
1856
|
+
charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
|
|
1857
|
+
|
|
1858
|
+
response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
|
|
1859
|
+
|
|
1860
|
+
self.assertEqual(response.status_code, 200)
|
|
1861
|
+
self.assertEqual(response.context["LANGUAGE_CODE"], "de")
|
|
1862
|
+
self.assertContains(response, 'lang="de"')
|
|
1863
|
+
self.assertContains(response, 'data-preferred-language="de"')
|
|
1864
|
+
|
|
1855
1865
|
def test_status_page_renders(self):
|
|
1856
1866
|
charger = Charger.objects.create(charger_id="PAGE2")
|
|
1857
1867
|
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
@@ -2078,7 +2088,10 @@ class ChargerLandingTests(TestCase):
|
|
|
2078
2088
|
log_id = store.identity_key("LOG1", None)
|
|
2079
2089
|
store.add_log(log_id, "hello", log_type="charger")
|
|
2080
2090
|
entry = store.get_logs(log_id, log_type="charger")[0]
|
|
2081
|
-
self.assertRegex(
|
|
2091
|
+
self.assertRegex(
|
|
2092
|
+
entry,
|
|
2093
|
+
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
|
|
2094
|
+
)
|
|
2082
2095
|
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
2083
2096
|
self.assertEqual(resp.status_code, 200)
|
|
2084
2097
|
self.assertContains(resp, "hello")
|
|
@@ -2949,6 +2962,44 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2949
2962
|
|
|
2950
2963
|
await communicator.disconnect()
|
|
2951
2964
|
|
|
2965
|
+
async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
|
|
2966
|
+
charger_id = "AUTHWARN"
|
|
2967
|
+
tag_value = "WARN01"
|
|
2968
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
2969
|
+
charger_id=charger_id, require_rfid=True
|
|
2970
|
+
)
|
|
2971
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
2972
|
+
|
|
2973
|
+
pending_key = store.pending_key(charger_id)
|
|
2974
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2975
|
+
|
|
2976
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
2977
|
+
connected, _ = await communicator.connect()
|
|
2978
|
+
self.assertTrue(connected)
|
|
2979
|
+
|
|
2980
|
+
message_id = "auth-unlinked"
|
|
2981
|
+
await communicator.send_json_to(
|
|
2982
|
+
[2, message_id, "Authorize", {"idTag": tag_value}]
|
|
2983
|
+
)
|
|
2984
|
+
response = await communicator.receive_json_from()
|
|
2985
|
+
self.assertEqual(response[0], 3)
|
|
2986
|
+
self.assertEqual(response[1], message_id)
|
|
2987
|
+
self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
|
|
2988
|
+
|
|
2989
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
2990
|
+
self.assertTrue(
|
|
2991
|
+
any(
|
|
2992
|
+
"Authorized RFID" in entry
|
|
2993
|
+
and tag_value in entry
|
|
2994
|
+
and charger_id in entry
|
|
2995
|
+
for entry in log_entries
|
|
2996
|
+
),
|
|
2997
|
+
log_entries,
|
|
2998
|
+
)
|
|
2999
|
+
|
|
3000
|
+
await communicator.disconnect()
|
|
3001
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3002
|
+
|
|
2952
3003
|
async def test_authorize_without_requirement_records_rfid(self):
|
|
2953
3004
|
await database_sync_to_async(Charger.objects.create)(
|
|
2954
3005
|
charger_id="AUTHOPT", require_rfid=False
|
|
@@ -3041,6 +3092,61 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
3041
3092
|
)
|
|
3042
3093
|
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
3043
3094
|
|
|
3095
|
+
async def test_start_transaction_allows_allowed_tag_without_account(self):
|
|
3096
|
+
charger_id = "STARTWARN"
|
|
3097
|
+
tag_value = "WARN02"
|
|
3098
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
3099
|
+
charger_id=charger_id, require_rfid=True
|
|
3100
|
+
)
|
|
3101
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
3102
|
+
|
|
3103
|
+
pending_key = store.pending_key(charger_id)
|
|
3104
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3105
|
+
|
|
3106
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
3107
|
+
connected, _ = await communicator.connect()
|
|
3108
|
+
self.assertTrue(connected)
|
|
3109
|
+
|
|
3110
|
+
start_payload = {
|
|
3111
|
+
"meterStart": 5,
|
|
3112
|
+
"idTag": tag_value,
|
|
3113
|
+
"connectorId": 1,
|
|
3114
|
+
}
|
|
3115
|
+
await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
|
|
3116
|
+
response = await communicator.receive_json_from()
|
|
3117
|
+
self.assertEqual(response[0], 3)
|
|
3118
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
3119
|
+
tx_id = response[2]["transactionId"]
|
|
3120
|
+
|
|
3121
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
3122
|
+
pk=tx_id, charger__charger_id=charger_id
|
|
3123
|
+
)
|
|
3124
|
+
self.assertIsNone(tx.account_id)
|
|
3125
|
+
|
|
3126
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
3127
|
+
self.assertTrue(
|
|
3128
|
+
any(
|
|
3129
|
+
"Authorized RFID" in entry
|
|
3130
|
+
and tag_value in entry
|
|
3131
|
+
and charger_id in entry
|
|
3132
|
+
for entry in log_entries
|
|
3133
|
+
),
|
|
3134
|
+
log_entries,
|
|
3135
|
+
)
|
|
3136
|
+
|
|
3137
|
+
await communicator.send_json_to(
|
|
3138
|
+
[
|
|
3139
|
+
2,
|
|
3140
|
+
"stop-1",
|
|
3141
|
+
"StopTransaction",
|
|
3142
|
+
{"transactionId": tx_id, "meterStop": 6},
|
|
3143
|
+
]
|
|
3144
|
+
)
|
|
3145
|
+
await communicator.receive_json_from()
|
|
3146
|
+
|
|
3147
|
+
await communicator.disconnect()
|
|
3148
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3149
|
+
|
|
3044
3150
|
async def test_status_fields_updated(self):
|
|
3045
3151
|
communicator = WebsocketCommunicator(application, "/STAT/")
|
|
3046
3152
|
connected, _ = await communicator.connect()
|
ocpp/views.py
CHANGED
|
@@ -11,6 +11,7 @@ from django.core.paginator import Paginator
|
|
|
11
11
|
from django.contrib.auth.decorators import login_required
|
|
12
12
|
from django.contrib.auth.views import redirect_to_login
|
|
13
13
|
from django.utils.translation import gettext_lazy as _, gettext, ngettext
|
|
14
|
+
from django.utils.text import slugify
|
|
14
15
|
from django.urls import NoReverseMatch, reverse
|
|
15
16
|
from django.conf import settings
|
|
16
17
|
from django.utils import translation, timezone
|
|
@@ -309,9 +310,14 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
|
|
|
309
310
|
"""Return static translations used by the charger public landing page."""
|
|
310
311
|
|
|
311
312
|
catalog: dict[str, dict[str, str]] = {}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
seen_codes: set[str] = set()
|
|
314
|
+
for code, _name in settings.LANGUAGES:
|
|
315
|
+
normalized = str(code).strip()
|
|
316
|
+
if not normalized or normalized in seen_codes:
|
|
317
|
+
continue
|
|
318
|
+
seen_codes.add(normalized)
|
|
319
|
+
with translation.override(normalized):
|
|
320
|
+
catalog[normalized] = {
|
|
315
321
|
"serial_number_label": gettext("Serial Number"),
|
|
316
322
|
"connector_label": gettext("Connector"),
|
|
317
323
|
"advanced_view_label": gettext("Advanced View"),
|
|
@@ -842,11 +848,30 @@ def charger_page(request, cid, connector=None):
|
|
|
842
848
|
state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
|
|
843
849
|
state, color = _charger_state(charger, state_source)
|
|
844
850
|
language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
851
|
+
available_languages = [
|
|
852
|
+
str(code).strip()
|
|
853
|
+
for code, _ in settings.LANGUAGES
|
|
854
|
+
if str(code).strip()
|
|
855
|
+
]
|
|
856
|
+
supported_languages = set(available_languages)
|
|
857
|
+
charger_language = (charger.language or "es").strip()
|
|
858
|
+
if charger_language not in supported_languages:
|
|
859
|
+
fallback = "es" if "es" in supported_languages else ""
|
|
860
|
+
if not fallback and available_languages:
|
|
861
|
+
fallback = available_languages[0]
|
|
862
|
+
charger_language = fallback
|
|
863
|
+
if (
|
|
864
|
+
charger_language
|
|
865
|
+
and (
|
|
866
|
+
not language_cookie
|
|
867
|
+
or language_cookie not in supported_languages
|
|
868
|
+
or language_cookie != charger_language
|
|
869
|
+
)
|
|
870
|
+
):
|
|
871
|
+
translation.activate(charger_language)
|
|
872
|
+
current_language = translation.get_language()
|
|
873
|
+
request.LANGUAGE_CODE = current_language
|
|
874
|
+
preferred_language = charger_language or current_language
|
|
850
875
|
connector_links = [
|
|
851
876
|
{
|
|
852
877
|
"slug": item["slug"],
|
|
@@ -874,6 +899,7 @@ def charger_page(request, cid, connector=None):
|
|
|
874
899
|
"active_connector_count": active_connector_count,
|
|
875
900
|
"status_url": status_url,
|
|
876
901
|
"landing_translations": _landing_page_translations(),
|
|
902
|
+
"preferred_language": preferred_language,
|
|
877
903
|
"state": state,
|
|
878
904
|
"color": color,
|
|
879
905
|
},
|
|
@@ -1209,17 +1235,65 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1209
1235
|
charger_id=cid
|
|
1210
1236
|
)
|
|
1211
1237
|
target_id = cid
|
|
1212
|
-
|
|
1238
|
+
|
|
1239
|
+
slug_source = slugify(target_id) or slugify(cid) or "log"
|
|
1240
|
+
filename_parts = [log_type, slug_source]
|
|
1241
|
+
download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
|
|
1242
|
+
limit_options = [
|
|
1243
|
+
{"value": "20", "label": "20"},
|
|
1244
|
+
{"value": "40", "label": "40"},
|
|
1245
|
+
{"value": "100", "label": "100"},
|
|
1246
|
+
{"value": "all", "label": gettext("All")},
|
|
1247
|
+
]
|
|
1248
|
+
allowed_values = [item["value"] for item in limit_options]
|
|
1249
|
+
limit_choice = request.GET.get("limit", "20")
|
|
1250
|
+
if limit_choice not in allowed_values:
|
|
1251
|
+
limit_choice = "20"
|
|
1252
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1253
|
+
|
|
1254
|
+
log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1255
|
+
download_requested = request.GET.get("download") == "1"
|
|
1256
|
+
if download_requested:
|
|
1257
|
+
download_content = "\n".join(log_entries_all)
|
|
1258
|
+
if download_content and not download_content.endswith("\n"):
|
|
1259
|
+
download_content = f"{download_content}\n"
|
|
1260
|
+
response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
|
|
1261
|
+
response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
|
|
1262
|
+
return response
|
|
1263
|
+
|
|
1264
|
+
log_entries = log_entries_all
|
|
1265
|
+
if limit_choice != "all":
|
|
1266
|
+
try:
|
|
1267
|
+
limit_value = int(limit_choice)
|
|
1268
|
+
except (TypeError, ValueError):
|
|
1269
|
+
limit_value = 20
|
|
1270
|
+
limit_choice = "20"
|
|
1271
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1272
|
+
log_entries = log_entries[-limit_value:]
|
|
1273
|
+
|
|
1274
|
+
download_params = request.GET.copy()
|
|
1275
|
+
download_params["download"] = "1"
|
|
1276
|
+
download_params.pop("limit", None)
|
|
1277
|
+
download_query = download_params.urlencode()
|
|
1278
|
+
log_download_url = f"{request.path}?{download_query}" if download_query else request.path
|
|
1279
|
+
|
|
1280
|
+
limit_label = limit_options[limit_index]["label"]
|
|
1213
1281
|
return render(
|
|
1214
1282
|
request,
|
|
1215
1283
|
"ocpp/charger_logs.html",
|
|
1216
1284
|
{
|
|
1217
1285
|
"charger": charger,
|
|
1218
|
-
"log":
|
|
1286
|
+
"log": log_entries,
|
|
1219
1287
|
"log_type": log_type,
|
|
1220
1288
|
"connector_slug": connector_slug,
|
|
1221
1289
|
"connector_links": connector_links,
|
|
1222
1290
|
"status_url": status_url,
|
|
1291
|
+
"log_limit_options": limit_options,
|
|
1292
|
+
"log_limit_index": limit_index,
|
|
1293
|
+
"log_limit_choice": limit_choice,
|
|
1294
|
+
"log_limit_label": limit_label,
|
|
1295
|
+
"log_download_url": log_download_url,
|
|
1296
|
+
"log_filename": download_filename,
|
|
1223
1297
|
},
|
|
1224
1298
|
)
|
|
1225
1299
|
|
pages/admin.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections import deque
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
from django.contrib import admin, messages
|
|
@@ -6,12 +7,14 @@ from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
|
|
|
6
7
|
from django.contrib.sites.models import Site
|
|
7
8
|
from django import forms
|
|
8
9
|
from django.shortcuts import redirect, render, get_object_or_404
|
|
9
|
-
from django.urls import path, reverse
|
|
10
|
+
from django.urls import NoReverseMatch, path, reverse
|
|
10
11
|
from django.utils.html import format_html
|
|
12
|
+
|
|
11
13
|
from django.template.response import TemplateResponse
|
|
12
|
-
from django.http import JsonResponse
|
|
14
|
+
from django.http import FileResponse, JsonResponse
|
|
13
15
|
from django.utils import timezone
|
|
14
16
|
from django.db.models import Count
|
|
17
|
+
from django.core.exceptions import FieldError
|
|
15
18
|
from django.db.models.functions import TruncDate
|
|
16
19
|
from datetime import datetime, time, timedelta
|
|
17
20
|
import ipaddress
|
|
@@ -25,6 +28,7 @@ from nodes.utils import capture_screenshot, save_screenshot
|
|
|
25
28
|
|
|
26
29
|
from .forms import UserManualAdminForm
|
|
27
30
|
from .module_defaults import reload_default_modules as restore_default_modules
|
|
31
|
+
from .site_config import ensure_site_fields
|
|
28
32
|
from .utils import landing_leads_supported
|
|
29
33
|
|
|
30
34
|
from .models import (
|
|
@@ -41,6 +45,7 @@ from .models import (
|
|
|
41
45
|
UserStory,
|
|
42
46
|
)
|
|
43
47
|
from django.contrib.contenttypes.models import ContentType
|
|
48
|
+
from core.models import ReleaseManager
|
|
44
49
|
from core.user_data import EntityModelAdmin
|
|
45
50
|
|
|
46
51
|
|
|
@@ -73,12 +78,47 @@ class SiteForm(forms.ModelForm):
|
|
|
73
78
|
fields = "__all__"
|
|
74
79
|
|
|
75
80
|
|
|
81
|
+
ensure_site_fields()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _BooleanAttributeListFilter(admin.SimpleListFilter):
|
|
85
|
+
"""Filter helper for boolean attributes on :class:`~django.contrib.sites.models.Site`."""
|
|
86
|
+
|
|
87
|
+
field_name: str
|
|
88
|
+
|
|
89
|
+
def lookups(self, request, model_admin): # pragma: no cover - admin UI
|
|
90
|
+
return (("1", _("Yes")), ("0", _("No")))
|
|
91
|
+
|
|
92
|
+
def queryset(self, request, queryset):
|
|
93
|
+
value = self.value()
|
|
94
|
+
if value not in {"0", "1"}:
|
|
95
|
+
return queryset
|
|
96
|
+
expected = value == "1"
|
|
97
|
+
try:
|
|
98
|
+
return queryset.filter(**{self.field_name: expected})
|
|
99
|
+
except FieldError: # pragma: no cover - defensive when fields missing
|
|
100
|
+
return queryset
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ManagedSiteListFilter(_BooleanAttributeListFilter):
|
|
104
|
+
title = _("Managed by local NGINX")
|
|
105
|
+
parameter_name = "managed"
|
|
106
|
+
field_name = "managed"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RequireHttpsListFilter(_BooleanAttributeListFilter):
|
|
110
|
+
title = _("Require HTTPS")
|
|
111
|
+
parameter_name = "require_https"
|
|
112
|
+
field_name = "require_https"
|
|
113
|
+
|
|
114
|
+
|
|
76
115
|
class SiteAdmin(DjangoSiteAdmin):
|
|
77
116
|
form = SiteForm
|
|
78
117
|
inlines = [SiteBadgeInline]
|
|
79
118
|
change_list_template = "admin/sites/site/change_list.html"
|
|
80
|
-
fields = ("domain", "name")
|
|
81
|
-
list_display = ("domain", "name")
|
|
119
|
+
fields = ("domain", "name", "managed", "require_https")
|
|
120
|
+
list_display = ("domain", "name", "managed", "require_https")
|
|
121
|
+
list_filter = (ManagedSiteListFilter, RequireHttpsListFilter)
|
|
82
122
|
actions = ["capture_screenshot"]
|
|
83
123
|
|
|
84
124
|
@admin.action(description="Capture screenshot")
|
|
@@ -110,6 +150,27 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
110
150
|
messages.INFO,
|
|
111
151
|
)
|
|
112
152
|
|
|
153
|
+
def save_model(self, request, obj, form, change):
|
|
154
|
+
super().save_model(request, obj, form, change)
|
|
155
|
+
if {"managed", "require_https"} & set(form.changed_data or []):
|
|
156
|
+
self.message_user(
|
|
157
|
+
request,
|
|
158
|
+
_(
|
|
159
|
+
"Managed NGINX configuration staged. Run network-setup.sh to apply changes."
|
|
160
|
+
),
|
|
161
|
+
messages.INFO,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def delete_model(self, request, obj):
|
|
165
|
+
super().delete_model(request, obj)
|
|
166
|
+
self.message_user(
|
|
167
|
+
request,
|
|
168
|
+
_(
|
|
169
|
+
"Managed NGINX configuration staged. Run network-setup.sh to apply changes."
|
|
170
|
+
),
|
|
171
|
+
messages.INFO,
|
|
172
|
+
)
|
|
173
|
+
|
|
113
174
|
def _reload_site_fixtures(self, request):
|
|
114
175
|
fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
|
|
115
176
|
fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
|
|
@@ -230,14 +291,19 @@ class ApplicationAdmin(EntityModelAdmin):
|
|
|
230
291
|
class LandingInline(admin.TabularInline):
|
|
231
292
|
model = Landing
|
|
232
293
|
extra = 0
|
|
233
|
-
fields = ("path", "label", "enabled")
|
|
294
|
+
fields = ("path", "label", "enabled", "track_leads")
|
|
234
295
|
show_change_link = True
|
|
235
296
|
|
|
236
297
|
|
|
237
298
|
@admin.register(Landing)
|
|
238
299
|
class LandingAdmin(EntityModelAdmin):
|
|
239
|
-
list_display = ("label", "path", "module", "enabled")
|
|
240
|
-
list_filter = (
|
|
300
|
+
list_display = ("label", "path", "module", "enabled", "track_leads")
|
|
301
|
+
list_filter = (
|
|
302
|
+
"enabled",
|
|
303
|
+
"track_leads",
|
|
304
|
+
"module__node_role",
|
|
305
|
+
"module__application",
|
|
306
|
+
)
|
|
241
307
|
search_fields = (
|
|
242
308
|
"label",
|
|
243
309
|
"path",
|
|
@@ -246,7 +312,7 @@ class LandingAdmin(EntityModelAdmin):
|
|
|
246
312
|
"module__application__name",
|
|
247
313
|
"module__node_role__name",
|
|
248
314
|
)
|
|
249
|
-
fields = ("module", "path", "label", "enabled", "description")
|
|
315
|
+
fields = ("module", "path", "label", "enabled", "track_leads", "description")
|
|
250
316
|
list_select_related = ("module", "module__application", "module__node_role")
|
|
251
317
|
|
|
252
318
|
|
|
@@ -708,10 +774,33 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
708
774
|
issue_url = story.create_github_issue()
|
|
709
775
|
except Exception as exc: # pragma: no cover - network/runtime errors
|
|
710
776
|
logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
|
|
777
|
+
message = _("Unable to create a GitHub issue for %(story)s: %(error)s") % {
|
|
778
|
+
"story": story,
|
|
779
|
+
"error": exc,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (
|
|
783
|
+
isinstance(exc, RuntimeError)
|
|
784
|
+
and "GitHub token is not configured" in str(exc)
|
|
785
|
+
):
|
|
786
|
+
try:
|
|
787
|
+
opts = ReleaseManager._meta
|
|
788
|
+
config_url = reverse(
|
|
789
|
+
f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
|
|
790
|
+
)
|
|
791
|
+
except NoReverseMatch: # pragma: no cover - defensive guard
|
|
792
|
+
config_url = None
|
|
793
|
+
if config_url:
|
|
794
|
+
message = format_html(
|
|
795
|
+
"{} <a href=\"{}\">{}</a>",
|
|
796
|
+
message,
|
|
797
|
+
config_url,
|
|
798
|
+
_("Configure GitHub credentials."),
|
|
799
|
+
)
|
|
800
|
+
|
|
711
801
|
self.message_user(
|
|
712
802
|
request,
|
|
713
|
-
|
|
714
|
-
% {"story": story, "error": exc},
|
|
803
|
+
message,
|
|
715
804
|
messages.ERROR,
|
|
716
805
|
)
|
|
717
806
|
continue
|
|
@@ -796,6 +885,13 @@ def favorite_clear(request):
|
|
|
796
885
|
return redirect("admin:favorite_list")
|
|
797
886
|
|
|
798
887
|
|
|
888
|
+
def _read_log_tail(path: Path, limit: int) -> str:
|
|
889
|
+
"""Return the last ``limit`` lines from ``path`` preserving newlines."""
|
|
890
|
+
|
|
891
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
892
|
+
return "".join(deque(handle, maxlen=limit))
|
|
893
|
+
|
|
894
|
+
|
|
799
895
|
def log_viewer(request):
|
|
800
896
|
logs_dir = Path(settings.BASE_DIR) / "logs"
|
|
801
897
|
logs_exist = logs_dir.exists() and logs_dir.is_dir()
|
|
@@ -813,16 +909,50 @@ def log_viewer(request):
|
|
|
813
909
|
selected_log = request.GET.get("log", "")
|
|
814
910
|
log_content = ""
|
|
815
911
|
log_error = ""
|
|
912
|
+
limit_options = [
|
|
913
|
+
{"value": "20", "label": "20"},
|
|
914
|
+
{"value": "40", "label": "40"},
|
|
915
|
+
{"value": "100", "label": "100"},
|
|
916
|
+
{"value": "all", "label": _("All")},
|
|
917
|
+
]
|
|
918
|
+
allowed_limits = [item["value"] for item in limit_options]
|
|
919
|
+
limit_choice = request.GET.get("limit", "20")
|
|
920
|
+
if limit_choice not in allowed_limits:
|
|
921
|
+
limit_choice = "20"
|
|
922
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
923
|
+
download_requested = request.GET.get("download") == "1"
|
|
816
924
|
|
|
817
925
|
if selected_log:
|
|
818
926
|
if selected_log in available_logs:
|
|
819
927
|
selected_path = logs_dir / selected_log
|
|
820
928
|
try:
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
929
|
+
if download_requested:
|
|
930
|
+
return FileResponse(
|
|
931
|
+
selected_path.open("rb"),
|
|
932
|
+
as_attachment=True,
|
|
933
|
+
filename=selected_log,
|
|
934
|
+
)
|
|
935
|
+
if limit_choice == "all":
|
|
936
|
+
try:
|
|
937
|
+
log_content = selected_path.read_text(encoding="utf-8")
|
|
938
|
+
except UnicodeDecodeError:
|
|
939
|
+
log_content = selected_path.read_text(
|
|
940
|
+
encoding="utf-8", errors="replace"
|
|
941
|
+
)
|
|
942
|
+
else:
|
|
943
|
+
try:
|
|
944
|
+
limit_value = int(limit_choice)
|
|
945
|
+
except (TypeError, ValueError):
|
|
946
|
+
limit_value = 20
|
|
947
|
+
limit_choice = "20"
|
|
948
|
+
limit_index = allowed_limits.index(limit_choice)
|
|
949
|
+
try:
|
|
950
|
+
log_content = _read_log_tail(selected_path, limit_value)
|
|
951
|
+
except UnicodeDecodeError:
|
|
952
|
+
with selected_path.open(
|
|
953
|
+
"r", encoding="utf-8", errors="replace"
|
|
954
|
+
) as handle:
|
|
955
|
+
log_content = "".join(deque(handle, maxlen=limit_value))
|
|
826
956
|
except OSError as exc: # pragma: no cover - filesystem edge cases
|
|
827
957
|
logger.warning("Unable to read log file %s", selected_path, exc_info=exc)
|
|
828
958
|
log_error = _(
|
|
@@ -840,6 +970,7 @@ def log_viewer(request):
|
|
|
840
970
|
else:
|
|
841
971
|
log_notice = ""
|
|
842
972
|
|
|
973
|
+
limit_label = limit_options[limit_index]["label"]
|
|
843
974
|
context = {**admin.site.each_context(request)}
|
|
844
975
|
context.update(
|
|
845
976
|
{
|
|
@@ -850,6 +981,10 @@ def log_viewer(request):
|
|
|
850
981
|
"log_error": log_error,
|
|
851
982
|
"log_notice": log_notice,
|
|
852
983
|
"logs_directory": logs_dir,
|
|
984
|
+
"log_limit_options": limit_options,
|
|
985
|
+
"log_limit_index": limit_index,
|
|
986
|
+
"log_limit_choice": limit_choice,
|
|
987
|
+
"log_limit_label": limit_label,
|
|
853
988
|
}
|
|
854
989
|
)
|
|
855
990
|
return TemplateResponse(request, "admin/log_viewer.html", context)
|
pages/apps.py
CHANGED
pages/context_processors.py
CHANGED
|
@@ -87,6 +87,17 @@ def nav_links(request):
|
|
|
87
87
|
continue
|
|
88
88
|
landings.append(landing)
|
|
89
89
|
if landings:
|
|
90
|
+
normalized_module_path = module.path.rstrip("/") or "/"
|
|
91
|
+
if normalized_module_path == "/read":
|
|
92
|
+
primary_landings = [
|
|
93
|
+
landing
|
|
94
|
+
for landing in landings
|
|
95
|
+
if landing.path.rstrip("/") == normalized_module_path
|
|
96
|
+
]
|
|
97
|
+
if primary_landings:
|
|
98
|
+
landings = primary_landings
|
|
99
|
+
else:
|
|
100
|
+
landings = [landings[0]]
|
|
90
101
|
app_name = getattr(module.application, "name", "").lower()
|
|
91
102
|
if app_name == "awg":
|
|
92
103
|
module.menu = "Calculate"
|