Coverage for netbox_data_import/views.py: 96%

2403 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) 2025 Marcin Zieba <marcinpsk@gmail.com> 

3import difflib 

4import logging 

5import time 

6import uuid 

7from collections.abc import Mapping 

8from dataclasses import dataclass, replace 

9from typing import NamedTuple 

10from urllib.parse import parse_qs, urlsplit 

11 

12from django.contrib import messages 

13from django.contrib.auth.mixins import PermissionRequiredMixin 

14from django.core.exceptions import ValidationError 

15from django.db import DatabaseError, IntegrityError, transaction 

16from django.http import Http404, HttpResponse, JsonResponse 

17from django.shortcuts import get_object_or_404, redirect, render 

18from django.urls import reverse 

19from django.utils.http import url_has_allowed_host_and_scheme 

20from django.views import View 

21from netbox.views import generic 

22from utilities.permissions import get_permission_for_model 

23from utilities.views import ConditionalLoginRequiredMixin 

24 

25from .filters import ImportProfileFilterSet 

26from .forms import ( 

27 CableClassMappingForm, 

28 InferenceBackendForm, 

29 ClassRoleMappingForm, 

30 ColumnMappingForm, 

31 ColumnTransformRuleForm, 

32 DeviceTypeMappingForm, 

33 ImportProfileBulkEditForm, 

34 ImportProfileForm, 

35 ImportProfileImportForm, 

36 ImportSetupForm, 

37) 

38from .catalog import CANDIDATE_TARGET_PREFIX, CATALOG, POLICY_SECTIONS 

39from .values import ( 

40 effective_device_name, 

41 identity_text, 

42 normalize_for_compare, 

43 source_position, 

44 source_text, 

45 status_map, 

46 translation_maps, 

47) 

48from . import __version__ as _plugin_version 

49from .models import ( 

50 CableClassMapping, 

51 InferenceBackend, 

52 locked_profile_policy, 

53 locked_resolution_policy, 

54 ClassRoleMapping, 

55 ColumnMapping, 

56 ColumnTransformRule, 

57 DeviceExistingMatch, 

58 DeviceTypeMapping, 

59 IgnoredFieldDifference, 

60 ImportExecution, 

61 ImportProfile, 

62 ManufacturerMapping, 

63 SourceDocument, 

64 SourceResolution, 

65 stored_import_source, 

66 validate_contact_candidate_resolution, 

67 validate_adapter_target_module, 

68 validate_registered_adapter, 

69 validate_source_resolution_fields, 

70) 

71from .tables import ( 

72 CableClassMappingTable, 

73 InferenceBackendTable, 

74 ClassRoleMappingTable, 

75 ColumnMappingTable, 

76 ColumnTransformRuleTable, 

77 DeviceTypeMappingTable, 

78 ImportExecutionTable, 

79 ImportProfileTable, 

80) 

81from . import adapters, ip_assignment 

82from .contact_resolution import PrimaryContactResolver, contact_identity, suggest_contact_roles 

83from .device_field_review import DeviceFieldReviewer 

84from .object_permissions import ( 

85 ObjectPermissionDenied, 

86 delete_permission_scoped_objects, 

87 save_or_refetch, 

88 save_permission_scoped_object, 

89) 

90from .preview_row_actions import ( 

91 PREVIEW_DIRTY_SESSION_KEY, 

92 PREVIEW_PLAN_SESSION_KEY, 

93 PREVIEW_USE_MATERIALIZED_ONCE_SESSION_KEY, 

94 PreviewActionInvalid, 

95 PreviewLocked, 

96 assert_preview_may_move, 

97 clear_preview_state, 

98 current_preview_revision, 

99 load_cached_preview, 

100 mark_preview_dirty, 

101 pending_preview_payload, 

102 record_recalculated_preview, 

103 restore_preview_plan, 

104 retained_sync_block_reason, 

105 retire_preview_revision, 

106 start_new_preview, 

107) 

108from .import_engine import ( 

109 ImportEngine, 

110 PreconditionFailed, 

111 SelectionError, 

112 StalePlan, 

113 StaleSourceDocument, 

114 operator_failure_message, 

115) 

116from .cable_target import ELIGIBLE_TERMINATION_LIMIT, eligible_terminations 

117from .field_keys import SELECT_TERMINATION_TASK 

118from .netbox_reader import NetBoxReader, PlanningTargetUnavailable 

119from .plan import ImportPlan, PlanError, fingerprint_of 

120from .review_workspace import ReviewWorkspace, save_termination_resolution_and_replan 

121 

122 

123def _safe_next_url(request, fallback: str) -> str: 

124 """Return a validated same-host redirect URL from POST or the fallback view name.""" 

125 url = request.POST.get("next", "") 

126 if url and url_has_allowed_host_and_scheme( 

127 url, allowed_hosts={request.get_host()}, require_https=request.is_secure() 

128 ): 

129 return url 

130 return reverse(fallback) 

131 

132 

133def _navigation_response(request, url): 

134 """Send an HTMX caller through a real page load, or redirect a standard browser.""" 

135 if request.headers.get("HX-Request") == "true": 

136 response = HttpResponse(status=204) 

137 response["HX-Redirect"] = url 

138 return response 

139 return redirect(url) 

140 

141 

142def _name_resolution_response(request, url): 

143 """Return an updated preview for HTMX or redirect a standard browser.""" 

144 if request.headers.get("HX-Request") == "true": 

145 preview_path = reverse("plugins:netbox_data_import:import_preview") 

146 if urlsplit(url).path == preview_path: 

147 preview_response = ImportPreviewView().render_preview(request, url) 

148 if not 300 <= preview_response.status_code < 400: 

149 return preview_response 

150 url = preview_response.headers["Location"] 

151 return _navigation_response(request, url) 

152 

153 

154def _parse_posted_profile_id(request): 

155 """Return the posted integer profile ID, or None when it is invalid.""" 

156 try: 

157 return int(request.POST.get("profile_id", "")) 

158 except (TypeError, ValueError): 

159 return None 

160 

161 

162def _candidate_values(extra_data): 

163 """Return candidate values after validating the serialized display shape.""" 

164 candidate_values = extra_data.get("candidate_values", {}) 

165 if not isinstance(candidate_values, Mapping) or any( 

166 not isinstance(candidates, Mapping) for candidates in candidate_values.values() 

167 ): 

168 raise ValidationError("The active Import Plan has invalid candidate values.") 

169 return candidate_values 

170 

171 

172def _contact_candidate_context(request, profile_id, source_id): 

173 """Return Contact candidates and row state for one active preview row.""" 

174 plan_data = request.session.get(PREVIEW_PLAN_SESSION_KEY) or {} 

175 try: 

176 workspace = ReviewWorkspace.from_dict(plan_data) 

177 except PlanError as exc: 

178 raise ValidationError("The active Import Plan is no longer readable.") from exc 

179 result_rows = [ 

180 row for row in workspace.units if str(row.source_id) == str(source_id) and row.object_type == "device" 

181 ] 

182 context = request.session.get("import_context") or {} 

183 source_rows = [ 

184 row for row in (request.session.get("import_rows") or []) if str(row.get("source_id")) == str(source_id) 

185 ] 

186 if str(context.get("profile_id")) != str(profile_id) or len(source_rows) != 1 or len(result_rows) != 1: 

187 raise ValidationError("The candidate resolution does not identify one active preview row.") 

188 

189 candidates = _candidate_values(result_rows[0].extra_data).get("contact", {}) 

190 if not isinstance(candidates, Mapping) or not candidates: 

191 raise ValidationError("The active preview row has no Contact candidate values.") 

192 return ( 

193 {str(source_column): str(value) for source_column, value in candidates.items()}, 

194 source_rows[0], 

195 result_rows[0], 

196 ) 

197 

198 

199def _store_contact_for_unmatched_row(profile, resolved_fields, candidates, user): 

200 """Store the Contact a row names while it still has no Device to assign it to. 

201 

202 Returns the resolved fields to save, which name the stored Contact, the sentence the operator 

203 is told about it, and the stored Contact itself. A decision that names no Contact stores nothing. 

204 """ 

205 selection = PrimaryContactResolver.selection_for_resolution(profile, resolved_fields, candidates) 

206 if selection is None: 

207 return resolved_fields, "", None 

208 contact, created = PrimaryContactResolver.create_contact(profile, selection, user) 

209 note = ( 

210 f" Contact '{contact.name}' was created in NetBox." 

211 if created 

212 else f" Contact '{contact.name}' already existed in NetBox." 

213 ) 

214 return {**resolved_fields, "contact_id": contact.pk}, note, contact_identity(contact) 

215 

216 

217def _planned_device_id(result_row): 

218 """Return the Device a row plans to write to, or None when the import refused the row. 

219 

220 A refused row still carries the Device it matched, so the identifier alone does not mean the 

221 import accepted the match. 

222 """ 

223 if result_row.action != "update": 

224 return None 

225 return result_row.extra_data.get("netbox_device_id") 

226 

227 

228@dataclass(frozen=True) 

229class _ContactWrite: 

230 """What one decided Contact changed on the Device a row already matched.""" 

231 

232 assignment_changed: bool 

233 contact_created: bool 

234 contact: dict 

235 

236 

237def _assign_contact_to_matched_device(profile, resolved_fields, source_row, device_id, user) -> _ContactWrite | None: 

238 """Apply the decided Contact to the Device this row already matched. 

239 

240 Returns what the write changed, or None when the decision names no Contact. `apply` creates the 

241 Contact even when the assignment itself is unchanged, so the two are reported separately. 

242 """ 

243 from dcim.models import Device 

244 

245 device = Device.objects.restrict(user, "change").filter(pk=device_id).first() 

246 if device is None: 

247 raise ObjectPermissionDenied("dcim.change_device") 

248 resolved_row = dict(source_row) 

249 resolved_row.update(resolved_fields) 

250 review = PrimaryContactResolver.review(device, resolved_row, profile, user) 

251 plan = PrimaryContactResolver.apply(device, profile, review, user) 

252 if plan is None: 

253 return None 

254 return _ContactWrite( 

255 assignment_changed=plan["assignment_action"] != "unchanged", 

256 contact_created=plan["contact_id"] is None, 

257 contact=plan["saved_contact"], 

258 ) 

259 

260 

261@dataclass(frozen=True) 

262class _ContactDecision: 

263 """One saved Contact decision: the fields to store, and what writing it changed.""" 

264 

265 resolved_fields: Mapping 

266 write: _ContactWrite | None = None 

267 note: str = "" 

268 contact: dict | None = None 

269 

270 

271def _persist_contact_decision(profile, resolved_fields, candidates, contact_context, user) -> _ContactDecision: 

272 """Write the Contact a decision names, before the decision itself is stored. 

273 

274 A row with a planned Device has the Contact assigned to it. A row without one stores the Contact 

275 alone. Either way the returned fields name the Contact that was persisted, so the stored decision 

276 links it instead of the null the page posted. 

277 """ 

278 if contact_context is None: 

279 return _ContactDecision(resolved_fields) 

280 source_row, result_row = contact_context 

281 device_id = _planned_device_id(result_row) 

282 if not device_id: 

283 # No Device to assign to yet, so the Contact itself is stored now. 

284 fields, note, contact = _store_contact_for_unmatched_row(profile, resolved_fields, candidates, user) 

285 return _ContactDecision(fields, note=note, contact=contact) 

286 write = _assign_contact_to_matched_device(profile, resolved_fields, source_row, device_id, user) 

287 if write is None: 

288 return _ContactDecision(resolved_fields) 

289 return _ContactDecision( 

290 {**resolved_fields, "contact_id": write.contact["id"]}, 

291 write=write, 

292 contact=write.contact, 

293 ) 

294 

295 

296def _saved_resolution_report(contact_write, contact_note): 

297 """Return the sentence and the write detail one saved Contact decision reports. 

298 

299 An assignment that did not move is not a Device Contact update, and a Contact this save created 

300 is reported whether or not the assignment moved. 

301 """ 

302 if contact_write is None: 

303 return "Resolution saved. Recalculate the preview to apply it." + contact_note, contact_note.strip() 

304 detail = ( 

305 f"Contact '{contact_write.contact['name']}' was created in NetBox." 

306 if contact_write.contact_created 

307 else contact_note.strip() 

308 ) 

309 message = ( 

310 "Resolution saved and the linked Device Contact was updated." 

311 if contact_write.assignment_changed 

312 else "Resolution saved. The Device Contact already stood as decided." 

313 ) 

314 return (f"{message} {detail}" if detail else message), detail 

315 

316 

317def _ensure_field_review_device_match(user, profile, source_id, device, source_asset_tag=""): 

318 """Persist the confirmed source-to-device identity for a field review.""" 

319 existing_match = ( 

320 DeviceExistingMatch.objects.select_for_update().filter(profile=profile, source_id=source_id).first() 

321 ) 

322 if existing_match is not None and existing_match.netbox_device_id != device.pk: 

323 return False, "conflict" 

324 conflicting_match = ( 

325 DeviceExistingMatch.objects.select_for_update() 

326 .filter(profile=profile, netbox_device_id=device.pk) 

327 .exclude(source_id=source_id) 

328 .first() 

329 ) 

330 if conflicting_match is not None: 

331 return False, "conflict" 

332 if existing_match is not None and existing_match.netbox_device_id == device.pk: 

333 return True, "" 

334 try: 

335 save_permission_scoped_object( 

336 user, 

337 DeviceExistingMatch, 

338 {"profile": profile, "source_id": source_id}, 

339 { 

340 "netbox_device_id": device.pk, 

341 "device_name": device.name, 

342 "source_asset_tag": source_asset_tag, 

343 }, 

344 ) 

345 except ObjectPermissionDenied: 

346 return False, "permission" 

347 return True, "" 

348 

349 

350# --------------------------------------------------------------------------- 

351# Fuzzy matching: source column name → NetBox target field canonical name 

352# --------------------------------------------------------------------------- 

353 

354_ALIAS_TO_CANONICAL: dict[str, str] = { 

355 # rack_name 

356 "rack": "rack_name", 

357 "rack_name": "rack_name", 

358 "rack name": "rack_name", 

359 # device_name 

360 "name": "device_name", 

361 "device_name": "device_name", 

362 "device name": "device_name", 

363 "hostname": "device_name", 

364 "host": "device_name", 

365 # make 

366 "make": "make", 

367 "manufacturer": "make", 

368 "vendor": "make", 

369 "brand": "make", 

370 # model 

371 "model": "model", 

372 "device_type": "model", 

373 "device type": "model", 

374 "product": "model", 

375 # serial 

376 "serial": "serial", 

377 "serial_number": "serial", 

378 "serial number": "serial", 

379 "sn": "serial", 

380 # asset_tag 

381 "asset_tag": "asset_tag", 

382 "asset tag": "asset_tag", 

383 "asset": "asset_tag", 

384 "tag": "asset_tag", 

385 # source_id 

386 "source_id": "source_id", 

387 "source id": "source_id", 

388 "id": "source_id", 

389 "uid": "source_id", 

390 # u_position 

391 "u_position": "u_position", 

392 "u position": "u_position", 

393 "position": "u_position", 

394 "unit": "u_position", 

395 "u": "u_position", 

396 # u_height 

397 "u_height": "u_height", 

398 "u height": "u_height", 

399 "height": "u_height", 

400 "size": "u_height", 

401 # face 

402 "face": "face", 

403 "side": "face", 

404 # airflow 

405 "airflow": "airflow", 

406 "air_flow": "airflow", 

407 # status 

408 "status": "status", 

409 "state": "status", 

410 # device_class 

411 "device_class": "device_class", 

412 "device class": "device_class", 

413 "class": "device_class", 

414 "type": "device_class", 

415 "role": "device_class", 

416} 

417 

418 

419def _fuzzy_match_netbox_field(column_name: str) -> str | None: 

420 """Return the best-matching canonical target field name for a source column, or None.""" 

421 normalised = column_name.strip().lower() 

422 if normalised in _ALIAS_TO_CANONICAL: 

423 return _ALIAS_TO_CANONICAL[normalised] 

424 matches = difflib.get_close_matches(normalised, _ALIAS_TO_CANONICAL.keys(), n=1, cutoff=0.6) 

425 if matches: 

426 return _ALIAS_TO_CANONICAL[matches[0]] 

427 return None 

428 

429 

430# --------------------------------------------------------------------------- 

431# ImportProfile 

432# --------------------------------------------------------------------------- 

433 

434 

435logger = logging.getLogger(__name__) 

436 

437 

438class ImportProfileListView(generic.ObjectListView): 

439 """List all import profiles with their mapping counts.""" 

440 

441 queryset = ImportProfile.objects.prefetch_related("column_mappings", "class_role_mappings", "device_type_mappings") 

442 table = ImportProfileTable 

443 filterset = ImportProfileFilterSet 

444 template_name = "netbox_data_import/importprofile_list.html" 

445 

446 

447class ImportProfileView(generic.ObjectView): 

448 """Detail view for a single import profile, with inline mapping tables.""" 

449 

450 queryset = ImportProfile.objects.prefetch_related( 

451 "column_mappings", 

452 "class_role_mappings", 

453 "device_type_mappings", 

454 "cable_class_mappings", 

455 ) 

456 

457 def get_extra_context(self, request, instance): 

458 """Inject inline mapping tables into the template context.""" 

459 column_table = ColumnMappingTable(instance.column_mappings.all()) 

460 class_role_table = ClassRoleMappingTable(instance.class_role_mappings.all()) 

461 device_type_table = DeviceTypeMappingTable(instance.device_type_mappings.all()) 

462 transform_table = ColumnTransformRuleTable(instance.column_transform_rules.all()) 

463 cable_class_table = CableClassMappingTable(instance.cable_class_mappings.all()) 

464 applicable_policy_sections = frozenset( 

465 section.key for section in POLICY_SECTIONS if section.applies_to(instance.output_kinds) 

466 ) 

467 return { 

468 "column_table": column_table, 

469 "class_role_table": class_role_table, 

470 "device_type_table": device_type_table, 

471 "transform_table": transform_table, 

472 "cable_class_table": cable_class_table, 

473 "applicable_policy_sections": applicable_policy_sections, 

474 } 

475 

476 

477class ImportProfileEditView(generic.ObjectEditView): 

478 """Create or edit an ImportProfile.""" 

479 

480 queryset = ImportProfile.objects.all() 

481 form = ImportProfileForm 

482 

483 

484class ImportProfileDeleteView(generic.ObjectDeleteView): 

485 """Delete an ImportProfile and all its child mappings.""" 

486 

487 queryset = ImportProfile.objects.all() 

488 

489 

490class InferenceBackendListView(generic.ObjectListView): 

491 """Every configured backend row. At most one may be enabled, and that one is the active backend.""" 

492 

493 queryset = InferenceBackend.objects.all() 

494 table = InferenceBackendTable 

495 

496 

497class InferenceBackendView(generic.ObjectView): 

498 """One backend row, as `resolve_active_backend` reads it while this row is the enabled one.""" 

499 

500 queryset = InferenceBackend.objects.all() 

501 

502 

503class InferenceBackendEditView(generic.ObjectEditView): 

504 """Create or edit one backend row. Model validation applies the api_root trust boundary.""" 

505 

506 queryset = InferenceBackend.objects.all() 

507 form = InferenceBackendForm 

508 

509 

510class InferenceBackendDeleteView(generic.ObjectDeleteView): 

511 """Delete one backend row. With no enabled row left, the active backend is the plugin setting fallback.""" 

512 

513 queryset = InferenceBackend.objects.all() 

514 

515 

516class InferenceBackendChangeLogView(generic.ObjectChangeLogView): 

517 """Display the change log for one InferenceBackend.""" 

518 

519 queryset = InferenceBackend.objects.all() 

520 

521 

522class InferenceBackendConnectionTestView(PermissionRequiredMixin, View): 

523 """Queue the connection test. Specification 13.1 authorizes it with this one permission.""" 

524 

525 permission_required = "netbox_data_import.change_inferencebackend" 

526 

527 def post(self, request, pk): 

528 """Enqueue the worker Job, so no web process ever resolves a credential.""" 

529 from .jobs import InferenceBackendConnectionTestJob 

530 

531 # restrict() applies the ObjectPermission constraints a model-level check would ignore. 

532 backend = get_object_or_404(InferenceBackend.objects.restrict(request.user, "change"), pk=pk) 

533 job = InferenceBackendConnectionTestJob.enqueue( 

534 name=InferenceBackendConnectionTestJob.Meta.name, 

535 instance=backend, 

536 user=request.user, 

537 # The row ID binds authorization; the editable key is operator-facing text. 

538 pk=backend.pk, 

539 backend_key=backend.backend_key, 

540 ) 

541 messages.success(request, f"Connection test queued as job {job.pk}.") 

542 return redirect(backend.get_absolute_url()) 

543 

544 

545class ImportProfileBulkEditView(generic.BulkEditView): 

546 """Bulk-edit selected ImportProfiles.""" 

547 

548 queryset = ImportProfile.objects.all() 

549 filterset = ImportProfileFilterSet 

550 table = ImportProfileTable 

551 form = ImportProfileBulkEditForm 

552 

553 

554class ImportProfileBulkDeleteView(generic.BulkDeleteView): 

555 """Bulk-delete selected ImportProfiles.""" 

556 

557 queryset = ImportProfile.objects.all() 

558 table = ImportProfileTable 

559 

560 

561class ImportProfileChangeLogView(generic.ObjectChangeLogView): 

562 """Display the change log for one ImportProfile.""" 

563 

564 queryset = ImportProfile.objects.all() 

565 

566 

567# Scalar profile fields handled by _apply_profile_yaml_data. 

568# 'tags' (M2M) is intentionally excluded — use the edit UI or the flat import path. 

569_PROFILE_FIELDS = ("description", "source_adapter") 

570 

571 

572def _validate_model_instance(instance, label): 

573 """Call full_clean() and surface ValidationErrors as ValueError so the atomic block rolls back.""" 

574 from django.core.exceptions import ValidationError as DjangoValidationError 

575 

576 try: 

577 instance.full_clean(validate_unique=False) 

578 except DjangoValidationError as exc: 

579 if hasattr(exc, "message_dict"): 

580 msg = "; ".join(f"{f}: {', '.join(es)}" for f, es in exc.message_dict.items()) 

581 else: 

582 msg = "; ".join(exc.messages) 

583 raise PreviewActionInvalid(f"Validation error in {label}: {msg}") from exc 

584 

585 

586def _legacy_adapter_config(profile_data): 

587 """Return the top-level `profile` keys releases up to 1.5.2 exported, as adapter configuration.""" 

588 from .adapter_forms import FlatWorkbookConfigForm 

589 

590 # The legacy keys are exactly the flat-workbook adapter's own settings. 

591 legacy_keys = set(FlatWorkbookConfigForm.base_fields) & set(profile_data) 

592 if not legacy_keys: 

593 return None 

594 conflicting = sorted({"adapter_config", "source_adapter"} & set(profile_data)) 

595 if conflicting: 

596 raise ValueError( 

597 f"Profile key(s) {', '.join(sorted(legacy_keys))} belong to a release before the adapter " 

598 f"cutover and cannot be combined with {', '.join(conflicting)}." 

599 ) 

600 config = {key: profile_data[key] for key in legacy_keys} 

601 # The legacy file names the Contact Role by slug; adapter_config stores its name. 

602 slug = config.get("primary_contact_role") 

603 if slug: 

604 from tenancy.models import ContactRole 

605 

606 role = ContactRole.objects.filter(slug=slug).first() 

607 if role is None: 

608 raise ValueError(f"No Contact Role matches the primary_contact_role slug '{slug}'.") 

609 config["primary_contact_role"] = role.name 

610 return config 

611 

612 

613def _profile_defaults_from_yaml(profile_data): 

614 """Resolve the scalar profile values and the adapter configuration from YAML.""" 

615 legacy_config = _legacy_adapter_config(profile_data) 

616 accepted = {"name", "adapter_config", *_PROFILE_FIELDS} 

617 if legacy_config is not None: 

618 accepted |= set(legacy_config) 

619 unknown = sorted(set(profile_data) - accepted) 

620 if unknown: 

621 raise ValueError(f"Unknown profile key(s): {', '.join(unknown)}") 

622 profile_defaults = {field: profile_data[field] for field in _PROFILE_FIELDS if field in profile_data} 

623 if legacy_config is not None: 

624 from .adapters import FlatWorkbookAdapter 

625 

626 # Pinned, not DEFAULT_ADAPTER_KEY: a legacy file is a flat workbook whatever the default becomes. 

627 profile_defaults["source_adapter"] = FlatWorkbookAdapter.key 

628 profile_defaults["adapter_config"] = legacy_config 

629 elif "adapter_config" in profile_data: 

630 profile_defaults["adapter_config"] = profile_data["adapter_config"] 

631 return profile_defaults 

632 

633 

634def _get_or_init(model_class, **lookup): 

635 """Return the existing persisted instance matching *lookup*, or a new unsaved one. 

636 

637 This enables validate-before-save semantics: callers can set fields on the 

638 returned instance, call ``_validate_model_instance``, and only then call 

639 ``instance.save()``. DB-level errors (e.g. overlength strings) are thus 

640 caught by Django's field validators before any write reaches the database. 

641 """ 

642 return model_class.objects.filter(**lookup).first() or model_class(**lookup) 

643 

644 

645def _set_if_present(instance, data, fields): 

646 """Set attributes on *instance* only when the corresponding key exists in *data*.""" 

647 for name in fields: 

648 if name in data: 

649 setattr(instance, name, data[name]) 

650 

651 

652def _save_or_refetch(instance, model_class, **lookup): 

653 """Persist *instance*, or return the row that won the concurrent insert.""" 

654 resolved, _saved = save_or_refetch(instance, model_class, lookup) 

655 return resolved 

656 

657 

658def _iter_yaml_section(data, section_name, required_keys=()): 

659 """Yield mapping items for a named section in a parsed YAML dict. 

660 

661 - Absent key → yields nothing (caller skips reconciliation). 

662 - Explicit null or non-list value → raises ValueError. 

663 - Explicit empty list → yields nothing (caller reconcile-deletes all). 

664 - Item missing a required key → raises ValueError with index and key name(s), 

665 preventing a bare KeyError from bubbling up with no context. 

666 """ 

667 if section_name not in data: 

668 return 

669 section = data[section_name] 

670 if section is None or not isinstance(section, list): 

671 raise ValueError( 

672 f"'{section_name}' must be a list of mappings; " 

673 f"use [] to explicitly remove all entries, got {type(section).__name__}." 

674 ) 

675 for idx, item in enumerate(section, start=1): 

676 if not isinstance(item, dict): 

677 raise TypeError(f"'{section_name}[{idx}]' must be a mapping, got {type(item).__name__}.") 

678 missing = [k for k in required_keys if k not in item] 

679 if missing: 

680 raise ValueError(f"'{section_name}[{idx}]' missing required key(s): {', '.join(missing)}") 

681 yield item 

682 

683 

684def _delete_stale_device_type_mappings(profile, keep_keys): 

685 """Delete DeviceTypeMapping rows whose (source_make, source_model) is not in *keep_keys*. 

686 

687 Uses a single DB-level exclusion via Q objects, consistent with how other sections 

688 handle reconcile-deletes, and avoids loading all existing rows into Python. 

689 """ 

690 from django.db.models import Q 

691 

692 qs = DeviceTypeMapping.objects.filter(profile=profile) 

693 if keep_keys: 

694 keep_q = Q() 

695 for make, model in keep_keys: 

696 keep_q |= Q(source_make=make, source_model=model) 

697 qs = qs.exclude(keep_q) 

698 qs.delete() 

699 

700 

701def _import_class_role_mappings(data, profile, stats): 

702 """Import class_role_mappings from YAML data into the given profile.""" 

703 crm_source_classes = [] 

704 for m in _iter_yaml_section(data, "class_role_mappings", ("source_class",)): 

705 instance = _get_or_init(ClassRoleMapping, profile=profile, source_class=m["source_class"]) 

706 _set_if_present(instance, m, ("creates_rack", "role_slug", "ignore")) 

707 if m.get("rack_type"): 

708 from dcim.models import RackType 

709 

710 try: 

711 instance.rack_type = RackType.objects.get(slug=m["rack_type"]) 

712 except RackType.DoesNotExist as exc: 

713 raise ValueError( 

714 f"class_role_mappings[{m['source_class']}]: RackType with slug '{m['rack_type']}' not found" 

715 ) from exc 

716 elif "rack_type" in m: 

717 instance.rack_type = None 

718 _validate_model_instance(instance, f"class_role_mappings[{m['source_class']}]") 

719 _save_or_refetch(instance, ClassRoleMapping, profile=profile, source_class=m["source_class"]) 

720 crm_source_classes.append(m["source_class"]) 

721 stats["class_role_mappings"] = stats.get("class_role_mappings", 0) + 1 

722 if "class_role_mappings" in data: 

723 ClassRoleMapping.objects.filter(profile=profile).exclude(source_class__in=crm_source_classes).delete() 

724 

725 

726def _import_cable_class_mappings(data, profile, stats): 

727 """Import cable_class_mappings from YAML data into the given profile.""" 

728 ccm_cable_classes = [] 

729 for m in _iter_yaml_section(data, "cable_class_mappings", ("cable_class",)): 

730 instance = _get_or_init(CableClassMapping, profile=profile, cable_class=m["cable_class"]) 

731 _set_if_present(instance, m, ("cable_type_resolved", "cable_type", "cable_profile_resolved", "cable_profile")) 

732 _validate_model_instance(instance, f"cable_class_mappings[{m['cable_class']}]") 

733 _save_or_refetch(instance, CableClassMapping, profile=profile, cable_class=m["cable_class"]) 

734 ccm_cable_classes.append(m["cable_class"]) 

735 stats["cable_class_mappings"] = stats.get("cable_class_mappings", 0) + 1 

736 if "cable_class_mappings" in data: 

737 CableClassMapping.objects.filter(profile=profile).exclude(cable_class__in=ccm_cable_classes).delete() 

738 

739 

740def _release_replaced_column_policy_rows(profile, mapping_rows, transform_rows): 

741 """Remove rows that leave or change target ownership before validating their replacements.""" 

742 if mapping_rows is not None: 

743 retained_mappings = {(row["source_column"], row["target_field"]) for row in mapping_rows} 

744 stale_mapping_ids = [ 

745 mapping.pk 

746 for mapping in profile.column_mappings.only("pk", "source_column", "target_field") 

747 if (mapping.source_column, mapping.target_field) not in retained_mappings 

748 ] 

749 ColumnMapping.objects.filter(pk__in=stale_mapping_ids).delete() 

750 

751 if transform_rows is None: 

752 return 

753 desired_by_source = {row["source_column"]: row for row in transform_rows} 

754 stale_transform_ids = [] 

755 for rule in profile.column_transform_rules.only( 

756 "pk", "source_column", "pattern", "group_1_target", "group_2_target" 

757 ): 

758 desired = desired_by_source.get(rule.source_column) 

759 if desired is None or any( 

760 getattr(rule, field) != desired.get(field, getattr(rule, field)) 

761 for field in ("pattern", "group_1_target", "group_2_target") 

762 ): 

763 stale_transform_ids.append(rule.pk) 

764 profile.column_transform_rules.filter(pk__in=stale_transform_ids).delete() 

765 

766 

767def _apply_profile_yaml_data(data): 

768 """Create or update an ImportProfile and all its nested mappings from parsed YAML data. 

769 

770 ``data`` must be a dict with a top-level ``profile`` key (the format 

771 produced by :class:`ExportProfileYamlView`). 

772 

773 Returns ``(profile, stats)`` where *stats* is a ``{section: count}`` dict. 

774 Raises ``TypeError`` or ``ValueError`` with a descriptive message on invalid input. 

775 """ 

776 from django.db import transaction 

777 

778 from .models import ColumnTransformRule 

779 

780 if not isinstance(data, dict) or "profile" not in data: 

781 raise ValueError("YAML must contain a top-level 'profile' key.") 

782 

783 pdata = data["profile"] 

784 if not isinstance(pdata, dict): 

785 raise TypeError("The 'profile' value must be a mapping (dict), not a scalar or list.") 

786 if not pdata.get("name"): 

787 raise ValueError("Profile YAML must include a 'name' field.") 

788 

789 mapping_rows = ( 

790 list(_iter_yaml_section(data, "column_mappings", ("target_field", "source_column"))) 

791 if "column_mappings" in data 

792 else None 

793 ) 

794 transform_rows = ( 

795 list(_iter_yaml_section(data, "column_transform_rules", ("source_column", "pattern"))) 

796 if "column_transform_rules" in data 

797 else None 

798 ) 

799 

800 with transaction.atomic(): 

801 # Only include fields that are explicitly present in the YAML so that a 

802 # partial reimport (e.g. just trimming child sections) does not silently 

803 # reset unrelated profile settings back to hard-coded defaults. 

804 profile_defaults = _profile_defaults_from_yaml(pdata) 

805 profile = _get_or_init(ImportProfile, name=pdata["name"]) 

806 for field, value in profile_defaults.items(): 

807 setattr(profile, field, value) 

808 _validate_model_instance(profile, "profile") 

809 profile = _save_or_refetch(profile, ImportProfile, name=pdata["name"]) 

810 

811 stats = {} 

812 _release_replaced_column_policy_rows(profile, mapping_rows, transform_rows) 

813 

814 cm_ids = [] 

815 for cm in mapping_rows or (): 

816 mapping_key = { 

817 "profile": profile, 

818 "source_column": cm["source_column"], 

819 "target_field": cm["target_field"], 

820 } 

821 instance = _get_or_init(ColumnMapping, **mapping_key) 

822 _validate_model_instance(instance, f"column_mappings[{cm['source_column']}->{cm['target_field']}]") 

823 instance = _save_or_refetch(instance, ColumnMapping, **mapping_key) 

824 cm_ids.append(instance.pk) 

825 stats["column_mappings"] = stats.get("column_mappings", 0) + 1 

826 if "column_mappings" in data: 

827 ColumnMapping.objects.filter(profile=profile).exclude(pk__in=cm_ids).delete() 

828 

829 _import_class_role_mappings(data, profile, stats) 

830 _import_cable_class_mappings(data, profile, stats) 

831 

832 dtm_keys = [] 

833 for m in _iter_yaml_section( 

834 data, 

835 "device_type_mappings", 

836 ("source_make", "source_model", "netbox_manufacturer_slug", "netbox_device_type_slug"), 

837 ): 

838 instance = _get_or_init( 

839 DeviceTypeMapping, profile=profile, source_make=m["source_make"], source_model=m["source_model"] 

840 ) 

841 instance.netbox_manufacturer_slug = m["netbox_manufacturer_slug"] 

842 instance.netbox_device_type_slug = m["netbox_device_type_slug"] 

843 _validate_model_instance(instance, f"device_type_mappings[{m['source_make']}/{m['source_model']}]") 

844 _save_or_refetch( 

845 instance, 

846 DeviceTypeMapping, 

847 profile=profile, 

848 source_make=m["source_make"], 

849 source_model=m["source_model"], 

850 ) 

851 dtm_keys.append((m["source_make"], m["source_model"])) 

852 stats["device_type_mappings"] = stats.get("device_type_mappings", 0) + 1 

853 if "device_type_mappings" in data: 

854 _delete_stale_device_type_mappings(profile, dtm_keys) 

855 

856 mm_source_makes = [] 

857 for m in _iter_yaml_section(data, "manufacturer_mappings", ("source_make", "netbox_manufacturer_slug")): 

858 instance = _get_or_init(ManufacturerMapping, profile=profile, source_make=m["source_make"]) 

859 instance.netbox_manufacturer_slug = m["netbox_manufacturer_slug"] 

860 _validate_model_instance(instance, f"manufacturer_mappings[{m['source_make']}]") 

861 _save_or_refetch(instance, ManufacturerMapping, profile=profile, source_make=m["source_make"]) 

862 mm_source_makes.append(m["source_make"]) 

863 stats["manufacturer_mappings"] = stats.get("manufacturer_mappings", 0) + 1 

864 if "manufacturer_mappings" in data: 

865 ManufacturerMapping.objects.filter(profile=profile).exclude(source_make__in=mm_source_makes).delete() 

866 

867 ctr_source_columns = [] 

868 for r in transform_rows or (): 

869 instance = _get_or_init(ColumnTransformRule, profile=profile, source_column=r["source_column"]) 

870 instance.pattern = r["pattern"] 

871 _set_if_present(instance, r, ("group_1_target", "group_2_target")) 

872 _validate_model_instance(instance, f"column_transform_rules[{r['source_column']}]") 

873 _save_or_refetch(instance, ColumnTransformRule, profile=profile, source_column=r["source_column"]) 

874 ctr_source_columns.append(r["source_column"]) 

875 stats["column_transform_rules"] = stats.get("column_transform_rules", 0) + 1 

876 if "column_transform_rules" in data: 

877 ColumnTransformRule.objects.filter(profile=profile).exclude(source_column__in=ctr_source_columns).delete() 

878 

879 return profile, stats 

880 

881 

882class ImportProfileBulkImportView(generic.BulkImportView): 

883 """Import ImportProfile objects via NetBox's built-in import UI. 

884 

885 Supports two formats from the same text area / file upload: 

886 

887 * **Hierarchical YAML** - the format produced by the "Export YAML" button 

888 (top-level keys: ``profile``, ``column_mappings``, ``class_role_mappings``, 

889 ``device_type_mappings``, ``manufacturer_mappings``, 

890 ``column_transform_rules``, ``cable_class_mappings``). All nested 

891 mappings are created/updated. 

892 * **Flat CSV/YAML** - one record per profile, plain metadata fields only 

893 (name, description, sheet_name, …). Falls back to NetBox's standard 

894 bulk-import logic. 

895 """ 

896 

897 queryset = ImportProfile.objects.all() 

898 model_form = ImportProfileImportForm 

899 

900 def post(self, request): 

901 """Detect format and apply hierarchical YAML or delegate to flat bulk import.""" 

902 import yaml 

903 

904 # Read the raw input from the file upload or the text area. 

905 upload = request.FILES.get("upload_file") 

906 if upload: 

907 try: 

908 raw = upload.read().decode("utf-8-sig") 

909 except (UnicodeDecodeError, OSError) as exc: 

910 messages.error(request, f"Could not read uploaded file: {exc}") 

911 return redirect(reverse("plugins:netbox_data_import:importprofile_bulk_import")) 

912 else: 

913 raw = request.POST.get("data", "").strip() 

914 

915 if not raw: 

916 messages.error(request, "No data provided.") 

917 return redirect(reverse("plugins:netbox_data_import:importprofile_bulk_import")) 

918 

919 try: 

920 data = yaml.safe_load(raw) 

921 except yaml.YAMLError: 

922 # Input failed YAML parsing — let NetBox's BulkImportView handle it 

923 # (covers CSV and flat formats with YAML-invalid characters). 

924 if upload: 

925 upload.seek(0) 

926 return super().post(request) 

927 

928 # Hierarchical format: delegate to shared helper. 

929 if isinstance(data, dict) and "profile" in data: 

930 try: 

931 profile, stats = _apply_profile_yaml_data(data) 

932 except (TypeError, ValueError) as exc: # The YAML helpers validate mapping types and required keys. 

933 messages.error(request, str(exc)) 

934 return redirect(reverse("plugins:netbox_data_import:importprofile_bulk_import")) 

935 summary = ", ".join(f"{v} {k.replace('_', ' ')}" for k, v in stats.items()) 

936 messages.success(request, f"Profile '{profile.name}' imported/updated. {summary}.") 

937 return redirect(profile.get_absolute_url()) 

938 

939 # Flat format: let NetBox's BulkImportView handle it. 

940 # Rewind the file stream so the parent handler receives the full content. 

941 if upload: 

942 upload.seek(0) 

943 return super().post(request) 

944 

945 

946# --------------------------------------------------------------------------- 

947# Shared base views for ImportProfile child objects 

948# --------------------------------------------------------------------------- 

949 

950 

951def _profile_pk_for_policy_write(view, url_kwargs): 

952 """Return the ImportProfile whose policy this write changes. 

953 

954 An edit or delete reads it off the row, through the already-scoped queryset. An add names it in 

955 the URL and reads it through the same scope. Either way a target outside the operator's grant 

956 raises here, which `post()` reaches before it enters the lock, so it never holds a row the 

957 operator cannot see. 

958 """ 

959 if "pk" in url_kwargs: 

960 return get_object_or_404(view.queryset, pk=url_kwargs["pk"]).profile_id 

961 profile_pk = url_kwargs.get("profile_pk") 

962 if profile_pk is None: 

963 return None 

964 return get_object_or_404(ImportProfile.objects.restrict(view.request.user, "view"), pk=profile_pk).pk 

965 

966 

967class _ProfileChildEditView(generic.ObjectEditView): 

968 """Base add/edit view for objects that belong to an ImportProfile. 

969 

970 Assigns ``profile`` on add from the ``profile_pk`` URL kwarg, and redirects back to the 

971 parent profile detail page after a successful save. The forms carry no ``profile`` field, 

972 so a posted one is ignored. 

973 

974 Object scoping comes from NetBox's ``ObjectPermissionRequiredMixin``. Django's 

975 ``PermissionRequiredMixin`` must not sit ahead of it: that shadows the ``restrict()`` call. 

976 

977 Override ``get_required_permission`` so that add-URLs (which carry 

978 ``profile_pk`` but not ``pk``) are not misidentified as edit-URLs by 

979 NetBox's generic ``dispatch`` hook. 

980 """ 

981 

982 def get_required_permission(self): 

983 action = "change" if "pk" in self.kwargs else "add" 

984 return get_permission_for_model(self.queryset.model, action) 

985 

986 def get_object(self, **kwargs): 

987 """Filter only by ``pk`` — ignore ``profile_pk`` URL kwarg. 

988 

989 NetBox's ``ObjectEditView.get()`` passes all URL kwargs to 

990 ``get_object_or_404``. ``profile_pk`` is not a field on child 

991 models, so we must strip it before the ORM lookup. 

992 """ 

993 if "pk" in kwargs: 

994 return get_object_or_404(self.queryset, pk=kwargs["pk"]) 

995 return self.queryset.model() 

996 

997 def alter_object(self, obj, request, url_args, url_kwargs): 

998 if not obj.pk and "profile_pk" in url_kwargs: 

999 # The URL names the parent, so the add scope has to cover the profile as well as the row. 

1000 obj.profile = get_object_or_404( 

1001 ImportProfile.objects.restrict(request.user, "view"), pk=url_kwargs["profile_pk"] 

1002 ) 

1003 return obj 

1004 

1005 def get_return_url(self, request, obj=None): 

1006 if obj is not None and getattr(obj, "profile", None): 

1007 return obj.profile.get_absolute_url() 

1008 return super().get_return_url(request, obj) 

1009 

1010 def get_extra_context(self, request, instance): 

1011 if instance.pk: 

1012 return {"profile": instance.profile} 

1013 profile_pk = self.kwargs.get("profile_pk") 

1014 if profile_pk: 

1015 return {"profile": get_object_or_404(ImportProfile.objects.restrict(request.user, "view"), pk=profile_pk)} 

1016 return {} 

1017 

1018 def post(self, request, *args, **kwargs): 

1019 """Write under the profile policy lock, so a replan cannot commit against stale policy.""" 

1020 try: 

1021 with locked_profile_policy(_profile_pk_for_policy_write(self, kwargs)): 

1022 # atomic-exit-safe: locked-policy-write-committed 

1023 return super().post(request, *args, **kwargs) 

1024 except ImportProfile.DoesNotExist: 

1025 # The URL names a profile that is gone, which is the 404 its own fetch would give. 

1026 raise Http404 from None 

1027 

1028 

1029class _ProfileChildDeleteView(generic.ObjectDeleteView): 

1030 """Base delete view for objects that belong to an ImportProfile. 

1031 

1032 Redirects to the parent profile detail page after successful deletion. Object scoping comes from 

1033 NetBox's ``ObjectPermissionRequiredMixin``, which Django's must not shadow. 

1034 """ 

1035 

1036 def get_return_url(self, request, obj=None): 

1037 if obj is not None and getattr(obj, "profile", None): 

1038 return obj.profile.get_absolute_url() 

1039 return super().get_return_url(request, obj) 

1040 

1041 def post(self, request, *args, **kwargs): 

1042 """Delete under the profile policy lock, for the same reason the edit view takes it.""" 

1043 try: 

1044 with locked_profile_policy(_profile_pk_for_policy_write(self, kwargs)): 

1045 # atomic-exit-safe: locked-policy-delete-committed 

1046 return super().post(request, *args, **kwargs) 

1047 except ImportProfile.DoesNotExist: 

1048 raise Http404 from None 

1049 

1050 

1051# --------------------------------------------------------------------------- 

1052# ColumnMapping CRUD 

1053# --------------------------------------------------------------------------- 

1054 

1055 

1056class ColumnMappingAddView(_ProfileChildEditView): 

1057 """Add a column mapping to an existing ImportProfile.""" 

1058 

1059 queryset = ColumnMapping.objects.all() 

1060 form = ColumnMappingForm 

1061 template_name = "netbox_data_import/columnmapping_edit.html" 

1062 

1063 

1064class ColumnMappingEditView(_ProfileChildEditView): 

1065 """Edit an existing column mapping.""" 

1066 

1067 queryset = ColumnMapping.objects.all() 

1068 form = ColumnMappingForm 

1069 template_name = "netbox_data_import/columnmapping_edit.html" 

1070 

1071 

1072class ColumnMappingDeleteView(_ProfileChildDeleteView): 

1073 """Delete a column mapping.""" 

1074 

1075 queryset = ColumnMapping.objects.all() 

1076 

1077 

1078# --------------------------------------------------------------------------- 

1079# ClassRoleMapping CRUD 

1080# --------------------------------------------------------------------------- 

1081 

1082 

1083class ClassRoleMappingAddView(_ProfileChildEditView): 

1084 """Add a class→role mapping to an existing ImportProfile.""" 

1085 

1086 queryset = ClassRoleMapping.objects.all() 

1087 form = ClassRoleMappingForm 

1088 template_name = "netbox_data_import/classrolemapping_edit.html" 

1089 

1090 

1091class ClassRoleMappingEditView(_ProfileChildEditView): 

1092 """Edit an existing class→role mapping.""" 

1093 

1094 queryset = ClassRoleMapping.objects.all() 

1095 form = ClassRoleMappingForm 

1096 template_name = "netbox_data_import/classrolemapping_edit.html" 

1097 

1098 

1099class ClassRoleMappingDeleteView(_ProfileChildDeleteView): 

1100 """Delete a class→role mapping.""" 

1101 

1102 queryset = ClassRoleMapping.objects.all() 

1103 

1104 

1105class CableClassMappingAddView(_ProfileChildEditView): 

1106 """Add a CableClass mapping to an existing ImportProfile.""" 

1107 

1108 queryset = CableClassMapping.objects.all() 

1109 form = CableClassMappingForm 

1110 template_name = "netbox_data_import/cableclassmapping_edit.html" 

1111 permission_required = "netbox_data_import.add_cableclassmapping" 

1112 

1113 

1114class CableClassMappingEditView(_ProfileChildEditView): 

1115 """Edit an existing CableClass mapping.""" 

1116 

1117 queryset = CableClassMapping.objects.all() 

1118 form = CableClassMappingForm 

1119 template_name = "netbox_data_import/cableclassmapping_edit.html" 

1120 permission_required = "netbox_data_import.change_cableclassmapping" 

1121 

1122 

1123class CableClassMappingDeleteView(_ProfileChildDeleteView): 

1124 """Delete a CableClass mapping.""" 

1125 

1126 queryset = CableClassMapping.objects.all() 

1127 permission_required = "netbox_data_import.delete_cableclassmapping" 

1128 

1129 

1130# --------------------------------------------------------------------------- 

1131# DeviceTypeMapping CRUD 

1132# --------------------------------------------------------------------------- 

1133 

1134 

1135class DeviceTypeMappingAddView(_ProfileChildEditView): 

1136 """Add a device type mapping to an existing ImportProfile.""" 

1137 

1138 queryset = DeviceTypeMapping.objects.all() 

1139 form = DeviceTypeMappingForm 

1140 template_name = "netbox_data_import/devicetypemapping_edit.html" 

1141 

1142 

1143class DeviceTypeMappingEditView(_ProfileChildEditView): 

1144 """Edit an existing device type mapping.""" 

1145 

1146 queryset = DeviceTypeMapping.objects.all() 

1147 form = DeviceTypeMappingForm 

1148 template_name = "netbox_data_import/devicetypemapping_edit.html" 

1149 

1150 

1151class DeviceTypeMappingDeleteView(_ProfileChildDeleteView): 

1152 """Delete a device type mapping.""" 

1153 

1154 queryset = DeviceTypeMapping.objects.all() 

1155 

1156 

1157# --------------------------------------------------------------------------- 

1158# Import Wizard — Phase 2 (setup + preview) 

1159# --------------------------------------------------------------------------- 

1160 

1161# These views intentionally use raw django.views.View rather than a NetBox 

1162# generic view base. The wizard is a three-step, session-backed state machine 

1163# (setup → preview → run → results) that does not correspond to any single 

1164# NetBox generic view pattern (ObjectEditView, ObjectListView, etc.). Using a 

1165# raw View keeps the control flow explicit and avoids fighting ObjectEditView's 

1166# form-save lifecycle, queryset requirements, and redirect conventions. 

1167 

1168 

1169class ImportSetupView(PermissionRequiredMixin, View): 

1170 """Step 1: select profile, upload file, choose site/location/tenant.""" 

1171 

1172 permission_required = "netbox_data_import.change_importprofile" 

1173 

1174 def get(self, request): 

1175 """Render the import setup form.""" 

1176 initial = {} 

1177 if profile_pk := request.GET.get("profile"): 

1178 initial["profile"] = profile_pk 

1179 form = ImportSetupForm(initial=initial, user=request.user) 

1180 return render(request, "netbox_data_import/import_setup.html", _import_setup_context(request, form)) 

1181 

1182 def post(self, request): 

1183 """Store the uploaded file, plan it, and redirect to the preview step.""" 

1184 form = ImportSetupForm(request.POST, request.FILES, user=request.user) 

1185 if not form.is_valid(): 

1186 return render(request, "netbox_data_import/import_setup.html", _import_setup_context(request, form)) 

1187 

1188 profile = form.cleaned_data["profile"] 

1189 excel_file = form.cleaned_data["excel_file"] 

1190 site = form.cleaned_data["site"] 

1191 location = form.cleaned_data.get("location") 

1192 tenant = form.cleaned_data.get("tenant") 

1193 

1194 context_data = { 

1195 "profile_id": profile.pk, 

1196 "site_id": site.pk, 

1197 "location_id": location.pk if location else None, 

1198 "tenant_id": tenant.pk if tenant else None, 

1199 "filename": excel_file.name, 

1200 } 

1201 document = SourceDocument.store( 

1202 profile=profile, 

1203 content=excel_file.read(), 

1204 filename=excel_file.name, 

1205 uploaded_by=request.user, 

1206 ) 

1207 context_data["source_document_id"] = document.pk 

1208 planning_context = { 

1209 "site_id": context_data["site_id"], 

1210 "location_id": context_data["location_id"], 

1211 "tenant_id": context_data["tenant_id"], 

1212 } 

1213 try: 

1214 plan = ImportEngine.plan(profile, document, request.user, planning_context) 

1215 except (adapters.SourceUnreadable, adapters.UnknownSourceAdapter, PlanningTargetUnavailable, PlanError) as exc: 

1216 document.delete() 

1217 messages.error(request, f"Failed to parse file: {exc}") 

1218 return render(request, "netbox_data_import/import_setup.html", _import_setup_context(request, form)) 

1219 

1220 workspace = ReviewWorkspace(plan) 

1221 start_new_preview(request.session, plan) 

1222 request.session["import_rows"] = workspace.source_rows 

1223 request.session["import_context"] = context_data 

1224 request.session["import_preview_pending"] = True 

1225 request.session[PREVIEW_USE_MATERIALIZED_ONCE_SESSION_KEY] = True 

1226 request.session.pop("import_preview_source_job_id", None) 

1227 _clear_restored_import_job(request) 

1228 request.session["import_unused_columns"] = { 

1229 column["name"]: {"count": column["count"], "samples": column["samples"]} 

1230 for column in workspace.unused_columns 

1231 } 

1232 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

1233 

1234 

1235_DEVICE_CONFLICT_ROW_LIST_KEYS = ( 

1236 "duplicate_serial_rows", 

1237 "duplicate_asset_tag_rows", 

1238) 

1239 

1240 

1241def _other_conflict_row_identities(row, source_object_types_by_number): 

1242 """Return the row number and object type named by one preview error.""" 

1243 identities = [ 

1244 (row_number, source_object_types_by_number.get(row_number, row.object_type)) 

1245 for row_number in row.extra_data.get("duplicate_source_id_rows", ()) 

1246 ] 

1247 for key in _DEVICE_CONFLICT_ROW_LIST_KEYS: 

1248 identities.extend((row_number, row.object_type) for row_number in row.extra_data.get(key, ())) 

1249 conflict_row_number = row.extra_data.get("conflict_row_number") 

1250 if conflict_row_number is not None: 

1251 identities.append((conflict_row_number, row.object_type)) 

1252 claimed_by_row = row.extra_data.get("claimed_by_row") 

1253 if claimed_by_row is not None: 

1254 identities.append((claimed_by_row, row.object_type)) 

1255 return tuple(dict.fromkeys(identity for identity in identities if identity != (row.row_number, row.object_type))) 

1256 

1257 

1258def _conflict_comparison_row(row, source_rows_by_number, *, is_current): 

1259 """Return the source facts that the conflict comparison shows for one result row.""" 

1260 source_row = source_rows_by_number.get(row.row_number, {}) 

1261 extra_data = row.extra_data 

1262 serial = extra_data.get("source_serial", source_row.get("serial", "")) 

1263 asset_tag = extra_data.get("asset_tag", source_row.get("asset_tag", "")) 

1264 rack_name = row.rack_name or source_row.get("rack_name", "") 

1265 if not rack_name and row.object_type == "rack": 

1266 rack_name = row.name 

1267 return { 

1268 "row_number": row.row_number, 

1269 "name": row.name, 

1270 "source_id": row.source_id, 

1271 "serial": serial, 

1272 "asset_tag": asset_tag, 

1273 "rack_name": rack_name, 

1274 "u_position": extra_data.get("u_position", source_row.get("u_position")), 

1275 "face": extra_data.get("face", source_row.get("face", "")), 

1276 "action": row.action, 

1277 "detail": row.detail, 

1278 # The comparison offers the same action the row column does, so it reads the same list. 

1279 "offered_actions": extra_data.get("offered_actions", []), 

1280 "duplicate_serial": extra_data.get("duplicate_serial", ""), 

1281 "is_current": is_current, 

1282 } 

1283 

1284 

1285def _preview_rows_with_conflict_comparisons(workspace, source_rows, profile): 

1286 """Copy preview rows and attach comparisons for each within-import row conflict.""" 

1287 result_rows_by_identity = {(row.row_number, row.object_type): row for row in workspace.units} 

1288 source_rows_by_number = {row.get("_row_number"): row for row in source_rows} 

1289 object_types_by_class = { 

1290 mapping.source_class: "rack" if mapping.creates_rack else "device" 

1291 for mapping in profile.class_role_mappings.all() 

1292 } 

1293 source_object_types_by_number = {} 

1294 for source_row in source_rows: 

1295 source_class = source_text(source_row.get("device_class")) 

1296 if source_class in object_types_by_class: 

1297 source_object_types_by_number[source_row.get("_row_number")] = object_types_by_class[source_class] 

1298 conflict_rows_by_row = {} 

1299 for row in workspace.units: 

1300 other_rows = [ 

1301 result_rows_by_identity.get(identity) 

1302 for identity in _other_conflict_row_identities(row, source_object_types_by_number) 

1303 ] 

1304 other_rows = [other_row for other_row in other_rows if other_row is not None] 

1305 if not other_rows: 

1306 continue 

1307 conflict_rows_by_row[(row.row_number, row.object_type)] = [ 

1308 _conflict_comparison_row(row, source_rows_by_number, is_current=True), 

1309 *(_conflict_comparison_row(other_row, source_rows_by_number, is_current=False) for other_row in other_rows), 

1310 ] 

1311 

1312 preview_rows = [] 

1313 for row in workspace.units: 

1314 preview_rows.append( 

1315 replace( 

1316 row, 

1317 extra_data={ 

1318 **row.extra_data, 

1319 "conflict_rows": conflict_rows_by_row.get((row.row_number, row.object_type), []), 

1320 }, 

1321 ) 

1322 ) 

1323 return preview_rows 

1324 

1325 

1326class ImportPreviewView(PermissionRequiredMixin, View): 

1327 """Step 2: show dry-run results, let user confirm or go back.""" 

1328 

1329 permission_required = "netbox_data_import.change_importprofile" 

1330 

1331 def get(self, request): 

1332 """Render the current preview URL.""" 

1333 use_materialized_result = request.session.pop(PREVIEW_USE_MATERIALIZED_ONCE_SESSION_KEY, False) is True 

1334 return self.render_preview( 

1335 request, 

1336 request.get_full_path(), 

1337 use_materialized_result=use_materialized_result, 

1338 ) 

1339 

1340 def _replanned_preview(self, request, profile, document, planning_context): 

1341 """Return the freshly planned preview, or the response that ends this request instead.""" 

1342 try: 

1343 plan = ImportEngine.plan(profile, document, request.user, planning_context) 

1344 except PlanningTargetUnavailable: 

1345 _discard_import_preview(request) 

1346 messages.warning(request, "The saved import target is no longer available. Start a new preview.") 

1347 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1348 try: 

1349 record_recalculated_preview(request.session, plan, user=request.user) 

1350 except PreviewLocked as exc: 

1351 # A sync started after the caller's check, so this fresh plan must not replace the stored one. 

1352 messages.warning(request, str(exc)) 

1353 return redirect(reverse("plugins:netbox_data_import:trace_workspace")) 

1354 return plan 

1355 

1356 def render_preview(self, request, preview_url, *, use_materialized_result=False): 

1357 """Replan the stored source and render the Review Workspace.""" 

1358 ctx = request.session.get("import_context", {}) 

1359 if not ctx: 

1360 messages.warning(request, "No import in progress. Please start a new import.") 

1361 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1362 

1363 profile = ImportProfile.objects.restrict(request.user, "change").filter(pk=ctx.get("profile_id")).first() 

1364 if not profile: 

1365 _discard_import_preview(request) 

1366 messages.warning(request, "Import profile not found.") 

1367 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1368 

1369 # The session outlives an upgrade, so the stored profile can name a retired adapter. 

1370 try: 

1371 validate_registered_adapter(profile) 

1372 validate_adapter_target_module(profile.source_adapter) 

1373 except ValidationError as exc: 

1374 _discard_import_preview(request) 

1375 messages.error(request, "; ".join(exc.messages)) 

1376 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1377 

1378 document = SourceDocument.objects.filter(pk=ctx.get("source_document_id"), profile=profile).first() 

1379 if document is None: 

1380 _discard_import_preview(request) 

1381 messages.warning(request, "The stored source is no longer available. Upload it again.") 

1382 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1383 

1384 # A retained sync is mid-write, so NetBox is not authoritative and the plan must not move. 

1385 retained_reason = _retained_sync_block_reason(request) 

1386 if retained_reason: 

1387 messages.warning(request, retained_reason) 

1388 stored_plan = request.session.get(PREVIEW_PLAN_SESSION_KEY) 

1389 if (use_materialized_result or retained_reason) and isinstance(stored_plan, dict): 

1390 try: 

1391 plan = ImportPlan.from_dict(stored_plan) 

1392 except PlanError as exc: 

1393 _discard_import_preview(request) 

1394 messages.error(request, str(exc)) 

1395 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1396 else: 

1397 planning_context = { 

1398 "site_id": ctx.get("site_id"), 

1399 "location_id": ctx.get("location_id"), 

1400 "tenant_id": ctx.get("tenant_id"), 

1401 } 

1402 planned = self._replanned_preview(request, profile, document, planning_context) 

1403 if not isinstance(planned, ImportPlan): 

1404 return planned 

1405 plan = planned 

1406 result = ReviewWorkspace(plan) 

1407 rows = result.source_rows 

1408 request.session["import_rows"] = rows 

1409 

1410 # Build existing resolutions map for the split-name modal preview 

1411 import json as _json 

1412 

1413 from .models import SourceResolution 

1414 

1415 existing_resolutions = {} 

1416 for res in SourceResolution.objects.filter(profile=profile): 

1417 existing_resolutions.setdefault(str(res.source_id), {})[res.source_column] = { 

1418 "original_value": res.original_value, 

1419 "resolved_fields": res.resolved_fields, 

1420 } 

1421 

1422 # Build device matching context for template 

1423 device_matches = DeviceExistingMatch.objects.filter(profile=profile) 

1424 device_match_source_ids = [m.source_id for m in device_matches] 

1425 device_match_info = {} 

1426 

1427 # Fetch device serial numbers from NetBox Device objects 

1428 from dcim.models import Device 

1429 

1430 netbox_device_ids = [m.netbox_device_id for m in device_matches] 

1431 devices_by_id = { 

1432 d.id: d for d in Device.objects.restrict(request.user, "view").filter(id__in=netbox_device_ids) 

1433 } 

1434 

1435 for match in device_matches: 

1436 device = devices_by_id.get(match.netbox_device_id) 

1437 # Bindings to devices outside the user's view scope carry no target metadata. 

1438 if device is None: 

1439 continue 

1440 device_match_info[match.source_id] = { 

1441 "device_id": match.netbox_device_id, 

1442 "device_name": match.device_name, 

1443 "device_serial": device.serial, 

1444 } 

1445 

1446 # The preview serves every adapter, and only the flat one declares a stored view mode. 

1447 stored_view_mode = profile.adapter_settings.get("preview_view_mode", "rows") 

1448 view_mode = parse_qs(urlsplit(preview_url).query).get("view", [stored_view_mode])[-1] 

1449 

1450 # Build unused columns list: filter out any that are now mapped 

1451 mapped_source_cols = set(profile.column_mappings.values_list("source_column", flat=True)) 

1452 raw_unused = {column["name"]: column for column in result.unused_columns} 

1453 unused_columns = [ 

1454 { 

1455 "name": col, 

1456 "count": int(stats.get("count") or 0), 

1457 "samples": stats.get("samples") or [], 

1458 "suggested_field": _fuzzy_match_netbox_field(col), 

1459 } 

1460 for col, stats in raw_unused.items() 

1461 if isinstance(stats, dict) and col not in mapped_source_cols 

1462 ] 

1463 unused_columns.sort(key=lambda x: -x["count"]) 

1464 conflicts_by_row = { 

1465 str(r.row_number): r.extra_data.get("conflicts", {}) for r in result.units if r.extra_data.get("conflicts") 

1466 } 

1467 # The modal names a field for the operator; the catalog is where those names live. 

1468 target_field_labels = {key: CATALOG.display(key) for key, _label in CATALOG.choices()} 

1469 candidate_values_by_row = {} 

1470 try: 

1471 for row in result.units: 

1472 candidate_values = _candidate_values(row.extra_data) 

1473 if candidate_values: 

1474 candidate_values_by_row[str(row.row_number)] = candidate_values 

1475 except ValidationError as exc: 

1476 _discard_import_preview(request) 

1477 messages.error(request, "; ".join(exc.messages)) 

1478 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1479 contact_suggestions_by_row = { 

1480 str(r.row_number): r.extra_data["contact_suggestion"] 

1481 for r in result.units 

1482 if r.extra_data.get("contact_suggestion") 

1483 } 

1484 contact_role_suggestions_by_row = { 

1485 row_number: suggest_contact_roles(candidates["contact"]) 

1486 for row_number, candidates in candidate_values_by_row.items() 

1487 if candidates.get("contact") 

1488 } 

1489 extra_columns_by_row = { 

1490 str(r.row_number): r.extra_data.get("extra_columns", {}) 

1491 for r in result.units 

1492 if r.extra_data.get("extra_columns") 

1493 } 

1494 split_field_values_by_source_id = { 

1495 r.source_id: { 

1496 "device_name": r.name or "", 

1497 "asset_tag": r.extra_data.get("asset_tag", ""), 

1498 "serial": r.extra_data.get("source_serial", ""), 

1499 "make": r.extra_data.get("source_make", ""), 

1500 "model": r.extra_data.get("source_model", ""), 

1501 "rack_name": r.rack_name or "", 

1502 "source_id": r.source_id, 

1503 } 

1504 for r in result.units 

1505 if r.object_type == "device" and r.source_id 

1506 } 

1507 preview_rows = _preview_rows_with_conflict_comparisons(result, rows, profile) 

1508 

1509 non_card_error_rows = [ 

1510 r 

1511 for r in result.units 

1512 if r.action == "error" and not (r.object_type == "device" or (r.object_type == "rack" and r.name)) 

1513 ] 

1514 

1515 return render( 

1516 request, 

1517 "netbox_data_import/import_preview.html", 

1518 { 

1519 "result": result, 

1520 "preview_rows": preview_rows, 

1521 "filename": ctx.get("filename", ""), 

1522 "profile_id": ctx.get("profile_id"), 

1523 "profile": profile, 

1524 "preview_url": preview_url, 

1525 "view_mode": view_mode, 

1526 # Only a trace preview has a workspace to open, so only it offers the link. 

1527 "trace_workspace_available": result.has_traces, 

1528 "existing_resolutions_json": _json.dumps(existing_resolutions).translate( 

1529 {ord("<"): "\\u003C", ord(">"): "\\u003E", ord("&"): "\\u0026"} 

1530 ), 

1531 "existing_resolutions": existing_resolutions, 

1532 "plugin_version": _plugin_version, 

1533 "resolved_contact_source_ids": [ 

1534 source_id for source_id, columns in existing_resolutions.items() if "candidate:contact" in columns 

1535 ], 

1536 "configured_source_classes": set(profile.class_role_mappings.values_list("source_class", flat=True)), 

1537 "can_create_role": request.user.has_perm("dcim.add_devicerole"), 

1538 "unused_columns": unused_columns, 

1539 "target_field_choices": CATALOG.choices(output_kinds=profile.output_kinds), 

1540 "syncable_fields": SyncDeviceFieldView._ALLOWED_FIELDS, 

1541 "reviewable_fields": DeviceFieldReviewer.reviewable_fields(), 

1542 "device_match_source_ids": device_match_source_ids, 

1543 "device_match_info": device_match_info, 

1544 "conflicts_by_row": conflicts_by_row, 

1545 "target_field_labels": target_field_labels, 

1546 "candidate_values_by_row": candidate_values_by_row, 

1547 "contact_suggestions_by_row": contact_suggestions_by_row, 

1548 "contact_role_suggestions_by_row": contact_role_suggestions_by_row, 

1549 "extra_columns_by_row": extra_columns_by_row, 

1550 "split_field_values_by_source_id": split_field_values_by_source_id, 

1551 "non_card_error_rows": non_card_error_rows, 

1552 "preview_revision": current_preview_revision(request.session), 

1553 }, 

1554 ) 

1555 

1556 

1557def _user_import_jobs(request): 

1558 """Return native data-import Jobs owned by the current user.""" 

1559 from .jobs import ImportJobRunner 

1560 

1561 return ImportJobRunner.get_jobs().filter( 

1562 user=request.user, 

1563 data__job_type=ImportJobRunner.job_type, 

1564 ) 

1565 

1566 

1567def _import_setup_context(request, form): 

1568 """Return the setup form and the most relevant resumable import state.""" 

1569 resume_job = _resume_import_job(request) 

1570 preview_rows = request.session.get("import_rows") 

1571 preview_context = request.session.get("import_context") 

1572 resume_preview = ( 

1573 request.session.get("import_preview_pending") is True 

1574 and isinstance(preview_rows, list) 

1575 and bool(preview_rows) 

1576 and isinstance(preview_context, dict) 

1577 and bool(preview_context.get("profile_id")) 

1578 and bool(preview_context.get("site_id")) 

1579 ) 

1580 return {"form": form, "resume_job": resume_job, "resume_preview": resume_preview} 

1581 

1582 

1583def _discard_import_preview(request): 

1584 """Remove session data that belongs only to an unsubmitted preview.""" 

1585 for key in ("import_context", "import_idempotency_key", "import_rows", "import_unused_columns"): 

1586 request.session.pop(key, None) 

1587 clear_preview_state(request.session) 

1588 request.session["import_preview_pending"] = False 

1589 request.session.pop(PREVIEW_USE_MATERIALIZED_ONCE_SESSION_KEY, None) 

1590 request.session.pop("import_preview_source_job_id", None) 

1591 

1592 

1593def _clear_restored_import_job(request): 

1594 """Remove an audit result restored beside a pending preview.""" 

1595 request.session.pop("import_restored_execution_id", None) 

1596 

1597 

1598def _resume_import_job(request): 

1599 """Return the session Job or the user's latest active import Job.""" 

1600 from core.choices import JobStatusChoices 

1601 

1602 jobs = _user_import_jobs(request).filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES) 

1603 if (job_pk := request.session.get("import_background_job_id")) and (job := jobs.filter(pk=job_pk).first()): 

1604 return job 

1605 return jobs.first() 

1606 

1607 

1608def _import_source_rows_available(request, job): 

1609 """Return whether one Job's stored source is still available.""" 

1610 source_document_id = (job.data or {}).get("source_document_id") 

1611 return bool(source_document_id and SourceDocument.objects.filter(pk=source_document_id).exists()) 

1612 

1613 

1614def _import_job_progress(job, preview_blocked=False, source_rows_available=False): 

1615 """Return current row progress from native Job data and RQ metadata.""" 

1616 from core.choices import JobStatusChoices 

1617 

1618 data = job.data or {} 

1619 processed = int(data.get("processed") or 0) 

1620 total = int(data.get("total") or 0) 

1621 if job.status in JobStatusChoices.ENQUEUED_STATE_CHOICES: 

1622 import django_rq 

1623 

1624 try: 

1625 queue = django_rq.get_queue(job.queue_name or "default") 

1626 except KeyError: 

1627 rq_job = None 

1628 else: 

1629 rq_job = queue.fetch_job(str(job.job_id)) 

1630 if rq_job is not None: 

1631 processed = int(rq_job.meta.get("processed", processed) or 0) 

1632 total = int(rq_job.meta.get("total", total) or 0) 

1633 percentage = round(processed * 100 / total) if total else 0 

1634 return { 

1635 "job": job, 

1636 "processed": processed, 

1637 "total": total, 

1638 "percentage": percentage, 

1639 "is_active": job.status in JobStatusChoices.ENQUEUED_STATE_CHOICES, 

1640 "is_completed": job.status == JobStatusChoices.STATUS_COMPLETED, 

1641 "is_failed": job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED), 

1642 "preview_available": bool(data.get("accepted_plan")) 

1643 and isinstance(data.get("context_data"), dict) 

1644 and source_rows_available, 

1645 "preview_blocked": preview_blocked, 

1646 "message": data.get("message") or "", 

1647 } 

1648 

1649 

1650def _restore_import_session(request, job): 

1651 """Restore preview or audit state when a user returns to a Job URL.""" 

1652 from core.choices import JobStatusChoices 

1653 

1654 data = job.data or {} 

1655 if request.session.get("import_background_job_id") != job.pk: 

1656 request.session["import_background_job_id"] = job.pk 

1657 preview_is_pending = request.session.get("import_preview_pending") is True 

1658 failed_preview_available = job.status in ( 

1659 JobStatusChoices.STATUS_FAILED, 

1660 JobStatusChoices.STATUS_ERRORED, 

1661 ) and ( 

1662 data.get("accepted_plan") 

1663 and isinstance(data.get("context_data"), dict) 

1664 and _import_source_rows_available(request, job) 

1665 ) 

1666 if failed_preview_available and not preview_is_pending: 

1667 restore_preview_plan(request.session, data["accepted_plan"]) 

1668 request.session["import_context"] = data["context_data"] 

1669 request.session["import_preview_pending"] = True 

1670 request.session["import_preview_source_job_id"] = job.pk 

1671 retire_preview_revision(request.session) 

1672 preview_is_pending = True 

1673 if data.get("import_execution_id"): 

1674 if preview_is_pending: 

1675 request.session["import_restored_execution_id"] = data["import_execution_id"] 

1676 else: 

1677 _clear_restored_import_job(request) 

1678 request.session["import_execution_id"] = data["import_execution_id"] 

1679 request.session["import_preview_pending"] = False 

1680 return data 

1681 

1682 

1683def _queue_accepted_plan(request, profile, document, ctx_data, plan_data, selection, *, keep_preview=False): 

1684 """Queue one accepted Import Plan for the given selection and hand over its progress page. 

1685 

1686 A per-trace command runs several times against one preview, so it keys each execution on the 

1687 selection it queues. Reusing the wizard's own key would return the first execution and apply 

1688 nothing. It also keeps the preview, which the operator returns to for the next trace. 

1689 """ 

1690 from core.choices import JobNotificationChoices, JobStatusChoices 

1691 from core.models import Job 

1692 

1693 from .jobs import ImportJobRunner 

1694 

1695 if keep_preview: 

1696 # `ImportPlan.revision` never advances, so the plan's own content is what tells two apart. 

1697 idempotency_key = fingerprint_of({"plan": plan_data, "selection": sorted(selection)}) 

1698 else: 

1699 idempotency_key = request.session.get("import_idempotency_key") or uuid.uuid4().hex 

1700 request.session["import_idempotency_key"] = idempotency_key 

1701 _clear_restored_import_job(request) 

1702 job = None 

1703 try: 

1704 # The profile row orders the check against every competing enqueue, so two cannot both pass it. 

1705 with locked_profile_policy(profile.pk): 

1706 # The second writer that can break the invariant: a queue while one is already retained. 

1707 assert_preview_may_move(request.session, request.user) 

1708 job = ImportJobRunner.enqueue( 

1709 name=ImportJobRunner.name, 

1710 user=request.user, 

1711 notifications=JobNotificationChoices.NOTIFICATION_NEVER, 

1712 job_timeout=3600, 

1713 profile_id=profile.pk, 

1714 source_document_id=document.pk, 

1715 accepted_plan=plan_data, 

1716 selection=selection, 

1717 idempotency_key=idempotency_key, 

1718 ) 

1719 job.data = { 

1720 "job_type": ImportJobRunner.job_type, 

1721 "phase": "queued", 

1722 "processed": 0, 

1723 "total": 0, 

1724 "filename": ctx_data.get("filename", ""), 

1725 "profile_id": profile.pk, 

1726 "profile_name": profile.name, 

1727 "source_document_id": document.pk, 

1728 "accepted_plan": plan_data, 

1729 "context_data": ctx_data, 

1730 # What makes this Job hold the preview, so the guard finds it without the session. 

1731 "keeps_preview": keep_preview, 

1732 } 

1733 job.save(update_fields=["data"]) 

1734 except Exception: 

1735 # The queue push runs on commit, so a Job no worker will run must not hold the preview. 

1736 if job is not None: 

1737 Job.objects.filter(pk=job.pk, status=JobStatusChoices.STATUS_PENDING).update( 

1738 status=JobStatusChoices.STATUS_ERRORED 

1739 ) 

1740 raise 

1741 

1742 request.session["import_background_job_id"] = job.pk 

1743 if keep_preview: 

1744 # The write just made the reviewed plan stale, so the next command has to re-read first. 

1745 mark_preview_dirty(request.session) 

1746 else: 

1747 request.session["import_preview_pending"] = False 

1748 request.session.pop("import_preview_source_job_id", None) 

1749 return redirect(reverse("plugins:netbox_data_import:import_progress", kwargs={"pk": job.pk})) 

1750 

1751 

1752class ImportRunView(PermissionRequiredMixin, View): 

1753 """Step 3: queue the accepted Import Plan.""" 

1754 

1755 permission_required = "netbox_data_import.change_importprofile" 

1756 

1757 def post(self, request): 

1758 """Queue the accepted plan and redirect to its progress page.""" 

1759 ctx_data = request.session.get("import_context") 

1760 plan_data = request.session.get(PREVIEW_PLAN_SESSION_KEY) 

1761 if not isinstance(ctx_data, dict) or not isinstance(plan_data, dict): 

1762 messages.warning(request, "No import in progress.") 

1763 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1764 if request.session.get("import_preview_pending") is not True: 

1765 if job_pk := request.session.get("import_background_job_id"): 

1766 return redirect(reverse("plugins:netbox_data_import:import_progress", kwargs={"pk": job_pk})) 

1767 messages.warning(request, "No import in progress.") 

1768 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1769 if request.session.get(PREVIEW_DIRTY_SESSION_KEY) is True: 

1770 messages.warning(request, "Recalculate and review the saved preview changes before importing.") 

1771 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

1772 

1773 profile = get_object_or_404( 

1774 ImportProfile.objects.restrict(request.user, "change"), 

1775 pk=ctx_data["profile_id"], 

1776 ) 

1777 try: 

1778 validate_registered_adapter(profile) 

1779 except ValidationError as exc: 

1780 _discard_import_preview(request) 

1781 messages.error(request, "; ".join(exc.messages)) 

1782 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1783 

1784 document = SourceDocument.objects.filter(pk=ctx_data.get("source_document_id"), profile=profile).first() 

1785 if document is None: 

1786 _discard_import_preview(request) 

1787 messages.error(request, "The stored source is no longer available. Upload it again.") 

1788 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1789 try: 

1790 accepted = ImportPlan.from_dict(plan_data) 

1791 except PlanError as exc: 

1792 _discard_import_preview(request) 

1793 messages.error(request, str(exc)) 

1794 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1795 if ReviewWorkspace(accepted).has_errors: 

1796 messages.warning(request, "Resolve every preview error before importing.") 

1797 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

1798 selection = [unit.identity for unit in accepted.units if unit.disposition == "actionable"] 

1799 if not selection: 

1800 messages.info(request, "The accepted Import Plan has no changes to apply.") 

1801 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

1802 

1803 try: 

1804 return _queue_accepted_plan(request, profile, document, ctx_data, plan_data, selection) 

1805 except PreviewLocked as exc: 

1806 messages.warning(request, str(exc)) 

1807 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

1808 

1809 

1810class ImportProgressView(PermissionRequiredMixin, View): 

1811 """Show one resumable background import and its current progress.""" 

1812 

1813 permission_required = "netbox_data_import.change_importprofile" 

1814 

1815 def get(self, request, pk): 

1816 """Render the full progress page.""" 

1817 job = get_object_or_404(_user_import_jobs(request), pk=pk) 

1818 preview_was_pending = ( 

1819 request.session.get("import_preview_pending") is True 

1820 and request.session.get("import_preview_source_job_id") != job.pk 

1821 ) 

1822 _restore_import_session(request, job) 

1823 return render( 

1824 request, 

1825 "netbox_data_import/import_progress.html", 

1826 _import_job_progress( 

1827 job, 

1828 preview_blocked=preview_was_pending, 

1829 source_rows_available=_import_source_rows_available(request, job), 

1830 ), 

1831 ) 

1832 

1833 

1834class ImportProgressStatusView(PermissionRequiredMixin, View): 

1835 """Render the HTMX progress fragment or redirect a completed import.""" 

1836 

1837 permission_required = "netbox_data_import.change_importprofile" 

1838 

1839 def get(self, request, pk): 

1840 """Return the current Job state.""" 

1841 job = get_object_or_404(_user_import_jobs(request), pk=pk) 

1842 preview_was_pending = ( 

1843 request.session.get("import_preview_pending") is True 

1844 and request.session.get("import_preview_source_job_id") != job.pk 

1845 ) 

1846 data = _restore_import_session(request, job) 

1847 if job.status == "completed" and data.get("import_execution_id"): 

1848 response = HttpResponse(status=204) 

1849 response["HX-Redirect"] = reverse("plugins:netbox_data_import:import_results") 

1850 return response 

1851 return render( 

1852 request, 

1853 "netbox_data_import/_import_progress.html", 

1854 _import_job_progress( 

1855 job, 

1856 preview_blocked=preview_was_pending, 

1857 source_rows_available=_import_source_rows_available(request, job), 

1858 ), 

1859 ) 

1860 

1861 

1862class ImportResultsView(PermissionRequiredMixin, View): 

1863 """Step 4: show the Import Execution audit outcome.""" 

1864 

1865 permission_required = "netbox_data_import.view_importexecution" 

1866 

1867 def get(self, request): 

1868 """Render the results page for the most recent Import Execution.""" 

1869 restored_execution_id = request.session.get("import_restored_execution_id") 

1870 execution_id = restored_execution_id or request.session.get("import_execution_id") 

1871 if not execution_id: 

1872 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1873 

1874 execution = ( 

1875 ImportExecution.objects.select_related("profile", "source_document") 

1876 .filter( 

1877 pk=execution_id, 

1878 actor=request.user, 

1879 ) 

1880 .first() 

1881 ) 

1882 if execution is None or not request.user.has_perm(self.permission_required, execution): 

1883 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

1884 if restored_execution_id is None: 

1885 request.session.pop("import_background_job_id", None) 

1886 request.session["import_preview_pending"] = False 

1887 request.session.pop("import_preview_source_job_id", None) 

1888 for key in ("import_rows", "import_context", "import_unused_columns"): 

1889 request.session.pop(key, None) 

1890 clear_preview_state(request.session) 

1891 return render( 

1892 request, 

1893 "netbox_data_import/import_results.html", 

1894 {"execution": execution, "job_id": execution.pk}, 

1895 ) 

1896 

1897 

1898class ImportExecutionListView(PermissionRequiredMixin, generic.ObjectListView): 

1899 """List every past Import Execution, including the retained legacy rows.""" 

1900 

1901 queryset = ImportExecution.objects.select_related("profile").all() 

1902 table = ImportExecutionTable 

1903 template_name = "netbox_data_import/importexecution_list.html" 

1904 permission_required = "netbox_data_import.view_importexecution" 

1905 

1906 def get_required_permission(self): 

1907 """Answer NetBox's own permission hook, which it checks separately from permission_required.""" 

1908 return "netbox_data_import.view_importexecution" 

1909 

1910 

1911# --------------------------------------------------------------------------- 

1912# ColumnTransformRule CRUD 

1913# --------------------------------------------------------------------------- 

1914 

1915 

1916class ColumnTransformRuleAddView(_ProfileChildEditView): 

1917 """Add a column transform rule to an existing ImportProfile.""" 

1918 

1919 queryset = ColumnTransformRule.objects.all() 

1920 form = ColumnTransformRuleForm 

1921 template_name = "netbox_data_import/columntransformrule_edit.html" 

1922 

1923 

1924class ColumnTransformRuleEditView(_ProfileChildEditView): 

1925 """Edit an existing column transform rule.""" 

1926 

1927 queryset = ColumnTransformRule.objects.all() 

1928 form = ColumnTransformRuleForm 

1929 template_name = "netbox_data_import/columntransformrule_edit.html" 

1930 

1931 

1932class ColumnTransformRuleDeleteView(_ProfileChildDeleteView): 

1933 """Delete a column transform rule.""" 

1934 

1935 queryset = ColumnTransformRule.objects.all() 

1936 

1937 

1938# --------------------------------------------------------------------------- 

1939# Ignore / Unignore device 

1940# --------------------------------------------------------------------------- 

1941# The action views below (Ignore/Unignore/Sync/Quick*) are lightweight POST 

1942# endpoints that return JSON or an immediate redirect. No NetBox generic base 

1943# class exists for this pattern; PermissionRequiredMixin + View is intentional. 

1944# --------------------------------------------------------------------------- 

1945 

1946 

1947class _PermissionScopedWriteMixin: 

1948 """Mark preview writers and render the refusals their policy writes raise in one place.""" 

1949 

1950 def dispatch(self, request, *args, **kwargs): 

1951 try: 

1952 return super().dispatch(request, *args, **kwargs) 

1953 except ObjectPermissionDenied as exc: 

1954 # The permission names an object the caller may not be allowed to know exists. 

1955 logger.warning("%s: write refused outside the caller's object scope: %s", type(self).__name__, exc) 

1956 return self._refusal( 

1957 request, 

1958 "Permission denied: this action is outside your NetBox object permissions.", 

1959 403, 

1960 ) 

1961 except ImportProfile.DoesNotExist: 

1962 # Every policy write locks its profile, which can be deleted after the view looked it up. 

1963 return self._refusal(request, "The import profile is no longer available.", 404) 

1964 

1965 def _refusal(self, request, error, status): 

1966 """Render one refused write the way this caller asked for its answer.""" 

1967 if getattr(self, "permission_denied_response_format", "redirect") == "json" or _wants_json(request): 

1968 return JsonResponse({"ok": False, "error": error}, status=status) 

1969 messages.error(request, error) 

1970 return redirect(_safe_next_url(request, "plugins:netbox_data_import:import_preview")) 

1971 

1972 

1973class IgnoreDeviceView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

1974 """Mark a specific device (by source_id) as ignored for a profile.""" 

1975 

1976 permission_required = "netbox_data_import.change_importprofile" 

1977 

1978 def post(self, request): 

1979 """Add the specified device to the profile's ignore list.""" 

1980 from .models import IgnoredDevice 

1981 

1982 profile_id = _parse_posted_profile_id(request) 

1983 source_id = request.POST.get("source_id") 

1984 device_name = request.POST.get("device_name", "") 

1985 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

1986 

1987 if profile_id is None: 

1988 messages.error(request, "A valid import profile is required.") 

1989 elif source_id: 

1990 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

1991 ignored = _get_or_init(IgnoredDevice, profile=profile, source_id=source_id) 

1992 if ignored.pk is None: 

1993 ignored.device_name = device_name 

1994 try: 

1995 _validate_model_instance(ignored, f"ignored device '{source_id}'") 

1996 except PreviewActionInvalid as exc: 

1997 messages.error(request, str(exc)) 

1998 return redirect(next_url) 

1999 save_permission_scoped_object( 

2000 request.user, 

2001 IgnoredDevice, 

2002 {"profile": profile, "source_id": source_id}, 

2003 {"device_name": device_name}, 

2004 on_existing="keep", 

2005 ) 

2006 messages.success(request, f"Device '{device_name or source_id}' added to ignore list.") 

2007 return redirect(next_url) 

2008 

2009 

2010class UnignoreDeviceView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

2011 """Remove a device from the ignore list.""" 

2012 

2013 permission_required = "netbox_data_import.change_importprofile" 

2014 

2015 def post(self, request): 

2016 """Remove the specified device from the profile's ignore list.""" 

2017 from .models import IgnoredDevice 

2018 

2019 profile_id = _parse_posted_profile_id(request) 

2020 source_id = request.POST.get("source_id") 

2021 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

2022 

2023 if profile_id is None: 

2024 messages.error(request, "A valid import profile is required.") 

2025 elif source_id: 

2026 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

2027 # Serialize against an executing import, which holds the same profile row. 

2028 with locked_profile_policy(profile.pk): 

2029 count = delete_permission_scoped_objects( 

2030 request.user, 

2031 IgnoredDevice.objects.filter(profile=profile, source_id=source_id), 

2032 ) 

2033 if count: 

2034 messages.success(request, "Device removed from ignore list.") 

2035 else: 

2036 messages.warning(request, "Device was not on the ignore list (may be ignored by class mapping).") 

2037 return redirect(next_url) 

2038 

2039 

2040def _wants_json(request) -> bool: 

2041 """Return whether one preview action expects a JSON response.""" 

2042 return "application/json" in request.headers.get("Accept", "") 

2043 

2044 

2045def _session_holds_a_preview(request) -> bool: 

2046 """Answer whether this save belongs to a preview at all, rather than standing alone.""" 

2047 return bool(request.session.get("import_rows") or request.session.get("import_context")) 

2048 

2049 

2050def _preview_is_active(request) -> bool: 

2051 """Answer whether a preview is still on screen and able to receive a decision.""" 

2052 return request.session.get("import_preview_pending") is True 

2053 

2054 

2055def _preview_accepts_decisions(request) -> bool: 

2056 """Answer whether the posted decision belongs to the preview that is still active.""" 

2057 return _preview_is_active(request) and request.POST.get("preview_revision") == current_preview_revision( 

2058 request.session 

2059 ) 

2060 

2061 

2062def _preview_action_error(request, next_url, message, *, status=409): 

2063 """Return one preview-action error through JSON or the form fallback.""" 

2064 if _wants_json(request): 

2065 # A JSON caller renders the reason itself, so a queued message would surface later 

2066 # on an unrelated page. 

2067 return JsonResponse({"ok": False, "error": message}, status=status) 

2068 messages.error(request, message) 

2069 return redirect(next_url) 

2070 

2071 

2072def _saved_preview_action_response(request, next_url, message): 

2073 """Report a saved preview decision without rebuilding its materialized plan.""" 

2074 mark_preview_dirty(request.session) 

2075 if _wants_json(request): 

2076 return JsonResponse(pending_preview_payload(None, message)) 

2077 messages.success(request, message) 

2078 return redirect(next_url) 

2079 

2080 

2081def _resolved_import_target(ctx_data, user): 

2082 """Return the engine context the saved import names, or None once that target went stale. 

2083 

2084 The session outlives a permission change, so each request re-reads the target in the operator's 

2085 own scope: a revoked ObjectPermission has to make the target unavailable, not merely unlisted. 

2086 """ 

2087 from dcim.models import Location, Site 

2088 from tenancy.models import Tenant 

2089 

2090 sites = Site.objects.restrict(user, "view") 

2091 locations = Location.objects.restrict(user, "view") 

2092 tenants = Tenant.objects.restrict(user, "view") 

2093 site = sites.filter(pk=ctx_data.get("site_id")).first() 

2094 location = locations.filter(pk=ctx_data.get("location_id")).first() if ctx_data.get("location_id") else None 

2095 tenant = tenants.filter(pk=ctx_data.get("tenant_id")).first() if ctx_data.get("tenant_id") else None 

2096 if ( 

2097 site is None 

2098 or (ctx_data.get("location_id") and (location is None or location.site_id != site.pk)) 

2099 or (ctx_data.get("tenant_id") and tenant is None) 

2100 ): 

2101 return None 

2102 return {"site": site, "location": location, "tenant": tenant} 

2103 

2104 

2105def _stale_preview_reason(request): 

2106 """Return why the preview can no longer take a decision, or None.""" 

2107 if not _session_holds_a_preview(request): 

2108 return None 

2109 if not _preview_is_active(request): 

2110 return "The import already started, so this preview can no longer take a decision." 

2111 # A second tab can recalculate between opening the modal and saving it. 

2112 if not _preview_accepts_decisions(request): 

2113 return "This preview is no longer the current one. Reload the preview and choose again." 

2114 return None 

2115 

2116 

2117class _PreviewRowDecision(NamedTuple): 

2118 """The request state a preview row decision has to establish before it may write.""" 

2119 

2120 profile: object 

2121 ctx_data: dict 

2122 rows: list 

2123 row_number: int 

2124 source_id: str 

2125 source_row: dict 

2126 next_url: str 

2127 

2128 

2129def _preview_row_decision(request): 

2130 """Return the validated state for a preview row decision, or the response that refuses it. 

2131 

2132 Both decisions guard the same preview, so they read these preconditions from here: two copies 

2133 drift, and a gate that is missing on one of them is a write the operator never authorized. 

2134 """ 

2135 ctx_data = request.session.get("import_context") or {} 

2136 rows = request.session.get("import_rows") or [] 

2137 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

2138 profile_id = _parse_posted_profile_id(request) 

2139 if profile_id is None: 

2140 messages.error(request, "A valid import profile is required.") 

2141 return None, _name_resolution_response(request, next_url) 

2142 if str(ctx_data.get("profile_id")) != str(profile_id): 

2143 messages.error(request, "The selected profile is not the active import profile.") 

2144 return None, _name_resolution_response(request, next_url) 

2145 

2146 profile = get_object_or_404( 

2147 ImportProfile.objects.restrict(request.user, "change"), 

2148 pk=profile_id, 

2149 ) 

2150 stale_reason = _stale_preview_reason(request) 

2151 if stale_reason is not None: 

2152 # An inline render would replace the preview that the queued import has frozen. 

2153 messages.error(request, stale_reason) 

2154 return None, _navigation_response(request, next_url) 

2155 try: 

2156 row_number = int(request.POST.get("row_number", "")) 

2157 except (TypeError, ValueError): 

2158 messages.error(request, "A valid source row is required.") 

2159 return None, _name_resolution_response(request, next_url) 

2160 

2161 source_id = source_text(request.POST.get("source_id")) 

2162 source_rows = [ 

2163 row for row in rows if row.get("_row_number") == row_number and source_text(row.get("source_id")) == source_id 

2164 ] 

2165 if not source_id or len(source_rows) != 1: 

2166 messages.error(request, "The source ID and row must identify one active import row.") 

2167 return None, _name_resolution_response(request, next_url) 

2168 

2169 return _PreviewRowDecision(profile, ctx_data, rows, row_number, source_id, source_rows[0], next_url), None 

2170 

2171 

2172def _field_review_row(request): 

2173 """Return the current row and target field for a review POST.""" 

2174 preview = load_cached_preview(request) 

2175 if preview is None: 

2176 return None 

2177 profile, result = preview 

2178 try: 

2179 row_number = int(request.POST.get("row_number", "")) 

2180 except (TypeError, ValueError): 

2181 return None 

2182 target_field = request.POST.get("target_field", "").strip() 

2183 row = next( 

2184 ( 

2185 item 

2186 for item in result.units 

2187 if item.row_number == row_number and item.object_type == "device" and item.action in {"update", "error"} 

2188 ), 

2189 None, 

2190 ) 

2191 if row is None or DeviceFieldReviewer.definition(target_field) is None: 

2192 return None 

2193 if not source_text(row.source_id): 

2194 return None 

2195 return profile, result, row, target_field 

2196 

2197 

2198def _preview_device_action(request): 

2199 """Return the cached row and permitted Device for one preview action.""" 

2200 preview = load_cached_preview(request) 

2201 if preview is None: 

2202 return None 

2203 _profile, result = preview 

2204 try: 

2205 row_number = int(request.POST.get("row_number", "")) 

2206 except (TypeError, ValueError): 

2207 return None 

2208 row = next( 

2209 ( 

2210 item 

2211 for item in result.units 

2212 if item.row_number == row_number and item.object_type == "device" and item.action in {"update", "error"} 

2213 ), 

2214 None, 

2215 ) 

2216 device_id = row.extra_data.get("netbox_device_id") if row is not None else None 

2217 if not device_id: 

2218 return None 

2219 

2220 from dcim.models import Device 

2221 

2222 device = ( 

2223 Device.objects.restrict(request.user, "change") 

2224 .select_related("device_type__manufacturer", "rack__location", "role", "tenant", "location") 

2225 .filter(pk=device_id) 

2226 .first() 

2227 ) 

2228 if device is None: 

2229 return None 

2230 return row, device 

2231 

2232 

2233def _offered_difference(row, target_field) -> bool: 

2234 """Return whether the preview offered this field for review. 

2235 

2236 A field the import does not write is reported too, and an operator ignores it to stop 

2237 the preview reporting it. Only a synced field has to be a writable difference. 

2238 """ 

2239 return target_field in row.extra_data.get("field_diff", {}) or target_field in row.extra_data.get( 

2240 "field_informational", {} 

2241 ) 

2242 

2243 

2244def _preview_field_intent(request, target_field): 

2245 """Return one authoritative field value after checking its NetBox baseline.""" 

2246 action = _preview_device_action(request) 

2247 if action is None: 

2248 return None, "The active preview row is no longer available." 

2249 row, device = action 

2250 if target_field not in row.extra_data.get("field_diff", {}): 

2251 return None, "The selected field difference is no longer present." 

2252 snapshots = row.extra_data.get("field_review_snapshots", {}).get(target_field) 

2253 if not isinstance(snapshots, dict): 

2254 return None, "The selected field has no authoritative preview value." 

2255 current = DeviceFieldReviewer.current_snapshot(device, target_field) 

2256 if current is None or current.get("canonical") != snapshots.get("netbox", {}).get("canonical"): 

2257 return None, "The matched NetBox value changed. Recalculate the preview and try again." 

2258 if target_field in {"u_position", "face"} and not _placement_matches_preview(device, row): 

2259 return None, "The matched NetBox placement changed. Recalculate the preview and try again." 

2260 return (row, device, snapshots.get("file", {}).get("canonical", "")), None 

2261 

2262 

2263def _placement_matches_preview(device, row) -> bool: 

2264 """Return whether placement fields still match the materialized preview.""" 

2265 state = row.extra_data.get("_placement_state") 

2266 # A baseline that states no location cannot prove the Device stayed in one, so it fails closed. 

2267 if not isinstance(state, dict) or "location_id" not in state: 

2268 return False 

2269 return ( 

2270 device.location_id == state["location_id"] 

2271 and device.rack_id == state.get("rack_id") 

2272 and normalize_for_compare(device.position) == state.get("position", "") 

2273 and (device.face or "") == state.get("face", "") 

2274 ) 

2275 

2276 

2277def _locked_placement_device(request, device_pk): 

2278 """Return the Device row locked for update, or None when it is gone or not permitted.""" 

2279 from dcim.models import Device 

2280 

2281 # PostgreSQL refuses FOR UPDATE on a nullable outer join, so `of` locks the Device row alone. 

2282 return ( 

2283 Device.objects.restrict(request.user, "change") 

2284 .select_for_update(of=("self",)) 

2285 .select_related("site", "location", "rack", "device_type") 

2286 .filter(pk=device_pk) 

2287 .first() 

2288 ) 

2289 

2290 

2291def _placement_action_intent(request): 

2292 """Return authoritative placement inputs for preview and direct actions.""" 

2293 from dcim.models import Device 

2294 

2295 if request.POST.get("row_number"): 

2296 action = _preview_device_action(request) 

2297 if action is None: 

2298 return None, "The active preview row is no longer available.", 409 

2299 row, device = action 

2300 if not _placement_matches_preview(device, row): 

2301 return ( 

2302 None, 

2303 "The matched NetBox placement changed. Recalculate the preview and try again.", 

2304 409, 

2305 ) 

2306 return ( 

2307 { 

2308 "row": row, 

2309 "device": device, 

2310 "rack_name": row.rack_name, 

2311 "u_position": row.extra_data.get("u_position", ""), 

2312 "face": row.extra_data.get("face", ""), 

2313 }, 

2314 None, 

2315 None, 

2316 ) 

2317 

2318 try: 

2319 device = ( 

2320 Device.objects.restrict(request.user, "change") 

2321 .select_related("site", "location", "rack", "device_type") 

2322 .get(pk=request.POST.get("device_id")) 

2323 ) 

2324 except (Device.DoesNotExist, ValueError, TypeError): 

2325 return None, "Device not found", 200 

2326 return ( 

2327 { 

2328 "row": None, 

2329 "device": device, 

2330 "rack_name": request.POST.get("rack_name", ""), 

2331 "u_position": request.POST.get("u_position", ""), 

2332 "face": request.POST.get("face", ""), 

2333 }, 

2334 None, 

2335 None, 

2336 ) 

2337 

2338 

2339class IgnoreFieldDifferenceView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

2340 """Ignore one exact current field difference for a matched Device.""" 

2341 

2342 permission_required = "netbox_data_import.add_ignoredfielddifference" 

2343 

2344 def post(self, request): 

2345 """Save current snapshots from a fresh active preview.""" 

2346 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

2347 review = _field_review_row(request) 

2348 if review is None: 

2349 return _preview_action_error( 

2350 request, 

2351 next_url, 

2352 "The selected field difference is no longer current. Recalculate the preview and try again.", 

2353 ) 

2354 profile, _result, row, target_field = review 

2355 if not _offered_difference(row, target_field): 

2356 return _preview_action_error( 

2357 request, 

2358 next_url, 

2359 "The selected field difference is no longer present. Refresh the preview.", 

2360 ) 

2361 snapshots = row.extra_data.get("field_review_snapshots", {}).get(target_field) 

2362 device_id = row.extra_data.get("netbox_device_id") 

2363 if not isinstance(snapshots, dict) or not device_id: 

2364 return _preview_action_error( 

2365 request, 

2366 next_url, 

2367 "The selected field difference has no current matched device.", 

2368 ) 

2369 

2370 from dcim.models import Device 

2371 

2372 device = Device.objects.restrict(request.user, "view").filter(pk=device_id).first() 

2373 if device is None: 

2374 return _preview_action_error(request, next_url, "The matched NetBox device is no longer available.") 

2375 current_snapshot = DeviceFieldReviewer.current_snapshot(device, target_field) 

2376 if current_snapshot is None or current_snapshot.get("canonical") != snapshots.get("netbox", {}).get( 

2377 "canonical" 

2378 ): 

2379 return _preview_action_error( 

2380 request, 

2381 next_url, 

2382 "The matched NetBox value changed. Recalculate the preview and try again.", 

2383 ) 

2384 lookup = { 

2385 "profile": profile, 

2386 "source_id": row.source_id, 

2387 "netbox_device_id": device.pk, 

2388 "target_field": target_field, 

2389 } 

2390 defaults = { 

2391 "file_snapshot": snapshots.get("file", {}), 

2392 "netbox_snapshot": snapshots.get("netbox", {}), 

2393 } 

2394 try: 

2395 with transaction.atomic(): 

2396 binding_allowed, binding_error = _ensure_field_review_device_match( 

2397 request.user, 

2398 profile, 

2399 row.source_id, 

2400 device, 

2401 source_text(row.extra_data.get("asset_tag"))[:50], 

2402 ) 

2403 if not binding_allowed: 

2404 message = ( 

2405 "Permission denied: cannot persist the source-to-device field-review match." 

2406 if binding_error == "permission" 

2407 else "The source row or device is already linked elsewhere." 

2408 ) 

2409 # atomic-exit-safe: binding-refused-before-write 

2410 return _preview_action_error(request, next_url, message) 

2411 # A denial raises, so the binding written above unwinds with the block. 

2412 save_permission_scoped_object( 

2413 request.user, 

2414 IgnoredFieldDifference, 

2415 lookup, 

2416 defaults, 

2417 ) 

2418 except ObjectPermissionDenied: 

2419 return _preview_action_error( 

2420 request, 

2421 next_url, 

2422 "Permission denied: cannot create or change this field review.", 

2423 ) 

2424 except ValidationError as exc: 

2425 return _preview_action_error(request, next_url, "; ".join(exc.messages), status=400) 

2426 except IntegrityError: 

2427 return _preview_action_error( 

2428 request, 

2429 next_url, 

2430 "The field review or device link changed while this request was being processed. Try again.", 

2431 ) 

2432 messages.success(request, f"Ignored the current {target_field} difference.") 

2433 mark_preview_dirty(request.session) 

2434 if _wants_json(request): 

2435 return JsonResponse( 

2436 pending_preview_payload( 

2437 row.row_number, 

2438 f"Ignored the current {target_field} difference.", 

2439 ) 

2440 ) 

2441 return redirect(next_url) 

2442 

2443 

2444class UnignoreFieldDifferenceView(PermissionRequiredMixin, View): 

2445 """Remove one exact current field-difference review for a matched Device.""" 

2446 

2447 permission_required = "netbox_data_import.delete_ignoredfielddifference" 

2448 

2449 def post(self, request): 

2450 """Delete only the review represented by the fresh active preview.""" 

2451 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

2452 review = _field_review_row(request) 

2453 if review is None: 

2454 return _preview_action_error( 

2455 request, 

2456 next_url, 

2457 "The selected field review is no longer current. Recalculate the preview and try again.", 

2458 ) 

2459 profile, _result, row, target_field = review 

2460 if target_field not in row.extra_data.get("field_ignored", {}): 

2461 return _preview_action_error( 

2462 request, 

2463 next_url, 

2464 "The selected field review is no longer current. Refresh the preview.", 

2465 ) 

2466 device_id = row.extra_data.get("netbox_device_id") 

2467 from dcim.models import Device 

2468 

2469 device = Device.objects.restrict(request.user, "view").filter(pk=device_id).first() 

2470 if device is None: 

2471 return _preview_action_error(request, next_url, "The matched NetBox device is no longer available.") 

2472 try: 

2473 with transaction.atomic(): 

2474 record = ( 

2475 IgnoredFieldDifference.objects.select_for_update() 

2476 .filter( 

2477 profile=profile, 

2478 source_id=row.source_id, 

2479 netbox_device_id=device.pk, 

2480 target_field=target_field, 

2481 ) 

2482 .first() 

2483 ) 

2484 if record is None: 

2485 # atomic-exit-safe: record-absent-before-write 

2486 return _preview_action_error( 

2487 request, 

2488 next_url, 

2489 "The selected field review is no longer current. Refresh the preview.", 

2490 ) 

2491 if not request.user.has_perm("netbox_data_import.delete_ignoredfielddifference", record): 

2492 # atomic-exit-safe: delete-denied-before-write 

2493 return _preview_action_error( 

2494 request, 

2495 next_url, 

2496 "Permission denied: cannot remove this field review.", 

2497 ) 

2498 binding_allowed, binding_error = _ensure_field_review_device_match( 

2499 request.user, 

2500 profile, 

2501 row.source_id, 

2502 device, 

2503 source_text(row.extra_data.get("asset_tag"))[:50], 

2504 ) 

2505 if not binding_allowed: 

2506 message = ( 

2507 "Permission denied: cannot persist the source-to-device field-review match." 

2508 if binding_error == "permission" 

2509 else "The source row or device is already linked elsewhere." 

2510 ) 

2511 # atomic-exit-safe: binding-refused-before-delete 

2512 return _preview_action_error(request, next_url, message) 

2513 record.delete() 

2514 except IntegrityError: 

2515 return _preview_action_error( 

2516 request, 

2517 next_url, 

2518 "The field review or device link changed while this request was being processed. Try again.", 

2519 ) 

2520 messages.success(request, f"Showing the {target_field} difference again.") 

2521 mark_preview_dirty(request.session) 

2522 if _wants_json(request): 

2523 return JsonResponse( 

2524 pending_preview_payload( 

2525 row.row_number, 

2526 f"Showing the {target_field} difference again.", 

2527 ) 

2528 ) 

2529 return redirect(next_url) 

2530 

2531 

2532class RemoveExtraIpView(PermissionRequiredMixin, View): 

2533 """Remove one stored IP from a device's import record.""" 

2534 

2535 permission_required = "dcim.change_device" 

2536 

2537 def post(self, request): 

2538 """Remove an IP field from the device's import record.""" 

2539 from dcim.models import Device 

2540 

2541 device_id = request.POST.get("device_id") 

2542 ip_field = request.POST.get("ip_field") 

2543 

2544 def _safe_return(device=None): 

2545 url = request.POST.get("next", "") 

2546 if url and url_has_allowed_host_and_scheme( 

2547 url, allowed_hosts={request.get_host()}, require_https=request.is_secure() 

2548 ): 

2549 return redirect(url) 

2550 if device: 

2551 return redirect(device.get_absolute_url()) 

2552 return redirect("/") 

2553 

2554 if not device_id or not ip_field: 

2555 messages.error(request, "Missing device_id or ip_field.") 

2556 return _safe_return() 

2557 

2558 if ip_field not in ("primary_ip4", "primary_ip6", "oob_ip"): 

2559 messages.error(request, f"Invalid ip_field: {ip_field}") 

2560 return _safe_return() 

2561 

2562 device = get_object_or_404(Device.objects.restrict(request.user, "change"), pk=device_id) 

2563 import_source = stored_import_source(device) 

2564 unassigned_ips = dict(import_source.unassigned_ips) if import_source is not None else {} 

2565 

2566 if ip_field in unassigned_ips: 

2567 del unassigned_ips[ip_field] 

2568 import_source.unassigned_ips = unassigned_ips 

2569 import_source.save(update_fields=["unassigned_ips"]) 

2570 messages.success(request, f"Removed {ip_field} from the import record.") 

2571 else: 

2572 messages.info(request, f"{ip_field} was not in the import record.") 

2573 

2574 return _safe_return(device) 

2575 

2576 

2577# --------------------------------------------------------------------------- 

2578# Sync single device field from import file value 

2579# --------------------------------------------------------------------------- 

2580 

2581 

2582class _AjaxPermissionView(ConditionalLoginRequiredMixin, View): 

2583 """Base for AJAX/JSON endpoints — inherits NetBox's ``ConditionalLoginRequiredMixin``. 

2584 

2585 Subclasses set ``permission_required`` (a Django permission string) to gate 

2586 access. Unauthenticated requests receive a JSON 401 (never a redirect, since 

2587 these endpoints are called via ``fetch``). Authenticated users without the 

2588 required permission receive a JSON 403. ``ConditionalLoginRequiredMixin`` is 

2589 still inherited so that login redirects work if the request is ever reached 

2590 via a browser navigation (e.g. direct URL), but the explicit checks above 

2591 fire first for API callers. 

2592 """ 

2593 

2594 permission_required: str | tuple[str, ...] | None = None 

2595 permission_denied_response_format = "json" 

2596 

2597 def dispatch(self, request, *args, **kwargs): 

2598 from django.http import JsonResponse 

2599 

2600 if not request.user.is_authenticated: 

2601 return JsonResponse({"ok": False, "error": "Authentication required"}, status=401) 

2602 if self.permission_required and not request.user.has_perm(self.permission_required): 

2603 return JsonResponse({"ok": False, "error": "Permission denied"}, status=403) 

2604 return super().dispatch(request, *args, **kwargs) 

2605 

2606 

2607class ContactLookupView(_AjaxPermissionView): 

2608 """Search visible NetBox Contacts for the contact-resolution picker.""" 

2609 

2610 permission_required = "tenancy.view_contact" 

2611 

2612 def get(self, request): 

2613 """Return a small real-shape Contact result set.""" 

2614 from django.db.models import Q 

2615 from django.http import JsonResponse 

2616 from tenancy.models import Contact 

2617 

2618 query = request.GET.get("q", "").strip() 

2619 if len(query) < 2: 

2620 return JsonResponse({"results": []}) 

2621 contacts = ( 

2622 Contact.objects.restrict(request.user, "view") 

2623 .filter(Q(name__icontains=query) | Q(email__icontains=query) | Q(phone__icontains=query)) 

2624 .order_by("name", "email", "pk")[:20] 

2625 ) 

2626 return JsonResponse( 

2627 { 

2628 "results": [ 

2629 { 

2630 "id": contact.pk, 

2631 "name": contact.name, 

2632 "email": contact.email, 

2633 "phone": contact.phone, 

2634 } 

2635 for contact in contacts 

2636 ] 

2637 } 

2638 ) 

2639 

2640 

2641class ContactSuggestionView(_AjaxPermissionView): 

2642 """Return the Contact one preview row's candidate values identify, as it stands now.""" 

2643 

2644 permission_required = "tenancy.view_contact" 

2645 

2646 def get(self, request): 

2647 """Recompute one row's suggestion, so a Contact created on another row is offered here.""" 

2648 from django.http import JsonResponse 

2649 

2650 try: 

2651 profile_id = int(request.GET.get("profile_id", "")) 

2652 except (TypeError, ValueError): 

2653 profile_id = None 

2654 source_id = request.GET.get("source_id", "") 

2655 if profile_id is None or not source_id: 

2656 return JsonResponse({"error": "A valid import profile and source row are required."}, status=400) 

2657 profile = ImportProfile.objects.filter(pk=profile_id).first() 

2658 if profile is None: 

2659 return JsonResponse({"error": "A valid import profile is required."}, status=400) 

2660 try: 

2661 # The open picker outlives an upgrade, so the stored profile can name a retired adapter. 

2662 validate_registered_adapter(profile) 

2663 candidates, _source_row, _result_row = _contact_candidate_context(request, profile.pk, source_id) 

2664 except ValidationError as exc: 

2665 return JsonResponse({"error": "; ".join(exc.messages)}, status=400) 

2666 return JsonResponse({"suggestion": PrimaryContactResolver.suggest(candidates, profile, request.user)}) 

2667 

2668 

2669class SyncDeviceFieldView(_AjaxPermissionView): 

2670 """Apply a single field value from the import file to an existing NetBox device.""" 

2671 

2672 permission_required = "dcim.change_device" 

2673 

2674 _IP_FIELDS = ("primary_ip4", "primary_ip6", "oob_ip") 

2675 _ALLOWED_FIELDS = {"device_name", "u_position", "status", "serial", "asset_tag", "face", "airflow", *_IP_FIELDS} 

2676 

2677 def post(self, request): 

2678 """Apply one previewed field value to its matched Device.""" 

2679 from django.http import JsonResponse 

2680 

2681 from dcim.models import Device 

2682 

2683 field = request.POST.get("field", "") 

2684 

2685 if not field or field not in self._ALLOWED_FIELDS: 

2686 return JsonResponse({"ok": False, "error": f"Field '{field}' is not syncable"}) 

2687 

2688 is_preview_action = bool(request.POST.get("row_number")) 

2689 if is_preview_action: 

2690 intent, error = _preview_field_intent(request, field) 

2691 if error: 

2692 return JsonResponse({"ok": False, "error": error}, status=409) 

2693 row, device, value = intent 

2694 else: 

2695 device_id = request.POST.get("device_id") 

2696 value = request.POST.get("value", "") 

2697 try: 

2698 device = Device.objects.restrict(request.user, "change").select_related("device_type").get(pk=device_id) 

2699 except (Device.DoesNotExist, ValueError, TypeError): 

2700 return JsonResponse({"ok": False, "error": "Device not found"}) 

2701 

2702 try: 

2703 # Nothing wraps this request, and a receiver on the model can require a transaction. 

2704 with transaction.atomic(): 

2705 display = self._apply_field(device, field, value, status_map(), request.user) 

2706 except PreviewActionInvalid as exc: 

2707 return JsonResponse({"ok": False, "error": str(exc)}) 

2708 except Exception: 

2709 logger.exception( 

2710 "SyncDeviceFieldView failed for device_id=%s field=%s", 

2711 device.pk, 

2712 field, 

2713 ) 

2714 return JsonResponse({"ok": False, "error": "An internal error occurred."}, status=500) 

2715 

2716 if is_preview_action: 

2717 mark_preview_dirty(request.session) 

2718 return JsonResponse( 

2719 pending_preview_payload( 

2720 row.row_number, 

2721 f"Updated {field} to {display}.", 

2722 ) 

2723 ) 

2724 return JsonResponse({"ok": True, "display": display}) 

2725 

2726 @staticmethod 

2727 def _writer_safe_text(device, label, model_field, value): 

2728 """Reject a value the writer would otherwise truncate away from what the preview showed.""" 

2729 text = str(value) 

2730 limit = type(device)._meta.get_field(model_field).max_length 

2731 if len(text) > limit: 

2732 raise PreviewActionInvalid(f"The {label} is {len(text)} characters; NetBox allows {limit}.") 

2733 return text 

2734 

2735 def _apply_field(self, device, field, value, status_map, user): 

2736 """Write one previewed value onto the device, through that field's own writer.""" 

2737 if field in self._IP_FIELDS: 

2738 return self._apply_ip_field(device, field, value, user) 

2739 writer = { 

2740 "airflow": lambda: self._apply_airflow(device, value), 

2741 "device_name": lambda: self._apply_device_name(device, value), 

2742 "u_position": lambda: self._apply_u_position(device, value), 

2743 "status": lambda: self._apply_status(device, value, status_map), 

2744 "serial": lambda: self._apply_serial(device, value), 

2745 "asset_tag": lambda: self._apply_asset_tag(device, value), 

2746 "face": lambda: self._apply_face(device, value), 

2747 }.get(field) 

2748 if writer is None: 

2749 raise PreviewActionInvalid(f"Field '{field}' is not syncable") 

2750 return writer() 

2751 

2752 def _apply_device_name(self, device, value): 

2753 new_name = self._writer_safe_text(device, "device name", "name", value) 

2754 if type(device).objects.filter(site=device.site, name=new_name).exclude(pk=device.pk).exists(): 

2755 raise PreviewActionInvalid(f"A device named '{new_name}' already exists in site '{device.site}'") 

2756 device.name = new_name 

2757 device.save(update_fields=["name"]) 

2758 return new_name 

2759 

2760 def _apply_u_position(self, device, value): 

2761 pos = source_position(value) 

2762 if pos is None: 

2763 raise PreviewActionInvalid(f"Cannot parse '{value}' as a finite number for u_position") 

2764 zero_u_type = _zero_u_device_type(device) 

2765 if zero_u_type: 

2766 raise PreviewActionInvalid(f"Cannot set a rack position: the device type '{zero_u_type}' is 0U.") 

2767 device.position = pos 

2768 self._reject_invalid_placement(device) 

2769 device.save(update_fields=["position"]) 

2770 return f"U{device.position}" 

2771 

2772 @staticmethod 

2773 def _apply_status(device, value, status_map): 

2774 text = str(value).strip().lower() 

2775 # A NetBox status slug is accepted directly too (for example "active", "offline"). 

2776 mapped = status_map.get(text) or (text if text in set(status_map.values()) else None) 

2777 if mapped is None: 

2778 raise PreviewActionInvalid(f"Unknown status value '{value}'") 

2779 device.status = mapped 

2780 device.save(update_fields=["status"]) 

2781 return device.status 

2782 

2783 def _apply_serial(self, device, value): 

2784 device.serial = self._writer_safe_text(device, "serial", "serial", value) 

2785 device.save(update_fields=["serial"]) 

2786 return device.serial 

2787 

2788 def _apply_asset_tag(self, device, value): 

2789 device.asset_tag = self._writer_safe_text(device, "asset tag", "asset_tag", value) if value else None 

2790 device.save(update_fields=["asset_tag"]) 

2791 return device.asset_tag 

2792 

2793 def _apply_face(self, device, value): 

2794 if device.rack_id is None: 

2795 raise PreviewActionInvalid( 

2796 "Cannot set face: device has no rack assigned. Sync rack first, or use Sync Placement." 

2797 ) 

2798 zero_u_type = _zero_u_device_type(device) 

2799 if zero_u_type: 

2800 raise PreviewActionInvalid(f"Cannot set a rack face: the device type '{zero_u_type}' is 0U.") 

2801 mapped = _FACE_MAP.get(str(value).strip().lower()) 

2802 if mapped is None: 

2803 raise PreviewActionInvalid(f"Unknown face value '{value}' — expected 'front' or 'rear'") 

2804 device.face = mapped 

2805 self._reject_invalid_placement(device) 

2806 device.save(update_fields=["face"]) 

2807 return device.face 

2808 

2809 @staticmethod 

2810 def _apply_airflow(device, value): 

2811 """Write the airflow the source row states, in the wording the importer already reads.""" 

2812 _side, airflow_map, _status = translation_maps() 

2813 text = str(value).strip().lower() 

2814 mapped = airflow_map.get(text) 

2815 # The stored value is also accepted, so a row already carrying one syncs as it stands. 

2816 if mapped is None and text in set(airflow_map.values()): 

2817 mapped = text 

2818 if mapped is None: 

2819 raise PreviewActionInvalid(f"Unknown airflow value '{value}'") 

2820 device.airflow = mapped 

2821 device.save(update_fields=["airflow"]) 

2822 return device.airflow 

2823 

2824 def _apply_ip_field(self, device, field, value, user): 

2825 """Point one of the device's IP fields at the address the source row carries.""" 

2826 try: 

2827 target = ip_assignment.resolve(device, field, value) 

2828 except ip_assignment.IPAssignmentError as exc: 

2829 raise PreviewActionInvalid(str(exc)) from exc 

2830 

2831 if target.already_held: 

2832 # The device carries it already, so only the field moves. No IPAM row is written. 

2833 held = target.held 

2834 if getattr(device, f"{field}_id", None) != held.pk: 

2835 setattr(device, field, held) 

2836 device.save(update_fields=[field]) 

2837 return target.summary 

2838 

2839 try: 

2840 address = ip_assignment.apply(target, user) 

2841 except ValidationError as exc: 

2842 raise PreviewActionInvalid("; ".join(exc.messages)) from exc 

2843 except ObjectPermissionDenied as exc: 

2844 raise PreviewActionInvalid(f"Permission denied: {exc} for this IP address.") from exc 

2845 setattr(device, field, address) 

2846 device.save(update_fields=[field]) 

2847 return f"{address.address} on {target.interface.name}" 

2848 

2849 @staticmethod 

2850 def _reject_invalid_placement(device) -> None: 

2851 """Reject a placement value NetBox would refuse, before it reaches an unvalidated save.""" 

2852 try: 

2853 _validate_device_placement(device) 

2854 except ValidationError as exc: 

2855 raise PreviewActionInvalid(_placement_error_text(exc)) from exc 

2856 

2857 

2858def _lookup_rack_for_device(request, device, value): 

2859 """Look up a Rack by name within ``device.site``, honoring ``device.location``. 

2860 

2861 If the device has a location set, the rack must be in the same location. If the 

2862 device has no location, the rack must also have no location (the implicit 

2863 "default location" semantic). 

2864 

2865 Returns ``(rack, None)`` on success or ``(None, error_message)`` on failure. 

2866 Error messages are static, controlled strings — no exception text is exposed, 

2867 so the result is safe to return directly to the client. 

2868 """ 

2869 from dcim.models import Rack 

2870 

2871 name = (str(value) if value is not None else "").strip() 

2872 if not name: 

2873 return None, "Rack name is empty" 

2874 if device.site_id is None: 

2875 return None, "Device has no site; cannot resolve rack" 

2876 qs = Rack.objects.restrict(request.user, "view").filter(site=device.site, name=name) 

2877 if device.location_id is not None: 

2878 qs = qs.filter(location=device.location) 

2879 loc_str = f" / location '{device.location}'" 

2880 else: 

2881 qs = qs.filter(location__isnull=True) 

2882 loc_str = "" 

2883 racks = list(qs[:2]) 

2884 if not racks: 

2885 return None, f"Rack '{name}' not found in site '{device.site}'{loc_str}" 

2886 if len(racks) > 1: 

2887 return None, f"Multiple racks named '{name}' found; cannot disambiguate" 

2888 return racks[0], None 

2889 

2890 

2891def _validate_device_placement(device) -> None: 

2892 """Run NetBox validation and reject only errors caused by placement fields.""" 

2893 try: 

2894 device.full_clean() 

2895 except ValidationError as exc: 

2896 if not hasattr(exc, "message_dict"): 

2897 raise 

2898 placement_fields = {"rack", "location", "position", "face", "device_type", "__all__"} 

2899 errors = {field: messages for field, messages in exc.message_dict.items() if field in placement_fields} 

2900 if errors: 

2901 raise ValidationError(errors) from exc 

2902 

2903 

2904_FACE_MAP = {"front": "front", "rear": "rear", "0": "front", "1": "rear"} 

2905 

2906 

2907def _placement_error_text(exc) -> str: 

2908 """Return one readable line for a placement ValidationError.""" 

2909 if hasattr(exc, "message_dict"): 

2910 return "; ".join(f"{name}: {', '.join(messages)}" for name, messages in exc.message_dict.items()) 

2911 return "; ".join(exc.messages) 

2912 

2913 

2914def _zero_u_device_type(device) -> str: 

2915 """Return the device type label when it is zero-U, which takes no position or face.""" 

2916 device_type = device.device_type 

2917 if device_type is not None and device_type.u_height == 0: 

2918 return str(device_type) 

2919 return "" 

2920 

2921 

2922def _set_rack_placement(device, u_position, face, zero_u_type): 

2923 """Set the rack position and face on *device*. 

2924 

2925 Returns the written field names, the field names a zero-U device type cannot take, 

2926 and one error message for a value the writer cannot accept. 

2927 """ 

2928 update_fields = [] 

2929 skipped = [] 

2930 

2931 if zero_u_type: 

2932 # Clear a stored position the way the import writer does, so the device stays valid. 

2933 if device.position is not None: 

2934 device.position = None 

2935 update_fields.append("position") 

2936 if device.face: 

2937 device.face = None 

2938 update_fields.append("face") 

2939 if u_position not in ("", None): 

2940 skipped.append("position") 

2941 if face not in ("", None): 

2942 skipped.append("face") 

2943 return update_fields, skipped, None 

2944 

2945 if u_position not in ("", None): 

2946 position = source_position(u_position) 

2947 if position is None: 

2948 return update_fields, skipped, f"Cannot parse '{u_position}' as a finite number for u_position" 

2949 device.position = position 

2950 update_fields.append("position") 

2951 

2952 if face not in ("", None): 

2953 mapped = _FACE_MAP.get(str(face).strip().lower()) 

2954 if mapped is None: 

2955 return update_fields, skipped, f"Unknown face value '{face}' — expected 'front' or 'rear'" 

2956 device.face = mapped 

2957 update_fields.append("face") 

2958 

2959 return update_fields, skipped, None 

2960 

2961 

2962class SyncPlacementView(_AjaxPermissionView): 

2963 """Atomically sync rack + (optional) u_position + (optional) face for a device. 

2964 

2965 All-or-nothing: if the rack lookup fails, nothing is saved. 

2966 """ 

2967 

2968 permission_required = "dcim.change_device" 

2969 

2970 def post(self, request): 

2971 """Apply the previewed placement to its matched Device.""" 

2972 from django.http import JsonResponse 

2973 

2974 intent, error, status = _placement_action_intent(request) 

2975 if error: 

2976 return JsonResponse({"ok": False, "error": error}, status=status) 

2977 row = intent["row"] 

2978 device = intent["device"] 

2979 rack_name = intent["rack_name"] 

2980 u_position = intent["u_position"] 

2981 face = intent["face"] 

2982 

2983 with transaction.atomic(): 

2984 # The baseline check above read the Device unlocked, so recheck it under the row lock. 

2985 device = _locked_placement_device(request, device.pk) 

2986 if device is None: 

2987 # atomic-exit-safe: device-gone-before-write 

2988 return JsonResponse({"ok": False, "error": "Device not found"}, status=409) 

2989 if row is not None and not _placement_matches_preview(device, row): 

2990 # atomic-exit-safe: baseline-moved-before-write 

2991 return JsonResponse( 

2992 { 

2993 "ok": False, 

2994 "error": "The matched NetBox placement changed. Recalculate the preview and try again.", 

2995 }, 

2996 status=409, 

2997 ) 

2998 

2999 rack, err = _lookup_rack_for_device(request, device, rack_name) 

3000 if err: 

3001 # atomic-exit-safe: rack-unresolved-before-write 

3002 return JsonResponse({"ok": False, "error": err}) 

3003 

3004 device.rack = rack 

3005 # NetBox rejects a rack position on a zero-U device type, so sync the rack alone. 

3006 zero_u_type = _zero_u_device_type(device) 

3007 placement_fields, skipped, error = _set_rack_placement(device, u_position, face, zero_u_type) 

3008 if error: 

3009 # atomic-exit-safe: placement-value-refused-before-write 

3010 return JsonResponse({"ok": False, "error": error}) 

3011 update_fields = ["rack", *placement_fields] 

3012 

3013 try: 

3014 _validate_device_placement(device) 

3015 except ValidationError as exc: 

3016 # full_clean sends post_clean, whose receivers may write before raising. 

3017 transaction.set_rollback(True) 

3018 return JsonResponse( 

3019 {"ok": False, "error": f"Validation failed: {_placement_error_text(exc)}"}, status=400 

3020 ) 

3021 except Exception: 

3022 logger.exception("SyncPlacementView full_clean failed for device_id=%s", device.pk) 

3023 transaction.set_rollback(True) 

3024 return JsonResponse({"ok": False, "error": "An internal error occurred."}, status=500) 

3025 

3026 try: 

3027 device.save(update_fields=update_fields) 

3028 except Exception: 

3029 logger.exception("SyncPlacementView save failed for device_id=%s", device.pk) 

3030 # A receiver raising after the UPDATE would otherwise commit a write reported as failed. 

3031 transaction.set_rollback(True) 

3032 return JsonResponse({"ok": False, "error": "An internal error occurred."}, status=500) 

3033 

3034 parts = [f"rack={rack.name}"] 

3035 if "position" in update_fields and device.position is not None: 

3036 parts.append(f"U{device.position}") 

3037 if "face" in update_fields and device.face: 

3038 parts.append(device.face) 

3039 display = ", ".join(parts) 

3040 if skipped: 

3041 display += f" (0U device type {zero_u_type} takes no {' or '.join(skipped)})" 

3042 if row is not None: 

3043 mark_preview_dirty(request.session) 

3044 return JsonResponse( 

3045 pending_preview_payload( 

3046 row.row_number, 

3047 f"Updated placement to {display}.", 

3048 ) 

3049 ) 

3050 return JsonResponse({"ok": True, "display": display}) 

3051 

3052 

3053# --------------------------------------------------------------------------- 

3054# Save resolution (rerere) 

3055# --------------------------------------------------------------------------- 

3056 

3057 

3058def _device_name_already_claimed(effective_rows, row_number, new_name, target): 

3059 """Return why this device name is unavailable at the import target, or None when it is free.""" 

3060 from dcim.models import Device 

3061 

3062 other_names = { 

3063 identity_text(device_name) 

3064 for row in effective_rows 

3065 if row.get("_row_number") != row_number and (device_name := effective_device_name(row)) 

3066 } 

3067 if identity_text(new_name) in other_names: 

3068 return f"Device name '{new_name}' is already used by another source row." 

3069 tenant = target["tenant"] 

3070 tenant_filter = {"tenant": tenant} if tenant is not None else {"tenant__isnull": True} 

3071 if Device.objects.filter(site=target["site"], name__iexact=new_name, **tenant_filter).exists(): 

3072 return f"Device name '{new_name}' already exists at the active import site." 

3073 return None 

3074 

3075 

3076class ResolveDuplicateNameView(PermissionRequiredMixin, View): 

3077 """Save a unique device name for one duplicate source row.""" 

3078 

3079 permission_required = "netbox_data_import.change_importprofile" 

3080 

3081 def post(self, request): 

3082 """Validate and persist the replacement device name.""" 

3083 decision, refused = _preview_row_decision(request) 

3084 if refused is not None: 

3085 return refused 

3086 profile = decision.profile 

3087 ctx_data = decision.ctx_data 

3088 rows = decision.rows 

3089 row_number = decision.row_number 

3090 source_id = decision.source_id 

3091 next_url = decision.next_url 

3092 

3093 new_name = request.POST.get("new_name", "").strip() 

3094 if not new_name or len(new_name) > 64: 

3095 messages.error(request, "The device name must contain 1 to 64 characters.") 

3096 return _name_resolution_response(request, next_url) 

3097 

3098 resolution_values = { 

3099 "original_value": source_text(decision.source_row.get("device_name")), 

3100 "resolved_fields": {"device_name": new_name}, 

3101 } 

3102 refusal = None 

3103 try: 

3104 # Serialize against an executing import, which holds the same profile row. 

3105 with locked_profile_policy(profile.pk): 

3106 # Read the target and the claims under the lock: a name saved between the check and 

3107 # the write would otherwise let two source rows resolve to the same device name. 

3108 target = _resolved_import_target(ctx_data, request.user) 

3109 if target is None: 

3110 refusal = "The saved import target is no longer available. Start a new preview." 

3111 else: 

3112 refusal = _device_name_already_claimed(rows, row_number, new_name, target) 

3113 if refusal is None: 

3114 save_permission_scoped_object( 

3115 request.user, 

3116 SourceResolution, 

3117 {"profile": profile, "source_id": source_id, "source_column": "device_name"}, 

3118 resolution_values, 

3119 ) 

3120 except ImportProfile.DoesNotExist: 

3121 messages.error(request, "The import profile is no longer available.") 

3122 return _name_resolution_response(request, next_url) 

3123 except ObjectPermissionDenied: 

3124 messages.error(request, "Permission denied: cannot create or change this saved name.") 

3125 return _name_resolution_response(request, next_url) 

3126 except ValidationError as exc: 

3127 messages.error(request, "; ".join(exc.messages)) 

3128 return _name_resolution_response(request, next_url) 

3129 except IntegrityError: 

3130 messages.error(request, "The saved name changed while this request was being processed. Try again.") 

3131 return _name_resolution_response(request, next_url) 

3132 

3133 if refusal is not None: 

3134 messages.error(request, refusal) 

3135 return _name_resolution_response(request, next_url) 

3136 

3137 messages.success(request, f"Source '{source_id}' will use device name '{new_name}'.") 

3138 return _name_resolution_response(request, next_url) 

3139 

3140 

3141def _duplicate_serial_shown(preview_rows, row_number) -> str: 

3142 """Return the serial the engine calls a duplicate on this source row, or an empty string.""" 

3143 for item in preview_rows: 

3144 if ( 

3145 item.row_number == row_number 

3146 and item.object_type == "device" 

3147 and item.extra_data.get("identity_conflict") == "duplicate_serial" 

3148 ): 

3149 return source_text(item.extra_data.get("duplicate_serial")) 

3150 return "" 

3151 

3152 

3153class IgnoreDuplicateSerialView(PermissionRequiredMixin, View): 

3154 """Drop the serial from one source row so the rows sharing it stop colliding.""" 

3155 

3156 permission_required = "netbox_data_import.change_importprofile" 

3157 

3158 def post(self, request): 

3159 """Persist an empty serial for the row the operator gives it up on.""" 

3160 decision, refused = _preview_row_decision(request) 

3161 if refused is not None: 

3162 return refused 

3163 profile = decision.profile 

3164 ctx_data = decision.ctx_data 

3165 rows = decision.rows 

3166 row_number = decision.row_number 

3167 source_id = decision.source_id 

3168 next_url = decision.next_url 

3169 

3170 preview = load_cached_preview(request) 

3171 # The action settles the collision the operator was shown, on the serial it named. 

3172 shown_serial = _duplicate_serial_shown(preview[1].units, row_number) if preview is not None else "" 

3173 if not shown_serial: 

3174 messages.error(request, "This row shows no duplicate serial in the current preview.") 

3175 return _name_resolution_response(request, next_url) 

3176 

3177 original_serial = source_text(decision.source_row.get("serial")) 

3178 if not original_serial: 

3179 messages.error(request, "This row carries no serial to give up.") 

3180 return _name_resolution_response(request, next_url) 

3181 

3182 refusal = None 

3183 try: 

3184 # Serialize against an executing import, which holds the same profile row. 

3185 with locked_profile_policy(profile.pk): 

3186 held_since = time.monotonic() 

3187 document = SourceDocument.objects.filter( 

3188 pk=ctx_data.get("source_document_id"), 

3189 profile=profile, 

3190 ).first() 

3191 if document is None: 

3192 refusal = "The stored source is no longer available. Upload it again." 

3193 else: 

3194 current = ReviewWorkspace( 

3195 ImportEngine.plan( 

3196 profile, 

3197 document, 

3198 request.user, 

3199 { 

3200 "site_id": ctx_data.get("site_id"), 

3201 "location_id": ctx_data.get("location_id"), 

3202 "tenant_id": ctx_data.get("tenant_id"), 

3203 }, 

3204 ) 

3205 ) 

3206 if _duplicate_serial_shown(current.units, row_number) != shown_serial: 

3207 refusal = f"No other row this import creates still claims serial '{shown_serial}'." 

3208 else: 

3209 save_permission_scoped_object( 

3210 request.user, 

3211 SourceResolution, 

3212 {"profile": profile, "source_id": source_id, "source_column": "serial"}, 

3213 {"original_value": original_serial, "resolved_fields": {"serial": ""}}, 

3214 ) 

3215 # The dry run costs more as the file grows, and the import worker waits behind it. 

3216 logger.info( 

3217 "IgnoreDuplicateSerialView: held the profile policy lock for %.2fs over %d source rows.", 

3218 time.monotonic() - held_since, 

3219 len(rows), 

3220 ) 

3221 except ImportProfile.DoesNotExist: 

3222 messages.error(request, "The import profile is no longer available.") 

3223 return _name_resolution_response(request, next_url) 

3224 except ObjectPermissionDenied: 

3225 messages.error(request, "Permission denied: cannot create or change this saved serial.") 

3226 return _name_resolution_response(request, next_url) 

3227 except ValidationError as exc: 

3228 messages.error(request, "; ".join(exc.messages)) 

3229 return _name_resolution_response(request, next_url) 

3230 except IntegrityError: 

3231 messages.error(request, "The saved serial changed while this request was being processed. Try again.") 

3232 return _name_resolution_response(request, next_url) 

3233 

3234 if refusal is not None: 

3235 messages.error(request, refusal) 

3236 return _name_resolution_response(request, next_url) 

3237 

3238 messages.success(request, f"Source '{source_id}' will import without serial '{shown_serial}'.") 

3239 return _name_resolution_response(request, next_url) 

3240 

3241 

3242class SaveResolutionView(_AjaxPermissionView): 

3243 """Save a manual field resolution for rerere replay.""" 

3244 

3245 permission_required = "netbox_data_import.change_importprofile" 

3246 

3247 def post(self, request): 

3248 """Persist a manual field resolution for rerere replay.""" 

3249 import json 

3250 

3251 profile_id = _parse_posted_profile_id(request) 

3252 source_id = request.POST.get("source_id") 

3253 source_column = request.POST.get("source_column") 

3254 original_value = request.POST.get("original_value") 

3255 resolved_fields_json = request.POST.get("resolved_fields", "{}") 

3256 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

3257 if profile_id is None: 

3258 return _preview_action_error(request, next_url, "A valid import profile is required.", status=400) 

3259 

3260 try: 

3261 resolved_fields = json.loads(resolved_fields_json) 

3262 except (json.JSONDecodeError, TypeError): 

3263 resolved_fields = {} 

3264 if not isinstance(resolved_fields, Mapping): 

3265 return _preview_action_error( 

3266 request, 

3267 next_url, 

3268 "Resolved fields must be a JSON object.", 

3269 status=400, 

3270 ) 

3271 

3272 if profile_id and source_id and source_column: 

3273 profile = get_object_or_404( 

3274 ImportProfile.objects.restrict(request.user, "change"), 

3275 pk=profile_id, 

3276 ) 

3277 stale_reason = _stale_preview_reason(request) 

3278 if stale_reason is not None: 

3279 return _preview_action_error(request, next_url, stale_reason, status=409) 

3280 

3281 contact_context = None 

3282 candidates = {} 

3283 if source_column == "candidate:contact": 

3284 try: 

3285 validate_registered_adapter(profile) 

3286 candidates, source_row, result_row = _contact_candidate_context(request, profile.pk, source_id) 

3287 validate_contact_candidate_resolution( 

3288 resolved_fields, 

3289 profile.adapter_settings.primary_contact_lookup_field, 

3290 candidates, 

3291 ) 

3292 except ValidationError as exc: 

3293 return _preview_action_error(request, next_url, "; ".join(exc.messages), status=400) 

3294 original_value = json.dumps(candidates, sort_keys=True) 

3295 contact_context = (source_row, result_row) 

3296 try: 

3297 validate_source_resolution_fields(profile, source_column, resolved_fields) 

3298 # Serialize against an executing import, which holds the same profile row. 

3299 with locked_profile_policy(profile.pk): 

3300 decision = _persist_contact_decision( 

3301 profile, resolved_fields, candidates, contact_context, request.user 

3302 ) 

3303 resolved_fields = decision.resolved_fields 

3304 save_permission_scoped_object( 

3305 request.user, 

3306 SourceResolution, 

3307 {"profile": profile, "source_id": source_id, "source_column": source_column}, 

3308 { 

3309 "original_value": original_value or "", 

3310 "resolved_fields": resolved_fields, 

3311 }, 

3312 ) 

3313 except IntegrityError: 

3314 return _preview_action_error( 

3315 request, 

3316 next_url, 

3317 "The resolution changed while this request was being processed. Try again.", 

3318 ) 

3319 except ObjectPermissionDenied as exc: 

3320 logger.warning("SaveResolutionView: write refused outside the caller's object scope: %s", exc) 

3321 return _preview_action_error( 

3322 request, 

3323 next_url, 

3324 "Permission denied: this action is outside your NetBox object permissions.", 

3325 status=403, 

3326 ) 

3327 except ValidationError as exc: 

3328 return _preview_action_error(request, next_url, "; ".join(exc.messages), status=400) 

3329 saved_message, contact_detail = _saved_resolution_report(decision.write, decision.note) 

3330 # The rendered row keeps the action it had before this decision, so the page must 

3331 # ask for a recalculation whichever path saved it. 

3332 mark_preview_dirty(request.session) 

3333 if _wants_json(request): 

3334 row_number = contact_context[1].row_number if contact_context else None 

3335 # The page keeps its own copy of the decision, so it is given the saved one. 

3336 resolution = { 

3337 "original_value": original_value or "", 

3338 "resolved_fields": resolved_fields, 

3339 "contact": decision.contact, 

3340 } 

3341 return JsonResponse( 

3342 pending_preview_payload(row_number, saved_message, contact_detail, resolution=resolution) 

3343 ) 

3344 messages.success(request, saved_message) 

3345 return redirect(next_url) 

3346 return _preview_action_error(request, next_url, "A source row and column are required.", status=400) 

3347 

3348 

3349# --------------------------------------------------------------------------- 

3350# Device type analysis view 

3351# --------------------------------------------------------------------------- 

3352 

3353 

3354class DeviceTypeAnalysisView(PermissionRequiredMixin, View): 

3355 """Show all unique (make, model) pairs across import jobs and profiles. 

3356 

3357 Highlights which ones have explicit DeviceTypeMapping vs auto-slugified. 

3358 """ 

3359 

3360 permission_required = "netbox_data_import.view_importprofile" 

3361 

3362 def get(self, request, profile_pk=None): 

3363 """Render the device type analysis page for the given profile.""" 

3364 profile = get_object_or_404(ImportProfile, pk=profile_pk) if profile_pk else None 

3365 profiles = ImportProfile.objects.all() 

3366 

3367 # Build analysis from DeviceTypeMapping + auto-slugify check 

3368 if profile: 

3369 dt_mappings = DeviceTypeMapping.objects.filter(profile=profile) 

3370 else: 

3371 dt_mappings = DeviceTypeMapping.objects.select_related("profile").all() 

3372 

3373 # Collect entries: explicit mappings 

3374 entries = [] 

3375 for dtm in dt_mappings: 

3376 entries.append( 

3377 { 

3378 "profile": dtm.profile, 

3379 "source_make": dtm.source_make, 

3380 "source_model": dtm.source_model, 

3381 "manufacturer_slug": dtm.netbox_manufacturer_slug, 

3382 "device_type_slug": dtm.netbox_device_type_slug, 

3383 "mapping_type": "explicit", 

3384 "mapping_pk": dtm.pk, 

3385 } 

3386 ) 

3387 

3388 # Check which mapped device types exist in NetBox 

3389 from dcim.models import DeviceType 

3390 

3391 for entry in entries: 

3392 entry["exists_in_netbox"] = DeviceType.objects.filter( 

3393 manufacturer__slug=entry["manufacturer_slug"], 

3394 slug=entry["device_type_slug"], 

3395 ).exists() 

3396 

3397 return render( 

3398 request, 

3399 "netbox_data_import/analysis.html", 

3400 { 

3401 "profile": profile, 

3402 "profiles": profiles, 

3403 "entries": entries, 

3404 }, 

3405 ) 

3406 

3407 

3408# --------------------------------------------------------------------------- 

3409# Bulk YAML import for mappings 

3410# --------------------------------------------------------------------------- 

3411 

3412 

3413class BulkYamlImportView(PermissionRequiredMixin, View): 

3414 """Accept a YAML file and bulk-create ClassRoleMappings or DeviceTypeMappings for a profile. 

3415 

3416 Useful for bootstrapping from contrib/ definition files. 

3417 """ 

3418 

3419 permission_required = "netbox_data_import.change_importprofile" 

3420 

3421 def get(self, request, profile_pk): 

3422 """Render the bulk YAML import form.""" 

3423 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

3424 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

3425 

3426 def _import_class_role_rows(self, data, profile, errors): 

3427 """Import a list of class-role mapping items; return (created, skipped).""" 

3428 created = skipped = 0 

3429 for item in data: 

3430 try: 

3431 rack_type = None 

3432 rack_type_present = "rack_type" in item 

3433 rack_type_slug = item.get("rack_type") if rack_type_present else None 

3434 if rack_type_slug: 

3435 from dcim.models import RackType 

3436 

3437 try: 

3438 rack_type = RackType.objects.get(slug=rack_type_slug) 

3439 except RackType.DoesNotExist: 

3440 errors.append( 

3441 f"RackType with slug '{rack_type_slug}' not found for source_class '{item.get('source_class')}'" 

3442 ) 

3443 continue 

3444 

3445 defaults = { 

3446 "creates_rack": item.get("creates_rack", False), 

3447 "role_slug": item.get("role_slug", ""), 

3448 "ignore": item.get("ignore", False), 

3449 } 

3450 if rack_type_present: 

3451 defaults["rack_type"] = rack_type 

3452 

3453 obj, was_created = ClassRoleMapping.objects.get_or_create( 

3454 profile=profile, 

3455 source_class=item["source_class"], 

3456 defaults=defaults, 

3457 ) 

3458 if not was_created and rack_type_present: 

3459 obj.rack_type = rack_type 

3460 obj.save(update_fields=["rack_type"]) 

3461 if was_created: 

3462 created += 1 

3463 else: 

3464 skipped += 1 

3465 except (KeyError, ValueError) as exc: 

3466 errors.append(str(exc)) 

3467 except Exception: 

3468 logger.exception("BulkYamlImportView class_role row failed for profile_id=%s", profile.pk) 

3469 errors.append("A row failed due to an unexpected error — see server logs.") 

3470 return created, skipped 

3471 

3472 def _import_device_type_rows(self, data, profile, errors): 

3473 """Import a list of device-type mapping items; return (created, skipped).""" 

3474 created = skipped = 0 

3475 for item in data: 

3476 try: 

3477 _, was_created = DeviceTypeMapping.objects.get_or_create( 

3478 profile=profile, 

3479 source_make=item["source_make"], 

3480 source_model=item["source_model"], 

3481 defaults={ 

3482 "netbox_manufacturer_slug": item["netbox_manufacturer_slug"], 

3483 "netbox_device_type_slug": item["netbox_device_type_slug"], 

3484 }, 

3485 ) 

3486 if was_created: 

3487 created += 1 

3488 else: 

3489 skipped += 1 

3490 except (KeyError, ValueError) as exc: 

3491 errors.append(str(exc)) 

3492 except Exception: 

3493 logger.exception("BulkYamlImportView device_type row failed for profile_id=%s", profile.pk) 

3494 errors.append("A row failed due to an unexpected error — see server logs.") 

3495 return created, skipped 

3496 

3497 def post(self, request, profile_pk): 

3498 """Parse the uploaded YAML file and create mappings in bulk.""" 

3499 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

3500 yaml_file = request.FILES.get("yaml_file") 

3501 mapping_type = request.POST.get("mapping_type", "class_role") 

3502 

3503 if not yaml_file: 

3504 messages.error(request, "No YAML file uploaded.") 

3505 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

3506 

3507 try: 

3508 import yaml 

3509 

3510 data = yaml.safe_load(yaml_file.read()) 

3511 except yaml.YAMLError as exc: 

3512 messages.error(request, f"Failed to parse YAML: {exc}") 

3513 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

3514 except Exception: 

3515 logger.exception("BulkYamlImportView: failed to read uploaded file for profile_id=%s", profile_pk) 

3516 messages.error(request, "Could not read the uploaded file.") 

3517 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

3518 

3519 if not isinstance(data, list): 

3520 messages.error(request, "YAML must be a list of mapping objects.") 

3521 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

3522 

3523 errors = [] 

3524 if mapping_type == "class_role": 

3525 created, skipped = self._import_class_role_rows(data, profile, errors) 

3526 elif mapping_type == "device_type": 

3527 created, skipped = self._import_device_type_rows(data, profile, errors) 

3528 else: 

3529 messages.error(request, f"Unknown mapping type '{mapping_type}'.") 

3530 return redirect(profile.get_absolute_url()) 

3531 

3532 if errors: 

3533 messages.warning( 

3534 request, f"Created {created}, skipped {skipped}, {len(errors)} errors: {'; '.join(errors[:3])}" 

3535 ) 

3536 else: 

3537 messages.success(request, f"Bulk import complete: {created} created, {skipped} already existed.") 

3538 return redirect(profile.get_absolute_url()) 

3539 

3540 

3541# --------------------------------------------------------------------------- 

3542# Profile YAML export / full-profile YAML import 

3543# --------------------------------------------------------------------------- 

3544 

3545 

3546class ExportProfileYamlView(PermissionRequiredMixin, View): 

3547 """Download all profile configuration as a single YAML file.""" 

3548 

3549 permission_required = "netbox_data_import.change_importprofile" 

3550 

3551 def get(self, request, pk): 

3552 """Serialize the profile and all its mappings to YAML and return as a file download.""" 

3553 import yaml 

3554 from django.http import HttpResponse 

3555 

3556 profile = get_object_or_404(ImportProfile, pk=pk) 

3557 

3558 data = { 

3559 "profile": { 

3560 "name": profile.name, 

3561 "description": profile.description, 

3562 "source_adapter": profile.source_adapter, 

3563 "adapter_config": profile.adapter_config, 

3564 }, 

3565 "column_mappings": [ 

3566 {"source_column": cm.source_column, "target_field": cm.target_field} 

3567 for cm in profile.column_mappings.all() 

3568 ], 

3569 "class_role_mappings": [ 

3570 { 

3571 **{ 

3572 k: v 

3573 for k, v in { 

3574 "source_class": m.source_class, 

3575 "creates_rack": m.creates_rack, 

3576 "role_slug": m.role_slug, 

3577 "ignore": m.ignore, 

3578 }.items() 

3579 if v != "" 

3580 }, 

3581 "rack_type": m.rack_type.slug if m.rack_type_id else None, 

3582 } 

3583 for m in profile.class_role_mappings.select_related("rack_type").all() 

3584 ], 

3585 "device_type_mappings": [ 

3586 { 

3587 "source_make": m.source_make, 

3588 "source_model": m.source_model, 

3589 "netbox_manufacturer_slug": m.netbox_manufacturer_slug, 

3590 "netbox_device_type_slug": m.netbox_device_type_slug, 

3591 } 

3592 for m in profile.device_type_mappings.all() 

3593 ], 

3594 "manufacturer_mappings": [ 

3595 { 

3596 "source_make": m.source_make, 

3597 "netbox_manufacturer_slug": m.netbox_manufacturer_slug, 

3598 } 

3599 for m in profile.manufacturer_mappings.all() 

3600 ], 

3601 "column_transform_rules": [ 

3602 { 

3603 "source_column": r.source_column, 

3604 "pattern": r.pattern, 

3605 "group_1_target": r.group_1_target, 

3606 "group_2_target": r.group_2_target, 

3607 } 

3608 for r in profile.column_transform_rules.all() 

3609 ], 

3610 # All four fields travel: "decided as none" and "not decided" are different answers. 

3611 "cable_class_mappings": [ 

3612 { 

3613 "cable_class": m.cable_class, 

3614 "cable_type_resolved": m.cable_type_resolved, 

3615 "cable_type": m.cable_type, 

3616 "cable_profile_resolved": m.cable_profile_resolved, 

3617 "cable_profile": m.cable_profile, 

3618 } 

3619 for m in profile.cable_class_mappings.all() 

3620 ], 

3621 } 

3622 

3623 yaml_str = yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False) 

3624 safe_name = profile.name.lower().replace(" ", "_").replace("/", "-") 

3625 filename = f"profile_{safe_name}.yaml" 

3626 return HttpResponse( 

3627 yaml_str, 

3628 content_type="application/x-yaml", 

3629 headers={"Content-Disposition": f'attachment; filename="{filename}"'}, 

3630 ) 

3631 

3632 

3633class ImportProfileYamlView(PermissionRequiredMixin, View): 

3634 """Import a full profile YAML (as exported by ExportProfileYamlView). 

3635 

3636 If the profile already exists (by name), merges/updates its mappings. 

3637 """ 

3638 

3639 permission_required = "netbox_data_import.change_importprofile" 

3640 

3641 def get(self, request): 

3642 """Render the profile YAML import form.""" 

3643 return render(request, "netbox_data_import/import_profile_yaml.html") 

3644 

3645 def post(self, request): 

3646 """Parse the uploaded YAML and create or update the profile and its mappings.""" 

3647 import yaml 

3648 

3649 yaml_file = request.FILES.get("yaml_file") 

3650 if not yaml_file: 

3651 messages.error(request, "No YAML file uploaded.") 

3652 return render(request, "netbox_data_import/import_profile_yaml.html") 

3653 

3654 try: 

3655 data = yaml.safe_load(yaml_file.read()) 

3656 except (yaml.YAMLError, UnicodeDecodeError, OSError) as exc: 

3657 messages.error(request, f"Failed to parse YAML: {exc}") 

3658 return render(request, "netbox_data_import/import_profile_yaml.html") 

3659 

3660 try: 

3661 profile, stats = _apply_profile_yaml_data(data) 

3662 except (TypeError, ValueError) as exc: # The YAML helpers validate mapping types and required keys. 

3663 messages.error(request, str(exc)) 

3664 return render(request, "netbox_data_import/import_profile_yaml.html") 

3665 

3666 summary = ", ".join(f"{v} {k.replace('_', ' ')}" for k, v in stats.items()) 

3667 messages.success(request, f"Profile '{profile.name}' imported/updated. {summary}.") 

3668 return redirect(profile.get_absolute_url()) 

3669 

3670 

3671# --------------------------------------------------------------------------- 

3672 

3673 

3674class CheckDeviceNameView(PermissionRequiredMixin, View): 

3675 """AJAX endpoint: check if a device with the given name exists in NetBox. 

3676 

3677 Returns JSON: {"exists": bool, "url": str|null, "id": int|null}. 

3678 """ 

3679 

3680 permission_required = "netbox_data_import.view_importprofile" 

3681 

3682 def get(self, request): 

3683 """Return JSON indicating whether a device with the given name exists.""" 

3684 from dcim.models import Device 

3685 from django.http import JsonResponse 

3686 

3687 if not request.user.has_perm("dcim.view_device"): # pragma: no cover 

3688 from django.http import HttpResponseForbidden 

3689 

3690 return HttpResponseForbidden() 

3691 

3692 name = request.GET.get("name", "").strip() 

3693 if not name: 

3694 return JsonResponse({"exists": False, "url": None, "id": None}) 

3695 

3696 try: 

3697 device = Device.objects.get(name=name) 

3698 return JsonResponse( 

3699 { 

3700 "exists": True, 

3701 "url": request.build_absolute_uri(device.get_absolute_url()), 

3702 "id": device.pk, 

3703 } 

3704 ) 

3705 except Device.DoesNotExist: 

3706 return JsonResponse({"exists": False, "url": None, "id": None}) 

3707 except Device.MultipleObjectsReturned: 

3708 devices = Device.objects.filter(name=name) 

3709 first = devices.first() 

3710 return JsonResponse( 

3711 { 

3712 "exists": True, 

3713 "url": request.build_absolute_uri(first.get_absolute_url()), 

3714 "id": first.pk, 

3715 "count": devices.count(), 

3716 } 

3717 ) 

3718 

3719 

3720# --------------------------------------------------------------------------- 

3721# Source Resolutions list view (per profile) 

3722# --------------------------------------------------------------------------- 

3723 

3724 

3725class SourceResolutionListView(PermissionRequiredMixin, View): 

3726 """List all saved name-split resolutions for a profile.""" 

3727 

3728 permission_required = "netbox_data_import.view_importprofile" 

3729 

3730 def get(self, request, profile_pk): 

3731 """Render the list of saved source resolutions for the given profile.""" 

3732 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

3733 resolutions = SourceResolution.objects.filter(profile=profile).order_by("source_id") 

3734 return render( 

3735 request, 

3736 "netbox_data_import/source_resolution_list.html", 

3737 { 

3738 "profile": profile, 

3739 "resolutions": resolutions, 

3740 }, 

3741 ) 

3742 

3743 

3744class SourceResolutionDeleteView(_ProfileChildDeleteView): 

3745 """Delete a saved source resolution.""" 

3746 

3747 queryset = SourceResolution.objects.all() 

3748 

3749 def post(self, request, *args, **kwargs): 

3750 """Serialize against an executing import, which holds the same profile row.""" 

3751 resolution = self.get_object(**kwargs) 

3752 try: 

3753 with locked_resolution_policy(resolution.pk): 

3754 # atomic-exit-safe: locked-delete-committed 

3755 return super().post(request, *args, **kwargs) 

3756 except (SourceResolution.DoesNotExist, ImportProfile.DoesNotExist): 

3757 # The row went away between the fetch and the lock, which is the 404 the fetch would give. 

3758 raise Http404 from None 

3759 

3760 

3761# --------------------------------------------------------------------------- 

3762# Quick-resolve views (inline fixes from preview page) 

3763# --------------------------------------------------------------------------- 

3764 

3765 

3766def _trace_sync_block_reason(reviewed_plan: ImportPlan, live_plan: ImportPlan) -> str: 

3767 """Return why live NetBox prevents synchronization of the reviewed plan.""" 

3768 if live_plan.fingerprint != reviewed_plan.fingerprint: 

3769 return "NetBox has changed. Re-read the preview before synchronizing." 

3770 return "" 

3771 

3772 

3773def _retained_sync_block_reason(request) -> str: 

3774 """Return why the retained trace sync holds this preview, for a page that has to say so. 

3775 

3776 Refusing is the writers' job, in `preview_row_actions`. This read only routes and renders. 

3777 """ 

3778 return retained_sync_block_reason(request.session, request.user) 

3779 

3780 

3781def _with_blocked_sync(trace, reason: str): 

3782 """Refuse the sync action the view would reject, so the page cannot offer what the POST refuses.""" 

3783 actions = tuple( 

3784 replace(action, enabled=False, reason=reason) if action.key == "sync" and action.enabled else action 

3785 for action in trace.actions 

3786 ) 

3787 return replace(trace, actions=actions) 

3788 

3789 

3790def _workspace_field_keys(workspace) -> set: 

3791 """Return every termination field key the reviewed preview actually asked about.""" 

3792 return {item["field_key"] for trace in workspace.traces for item in trace.terminations} 

3793 

3794 

3795def _object_type_label(obj) -> str: 

3796 """Return the ``app_label.model_name`` key one termination is offered under.""" 

3797 return f"{obj._meta.app_label}.{obj._meta.model_name}" 

3798 

3799 

3800class _TraceWorkspaceMixin: 

3801 """Load the reviewed preview a trace workspace request acts on.""" 

3802 

3803 def reviewed_preview(self, request): 

3804 """Return the profile, the stored document and the reviewed workspace, or None.""" 

3805 preview = load_cached_preview(request) 

3806 if preview is None: 

3807 return None 

3808 profile, workspace = preview 

3809 context = request.session.get("import_context") or {} 

3810 document = SourceDocument.objects.filter(pk=context.get("source_document_id"), profile=profile).first() 

3811 if document is None: 

3812 return None 

3813 planning_context = { 

3814 "site_id": context.get("site_id"), 

3815 "location_id": context.get("location_id"), 

3816 "tenant_id": context.get("tenant_id"), 

3817 } 

3818 return profile, document, workspace, planning_context 

3819 

3820 def discard_unavailable_target(self, request): 

3821 """Return the response that ends a request whose saved import target is gone.""" 

3822 _discard_import_preview(request) 

3823 messages.warning(request, "The saved import target is no longer available. Start a new preview.") 

3824 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

3825 

3826 def refuse_unregistered_adapter(self, request, profile): 

3827 """Return the response that ends a request this release cannot plan for, or None. 

3828 

3829 Planning raises UnknownSourceAdapter, so a workspace request that reaches it without this 

3830 gate answers a 500. The preview is discarded because no release-side decision revives it. 

3831 """ 

3832 try: 

3833 validate_registered_adapter(profile) 

3834 # Planning raises the same error for a registered adapter no Target Module implements. 

3835 validate_adapter_target_module(profile.source_adapter) 

3836 except ValidationError as exc: 

3837 _discard_import_preview(request) 

3838 messages.warning(request, "; ".join(exc.messages)) 

3839 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

3840 return None 

3841 

3842 @staticmethod 

3843 def live_plan(profile, document, request, planning_context): 

3844 """Return the plan live NetBox states right now, or None when the target is gone.""" 

3845 try: 

3846 return ImportEngine.plan(profile, document, request.user, planning_context) 

3847 except PlanningTargetUnavailable: 

3848 return None 

3849 

3850 

3851class TraceReviewWorkspaceView(_TraceWorkspaceMixin, PermissionRequiredMixin, View): 

3852 """Section 10.2: one review workspace page per preview, for the traces it planned.""" 

3853 

3854 permission_required = "netbox_data_import.change_importprofile" 

3855 

3856 def get(self, request): 

3857 """Render the reviewed traces and say whether live NetBox has moved under them.""" 

3858 loaded = self.reviewed_preview(request) 

3859 if loaded is None: 

3860 messages.warning(request, "No import preview in progress. Start a new import.") 

3861 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

3862 profile, document, workspace, planning_context = loaded 

3863 refusal = self.refuse_unregistered_adapter(request, profile) 

3864 if refusal is not None: 

3865 return refusal 

3866 live = self.live_plan(profile, document, request, planning_context) 

3867 if live is None: 

3868 return self.discard_unavailable_target(request) 

3869 # Section 10.2: compared on each full load and on the re-read action, never polled. 

3870 sync_block_reason = _trace_sync_block_reason(workspace.plan, live) 

3871 drift = bool(sync_block_reason) 

3872 retained_reason = _retained_sync_block_reason(request) 

3873 block_reason = retained_reason or sync_block_reason 

3874 traces = ( 

3875 [_with_blocked_sync(trace, block_reason) for trace in workspace.traces] 

3876 if block_reason 

3877 else workspace.traces 

3878 ) 

3879 wanted = request.GET.get("trace", "") 

3880 selected = next((trace for trace in traces if trace.identity == wanted), traces[0] if traces else None) 

3881 summary = dict(workspace.trace_summary) 

3882 from .models import TerminationResolution 

3883 

3884 summary["saved_decisions"] = TerminationResolution.objects.filter(profile=profile).count() 

3885 summary["preview_state"] = self._preview_state(request, drift) 

3886 return render( 

3887 request, 

3888 "netbox_data_import/trace_workspace.html", 

3889 { 

3890 "profile": profile, 

3891 "traces": traces, 

3892 "selected_trace": selected, 

3893 "summary": summary, 

3894 "drift": drift, 

3895 "retained_sync_reason": retained_reason, 

3896 "preview_revision": current_preview_revision(request.session), 

3897 "plugin_version": _plugin_version, 

3898 }, 

3899 ) 

3900 

3901 @staticmethod 

3902 def _preview_state(request, drift: bool) -> str: 

3903 """Return what the strip says about the preview the operator is reviewing.""" 

3904 if request.session.get(PREVIEW_DIRTY_SESSION_KEY) is True: 

3905 return "recalculation required" 

3906 return "changed in NetBox" if drift else "current" 

3907 

3908 

3909class TraceWorkspaceRereadView(_TraceWorkspaceMixin, PermissionRequiredMixin, View): 

3910 """Adopt the plan live NetBox states now, which is what clears the drift strip.""" 

3911 

3912 permission_required = "netbox_data_import.change_importprofile" 

3913 

3914 def post(self, request): 

3915 """Replace the reviewed preview with a freshly read one.""" 

3916 next_url = reverse("plugins:netbox_data_import:trace_workspace") 

3917 loaded = self.reviewed_preview(request) 

3918 if loaded is None: 

3919 messages.warning(request, "No import preview in progress. Start a new import.") 

3920 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

3921 profile, document, _workspace, planning_context = loaded 

3922 stale_reason = _stale_preview_reason(request) 

3923 if stale_reason is not None: 

3924 messages.warning(request, stale_reason) 

3925 return redirect(next_url) 

3926 refusal = self.refuse_unregistered_adapter(request, profile) 

3927 if refusal is not None: 

3928 return refusal 

3929 # Refuse before reading: a sync that ends mid-request would let a pre-write plan land clean. 

3930 if retained_reason := _retained_sync_block_reason(request): 

3931 messages.warning(request, retained_reason) 

3932 return redirect(next_url) 

3933 live = self.live_plan(profile, document, request, planning_context) 

3934 if live is None: 

3935 return self.discard_unavailable_target(request) 

3936 try: 

3937 record_recalculated_preview(request.session, live, user=request.user) 

3938 except PreviewLocked as exc: 

3939 messages.warning(request, str(exc)) 

3940 return redirect(next_url) 

3941 messages.success(request, "The workspace was re-read from NetBox.") 

3942 return redirect(next_url) 

3943 

3944 

3945class TraceSyncView(_TraceWorkspaceMixin, PermissionRequiredMixin, View): 

3946 """Synchronize one Source Trace together with the units its changes depend on.""" 

3947 

3948 permission_required = "netbox_data_import.change_importprofile" 

3949 

3950 def post(self, request): 

3951 """Queue the reviewed plan for one trace's own selection.""" 

3952 next_url = reverse("plugins:netbox_data_import:trace_workspace") 

3953 loaded = self.reviewed_preview(request) 

3954 if loaded is None: 

3955 messages.warning(request, "No import preview in progress. Start a new import.") 

3956 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

3957 profile, document, workspace, planning_context = loaded 

3958 stale_reason = _stale_preview_reason(request) 

3959 if stale_reason is not None: 

3960 messages.warning(request, stale_reason) 

3961 return redirect(next_url) 

3962 if request.session.get(PREVIEW_DIRTY_SESSION_KEY) is True: 

3963 messages.warning(request, "Recalculate and review the saved preview changes before importing.") 

3964 return redirect(next_url) 

3965 refusal = self.refuse_unregistered_adapter(request, profile) 

3966 if refusal is not None: 

3967 return refusal 

3968 live = self.live_plan(profile, document, request, planning_context) 

3969 if live is None: 

3970 return self.discard_unavailable_target(request) 

3971 sync_block_reason = _trace_sync_block_reason(workspace.plan, live) 

3972 if sync_block_reason: 

3973 messages.warning(request, sync_block_reason) 

3974 return redirect(next_url) 

3975 selection = workspace.sync_selection(request.POST.get("identity", "").strip()) 

3976 if not selection: 

3977 messages.warning(request, "That trace has no changes to synchronize.") 

3978 return redirect(next_url) 

3979 try: 

3980 return _queue_accepted_plan( 

3981 request, 

3982 profile, 

3983 document, 

3984 request.session.get("import_context") or {}, 

3985 request.session.get(PREVIEW_PLAN_SESSION_KEY), 

3986 list(selection), 

3987 keep_preview=True, 

3988 ) 

3989 except PreviewLocked as exc: 

3990 messages.warning(request, str(exc)) 

3991 return redirect(next_url) 

3992 

3993 

3994class TraceTerminationCandidatesView(_TraceWorkspaceMixin, PermissionRequiredMixin, View): 

3995 """Serve one page of eligible terminations for the workspace picker.""" 

3996 

3997 permission_required = "netbox_data_import.change_importprofile" 

3998 

3999 def get(self, request): 

4000 """Return the eligible candidates and the uncapped total the count states.""" 

4001 loaded = self.reviewed_preview(request) 

4002 if loaded is None: 

4003 return JsonResponse({"ok": False, "error": "No import preview in progress."}, status=409) 

4004 profile, _document, _workspace, planning_context = loaded 

4005 field_key = request.GET.get("field_key", "").strip() 

4006 try: 

4007 requested = int(request.GET.get("limit", ELIGIBLE_TERMINATION_LIMIT)) 

4008 except (TypeError, ValueError): 

4009 requested = ELIGIBLE_TERMINATION_LIMIT 

4010 # The limit becomes a QuerySet slice stop, which refuses a value below one. 

4011 limit = min(max(requested, 1), ELIGIBLE_TERMINATION_LIMIT) 

4012 try: 

4013 found = self._eligible(request, profile, planning_context, field_key, request.GET.get("search", ""), limit) 

4014 except (PlanningTargetUnavailable, ValueError): 

4015 return JsonResponse({"ok": False, "error": "That termination cannot be resolved here."}, status=400) 

4016 return JsonResponse( 

4017 { 

4018 "ok": True, 

4019 "candidates": [ 

4020 {"id": candidate.pk, "name": candidate.name, "display": str(candidate)} 

4021 for candidate in found.candidates 

4022 ], 

4023 "shown": len(found.candidates), 

4024 "total": found.total, 

4025 } 

4026 ) 

4027 

4028 @staticmethod 

4029 def _eligible(request, profile, planning_context, field_key, search, limit): 

4030 """Return the eligible page, inside the caller's own read scope.""" 

4031 reader = NetBoxReader.for_actor(request.user).for_planning_context(planning_context) 

4032 return eligible_terminations(field_key, reader, profile=profile, search=search, limit=limit) 

4033 

4034 

4035class TraceResolveTerminationView(_TraceWorkspaceMixin, _PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4036 """Record one operator termination decision and ask for a fresh Import Plan.""" 

4037 

4038 permission_required = "netbox_data_import.change_importprofile" 

4039 

4040 def post(self, request): 

4041 """Save the selection the picker offered, then replan the preview against it.""" 

4042 next_url = reverse("plugins:netbox_data_import:trace_workspace") 

4043 loaded = self.reviewed_preview(request) 

4044 if loaded is None: 

4045 messages.warning(request, "No import preview in progress. Start a new import.") 

4046 return redirect(reverse("plugins:netbox_data_import:import_setup")) 

4047 profile, document, workspace, planning_context = loaded 

4048 stale_reason = _stale_preview_reason(request) 

4049 if stale_reason is not None: 

4050 return _preview_action_error(request, next_url, stale_reason, status=409) 

4051 # Refuse before writing: the decision and its replan commit together. 

4052 if retained_reason := _retained_sync_block_reason(request): 

4053 return _preview_action_error(request, next_url, retained_reason, status=409) 

4054 refusal = self.refuse_unregistered_adapter(request, profile) 

4055 if refusal is not None: 

4056 return refusal 

4057 field_key = request.POST.get("field_key", "").strip() 

4058 object_type = request.POST.get("object_type", "").strip() 

4059 try: 

4060 object_id = int(request.POST.get("object_id", "")) 

4061 except (TypeError, ValueError): 

4062 return _preview_action_error(request, next_url, "A termination selection names one object.", status=400) 

4063 # A review command answers a question this preview asked, never one the caller invented. 

4064 if field_key not in _workspace_field_keys(workspace): 

4065 return _preview_action_error( 

4066 request, next_url, "This preview asked no question about that termination.", status=400 

4067 ) 

4068 try: 

4069 reader = NetBoxReader.for_actor(request.user).for_planning_context(planning_context) 

4070 # The recheck repeats the query that made the offer, so a searched candidate still counts. 

4071 found = eligible_terminations( 

4072 field_key, 

4073 reader, 

4074 profile=profile, 

4075 search=request.POST.get("search", ""), 

4076 limit=ELIGIBLE_TERMINATION_LIMIT, 

4077 ) 

4078 except (PlanningTargetUnavailable, ValueError): 

4079 return _preview_action_error(request, next_url, "That termination cannot be resolved here.", status=400) 

4080 # The picker is the only legal source of a choice, so the write rechecks the offer. 

4081 chosen = next( 

4082 ( 

4083 candidate 

4084 for candidate in found.candidates 

4085 if candidate.pk == object_id and _object_type_label(candidate) == object_type 

4086 ), 

4087 None, 

4088 ) 

4089 if chosen is None: 

4090 return _preview_action_error( 

4091 request, next_url, "That termination is not one of the eligible candidates.", status=400 

4092 ) 

4093 from core.models import ObjectType 

4094 

4095 try: 

4096 # One transaction: a target lost before the replan rolls the saved decision back with it. 

4097 plan = save_termination_resolution_and_replan( 

4098 profile=profile, 

4099 source_document=document, 

4100 actor=request.user, 

4101 planning_context=planning_context, 

4102 task_type=SELECT_TERMINATION_TASK, 

4103 field_key=field_key, 

4104 selected_object_type=ObjectType.objects.get_for_model(type(chosen)), 

4105 selected_object_id=chosen.pk, 

4106 selected_display_name=str(chosen), 

4107 ) 

4108 except PlanningTargetUnavailable: 

4109 return self.discard_unavailable_target(request) 

4110 try: 

4111 record_recalculated_preview(request.session, plan, user=request.user) 

4112 except PreviewLocked as exc: 

4113 return _preview_action_error(request, next_url, str(exc), status=409) 

4114 messages.success(request, f"Termination resolved to '{chosen}'.") 

4115 return redirect(next_url) 

4116 

4117 

4118class QuickResolveManufacturerView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4119 """Save a ManufacturerMapping (source make → NetBox manufacturer slug) from the preview page.""" 

4120 

4121 permission_required = "netbox_data_import.change_importprofile" 

4122 

4123 def post(self, request): 

4124 """Save the manufacturer mapping and report the pending preview change.""" 

4125 next_url = reverse("plugins:netbox_data_import:import_preview") 

4126 profile_id = _parse_posted_profile_id(request) 

4127 if profile_id is None: 

4128 return _preview_action_error( 

4129 request, 

4130 next_url, 

4131 "A valid import profile is required. Reload the preview and try again.", 

4132 status=400, 

4133 ) 

4134 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4135 stale_reason = _stale_preview_reason(request) 

4136 if stale_reason is not None: 

4137 return _preview_action_error(request, next_url, stale_reason, status=409) 

4138 source_make = " ".join(request.POST.get("source_make", "").split()) 

4139 netbox_mfg_slug = request.POST.get("netbox_mfg_slug", "").strip() 

4140 if not source_make or not netbox_mfg_slug: 

4141 return _preview_action_error( 

4142 request, 

4143 next_url, 

4144 "Source make and NetBox manufacturer slug are required.", 

4145 status=400, 

4146 ) 

4147 mapping = _get_or_init(ManufacturerMapping, profile=profile, source_make=source_make) 

4148 mapping.netbox_manufacturer_slug = netbox_mfg_slug 

4149 try: 

4150 _validate_model_instance(mapping, f"manufacturer mapping '{source_make}'") 

4151 except PreviewActionInvalid as exc: 

4152 return _preview_action_error(request, next_url, str(exc), status=400) 

4153 result = save_permission_scoped_object( 

4154 request.user, 

4155 ManufacturerMapping, 

4156 {"profile": profile, "source_make": source_make}, 

4157 {"netbox_manufacturer_slug": netbox_mfg_slug}, 

4158 ) 

4159 verb = "Created" if result.created else "Updated" 

4160 return _saved_preview_action_response( 

4161 request, 

4162 next_url, 

4163 f"{verb} manufacturer mapping: '{source_make}' → {netbox_mfg_slug}", 

4164 ) 

4165 

4166 

4167class QuickResolveDeviceTypeView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4168 """Save a DeviceTypeMapping (source make/model → NetBox slugs) from the preview page.""" 

4169 

4170 permission_required = "netbox_data_import.change_importprofile" 

4171 

4172 def post(self, request): 

4173 """Save the device type mapping and report the pending preview change.""" 

4174 from .device_identity import default_identity_slugs 

4175 

4176 next_url = reverse("plugins:netbox_data_import:import_preview") 

4177 profile_id = _parse_posted_profile_id(request) 

4178 if profile_id is None: 

4179 return _preview_action_error( 

4180 request, 

4181 next_url, 

4182 "A valid import profile is required. Reload the preview and try again.", 

4183 status=400, 

4184 ) 

4185 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4186 stale_reason = _stale_preview_reason(request) 

4187 if stale_reason is not None: 

4188 return _preview_action_error(request, next_url, stale_reason, status=409) 

4189 source_make = " ".join(request.POST.get("source_make", "").split()) 

4190 source_model = " ".join(request.POST.get("source_model", "").split()) 

4191 netbox_mfg_slug = request.POST.get("netbox_mfg_slug", "").strip() 

4192 netbox_dt_slug = request.POST.get("netbox_dt_slug", "").strip() 

4193 action = request.POST.get("action", "map") 

4194 

4195 if not source_make or not source_model: 

4196 return _preview_action_error(request, next_url, "Source make and model are required.", status=400) 

4197 if action != "map": 

4198 return _preview_action_error( 

4199 request, next_url, "The requested Device Type action is not supported.", status=400 

4200 ) 

4201 

4202 # The importer derives both slugs the same way, so a default that differs maps to nothing. 

4203 default_mfg_slug, default_dt_slug = default_identity_slugs(source_make, source_model) 

4204 if not netbox_mfg_slug: 

4205 netbox_mfg_slug = default_mfg_slug 

4206 if not netbox_dt_slug: 

4207 netbox_dt_slug = default_dt_slug 

4208 

4209 try: 

4210 mapping = _get_or_init( 

4211 DeviceTypeMapping, 

4212 profile=profile, 

4213 source_make=source_make, 

4214 source_model=source_model, 

4215 ) 

4216 mapping.netbox_manufacturer_slug = netbox_mfg_slug 

4217 mapping.netbox_device_type_slug = netbox_dt_slug 

4218 _validate_model_instance( 

4219 mapping, 

4220 f"device type mapping '{source_make} / {source_model}'", 

4221 ) 

4222 mapping_result = save_permission_scoped_object( 

4223 request.user, 

4224 DeviceTypeMapping, 

4225 {"profile": profile, "source_make": source_make, "source_model": source_model}, 

4226 { 

4227 "netbox_manufacturer_slug": netbox_mfg_slug, 

4228 "netbox_device_type_slug": netbox_dt_slug, 

4229 }, 

4230 ) 

4231 except PreviewActionInvalid as exc: 

4232 return _preview_action_error(request, next_url, str(exc), status=400) 

4233 

4234 verb = "created" if mapping_result.created else "updated" 

4235 saved_message = ( 

4236 f"DeviceType mapping {verb}: '{source_make} / {source_model}' → {netbox_mfg_slug}/{netbox_dt_slug}" 

4237 ) 

4238 

4239 return _saved_preview_action_response(request, next_url, saved_message) 

4240 

4241 

4242class QuickAddClassRoleMappingView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4243 """Quickly add a ClassRoleMapping (ignore / role) directly from an error row in preview.""" 

4244 

4245 permission_required = "netbox_data_import.change_importprofile" 

4246 

4247 def post(self, request): 

4248 """Save the class-to-role mapping and report the pending preview change.""" 

4249 from dcim.models import RackType 

4250 

4251 next_url = reverse("plugins:netbox_data_import:import_preview") 

4252 profile_id = _parse_posted_profile_id(request) 

4253 if profile_id is None: 

4254 return _preview_action_error( 

4255 request, 

4256 next_url, 

4257 "A valid import profile is required. Reload the preview and try again.", 

4258 status=400, 

4259 ) 

4260 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4261 stale_reason = _stale_preview_reason(request) 

4262 if stale_reason is not None: 

4263 return _preview_action_error(request, next_url, stale_reason, status=409) 

4264 source_class = request.POST.get("source_class", "").strip() 

4265 mapping_action = request.POST.get("mapping_action", "ignore") # "ignore", "role", or "rack" 

4266 role_slug = request.POST.get("role_slug", "").strip() 

4267 creates_rack = mapping_action == "rack" 

4268 rack_type_id = request.POST.get("rack_type_id", "").strip() 

4269 

4270 rack_type = None 

4271 if creates_rack and rack_type_id: 

4272 try: 

4273 rack_type = RackType.objects.get(pk=int(rack_type_id)) 

4274 except (RackType.DoesNotExist, ValueError, TypeError): 

4275 return _preview_action_error( 

4276 request, 

4277 next_url, 

4278 f"Invalid rack type selected for class '{source_class}'. Please choose a valid rack type.", 

4279 status=400, 

4280 ) 

4281 

4282 if not source_class: 

4283 return _preview_action_error(request, next_url, "Source class is required.", status=400) 

4284 

4285 _valid_actions = ("ignore", "role", "rack") 

4286 if mapping_action not in _valid_actions: 

4287 return _preview_action_error( 

4288 request, 

4289 next_url, 

4290 f"Invalid mapping action '{mapping_action}'. Must be one of: {', '.join(_valid_actions)}.", 

4291 status=400, 

4292 ) 

4293 

4294 if mapping_action == "role" and not role_slug: 

4295 return _preview_action_error( 

4296 request, 

4297 next_url, 

4298 "A role slug is required when mapping action is 'role'.", 

4299 status=400, 

4300 ) 

4301 

4302 values = { 

4303 "ignore": mapping_action == "ignore", 

4304 "creates_rack": creates_rack, 

4305 "rack_type": rack_type, 

4306 "role_slug": role_slug if mapping_action == "role" else "", 

4307 } 

4308 mapping = _get_or_init(ClassRoleMapping, profile=profile, source_class=source_class) 

4309 for field_name, value in values.items(): 

4310 setattr(mapping, field_name, value) 

4311 try: 

4312 _validate_model_instance(mapping, f"class role mapping '{source_class}'") 

4313 except PreviewActionInvalid as exc: 

4314 return _preview_action_error(request, next_url, str(exc), status=400) 

4315 result = save_permission_scoped_object( 

4316 request.user, 

4317 ClassRoleMapping, 

4318 {"profile": profile, "source_class": source_class}, 

4319 values, 

4320 ) 

4321 verb = "Created" if result.created else "Updated" 

4322 if mapping_action == "ignore": 

4323 action_label = "ignore" 

4324 elif mapping_action == "rack": 

4325 rt_suffix = f" (type: {rack_type})" if rack_type else "" 

4326 action_label = f"creates rack{rt_suffix}" 

4327 else: 

4328 action_label = f"role '{role_slug}'" 

4329 return _saved_preview_action_response( 

4330 request, 

4331 next_url, 

4332 f"{verb} mapping: class '{source_class}' → {action_label}", 

4333 ) 

4334 

4335 

4336class QuickAddColumnMappingView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4337 """Quickly map an unmapped source column to a NetBox target field from the preview panel.""" 

4338 

4339 permission_required = "netbox_data_import.change_importprofile" 

4340 

4341 def post(self, request): 

4342 """Save the column mapping and report the pending preview change.""" 

4343 next_url = reverse("plugins:netbox_data_import:import_preview") 

4344 profile_id = _parse_posted_profile_id(request) 

4345 if profile_id is None: 

4346 return _preview_action_error( 

4347 request, 

4348 next_url, 

4349 "A valid import profile is required. Reload the preview and try again.", 

4350 status=400, 

4351 ) 

4352 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4353 stale_reason = _stale_preview_reason(request) 

4354 if stale_reason is not None: 

4355 return _preview_action_error(request, next_url, stale_reason, status=409) 

4356 source_column = request.POST.get("source_column", "").strip() 

4357 target_field = request.POST.get("target_field", "").strip() 

4358 

4359 if not source_column or not CATALOG.is_valid(target_field, output_kinds=profile.output_kinds): 

4360 return _preview_action_error( 

4361 request, 

4362 next_url, 

4363 "Valid source column and target field are required.", 

4364 status=400, 

4365 ) 

4366 

4367 # The catalog accepts any non-empty name after a family prefix, so it cannot bound length. 

4368 # Validate before the displaced row is deleted: an invalid write must strand nothing. 

4369 try: 

4370 _validate_model_instance( 

4371 ColumnMapping(profile=profile, source_column=source_column, target_field=target_field), 

4372 f"column mapping '{source_column}' -> {target_field}", 

4373 ) 

4374 except PreviewActionInvalid as exc: 

4375 return _preview_action_error(request, next_url, str(exc), status=400) 

4376 

4377 if target_field.startswith(CANDIDATE_TARGET_PREFIX): 

4378 result = save_permission_scoped_object( 

4379 request.user, 

4380 ColumnMapping, 

4381 {"profile": profile, "source_column": source_column, "target_field": target_field}, 

4382 {}, 

4383 on_existing="keep", 

4384 ) 

4385 verb = "Created" if result.created else "Kept" 

4386 saved_message = f"{verb} candidate mapping: '{source_column}' → {target_field}" 

4387 else: 

4388 # A quick direct mapping replaces the source column that supplied the target before it. 

4389 with locked_profile_policy(profile.pk): 

4390 displaced = ColumnMapping.objects.filter(profile=profile, target_field=target_field).exclude( 

4391 source_column=source_column 

4392 ) 

4393 displaced_source = displaced.values_list("source_column", flat=True).first() 

4394 delete_permission_scoped_objects(request.user, displaced) 

4395 result = save_permission_scoped_object( 

4396 request.user, 

4397 ColumnMapping, 

4398 {"profile": profile, "source_column": source_column, "target_field": target_field}, 

4399 {}, 

4400 on_existing="keep", 

4401 ) 

4402 if displaced_source: 

4403 saved_message = ( 

4404 f"Reassigned: '{source_column}' → {target_field} (previously mapped from '{displaced_source}')" 

4405 ) 

4406 else: 

4407 verb = "Created" if result.created else "Kept" 

4408 saved_message = f"{verb} mapping: '{source_column}' → {target_field}" 

4409 

4410 return _saved_preview_action_response(request, next_url, saved_message) 

4411 

4412 

4413class MatchExistingDeviceView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4414 """Link a source row to an existing NetBox device (by device ID). 

4415 

4416 Saves a DeviceExistingMatch; on next preview re-run the row shows action='update'. 

4417 """ 

4418 

4419 permission_required = ( 

4420 "netbox_data_import.change_importprofile", 

4421 "dcim.view_device", 

4422 ) 

4423 

4424 def post(self, request): 

4425 """Save the device match and redirect back to preview.""" 

4426 from dcim.models import Device 

4427 

4428 profile_id = _parse_posted_profile_id(request) 

4429 if profile_id is None: 

4430 messages.error(request, "A valid import profile is required.") 

4431 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4432 profile = get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4433 source_id = source_text(request.POST.get("source_id")) 

4434 netbox_device_id = request.POST.get("netbox_device_id", "").strip() 

4435 

4436 if not source_id or not netbox_device_id: 

4437 messages.error(request, "source_id and netbox_device_id are required.") 

4438 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4439 

4440 preview = load_cached_preview(request) 

4441 if preview is None or preview[0].pk != profile.pk: 

4442 messages.error(request, "The selected profile is not the active import profile.") 

4443 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4444 workspace = preview[1] 

4445 ctx_data = request.session.get("import_context") or {} 

4446 rows = workspace.source_rows 

4447 source_rows = [row for row in rows if source_text(row.get("source_id")) == source_id] 

4448 if len(source_rows) != 1: 

4449 messages.error(request, "The source ID must identify exactly one row in the active import.") 

4450 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4451 

4452 try: 

4453 device = Device.objects.restrict(request.user, "view").get(pk=int(netbox_device_id)) 

4454 except (Device.DoesNotExist, ValueError): 

4455 messages.error(request, f"Device #{netbox_device_id} not found.") 

4456 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4457 

4458 if device.site_id != ctx_data.get("site_id"): 

4459 messages.error(request, "The selected device is outside the active import site.") 

4460 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4461 conflicting_match = ( 

4462 profile.device_matches.filter(netbox_device_id=device.pk).exclude(source_id=source_id).first() 

4463 ) 

4464 if conflicting_match: 

4465 messages.error( 

4466 request, 

4467 f"Device '{device.name}' is already linked to source '{conflicting_match.source_id}'.", 

4468 ) 

4469 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4470 

4471 binding_values = { 

4472 "netbox_device_id": device.pk, 

4473 "device_name": device.name, 

4474 "source_asset_tag": source_text(source_rows[0].get("asset_tag"))[:50], 

4475 } 

4476 try: 

4477 save_permission_scoped_object( 

4478 request.user, 

4479 DeviceExistingMatch, 

4480 {"profile": profile, "source_id": source_id}, 

4481 binding_values, 

4482 ) 

4483 except ObjectPermissionDenied: 

4484 messages.error(request, "Permission denied: cannot create or change this device link.") 

4485 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4486 except ValidationError as exc: 

4487 messages.error(request, "; ".join(exc.messages)) 

4488 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4489 except IntegrityError: 

4490 messages.error(request, "The device link changed while this request was being processed. Try again.") 

4491 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4492 

4493 messages.success(request, f"Source '{source_id}' linked to existing device '{device.name}'.") 

4494 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4495 

4496 

4497def _device_name_filter(q: str): 

4498 """Build a Django Q filter for device name search. 

4499 

4500 Exact icontains is tried first; when the query contains separators (-, _, .) 

4501 individual tokens (≥3 chars) are OR-ed in so that e.g. "EXAMPLE-SITE03-SW3" 

4502 matches "edge-site03-switch03.lab.example.invalid" via the "SITE03" token. 

4503 """ 

4504 import re as _re 

4505 

4506 from django.db.models import Q as _Q 

4507 

4508 base = _Q(name__icontains=q) 

4509 tokens = [t for t in _re.split(r"[-_.\s]+", q) if len(t) >= 3] 

4510 if len(tokens) > 1: 

4511 token_q = _Q() 

4512 for tok in tokens: 

4513 token_q |= _Q(name__icontains=tok) 

4514 return base | token_q 

4515 return base 

4516 

4517 

4518class SearchNetBoxObjectsView(_AjaxPermissionView): 

4519 """AJAX search endpoint for NetBox objects used in preview quick-fix modals. 

4520 

4521 GET params: type (manufacturer|device_type|device|role|rack_type), q (search string). 

4522 Returns JSON list of {id, name, slug, url} dicts. 

4523 """ 

4524 

4525 permission_required = "netbox_data_import.view_importprofile" 

4526 

4527 def get(self, request): 

4528 """Return a JSON list of matching NetBox objects for the given type and query.""" 

4529 from dcim.models import DeviceRole, DeviceType, Manufacturer, RackType 

4530 from django.http import JsonResponse 

4531 

4532 obj_type = request.GET.get("type", "device") 

4533 q = request.GET.get("q", "").strip() 

4534 limit = 20 

4535 

4536 _perm_map = { 

4537 "manufacturer": "dcim.view_manufacturer", 

4538 "device_type": "dcim.view_devicetype", 

4539 "device": "dcim.view_device", 

4540 "role": "dcim.view_devicerole", 

4541 "rack_type": "dcim.view_racktype", 

4542 } 

4543 required_perm = _perm_map.get(obj_type) 

4544 if required_perm and not request.user.has_perm(required_perm): # pragma: no cover 

4545 return JsonResponse({"results": [], "error": "permission_denied"}, status=403) 

4546 

4547 if not q: 

4548 return JsonResponse({"results": []}) 

4549 

4550 results = [] 

4551 if obj_type == "manufacturer": 

4552 for mfg in Manufacturer.objects.filter(name__icontains=q)[:limit]: 

4553 results.append( 

4554 { 

4555 "id": mfg.pk, 

4556 "name": mfg.name, 

4557 "slug": mfg.slug, 

4558 "url": request.build_absolute_uri(mfg.get_absolute_url()), 

4559 } 

4560 ) 

4561 elif obj_type == "device_type": 

4562 mfg_filter = request.GET.get("mfg_slug", "") 

4563 qs = DeviceType.objects.select_related("manufacturer") 

4564 if mfg_filter: 

4565 qs = qs.filter(manufacturer__slug=mfg_filter) 

4566 for dt in qs.filter(model__icontains=q)[:limit]: 

4567 results.append( 

4568 { 

4569 "id": dt.pk, 

4570 "name": f"{dt.manufacturer.name} / {dt.model}", 

4571 "slug": dt.slug, 

4572 "mfg_slug": dt.manufacturer.slug, 

4573 "url": request.build_absolute_uri(dt.get_absolute_url()), 

4574 } 

4575 ) 

4576 elif obj_type == "device": 

4577 self._search_devices(request, q, limit, results) 

4578 elif obj_type == "role": 

4579 for role in DeviceRole.objects.filter(name__icontains=q)[:limit]: 

4580 results.append( 

4581 { 

4582 "id": role.pk, 

4583 "name": role.name, 

4584 "slug": role.slug, 

4585 "url": request.build_absolute_uri(role.get_absolute_url()), 

4586 } 

4587 ) 

4588 elif obj_type == "rack_type": 

4589 from django.db.models import Q 

4590 

4591 qs = RackType.objects.select_related("manufacturer").filter( 

4592 Q(model__icontains=q) | Q(manufacturer__name__icontains=q) | Q(slug__icontains=q) 

4593 )[:limit] 

4594 for rt in qs: 

4595 results.append( 

4596 { 

4597 "id": rt.pk, 

4598 "name": f"{rt.manufacturer.name} / {rt.model}" if rt.manufacturer else rt.model, 

4599 "slug": rt.slug, 

4600 "url": request.build_absolute_uri(rt.get_absolute_url()), 

4601 } 

4602 ) 

4603 

4604 return JsonResponse({"results": results}) 

4605 

4606 def _search_devices(self, request, q, limit, results): 

4607 """Two-phase device search: full-string matches first, then token matches. 

4608 

4609 This prevents a relevant exact-substring match (e.g. "example-zone03d-rc1") 

4610 from being pushed off the result list by noisy short tokens like "rc1" 

4611 or "prod" that match many devices. 

4612 """ 

4613 from dcim.models import Device 

4614 

4615 visible_devices = Device.objects.restrict(request.user, "view") 

4616 base_qs = visible_devices.filter(name__icontains=q).distinct().select_related("site").order_by("name") 

4617 seen_ids = set() 

4618 for dev in base_qs[:limit]: 

4619 seen_ids.add(dev.pk) 

4620 results.append( 

4621 { 

4622 "id": dev.pk, 

4623 "name": dev.name, 

4624 "serial": dev.serial or None, 

4625 "site": dev.site.name if dev.site else "", 

4626 "url": request.build_absolute_uri(dev.get_absolute_url()), 

4627 } 

4628 ) 

4629 if len(results) >= limit: 

4630 return 

4631 token_qs = ( 

4632 visible_devices.filter(_device_name_filter(q)) 

4633 .exclude(pk__in=seen_ids) 

4634 .distinct() 

4635 .select_related("site") 

4636 .order_by("name") 

4637 ) 

4638 for dev in token_qs[: limit - len(results)]: 

4639 results.append( 

4640 { 

4641 "id": dev.pk, 

4642 "name": dev.name, 

4643 "serial": dev.serial or None, 

4644 "site": dev.site.name if dev.site else "", 

4645 "url": request.build_absolute_uri(dev.get_absolute_url()), 

4646 } 

4647 ) 

4648 

4649 

4650class QuickCreateDeviceRoleView(_PermissionScopedWriteMixin, _AjaxPermissionView): 

4651 """AJAX endpoint: create a new DeviceRole and return its details as JSON. 

4652 

4653 Used by the Configure Class modal so operators can create missing roles 

4654 without leaving the import preview page. 

4655 """ 

4656 

4657 permission_required = "netbox_data_import.change_importprofile" 

4658 

4659 def post(self, request): 

4660 """Create the DeviceRole and return JSON {id, name, slug}.""" 

4661 from dcim.models import DeviceRole 

4662 from django.http import JsonResponse 

4663 

4664 profile_id = _parse_posted_profile_id(request) 

4665 if profile_id is None: 

4666 return JsonResponse({"error": "A valid import profile is required."}, status=400) 

4667 get_object_or_404(ImportProfile.objects.restrict(request.user, "change"), pk=profile_id) 

4668 

4669 name = request.POST.get("name", "").strip() 

4670 slug = request.POST.get("slug", "").strip() 

4671 color = request.POST.get("color", "9e9e9e").strip() or "9e9e9e" 

4672 

4673 if not name or not slug: 

4674 return JsonResponse({"error": "Role name and slug are required."}, status=400) 

4675 

4676 import re 

4677 

4678 if not re.match(r"^[-a-z0-9_]+$", slug): 

4679 return JsonResponse( 

4680 {"error": "Slug may only contain lowercase letters, numbers, hyphens, and underscores."}, status=400 

4681 ) 

4682 

4683 try: 

4684 role = _get_or_init(DeviceRole, slug=slug) 

4685 if role.pk is None: 

4686 role.name = name 

4687 role.color = color 

4688 _validate_model_instance(role, f"device role '{name}'") 

4689 result = save_permission_scoped_object( 

4690 request.user, 

4691 DeviceRole, 

4692 {"slug": slug}, 

4693 {"name": name, "color": color}, 

4694 on_existing="keep", 

4695 ) 

4696 role = result.instance 

4697 created = result.created 

4698 except IntegrityError: 

4699 logger.exception("QuickCreateDeviceRoleView: integrity error creating role slug=%s", slug) 

4700 return JsonResponse({"error": "A device role with that slug already exists."}, status=400) 

4701 except (ValueError, ValidationError): 

4702 logger.exception("QuickCreateDeviceRoleView: validation error creating role slug=%s", slug) 

4703 return JsonResponse({"error": "Invalid role data."}, status=400) 

4704 except DatabaseError: 

4705 logger.exception("QuickCreateDeviceRoleView: database error creating role slug=%s", slug) 

4706 return JsonResponse({"error": "An internal error occurred."}, status=500) 

4707 

4708 return JsonResponse( 

4709 { 

4710 "id": role.pk, 

4711 "name": role.name, 

4712 "slug": role.slug, 

4713 "created": created, 

4714 } 

4715 ) 

4716 

4717 

4718class AutoMatchDevicesView(_PermissionScopedWriteMixin, PermissionRequiredMixin, View): 

4719 """Run the Review Workspace device auto-match command.""" 

4720 

4721 permission_required = ( 

4722 "netbox_data_import.change_importprofile", 

4723 "netbox_data_import.add_deviceexistingmatch", 

4724 "dcim.view_device", 

4725 ) 

4726 

4727 def post(self, request): 

4728 """Run auto-matching and redirect back to preview with a summary message.""" 

4729 profile_id = _parse_posted_profile_id(request) 

4730 if profile_id is None: 

4731 messages.error(request, "A valid import profile is required.") 

4732 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4733 preview = load_cached_preview(request) 

4734 if preview is None or preview[0].pk != profile_id: 

4735 messages.error(request, "The selected profile is not the active import profile.") 

4736 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4737 profile, workspace = preview 

4738 ctx_data = request.session.get("import_context") or {} 

4739 target = _resolved_import_target(ctx_data, request.user) 

4740 if target is None: 

4741 messages.error(request, "The saved import target is no longer available. Start a new preview.") 

4742 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4743 summary = workspace.auto_match_devices(profile, request.user, target) 

4744 messages.success(request, summary.message()) 

4745 return redirect(reverse("plugins:netbox_data_import:import_preview")) 

4746 

4747 

4748def _refused_row_write_response(exc, row_number): 

4749 """Return the answer one refused row write gives the operator. 

4750 

4751 The worker reports the same failures, so both read the message from one place. 

4752 """ 

4753 if isinstance(exc, DatabaseError): 

4754 logger.error("SyncSingleRowView: database error for row_number=%s", row_number, exc_info=exc) 

4755 return JsonResponse({"ok": False, "error": operator_failure_message(exc)}, status=400) 

4756 

4757 

4758class SyncSingleRowView(_AjaxPermissionView): 

4759 """AJAX endpoint: execute a single row from the current import session. 

4760 

4761 POST body: row_number=<int> 

4762 Returns the deferred preview-action JSON envelope. 

4763 """ 

4764 

4765 permission_required = "netbox_data_import.change_importprofile" 

4766 

4767 def post(self, request): 

4768 """Execute one selected Synchronization Unit and return JSON.""" 

4769 ctx_data = request.session.get("import_context") 

4770 plan_data = request.session.get(PREVIEW_PLAN_SESSION_KEY) 

4771 if not isinstance(ctx_data, dict) or not isinstance(plan_data, dict): 

4772 return JsonResponse({"ok": False, "error": "No import in progress"}, status=400) 

4773 if stale_reason := _stale_preview_reason(request): 

4774 return JsonResponse({"ok": False, "error": stale_reason}, status=409) 

4775 if request.session.get(PREVIEW_DIRTY_SESSION_KEY) is True: 

4776 return JsonResponse( 

4777 {"ok": False, "error": "Recalculate the preview before synchronizing a row."}, 

4778 status=409, 

4779 ) 

4780 

4781 raw_row_number = request.POST.get("row_number") 

4782 if raw_row_number is None: 

4783 return JsonResponse({"ok": False, "error": "row_number is required"}, status=400) 

4784 try: 

4785 row_number = int(raw_row_number) 

4786 except (TypeError, ValueError): 

4787 return JsonResponse({"ok": False, "error": "Invalid row number"}, status=400) 

4788 

4789 profile = ImportProfile.objects.restrict(request.user, "change").filter(pk=ctx_data.get("profile_id")).first() 

4790 if not profile: 

4791 return JsonResponse({"ok": False, "error": "Import profile not found"}, status=400) 

4792 try: 

4793 validate_registered_adapter(profile) 

4794 validate_adapter_target_module(profile.source_adapter) 

4795 except ValidationError as exc: 

4796 return JsonResponse({"ok": False, "error": "; ".join(exc.messages)}, status=400) 

4797 

4798 try: 

4799 accepted = ImportPlan.from_dict(plan_data) 

4800 except PlanError as exc: 

4801 return JsonResponse({"ok": False, "error": str(exc)}, status=409) 

4802 workspace = ReviewWorkspace(accepted) 

4803 preview_unit = next( 

4804 ( 

4805 unit 

4806 for unit in workspace.units 

4807 if unit.row_number == row_number and unit.object_type in {"device", "rack"} 

4808 ), 

4809 None, 

4810 ) 

4811 if preview_unit is None: 

4812 return JsonResponse({"ok": False, "error": "Row not found in current preview data"}, status=400) 

4813 if preview_unit.action != "create": 

4814 return JsonResponse( 

4815 {"ok": False, "error": "Only 'create' rows can be synced individually"}, 

4816 status=400, 

4817 ) 

4818 

4819 document = SourceDocument.objects.filter(pk=ctx_data.get("source_document_id"), profile=profile).first() 

4820 if document is None: 

4821 return JsonResponse( 

4822 {"ok": False, "error": "The stored source is no longer available. Upload it again."}, 

4823 status=400, 

4824 ) 

4825 

4826 try: 

4827 ImportEngine.execute( 

4828 profile, 

4829 document, 

4830 plan_data, 

4831 [preview_unit.identity], 

4832 uuid.uuid4().hex, 

4833 request.user, 

4834 ) 

4835 except ( 

4836 PlanError, 

4837 PlanningTargetUnavailable, 

4838 PreconditionFailed, 

4839 SelectionError, 

4840 StalePlan, 

4841 StaleSourceDocument, 

4842 ) as exc: 

4843 return JsonResponse({"ok": False, "error": str(exc)}, status=409) 

4844 except (DatabaseError, ObjectPermissionDenied, ValidationError) as exc: 

4845 return _refused_row_write_response(exc, row_number) 

4846 except Exception: 

4847 logger.exception("SyncSingleRowView: unexpected error for row_number=%s", row_number) 

4848 return JsonResponse( 

4849 {"ok": False, "error": "An unexpected error occurred. See server logs."}, 

4850 status=500, 

4851 ) 

4852 

4853 mark_preview_dirty(request.session) 

4854 written = f"{preview_unit.object_type.capitalize()} '{preview_unit.name}' was created in NetBox." 

4855 return JsonResponse(pending_preview_payload(row_number, "Synchronized.", written)) 

4856 

4857 

4858class UnlinkDeviceView(_AjaxPermissionView): 

4859 """Remove a DeviceExistingMatch (unlink a manually-linked device).""" 

4860 

4861 permission_required = "netbox_data_import.delete_deviceexistingmatch" 

4862 

4863 def post(self, request): 

4864 """Delete the DeviceExistingMatch and redirect back to preview.""" 

4865 profile_id = request.POST.get("profile_id", "").strip() 

4866 source_id = request.POST.get("source_id", "").strip() 

4867 next_url = _safe_next_url(request, "plugins:netbox_data_import:import_preview") 

4868 

4869 if profile_id and source_id: 

4870 profile = get_object_or_404( 

4871 ImportProfile.objects.restrict(request.user, "change"), 

4872 pk=profile_id, 

4873 ) 

4874 with transaction.atomic(): 

4875 binding = ( 

4876 DeviceExistingMatch.objects.select_for_update().filter(profile=profile, source_id=source_id).first() 

4877 ) 

4878 dependent_reviews = list( 

4879 IgnoredFieldDifference.objects.select_for_update().filter( 

4880 profile=profile, 

4881 source_id=source_id, 

4882 ) 

4883 ) 

4884 if binding is not None and not request.user.has_perm( 

4885 "netbox_data_import.delete_deviceexistingmatch", 

4886 binding, 

4887 ): 

4888 messages.error(request, "Permission denied: cannot delete this device link.") 

4889 elif any( 

4890 not request.user.has_perm("netbox_data_import.delete_ignoredfielddifference", review) 

4891 for review in dependent_reviews 

4892 ): 

4893 messages.error(request, "Permission denied: cannot remove the dependent field reviews.") 

4894 else: 

4895 if dependent_reviews: 

4896 IgnoredFieldDifference.objects.filter( 

4897 pk__in=[review.pk for review in dependent_reviews] 

4898 ).delete() 

4899 if binding is not None: 

4900 binding.delete() 

4901 if dependent_reviews: 

4902 messages.success( 

4903 request, 

4904 f"Unlinked source '{source_id}' and removed {len(dependent_reviews)} field review(s).", 

4905 ) 

4906 elif binding is not None: 

4907 messages.success(request, f"Unlinked source '{source_id}'.") 

4908 

4909 return redirect(next_url)