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.

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 = _("Odoo Employee")
737
- verbose_name_plural = _("Odoo Employees")
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 OpenPay gateway credentials for a user or security group."""
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
- merchant_id = SigilShortAutoField(max_length=100)
764
- private_key = SigilShortAutoField(max_length=255)
765
- public_key = SigilShortAutoField(max_length=255)
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 verify(self):
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
- return f"{owner} @ {self.merchant_id}" if owner else self.merchant_id
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 = _("OpenPay Merchant")
895
- verbose_name_plural = _("OpenPay Merchants")
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
- name = f"client_report_schedule_{self.pk}"
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 Charger, Transaction
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").prefetch_related("meter_values")
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
- readings_manager = getattr(tx, "meter_values", None)
3537
- readings = []
3538
- if readings_manager is not None:
3539
- readings = [
3540
- reading
3541
- for reading in readings_manager.all()
3542
- if getattr(reading, "energy", None) is not None
3543
- ]
3544
- if readings:
3545
- readings.sort(key=lambda item: item.timestamp)
3546
- if start_value is None:
3547
- start_value = float(readings[0].energy or 0)
3548
- if end_value is None:
3549
- end_value = float(readings[-1].energy or 0)
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
- report_title = self.normalize_title(self.title) or gettext(
3780
- "Consumer Report"
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
- story.append(
3791
- Paragraph(
3792
- gettext("Period: %(start)s to %(end)s")
3793
- % {"start": start_display, "end": end_display},
3794
- emphasis_style,
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 = gettext("Total kW (all time)")
3800
- total_kw_period_label = gettext("Total kW (period)")
3801
- connector_label = gettext("Connector")
3802
- account_label = gettext("Account")
3803
- session_kwh_label = gettext("Session kW")
3804
- time_label = gettext("Time")
3805
- no_sessions_period = gettext(
3806
- "No charging sessions recorded for the selected period."
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
- no_sessions_point = gettext(
3809
- "No charging sessions recorded for this charge point."
4034
+ no_structured_data = label(
4035
+ "no_structured_data",
4036
+ "No structured data is available for this report.",
3810
4037
  )
3811
- no_structured_data = gettext(
3812
- "No structured data is available for this report."
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 gettext("Charge Point")
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
- header_text = gettext("%(name)s (Serial: %(serial)s)") % {
3844
- "name": display_name,
3845
- "serial": serial_number,
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
- gettext("Session start"),
3866
- gettext("Session end"),
4105
+ session_start_label,
4106
+ session_end_label,
3867
4107
  time_label,
3868
4108
  connector_label,
3869
- gettext("RFID label"),
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 device settings")
5041
- verbose_name_plural = _("Authenticator device settings")
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 = 8000
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 = 8000 if mode == "public" else 8888
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"