arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -4,7 +4,7 @@ from django.contrib.auth.models import (
|
|
|
4
4
|
UserManager as DjangoUserManager,
|
|
5
5
|
)
|
|
6
6
|
from django.db import DatabaseError, IntegrityError, connections, models, transaction
|
|
7
|
-
from django.db.models import Q
|
|
7
|
+
from django.db.models import Q, F
|
|
8
8
|
from django.db.models.functions import Lower, Length
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from django.contrib.auth import get_user_model
|
|
@@ -42,6 +42,7 @@ from django.core.management.color import no_style
|
|
|
42
42
|
from urllib.parse import quote, quote_plus, urlparse
|
|
43
43
|
from zoneinfo import ZoneInfo
|
|
44
44
|
from utils import revision as revision_utils
|
|
45
|
+
from core.celery_utils import normalize_periodic_task_name
|
|
45
46
|
from typing import Any, Type
|
|
46
47
|
from defusedxml import xmlrpc as defused_xmlrpc
|
|
47
48
|
import requests
|
|
@@ -388,7 +389,6 @@ class User(Entity, AbstractUser):
|
|
|
388
389
|
objects = EntityUserManager()
|
|
389
390
|
all_objects = DjangoUserManager()
|
|
390
391
|
"""Custom user model."""
|
|
391
|
-
birthday = models.DateField(null=True, blank=True)
|
|
392
392
|
data_path = models.CharField(max_length=255, blank=True)
|
|
393
393
|
last_visit_ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
394
394
|
operate_as = models.ForeignKey(
|
|
@@ -579,6 +579,11 @@ class User(Entity, AbstractUser):
|
|
|
579
579
|
return self._direct_profile("GoogleCalendarProfile")
|
|
580
580
|
|
|
581
581
|
|
|
582
|
+
class Meta(AbstractUser.Meta):
|
|
583
|
+
verbose_name = _("User")
|
|
584
|
+
verbose_name_plural = _("Users")
|
|
585
|
+
|
|
586
|
+
|
|
582
587
|
class UserPhoneNumber(Entity):
|
|
583
588
|
"""Store phone numbers associated with a user."""
|
|
584
589
|
|
|
@@ -605,11 +610,19 @@ class UserPhoneNumber(Entity):
|
|
|
605
610
|
class OdooProfile(Profile):
|
|
606
611
|
"""Store Odoo API credentials for a user."""
|
|
607
612
|
|
|
613
|
+
class CRM(models.TextChoices):
|
|
614
|
+
ODOO = "odoo", _("Odoo")
|
|
615
|
+
|
|
608
616
|
profile_fields = ("host", "database", "username", "password")
|
|
609
617
|
host = SigilShortAutoField(max_length=255)
|
|
610
618
|
database = SigilShortAutoField(max_length=255)
|
|
611
619
|
username = SigilShortAutoField(max_length=255)
|
|
612
620
|
password = SigilShortAutoField(max_length=255)
|
|
621
|
+
crm = models.CharField(
|
|
622
|
+
max_length=32,
|
|
623
|
+
choices=CRM.choices,
|
|
624
|
+
default=CRM.ODOO,
|
|
625
|
+
)
|
|
613
626
|
verified_on = models.DateTimeField(null=True, blank=True)
|
|
614
627
|
odoo_uid = models.PositiveIntegerField(null=True, blank=True, editable=False)
|
|
615
628
|
name = models.CharField(max_length=255, blank=True, editable=False)
|
|
@@ -733,8 +746,8 @@ class OdooProfile(Profile):
|
|
|
733
746
|
return f"{owner} @ {self.host}" if owner else self.host
|
|
734
747
|
|
|
735
748
|
class Meta:
|
|
736
|
-
verbose_name = _("
|
|
737
|
-
verbose_name_plural = _("
|
|
749
|
+
verbose_name = _("CRM Employee")
|
|
750
|
+
verbose_name_plural = _("CRM Employees")
|
|
738
751
|
constraints = [
|
|
739
752
|
models.CheckConstraint(
|
|
740
753
|
check=(
|
|
@@ -747,24 +760,47 @@ class OdooProfile(Profile):
|
|
|
747
760
|
|
|
748
761
|
|
|
749
762
|
class OpenPayProfile(Profile):
|
|
750
|
-
"""Store
|
|
763
|
+
"""Store payment processor credentials for a user or security group."""
|
|
764
|
+
|
|
765
|
+
PROCESSOR_OPENPAY = "openpay"
|
|
766
|
+
PROCESSOR_PAYPAL = "paypal"
|
|
767
|
+
PROCESSOR_CHOICES = (
|
|
768
|
+
(PROCESSOR_OPENPAY, _("OpenPay")),
|
|
769
|
+
(PROCESSOR_PAYPAL, _("PayPal")),
|
|
770
|
+
)
|
|
751
771
|
|
|
752
772
|
SANDBOX_API_URL = "https://sandbox-api.openpay.mx/v1"
|
|
753
773
|
PRODUCTION_API_URL = "https://api.openpay.mx/v1"
|
|
754
774
|
|
|
775
|
+
PAYPAL_SANDBOX_API_URL = "https://api-m.sandbox.paypal.com"
|
|
776
|
+
PAYPAL_PRODUCTION_API_URL = "https://api-m.paypal.com"
|
|
777
|
+
|
|
755
778
|
profile_fields = (
|
|
756
779
|
"merchant_id",
|
|
757
780
|
"private_key",
|
|
758
781
|
"public_key",
|
|
759
782
|
"is_production",
|
|
760
783
|
"webhook_secret",
|
|
784
|
+
"paypal_client_id",
|
|
785
|
+
"paypal_client_secret",
|
|
786
|
+
"paypal_webhook_id",
|
|
787
|
+
"paypal_is_production",
|
|
761
788
|
)
|
|
762
789
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
790
|
+
default_processor = models.CharField(
|
|
791
|
+
max_length=20,
|
|
792
|
+
choices=PROCESSOR_CHOICES,
|
|
793
|
+
default=PROCESSOR_OPENPAY,
|
|
794
|
+
)
|
|
795
|
+
merchant_id = SigilShortAutoField(max_length=100, blank=True)
|
|
796
|
+
private_key = SigilShortAutoField(max_length=255, blank=True)
|
|
797
|
+
public_key = SigilShortAutoField(max_length=255, blank=True)
|
|
766
798
|
is_production = models.BooleanField(default=False)
|
|
767
799
|
webhook_secret = SigilShortAutoField(max_length=255, blank=True)
|
|
800
|
+
paypal_client_id = SigilShortAutoField(max_length=255, blank=True)
|
|
801
|
+
paypal_client_secret = SigilShortAutoField(max_length=255, blank=True)
|
|
802
|
+
paypal_webhook_id = SigilShortAutoField(max_length=255, blank=True)
|
|
803
|
+
paypal_is_production = models.BooleanField(default=False)
|
|
768
804
|
verified_on = models.DateTimeField(null=True, blank=True)
|
|
769
805
|
verification_reference = models.CharField(max_length=255, blank=True, editable=False)
|
|
770
806
|
|
|
@@ -781,6 +817,11 @@ class OpenPayProfile(Profile):
|
|
|
781
817
|
or old.public_key != self.public_key
|
|
782
818
|
or old.is_production != self.is_production
|
|
783
819
|
or old.webhook_secret != self.webhook_secret
|
|
820
|
+
or old.default_processor != self.default_processor
|
|
821
|
+
or old.paypal_client_id != self.paypal_client_id
|
|
822
|
+
or old.paypal_client_secret != self.paypal_client_secret
|
|
823
|
+
or old.paypal_webhook_id != self.paypal_webhook_id
|
|
824
|
+
or old.paypal_is_production != self.paypal_is_production
|
|
784
825
|
):
|
|
785
826
|
self._clear_verification()
|
|
786
827
|
super().save(*args, **kwargs)
|
|
@@ -789,6 +830,8 @@ class OpenPayProfile(Profile):
|
|
|
789
830
|
def is_verified(self):
|
|
790
831
|
return self.verified_on is not None
|
|
791
832
|
|
|
833
|
+
# --- OpenPay helpers -------------------------------------------------
|
|
834
|
+
|
|
792
835
|
def get_api_base_url(self) -> str:
|
|
793
836
|
return self.PRODUCTION_API_URL if self.is_production else self.SANDBOX_API_URL
|
|
794
837
|
|
|
@@ -805,6 +848,47 @@ class OpenPayProfile(Profile):
|
|
|
805
848
|
def is_sandbox(self) -> bool:
|
|
806
849
|
return not self.is_production
|
|
807
850
|
|
|
851
|
+
# --- PayPal helpers --------------------------------------------------
|
|
852
|
+
|
|
853
|
+
def get_paypal_api_base_url(self) -> str:
|
|
854
|
+
return (
|
|
855
|
+
self.PAYPAL_PRODUCTION_API_URL
|
|
856
|
+
if self.paypal_is_production
|
|
857
|
+
else self.PAYPAL_SANDBOX_API_URL
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
def get_paypal_auth(self) -> tuple[str, str]:
|
|
861
|
+
return (self.paypal_client_id, self.paypal_client_secret)
|
|
862
|
+
|
|
863
|
+
# --- Processor utilities --------------------------------------------
|
|
864
|
+
|
|
865
|
+
def has_openpay_credentials(self) -> bool:
|
|
866
|
+
return all(
|
|
867
|
+
getattr(self, field)
|
|
868
|
+
for field in ("merchant_id", "private_key", "public_key")
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
def has_paypal_credentials(self) -> bool:
|
|
872
|
+
return all(
|
|
873
|
+
getattr(self, field)
|
|
874
|
+
for field in ("paypal_client_id", "paypal_client_secret")
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def iter_processors(self):
|
|
878
|
+
preferred = self.default_processor or self.PROCESSOR_OPENPAY
|
|
879
|
+
ordered = [preferred]
|
|
880
|
+
other = (
|
|
881
|
+
self.PROCESSOR_PAYPAL
|
|
882
|
+
if preferred == self.PROCESSOR_OPENPAY
|
|
883
|
+
else self.PROCESSOR_OPENPAY
|
|
884
|
+
)
|
|
885
|
+
ordered.append(other)
|
|
886
|
+
for processor in ordered:
|
|
887
|
+
if processor == self.PROCESSOR_OPENPAY and self.has_openpay_credentials():
|
|
888
|
+
yield processor
|
|
889
|
+
elif processor == self.PROCESSOR_PAYPAL and self.has_paypal_credentials():
|
|
890
|
+
yield processor
|
|
891
|
+
|
|
808
892
|
def sign_webhook(self, payload: bytes | str, timestamp: str | None = None) -> str:
|
|
809
893
|
if not self.webhook_secret:
|
|
810
894
|
raise ValueError("Webhook secret is not configured")
|
|
@@ -837,7 +921,7 @@ class OpenPayProfile(Profile):
|
|
|
837
921
|
self._clear_verification()
|
|
838
922
|
return self
|
|
839
923
|
|
|
840
|
-
def
|
|
924
|
+
def _verify_openpay(self):
|
|
841
925
|
url = self.build_api_url("charges")
|
|
842
926
|
try:
|
|
843
927
|
response = requests.get(
|
|
@@ -886,13 +970,62 @@ class OpenPayProfile(Profile):
|
|
|
886
970
|
self.save(update_fields=["verification_reference", "verified_on"])
|
|
887
971
|
return True
|
|
888
972
|
|
|
973
|
+
def _verify_paypal(self):
|
|
974
|
+
url = f"{self.get_paypal_api_base_url()}/v1/oauth2/token"
|
|
975
|
+
try:
|
|
976
|
+
response = requests.post(
|
|
977
|
+
url,
|
|
978
|
+
auth=self.get_paypal_auth(),
|
|
979
|
+
data={"grant_type": "client_credentials"},
|
|
980
|
+
timeout=10,
|
|
981
|
+
)
|
|
982
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
983
|
+
self._clear_verification()
|
|
984
|
+
if self.pk:
|
|
985
|
+
self.save(update_fields=["verification_reference", "verified_on"])
|
|
986
|
+
raise ValidationError(
|
|
987
|
+
_("Unable to verify PayPal credentials: %(error)s")
|
|
988
|
+
% {"error": exc}
|
|
989
|
+
) from exc
|
|
990
|
+
if response.status_code != 200:
|
|
991
|
+
self._clear_verification()
|
|
992
|
+
if self.pk:
|
|
993
|
+
self.save(update_fields=["verification_reference", "verified_on"])
|
|
994
|
+
raise ValidationError(_("Invalid PayPal credentials"))
|
|
995
|
+
try:
|
|
996
|
+
payload = response.json() or {}
|
|
997
|
+
except ValueError:
|
|
998
|
+
payload = {}
|
|
999
|
+
scope = ""
|
|
1000
|
+
if isinstance(payload, dict):
|
|
1001
|
+
scope = payload.get("scope") or payload.get("access_token") or ""
|
|
1002
|
+
self.verification_reference = f"PayPal: {scope}" if scope else "PayPal"
|
|
1003
|
+
self.verified_on = timezone.now()
|
|
1004
|
+
self.save(update_fields=["verification_reference", "verified_on"])
|
|
1005
|
+
return True
|
|
1006
|
+
|
|
1007
|
+
def verify(self):
|
|
1008
|
+
errors = []
|
|
1009
|
+
for processor in self.iter_processors():
|
|
1010
|
+
try:
|
|
1011
|
+
if processor == self.PROCESSOR_OPENPAY:
|
|
1012
|
+
return self._verify_openpay()
|
|
1013
|
+
if processor == self.PROCESSOR_PAYPAL:
|
|
1014
|
+
return self._verify_paypal()
|
|
1015
|
+
except ValidationError as exc:
|
|
1016
|
+
errors.append(exc)
|
|
1017
|
+
if errors:
|
|
1018
|
+
raise errors[-1]
|
|
1019
|
+
raise ValidationError(_("No payment processors are configured."))
|
|
1020
|
+
|
|
889
1021
|
def __str__(self): # pragma: no cover - simple representation
|
|
890
1022
|
owner = self.owner_display()
|
|
891
|
-
|
|
1023
|
+
identifier = self.merchant_id or self.paypal_client_id or ""
|
|
1024
|
+
return f"{owner} @ {identifier}" if owner and identifier else (owner or identifier)
|
|
892
1025
|
|
|
893
1026
|
class Meta:
|
|
894
|
-
verbose_name = _("
|
|
895
|
-
verbose_name_plural = _("
|
|
1027
|
+
verbose_name = _("Payment Processor")
|
|
1028
|
+
verbose_name_plural = _("Payment Processors")
|
|
896
1029
|
constraints = [
|
|
897
1030
|
models.CheckConstraint(
|
|
898
1031
|
check=(
|
|
@@ -1995,6 +2128,11 @@ class Reference(Entity):
|
|
|
1995
2128
|
return (self.alt_text,)
|
|
1996
2129
|
|
|
1997
2130
|
|
|
2131
|
+
class Meta:
|
|
2132
|
+
verbose_name = _("Reference")
|
|
2133
|
+
verbose_name_plural = _("References")
|
|
2134
|
+
|
|
2135
|
+
|
|
1998
2136
|
class RFID(Entity):
|
|
1999
2137
|
"""RFID tag that may be assigned to one account."""
|
|
2000
2138
|
|
|
@@ -2871,7 +3009,8 @@ class ClientReportSchedule(Entity):
|
|
|
2871
3009
|
month_of_year="*",
|
|
2872
3010
|
)
|
|
2873
3011
|
|
|
2874
|
-
|
|
3012
|
+
raw_name = f"client_report_schedule_{self.pk}"
|
|
3013
|
+
name = normalize_periodic_task_name(PeriodicTask.objects, raw_name)
|
|
2875
3014
|
defaults = {
|
|
2876
3015
|
"crontab": schedule,
|
|
2877
3016
|
"task": "core.tasks.run_client_report_schedule",
|
|
@@ -3358,7 +3497,11 @@ class ClientReport(Entity):
|
|
|
3358
3497
|
@staticmethod
|
|
3359
3498
|
def _build_dataset(start_date=None, end_date=None, *, chargers=None):
|
|
3360
3499
|
from datetime import datetime, time, timedelta, timezone as pytimezone
|
|
3361
|
-
from ocpp.models import
|
|
3500
|
+
from ocpp.models import (
|
|
3501
|
+
Charger,
|
|
3502
|
+
Transaction,
|
|
3503
|
+
annotate_transaction_energy_bounds,
|
|
3504
|
+
)
|
|
3362
3505
|
|
|
3363
3506
|
qs = Transaction.objects.all()
|
|
3364
3507
|
|
|
@@ -3381,7 +3524,12 @@ class ClientReport(Entity):
|
|
|
3381
3524
|
if selected_base_ids:
|
|
3382
3525
|
qs = qs.filter(charger__charger_id__in=selected_base_ids)
|
|
3383
3526
|
|
|
3384
|
-
qs = qs.select_related("account", "charger")
|
|
3527
|
+
qs = qs.select_related("account", "charger")
|
|
3528
|
+
qs = annotate_transaction_energy_bounds(
|
|
3529
|
+
qs,
|
|
3530
|
+
start_field="report_meter_energy_start",
|
|
3531
|
+
end_field="report_meter_energy_end",
|
|
3532
|
+
)
|
|
3385
3533
|
transactions = list(qs.order_by("start_time", "pk"))
|
|
3386
3534
|
|
|
3387
3535
|
rfid_values = {tx.rfid for tx in transactions if tx.rfid}
|
|
@@ -3533,20 +3681,34 @@ class ClientReport(Entity):
|
|
|
3533
3681
|
start_value = _convert(getattr(tx, "meter_start", None))
|
|
3534
3682
|
end_value = _convert(getattr(tx, "meter_stop", None))
|
|
3535
3683
|
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
if
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3684
|
+
def _coerce_energy(value):
|
|
3685
|
+
if value in {None, ""}:
|
|
3686
|
+
return None
|
|
3687
|
+
try:
|
|
3688
|
+
return float(value)
|
|
3689
|
+
except (TypeError, ValueError):
|
|
3690
|
+
return None
|
|
3691
|
+
|
|
3692
|
+
if start_value is None:
|
|
3693
|
+
annotated_start = getattr(tx, "report_meter_energy_start", None)
|
|
3694
|
+
start_value = _coerce_energy(annotated_start)
|
|
3695
|
+
|
|
3696
|
+
if end_value is None:
|
|
3697
|
+
annotated_end = getattr(tx, "report_meter_energy_end", None)
|
|
3698
|
+
end_value = _coerce_energy(annotated_end)
|
|
3699
|
+
|
|
3700
|
+
if start_value is None or end_value is None:
|
|
3701
|
+
readings_manager = getattr(tx, "meter_values", None)
|
|
3702
|
+
if readings_manager is not None:
|
|
3703
|
+
qs = readings_manager.filter(energy__isnull=False).order_by("timestamp")
|
|
3704
|
+
if start_value is None:
|
|
3705
|
+
first_energy = qs.values_list("energy", flat=True).first()
|
|
3706
|
+
start_value = _coerce_energy(first_energy)
|
|
3707
|
+
if end_value is None:
|
|
3708
|
+
last_energy = qs.order_by("-timestamp").values_list(
|
|
3709
|
+
"energy", flat=True
|
|
3710
|
+
).first()
|
|
3711
|
+
end_value = _coerce_energy(last_energy)
|
|
3550
3712
|
|
|
3551
3713
|
return start_value, end_value
|
|
3552
3714
|
|
|
@@ -3707,6 +3869,43 @@ class ClientReport(Entity):
|
|
|
3707
3869
|
except ValueError:
|
|
3708
3870
|
return str(path)
|
|
3709
3871
|
|
|
3872
|
+
@classmethod
|
|
3873
|
+
def _load_pdf_template(cls, language_code: str | None) -> dict[str, str]:
|
|
3874
|
+
from django.template import TemplateDoesNotExist
|
|
3875
|
+
from django.template.loader import render_to_string
|
|
3876
|
+
|
|
3877
|
+
candidates: list[str] = []
|
|
3878
|
+
normalized = cls.normalize_language(language_code)
|
|
3879
|
+
if normalized:
|
|
3880
|
+
candidates.append(normalized)
|
|
3881
|
+
|
|
3882
|
+
default_code = default_report_language()
|
|
3883
|
+
if default_code and default_code not in candidates:
|
|
3884
|
+
candidates.append(default_code)
|
|
3885
|
+
|
|
3886
|
+
if "en" not in candidates:
|
|
3887
|
+
candidates.append("en")
|
|
3888
|
+
|
|
3889
|
+
for code in dict.fromkeys(candidates):
|
|
3890
|
+
template_name = f"core/reports/client_report_pdf/{code}.json"
|
|
3891
|
+
try:
|
|
3892
|
+
rendered = render_to_string(template_name)
|
|
3893
|
+
except TemplateDoesNotExist:
|
|
3894
|
+
continue
|
|
3895
|
+
if not rendered:
|
|
3896
|
+
continue
|
|
3897
|
+
try:
|
|
3898
|
+
data = json.loads(rendered)
|
|
3899
|
+
except json.JSONDecodeError:
|
|
3900
|
+
logger.warning(
|
|
3901
|
+
"Invalid client report PDF template %s", template_name, exc_info=True
|
|
3902
|
+
)
|
|
3903
|
+
continue
|
|
3904
|
+
if isinstance(data, dict):
|
|
3905
|
+
return data
|
|
3906
|
+
|
|
3907
|
+
return {}
|
|
3908
|
+
|
|
3710
3909
|
@staticmethod
|
|
3711
3910
|
def resolve_reply_to_for_owner(owner) -> list[str]:
|
|
3712
3911
|
if not owner:
|
|
@@ -3775,9 +3974,16 @@ class ClientReport(Entity):
|
|
|
3775
3974
|
)
|
|
3776
3975
|
|
|
3777
3976
|
story: list = []
|
|
3977
|
+
labels = self._load_pdf_template(language_code)
|
|
3778
3978
|
|
|
3779
|
-
|
|
3780
|
-
|
|
3979
|
+
def label(key: str, default: str) -> str:
|
|
3980
|
+
value = labels.get(key) if isinstance(labels, dict) else None
|
|
3981
|
+
if isinstance(value, str) and value.strip():
|
|
3982
|
+
return value
|
|
3983
|
+
return gettext(default)
|
|
3984
|
+
|
|
3985
|
+
report_title = self.normalize_title(self.title) or label(
|
|
3986
|
+
"title", "Consumer Report"
|
|
3781
3987
|
)
|
|
3782
3988
|
story.append(Paragraph(report_title, title_style))
|
|
3783
3989
|
|
|
@@ -3787,32 +3993,58 @@ class ClientReport(Entity):
|
|
|
3787
3993
|
end_display = formats.date_format(
|
|
3788
3994
|
self.end_date, format="DATE_FORMAT", use_l10n=True
|
|
3789
3995
|
)
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3996
|
+
default_period_text = gettext("Period: %(start)s to %(end)s") % {
|
|
3997
|
+
"start": start_display,
|
|
3998
|
+
"end": end_display,
|
|
3999
|
+
}
|
|
4000
|
+
period_template = labels.get("period") if isinstance(labels, dict) else None
|
|
4001
|
+
if isinstance(period_template, str):
|
|
4002
|
+
try:
|
|
4003
|
+
period_text = period_template.format(
|
|
4004
|
+
start=start_display, end=end_display
|
|
4005
|
+
)
|
|
4006
|
+
except (KeyError, IndexError, ValueError):
|
|
4007
|
+
logger.warning(
|
|
4008
|
+
"Invalid period template for client report PDF: %s",
|
|
4009
|
+
period_template,
|
|
4010
|
+
)
|
|
4011
|
+
period_text = default_period_text
|
|
4012
|
+
else:
|
|
4013
|
+
period_text = default_period_text
|
|
4014
|
+
story.append(Paragraph(period_text, emphasis_style))
|
|
3797
4015
|
story.append(Spacer(1, 0.25 * inch))
|
|
3798
4016
|
|
|
3799
|
-
total_kw_all_time_label =
|
|
3800
|
-
total_kw_period_label =
|
|
3801
|
-
connector_label =
|
|
3802
|
-
account_label =
|
|
3803
|
-
session_kwh_label =
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
4017
|
+
total_kw_all_time_label = label("total_kw_all_time", "Total kW (all time)")
|
|
4018
|
+
total_kw_period_label = label("total_kw_period", "Total kW (period)")
|
|
4019
|
+
connector_label = label("connector", "Connector")
|
|
4020
|
+
account_label = label("account", "Account")
|
|
4021
|
+
session_kwh_label = label("session_kwh", "Session kW")
|
|
4022
|
+
session_start_label = label("session_start", "Session start")
|
|
4023
|
+
session_end_label = label("session_end", "Session end")
|
|
4024
|
+
time_label = label("time", "Time")
|
|
4025
|
+
rfid_label = label("rfid_label", "RFID label")
|
|
4026
|
+
no_sessions_period = label(
|
|
4027
|
+
"no_sessions_period",
|
|
4028
|
+
"No charging sessions recorded for the selected period.",
|
|
4029
|
+
)
|
|
4030
|
+
no_sessions_point = label(
|
|
4031
|
+
"no_sessions_point",
|
|
4032
|
+
"No charging sessions recorded for this charge point.",
|
|
3807
4033
|
)
|
|
3808
|
-
|
|
3809
|
-
"
|
|
4034
|
+
no_structured_data = label(
|
|
4035
|
+
"no_structured_data",
|
|
4036
|
+
"No structured data is available for this report.",
|
|
3810
4037
|
)
|
|
3811
|
-
|
|
3812
|
-
|
|
4038
|
+
report_totals_label = label("report_totals", "Report totals")
|
|
4039
|
+
total_kw_period_line = label(
|
|
4040
|
+
"total_kw_period_line", "Total kW during period"
|
|
4041
|
+
)
|
|
4042
|
+
charge_point_label = label("charge_point", "Charge Point")
|
|
4043
|
+
serial_template = (
|
|
4044
|
+
labels.get("charge_point_serial")
|
|
4045
|
+
if isinstance(labels, dict)
|
|
4046
|
+
else None
|
|
3813
4047
|
)
|
|
3814
|
-
report_totals_label = gettext("Report totals")
|
|
3815
|
-
total_kw_period_line = gettext("Total kW during period")
|
|
3816
4048
|
|
|
3817
4049
|
def format_datetime(value):
|
|
3818
4050
|
if not value:
|
|
@@ -3837,13 +4069,21 @@ class ClientReport(Entity):
|
|
|
3837
4069
|
if index:
|
|
3838
4070
|
story.append(Spacer(1, 0.2 * inch))
|
|
3839
4071
|
|
|
3840
|
-
display_name = evcs.get("display_name") or
|
|
4072
|
+
display_name = evcs.get("display_name") or charge_point_label
|
|
3841
4073
|
serial_number = evcs.get("serial_number")
|
|
3842
4074
|
if serial_number:
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
4075
|
+
if isinstance(serial_template, str):
|
|
4076
|
+
try:
|
|
4077
|
+
header_text = serial_template.format(
|
|
4078
|
+
name=display_name, serial=serial_number
|
|
4079
|
+
)
|
|
4080
|
+
except (KeyError, IndexError, ValueError):
|
|
4081
|
+
header_text = serial_template
|
|
4082
|
+
else:
|
|
4083
|
+
header_text = gettext("%(name)s (Serial: %(serial)s)") % {
|
|
4084
|
+
"name": display_name,
|
|
4085
|
+
"serial": serial_number,
|
|
4086
|
+
}
|
|
3847
4087
|
else:
|
|
3848
4088
|
header_text = display_name
|
|
3849
4089
|
story.append(Paragraph(header_text, subtitle_style))
|
|
@@ -3862,11 +4102,11 @@ class ClientReport(Entity):
|
|
|
3862
4102
|
table_data = [
|
|
3863
4103
|
[
|
|
3864
4104
|
session_kwh_label,
|
|
3865
|
-
|
|
3866
|
-
|
|
4105
|
+
session_start_label,
|
|
4106
|
+
session_end_label,
|
|
3867
4107
|
time_label,
|
|
3868
4108
|
connector_label,
|
|
3869
|
-
|
|
4109
|
+
rfid_label,
|
|
3870
4110
|
account_label,
|
|
3871
4111
|
]
|
|
3872
4112
|
]
|
|
@@ -4128,6 +4368,11 @@ class Product(Entity):
|
|
|
4128
4368
|
return self.name
|
|
4129
4369
|
|
|
4130
4370
|
|
|
4371
|
+
class Meta:
|
|
4372
|
+
verbose_name = _("Product")
|
|
4373
|
+
verbose_name_plural = _("Products")
|
|
4374
|
+
|
|
4375
|
+
|
|
4131
4376
|
class AdminHistory(Entity):
|
|
4132
4377
|
"""Record of recently visited admin changelists for a user."""
|
|
4133
4378
|
|
|
@@ -4737,7 +4982,6 @@ class TodoManager(EntityManager):
|
|
|
4737
4982
|
def get_by_natural_key(self, request: str):
|
|
4738
4983
|
return self.get(request=request)
|
|
4739
4984
|
|
|
4740
|
-
|
|
4741
4985
|
class Todo(Entity):
|
|
4742
4986
|
"""Tasks requested for the Release Manager."""
|
|
4743
4987
|
|
|
@@ -5037,5 +5281,5 @@ class TOTPDeviceSettings(models.Model):
|
|
|
5037
5281
|
is_user_data = models.BooleanField(default=False)
|
|
5038
5282
|
|
|
5039
5283
|
class Meta:
|
|
5040
|
-
verbose_name = _("Authenticator
|
|
5041
|
-
verbose_name_plural = _("Authenticator
|
|
5284
|
+
verbose_name = _("Authenticator Device Setting")
|
|
5285
|
+
verbose_name_plural = _("Authenticator Device Settings")
|
core/system.py
CHANGED
|
@@ -903,6 +903,21 @@ def _parse_runserver_port(command_line: str) -> int | None:
|
|
|
903
903
|
return None
|
|
904
904
|
|
|
905
905
|
|
|
906
|
+
def _configured_backend_port(base_dir: Path) -> int:
|
|
907
|
+
lock_file = base_dir / "locks" / "backend_port.lck"
|
|
908
|
+
try:
|
|
909
|
+
raw = lock_file.read_text().strip()
|
|
910
|
+
except OSError:
|
|
911
|
+
return 8888
|
|
912
|
+
try:
|
|
913
|
+
value = int(raw)
|
|
914
|
+
except (TypeError, ValueError):
|
|
915
|
+
return 8888
|
|
916
|
+
if 1 <= value <= 65535:
|
|
917
|
+
return value
|
|
918
|
+
return 8888
|
|
919
|
+
|
|
920
|
+
|
|
906
921
|
def _detect_runserver_process() -> tuple[bool, int | None]:
|
|
907
922
|
"""Return whether the dev server is running and the port if available."""
|
|
908
923
|
|
|
@@ -932,7 +947,7 @@ def _detect_runserver_process() -> tuple[bool, int | None]:
|
|
|
932
947
|
break
|
|
933
948
|
|
|
934
949
|
if port is None:
|
|
935
|
-
port =
|
|
950
|
+
port = _configured_backend_port(Path(settings.BASE_DIR))
|
|
936
951
|
|
|
937
952
|
return True, port
|
|
938
953
|
|
|
@@ -981,7 +996,7 @@ def _gather_info() -> dict:
|
|
|
981
996
|
raw_mode = ""
|
|
982
997
|
mode = raw_mode.lower() or "internal"
|
|
983
998
|
info["mode"] = mode
|
|
984
|
-
default_port =
|
|
999
|
+
default_port = _configured_backend_port(base_dir)
|
|
985
1000
|
detected_port: int | None = None
|
|
986
1001
|
|
|
987
1002
|
screen_file = lock_dir / "screen_mode.lck"
|