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

ocpp/transactions_io.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
  from typing import Iterable
5
5
 
6
+ from django.core.exceptions import ValidationError
6
7
  from django.utils import timezone
7
8
  from django.utils.dateparse import parse_datetime
8
9
 
@@ -107,23 +108,36 @@ def import_transactions(data: dict) -> int:
107
108
  """
108
109
  charger_map: dict[str, Charger] = {}
109
110
  for item in data.get("chargers", []):
111
+ try:
112
+ serial = Charger.validate_serial(item.get("charger_id"))
113
+ except ValidationError:
114
+ continue
110
115
  connector_value = item.get("connector_id", None)
111
116
  if connector_value in ("", None):
112
117
  connector_value = None
113
118
  elif isinstance(connector_value, str):
114
119
  connector_value = int(connector_value)
115
120
  charger, _ = Charger.objects.get_or_create(
116
- charger_id=item["charger_id"],
121
+ charger_id=serial,
117
122
  defaults={
118
123
  "connector_id": connector_value,
119
124
  "require_rfid": item.get("require_rfid", False),
120
125
  },
121
126
  )
122
- charger_map[item["charger_id"]] = charger
127
+ charger_map[serial] = charger
123
128
 
124
129
  imported = 0
125
130
  for tx in data.get("transactions", []):
126
- charger = charger_map.get(tx.get("charger"))
131
+ serial = Charger.normalize_serial(tx.get("charger"))
132
+ if not serial or Charger.is_placeholder_serial(serial):
133
+ continue
134
+ charger = charger_map.get(serial)
135
+ if charger is None:
136
+ try:
137
+ charger, _ = Charger.objects.get_or_create(charger_id=serial)
138
+ except ValidationError:
139
+ continue
140
+ charger_map[serial] = charger
127
141
  transaction = Transaction.objects.create(
128
142
  charger=charger,
129
143
  account_id=tx.get("account"),
ocpp/views.py CHANGED
@@ -1,5 +1,5 @@
1
- import asyncio
2
1
  import json
2
+ import uuid
3
3
  from datetime import datetime, timedelta, timezone as dt_timezone
4
4
  from types import SimpleNamespace
5
5
 
@@ -13,7 +13,10 @@ from django.contrib.auth.views import redirect_to_login
13
13
  from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
14
  from django.urls import NoReverseMatch, reverse
15
15
  from django.conf import settings
16
- from django.utils import translation
16
+ from django.utils import translation, timezone
17
+ from django.core.exceptions import ValidationError
18
+
19
+ from asgiref.sync import async_to_sync
17
20
 
18
21
  from utils.api import api_login_required
19
22
 
@@ -23,7 +26,7 @@ from pages.utils import landing
23
26
  from core.liveupdate import live_update
24
27
 
25
28
  from . import store
26
- from .models import Transaction, Charger
29
+ from .models import Transaction, Charger, DataTransferMessage
27
30
  from .evcs import (
28
31
  _start_simulator,
29
32
  _stop_simulator,
@@ -57,6 +60,10 @@ def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
57
60
  def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
58
61
  """Return charger for the requested identity, creating if necessary."""
59
62
 
63
+ try:
64
+ serial = Charger.validate_serial(serial)
65
+ except ValidationError as exc:
66
+ raise Http404("Charger not found") from exc
60
67
  connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
61
68
  if connector_value is None:
62
69
  charger, _ = Charger.objects.get_or_create(
@@ -79,11 +86,26 @@ def _connector_set(charger: Charger) -> list[Charger]:
79
86
  return siblings
80
87
 
81
88
 
82
- def _connector_overview(charger: Charger) -> list[dict]:
89
+ def _visible_chargers(user):
90
+ """Return chargers visible to ``user`` on public dashboards."""
91
+
92
+ return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
93
+
94
+
95
+ def _ensure_charger_access(user, charger: Charger):
96
+ """Raise 404 when the user cannot view the charger."""
97
+
98
+ if not charger.is_visible_to(user):
99
+ raise Http404("Charger not found")
100
+
101
+
102
+ def _connector_overview(charger: Charger, user=None) -> list[dict]:
83
103
  """Return connector metadata used for navigation and summaries."""
84
104
 
85
105
  overview: list[dict] = []
86
106
  for sibling in _connector_set(charger):
107
+ if user is not None and not sibling.is_visible_to(user):
108
+ continue
87
109
  tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
88
110
  state, color = _charger_state(sibling, tx_obj)
89
111
  overview.append(
@@ -232,7 +254,7 @@ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
232
254
  def charger_list(request):
233
255
  """Return a JSON list of known chargers and state."""
234
256
  data = []
235
- for charger in Charger.objects.filter(public_display=True):
257
+ for charger in _visible_chargers(request.user):
236
258
  cid = charger.charger_id
237
259
  sessions: list[tuple[Charger, Transaction]] = []
238
260
  tx_obj = store.get_transaction(cid, charger.connector_id)
@@ -324,6 +346,7 @@ def charger_list(request):
324
346
  @api_login_required
325
347
  def charger_detail(request, cid, connector=None):
326
348
  charger, connector_slug = _get_charger(cid, connector)
349
+ _ensure_charger_access(request.user, charger)
327
350
 
328
351
  sessions: list[tuple[Charger, Transaction]] = []
329
352
  tx_obj = store.get_transaction(cid, charger.connector_id)
@@ -413,7 +436,7 @@ def charger_detail(request, cid, connector=None):
413
436
  return JsonResponse(payload)
414
437
 
415
438
 
416
- @landing("OCPP CSMS Dashboard")
439
+ @landing("CPMS Online Dashboard")
417
440
  @live_update()
418
441
  def dashboard(request):
419
442
  """Landing page listing all known chargers and their status."""
@@ -425,7 +448,7 @@ def dashboard(request):
425
448
  request.get_full_path(), login_url=reverse("pages:login")
426
449
  )
427
450
  chargers = []
428
- for charger in Charger.objects.filter(public_display=True):
451
+ for charger in _visible_chargers(request.user):
429
452
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
430
453
  if not tx_obj:
431
454
  tx_obj = (
@@ -447,7 +470,7 @@ def dashboard(request):
447
470
  return render(request, "ocpp/dashboard.html", context)
448
471
 
449
472
 
450
- @login_required
473
+ @login_required(login_url="pages:login")
451
474
  @landing("Charge Point Simulator")
452
475
  @live_update()
453
476
  def cp_simulator(request):
@@ -551,7 +574,8 @@ def cp_simulator(request):
551
574
  def charger_page(request, cid, connector=None):
552
575
  """Public landing page for a charger displaying usage guidance or progress."""
553
576
  charger, connector_slug = _get_charger(cid, connector)
554
- overview = _connector_overview(charger)
577
+ _ensure_charger_access(request.user, charger)
578
+ overview = _connector_overview(charger, request.user)
555
579
  sessions = _live_sessions(charger)
556
580
  tx = None
557
581
  active_connector_count = 0
@@ -618,6 +642,7 @@ def charger_page(request, cid, connector=None):
618
642
  @login_required
619
643
  def charger_status(request, cid, connector=None):
620
644
  charger, connector_slug = _get_charger(cid, connector)
645
+ _ensure_charger_access(request.user, charger)
621
646
  session_id = request.GET.get("session")
622
647
  sessions = _live_sessions(charger)
623
648
  live_tx = None
@@ -721,7 +746,7 @@ def charger_status(request, cid, connector=None):
721
746
  "connector_id": connector_id,
722
747
  }
723
748
  )
724
- overview = _connector_overview(charger)
749
+ overview = _connector_overview(charger, request.user)
725
750
  connector_links = [
726
751
  {
727
752
  "slug": item["slug"],
@@ -797,6 +822,7 @@ def charger_status(request, cid, connector=None):
797
822
  @login_required
798
823
  def charger_session_search(request, cid, connector=None):
799
824
  charger, connector_slug = _get_charger(cid, connector)
825
+ _ensure_charger_access(request.user, charger)
800
826
  date_str = request.GET.get("date")
801
827
  transactions = None
802
828
  if date_str:
@@ -814,7 +840,7 @@ def charger_session_search(request, cid, connector=None):
814
840
  transactions = qs.order_by("-start_time")
815
841
  except ValueError:
816
842
  transactions = []
817
- overview = _connector_overview(charger)
843
+ overview = _connector_overview(charger, request.user)
818
844
  connector_links = [
819
845
  {
820
846
  "slug": item["slug"],
@@ -848,8 +874,9 @@ def charger_log_page(request, cid, connector=None):
848
874
  status_url = None
849
875
  if log_type == "charger":
850
876
  charger, connector_slug = _get_charger(cid, connector)
877
+ _ensure_charger_access(request.user, charger)
851
878
  log_key = store.identity_key(cid, charger.connector_id)
852
- overview = _connector_overview(charger)
879
+ overview = _connector_overview(charger, request.user)
853
880
  connector_links = [
854
881
  {
855
882
  "slug": item["slug"],
@@ -885,6 +912,30 @@ def charger_log_page(request, cid, connector=None):
885
912
  @api_login_required
886
913
  def dispatch_action(request, cid, connector=None):
887
914
  connector_value, _ = _normalize_connector_slug(connector)
915
+ log_key = store.identity_key(cid, connector_value)
916
+ if connector_value is None:
917
+ charger_obj = (
918
+ Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
919
+ .order_by("pk")
920
+ .first()
921
+ )
922
+ else:
923
+ charger_obj = (
924
+ Charger.objects.filter(charger_id=cid, connector_id=connector_value)
925
+ .order_by("pk")
926
+ .first()
927
+ )
928
+ if charger_obj is None:
929
+ if connector_value is None:
930
+ charger_obj, _ = Charger.objects.get_or_create(
931
+ charger_id=cid, connector_id=None
932
+ )
933
+ else:
934
+ charger_obj, _ = Charger.objects.get_or_create(
935
+ charger_id=cid, connector_id=connector_value
936
+ )
937
+
938
+ _ensure_charger_access(request.user, charger_obj)
888
939
  ws = store.get_connection(cid, connector_value)
889
940
  if ws is None:
890
941
  return JsonResponse({"detail": "no connection"}, status=404)
@@ -897,15 +948,27 @@ def dispatch_action(request, cid, connector=None):
897
948
  tx_obj = store.get_transaction(cid, connector_value)
898
949
  if not tx_obj:
899
950
  return JsonResponse({"detail": "no transaction"}, status=404)
951
+ message_id = uuid.uuid4().hex
900
952
  msg = json.dumps(
901
953
  [
902
954
  2,
903
- str(datetime.utcnow().timestamp()),
955
+ message_id,
904
956
  "RemoteStopTransaction",
905
957
  {"transactionId": tx_obj.pk},
906
958
  ]
907
959
  )
908
- asyncio.get_event_loop().create_task(ws.send(msg))
960
+ async_to_sync(ws.send)(msg)
961
+ store.register_pending_call(
962
+ message_id,
963
+ {
964
+ "action": "RemoteStopTransaction",
965
+ "charger_id": cid,
966
+ "connector_id": connector_value,
967
+ "log_key": log_key,
968
+ "transaction_id": tx_obj.pk,
969
+ "requested_at": timezone.now(),
970
+ },
971
+ )
909
972
  elif action == "remote_start":
910
973
  id_tag = data.get("idTag")
911
974
  if not isinstance(id_tag, str) or not id_tag.strip():
@@ -924,20 +987,163 @@ def dispatch_action(request, cid, connector=None):
924
987
  payload["connectorId"] = connector_id
925
988
  if "chargingProfile" in data and data["chargingProfile"] is not None:
926
989
  payload["chargingProfile"] = data["chargingProfile"]
990
+ message_id = uuid.uuid4().hex
927
991
  msg = json.dumps(
928
992
  [
929
993
  2,
930
- str(datetime.utcnow().timestamp()),
994
+ message_id,
931
995
  "RemoteStartTransaction",
932
996
  payload,
933
997
  ]
934
998
  )
935
- asyncio.get_event_loop().create_task(ws.send(msg))
999
+ async_to_sync(ws.send)(msg)
1000
+ store.register_pending_call(
1001
+ message_id,
1002
+ {
1003
+ "action": "RemoteStartTransaction",
1004
+ "charger_id": cid,
1005
+ "connector_id": connector_value,
1006
+ "log_key": log_key,
1007
+ "id_tag": id_tag,
1008
+ "requested_at": timezone.now(),
1009
+ },
1010
+ )
1011
+ elif action == "change_availability":
1012
+ availability_type = data.get("type")
1013
+ if availability_type not in {"Operative", "Inoperative"}:
1014
+ return JsonResponse({"detail": "invalid availability type"}, status=400)
1015
+ connector_payload = connector_value if connector_value is not None else 0
1016
+ if "connectorId" in data:
1017
+ candidate = data.get("connectorId")
1018
+ if candidate not in (None, ""):
1019
+ try:
1020
+ connector_payload = int(candidate)
1021
+ except (TypeError, ValueError):
1022
+ connector_payload = candidate
1023
+ message_id = uuid.uuid4().hex
1024
+ payload = {"connectorId": connector_payload, "type": availability_type}
1025
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1026
+ async_to_sync(ws.send)(msg)
1027
+ requested_at = timezone.now()
1028
+ store.register_pending_call(
1029
+ message_id,
1030
+ {
1031
+ "action": "ChangeAvailability",
1032
+ "charger_id": cid,
1033
+ "connector_id": connector_value,
1034
+ "availability_type": availability_type,
1035
+ "requested_at": requested_at,
1036
+ },
1037
+ )
1038
+ if charger_obj:
1039
+ updates = {
1040
+ "availability_requested_state": availability_type,
1041
+ "availability_requested_at": requested_at,
1042
+ "availability_request_status": "",
1043
+ "availability_request_status_at": None,
1044
+ "availability_request_details": "",
1045
+ }
1046
+ Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1047
+ for field, value in updates.items():
1048
+ setattr(charger_obj, field, value)
1049
+ elif action == "data_transfer":
1050
+ vendor_id = data.get("vendorId")
1051
+ if not isinstance(vendor_id, str) or not vendor_id.strip():
1052
+ return JsonResponse({"detail": "vendorId required"}, status=400)
1053
+ vendor_id = vendor_id.strip()
1054
+ payload: dict[str, object] = {"vendorId": vendor_id}
1055
+ message_identifier = ""
1056
+ if "messageId" in data and data["messageId"] is not None:
1057
+ message_candidate = data["messageId"]
1058
+ if not isinstance(message_candidate, str):
1059
+ return JsonResponse({"detail": "messageId must be a string"}, status=400)
1060
+ message_identifier = message_candidate.strip()
1061
+ if message_identifier:
1062
+ payload["messageId"] = message_identifier
1063
+ if "data" in data:
1064
+ payload["data"] = data["data"]
1065
+ message_id = uuid.uuid4().hex
1066
+ msg = json.dumps([2, message_id, "DataTransfer", payload])
1067
+ record = DataTransferMessage.objects.create(
1068
+ charger=charger_obj,
1069
+ connector_id=connector_value,
1070
+ direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
1071
+ ocpp_message_id=message_id,
1072
+ vendor_id=vendor_id,
1073
+ message_id=message_identifier,
1074
+ payload=payload,
1075
+ status="Pending",
1076
+ )
1077
+ async_to_sync(ws.send)(msg)
1078
+ store.register_pending_call(
1079
+ message_id,
1080
+ {
1081
+ "action": "DataTransfer",
1082
+ "charger_id": cid,
1083
+ "connector_id": connector_value,
1084
+ "message_pk": record.pk,
1085
+ "log_key": log_key,
1086
+ },
1087
+ )
936
1088
  elif action == "reset":
937
- msg = json.dumps(
938
- [2, str(datetime.utcnow().timestamp()), "Reset", {"type": "Soft"}]
1089
+ message_id = uuid.uuid4().hex
1090
+ msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
1091
+ async_to_sync(ws.send)(msg)
1092
+ store.register_pending_call(
1093
+ message_id,
1094
+ {
1095
+ "action": "Reset",
1096
+ "charger_id": cid,
1097
+ "connector_id": connector_value,
1098
+ "log_key": log_key,
1099
+ "requested_at": timezone.now(),
1100
+ },
1101
+ )
1102
+ elif action == "trigger_message":
1103
+ trigger_target = data.get("target") or data.get("triggerTarget")
1104
+ if not isinstance(trigger_target, str) or not trigger_target.strip():
1105
+ return JsonResponse({"detail": "target required"}, status=400)
1106
+ trigger_target = trigger_target.strip()
1107
+ allowed_targets = {
1108
+ "BootNotification",
1109
+ "DiagnosticsStatusNotification",
1110
+ "FirmwareStatusNotification",
1111
+ "Heartbeat",
1112
+ "MeterValues",
1113
+ "StatusNotification",
1114
+ }
1115
+ if trigger_target not in allowed_targets:
1116
+ return JsonResponse({"detail": "invalid target"}, status=400)
1117
+ payload: dict[str, object] = {"requestedMessage": trigger_target}
1118
+ trigger_connector = None
1119
+ connector_field = data.get("connectorId")
1120
+ if connector_field in (None, ""):
1121
+ connector_field = data.get("connector")
1122
+ if connector_field in (None, "") and connector_value is not None:
1123
+ connector_field = connector_value
1124
+ if connector_field not in (None, ""):
1125
+ try:
1126
+ trigger_connector = int(connector_field)
1127
+ except (TypeError, ValueError):
1128
+ return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
1129
+ if trigger_connector <= 0:
1130
+ return JsonResponse({"detail": "connectorId must be positive"}, status=400)
1131
+ payload["connectorId"] = trigger_connector
1132
+ message_id = uuid.uuid4().hex
1133
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
1134
+ async_to_sync(ws.send)(msg)
1135
+ store.register_pending_call(
1136
+ message_id,
1137
+ {
1138
+ "action": "TriggerMessage",
1139
+ "charger_id": cid,
1140
+ "connector_id": connector_value,
1141
+ "log_key": log_key,
1142
+ "trigger_target": trigger_target,
1143
+ "trigger_connector": trigger_connector,
1144
+ "requested_at": timezone.now(),
1145
+ },
939
1146
  )
940
- asyncio.get_event_loop().create_task(ws.send(msg))
941
1147
  else:
942
1148
  return JsonResponse({"detail": "unknown action"}, status=400)
943
1149
  log_key = store.identity_key(cid, connector_value)
pages/admin.py CHANGED
@@ -1,3 +1,5 @@
1
+ import logging
2
+
1
3
  from django.contrib import admin, messages
2
4
  from django.contrib.sites.admin import SiteAdmin as DjangoSiteAdmin
3
5
  from django.contrib.sites.models import Site
@@ -12,10 +14,11 @@ from django.http import JsonResponse
12
14
  from django.utils import timezone
13
15
  from django.db.models import Count
14
16
  from django.db.models.functions import TruncDate
15
- from datetime import timedelta
17
+ from datetime import datetime, time, timedelta
16
18
  import ipaddress
17
19
  from django.apps import apps as django_apps
18
20
  from django.conf import settings
21
+ from django.utils.translation import gettext_lazy as _, ngettext
19
22
 
20
23
  from nodes.models import Node
21
24
  from nodes.utils import capture_screenshot, save_screenshot
@@ -29,11 +32,15 @@ from .models import (
29
32
  Favorite,
30
33
  ViewHistory,
31
34
  UserManual,
35
+ UserStory,
32
36
  )
33
37
  from django.contrib.contenttypes.models import ContentType
34
38
  from core.user_data import EntityModelAdmin
35
39
 
36
40
 
41
+ logger = logging.getLogger(__name__)
42
+
43
+
37
44
  def get_local_app_choices():
38
45
  choices = []
39
46
  for app_label in getattr(settings, "LOCAL_APPS", []):
@@ -255,8 +262,20 @@ class ViewHistoryAdmin(EntityModelAdmin):
255
262
  def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
256
263
  end_date = timezone.localdate()
257
264
  start_date = end_date - timedelta(days=days - 1)
265
+
266
+ start_at = datetime.combine(start_date, time.min)
267
+ end_at = datetime.combine(end_date + timedelta(days=1), time.min)
268
+
269
+ if settings.USE_TZ:
270
+ current_tz = timezone.get_current_timezone()
271
+ start_at = timezone.make_aware(start_at, current_tz)
272
+ end_at = timezone.make_aware(end_at, current_tz)
273
+ trunc_expression = TruncDate("visited_at", tzinfo=current_tz)
274
+ else:
275
+ trunc_expression = TruncDate("visited_at")
276
+
258
277
  queryset = ViewHistory.objects.filter(
259
- visited_at__date__range=(start_date, end_date)
278
+ visited_at__gte=start_at, visited_at__lt=end_at
260
279
  )
261
280
 
262
281
  meta = {
@@ -282,7 +301,7 @@ class ViewHistoryAdmin(EntityModelAdmin):
282
301
 
283
302
  aggregates = (
284
303
  queryset.filter(path__in=paths)
285
- .annotate(day=TruncDate("visited_at"))
304
+ .annotate(day=trunc_expression)
286
305
  .values("day", "path")
287
306
  .order_by("day")
288
307
  .annotate(total=Count("id"))
@@ -326,6 +345,119 @@ class ViewHistoryAdmin(EntityModelAdmin):
326
345
  return {"labels": labels, "datasets": datasets, "meta": meta}
327
346
 
328
347
 
348
+ @admin.register(UserStory)
349
+ class UserStoryAdmin(EntityModelAdmin):
350
+ date_hierarchy = "submitted_at"
351
+ actions = ["create_github_issues"]
352
+ list_display = (
353
+ "name",
354
+ "rating",
355
+ "path",
356
+ "submitted_at",
357
+ "github_issue_display",
358
+ "take_screenshot",
359
+ "owner",
360
+ )
361
+ list_filter = ("rating", "submitted_at", "take_screenshot")
362
+ search_fields = ("name", "comments", "path", "github_issue_url")
363
+ readonly_fields = (
364
+ "name",
365
+ "rating",
366
+ "comments",
367
+ "take_screenshot",
368
+ "path",
369
+ "user",
370
+ "owner",
371
+ "submitted_at",
372
+ "github_issue_number",
373
+ "github_issue_url",
374
+ )
375
+ ordering = ("-submitted_at",)
376
+ fields = (
377
+ "name",
378
+ "rating",
379
+ "comments",
380
+ "take_screenshot",
381
+ "path",
382
+ "user",
383
+ "owner",
384
+ "submitted_at",
385
+ "github_issue_number",
386
+ "github_issue_url",
387
+ )
388
+
389
+ @admin.display(description=_("GitHub issue"), ordering="github_issue_number")
390
+ def github_issue_display(self, obj):
391
+ if obj.github_issue_url:
392
+ label = (
393
+ f"#{obj.github_issue_number}"
394
+ if obj.github_issue_number is not None
395
+ else obj.github_issue_url
396
+ )
397
+ return format_html(
398
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
399
+ obj.github_issue_url,
400
+ label,
401
+ )
402
+ if obj.github_issue_number is not None:
403
+ return f"#{obj.github_issue_number}"
404
+ return _("Not created")
405
+
406
+ @admin.action(description=_("Create GitHub issues"))
407
+ def create_github_issues(self, request, queryset):
408
+ created = 0
409
+ skipped = 0
410
+
411
+ for story in queryset:
412
+ if story.github_issue_url:
413
+ skipped += 1
414
+ continue
415
+
416
+ try:
417
+ issue_url = story.create_github_issue()
418
+ except Exception as exc: # pragma: no cover - network/runtime errors
419
+ logger.exception("Failed to create GitHub issue for UserStory %s", story.pk)
420
+ self.message_user(
421
+ request,
422
+ _("Unable to create a GitHub issue for %(story)s: %(error)s")
423
+ % {"story": story, "error": exc},
424
+ messages.ERROR,
425
+ )
426
+ continue
427
+
428
+ if issue_url:
429
+ created += 1
430
+ else:
431
+ skipped += 1
432
+
433
+ if created:
434
+ self.message_user(
435
+ request,
436
+ ngettext(
437
+ "Created %(count)d GitHub issue.",
438
+ "Created %(count)d GitHub issues.",
439
+ created,
440
+ )
441
+ % {"count": created},
442
+ messages.SUCCESS,
443
+ )
444
+
445
+ if skipped:
446
+ self.message_user(
447
+ request,
448
+ ngettext(
449
+ "Skipped %(count)d feedback item (issue already exists or was throttled).",
450
+ "Skipped %(count)d feedback items (issues already exist or were throttled).",
451
+ skipped,
452
+ )
453
+ % {"count": skipped},
454
+ messages.INFO,
455
+ )
456
+
457
+ def has_add_permission(self, request):
458
+ return False
459
+
460
+
329
461
  def favorite_toggle(request, ct_id):
330
462
  ct = get_object_or_404(ContentType, pk=ct_id)
331
463
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
@@ -9,11 +9,22 @@ from core.reference_utils import filter_visible_references
9
9
  from .models import Module
10
10
 
11
11
  _favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
12
+ _control_favicon_path = (
13
+ Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon_control.txt"
14
+ )
15
+
12
16
  try:
13
17
  _DEFAULT_FAVICON = f"data:image/png;base64,{_favicon_path.read_text().strip()}"
14
18
  except OSError:
15
19
  _DEFAULT_FAVICON = ""
16
20
 
21
+ try:
22
+ _CONTROL_FAVICON = (
23
+ f"data:image/png;base64,{_control_favicon_path.read_text().strip()}"
24
+ )
25
+ except OSError:
26
+ _CONTROL_FAVICON = _DEFAULT_FAVICON
27
+
17
28
 
18
29
  def nav_links(request):
19
30
  """Provide navigation links for the current site."""
@@ -84,7 +95,10 @@ def nav_links(request):
84
95
  except Exception:
85
96
  pass
86
97
  if not favicon_url:
87
- favicon_url = _DEFAULT_FAVICON
98
+ if node and getattr(node.role, "name", "") == "Control":
99
+ favicon_url = _CONTROL_FAVICON
100
+ else:
101
+ favicon_url = _DEFAULT_FAVICON
88
102
 
89
103
  header_refs_qs = (
90
104
  Reference.objects.filter(show_in_header=True)
pages/defaults.py CHANGED
@@ -8,7 +8,6 @@ DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
8
8
  "core": "Support for Business Processes and monetization.",
9
9
  "ocpp": "Compatibility with Standards and Good Practices.",
10
10
  "nodes": "System and Node-level operations,",
11
- "pages": "Scheduling, Periodicity and Event Signaling,",
11
+ "pages": "User QA, Continuity Design and Chaos Testing.",
12
12
  "teams": "Identity, Entitlements and Access Controls.",
13
- "man": "User QA, Continuity Design and Chaos Testing.",
14
13
  }