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/admin.py CHANGED
@@ -21,6 +21,8 @@ from import_export.admin import ImportExportModelAdmin
21
21
  from import_export.widgets import ForeignKeyWidget
22
22
  from django.contrib.auth.models import Group
23
23
  from django.templatetags.static import static
24
+ from django.utils import timezone
25
+ from django.utils.dateparse import parse_datetime
24
26
  from django.utils.html import format_html
25
27
  from django.utils.translation import gettext_lazy as _
26
28
  from django.forms.models import BaseInlineFormSet
@@ -51,6 +53,7 @@ from .models import (
51
53
  Reference,
52
54
  OdooProfile,
53
55
  EmailInbox,
56
+ SocialProfile,
54
57
  EmailCollector,
55
58
  Package,
56
59
  PackageRelease,
@@ -260,6 +263,7 @@ class ReferenceAdmin(EntityModelAdmin):
260
263
  list_display = (
261
264
  "alt_text",
262
265
  "content_type",
266
+ "link",
263
267
  "header",
264
268
  "footer",
265
269
  "visibility",
@@ -304,6 +308,15 @@ class ReferenceAdmin(EntityModelAdmin):
304
308
  def visibility(self, obj):
305
309
  return obj.get_footer_visibility_display()
306
310
 
311
+ @admin.display(description="LINK")
312
+ def link(self, obj):
313
+ if obj.value:
314
+ return format_html(
315
+ '<a href="{}" target="_blank" rel="noopener noreferrer">open</a>',
316
+ obj.value,
317
+ )
318
+ return ""
319
+
307
320
  def get_urls(self):
308
321
  urls = super().get_urls()
309
322
  custom = [
@@ -532,7 +545,14 @@ class SecurityGroupAdmin(DjangoGroupAdmin):
532
545
 
533
546
 
534
547
  class InviteLeadAdmin(EntityModelAdmin):
535
- list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
548
+ list_display = (
549
+ "email",
550
+ "mac_address",
551
+ "created_on",
552
+ "sent_on",
553
+ "sent_via_outbox",
554
+ "short_error",
555
+ )
536
556
  search_fields = ("email", "comment")
537
557
  readonly_fields = (
538
558
  "created_on",
@@ -543,6 +563,7 @@ class InviteLeadAdmin(EntityModelAdmin):
543
563
  "ip_address",
544
564
  "mac_address",
545
565
  "sent_on",
566
+ "sent_via_outbox",
546
567
  "error",
547
568
  )
548
569
 
@@ -851,6 +872,14 @@ class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
851
872
  exclude = ("user", "group")
852
873
 
853
874
 
875
+ class SocialProfileInlineForm(ProfileFormMixin, forms.ModelForm):
876
+ profile_fields = SocialProfile.profile_fields
877
+
878
+ class Meta:
879
+ model = SocialProfile
880
+ fields = ("network", "handle", "domain", "did")
881
+
882
+
854
883
  class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
855
884
  profile_fields = EmailOutbox.profile_fields
856
885
  password = forms.CharField(
@@ -869,6 +898,7 @@ class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
869
898
  "use_tls",
870
899
  "use_ssl",
871
900
  "from_email",
901
+ "is_enabled",
872
902
  )
873
903
 
874
904
  def __init__(self, *args, **kwargs):
@@ -998,6 +1028,22 @@ PROFILE_INLINE_CONFIG = {
998
1028
  "from_email",
999
1029
  ),
1000
1030
  },
1031
+ SocialProfile: {
1032
+ "form": SocialProfileInlineForm,
1033
+ "fieldsets": (
1034
+ (
1035
+ _("Configuration: Bluesky"),
1036
+ {
1037
+ "fields": ("network", "handle", "domain", "did"),
1038
+ "description": _(
1039
+ "1. Set your Bluesky handle to the domain managed by Arthexis. "
1040
+ "2. Publish a _atproto TXT record or /.well-known/atproto-did file pointing to the DID below. "
1041
+ "3. Save once Bluesky confirms the domain matches the DID."
1042
+ ),
1043
+ },
1044
+ ),
1045
+ ),
1046
+ },
1001
1047
  ReleaseManager: {
1002
1048
  "form": ReleaseManagerInlineForm,
1003
1049
  "fields": (
@@ -1056,6 +1102,7 @@ PROFILE_MODELS = (
1056
1102
  OdooProfile,
1057
1103
  EmailInbox,
1058
1104
  EmailOutbox,
1105
+ SocialProfile,
1059
1106
  ReleaseManager,
1060
1107
  AssistantProfile,
1061
1108
  )
@@ -1202,8 +1249,68 @@ class EmailCollectorInline(admin.TabularInline):
1202
1249
 
1203
1250
 
1204
1251
  class EmailCollectorAdmin(EntityModelAdmin):
1205
- list_display = ("inbox", "subject", "sender", "body", "fragment")
1206
- search_fields = ("subject", "sender", "body", "fragment")
1252
+ list_display = ("name", "inbox", "subject", "sender", "body", "fragment")
1253
+ search_fields = ("name", "subject", "sender", "body", "fragment")
1254
+ actions = ["preview_messages"]
1255
+
1256
+ @admin.action(description=_("Preview matches"))
1257
+ def preview_messages(self, request, queryset):
1258
+ results = []
1259
+ for collector in queryset.select_related("inbox"):
1260
+ try:
1261
+ messages = collector.search_messages(limit=5)
1262
+ error = None
1263
+ except ValidationError as exc:
1264
+ messages = []
1265
+ error = str(exc)
1266
+ except Exception as exc: # pragma: no cover - admin feedback
1267
+ messages = []
1268
+ error = str(exc)
1269
+ results.append(
1270
+ {
1271
+ "collector": collector,
1272
+ "messages": messages,
1273
+ "error": error,
1274
+ }
1275
+ )
1276
+ context = {
1277
+ "title": _("Preview Email Collectors"),
1278
+ "results": results,
1279
+ "opts": self.model._meta,
1280
+ "queryset": queryset,
1281
+ }
1282
+ return TemplateResponse(
1283
+ request, "admin/core/emailcollector/preview.html", context
1284
+ )
1285
+
1286
+
1287
+ @admin.register(SocialProfile)
1288
+ class SocialProfileAdmin(
1289
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1290
+ ):
1291
+ list_display = ("owner", "network", "handle", "domain")
1292
+ list_filter = ("network",)
1293
+ search_fields = ("handle", "domain", "did")
1294
+ changelist_actions = ["my_profile"]
1295
+ change_actions = ["my_profile_action"]
1296
+ fieldsets = (
1297
+ (_("Owner"), {"fields": ("user", "group")}),
1298
+ (
1299
+ _("Configuration: Bluesky"),
1300
+ {
1301
+ "fields": ("network", "handle", "domain", "did"),
1302
+ "description": _(
1303
+ "Link Arthexis to Bluesky by using a verified domain handle. "
1304
+ "Publish a _atproto TXT record or /.well-known/atproto-did file "
1305
+ "that returns the DID stored here before saving."
1306
+ ),
1307
+ },
1308
+ ),
1309
+ )
1310
+
1311
+ @admin.display(description=_("Owner"))
1312
+ def owner(self, obj):
1313
+ return obj.owner_display()
1207
1314
 
1208
1315
 
1209
1316
  @admin.register(OdooProfile)
@@ -1383,6 +1490,7 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
1383
1490
  subject=form.cleaned_data["subject"],
1384
1491
  from_address=form.cleaned_data["from_address"],
1385
1492
  body=form.cleaned_data["body"],
1493
+ use_regular_expressions=False,
1386
1494
  )
1387
1495
  context = {
1388
1496
  "form": form,
@@ -2265,6 +2373,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2265
2373
  "package_link",
2266
2374
  "is_current",
2267
2375
  "pypi_url",
2376
+ "release_on",
2268
2377
  "revision_short",
2269
2378
  "published_status",
2270
2379
  )
@@ -2272,7 +2381,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2272
2381
  actions = ["publish_release", "validate_releases"]
2273
2382
  change_actions = ["publish_release_action"]
2274
2383
  changelist_actions = ["refresh_from_pypi", "prepare_next_release"]
2275
- readonly_fields = ("pypi_url", "is_current", "revision")
2384
+ readonly_fields = ("pypi_url", "release_on", "is_current", "revision")
2276
2385
  fields = (
2277
2386
  "package",
2278
2387
  "release_manager",
@@ -2280,6 +2389,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2280
2389
  "revision",
2281
2390
  "is_current",
2282
2391
  "pypi_url",
2392
+ "release_on",
2283
2393
  )
2284
2394
 
2285
2395
  @admin.display(description="package", ordering="package")
@@ -2307,32 +2417,84 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
2307
2417
  return
2308
2418
  releases = resp.json().get("releases", {})
2309
2419
  created = 0
2310
- for version in releases:
2311
- exists = PackageRelease.all_objects.filter(
2420
+ updated = 0
2421
+ restored = 0
2422
+
2423
+ for version, files in releases.items():
2424
+ release_on = self._release_on_from_files(files)
2425
+ release = PackageRelease.all_objects.filter(
2312
2426
  package=package, version=version
2313
- ).exists()
2314
- if not exists:
2315
- PackageRelease.objects.create(
2316
- package=package,
2317
- release_manager=package.release_manager,
2318
- version=version,
2319
- revision="",
2320
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
2321
- )
2322
- created += 1
2323
- if created:
2324
- PackageRelease.dump_fixture()
2325
- self.message_user(
2326
- request,
2327
- f"Created {created} release{'s' if created != 1 else ''} from PyPI",
2328
- messages.SUCCESS,
2427
+ ).first()
2428
+ if release:
2429
+ update_fields = []
2430
+ if release.is_deleted:
2431
+ release.is_deleted = False
2432
+ update_fields.append("is_deleted")
2433
+ restored += 1
2434
+ if not release.pypi_url:
2435
+ release.pypi_url = (
2436
+ f"https://pypi.org/project/{package.name}/{version}/"
2437
+ )
2438
+ update_fields.append("pypi_url")
2439
+ if release_on and release.release_on != release_on:
2440
+ release.release_on = release_on
2441
+ update_fields.append("release_on")
2442
+ updated += 1
2443
+ if update_fields:
2444
+ release.save(update_fields=update_fields)
2445
+ continue
2446
+ PackageRelease.objects.create(
2447
+ package=package,
2448
+ release_manager=package.release_manager,
2449
+ version=version,
2450
+ revision="",
2451
+ pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
2452
+ release_on=release_on,
2329
2453
  )
2454
+ created += 1
2455
+
2456
+ if created or updated or restored:
2457
+ PackageRelease.dump_fixture()
2458
+ message_parts = []
2459
+ if created:
2460
+ message_parts.append(
2461
+ f"Created {created} release{'s' if created != 1 else ''} from PyPI"
2462
+ )
2463
+ if updated:
2464
+ message_parts.append(
2465
+ f"Updated release date for {updated} release"
2466
+ f"{'s' if updated != 1 else ''}"
2467
+ )
2468
+ if restored:
2469
+ message_parts.append(
2470
+ f"Restored {restored} release{'s' if restored != 1 else ''}"
2471
+ )
2472
+ self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
2330
2473
  else:
2331
2474
  self.message_user(request, "No new releases found", messages.INFO)
2332
2475
 
2333
2476
  refresh_from_pypi.label = "Refresh from PyPI"
2334
2477
  refresh_from_pypi.short_description = "Refresh from PyPI"
2335
2478
 
2479
+ @staticmethod
2480
+ def _release_on_from_files(files):
2481
+ if not files:
2482
+ return None
2483
+ candidates = []
2484
+ for item in files:
2485
+ stamp = item.get("upload_time_iso_8601") or item.get("upload_time")
2486
+ if not stamp:
2487
+ continue
2488
+ when = parse_datetime(stamp)
2489
+ if when is None:
2490
+ continue
2491
+ if timezone.is_naive(when):
2492
+ when = timezone.make_aware(when, datetime.timezone.utc)
2493
+ candidates.append(when.astimezone(datetime.timezone.utc))
2494
+ if not candidates:
2495
+ return None
2496
+ return min(candidates)
2497
+
2336
2498
  def prepare_next_release(self, request, queryset):
2337
2499
  package = Package.objects.filter(is_active=True).first()
2338
2500
  if not package:
core/apps.py CHANGED
@@ -104,8 +104,11 @@ class CoreConfig(AppConfig):
104
104
 
105
105
  lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
106
 
107
+ from django.db.backends.signals import connection_created
108
+
107
109
  if lock.exists():
108
110
  from .auto_upgrade import ensure_auto_upgrade_periodic_task
111
+ from django.db import DEFAULT_DB_ALIAS, connections
109
112
 
110
113
  def ensure_email_collector_task(**kwargs):
111
114
  try: # pragma: no cover - optional dependency
@@ -133,9 +136,31 @@ class CoreConfig(AppConfig):
133
136
 
134
137
  post_migrate.connect(ensure_email_collector_task, sender=self)
135
138
  post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
136
- ensure_auto_upgrade_periodic_task()
137
139
 
138
- from django.db.backends.signals import connection_created
140
+ auto_upgrade_dispatch_uid = "core.apps.ensure_auto_upgrade_periodic_task"
141
+
142
+ def ensure_auto_upgrade_on_connection(**kwargs):
143
+ connection = kwargs.get("connection")
144
+ if connection is not None and connection.alias != "default":
145
+ return
146
+
147
+ try:
148
+ ensure_auto_upgrade_periodic_task()
149
+ finally:
150
+ connection_created.disconnect(
151
+ receiver=ensure_auto_upgrade_on_connection,
152
+ dispatch_uid=auto_upgrade_dispatch_uid,
153
+ )
154
+
155
+ connection_created.connect(
156
+ ensure_auto_upgrade_on_connection,
157
+ dispatch_uid=auto_upgrade_dispatch_uid,
158
+ weak=False,
159
+ )
160
+
161
+ default_connection = connections[DEFAULT_DB_ALIAS]
162
+ if default_connection.connection is not None:
163
+ ensure_auto_upgrade_on_connection(connection=default_connection)
139
164
 
140
165
  def enable_sqlite_wal(**kwargs):
141
166
  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