Coverage for netbox_data_import/device_field_review.py: 100.00%

197 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 09:47 +0000

1# SPDX-License-Identifier: Apache-2.0 

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

3"""Review exact source/NetBox value pairs for matched Device fields.""" 

4 

5from __future__ import annotations 

6 

7from collections.abc import Callable, Mapping 

8from dataclasses import dataclass 

9from typing import Any 

10 

11 

12def _text(value: Any) -> str: 

13 """Return the display text used for an exact text comparison.""" 

14 if value is None: 

15 return "" 

16 return str(value).strip() 

17 

18 

19def _number(value: Any) -> str: 

20 """Return a stable string for values that represent a number.""" 

21 if value is None or value == "": 

22 return "" 

23 try: 

24 number = float(value) 

25 if number == int(number): 

26 return str(int(number)) 

27 return str(number) 

28 except (TypeError, ValueError, OverflowError): 

29 return _text(value) 

30 

31 

32def _identity(value: Any) -> str: 

33 """Return the canonical identity for a model instance or scalar.""" 

34 if value is None: 

35 return "" 

36 primary_key = getattr(value, "pk", None) 

37 return _text(primary_key if primary_key is not None else value) 

38 

39 

40def _related_display(value: Any) -> str: 

41 """Return a human-readable value for a related object snapshot.""" 

42 if value is None: 

43 return "" 

44 return _text(getattr(value, "name", value)) 

45 

46 

47def _device_rack_name(device) -> str: 

48 """Return the raw rack name currently assigned to a device.""" 

49 return device.rack.name if getattr(device, "rack_id", None) else "" 

50 

51 

52def _device_rack_display(device) -> str: 

53 """Return the location-aware rack label currently assigned to a device.""" 

54 if not getattr(device, "rack_id", None): 

55 return "" 

56 rack = device.rack 

57 if getattr(rack, "location_id", None): 

58 return f"{rack.location} / {rack.name}" 

59 return _text(rack.name) 

60 

61 

62def _device_rack_location_id(device): 

63 """Return the location that scopes a device's rack, if it has one.""" 

64 return getattr(device.rack, "location_id", None) if getattr(device, "rack_id", None) else None 

65 

66 

67def _scope_rack_canonical(snapshot: dict[str, str], location_id) -> dict[str, str]: 

68 """Prefix a rack canonical value with its location so both sides compare the same way.""" 

69 snapshot["canonical"] = f"{location_id or ''}:{snapshot['canonical']}" 

70 return snapshot 

71 

72 

73def _device_type_value(device): 

74 """Return the canonical and display data for the current DeviceType.""" 

75 device_type = device.device_type 

76 manufacturer = device_type.manufacturer 

77 return (manufacturer.slug, device_type.slug, manufacturer.name, device_type.model) 

78 

79 

80def _device_type_display(value: Any) -> str: 

81 """Return a human-readable DeviceType value.""" 

82 if isinstance(value, (tuple, list)) and len(value) >= 4: 

83 return f"{value[2]} / {value[3]}" 

84 return _text(value) 

85 

86 

87def _device_type_normalize(value: Any) -> str: 

88 """Return the stable manufacturer/device-type slug pair.""" 

89 if isinstance(value, (tuple, list)) and len(value) >= 2: 

90 return f"{_text(value[0])}/{_text(value[1])}" 

91 return _text(value) 

92 

93 

94def _device_role_value(device) -> str: 

95 """Return the current DeviceRole slug.""" 

96 return _text(device.role.slug) if getattr(device, "role_id", None) else "" 

97 

98 

99def _device_ip_value(target_field: str): 

100 """Return a reader for one of the Device's IP fields.""" 

101 

102 def read(device): 

103 current = getattr(device, target_field, None) 

104 return _text(current.address) if current is not None else "" 

105 

106 return read 

107 

108 

109def _ip_normalize(value: Any) -> str: 

110 """Return an address in the one spelling both sides compare on.""" 

111 import ipaddress 

112 

113 text = _text(value) 

114 if not text: 

115 return "" 

116 try: 

117 return str(ipaddress.ip_interface(text)) 

118 except ValueError: 

119 return text 

120 

121 

122@dataclass(frozen=True) 

123class FieldDefinition: 

124 """One source of truth for a Device field's review behavior.""" 

125 

126 target_field: str 

127 current_value: Callable[[Any], Any] 

128 normalize: Callable[[Any], str] = _text 

129 display: Callable[[Any], str] = _text 

130 provided: Callable[[Any], bool] = lambda value: True 

131 writable: bool = True 

132 reviewable: bool = True 

133 

134 def snapshot(self, value: Any, display_override: str | None = None) -> dict[str, str]: 

135 """Return the persisted canonical and display representation.""" 

136 return { 

137 "canonical": self.normalize(value), 

138 "display": self.display(value) if display_override is None else display_override, 

139 } 

140 

141 

142def _provided_nonempty(value: Any) -> bool: 

143 """Return whether a writer receives a value for an optional field.""" 

144 return bool(_text(value)) 

145 

146 

147def _provided_optional(value: Any) -> bool: 

148 """Return whether a source row explicitly supplies an optional field.""" 

149 return value is not None and value != "" 

150 

151 

152def _provided_not_none(value: Any) -> bool: 

153 """Return whether a source row supplied a value, including zero and empty text.""" 

154 return value is not None 

155 

156 

157# Keep this registry private. Callers use DeviceFieldReviewer instead of 

158# reimplementing normalization, comparison, display, or write semantics. 

159_FIELD_DEFINITIONS: tuple[FieldDefinition, ...] = ( 

160 FieldDefinition("rack_name", _device_rack_name, display=_text), 

161 FieldDefinition("u_position", lambda device: device.position, normalize=_number, display=_number), 

162 FieldDefinition("face", lambda device: device.face or "", provided=_provided_optional), 

163 FieldDefinition("airflow", lambda device: device.airflow or "", provided=_provided_nonempty), 

164 FieldDefinition("status", lambda device: device.status or ""), 

165 FieldDefinition("serial", lambda device: device.serial or "", provided=_provided_nonempty), 

166 FieldDefinition("asset_tag", lambda device: device.asset_tag or "", provided=_provided_nonempty), 

167 FieldDefinition( 

168 "device_type", 

169 _device_type_value, 

170 normalize=_device_type_normalize, 

171 display=_device_type_display, 

172 ), 

173 FieldDefinition("role", _device_role_value), 

174 # The writer assigns these, so they are differences the preview has to report. 

175 FieldDefinition( 

176 "primary_ip4", _device_ip_value("primary_ip4"), normalize=_ip_normalize, provided=_provided_nonempty 

177 ), 

178 FieldDefinition( 

179 "primary_ip6", _device_ip_value("primary_ip6"), normalize=_ip_normalize, provided=_provided_nonempty 

180 ), 

181 FieldDefinition("oob_ip", _device_ip_value("oob_ip"), normalize=_ip_normalize, provided=_provided_nonempty), 

182 FieldDefinition("tenant", lambda device: device.tenant, normalize=_identity, display=_related_display), 

183 FieldDefinition("location", lambda device: device.location, normalize=_identity, display=_related_display), 

184 # These values are shown by the legacy field-diff helper, but the Device 

185 # writer does not assign them. Keep them reviewable and non-writable so an 

186 # ignored review never claims that a write was suppressed. 

187 FieldDefinition("device_name", lambda device: device.name, writable=False), 

188 FieldDefinition( 

189 "u_height", 

190 lambda device: device.device_type.u_height if getattr(device, "device_type_id", None) else None, 

191 normalize=_number, 

192 display=_number, 

193 provided=_provided_not_none, 

194 writable=False, 

195 ), 

196) 

197 

198_DEFINITIONS_BY_FIELD = {definition.target_field: definition for definition in _FIELD_DEFINITIONS} 

199 

200 

201@dataclass(frozen=True) 

202class DeviceFieldReview: 

203 """The review state and effective proposal for one matched Device.""" 

204 

205 differing: dict[str, dict[str, str]] 

206 ignored: dict[str, dict[str, str]] 

207 informational: dict[str, dict[str, str]] 

208 matching: dict[str, dict[str, str]] 

209 effective_proposal: dict[str, Any] 

210 snapshots: dict[str, tuple[dict[str, str], dict[str, str]]] 

211 

212 

213class DeviceFieldReviewer: 

214 """Apply exact persisted reviews to a matched Device proposal.""" 

215 

216 def __init__(self, profile, ignored_records: Mapping[tuple[str, int, str], Any] | None = None): 

217 self.profile = profile 

218 self._ignored_records = dict(ignored_records or {}) 

219 review_device_ids: dict[str, set[int]] = {} 

220 for source_id, device_id, _target_field in self._ignored_records: 

221 review_device_ids.setdefault(source_id, set()).add(device_id) 

222 self._review_device_ids = { 

223 source_id: frozenset(device_ids) for source_id, device_ids in review_device_ids.items() 

224 } 

225 

226 @classmethod 

227 def for_profile(cls, profile): 

228 """Load the profile's current review records once for an import run.""" 

229 from .models import IgnoredFieldDifference 

230 

231 records = IgnoredFieldDifference.objects.filter(profile=profile) 

232 return cls( 

233 profile, 

234 {(_text(record.source_id), record.netbox_device_id, record.target_field): record for record in records}, 

235 ) 

236 

237 @staticmethod 

238 def definition(target_field: str) -> FieldDefinition | None: 

239 """Return the registered definition for one target field.""" 

240 return _DEFINITIONS_BY_FIELD.get(target_field) 

241 

242 @staticmethod 

243 def reviewable_fields() -> frozenset[str]: 

244 """Return target fields that can be reviewed and ignored.""" 

245 return frozenset(d.target_field for d in _FIELD_DEFINITIONS if d.reviewable) 

246 

247 @staticmethod 

248 def non_writable_fields() -> frozenset[str]: 

249 """Return fields shown for information but not assigned by the writer.""" 

250 return frozenset(d.target_field for d in _FIELD_DEFINITIONS if not d.writable) 

251 

252 @staticmethod 

253 def current_snapshot(matched_device, target_field: str) -> dict[str, str] | None: 

254 """Return the current canonical NetBox snapshot for one registered field.""" 

255 definition = _DEFINITIONS_BY_FIELD.get(target_field) 

256 if definition is None: 

257 return None 

258 value = definition.current_value(matched_device) 

259 if target_field != "rack_name": 

260 return definition.snapshot(value) 

261 snapshot = definition.snapshot(value, _device_rack_display(matched_device)) 

262 return _scope_rack_canonical(snapshot, _device_rack_location_id(matched_device)) 

263 

264 def review_device_ids(self, source_id: str) -> frozenset[int]: 

265 """Return unique Device IDs bound to reviews for one source row.""" 

266 return self._review_device_ids.get(_text(source_id), frozenset()) 

267 

268 @staticmethod 

269 def field_differences( 

270 matched_device, 

271 proposal: Mapping[str, Any], 

272 *, 

273 display_overrides: Mapping[str, str] | None = None, 

274 ): 

275 """Return the writable differences and the reported-only ones as two maps.""" 

276 differing, informational, _, _ = DeviceFieldReviewer._compare( 

277 matched_device, 

278 proposal, 

279 display_overrides=display_overrides, 

280 ) 

281 return differing, informational 

282 

283 @staticmethod 

284 def field_diff( 

285 matched_device, 

286 proposal: Mapping[str, Any], 

287 *, 

288 include_informational: bool = False, 

289 display_overrides: Mapping[str, str] | None = None, 

290 ): 

291 """Return current differences without loading persisted review records.""" 

292 differing, informational = DeviceFieldReviewer.field_differences( 

293 matched_device, 

294 proposal, 

295 display_overrides=display_overrides, 

296 ) 

297 if include_informational: 

298 return {**differing, **informational} 

299 return differing 

300 

301 def review( 

302 self, 

303 source_id: str, 

304 matched_device, 

305 proposal: Mapping[str, Any], 

306 *, 

307 display_overrides: Mapping[str, str] | None = None, 

308 ) -> DeviceFieldReview: 

309 """Return differing, ignored, and write-safe values for one Device.""" 

310 differing, informational, matching, snapshots = self._compare( 

311 matched_device, 

312 proposal, 

313 display_overrides=display_overrides, 

314 ) 

315 effective = dict(proposal) 

316 ignored = {} 

317 for target_field, (file_snapshot, netbox_snapshot) in snapshots.items(): 

318 record = self._ignored_records.get((_text(source_id), matched_device.pk, target_field)) 

319 if record is None: 

320 continue 

321 if ( 

322 record.file_snapshot.get("canonical") == file_snapshot["canonical"] 

323 and record.netbox_snapshot.get("canonical") == netbox_snapshot["canonical"] 

324 ): 

325 ignored[target_field] = { 

326 "netbox": netbox_snapshot["display"], 

327 "file": file_snapshot["display"], 

328 } 

329 definition = _DEFINITIONS_BY_FIELD[target_field] 

330 effective[target_field] = definition.current_value(matched_device) 

331 differing.pop(target_field, None) 

332 informational.pop(target_field, None) 

333 return DeviceFieldReview( 

334 differing=differing, 

335 ignored=ignored, 

336 informational=informational, 

337 matching=matching, 

338 effective_proposal=effective, 

339 snapshots=snapshots, 

340 ) 

341 

342 @staticmethod 

343 def _compare( 

344 matched_device, 

345 proposal: Mapping[str, Any], 

346 *, 

347 display_overrides: Mapping[str, str] | None = None, 

348 ): 

349 """Compare a proposal to a Device using the private field registry.""" 

350 display_overrides = display_overrides or {} 

351 differing: dict[str, dict[str, str]] = {} 

352 informational: dict[str, dict[str, str]] = {} 

353 matching: dict[str, dict[str, str]] = {} 

354 snapshots: dict[str, tuple[dict[str, str], dict[str, str]]] = {} 

355 for definition in _FIELD_DEFINITIONS: 

356 if definition.target_field not in proposal or not definition.provided(proposal[definition.target_field]): 

357 continue 

358 file_value = proposal[definition.target_field] 

359 netbox_value = definition.current_value(matched_device) 

360 file_snapshot = definition.snapshot(file_value, display_overrides.get(definition.target_field)) 

361 netbox_override = None 

362 if definition.target_field == "rack_name": 

363 netbox_override = _device_rack_display(matched_device) 

364 _scope_rack_canonical(file_snapshot, proposal.get("_rack_location_id")) 

365 netbox_snapshot = definition.snapshot(netbox_value, netbox_override) 

366 if definition.target_field == "rack_name": 

367 _scope_rack_canonical(netbox_snapshot, _device_rack_location_id(matched_device)) 

368 if file_snapshot["canonical"] == netbox_snapshot["canonical"]: 

369 # Recorded, not dropped: a sync has to be able to say what it leaves alone. 

370 matching[definition.target_field] = { 

371 "netbox": netbox_snapshot["display"], 

372 "file": file_snapshot["display"], 

373 } 

374 continue 

375 snapshots[definition.target_field] = (file_snapshot, netbox_snapshot) 

376 values = {"netbox": netbox_snapshot["display"], "file": file_snapshot["display"]} 

377 if definition.writable: 

378 differing[definition.target_field] = values 

379 else: 

380 informational[definition.target_field] = values 

381 return differing, informational, matching, snapshots 

382 

383 

384#: What a sync would do to one field. The preview and the sync modal read these same names. 

385SYNC_STATE_CHANGE = "change" 

386SYNC_STATE_UNCHANGED = "unchanged" 

387SYNC_STATE_IGNORED = "ignored" 

388SYNC_STATE_NOT_WRITTEN = "not_written" 

389 

390#: The review bucket each state is built from, in the order the operator reads them. 

391_SYNC_STATE_BUCKETS: tuple[tuple[str, str], ...] = ( 

392 (SYNC_STATE_CHANGE, "field_diff"), 

393 (SYNC_STATE_UNCHANGED, "field_matching"), 

394 (SYNC_STATE_IGNORED, "field_ignored"), 

395 (SYNC_STATE_NOT_WRITTEN, "field_informational"), 

396) 

397 

398 

399def sync_change_preview(extra_data: Mapping[str, Any], labels: Mapping[str, str]) -> list[dict[str, str]]: 

400 """Return one matched row's reviewed fields, each labelled with what a sync would do to it.""" 

401 entries = [] 

402 for state, bucket in _SYNC_STATE_BUCKETS: 

403 values = extra_data.get(bucket) or {} 

404 for target_field in sorted(values): 

405 pair = values[target_field] 

406 entries.append( 

407 { 

408 "field": target_field, 

409 "label": labels.get(target_field) or target_field, 

410 "netbox": pair.get("netbox", ""), 

411 "file": pair.get("file", ""), 

412 "state": state, 

413 } 

414 ) 

415 return entries