netbox-data-import 2.3.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/PKG-INFO +1 -1
  2. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/__init__.py +1 -1
  3. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/adapters.py +53 -2
  4. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/cable_target.py +39 -43
  5. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/catalog.py +1 -0
  6. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/forms.py +24 -3
  7. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/import_engine.py +3 -1
  8. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/inference_adapter.py +147 -48
  9. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/inference_backend.py +18 -0
  10. netbox_data_import-2.4.0/netbox_data_import/inference_connection_test.py +146 -0
  11. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/inference_credentials.py +71 -20
  12. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/inference_settings.py +3 -4
  13. netbox_data_import-2.4.0/netbox_data_import/inference_transport.py +349 -0
  14. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/ip_assignment.py +72 -0
  15. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/jobs.py +0 -23
  16. netbox_data_import-2.4.0/netbox_data_import/migrations/0034_tracedeviceresolution.py +41 -0
  17. netbox_data_import-2.4.0/netbox_data_import/migrations/0035_retire_superseded_proposals.py +28 -0
  18. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/models.py +69 -11
  19. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/netbox_reader.py +6 -0
  20. netbox_data_import-2.4.0/netbox_data_import/object_permissions.py +677 -0
  21. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/profile_yaml.py +168 -60
  22. netbox_data_import-2.4.0/netbox_data_import/proposal_contract.py +30 -0
  23. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/proposal_jobs.py +12 -17
  24. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/proposal_response.py +36 -16
  25. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/review_workspace.py +74 -12
  26. netbox_data_import-2.4.0/netbox_data_import/source_trace.py +124 -0
  27. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/sync_row_modal.js +2 -1
  28. netbox_data_import-2.4.0/netbox_data_import/static/netbox_data_import/js/trace_device_picker.js +130 -0
  29. netbox_data_import-2.4.0/netbox_data_import/static/netbox_data_import/js/trace_picker_search.js +54 -0
  30. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/trace_termination_picker.js +16 -37
  31. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/target_modules.py +244 -78
  32. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/import_preview.html +43 -3
  33. netbox_data_import-2.4.0/netbox_data_import/templates/netbox_data_import/importprofile_edit.html +45 -0
  34. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/inferencebackend.html +20 -0
  35. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/trace_workspace.html +118 -2
  36. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/termination_proposal.py +14 -6
  37. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/helpers.py +37 -2
  38. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/inference_http.py +7 -2
  39. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/mock_discipline.py +2 -1
  40. netbox_data_import-2.4.0/netbox_data_import/tests/redaction.py +54 -0
  41. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_atomic_exits.py +4 -0
  42. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_cable_module.py +68 -3
  43. netbox_data_import-2.4.0/netbox_data_import/tests/test_configuration_docs.py +55 -0
  44. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_configuration_surfaces.py +317 -0
  45. netbox_data_import-2.4.0/netbox_data_import/tests/test_conflict_row_actions.py +241 -0
  46. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_contact_sync.py +2 -43
  47. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_cutover_edge_contracts.py +10 -60
  48. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_device_module.py +190 -15
  49. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_digest_indexed_models.py +18 -1
  50. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_import_cutover.py +95 -23
  51. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_import_engine.py +109 -2
  52. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_import_execution.py +105 -8
  53. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_inference_adapter.py +431 -3
  54. netbox_data_import-2.4.0/netbox_data_import/tests/test_inference_connection_test.py +666 -0
  55. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_inference_credentials.py +80 -50
  56. netbox_data_import-2.4.0/netbox_data_import/tests/test_inference_secret_containment.py +338 -0
  57. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_inference_settings.py +6 -3
  58. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_migrations.py +1 -0
  59. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_mock_discipline.py +11 -0
  60. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_module_boundaries.py +55 -0
  61. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_object_permission_writer.py +609 -3
  62. netbox_data_import-2.4.0/netbox_data_import/tests/test_policy_write_refusals.py +155 -0
  63. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_pristine_preview_rows.py +2 -18
  64. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_proposal_jobs.py +82 -4
  65. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_proposal_response.py +36 -16
  66. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_proposal_tasks.py +12 -2
  67. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_proposal_workspace.py +19 -2
  68. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_quick_action_input_bounds.py +18 -0
  69. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_rack_module.py +53 -1
  70. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_resolution_proposal_api.py +4 -3
  71. netbox_data_import-2.4.0/netbox_data_import/tests/test_retire_superseded_proposals_migration.py +173 -0
  72. netbox_data_import-2.4.0/netbox_data_import/tests/test_source_trace_contract.py +66 -0
  73. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_standard_views.py +23 -1
  74. netbox_data_import-2.4.0/netbox_data_import/tests/test_target_module_edge_contracts.py +1069 -0
  75. netbox_data_import-2.4.0/netbox_data_import/tests/test_trace_device_resolution.py +843 -0
  76. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_trace_workbook.py +0 -29
  77. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_trace_workspace.py +161 -14
  78. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_views.py +59 -1
  79. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_views_coverage2.py +11 -8
  80. netbox_data_import-2.4.0/netbox_data_import/trace_device_resolution.py +482 -0
  81. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/trace_workbook.py +19 -104
  82. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/urls.py +11 -0
  83. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/views.py +458 -93
  84. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import.egg-info/PKG-INFO +1 -1
  85. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import.egg-info/SOURCES.txt +15 -1
  86. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/pyproject.toml +6 -2
  87. netbox_data_import-2.3.0/netbox_data_import/inference_connection_test.py +0 -73
  88. netbox_data_import-2.3.0/netbox_data_import/inference_transport.py +0 -130
  89. netbox_data_import-2.3.0/netbox_data_import/object_permissions.py +0 -340
  90. netbox_data_import-2.3.0/netbox_data_import/tests/test_inference_connection_test.py +0 -452
  91. netbox_data_import-2.3.0/netbox_data_import/tests/test_inference_secret_containment.py +0 -216
  92. netbox_data_import-2.3.0/netbox_data_import/tests/test_legacy_profile_yaml.py +0 -171
  93. netbox_data_import-2.3.0/netbox_data_import/tests/test_target_module_edge_contracts.py +0 -449
  94. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/LICENSE +0 -0
  95. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/README.md +0 -0
  96. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/adapter_config.py +0 -0
  97. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/adapter_forms.py +0 -0
  98. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/api/__init__.py +0 -0
  99. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/api/serializers.py +0 -0
  100. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/api/urls.py +0 -0
  101. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/api/views.py +0 -0
  102. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/contact_resolution.py +0 -0
  103. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/device_field_review.py +0 -0
  104. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/device_identity.py +0 -0
  105. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/field_keys.py +0 -0
  106. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/filters.py +0 -0
  107. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/flat_workbook.py +0 -0
  108. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/graphql/__init__.py +0 -0
  109. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/graphql/filters.py +0 -0
  110. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/graphql/schema.py +0 -0
  111. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/graphql/types.py +0 -0
  112. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/inference_trust.py +0 -0
  113. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0001_initial.py +0 -0
  114. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0002_importjob.py +0 -0
  115. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0003_remove_classrolemapping_unique_class_role_mapping_per_profile_and_more.py +0 -0
  116. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0004_features.py +0 -0
  117. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0005_deviceexistingmatch.py +0 -0
  118. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0006_deviceexistingmatch_asset_tag.py +0 -0
  119. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0007_manufacturermapping.py +0 -0
  120. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0008_unique_constraints.py +0 -0
  121. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0009_add_rack_type_to_classrolemapping.py +0 -0
  122. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0010_alter_manufacturermapping_id.py +0 -0
  123. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0011_importprofile_capture_extra_data.py +0 -0
  124. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0012_classrolemapping_rack_type_related_name.py +0 -0
  125. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0013_remove_columnmapping_unique_target.py +0 -0
  126. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0014_alter_columnmapping_target_field_and_more.py +0 -0
  127. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0015_cleanup_duplicate_device_matches.py +0 -0
  128. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0016_deviceexistingmatch_ndi_devicematch_profile_device.py +0 -0
  129. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0017_native_contact_sync.py +0 -0
  130. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0018_ignoredfielddifference.py +0 -0
  131. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0019_deviceimportsource.py +0 -0
  132. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0020_migrate_import_source_custom_field.py +0 -0
  133. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0021_importprofile_adapter_config.py +0 -0
  134. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0022_migrate_profile_adapter_config.py +0 -0
  135. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0023_drop_moved_profile_columns.py +0 -0
  136. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0024_rename_importjob_importexecution.py +0 -0
  137. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0025_remove_importexecution_dry_run_and_more.py +0 -0
  138. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0026_alter_sourcedocument_profile.py +0 -0
  139. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0027_cableclassmapping_terminationresolution.py +0 -0
  140. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0028_cableimportsource.py +0 -0
  141. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0029_alter_cableimportsource_from_text_and_more.py +0 -0
  142. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0030_remove_device_type_creation_config.py +0 -0
  143. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0031_inferencebackend.py +0 -0
  144. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0032_inference_timeout_floor.py +0 -0
  145. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/0033_resolutionproposal.py +0 -0
  146. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/migrations/__init__.py +0 -0
  147. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/navigation.py +0 -0
  148. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/plan.py +0 -0
  149. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/preview_row_actions.py +0 -0
  150. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/proposal_decisions.py +0 -0
  151. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/proposal_presentation.py +0 -0
  152. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/proposal_tasks.py +0 -0
  153. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/resolution_proposals.py +0 -0
  154. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/signals.py +0 -0
  155. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/source_resolution.py +0 -0
  156. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/conflict_modal.js +0 -0
  157. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/contact_candidate_modal.js +0 -0
  158. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/preview_row_actions.js +0 -0
  159. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/preview_row_controls.js +0 -0
  160. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/split_name_modal.js +0 -0
  161. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/static/netbox_data_import/js/trace_proposals.js +0 -0
  162. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tables.py +0 -0
  163. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/target_runtime.py +0 -0
  164. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/template_content.py +0 -0
  165. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/_import_progress.html +0 -0
  166. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/_proposal_action.html +0 -0
  167. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/_proposal_card.html +0 -0
  168. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/analysis.html +0 -0
  169. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/bulk_yaml_import.html +0 -0
  170. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/cableclassmapping_edit.html +0 -0
  171. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/classrolemapping_edit.html +0 -0
  172. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/columnmapping_edit.html +0 -0
  173. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/columntransformrule_edit.html +0 -0
  174. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/device_import_data.html +0 -0
  175. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/devicetypemapping_edit.html +0 -0
  176. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/import_profile_yaml.html +0 -0
  177. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/import_progress.html +0 -0
  178. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/import_results.html +0 -0
  179. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/import_setup.html +0 -0
  180. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/importexecution_list.html +0 -0
  181. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/importprofile.html +0 -0
  182. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/importprofile_list.html +0 -0
  183. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/templates/netbox_data_import/source_resolution_list.html +0 -0
  184. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/__init__.py +0 -0
  185. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/conftest.py +0 -0
  186. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/fixtures/combined_name.xlsx +0 -0
  187. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/fixtures/sample_cans.xlsx +0 -0
  188. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/fixtures/trace_copper.xlsx +0 -0
  189. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/fixtures/trace_fiber.xlsx +0 -0
  190. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/isolated_settings.py +0 -0
  191. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/mixins.py +0 -0
  192. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/netbox_configuration.py +0 -0
  193. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/parallel.py +0 -0
  194. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_adapter_config.py +0 -0
  195. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_adapter_config_migration.py +0 -0
  196. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_api.py +0 -0
  197. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_ci_configuration.py +0 -0
  198. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_comment_style.py +0 -0
  199. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_contact_resolution_ajax.py +0 -0
  200. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_contact_role_suggestion.py +0 -0
  201. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_devcontainer_e2e.py +0 -0
  202. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_execution_audit.py +0 -0
  203. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_graphql_filters.py +0 -0
  204. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_import_plan.py +0 -0
  205. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_import_source_migration.py +0 -0
  206. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_inference_backend.py +0 -0
  207. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_inference_trust.py +0 -0
  208. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_installation_docs.py +0 -0
  209. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_ip_target.py +0 -0
  210. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_misc_coverage.py +0 -0
  211. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_model_base_conventions.py +0 -0
  212. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_models.py +0 -0
  213. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_navigation.py +0 -0
  214. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_netbox_reader.py +0 -0
  215. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_parallel_test_setup.py +0 -0
  216. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_placement_reference_locks.py +0 -0
  217. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_preview_assets.py +0 -0
  218. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_preview_state_ownership.py +0 -0
  219. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_profile_policy_crud_scoping.py +0 -0
  220. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_proposal_decisions.py +0 -0
  221. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_quick_action_object_permissions.py +0 -0
  222. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_quick_action_profile_id.py +0 -0
  223. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_release_metadata.py +0 -0
  224. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_remove_device_type_creation_config_migration.py +0 -0
  225. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_resolution_proposals.py +0 -0
  226. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_review_actions.py +0 -0
  227. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_review_resolutions.py +0 -0
  228. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_review_workspace.py +0 -0
  229. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_serial_display.py +0 -0
  230. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_source_adapter.py +0 -0
  231. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_target_field_catalog.py +0 -0
  232. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_trace_decisions.py +0 -0
  233. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/tests/test_views_extra.py +0 -0
  234. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/trace_schema.py +0 -0
  235. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/transform_regex.py +0 -0
  236. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/utils.py +0 -0
  237. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import/values.py +0 -0
  238. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import.egg-info/dependency_links.txt +0 -0
  239. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import.egg-info/requires.txt +0 -0
  240. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/netbox_data_import.egg-info/top_level.txt +0 -0
  241. {netbox_data_import-2.3.0 → netbox_data_import-2.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-data-import
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: NetBox plugin for importing data from external DCIM systems
5
5
  Author: Marcin Zieba
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ from typing import Any
4
4
 
5
5
  from netbox.plugins import PluginConfig
6
6
 
7
- __version__ = "2.3.0"
7
+ __version__ = "2.4.0"
8
8
 
9
9
 
10
10
  class NetBoxDataImportConfig(PluginConfig):
@@ -13,13 +13,14 @@ the NetBox boundary.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- from dataclasses import dataclass, field
16
+ from dataclasses import dataclass, field, replace
17
17
  from typing import TYPE_CHECKING
18
18
 
19
19
  from .catalog import OutputKind, has_implemented_module
20
+ from .values import identity_text
20
21
 
21
22
  if TYPE_CHECKING:
22
- from .trace_workbook import SourceTrace
23
+ from .source_trace import SourceTrace
23
24
 
24
25
 
25
26
  class UnknownSourceAdapter(Exception):
@@ -39,6 +40,16 @@ class SourceDiagnostic:
39
40
  row_number: int | None = None
40
41
 
41
42
 
43
+ def _foreign_row_number(row) -> int | None:
44
+ """Return the source row a foreign row states, so its diagnostic can name a location."""
45
+ provenance = getattr(row, "provenance", ())
46
+ if provenance:
47
+ return provenance[0].row_start
48
+ if isinstance(row, dict):
49
+ return row.get("_row_number")
50
+ return None
51
+
52
+
42
53
  @dataclass(frozen=True)
43
54
  class SourceBatch:
44
55
  """The typed source items and source diagnostics from one file (section 1)."""
@@ -48,6 +59,46 @@ class SourceBatch:
48
59
  diagnostics: tuple[SourceDiagnostic, ...] = ()
49
60
  unused_columns: dict[str, dict] = field(default_factory=dict)
50
61
 
62
+ def __post_init__(self):
63
+ """Exclude a row the output kinds cannot carry, and mark a Trace with empty Device references."""
64
+ from .source_trace import SourceTrace
65
+
66
+ trace_batch = OutputKind.SOURCE_TRACE in self.output_kinds
67
+ expected = SourceTrace if trace_batch else dict
68
+ carries = "Source Traces" if trace_batch else "source rows"
69
+ rows = []
70
+ diagnostics = list(self.diagnostics)
71
+ for row in self.rows:
72
+ if not isinstance(row, expected):
73
+ # Excluded here, because every reader below reads the row the output kinds declare.
74
+ diagnostics.append(
75
+ SourceDiagnostic(
76
+ code="source.row_type_unexpected",
77
+ message=f"This batch carries {carries} only; got {type(row).__name__}.",
78
+ row_number=_foreign_row_number(row),
79
+ )
80
+ )
81
+ continue
82
+ if not trace_batch:
83
+ rows.append(row)
84
+ continue
85
+ summary = row.endpoint_summary
86
+ references = [summary.from_termination, summary.to_termination, *row.corroboration]
87
+ for segment in row.segments:
88
+ references.extend((segment.left, segment.right))
89
+ if all(identity_text(reference.device) for reference in references):
90
+ rows.append(row)
91
+ continue
92
+ diagnostic = SourceDiagnostic(
93
+ code="trace.device_required",
94
+ message="A Source Trace Termination Reference must name a Device.",
95
+ row_number=row.provenance[0].row_start,
96
+ )
97
+ diagnostics.append(diagnostic)
98
+ rows.append(replace(row, errors=(*row.errors, diagnostic)))
99
+ object.__setattr__(self, "rows", tuple(rows))
100
+ object.__setattr__(self, "diagnostics", tuple(diagnostics))
101
+
51
102
 
52
103
  class SourceAdapter:
53
104
  """Base class for a Source Adapter declaration."""
@@ -36,6 +36,13 @@ from .field_keys import (
36
36
  from .object_permissions import enforce_saved_object_permission
37
37
  from .plan import Diagnostic, Disposition, PlannedChange, Severity, SynchronizationUnit
38
38
  from .target_runtime import DeletedObject, PreconditionFailed
39
+ from .trace_device_resolution import (
40
+ STALE as DEVICE_STALE,
41
+ collect_trace_device_evidence,
42
+ resolve_trace_devices,
43
+ resolved_trace_device,
44
+ source_device_key,
45
+ )
39
46
  from .values import identity_text, source_text
40
47
 
41
48
  CABLE_STATUS = "connected"
@@ -207,7 +214,9 @@ class _TraceAnalysis:
207
214
  segments: list = field(default_factory=list)
208
215
  proven: dict = field(default_factory=dict)
209
216
  policies: dict = field(default_factory=dict)
217
+ devices: dict = field(default_factory=dict)
210
218
  terminations: dict = field(default_factory=dict)
219
+ resolution_started: bool = False
211
220
  topology_read: bool = False
212
221
  logical_cable: Any = None
213
222
  retains_logical_cable: bool = False
@@ -303,38 +312,19 @@ def _source_record(trace, segment_index: int) -> dict:
303
312
  }
304
313
 
305
314
 
306
- def _visible_devices(netbox_reader, names, *, _lock_rows=False) -> dict[str, list]:
307
- """Return the visible Devices at the import target, grouped by comparison name."""
308
- from django.db.models import Q
309
-
310
- wanted = {identity_text(name) for name in names if identity_text(name)}
311
- if not wanted:
312
- return {}
313
- devices = netbox_reader.devices()
314
- if netbox_reader.site is not None:
315
- devices = devices.filter(site=netbox_reader.site)
316
- if _lock_rows:
317
- devices = devices.order_by("pk").select_for_update(of=("self",))
318
- lookup = Q()
319
- for name in sorted(wanted):
320
- lookup |= Q(name__iexact=name)
321
- grouped: dict[str, list] = {}
322
- for device in devices.filter(lookup):
323
- comparison = identity_text(device.name)
324
- if comparison in wanted:
325
- grouped.setdefault(comparison, []).append(device)
326
- return grouped
327
-
328
-
329
- def resolved_device_for(field_key: str, netbox_reader, *, _lock_rows=False):
315
+ def resolved_device_for(field_key: str, netbox_reader, *, profile, _lock_rows=False):
330
316
  """Return the one visible Device a termination field key names, or None when it names no single one.
331
317
 
332
318
  A proposal freezes this reference and revalidates it, so the retrieval and the freshness check
333
319
  have to agree on what "the resolved Device" means.
334
320
  """
335
321
  device_name = parse_termination_field_key(field_key)["device"]
336
- devices = _visible_devices(netbox_reader, [device_name], _lock_rows=_lock_rows).get(identity_text(device_name), [])
337
- return devices[0] if len(devices) == 1 else None
322
+ return resolved_trace_device(
323
+ profile=profile,
324
+ reader=netbox_reader,
325
+ source_label=device_name,
326
+ lock_rows=_lock_rows,
327
+ )
338
328
 
339
329
 
340
330
  _RESOLVED_DEVICE_UNSET = object()
@@ -361,7 +351,7 @@ def eligible_terminations(
361
351
  kind = parsed["kind"]
362
352
  accessor = _READER_ACCESSOR_BY_KIND[kind]
363
353
  device = (
364
- resolved_device_for(field_key, netbox_reader, _lock_rows=_lock_rows)
354
+ resolved_device_for(field_key, netbox_reader, profile=profile, _lock_rows=_lock_rows)
365
355
  if _resolved_device is _RESOLVED_DEVICE_UNSET
366
356
  else _resolved_device
367
357
  )
@@ -430,6 +420,7 @@ class _CableBatch:
430
420
  """Plan every Source Trace in one batch, so an identical shared segment plans once."""
431
421
 
432
422
  def __init__(self, traces, profile, netbox_reader, *, lock_plan_references: bool = False):
423
+ traces = tuple(traces)
433
424
  self.profile = profile
434
425
  self.reader = netbox_reader
435
426
  self.actor = netbox_reader.actor
@@ -445,7 +436,14 @@ class _CableBatch:
445
436
  self._occupied: dict[tuple[str, int], _ExistingCable] = {}
446
437
  self._mapping_rows: dict[str, Any] | None = None
447
438
  self._stored = self._stored_resolutions()
448
- self._devices = self._load_devices()
439
+ active_traces = (analysis.trace for analysis in self.analyses if not analysis.stopped)
440
+ self._device_evidence = collect_trace_device_evidence(active_traces)
441
+ self._device_resolutions = resolve_trace_devices(
442
+ profile=self.profile,
443
+ reader=self.reader,
444
+ evidence=self._device_evidence,
445
+ lock_rows=self.lock_plan_references,
446
+ )
449
447
  self._resolve_terminations()
450
448
  self._load_mappings()
451
449
  self._build_segments()
@@ -512,16 +510,6 @@ class _CableBatch:
512
510
  ).select_related("selected_object_type")
513
511
  return {row.field_key: row for row in rows}
514
512
 
515
- def _load_devices(self) -> dict[str, list]:
516
- """Return the visible Devices every planned trace names, grouped by comparison name."""
517
- names = [
518
- reference.device
519
- for analysis in self.analyses
520
- if not analysis.stopped
521
- for reference in self._references(analysis.trace)
522
- ]
523
- return _visible_devices(self.reader, names)
524
-
525
513
  def _components_for(self, device_id: int, kind: str) -> dict[str, list]:
526
514
  """Return one Device's terminations of one kind, grouped by comparison name."""
527
515
  cached = self._components.get((device_id, kind))
@@ -537,6 +525,7 @@ class _CableBatch:
537
525
  for analysis in self.analyses:
538
526
  if analysis.stopped:
539
527
  continue
528
+ analysis.resolution_started = True
540
529
  resolved: dict[tuple, _Termination] = {}
541
530
  for reference in self._references(analysis.trace):
542
531
  if reference.identity_key in resolved:
@@ -548,19 +537,24 @@ class _CableBatch:
548
537
 
549
538
  def _resolve_one(self, analysis: _TraceAnalysis, reference) -> _Termination | None:
550
539
  """Return the NetBox object one Termination Reference names, or record the open decision."""
551
- devices = self._devices.get(identity_text(reference.device), [])
552
- if len(devices) != 1:
553
- analysis.block("trace.device_unresolved", {**_reference_display(reference), "matches": len(devices)})
540
+ resolution = self._device_resolutions[source_device_key(reference.device)]
541
+ analysis.devices.setdefault(resolution.evidence.key, resolution.to_question())
542
+ if resolution.device is None:
543
+ code = "trace.device_resolution_stale" if resolution.state == DEVICE_STALE else "trace.device_unresolved"
544
+ analysis.block(
545
+ code,
546
+ {**_reference_display(reference), "matches": resolution.exact_match_count},
547
+ )
554
548
  # With no resolved Device there is nothing to pick from, so the picker cannot help here.
555
549
  self._record_resolution(
556
550
  analysis,
557
551
  reference,
558
552
  UNRESOLVED,
559
553
  None,
560
- reason=f"The source names {len(devices)} matching Devices, so no port list applies.",
554
+ reason="Resolve the source Device before choosing one of its ports.",
561
555
  )
562
556
  return None
563
- device = devices[0]
557
+ device = resolution.device
564
558
  stored = self._stored.get(_field_key(reference))
565
559
  if stored is not None:
566
560
  termination = self._stored_termination(analysis, reference, device, stored)
@@ -1222,9 +1216,11 @@ class _CableBatch:
1222
1216
  },
1223
1217
  "segments": segments,
1224
1218
  "logical_cable": self._logical_cable_display(analysis),
1219
+ "resolution_started": analysis.resolution_started,
1225
1220
  "topology_known": analysis.topology_read,
1226
1221
  # A unit with no changes proposes nothing, so the panel must not offer to delete one.
1227
1222
  "deletes_logical_cable": writes and analysis.deleted_logical_cable is not None,
1223
+ "devices": list(analysis.devices.values()),
1228
1224
  "terminations": list(analysis.terminations.values()),
1229
1225
  }
1230
1226
 
@@ -259,6 +259,7 @@ POLICY_SECTIONS: tuple[PolicySection, ...] = (
259
259
  PolicySection("source_resolutions", "Source Resolutions", _DEVICE_ONLY),
260
260
  PolicySection("device_existing_matches", "Device Existing Matches", _DEVICE_ONLY),
261
261
  PolicySection("ignored_field_differences", "Ignored Field Differences", _DEVICE_ONLY),
262
+ PolicySection("trace_device_resolutions", "Trace Device Resolutions", _TRACE_ONLY),
262
263
  PolicySection("termination_resolutions", "Termination Resolutions", _TRACE_ONLY),
263
264
  PolicySection("cable_class_mappings", "CableClass Mappings", _TRACE_ONLY),
264
265
  )
@@ -3,6 +3,7 @@
3
3
  import copy
4
4
 
5
5
  from django import forms
6
+ from django.utils.html import format_html
6
7
  from dcim.models import Site, Location
7
8
  from tenancy.models import Tenant
8
9
  from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelForm, NetBoxModelImportForm
@@ -25,6 +26,24 @@ from .models import (
25
26
 
26
27
  _EXPLICIT_NONE = "__explicit_none__"
27
28
 
29
+ _CREDENTIAL_REFERENCE_EXAMPLE = """{
30
+ "backend": "vault_kv_v2",
31
+ "mount": "secret",
32
+ "path": "inference/backend",
33
+ "field": "api_key"
34
+ }"""
35
+
36
+ _CREDENTIAL_REFERENCE_HELP = format_html(
37
+ "This JSON tells the plugin where to find the API key in Vault. It does not contain the API key. "
38
+ "Configure Vault first in the NetBox plugin settings at "
39
+ "<code>PLUGINS_CONFIG[&quot;netbox_data_import&quot;][&quot;vault&quot;]</code>. "
40
+ '<details class="mt-1" data-credential-reference-help>'
41
+ "<summary>Show the required JSON shape</summary>"
42
+ '<pre class="mt-2 mb-0"><code>{}</code></pre>'
43
+ "</details>",
44
+ _CREDENTIAL_REFERENCE_EXAMPLE,
45
+ )
46
+
28
47
 
29
48
  class _RuntimeCableChoiceField(forms.ChoiceField):
30
49
  """Render only current choices while letting shared validation classify submitted values."""
@@ -132,7 +151,7 @@ class ImportProfileForm(NetBoxModelForm):
132
151
  return self.instance.source_adapter
133
152
  if self.is_bound:
134
153
  return self.data.get(self.add_prefix("source_adapter")) or self.instance.source_adapter
135
- return self.instance.source_adapter
154
+ return self.initial.get("source_adapter") or self.instance.source_adapter
136
155
 
137
156
  def clean(self):
138
157
  """Collect the adapter-declared fields into ``adapter_config``."""
@@ -306,8 +325,10 @@ class InferenceBackendForm(NetBoxModelForm):
306
325
  "tags",
307
326
  )
308
327
  help_texts = {
309
- "credential_reference": (
310
- "A typed Vault KV v2 reference: backend, mount, path and field. It never holds a key value."
328
+ "credential_reference": _CREDENTIAL_REFERENCE_HELP,
329
+ "model": (
330
+ "Enter the exact model id. After you save the backend, run the connection test to discover "
331
+ "model suggestions when the endpoint supports them."
311
332
  ),
312
333
  }
313
334
 
@@ -18,7 +18,7 @@ from django.db import DatabaseError
18
18
  from . import adapter_config, adapters, catalog, target_modules
19
19
  from .models import FailureReason, ImportExecution, SourceDocument, locked_profile_policy
20
20
  from .netbox_reader import NetBoxReader, PlanningTargetUnavailable
21
- from .object_permissions import ObjectPermissionDenied
21
+ from .object_permissions import ObjectPermissionDenied, clear_user_permission_caches
22
22
  from .plan import Diagnostic, Disposition, ImportPlan, PlanInvalid, Severity, executable_units, merge_changes
23
23
  from .source_resolution import derive_effective_rows
24
24
  from .target_runtime import DeletedObject, ExecutionContext, PreconditionFailed
@@ -225,6 +225,7 @@ class ImportEngine:
225
225
  progress_callback,
226
226
  ) -> None:
227
227
  """Compare the selection against a fresh plan and apply it, inside the caller's transaction."""
228
+ clear_user_permission_caches(actor)
228
229
  current = cls.plan(
229
230
  profile,
230
231
  source_document,
@@ -253,6 +254,7 @@ class ImportEngine:
253
254
  if runtime is None:
254
255
  raise EngineConfigurationError(f"No Target Module runtime is registered for '{change.target_module}'.")
255
256
  try:
257
+ clear_user_permission_caches(actor)
256
258
  applied = runtime.apply(change, context)
257
259
  except (PreconditionFailed, ObjectPermissionDenied, ValidationError, DatabaseError) as exc:
258
260
  raise _ExecutionFailed(
@@ -2,9 +2,9 @@
2
2
  # SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com>
3
3
  """The OpenAI-compatible Inference Backend adapter (specification 8.1, 8.3, 8.4).
4
4
 
5
- One non-streaming Chat Completions call, built entirely from configuration. The adapter knows
6
- nothing about NetBox, candidates, proposals or jobs: it takes an InferenceRequest and returns an
7
- InferenceCompletion, or raises a typed backend error.
5
+ The adapter makes one non-streaming Chat Completions call and can discover optional model-id
6
+ suggestions. It knows nothing about NetBox, candidates, proposals or jobs: it takes an
7
+ InferenceRequest and returns an InferenceCompletion, or raises a typed backend error.
8
8
 
9
9
  A returned completion means the call reached the backend and the envelope parsed. Deciding what the
10
10
  content means belongs to the application service, not here.
@@ -19,7 +19,15 @@ from contextlib import suppress
19
19
  import requests
20
20
  from urllib3.exceptions import ReadTimeoutError
21
21
 
22
- from .inference_transport import ResponseProcessingFailure, is_preconnect_failure, request_to_resolved_address
22
+ from .inference_transport import (
23
+ ResponseBodyTooLarge,
24
+ ResponseProcessingFailure,
25
+ WallClockDeadline,
26
+ WallClockDeadlineExceeded,
27
+ is_preconnect_failure,
28
+ request_to_resolved_address,
29
+ )
30
+ from .inference_settings import MODEL_MAX_LENGTH
23
31
  from .inference_trust import (
24
32
  InvalidInferenceConfiguration,
25
33
  assert_resolved_address_allowed,
@@ -28,6 +36,10 @@ from .inference_trust import (
28
36
  )
29
37
 
30
38
  CHAT_COMPLETIONS_PATH = "/chat/completions"
39
+ MODELS_PATH = "/models"
40
+ CHAT_COMPLETION_RESPONSE_LIMIT = 65_536
41
+ MODEL_DISCOVERY_LIMIT = 100
42
+ MODEL_DISCOVERY_RESPONSE_LIMIT = 65_536
31
43
  DIAGNOSTIC_TEXT_LIMIT = 4096
32
44
 
33
45
  # Section 8.4 rejects every other terminal reason: only a completed answer is a completion.
@@ -193,6 +205,46 @@ def _retry_after(response) -> int | None:
193
205
  return None
194
206
 
195
207
 
208
+ def _status_failure(status: int, diagnostic, retry_after: int | None) -> InferenceBackendError | None:
209
+ """Return the typed failure one HTTP status maps to, or None when the status carries none."""
210
+ if 300 <= status < 400:
211
+ return InvalidBackendConfiguration(
212
+ f"The backend redirected the call (HTTP {status}), which is not followed.", diagnostic=diagnostic
213
+ )
214
+ if status in (401, 403):
215
+ return AuthenticationFailure(f"The backend refused the credential (HTTP {status}).", diagnostic=diagnostic)
216
+ if status == 429:
217
+ return RateLimited("The backend rate limited the call.", retry_after=retry_after, diagnostic=diagnostic)
218
+ if status in TRANSIENT_STATUSES:
219
+ return TransportFailure(f"The backend answered HTTP {status}.", diagnostic=diagnostic)
220
+ if status >= 400:
221
+ return InvalidBackendConfiguration(
222
+ f"The backend rejected the request (HTTP {status}), which repeating cannot fix.",
223
+ diagnostic=diagnostic,
224
+ )
225
+ return None
226
+
227
+
228
+ def _processing_failure(exc: ResponseProcessingFailure, api_key: str) -> InferenceBackendError:
229
+ """Return the typed failure one interrupted or refused response body maps to."""
230
+ diagnostic = ResponseDiagnostic(receipt=BODY_INTERRUPTED, status_code=exc.response.status_code)
231
+ if isinstance(exc.cause, ResponseBodyTooLarge):
232
+ # The body is refused unread, so the status is the only classification left.
233
+ return _status_failure(exc.response.status_code, diagnostic, _retry_after(exc.response)) or MalformedEnvelope(
234
+ "The backend response is too large.", diagnostic=diagnostic
235
+ )
236
+ if isinstance(exc.cause, ValueError):
237
+ return InvalidBackendConfiguration(
238
+ f"The backend answered with a location this delivery cannot use ({type(exc.cause).__name__}).",
239
+ diagnostic=_diagnostic(exc.response, api_key),
240
+ )
241
+ if isinstance(exc.cause, requests.Timeout) or _is_response_read_timeout(exc.cause):
242
+ return BackendTimeout(
243
+ "The backend did not finish its answer inside the configured limits.", diagnostic=diagnostic
244
+ )
245
+ return TransportFailure(f"The backend answer was interrupted ({type(exc.cause).__name__}).", diagnostic=diagnostic)
246
+
247
+
196
248
  def _is_response_read_timeout(exc: Exception) -> bool:
197
249
  """Return whether Requests timed out after it had received response headers."""
198
250
  return isinstance(exc, requests.ConnectionError) and bool(exc.args) and isinstance(exc.args[0], ReadTimeoutError)
@@ -211,6 +263,7 @@ class OpenAICompatibleAdapter:
211
263
  connect_timeout: int = 5,
212
264
  read_timeout: int = 60,
213
265
  session: "requests.Session | None" = None,
266
+ deadline: WallClockDeadline | None = None,
214
267
  ):
215
268
  # Verbatim: normalizing here would pass the request-time recheck a value the form refuses.
216
269
  self.api_root = api_root
@@ -221,6 +274,7 @@ class OpenAICompatibleAdapter:
221
274
  self.connect_timeout = connect_timeout
222
275
  self.read_timeout = read_timeout
223
276
  self._session = session or requests.Session()
277
+ self._deadline = deadline
224
278
 
225
279
  def _check_response_mode(self, request: InferenceRequest) -> None:
226
280
  """Reject a request for a mode this backend is not configured to serve."""
@@ -253,17 +307,32 @@ class OpenAICompatibleAdapter:
253
307
  """Return all approved addresses, rechecking the destination at request time."""
254
308
  try:
255
309
  validate_api_root(self.api_root, self.allowlist, self.authentication)
256
- addresses = resolve_addresses(self.api_root)
310
+ addresses = (
311
+ resolve_addresses(self.api_root)
312
+ if self._deadline is None
313
+ else self._deadline.run(resolve_addresses, self.api_root)
314
+ )
257
315
  assert_resolved_address_allowed(self.api_root, self.allowlist, addresses)
316
+ except WallClockDeadlineExceeded:
317
+ raise BackendTimeout("The connection test exceeded its overall time limit.") from None
258
318
  except InvalidInferenceConfiguration as exc:
259
319
  raise InvalidBackendConfiguration(str(exc)) from None
260
320
  return addresses
261
321
 
262
- def complete(self, request: InferenceRequest, api_key: str) -> InferenceCompletion:
263
- """Return one completion, or raise the typed error the backend condition maps to."""
264
- self._check_response_mode(request)
322
+ def _send(
323
+ self,
324
+ method: str,
325
+ path: str,
326
+ api_key: str,
327
+ *,
328
+ response_body_limit: int,
329
+ **kwargs,
330
+ ) -> requests.Response:
331
+ """Send one authenticated request through the checked and pinned destination boundary."""
265
332
  resolved_addresses = self._resolved_destinations()
266
- headers = {"Content-Type": "application/json", "Accept": "application/json"}
333
+ headers = {"Accept": "application/json"}
334
+ if "json" in kwargs:
335
+ headers["Content-Type"] = "application/json"
267
336
  if self.authentication == "bearer":
268
337
  headers["Authorization"] = f"Bearer {api_key}"
269
338
  connection_failure: requests.RequestException | None = None
@@ -271,31 +340,19 @@ class OpenAICompatibleAdapter:
271
340
  try:
272
341
  response = request_to_resolved_address(
273
342
  self._session,
274
- "POST",
275
- f"{self.api_root}{CHAT_COMPLETIONS_PATH}",
343
+ method,
344
+ f"{self.api_root}{path}",
276
345
  resolved_address,
277
- json=self._body(request),
278
346
  headers=headers,
279
347
  timeout=(self.connect_timeout, self.read_timeout),
348
+ deadline=self._deadline,
349
+ response_body_limit=response_body_limit,
280
350
  # A redirect is a different destination, so it is refused rather than followed.
281
351
  allow_redirects=False,
352
+ **kwargs,
282
353
  )
283
354
  except ResponseProcessingFailure as exc:
284
- if isinstance(exc.cause, ValueError):
285
- raise InvalidBackendConfiguration(
286
- f"The backend answered with a location this delivery cannot use ({type(exc.cause).__name__}).",
287
- diagnostic=_diagnostic(exc.response, api_key),
288
- ) from None
289
- diagnostic = ResponseDiagnostic(receipt=BODY_INTERRUPTED, status_code=exc.response.status_code)
290
- if isinstance(exc.cause, requests.Timeout) or _is_response_read_timeout(exc.cause):
291
- raise BackendTimeout(
292
- "The backend did not finish its answer inside the configured limits.",
293
- diagnostic=diagnostic,
294
- ) from None
295
- raise TransportFailure(
296
- f"The backend answer was interrupted ({type(exc.cause).__name__}).",
297
- diagnostic=diagnostic,
298
- ) from None
355
+ raise _processing_failure(exc, api_key) from None
299
356
  except requests.ConnectTimeout as exc:
300
357
  connection_failure = exc
301
358
  continue
@@ -321,7 +378,7 @@ class OpenAICompatibleAdapter:
321
378
  raise InvalidBackendConfiguration(
322
379
  f"The backend answered with a location this delivery cannot use ({type(exc).__name__})."
323
380
  ) from None
324
- return self._read(response, api_key)
381
+ return response
325
382
 
326
383
  if isinstance(connection_failure, requests.ConnectTimeout):
327
384
  raise BackendTimeout("The backend did not answer inside the configured limits.") from None
@@ -330,28 +387,68 @@ class OpenAICompatibleAdapter:
330
387
  diagnostic=ABSENT_DIAGNOSTIC,
331
388
  ) from None
332
389
 
390
+ @staticmethod
391
+ def _raise_for_status(response: requests.Response, api_key: str) -> ResponseDiagnostic:
392
+ """Return a safe diagnostic for success, or raise the typed HTTP failure."""
393
+ diagnostic = _diagnostic(response, api_key)
394
+ failure = _status_failure(response.status_code, diagnostic, _retry_after(response))
395
+ if failure is not None:
396
+ raise failure
397
+ return diagnostic
398
+
399
+ def complete(self, request: InferenceRequest, api_key: str) -> InferenceCompletion:
400
+ """Return one completion, or raise the typed error the backend condition maps to."""
401
+ self._check_response_mode(request)
402
+ response = self._send(
403
+ "POST",
404
+ CHAT_COMPLETIONS_PATH,
405
+ api_key,
406
+ response_body_limit=CHAT_COMPLETION_RESPONSE_LIMIT,
407
+ json=self._body(request),
408
+ )
409
+ return self._read(response, api_key)
410
+
411
+ def discover_models(self, api_key: str) -> tuple[str, ...]:
412
+ """Return bounded model-id suggestions from a compatible optional models endpoint."""
413
+ response = self._send(
414
+ "GET",
415
+ MODELS_PATH,
416
+ api_key,
417
+ response_body_limit=MODEL_DISCOVERY_RESPONSE_LIMIT,
418
+ )
419
+ diagnostic = self._raise_for_status(response, api_key)
420
+ if diagnostic.redacted:
421
+ raise MalformedEnvelope("The backend model list contained credential material.", diagnostic=diagnostic)
422
+ try:
423
+ envelope = response.json()
424
+ except (ValueError, RecursionError):
425
+ raise MalformedEnvelope("The backend model list is not JSON.", diagnostic=diagnostic) from None
426
+ if not isinstance(envelope, dict) or not isinstance(envelope.get("data"), list):
427
+ raise MalformedEnvelope("The backend did not provide a compatible model list.", diagnostic=diagnostic)
428
+
429
+ models = []
430
+ seen = set()
431
+ for item in envelope["data"]:
432
+ model_id = item.get("id") if isinstance(item, dict) else None
433
+ if (
434
+ not isinstance(model_id, str)
435
+ or not model_id
436
+ or model_id != model_id.strip()
437
+ or not model_id.isprintable()
438
+ or len(model_id) > MODEL_MAX_LENGTH
439
+ or model_id in seen
440
+ ):
441
+ continue
442
+ seen.add(model_id)
443
+ models.append(model_id)
444
+ if len(models) == MODEL_DISCOVERY_LIMIT:
445
+ break
446
+ return tuple(models)
447
+
333
448
  def _read(self, response, api_key: str = "") -> InferenceCompletion:
334
449
  """Classify the answer, then parse the one envelope a completed call returns."""
335
- # Captured before any classification, so every failure below can carry what it received.
336
- diagnostic = _diagnostic(response, api_key)
337
- status = response.status_code
338
- if status in (301, 302, 303, 307, 308):
339
- raise InvalidBackendConfiguration(
340
- f"The backend redirected the call (HTTP {status}), which is not followed.", diagnostic=diagnostic
341
- )
342
- if status in (401, 403):
343
- raise AuthenticationFailure(f"The backend refused the credential (HTTP {status}).", diagnostic=diagnostic)
344
- if status == 429:
345
- raise RateLimited(
346
- "The backend rate limited the call.", retry_after=_retry_after(response), diagnostic=diagnostic
347
- )
348
- if status in TRANSIENT_STATUSES:
349
- raise TransportFailure(f"The backend answered HTTP {status}.", diagnostic=diagnostic)
350
- if status >= 400:
351
- raise InvalidBackendConfiguration(
352
- f"The backend rejected the request (HTTP {status}), which repeating cannot fix.",
353
- diagnostic=diagnostic,
354
- )
450
+ # Captured before any parsing, so every failure below can carry what it received.
451
+ diagnostic = self._raise_for_status(response, api_key)
355
452
  try:
356
453
  envelope = response.json()
357
454
  except (ValueError, RecursionError):
@@ -405,6 +502,8 @@ def encode_payload(value) -> str:
405
502
 
406
503
  __all__ = (
407
504
  "CHAT_COMPLETIONS_PATH",
505
+ "MODELS_PATH",
506
+ "MODEL_DISCOVERY_LIMIT",
408
507
  "AuthenticationFailure",
409
508
  "BackendTimeout",
410
509
  "InferenceBackendError",
@@ -93,6 +93,24 @@ class ResolvedInferenceBackend:
93
93
  }
94
94
 
95
95
 
96
+ def adapter_for_backend(backend: ResolvedInferenceBackend, *, deadline=None):
97
+ """Build the adapter that both proposal jobs and foreground tests use for this backend."""
98
+ if backend.adapter_type != "openai_compatible":
99
+ raise InvalidInferenceConfiguration(f"Unsupported Inference Backend adapter '{backend.adapter_type}'.")
100
+ from .inference_adapter import OpenAICompatibleAdapter
101
+
102
+ return OpenAICompatibleAdapter(
103
+ api_root=backend.api_root,
104
+ model=backend.model,
105
+ allowlist=origin_allowlist(),
106
+ authentication=backend.authentication,
107
+ response_mode=backend.response_mode,
108
+ connect_timeout=backend.connect_timeout,
109
+ read_timeout=backend.read_timeout,
110
+ deadline=deadline,
111
+ )
112
+
113
+
96
114
  def _from_row(row, allowlist) -> ResolvedInferenceBackend:
97
115
  """Return the resolved backend one enabled database row describes."""
98
116
  # A saved row outlives the allowlist that approved it, so spec 8.3 validates both sources alike.