Coverage for netbox/api/pagination.py: 85%

116 statements  

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

1from django.db.models import QuerySet 

2from django.utils.translation import gettext_lazy as _ 

3from rest_framework.exceptions import ValidationError 

4from rest_framework.pagination import LimitOffsetPagination 

5from rest_framework.utils.urls import remove_query_param, replace_query_param 

6 

7from netbox.api.exceptions import QuerySetNotOrdered 

8from netbox.config import get_config 

9 

10 

11class NetBoxPagination(LimitOffsetPagination): 

12 """ 

13 Provides two mutually exclusive pagination mechanisms: offset-based and cursor-based. 

14 

15 Offset-based pagination employs `offset` and (optionally) `limit` parameters to page through results following the 

16 model's natural order. `offset` indicates the number of results to skip. This provides very human-friendly behavior, 

17 but performance can suffer when querying very large data sets due the overhead required to determine the starting 

18 point in the database. 

19 

20 Cursor-based pagination employs `start` and (optionally) `limit` parameters to page through results as ordered by 

21 the model's primary key (i.e. `id`). `start` indicates the numeric ID of the first object to return; `limit` 

22 indicates the maximum number of objects to return beginning with the specified ID. Objects *must* be ordered by ID 

23 to ensure pagination is consistent. This approach is less human-friendly but offers superior performance to 

24 offset-based pagination. In cursor mode, `count` is omitted (null) for performance. 

25 

26 Offset- and cursor-based pagination are mutually exclusive: Only `offset` _or_ `start` is permitted for a request. 

27 

28 `limit` may be set to zero (`?limit=0`). This returns all objects matching a query, but retains the same format as 

29 a paginated request. The limit can only be disabled if `MAX_PAGE_SIZE` has been set to 0 or None. 

30 """ 

31 start_query_param = 'start' 

32 

33 def __init__(self): 

34 self.default_limit = get_config().PAGINATE_COUNT 

35 self.start = None 

36 self._page_length = 0 

37 self._last_pk = None 

38 

39 def paginate_queryset(self, queryset, request, view=None): 

40 

41 if isinstance(queryset, QuerySet) and not queryset.ordered: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true

42 raise QuerySetNotOrdered( 

43 "Paginating over an unordered queryset is unreliable. Ensure that a minimal " 

44 "ordering has been applied to the queryset for this API endpoint." 

45 ) 

46 

47 self.start = self.get_start(request) 

48 self.limit = self.get_limit(request) 

49 self.request = request 

50 

51 # Cursor-based pagination 

52 if self.start is not None: 

53 if self.offset_query_param in request.query_params: 

54 raise ValidationError( 

55 _("'{start_param}' and '{offset_param}' are mutually exclusive.").format( 

56 start_param=self.start_query_param, 

57 offset_param=self.offset_query_param, 

58 ) 

59 ) 

60 if 'ordering' in request.query_params: 

61 raise ValidationError(_("Ordering cannot be specified in conjunction with cursor-based pagination.")) 

62 

63 self.count = None 

64 self.offset = 0 

65 

66 queryset = queryset.filter(pk__gte=self.start).order_by('pk') 

67 results = list(queryset[:self.limit]) if self.limit else list(queryset) 

68 

69 self._page_length = len(results) 

70 if results: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 self._last_pk = results[-1].pk if hasattr(results[-1], 'pk') else results[-1]['pk'] 

72 

73 return results 

74 

75 # Offset-based pagination 

76 if isinstance(queryset, QuerySet): 76 ↛ 80line 76 didn't jump to line 80 because the condition on line 76 was always true

77 self.count = self.get_queryset_count(queryset) 

78 else: 

79 # We're dealing with an iterable, not a QuerySet 

80 self.count = len(queryset) 

81 

82 self.offset = self.get_offset(request) 

83 

84 if self.limit and self.count > self.limit and self.template is not None: 

85 self.display_page_controls = True 

86 

87 if self.count == 0 or self.offset > self.count: 

88 return list() 

89 

90 if self.limit: 90 ↛ 92line 90 didn't jump to line 92 because the condition on line 90 was always true

91 return list(queryset[self.offset:self.offset + self.limit]) 

92 return list(queryset[self.offset:]) 

93 

94 def get_start(self, request): 

95 try: 

96 value = int(request.query_params[self.start_query_param]) 

97 if value < 0: 

98 raise ValidationError( 

99 _("Invalid '{param}' parameter: must be a non-negative integer.").format( 

100 param=self.start_query_param, 

101 ) 

102 ) 

103 return value 

104 except KeyError: 

105 return None 

106 except (ValueError, TypeError): 

107 raise ValidationError( 

108 _("Invalid '{param}' parameter: must be a non-negative integer.").format( 

109 param=self.start_query_param, 

110 ) 

111 ) 

112 

113 def get_limit(self, request): 

114 max_limit = self.default_limit 

115 MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE 

116 if MAX_PAGE_SIZE: 116 ↛ 119line 116 didn't jump to line 119 because the condition on line 116 was always true

117 max_limit = min(max_limit, MAX_PAGE_SIZE) 

118 

119 if self.limit_query_param: 119 ↛ 135line 119 didn't jump to line 135 because the condition on line 119 was always true

120 try: 

121 limit = int(request.query_params[self.limit_query_param]) 

122 if limit < 0: 

123 raise ValueError() 

124 

125 if MAX_PAGE_SIZE: 125 ↛ 131line 125 didn't jump to line 131 because the condition on line 125 was always true

126 if limit == 0: 

127 max_limit = MAX_PAGE_SIZE 

128 else: 

129 max_limit = min(MAX_PAGE_SIZE, limit) 

130 else: 

131 max_limit = limit 

132 except (KeyError, ValueError): 

133 pass 

134 

135 return max_limit 

136 

137 def get_queryset_count(self, queryset): 

138 return queryset.count() 

139 

140 def get_next_link(self): 

141 

142 # Pagination has been disabled 

143 if not self.limit: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 return None 

145 

146 # Cursor mode 

147 if self.start is not None: 

148 if self._page_length < self.limit: 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was always true

149 return None 

150 url = self.request.build_absolute_uri() 

151 url = replace_query_param(url, self.start_query_param, self._last_pk + 1) 

152 url = replace_query_param(url, self.limit_query_param, self.limit) 

153 url = remove_query_param(url, self.offset_query_param) 

154 return url 

155 

156 return super().get_next_link() 

157 

158 def get_previous_link(self): 

159 

160 # Pagination has been disabled 

161 if not self.limit: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 return None 

163 

164 # Cursor mode: forward-only 

165 if self.start is not None: 

166 return None 

167 

168 return super().get_previous_link() 

169 

170 def get_schema_operation_parameters(self, view): 

171 parameters = super().get_schema_operation_parameters(view) 

172 parameters.append({ 

173 'name': self.start_query_param, 

174 'required': False, 

175 'in': 'query', 

176 'description': ( 

177 'Cursor-based pagination: return results with pk >= start, ordered by pk. ' 

178 'Mutually exclusive with offset.' 

179 ), 

180 'schema': { 

181 'type': 'integer', 

182 }, 

183 }) 

184 return parameters 

185 

186 

187class StripCountAnnotationsPaginator(NetBoxPagination): 

188 """ 

189 Strips the annotations on the queryset before getting the count 

190 to optimize pagination of complex queries. 

191 """ 

192 def get_queryset_count(self, queryset): 

193 # Clone the queryset to avoid messing up the actual query 

194 cloned_queryset = queryset.all() 

195 cloned_queryset.query.annotations.clear() 

196 

197 return cloned_queryset.count() 

198 

199 

200class LimitOffsetListPagination(LimitOffsetPagination): 

201 """ 

202 DRF LimitOffset Paginator but for list instead of queryset 

203 """ 

204 count = 0 

205 offset = 0 

206 

207 def paginate_list(self, data, request, view=None): 

208 self.request = request 

209 self.limit = self.get_limit(request) 

210 self.count = len(data) 

211 self.offset = self.get_offset(request) 

212 

213 if self.limit is None: 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true

214 self.limit = self.count 

215 

216 if self.count == 0 or self.offset > self.count: 

217 return [] 

218 

219 if self.count > self.limit and self.template is not None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 self.display_page_controls = True 

221 

222 return data[self.offset:self.offset + self.limit]