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.
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
|
13
|
-
|
|
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": _("
|
|
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 =
|
|
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
|
-
|
|
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,57 @@ 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
|
+
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":
|
|
691
|
-
"from":
|
|
692
|
-
"body":
|
|
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
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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")
|