Coverage for netbox_data_import/profile_yaml.py: 97.99%

249 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# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Serialize and apply portable Import Profile YAML documents.""" 

4 

5from dataclasses import dataclass 

6from typing import Any 

7 

8import yaml 

9from django.core.exceptions import ValidationError 

10from django.db import transaction 

11from yaml.constructor import ConstructorError 

12from yaml.resolver import BaseResolver 

13 

14from . import branching 

15from .catalog import POLICY_SECTIONS, policy_section 

16from .models import ( 

17 CableClassMapping, 

18 PortClassMapping, 

19 ClassRoleMapping, 

20 ColumnMapping, 

21 ColumnTransformRule, 

22 DeviceTypeMapping, 

23 ImportProfile, 

24 ManufacturerMapping, 

25 locked_profile_policy, 

26) 

27from .object_permissions import ( 

28 ObjectPermissionDenied, 

29 delete_permission_scoped_objects, 

30 enforce_saved_object_permission, 

31 save_permission_scoped_object, 

32) 

33 

34 

35@dataclass(frozen=True) 

36class NaturalKeyField: 

37 """Describe one related field represented by a stable lookup value.""" 

38 

39 name: str 

40 lookup: str 

41 

42 

43@dataclass(frozen=True) 

44class PolicyDocumentSchema: 

45 """Describe one catalog policy section's portable document shape.""" 

46 

47 model: Any 

48 identity_fields: tuple[str, ...] 

49 required_fields: tuple[str, ...] 

50 fields: tuple[str, ...] 

51 natural_keys: tuple[NaturalKeyField, ...] = () 

52 release_changed_before_write: bool = False 

53 

54 @property 

55 def key(self) -> str: 

56 """Return the catalog key owned by the model.""" 

57 return self.model.POLICY_SECTION 

58 

59 @property 

60 def natural_key_map(self) -> dict[str, NaturalKeyField]: 

61 """Return natural-key declarations by model field name.""" 

62 return {field.name: field for field in self.natural_keys} 

63 

64 

65_POLICY_DOCUMENT_SCHEMAS = ( 

66 PolicyDocumentSchema( 

67 model=ColumnMapping, 

68 identity_fields=("source_column", "target_field"), 

69 required_fields=("source_column", "target_field"), 

70 fields=("source_column", "target_field"), 

71 release_changed_before_write=True, 

72 ), 

73 PolicyDocumentSchema( 

74 model=ClassRoleMapping, 

75 identity_fields=("source_class",), 

76 required_fields=("source_class",), 

77 fields=("source_class", "creates_rack", "rack_type", "role_slug", "ignore"), 

78 natural_keys=(NaturalKeyField("rack_type", "slug"),), 

79 ), 

80 PolicyDocumentSchema( 

81 model=DeviceTypeMapping, 

82 identity_fields=("source_make", "source_model"), 

83 required_fields=( 

84 "source_make", 

85 "source_model", 

86 "netbox_manufacturer_slug", 

87 "netbox_device_type_slug", 

88 ), 

89 fields=( 

90 "source_make", 

91 "source_model", 

92 "netbox_manufacturer_slug", 

93 "netbox_device_type_slug", 

94 ), 

95 ), 

96 PolicyDocumentSchema( 

97 model=ManufacturerMapping, 

98 identity_fields=("source_make",), 

99 required_fields=("source_make", "netbox_manufacturer_slug"), 

100 fields=("source_make", "netbox_manufacturer_slug"), 

101 ), 

102 PolicyDocumentSchema( 

103 model=ColumnTransformRule, 

104 identity_fields=("source_column",), 

105 required_fields=("source_column", "pattern"), 

106 fields=("source_column", "pattern", "group_1_target", "group_2_target"), 

107 release_changed_before_write=True, 

108 ), 

109 PolicyDocumentSchema( 

110 model=CableClassMapping, 

111 identity_fields=("cable_class",), 

112 required_fields=("cable_class",), 

113 fields=( 

114 "cable_class", 

115 "cable_type_resolved", 

116 "cable_type", 

117 "cable_profile_resolved", 

118 "cable_profile", 

119 ), 

120 ), 

121 PolicyDocumentSchema( 

122 model=PortClassMapping, 

123 identity_fields=("source_label",), 

124 required_fields=("source_label", "port_class"), 

125 fields=("source_label", "port_class"), 

126 ), 

127) 

128 

129_SCHEMAS_BY_KEY = {schema.key: schema for schema in _POLICY_DOCUMENT_SCHEMAS} 

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

131 

132 

133class ProfileDocumentInvalid(Exception): 

134 """A profile YAML document breaks the document schema, and the message names the part that does.""" 

135 

136 

137class DuplicateYamlKeyError(ConstructorError): 

138 """A YAML mapping repeats a key whose first value would otherwise be discarded.""" 

139 

140 

141class _UniqueKeySafeLoader(yaml.SafeLoader): 

142 """Load the safe YAML subset and reject duplicate mapping keys at every depth.""" 

143 

144 

145def _construct_unique_mapping(loader, node, deep=False): 

146 """Construct one mapping without PyYAML's last-value-wins behavior.""" 

147 loader.flatten_mapping(node) 

148 mapping = {} 

149 for key_node, value_node in node.value: 

150 key = loader.construct_object(key_node, deep=deep) 

151 try: 

152 duplicate = key in mapping 

153 except TypeError as exc: 

154 raise ConstructorError( 

155 "while constructing a mapping", 

156 node.start_mark, 

157 "found an unhashable mapping key", 

158 key_node.start_mark, 

159 ) from exc 

160 if duplicate: 

161 raise DuplicateYamlKeyError( 

162 "while constructing a mapping", 

163 node.start_mark, 

164 f"found duplicate mapping key {key!r}", 

165 key_node.start_mark, 

166 ) 

167 mapping[key] = loader.construct_object(value_node, deep=deep) 

168 return mapping 

169 

170 

171_UniqueKeySafeLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) 

172 

173 

174def load_yaml_document(stream) -> Any: 

175 """Load untrusted YAML through the shared duplicate-key-rejecting safe loader.""" 

176 loader = _UniqueKeySafeLoader(stream) 

177 try: 

178 return loader.get_single_data() 

179 finally: 

180 loader.dispose() 

181 

182 

183def serialize_profile(profile: ImportProfile, actor=None) -> dict[str, Any]: 

184 """Return one portable profile document with only applicable policy sections.""" 

185 document: dict[str, Any] = { 

186 "profile": { 

187 "name": profile.name, 

188 "description": profile.description, 

189 "source_adapter": profile.source_adapter, 

190 "adapter_config": profile.adapter_config, 

191 } 

192 } 

193 for section in POLICY_SECTIONS: 

194 schema = _SCHEMAS_BY_KEY.get(section.key) 

195 if schema is None or not section.applies_to(profile.output_kinds): 

196 continue 

197 relation = schema.model._meta.get_field("profile").remote_field.get_accessor_name() 

198 rows = getattr(profile, relation).all() 

199 if actor is not None: 

200 visible = schema.model.objects.restrict(actor, "view").filter(profile=profile) 

201 if rows.exclude(pk__in=visible).exists(): 

202 raise ObjectPermissionDenied(f"{schema.model._meta.app_label}.view_{schema.model._meta.model_name}") 

203 rows = visible 

204 if schema.natural_keys: 

205 rows = rows.select_related(*(field.name for field in schema.natural_keys)) 

206 if actor is not None: 

207 _enforce_natural_key_view_permissions(schema, rows, actor) 

208 document[section.key] = [_serialize_policy_row(schema, row) for row in rows] 

209 return document 

210 

211 

212def _enforce_natural_key_view_permissions(schema: PolicyDocumentSchema, rows, actor) -> None: 

213 """Reject export when a natural-key reference names an object the actor cannot view.""" 

214 for natural_key in schema.natural_keys: 

215 relation = schema.model._meta.get_field(natural_key.name) 

216 related_model = relation.remote_field.model 

217 referenced_ids = set( 

218 rows.exclude(**{f"{natural_key.name}__isnull": True}).values_list( 

219 f"{natural_key.name}__pk", 

220 flat=True, 

221 ) 

222 ) 

223 visible_ids = set( 

224 related_model.objects.restrict(actor, "view").filter(pk__in=referenced_ids).values_list("pk", flat=True) 

225 ) 

226 if referenced_ids - visible_ids: 

227 raise ObjectPermissionDenied(f"{related_model._meta.app_label}.view_{related_model._meta.model_name}") 

228 

229 

230def apply_profile_document(data: Any, actor=None) -> tuple[ImportProfile, dict[str, int]]: 

231 """Create or update one profile and reconcile each supplied policy section.""" 

232 branching.refuse_branch() 

233 profile_data, section_rows = _validate_document_shape(data) 

234 with transaction.atomic(): 

235 profile_values = _profile_values(profile_data) 

236 profile = ImportProfile.objects.filter(name=profile_data["name"]).first() 

237 created = False 

238 if profile is None: 

239 profile = ImportProfile(name=profile_data["name"]) 

240 for field, value in profile_values.items(): 

241 setattr(profile, field, value) 

242 _validate_instance(profile, "profile") 

243 result = save_permission_scoped_object( 

244 actor, 

245 ImportProfile, 

246 {"name": profile_data["name"]}, 

247 profile_values, 

248 ) 

249 profile = result.instance 

250 created = result.created 

251 elif actor is not None and not ImportProfile.objects.restrict(actor, "change").filter(pk=profile.pk).exists(): 

252 raise ObjectPermissionDenied("netbox_data_import.change_importprofile") 

253 

254 with locked_profile_policy(profile.pk): 

255 profile = ImportProfile.objects.get(pk=profile.pk) 

256 if not created: 

257 for field, value in profile_values.items(): 

258 setattr(profile, field, value) 

259 _validate_instance(profile, "profile") 

260 profile = save_permission_scoped_object( 

261 actor, 

262 ImportProfile, 

263 {"pk": profile.pk}, 

264 profile_values, 

265 ).instance 

266 

267 _validate_section_applicability(profile, section_rows) 

268 prepared_rows = {} 

269 for key, rows in section_rows.items(): 

270 schema = _SCHEMAS_BY_KEY[key] 

271 prepared = [_prepare_policy_row(schema, row, index, actor) for index, row in enumerate(rows, 1)] 

272 _validate_distinct_policy_identities(schema, prepared) 

273 prepared_rows[key] = prepared 

274 released_updates = {} 

275 for key, rows in prepared_rows.items(): 

276 schema = _SCHEMAS_BY_KEY[key] 

277 if schema.release_changed_before_write: 

278 released_updates[key] = _release_changed_rows(profile, schema, rows, actor) 

279 

280 stats = {} 

281 for section in POLICY_SECTIONS: 

282 if section.key not in prepared_rows: 

283 continue 

284 schema = _SCHEMAS_BY_KEY[section.key] 

285 stats[section.key] = _reconcile_policy_rows( 

286 profile, 

287 schema, 

288 prepared_rows[section.key], 

289 actor, 

290 released_updates.get(section.key, {}), 

291 ) 

292 # atomic-exit-safe: profile-import-committed 

293 return profile, stats 

294 

295 

296def _serialize_policy_row(schema: PolicyDocumentSchema, row) -> dict[str, Any]: 

297 """Return one policy row using stable related-object references.""" 

298 natural_keys = schema.natural_key_map 

299 serialized = {} 

300 for name in schema.fields: 

301 value = getattr(row, name) 

302 if name in natural_keys: 

303 value = None if value is None else getattr(value, natural_keys[name].lookup) 

304 serialized[name] = value 

305 return serialized 

306 

307 

308def _validate_document_shape(data: Any) -> tuple[dict[str, Any], dict[str, list[dict[str, Any]]]]: 

309 """Validate the document container and return its profile and section mappings.""" 

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

311 raise ProfileDocumentInvalid("YAML must contain a top-level 'profile' key.") 

312 _require_string_keys(data, "Top-level") 

313 profile_data = data["profile"] 

314 if not isinstance(profile_data, dict): 

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

316 _require_string_keys(profile_data, "'profile'") 

317 if not profile_data.get("name"): 

318 raise ProfileDocumentInvalid("Profile YAML must include a 'name' field.") 

319 

320 unknown = sorted(set(data) - {"profile", *_SCHEMAS_BY_KEY}) 

321 if unknown: 

322 raise ProfileDocumentInvalid(f"Unknown profile YAML section(s): {', '.join(unknown)}") 

323 

324 sections = {} 

325 for key in data: 

326 if key == "profile": 

327 continue 

328 value = data[key] 

329 if not isinstance(value, list): 

330 raise ProfileDocumentInvalid( 

331 f"'{key}' must be a list of mappings; use [] to explicitly remove all entries, " 

332 f"got {type(value).__name__}." 

333 ) 

334 for index, row in enumerate(value, 1): 

335 if not isinstance(row, dict): 

336 raise ProfileDocumentInvalid(f"'{key}[{index}]' must be a mapping, got {type(row).__name__}.") 

337 _require_string_keys(row, f"'{key}[{index}]'") 

338 sections[key] = value 

339 return profile_data, sections 

340 

341 

342def _require_string_keys(mapping: dict, label: str) -> None: 

343 """Reject a key that is not a string: YAML also reads a number, a boolean or a date as a key.""" 

344 for key in mapping: 

345 if not isinstance(key, str): 

346 raise ProfileDocumentInvalid(f"{label} keys must be strings, got {key!r}.") 

347 

348 

349def _profile_values(profile_data: dict[str, Any]) -> dict[str, Any]: 

350 """Return validated scalar profile values and adapter configuration.""" 

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

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

353 if unknown: 

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

355 values = {field: profile_data[field] for field in _PROFILE_FIELDS if field in profile_data} 

356 if "adapter_config" in profile_data: 

357 values["adapter_config"] = profile_data["adapter_config"] 

358 return values 

359 

360 

361def _validate_section_applicability(profile: ImportProfile, sections: dict[str, list]) -> None: 

362 """Reject each supplied section that the selected Source Adapter cannot use.""" 

363 for key in sections: 

364 section = policy_section(key) 

365 if section is None or not section.applies_to(profile.output_kinds): 

366 raise ProfileDocumentInvalid( 

367 f"Policy section '{key}' does not apply to source adapter '{profile.source_adapter}'." 

368 ) 

369 

370 

371def _prepare_policy_row(schema: PolicyDocumentSchema, row: dict[str, Any], index: int, actor=None) -> dict[str, Any]: 

372 """Validate one row's keys and resolve its stable related-object references.""" 

373 missing = [field for field in schema.required_fields if field not in row] 

374 if missing: 

375 raise ProfileDocumentInvalid(f"'{schema.key}[{index}]' missing required key(s): {', '.join(missing)}") 

376 unknown = sorted(set(row) - set(schema.fields)) 

377 if unknown: 

378 raise ProfileDocumentInvalid(f"'{schema.key}[{index}]' has unknown key(s): {', '.join(unknown)}") 

379 # An identity or a lookup compares one value, so a list or a mapping there cannot name a row. 

380 for name in (*schema.identity_fields, *(natural_key.name for natural_key in schema.natural_keys)): 

381 if isinstance(row.get(name), (dict, list)): 

382 raise ProfileDocumentInvalid(f"'{schema.key}[{index}]' key '{name}' must be a single value.") 

383 

384 prepared = dict(row) 

385 for natural_key in schema.natural_keys: 

386 if natural_key.name not in prepared or prepared[natural_key.name] is None: 

387 continue 

388 model_field = schema.model._meta.get_field(natural_key.name) 

389 related_model = model_field.remote_field.model 

390 visible = related_model.objects if actor is None else related_model.objects.restrict(actor, "view") 

391 value = prepared[natural_key.name] 

392 try: 

393 prepared[natural_key.name] = visible.get(**{natural_key.lookup: value}) 

394 except related_model.DoesNotExist as exc: 

395 raise ProfileDocumentInvalid( 

396 f"{schema.key}[{index}]: {related_model._meta.verbose_name.title()} with " 

397 f"{natural_key.lookup} '{value}' not found" 

398 ) from exc 

399 return prepared 

400 

401 

402def _validate_distinct_policy_identities(schema: PolicyDocumentSchema, rows: list[dict[str, Any]]) -> None: 

403 """Reject duplicate natural identities before any policy write.""" 

404 seen = set() 

405 for row in rows: 

406 identity = tuple(row[name] for name in schema.identity_fields) 

407 if identity in seen: 

408 display = "/".join(str(value) for value in identity) 

409 raise ProfileDocumentInvalid(f"Duplicate {schema.key} identity: {display}") 

410 seen.add(identity) 

411 

412 

413def _release_changed_rows( 

414 profile: ImportProfile, 

415 schema: PolicyDocumentSchema, 

416 rows: list[dict[str, Any]], 

417 actor, 

418) -> dict[tuple[Any, ...], Any]: 

419 """Release target ownership while preserving logical update permissions.""" 

420 desired = {tuple(row[name] for name in schema.identity_fields): row for row in rows} 

421 deleted_ids = [] 

422 released_updates = {} 

423 for stored in schema.model.objects.filter(profile=profile): 

424 key = tuple(getattr(stored, name) for name in schema.identity_fields) 

425 row = desired.get(key) 

426 if row is None: 

427 deleted_ids.append(stored.pk) 

428 elif any(getattr(stored, name) != value for name, value in row.items()): 

429 enforce_saved_object_permission(stored, actor, "change") 

430 released_updates[key] = stored 

431 delete_permission_scoped_objects(actor, schema.model.objects.filter(pk__in=deleted_ids)) 

432 schema.model.objects.filter(pk__in=[row.pk for row in released_updates.values()]).delete() 

433 return released_updates 

434 

435 

436def _reconcile_policy_rows( 

437 profile: ImportProfile, 

438 schema: PolicyDocumentSchema, 

439 rows: list[dict[str, Any]], 

440 actor, 

441 released_updates: dict[tuple[Any, ...], Any], 

442) -> int: 

443 """Create or update supplied rows and remove rows absent from the section.""" 

444 retained_ids = [] 

445 for row in rows: 

446 identity = tuple(row[name] for name in schema.identity_fields) 

447 lookup = {"profile": profile, **dict(zip(schema.identity_fields, identity, strict=True))} 

448 instance = released_updates.get(identity) or schema.model.objects.filter(**lookup).first() 

449 if instance is None: 

450 instance = schema.model(**lookup) 

451 for name, value in row.items(): 

452 setattr(instance, name, value) 

453 _validate_instance(instance, _policy_row_label(schema, row)) 

454 values = {name: getattr(instance, name) for name in row if name not in schema.identity_fields} 

455 if identity in released_updates: 

456 instance.save(force_insert=True) 

457 enforce_saved_object_permission(instance, actor, "change") 

458 retained_ids.append(instance.pk) 

459 else: 

460 result = save_permission_scoped_object(actor, schema.model, lookup, values) 

461 retained_ids.append(result.instance.pk) 

462 delete_permission_scoped_objects( 

463 actor, 

464 schema.model.objects.filter(profile=profile).exclude(pk__in=retained_ids), 

465 ) 

466 return len(rows) 

467 

468 

469def _policy_row_label(schema: PolicyDocumentSchema, row: dict[str, Any]) -> str: 

470 """Return one stable validation label from the row identity.""" 

471 identity = "/".join(str(row[field]) for field in schema.identity_fields) 

472 return f"{schema.key}[{identity}]" 

473 

474 

475def _validate_instance(instance, label: str) -> None: 

476 """Validate a model and expose its field errors through the document interface.""" 

477 try: 

478 instance.full_clean(validate_unique=False) 

479 except ValidationError as exc: 

480 if hasattr(exc, "message_dict"): 

481 message = "; ".join(f"{field}: {', '.join(errors)}" for field, errors in exc.message_dict.items()) 

482 else: 

483 message = "; ".join(exc.messages) 

484 raise ProfileDocumentInvalid(f"Validation error in {label}: {message}") from exc 

485 

486 

487__all__ = ( 

488 "DuplicateYamlKeyError", 

489 "ProfileDocumentInvalid", 

490 "apply_profile_document", 

491 "load_yaml_document", 

492 "serialize_profile", 

493)