arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/models.py CHANGED
@@ -9,12 +9,13 @@ from django.db.models.functions import Lower
9
9
  from django.conf import settings
10
10
  from django.contrib.auth import get_user_model
11
11
  from django.utils.translation import gettext_lazy as _
12
- from django.core.validators import RegexValidator
12
+ from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
13
13
  from django.core.exceptions import ValidationError
14
14
  from django.apps import apps
15
15
  from django.db.models.signals import m2m_changed, post_delete, post_save
16
16
  from django.dispatch import receiver
17
- from datetime import timedelta
17
+ from django.views.decorators.debug import sensitive_variables
18
+ from datetime import time as datetime_time, timedelta
18
19
  from django.contrib.contenttypes.models import ContentType
19
20
  import hashlib
20
21
  import os
@@ -38,8 +39,13 @@ xmlrpc_client = defused_xmlrpc.xmlrpc_client
38
39
 
39
40
  from .entity import Entity, EntityUserManager, EntityManager
40
41
  from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
42
+ from . import temp_passwords
41
43
  from . import user_data # noqa: F401 - ensure signal registration
42
- from .fields import SigilShortAutoField
44
+ from .fields import (
45
+ SigilShortAutoField,
46
+ ConditionTextField,
47
+ ConditionCheckResult,
48
+ )
43
49
 
44
50
 
45
51
  class SecurityGroup(Group):
@@ -156,6 +162,25 @@ class Profile(Entity):
156
162
  return str(owner)
157
163
 
158
164
 
165
+ _SOCIAL_DOMAIN_PATTERN = re.compile(
166
+ r"^(?=.{1,253}\Z)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$"
167
+ )
168
+
169
+
170
+ social_domain_validator = RegexValidator(
171
+ regex=_SOCIAL_DOMAIN_PATTERN,
172
+ message=_("Enter a valid domain name such as example.com."),
173
+ code="invalid",
174
+ )
175
+
176
+
177
+ social_did_validator = RegexValidator(
178
+ regex=r"^(|did:[a-z0-9]+:[A-Za-z0-9.\-_:]+)$",
179
+ message=_("Enter a valid DID such as did:plc:1234abcd."),
180
+ code="invalid",
181
+ )
182
+
183
+
159
184
  class SigilRootManager(EntityManager):
160
185
  def get_by_natural_key(self, prefix: str):
161
186
  return self.get(prefix=prefix)
@@ -215,6 +240,13 @@ class InviteLead(Lead):
215
240
  sent_on = models.DateTimeField(null=True, blank=True)
216
241
  error = models.TextField(blank=True)
217
242
  mac_address = models.CharField(max_length=17, blank=True)
243
+ sent_via_outbox = models.ForeignKey(
244
+ "nodes.EmailOutbox",
245
+ null=True,
246
+ blank=True,
247
+ on_delete=models.SET_NULL,
248
+ related_name="invite_leads",
249
+ )
218
250
 
219
251
  class Meta:
220
252
  verbose_name = "Invite Lead"
@@ -225,7 +257,7 @@ class InviteLead(Lead):
225
257
 
226
258
 
227
259
  class PublicWifiAccess(Entity):
228
- """Allow public Wi-Fi clients onto the wider internet."""
260
+ """Represent a Wi-Fi lease granted to a client for internet access."""
229
261
 
230
262
  user = models.ForeignKey(
231
263
  settings.AUTH_USER_MODEL,
@@ -239,8 +271,8 @@ class PublicWifiAccess(Entity):
239
271
 
240
272
  class Meta:
241
273
  unique_together = ("user", "mac_address")
242
- verbose_name = "Public Wi-Fi Access"
243
- verbose_name_plural = "Public Wi-Fi Access"
274
+ verbose_name = "Wi-Fi Lease"
275
+ verbose_name_plural = "Wi-Fi Leases"
244
276
 
245
277
  def __str__(self) -> str: # pragma: no cover - simple representation
246
278
  return f"{self.user} -> {self.mac_address}"
@@ -265,7 +297,7 @@ def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
265
297
  class User(Entity, AbstractUser):
266
298
  SYSTEM_USERNAME = "arthexis"
267
299
  ADMIN_USERNAME = "admin"
268
- PROFILE_RESTRICTED_USERNAMES = frozenset({SYSTEM_USERNAME, ADMIN_USERNAME})
300
+ PROFILE_RESTRICTED_USERNAMES = frozenset()
269
301
 
270
302
  objects = EntityUserManager()
271
303
  all_objects = DjangoUserManager()
@@ -299,6 +331,28 @@ class User(Entity, AbstractUser):
299
331
  def is_system_username(cls, username):
300
332
  return bool(username) and username == cls.SYSTEM_USERNAME
301
333
 
334
+ @sensitive_variables("raw_password")
335
+ def set_password(self, raw_password):
336
+ result = super().set_password(raw_password)
337
+ temp_passwords.discard_temp_password(self.username)
338
+ return result
339
+
340
+ @sensitive_variables("raw_password")
341
+ def check_password(self, raw_password):
342
+ if super().check_password(raw_password):
343
+ return True
344
+ if raw_password is None:
345
+ return False
346
+ entry = temp_passwords.load_temp_password(self.username)
347
+ if entry is None:
348
+ return False
349
+ if entry.is_expired:
350
+ temp_passwords.discard_temp_password(self.username)
351
+ return False
352
+ if not entry.allow_change:
353
+ return False
354
+ return entry.check_password(raw_password)
355
+
302
356
  @classmethod
303
357
  def is_profile_restricted_username(cls, username):
304
358
  return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
@@ -371,12 +425,16 @@ class User(Entity, AbstractUser):
371
425
  )
372
426
 
373
427
  def _profile_for(self, profile_cls: Type[Profile], user: "User"):
374
- profile = profile_cls.objects.filter(user=user).first()
428
+ queryset = profile_cls.objects.all()
429
+ if hasattr(profile_cls, "is_enabled"):
430
+ queryset = queryset.filter(is_enabled=True)
431
+
432
+ profile = queryset.filter(user=user).first()
375
433
  if profile:
376
434
  return profile
377
435
  group_ids = list(user.groups.values_list("id", flat=True))
378
436
  if group_ids:
379
- return profile_cls.objects.filter(group_id__in=group_ids).first()
437
+ return queryset.filter(group_id__in=group_ids).first()
380
438
  return None
381
439
 
382
440
  def get_profile(self, profile_cls: Type[Profile]):
@@ -430,6 +488,10 @@ class User(Entity, AbstractUser):
430
488
  def assistant_profile(self):
431
489
  return self._direct_profile("AssistantProfile")
432
490
 
491
+ @property
492
+ def social_profile(self):
493
+ return self._direct_profile("SocialProfile")
494
+
433
495
  @property
434
496
  def chat_profile(self):
435
497
  return self.assistant_profile
@@ -633,13 +695,45 @@ class EmailInbox(Profile):
633
695
  except Exception as exc:
634
696
  raise ValidationError(str(exc))
635
697
 
636
- def search_messages(self, subject="", from_address="", body="", limit: int = 10):
698
+ def search_messages(
699
+ self,
700
+ subject="",
701
+ from_address="",
702
+ body="",
703
+ limit: int = 10,
704
+ use_regular_expressions: bool = False,
705
+ ):
637
706
  """Retrieve up to ``limit`` recent messages matching the filters.
638
707
 
639
- Parameters are case-insensitive fragments. Results are returned as a list
640
- of dictionaries with ``subject``, ``from`` and ``body`` keys.
708
+ Parameters are case-insensitive fragments by default. When
709
+ ``use_regular_expressions`` is ``True`` the filters are treated as regular
710
+ expressions using case-insensitive matching. Results are returned as a
711
+ list of dictionaries with ``subject``, ``from``, ``body`` and ``date``
712
+ keys.
641
713
  """
642
714
 
715
+ def _compile(pattern: str | None):
716
+ if not pattern:
717
+ return None
718
+ try:
719
+ return re.compile(pattern, re.IGNORECASE)
720
+ except re.error as exc:
721
+ raise ValidationError(str(exc))
722
+
723
+ subject_regex = sender_regex = body_regex = None
724
+ if use_regular_expressions:
725
+ subject_regex = _compile(subject)
726
+ sender_regex = _compile(from_address)
727
+ body_regex = _compile(body)
728
+
729
+ def _matches(value: str, needle: str, regex):
730
+ value = value or ""
731
+ if regex is not None:
732
+ return bool(regex.search(value))
733
+ if not needle:
734
+ return True
735
+ return needle.lower() in value.lower()
736
+
643
737
  def _get_body(msg):
644
738
  if msg.is_multipart():
645
739
  for part in msg.walk():
@@ -666,28 +760,44 @@ class EmailInbox(Profile):
666
760
  )
667
761
  conn.login(self.username, self.password)
668
762
  conn.select("INBOX")
669
- criteria = []
670
- if subject:
671
- criteria.extend(["SUBJECT", f'"{subject}"'])
672
- if from_address:
673
- criteria.extend(["FROM", f'"{from_address}"'])
674
- if body:
675
- criteria.extend(["TEXT", f'"{body}"'])
676
- if not criteria:
677
- criteria = ["ALL"]
678
- typ, data = conn.search(None, *criteria)
679
- ids = data[0].split()[-limit:]
763
+ fetch_limit = limit if not use_regular_expressions else max(limit * 5, limit)
764
+ if use_regular_expressions:
765
+ typ, data = conn.search(None, "ALL")
766
+ else:
767
+ criteria = []
768
+ if subject:
769
+ criteria.extend(["SUBJECT", f'"{subject}"'])
770
+ if from_address:
771
+ criteria.extend(["FROM", f'"{from_address}"'])
772
+ if body:
773
+ criteria.extend(["TEXT", f'"{body}"'])
774
+ if not criteria:
775
+ criteria = ["ALL"]
776
+ typ, data = conn.search(None, *criteria)
777
+ ids = data[0].split()[-fetch_limit:]
680
778
  messages = []
681
779
  for mid in ids:
682
780
  typ, msg_data = conn.fetch(mid, "(RFC822)")
683
781
  msg = email.message_from_bytes(msg_data[0][1])
782
+ body_text = _get_body(msg)
783
+ subj_value = msg.get("Subject", "")
784
+ from_value = msg.get("From", "")
785
+ if not (
786
+ _matches(subj_value, subject, subject_regex)
787
+ and _matches(from_value, from_address, sender_regex)
788
+ and _matches(body_text, body, body_regex)
789
+ ):
790
+ continue
684
791
  messages.append(
685
792
  {
686
- "subject": msg.get("Subject", ""),
687
- "from": msg.get("From", ""),
688
- "body": _get_body(msg),
793
+ "subject": subj_value,
794
+ "from": from_value,
795
+ "body": body_text,
796
+ "date": msg.get("Date", ""),
689
797
  }
690
798
  )
799
+ if len(messages) >= limit:
800
+ break
691
801
  conn.logout()
692
802
  return list(reversed(messages))
693
803
 
@@ -709,25 +819,137 @@ class EmailInbox(Profile):
709
819
  subj = msg.get("Subject", "")
710
820
  frm = msg.get("From", "")
711
821
  body_text = _get_body(msg)
712
- if subject and subject.lower() not in subj.lower():
713
- continue
714
- if from_address and from_address.lower() not in frm.lower():
715
- continue
716
- if body and body.lower() not in body_text.lower():
822
+ if not (
823
+ _matches(subj, subject, subject_regex)
824
+ and _matches(frm, from_address, sender_regex)
825
+ and _matches(body_text, body, body_regex)
826
+ ):
717
827
  continue
718
- messages.append({"subject": subj, "from": frm, "body": body_text})
828
+ messages.append(
829
+ {
830
+ "subject": subj,
831
+ "from": frm,
832
+ "body": body_text,
833
+ "date": msg.get("Date", ""),
834
+ }
835
+ )
719
836
  if len(messages) >= limit:
720
837
  break
721
838
  conn.quit()
722
839
  return messages
723
840
 
724
841
  def __str__(self): # pragma: no cover - simple representation
725
- return f"{self.username}@{self.host}"
842
+ username = (self.username or "").strip()
843
+ host = (self.host or "").strip()
844
+
845
+ if username:
846
+ if "@" in username:
847
+ return username
848
+ if host:
849
+ return f"{username}@{host}"
850
+ return username
851
+
852
+ if host:
853
+ return host
854
+
855
+ owner = self.owner_display()
856
+ if owner:
857
+ return owner
858
+
859
+ return super().__str__()
860
+
861
+
862
+ class SocialProfile(Profile):
863
+ """Store configuration required to link social accounts such as Bluesky."""
864
+
865
+ class Network(models.TextChoices):
866
+ BLUESKY = "bluesky", _("Bluesky")
867
+
868
+ profile_fields = ("handle", "domain", "did")
869
+
870
+ network = models.CharField(
871
+ max_length=32,
872
+ choices=Network.choices,
873
+ default=Network.BLUESKY,
874
+ help_text=_(
875
+ "Select the social network you want to connect. Only Bluesky is supported at the moment."
876
+ ),
877
+ )
878
+ handle = models.CharField(
879
+ max_length=253,
880
+ help_text=_(
881
+ "Bluesky handle that should resolve to Arthexis. Use the verified domain (for example arthexis.com)."
882
+ ),
883
+ validators=[social_domain_validator],
884
+ )
885
+ domain = models.CharField(
886
+ max_length=253,
887
+ help_text=_(
888
+ "Domain that hosts the Bluesky verification. Publish a _atproto TXT record or a /.well-known/atproto-did file with the DID below."
889
+ ),
890
+ validators=[social_domain_validator],
891
+ )
892
+ did = models.CharField(
893
+ max_length=255,
894
+ blank=True,
895
+ help_text=_(
896
+ "Optional DID that Bluesky assigns once the domain is linked (for example did:plc:1234abcd)."
897
+ ),
898
+ validators=[social_did_validator],
899
+ )
900
+
901
+ def clean(self):
902
+ super().clean()
903
+ if self.network == self.Network.BLUESKY:
904
+ errors = {}
905
+ if not self.handle:
906
+ errors["handle"] = _("Provide the handle that should point to this domain.")
907
+ if not self.domain:
908
+ errors["domain"] = _("A verified domain is required for Bluesky handles.")
909
+ if errors:
910
+ raise ValidationError(errors)
911
+
912
+ def save(self, *args, **kwargs):
913
+ if self.handle:
914
+ self.handle = self.handle.strip().lower()
915
+ if self.domain:
916
+ self.domain = self.domain.strip().lower()
917
+ super().save(*args, **kwargs)
918
+
919
+ def __str__(self): # pragma: no cover - simple representation
920
+ handle = self.handle or self.domain
921
+ label = f"{self.get_network_display()} ({handle})" if handle else self.get_network_display()
922
+ owner = self.owner_display()
923
+ return f"{owner} – {label}" if owner else label
924
+
925
+ class Meta:
926
+ verbose_name = _("Social Identity")
927
+ verbose_name_plural = _("Social Identities")
928
+ constraints = [
929
+ models.UniqueConstraint(
930
+ fields=["network", "handle"], name="socialprofile_network_handle"
931
+ ),
932
+ models.UniqueConstraint(
933
+ fields=["network", "domain"], name="socialprofile_network_domain"
934
+ ),
935
+ models.CheckConstraint(
936
+ check=(
937
+ (Q(user__isnull=False) & Q(group__isnull=True))
938
+ | (Q(user__isnull=True) & Q(group__isnull=False))
939
+ ),
940
+ name="socialprofile_requires_owner",
941
+ ),
942
+ ]
726
943
 
727
944
 
728
945
  class EmailCollector(Entity):
729
946
  """Search an inbox for matching messages and extract data via sigils."""
730
947
 
948
+ name = models.CharField(
949
+ max_length=255,
950
+ blank=True,
951
+ help_text="Optional label to identify this collector.",
952
+ )
731
953
  inbox = models.ForeignKey(
732
954
  "EmailInbox",
733
955
  related_name="collectors",
@@ -741,6 +963,10 @@ class EmailCollector(Entity):
741
963
  blank=True,
742
964
  help_text="Pattern with [sigils] to extract values from the body.",
743
965
  )
966
+ use_regular_expressions = models.BooleanField(
967
+ default=False,
968
+ help_text="Treat subject, sender and body filters as regular expressions (case-insensitive).",
969
+ )
744
970
 
745
971
  def _parse_sigils(self, text: str) -> dict[str, str]:
746
972
  """Extract values from ``text`` according to ``fragment`` sigils."""
@@ -760,16 +986,32 @@ class EmailCollector(Entity):
760
986
  return {}
761
987
  return {k: v.strip() for k, v in match.groupdict().items()}
762
988
 
763
- def collect(self, limit: int = 10) -> None:
764
- """Poll the inbox and store new artifacts until an existing one is found."""
765
- from .models import EmailArtifact
766
-
767
- messages = self.inbox.search_messages(
989
+ def __str__(self): # pragma: no cover - simple representation
990
+ if self.name:
991
+ return self.name
992
+ parts = []
993
+ if self.subject:
994
+ parts.append(self.subject)
995
+ if self.sender:
996
+ parts.append(self.sender)
997
+ if not parts:
998
+ parts.append(str(self.inbox))
999
+ return " – ".join(parts)
1000
+
1001
+ def search_messages(self, limit: int = 10):
1002
+ return self.inbox.search_messages(
768
1003
  subject=self.subject,
769
1004
  from_address=self.sender,
770
1005
  body=self.body,
771
1006
  limit=limit,
1007
+ use_regular_expressions=self.use_regular_expressions,
772
1008
  )
1009
+
1010
+ def collect(self, limit: int = 10) -> None:
1011
+ """Poll the inbox and store new artifacts until an existing one is found."""
1012
+ from .models import EmailArtifact
1013
+
1014
+ messages = self.search_messages(limit=limit)
773
1015
  for msg in messages:
774
1016
  fp = EmailArtifact.fingerprint_for(
775
1017
  msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
@@ -844,6 +1086,9 @@ class Reference(Entity):
844
1086
  include_in_footer = models.BooleanField(
845
1087
  default=False, verbose_name="Include in Footer"
846
1088
  )
1089
+ show_in_header = models.BooleanField(
1090
+ default=False, verbose_name="Show in Header"
1091
+ )
847
1092
  FOOTER_PUBLIC = "public"
848
1093
  FOOTER_PRIVATE = "private"
849
1094
  FOOTER_STAFF = "staff"
@@ -1046,6 +1291,195 @@ class RFID(Entity):
1046
1291
  db_table = "core_rfid"
1047
1292
 
1048
1293
 
1294
+ class EnergyTariffManager(EntityManager):
1295
+ def get_by_natural_key(
1296
+ self,
1297
+ year: int,
1298
+ season: str,
1299
+ zone: str,
1300
+ contract_type: str,
1301
+ period: str,
1302
+ unit: str,
1303
+ start_time,
1304
+ end_time,
1305
+ ):
1306
+ if isinstance(start_time, str):
1307
+ start_time = datetime_time.fromisoformat(start_time)
1308
+ if isinstance(end_time, str):
1309
+ end_time = datetime_time.fromisoformat(end_time)
1310
+ return self.get(
1311
+ year=year,
1312
+ season=season,
1313
+ zone=zone,
1314
+ contract_type=contract_type,
1315
+ period=period,
1316
+ unit=unit,
1317
+ start_time=start_time,
1318
+ end_time=end_time,
1319
+ )
1320
+
1321
+
1322
+ class EnergyTariff(Entity):
1323
+ class Zone(models.TextChoices):
1324
+ ONE = "1", _("Zone 1")
1325
+ ONE_A = "1A", _("Zone 1A")
1326
+ ONE_B = "1B", _("Zone 1B")
1327
+ ONE_C = "1C", _("Zone 1C")
1328
+ ONE_D = "1D", _("Zone 1D")
1329
+ ONE_E = "1E", _("Zone 1E")
1330
+ ONE_F = "1F", _("Zone 1F")
1331
+
1332
+ class Season(models.TextChoices):
1333
+ ANNUAL = "annual", _("All year")
1334
+ SUMMER = "summer", _("Summer season")
1335
+ NON_SUMMER = "non_summer", _("Non-summer season")
1336
+
1337
+ class Period(models.TextChoices):
1338
+ FLAT = "flat", _("Flat rate")
1339
+ BASIC = "basic", _("Basic block")
1340
+ INTERMEDIATE_1 = "intermediate_1", _("Intermediate block 1")
1341
+ INTERMEDIATE_2 = "intermediate_2", _("Intermediate block 2")
1342
+ EXCESS = "excess", _("Excess consumption")
1343
+ BASE = "base", _("Base")
1344
+ INTERMEDIATE = "intermediate", _("Intermediate")
1345
+ PEAK = "peak", _("Peak")
1346
+ CRITICAL_PEAK = "critical_peak", _("Critical peak")
1347
+ DEMAND = "demand", _("Demand charge")
1348
+ CAPACITY = "capacity", _("Capacity charge")
1349
+ DISTRIBUTION = "distribution", _("Distribution charge")
1350
+ FIXED = "fixed", _("Fixed charge")
1351
+
1352
+ class ContractType(models.TextChoices):
1353
+ DOMESTIC = "domestic", _("Domestic service (Tarifa 1)")
1354
+ DAC = "dac", _("High consumption domestic (DAC)")
1355
+ PDBT = "pdbt", _("General service low demand (PDBT)")
1356
+ GDBT = "gdbt", _("General service high demand (GDBT)")
1357
+ GDMTO = "gdmto", _("General distribution medium tension (GDMTO)")
1358
+ GDMTH = "gdmth", _("General distribution medium tension hourly (GDMTH)")
1359
+
1360
+ class Unit(models.TextChoices):
1361
+ KWH = "kwh", _("Kilowatt-hour")
1362
+ KW = "kw", _("Kilowatt")
1363
+ MONTH = "month", _("Monthly charge")
1364
+
1365
+ year = models.PositiveIntegerField(
1366
+ validators=[MinValueValidator(2000)],
1367
+ help_text=_("Calendar year when the tariff applies."),
1368
+ )
1369
+ season = models.CharField(
1370
+ max_length=16,
1371
+ choices=Season.choices,
1372
+ default=Season.ANNUAL,
1373
+ help_text=_("Season or applicability window defined by CFE."),
1374
+ )
1375
+ zone = models.CharField(
1376
+ max_length=3,
1377
+ choices=Zone.choices,
1378
+ help_text=_("CFE climate zone associated with the tariff."),
1379
+ )
1380
+ contract_type = models.CharField(
1381
+ max_length=16,
1382
+ choices=ContractType.choices,
1383
+ help_text=_("Type of service contract regulated by CFE."),
1384
+ )
1385
+ period = models.CharField(
1386
+ max_length=32,
1387
+ choices=Period.choices,
1388
+ help_text=_("Tariff block, demand component, or time-of-use period."),
1389
+ )
1390
+ unit = models.CharField(
1391
+ max_length=16,
1392
+ choices=Unit.choices,
1393
+ default=Unit.KWH,
1394
+ help_text=_("Measurement unit for the tariff charge."),
1395
+ )
1396
+ start_time = models.TimeField(
1397
+ help_text=_("Start time for the tariff's applicability window."),
1398
+ )
1399
+ end_time = models.TimeField(
1400
+ help_text=_("End time for the tariff's applicability window."),
1401
+ )
1402
+ price_mxn = models.DecimalField(
1403
+ max_digits=10,
1404
+ decimal_places=4,
1405
+ help_text=_("Customer price per unit in MXN."),
1406
+ )
1407
+ cost_mxn = models.DecimalField(
1408
+ max_digits=10,
1409
+ decimal_places=4,
1410
+ help_text=_("Provider cost per unit in MXN."),
1411
+ )
1412
+ notes = models.TextField(
1413
+ blank=True,
1414
+ default="",
1415
+ help_text=_("Context or special billing conditions published by CFE."),
1416
+ )
1417
+
1418
+ objects = EnergyTariffManager()
1419
+
1420
+ class Meta:
1421
+ verbose_name = _("Energy Tariff")
1422
+ verbose_name_plural = _("Energy Tariffs")
1423
+ ordering = (
1424
+ "-year",
1425
+ "season",
1426
+ "zone",
1427
+ "contract_type",
1428
+ "period",
1429
+ "start_time",
1430
+ )
1431
+ constraints = [
1432
+ models.UniqueConstraint(
1433
+ fields=[
1434
+ "year",
1435
+ "season",
1436
+ "zone",
1437
+ "contract_type",
1438
+ "period",
1439
+ "unit",
1440
+ "start_time",
1441
+ "end_time",
1442
+ ],
1443
+ name="uniq_energy_tariff_schedule",
1444
+ )
1445
+ ]
1446
+ indexes = [
1447
+ models.Index(
1448
+ fields=["year", "season", "zone", "contract_type"],
1449
+ name="energy_tariff_scope_idx",
1450
+ )
1451
+ ]
1452
+
1453
+ def clean(self):
1454
+ super().clean()
1455
+ if self.start_time >= self.end_time:
1456
+ raise ValidationError(
1457
+ {"end_time": _("End time must be after the start time.")}
1458
+ )
1459
+
1460
+ def __str__(self): # pragma: no cover - simple representation
1461
+ return _("%(contract)s %(zone)s %(season)s %(year)s (%(period)s)") % {
1462
+ "contract": self.get_contract_type_display(),
1463
+ "zone": self.zone,
1464
+ "season": self.get_season_display(),
1465
+ "year": self.year,
1466
+ "period": self.get_period_display(),
1467
+ }
1468
+
1469
+ def natural_key(self): # pragma: no cover - simple representation
1470
+ return (
1471
+ self.year,
1472
+ self.season,
1473
+ self.zone,
1474
+ self.contract_type,
1475
+ self.period,
1476
+ self.unit,
1477
+ self.start_time.isoformat(),
1478
+ self.end_time.isoformat(),
1479
+ )
1480
+
1481
+ natural_key.dependencies = [] # type: ignore[attr-defined]
1482
+
1049
1483
  class EnergyAccount(Entity):
1050
1484
  """Track kW energy credits for a user."""
1051
1485
 
@@ -1927,6 +2361,7 @@ class PackageRelease(Entity):
1927
2361
  max_length=40, blank=True, default=revision_utils.get_revision, editable=False
1928
2362
  )
1929
2363
  pypi_url = models.URLField("PyPI URL", blank=True, editable=False)
2364
+ release_on = models.DateTimeField(blank=True, null=True, editable=False)
1930
2365
 
1931
2366
  class Meta:
1932
2367
  verbose_name = "Package Release"
@@ -2162,6 +2597,7 @@ class Todo(Entity):
2162
2597
  )
2163
2598
  request_details = models.TextField(blank=True, default="")
2164
2599
  done_on = models.DateTimeField(null=True, blank=True)
2600
+ on_done_condition = ConditionTextField(blank=True, default="")
2165
2601
 
2166
2602
  objects = TodoManager()
2167
2603
 
@@ -2193,3 +2629,11 @@ class Todo(Entity):
2193
2629
  return (self.request,)
2194
2630
 
2195
2631
  natural_key.dependencies = []
2632
+
2633
+ def check_on_done_condition(self) -> ConditionCheckResult:
2634
+ """Evaluate the ``on_done_condition`` field for this TODO."""
2635
+
2636
+ field = self._meta.get_field("on_done_condition")
2637
+ if isinstance(field, ConditionTextField):
2638
+ return field.evaluate(self)
2639
+ return ConditionCheckResult(True, "")