arthexis 0.1.10__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.10.dist-info → arthexis-0.1.11.dist-info}/METADATA +36 -26
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/RECORD +42 -38
- config/context_processors.py +1 -0
- config/settings.py +24 -3
- config/urls.py +5 -4
- core/admin.py +184 -22
- core/apps.py +27 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +270 -31
- core/reference_utils.py +19 -8
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +247 -1
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +105 -3
- core/user_data.py +51 -8
- core/views.py +245 -8
- nodes/admin.py +137 -2
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/models.py +293 -7
- nodes/tests.py +312 -2
- nodes/views.py +14 -0
- ocpp/consumers.py +11 -8
- ocpp/models.py +3 -0
- ocpp/reference_utils.py +42 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +30 -0
- ocpp/views.py +8 -0
- pages/admin.py +9 -1
- pages/context_processors.py +6 -6
- pages/defaults.py +14 -0
- pages/models.py +53 -14
- pages/tests.py +19 -4
- pages/urls.py +3 -0
- pages/views.py +86 -19
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -14,6 +14,7 @@ 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 django.views.decorators.debug import sensitive_variables
|
|
17
18
|
from datetime import time as datetime_time, timedelta
|
|
18
19
|
from django.contrib.contenttypes.models import ContentType
|
|
19
20
|
import hashlib
|
|
@@ -38,6 +39,7 @@ 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
44
|
from .fields import (
|
|
43
45
|
SigilShortAutoField,
|
|
@@ -160,6 +162,25 @@ class Profile(Entity):
|
|
|
160
162
|
return str(owner)
|
|
161
163
|
|
|
162
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
|
+
|
|
163
184
|
class SigilRootManager(EntityManager):
|
|
164
185
|
def get_by_natural_key(self, prefix: str):
|
|
165
186
|
return self.get(prefix=prefix)
|
|
@@ -219,6 +240,13 @@ class InviteLead(Lead):
|
|
|
219
240
|
sent_on = models.DateTimeField(null=True, blank=True)
|
|
220
241
|
error = models.TextField(blank=True)
|
|
221
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
|
+
)
|
|
222
250
|
|
|
223
251
|
class Meta:
|
|
224
252
|
verbose_name = "Invite Lead"
|
|
@@ -303,6 +331,28 @@ class User(Entity, AbstractUser):
|
|
|
303
331
|
def is_system_username(cls, username):
|
|
304
332
|
return bool(username) and username == cls.SYSTEM_USERNAME
|
|
305
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
|
+
|
|
306
356
|
@classmethod
|
|
307
357
|
def is_profile_restricted_username(cls, username):
|
|
308
358
|
return bool(username) and username in cls.PROFILE_RESTRICTED_USERNAMES
|
|
@@ -375,12 +425,16 @@ class User(Entity, AbstractUser):
|
|
|
375
425
|
)
|
|
376
426
|
|
|
377
427
|
def _profile_for(self, profile_cls: Type[Profile], user: "User"):
|
|
378
|
-
|
|
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()
|
|
379
433
|
if profile:
|
|
380
434
|
return profile
|
|
381
435
|
group_ids = list(user.groups.values_list("id", flat=True))
|
|
382
436
|
if group_ids:
|
|
383
|
-
return
|
|
437
|
+
return queryset.filter(group_id__in=group_ids).first()
|
|
384
438
|
return None
|
|
385
439
|
|
|
386
440
|
def get_profile(self, profile_cls: Type[Profile]):
|
|
@@ -434,6 +488,10 @@ class User(Entity, AbstractUser):
|
|
|
434
488
|
def assistant_profile(self):
|
|
435
489
|
return self._direct_profile("AssistantProfile")
|
|
436
490
|
|
|
491
|
+
@property
|
|
492
|
+
def social_profile(self):
|
|
493
|
+
return self._direct_profile("SocialProfile")
|
|
494
|
+
|
|
437
495
|
@property
|
|
438
496
|
def chat_profile(self):
|
|
439
497
|
return self.assistant_profile
|
|
@@ -637,13 +695,45 @@ class EmailInbox(Profile):
|
|
|
637
695
|
except Exception as exc:
|
|
638
696
|
raise ValidationError(str(exc))
|
|
639
697
|
|
|
640
|
-
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
|
+
):
|
|
641
706
|
"""Retrieve up to ``limit`` recent messages matching the filters.
|
|
642
707
|
|
|
643
|
-
Parameters are case-insensitive fragments
|
|
644
|
-
|
|
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.
|
|
645
713
|
"""
|
|
646
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
|
+
|
|
647
737
|
def _get_body(msg):
|
|
648
738
|
if msg.is_multipart():
|
|
649
739
|
for part in msg.walk():
|
|
@@ -670,28 +760,44 @@ class EmailInbox(Profile):
|
|
|
670
760
|
)
|
|
671
761
|
conn.login(self.username, self.password)
|
|
672
762
|
conn.select("INBOX")
|
|
673
|
-
|
|
674
|
-
if
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
criteria
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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:]
|
|
684
778
|
messages = []
|
|
685
779
|
for mid in ids:
|
|
686
780
|
typ, msg_data = conn.fetch(mid, "(RFC822)")
|
|
687
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
|
|
688
791
|
messages.append(
|
|
689
792
|
{
|
|
690
|
-
"subject":
|
|
691
|
-
"from":
|
|
692
|
-
"body":
|
|
793
|
+
"subject": subj_value,
|
|
794
|
+
"from": from_value,
|
|
795
|
+
"body": body_text,
|
|
796
|
+
"date": msg.get("Date", ""),
|
|
693
797
|
}
|
|
694
798
|
)
|
|
799
|
+
if len(messages) >= limit:
|
|
800
|
+
break
|
|
695
801
|
conn.logout()
|
|
696
802
|
return list(reversed(messages))
|
|
697
803
|
|
|
@@ -713,25 +819,137 @@ class EmailInbox(Profile):
|
|
|
713
819
|
subj = msg.get("Subject", "")
|
|
714
820
|
frm = msg.get("From", "")
|
|
715
821
|
body_text = _get_body(msg)
|
|
716
|
-
if
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
+
):
|
|
721
827
|
continue
|
|
722
|
-
messages.append(
|
|
828
|
+
messages.append(
|
|
829
|
+
{
|
|
830
|
+
"subject": subj,
|
|
831
|
+
"from": frm,
|
|
832
|
+
"body": body_text,
|
|
833
|
+
"date": msg.get("Date", ""),
|
|
834
|
+
}
|
|
835
|
+
)
|
|
723
836
|
if len(messages) >= limit:
|
|
724
837
|
break
|
|
725
838
|
conn.quit()
|
|
726
839
|
return messages
|
|
727
840
|
|
|
728
841
|
def __str__(self): # pragma: no cover - simple representation
|
|
729
|
-
|
|
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
|
+
]
|
|
730
943
|
|
|
731
944
|
|
|
732
945
|
class EmailCollector(Entity):
|
|
733
946
|
"""Search an inbox for matching messages and extract data via sigils."""
|
|
734
947
|
|
|
948
|
+
name = models.CharField(
|
|
949
|
+
max_length=255,
|
|
950
|
+
blank=True,
|
|
951
|
+
help_text="Optional label to identify this collector.",
|
|
952
|
+
)
|
|
735
953
|
inbox = models.ForeignKey(
|
|
736
954
|
"EmailInbox",
|
|
737
955
|
related_name="collectors",
|
|
@@ -745,6 +963,10 @@ class EmailCollector(Entity):
|
|
|
745
963
|
blank=True,
|
|
746
964
|
help_text="Pattern with [sigils] to extract values from the body.",
|
|
747
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
|
+
)
|
|
748
970
|
|
|
749
971
|
def _parse_sigils(self, text: str) -> dict[str, str]:
|
|
750
972
|
"""Extract values from ``text`` according to ``fragment`` sigils."""
|
|
@@ -764,16 +986,32 @@ class EmailCollector(Entity):
|
|
|
764
986
|
return {}
|
|
765
987
|
return {k: v.strip() for k, v in match.groupdict().items()}
|
|
766
988
|
|
|
767
|
-
def
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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(
|
|
772
1003
|
subject=self.subject,
|
|
773
1004
|
from_address=self.sender,
|
|
774
1005
|
body=self.body,
|
|
775
1006
|
limit=limit,
|
|
1007
|
+
use_regular_expressions=self.use_regular_expressions,
|
|
776
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)
|
|
777
1015
|
for msg in messages:
|
|
778
1016
|
fp = EmailArtifact.fingerprint_for(
|
|
779
1017
|
msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
|
|
@@ -2123,6 +2361,7 @@ class PackageRelease(Entity):
|
|
|
2123
2361
|
max_length=40, blank=True, default=revision_utils.get_revision, editable=False
|
|
2124
2362
|
)
|
|
2125
2363
|
pypi_url = models.URLField("PyPI URL", blank=True, editable=False)
|
|
2364
|
+
release_on = models.DateTimeField(blank=True, null=True, editable=False)
|
|
2126
2365
|
|
|
2127
2366
|
class Meta:
|
|
2128
2367
|
verbose_name = "Package Release"
|
core/reference_utils.py
CHANGED
|
@@ -41,16 +41,27 @@ def filter_visible_references(
|
|
|
41
41
|
node = None
|
|
42
42
|
|
|
43
43
|
node_role_id = getattr(node, "role_id", None)
|
|
44
|
-
|
|
44
|
+
node_active_feature_ids: set[int] = set()
|
|
45
45
|
if node is not None:
|
|
46
|
-
|
|
47
|
-
if
|
|
46
|
+
assignments_manager = getattr(node, "feature_assignments", None)
|
|
47
|
+
if assignments_manager is not None:
|
|
48
48
|
try:
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
assignments = list(
|
|
50
|
+
assignments_manager.filter(is_deleted=False).select_related(
|
|
51
|
+
"feature"
|
|
52
|
+
)
|
|
51
53
|
)
|
|
52
54
|
except Exception:
|
|
53
|
-
|
|
55
|
+
assignments = []
|
|
56
|
+
for assignment in assignments:
|
|
57
|
+
feature = getattr(assignment, "feature", None)
|
|
58
|
+
if feature is None or getattr(feature, "is_deleted", False):
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
if feature.is_enabled:
|
|
62
|
+
node_active_feature_ids.add(feature.pk)
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
54
65
|
|
|
55
66
|
visible_refs: list["Reference"] = []
|
|
56
67
|
for ref in refs:
|
|
@@ -64,8 +75,8 @@ def filter_visible_references(
|
|
|
64
75
|
allowed = True
|
|
65
76
|
elif (
|
|
66
77
|
required_features
|
|
67
|
-
and
|
|
68
|
-
and
|
|
78
|
+
and node_active_feature_ids
|
|
79
|
+
and node_active_feature_ids.intersection(required_features)
|
|
69
80
|
):
|
|
70
81
|
allowed = True
|
|
71
82
|
elif required_sites and site_id and site_id in required_sites:
|
core/sigil_builder.py
CHANGED
|
@@ -17,7 +17,7 @@ from .sigil_resolver import (
|
|
|
17
17
|
def generate_model_sigils(**kwargs) -> None:
|
|
18
18
|
"""Ensure built-in configuration SigilRoot entries exist."""
|
|
19
19
|
SigilRoot = apps.get_model("core", "SigilRoot")
|
|
20
|
-
for prefix in ["ENV", "SYS"]:
|
|
20
|
+
for prefix in ["ENV", "CONF", "SYS"]:
|
|
21
21
|
# Ensure built-in configuration roots exist without violating the
|
|
22
22
|
# unique ``prefix`` constraint, even if older databases already have
|
|
23
23
|
# entries with a different ``context_type``.
|
|
@@ -40,7 +40,12 @@ def _sigil_builder_view(request):
|
|
|
40
40
|
{
|
|
41
41
|
"prefix": "ENV",
|
|
42
42
|
"url": reverse("admin:environment"),
|
|
43
|
-
"label": _("
|
|
43
|
+
"label": _("Environ"),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"prefix": "CONF",
|
|
47
|
+
"url": reverse("admin:config"),
|
|
48
|
+
"label": _("Config"),
|
|
44
49
|
},
|
|
45
50
|
{
|
|
46
51
|
"prefix": "SYS",
|
core/sigil_resolver.py
CHANGED
|
@@ -11,6 +11,7 @@ from django.core import serializers
|
|
|
11
11
|
from django.db import models
|
|
12
12
|
|
|
13
13
|
from .sigil_context import get_context
|
|
14
|
+
from .system import get_system_sigil_values, resolve_system_namespace_value
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger("core.entity")
|
|
16
17
|
|
|
@@ -150,6 +151,18 @@ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
|
|
|
150
151
|
SigilRoot = apps.get_model("core", "SigilRoot")
|
|
151
152
|
try:
|
|
152
153
|
root = SigilRoot.objects.get(prefix__iexact=lookup_root)
|
|
154
|
+
except SigilRoot.DoesNotExist:
|
|
155
|
+
logger.warning("Unknown sigil root [%s]", lookup_root)
|
|
156
|
+
return _failed_resolution(original_token)
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.exception(
|
|
159
|
+
"Error resolving sigil [%s.%s]",
|
|
160
|
+
lookup_root,
|
|
161
|
+
key_upper or normalized_key or raw_key,
|
|
162
|
+
)
|
|
163
|
+
return _failed_resolution(original_token)
|
|
164
|
+
|
|
165
|
+
try:
|
|
153
166
|
if root.context_type == SigilRoot.Context.CONFIG:
|
|
154
167
|
if not normalized_key:
|
|
155
168
|
return ""
|
|
@@ -176,7 +189,7 @@ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
|
|
|
176
189
|
key_upper or normalized_key or raw_key or "",
|
|
177
190
|
)
|
|
178
191
|
return _failed_resolution(original_token)
|
|
179
|
-
if root.prefix.upper() == "
|
|
192
|
+
if root.prefix.upper() == "CONF":
|
|
180
193
|
for candidate in [normalized_key, key_upper, key_lower]:
|
|
181
194
|
if not candidate:
|
|
182
195
|
continue
|
|
@@ -188,6 +201,26 @@ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
|
|
|
188
201
|
if fallback is not None:
|
|
189
202
|
return fallback
|
|
190
203
|
return ""
|
|
204
|
+
if root.prefix.upper() == "SYS":
|
|
205
|
+
values = get_system_sigil_values()
|
|
206
|
+
candidates = {
|
|
207
|
+
key_upper,
|
|
208
|
+
normalized_key.upper() if normalized_key else None,
|
|
209
|
+
(raw_key or "").upper(),
|
|
210
|
+
}
|
|
211
|
+
for candidate in candidates:
|
|
212
|
+
if not candidate:
|
|
213
|
+
continue
|
|
214
|
+
if candidate in values:
|
|
215
|
+
return values[candidate]
|
|
216
|
+
resolved = resolve_system_namespace_value(candidate)
|
|
217
|
+
if resolved is not None:
|
|
218
|
+
return resolved
|
|
219
|
+
logger.warning(
|
|
220
|
+
"Missing system information for sigil [SYS.%s]",
|
|
221
|
+
key_upper or normalized_key or raw_key or "",
|
|
222
|
+
)
|
|
223
|
+
return _failed_resolution(original_token)
|
|
191
224
|
elif root.context_type == SigilRoot.Context.ENTITY:
|
|
192
225
|
model = root.content_type.model_class() if root.content_type else None
|
|
193
226
|
instance = None
|
|
@@ -243,15 +276,13 @@ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
|
|
|
243
276
|
return _failed_resolution(original_token)
|
|
244
277
|
return serializers.serialize("json", [instance])
|
|
245
278
|
return _failed_resolution(original_token)
|
|
246
|
-
except SigilRoot.DoesNotExist:
|
|
247
|
-
logger.warning("Unknown sigil root [%s]", lookup_root)
|
|
248
279
|
except Exception:
|
|
249
280
|
logger.exception(
|
|
250
281
|
"Error resolving sigil [%s.%s]",
|
|
251
282
|
lookup_root,
|
|
252
283
|
key_upper or normalized_key or raw_key,
|
|
253
284
|
)
|
|
254
|
-
|
|
285
|
+
return _failed_resolution(original_token)
|
|
255
286
|
|
|
256
287
|
|
|
257
288
|
def resolve_sigils(text: str, current: Optional[models.Model] = None) -> str:
|