Coverage for utilities/fields.py: 32%

183 statements  

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

1from collections import defaultdict 

2 

3from django.contrib.contenttypes.fields import GenericForeignKey, GenericForeignKeyDescriptor 

4from django.contrib.contenttypes.models import ContentType 

5from django.core.exceptions import ObjectDoesNotExist 

6from django.db import models 

7from django.db.models.fields.mixins import FieldCacheMixin 

8from django.utils.functional import cached_property 

9from django.utils.translation import gettext_lazy as _ 

10 

11from .forms.widgets import ColorSelect 

12from .validators import ColorValidator 

13 

14__all__ = ( 

15 'ColorField', 

16 'CounterCacheField', 

17 'GenericArrayForeignKey', 

18 'NaturalOrderingField', 

19 'RestrictedGenericForeignKey', 

20) 

21 

22 

23class ColorField(models.CharField): 

24 default_validators = [ColorValidator] 

25 description = "A hexadecimal RGB color code" 

26 

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

28 kwargs['max_length'] = 6 

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

30 

31 def formfield(self, **kwargs): 

32 kwargs['widget'] = ColorSelect 

33 return super().formfield(**kwargs) 

34 

35 

36class NaturalOrderingField(models.CharField): 

37 """ 

38 A field which stores a naturalized representation of its target field, to be used for ordering its parent model. 

39 

40 :param target_field: Name of the field of the parent model to be naturalized 

41 :param naturalize_function: The function used to generate a naturalized value (optional) 

42 """ 

43 description = "Stores a representation of its target field suitable for natural ordering" 

44 

45 def __init__(self, target_field, naturalize_function, *args, **kwargs): 

46 self.target_field = target_field 

47 self.naturalize_function = naturalize_function 

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

49 

50 def pre_save(self, model_instance, add): 

51 """ 

52 Generate a naturalized value from the target field 

53 """ 

54 original_value = getattr(model_instance, self.target_field) 

55 naturalized_value = self.naturalize_function(original_value, max_length=self.max_length) 

56 setattr(model_instance, self.attname, naturalized_value) 

57 

58 return naturalized_value 

59 

60 def deconstruct(self): 

61 kwargs = super().deconstruct()[3] # Pass kwargs from CharField 

62 kwargs['naturalize_function'] = self.naturalize_function 

63 return ( 

64 self.name, 

65 'utilities.fields.NaturalOrderingField', 

66 [self.target_field], 

67 kwargs, 

68 ) 

69 

70 

71class RestrictedGenericForeignKeyDescriptor(GenericForeignKeyDescriptor): 

72 """ 

73 Django 6.1 moved get_prefetch_querysets() off GenericForeignKey and onto a separate 

74 descriptor, which prefetch_related() now prefers over the field itself. Delegate back to 

75 the field so that RestrictedGenericForeignKey's restrict()-aware implementation is used. 

76 """ 

77 def get_prefetch_querysets(self, instances, querysets=None): 

78 return self.field.get_prefetch_querysets(instances, querysets) 

79 

80 

81class RestrictedGenericForeignKey(GenericForeignKey): 

82 

83 def contribute_to_class(self, cls, name, **kwargs): 

84 super().contribute_to_class(cls, name, **kwargs) 

85 # Replace the descriptor installed by GenericForeignKey with one which defers to 

86 # get_prefetch_querysets() below. 

87 setattr(cls, self.attname, RestrictedGenericForeignKeyDescriptor(self)) 

88 

89 # Replicated largely from GenericForeignKeyDescriptor. Changes include: 

90 # 1. Capture restrict_params from RestrictedPrefetch (hack) 

91 # 2. If restrict_params is set, call restrict() on the queryset for 

92 # the related model 

93 def get_prefetch_querysets(self, instances, querysets=None): 

94 restrict_params = {} 

95 custom_queryset_dict = {} 

96 

97 # Compensate for the hack in RestrictedPrefetch 

98 if type(querysets) is dict: 

99 restrict_params = querysets 

100 

101 elif querysets is not None: 

102 for queryset in querysets: 

103 ct_id = self.get_content_type( 

104 model=queryset.query.model, using=queryset.db 

105 ).pk 

106 if ct_id in custom_queryset_dict: 

107 raise ValueError( 

108 "Only one queryset is allowed for each content type." 

109 ) 

110 custom_queryset_dict[ct_id] = queryset 

111 

112 # For efficiency, group the instances by content type and then do one 

113 # query per model 

114 fk_dict = defaultdict(set) 

115 # We need one instance for each group in order to get the right db: 

116 instance_dict = {} 

117 ct_attname = self.model._meta.get_field(self.ct_field).get_attname() 

118 for instance in instances: 

119 # We avoid looking for values if either ct_id or fkey value is None 

120 ct_id = getattr(instance, ct_attname) 

121 if ct_id is not None: 

122 # Check if the content type actually exists 

123 if not self.get_content_type(id=ct_id, using=instance._state.db).model_class(): 

124 continue 

125 

126 fk_val = getattr(instance, self.fk_field) 

127 if fk_val is not None: 

128 fk_dict[ct_id].add(fk_val) 

129 instance_dict[ct_id] = instance 

130 

131 ret_val = [] 

132 for ct_id, fkeys in fk_dict.items(): 

133 if ct_id in custom_queryset_dict: 

134 # Return values from the custom queryset, if provided. 

135 qs = custom_queryset_dict[ct_id].filter(pk__in=fkeys) 

136 else: 

137 instance = instance_dict[ct_id] 

138 ct = self.get_content_type(id=ct_id, using=instance._state.db) 

139 model = ct.model_class() 

140 # A model may declare its manager under a name other than `objects` 

141 manager = getattr(model, 'objects', model._default_manager) 

142 qs = manager.filter(pk__in=fkeys) 

143 if restrict_params: 

144 qs = qs.restrict(**restrict_params) 

145 # Carry the fetch mode of the objects being prefetched over to the objects prefetched 

146 # onto them. Every instance in a batch shares one fetch mode, so the first is 

147 # representative; it is safe to index because fk_dict is populated from `instances`, 

148 # and so is empty (skipping this loop entirely) whenever `instances` is. 

149 ret_val.extend(qs.fetch_mode(instances[0]._state.fetch_mode)) 

150 

151 # For doing the join in Python, we have to match both the FK val and the 

152 # content type, so we use a callable that returns a (fk, class) pair. 

153 def gfk_key(obj): 

154 ct_id = getattr(obj, ct_attname) 

155 if ct_id is None: 

156 return None 

157 if model := self.get_content_type( 

158 id=ct_id, using=obj._state.db 

159 ).model_class(): 

160 return ( 

161 model._meta.pk.get_prep_value(getattr(obj, self.fk_field)), 

162 model, 

163 ) 

164 return None 

165 

166 return ( 

167 ret_val, 

168 lambda obj: (obj.pk, obj.__class__), 

169 gfk_key, 

170 True, 

171 self.name, 

172 False, 

173 ) 

174 

175 

176class CounterCacheField(models.BigIntegerField): 

177 """ 

178 Counter field to keep track of related model counts. 

179 """ 

180 def __init__(self, to_model, to_field, *args, **kwargs): 

181 if not isinstance(to_model, str): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 raise TypeError( 

183 _("%s(%r) is invalid. to_model parameter to CounterCacheField must be " 

184 "a string in the format 'app.model'") 

185 % ( 

186 self.__class__.__name__, 

187 to_model, 

188 ) 

189 ) 

190 

191 if not isinstance(to_field, str): 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 raise TypeError( 

193 _("%s(%r) is invalid. to_field parameter to CounterCacheField must be " 

194 "a string in the format 'field'") 

195 % ( 

196 self.__class__.__name__, 

197 to_field, 

198 ) 

199 ) 

200 

201 self.to_model_name = to_model 

202 self.to_field_name = to_field 

203 

204 kwargs['default'] = kwargs.get('default', 0) 

205 kwargs['editable'] = False 

206 

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

208 

209 def deconstruct(self): 

210 name, path, args, kwargs = super().deconstruct() 

211 kwargs["to_model"] = self.to_model_name 

212 kwargs["to_field"] = self.to_field_name 

213 return name, path, args, kwargs 

214 

215 

216class GenericArrayForeignKey(FieldCacheMixin, models.Field): 

217 """ 

218 Provide a generic many-to-many relation through an 2d array field 

219 """ 

220 

221 many_to_many = False 

222 many_to_one = False 

223 one_to_many = True 

224 one_to_one = False 

225 

226 def __init__(self, field, for_concrete_model=True): 

227 super().__init__(editable=False) 

228 self.field = field 

229 self.for_concrete_model = for_concrete_model 

230 self.is_relation = True 

231 

232 def contribute_to_class(self, cls, name, **kwargs): 

233 super().contribute_to_class(cls, name, private_only=True, **kwargs) 

234 # GenericArrayForeignKey is its own descriptor. 

235 setattr(cls, self.attname, self) 

236 

237 @cached_property 

238 def cache_name(self): 

239 return self.name 

240 

241 def get_cache_name(self): 

242 return self.cache_name 

243 

244 def _get_ids(self, instance): 

245 return getattr(instance, self.field) 

246 

247 def get_content_type_by_id(self, id=None, using=None): 

248 return ContentType.objects.db_manager(using).get_for_id(id) 

249 

250 def get_content_type_of_obj(self, obj=None): 

251 return ContentType.objects.db_manager(obj._state.db).get_for_model( 

252 obj, for_concrete_model=self.for_concrete_model 

253 ) 

254 

255 def get_content_type_for_model(self, using=None, model=None): 

256 return ContentType.objects.db_manager(using).get_for_model( 

257 model, for_concrete_model=self.for_concrete_model 

258 ) 

259 

260 def get_prefetch_querysets(self, instances, querysets=None): 

261 custom_queryset_dict = {} 

262 if querysets is not None: 

263 for queryset in querysets: 

264 ct_id = self.get_content_type_for_model( 

265 model=queryset.query.model, using=queryset.db 

266 ).pk 

267 if ct_id in custom_queryset_dict: 

268 raise ValueError( 

269 "Only one queryset is allowed for each content type." 

270 ) 

271 custom_queryset_dict[ct_id] = queryset 

272 

273 # For efficiency, group the instances by content type and then do one 

274 # query per model 

275 fk_dict = defaultdict(set) # type id, db -> model ids 

276 for instance in instances: 

277 for step in self._get_ids(instance): 

278 for ct_id, fk_val in step: 

279 fk_dict[(ct_id, instance._state.db)].add(fk_val) 

280 

281 rel_objects = [] 

282 for (ct_id, db), fkeys in fk_dict.items(): 

283 if ct_id in custom_queryset_dict: 

284 rel_objects.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) 

285 else: 

286 ct = self.get_content_type_by_id(id=ct_id, using=db) 

287 rel_objects.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) 

288 

289 # reorganize objects to fix usage 

290 items = { 

291 (self.get_content_type_of_obj(obj=rel_obj).pk, rel_obj.pk, rel_obj._state.db): rel_obj 

292 for rel_obj in rel_objects 

293 } 

294 lists = [] 

295 lists_keys = {} 

296 for instance in instances: 

297 data = [] 

298 lists.append(data) 

299 lists_keys[instance] = id(data) 

300 for step in self._get_ids(instance): 

301 nodes = [] 

302 for ct, fk in step: 

303 if rel_obj := items.get((ct, fk, instance._state.db)): 

304 nodes.append(rel_obj) 

305 data.append(nodes) 

306 

307 return ( 

308 lists, 

309 lambda obj: id(obj), 

310 lambda obj: lists_keys[obj], 

311 True, 

312 self.cache_name, 

313 False, 

314 ) 

315 

316 def __get__(self, instance, cls=None): 

317 if instance is None: 

318 return self 

319 rel_objects = self.get_cached_value(instance, default=...) 

320 expected_ids = self._get_ids(instance) 

321 # we do not check if cache actual 

322 if rel_objects is not ...: 

323 return rel_objects 

324 # load value 

325 if expected_ids is None: 

326 self.set_cached_value(instance, rel_objects) 

327 return rel_objects 

328 data = [] 

329 for step in self._get_ids(instance): 

330 rel_objects = [] 

331 for ct_id, pk_val in step: 

332 ct = self.get_content_type_by_id(id=ct_id, using=instance._state.db) 

333 try: 

334 rel_obj = ct.get_object_for_this_type(pk=pk_val) 

335 rel_objects.append(rel_obj) 

336 except ObjectDoesNotExist: 

337 pass 

338 data.append(rel_objects) 

339 self.set_cached_value(instance, data) 

340 return data