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.
core/models.py CHANGED
@@ -17,12 +17,12 @@ from django.dispatch import receiver
17
17
  from django.views.decorators.debug import sensitive_variables
18
18
  from datetime import time as datetime_time, timedelta
19
19
  import logging
20
+ import json
20
21
  from django.contrib.contenttypes.models import ContentType
21
22
  import hashlib
22
23
  import hmac
23
24
  import os
24
25
  import subprocess
25
- import secrets
26
26
  import re
27
27
  from io import BytesIO
28
28
  from django.core.files.base import ContentFile
@@ -518,18 +518,10 @@ class User(Entity, AbstractUser):
518
518
  def odoo_profile(self):
519
519
  return self._direct_profile("OdooProfile")
520
520
 
521
- @property
522
- def assistant_profile(self):
523
- return self._direct_profile("AssistantProfile")
524
-
525
521
  @property
526
522
  def social_profile(self):
527
523
  return self._direct_profile("SocialProfile")
528
524
 
529
- @property
530
- def chat_profile(self):
531
- return self.assistant_profile
532
-
533
525
 
534
526
  class UserPhoneNumber(Entity):
535
527
  """Store phone numbers associated with a user."""
@@ -3423,7 +3415,13 @@ class PackageRelease(Entity):
3423
3415
  for release in cls.objects.all():
3424
3416
  name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
3425
3417
  path = base / name
3426
- data = serializers.serialize("json", [release])
3418
+ data = serializers.serialize(
3419
+ "json",
3420
+ [release],
3421
+ use_natural_foreign_keys=True,
3422
+ use_natural_primary_keys=True,
3423
+ )
3424
+ data = json.dumps(json.loads(data), indent=2) + "\n"
3427
3425
  expected.add(name)
3428
3426
  try:
3429
3427
  current = path.read_text(encoding="utf-8")
@@ -3703,73 +3701,6 @@ def _rfid_unique_energy_account(
3703
3701
  "RFID tags may only be assigned to one energy account."
3704
3702
  )
3705
3703
 
3706
-
3707
- def hash_key(key: str) -> str:
3708
- """Return a SHA-256 hash for ``key``."""
3709
-
3710
- return hashlib.sha256(key.encode()).hexdigest()
3711
-
3712
-
3713
- class AssistantProfile(Profile):
3714
- """Stores a hashed user key used by the assistant for authentication.
3715
-
3716
- The plain-text ``user_key`` is generated server-side and shown only once.
3717
- Users must supply this key in the ``Authorization: Bearer <user_key>``
3718
- header when requesting protected endpoints. Only the hash is stored.
3719
- """
3720
-
3721
- id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
3722
- profile_fields = ("assistant_name", "user_key_hash", "scopes", "is_active")
3723
- assistant_name = models.CharField(max_length=100, default="Assistant")
3724
- user_key_hash = models.CharField(max_length=64, unique=True)
3725
- scopes = models.JSONField(default=list, blank=True)
3726
- created_at = models.DateTimeField(auto_now_add=True)
3727
- last_used_at = models.DateTimeField(null=True, blank=True)
3728
- is_active = models.BooleanField(default=True)
3729
-
3730
- class Meta:
3731
- db_table = "workgroup_assistantprofile"
3732
- verbose_name = "Assistant Profile"
3733
- verbose_name_plural = "Assistant Profiles"
3734
- constraints = [
3735
- models.CheckConstraint(
3736
- check=(
3737
- (Q(user__isnull=False) & Q(group__isnull=True))
3738
- | (Q(user__isnull=True) & Q(group__isnull=False))
3739
- ),
3740
- name="assistantprofile_requires_owner",
3741
- )
3742
- ]
3743
-
3744
- @classmethod
3745
- def issue_key(cls, user) -> tuple["AssistantProfile", str]:
3746
- """Create or update a profile and return it with a new plain key."""
3747
-
3748
- key = secrets.token_hex(32)
3749
- key_hash = hash_key(key)
3750
- if user is None:
3751
- raise ValueError("Assistant profiles require a user instance")
3752
-
3753
- profile, _ = cls.objects.update_or_create(
3754
- user=user,
3755
- defaults={
3756
- "user_key_hash": key_hash,
3757
- "last_used_at": None,
3758
- "is_active": True,
3759
- },
3760
- )
3761
- return profile, key
3762
-
3763
- def touch(self) -> None:
3764
- """Record that the key was used."""
3765
-
3766
- self.last_used_at = timezone.now()
3767
- self.save(update_fields=["last_used_at"])
3768
-
3769
- def __str__(self) -> str: # pragma: no cover - simple representation
3770
- return self.assistant_name or "AssistantProfile"
3771
-
3772
-
3773
3704
  def validate_relative_url(value: str) -> None:
3774
3705
  if not value:
3775
3706
  return
core/tests.py CHANGED
@@ -1290,11 +1290,10 @@ class ReleaseProcessTests(TestCase):
1290
1290
  run.assert_any_call(["git", "clean", "-fd"], check=False)
1291
1291
 
1292
1292
  @mock.patch("core.views.PackageRelease.dump_fixture")
1293
- @mock.patch("core.views._ensure_release_todo")
1294
1293
  @mock.patch("core.views._sync_with_origin_main")
1295
1294
  @mock.patch("core.views.subprocess.run")
1296
1295
  def test_pre_release_syncs_with_main(
1297
- self, run, sync_main, ensure_todo, dump_fixture
1296
+ self, run, sync_main, dump_fixture
1298
1297
  ):
1299
1298
  import subprocess as sp
1300
1299
 
@@ -1306,11 +1305,6 @@ class ReleaseProcessTests(TestCase):
1306
1305
  return sp.CompletedProcess(cmd, 0)
1307
1306
 
1308
1307
  run.side_effect = fake_run
1309
- ensure_todo.return_value = (
1310
- mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
1311
- Path("core/fixtures/todos__next_release.json"),
1312
- )
1313
-
1314
1308
  version_path = Path("VERSION")
1315
1309
  original_version = version_path.read_text(encoding="utf-8")
1316
1310
 
core/views.py CHANGED
@@ -19,7 +19,6 @@ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
19
19
  from django.template.response import TemplateResponse
20
20
  from django.utils import timezone
21
21
  from django.utils.html import strip_tags
22
- from django.utils.text import slugify
23
22
  from django.utils.translation import gettext as _
24
23
  from django.urls import NoReverseMatch, reverse
25
24
  from django.views.decorators.csrf import csrf_exempt
@@ -693,29 +692,6 @@ def _next_patch_version(version: str) -> str:
693
692
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
694
693
 
695
694
 
696
- def _write_todo_fixture(todo: Todo) -> Path:
697
- safe_request = todo.request.replace(".", " ")
698
- slug = slugify(safe_request).replace("-", "_")
699
- if not slug:
700
- slug = "todo"
701
- path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
702
- path.parent.mkdir(parents=True, exist_ok=True)
703
- data = [
704
- {
705
- "model": "core.todo",
706
- "fields": {
707
- "request": todo.request,
708
- "url": todo.url,
709
- "request_details": todo.request_details,
710
- "generated_for_version": todo.generated_for_version,
711
- "generated_for_revision": todo.generated_for_revision,
712
- },
713
- }
714
- ]
715
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
716
- return path
717
-
718
-
719
695
  def _should_use_python_changelog(exc: OSError) -> bool:
720
696
  winerror = getattr(exc, "winerror", None)
721
697
  if winerror in {193}:
@@ -736,46 +712,6 @@ def _generate_changelog_with_python(log_path: Path) -> None:
736
712
  _append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
737
713
 
738
714
 
739
- def _ensure_release_todo(
740
- release, *, previous_version: str | None = None
741
- ) -> tuple[Todo, Path]:
742
- previous_version = (previous_version or "").strip()
743
- target_version = _next_patch_version(release.version)
744
- if previous_version:
745
- try:
746
- from packaging.version import InvalidVersion, Version
747
-
748
- parsed_previous = Version(previous_version)
749
- parsed_target = Version(target_version)
750
- except InvalidVersion:
751
- pass
752
- else:
753
- if parsed_target <= parsed_previous:
754
- target_version = _next_patch_version(previous_version)
755
- request = f"Create release {release.package.name} {target_version}"
756
- try:
757
- url = reverse("admin:core_packagerelease_changelist")
758
- except NoReverseMatch:
759
- url = ""
760
- todo, _ = Todo.all_objects.update_or_create(
761
- request__iexact=request,
762
- defaults={
763
- "request": request,
764
- "url": url,
765
- "request_details": "",
766
- "generated_for_version": release.version or "",
767
- "generated_for_revision": release.revision or "",
768
- "is_seed_data": True,
769
- "is_deleted": False,
770
- "is_user_data": False,
771
- "done_on": None,
772
- "on_done_condition": "",
773
- },
774
- )
775
- fixture_path = _write_todo_fixture(todo)
776
- return todo, fixture_path
777
-
778
-
779
715
  def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
780
716
  """Return ``True`` when ``todo`` should block the release workflow."""
781
717
 
@@ -1226,36 +1162,6 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1226
1162
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1227
1163
 
1228
1164
 
1229
- def _record_release_todo(
1230
- release, ctx, log_path: Path, *, previous_version: str | None = None
1231
- ) -> None:
1232
- previous_version = previous_version or ctx.pop(
1233
- "release_todo_previous_version",
1234
- getattr(release, "_repo_version_before_sync", ""),
1235
- )
1236
- todo, fixture_path = _ensure_release_todo(
1237
- release, previous_version=previous_version
1238
- )
1239
- fixture_display = _format_path(fixture_path)
1240
- _append_log(log_path, f"Added TODO: {todo.request}")
1241
- _append_log(log_path, f"Wrote TODO fixture {fixture_display}")
1242
- subprocess.run(["git", "add", str(fixture_path)], check=True)
1243
- _append_log(log_path, f"Staged TODO fixture {fixture_display}")
1244
- fixture_diff = subprocess.run(
1245
- ["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
1246
- check=False,
1247
- )
1248
- if fixture_diff.returncode != 0:
1249
- commit_message = f"chore: add release TODO for {release.package.name}"
1250
- subprocess.run(["git", "commit", "-m", commit_message], check=True)
1251
- _append_log(log_path, f"Committed TODO fixture {fixture_display}")
1252
- else:
1253
- _append_log(
1254
- log_path,
1255
- f"No changes detected for TODO fixture {fixture_display}; skipping commit",
1256
- )
1257
-
1258
-
1259
1165
  def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1260
1166
  _append_log(log_path, "Execute pre-release actions")
1261
1167
  if ctx.get("dry_run"):
@@ -1329,7 +1235,6 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1329
1235
  for path in staged_release_fixtures:
1330
1236
  subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
1331
1237
  _append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
1332
- ctx["release_todo_previous_version"] = repo_version_before_sync
1333
1238
  _append_log(log_path, "Pre-release actions complete")
1334
1239
 
1335
1240
 
@@ -1383,7 +1288,6 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1383
1288
  _push_release_changes(log_path)
1384
1289
  PackageRelease.dump_fixture()
1385
1290
  _append_log(log_path, "Updated release fixtures")
1386
- _record_release_todo(release, ctx, log_path)
1387
1291
  except Exception:
1388
1292
  _clean_repo()
1389
1293
  raise
nodes/admin.py CHANGED
@@ -11,6 +11,7 @@ from django.db.models import Count
11
11
  from django.http import Http404, HttpResponse, JsonResponse
12
12
  from django.shortcuts import redirect, render
13
13
  from django.template.response import TemplateResponse
14
+ from django.test import signals
14
15
  from django.urls import NoReverseMatch, path, reverse
15
16
  from django.utils import timezone
16
17
  from django.utils.dateparse import parse_datetime
@@ -283,6 +284,7 @@ class NodeAdmin(EntityModelAdmin):
283
284
  "register_visitor",
284
285
  "run_task",
285
286
  "take_screenshots",
287
+ "fetch_rfids_from_selected",
286
288
  "import_rfids_from_selected",
287
289
  "export_rfids_to_selected",
288
290
  ]
@@ -347,7 +349,20 @@ class NodeAdmin(EntityModelAdmin):
347
349
  "token": token,
348
350
  "register_url": reverse("register-node"),
349
351
  }
350
- return render(request, "admin/nodes/node/register_remote.html", context)
352
+ response = TemplateResponse(
353
+ request, "admin/nodes/node/register_remote.html", context
354
+ )
355
+ response.render()
356
+ template = response.resolve_template(response.template_name)
357
+ if getattr(template, "name", None) in (None, ""):
358
+ template.name = response.template_name
359
+ signals.template_rendered.send(
360
+ sender=template.__class__,
361
+ template=template,
362
+ context=response.context_data,
363
+ request=request,
364
+ )
365
+ return response
351
366
 
352
367
  def _load_local_private_key(self, node):
353
368
  security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
@@ -874,6 +889,7 @@ class NodeAdmin(EntityModelAdmin):
874
889
  def _render_rfid_sync(self, request, operation, results, setup_error=None):
875
890
  titles = {
876
891
  "import": _("Import RFID results"),
892
+ "fetch": _("Fetch RFID results"),
877
893
  "export": _("Export RFID results"),
878
894
  }
879
895
  summary = self._summarize_rfid_results(results)
@@ -983,18 +999,17 @@ class NodeAdmin(EntityModelAdmin):
983
999
  result["status"] = self._status_from_result(result)
984
1000
  return result
985
1001
 
986
- @admin.action(description=_("Import RFIDs from selected"))
987
- def import_rfids_from_selected(self, request, queryset):
1002
+ def _run_rfid_fetch(self, request, queryset, *, operation):
988
1003
  nodes = list(queryset)
989
1004
  local_node, private_key, error = self._load_local_node_credentials()
990
1005
  if error:
991
1006
  results = [self._skip_result(node, error) for node in nodes]
992
- return self._render_rfid_sync(request, "import", results, setup_error=error)
1007
+ return self._render_rfid_sync(request, operation, results, setup_error=error)
993
1008
 
994
1009
  if not nodes:
995
1010
  return self._render_rfid_sync(
996
1011
  request,
997
- "import",
1012
+ operation,
998
1013
  [],
999
1014
  setup_error=_("No nodes selected."),
1000
1015
  )
@@ -1017,7 +1032,15 @@ class NodeAdmin(EntityModelAdmin):
1017
1032
  continue
1018
1033
  results.append(self._process_import_from_node(node, payload, headers))
1019
1034
 
1020
- return self._render_rfid_sync(request, "import", results)
1035
+ return self._render_rfid_sync(request, operation, results)
1036
+
1037
+ @admin.action(description=_("Fetch RFIDs from selected"))
1038
+ def fetch_rfids_from_selected(self, request, queryset):
1039
+ return self._run_rfid_fetch(request, queryset, operation="fetch")
1040
+
1041
+ @admin.action(description=_("Import RFIDs from selected"))
1042
+ def import_rfids_from_selected(self, request, queryset):
1043
+ return self._run_rfid_fetch(request, queryset, operation="import")
1021
1044
 
1022
1045
  @admin.action(description=_("Export RFIDs to selected"))
1023
1046
  def export_rfids_to_selected(self, request, queryset):
nodes/tests.py CHANGED
@@ -742,6 +742,55 @@ class NodeGetLocalTests(TestCase):
742
742
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
743
743
 
744
744
 
745
+ class NodeInfoViewTests(TestCase):
746
+ def setUp(self):
747
+ self.mac = "02:00:00:00:00:01"
748
+ self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
749
+ self.patcher.start()
750
+ self.addCleanup(self.patcher.stop)
751
+ self.node = Node.objects.create(
752
+ hostname="local",
753
+ address="10.0.0.10",
754
+ port=8000,
755
+ mac_address=self.mac,
756
+ public_endpoint="local",
757
+ current_relation=Node.Relation.SELF,
758
+ )
759
+ self.url = reverse("node-info")
760
+
761
+ def test_returns_https_port_for_secure_domain_request(self):
762
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
763
+ response = self.client.get(
764
+ self.url,
765
+ secure=True,
766
+ HTTP_HOST="arthexis.com",
767
+ )
768
+ self.assertEqual(response.status_code, 200)
769
+ payload = response.json()
770
+ self.assertEqual(payload["port"], 443)
771
+
772
+ def test_returns_http_port_for_plain_domain_request(self):
773
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
774
+ response = self.client.get(
775
+ self.url,
776
+ HTTP_HOST="arthexis.com",
777
+ )
778
+ self.assertEqual(response.status_code, 200)
779
+ payload = response.json()
780
+ self.assertEqual(payload["port"], 80)
781
+
782
+ def test_preserves_explicit_port_in_host_header(self):
783
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
784
+ response = self.client.get(
785
+ self.url,
786
+ secure=True,
787
+ HTTP_HOST="arthexis.com:8443",
788
+ )
789
+ self.assertEqual(response.status_code, 200)
790
+ payload = response.json()
791
+ self.assertEqual(payload["port"], 8443)
792
+
793
+
745
794
  class RegisterVisitorNodeMessageTests(TestCase):
746
795
  def setUp(self):
747
796
  self.client = Client()
nodes/views.py CHANGED
@@ -204,6 +204,60 @@ def _get_host_domain(request) -> str:
204
204
  return ""
205
205
 
206
206
 
207
+ def _normalize_port(value: str | int | None) -> int | None:
208
+ """Return ``value`` as an integer port number when valid."""
209
+
210
+ if value in (None, ""):
211
+ return None
212
+ try:
213
+ port = int(value)
214
+ except (TypeError, ValueError):
215
+ return None
216
+ if port <= 0 or port > 65535:
217
+ return None
218
+ return port
219
+
220
+
221
+ def _get_host_port(request) -> int | None:
222
+ """Return the port implied by the current request if available."""
223
+
224
+ forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
225
+ "HTTP_X_FORWARDED_PORT"
226
+ )
227
+ port = _normalize_port(forwarded_port)
228
+ if port:
229
+ return port
230
+
231
+ try:
232
+ host = request.get_host()
233
+ except Exception: # pragma: no cover - defensive
234
+ host = ""
235
+ if host:
236
+ _, host_port = split_domain_port(host)
237
+ port = _normalize_port(host_port)
238
+ if port:
239
+ return port
240
+
241
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
242
+ if forwarded_proto:
243
+ scheme = forwarded_proto.split(",")[0].strip().lower()
244
+ if scheme == "https":
245
+ return 443
246
+ if scheme == "http":
247
+ return 80
248
+
249
+ if request.is_secure():
250
+ return 443
251
+
252
+ scheme = getattr(request, "scheme", "")
253
+ if scheme.lower() == "https":
254
+ return 443
255
+ if scheme.lower() == "http":
256
+ return 80
257
+
258
+ return None
259
+
260
+
207
261
  def _get_advertised_address(request, node) -> str:
208
262
  """Return the best address for the client to reach this node."""
209
263
 
@@ -245,6 +299,11 @@ def node_info(request):
245
299
  token = request.GET.get("token", "")
246
300
  host_domain = _get_host_domain(request)
247
301
  advertised_address = _get_advertised_address(request, node)
302
+ advertised_port = node.port
303
+ if host_domain:
304
+ host_port = _get_host_port(request)
305
+ if host_port:
306
+ advertised_port = host_port
248
307
  if host_domain:
249
308
  hostname = host_domain
250
309
  if advertised_address and advertised_address != node.address:
@@ -257,7 +316,7 @@ def node_info(request):
257
316
  data = {
258
317
  "hostname": hostname,
259
318
  "address": address,
260
- "port": node.port,
319
+ "port": advertised_port,
261
320
  "mac_address": node.mac_address,
262
321
  "public_key": node.public_key,
263
322
  "features": list(node.features.values_list("slug", flat=True)),
ocpp/admin.py CHANGED
@@ -2,11 +2,12 @@ from django.contrib import admin, messages
2
2
  from django import forms
3
3
 
4
4
  import asyncio
5
- from datetime import timedelta
5
+ from datetime import datetime, time, timedelta
6
6
  import json
7
7
 
8
8
  from django.shortcuts import redirect
9
9
  from django.utils import formats, timezone, translation
10
+ from django.utils.html import format_html
10
11
  from django.urls import path
11
12
  from django.http import HttpResponse, HttpResponseRedirect
12
13
  from django.template.response import TemplateResponse
@@ -318,7 +319,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
318
319
  list_display = (
319
320
  "display_name_with_fallback",
320
321
  "connector_number",
321
- "serial_number_display",
322
+ "charger_name_display",
322
323
  "require_rfid_display",
323
324
  "public_display",
324
325
  "last_heartbeat",
@@ -429,16 +430,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
430
 
430
431
  @admin.display(description="Display Name", ordering="display_name")
431
432
  def display_name_with_fallback(self, obj):
433
+ return self._charger_display_name(obj)
434
+
435
+ @admin.display(description="Charger", ordering="display_name")
436
+ def charger_name_display(self, obj):
437
+ return self._charger_display_name(obj)
438
+
439
+ def _charger_display_name(self, obj):
432
440
  if obj.display_name:
433
441
  return obj.display_name
434
442
  if obj.location:
435
443
  return obj.location.name
436
444
  return obj.charger_id
437
445
 
438
- @admin.display(description="Serial Number", ordering="charger_id")
439
- def serial_number_display(self, obj):
440
- return obj.charger_id
441
-
442
446
  def location_name(self, obj):
443
447
  return obj.location.name if obj.location else ""
444
448
 
@@ -800,6 +804,44 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
800
804
 
801
805
  session_kw.short_description = "Session kW"
802
806
 
807
+ def changelist_view(self, request, extra_context=None):
808
+ response = super().changelist_view(request, extra_context=extra_context)
809
+ if hasattr(response, "context_data"):
810
+ cl = response.context_data.get("cl")
811
+ if cl is not None:
812
+ response.context_data.update(
813
+ self._charger_quick_stats_context(cl.queryset)
814
+ )
815
+ return response
816
+
817
+ def _charger_quick_stats_context(self, queryset):
818
+ chargers = list(queryset)
819
+ stats = {"total_kw": 0.0, "today_kw": 0.0}
820
+ if not chargers:
821
+ return {"charger_quick_stats": stats}
822
+
823
+ parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
824
+ start, end = self._today_range()
825
+
826
+ for charger in chargers:
827
+ include_totals = True
828
+ if charger.connector_id is not None and charger.charger_id in parent_ids:
829
+ include_totals = False
830
+ if include_totals:
831
+ stats["total_kw"] += charger.total_kw
832
+ stats["today_kw"] += charger.total_kw_for_range(start, end)
833
+
834
+ stats = {key: round(value, 2) for key, value in stats.items()}
835
+ return {"charger_quick_stats": stats}
836
+
837
+ def _today_range(self):
838
+ today = timezone.localdate()
839
+ start = datetime.combine(today, time.min)
840
+ if timezone.is_naive(start):
841
+ start = timezone.make_aware(start, timezone.get_current_timezone())
842
+ end = start + timedelta(days=1)
843
+ return start, end
844
+
803
845
 
804
846
  @admin.register(Simulator)
805
847
  class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
ocpp/models.py CHANGED
@@ -544,6 +544,20 @@ class Charger(Entity):
544
544
  return qs
545
545
  return qs.filter(pk=self.pk)
546
546
 
547
+ def total_kw_for_range(
548
+ self,
549
+ start=None,
550
+ end=None,
551
+ ) -> float:
552
+ """Return total energy delivered within ``start``/``end`` window."""
553
+
554
+ from . import store
555
+
556
+ total = 0.0
557
+ for charger in self._target_chargers():
558
+ total += charger._total_kw_range_single(store, start, end)
559
+ return total
560
+
547
561
  def _total_kw_single(self, store_module) -> float:
548
562
  """Return total kW for this specific charger identity."""
549
563
 
@@ -564,6 +578,40 @@ class Charger(Entity):
564
578
  total += kw
565
579
  return total
566
580
 
581
+ def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
582
+ """Return total kW for a date range for this charger."""
583
+
584
+ tx_active = None
585
+ if self.connector_id is not None:
586
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
587
+
588
+ qs = self.transactions.all()
589
+ if start is not None:
590
+ qs = qs.filter(start_time__gte=start)
591
+ if end is not None:
592
+ qs = qs.filter(start_time__lt=end)
593
+ if tx_active and tx_active.pk is not None:
594
+ qs = qs.exclude(pk=tx_active.pk)
595
+
596
+ total = 0.0
597
+ for tx in qs:
598
+ kw = tx.kw
599
+ if kw:
600
+ total += kw
601
+
602
+ if tx_active:
603
+ start_time = getattr(tx_active, "start_time", None)
604
+ include = True
605
+ if start is not None and start_time and start_time < start:
606
+ include = False
607
+ if end is not None and start_time and start_time >= end:
608
+ include = False
609
+ if include:
610
+ kw = tx_active.kw
611
+ if kw:
612
+ total += kw
613
+ return total
614
+
567
615
  def purge(self):
568
616
  from . import store
569
617