Coverage for apis_core/generic/views.py: 81%

352 statements  

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

1import logging 

2import traceback 

3from collections import namedtuple 

4from copy import copy 

5 

6from crispy_forms.layout import Field 

7from dal import autocomplete 

8from django import http 

9from django.conf import settings 

10from django.contrib import messages 

11from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin 

12from django.contrib.contenttypes.models import ContentType 

13from django.contrib.messages.views import SuccessMessageMixin 

14from django.core.exceptions import ImproperlyConfigured, ValidationError 

15from django.core.validators import URLValidator 

16from django.db import transaction 

17from django.db.models.fields.related import ManyToManyRel 

18from django.forms import modelform_factory 

19from django.forms.utils import pretty_name 

20from django.http import QueryDict 

21from django.shortcuts import get_object_or_404, redirect 

22from django.template.exceptions import TemplateDoesNotExist 

23from django.template.loader import select_template 

24from django.utils.text import capfirst 

25from django.views import View 

26from django.views.generic import DetailView 

27from django.views.generic.base import TemplateView 

28from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView 

29from django_filters.filterset import filterset_factory 

30from django_filters.views import FilterView 

31from django_tables2 import SingleTableMixin 

32from django_tables2.columns import library 

33from django_tables2.export.views import ExportMixin 

34from django_tables2.tables import table_factory 

35 

36from apis_core.generic.utils import get_autocomplete_data_and_normalized_uri 

37from apis_core.uris.models import Uri 

38 

39from .filtersets import GenericFilterSet 

40from .forms import ( 

41 ColumnsSelectorForm, 

42 GenericEnrichForm, 

43 GenericImportForm, 

44 GenericMergeWithForm, 

45 GenericModelForm, 

46 GenericSelectMergeOrEnrichForm, 

47) 

48from .helpers import ( 

49 first_member_match, 

50 generate_search_filter, 

51 module_paths, 

52 permission_fullname, 

53 template_names_via_mro, 

54) 

55from .tables import GenericTable 

56 

57logger = logging.getLogger(__name__) 

58 

59 

60class Overview(LoginRequiredMixin, TemplateView): 

61 template_name = "generic/overview.html" 

62 

63 

64class GenericModelPermissionRequiredMixin(PermissionRequiredMixin): 

65 """ 

66 Verify that the current user has the required permission for this model. 

67 The model overrides the `PermissionRequiredMixin.get_permission_required` 

68 method to generate the required permission name on the fly, based on a 

69 verb (`permission_action_required`) and the model this view act upon. 

70 This allows us to set `permission_action_required` simply to `add`, or 

71 `view` and reuse the mixin for views that work with different models. 

72 In addition, for the views that have `permission_action_required` set to 

73 `view`, it check if there is the global setting `APIS_ANON_VIEWS_ALLOWED` 

74 set to `True`, which permits anonymouse users access to the view. 

75 """ 

76 

77 def get_permission_required(self): 

78 if not hasattr(self, "model"): 

79 raise ImproperlyConfigured( 

80 f"{self.__class__.__name__} is missing the model attribute" 

81 ) 

82 if getattr(self, "permission_action_required", None) == "view" and getattr( 

83 settings, "APIS_ANON_VIEWS_ALLOWED", False 

84 ): 

85 return [] 

86 if hasattr(self, "permission_action_required"): 

87 return [permission_fullname(self.permission_action_required, self.model)] 

88 return [] 

89 

90 

91class GenericModelMixin: 

92 """ 

93 A mixin providing the common functionality for all the views working 

94 with `generic` models - that is models that are accessed via the 

95 contenttype framework (using `app_label.model`). 

96 It sets the `.model` of the view and generates a list of possible template 

97 names (based on the MRO of the model). 

98 If the view has a `permission_action_required` attribute, this is used 

99 to set the permission required to access the view for this specific model. 

100 """ 

101 

102 def setup(self, *args, **kwargs): 

103 super().setup(*args, **kwargs) 

104 if contenttype := kwargs.get("contenttype"): 

105 self.model = contenttype.model_class() 

106 self.queryset = self.model.objects.all() 

107 

108 def get_template_names(self): 

109 template_names = [] 

110 if hasattr(super(), "get_template_names"): 

111 # Some parent classes come with custom template_names, 

112 # some need a `.template_name` attribute set. For the 

113 # latter ones we handle the missing `.template_name` 

114 # gracefully 

115 try: 

116 template_names = super().get_template_names() 

117 except ImproperlyConfigured: 

118 pass 

119 suffix = ".html" 

120 if hasattr(self, "template_name_suffix"): 

121 suffix = self.template_name_suffix + ".html" 

122 additional_templates = template_names_via_mro(self.model, suffix=suffix) 

123 template_names += filter( 

124 lambda template: template not in template_names, additional_templates 

125 ) 

126 return template_names 

127 

128 

129class List( 

130 GenericModelMixin, 

131 GenericModelPermissionRequiredMixin, 

132 ExportMixin, 

133 SingleTableMixin, 

134 FilterView, 

135): 

136 """ 

137 List view for a generic model. 

138 Access requires the `<model>_view` permission. 

139 It is based on django-filters FilterView and django-tables SingleTableMixin. 

140 The table class is overridden by the first match from 

141 the `first_member_match` helper. 

142 The filterset class is overridden by the first match from 

143 the `first_member_match` helper. 

144 The queryset is overridden by the first match from 

145 the `first_member_match` helper. 

146 """ 

147 

148 template_name_suffix = "_list" 

149 permission_action_required = "view" 

150 

151 def setup(self, *args, **kwargs): 

152 super().setup(*args, **kwargs) 

153 self.cookie_name = f"{self.model._meta.label_lower}-list" 

154 cookie = QueryDict(self.request.COOKIES.get(self.cookie_name, "")) 

155 get = self.request.GET.copy() 

156 for prefix in ["filterset", "choices"]: 

157 id_ = f"{prefix}-remember" 

158 passed_prefix = any([prefix in key for key in self.request.GET.keys()]) 

159 use_cookie = cookie.get(id_, False) and not passed_prefix 

160 if use_cookie: 

161 for key in [key for key in cookie if key.startswith(prefix)]: 

162 get.setlist(key, cookie.getlist(key)) 

163 if "sort" not in get.keys() and "sort" in cookie.keys(): 

164 get["sort"] = cookie.get("sort") 

165 self.request.GET = get 

166 

167 def get(self, request, *args, **kwargs): 

168 response = super().get(request, *args, **kwargs) 

169 response.set_cookie(self.cookie_name, self.request.GET.urlencode()) 

170 return response 

171 

172 def get_table_class(self): 

173 table_modules = module_paths(self.model, path="tables", suffix="Table") 

174 table_class = first_member_match(table_modules, GenericTable) 

175 return table_factory(self.model, table_class) 

176 

177 export_formats = getattr(settings, "EXPORT_FORMATS", ["csv", "json"]) 

178 

179 def get_export_filename(self, extension): 

180 table_class = self.get_table_class() 

181 if hasattr(table_class, "export_filename"): 

182 return f"{table_class.export_filename}.{extension}" 

183 

184 return super().get_export_filename(extension) 

185 

186 def get_table_kwargs(self): 

187 kwargs = super().get_table_kwargs() 

188 

189 selected_columns = self.request.GET.getlist("choices-columns", []) 

190 modelfields = self.model._meta.get_fields() 

191 # if the form was submitted, we look at the selected 

192 # columns and exclude all columns that are not part of that list 

193 if self.request.GET and "choices-columns" in self.request.GET: 

194 columns_exclude = self.get_filterset_class().Meta.form.columns_exclude 

195 other_columns = [ 

196 name for (name, field) in self._get_columns_choices(columns_exclude) 

197 ] 

198 kwargs["exclude"] = [ 

199 field for field in other_columns if field not in selected_columns 

200 ] 

201 

202 # now we look at the selected columns and 

203 # add all modelfields and annotated fields that 

204 # are part of the selected columns to the extra_columns 

205 annotationfields = list() 

206 for key, value in self.object_list.query.annotations.items(): 

207 # we have to use copy, so we don't edit the original field 

208 fake_field = copy(getattr(value, "field", value.output_field)) 

209 setattr(fake_field, "name", key) 

210 annotationfields.append(fake_field) 

211 extra_fields = list( 

212 filter( 

213 lambda x: x.name in selected_columns, 

214 modelfields + tuple(annotationfields), 

215 ) 

216 ) 

217 kwargs["extra_columns"] = [ 

218 (field.name, library.column_for_field(field, accessor=field.name)) 

219 for field in extra_fields 

220 if field.name not in self.get_table_class().base_columns 

221 ] 

222 

223 return kwargs 

224 

225 def get_filterset_class(self): 

226 filterset_modules = module_paths( 

227 self.model, path="filtersets", suffix="FilterSet" 

228 ) 

229 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

230 return filterset_factory(self.model, filterset_class) 

231 

232 def _get_columns_choices(self, columns_exclude): 

233 # lets start with the custom table fields 

234 choices = { 

235 key.name: capfirst(str(key) or key.name or "Nameless column") 

236 for key in self.get_table().columns 

237 } 

238 # then add the model fields, but only the ones 

239 # that are not automatically created (parent keys) 

240 # and not the m2m relations and not any that are 

241 # already part of the choices 

242 choices |= { 

243 field.name: pretty_name(getattr(field, "verbose_name", field.name)) 

244 for field in self.model._meta.get_fields() 

245 if not getattr(field, "auto_created", False) 

246 and not isinstance(field, ManyToManyRel) 

247 and field.name not in choices.keys() 

248 } 

249 # finally we add any annotated fields 

250 choices |= {key: key for key in self.get_queryset().query.annotations.keys()} 

251 # now we drop all the choices that are listed in columns_exclude 

252 choices = { 

253 key: value for key, value in choices.items() if key not in columns_exclude 

254 } 

255 return choices.items() 

256 

257 def get_filterset_kwargs(self, filterset_class): 

258 kwargs = super().get_filterset_kwargs(filterset_class) 

259 kwargs["prefix"] = "filterset" 

260 return kwargs 

261 

262 def get_filterset(self, filterset_class): 

263 """ 

264 We override the `get_filterset` method, so we can add a 

265 css class to the the selected filters 

266 """ 

267 filterset = super().get_filterset(filterset_class) 

268 

269 # If the filterset form contains form data 

270 # we add a CSS class to the element wrapping 

271 # that field in HTML. This CSS class can be 

272 # used to emphasize the fields that are used. 

273 # To be able to compare the fields with the form 

274 # data, we create a temporary mapping between 

275 # widget_names and fields 

276 fields = {} 

277 for name, field in filterset.form.fields.items(): 

278 fields[name] = name 

279 if hasattr(field.widget, "widgets_names"): 

280 for widget_name in field.widget.widgets_names: 

281 fields[name + widget_name] = name 

282 if filterset.form.is_valid(): 

283 data = filterset.form.cleaned_data 

284 for param in [param for param, value in data.items() if value]: 

285 if fieldname := fields.get(param, None): 

286 filterset.form.helper[fieldname].wrap( 

287 Field, wrapper_class="filter-input-selected" 

288 ) 

289 

290 return filterset 

291 

292 def get_queryset(self): 

293 queryset_methods = module_paths( 

294 self.model, path="querysets", suffix="ListViewQueryset" 

295 ) 

296 queryset = first_member_match(queryset_methods) or (lambda x: x) 

297 return queryset(self.model.objects.all()) 

298 

299 def get_table_pagination(self, table): 

300 """ 

301 Override `get_table_pagination` from the tables2 TableMixinBase, 

302 so we can set the table_pagination value as attribute of the table. 

303 """ 

304 self.table_pagination = getattr(table, "table_pagination", None) 

305 return super().get_table_pagination(table) 

306 

307 def get_context_data(self, *args, **kwargs): 

308 context = super().get_context_data(*args, **kwargs) 

309 table = context.get("table", None) 

310 filterset = context.get("filter", None) 

311 context["filterset_remember"] = ( 

312 self.request.GET.get("filterset-remember", "") == "on" 

313 ) 

314 if table and filterset: 

315 columns_exclude = filterset.form.columns_exclude 

316 initial_columns = [ 

317 col.name for col in table.columns if col.name not in columns_exclude 

318 ] 

319 data = ( 

320 self.request.GET 

321 if any(["choices" in key for key in self.request.GET.keys()]) 

322 else None 

323 ) 

324 if choices := self._get_columns_choices(columns_exclude=columns_exclude): 

325 context["columns_selector"] = ColumnsSelectorForm( 

326 choices=choices, 

327 initial={"choices": initial_columns}, 

328 prefix="choices", 

329 data=data, 

330 ) 

331 return context 

332 

333 

334class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView): 

335 """ 

336 Detail view for a generic model. 

337 Access requires the `<model>_view` permission. 

338 """ 

339 

340 permission_action_required = "view" 

341 

342 

343class Create( 

344 GenericModelMixin, 

345 GenericModelPermissionRequiredMixin, 

346 SuccessMessageMixin, 

347 CreateView, 

348): 

349 """ 

350 Create view for a generic model. 

351 Access requires the `<model>_add` permission. 

352 The form class is overridden by the first match from 

353 the `first_member_match` helper. 

354 """ 

355 

356 template_name_suffix = "_create" 

357 permission_action_required = "add" 

358 

359 def get_form_class(self): 

360 form_modules = module_paths(self.model, path="forms", suffix="Form") 

361 form_class = first_member_match(form_modules, GenericModelForm) 

362 return modelform_factory(self.model, form_class) 

363 

364 def get_success_message(self, cleaned_data): 

365 message_templates = template_names_via_mro( 

366 self.model, suffix="_create_success_message.html" 

367 ) 

368 template = select_template(message_templates) 

369 return template.render({"object": self.object}) 

370 

371 def get_success_url(self): 

372 return self.object.get_create_success_url(request=self.request) 

373 

374 

375class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView): 

376 """ 

377 Delete view for a generic model. 

378 Access requires the `<model>_delete` permission. 

379 """ 

380 

381 permission_action_required = "delete" 

382 

383 def get_success_url(self): 

384 return self.object.get_delete_success_url(request=self.request) 

385 

386 

387class Update( 

388 GenericModelMixin, 

389 GenericModelPermissionRequiredMixin, 

390 SuccessMessageMixin, 

391 UpdateView, 

392): 

393 """ 

394 Update view for a generic model. 

395 Access requires the `<model>_change` permission. 

396 The form class is overridden by the first match from 

397 the `first_member_match` helper. 

398 """ 

399 

400 permission_action_required = "change" 

401 

402 def get_form_class(self): 

403 form_modules = module_paths(self.model, path="forms", suffix="Form") 

404 form_class = first_member_match(form_modules, GenericModelForm) 

405 return modelform_factory(self.model, form_class) 

406 

407 def get_success_message(self, cleaned_data): 

408 message_templates = template_names_via_mro( 

409 self.model, suffix="_update_success_message.html" 

410 ) 

411 template = select_template(message_templates) 

412 return template.render({"object": self.object}) 

413 

414 def get_success_url(self): 

415 return self.object.get_update_success_url(request=self.request) 

416 

417 

418class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View): 

419 permission_action_required = "add" 

420 

421 def get(self, request, *args, **kwargs): 

422 source_obj = get_object_or_404(self.model, pk=kwargs["pk"]) 

423 newobj = source_obj.duplicate() 

424 

425 message_templates = template_names_via_mro( 

426 self.model, suffix="_duplicate_success_message.html" 

427 ) 

428 template = select_template(message_templates) 

429 messages.success(request, template.render({"object": source_obj})) 

430 return redirect(newobj.get_edit_url()) 

431 

432 

433class Autocomplete( 

434 GenericModelMixin, 

435 GenericModelPermissionRequiredMixin, 

436 autocomplete.Select2QuerySetView, 

437): 

438 """ 

439 Autocomplete view for a generic model. 

440 Access requires the `<model>_view` permission. 

441 The queryset is overridden by the first match from 

442 the `first_member_match` helper. 

443 """ 

444 

445 permission_action_required = "view" 

446 template_name_suffix = "_autocomplete_result" 

447 

448 def setup(self, *args, **kwargs): 

449 super().setup(*args, **kwargs) 

450 # We use a URI parameter to enable the create functionality in the 

451 # autocomplete dropdown. It is not important what the value of the 

452 # `create_field` is, because we override create_object anyway. 

453 self.create_field = self.request.GET.get("create", None) 

454 try: 

455 template = select_template(self.get_template_names()) 

456 self.template = template.template.name 

457 except TemplateDoesNotExist: 

458 self.template = None 

459 

460 def get_queryset(self): 

461 queryset_methods = module_paths( 

462 self.model, path="querysets", suffix="AutocompleteQueryset" 

463 ) 

464 queryset = first_member_match(queryset_methods) 

465 if queryset: 

466 return queryset(self.model, self.q) 

467 return self.model.objects.filter(generate_search_filter(self.model, self.q)) 

468 

469 def get_results(self, context): 

470 external_only = self.kwargs.get("external_only", False) 

471 results = [] if external_only else super().get_results(context) 

472 queryset_methods = module_paths( 

473 self.model, path="querysets", suffix="ExternalAutocomplete" 

474 ) 

475 ExternalAutocomplete = first_member_match(queryset_methods) 

476 if ExternalAutocomplete: 

477 results.extend(ExternalAutocomplete().get_results(self.q)) 

478 return results 

479 

480 def create_object(self, value): 

481 """ 

482 We try multiple approaches to create a model instance from a value: 

483 * we first test if the value is an URL and if so we expect it to be 

484 something that can be imported using one of the configured importers 

485 and so we pass the value to the import logic. 

486 * if the value is not a string, we try to pass it to the `create_from_string` 

487 method of the model, if that does exist. Its the models responsibility to 

488 implement this method and the method should somehow know how to create 

489 model instance from the value... 

490 * finally we pass the value to the `create_object` method from the DAL 

491 view, which tries to pass it to `get_or_create` which likely also fails, 

492 but this is expected and we raise a more useful exception. 

493 """ 

494 try: 

495 URLValidator()(value) 

496 return self.queryset.model.import_from(value) 

497 except ValidationError: 

498 pass 

499 try: 

500 return self.queryset.model.create_from_string(value) 

501 except AttributeError: 

502 raise ImproperlyConfigured( 

503 f'Model "{self.queryset.model._meta.verbose_name}" not configured to create from string' 

504 ) 

505 

506 def post(self, request, *args, **kwargs): 

507 try: 

508 with transaction.atomic(): 

509 return super().post(request, *args, **kwargs) 

510 except Exception as e: 

511 logger.debug(traceback.format_exc()) 

512 return http.JsonResponse({"error": str(e)}) 

513 

514 

515class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

516 template_name_suffix = "_import" 

517 permission_action_required = "add" 

518 

519 def get_form_class(self): 

520 form_modules = module_paths(self.model, path="forms", suffix="ImportForm") 

521 form_class = first_member_match(form_modules, GenericImportForm) 

522 return modelform_factory(self.model, form_class) 

523 

524 def form_valid(self, form): 

525 self.object = form.cleaned_data["url"] 

526 for field, error in getattr(self.object, "_import_errors", {}).items(): 

527 messages.error(self.request, f"Could not set {field}: {error}") 

528 return super().form_valid(form) 

529 

530 def get_success_url(self): 

531 return self.object.get_absolute_url() 

532 

533 

534class SelectMergeOrEnrich( 

535 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView 

536): 

537 """ 

538 This view provides a simple form that allows to select other entities (also from 

539 external sources, if set up) and on form submit redirects to the Enrich view. 

540 """ 

541 

542 template_name_suffix = "_selectmergeorenrich" 

543 permission_action_required = "add" 

544 form_class = GenericSelectMergeOrEnrichForm 

545 

546 def get_object(self, *args, **kwargs): 

547 return get_object_or_404(self.model, pk=self.kwargs.get("pk")) 

548 

549 def get_context_data(self, *args, **kwargs): 

550 context = super().get_context_data(*args, **kwargs) 

551 context["object"] = self.get_object() 

552 return context 

553 

554 def get_form_kwargs(self, *args, **kwargs): 

555 kwargs = super().get_form_kwargs(*args, **kwargs) 

556 kwargs["content_type"] = ContentType.objects.get_for_model(self.model) 

557 return kwargs 

558 

559 def form_valid(self, form): 

560 uri = form.cleaned_data["uri"] 

561 if uri.isdigit(): 

562 return redirect(self.get_object().get_merge_url(uri)) 

563 return redirect(self.get_object().get_enrich_url() + f"?uri={uri}") 

564 

565 

566class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

567 """ 

568 Generic merge view. 

569 """ 

570 

571 permission_action_required = "change" 

572 form_class = GenericMergeWithForm 

573 template_name_suffix = "_merge" 

574 

575 def setup(self, *args, **kwargs): 

576 super().setup(*args, **kwargs) 

577 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"]) 

578 self.other = get_object_or_404(self.model, pk=self.kwargs["otherpk"]) 

579 

580 def get_context_data(self, **kwargs): 

581 """ 

582 The context consists of the two objects that are merged as well 

583 as a list of changes. Those changes are presented in the view as 

584 a table with diffs 

585 """ 

586 Change = namedtuple("Change", "field old new") 

587 ctx = super().get_context_data(**kwargs) 

588 ctx["changes"] = [] 

589 for field in self.object._meta.fields: 

590 newval = self.object.get_field_value_after_merge(self.other, field) 

591 ctx["changes"].append( 

592 Change(field.verbose_name, getattr(self.object, field.name), newval) 

593 ) 

594 ctx["object"] = self.object 

595 ctx["other"] = self.other 

596 return ctx 

597 

598 def form_valid(self, form): 

599 self.object.merge_with([self.other]) 

600 messages.info(self.request, f"Merged values of {self.other} into {self.object}") 

601 return super().form_valid(form) 

602 

603 def get_success_url(self): 

604 return self.object.get_absolute_url() 

605 

606 

607class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

608 """ 

609 Enrich an entity with data from an external source 

610 Provides the user with a form to select the fields that should be updated. 

611 """ 

612 

613 permission_action_required = "change" 

614 template_name_suffix = "_enrich" 

615 form_class = GenericEnrichForm 

616 importer_class = None 

617 

618 def setup(self, *args, **kwargs): 

619 super().setup(*args, **kwargs) 

620 self.object = get_object_or_404(self.model, pk=self.kwargs["pk"]) 

621 _, self.uri = get_autocomplete_data_and_normalized_uri( 

622 self.request.GET.get("uri") 

623 ) 

624 if not self.uri: 

625 messages.error(self.request, "No uri parameter specified.") 

626 

627 def get(self, *args, **kwargs): 

628 try: 

629 uriobj = Uri.objects.get(uri=self.uri) 

630 if uriobj.object_id != self.object.id: 

631 messages.info( 

632 self.request, 

633 f"Object with URI {self.uri} already exists, you were redirected to the merge form.", 

634 ) 

635 return redirect(self.object.get_merge_url(uriobj.object_id)) 

636 except Uri.DoesNotExist: 

637 pass 

638 return super().get(*args, **kwargs) 

639 

640 def get_context_data(self, **kwargs): 

641 ctx = super().get_context_data(**kwargs) 

642 ctx["object"] = self.object 

643 ctx["uri"] = self.uri 

644 return ctx 

645 

646 def get_form_kwargs(self, *args, **kwargs): 

647 kwargs = super().get_form_kwargs(*args, **kwargs) 

648 kwargs["instance"] = self.object 

649 try: 

650 self.data = self.model.fetch_from(self.request.GET.get("uri")) 

651 kwargs["data"] = self.data 

652 except ImproperlyConfigured as e: 

653 messages.error(self.request, e) 

654 return kwargs 

655 

656 def form_valid(self, form): 

657 """ 

658 Go through all the form fields and extract the ones that 

659 start with `update_` and that are set (those are the checkboxes that 

660 select which fields to update). 

661 Create a dict from those values, add the uri and pass the dict on to 

662 the models `import_data` method. 

663 """ 

664 data = {} 

665 for key, values in self.request.POST.items(): 

666 if key.startswith("update_"): 

667 key = key.removeprefix("update_") 

668 data[key] = self.data[key] 

669 if data: 

670 self.object.import_data(data) 

671 for field, error in getattr(self.object, "_import_errors", {}).items(): 

672 messages.error(self.request, f"Could not update {field}: {error}") 

673 messages.info(self.request, f"Updated fields {data.keys()}") 

674 return super().form_valid(form) 

675 

676 def get_success_url(self): 

677 return self.object.get_absolute_url()