arthexis 0.1.10__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.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
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):
@@ -464,6 +487,7 @@ def cp_simulator(request):
464
487
  default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
465
488
 
466
489
  message = ""
490
+ dashboard_link: str | None = None
467
491
  if request.method == "POST":
468
492
  cp_idx = int(request.POST.get("cp") or 1)
469
493
  action = request.POST.get("action")
@@ -500,6 +524,12 @@ def cp_simulator(request):
500
524
  started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
501
525
  if started:
502
526
  message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
527
+ try:
528
+ dashboard_link = reverse(
529
+ "charger-status", args=[sim_params["cp_path"]]
530
+ )
531
+ except NoReverseMatch: # pragma: no cover - defensive
532
+ dashboard_link = None
503
533
  else:
504
534
  message = f"CP{cp_idx} {status}. Logs: {log_file}"
505
535
  except Exception as exc: # pragma: no cover - unexpected
@@ -526,6 +556,7 @@ def cp_simulator(request):
526
556
 
527
557
  context = {
528
558
  "message": message,
559
+ "dashboard_link": dashboard_link,
529
560
  "states": state_list,
530
561
  "default_host": default_host,
531
562
  "default_ws_port": default_ws_port,
@@ -543,7 +574,8 @@ def cp_simulator(request):
543
574
  def charger_page(request, cid, connector=None):
544
575
  """Public landing page for a charger displaying usage guidance or progress."""
545
576
  charger, connector_slug = _get_charger(cid, connector)
546
- overview = _connector_overview(charger)
577
+ _ensure_charger_access(request.user, charger)
578
+ overview = _connector_overview(charger, request.user)
547
579
  sessions = _live_sessions(charger)
548
580
  tx = None
549
581
  active_connector_count = 0
@@ -610,6 +642,7 @@ def charger_page(request, cid, connector=None):
610
642
  @login_required
611
643
  def charger_status(request, cid, connector=None):
612
644
  charger, connector_slug = _get_charger(cid, connector)
645
+ _ensure_charger_access(request.user, charger)
613
646
  session_id = request.GET.get("session")
614
647
  sessions = _live_sessions(charger)
615
648
  live_tx = None
@@ -713,7 +746,7 @@ def charger_status(request, cid, connector=None):
713
746
  "connector_id": connector_id,
714
747
  }
715
748
  )
716
- overview = _connector_overview(charger)
749
+ overview = _connector_overview(charger, request.user)
717
750
  connector_links = [
718
751
  {
719
752
  "slug": item["slug"],
@@ -789,6 +822,7 @@ def charger_status(request, cid, connector=None):
789
822
  @login_required
790
823
  def charger_session_search(request, cid, connector=None):
791
824
  charger, connector_slug = _get_charger(cid, connector)
825
+ _ensure_charger_access(request.user, charger)
792
826
  date_str = request.GET.get("date")
793
827
  transactions = None
794
828
  if date_str:
@@ -806,7 +840,7 @@ def charger_session_search(request, cid, connector=None):
806
840
  transactions = qs.order_by("-start_time")
807
841
  except ValueError:
808
842
  transactions = []
809
- overview = _connector_overview(charger)
843
+ overview = _connector_overview(charger, request.user)
810
844
  connector_links = [
811
845
  {
812
846
  "slug": item["slug"],
@@ -840,8 +874,9 @@ def charger_log_page(request, cid, connector=None):
840
874
  status_url = None
841
875
  if log_type == "charger":
842
876
  charger, connector_slug = _get_charger(cid, connector)
877
+ _ensure_charger_access(request.user, charger)
843
878
  log_key = store.identity_key(cid, charger.connector_id)
844
- overview = _connector_overview(charger)
879
+ overview = _connector_overview(charger, request.user)
845
880
  connector_links = [
846
881
  {
847
882
  "slug": item["slug"],
@@ -877,6 +912,30 @@ def charger_log_page(request, cid, connector=None):
877
912
  @api_login_required
878
913
  def dispatch_action(request, cid, connector=None):
879
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)
880
939
  ws = store.get_connection(cid, connector_value)
881
940
  if ws is None:
882
941
  return JsonResponse({"detail": "no connection"}, status=404)
@@ -889,15 +948,27 @@ def dispatch_action(request, cid, connector=None):
889
948
  tx_obj = store.get_transaction(cid, connector_value)
890
949
  if not tx_obj:
891
950
  return JsonResponse({"detail": "no transaction"}, status=404)
951
+ message_id = uuid.uuid4().hex
892
952
  msg = json.dumps(
893
953
  [
894
954
  2,
895
- str(datetime.utcnow().timestamp()),
955
+ message_id,
896
956
  "RemoteStopTransaction",
897
957
  {"transactionId": tx_obj.pk},
898
958
  ]
899
959
  )
900
- 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
+ )
901
972
  elif action == "remote_start":
902
973
  id_tag = data.get("idTag")
903
974
  if not isinstance(id_tag, str) or not id_tag.strip():
@@ -916,20 +987,163 @@ def dispatch_action(request, cid, connector=None):
916
987
  payload["connectorId"] = connector_id
917
988
  if "chargingProfile" in data and data["chargingProfile"] is not None:
918
989
  payload["chargingProfile"] = data["chargingProfile"]
990
+ message_id = uuid.uuid4().hex
919
991
  msg = json.dumps(
920
992
  [
921
993
  2,
922
- str(datetime.utcnow().timestamp()),
994
+ message_id,
923
995
  "RemoteStartTransaction",
924
996
  payload,
925
997
  ]
926
998
  )
927
- 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
+ )
928
1088
  elif action == "reset":
929
- msg = json.dumps(
930
- [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
+ },
931
1146
  )
932
- asyncio.get_event_loop().create_task(ws.send(msg))
933
1147
  else:
934
1148
  return JsonResponse({"detail": "unknown action"}, status=400)
935
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
@@ -28,11 +31,16 @@ from .models import (
28
31
  Landing,
29
32
  Favorite,
30
33
  ViewHistory,
34
+ UserManual,
35
+ UserStory,
31
36
  )
32
37
  from django.contrib.contenttypes.models import ContentType
33
38
  from core.user_data import EntityModelAdmin
34
39
 
35
40
 
41
+ logger = logging.getLogger(__name__)
42
+
43
+
36
44
  def get_local_app_choices():
37
45
  choices = []
38
46
  for app_label in getattr(settings, "LOCAL_APPS", []):
@@ -153,7 +161,7 @@ class ApplicationModuleInline(admin.TabularInline):
153
161
  @admin.register(Application)
154
162
  class ApplicationAdmin(EntityModelAdmin):
155
163
  form = ApplicationForm
156
- list_display = ("name", "app_verbose_name", "installed")
164
+ list_display = ("name", "app_verbose_name", "description", "installed")
157
165
  readonly_fields = ("installed",)
158
166
  inlines = [ApplicationModuleInline]
159
167
 
@@ -180,6 +188,13 @@ class ModuleAdmin(EntityModelAdmin):
180
188
  inlines = [LandingInline]
181
189
 
182
190
 
191
+ @admin.register(UserManual)
192
+ class UserManualAdmin(EntityModelAdmin):
193
+ list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
194
+ search_fields = ("title", "slug", "description")
195
+ list_filter = ("is_seed_data", "is_user_data")
196
+
197
+
183
198
  @admin.register(ViewHistory)
184
199
  class ViewHistoryAdmin(EntityModelAdmin):
185
200
  date_hierarchy = "visited_at"
@@ -247,8 +262,20 @@ class ViewHistoryAdmin(EntityModelAdmin):
247
262
  def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
248
263
  end_date = timezone.localdate()
249
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
+
250
277
  queryset = ViewHistory.objects.filter(
251
- visited_at__date__range=(start_date, end_date)
278
+ visited_at__gte=start_at, visited_at__lt=end_at
252
279
  )
253
280
 
254
281
  meta = {
@@ -274,7 +301,7 @@ class ViewHistoryAdmin(EntityModelAdmin):
274
301
 
275
302
  aggregates = (
276
303
  queryset.filter(path__in=paths)
277
- .annotate(day=TruncDate("visited_at"))
304
+ .annotate(day=trunc_expression)
278
305
  .values("day", "path")
279
306
  .order_by("day")
280
307
  .annotate(total=Count("id"))
@@ -318,6 +345,119 @@ class ViewHistoryAdmin(EntityModelAdmin):
318
345
  return {"labels": labels, "datasets": datasets, "meta": meta}
319
346
 
320
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
+
321
461
  def favorite_toggle(request, ct_id):
322
462
  ct = get_object_or_404(ContentType, pk=ct_id)
323
463
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()