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.

Files changed (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {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 = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
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
- None,
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 = ("subject", "body", "reach", "created", "complete")
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. Otherwise, the first available outbox is used. When no outboxes are
17
- configured, the system falls back to Django's default SMTP settings.
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 _select_outbox(self, from_email: str | None) -> EmailOutbox | None:
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
- return (
23
- EmailOutbox.objects.filter(
45
+ email_matches = list(
46
+ enabled_outboxes.filter(
24
47
  Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
25
- ).first()
26
- or EmailOutbox.objects.first()
48
+ )
27
49
  )
28
- return EmailOutbox.objects.first()
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
- outbox = self._select_outbox(message.from_email)
114
+ original_from_email = message.from_email
115
+ outbox, fallbacks = self._select_outbox(message)
116
+ tried_outboxes = []
34
117
  if outbox:
35
- connection = outbox.get_connection()
36
- if not message.from_email:
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
- outbox.from_email or settings.DEFAULT_FROM_EMAIL
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.close()
51
- except Exception: # pragma: no cover - close errors shouldn't fail send
52
- pass
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
+