Coverage for  / home / runner / work / netbox-data-import-plugin / netbox-data-import-plugin / netbox-data-import-plugin / netbox_data_import / views.py: 98%

657 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 12:13 +0000

1# SPDX-License-Identifier: Apache-2.0 

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

3from django.contrib import messages 

4from django.contrib.auth.mixins import LoginRequiredMixin 

5from django.shortcuts import get_object_or_404, redirect, render 

6from django.urls import reverse 

7from django.utils.http import url_has_allowed_host_and_scheme 

8from django.views import View 

9from netbox.views import generic 

10from .models import ( 

11 ImportProfile, 

12 ColumnMapping, 

13 ClassRoleMapping, 

14 DeviceTypeMapping, 

15 ImportJob, 

16 ColumnTransformRule, 

17 DeviceExistingMatch, 

18 ManufacturerMapping, 

19) 

20from .forms import ( 

21 ImportProfileForm, 

22 ColumnMappingForm, 

23 ClassRoleMappingForm, 

24 DeviceTypeMappingForm, 

25 ColumnTransformRuleForm, 

26 ImportSetupForm, 

27) 

28from .tables import ( 

29 ImportProfileTable, 

30 ColumnMappingTable, 

31 ClassRoleMappingTable, 

32 DeviceTypeMappingTable, 

33 ColumnTransformRuleTable, 

34) 

35from .filters import ImportProfileFilterSet 

36 

37 

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

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

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

41 if url and url_has_allowed_host_and_scheme( 

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

43 ): 

44 return url 

45 return reverse(fallback) 

46 

47 

48# --------------------------------------------------------------------------- 

49# ImportProfile 

50# --------------------------------------------------------------------------- 

51 

52 

53class ImportProfileListView(generic.ObjectListView): 

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

55 

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

57 table = ImportProfileTable 

58 filterset = ImportProfileFilterSet 

59 template_name = "netbox_data_import/importprofile_list.html" 

60 

61 

62class ImportProfileView(generic.ObjectView): 

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

64 

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

66 

67 def get_extra_context(self, request, instance): 

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

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

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

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

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

73 return { 

74 "column_table": column_table, 

75 "class_role_table": class_role_table, 

76 "device_type_table": device_type_table, 

77 "transform_table": transform_table, 

78 } 

79 

80 

81class ImportProfileEditView(generic.ObjectEditView): 

82 """Create or edit an ImportProfile.""" 

83 

84 queryset = ImportProfile.objects.all() 

85 form = ImportProfileForm 

86 

87 

88class ImportProfileDeleteView(generic.ObjectDeleteView): 

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

90 

91 queryset = ImportProfile.objects.all() 

92 

93 

94# --------------------------------------------------------------------------- 

95# ColumnMapping CRUD 

96# --------------------------------------------------------------------------- 

97 

98 

99class ColumnMappingAddView(LoginRequiredMixin, View): 

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

101 

102 def get(self, request, profile_pk): 

103 """Render the add form for a new column mapping.""" 

104 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

105 form = ColumnMappingForm(initial={"profile": profile}) 

106 return render(request, "netbox_data_import/columnmapping_edit.html", {"form": form, "profile": profile}) 

107 

108 def post(self, request, profile_pk): 

109 """Save a new column mapping or re-render the form with errors.""" 

110 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

111 form = ColumnMappingForm(request.POST) 

112 if form.is_valid(): 

113 form.save() 

114 messages.success(request, "Column mapping added.") 

115 return redirect(profile.get_absolute_url()) 

116 return render(request, "netbox_data_import/columnmapping_edit.html", {"form": form, "profile": profile}) 

117 

118 

119class ColumnMappingEditView(LoginRequiredMixin, View): 

120 """Edit an existing column mapping.""" 

121 

122 def get(self, request, pk): 

123 """Render the edit form for an existing column mapping.""" 

124 obj = get_object_or_404(ColumnMapping, pk=pk) 

125 form = ColumnMappingForm(instance=obj) 

126 return render( 

127 request, "netbox_data_import/columnmapping_edit.html", {"form": form, "profile": obj.profile, "object": obj} 

128 ) 

129 

130 def post(self, request, pk): 

131 """Save edits to an existing column mapping or re-render with errors.""" 

132 obj = get_object_or_404(ColumnMapping, pk=pk) 

133 form = ColumnMappingForm(request.POST, instance=obj) 

134 if form.is_valid(): 

135 form.save() 

136 messages.success(request, "Column mapping updated.") 

137 return redirect(obj.profile.get_absolute_url()) 

138 return render( 

139 request, "netbox_data_import/columnmapping_edit.html", {"form": form, "profile": obj.profile, "object": obj} 

140 ) 

141 

142 

143class ColumnMappingDeleteView(LoginRequiredMixin, View): 

144 """Delete a column mapping.""" 

145 

146 def get(self, request, pk): 

147 """Render the delete confirmation page for a column mapping.""" 

148 obj = get_object_or_404(ColumnMapping, pk=pk) 

149 return render( 

150 request, 

151 "netbox_data_import/confirm_delete.html", 

152 {"object": obj, "return_url": obj.profile.get_absolute_url()}, 

153 ) 

154 

155 def post(self, request, pk): 

156 """Delete the column mapping and redirect to the parent profile.""" 

157 obj = get_object_or_404(ColumnMapping, pk=pk) 

158 profile_url = obj.profile.get_absolute_url() 

159 obj.delete() 

160 messages.success(request, "Column mapping deleted.") 

161 return redirect(profile_url) 

162 

163 

164# --------------------------------------------------------------------------- 

165# ClassRoleMapping CRUD 

166# --------------------------------------------------------------------------- 

167 

168 

169class ClassRoleMappingAddView(LoginRequiredMixin, View): 

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

171 

172 def get(self, request, profile_pk): 

173 """Render the add form for a new class→role mapping.""" 

174 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

175 form = ClassRoleMappingForm(initial={"profile": profile}) 

176 return render(request, "netbox_data_import/classrolemapping_edit.html", {"form": form, "profile": profile}) 

177 

178 def post(self, request, profile_pk): 

179 """Save a new class→role mapping or re-render with errors.""" 

180 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

181 form = ClassRoleMappingForm(request.POST) 

182 if form.is_valid(): 

183 form.save() 

184 messages.success(request, "Class→Role mapping added.") 

185 return redirect(profile.get_absolute_url()) 

186 return render(request, "netbox_data_import/classrolemapping_edit.html", {"form": form, "profile": profile}) 

187 

188 

189class ClassRoleMappingEditView(LoginRequiredMixin, View): 

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

191 

192 def get(self, request, pk): 

193 """Render the edit form for an existing class→role mapping.""" 

194 obj = get_object_or_404(ClassRoleMapping, pk=pk) 

195 form = ClassRoleMappingForm(instance=obj) 

196 return render( 

197 request, 

198 "netbox_data_import/classrolemapping_edit.html", 

199 {"form": form, "profile": obj.profile, "object": obj}, 

200 ) 

201 

202 def post(self, request, pk): 

203 """Save edits to an existing class→role mapping or re-render with errors.""" 

204 obj = get_object_or_404(ClassRoleMapping, pk=pk) 

205 form = ClassRoleMappingForm(request.POST, instance=obj) 

206 if form.is_valid(): 

207 form.save() 

208 messages.success(request, "Class→Role mapping updated.") 

209 return redirect(obj.profile.get_absolute_url()) 

210 return render( 

211 request, 

212 "netbox_data_import/classrolemapping_edit.html", 

213 {"form": form, "profile": obj.profile, "object": obj}, 

214 ) 

215 

216 

217class ClassRoleMappingDeleteView(LoginRequiredMixin, View): 

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

219 

220 def get(self, request, pk): 

221 """Render the delete confirmation page for a class→role mapping.""" 

222 obj = get_object_or_404(ClassRoleMapping, pk=pk) 

223 return render( 

224 request, 

225 "netbox_data_import/confirm_delete.html", 

226 {"object": obj, "return_url": obj.profile.get_absolute_url()}, 

227 ) 

228 

229 def post(self, request, pk): 

230 """Delete the class→role mapping and redirect to the parent profile.""" 

231 obj = get_object_or_404(ClassRoleMapping, pk=pk) 

232 profile_url = obj.profile.get_absolute_url() 

233 obj.delete() 

234 messages.success(request, "Class→Role mapping deleted.") 

235 return redirect(profile_url) 

236 

237 

238# --------------------------------------------------------------------------- 

239# DeviceTypeMapping CRUD 

240# --------------------------------------------------------------------------- 

241 

242 

243class DeviceTypeMappingAddView(LoginRequiredMixin, View): 

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

245 

246 def get(self, request, profile_pk): 

247 """Render the add form for a new device type mapping.""" 

248 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

249 form = DeviceTypeMappingForm(initial={"profile": profile}) 

250 return render(request, "netbox_data_import/devicetypemapping_edit.html", {"form": form, "profile": profile}) 

251 

252 def post(self, request, profile_pk): 

253 """Save a new device type mapping or re-render with errors.""" 

254 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

255 form = DeviceTypeMappingForm(request.POST) 

256 if form.is_valid(): 

257 form.save() 

258 messages.success(request, "Device type mapping added.") 

259 return redirect(profile.get_absolute_url()) 

260 return render(request, "netbox_data_import/devicetypemapping_edit.html", {"form": form, "profile": profile}) 

261 

262 

263class DeviceTypeMappingEditView(LoginRequiredMixin, View): 

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

265 

266 def get(self, request, pk): 

267 """Render the edit form for an existing device type mapping.""" 

268 obj = get_object_or_404(DeviceTypeMapping, pk=pk) 

269 form = DeviceTypeMappingForm(instance=obj) 

270 return render( 

271 request, 

272 "netbox_data_import/devicetypemapping_edit.html", 

273 {"form": form, "profile": obj.profile, "object": obj}, 

274 ) 

275 

276 def post(self, request, pk): 

277 """Save edits to an existing device type mapping or re-render with errors.""" 

278 obj = get_object_or_404(DeviceTypeMapping, pk=pk) 

279 form = DeviceTypeMappingForm(request.POST, instance=obj) 

280 if form.is_valid(): 

281 form.save() 

282 messages.success(request, "Device type mapping updated.") 

283 return redirect(obj.profile.get_absolute_url()) 

284 return render( 

285 request, 

286 "netbox_data_import/devicetypemapping_edit.html", 

287 {"form": form, "profile": obj.profile, "object": obj}, 

288 ) 

289 

290 

291class DeviceTypeMappingDeleteView(LoginRequiredMixin, View): 

292 """Delete a device type mapping.""" 

293 

294 def get(self, request, pk): 

295 """Render the delete confirmation page for a device type mapping.""" 

296 obj = get_object_or_404(DeviceTypeMapping, pk=pk) 

297 return render( 

298 request, 

299 "netbox_data_import/confirm_delete.html", 

300 {"object": obj, "return_url": obj.profile.get_absolute_url()}, 

301 ) 

302 

303 def post(self, request, pk): 

304 """Delete the device type mapping and redirect to the parent profile.""" 

305 obj = get_object_or_404(DeviceTypeMapping, pk=pk) 

306 profile_url = obj.profile.get_absolute_url() 

307 obj.delete() 

308 messages.success(request, "Device type mapping deleted.") 

309 return redirect(profile_url) 

310 

311 

312# --------------------------------------------------------------------------- 

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

314# --------------------------------------------------------------------------- 

315 

316 

317class ImportSetupView(LoginRequiredMixin, View): 

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

319 

320 def get(self, request): 

321 """Render the import setup form.""" 

322 initial = {} 

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

324 initial["profile"] = profile_pk 

325 form = ImportSetupForm(initial=initial) 

326 return render(request, "netbox_data_import/import_setup.html", {"form": form}) 

327 

328 def post(self, request): 

329 """Parse the uploaded file and redirect to the preview step.""" 

330 form = ImportSetupForm(request.POST, request.FILES) 

331 if not form.is_valid(): 

332 return render(request, "netbox_data_import/import_setup.html", {"form": form}) 

333 

334 from . import engine 

335 

336 profile = form.cleaned_data["profile"] 

337 excel_file = form.cleaned_data["excel_file"] 

338 site = form.cleaned_data["site"] 

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

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

341 

342 try: 

343 rows = engine.parse_file(excel_file, profile) 

344 except engine.ParseError as exc: 

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

346 return render(request, "netbox_data_import/import_setup.html", {"form": form}) 

347 

348 context = {"site": site, "location": location, "tenant": tenant} 

349 result = engine.run_import(rows, profile, context, dry_run=True) 

350 

351 # Store result + raw rows + context in session for the preview/execute steps 

352 # Rows need JSON-safe serialization (handle datetime from Excel) 

353 request.session["import_result"] = result.to_session_dict() 

354 request.session["import_rows"] = _serialize_rows(rows) 

355 request.session["import_context"] = { 

356 "profile_id": profile.pk, 

357 "site_id": site.pk, 

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

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

360 "filename": excel_file.name, 

361 } 

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

363 

364 

365class ImportPreviewView(LoginRequiredMixin, View): 

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

367 

368 def get(self, request): 

369 """Re-run the dry-run import and render the preview template.""" 

370 rows = request.session.get("import_rows") 

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

372 if not rows or not ctx: 

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

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

375 

376 from . import engine 

377 from dcim.models import Site, Location 

378 from tenancy.models import Tenant 

379 

380 profile = ImportProfile.objects.filter(pk=ctx.get("profile_id")).first() 

381 if not profile: 

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

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

384 

385 site = Site.objects.filter(pk=ctx.get("site_id")).first() 

386 location = Location.objects.filter(pk=ctx.get("location_id")).first() if ctx.get("location_id") else None 

387 tenant = Tenant.objects.filter(pk=ctx.get("tenant_id")).first() if ctx.get("tenant_id") else None 

388 

389 context_obj = {"site": site, "location": location, "tenant": tenant} 

390 # Always re-run so any new mappings/matches are immediately reflected 

391 result = engine.run_import(rows, profile, context_obj, dry_run=True) 

392 request.session["import_result"] = result.to_session_dict() 

393 

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

395 from .models import SourceResolution 

396 import json as _json 

397 

398 existing_resolutions = {} 

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

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

401 "original_value": res.original_value, 

402 "resolved_fields": res.resolved_fields, 

403 } 

404 

405 view_mode = request.GET.get("view", profile.preview_view_mode) 

406 return render( 

407 request, 

408 "netbox_data_import/import_preview.html", 

409 { 

410 "result": result, 

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

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

413 "profile": profile, 

414 "view_mode": view_mode, 

415 "existing_resolutions_json": _json.dumps(existing_resolutions), 

416 }, 

417 ) 

418 

419 

420class ImportRunView(LoginRequiredMixin, View): 

421 """Step 3: run the real import (dry_run=False).""" 

422 

423 def post(self, request): 

424 """Execute the real import and redirect to the results page.""" 

425 rows = request.session.get("import_rows") 

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

427 if not rows or not ctx_data: 

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

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

430 

431 from django.db import transaction 

432 from dcim.models import Site, Location 

433 from tenancy.models import Tenant 

434 from . import engine 

435 

436 profile = get_object_or_404(ImportProfile, pk=ctx_data["profile_id"]) 

437 site = get_object_or_404(Site, pk=ctx_data["site_id"]) 

438 location = get_object_or_404(Location, pk=ctx_data["location_id"]) if ctx_data.get("location_id") else None 

439 tenant = get_object_or_404(Tenant, pk=ctx_data["tenant_id"]) if ctx_data.get("tenant_id") else None 

440 

441 context = {"site": site, "location": location, "tenant": tenant} 

442 

443 with transaction.atomic(): 

444 result = engine.run_import(rows, profile, context, dry_run=False) 

445 

446 # Persist job record 

447 from .models import ImportJob 

448 

449 job = ImportJob.objects.create( 

450 profile=profile, 

451 input_filename=ctx_data.get("filename", ""), 

452 dry_run=False, 

453 site_name=site.name, 

454 result_counts=result.counts, 

455 result_rows=[r.to_dict() for r in result.rows], 

456 ) 

457 

458 request.session["import_result"] = result.to_session_dict() 

459 request.session["import_job_id"] = job.pk 

460 messages.success( 

461 request, 

462 f"Import complete: {result.counts.get('devices_created', 0)} devices created, " 

463 f"{result.counts.get('racks_created', 0)} racks created.", 

464 ) 

465 return redirect(reverse("plugins:netbox_data_import:import_results")) 

466 

467 

468class ImportResultsView(LoginRequiredMixin, View): 

469 """Step 4: show final results with links to created objects.""" 

470 

471 def get(self, request): 

472 """Render the results page for the most recent import.""" 

473 session_data = request.session.get("import_result") 

474 if not session_data: 

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

476 

477 from . import engine 

478 

479 result = engine.ImportResult.from_session_dict(session_data) 

480 job_id = request.session.get("import_job_id") 

481 return render(request, "netbox_data_import/import_results.html", {"result": result, "job_id": job_id}) 

482 

483 

484# --------------------------------------------------------------------------- 

485# Import Job history 

486# --------------------------------------------------------------------------- 

487 

488 

489class ImportJobListView(LoginRequiredMixin, View): 

490 """List all past import jobs for audit / history.""" 

491 

492 def get(self, request): 

493 """Render the import job history list.""" 

494 jobs = ImportJob.objects.select_related("profile").all() 

495 return render(request, "netbox_data_import/importjob_list.html", {"jobs": jobs}) 

496 

497 

498# --------------------------------------------------------------------------- 

499# ColumnTransformRule CRUD 

500# --------------------------------------------------------------------------- 

501 

502 

503class ColumnTransformRuleAddView(LoginRequiredMixin, View): 

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

505 

506 def get(self, request, profile_pk): 

507 """Render the add form for a new column transform rule.""" 

508 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

509 form = ColumnTransformRuleForm(initial={"profile": profile}) 

510 return render(request, "netbox_data_import/columntransformrule_edit.html", {"form": form, "profile": profile}) 

511 

512 def post(self, request, profile_pk): 

513 """Save a new column transform rule or re-render with errors.""" 

514 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

515 form = ColumnTransformRuleForm(request.POST) 

516 if form.is_valid(): 

517 form.save() 

518 messages.success(request, "Column transform rule added.") 

519 return redirect(profile.get_absolute_url()) 

520 return render(request, "netbox_data_import/columntransformrule_edit.html", {"form": form, "profile": profile}) 

521 

522 

523class ColumnTransformRuleEditView(LoginRequiredMixin, View): 

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

525 

526 def get(self, request, pk): 

527 """Render the edit form for an existing column transform rule.""" 

528 obj = get_object_or_404(ColumnTransformRule, pk=pk) 

529 form = ColumnTransformRuleForm(instance=obj) 

530 return render( 

531 request, 

532 "netbox_data_import/columntransformrule_edit.html", 

533 {"form": form, "profile": obj.profile, "object": obj}, 

534 ) 

535 

536 def post(self, request, pk): 

537 """Save edits to an existing column transform rule or re-render with errors.""" 

538 obj = get_object_or_404(ColumnTransformRule, pk=pk) 

539 form = ColumnTransformRuleForm(request.POST, instance=obj) 

540 if form.is_valid(): 

541 form.save() 

542 messages.success(request, "Column transform rule updated.") 

543 return redirect(obj.profile.get_absolute_url()) 

544 return render( 

545 request, 

546 "netbox_data_import/columntransformrule_edit.html", 

547 {"form": form, "profile": obj.profile, "object": obj}, 

548 ) 

549 

550 

551class ColumnTransformRuleDeleteView(LoginRequiredMixin, View): 

552 """Delete a column transform rule.""" 

553 

554 def get(self, request, pk): 

555 """Render the delete confirmation page for a column transform rule.""" 

556 obj = get_object_or_404(ColumnTransformRule, pk=pk) 

557 return render( 

558 request, 

559 "netbox_data_import/confirm_delete.html", 

560 {"object": obj, "return_url": obj.profile.get_absolute_url()}, 

561 ) 

562 

563 def post(self, request, pk): 

564 """Delete the column transform rule and redirect to the parent profile.""" 

565 obj = get_object_or_404(ColumnTransformRule, pk=pk) 

566 profile_url = obj.profile.get_absolute_url() 

567 obj.delete() 

568 messages.success(request, "Column transform rule deleted.") 

569 return redirect(profile_url) 

570 

571 

572# --------------------------------------------------------------------------- 

573# Ignore / Unignore device 

574# --------------------------------------------------------------------------- 

575 

576 

577class IgnoreDeviceView(LoginRequiredMixin, View): 

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

579 

580 def post(self, request): 

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

582 from .models import IgnoredDevice 

583 

584 profile_id = request.POST.get("profile_id") 

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

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

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

588 

589 if profile_id and source_id: 

590 profile = get_object_or_404(ImportProfile, pk=profile_id) 

591 IgnoredDevice.objects.get_or_create( 

592 profile=profile, 

593 source_id=source_id, 

594 defaults={"device_name": device_name}, 

595 ) 

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

597 return redirect(next_url) 

598 

599 

600class UnignoreDeviceView(LoginRequiredMixin, View): 

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

602 

603 def post(self, request): 

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

605 from .models import IgnoredDevice 

606 

607 profile_id = request.POST.get("profile_id") 

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

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

610 

611 if profile_id and source_id: 

612 IgnoredDevice.objects.filter( 

613 profile_id=profile_id, 

614 source_id=source_id, 

615 ).delete() 

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

617 return redirect(next_url) 

618 

619 

620# --------------------------------------------------------------------------- 

621# Save resolution (rerere) 

622# --------------------------------------------------------------------------- 

623 

624 

625class SaveResolutionView(LoginRequiredMixin, View): 

626 """Save a manual field resolution for rerere replay.""" 

627 

628 def post(self, request): 

629 """Persist a manual field resolution for rerere replay.""" 

630 import json 

631 from .models import SourceResolution 

632 

633 profile_id = request.POST.get("profile_id") 

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

635 source_column = request.POST.get("source_column") 

636 original_value = request.POST.get("original_value") 

637 resolved_fields_json = request.POST.get("resolved_fields", "{}") 

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

639 

640 try: 

641 resolved_fields = json.loads(resolved_fields_json) 

642 except (json.JSONDecodeError, TypeError): 

643 resolved_fields = {} 

644 

645 if profile_id and source_id and source_column: 

646 profile = get_object_or_404(ImportProfile, pk=profile_id) 

647 SourceResolution.objects.update_or_create( 

648 profile=profile, 

649 source_id=source_id, 

650 source_column=source_column, 

651 defaults={ 

652 "original_value": original_value or "", 

653 "resolved_fields": resolved_fields, 

654 }, 

655 ) 

656 messages.success(request, "Resolution saved. Re-run the import to apply it.") 

657 return redirect(next_url) 

658 

659 

660# --------------------------------------------------------------------------- 

661# Device type analysis view 

662# --------------------------------------------------------------------------- 

663 

664 

665class DeviceTypeAnalysisView(LoginRequiredMixin, View): 

666 """Show all unique (make, model) pairs across import jobs and profiles. 

667 

668 Highlights which ones have explicit DeviceTypeMapping vs auto-slugified. 

669 """ 

670 

671 def get(self, request, profile_pk=None): 

672 """Render the device type analysis page for the given profile.""" 

673 profile = get_object_or_404(ImportProfile, pk=profile_pk) if profile_pk else None 

674 profiles = ImportProfile.objects.all() 

675 

676 # Collect unique (make, model) pairs from all recent import jobs 

677 job_qs = ImportJob.objects.all() 

678 if profile: 

679 job_qs = job_qs.filter(profile=profile) 

680 

681 for job in job_qs.order_by("-created")[:50]: 

682 for row in job.result_rows: 

683 if row.get("object_type") in ("manufacturer", "device_type"): 

684 continue 

685 # We don't store make/model in result_rows, so skip 

686 pass 

687 

688 # Better: build analysis from DeviceTypeMapping + auto-slugify check 

689 if profile: 

690 dt_mappings = DeviceTypeMapping.objects.filter(profile=profile) 

691 else: 

692 dt_mappings = DeviceTypeMapping.objects.select_related("profile").all() 

693 

694 # Collect entries: explicit mappings 

695 entries = [] 

696 for dtm in dt_mappings: 

697 entries.append( 

698 { 

699 "profile": dtm.profile, 

700 "source_make": dtm.source_make, 

701 "source_model": dtm.source_model, 

702 "manufacturer_slug": dtm.netbox_manufacturer_slug, 

703 "device_type_slug": dtm.netbox_device_type_slug, 

704 "mapping_type": "explicit", 

705 "mapping_pk": dtm.pk, 

706 } 

707 ) 

708 

709 # Check which mapped device types exist in NetBox 

710 from dcim.models import DeviceType 

711 

712 for entry in entries: 

713 entry["exists_in_netbox"] = DeviceType.objects.filter( 

714 manufacturer__slug=entry["manufacturer_slug"], 

715 slug=entry["device_type_slug"], 

716 ).exists() 

717 

718 return render( 

719 request, 

720 "netbox_data_import/analysis.html", 

721 { 

722 "profile": profile, 

723 "profiles": profiles, 

724 "entries": entries, 

725 }, 

726 ) 

727 

728 

729# --------------------------------------------------------------------------- 

730# Bulk YAML import for mappings 

731# --------------------------------------------------------------------------- 

732 

733 

734class BulkYamlImportView(LoginRequiredMixin, View): 

735 """Accept a YAML file and bulk-create ClassRoleMappings or DeviceTypeMappings for a profile. 

736 

737 Useful for bootstrapping from contrib/ definition files. 

738 """ 

739 

740 def get(self, request, profile_pk): 

741 """Render the bulk YAML import form.""" 

742 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

743 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

744 

745 def post(self, request, profile_pk): 

746 """Parse the uploaded YAML file and create mappings in bulk.""" 

747 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

748 yaml_file = request.FILES.get("yaml_file") 

749 mapping_type = request.POST.get("mapping_type", "class_role") 

750 

751 if not yaml_file: 

752 messages.error(request, "No YAML file uploaded.") 

753 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

754 

755 try: 

756 import yaml 

757 

758 data = yaml.safe_load(yaml_file.read()) 

759 except Exception as exc: 

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

761 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

762 

763 if not isinstance(data, list): 

764 messages.error(request, "YAML must be a list of mapping objects.") 

765 return render(request, "netbox_data_import/bulk_yaml_import.html", {"profile": profile}) 

766 

767 created = 0 

768 skipped = 0 

769 errors = [] 

770 

771 if mapping_type == "class_role": 

772 for item in data: 

773 try: 

774 _, was_created = ClassRoleMapping.objects.get_or_create( 

775 profile=profile, 

776 source_class=item["source_class"], 

777 defaults={ 

778 "creates_rack": item.get("creates_rack", False), 

779 "role_slug": item.get("role_slug", ""), 

780 "ignore": item.get("ignore", False), 

781 }, 

782 ) 

783 if was_created: 

784 created += 1 

785 else: 

786 skipped += 1 

787 except Exception as exc: 

788 errors.append(str(exc)) 

789 elif mapping_type == "device_type": 

790 for item in data: 

791 try: 

792 _, was_created = DeviceTypeMapping.objects.get_or_create( 

793 profile=profile, 

794 source_make=item["source_make"], 

795 source_model=item["source_model"], 

796 defaults={ 

797 "netbox_manufacturer_slug": item["netbox_manufacturer_slug"], 

798 "netbox_device_type_slug": item["netbox_device_type_slug"], 

799 }, 

800 ) 

801 if was_created: 

802 created += 1 

803 else: 

804 skipped += 1 

805 except Exception as exc: 

806 errors.append(str(exc)) 

807 

808 if errors: 

809 messages.warning( 

810 request, f"Created {created}, skipped {skipped}, {len(errors)} errors: {'; '.join(errors[:3])}" 

811 ) 

812 else: 

813 messages.success(request, f"Bulk import complete: {created} created, {skipped} already existed.") 

814 return redirect(profile.get_absolute_url()) 

815 

816 

817# --------------------------------------------------------------------------- 

818# Profile YAML export / full-profile YAML import 

819# --------------------------------------------------------------------------- 

820 

821 

822class ExportProfileYamlView(LoginRequiredMixin, View): 

823 """Download all profile configuration as a single YAML file.""" 

824 

825 def get(self, request, pk): 

826 """Serialize the profile and all its mappings to YAML and return as a file download.""" 

827 from django.http import HttpResponse 

828 import yaml 

829 

830 profile = get_object_or_404(ImportProfile, pk=pk) 

831 

832 data = { 

833 "profile": { 

834 "name": profile.name, 

835 "description": profile.description, 

836 "sheet_name": profile.sheet_name, 

837 "source_id_column": profile.source_id_column, 

838 "custom_field_name": profile.custom_field_name, 

839 "update_existing": profile.update_existing, 

840 "create_missing_device_types": profile.create_missing_device_types, 

841 "preview_view_mode": profile.preview_view_mode, 

842 }, 

843 "column_mappings": [ 

844 {"source_column": cm.source_column, "target_field": cm.target_field} 

845 for cm in profile.column_mappings.all() 

846 ], 

847 "class_role_mappings": [ 

848 { 

849 "source_class": m.source_class, 

850 "creates_rack": m.creates_rack, 

851 "role_slug": m.role_slug, 

852 "ignore": m.ignore, 

853 } 

854 for m in profile.class_role_mappings.all() 

855 ], 

856 "device_type_mappings": [ 

857 { 

858 "source_make": m.source_make, 

859 "source_model": m.source_model, 

860 "netbox_manufacturer_slug": m.netbox_manufacturer_slug, 

861 "netbox_device_type_slug": m.netbox_device_type_slug, 

862 } 

863 for m in profile.device_type_mappings.all() 

864 ], 

865 "manufacturer_mappings": [ 

866 { 

867 "source_make": m.source_make, 

868 "netbox_manufacturer_slug": m.netbox_manufacturer_slug, 

869 } 

870 for m in profile.manufacturer_mappings.all() 

871 ], 

872 "column_transform_rules": [ 

873 { 

874 "source_column": r.source_column, 

875 "pattern": r.pattern, 

876 "group_1_target": r.group_1_target, 

877 "group_2_target": r.group_2_target, 

878 } 

879 for r in profile.column_transform_rules.all() 

880 ], 

881 } 

882 

883 yaml_str = yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False) 

884 safe_name = profile.name.lower().replace(" ", "_").replace("/", "-") 

885 filename = f"profile_{safe_name}.yaml" 

886 return HttpResponse( 

887 yaml_str, 

888 content_type="application/x-yaml", 

889 headers={"Content-Disposition": f'attachment; filename="{filename}"'}, 

890 ) 

891 

892 

893class ImportProfileYamlView(LoginRequiredMixin, View): 

894 """Import a full profile YAML (as exported by ExportProfileYamlView). 

895 

896 If the profile already exists (by name), merges/updates its mappings. 

897 """ 

898 

899 def get(self, request): 

900 """Render the profile YAML import form.""" 

901 return render(request, "netbox_data_import/import_profile_yaml.html") 

902 

903 def post(self, request): 

904 """Parse the uploaded YAML and create or update the profile and its mappings.""" 

905 import yaml 

906 

907 yaml_file = request.FILES.get("yaml_file") 

908 if not yaml_file: 

909 messages.error(request, "No YAML file uploaded.") 

910 return render(request, "netbox_data_import/import_profile_yaml.html") 

911 

912 try: 

913 data = yaml.safe_load(yaml_file.read()) 

914 except Exception as exc: 

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

916 return render(request, "netbox_data_import/import_profile_yaml.html") 

917 

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

919 messages.error(request, "YAML must contain a top-level 'profile' key.") 

920 return render(request, "netbox_data_import/import_profile_yaml.html") 

921 

922 pdata = data["profile"] 

923 profile, _ = ImportProfile.objects.update_or_create( 

924 name=pdata["name"], 

925 defaults={ 

926 "description": pdata.get("description", ""), 

927 "sheet_name": pdata.get("sheet_name", "Data"), 

928 "source_id_column": pdata.get("source_id_column", ""), 

929 "custom_field_name": pdata.get("custom_field_name", ""), 

930 "update_existing": pdata.get("update_existing", True), 

931 "create_missing_device_types": pdata.get("create_missing_device_types", True), 

932 "preview_view_mode": pdata.get("preview_view_mode", "rows"), 

933 }, 

934 ) 

935 

936 stats = {} 

937 for cm in data.get("column_mappings", []): 

938 _, c = ColumnMapping.objects.update_or_create( 

939 profile=profile, 

940 target_field=cm["target_field"], 

941 defaults={"source_column": cm["source_column"]}, 

942 ) 

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

944 

945 for m in data.get("class_role_mappings", []): 

946 ClassRoleMapping.objects.update_or_create( 

947 profile=profile, 

948 source_class=m["source_class"], 

949 defaults={ 

950 "creates_rack": m.get("creates_rack", False), 

951 "role_slug": m.get("role_slug", ""), 

952 "ignore": m.get("ignore", False), 

953 }, 

954 ) 

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

956 

957 for m in data.get("device_type_mappings", []): 

958 DeviceTypeMapping.objects.update_or_create( 

959 profile=profile, 

960 source_make=m["source_make"], 

961 source_model=m["source_model"], 

962 defaults={ 

963 "netbox_manufacturer_slug": m["netbox_manufacturer_slug"], 

964 "netbox_device_type_slug": m["netbox_device_type_slug"], 

965 }, 

966 ) 

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

968 

969 for m in data.get("manufacturer_mappings", []): 

970 ManufacturerMapping.objects.update_or_create( 

971 profile=profile, 

972 source_make=m["source_make"], 

973 defaults={"netbox_manufacturer_slug": m["netbox_manufacturer_slug"]}, 

974 ) 

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

976 

977 from .models import ColumnTransformRule 

978 

979 for r in data.get("column_transform_rules", []): 

980 ColumnTransformRule.objects.update_or_create( 

981 profile=profile, 

982 source_column=r["source_column"], 

983 defaults={ 

984 "pattern": r["pattern"], 

985 "group_1_target": r.get("group_1_target", ""), 

986 "group_2_target": r.get("group_2_target", ""), 

987 }, 

988 ) 

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

990 

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

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

993 return redirect(profile.get_absolute_url()) 

994 

995 

996# --------------------------------------------------------------------------- 

997 

998 

999class CheckDeviceNameView(LoginRequiredMixin, View): 

1000 """AJAX endpoint: check if a device with the given name exists in NetBox. 

1001 

1002 Returns JSON: {"exists": bool, "url": str|null, "id": int|null}. 

1003 """ 

1004 

1005 def get(self, request): 

1006 """Return JSON indicating whether a device with the given name exists.""" 

1007 from django.http import JsonResponse 

1008 from dcim.models import Device 

1009 

1010 name = request.GET.get("name", "").strip() 

1011 if not name: 

1012 return JsonResponse({"exists": False, "url": None, "id": None}) 

1013 

1014 try: 

1015 device = Device.objects.get(name=name) 

1016 return JsonResponse( 

1017 { 

1018 "exists": True, 

1019 "url": request.build_absolute_uri(device.get_absolute_url()), 

1020 "id": device.pk, 

1021 } 

1022 ) 

1023 except Device.DoesNotExist: 

1024 return JsonResponse({"exists": False, "url": None, "id": None}) 

1025 except Device.MultipleObjectsReturned: 

1026 devices = Device.objects.filter(name=name) 

1027 first = devices.first() 

1028 return JsonResponse( 

1029 { 

1030 "exists": True, 

1031 "url": request.build_absolute_uri(first.get_absolute_url()), 

1032 "id": first.pk, 

1033 "count": devices.count(), 

1034 } 

1035 ) 

1036 

1037 

1038# --------------------------------------------------------------------------- 

1039# Source Resolutions list view (per profile) 

1040# --------------------------------------------------------------------------- 

1041 

1042 

1043class SourceResolutionListView(LoginRequiredMixin, View): 

1044 """List all saved name-split resolutions for a profile.""" 

1045 

1046 def get(self, request, profile_pk): 

1047 """Render the list of saved source resolutions for the given profile.""" 

1048 from .models import SourceResolution 

1049 

1050 profile = get_object_or_404(ImportProfile, pk=profile_pk) 

1051 resolutions = SourceResolution.objects.filter(profile=profile).order_by("source_id") 

1052 return render( 

1053 request, 

1054 "netbox_data_import/source_resolution_list.html", 

1055 { 

1056 "profile": profile, 

1057 "resolutions": resolutions, 

1058 }, 

1059 ) 

1060 

1061 

1062class SourceResolutionDeleteView(LoginRequiredMixin, View): 

1063 """Delete a saved source resolution.""" 

1064 

1065 def get(self, request, pk): 

1066 """Render the delete confirmation page for a source resolution.""" 

1067 from .models import SourceResolution 

1068 

1069 obj = get_object_or_404(SourceResolution, pk=pk) 

1070 return render( 

1071 request, 

1072 "netbox_data_import/confirm_delete.html", 

1073 { 

1074 "object": obj, 

1075 "return_url": reverse("plugins:netbox_data_import:source_resolution_list", args=[obj.profile_id]), 

1076 }, 

1077 ) 

1078 

1079 def post(self, request, pk): 

1080 """Delete the source resolution and redirect to the profile's resolution list.""" 

1081 from .models import SourceResolution 

1082 

1083 obj = get_object_or_404(SourceResolution, pk=pk) 

1084 profile_pk = obj.profile_id 

1085 obj.delete() 

1086 messages.success(request, "Resolution deleted.") 

1087 return redirect(reverse("plugins:netbox_data_import:source_resolution_list", args=[profile_pk])) 

1088 

1089 

1090# --------------------------------------------------------------------------- 

1091# Quick-resolve views (inline fixes from preview page) 

1092# --------------------------------------------------------------------------- 

1093 

1094 

1095class QuickCreateManufacturerView(LoginRequiredMixin, View): 

1096 """Immediately create a Manufacturer in NetBox from the preview page. 

1097 

1098 Redirects back to preview so the row changes from 'create' to a device action. 

1099 """ 

1100 

1101 def post(self, request): 

1102 """Create the manufacturer in NetBox and redirect back to preview.""" 

1103 from dcim.models import Manufacturer 

1104 

1105 mfg_name = request.POST.get("mfg_name", "").strip() 

1106 mfg_slug = request.POST.get("mfg_slug", "").strip() 

1107 if not mfg_name or not mfg_slug: 

1108 messages.error(request, "Manufacturer name and slug are required.") 

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

1110 mfg, created = Manufacturer.objects.get_or_create( 

1111 slug=mfg_slug, 

1112 defaults={"name": mfg_name}, 

1113 ) 

1114 if created: 

1115 messages.success(request, f"Manufacturer '{mfg.name}' created.") 

1116 else: 

1117 messages.info(request, f"Manufacturer '{mfg.name}' already existed.") 

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

1119 

1120 

1121class QuickResolveManufacturerView(LoginRequiredMixin, View): 

1122 """Save a ManufacturerMapping (source make → NetBox manufacturer slug) from the preview page. 

1123 

1124 Used when a source has inconsistent naming (e.g. 'Dell EMC' → 'dell'). 

1125 Redirects back to preview which re-runs with the mapping applied. 

1126 """ 

1127 

1128 def post(self, request): 

1129 """Save the manufacturer mapping and redirect back to preview.""" 

1130 profile_id = request.POST.get("profile_id") 

1131 profile = get_object_or_404(ImportProfile, pk=profile_id) 

1132 source_make = " ".join(request.POST.get("source_make", "").split()) 

1133 netbox_mfg_slug = request.POST.get("netbox_mfg_slug", "").strip() 

1134 if not source_make or not netbox_mfg_slug: 

1135 messages.error(request, "Source make and NetBox manufacturer slug are required.") 

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

1137 _, created = ManufacturerMapping.objects.update_or_create( 

1138 profile=profile, 

1139 source_make=source_make, 

1140 defaults={"netbox_manufacturer_slug": netbox_mfg_slug}, 

1141 ) 

1142 verb = "Created" if created else "Updated" 

1143 messages.success(request, f"{verb} manufacturer mapping: '{source_make}' → {netbox_mfg_slug}") 

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

1145 

1146 

1147class QuickResolveDeviceTypeView(LoginRequiredMixin, View): 

1148 """Save a DeviceTypeMapping (source make/model → NetBox slugs) from the preview page. 

1149 

1150 Optionally also creates the manufacturer and/or device type in NetBox right now. 

1151 Redirects back to preview which re-runs and shows the resolved rows. 

1152 """ 

1153 

1154 def post(self, request): 

1155 """Save the device type mapping (and optionally create objects) then redirect.""" 

1156 from dcim.models import Manufacturer, DeviceType 

1157 from django.utils.text import slugify 

1158 

1159 profile_id = request.POST.get("profile_id") 

1160 profile = get_object_or_404(ImportProfile, pk=profile_id) 

1161 source_make = " ".join(request.POST.get("source_make", "").split()) 

1162 source_model = " ".join(request.POST.get("source_model", "").split()) 

1163 netbox_mfg_slug = request.POST.get("netbox_mfg_slug", "").strip() 

1164 netbox_dt_slug = request.POST.get("netbox_dt_slug", "").strip() 

1165 action = request.POST.get("action", "map") # "map" or "create_now" 

1166 

1167 if not source_make or not source_model: 

1168 messages.error(request, "Source make and model are required.") 

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

1170 

1171 if not netbox_mfg_slug: 

1172 netbox_mfg_slug = slugify(source_make) 

1173 if not netbox_dt_slug: 

1174 netbox_dt_slug = slugify(source_model) 

1175 

1176 # Save/update DeviceTypeMapping 

1177 dtm, created = DeviceTypeMapping.objects.update_or_create( 

1178 profile=profile, 

1179 source_make=source_make, 

1180 source_model=source_model, 

1181 defaults={ 

1182 "netbox_manufacturer_slug": netbox_mfg_slug, 

1183 "netbox_device_type_slug": netbox_dt_slug, 

1184 }, 

1185 ) 

1186 

1187 if action == "create_now": 

1188 mfg, _ = Manufacturer.objects.get_or_create( 

1189 slug=netbox_mfg_slug, 

1190 defaults={"name": source_make}, 

1191 ) 

1192 dt_name = request.POST.get("netbox_dt_name", source_model).strip() or source_model 

1193 try: 

1194 u_height = max(1, int(request.POST.get("u_height", "1"))) 

1195 except ValueError: 

1196 u_height = 1 

1197 DeviceType.objects.get_or_create( 

1198 manufacturer=mfg, 

1199 slug=netbox_dt_slug, 

1200 defaults={"model": dt_name, "u_height": u_height}, 

1201 ) 

1202 messages.success( 

1203 request, f"Mapping saved and device type '{source_make} / {source_model}' created in NetBox." 

1204 ) 

1205 else: 

1206 verb = "created" if created else "updated" 

1207 messages.success( 

1208 request, 

1209 f"DeviceType mapping {verb}: '{source_make} / {source_model}' → {netbox_mfg_slug}/{netbox_dt_slug}", 

1210 ) 

1211 

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

1213 

1214 

1215class QuickAddClassRoleMappingView(LoginRequiredMixin, View): 

1216 """Quickly add a ClassRoleMapping (ignore / role) directly from an error row in preview. 

1217 

1218 Redirects back to preview; error rows for that class disappear on re-run. 

1219 """ 

1220 

1221 def post(self, request): 

1222 """Save the class→role mapping and redirect back to preview.""" 

1223 profile_id = request.POST.get("profile_id") 

1224 profile = get_object_or_404(ImportProfile, pk=profile_id) 

1225 source_class = request.POST.get("source_class", "").strip() 

1226 mapping_action = request.POST.get("mapping_action", "ignore") # "ignore" or "role" 

1227 role_slug = request.POST.get("role_slug", "").strip() 

1228 creates_rack = request.POST.get("creates_rack") == "1" 

1229 

1230 if not source_class: 

1231 messages.error(request, "Source class is required.") 

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

1233 

1234 _, created = ClassRoleMapping.objects.update_or_create( 

1235 profile=profile, 

1236 source_class=source_class, 

1237 defaults={ 

1238 "ignore": mapping_action == "ignore", 

1239 "creates_rack": creates_rack, 

1240 "role_slug": role_slug if mapping_action == "role" else "", 

1241 }, 

1242 ) 

1243 verb = "Created" if created else "Updated" 

1244 if mapping_action == "ignore": 

1245 action_label = "ignore" 

1246 elif mapping_action == "rack": 

1247 action_label = "creates rack" 

1248 else: 

1249 action_label = f"role '{role_slug}'" 

1250 messages.success(request, f"{verb} mapping: class '{source_class}' → {action_label}") 

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

1252 

1253 

1254class MatchExistingDeviceView(LoginRequiredMixin, View): 

1255 """Link a source row to an existing NetBox device (by device ID). 

1256 

1257 Saves a DeviceExistingMatch; on next preview re-run the row shows action='update'. 

1258 """ 

1259 

1260 def post(self, request): 

1261 """Save the device match and redirect back to preview.""" 

1262 from dcim.models import Device 

1263 

1264 profile_id = request.POST.get("profile_id") 

1265 profile = get_object_or_404(ImportProfile, pk=profile_id) 

1266 source_id = request.POST.get("source_id", "").strip() 

1267 netbox_device_id = request.POST.get("netbox_device_id", "").strip() 

1268 

1269 if not source_id or not netbox_device_id: 

1270 messages.error(request, "source_id and netbox_device_id are required.") 

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

1272 

1273 try: 

1274 device = Device.objects.get(pk=int(netbox_device_id)) 

1275 except (Device.DoesNotExist, ValueError): 

1276 messages.error(request, f"Device #{netbox_device_id} not found.") 

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

1278 

1279 DeviceExistingMatch.objects.update_or_create( 

1280 profile=profile, 

1281 source_id=source_id, 

1282 defaults={ 

1283 "netbox_device_id": device.pk, 

1284 "device_name": device.name, 

1285 "source_asset_tag": request.POST.get("source_asset_tag", "").strip(), 

1286 }, 

1287 ) 

1288 messages.success(request, f"Source '{source_id}' linked to existing device '{device.name}'.") 

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

1290 

1291 

1292class SearchNetBoxObjectsView(LoginRequiredMixin, View): 

1293 """AJAX search endpoint for NetBox objects used in preview quick-fix modals. 

1294 

1295 GET params: type (manufacturer|device_type|device|role), q (search string). 

1296 Returns JSON list of {id, name, slug, url} dicts. 

1297 """ 

1298 

1299 def get(self, request): 

1300 """Return a JSON list of matching NetBox objects for the given type and query.""" 

1301 from django.http import JsonResponse 

1302 from dcim.models import Manufacturer, DeviceType, Device, DeviceRole 

1303 

1304 obj_type = request.GET.get("type", "device") 

1305 q = request.GET.get("q", "").strip() 

1306 limit = 20 

1307 

1308 if not q: 

1309 return JsonResponse({"results": []}) 

1310 

1311 results = [] 

1312 if obj_type == "manufacturer": 

1313 for mfg in Manufacturer.objects.filter(name__icontains=q)[:limit]: 

1314 results.append( 

1315 { 

1316 "id": mfg.pk, 

1317 "name": mfg.name, 

1318 "slug": mfg.slug, 

1319 "url": request.build_absolute_uri(mfg.get_absolute_url()), 

1320 } 

1321 ) 

1322 elif obj_type == "device_type": 

1323 mfg_filter = request.GET.get("mfg_slug", "") 

1324 qs = DeviceType.objects.select_related("manufacturer") 

1325 if mfg_filter: 

1326 qs = qs.filter(manufacturer__slug=mfg_filter) 

1327 for dt in qs.filter(model__icontains=q)[:limit]: 

1328 results.append( 

1329 { 

1330 "id": dt.pk, 

1331 "name": f"{dt.manufacturer.name} / {dt.model}", 

1332 "slug": dt.slug, 

1333 "mfg_slug": dt.manufacturer.slug, 

1334 "url": request.build_absolute_uri(dt.get_absolute_url()), 

1335 } 

1336 ) 

1337 elif obj_type == "device": 

1338 for dev in Device.objects.filter(name__icontains=q).select_related("site")[:limit]: 

1339 results.append( 

1340 { 

1341 "id": dev.pk, 

1342 "name": dev.name, 

1343 "site": dev.site.name if dev.site else "", 

1344 "url": request.build_absolute_uri(dev.get_absolute_url()), 

1345 } 

1346 ) 

1347 elif obj_type == "role": 

1348 for role in DeviceRole.objects.filter(name__icontains=q)[:limit]: 

1349 results.append( 

1350 { 

1351 "id": role.pk, 

1352 "name": role.name, 

1353 "slug": role.slug, 

1354 "url": request.build_absolute_uri(role.get_absolute_url()), 

1355 } 

1356 ) 

1357 

1358 return JsonResponse({"results": results}) 

1359 

1360 

1361def _auto_match_single_device(device_model, device_name, serial, asset_tag): 

1362 """Try to match a single device row to an existing NetBox device. 

1363 

1364 Returns (device_or_None, is_ambiguous). Matching priority: serial → 

1365 asset_tag → exact name. Multiple matches on any field → ambiguous. 

1366 """ 

1367 device = None 

1368 if serial: 

1369 results = list(device_model.objects.filter(serial=serial)[:2]) 

1370 if len(results) == 1: 

1371 device = results[0] 

1372 elif len(results) > 1: 

1373 return None, True 

1374 

1375 if device is None and asset_tag: 

1376 results = list(device_model.objects.filter(asset_tag=asset_tag)[:2]) 

1377 if len(results) == 1: 

1378 device = results[0] 

1379 elif len(results) > 1: 

1380 return None, True 

1381 

1382 if device is None and device_name: 

1383 results = list(device_model.objects.filter(name=device_name)[:2]) 

1384 if len(results) == 1: 

1385 device = results[0] 

1386 elif len(results) > 1: 

1387 return None, True 

1388 

1389 return device, False 

1390 

1391 

1392class AutoMatchDevicesView(LoginRequiredMixin, View): 

1393 """Scan all device rows in the session and auto-match to existing NetBox devices. 

1394 

1395 Priority: serial > asset_tag > exact name match. 

1396 Name substring matches are recorded as probable_matches only (not auto-linked). 

1397 """ 

1398 

1399 def post(self, request): 

1400 """Run auto-matching and redirect back to preview with a summary message.""" 

1401 from dcim.models import Device 

1402 

1403 profile_id = request.POST.get("profile_id") 

1404 profile = get_object_or_404(ImportProfile, pk=profile_id) 

1405 rows = request.session.get("import_rows", []) 

1406 

1407 matched = 0 

1408 ambiguous = 0 

1409 already = 0 

1410 probable = 0 

1411 

1412 for row in rows: 

1413 source_id = str(row.get("source_id", "")).strip() 

1414 device_name = str(row.get("device_name", "")).strip() 

1415 serial = str(row.get("serial", "")).strip() 

1416 asset_tag = str(row.get("asset_tag", "")).strip() 

1417 if not source_id: 

1418 continue 

1419 if profile.device_matches.filter(source_id=source_id).exists(): 

1420 already += 1 

1421 continue 

1422 

1423 device, is_ambiguous = _auto_match_single_device(Device, device_name, serial, asset_tag) 

1424 if is_ambiguous: 

1425 ambiguous += 1 

1426 continue 

1427 

1428 if device is not None: 

1429 DeviceExistingMatch.objects.create( 

1430 profile=profile, 

1431 source_id=source_id, 

1432 netbox_device_id=device.pk, 

1433 device_name=device.name, 

1434 source_asset_tag=asset_tag, 

1435 ) 

1436 matched += 1 

1437 elif device_name: 

1438 # Substring name match → probable only (no auto-link) 

1439 short_name = device_name.split(" - ")[-1].strip() if " - " in device_name else device_name 

1440 if Device.objects.filter(name__icontains=short_name).exists(): 

1441 probable += 1 

1442 

1443 msg_parts = [] 

1444 if matched: 

1445 msg_parts.append(f"{matched} auto-matched (serial/asset_tag/name)") 

1446 if probable: 

1447 msg_parts.append(f"{probable} probable name match(es) — use Link button to confirm") 

1448 if ambiguous: 

1449 msg_parts.append(f"{ambiguous} ambiguous (multiple devices)") 

1450 if already: 

1451 msg_parts.append(f"{already} already matched") 

1452 messages.success(request, f"Auto-match: {', '.join(msg_parts) or 'nothing found'}.") 

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

1454 

1455 

1456# --------------------------------------------------------------------------- 

1457# Session helpers 

1458# --------------------------------------------------------------------------- 

1459 

1460 

1461def _serialize_rows(rows: list) -> list: 

1462 """Convert parsed rows to JSON-serializable format (handle Excel datetime values).""" 

1463 import datetime 

1464 

1465 safe_rows = [] 

1466 for row in rows: 

1467 safe_row = {} 

1468 for k, v in row.items(): 

1469 if isinstance(v, (datetime.datetime, datetime.date)): 

1470 safe_row[k] = v.isoformat() 

1471 else: 

1472 safe_row[k] = v 

1473 safe_rows.append(safe_row) 

1474 return safe_rows