arthexis 0.1.12__py3-none-any.whl → 0.1.13__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
@@ -61,6 +61,12 @@ def export_transactions(
61
61
  "soc_stop": tx.soc_stop,
62
62
  "start_time": tx.start_time.isoformat(),
63
63
  "stop_time": tx.stop_time.isoformat() if tx.stop_time else None,
64
+ "received_start_time": tx.received_start_time.isoformat()
65
+ if tx.received_start_time
66
+ else None,
67
+ "received_stop_time": tx.received_stop_time.isoformat()
68
+ if tx.received_stop_time
69
+ else None,
64
70
  "meter_values": [
65
71
  {
66
72
  "connector_id": mv.connector_id,
@@ -157,6 +163,10 @@ def import_transactions(data: dict) -> int:
157
163
  soc_stop=tx.get("soc_stop"),
158
164
  start_time=_parse_dt(tx.get("start_time")),
159
165
  stop_time=_parse_dt(tx.get("stop_time")),
166
+ received_start_time=_parse_dt(tx.get("received_start_time"))
167
+ or _parse_dt(tx.get("start_time")),
168
+ received_stop_time=_parse_dt(tx.get("received_stop_time"))
169
+ or _parse_dt(tx.get("stop_time")),
160
170
  )
161
171
  for mv in tx.get("meter_values", []):
162
172
  connector_id = mv.get("connector_id")
ocpp/views.py CHANGED
@@ -26,13 +26,14 @@ from pages.utils import landing
26
26
  from core.liveupdate import live_update
27
27
 
28
28
  from . import store
29
- from .models import Transaction, Charger, DataTransferMessage
29
+ from .models import Transaction, Charger, DataTransferMessage, RFID
30
30
  from .evcs import (
31
31
  _start_simulator,
32
32
  _stop_simulator,
33
33
  get_simulator_state,
34
34
  _simulator_status_json,
35
35
  )
36
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
36
37
 
37
38
 
38
39
  def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
@@ -99,7 +100,41 @@ def _ensure_charger_access(user, charger: Charger):
99
100
  raise Http404("Charger not found")
100
101
 
101
102
 
102
- def _connector_overview(charger: Charger, user=None) -> list[dict]:
103
+ def _transaction_rfid_details(
104
+ tx_obj, *, cache: dict[str, dict[str, str | None]] | None = None
105
+ ) -> dict[str, str | None] | None:
106
+ """Return normalized RFID metadata for a transaction-like object."""
107
+
108
+ if not tx_obj:
109
+ return None
110
+ rfid_value = getattr(tx_obj, "rfid", None)
111
+ if not rfid_value:
112
+ return None
113
+ normalized = str(rfid_value).strip()
114
+ if not normalized:
115
+ return None
116
+ normalized = normalized.upper()
117
+ if cache is not None and normalized in cache:
118
+ return cache[normalized]
119
+ tag = RFID.objects.filter(rfid=normalized).only("pk").first()
120
+ rfid_url = None
121
+ if tag:
122
+ try:
123
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
124
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
125
+ rfid_url = None
126
+ details = {"value": normalized, "url": rfid_url}
127
+ if cache is not None:
128
+ cache[normalized] = details
129
+ return details
130
+
131
+
132
+ def _connector_overview(
133
+ charger: Charger,
134
+ user=None,
135
+ *,
136
+ rfid_cache: dict[str, dict[str, str | None]] | None = None,
137
+ ) -> list[dict]:
103
138
  """Return connector metadata used for navigation and summaries."""
104
139
 
105
140
  overview: list[dict] = []
@@ -123,6 +158,9 @@ def _connector_overview(charger: Charger, user=None) -> list[dict]:
123
158
  "last_status_timestamp": sibling.last_status_timestamp,
124
159
  "last_status_vendor_info": sibling.last_status_vendor_info,
125
160
  "tx": tx_obj,
161
+ "rfid_details": _transaction_rfid_details(
162
+ tx_obj, cache=rfid_cache
163
+ ),
126
164
  "connected": store.is_connected(
127
165
  sibling.charger_id, sibling.connector_id
128
166
  ),
@@ -166,6 +204,7 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
166
204
  "charging_label": gettext("Charging"),
167
205
  "energy_label": gettext("Energy"),
168
206
  "started_label": gettext("Started"),
207
+ "rfid_label": gettext("RFID"),
169
208
  "instruction_text": gettext(
170
209
  "Plug in your vehicle and slide your RFID card over the reader to begin charging."
171
210
  ),
@@ -190,32 +229,26 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
190
229
  return catalog
191
230
 
192
231
 
193
- STATUS_BADGE_MAP: dict[str, tuple[str, str]] = {
194
- "available": (_("Available"), "#0d6efd"),
195
- "preparing": (_("Preparing"), "#0d6efd"),
196
- "charging": (_("Charging"), "#198754"),
197
- "suspendedevse": (_("Suspended (EVSE)"), "#fd7e14"),
198
- "suspendedev": (_("Suspended (EV)"), "#fd7e14"),
199
- "finishing": (_("Finishing"), "#20c997"),
200
- "faulted": (_("Faulted"), "#dc3545"),
201
- "unavailable": (_("Unavailable"), "#6c757d"),
202
- "reserved": (_("Reserved"), "#6f42c1"),
203
- "occupied": (_("Occupied"), "#0dcaf0"),
204
- "outofservice": (_("Out of Service"), "#6c757d"),
205
- }
206
-
207
- _ERROR_OK_VALUES = {"", "noerror", "no_error"}
208
-
209
-
210
232
  def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
211
233
  """Return human readable state and color for a charger."""
212
234
 
213
235
  status_value = (charger.last_status or "").strip()
236
+ has_session = bool(tx_obj)
214
237
  if status_value:
215
238
  key = status_value.lower()
216
239
  label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
217
240
  error_code = (charger.last_error_code or "").strip()
218
- if error_code and error_code.lower() not in _ERROR_OK_VALUES:
241
+ error_code_lower = error_code.lower()
242
+ if (
243
+ has_session
244
+ and error_code_lower in ERROR_OK_VALUES
245
+ and (key not in STATUS_BADGE_MAP or key == "available")
246
+ ):
247
+ # Some stations continue reporting "Available" (or an unknown status)
248
+ # while a session is active. Override the badge so the user can see
249
+ # the charger is actually busy.
250
+ label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
251
+ elif error_code and error_code_lower not in ERROR_OK_VALUES:
219
252
  label = _("%(status)s (%(error)s)") % {
220
253
  "status": label,
221
254
  "error": error_code,
@@ -225,7 +258,6 @@ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
225
258
 
226
259
  cid = charger.charger_id
227
260
  connected = store.is_connected(cid, charger.connector_id)
228
- has_session = bool(tx_obj)
229
261
  if connected and has_session:
230
262
  return _("Charging"), "green"
231
263
  if connected:
@@ -575,7 +607,10 @@ def charger_page(request, cid, connector=None):
575
607
  """Public landing page for a charger displaying usage guidance or progress."""
576
608
  charger, connector_slug = _get_charger(cid, connector)
577
609
  _ensure_charger_access(request.user, charger)
578
- overview = _connector_overview(charger, request.user)
610
+ rfid_cache: dict[str, dict[str, str | None]] = {}
611
+ overview = _connector_overview(
612
+ charger, request.user, rfid_cache=rfid_cache
613
+ )
579
614
  sessions = _live_sessions(charger)
580
615
  tx = None
581
616
  active_connector_count = 0
@@ -621,12 +656,14 @@ def charger_page(request, cid, connector=None):
621
656
  item for item in overview if item["charger"].connector_id is not None
622
657
  ]
623
658
  status_url = _reverse_connector_url("charger-status", cid, connector_slug)
659
+ tx_rfid_details = _transaction_rfid_details(tx, cache=rfid_cache)
624
660
  return render(
625
661
  request,
626
662
  "ocpp/charger_page.html",
627
663
  {
628
664
  "charger": charger,
629
665
  "tx": tx,
666
+ "tx_rfid_details": tx_rfid_details,
630
667
  "connector_slug": connector_slug,
631
668
  "connector_links": connector_links,
632
669
  "connector_overview": connector_overview,
@@ -680,7 +717,39 @@ def charger_status(request, cid, connector=None):
680
717
  paginator = Paginator(transactions_qs, 10)
681
718
  page_obj = paginator.get_page(request.GET.get("page"))
682
719
  transactions = page_obj.object_list
720
+ date_view = request.GET.get("dates", "charger").lower()
721
+ if date_view not in {"charger", "received"}:
722
+ date_view = "charger"
723
+
724
+ def _date_query(mode: str) -> str:
725
+ params = request.GET.copy()
726
+ params["dates"] = mode
727
+ query = params.urlencode()
728
+ return f"?{query}" if query else ""
729
+
730
+ date_view_options = {
731
+ "charger": _("Charger timestamps"),
732
+ "received": _("Received timestamps"),
733
+ }
734
+ date_toggle_links = [
735
+ {
736
+ "mode": mode,
737
+ "label": label,
738
+ "url": _date_query(mode),
739
+ "active": mode == date_view,
740
+ }
741
+ for mode, label in date_view_options.items()
742
+ ]
683
743
  chart_data = {"labels": [], "datasets": []}
744
+ pagination_params = request.GET.copy()
745
+ pagination_params["dates"] = date_view
746
+ pagination_params.pop("page", None)
747
+ pagination_query = pagination_params.urlencode()
748
+ session_params = request.GET.copy()
749
+ session_params["dates"] = date_view
750
+ session_params.pop("session", None)
751
+ session_params.pop("page", None)
752
+ session_query = session_params.urlencode()
684
753
 
685
754
  def _series_from_transaction(tx):
686
755
  points: list[tuple[str, float]] = []
@@ -785,6 +854,8 @@ def charger_status(request, cid, connector=None):
785
854
  "error": str(_("Unable to send remote start request.")),
786
855
  }
787
856
  action_url = _reverse_connector_url("charger-action", cid, connector_slug)
857
+ chart_should_animate = bool(has_active_session and not past_session)
858
+
788
859
  return render(
789
860
  request,
790
861
  "ocpp/charger_status.html",
@@ -815,6 +886,11 @@ def charger_status(request, cid, connector=None):
815
886
  for dataset in chart_data["datasets"]
816
887
  )
817
888
  ),
889
+ "date_view": date_view,
890
+ "date_toggle_links": date_toggle_links,
891
+ "pagination_query": pagination_query,
892
+ "session_query": session_query,
893
+ "chart_should_animate": chart_should_animate,
818
894
  },
819
895
  )
820
896
 
@@ -824,6 +900,28 @@ def charger_session_search(request, cid, connector=None):
824
900
  charger, connector_slug = _get_charger(cid, connector)
825
901
  _ensure_charger_access(request.user, charger)
826
902
  date_str = request.GET.get("date")
903
+ date_view = request.GET.get("dates", "charger").lower()
904
+ if date_view not in {"charger", "received"}:
905
+ date_view = "charger"
906
+
907
+ def _date_query(mode: str) -> str:
908
+ params = request.GET.copy()
909
+ params["dates"] = mode
910
+ query = params.urlencode()
911
+ return f"?{query}" if query else ""
912
+
913
+ date_toggle_links = [
914
+ {
915
+ "mode": mode,
916
+ "label": label,
917
+ "url": _date_query(mode),
918
+ "active": mode == date_view,
919
+ }
920
+ for mode, label in {
921
+ "charger": _("Charger timestamps"),
922
+ "received": _("Received timestamps"),
923
+ }.items()
924
+ ]
827
925
  transactions = None
828
926
  if date_str:
829
927
  try:
@@ -861,6 +959,8 @@ def charger_session_search(request, cid, connector=None):
861
959
  "connector_slug": connector_slug,
862
960
  "connector_links": connector_links,
863
961
  "status_url": status_url,
962
+ "date_view": date_view,
963
+ "date_toggle_links": date_toggle_links,
864
964
  },
865
965
  )
866
966
 
pages/admin.py CHANGED
@@ -23,6 +23,8 @@ from django.utils.translation import gettext_lazy as _, ngettext
23
23
  from nodes.models import Node
24
24
  from nodes.utils import capture_screenshot, save_screenshot
25
25
 
26
+ from .forms import UserManualAdminForm
27
+
26
28
  from .models import (
27
29
  SiteBadge,
28
30
  Application,
@@ -190,6 +192,7 @@ class ModuleAdmin(EntityModelAdmin):
190
192
 
191
193
  @admin.register(UserManual)
192
194
  class UserManualAdmin(EntityModelAdmin):
195
+ form = UserManualAdminForm
193
196
  list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
194
197
  search_fields = ("title", "slug", "description")
195
198
  list_filter = ("is_seed_data", "is_user_data")
pages/forms.py CHANGED
@@ -9,7 +9,9 @@ from django.core.exceptions import ValidationError
9
9
  from django.utils.translation import gettext_lazy as _
10
10
  from django.views.decorators.debug import sensitive_variables
11
11
 
12
- from .models import UserStory
12
+ from core.form_fields import Base64FileField
13
+
14
+ from .models import UserManual, UserStory
13
15
 
14
16
 
15
17
  class AuthenticatorLoginForm(AuthenticationForm):
@@ -133,6 +135,33 @@ class AuthenticatorEnrollmentForm(forms.Form):
133
135
  return self.device
134
136
 
135
137
 
138
+ _manual_pdf_field = UserManual._meta.get_field("content_pdf")
139
+
140
+
141
+ class UserManualAdminForm(forms.ModelForm):
142
+ content_pdf = Base64FileField(
143
+ label=_manual_pdf_field.verbose_name,
144
+ help_text=_manual_pdf_field.help_text,
145
+ required=not _manual_pdf_field.blank,
146
+ content_type="application/pdf",
147
+ download_name="manual.pdf",
148
+ )
149
+
150
+ class Meta:
151
+ model = UserManual
152
+ fields = "__all__"
153
+
154
+ def __init__(self, *args, **kwargs):
155
+ super().__init__(*args, **kwargs)
156
+ instance = getattr(self, "instance", None)
157
+ slug = getattr(instance, "slug", "")
158
+ if slug:
159
+ self.fields["content_pdf"].widget.download_name = f"{slug}.pdf"
160
+ self.fields["content_pdf"].widget.attrs.setdefault(
161
+ "accept", "application/pdf"
162
+ )
163
+
164
+
136
165
  class UserStoryForm(forms.ModelForm):
137
166
  class Meta:
138
167
  model = UserStory
pages/tests.py CHANGED
@@ -22,9 +22,15 @@ from pages.models import (
22
22
  SiteBadge,
23
23
  Favorite,
24
24
  ViewHistory,
25
+ UserManual,
25
26
  UserStory,
26
27
  )
27
- from pages.admin import ApplicationAdmin, UserStoryAdmin, ViewHistoryAdmin
28
+ from pages.admin import (
29
+ ApplicationAdmin,
30
+ UserManualAdmin,
31
+ UserStoryAdmin,
32
+ ViewHistoryAdmin,
33
+ )
28
34
  from pages.screenshot_specs import (
29
35
  ScreenshotSpec,
30
36
  ScreenshotSpecRunner,
@@ -1069,6 +1075,65 @@ class ConstellationNavTests(TestCase):
1069
1075
  resp = self.client.get(reverse("pages:index"))
1070
1076
  self.assertContains(resp, 'href="/ocpp/"')
1071
1077
 
1078
+ class ControlNavTests(TestCase):
1079
+ def setUp(self):
1080
+ self.client = Client()
1081
+ role, _ = NodeRole.objects.get_or_create(name="Control")
1082
+ Node.objects.update_or_create(
1083
+ mac_address=Node.get_current_mac(),
1084
+ defaults={
1085
+ "hostname": "localhost",
1086
+ "address": "127.0.0.1",
1087
+ "role": role,
1088
+ },
1089
+ )
1090
+ Site.objects.update_or_create(
1091
+ id=1, defaults={"domain": "testserver", "name": ""}
1092
+ )
1093
+ fixtures = [
1094
+ Path(
1095
+ settings.BASE_DIR,
1096
+ "pages",
1097
+ "fixtures",
1098
+ "control__application_ocpp.json",
1099
+ ),
1100
+ Path(
1101
+ settings.BASE_DIR,
1102
+ "pages",
1103
+ "fixtures",
1104
+ "control__module_ocpp.json",
1105
+ ),
1106
+ Path(
1107
+ settings.BASE_DIR,
1108
+ "pages",
1109
+ "fixtures",
1110
+ "control__landing_ocpp_dashboard.json",
1111
+ ),
1112
+ Path(
1113
+ settings.BASE_DIR,
1114
+ "pages",
1115
+ "fixtures",
1116
+ "control__landing_ocpp_cp_simulator.json",
1117
+ ),
1118
+ Path(
1119
+ settings.BASE_DIR,
1120
+ "pages",
1121
+ "fixtures",
1122
+ "control__landing_ocpp_rfid.json",
1123
+ ),
1124
+ ]
1125
+ call_command("loaddata", *map(str, fixtures))
1126
+
1127
+ def test_ocpp_dashboard_visible(self):
1128
+ user = get_user_model().objects.create_user("control", password="pw")
1129
+ self.client.force_login(user)
1130
+ resp = self.client.get(reverse("pages:index"))
1131
+ self.assertEqual(resp.status_code, 200)
1132
+ self.assertContains(resp, 'href="/ocpp/"')
1133
+ self.assertContains(
1134
+ resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1135
+ )
1136
+
1072
1137
  def test_header_links_visible_when_defined(self):
1073
1138
  Reference.objects.create(
1074
1139
  alt_text="Console",
@@ -1242,6 +1307,58 @@ class ApplicationAdminDisplayTests(TestCase):
1242
1307
  self.assertContains(resp, "Power, Energy and Cost calculations.")
1243
1308
 
1244
1309
 
1310
+ class UserManualAdminFormTests(TestCase):
1311
+ def setUp(self):
1312
+ self.manual = UserManual.objects.create(
1313
+ slug="manual-one",
1314
+ title="Manual One",
1315
+ description="Test manual",
1316
+ languages="en",
1317
+ content_html="<p>Manual</p>",
1318
+ content_pdf=base64.b64encode(b"initial").decode("ascii"),
1319
+ )
1320
+
1321
+ def test_widget_uses_slug_for_download(self):
1322
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1323
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1324
+ form = form_class(instance=self.manual)
1325
+ field = form.fields["content_pdf"]
1326
+ self.assertEqual(field.widget.download_name, f"{self.manual.slug}.pdf")
1327
+ self.assertEqual(field.widget.content_type, "application/pdf")
1328
+
1329
+ def test_upload_encodes_content_pdf(self):
1330
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1331
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1332
+ payload = {
1333
+ "slug": self.manual.slug,
1334
+ "title": self.manual.title,
1335
+ "description": self.manual.description,
1336
+ "languages": self.manual.languages,
1337
+ "content_html": self.manual.content_html,
1338
+ }
1339
+ upload = SimpleUploadedFile("manual.pdf", b"PDF data")
1340
+ form = form_class(data=payload, files={"content_pdf": upload}, instance=self.manual)
1341
+ self.assertTrue(form.is_valid(), form.errors.as_json())
1342
+ self.assertEqual(
1343
+ form.cleaned_data["content_pdf"],
1344
+ base64.b64encode(b"PDF data").decode("ascii"),
1345
+ )
1346
+
1347
+ def test_initial_base64_preserved_without_upload(self):
1348
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1349
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1350
+ payload = {
1351
+ "slug": self.manual.slug,
1352
+ "title": self.manual.title,
1353
+ "description": self.manual.description,
1354
+ "languages": self.manual.languages,
1355
+ "content_html": self.manual.content_html,
1356
+ }
1357
+ form = form_class(data=payload, files={}, instance=self.manual)
1358
+ self.assertTrue(form.is_valid(), form.errors.as_json())
1359
+ self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
1360
+
1361
+
1245
1362
  class LandingCreationTests(TestCase):
1246
1363
  def setUp(self):
1247
1364
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
pages/views.py CHANGED
@@ -169,7 +169,7 @@ def _build_model_graph(models):
169
169
  raise RuntimeError("Graphviz is not installed")
170
170
 
171
171
  graph = Digraph(
172
- "admin_app_models",
172
+ name="admin_app_models",
173
173
  graph_attr={
174
174
  "rankdir": "LR",
175
175
  "splines": "ortho",
@@ -229,7 +229,7 @@ def _build_model_graph(models):
229
229
  label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
230
230
  label += "\n ".join(rows)
231
231
  label += "\n </table>\n>"
232
- graph.node(node_id, label=label)
232
+ graph.node(name=node_id, label=label)
233
233
 
234
234
  edges = set()
235
235
  for model in models:
@@ -244,7 +244,11 @@ def _build_model_graph(models):
244
244
  key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
245
245
  if key not in edges:
246
246
  edges.add(key)
247
- graph.edge(source_id, node_ids[related], **attrs)
247
+ graph.edge(
248
+ tail_name=source_id,
249
+ head_name=node_ids[related],
250
+ **attrs,
251
+ )
248
252
 
249
253
  for field in model._meta.local_many_to_many:
250
254
  related = _resolve_related_model(field, model._meta.app_label)
@@ -259,7 +263,11 @@ def _build_model_graph(models):
259
263
  key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
260
264
  if key not in edges:
261
265
  edges.add(key)
262
- graph.edge(source_id, node_ids[related], **attrs)
266
+ graph.edge(
267
+ tail_name=source_id,
268
+ head_name=node_ids[related],
269
+ **attrs,
270
+ )
263
271
 
264
272
  return graph
265
273