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.

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
- 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()
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 profile_cls.objects.filter(group_id__in=group_ids).first()
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(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
+ ):
641
706
  """Retrieve up to ``limit`` recent messages matching the filters.
642
707
 
643
- Parameters are case-insensitive fragments. Results are returned as a list
644
- 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.
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
- criteria = []
674
- if subject:
675
- criteria.extend(["SUBJECT", f'"{subject}"'])
676
- if from_address:
677
- criteria.extend(["FROM", f'"{from_address}"'])
678
- if body:
679
- criteria.extend(["TEXT", f'"{body}"'])
680
- if not criteria:
681
- criteria = ["ALL"]
682
- typ, data = conn.search(None, *criteria)
683
- 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:]
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": msg.get("Subject", ""),
691
- "from": msg.get("From", ""),
692
- "body": _get_body(msg),
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 subject and subject.lower() not in subj.lower():
717
- continue
718
- if from_address and from_address.lower() not in frm.lower():
719
- continue
720
- 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
+ ):
721
827
  continue
722
- 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
+ )
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
- 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
+ ]
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 collect(self, limit: int = 10) -> None:
768
- """Poll the inbox and store new artifacts until an existing one is found."""
769
- from .models import EmailArtifact
770
-
771
- 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(
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
- node_feature_ids: set[int] = set()
44
+ node_active_feature_ids: set[int] = set()
45
45
  if node is not None:
46
- features_manager = getattr(node, "features", None)
47
- if features_manager is not None:
46
+ assignments_manager = getattr(node, "feature_assignments", None)
47
+ if assignments_manager is not None:
48
48
  try:
49
- node_feature_ids = set(
50
- features_manager.values_list("pk", flat=True)
49
+ assignments = list(
50
+ assignments_manager.filter(is_deleted=False).select_related(
51
+ "feature"
52
+ )
51
53
  )
52
54
  except Exception:
53
- node_feature_ids = set()
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 node_feature_ids
68
- and node_feature_ids.intersection(required_features)
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": _("Environment"),
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() == "SYS":
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
- return _failed_resolution(original_token)
285
+ return _failed_resolution(original_token)
255
286
 
256
287
 
257
288
  def resolve_sigils(text: str, current: Optional[models.Model] = None) -> str: