Coverage for netbox_data_import/contact_resolution.py: 99%

314 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-09 20:50 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# Copyright (C) 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Resolve, review, and apply one primary Contact decision.""" 

4 

5from __future__ import annotations 

6 

7import re 

8from copy import deepcopy 

9from dataclasses import dataclass 

10 

11from django.contrib.contenttypes.models import ContentType 

12from django.core.exceptions import ValidationError 

13from django.core.validators import validate_email 

14from django.db import connection, transaction 

15from django.db.models import Q 

16 

17from .models import CONTACT_RESOLUTION_FIELDS, stored_import_source, validate_contact_candidate_resolution 

18from .object_permissions import ObjectPermissionDenied, enforce_saved_object_permission 

19 

20 

21def _text(value) -> str: 

22 """Return a stripped string for one optional source value.""" 

23 if value is None: 

24 return "" 

25 return str(value).strip() 

26 

27 

28# `name` is proposed from its header, because any text is a valid name. The `email` and `phone` 

29# entries only keep a column that names a different field out of the name proposal. 

30_ROLE_HEADER_HINTS = { 

31 "email": ("email", "mail"), 

32 "phone": ("phone", "number", "tel", "mobile", "cell"), 

33 "name": ("name", "contact", "person"), 

34} 

35_PHONE_PUNCTUATION = re.compile(r"[\s()\-./]") 

36_PHONE_SHAPE = re.compile(r"\+?\d{7,}") 

37 

38 

39def _looks_like_email(value: str) -> bool: 

40 try: 

41 validate_email(value) 

42 except ValidationError: 

43 return False 

44 return True 

45 

46 

47def _looks_like_phone(value: str) -> bool: 

48 return bool(_PHONE_SHAPE.fullmatch(_PHONE_PUNCTUATION.sub("", value))) 

49 

50 

51def _header_hints_at(source_column: str, role: str) -> bool: 

52 lowered = source_column.lower() 

53 return any(hint in lowered for hint in _ROLE_HEADER_HINTS[role]) 

54 

55 

56def suggest_contact_roles(candidate_values: dict[str, str]) -> dict[str, str]: 

57 """Map each Contact field to the candidate column that most likely supplies it. 

58 

59 Returns ``{role: source_column}`` for the roles it can recognize, and leaves out the rest 

60 so the operator decides. The value shape settles ``email`` and ``phone``. Only a header 

61 keyword settles ``name``, because any text is a valid name and an organization looks the 

62 same as a person. 

63 """ 

64 by_shape = { 

65 "email": [column for column, value in candidate_values.items() if _looks_like_email(value)], 

66 "phone": [column for column, value in candidate_values.items() if _looks_like_phone(value)], 

67 } 

68 suggestions = {} 

69 for role, columns in by_shape.items(): 

70 # A header says which field a value can feed, never which of several same-shaped values 

71 # is the right one: `Backup Email` carries the keyword that `Primary Contact` does not. 

72 # So a second value of the same shape means the operator decides, because the collapsed 

73 # modal turns a proposal into a one-click save. 

74 if len(columns) == 1: 

75 suggestions[role] = columns[0] 

76 

77 recognized = set(by_shape["email"]) | set(by_shape["phone"]) 

78 claimed = set(suggestions.values()) 

79 # A header that says "email" or "phone" holds a malformed value of that type, never a name. 

80 named = [ 

81 column 

82 for column in candidate_values 

83 if column not in claimed 

84 and column not in recognized 

85 and _header_hints_at(column, "name") 

86 and not _header_hints_at(column, "email") 

87 and not _header_hints_at(column, "phone") 

88 ] 

89 if len(named) == 1: 

90 suggestions["name"] = named[0] 

91 return suggestions 

92 

93 

94@dataclass(frozen=True) 

95class ContactSelection: 

96 """The resolved Contact values and optional selected NetBox identity.""" 

97 

98 values: dict[str, str] 

99 contact_id: int | None = None 

100 

101 

102@dataclass(frozen=True) 

103class ContactReview: 

104 """A read-only Contact decision shared by preview and write paths.""" 

105 

106 selection: ContactSelection | None 

107 extra_columns: dict 

108 plan: dict | None 

109 candidate_values: dict[str, str] 

110 suggestion: dict | None 

111 

112 

113class DanglingProfileReference(ValidationError): 

114 """Report an `adapter_config` natural key that no longer resolves to a NetBox object.""" 

115 

116 

117class ContactResolutionRequired(ValidationError): 

118 """Require an operator decision for ambiguous or invalid Contact values.""" 

119 

120 candidate_target = "contact" 

121 

122 def __init__(self, candidate_values: dict[str, str], message: str | None = None, suggestion=None): 

123 self.candidate_values = candidate_values 

124 self.suggestion = suggestion 

125 super().__init__( 

126 { 

127 "contact": message 

128 or "Select which candidate values supply Contact fields, enter Contact details, or select no contact." 

129 } 

130 ) 

131 

132 

133def contact_identity(contact) -> dict: 

134 """Return the persisted Contact fields the preview picker needs to show it again.""" 

135 return {"id": contact.pk, "name": contact.name, "email": contact.email, "phone": contact.phone} 

136 

137 

138class PrimaryContactResolver: 

139 """Hide Contact resolution, lookup, assignment, and JSON migration behind one interface.""" 

140 

141 @staticmethod 

142 def candidate_source_columns(profile) -> dict[str, frozenset[str]]: 

143 """Return the source columns each candidate target collects, grouped by target.""" 

144 grouped: dict[str, set[str]] = {} 

145 for mapping in profile.column_mappings.filter(target_field__startswith="candidate:"): 

146 target = mapping.target_field.removeprefix("candidate:") 

147 grouped.setdefault(target, set()).add(mapping.source_column) 

148 return {target: frozenset(columns) for target, columns in grouped.items()} 

149 

150 @staticmethod 

151 def _candidate_values(row, candidate_source_columns, extra_columns) -> dict[str, str]: 

152 row_values = row.get("_candidate_values", {}).get("contact", {}) 

153 values = ( 

154 {str(source): _text(value) for source, value in row_values.items() if _text(value)} 

155 if isinstance(row_values, dict) 

156 else {} 

157 ) 

158 for source_column in candidate_source_columns.get("contact", ()): 

159 value = _text(extra_columns.pop(source_column, "")) 

160 if value and source_column not in values: 

161 values[source_column] = value 

162 return values 

163 

164 @classmethod 

165 def selection_for_resolution(cls, profile, resolved_fields, candidate_values) -> ContactSelection | None: 

166 """Return the Contact one saved resolution names, without planning an assignment.""" 

167 normalized = validate_contact_candidate_resolution( 

168 { 

169 "contact_resolution_applied": True, 

170 "contact_field_sources": resolved_fields.get("contact_field_sources", {}), 

171 "contact_field_values": resolved_fields.get("contact_field_values", {}), 

172 "contact_id": resolved_fields.get("contact_id"), 

173 }, 

174 profile.adapter_settings.primary_contact_lookup_field, 

175 candidate_values, 

176 ) 

177 values = dict(normalized["field_values"]) 

178 for field_name, source_column in normalized["field_sources"].items(): 

179 values[field_name] = candidate_values[source_column] 

180 if not values and normalized["contact_id"] is None: 

181 return None 

182 return ContactSelection(values=values, contact_id=normalized["contact_id"]) 

183 

184 @classmethod 

185 def _selection(cls, row, profile, candidate_values, legacy_primary_contact) -> ContactSelection | None: 

186 if row.get("contact_resolution_applied") is True: 

187 return cls.selection_for_resolution(profile, row, candidate_values) 

188 

189 if legacy_primary_contact: 

190 candidate_values.setdefault("Legacy primary contact", legacy_primary_contact) 

191 return ContactSelection( 

192 values={ 

193 "name": legacy_primary_contact, 

194 profile.adapter_settings.primary_contact_lookup_field: legacy_primary_contact, 

195 } 

196 ) 

197 if not candidate_values: 

198 return None 

199 raise ContactResolutionRequired(candidate_values) 

200 

201 @classmethod 

202 def review( 

203 cls, 

204 obj, 

205 row: dict, 

206 profile, 

207 user=None, 

208 *, 

209 candidate_source_columns: dict[str, frozenset[str]] | None = None, 

210 ) -> ContactReview: 

211 """Return the effective Contact plan without writing database state.""" 

212 extra_columns = {} 

213 import_source = stored_import_source(obj) 

214 if import_source is not None and isinstance(import_source.extra_columns, dict): 

215 extra_columns.update(import_source.extra_columns) 

216 row_extra = row.get("_extra_columns") 

217 if isinstance(row_extra, dict): 

218 extra_columns.update(row_extra) 

219 

220 legacy_primary_contact = _text(row.get("primary_contact")) or _text(extra_columns.get("primary_contact")) 

221 extra_columns.pop("primary_contact", None) 

222 source_columns = ( 

223 cls.candidate_source_columns(profile) if candidate_source_columns is None else candidate_source_columns 

224 ) 

225 candidate_values = cls._candidate_values(row, source_columns, extra_columns) 

226 try: 

227 selection = cls._selection(row, profile, candidate_values, legacy_primary_contact) 

228 plan = None if selection is None else cls._plan(obj, profile, selection, user) 

229 except ContactResolutionRequired as exc: 

230 exc.suggestion = cls.suggest(candidate_values, profile, user) 

231 raise 

232 except DanglingProfileReference: 

233 # The profile is at fault, so candidate values cannot turn this into a row decision. 

234 raise 

235 except ValidationError as exc: 

236 if not candidate_values: 

237 raise 

238 suggestion = cls.suggest(candidate_values, profile, user) 

239 raise ContactResolutionRequired(candidate_values, "; ".join(exc.messages), suggestion) from exc 

240 

241 suggestion = cls._suggestion_from_plan(plan) 

242 return ContactReview(selection, extra_columns, plan, candidate_values, suggestion) 

243 

244 @staticmethod 

245 def _suggestion_from_plan(plan) -> dict | None: 

246 if not plan or plan["contact_action"] != "reuse": 

247 return None 

248 return { 

249 "id": plan["contact_id"], 

250 "name": plan["contact_name"], 

251 "email": plan["contact_email"], 

252 "phone": plan["contact_phone"], 

253 } 

254 

255 @staticmethod 

256 def _unique_contact(values, fields, user) -> dict | None: 

257 """Return the one visible Contact that an exact match on *fields* identifies.""" 

258 from tenancy.models import Contact 

259 

260 query = Q() 

261 for field in fields: 

262 for value in values: 

263 query |= Q(**{f"{field}__iexact": value}) 

264 contacts = Contact.objects.filter(query) 

265 if user is not None: 

266 contacts = contacts.restrict(user, "view") 

267 matches = list(contacts.order_by("pk")[:2]) 

268 if len(matches) != 1: 

269 return None 

270 contact = matches[0] 

271 return { 

272 "id": contact.pk, 

273 "name": contact.name, 

274 "email": contact.email, 

275 "phone": contact.phone, 

276 } 

277 

278 @classmethod 

279 def suggest(cls, candidate_values: dict[str, str], profile, user=None) -> dict | None: 

280 """Return the one visible Contact that a candidate value in this row identifies.""" 

281 values = [value for value in map(_text, candidate_values.values()) if value] 

282 if not values: 

283 return None 

284 lookup_field = profile.adapter_settings.primary_contact_lookup_field 

285 # The configured lookup field answers first. A row carrying only a name matches nothing 

286 # there, so the remaining identity fields answer rather than leaving the picker empty. 

287 for fields in ([lookup_field], sorted(CONTACT_RESOLUTION_FIELDS - {lookup_field})): 

288 match = cls._unique_contact(values, fields, user) 

289 if match is not None: 

290 return match 

291 return None 

292 

293 @classmethod 

294 def _plan_assignment(cls, obj, role, contact, user, lock): 

295 from tenancy.models import ContactAssignment 

296 

297 if obj is None: 

298 if user is not None and not user.has_perm("tenancy.add_contactassignment"): 

299 raise ObjectPermissionDenied("tenancy.add_contactassignment") 

300 return None, None, "create" 

301 

302 scope = { 

303 "object_type": ContentType.objects.get_for_model(obj), 

304 "object_id": obj.pk, 

305 "role": role, 

306 } 

307 assignments = ContactAssignment.objects.select_for_update() if lock else ContactAssignment.objects 

308 primary_assignments = list(assignments.filter(**scope, priority="primary")[:2]) 

309 if len(primary_assignments) > 1: 

310 raise ValidationError( 

311 {"primary_contact": "More than one primary assignment exists for the selected contact role."} 

312 ) 

313 primary_assignment = primary_assignments[0] if primary_assignments else None 

314 assignment = assignments.filter(**scope, contact=contact).first() if contact.pk is not None else None 

315 

316 if primary_assignment is not None and primary_assignment.contact_id != contact.pk: 

317 enforce_saved_object_permission(primary_assignment, user, "change") 

318 if assignment is None: 

319 return primary_assignment, None, "replace" 

320 enforce_saved_object_permission(assignment, user, "change") 

321 return primary_assignment, assignment, "demote_and_promote" 

322 if assignment is None: 

323 if user is not None and not user.has_perm("tenancy.add_contactassignment"): 

324 raise ObjectPermissionDenied("tenancy.add_contactassignment") 

325 return primary_assignment, None, "create" 

326 if assignment.priority != "primary": 

327 enforce_saved_object_permission(assignment, user, "change") 

328 return primary_assignment, assignment, "promote" 

329 return primary_assignment, assignment, "unchanged" 

330 

331 @classmethod 

332 def _contact_for_values(cls, contact_values, lookup_field, user, contact_queryset): 

333 """Return the stored Contact these values identify, or an unsaved one to create.""" 

334 from tenancy.models import Contact 

335 

336 value = _text(contact_values.get(lookup_field)) 

337 if not value: 

338 raise ValidationError( 

339 {"primary_contact": f"Select or enter a value for the Contact {lookup_field} lookup field."} 

340 ) 

341 if lookup_field == "email": 

342 validate_email(value) 

343 proposed_contact = Contact(**contact_values) 

344 proposed_contact.full_clean() 

345 contacts = list(contact_queryset.filter(**{f"{lookup_field}__iexact": value})[:2]) 

346 if len(contacts) > 1: 

347 raise ValidationError({"primary_contact": f"More than one contact has the {lookup_field} value '{value}'."}) 

348 if contacts: 

349 contact = contacts[0] 

350 if user is not None and not Contact.objects.restrict(user, "view").filter(pk=contact.pk).exists(): 

351 raise ObjectPermissionDenied("tenancy.view_contact") 

352 return contact 

353 if user is not None and not user.has_perm("tenancy.add_contact"): 

354 raise ObjectPermissionDenied("tenancy.add_contact") 

355 return proposed_contact 

356 

357 @staticmethod 

358 def _reject_moved_lookup(contact, selection: ContactSelection, lookup_field) -> None: 

359 """Refuse a selected Contact whose lookup value no longer matches the saved decision.""" 

360 selected = _text(selection.values.get(lookup_field)) 

361 if selected and selected.casefold() != _text(getattr(contact, lookup_field)).casefold(): 

362 raise ValidationError( 

363 {"primary_contact": f"The selected Contact no longer has the chosen {lookup_field} value."} 

364 ) 

365 

366 @classmethod 

367 def create_contact(cls, profile, selection: ContactSelection, user=None): 

368 """Store the Contact one resolution names, so it exists before any import runs. 

369 

370 Returns ``(contact, created)``. A row whose values already name a stored Contact reuses it, 

371 which is what keeps a second save from making a duplicate identity. 

372 """ 

373 from tenancy.models import Contact 

374 

375 lookup_field = profile.adapter_settings.primary_contact_lookup_field 

376 if selection.contact_id is not None: 

377 contacts = Contact.objects.restrict(user, "view") if user is not None else Contact.objects 

378 contact = contacts.filter(pk=selection.contact_id).first() 

379 if contact is None: 

380 raise ValidationError({"primary_contact": "The selected NetBox Contact no longer exists."}) 

381 # `_plan` refuses this later, so storing it here would report success on a doomed decision. 

382 cls._reject_moved_lookup(contact, selection, lookup_field) 

383 return contact, False 

384 

385 with transaction.atomic(): 

386 cls.lock_imports() 

387 contact = cls._contact_for_values(selection.values, lookup_field, user, Contact.objects.select_for_update()) 

388 if contact.pk is not None: 

389 # atomic-exit-safe: stored-contact-reused 

390 return contact, False 

391 contact.save() 

392 enforce_saved_object_permission(contact, user, "add") 

393 # atomic-exit-safe: success-commit-intended 

394 return contact, True 

395 

396 @classmethod 

397 def _plan(cls, obj, profile, selection: ContactSelection, user=None, lock=False) -> dict: 

398 role_name = profile.adapter_settings.primary_contact_role 

399 if not role_name: 

400 raise ValidationError({"primary_contact": "Select a primary contact role on the import profile."}) 

401 # The profile memoizes the role, and one instance serves both review and apply. Re-read it 

402 # under the apply lock so a role deleted in between is refused here, not by the FK check. 

403 if lock: 

404 from tenancy.models import ContactRole 

405 

406 role = ContactRole.objects.select_for_update().filter(name=role_name).first() 

407 else: 

408 role = profile.resolved_primary_contact_role 

409 if role is None: 

410 raise DanglingProfileReference( 

411 { 

412 "primary_contact": f"The import profile references Contact Role '{role_name}', which no longer exists." 

413 } 

414 ) 

415 

416 from tenancy.models import Contact 

417 

418 lookup_field = profile.adapter_settings.primary_contact_lookup_field 

419 contact_queryset = Contact.objects.select_for_update() if lock else Contact.objects 

420 if selection.contact_id is not None: 

421 contact = contact_queryset.filter(pk=selection.contact_id).first() 

422 if contact is None: 

423 raise ValidationError({"primary_contact": "The selected NetBox Contact no longer exists."}) 

424 if user is not None and not Contact.objects.restrict(user, "view").filter(pk=contact.pk).exists(): 

425 raise ObjectPermissionDenied("tenancy.view_contact") 

426 cls._reject_moved_lookup(contact, selection, lookup_field) 

427 contact_values = { 

428 "name": contact.name, 

429 "email": contact.email, 

430 "phone": contact.phone, 

431 } 

432 else: 

433 contact_values = selection.values 

434 contact = cls._contact_for_values(contact_values, lookup_field, user, contact_queryset) 

435 

436 primary_assignment, assignment, assignment_action = cls._plan_assignment(obj, role, contact, user, lock) 

437 return { 

438 "lookup_field": lookup_field, 

439 "value": _text(getattr(contact, lookup_field, "")) or _text(contact_values.get(lookup_field)), 

440 "contact_values": contact_values, 

441 "role_id": role.pk, 

442 "contact_id": contact.pk, 

443 "contact_name": contact.name, 

444 "contact_email": contact.email, 

445 "contact_phone": contact.phone, 

446 "contact_action": "reuse" if contact.pk is not None else "create", 

447 "primary_assignment_id": primary_assignment.pk if primary_assignment is not None else None, 

448 "assignment_id": assignment.pk if assignment is not None else None, 

449 "assignment_action": assignment_action, 

450 } 

451 

452 @classmethod 

453 def apply(cls, obj, profile, review: ContactReview, user=None) -> dict | None: 

454 """Apply one reviewed Contact decision and remove its legacy JSON value.""" 

455 if review.selection is None: 

456 cls._remove_legacy_json(obj) 

457 return None 

458 

459 from tenancy.models import Contact, ContactAssignment 

460 

461 with transaction.atomic(): 

462 cls.lock_imports() 

463 plan = cls._plan(obj, profile, review.selection, user, lock=True) 

464 if plan["contact_id"] is None: 

465 contact = Contact(**plan["contact_values"]) 

466 contact.full_clean() 

467 contact.save() 

468 enforce_saved_object_permission(contact, user, "add") 

469 else: 

470 contact = Contact.objects.get(pk=plan["contact_id"]) 

471 

472 scope = { 

473 "object_type": ContentType.objects.get_for_model(obj), 

474 "object_id": obj.pk, 

475 "role_id": plan["role_id"], 

476 } 

477 action = plan["assignment_action"] 

478 assignment = None 

479 if action == "replace": 

480 assignment = ContactAssignment.objects.get(pk=plan["primary_assignment_id"]) 

481 assignment.contact = contact 

482 assignment.full_clean() 

483 assignment.save(update_fields=["contact"]) 

484 enforce_saved_object_permission(assignment, user, "change") 

485 elif action == "demote_and_promote": 

486 previous = ContactAssignment.objects.get(pk=plan["primary_assignment_id"]) 

487 previous.priority = "secondary" 

488 previous.full_clean() 

489 previous.save(update_fields=["priority"]) 

490 enforce_saved_object_permission(previous, user, "change") 

491 assignment = ContactAssignment.objects.get(pk=plan["assignment_id"]) 

492 elif action in ("promote", "unchanged"): 

493 assignment = ContactAssignment.objects.get(pk=plan["assignment_id"]) 

494 

495 if assignment is None: 

496 assignment = ContactAssignment(contact=contact, priority="primary", **scope) 

497 assignment.full_clean() 

498 assignment.save() 

499 enforce_saved_object_permission(assignment, user, "add") 

500 elif assignment.priority != "primary": 

501 assignment.priority = "primary" 

502 assignment.full_clean() 

503 assignment.save(update_fields=["priority"]) 

504 enforce_saved_object_permission(assignment, user, "change") 

505 

506 cls._remove_legacy_json(obj) 

507 plan["saved_contact"] = contact_identity(contact) 

508 # atomic-exit-safe: success-commit-intended 

509 return plan 

510 

511 @staticmethod 

512 def _remove_legacy_json(obj) -> None: 

513 """Drop the legacy primary_contact value once a native Contact assignment holds it.""" 

514 import_source = stored_import_source(obj) 

515 if import_source is None or "primary_contact" not in import_source.extra_columns: 

516 return 

517 extra_columns = deepcopy(import_source.extra_columns) 

518 extra_columns.pop("primary_contact") 

519 import_source.extra_columns = extra_columns 

520 import_source.save(update_fields=["extra_columns"]) 

521 

522 @staticmethod 

523 def lock_imports() -> None: 

524 """Serialize import jobs that can create shared Contact identities.""" 

525 with connection.cursor() as cursor: 

526 cursor.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", ["netbox_data_import.contact_sync"])