Coverage for netbox/models/deletion.py: 70%

58 statements  

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

1import logging 

2 

3from django.contrib.contenttypes.fields import GenericRelation 

4from django.db import router 

5from django.db.models.deletion import CASCADE, Collector 

6from django.utils.translation import gettext as _ 

7 

8logger = logging.getLogger("netbox.models.deletion") 

9 

10 

11class CountOnly: 

12 """ 

13 A stand-in for a list of dependent instances that reports a count without holding any 

14 instances. Used on the delete-confirmation page for high-cardinality relations (e.g. a 

15 JobsMixin object's jobs) which we deliberately do not materialize (see #22812). It is a 

16 lenient, empty iterable: `len()` returns the true row count, but iterating yields nothing, 

17 so it slots into the same `{model: <iterable>}` mapping as real instance lists and renders 

18 as a non-expandable row. 

19 """ 

20 # Template flag: distinguishes a count-only entry (no instances to list) from a real list, 

21 # so the confirmation page can render it without an expand/collapse affordance. 

22 count_only = True 

23 

24 def __init__(self, count): 

25 self.count = count 

26 

27 def __len__(self): 

28 return self.count 

29 

30 def __iter__(self): 

31 return iter(()) 

32 

33 

34class ConfirmCollector(Collector): 

35 """ 

36 A display-only Collector used to enumerate the objects that would be deleted along with a 

37 given object, for rendering the delete confirmation page. It behaves like Django's stock 

38 Collector (preserving the full FK cascade graph and its ProtectedError/RestrictedError 

39 behavior) except that it does not descend into the `jobs` GenericRelation. A JobsMixin 

40 object can accumulate thousands of Jobs, each carrying large data/log_entries payloads; 

41 materializing them all just to render a confirmation page can exhaust memory (see #22812). 

42 Instead, the related Jobs are counted and recorded in `generic_relation_counts`. 

43 

44 This is intentionally specific to Job, the only high-cardinality GenericRelation in the 

45 data model; it is not a general count-out over every GenericRelation. If another relation 

46 ever needs the same treatment, extend the check in collect() (and the matching write-path 

47 batching in JobsMixin/ScriptModule.delete) rather than assuming this already handles it. 

48 """ 

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

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

51 self.generic_relation_counts = {} 

52 

53 def collect(self, objs, source=None, *args, **kwargs): 

54 """ 

55 Override collect() to count the `jobs` GenericRelation rather than descend into it. 

56 

57 Django's Collector offers no per-relation skip hook, so we intercept the one call it 

58 makes when cascading into a GenericRelation: collect(sub_objs, source=model, ...), where 

59 `sub_objs` is a queryset of the related model. When that model is Job, we count the rows 

60 instead of collecting (and thus instantiating) them, and forward every other call to the 

61 stock implementation untouched. A directly-deleted Job (top-level call, source=None) 

62 still collects normally. 

63 """ 

64 from core.models import Job 

65 

66 if source is not None and getattr(objs, 'model', None) is Job: 

67 # Django calls this branch for the jobs relation even when there are none; only record 

68 # a count when there are actually jobs, so jobless objects don't get a spurious 

69 # "0 jobs" row on the delete-confirmation page. 

70 count = objs.count() 

71 if count: 

72 self.generic_relation_counts[Job] = self.generic_relation_counts.get(Job, 0) + count 

73 return None 

74 return super().collect(objs, source=source, *args, **kwargs) 

75 

76 

77class CustomCollector(Collector): 

78 """ 

79 Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions. 

80 """ 

81 

82 def collect( 

83 self, 

84 objs, 

85 source=None, 

86 nullable=False, 

87 collect_related=True, 

88 source_attr=None, 

89 reverse_dependency=False, 

90 keep_parents=False, 

91 fail_on_restricted=True, 

92 ): 

93 # By default, Django will force the deletion of dependent objects before the parent only if the ForeignKey field 

94 # is not nullable. We want to ensure proper ordering regardless, so if the ForeignKey has `on_delete=CASCADE` 

95 # applied, we set `nullable` to False when calling `collect()`. 

96 if objs and source and source_attr: 

97 model = objs[0].__class__ 

98 field = model._meta.get_field(source_attr) 

99 if field.remote_field.on_delete == CASCADE: 99 ↛ 102line 99 didn't jump to line 102 because the condition on line 99 was always true

100 nullable = False 

101 

102 super().collect( 

103 objs, 

104 source=source, 

105 nullable=nullable, 

106 collect_related=collect_related, 

107 source_attr=source_attr, 

108 reverse_dependency=reverse_dependency, 

109 keep_parents=keep_parents, 

110 fail_on_restricted=fail_on_restricted, 

111 ) 

112 

113 # Add GenericRelations to the dependency graph 

114 processed_relations = set() 

115 for _model, instances in list(self.data.items()): 

116 for instance in instances: 

117 # Get all GenericRelations for this model 

118 for field in instance._meta.private_fields: 

119 if isinstance(field, GenericRelation): 

120 # Create a unique key for this relation 

121 relation_key = f"{instance._meta.model_name}.{field.name}" 

122 if relation_key in processed_relations: 

123 continue 

124 processed_relations.add(relation_key) 

125 

126 # Add the model that the generic relation points to as a dependency 

127 self.add_dependency(field.related_model, instance, reverse_dependency=True) 

128 

129 

130class DeleteMixin: 

131 """ 

132 Mixin to override the model delete function to use our custom collector. 

133 """ 

134 

135 def delete(self, using=None, keep_parents=False): 

136 """ 

137 Override delete to use our custom collector. 

138 """ 

139 using = using or router.db_for_write(self.__class__, instance=self) 

140 if self._get_pk_val() is None: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 raise ValueError( 

142 _("{object_name} object can't be deleted because its {pk_attname} attribute is set to None.").format( 

143 object_name=self._meta.object_name, 

144 pk_attname=self._meta.pk.attname, 

145 ) 

146 ) 

147 

148 # Pass origin=self (matching Django's Model.delete) so signal receivers can tell that 

149 # cascaded child objects are being deleted as part of deleting this object. 

150 collector = CustomCollector(using=using, origin=self) 

151 collector.collect([self], keep_parents=keep_parents) 

152 

153 return collector.delete() 

154 

155 delete.alters_data = True 

156 

157 @classmethod 

158 def verify_mro(cls, instance): 

159 """ 

160 Verify that this mixin is first in the MRO. 

161 """ 

162 mro = instance.__class__.__mro__ 

163 if mro.index(cls) != 0: 

164 raise RuntimeError(f"{cls.__name__} must be first in the MRO. Current MRO: {mro}")