arthexis 0.1.20__py3-none-any.whl → 0.1.21__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.
ocpp/tests.py CHANGED
@@ -2335,6 +2335,36 @@ class ChargerAdminTests(TestCase):
2335
2335
  resp = self.client.get(url)
2336
2336
  self.assertContains(resp, "AdminLoc")
2337
2337
 
2338
+ def test_admin_changelist_displays_quick_stats(self):
2339
+ charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
2340
+ connector = Charger.objects.create(
2341
+ charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
2342
+ )
2343
+ start = timezone.now() - timedelta(minutes=30)
2344
+ Transaction.objects.create(
2345
+ charger=connector,
2346
+ start_time=start,
2347
+ stop_time=start + timedelta(minutes=10),
2348
+ meter_start=1000,
2349
+ meter_stop=6000,
2350
+ )
2351
+
2352
+ url = reverse("admin:ocpp_charger_changelist")
2353
+ resp = self.client.get(url)
2354
+
2355
+ self.assertContains(resp, "Total kW")
2356
+ self.assertContains(resp, "Today kW")
2357
+ self.assertContains(resp, "5.00")
2358
+
2359
+ def test_admin_changelist_does_not_indent_connectors(self):
2360
+ Charger.objects.create(charger_id="INDENTMAIN")
2361
+ Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
2362
+
2363
+ url = reverse("admin:ocpp_charger_changelist")
2364
+ resp = self.client.get(url)
2365
+
2366
+ self.assertNotContains(resp, 'class="charger-connector-entry"')
2367
+
2338
2368
  def test_last_fields_are_read_only(self):
2339
2369
  now = timezone.now()
2340
2370
  charger = Charger.objects.create(
@@ -4795,6 +4825,59 @@ class LiveUpdateViewTests(TestCase):
4795
4825
  )
4796
4826
  self.assertEqual(aggregate_entry["state"], available_label)
4797
4827
 
4828
+ def test_dashboard_groups_connectors_under_parent(self):
4829
+ aggregate = Charger.objects.create(charger_id="GROUPED")
4830
+ first = Charger.objects.create(
4831
+ charger_id=aggregate.charger_id, connector_id=1
4832
+ )
4833
+ second = Charger.objects.create(
4834
+ charger_id=aggregate.charger_id, connector_id=2
4835
+ )
4836
+
4837
+ resp = self.client.get(reverse("ocpp-dashboard"))
4838
+ self.assertEqual(resp.status_code, 200)
4839
+ groups = resp.context["charger_groups"]
4840
+ target = next(
4841
+ group
4842
+ for group in groups
4843
+ if group.get("parent")
4844
+ and group["parent"]["charger"].pk == aggregate.pk
4845
+ )
4846
+ child_ids = [item["charger"].pk for item in target["children"]]
4847
+ self.assertEqual(child_ids, [first.pk, second.pk])
4848
+
4849
+ def test_dashboard_includes_energy_totals(self):
4850
+ aggregate = Charger.objects.create(charger_id="KWSTATS")
4851
+ now = timezone.now()
4852
+ Transaction.objects.create(
4853
+ charger=aggregate,
4854
+ start_time=now - timedelta(hours=1),
4855
+ stop_time=now,
4856
+ meter_start=0,
4857
+ meter_stop=3000,
4858
+ )
4859
+ past_start = now - timedelta(days=2)
4860
+ Transaction.objects.create(
4861
+ charger=aggregate,
4862
+ start_time=past_start,
4863
+ stop_time=past_start + timedelta(hours=1),
4864
+ meter_start=0,
4865
+ meter_stop=1000,
4866
+ )
4867
+
4868
+ resp = self.client.get(reverse("ocpp-dashboard"))
4869
+ self.assertEqual(resp.status_code, 200)
4870
+ groups = resp.context["charger_groups"]
4871
+ target = next(
4872
+ group
4873
+ for group in groups
4874
+ if group.get("parent")
4875
+ and group["parent"]["charger"].pk == aggregate.pk
4876
+ )
4877
+ stats = target["parent"]["stats"]
4878
+ self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
4879
+ self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
4880
+
4798
4881
  def test_cp_simulator_includes_interval(self):
4799
4882
  resp = self.client.get(reverse("cp-simulator"))
4800
4883
  self.assertEqual(resp.context["request"].live_update_interval, 5)
ocpp/views.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import uuid
3
3
  from datetime import datetime, timedelta, timezone as dt_timezone
4
+ from datetime import datetime, time, timedelta
4
5
  from types import SimpleNamespace
5
6
 
6
7
  from django.http import Http404, HttpResponse, JsonResponse
@@ -752,8 +753,48 @@ def dashboard(request):
752
753
  request.get_full_path(), login_url=reverse("pages:login")
753
754
  )
754
755
  is_watchtower = role_name in {"Watchtower", "Constellation"}
755
- chargers = []
756
- for charger in _visible_chargers(request.user):
756
+ visible_chargers = (
757
+ _visible_chargers(request.user)
758
+ .select_related("location")
759
+ .order_by("charger_id", "connector_id")
760
+ )
761
+ stats_cache: dict[int, dict[str, float]] = {}
762
+
763
+ def _charger_display_name(charger: Charger) -> str:
764
+ if charger.display_name:
765
+ return charger.display_name
766
+ if charger.location:
767
+ return charger.location.name
768
+ return charger.charger_id
769
+
770
+ today = timezone.localdate()
771
+ tz = timezone.get_current_timezone()
772
+ day_start = datetime.combine(today, time.min)
773
+ if timezone.is_naive(day_start):
774
+ day_start = timezone.make_aware(day_start, tz)
775
+ day_end = day_start + timedelta(days=1)
776
+
777
+ def _charger_stats(charger: Charger) -> dict[str, float]:
778
+ cache_key = charger.pk or id(charger)
779
+ if cache_key not in stats_cache:
780
+ stats_cache[cache_key] = {
781
+ "total_kw": charger.total_kw,
782
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
783
+ }
784
+ return stats_cache[cache_key]
785
+
786
+ def _status_url(charger: Charger) -> str:
787
+ return _reverse_connector_url(
788
+ "charger-status",
789
+ charger.charger_id,
790
+ charger.connector_slug,
791
+ )
792
+
793
+ chargers: list[dict[str, object]] = []
794
+ charger_groups: list[dict[str, object]] = []
795
+ group_lookup: dict[str, dict[str, object]] = {}
796
+
797
+ for charger in visible_chargers:
757
798
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
758
799
  if not tx_obj:
759
800
  tx_obj = (
@@ -761,13 +802,54 @@ def dashboard(request):
761
802
  .order_by("-start_time")
762
803
  .first()
763
804
  )
805
+ has_session = _has_active_session(tx_obj)
764
806
  state, color = _charger_state(charger, tx_obj)
765
- chargers.append({"charger": charger, "state": state, "color": color})
807
+ if (
808
+ charger.connector_id is not None
809
+ and not has_session
810
+ and (charger.last_status or "").strip().casefold() == "charging"
811
+ ):
812
+ state, color = STATUS_BADGE_MAP["charging"]
813
+ entry = {
814
+ "charger": charger,
815
+ "state": state,
816
+ "color": color,
817
+ "display_name": _charger_display_name(charger),
818
+ "stats": _charger_stats(charger),
819
+ "status_url": _status_url(charger),
820
+ }
821
+ chargers.append(entry)
822
+ if charger.connector_id is None:
823
+ group = {"parent": entry, "children": []}
824
+ charger_groups.append(group)
825
+ group_lookup[charger.charger_id] = group
826
+ else:
827
+ group = group_lookup.get(charger.charger_id)
828
+ if group is None:
829
+ group = {"parent": None, "children": []}
830
+ charger_groups.append(group)
831
+ group_lookup[charger.charger_id] = group
832
+ group["children"].append(entry)
833
+
834
+ for group in charger_groups:
835
+ parent_entry = group.get("parent")
836
+ if not parent_entry or not group["children"]:
837
+ continue
838
+ connector_statuses = [
839
+ (child["charger"].last_status or "").strip().casefold()
840
+ for child in group["children"]
841
+ if child["charger"].connector_id is not None
842
+ ]
843
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
844
+ label, badge_color = STATUS_BADGE_MAP["charging"]
845
+ parent_entry["state"] = label
846
+ parent_entry["color"] = badge_color
766
847
  scheme = "wss" if request.is_secure() else "ws"
767
848
  host = request.get_host()
768
849
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
769
850
  context = {
770
851
  "chargers": chargers,
852
+ "charger_groups": charger_groups,
771
853
  "show_demo_notice": is_watchtower,
772
854
  "demo_ws_url": ws_url,
773
855
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
@@ -2,7 +2,6 @@ from utils.sites import get_site
2
2
  from django.urls import Resolver404, resolve
3
3
  from django.conf import settings
4
4
  from pathlib import Path
5
- from types import SimpleNamespace
6
5
  from nodes.models import Node
7
6
  from core.models import Reference
8
7
  from core.reference_utils import filter_visible_references
@@ -49,7 +48,6 @@ def nav_links(request):
49
48
  modules = []
50
49
 
51
50
  valid_modules = []
52
- datasette_enabled = False
53
51
  current_module = None
54
52
  user = getattr(request, "user", None)
55
53
  user_is_authenticated = getattr(user, "is_authenticated", False)
@@ -117,15 +115,6 @@ def nav_links(request):
117
115
  ):
118
116
  current_module = module
119
117
 
120
- datasette_lock = Path(settings.BASE_DIR) / "locks" / "datasette.lck"
121
- if datasette_lock.exists():
122
- datasette_enabled = True
123
- datasette_module = SimpleNamespace(
124
- menu_label="Data",
125
- path="/data/",
126
- enabled_landings=[SimpleNamespace(path="/data/", label="Datasette")],
127
- )
128
- valid_modules.append(datasette_module)
129
118
 
130
119
  valid_modules.sort(key=lambda m: m.menu_label.lower())
131
120
 
@@ -159,5 +148,4 @@ def nav_links(request):
159
148
  "nav_modules": valid_modules,
160
149
  "favicon_url": favicon_url,
161
150
  "header_references": header_references,
162
- "datasette_enabled": datasette_enabled,
163
151
  }
pages/tests.py CHANGED
@@ -3221,47 +3221,6 @@ class AdminModelGraphViewTests(TestCase):
3221
3221
  self.assertEqual(kwargs.get("format"), "pdf")
3222
3222
 
3223
3223
 
3224
- class DatasetteTests(TestCase):
3225
- def setUp(self):
3226
- self.client = Client()
3227
- User = get_user_model()
3228
- self.user = User.objects.create_user(username="ds", password="pwd")
3229
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
3230
-
3231
- def test_datasette_auth_endpoint(self):
3232
- resp = self.client.get(reverse("pages:datasette-auth"))
3233
- self.assertEqual(resp.status_code, 401)
3234
- self.client.force_login(self.user)
3235
- resp = self.client.get(reverse("pages:datasette-auth"))
3236
- self.assertEqual(resp.status_code, 200)
3237
-
3238
- def test_navbar_includes_datasette_when_enabled(self):
3239
- lock_dir = Path(settings.BASE_DIR) / "locks"
3240
- lock_dir.mkdir(exist_ok=True)
3241
- lock_file = lock_dir / "datasette.lck"
3242
- try:
3243
- lock_file.touch()
3244
- resp = self.client.get(reverse("pages:index"))
3245
- self.assertContains(resp, 'href="/data/"')
3246
- finally:
3247
- lock_file.unlink(missing_ok=True)
3248
-
3249
- def test_admin_home_includes_datasette_button_when_enabled(self):
3250
- lock_dir = Path(settings.BASE_DIR) / "locks"
3251
- lock_dir.mkdir(exist_ok=True)
3252
- lock_file = lock_dir / "datasette.lck"
3253
- try:
3254
- lock_file.touch()
3255
- self.user.is_staff = True
3256
- self.user.is_superuser = True
3257
- self.user.save()
3258
- self.client.force_login(self.user)
3259
- resp = self.client.get(reverse("admin:index"))
3260
- self.assertContains(resp, 'href="/data/"')
3261
- self.assertContains(resp, ">Datasette<")
3262
- finally:
3263
- lock_file.unlink(missing_ok=True)
3264
-
3265
3224
 
3266
3225
  class UserStorySubmissionTests(TestCase):
3267
3226
  def setUp(self):
pages/urls.py CHANGED
@@ -21,7 +21,6 @@ urlpatterns = [
21
21
  views.invitation_login,
22
22
  name="invitation-login",
23
23
  ),
24
- path("datasette-auth/", views.datasette_auth, name="datasette-auth"),
25
24
  path("man/", views.manual_list, name="manual-list"),
26
25
  path("man/<slug:slug>/", views.manual_detail, name="manual-detail"),
27
26
  path("man/<slug:slug>/pdf/", views.manual_pdf, name="manual-pdf"),
pages/views.py CHANGED
@@ -710,11 +710,6 @@ def release_checklist(request):
710
710
  return response
711
711
 
712
712
 
713
- @csrf_exempt
714
- def datasette_auth(request):
715
- if request.user.is_authenticated:
716
- return HttpResponse("OK")
717
- return HttpResponse(status=401)
718
713
 
719
714
 
720
715
  class CustomLoginView(LoginView):
core/workgroup_urls.py DELETED
@@ -1,17 +0,0 @@
1
- """URL routes for assistant profile endpoints."""
2
-
3
- from django.urls import path
4
-
5
- from . import workgroup_views as views
6
-
7
- app_name = "workgroup"
8
-
9
- urlpatterns = [
10
- path(
11
- "assistant-profiles/<int:user_id>/",
12
- views.issue_key,
13
- name="assistantprofile-issue",
14
- ),
15
- path("assistant/test/", views.assistant_test, name="assistant-test"),
16
- path("chat/", views.chat, name="chat"),
17
- ]
core/workgroup_views.py DELETED
@@ -1,94 +0,0 @@
1
- """REST endpoints for AssistantProfile issuance and authentication."""
2
-
3
- from __future__ import annotations
4
-
5
- from functools import wraps
6
-
7
- from django.apps import apps
8
- from django.contrib.auth import get_user_model
9
- from django.forms.models import model_to_dict
10
- from django.http import HttpResponse, JsonResponse
11
- from django.views.decorators.csrf import csrf_exempt
12
- from django.views.decorators.http import require_GET, require_POST
13
-
14
- from .models import AssistantProfile, hash_key
15
-
16
-
17
- @csrf_exempt
18
- @require_POST
19
- def issue_key(request, user_id: int) -> JsonResponse:
20
- """Issue a new ``user_key`` for ``user_id``.
21
-
22
- The response reveals the plain key once. Store only the hash server-side.
23
- """
24
-
25
- user = get_user_model().objects.get(pk=user_id)
26
- profile, key = AssistantProfile.issue_key(user)
27
- return JsonResponse({"user_id": user_id, "user_key": key})
28
-
29
-
30
- def authenticate(view_func):
31
- """View decorator that validates the ``Authorization`` header."""
32
-
33
- @wraps(view_func)
34
- def wrapper(request, *args, **kwargs):
35
- header = request.META.get("HTTP_AUTHORIZATION", "")
36
- if not header.startswith("Bearer "):
37
- return HttpResponse(status=401)
38
-
39
- key_hash = hash_key(header.split(" ", 1)[1])
40
- try:
41
- profile = AssistantProfile.objects.get(
42
- user_key_hash=key_hash, is_active=True
43
- )
44
- except AssistantProfile.DoesNotExist:
45
- return HttpResponse(status=401)
46
-
47
- profile.touch()
48
- request.assistant_profile = profile
49
- request.chat_profile = profile
50
- return view_func(request, *args, **kwargs)
51
-
52
- return wrapper
53
-
54
-
55
- @require_GET
56
- @authenticate
57
- def assistant_test(request):
58
- """Return a simple greeting to confirm authentication."""
59
-
60
- profile = getattr(request, "assistant_profile", None)
61
- user_id = profile.user_id if profile else None
62
- return JsonResponse({"message": f"Hello from user {user_id}"})
63
-
64
-
65
- @require_GET
66
- @authenticate
67
- def chat(request):
68
- """Return serialized data from any model.
69
-
70
- Clients must provide ``model`` as ``app_label.ModelName`` and may include a
71
- ``pk`` to fetch a specific record. When ``pk`` is omitted, the view returns
72
- up to 100 records.
73
- """
74
-
75
- model_label = request.GET.get("model")
76
- if not model_label:
77
- return JsonResponse({"error": "model parameter required"}, status=400)
78
- try:
79
- model = apps.get_model(model_label)
80
- except LookupError:
81
- return JsonResponse({"error": "unknown model"}, status=400)
82
-
83
- qs = model.objects.all()
84
- pk = request.GET.get("pk")
85
- if pk is not None:
86
- try:
87
- obj = qs.get(pk=pk)
88
- except model.DoesNotExist:
89
- return JsonResponse({"error": "object not found"}, status=404)
90
- data = model_to_dict(obj)
91
- else:
92
- data = [model_to_dict(o) for o in qs[:100]]
93
-
94
- return JsonResponse({"data": data})