Coverage for netbox/filtersets.py: 71%

174 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import json 

2from copy import deepcopy 

3 

4import django_filters 

5from django.contrib.auth.models import AnonymousUser 

6from django.contrib.contenttypes.models import ContentType 

7from django.db import models 

8from django.db.models import Q 

9from django.utils.translation import gettext as _ 

10from django_filters.exceptions import FieldLookupError 

11from django_filters.utils import get_model_field, resolve_field 

12 

13from core.choices import ObjectChangeActionChoices 

14from core.models import ObjectChange 

15from extras.choices import CustomFieldFilterLogicChoices 

16from extras.filters import TagFilter, TagIDFilter 

17from extras.models import CustomField, SavedFilter 

18from users.filterset_mixins import OwnerFilterMixin 

19from utilities import filters 

20from utilities.constants import ( 

21 FILTER_ARRAY_BASED_LOOKUP_MAP, 

22 FILTER_CHAR_BASED_LOOKUP_MAP, 

23 FILTER_NEGATION_LOOKUP_MAP, 

24 FILTER_NUMERIC_BASED_LOOKUP_MAP, 

25 FILTER_TAG_LOOKUP_MAP, 

26 FILTER_TREENODE_NEGATION_LOOKUP_MAP, 

27) 

28from utilities.forms.fields import MACAddressField 

29 

30__all__ = ( 

31 'AttributeFiltersMixin', 

32 'BaseFilterSet', 

33 'ChangeLoggedModelFilterSet', 

34 'NestedGroupModelFilterSet', 

35 'NetBoxModelFilterSet', 

36 'OrganizationalModelFilterSet', 

37 'PrimaryModelFilterSet', 

38) 

39 

40STANDARD_LOOKUPS = ( 

41 'exact', 

42 'iexact', 

43 'in', 

44 'contains', 

45) 

46 

47 

48# 

49# FilterSets 

50# 

51 

52class BaseFilterSet(django_filters.FilterSet): 

53 """ 

54 A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. 

55 """ 

56 FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) 

57 FILTER_DEFAULTS.update({ 

58 models.AutoField: { 

59 'filter_class': filters.MultiValueNumberFilter 

60 }, 

61 models.CharField: { 

62 'filter_class': filters.MultiValueCharFilter 

63 }, 

64 models.DateField: { 

65 'filter_class': filters.MultiValueDateFilter 

66 }, 

67 models.DateTimeField: { 

68 'filter_class': filters.MultiValueDateTimeFilter 

69 }, 

70 models.DecimalField: { 

71 'filter_class': filters.MultiValueDecimalFilter 

72 }, 

73 models.EmailField: { 

74 'filter_class': filters.MultiValueCharFilter 

75 }, 

76 models.FloatField: { 

77 'filter_class': filters.MultiValueNumberFilter 

78 }, 

79 models.IntegerField: { 

80 'filter_class': filters.MultiValueNumberFilter 

81 }, 

82 models.PositiveIntegerField: { 

83 'filter_class': filters.MultiValueNumberFilter 

84 }, 

85 models.PositiveSmallIntegerField: { 

86 'filter_class': filters.MultiValueNumberFilter 

87 }, 

88 models.SlugField: { 

89 'filter_class': filters.MultiValueCharFilter 

90 }, 

91 models.SmallIntegerField: { 

92 'filter_class': filters.MultiValueNumberFilter 

93 }, 

94 models.TimeField: { 

95 'filter_class': filters.MultiValueTimeFilter 

96 }, 

97 models.URLField: { 

98 'filter_class': filters.MultiValueCharFilter 

99 }, 

100 MACAddressField: { 

101 'filter_class': filters.MultiValueMACAddressFilter 

102 }, 

103 }) 

104 

105 def __init__(self, data=None, *args, **kwargs): 

106 # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready 

107 # however FilterSet Factory is setup before this which creates the 

108 # initial filters. This recreates the filters so Empty is picked up correctly. 

109 self.base_filters = self.__class__.get_filters() 

110 

111 # Apply any referenced SavedFilters 

112 if data and ('filter' in data or 'filter_id' in data): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 data = data.copy() # Get a mutable copy 

114 

115 # Coerce filter_id values to integers, ignoring any which are not valid (see #22568) 

116 filter_ids = [] 

117 for f_id in data.pop('filter_id', []): 

118 try: 

119 filter_ids.append(int(f_id)) 

120 except (ValueError, TypeError): 

121 pass 

122 

123 # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to 

124 # anonymous visibility (shared filters only) when no request is available. 

125 request = kwargs.get('request') 

126 user = request.user if request else AnonymousUser() 

127 saved_filters = SavedFilter.objects.restrict_to_shared(user).filter( 

128 Q(slug__in=data.pop('filter', [])) | 

129 Q(pk__in=filter_ids) 

130 ) 

131 for sf in saved_filters: 

132 for key, value in sf.parameters.items(): 

133 # QueryDicts are... fun 

134 if type(value) not in (list, tuple): 

135 value = [value] 

136 if key in data: 

137 for v in value: 

138 data.appendlist(key, v) 

139 else: 

140 data.setlist(key, value) 

141 

142 super().__init__(data, *args, **kwargs) 

143 

144 @staticmethod 

145 def _get_filter_lookup_dict(existing_filter): 

146 # Choose the lookup expression map based on the filter type 

147 if isinstance(existing_filter, ( 

148 django_filters.NumberFilter, 

149 filters.MultiValueDateFilter, 

150 filters.MultiValueDateTimeFilter, 

151 filters.MultiValueNumberFilter, 

152 filters.MultiValueDecimalFilter, 

153 filters.MultiValueTimeFilter 

154 )): 

155 return FILTER_NUMERIC_BASED_LOOKUP_MAP 

156 

157 if isinstance(existing_filter, ( 

158 filters.TreeNodeMultipleChoiceFilter, 

159 )): 

160 # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression 

161 return FILTER_TREENODE_NEGATION_LOOKUP_MAP 

162 

163 if isinstance(existing_filter, (TagFilter, TagIDFilter)): 

164 # Tags additionally support an "any of" (OR) mode, unlike other model choice filters 

165 return FILTER_TAG_LOOKUP_MAP 

166 

167 if isinstance(existing_filter, ( 

168 django_filters.ModelChoiceFilter, 

169 django_filters.ModelMultipleChoiceFilter, 

170 )): 

171 # These filter types support only negation 

172 return FILTER_NEGATION_LOOKUP_MAP 

173 

174 if isinstance(existing_filter, ( 174 ↛ 178line 174 didn't jump to line 178 because the condition on line 174 was never true

175 filters.MultiValueArrayFilter, 

176 )): 

177 # Must precede the char-based branch below, which would otherwise shadow this subclass 

178 return FILTER_ARRAY_BASED_LOOKUP_MAP 

179 

180 if isinstance(existing_filter, ( 

181 django_filters.filters.CharFilter, 

182 django_filters.ChoiceFilter, 

183 django_filters.MultipleChoiceFilter, 

184 filters.MultiValueCharFilter, 

185 filters.MultiValueMACAddressFilter 

186 )): 

187 return FILTER_CHAR_BASED_LOOKUP_MAP 

188 

189 return None 

190 

191 @classmethod 

192 def get_additional_lookups(cls, existing_filter_name, existing_filter): 

193 new_filters = {} 

194 

195 # Skip on abstract models 

196 if not cls._meta.model: 

197 return {} 

198 

199 # Skip nonstandard lookup expressions 

200 if existing_filter.method is not None or existing_filter.lookup_expr not in STANDARD_LOOKUPS: 

201 return {} 

202 

203 # Choose the lookup expression map based on the filter type 

204 lookup_map = cls._get_filter_lookup_dict(existing_filter) 

205 if lookup_map is None: 

206 # Do not augment this filter type with more lookup expressions 

207 return {} 

208 

209 # Get properties of the existing filter for later use 

210 field_name = existing_filter.field_name 

211 field = get_model_field(cls._meta.model, field_name) 

212 

213 # Create new filters for each lookup expression in the map 

214 for lookup_name, lookup_expr in lookup_map.items(): 

215 new_filter_name = f'{existing_filter_name}__{lookup_name}' 

216 existing_filter_extra = deepcopy(existing_filter.extra) 

217 

218 try: 

219 if existing_filter_name in cls.declared_filters: 

220 # The filter field has been explicitly defined on the filterset class so we must manually 

221 # create the new filter with the same type because there is no guarantee the defined type 

222 # is the same as the default type for the field 

223 if field is None: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 raise ValueError('Invalid field name/lookup on {}: {}'.format(existing_filter_name, field_name)) 

225 resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid 

226 filter_cls = type(existing_filter) 

227 if lookup_expr == 'empty': 

228 filter_cls = django_filters.BooleanFilter 

229 for param_to_remove in ('choices', 'null_value'): 

230 existing_filter_extra.pop(param_to_remove, None) 

231 new_filter = filter_cls( 

232 field_name=field_name, 

233 lookup_expr=lookup_expr, 

234 label=existing_filter.label, 

235 exclude=existing_filter.exclude, 

236 distinct=existing_filter.distinct, 

237 **existing_filter_extra 

238 ) 

239 elif hasattr(existing_filter, 'custom_field'): 239 ↛ 241line 239 didn't jump to line 241 because the condition on line 239 was never true

240 # Filter is for a custom field 

241 custom_field = existing_filter.custom_field 

242 new_filter = custom_field.to_filter(lookup_expr=lookup_expr) 

243 else: 

244 # The filter field is listed in Meta.fields so we can safely rely on default behaviour 

245 # Will raise FieldLookupError if the lookup is invalid 

246 new_filter = cls.filter_for_field(field, field_name, lookup_expr) 

247 except FieldLookupError: 

248 # The filter could not be created because the lookup expression is not supported on the field 

249 continue 

250 

251 if lookup_name.startswith('n'): 

252 # This is a negation filter which requires a queryset.exclude() clause 

253 # Of course setting the negation of the existing filter's exclude attribute handles both cases 

254 new_filter.exclude = not existing_filter.exclude 

255 

256 if lookup_name == 'any' and isinstance(new_filter, (TagFilter, TagIDFilter)): 

257 # "Any of" is an OR match, whereas TagFilter/TagIDFilter default to AND (conjoined=True) 

258 new_filter.conjoined = False 

259 

260 new_filters[new_filter_name] = new_filter 

261 

262 return new_filters 

263 

264 @classmethod 

265 def get_filters(cls): 

266 """ 

267 Override filter generation to support dynamic lookup expressions for certain filter types. 

268 

269 For specific filter types, new filters are created based on defined lookup expressions in 

270 the form `<field_name>__<lookup_expr>` 

271 """ 

272 filters = super().get_filters() 

273 

274 additional_filters = {} 

275 for existing_filter_name, existing_filter in filters.items(): 

276 additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter)) 

277 

278 filters.update(additional_filters) 

279 

280 return filters 

281 

282 @classmethod 

283 def filter_for_lookup(cls, field, lookup_type): 

284 

285 if lookup_type == 'empty': 

286 return django_filters.BooleanFilter, {} 

287 

288 return super().filter_for_lookup(field, lookup_type) 

289 

290 

291class ChangeLoggedModelFilterSet(BaseFilterSet): 

292 """ 

293 Base FilterSet for ChangeLoggedModel classes. 

294 """ 

295 created = filters.MultiValueDateTimeFilter() 

296 last_updated = filters.MultiValueDateTimeFilter() 

297 created_by_request = django_filters.UUIDFilter( 

298 method='filter_by_request' 

299 ) 

300 updated_by_request = django_filters.UUIDFilter( 

301 method='filter_by_request' 

302 ) 

303 modified_by_request = django_filters.UUIDFilter( 

304 method='filter_by_request' 

305 ) 

306 

307 def filter_by_request(self, queryset, name, value): 

308 content_type = ContentType.objects.get_for_model(self.Meta.model) 

309 action = { 

310 'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE), 

311 'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE), 

312 'modified_by_request': Q( 

313 action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE] 

314 ), 

315 }.get(name) 

316 request_id = value 

317 pks = ObjectChange.objects.filter( 

318 action, 

319 changed_object_type=content_type, 

320 request_id=request_id, 

321 ).values_list('changed_object_id', flat=True) 

322 return queryset.filter(pk__in=pks) 

323 

324 

325class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): 

326 """ 

327 Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models. 

328 """ 

329 q = django_filters.CharFilter( 

330 method='search', 

331 label=_('Search'), 

332 ) 

333 tag = TagFilter() 

334 tag_id = TagIDFilter() 

335 

336 def __init__(self, *args, **kwargs): 

337 super().__init__(*args, **kwargs) 

338 

339 custom_field_filters = {} 

340 for custom_field in CustomField.objects.get_for_model(self._meta.model): 340 ↛ 341line 340 didn't jump to line 341 because the loop on line 340 never started

341 if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED: 

342 # Skip disabled fields 

343 continue 

344 if filter_instance := custom_field.to_filter(): 

345 filter_name = f'cf_{custom_field.name}' 

346 custom_field_filters[filter_name] = filter_instance 

347 

348 # Add relevant additional lookups 

349 additional_lookups = self.get_additional_lookups(filter_name, filter_instance) 

350 custom_field_filters.update(additional_lookups) 

351 

352 self.filters.update(custom_field_filters) 

353 

354 def search(self, queryset, name, value): 

355 """ 

356 Override this method to apply a general-purpose search logic. 

357 """ 

358 return queryset 

359 

360 

361class PrimaryModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): 

362 """ 

363 Base filterset for models inheriting from PrimaryModel. 

364 """ 

365 pass 

366 

367 

368class OrganizationalModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): 

369 """ 

370 Base filterset for models inheriting from OrganizationalModel. 

371 """ 

372 def search(self, queryset, name, value): 

373 if not value.strip(): 

374 return queryset 

375 return queryset.filter( 

376 models.Q(name__icontains=value) | 

377 models.Q(slug__icontains=value) | 

378 models.Q(description__icontains=value) 

379 ) 

380 

381 

382class NestedGroupModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): 

383 """ 

384 Base filterset for models inheriting from NestedGroupModel. 

385 """ 

386 def search(self, queryset, name, value): 

387 if value.strip(): 

388 queryset = queryset.filter( 

389 models.Q(name__icontains=value) | 

390 models.Q(slug__icontains=value) | 

391 models.Q(description__icontains=value) | 

392 models.Q(comments__icontains=value) 

393 ) 

394 

395 return queryset 

396 

397 

398class AttributeFiltersMixin: 

399 attributes_field_name = 'attribute_data' 

400 attribute_filter_prefix = 'attr_' 

401 

402 def __init__(self, data=None, queryset=None, *, request=None, prefix=None): 

403 self.attr_filters = {} 

404 

405 # Extract JSONField-based filters from the incoming data 

406 if data is not None: 406 ↛ 415line 406 didn't jump to line 415 because the condition on line 406 was always true

407 for key, value in data.items(): 

408 if field := self._get_field_lookup(key): 408 ↛ 410line 408 didn't jump to line 410 because the condition on line 408 was never true

409 # Attempt to cast the value to a native JSON type 

410 try: 

411 self.attr_filters[field] = json.loads(value) 

412 except (ValueError, json.JSONDecodeError): 

413 self.attr_filters[field] = value 

414 

415 super().__init__(data=data, queryset=queryset, request=request, prefix=prefix) 

416 

417 def _get_field_lookup(self, key): 

418 if not key.startswith(self.attribute_filter_prefix): 418 ↛ 420line 418 didn't jump to line 420 because the condition on line 418 was always true

419 return None 

420 lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix 

421 return f'{self.attributes_field_name}__{lookup}' 

422 

423 def filter_queryset(self, queryset): 

424 return super().filter_queryset(queryset).filter(**self.attr_filters)