arthexis 0.1.9__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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -9,6 +9,7 @@ from django.db.models import Count
|
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from django.http import HttpResponse
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
12
13
|
import base64
|
|
13
14
|
import pyperclip
|
|
14
15
|
from pyperclip import PyperclipException
|
|
@@ -25,7 +26,10 @@ from .models import (
|
|
|
25
26
|
NodeFeatureAssignment,
|
|
26
27
|
ContentSample,
|
|
27
28
|
NetMessage,
|
|
29
|
+
NodeManager,
|
|
30
|
+
DNSRecord,
|
|
28
31
|
)
|
|
32
|
+
from . import dns as dns_utils
|
|
29
33
|
from core.user_data import EntityModelAdmin
|
|
30
34
|
|
|
31
35
|
|
|
@@ -42,6 +46,129 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
42
46
|
autocomplete_fields = ("feature",)
|
|
43
47
|
|
|
44
48
|
|
|
49
|
+
class DeployDNSRecordsForm(forms.Form):
|
|
50
|
+
manager = forms.ModelChoiceField(
|
|
51
|
+
label="Node Manager",
|
|
52
|
+
queryset=NodeManager.objects.none(),
|
|
53
|
+
help_text="Credentials used to authenticate with the DNS provider.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __init__(self, *args, **kwargs):
|
|
57
|
+
super().__init__(*args, **kwargs)
|
|
58
|
+
self.fields["manager"].queryset = NodeManager.objects.filter(
|
|
59
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@admin.register(NodeManager)
|
|
64
|
+
class NodeManagerAdmin(EntityModelAdmin):
|
|
65
|
+
list_display = ("__str__", "provider", "is_enabled", "default_domain")
|
|
66
|
+
list_filter = ("provider", "is_enabled")
|
|
67
|
+
search_fields = (
|
|
68
|
+
"default_domain",
|
|
69
|
+
"user__username",
|
|
70
|
+
"group__name",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@admin.register(DNSRecord)
|
|
75
|
+
class DNSRecordAdmin(EntityModelAdmin):
|
|
76
|
+
list_display = (
|
|
77
|
+
"record_type",
|
|
78
|
+
"fqdn",
|
|
79
|
+
"data",
|
|
80
|
+
"ttl",
|
|
81
|
+
"node_manager",
|
|
82
|
+
"last_synced_at",
|
|
83
|
+
"last_verified_at",
|
|
84
|
+
)
|
|
85
|
+
list_filter = ("record_type", "provider", "node_manager")
|
|
86
|
+
search_fields = ("domain", "name", "data")
|
|
87
|
+
autocomplete_fields = ("node_manager",)
|
|
88
|
+
actions = ["deploy_selected_records", "validate_selected_records"]
|
|
89
|
+
|
|
90
|
+
def _default_manager_for_queryset(self, queryset):
|
|
91
|
+
manager_ids = list(
|
|
92
|
+
queryset.exclude(node_manager__isnull=True)
|
|
93
|
+
.values_list("node_manager_id", flat=True)
|
|
94
|
+
.distinct()
|
|
95
|
+
)
|
|
96
|
+
if len(manager_ids) == 1:
|
|
97
|
+
return manager_ids[0]
|
|
98
|
+
available = list(
|
|
99
|
+
NodeManager.objects.filter(
|
|
100
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
101
|
+
).values_list("pk", flat=True)
|
|
102
|
+
)
|
|
103
|
+
if len(available) == 1:
|
|
104
|
+
return available[0]
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
@admin.action(description="Deploy Selected records")
|
|
108
|
+
def deploy_selected_records(self, request, queryset):
|
|
109
|
+
unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
|
|
110
|
+
for record in unsupported:
|
|
111
|
+
self.message_user(
|
|
112
|
+
request,
|
|
113
|
+
f"{record} uses unsupported provider {record.get_provider_display()}",
|
|
114
|
+
messages.WARNING,
|
|
115
|
+
)
|
|
116
|
+
queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
|
|
117
|
+
if not queryset:
|
|
118
|
+
self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if "apply" in request.POST:
|
|
122
|
+
form = DeployDNSRecordsForm(request.POST)
|
|
123
|
+
if form.is_valid():
|
|
124
|
+
manager = form.cleaned_data["manager"]
|
|
125
|
+
result = manager.publish_dns_records(list(queryset))
|
|
126
|
+
for record, reason in result.skipped.items():
|
|
127
|
+
self.message_user(request, f"{record}: {reason}", messages.WARNING)
|
|
128
|
+
for record, reason in result.failures.items():
|
|
129
|
+
self.message_user(request, f"{record}: {reason}", messages.ERROR)
|
|
130
|
+
if result.deployed:
|
|
131
|
+
self.message_user(
|
|
132
|
+
request,
|
|
133
|
+
f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
|
|
134
|
+
messages.SUCCESS,
|
|
135
|
+
)
|
|
136
|
+
return None
|
|
137
|
+
else:
|
|
138
|
+
initial_manager = self._default_manager_for_queryset(queryset)
|
|
139
|
+
form = DeployDNSRecordsForm(initial={"manager": initial_manager})
|
|
140
|
+
|
|
141
|
+
context = {
|
|
142
|
+
**self.admin_site.each_context(request),
|
|
143
|
+
"opts": self.model._meta,
|
|
144
|
+
"form": form,
|
|
145
|
+
"queryset": queryset,
|
|
146
|
+
"title": "Deploy DNS records",
|
|
147
|
+
}
|
|
148
|
+
return render(
|
|
149
|
+
request,
|
|
150
|
+
"admin/nodes/dnsrecord/deploy_records.html",
|
|
151
|
+
context,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@admin.action(description="Validate Selected records")
|
|
155
|
+
def validate_selected_records(self, request, queryset):
|
|
156
|
+
resolver = dns_utils.create_resolver()
|
|
157
|
+
successes = 0
|
|
158
|
+
for record in queryset:
|
|
159
|
+
ok, message = dns_utils.validate_record(record, resolver=resolver)
|
|
160
|
+
if ok:
|
|
161
|
+
successes += 1
|
|
162
|
+
else:
|
|
163
|
+
self.message_user(request, f"{record}: {message}", messages.WARNING)
|
|
164
|
+
if successes:
|
|
165
|
+
self.message_user(
|
|
166
|
+
request,
|
|
167
|
+
f"Validated {successes} DNS record(s).",
|
|
168
|
+
messages.SUCCESS,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
45
172
|
@admin.register(Node)
|
|
46
173
|
class NodeAdmin(EntityModelAdmin):
|
|
47
174
|
list_display = (
|
|
@@ -114,6 +241,9 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
114
241
|
|
|
115
242
|
token = uuid.uuid4().hex
|
|
116
243
|
context = {
|
|
244
|
+
**self.admin_site.each_context(request),
|
|
245
|
+
"opts": self.model._meta,
|
|
246
|
+
"title": _("Register Visitor Node"),
|
|
117
247
|
"token": token,
|
|
118
248
|
"info_url": reverse("node-info"),
|
|
119
249
|
"register_url": reverse("register-node"),
|
|
@@ -228,12 +358,20 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
228
358
|
|
|
229
359
|
@admin.register(EmailOutbox)
|
|
230
360
|
class EmailOutboxAdmin(EntityModelAdmin):
|
|
231
|
-
list_display = (
|
|
361
|
+
list_display = (
|
|
362
|
+
"owner_label",
|
|
363
|
+
"host",
|
|
364
|
+
"port",
|
|
365
|
+
"username",
|
|
366
|
+
"use_tls",
|
|
367
|
+
"use_ssl",
|
|
368
|
+
"is_enabled",
|
|
369
|
+
)
|
|
232
370
|
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
233
371
|
fieldsets = (
|
|
234
372
|
("Owner", {"fields": ("user", "group", "node")}),
|
|
235
373
|
(
|
|
236
|
-
|
|
374
|
+
"Configuration",
|
|
237
375
|
{
|
|
238
376
|
"fields": (
|
|
239
377
|
"host",
|
|
@@ -243,6 +381,7 @@ class EmailOutboxAdmin(EntityModelAdmin):
|
|
|
243
381
|
"use_tls",
|
|
244
382
|
"use_ssl",
|
|
245
383
|
"from_email",
|
|
384
|
+
"is_enabled",
|
|
246
385
|
)
|
|
247
386
|
},
|
|
248
387
|
),
|
|
@@ -280,9 +419,6 @@ class EmailOutboxAdmin(EntityModelAdmin):
|
|
|
280
419
|
self.message_user(request, str(exc), messages.ERROR)
|
|
281
420
|
return redirect("..")
|
|
282
421
|
|
|
283
|
-
def get_model_perms(self, request): # pragma: no cover - hide from index
|
|
284
|
-
return {}
|
|
285
|
-
|
|
286
422
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
287
423
|
extra_context = extra_context or {}
|
|
288
424
|
if object_id:
|
|
@@ -430,7 +566,14 @@ class ContentSampleAdmin(EntityModelAdmin):
|
|
|
430
566
|
|
|
431
567
|
@admin.register(NetMessage)
|
|
432
568
|
class NetMessageAdmin(EntityModelAdmin):
|
|
433
|
-
list_display = (
|
|
569
|
+
list_display = (
|
|
570
|
+
"subject",
|
|
571
|
+
"body",
|
|
572
|
+
"reach",
|
|
573
|
+
"node_origin",
|
|
574
|
+
"created",
|
|
575
|
+
"complete",
|
|
576
|
+
)
|
|
434
577
|
search_fields = ("subject", "body")
|
|
435
578
|
list_filter = ("complete", "reach")
|
|
436
579
|
ordering = ("-created",)
|
nodes/backends.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
3
6
|
from django.core.mail.backends.base import BaseEmailBackend
|
|
4
7
|
from django.core.mail import get_connection
|
|
5
8
|
from django.conf import settings
|
|
@@ -13,41 +16,145 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
13
16
|
|
|
14
17
|
If a matching outbox exists for the message's ``from_email`` (matching
|
|
15
18
|
either ``from_email`` or ``username``), that outbox's SMTP credentials are
|
|
16
|
-
used.
|
|
17
|
-
|
|
19
|
+
used. ``EmailOutbox`` associations to ``node``, ``user`` and ``group`` are
|
|
20
|
+
also considered and preferred when multiple criteria match. When no
|
|
21
|
+
outboxes are configured, the system falls back to Django's default SMTP
|
|
22
|
+
settings.
|
|
18
23
|
"""
|
|
19
24
|
|
|
20
|
-
def
|
|
25
|
+
def _resolve_identifier(self, message, attr: str):
|
|
26
|
+
value = getattr(message, attr, None)
|
|
27
|
+
if value is None:
|
|
28
|
+
value = getattr(message, f"{attr}_id", None)
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
return getattr(value, "pk", value)
|
|
32
|
+
|
|
33
|
+
def _select_outbox(
|
|
34
|
+
self, message
|
|
35
|
+
) -> tuple[EmailOutbox | None, list[EmailOutbox]]:
|
|
36
|
+
from_email = getattr(message, "from_email", None)
|
|
37
|
+
node_id = self._resolve_identifier(message, "node")
|
|
38
|
+
user_id = self._resolve_identifier(message, "user")
|
|
39
|
+
group_id = self._resolve_identifier(message, "group")
|
|
40
|
+
|
|
41
|
+
enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
|
|
42
|
+
match_sets: list[tuple[str, list[EmailOutbox]]] = []
|
|
43
|
+
|
|
21
44
|
if from_email:
|
|
22
|
-
|
|
23
|
-
|
|
45
|
+
email_matches = list(
|
|
46
|
+
enabled_outboxes.filter(
|
|
24
47
|
Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
|
|
25
|
-
)
|
|
26
|
-
or EmailOutbox.objects.first()
|
|
48
|
+
)
|
|
27
49
|
)
|
|
28
|
-
|
|
50
|
+
if email_matches:
|
|
51
|
+
match_sets.append(("from_email", email_matches))
|
|
52
|
+
|
|
53
|
+
if node_id:
|
|
54
|
+
node_matches = list(enabled_outboxes.filter(node_id=node_id))
|
|
55
|
+
if node_matches:
|
|
56
|
+
match_sets.append(("node", node_matches))
|
|
57
|
+
|
|
58
|
+
if user_id:
|
|
59
|
+
user_matches = list(enabled_outboxes.filter(user_id=user_id))
|
|
60
|
+
if user_matches:
|
|
61
|
+
match_sets.append(("user", user_matches))
|
|
62
|
+
|
|
63
|
+
if group_id:
|
|
64
|
+
group_matches = list(enabled_outboxes.filter(group_id=group_id))
|
|
65
|
+
if group_matches:
|
|
66
|
+
match_sets.append(("group", group_matches))
|
|
67
|
+
|
|
68
|
+
if not match_sets:
|
|
69
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
70
|
+
if fallback:
|
|
71
|
+
return fallback, []
|
|
72
|
+
return None, []
|
|
73
|
+
|
|
74
|
+
candidates: dict[int, EmailOutbox] = {}
|
|
75
|
+
scores: defaultdict[int, int] = defaultdict(int)
|
|
76
|
+
|
|
77
|
+
for _, matches in match_sets:
|
|
78
|
+
for outbox in matches:
|
|
79
|
+
candidates[outbox.pk] = outbox
|
|
80
|
+
scores[outbox.pk] += 1
|
|
81
|
+
|
|
82
|
+
if not candidates:
|
|
83
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
84
|
+
if fallback:
|
|
85
|
+
return fallback, []
|
|
86
|
+
return None, []
|
|
87
|
+
|
|
88
|
+
selected: EmailOutbox | None = None
|
|
89
|
+
fallbacks: list[EmailOutbox] = []
|
|
90
|
+
|
|
91
|
+
for score in sorted(set(scores.values()), reverse=True):
|
|
92
|
+
group = [candidates[pk] for pk, value in scores.items() if value == score]
|
|
93
|
+
if len(group) > 1:
|
|
94
|
+
random.shuffle(group)
|
|
95
|
+
if selected is None:
|
|
96
|
+
selected = group[0]
|
|
97
|
+
fallbacks.extend(group[1:])
|
|
98
|
+
else:
|
|
99
|
+
fallbacks.extend(group)
|
|
100
|
+
|
|
101
|
+
return selected, fallbacks
|
|
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()
|
|
29
110
|
|
|
30
111
|
def send_messages(self, email_messages):
|
|
31
112
|
sent = 0
|
|
32
113
|
for message in email_messages:
|
|
33
|
-
|
|
114
|
+
original_from_email = message.from_email
|
|
115
|
+
outbox, fallbacks = self._select_outbox(message)
|
|
116
|
+
tried_outboxes = []
|
|
34
117
|
if outbox:
|
|
35
|
-
|
|
36
|
-
|
|
118
|
+
tried_outboxes.append(outbox)
|
|
119
|
+
tried_outboxes.extend(fallbacks)
|
|
120
|
+
|
|
121
|
+
last_error: Exception | None = None
|
|
122
|
+
|
|
123
|
+
if tried_outboxes:
|
|
124
|
+
for candidate in tried_outboxes:
|
|
125
|
+
connection = candidate.get_connection()
|
|
37
126
|
message.from_email = (
|
|
38
|
-
|
|
127
|
+
original_from_email
|
|
128
|
+
or candidate.from_email
|
|
129
|
+
or settings.DEFAULT_FROM_EMAIL
|
|
39
130
|
)
|
|
131
|
+
try:
|
|
132
|
+
sent += connection.send_messages([message]) or 0
|
|
133
|
+
last_error = None
|
|
134
|
+
break
|
|
135
|
+
except Exception as exc: # pragma: no cover - retry on error
|
|
136
|
+
last_error = exc
|
|
137
|
+
finally:
|
|
138
|
+
try:
|
|
139
|
+
connection.close()
|
|
140
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
141
|
+
pass
|
|
142
|
+
if last_error is not None:
|
|
143
|
+
message.from_email = original_from_email
|
|
144
|
+
raise last_error
|
|
40
145
|
else:
|
|
41
146
|
connection = get_connection(
|
|
42
147
|
"django.core.mail.backends.smtp.EmailBackend"
|
|
43
148
|
)
|
|
44
149
|
if not message.from_email:
|
|
45
150
|
message.from_email = settings.DEFAULT_FROM_EMAIL
|
|
46
|
-
try:
|
|
47
|
-
sent += connection.send_messages([message]) or 0
|
|
48
|
-
finally:
|
|
49
151
|
try:
|
|
50
|
-
connection.
|
|
51
|
-
|
|
52
|
-
|
|
152
|
+
sent += connection.send_messages([message]) or 0
|
|
153
|
+
finally:
|
|
154
|
+
try:
|
|
155
|
+
connection.close()
|
|
156
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
message.from_email = original_from_email
|
|
53
160
|
return sent
|
nodes/dns.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable, Mapping, MutableMapping, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
from dns import exception as dns_exception
|
|
10
|
+
from dns import resolver as dns_resolver
|
|
11
|
+
from requests import Response
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover - imported for type checking only
|
|
14
|
+
from .models import DNSRecord, NodeManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeploymentResult:
|
|
19
|
+
deployed: list["DNSRecord"]
|
|
20
|
+
failures: MutableMapping["DNSRecord", str]
|
|
21
|
+
skipped: MutableMapping["DNSRecord", str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _error_from_response(response: Response) -> str:
|
|
25
|
+
try:
|
|
26
|
+
payload = response.json()
|
|
27
|
+
except ValueError:
|
|
28
|
+
payload = None
|
|
29
|
+
|
|
30
|
+
if isinstance(payload, dict):
|
|
31
|
+
for key in ("message", "detail", "error"):
|
|
32
|
+
message = payload.get(key)
|
|
33
|
+
if message:
|
|
34
|
+
return str(message)
|
|
35
|
+
elif isinstance(payload, list) and payload:
|
|
36
|
+
first = payload[0]
|
|
37
|
+
if isinstance(first, Mapping) and "message" in first:
|
|
38
|
+
return str(first["message"])
|
|
39
|
+
return str(first)
|
|
40
|
+
|
|
41
|
+
reason = response.reason or ""
|
|
42
|
+
return f"{response.status_code} {reason}".strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def deploy_records(manager: "NodeManager", records: Iterable["DNSRecord"]) -> DeploymentResult:
|
|
46
|
+
filtered: list["DNSRecord"] = []
|
|
47
|
+
skipped: MutableMapping["DNSRecord", str] = {}
|
|
48
|
+
for record in records:
|
|
49
|
+
if record.provider != record.Provider.GODADDY:
|
|
50
|
+
skipped[record] = "Unsupported DNS provider"
|
|
51
|
+
continue
|
|
52
|
+
domain = record.get_domain(manager)
|
|
53
|
+
if not domain:
|
|
54
|
+
skipped[record] = "Domain is required for deployment"
|
|
55
|
+
continue
|
|
56
|
+
filtered.append(record)
|
|
57
|
+
|
|
58
|
+
if not filtered:
|
|
59
|
+
return DeploymentResult([], {}, skipped)
|
|
60
|
+
|
|
61
|
+
session = requests.Session()
|
|
62
|
+
session.headers.update(
|
|
63
|
+
{
|
|
64
|
+
"Authorization": manager.get_auth_header(),
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
customer_id = manager.get_customer_id()
|
|
70
|
+
if customer_id:
|
|
71
|
+
session.headers["X-Shopper-Id"] = customer_id
|
|
72
|
+
|
|
73
|
+
grouped: MutableMapping[tuple[str, str, str], list["DNSRecord"]] = defaultdict(list)
|
|
74
|
+
for record in filtered:
|
|
75
|
+
key = (
|
|
76
|
+
record.get_domain(manager),
|
|
77
|
+
record.record_type,
|
|
78
|
+
record.get_name(),
|
|
79
|
+
)
|
|
80
|
+
grouped[key].append(record)
|
|
81
|
+
|
|
82
|
+
deployed: list["DNSRecord"] = []
|
|
83
|
+
failures: MutableMapping["DNSRecord", str] = {}
|
|
84
|
+
now = timezone.now()
|
|
85
|
+
|
|
86
|
+
base_url = manager.get_base_url()
|
|
87
|
+
for (domain, record_type, name), grouped_records in grouped.items():
|
|
88
|
+
payload = [record.to_godaddy_payload() for record in grouped_records]
|
|
89
|
+
url = f"{base_url}/v1/domains/{domain}/records/{record_type}/{name or '@'}"
|
|
90
|
+
try:
|
|
91
|
+
response = session.put(url, json=payload, timeout=30)
|
|
92
|
+
except requests.RequestException as exc:
|
|
93
|
+
message = str(exc)
|
|
94
|
+
for record in grouped_records:
|
|
95
|
+
record.mark_error(message, manager=manager)
|
|
96
|
+
failures[record] = message
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if response.status_code >= 400:
|
|
100
|
+
message = _error_from_response(response)
|
|
101
|
+
for record in grouped_records:
|
|
102
|
+
record.mark_error(message, manager=manager)
|
|
103
|
+
failures[record] = message
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
for record in grouped_records:
|
|
107
|
+
record.mark_deployed(manager=manager, timestamp=now)
|
|
108
|
+
deployed.append(record)
|
|
109
|
+
|
|
110
|
+
return DeploymentResult(deployed, failures, skipped)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def create_resolver() -> dns_resolver.Resolver:
|
|
114
|
+
return dns_resolver.Resolver()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_hostname(value: str) -> str:
|
|
118
|
+
return value.rstrip(".").lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_txt(rdata) -> str:
|
|
122
|
+
strings = getattr(rdata, "strings", None)
|
|
123
|
+
if strings:
|
|
124
|
+
parts = []
|
|
125
|
+
for segment in strings:
|
|
126
|
+
if isinstance(segment, bytes):
|
|
127
|
+
parts.append(segment.decode("utf-8", "ignore"))
|
|
128
|
+
else:
|
|
129
|
+
parts.append(str(segment))
|
|
130
|
+
return "".join(parts)
|
|
131
|
+
return str(rdata).strip('"')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _matches_record(record: "DNSRecord", rdata) -> bool:
|
|
135
|
+
expected = (record.resolve_sigils("data") or "").strip()
|
|
136
|
+
rtype = record.record_type
|
|
137
|
+
|
|
138
|
+
if rtype == record.Type.A:
|
|
139
|
+
return getattr(rdata, "address", str(rdata)) == expected
|
|
140
|
+
if rtype == record.Type.AAAA:
|
|
141
|
+
return getattr(rdata, "address", str(rdata)) == expected
|
|
142
|
+
if rtype == record.Type.CNAME:
|
|
143
|
+
actual = getattr(rdata, "target", None)
|
|
144
|
+
if actual is None:
|
|
145
|
+
return False
|
|
146
|
+
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
147
|
+
if rtype == record.Type.NS:
|
|
148
|
+
actual = getattr(rdata, "target", None)
|
|
149
|
+
if actual is None:
|
|
150
|
+
return False
|
|
151
|
+
return _normalize_hostname(actual.to_text()) == _normalize_hostname(expected)
|
|
152
|
+
if rtype == record.Type.MX:
|
|
153
|
+
host = getattr(rdata, "exchange", None)
|
|
154
|
+
if host is None:
|
|
155
|
+
return False
|
|
156
|
+
host_match = _normalize_hostname(host.to_text()) == _normalize_hostname(expected)
|
|
157
|
+
priority = getattr(rdata, "preference", None)
|
|
158
|
+
priority_match = record.priority is None or priority == record.priority
|
|
159
|
+
return host_match and priority_match
|
|
160
|
+
if rtype == record.Type.SRV:
|
|
161
|
+
target = getattr(rdata, "target", None)
|
|
162
|
+
if target is None:
|
|
163
|
+
return False
|
|
164
|
+
target_match = _normalize_hostname(target.to_text()) == _normalize_hostname(expected)
|
|
165
|
+
priority_match = record.priority is None or getattr(rdata, "priority", None) == record.priority
|
|
166
|
+
weight_match = record.weight is None or getattr(rdata, "weight", None) == record.weight
|
|
167
|
+
port_match = record.port is None or getattr(rdata, "port", None) == record.port
|
|
168
|
+
return target_match and priority_match and weight_match and port_match
|
|
169
|
+
if rtype == record.Type.TXT:
|
|
170
|
+
actual = _extract_txt(rdata)
|
|
171
|
+
return actual == expected
|
|
172
|
+
|
|
173
|
+
return str(rdata).strip() == expected
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_record(
|
|
177
|
+
record: "DNSRecord", resolver: dns_resolver.Resolver | None = None
|
|
178
|
+
) -> tuple[bool, str]:
|
|
179
|
+
resolver = resolver or create_resolver()
|
|
180
|
+
fqdn = record.fqdn()
|
|
181
|
+
if not fqdn:
|
|
182
|
+
message = "Domain is required for validation"
|
|
183
|
+
record.mark_error(message)
|
|
184
|
+
return False, message
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
answers = resolver.resolve(fqdn, record.record_type)
|
|
188
|
+
except (dns_exception.DNSException, OSError) as exc:
|
|
189
|
+
message = str(exc)
|
|
190
|
+
record.mark_error(message)
|
|
191
|
+
return False, message
|
|
192
|
+
|
|
193
|
+
for rdata in answers:
|
|
194
|
+
if _matches_record(record, rdata):
|
|
195
|
+
record.last_verified_at = timezone.now()
|
|
196
|
+
record.last_error = ""
|
|
197
|
+
record.save(update_fields=["last_verified_at", "last_error"])
|
|
198
|
+
return True, ""
|
|
199
|
+
|
|
200
|
+
message = "DNS record does not match expected value"
|
|
201
|
+
record.mark_error(message)
|
|
202
|
+
return False, message
|
|
203
|
+
|