arthexis 0.1.10__py3-none-any.whl → 0.1.12__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 (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
core/apps.py CHANGED
@@ -21,6 +21,7 @@ class CoreConfig(AppConfig):
21
21
  from pathlib import Path
22
22
 
23
23
  from django.conf import settings
24
+ from django.core.exceptions import ObjectDoesNotExist
24
25
  from django.contrib.auth import get_user_model
25
26
  from django.db.models.signals import post_migrate
26
27
  from django.core.signals import got_request_exception
@@ -39,6 +40,26 @@ class CoreConfig(AppConfig):
39
40
  )
40
41
  from .admin_history import patch_admin_history
41
42
 
43
+ from django_otp.plugins.otp_totp.models import TOTPDevice as OTP_TOTPDevice
44
+
45
+ if not hasattr(
46
+ OTP_TOTPDevice._read_str_from_settings, "_core_totp_issuer_patch"
47
+ ):
48
+ original_read_str = OTP_TOTPDevice._read_str_from_settings
49
+
50
+ def _core_totp_read_str(self, key):
51
+ if key == "OTP_TOTP_ISSUER":
52
+ try:
53
+ settings_obj = self.custom_settings
54
+ except ObjectDoesNotExist:
55
+ settings_obj = None
56
+ if settings_obj and settings_obj.issuer:
57
+ return settings_obj.issuer
58
+ return original_read_str(self, key)
59
+
60
+ _core_totp_read_str._core_totp_issuer_patch = True
61
+ OTP_TOTPDevice._read_str_from_settings = _core_totp_read_str
62
+
42
63
  def create_default_arthexis(**kwargs):
43
64
  User = get_user_model()
44
65
  if not User.all_objects.exists():
@@ -104,8 +125,11 @@ class CoreConfig(AppConfig):
104
125
 
105
126
  lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
127
 
128
+ from django.db.backends.signals import connection_created
129
+
107
130
  if lock.exists():
108
131
  from .auto_upgrade import ensure_auto_upgrade_periodic_task
132
+ from django.db import DEFAULT_DB_ALIAS, connections
109
133
 
110
134
  def ensure_email_collector_task(**kwargs):
111
135
  try: # pragma: no cover - optional dependency
@@ -133,9 +157,31 @@ class CoreConfig(AppConfig):
133
157
 
134
158
  post_migrate.connect(ensure_email_collector_task, sender=self)
135
159
  post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
136
- ensure_auto_upgrade_periodic_task()
137
160
 
138
- from django.db.backends.signals import connection_created
161
+ auto_upgrade_dispatch_uid = "core.apps.ensure_auto_upgrade_periodic_task"
162
+
163
+ def ensure_auto_upgrade_on_connection(**kwargs):
164
+ connection = kwargs.get("connection")
165
+ if connection is not None and connection.alias != "default":
166
+ return
167
+
168
+ try:
169
+ ensure_auto_upgrade_periodic_task()
170
+ finally:
171
+ connection_created.disconnect(
172
+ receiver=ensure_auto_upgrade_on_connection,
173
+ dispatch_uid=auto_upgrade_dispatch_uid,
174
+ )
175
+
176
+ connection_created.connect(
177
+ ensure_auto_upgrade_on_connection,
178
+ dispatch_uid=auto_upgrade_dispatch_uid,
179
+ weak=False,
180
+ )
181
+
182
+ default_connection = connections[DEFAULT_DB_ALIAS]
183
+ if default_connection.connection is not None:
184
+ ensure_auto_upgrade_on_connection(connection=default_connection)
139
185
 
140
186
  def enable_sqlite_wal(**kwargs):
141
187
  connection = kwargs.get("connection")
core/backends.py CHANGED
@@ -12,6 +12,7 @@ from django.http.request import split_domain_port
12
12
  from django_otp.plugins.otp_totp.models import TOTPDevice
13
13
 
14
14
  from .models import EnergyAccount
15
+ from . import temp_passwords
15
16
 
16
17
 
17
18
  TOTP_DEVICE_NAME = "authenticator"
@@ -196,3 +197,40 @@ class LocalhostAdminBackend(ModelBackend):
196
197
  return User.all_objects.get(pk=user_id)
197
198
  except User.DoesNotExist:
198
199
  return None
200
+
201
+
202
+ class TempPasswordBackend(ModelBackend):
203
+ """Authenticate using a temporary password stored in a lockfile."""
204
+
205
+ def authenticate(self, request, username=None, password=None, **kwargs):
206
+ if not username or not password:
207
+ return None
208
+
209
+ UserModel = get_user_model()
210
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
211
+ try:
212
+ user = manager.get_by_natural_key(username)
213
+ except UserModel.DoesNotExist:
214
+ return None
215
+
216
+ entry = temp_passwords.load_temp_password(user.username)
217
+ if entry is None:
218
+ return None
219
+ if entry.is_expired:
220
+ temp_passwords.discard_temp_password(user.username)
221
+ return None
222
+ if not entry.check_password(password):
223
+ return None
224
+
225
+ if not user.is_active:
226
+ user.is_active = True
227
+ user.save(update_fields=["is_active"])
228
+ return user
229
+
230
+ def get_user(self, user_id):
231
+ UserModel = get_user_model()
232
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
233
+ try:
234
+ return manager.get(pk=user_id)
235
+ except UserModel.DoesNotExist:
236
+ return None
core/environment.py CHANGED
@@ -9,22 +9,35 @@ from django.urls import path
9
9
  from django.utils.translation import gettext_lazy as _
10
10
 
11
11
 
12
- def _environment_view(request):
13
- env_vars = sorted(os.environ.items())
14
- django_settings = sorted(
12
+ def _get_django_settings():
13
+ return sorted(
15
14
  [(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
16
15
  )
16
+
17
+
18
+ def _environment_view(request):
19
+ env_vars = sorted(os.environ.items())
17
20
  context = admin.site.each_context(request)
18
21
  context.update(
19
22
  {
20
- "title": _("Environment"),
23
+ "title": _("Environ"),
21
24
  "env_vars": env_vars,
22
- "django_settings": django_settings,
23
25
  }
24
26
  )
25
27
  return TemplateResponse(request, "admin/environment.html", context)
26
28
 
27
29
 
30
+ def _config_view(request):
31
+ context = admin.site.each_context(request)
32
+ context.update(
33
+ {
34
+ "title": _("Config"),
35
+ "django_settings": _get_django_settings(),
36
+ }
37
+ )
38
+ return TemplateResponse(request, "admin/config.html", context)
39
+
40
+
28
41
  def patch_admin_environment_view() -> None:
29
42
  """Add custom admin view for environment information."""
30
43
  original_get_urls = admin.site.get_urls
@@ -37,6 +50,11 @@ def patch_admin_environment_view() -> None:
37
50
  admin.site.admin_view(_environment_view),
38
51
  name="environment",
39
52
  ),
53
+ path(
54
+ "config/",
55
+ admin.site.admin_view(_config_view),
56
+ name="config",
57
+ ),
40
58
  ]
41
59
  return custom + urls
42
60
 
core/mailer.py CHANGED
@@ -61,7 +61,9 @@ def can_send_email() -> bool:
61
61
 
62
62
  from nodes.models import EmailOutbox # imported lazily to avoid circular deps
63
63
 
64
- has_outbox = EmailOutbox.objects.exclude(host="").exists()
64
+ has_outbox = (
65
+ EmailOutbox.objects.filter(is_enabled=True).exclude(host="").exists()
66
+ )
65
67
  if has_outbox:
66
68
  return True
67
69
 
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,57 @@ 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
+ charset = None
769
+
770
+ def _append(term: str, value: str):
771
+ nonlocal charset
772
+ if not value:
773
+ return
774
+ try:
775
+ value.encode("ascii")
776
+ encoded_value = value
777
+ except UnicodeEncodeError:
778
+ charset = charset or "UTF-8"
779
+ encoded_value = value.encode("utf-8")
780
+ criteria.extend([term, encoded_value])
781
+
782
+ _append("SUBJECT", subject)
783
+ _append("FROM", from_address)
784
+ _append("TEXT", body)
785
+
786
+ if not criteria:
787
+ typ, data = conn.search(None, "ALL")
788
+ else:
789
+ typ, data = conn.search(charset, *criteria)
790
+ ids = data[0].split()[-fetch_limit:]
684
791
  messages = []
685
792
  for mid in ids:
686
793
  typ, msg_data = conn.fetch(mid, "(RFC822)")
687
794
  msg = email.message_from_bytes(msg_data[0][1])
795
+ body_text = _get_body(msg)
796
+ subj_value = msg.get("Subject", "")
797
+ from_value = msg.get("From", "")
798
+ if not (
799
+ _matches(subj_value, subject, subject_regex)
800
+ and _matches(from_value, from_address, sender_regex)
801
+ and _matches(body_text, body, body_regex)
802
+ ):
803
+ continue
688
804
  messages.append(
689
805
  {
690
- "subject": msg.get("Subject", ""),
691
- "from": msg.get("From", ""),
692
- "body": _get_body(msg),
806
+ "subject": subj_value,
807
+ "from": from_value,
808
+ "body": body_text,
809
+ "date": msg.get("Date", ""),
693
810
  }
694
811
  )
812
+ if len(messages) >= limit:
813
+ break
695
814
  conn.logout()
696
815
  return list(reversed(messages))
697
816
 
@@ -713,25 +832,137 @@ class EmailInbox(Profile):
713
832
  subj = msg.get("Subject", "")
714
833
  frm = msg.get("From", "")
715
834
  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():
835
+ if not (
836
+ _matches(subj, subject, subject_regex)
837
+ and _matches(frm, from_address, sender_regex)
838
+ and _matches(body_text, body, body_regex)
839
+ ):
721
840
  continue
722
- messages.append({"subject": subj, "from": frm, "body": body_text})
841
+ messages.append(
842
+ {
843
+ "subject": subj,
844
+ "from": frm,
845
+ "body": body_text,
846
+ "date": msg.get("Date", ""),
847
+ }
848
+ )
723
849
  if len(messages) >= limit:
724
850
  break
725
851
  conn.quit()
726
852
  return messages
727
853
 
728
854
  def __str__(self): # pragma: no cover - simple representation
729
- return f"{self.username}@{self.host}"
855
+ username = (self.username or "").strip()
856
+ host = (self.host or "").strip()
857
+
858
+ if username:
859
+ if "@" in username:
860
+ return username
861
+ if host:
862
+ return f"{username}@{host}"
863
+ return username
864
+
865
+ if host:
866
+ return host
867
+
868
+ owner = self.owner_display()
869
+ if owner:
870
+ return owner
871
+
872
+ return super().__str__()
873
+
874
+
875
+ class SocialProfile(Profile):
876
+ """Store configuration required to link social accounts such as Bluesky."""
877
+
878
+ class Network(models.TextChoices):
879
+ BLUESKY = "bluesky", _("Bluesky")
880
+
881
+ profile_fields = ("handle", "domain", "did")
882
+
883
+ network = models.CharField(
884
+ max_length=32,
885
+ choices=Network.choices,
886
+ default=Network.BLUESKY,
887
+ help_text=_(
888
+ "Select the social network you want to connect. Only Bluesky is supported at the moment."
889
+ ),
890
+ )
891
+ handle = models.CharField(
892
+ max_length=253,
893
+ help_text=_(
894
+ "Bluesky handle that should resolve to Arthexis. Use the verified domain (for example arthexis.com)."
895
+ ),
896
+ validators=[social_domain_validator],
897
+ )
898
+ domain = models.CharField(
899
+ max_length=253,
900
+ help_text=_(
901
+ "Domain that hosts the Bluesky verification. Publish a _atproto TXT record or a /.well-known/atproto-did file with the DID below."
902
+ ),
903
+ validators=[social_domain_validator],
904
+ )
905
+ did = models.CharField(
906
+ max_length=255,
907
+ blank=True,
908
+ help_text=_(
909
+ "Optional DID that Bluesky assigns once the domain is linked (for example did:plc:1234abcd)."
910
+ ),
911
+ validators=[social_did_validator],
912
+ )
913
+
914
+ def clean(self):
915
+ super().clean()
916
+ if self.network == self.Network.BLUESKY:
917
+ errors = {}
918
+ if not self.handle:
919
+ errors["handle"] = _("Provide the handle that should point to this domain.")
920
+ if not self.domain:
921
+ errors["domain"] = _("A verified domain is required for Bluesky handles.")
922
+ if errors:
923
+ raise ValidationError(errors)
924
+
925
+ def save(self, *args, **kwargs):
926
+ if self.handle:
927
+ self.handle = self.handle.strip().lower()
928
+ if self.domain:
929
+ self.domain = self.domain.strip().lower()
930
+ super().save(*args, **kwargs)
931
+
932
+ def __str__(self): # pragma: no cover - simple representation
933
+ handle = self.handle or self.domain
934
+ label = f"{self.get_network_display()} ({handle})" if handle else self.get_network_display()
935
+ owner = self.owner_display()
936
+ return f"{owner} – {label}" if owner else label
937
+
938
+ class Meta:
939
+ verbose_name = _("Social Identity")
940
+ verbose_name_plural = _("Social Identities")
941
+ constraints = [
942
+ models.UniqueConstraint(
943
+ fields=["network", "handle"], name="socialprofile_network_handle"
944
+ ),
945
+ models.UniqueConstraint(
946
+ fields=["network", "domain"], name="socialprofile_network_domain"
947
+ ),
948
+ models.CheckConstraint(
949
+ check=(
950
+ (Q(user__isnull=False) & Q(group__isnull=True))
951
+ | (Q(user__isnull=True) & Q(group__isnull=False))
952
+ ),
953
+ name="socialprofile_requires_owner",
954
+ ),
955
+ ]
730
956
 
731
957
 
732
958
  class EmailCollector(Entity):
733
959
  """Search an inbox for matching messages and extract data via sigils."""
734
960
 
961
+ name = models.CharField(
962
+ max_length=255,
963
+ blank=True,
964
+ help_text="Optional label to identify this collector.",
965
+ )
735
966
  inbox = models.ForeignKey(
736
967
  "EmailInbox",
737
968
  related_name="collectors",
@@ -745,6 +976,10 @@ class EmailCollector(Entity):
745
976
  blank=True,
746
977
  help_text="Pattern with [sigils] to extract values from the body.",
747
978
  )
979
+ use_regular_expressions = models.BooleanField(
980
+ default=False,
981
+ help_text="Treat subject, sender and body filters as regular expressions (case-insensitive).",
982
+ )
748
983
 
749
984
  def _parse_sigils(self, text: str) -> dict[str, str]:
750
985
  """Extract values from ``text`` according to ``fragment`` sigils."""
@@ -764,16 +999,32 @@ class EmailCollector(Entity):
764
999
  return {}
765
1000
  return {k: v.strip() for k, v in match.groupdict().items()}
766
1001
 
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(
1002
+ def __str__(self): # pragma: no cover - simple representation
1003
+ if self.name:
1004
+ return self.name
1005
+ parts = []
1006
+ if self.subject:
1007
+ parts.append(self.subject)
1008
+ if self.sender:
1009
+ parts.append(self.sender)
1010
+ if not parts:
1011
+ parts.append(str(self.inbox))
1012
+ return " – ".join(parts)
1013
+
1014
+ def search_messages(self, limit: int = 10):
1015
+ return self.inbox.search_messages(
772
1016
  subject=self.subject,
773
1017
  from_address=self.sender,
774
1018
  body=self.body,
775
1019
  limit=limit,
1020
+ use_regular_expressions=self.use_regular_expressions,
776
1021
  )
1022
+
1023
+ def collect(self, limit: int = 10) -> None:
1024
+ """Poll the inbox and store new artifacts until an existing one is found."""
1025
+ from .models import EmailArtifact
1026
+
1027
+ messages = self.search_messages(limit=limit)
777
1028
  for msg in messages:
778
1029
  fp = EmailArtifact.fingerprint_for(
779
1030
  msg.get("subject", ""), msg.get("from", ""), msg.get("body", "")
@@ -2123,6 +2374,7 @@ class PackageRelease(Entity):
2123
2374
  max_length=40, blank=True, default=revision_utils.get_revision, editable=False
2124
2375
  )
2125
2376
  pypi_url = models.URLField("PyPI URL", blank=True, editable=False)
2377
+ release_on = models.DateTimeField(blank=True, null=True, editable=False)
2126
2378
 
2127
2379
  class Meta:
2128
2380
  verbose_name = "Package Release"
@@ -2398,3 +2650,23 @@ class Todo(Entity):
2398
2650
  if isinstance(field, ConditionTextField):
2399
2651
  return field.evaluate(self)
2400
2652
  return ConditionCheckResult(True, "")
2653
+
2654
+
2655
+ class TOTPDeviceSettings(models.Model):
2656
+ """Per-device configuration options for authenticator enrollments."""
2657
+
2658
+ device = models.OneToOneField(
2659
+ "otp_totp.TOTPDevice",
2660
+ on_delete=models.CASCADE,
2661
+ related_name="custom_settings",
2662
+ )
2663
+ issuer = models.CharField(
2664
+ max_length=64,
2665
+ blank=True,
2666
+ default="",
2667
+ help_text=_("Label shown in authenticator apps. Leave blank to use Arthexis."),
2668
+ )
2669
+
2670
+ class Meta:
2671
+ verbose_name = _("Authenticator device settings")
2672
+ verbose_name_plural = _("Authenticator device settings")