Coverage for netbox_data_import/netbox_reader.py: 100.00%

84 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"""Permission-scoped target-state reads for import planning.""" 

4 

5from __future__ import annotations 

6 

7from .catalog import import_location_required 

8from .field_keys import CABLE_END_KINDS 

9from .public_refusal import PublicRefusal 

10 

11 

12class PlanningTargetUnavailable(PublicRefusal): 

13 """The planning context names a target this reader cannot resolve.""" 

14 

15 

16class NetBoxReader: 

17 """Permission-scoped reads of the NetBox objects planning compares against.""" 

18 

19 def __init__(self, actor, site=None, location=None, tenant=None, *, location_unavailable=False): 

20 self._actor = actor 

21 self._site = site 

22 self._location = location 

23 self._tenant = tenant 

24 self._location_unavailable = location_unavailable 

25 

26 def for_target(self, *, site, location=None, tenant=None) -> NetBoxReader: 

27 """Bind the planning target without widening the actor's read scope.""" 

28 return type(self)(self._actor, site=site, location=location, tenant=tenant) 

29 

30 def for_planning_context(self, planning_context, *, output_kinds: frozenset[str]) -> NetBoxReader: 

31 """Resolve a planning context for *output_kinds*, inside this reader's permission scope.""" 

32 from dcim.models import Location, Site 

33 from tenancy.models import Tenant 

34 

35 if planning_context.get("site_id") is None: 

36 raise PlanningTargetUnavailable("A planning context names the site the import writes into.") 

37 site = self._required(Site, planning_context["site_id"]) 

38 location_id = planning_context.get("location_id") 

39 location = None 

40 if location_id is not None: 

41 location = self._scoped(Location, "view").filter(pk=location_id, site=site).first() 

42 location_unavailable = location_id is not None and location is None 

43 # Section 6.1: a Location that only ranks candidates is evidence, not a target. 

44 if location_unavailable and import_location_required(output_kinds): 

45 self._required(Location, location_id) 

46 raise PlanningTargetUnavailable("The selected location does not belong to the selected site.") 

47 return type(self)( 

48 self._actor, 

49 site=site, 

50 location=location, 

51 tenant=self._optional(Tenant, planning_context.get("tenant_id")), 

52 location_unavailable=location_unavailable, 

53 ) 

54 

55 def _required(self, model, pk): 

56 """Return the object *pk* names, or refuse when it is gone or out of scope.""" 

57 found = self._scoped(model, "view").filter(pk=pk).first() 

58 if found is None: 

59 raise PlanningTargetUnavailable(f"{model._meta.verbose_name} {pk} is gone, or this actor cannot view it.") 

60 return found 

61 

62 def _optional(self, model, pk): 

63 """Return the object *pk* names, or None when the context names none.""" 

64 return None if pk is None else self._required(model, pk) 

65 

66 @property 

67 def site(self): 

68 """Return the site this import writes into, or None before a target is bound.""" 

69 return self._site 

70 

71 @property 

72 def location(self): 

73 """Return the location this import writes into, if the operator chose one.""" 

74 return self._location 

75 

76 @property 

77 def location_unavailable(self) -> bool: 

78 """Return whether the chosen import Location is gone, hidden, or outside the site.""" 

79 return self._location_unavailable 

80 

81 @property 

82 def tenant(self): 

83 """Return the tenant this import writes into, if the operator chose one.""" 

84 return self._tenant 

85 

86 @classmethod 

87 def for_actor(cls, actor) -> NetBoxReader: 

88 """Return a reader scoped to *actor*, which is required.""" 

89 if actor is None: 

90 raise ValueError("A scoped NetBoxReader needs an actor. Use unrestricted() for no actor.") 

91 return cls(actor) 

92 

93 @classmethod 

94 def unrestricted(cls) -> NetBoxReader: 

95 """Return a reader that applies no object permissions.""" 

96 return cls(None) 

97 

98 @classmethod 

99 def for_optional_actor(cls, actor) -> NetBoxReader: 

100 """Return a scoped reader, or an unrestricted one for an explicit system caller.""" 

101 return cls.unrestricted() if actor is None else cls.for_actor(actor) 

102 

103 @property 

104 def actor(self): 

105 """Return the actor every read is scoped to, or None for an unrestricted reader.""" 

106 return self._actor 

107 

108 def _scoped(self, model, action: str): 

109 """Return *model*'s objects limited to what the actor may take *action* on.""" 

110 if self._actor is None: 

111 return model.objects.all() 

112 return model.objects.restrict(self._actor, action) 

113 

114 def devices(self, action: str = "view"): 

115 """Return the Devices the actor may take *action* on.""" 

116 from dcim.models import Device 

117 

118 return self._scoped(Device, action) 

119 

120 def racks(self, action: str = "view"): 

121 """Return the Racks the actor may take *action* on.""" 

122 from dcim.models import Rack 

123 

124 return self._scoped(Rack, action) 

125 

126 def locations(self, action: str = "view"): 

127 """Return the Locations the actor may take *action* on.""" 

128 from dcim.models import Location 

129 

130 return self._scoped(Location, action) 

131 

132 def terminations(self, model_label: str, action: str = "view"): 

133 """Return the rows of one Cable End Kind, named ``app_label.model``, the actor may take *action* on.""" 

134 from django.apps import apps 

135 

136 if model_label not in CABLE_END_KINDS: 

137 raise ValueError(f"'{model_label}' is not a Cable End Kind.") 

138 return self._scoped(apps.get_model(model_label), action) 

139 

140 def port_mappings(self): 

141 """Return the PortMapping rows of the Devices this actor may view. 

142 

143 NetBox keeps the model private, with no manager of its own, so the parent Device carries 

144 the scope. 

145 """ 

146 from dcim.models import PortMapping 

147 

148 return PortMapping.objects.filter(device__in=self.devices()) 

149 

150 

151__all__ = ("NetBoxReader", "PlanningTargetUnavailable")