Coverage for netbox_data_import/device_field_review.py: 100%

181 statements  

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

1# SPDX-License-Identifier: Apache-2.0 

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

3"""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 effective_proposal: dict[str, Any] 

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

210 

211 

212class DeviceFieldReviewer: 

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

214 

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

216 self.profile = profile 

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

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

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

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

221 self._review_device_ids = { 

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

223 } 

224 

225 @classmethod 

226 def for_profile(cls, profile): 

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

228 from .models import IgnoredFieldDifference 

229 

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

231 return cls( 

232 profile, 

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

234 ) 

235 

236 @staticmethod 

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

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

239 return _DEFINITIONS_BY_FIELD.get(target_field) 

240 

241 @staticmethod 

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

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

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

245 

246 @staticmethod 

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

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

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

250 

251 @staticmethod 

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

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

254 definition = _DEFINITIONS_BY_FIELD.get(target_field) 

255 if definition is None: 

256 return None 

257 value = definition.current_value(matched_device) 

258 if target_field != "rack_name": 

259 return definition.snapshot(value) 

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

261 return _scope_rack_canonical(snapshot, _device_rack_location_id(matched_device)) 

262 

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

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

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

266 

267 @staticmethod 

268 def field_differences( 

269 matched_device, 

270 proposal: Mapping[str, Any], 

271 *, 

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

273 ): 

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

275 differing, informational, _ = DeviceFieldReviewer._compare( 

276 matched_device, 

277 proposal, 

278 display_overrides=display_overrides, 

279 ) 

280 return differing, informational 

281 

282 @staticmethod 

283 def field_diff( 

284 matched_device, 

285 proposal: Mapping[str, Any], 

286 *, 

287 include_informational: bool = False, 

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

289 ): 

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

291 differing, informational = DeviceFieldReviewer.field_differences( 

292 matched_device, 

293 proposal, 

294 display_overrides=display_overrides, 

295 ) 

296 if include_informational: 

297 return {**differing, **informational} 

298 return differing 

299 

300 def review( 

301 self, 

302 source_id: str, 

303 matched_device, 

304 proposal: Mapping[str, Any], 

305 *, 

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

307 ) -> DeviceFieldReview: 

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

309 differing, informational, snapshots = self._compare( 

310 matched_device, 

311 proposal, 

312 display_overrides=display_overrides, 

313 ) 

314 effective = dict(proposal) 

315 ignored = {} 

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

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

318 if record is None: 

319 continue 

320 if ( 

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

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

323 ): 

324 ignored[target_field] = { 

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

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

327 } 

328 definition = _DEFINITIONS_BY_FIELD[target_field] 

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

330 differing.pop(target_field, None) 

331 informational.pop(target_field, None) 

332 return DeviceFieldReview( 

333 differing=differing, 

334 ignored=ignored, 

335 informational=informational, 

336 effective_proposal=effective, 

337 snapshots=snapshots, 

338 ) 

339 

340 @staticmethod 

341 def _compare( 

342 matched_device, 

343 proposal: Mapping[str, Any], 

344 *, 

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

346 ): 

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

348 display_overrides = display_overrides or {} 

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

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

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

352 for definition in _FIELD_DEFINITIONS: 

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

354 continue 

355 file_value = proposal[definition.target_field] 

356 netbox_value = definition.current_value(matched_device) 

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

358 netbox_override = None 

359 if definition.target_field == "rack_name": 

360 netbox_override = _device_rack_display(matched_device) 

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

362 netbox_snapshot = definition.snapshot(netbox_value, netbox_override) 

363 if definition.target_field == "rack_name": 

364 _scope_rack_canonical(netbox_snapshot, _device_rack_location_id(matched_device)) 

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

366 continue 

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

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

369 if definition.writable: 

370 differing[definition.target_field] = values 

371 else: 

372 informational[definition.target_field] = values 

373 return differing, informational, snapshots