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
nodes/admin.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from django.contrib import admin, messages
2
- from django.urls import path, reverse
2
+ from django.urls import NoReverseMatch, path, reverse
3
3
  from django.shortcuts import redirect, render
4
+ from django.template.response import TemplateResponse
4
5
  from django.utils.html import format_html
5
6
  from django import forms
6
7
  from django.contrib.admin.widgets import FilteredSelectMultiple
@@ -9,15 +10,23 @@ from django.db.models import Count
9
10
  from django.conf import settings
10
11
  from pathlib import Path
11
12
  from django.http import HttpResponse
13
+ from django.utils import timezone
12
14
  from django.utils.translation import gettext_lazy as _
13
15
  import base64
14
16
  import pyperclip
15
17
  from pyperclip import PyperclipException
16
18
  import uuid
17
19
  import subprocess
18
- from .utils import capture_screenshot, save_screenshot
20
+ from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
19
21
  from .actions import NodeAction
22
+ from .reports import (
23
+ collect_celery_log_entries,
24
+ collect_scheduled_tasks,
25
+ iter_report_periods,
26
+ resolve_period,
27
+ )
20
28
 
29
+ from core.admin import EmailOutboxAdminForm
21
30
  from .models import (
22
31
  Node,
23
32
  EmailOutbox,
@@ -26,7 +35,10 @@ from .models import (
26
35
  NodeFeatureAssignment,
27
36
  ContentSample,
28
37
  NetMessage,
38
+ NodeManager,
39
+ DNSRecord,
29
40
  )
41
+ from . import dns as dns_utils
30
42
  from core.user_data import EntityModelAdmin
31
43
 
32
44
 
@@ -43,6 +55,147 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
43
55
  autocomplete_fields = ("feature",)
44
56
 
45
57
 
58
+ class DeployDNSRecordsForm(forms.Form):
59
+ manager = forms.ModelChoiceField(
60
+ label="Node Manager",
61
+ queryset=NodeManager.objects.none(),
62
+ help_text="Credentials used to authenticate with the DNS provider.",
63
+ )
64
+
65
+ def __init__(self, *args, **kwargs):
66
+ super().__init__(*args, **kwargs)
67
+ self.fields["manager"].queryset = NodeManager.objects.filter(
68
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
69
+ )
70
+
71
+
72
+ @admin.register(NodeManager)
73
+ class NodeManagerAdmin(EntityModelAdmin):
74
+ list_display = ("__str__", "provider", "is_enabled", "default_domain")
75
+ list_filter = ("provider", "is_enabled")
76
+ search_fields = (
77
+ "default_domain",
78
+ "user__username",
79
+ "group__name",
80
+ )
81
+ fieldsets = (
82
+ (_("Owner"), {"fields": ("user", "group")}),
83
+ (
84
+ _("Credentials"),
85
+ {"fields": ("api_key", "api_secret", "customer_id")},
86
+ ),
87
+ (
88
+ _("Configuration"),
89
+ {
90
+ "fields": (
91
+ "provider",
92
+ "default_domain",
93
+ "use_sandbox",
94
+ "is_enabled",
95
+ )
96
+ },
97
+ ),
98
+ )
99
+
100
+
101
+ @admin.register(DNSRecord)
102
+ class DNSRecordAdmin(EntityModelAdmin):
103
+ list_display = (
104
+ "record_type",
105
+ "fqdn",
106
+ "data",
107
+ "ttl",
108
+ "node_manager",
109
+ "last_synced_at",
110
+ "last_verified_at",
111
+ )
112
+ list_filter = ("record_type", "provider", "node_manager")
113
+ search_fields = ("domain", "name", "data")
114
+ autocomplete_fields = ("node_manager",)
115
+ actions = ["deploy_selected_records", "validate_selected_records"]
116
+
117
+ def _default_manager_for_queryset(self, queryset):
118
+ manager_ids = list(
119
+ queryset.exclude(node_manager__isnull=True)
120
+ .values_list("node_manager_id", flat=True)
121
+ .distinct()
122
+ )
123
+ if len(manager_ids) == 1:
124
+ return manager_ids[0]
125
+ available = list(
126
+ NodeManager.objects.filter(
127
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
128
+ ).values_list("pk", flat=True)
129
+ )
130
+ if len(available) == 1:
131
+ return available[0]
132
+ return None
133
+
134
+ @admin.action(description="Deploy Selected records")
135
+ def deploy_selected_records(self, request, queryset):
136
+ unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
137
+ for record in unsupported:
138
+ self.message_user(
139
+ request,
140
+ f"{record} uses unsupported provider {record.get_provider_display()}",
141
+ messages.WARNING,
142
+ )
143
+ queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
144
+ if not queryset:
145
+ self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
146
+ return None
147
+
148
+ if "apply" in request.POST:
149
+ form = DeployDNSRecordsForm(request.POST)
150
+ if form.is_valid():
151
+ manager = form.cleaned_data["manager"]
152
+ result = manager.publish_dns_records(list(queryset))
153
+ for record, reason in result.skipped.items():
154
+ self.message_user(request, f"{record}: {reason}", messages.WARNING)
155
+ for record, reason in result.failures.items():
156
+ self.message_user(request, f"{record}: {reason}", messages.ERROR)
157
+ if result.deployed:
158
+ self.message_user(
159
+ request,
160
+ f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
161
+ messages.SUCCESS,
162
+ )
163
+ return None
164
+ else:
165
+ initial_manager = self._default_manager_for_queryset(queryset)
166
+ form = DeployDNSRecordsForm(initial={"manager": initial_manager})
167
+
168
+ context = {
169
+ **self.admin_site.each_context(request),
170
+ "opts": self.model._meta,
171
+ "form": form,
172
+ "queryset": queryset,
173
+ "title": "Deploy DNS records",
174
+ }
175
+ return render(
176
+ request,
177
+ "admin/nodes/dnsrecord/deploy_records.html",
178
+ context,
179
+ )
180
+
181
+ @admin.action(description="Validate Selected records")
182
+ def validate_selected_records(self, request, queryset):
183
+ resolver = dns_utils.create_resolver()
184
+ successes = 0
185
+ for record in queryset:
186
+ ok, message = dns_utils.validate_record(record, resolver=resolver)
187
+ if ok:
188
+ successes += 1
189
+ else:
190
+ self.message_user(request, f"{record}: {message}", messages.WARNING)
191
+ if successes:
192
+ self.message_user(
193
+ request,
194
+ f"Validated {successes} DNS record(s).",
195
+ messages.SUCCESS,
196
+ )
197
+
198
+
46
199
  @admin.register(Node)
47
200
  class NodeAdmin(EntityModelAdmin):
48
201
  list_display = (
@@ -232,21 +385,31 @@ class NodeAdmin(EntityModelAdmin):
232
385
 
233
386
  @admin.register(EmailOutbox)
234
387
  class EmailOutboxAdmin(EntityModelAdmin):
235
- list_display = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
388
+ form = EmailOutboxAdminForm
389
+ list_display = (
390
+ "owner_label",
391
+ "host",
392
+ "port",
393
+ "username",
394
+ "use_tls",
395
+ "use_ssl",
396
+ "is_enabled",
397
+ )
236
398
  change_form_template = "admin/nodes/emailoutbox/change_form.html"
237
399
  fieldsets = (
238
- ("Owner", {"fields": ("user", "group", "node")}),
400
+ ("Owner", {"fields": ("user", "group")}),
401
+ ("Credentials", {"fields": ("username", "password")}),
239
402
  (
240
- None,
403
+ "Configuration",
241
404
  {
242
405
  "fields": (
406
+ "node",
243
407
  "host",
244
408
  "port",
245
- "username",
246
- "password",
247
409
  "use_tls",
248
410
  "use_ssl",
249
411
  "from_email",
412
+ "is_enabled",
250
413
  )
251
414
  },
252
415
  ),
@@ -337,7 +500,14 @@ class NodeRoleAdmin(EntityModelAdmin):
337
500
  @admin.register(NodeFeature)
338
501
  class NodeFeatureAdmin(EntityModelAdmin):
339
502
  filter_horizontal = ("roles",)
340
- list_display = ("display", "slug", "default_roles", "is_enabled")
503
+ list_display = (
504
+ "display",
505
+ "slug",
506
+ "default_roles",
507
+ "is_enabled_display",
508
+ "default_action",
509
+ )
510
+ actions = ["check_features_for_eligibility", "enable_selected_features"]
341
511
  readonly_fields = ("is_enabled",)
342
512
  search_fields = ("display", "slug")
343
513
 
@@ -350,6 +520,281 @@ class NodeFeatureAdmin(EntityModelAdmin):
350
520
  roles = [role.name for role in obj.roles.all()]
351
521
  return ", ".join(roles) if roles else "—"
352
522
 
523
+ @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
524
+ def is_enabled_display(self, obj):
525
+ return obj.is_enabled
526
+
527
+ @admin.display(description="Default Action")
528
+ def default_action(self, obj):
529
+ if not obj.is_enabled:
530
+ return "—"
531
+ action = obj.get_default_action()
532
+ if not action:
533
+ return "—"
534
+ try:
535
+ url = reverse(action.url_name)
536
+ except NoReverseMatch:
537
+ return action.label
538
+ return format_html('<a href="{}">{}</a>', url, action.label)
539
+
540
+ def _manual_enablement_message(self, feature, node):
541
+ if node is None:
542
+ return (
543
+ "Manual enablement is unavailable without a registered local node."
544
+ )
545
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS:
546
+ return "This feature can be enabled manually."
547
+ return "This feature cannot be enabled manually."
548
+
549
+ @admin.action(description="Check features for eligibility")
550
+ def check_features_for_eligibility(self, request, queryset):
551
+ from .feature_checks import feature_checks
552
+
553
+ features = list(queryset)
554
+ total = len(features)
555
+ successes = 0
556
+ node = Node.get_local()
557
+ for feature in features:
558
+ enablement_message = self._manual_enablement_message(feature, node)
559
+ try:
560
+ result = feature_checks.run(feature, node=node)
561
+ except Exception as exc: # pragma: no cover - defensive
562
+ self.message_user(
563
+ request,
564
+ f"{feature.display}: {exc} {enablement_message}",
565
+ level=messages.ERROR,
566
+ )
567
+ continue
568
+ if result is None:
569
+ self.message_user(
570
+ request,
571
+ f"No check is configured for {feature.display}. {enablement_message}",
572
+ level=messages.WARNING,
573
+ )
574
+ continue
575
+ message = result.message or (
576
+ f"{feature.display} check {'passed' if result.success else 'failed'}."
577
+ )
578
+ self.message_user(
579
+ request, f"{message} {enablement_message}", level=result.level
580
+ )
581
+ if result.success:
582
+ successes += 1
583
+ if total:
584
+ self.message_user(
585
+ request,
586
+ f"Completed {successes} of {total} feature check(s) successfully.",
587
+ level=messages.INFO,
588
+ )
589
+
590
+ @admin.action(description="Enable selected action")
591
+ def enable_selected_features(self, request, queryset):
592
+ node = Node.get_local()
593
+ if node is None:
594
+ self.message_user(
595
+ request,
596
+ "No local node is registered; unable to enable features manually.",
597
+ level=messages.ERROR,
598
+ )
599
+ return
600
+
601
+ manual_features = [
602
+ feature
603
+ for feature in queryset
604
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS
605
+ ]
606
+ non_manual_features = [
607
+ feature
608
+ for feature in queryset
609
+ if feature.slug not in Node.MANUAL_FEATURE_SLUGS
610
+ ]
611
+ for feature in non_manual_features:
612
+ self.message_user(
613
+ request,
614
+ f"{feature.display} cannot be enabled manually.",
615
+ level=messages.WARNING,
616
+ )
617
+
618
+ if not manual_features:
619
+ self.message_user(
620
+ request,
621
+ "None of the selected features can be enabled manually.",
622
+ level=messages.WARNING,
623
+ )
624
+ return
625
+
626
+ current_manual = set(
627
+ node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
628
+ "slug", flat=True
629
+ )
630
+ )
631
+ desired_manual = current_manual | {feature.slug for feature in manual_features}
632
+ newly_enabled = desired_manual - current_manual
633
+ if not newly_enabled:
634
+ self.message_user(
635
+ request,
636
+ "Selected manual features are already enabled.",
637
+ level=messages.INFO,
638
+ )
639
+ return
640
+
641
+ node.update_manual_features(desired_manual)
642
+ display_map = {feature.slug: feature.display for feature in manual_features}
643
+ newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
644
+ self.message_user(
645
+ request,
646
+ "Enabled {} feature(s): {}".format(
647
+ len(newly_enabled), ", ".join(newly_enabled_names)
648
+ ),
649
+ level=messages.SUCCESS,
650
+ )
651
+
652
+ def get_urls(self):
653
+ urls = super().get_urls()
654
+ custom = [
655
+ path(
656
+ "celery-report/",
657
+ self.admin_site.admin_view(self.celery_report),
658
+ name="nodes_nodefeature_celery_report",
659
+ ),
660
+ path(
661
+ "take-screenshot/",
662
+ self.admin_site.admin_view(self.take_screenshot),
663
+ name="nodes_nodefeature_take_screenshot",
664
+ ),
665
+ path(
666
+ "take-snapshot/",
667
+ self.admin_site.admin_view(self.take_snapshot),
668
+ name="nodes_nodefeature_take_snapshot",
669
+ ),
670
+ ]
671
+ return custom + urls
672
+
673
+ def celery_report(self, request):
674
+ period = resolve_period(request.GET.get("period"))
675
+ now = timezone.now()
676
+ window_end = now + period.delta
677
+ log_window_start = now - period.delta
678
+
679
+ scheduled_tasks = collect_scheduled_tasks(now, window_end)
680
+ log_collection = collect_celery_log_entries(log_window_start, now)
681
+
682
+ period_options = [
683
+ {
684
+ "key": candidate.key,
685
+ "label": candidate.label,
686
+ "selected": candidate.key == period.key,
687
+ "url": f"?period={candidate.key}",
688
+ }
689
+ for candidate in iter_report_periods()
690
+ ]
691
+
692
+ context = {
693
+ **self.admin_site.each_context(request),
694
+ "title": _("Celery Report"),
695
+ "period": period,
696
+ "period_options": period_options,
697
+ "current_time": now,
698
+ "window_end": window_end,
699
+ "log_window_start": log_window_start,
700
+ "scheduled_tasks": scheduled_tasks,
701
+ "log_entries": log_collection.entries,
702
+ "log_sources": log_collection.checked_sources,
703
+ }
704
+ return TemplateResponse(
705
+ request,
706
+ "admin/nodes/nodefeature/celery_report.html",
707
+ context,
708
+ )
709
+
710
+ def _ensure_feature_enabled(self, request, slug: str, action_label: str):
711
+ try:
712
+ feature = NodeFeature.objects.get(slug=slug)
713
+ except NodeFeature.DoesNotExist:
714
+ self.message_user(
715
+ request,
716
+ f"{action_label} is unavailable because the feature is not configured.",
717
+ level=messages.ERROR,
718
+ )
719
+ return None
720
+ if not feature.is_enabled:
721
+ self.message_user(
722
+ request,
723
+ f"{feature.display} feature is not enabled on this node.",
724
+ level=messages.WARNING,
725
+ )
726
+ return None
727
+ return feature
728
+
729
+ def take_screenshot(self, request):
730
+ feature = self._ensure_feature_enabled(
731
+ request, "screenshot-poll", "Take Screenshot"
732
+ )
733
+ if not feature:
734
+ return redirect("..")
735
+ url = request.build_absolute_uri("/")
736
+ try:
737
+ path = capture_screenshot(url)
738
+ except Exception as exc: # pragma: no cover - depends on selenium setup
739
+ self.message_user(request, str(exc), level=messages.ERROR)
740
+ return redirect("..")
741
+ node = Node.get_local()
742
+ sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
743
+ if not sample:
744
+ self.message_user(
745
+ request, "Duplicate screenshot; not saved", level=messages.INFO
746
+ )
747
+ return redirect("..")
748
+ self.message_user(
749
+ request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
750
+ )
751
+ try:
752
+ change_url = reverse(
753
+ "admin:nodes_contentsample_change", args=[sample.pk]
754
+ )
755
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
756
+ self.message_user(
757
+ request,
758
+ "Screenshot saved but the admin page could not be resolved.",
759
+ level=messages.WARNING,
760
+ )
761
+ return redirect("..")
762
+ return redirect(change_url)
763
+
764
+ def take_snapshot(self, request):
765
+ feature = self._ensure_feature_enabled(
766
+ request, "rpi-camera", "Take a Snapshot"
767
+ )
768
+ if not feature:
769
+ return redirect("..")
770
+ try:
771
+ path = capture_rpi_snapshot()
772
+ except Exception as exc: # pragma: no cover - depends on camera stack
773
+ self.message_user(request, str(exc), level=messages.ERROR)
774
+ return redirect("..")
775
+ node = Node.get_local()
776
+ sample = save_screenshot(path, node=node, method="RPI_CAMERA")
777
+ if not sample:
778
+ self.message_user(
779
+ request, "Duplicate snapshot; not saved", level=messages.INFO
780
+ )
781
+ return redirect("..")
782
+ self.message_user(
783
+ request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
784
+ )
785
+ try:
786
+ change_url = reverse(
787
+ "admin:nodes_contentsample_change", args=[sample.pk]
788
+ )
789
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
790
+ self.message_user(
791
+ request,
792
+ "Snapshot saved but the admin page could not be resolved.",
793
+ level=messages.WARNING,
794
+ )
795
+ return redirect("..")
796
+ return redirect(change_url)
797
+
353
798
 
354
799
  @admin.register(ContentSample)
355
800
  class ContentSampleAdmin(EntityModelAdmin):
nodes/backends.py CHANGED
@@ -38,11 +38,12 @@ class OutboxEmailBackend(BaseEmailBackend):
38
38
  user_id = self._resolve_identifier(message, "user")
39
39
  group_id = self._resolve_identifier(message, "group")
40
40
 
41
+ enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
41
42
  match_sets: list[tuple[str, list[EmailOutbox]]] = []
42
43
 
43
44
  if from_email:
44
45
  email_matches = list(
45
- EmailOutbox.objects.filter(
46
+ enabled_outboxes.filter(
46
47
  Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
47
48
  )
48
49
  )
@@ -50,22 +51,25 @@ class OutboxEmailBackend(BaseEmailBackend):
50
51
  match_sets.append(("from_email", email_matches))
51
52
 
52
53
  if node_id:
53
- node_matches = list(EmailOutbox.objects.filter(node_id=node_id))
54
+ node_matches = list(enabled_outboxes.filter(node_id=node_id))
54
55
  if node_matches:
55
56
  match_sets.append(("node", node_matches))
56
57
 
57
58
  if user_id:
58
- user_matches = list(EmailOutbox.objects.filter(user_id=user_id))
59
+ user_matches = list(enabled_outboxes.filter(user_id=user_id))
59
60
  if user_matches:
60
61
  match_sets.append(("user", user_matches))
61
62
 
62
63
  if group_id:
63
- group_matches = list(EmailOutbox.objects.filter(group_id=group_id))
64
+ group_matches = list(enabled_outboxes.filter(group_id=group_id))
64
65
  if group_matches:
65
66
  match_sets.append(("group", group_matches))
66
67
 
67
68
  if not match_sets:
68
- return EmailOutbox.objects.first(), []
69
+ fallback = self._fallback_outbox(enabled_outboxes)
70
+ if fallback:
71
+ return fallback, []
72
+ return None, []
69
73
 
70
74
  candidates: dict[int, EmailOutbox] = {}
71
75
  scores: defaultdict[int, int] = defaultdict(int)
@@ -76,7 +80,10 @@ class OutboxEmailBackend(BaseEmailBackend):
76
80
  scores[outbox.pk] += 1
77
81
 
78
82
  if not candidates:
79
- return EmailOutbox.objects.first(), []
83
+ fallback = self._fallback_outbox(enabled_outboxes)
84
+ if fallback:
85
+ return fallback, []
86
+ return None, []
80
87
 
81
88
  selected: EmailOutbox | None = None
82
89
  fallbacks: list[EmailOutbox] = []
@@ -93,6 +100,14 @@ class OutboxEmailBackend(BaseEmailBackend):
93
100
 
94
101
  return selected, fallbacks
95
102
 
103
+ def _fallback_outbox(self, queryset):
104
+ ownerless = queryset.filter(
105
+ node__isnull=True, user__isnull=True, group__isnull=True
106
+ ).order_by("pk").first()
107
+ if ownerless:
108
+ return ownerless
109
+ return queryset.order_by("pk").first()
110
+
96
111
  def send_messages(self, email_messages):
97
112
  sent = 0
98
113
  for message in email_messages: