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

353 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-08-21 08:53 +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 content_type = ContentType.objects.get_for_model(self.model) 

154 self.cookie_name = f"{content_type.app_label}.{content_type.model}-list" 

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

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

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

158 id_ = f"{prefix}-remember" 

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

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

161 if use_cookie: 

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

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

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

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

166 self.request.GET = get 

167 

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

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

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

171 return response 

172 

173 def get_table_class(self): 

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

175 table_class = first_member_match(table_modules, GenericTable) 

176 return table_factory(self.model, table_class) 

177 

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

179 

180 def get_export_filename(self, extension): 

181 table_class = self.get_table_class() 

182 if hasattr(table_class, "export_filename"): 

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

184 

185 return super().get_export_filename(extension) 

186 

187 def get_table_kwargs(self): 

188 kwargs = super().get_table_kwargs() 

189 

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

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

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

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

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

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

196 other_columns = [ 

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

198 ] 

199 kwargs["exclude"] = [ 

200 field for field in other_columns if field not in selected_columns 

201 ] 

202 

203 # now we look at the selected columns and 

204 # add all modelfields and annotated fields that 

205 # are part of the selected columns to the extra_columns 

206 annotationfields = list() 

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

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

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

210 setattr(fake_field, "name", key) 

211 annotationfields.append(fake_field) 

212 extra_fields = list( 

213 filter( 

214 lambda x: x.name in selected_columns, 

215 modelfields + tuple(annotationfields), 

216 ) 

217 ) 

218 kwargs["extra_columns"] = [ 

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

220 for field in extra_fields 

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

222 ] 

223 

224 return kwargs 

225 

226 def get_filterset_class(self): 

227 filterset_modules = module_paths( 

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

229 ) 

230 filterset_class = first_member_match(filterset_modules, GenericFilterSet) 

231 return filterset_factory(self.model, filterset_class) 

232 

233 def _get_columns_choices(self, columns_exclude): 

234 # lets start with the custom table fields 

235 choices = { 

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

237 for key in self.get_table().columns 

238 } 

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

240 # that are not automatically created (parent keys) 

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

242 # already part of the choices 

243 choices |= { 

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

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

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

247 and not isinstance(field, ManyToManyRel) 

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

249 } 

250 # finally we add any annotated fields 

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

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

253 choices = { 

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

255 } 

256 return choices.items() 

257 

258 def get_filterset_kwargs(self, filterset_class): 

259 kwargs = super().get_filterset_kwargs(filterset_class) 

260 kwargs["prefix"] = "filterset" 

261 return kwargs 

262 

263 def get_filterset(self, filterset_class): 

264 """ 

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

266 css class to the the selected filters 

267 """ 

268 filterset = super().get_filterset(filterset_class) 

269 

270 # If the filterset form contains form data 

271 # we add a CSS class to the element wrapping 

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

273 # used to emphasize the fields that are used. 

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

275 # data, we create a temporary mapping between 

276 # widget_names and fields 

277 fields = {} 

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

279 fields[name] = name 

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

281 for widget_name in field.widget.widgets_names: 

282 fields[name + widget_name] = name 

283 if filterset.form.is_valid(): 

284 data = filterset.form.cleaned_data 

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

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

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

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

289 ) 

290 

291 return filterset 

292 

293 def get_queryset(self): 

294 queryset_methods = module_paths( 

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

296 ) 

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

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

299 

300 def get_table_pagination(self, table): 

301 """ 

302 Override `get_table_pagination` from the tables2 TableMixinBase, 

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

304 """ 

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

306 return super().get_table_pagination(table) 

307 

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

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

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

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

312 context["filterset_remember"] = ( 

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

314 ) 

315 if table and filterset: 

316 columns_exclude = filterset.form.columns_exclude 

317 initial_columns = [ 

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

319 ] 

320 data = ( 

321 self.request.GET 

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

323 else None 

324 ) 

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

326 context["columns_selector"] = ColumnsSelectorForm( 

327 choices=choices, 

328 initial={"choices": initial_columns}, 

329 prefix="choices", 

330 data=data, 

331 ) 

332 return context 

333 

334 

335class Detail(GenericModelMixin, GenericModelPermissionRequiredMixin, DetailView): 

336 """ 

337 Detail view for a generic model. 

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

339 """ 

340 

341 permission_action_required = "view" 

342 

343 

344class Create( 

345 GenericModelMixin, 

346 GenericModelPermissionRequiredMixin, 

347 SuccessMessageMixin, 

348 CreateView, 

349): 

350 """ 

351 Create view for a generic model. 

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

353 The form class is overridden by the first match from 

354 the `first_member_match` helper. 

355 """ 

356 

357 template_name_suffix = "_create" 

358 permission_action_required = "add" 

359 

360 def get_form_class(self): 

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

362 form_class = first_member_match(form_modules, GenericModelForm) 

363 return modelform_factory(self.model, form_class) 

364 

365 def get_success_message(self, cleaned_data): 

366 message_templates = template_names_via_mro( 

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

368 ) 

369 template = select_template(message_templates) 

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

371 

372 def get_success_url(self): 

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

374 

375 

376class Delete(GenericModelMixin, GenericModelPermissionRequiredMixin, DeleteView): 

377 """ 

378 Delete view for a generic model. 

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

380 """ 

381 

382 permission_action_required = "delete" 

383 

384 def get_success_url(self): 

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

386 

387 

388class Update( 

389 GenericModelMixin, 

390 GenericModelPermissionRequiredMixin, 

391 SuccessMessageMixin, 

392 UpdateView, 

393): 

394 """ 

395 Update view for a generic model. 

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

397 The form class is overridden by the first match from 

398 the `first_member_match` helper. 

399 """ 

400 

401 permission_action_required = "change" 

402 

403 def get_form_class(self): 

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

405 form_class = first_member_match(form_modules, GenericModelForm) 

406 return modelform_factory(self.model, form_class) 

407 

408 def get_success_message(self, cleaned_data): 

409 message_templates = template_names_via_mro( 

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

411 ) 

412 template = select_template(message_templates) 

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

414 

415 def get_success_url(self): 

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

417 

418 

419class Duplicate(GenericModelMixin, GenericModelPermissionRequiredMixin, View): 

420 permission_action_required = "add" 

421 

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

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

424 newobj = source_obj.duplicate() 

425 

426 message_templates = template_names_via_mro( 

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

428 ) 

429 template = select_template(message_templates) 

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

431 return redirect(newobj.get_edit_url()) 

432 

433 

434class Autocomplete( 

435 GenericModelMixin, 

436 GenericModelPermissionRequiredMixin, 

437 autocomplete.Select2QuerySetView, 

438): 

439 """ 

440 Autocomplete view for a generic model. 

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

442 The queryset is overridden by the first match from 

443 the `first_member_match` helper. 

444 """ 

445 

446 permission_action_required = "view" 

447 template_name_suffix = "_autocomplete_result" 

448 

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

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

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

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

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

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

455 try: 

456 template = select_template(self.get_template_names()) 

457 self.template = template.template.name 

458 except TemplateDoesNotExist: 

459 self.template = None 

460 

461 def get_queryset(self): 

462 queryset_methods = module_paths( 

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

464 ) 

465 queryset = first_member_match(queryset_methods) 

466 if queryset: 

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

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

469 

470 def get_results(self, context): 

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

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

473 queryset_methods = module_paths( 

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

475 ) 

476 ExternalAutocomplete = first_member_match(queryset_methods) 

477 if ExternalAutocomplete: 

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

479 return results 

480 

481 def create_object(self, value): 

482 """ 

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

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

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

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

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

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

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

490 model instance from the value... 

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

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

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

494 """ 

495 try: 

496 URLValidator()(value) 

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

498 except ValidationError: 

499 pass 

500 try: 

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

502 except AttributeError: 

503 raise ImproperlyConfigured( 

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

505 ) 

506 

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

508 try: 

509 with transaction.atomic(): 

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

511 except Exception as e: 

512 logger.debug(traceback.format_exc()) 

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

514 

515 

516class Import(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

517 template_name_suffix = "_import" 

518 permission_action_required = "add" 

519 

520 def get_form_class(self): 

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

522 form_class = first_member_match(form_modules, GenericImportForm) 

523 return modelform_factory(self.model, form_class) 

524 

525 def form_valid(self, form): 

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

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

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

529 return super().form_valid(form) 

530 

531 def get_success_url(self): 

532 return self.object.get_absolute_url() 

533 

534 

535class SelectMergeOrEnrich( 

536 GenericModelMixin, GenericModelPermissionRequiredMixin, FormView 

537): 

538 """ 

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

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

541 """ 

542 

543 template_name_suffix = "_selectmergeorenrich" 

544 permission_action_required = "add" 

545 form_class = GenericSelectMergeOrEnrichForm 

546 

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

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

549 

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

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

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

553 return context 

554 

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

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

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

558 return kwargs 

559 

560 def form_valid(self, form): 

561 uri = form.cleaned_data["uri"] 

562 if uri.isdigit(): 

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

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

565 

566 

567class MergeWith(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

568 """ 

569 Generic merge view. 

570 """ 

571 

572 permission_action_required = "change" 

573 form_class = GenericMergeWithForm 

574 template_name_suffix = "_merge" 

575 

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

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

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

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

580 

581 def get_context_data(self, **kwargs): 

582 """ 

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

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

585 a table with diffs 

586 """ 

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

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

589 ctx["changes"] = [] 

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

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

592 ctx["changes"].append( 

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

594 ) 

595 ctx["object"] = self.object 

596 ctx["other"] = self.other 

597 return ctx 

598 

599 def form_valid(self, form): 

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

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

602 return super().form_valid(form) 

603 

604 def get_success_url(self): 

605 return self.object.get_absolute_url() 

606 

607 

608class Enrich(GenericModelMixin, GenericModelPermissionRequiredMixin, FormView): 

609 """ 

610 Enrich an entity with data from an external source 

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

612 """ 

613 

614 permission_action_required = "change" 

615 template_name_suffix = "_enrich" 

616 form_class = GenericEnrichForm 

617 importer_class = None 

618 

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

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

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

622 _, self.uri = get_autocomplete_data_and_normalized_uri( 

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

624 ) 

625 if not self.uri: 

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

627 

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

629 try: 

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

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

632 messages.info( 

633 self.request, 

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

635 ) 

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

637 except Uri.DoesNotExist: 

638 pass 

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

640 

641 def get_context_data(self, **kwargs): 

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

643 ctx["object"] = self.object 

644 ctx["uri"] = self.uri 

645 return ctx 

646 

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

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

649 kwargs["instance"] = self.object 

650 try: 

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

652 kwargs["data"] = self.data 

653 except ImproperlyConfigured as e: 

654 messages.error(self.request, e) 

655 return kwargs 

656 

657 def form_valid(self, form): 

658 """ 

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

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

661 select which fields to update). 

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

663 the models `import_data` method. 

664 """ 

665 data = {} 

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

667 if key.startswith("update_"): 

668 key = key.removeprefix("update_") 

669 data[key] = self.data[key] 

670 if data: 

671 self.object.import_data(data) 

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

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

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

675 return super().form_valid(form) 

676 

677 def get_success_url(self): 

678 return self.object.get_absolute_url()