Coverage for apis_core/generic/abc.py: 62%

260 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-28 13:07 +0000

1import functools 

2import logging 

3import re 

4from typing import Optional, Tuple 

5 

6from django.contrib.contenttypes.models import ContentType 

7from django.core.checks import Error 

8from django.core.exceptions import ImproperlyConfigured 

9from django.db import models 

10from django.db.models import BooleanField, CharField, TextField 

11from django.db.models.fields.related import ForeignKey, ManyToManyField 

12from django.db.models.query import QuerySet 

13from django.forms import model_to_dict 

14from django.http.request import HttpRequest 

15from django.urls import reverse 

16from django.utils.encoding import force_str 

17from django.utils.translation import gettext_lazy as _ 

18 

19from apis_core.generic.helpers import mro_paths, permission_fullname 

20from apis_core.generic.signals import ( 

21 post_duplicate, 

22 post_merge_with, 

23 pre_duplicate, 

24 pre_import_from, 

25 pre_merge_with, 

26) 

27from apis_core.generic.utils import get_autocomplete_data_and_normalized_uri 

28from apis_core.generic.utils.models import ConfigModel 

29from apis_core.utils.settings import apis_base_uri, rdf_namespace_prefix 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34class GenericModel(models.Model): 

35 class Meta: 

36 abstract = True 

37 

38 class Config: 

39 overview_section = _("Generic models") 

40 index_for_search = True 

41 

42 @classmethod 

43 def check(cls, **kwargs): 

44 errors = super().check(**kwargs) 

45 msgs = ConfigModel.validation_errors_to_error_messages(cls.Config) 

46 errors.extend([Error(msg, obj=cls) for msg in msgs]) 

47 return errors 

48 

49 def __repr__(self): 

50 if id := getattr(self, "id", None): 

51 return super().__repr__() + f" (ID: {id})" 

52 return super().__repr__() 

53 

54 @property 

55 def get_self_content_type(self): 

56 return ContentType.objects.get_for_model(self) 

57 

58 @classmethod 

59 def get_listview_url(cls): 

60 return reverse("apis_core:generic:list", args=[cls]) 

61 

62 @classmethod 

63 def get_createview_url(cls): 

64 return reverse("apis_core:generic:create", args=[cls]) 

65 

66 @classmethod 

67 def get_importview_url(cls): 

68 return reverse("apis_core:generic:import", args=[cls]) 

69 

70 @classmethod 

71 def get_openapi_tags(cls): 

72 return [item[-1] for item in mro_paths(cls)] 

73 

74 @classmethod 

75 def get_namespace_prefix(cls): 

76 ct = ContentType.objects.get_for_model(cls) 

77 return f"{rdf_namespace_prefix()}-{ct.model}" 

78 

79 @classmethod 

80 def get_namespace_uri(cls): 

81 return apis_base_uri() + cls.get_listview_url() 

82 

83 @classmethod 

84 def get_rdf_types(cls): 

85 return [] 

86 

87 @classmethod 

88 def get_count(cls): 

89 return cls.objects.count() 

90 

91 def get_edit_url(self): 

92 return reverse("apis_core:generic:update", args=[type(self), self.id]) 

93 

94 def get_duplicate_url(self): 

95 return reverse("apis_core:generic:duplicate", args=[type(self), self.id]) 

96 

97 def get_enrich_url(self): 

98 return reverse("apis_core:generic:enrich", args=[type(self), self.id]) 

99 

100 def get_absolute_url(self): 

101 return reverse("apis_core:generic:detail", args=[type(self), self.id]) 

102 

103 def get_delete_url(self): 

104 return reverse("apis_core:generic:delete", args=[type(self), self.id]) 

105 

106 def get_merge_url(self, other_id): 

107 return reverse("apis_core:generic:merge", args=[type(self), self.id, other_id]) 

108 

109 def get_select_merge_or_enrich_url(self): 

110 return reverse( 

111 "apis_core:generic:selectmergeorenrich", args=[type(self), self.id] 

112 ) 

113 

114 def get_create_success_url(self, request: Optional[HttpRequest] = None): 

115 if request and request.GET.get("redirect", False): 

116 return request.GET.get("redirect") 

117 return self.get_absolute_url() 

118 

119 def get_update_success_url(self, request: Optional[HttpRequest] = None): 

120 if request and request.GET.get("redirect", False): 

121 return request.GET.get("redirect") 

122 return self.get_edit_url() 

123 

124 def get_delete_success_url(self, request: Optional[HttpRequest] = None): 

125 if request and request.GET.get("redirect", False): 

126 return request.GET.get("redirect") 

127 return self.get_listview_url() 

128 

129 def get_api_detail_endpoint(self): 

130 return reverse( 

131 "apis_core:generic:genericmodelapi-detail", args=[type(self), self.id] 

132 ) 

133 

134 @classmethod 

135 def get_change_permission(self): 

136 return permission_fullname("change", self) 

137 

138 @classmethod 

139 def get_add_permission(self): 

140 return permission_fullname("add", self) 

141 

142 @classmethod 

143 def get_delete_permission(self): 

144 return permission_fullname("delete", self) 

145 

146 @classmethod 

147 def get_view_permission(self): 

148 return permission_fullname("view", self) 

149 

150 @classmethod 

151 def get_verbose_name_plural(cls): 

152 return cls._meta.verbose_name_plural 

153 

154 @classmethod 

155 def get_verbose_name(cls): 

156 return cls._meta.verbose_name 

157 

158 @classmethod 

159 def valid_import_url(cls, uri: str): 

160 """ 

161 Check if an URI is a can be imported. 

162 The exact fetching logic for an URI is defined in the 

163 `import_definitions` attribute of the class. 

164 `import_definitions` has to be a dict, mapping a regex 

165 matching the URI to a callable taking the URI as an argument. 

166 This method check if there is a callable defined for this URI. 

167 """ 

168 _, uri = get_autocomplete_data_and_normalized_uri(uri) 

169 for regex, fn in getattr(cls, "import_definitions", {}).items(): 

170 if re.match(regex, uri): 

171 return fn 

172 return False 

173 

174 @classmethod 

175 def get_data_and_normalized_uri(cls, uri: str) -> Tuple[dict, str]: 

176 data, uri = get_autocomplete_data_and_normalized_uri(uri) 

177 return data, uri 

178 

179 @classmethod 

180 @functools.cache 

181 def fetch_from(cls, uri: str): 

182 """ 

183 Normalize the URI and extract the autocomplete data. 

184 Then try to fetch data from an URI: 

185 Check if there is import logic configured for this URI and if 

186 so, use that import logic to fetch the data. 

187 Finally, combine the fetched data and the autocomplete data. 

188 """ 

189 logger.debug("Fetch from %s", uri) 

190 data, nuri = cls.get_data_and_normalized_uri(uri) 

191 if fn := cls.valid_import_url(nuri): 

192 fetcheddata = fn(nuri) or {} 

193 # merge the two dicts 

194 ret = fetcheddata | data 

195 # combine values that exist in both dicts 

196 for key in set(fetcheddata).intersection(data): 

197 ret[key] = fetcheddata[key] + data[key] 

198 return ret 

199 raise ImproperlyConfigured(f"Import not configured for URI {uri}") 

200 

201 @classmethod 

202 def import_from(cls, uri: str, allow_empty: bool = True): 

203 """ 

204 Fetch data from an URI and create a model instance using 

205 that data. If the `allow_empty` argument is set, this also 

206 creates a model instance if the data fetched was empty. This 

207 might make sense if you still want to create an instance and 

208 attach the URI to it. 

209 """ 

210 # we allow other apps to injercept the import 

211 # whatever they return will be used instead of 

212 # creating a new object 

213 _, nuri = cls.get_data_and_normalized_uri(uri) 

214 for receiver, response in pre_import_from.send(sender=cls, uri=nuri): 

215 if response: 

216 return response 

217 data = cls.fetch_from(uri) or {} 

218 if allow_empty or data: 

219 instance = cls() 

220 instance.save() 

221 instance.import_data(data) 

222 return instance 

223 raise ValueError(f"Could not fetch data to import from {uri}") 

224 

225 def import_from_dict_subset(self, **data): 

226 """ 

227 Import attributes of this instance from data in a dict. 

228 We iterate through the individual values of the dict and 

229 a) only set them if the instance has an attribute matching 

230 the key and b) use the fields `clean` method to check if 

231 the value validates. If it does not validate, we return 

232 the validation error in the errors dict. 

233 """ 

234 self._import_errors = {} 

235 if data: 

236 for field in self._meta.fields: 

237 if data.get(field.name, False): 

238 value = str(data[field.name][0]) 

239 try: 

240 field.clean(value, self) 

241 except Exception as e: 

242 logger.info( 

243 "Could not set %s on %s: %s", field.name, str(self), str(e) 

244 ) 

245 self._import_errors[field.name] = str(e) 

246 else: 

247 setattr(self, field.name, value) 

248 self.save() 

249 

250 def import_data(self, data): 

251 self.import_from_dict_subset(**data) 

252 

253 def get_merge_charfield_value(self, other: CharField, field: CharField): 

254 res = getattr(self, str(field.name)) 

255 if not field.choices: 

256 otherres = getattr(other, str(field.name), res) 

257 if not res: 

258 res = otherres 

259 elif otherres and otherres != res: 

260 res += f" ({otherres})" 

261 

262 return res 

263 

264 def get_merge_textfield_value(self, other: TextField, field: TextField): 

265 res = getattr(self, str(field.name)) 

266 if getattr(other, str(field.name)): 

267 # if own value is None, fallback to empty string 

268 res = res or "" 

269 res += "\n" + f"Merged from {other}:\n" + getattr(other, str(field.name)) 

270 return res 

271 

272 def get_merge_booleanfield(self, other: BooleanField, field: BooleanField): 

273 return getattr(other, str(field.name)) 

274 

275 def get_field_value_after_merge(self, other, field): 

276 """ 

277 This method finds the value of a field after merging `other` into `self`. 

278 It first tries to find a merge method that is specific to that field 

279 (merge_{fieldname}) and then tries to find a method that is specific to 

280 the type of the field (merge_{fieldtype}) 

281 If neither of those exist, it uses the others field value if the field 

282 in self is not set, otherwise it keeps the value in self. 

283 """ 

284 fieldtype = field.get_internal_type().lower() 

285 # if there is a `get_merge_{fieldname}` method in this model, use that one 

286 if callable(getattr(self, f"get_merge_{field.name}_value", None)): 

287 return getattr(self, f"get_merge_{field.name}_value")(other) 

288 # otherwise we check if there is a method for the field type and use that one 

289 elif callable(getattr(self, f"get_merge_{fieldtype}_value", None)): 

290 return getattr(self, f"get_merge_{fieldtype}_value")(other, field) 

291 else: 

292 if not getattr(self, str(field.name)): 

293 return getattr(other, str(field.name)) 

294 return getattr(self, field.name) 

295 

296 def merge_fields(self, other): 

297 """ 

298 This method iterates through the model fields and uses the 

299 `get_field_value_after_merge` method to copy values from `other` to `self`. 

300 It is called by the `merge_with` method. 

301 """ 

302 for field in self._meta.fields: 

303 newval = self.get_field_value_after_merge(other, field) 

304 if newval != getattr(self, str(field.name)): 

305 setattr(self, str(field.name), newval) 

306 self.save() 

307 

308 def merge_with(self, entities): 

309 if self in entities: 

310 entities.remove(self) 

311 origin = self.__class__ 

312 pre_merge_with.send(sender=origin, instance=self, entities=entities) 

313 

314 e_a = type(self).__name__ 

315 self_model_class = ContentType.objects.get(model__iexact=e_a).model_class() 

316 if isinstance(entities, int): 

317 entities = self_model_class.objects.get(pk=entities) 

318 if not isinstance(entities, list) and not isinstance(entities, QuerySet): 

319 entities = [entities] 

320 entities = [ 

321 self_model_class.objects.get(pk=ent) if isinstance(ent, int) else ent 

322 for ent in entities 

323 ] 

324 for ent in entities: 

325 e_b = type(ent).__name__ 

326 if e_a != e_b: 

327 continue 

328 for f in ent._meta.local_many_to_many: 

329 if not f.name.endswith("_set"): 

330 sl = list(getattr(self, f.name).all()) 

331 for s in getattr(ent, f.name).all(): 

332 if s not in sl: 

333 getattr(self, f.name).add(s) 

334 

335 for ent in entities: 

336 self.merge_fields(ent) 

337 

338 post_merge_with.send(sender=origin, instance=self, entities=entities) 

339 

340 for ent in entities: 

341 ent.delete() 

342 

343 def duplicate(self): 

344 origin = self.__class__ 

345 pre_duplicate.send(sender=origin, instance=self) 

346 # usually, copying instances would work like 

347 # https://docs.djangoproject.com/en/4.2/topics/db/queries/#copying-model-instances 

348 # but we are working with abstract classes, 

349 # so we have to do it by hand using model_to_dict:( 

350 objdict = model_to_dict(self) 

351 

352 # remove unique fields from dict representation 

353 unique_fields = [field for field in self._meta.fields if field.unique] 

354 for field in unique_fields: 

355 logger.info(f"Duplicating {self}: ignoring unique field {field.name}") 

356 objdict.pop(field.name, None) 

357 

358 # remove related fields from dict representation 

359 related_fields = [ 

360 field for field in self._meta.get_fields() if field.is_relation 

361 ] 

362 for field in related_fields: 

363 objdict.pop(field.name, None) 

364 

365 newobj = type(self).objects.create(**objdict) 

366 

367 for field in related_fields: 

368 # we are not using `isinstance` because we want to 

369 # differentiate between different levels of inheritance 

370 if type(field) is ForeignKey: 

371 setattr(newobj, field.name, getattr(self, field.name)) 

372 if type(field) is ManyToManyField: 

373 objfield = getattr(newobj, field.name) 

374 values = getattr(self, field.name).all() 

375 objfield.set(values) 

376 

377 newobj.save() 

378 post_duplicate.send(sender=origin, instance=self, duplicate=newobj) 

379 return newobj 

380 

381 duplicate.alters_data = True 

382 

383 def uri_set(self): 

384 ct = ContentType.objects.get_for_model(self) 

385 return ( 

386 ContentType.objects.get(app_label="uris", model="uri") 

387 .model_class() 

388 .objects.filter(content_type=ct, object_id=self.id) 

389 .all() 

390 ) 

391 

392 def uri_set_with_importer(self): 

393 return [uri for uri in self.uri_set() if self.valid_import_url(uri.uri)] 

394 

395 

396class SimpleLabelModel(GenericModel): 

397 label = models.CharField( 

398 blank=True, default="", max_length=4096, verbose_name=_("label") 

399 ) 

400 

401 class Meta: 

402 abstract = True 

403 ordering = ["label"] 

404 

405 def __str__(self): 

406 return self.label or force_str(_("No label")) 

407 

408 @classmethod 

409 def create_from_string(cls, string): 

410 return cls.objects.create(label=string)