arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/apps.py CHANGED
@@ -1,70 +1,98 @@
1
- import logging
2
- import os
3
- import socket
4
- import threading
5
- import time
6
- from pathlib import Path
7
-
8
- from django.apps import AppConfig
9
- from django.conf import settings
10
- from django.db import connections
11
- from django.db.utils import OperationalError
12
- from utils import revision
13
-
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- def _startup_notification() -> None:
19
- """Queue a Net Message with ``hostname:port`` and version info."""
20
-
21
- host = socket.gethostname()
22
-
23
- port = os.environ.get("PORT", "8000")
24
-
25
- version = ""
26
- ver_path = Path(settings.BASE_DIR) / "VERSION"
27
- if ver_path.exists():
28
- version = ver_path.read_text().strip()
29
-
30
- revision_value = revision.get_revision()
31
- rev_short = revision_value[-6:] if revision_value else ""
32
-
33
- body = version
34
- if rev_short:
35
- body = f"{body} r{rev_short}" if body else f"r{rev_short}"
36
-
37
- def _worker() -> None: # pragma: no cover - background thread
38
- # Allow the LCD a moment to become ready and retry a few times
39
- for _ in range(5):
40
- try:
41
- from nodes.models import NetMessage
42
-
43
- NetMessage.broadcast(subject=f"{host}:{port}", body=body)
44
- break
45
- except Exception:
46
- time.sleep(1)
47
-
48
- threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
49
-
50
-
51
- def _trigger_startup_notification(**_: object) -> None:
52
- """Attempt to send the startup notification in the background."""
53
-
54
- try:
55
- connections["default"].ensure_connection()
56
- except OperationalError:
57
- logger.exception("Startup notification skipped: database unavailable")
58
- return
59
- _startup_notification()
60
-
61
-
62
- class NodesConfig(AppConfig):
63
- default_auto_field = "django.db.models.BigAutoField"
64
- name = "nodes"
65
- verbose_name = "4. Infrastructure"
66
-
67
- def ready(self): # pragma: no cover - exercised on app start
68
- from django.db.models.signals import post_migrate
69
-
70
- post_migrate.connect(_trigger_startup_notification, sender=self)
1
+ import logging
2
+ import os
3
+ import socket
4
+ import sys
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from django.apps import AppConfig
10
+ from django.conf import settings
11
+ from django.db import connections
12
+ from django.db.utils import OperationalError
13
+ from utils import revision
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _startup_notification() -> None:
20
+ """Queue a Net Message with ``hostname:port`` and version info."""
21
+
22
+ host = socket.gethostname()
23
+
24
+ port = os.environ.get("PORT", "8000")
25
+
26
+ version = ""
27
+ ver_path = Path(settings.BASE_DIR) / "VERSION"
28
+ if ver_path.exists():
29
+ version = ver_path.read_text().strip()
30
+
31
+ revision_value = revision.get_revision()
32
+ rev_short = revision_value[-6:] if revision_value else ""
33
+
34
+ body = version
35
+ if body:
36
+ normalized = body.lstrip("vV") or body
37
+ base_version = normalized.rstrip("+")
38
+ needs_marker = False
39
+ if base_version and revision_value:
40
+ try: # pragma: no cover - defensive guard
41
+ from core.models import PackageRelease
42
+
43
+ needs_marker = not PackageRelease.matches_revision(
44
+ base_version, revision_value
45
+ )
46
+ except Exception:
47
+ logger.debug("Startup release comparison failed", exc_info=True)
48
+ if needs_marker and not normalized.endswith("+"):
49
+ body = f"{body}+"
50
+ if rev_short:
51
+ body = f"{body} r{rev_short}" if body else f"r{rev_short}"
52
+
53
+ def _worker() -> None: # pragma: no cover - background thread
54
+ # Allow the LCD a moment to become ready and retry a few times
55
+ for _ in range(5):
56
+ try:
57
+ from nodes.models import NetMessage
58
+
59
+ NetMessage.broadcast(subject=f"{host}:{port}", body=body)
60
+ break
61
+ except Exception:
62
+ time.sleep(1)
63
+
64
+ threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
65
+
66
+
67
+ def _trigger_startup_notification(**_: object) -> None:
68
+ """Attempt to send the startup notification in the background."""
69
+
70
+ if _is_running_migration_command():
71
+ logger.debug("Startup notification skipped: running migration command")
72
+ return
73
+
74
+ try:
75
+ connections["default"].ensure_connection()
76
+ except OperationalError:
77
+ logger.exception("Startup notification skipped: database unavailable")
78
+ return
79
+ _startup_notification()
80
+
81
+
82
+ def _is_running_migration_command() -> bool:
83
+ """Return ``True`` when Django's ``migrate`` command is executing."""
84
+
85
+ return len(sys.argv) > 1 and sys.argv[1] == "migrate"
86
+
87
+
88
+ class NodesConfig(AppConfig):
89
+ default_auto_field = "django.db.models.BigAutoField"
90
+ name = "nodes"
91
+ verbose_name = "4. Infrastructure"
92
+
93
+ def ready(self): # pragma: no cover - exercised on app start
94
+ from django.db.models.signals import post_migrate
95
+
96
+ post_migrate.connect(_trigger_startup_notification, sender=self)
97
+ # Import signal handlers for content classifiers
98
+ from . import signals # noqa: F401
nodes/backends.py CHANGED
@@ -1,53 +1,160 @@
1
- from __future__ import annotations
2
-
3
- from django.core.mail.backends.base import BaseEmailBackend
4
- from django.core.mail import get_connection
5
- from django.conf import settings
6
- from django.db.models import Q
7
-
8
- from .models import EmailOutbox
9
-
10
-
11
- class OutboxEmailBackend(BaseEmailBackend):
12
- """Email backend that selects an :class:`EmailOutbox` automatically.
13
-
14
- If a matching outbox exists for the message's ``from_email`` (matching
15
- 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.
18
- """
19
-
20
- def _select_outbox(self, from_email: str | None) -> EmailOutbox | None:
21
- if from_email:
22
- return (
23
- EmailOutbox.objects.filter(
24
- Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
25
- ).first()
26
- or EmailOutbox.objects.first()
27
- )
28
- return EmailOutbox.objects.first()
29
-
30
- def send_messages(self, email_messages):
31
- sent = 0
32
- for message in email_messages:
33
- outbox = self._select_outbox(message.from_email)
34
- if outbox:
35
- connection = outbox.get_connection()
36
- if not message.from_email:
37
- message.from_email = (
38
- outbox.from_email or settings.DEFAULT_FROM_EMAIL
39
- )
40
- else:
41
- connection = get_connection(
42
- "django.core.mail.backends.smtp.EmailBackend"
43
- )
44
- if not message.from_email:
45
- message.from_email = settings.DEFAULT_FROM_EMAIL
46
- try:
47
- sent += connection.send_messages([message]) or 0
48
- finally:
49
- try:
50
- connection.close()
51
- except Exception: # pragma: no cover - close errors shouldn't fail send
52
- pass
53
- return sent
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from collections import defaultdict
5
+
6
+ from django.core.mail.backends.base import BaseEmailBackend
7
+ from django.core.mail import get_connection
8
+ from django.conf import settings
9
+ from django.db.models import Q
10
+
11
+ from .models import EmailOutbox
12
+
13
+
14
+ class OutboxEmailBackend(BaseEmailBackend):
15
+ """Email backend that selects an :class:`EmailOutbox` automatically.
16
+
17
+ If a matching outbox exists for the message's ``from_email`` (matching
18
+ either ``from_email`` or ``username``), that outbox's SMTP credentials are
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.
23
+ """
24
+
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
+
44
+ if from_email:
45
+ email_matches = list(
46
+ enabled_outboxes.filter(
47
+ Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
48
+ )
49
+ )
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()
110
+
111
+ def send_messages(self, email_messages):
112
+ sent = 0
113
+ for message in email_messages:
114
+ original_from_email = message.from_email
115
+ outbox, fallbacks = self._select_outbox(message)
116
+ tried_outboxes = []
117
+ if outbox:
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()
126
+ message.from_email = (
127
+ original_from_email
128
+ or candidate.from_email
129
+ or settings.DEFAULT_FROM_EMAIL
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
145
+ else:
146
+ connection = get_connection(
147
+ "django.core.mail.backends.smtp.EmailBackend"
148
+ )
149
+ if not message.from_email:
150
+ message.from_email = settings.DEFAULT_FROM_EMAIL
151
+ try:
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
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
+