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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
|
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
|
-
"""
|
|
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 = "
|
|
243
|
-
verbose_name_plural = "
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
640
|
-
|
|
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
|
-
|
|
670
|
-
if
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
criteria
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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":
|
|
687
|
-
"from":
|
|
688
|
-
"body":
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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, "")
|