Coverage for src/django_resume/plugins/base.py: 95%

510 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-11 12:04 +0200

1from uuid import uuid4 

2 

3from typing import Protocol, runtime_checkable, Callable, TypeAlias, Any 

4 

5from django import forms 

6from django.contrib.auth.decorators import login_required 

7from django.http import HttpResponse, HttpRequest 

8from django.shortcuts import get_object_or_404, render 

9from django.urls import reverse, path, URLPattern 

10from django.utils.html import format_html 

11from django.core.exceptions import PermissionDenied 

12 

13from ..models import Resume 

14 

15 

16URLPatterns: TypeAlias = list[URLPattern] 

17FormClasses: TypeAlias = dict[str, type[forms.Form]] 

18ContextDict: TypeAlias = dict[str, Any] 

19 

20 

21@runtime_checkable 

22class Plugin(Protocol): 

23 name: str 

24 verbose_name: str 

25 form_classes: FormClasses 

26 

27 def get_admin_urls(self, admin_view: Callable) -> URLPatterns: 

28 """Return a list of urls that are used to manage the plugin data in the Django admin interface.""" 

29 ... # pragma: no cover 

30 

31 def get_admin_link(self, resume_id: int) -> str: 

32 """Return a formatted html link to the main admin view for this plugin.""" 

33 ... # pragma: no cover 

34 

35 def get_inline_urls(self) -> URLPatterns: 

36 """Return a list of urls that are used to manage the plugin data inline.""" 

37 ... # pragma: no cover 

38 

39 def get_form_classes(self) -> FormClasses: 

40 """ 

41 Return a dictionary of form classes that are used to manage the plugin data. 

42 Overwrite this method or set the form_classes attribute. 

43 """ 

44 ... # pragma: no cover 

45 

46 def get_data(self, resume: Resume) -> dict: 

47 """Return the plugin data for a resume.""" 

48 ... # pragma: no cover 

49 

50 def get_context( 

51 self, 

52 request: HttpRequest, 

53 plugin_data: dict, 

54 resume_pk: int, 

55 *, 

56 context: dict, 

57 edit: bool = False, 

58 ) -> object: 

59 """Return the object which is stored in context for the plugin.""" 

60 ... # pragma: no cover 

61 

62 

63class SimpleData: 

64 def __init__(self, *, plugin_name: str): 

65 self.plugin_name = plugin_name 

66 

67 def get_data(self, resume: Resume) -> dict: 

68 return resume.plugin_data.get(self.plugin_name, {}) 

69 

70 def set_data(self, resume: Resume, data: dict) -> Resume: 

71 if not resume.plugin_data: 

72 resume.plugin_data = {} 

73 resume.plugin_data[self.plugin_name] = data 

74 return resume 

75 

76 def create(self, resume: Resume, data: dict) -> Resume: 

77 return self.set_data(resume, data) 

78 

79 def update(self, resume: Resume, data: dict) -> Resume: 

80 return self.set_data(resume, data) 

81 

82 

83class SimpleJsonForm(forms.Form): 

84 plugin_data = forms.JSONField(widget=forms.Textarea) 

85 

86 

87class SimpleAdmin: 

88 admin_template = "django_resume/admin/simple_plugin_admin_view.html" 

89 change_form = "django_resume/admin/simple_plugin_admin_form.html" 

90 

91 def __init__( 

92 self, 

93 *, 

94 plugin_name: str, 

95 plugin_verbose_name, 

96 form_class: type[forms.Form], 

97 data: SimpleData, 

98 ): 

99 self.plugin_name = plugin_name 

100 self.plugin_verbose_name = plugin_verbose_name 

101 self.form_class = form_class 

102 self.data = data 

103 

104 @staticmethod 

105 def check_permissions(request: HttpRequest, resume: Resume) -> bool: 

106 is_owner = resume.owner == request.user 

107 is_staff = request.user.is_staff 

108 return is_owner and is_staff 

109 

110 def get_resume_or_error(self, request: HttpRequest, resume_id: int) -> Resume: 

111 """Returns the resume or generates a 404 or 403 response.""" 

112 resume = get_object_or_404(Resume, id=resume_id) 

113 if not self.check_permissions(request, resume): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 raise PermissionDenied("Permission denied") 

115 return resume 

116 

117 def get_change_url(self, resume_id: int) -> str: 

118 return reverse( 

119 f"admin:{self.plugin_name}-admin-change", kwargs={"resume_id": resume_id} 

120 ) 

121 

122 def get_admin_link(self, resume_id: int) -> str: 

123 url = self.get_change_url(resume_id) 

124 return format_html( 

125 '<a href="{}">{}</a>', url, f"Edit {self.plugin_verbose_name}" 

126 ) 

127 

128 def get_change_post_url(self, resume_id: int) -> str: 

129 return reverse( 

130 f"admin:{self.plugin_name}-admin-post", kwargs={"resume_id": resume_id} 

131 ) 

132 

133 def get_change_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

134 """ 

135 Return the main admin view for this plugin. This view should display a form 

136 to edit the plugin data. 

137 """ 

138 resume = self.get_resume_or_error(request, resume_id) 

139 plugin_data = self.data.get_data(resume) 

140 if self.form_class == SimpleJsonForm: 

141 # special case for the SimpleJsonForm which has a JSONField for the plugin data 

142 form = self.form_class(initial={"plugin_data": plugin_data}) 

143 else: 

144 form = self.form_class(initial=plugin_data) 

145 form.post_url = self.get_change_post_url(resume.pk) 

146 context = { 

147 "title": f"{self.plugin_verbose_name} for {resume.name}", 

148 "resume": resume, 

149 "opts": Resume._meta, 

150 "form": form, 

151 "form_template": self.change_form, 

152 # context for admin/change_form.html template 

153 "add": False, 

154 "change": True, 

155 "is_popup": False, 

156 "save_as": False, 

157 "has_add_permission": False, 

158 "has_view_permission": True, 

159 "has_change_permission": True, 

160 "has_delete_permission": False, 

161 "has_editable_inline_admin_formsets": False, 

162 } 

163 return render(request, self.admin_template, context) 

164 

165 def post_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

166 """ 

167 Handle post requests to update the plugin data and returns either the main template or 

168 the form with errors. 

169 """ 

170 resume = self.get_resume_or_error(request, resume_id) 

171 form = self.form_class(request.POST) 

172 form.post_url = self.get_change_post_url(resume.pk) 

173 context = {"form": form} 

174 if form.is_valid(): 

175 if self.form_class == SimpleJsonForm: 

176 # special case for the SimpleJsonForm which has a JSONField for the plugin data 

177 plugin_data = form.cleaned_data["plugin_data"] 

178 else: 

179 plugin_data = form.cleaned_data 

180 resume = self.data.update(resume, plugin_data) 

181 resume.save() 

182 return render(request, self.change_form, context) 

183 

184 def get_urls(self, admin_view: Callable) -> URLPatterns: 

185 """ 

186 This method should return a list of urls that are used to manage the 

187 plugin data in the admin interface. 

188 """ 

189 plugin_name = self.plugin_name 

190 urls = [ 

191 path( 

192 f"<int:resume_id>/plugin/{plugin_name}/change/", 

193 login_required(admin_view(self.get_change_view)), 

194 name=f"{plugin_name}-admin-change", 

195 ), 

196 path( 

197 f"<int:resume_id>/plugin/{plugin_name}/post/", 

198 login_required(admin_view(self.post_view)), 

199 name=f"{plugin_name}-admin-post", 

200 ), 

201 ] 

202 return urls 

203 

204 

205class SimpleTemplates: 

206 def __init__(self, *, main: str, form: str): 

207 self.main = main 

208 self.form = form 

209 

210 

211class SimpleInline: 

212 def __init__( 

213 self, 

214 *, 

215 plugin_name: str, 

216 plugin_verbose_name: str, 

217 form_class: type[forms.Form], 

218 data: SimpleData, 

219 templates: SimpleTemplates, 

220 get_context: Callable, 

221 ): 

222 self.plugin_name = plugin_name 

223 self.plugin_verbose_name = plugin_verbose_name 

224 self.form_class = form_class 

225 self.data = data 

226 self.templates = templates 

227 self.get_context = get_context 

228 

229 def get_edit_url(self, resume_id: int) -> str: 

230 return reverse( 

231 f"django_resume:{self.plugin_name}-edit", kwargs={"resume_id": resume_id} 

232 ) 

233 

234 def get_post_url(self, resume_id: int) -> str: 

235 return reverse( 

236 f"django_resume:{self.plugin_name}-post", kwargs={"resume_id": resume_id} 

237 ) 

238 

239 @staticmethod 

240 def check_permissions(request: HttpRequest, resume: Resume) -> bool: 

241 return resume.owner == request.user 

242 

243 def get_resume_or_error(self, request: HttpRequest, resume_id: int) -> Resume: 

244 """Returns the resume or generates a 404 or 403 response.""" 

245 resume = get_object_or_404(Resume, id=resume_id) 

246 if not self.check_permissions(request, resume): 

247 raise PermissionDenied("Permission denied") 

248 return resume 

249 

250 def get_edit_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

251 """Return the inline edit form for the plugin.""" 

252 resume = self.get_resume_or_error(request, resume_id) 

253 plugin_data = self.data.get_data(resume) 

254 print("get edit view!") 

255 form = self.form_class(initial=plugin_data) 

256 form.post_url = self.get_post_url(resume.pk) 

257 context = {"form": form} 

258 return render(request, self.templates.form, context) 

259 

260 def post_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

261 """ 

262 Handle post requests to update the plugin data and returns either the main template or 

263 the form with errors. 

264 """ 

265 resume = self.get_resume_or_error(request, resume_id) 

266 plugin_data = self.data.get_data(resume) 

267 form_class = self.form_class 

268 print("post view: ", request.POST, request.FILES) 

269 form = form_class(request.POST, request.FILES, initial=plugin_data) 

270 form.post_url = self.get_post_url(resume.pk) 

271 context: dict[str, Any] = {"form": form} 

272 if form.is_valid(): 

273 # update the plugin data and render the main template 

274 resume = self.data.update(resume, form.cleaned_data) 

275 resume.save() 

276 context[self.plugin_name] = self.get_context( 

277 request, form.cleaned_data, resume.pk, context=context 

278 ) 

279 context["show_edit_button"] = True 

280 context[self.plugin_name].update(form.cleaned_data) 

281 context[self.plugin_name]["edit_url"] = self.get_edit_url(resume.pk) 

282 return render(request, self.templates.main, context) 

283 # render the form again with errors 

284 return render(request, self.templates.form, context) 

285 

286 def get_urls(self) -> URLPatterns: 

287 """ 

288 Return a list of urls that are used to manage the plugin data inline. 

289 """ 

290 plugin_name = self.plugin_name 

291 urls: URLPatterns = [ 

292 # flat 

293 path( 

294 f"<int:resume_id>/plugin/{plugin_name}/edit/", 

295 login_required(self.get_edit_view), 

296 name=f"{plugin_name}-edit", 

297 ), 

298 path( 

299 f"<int:resume_id>/plugin/{plugin_name}/edit/post/", 

300 login_required(self.post_view), 

301 name=f"{plugin_name}-post", 

302 ), 

303 ] 

304 return urls 

305 

306 

307class SimplePlugin: 

308 """ 

309 A simple plugin that only stores a json serializable dict of data. It's simple, 

310 because there is only one form for the plugin data and no items with IDs or other 

311 complex logic. 

312 """ 

313 

314 name = "simple_plugin" 

315 verbose_name = "Simple Plugin" 

316 templates: SimpleTemplates = SimpleTemplates( 

317 # those two templates are just a dummies - overwrite them 

318 main="django_resume/plain/simple_plugin.html", 

319 form="django_resume/plain/simple_plugin_form.html", 

320 ) 

321 

322 def __init__(self): 

323 super().__init__() 

324 self.data = data = SimpleData(plugin_name=self.name) 

325 self.admin = SimpleAdmin( 

326 plugin_name=self.name, 

327 plugin_verbose_name=self.verbose_name, 

328 form_class=self.get_admin_form_class(), 

329 data=data, 

330 ) 

331 self.inline = SimpleInline( 

332 plugin_name=self.name, 

333 plugin_verbose_name=self.verbose_name, 

334 form_class=self.get_inline_form_class(), 

335 data=data, 

336 templates=self.templates, 

337 get_context=self.get_context, 

338 ) 

339 

340 # plugin protocol methods 

341 

342 def get_context( 

343 self, 

344 _request: HttpRequest, 

345 plugin_data: dict, 

346 resume_pk: int, 

347 *, 

348 context: ContextDict, 

349 edit: bool = False, 

350 ) -> ContextDict: 

351 """This method returns the context of the plugin for inline editing.""" 

352 if plugin_data == {}: 

353 # no data yet, use initial data from inline form 

354 form = self.get_inline_form_class()() 

355 initial_values = { 

356 field_name: form.get_initial_for_field(field, field_name) 

357 for field_name, field in form.fields.items() 

358 } 

359 plugin_data = initial_values 

360 context.update(plugin_data) 

361 context["edit_url"] = self.inline.get_edit_url(resume_pk) 

362 context["show_edit_button"] = edit 

363 context["templates"] = self.templates 

364 return context 

365 

366 def get_admin_form_class(self) -> type[forms.Form]: 

367 """Set admin_form_class attribute or overwrite this method.""" 

368 if hasattr(self, "admin_form_class"): 

369 return self.admin_form_class 

370 return SimpleJsonForm # default 

371 

372 def get_inline_form_class(self) -> type[forms.Form]: 

373 """Set inline_form_class attribute or overwrite this method.""" 

374 if hasattr(self, "inline_form_class"): 

375 return self.inline_form_class 

376 return SimpleJsonForm # default 

377 

378 def get_admin_urls(self, admin_view: Callable) -> URLPatterns: 

379 return self.admin.get_urls(admin_view) 

380 

381 def get_admin_link(self, resume_id: int | None) -> str: 

382 if resume_id is None: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 return "" 

384 return self.admin.get_admin_link(resume_id) 

385 

386 def get_inline_urls(self) -> URLPatterns: 

387 return self.inline.get_urls() 

388 

389 def get_data(self, resume: Resume) -> dict: 

390 return self.data.get_data(resume) 

391 

392 

393class ListItemFormMixin(forms.Form): 

394 id = forms.CharField(widget=forms.HiddenInput(), required=False) 

395 

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

397 self.resume = kwargs.pop("resume") 

398 self.existing_items = kwargs.pop("existing_items", []) 

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

400 

401 @property 

402 def is_new(self): 

403 """Used to determine if the form is for a new item or an existing one.""" 

404 if self.is_bound: 

405 return False 

406 return not self.initial.get("id", False) 

407 

408 @property 

409 def item_id(self): 

410 """ 

411 Use an uuid for the item id if there is no id in the initial data. This is to 

412 allow the htmx delete button to work even when there are multiple new item 

413 forms on the page. 

414 """ 

415 if self.is_bound: 

416 return self.cleaned_data.get("id", uuid4()) 

417 if self.initial.get("id") is None: 

418 self.initial["id"] = uuid4() 

419 return self.initial["id"] 

420 

421 

422class ListTemplates: 

423 def __init__( 

424 self, *, main: str, flat: str, flat_form: str, item: str, item_form: str 

425 ): 

426 self.main = main 

427 self.flat = flat 

428 self.flat_form = flat_form 

429 self.item = item 

430 self.item_form = item_form 

431 

432 

433class ListData: 

434 """ 

435 This class contains the logic of the list plugin concerned with the data handling. 

436 

437 Simple crud operations are supported. 

438 """ 

439 

440 def __init__(self, *, plugin_name: str): 

441 self.plugin_name = plugin_name 

442 

443 # read 

444 def get_data(self, resume: Resume) -> dict: 

445 return resume.plugin_data.get(self.plugin_name, {}) 

446 

447 def get_item_by_id(self, resume: Resume, item_id: str) -> dict | None: 

448 items = self.get_data(resume).get("items", []) 

449 for item in items: 

450 if item["id"] == item_id: 

451 return item 

452 return None 

453 

454 # write 

455 def set_data(self, resume: Resume, data: dict) -> Resume: 

456 if not resume.plugin_data: 

457 resume.plugin_data = {} 

458 resume.plugin_data[self.plugin_name] = data 

459 return resume 

460 

461 def create(self, resume: Resume, data: dict) -> Resume: 

462 """Create an item in the items list of this plugin.""" 

463 plugin_data = self.get_data(resume) 

464 plugin_data.setdefault("items", []).append(data) 

465 resume = self.set_data(resume, plugin_data) 

466 return resume 

467 

468 def update(self, resume: Resume, data: dict) -> Resume: 

469 """Update an item in the items list of this plugin.""" 

470 plugin_data = self.get_data(resume) 

471 items = plugin_data.get("items", []) 

472 print(items, data) 

473 for item in items: 

474 if item["id"] == data["id"]: 

475 item.update(data) 

476 break 

477 plugin_data["items"] = items 

478 return self.set_data(resume, plugin_data) 

479 

480 def update_flat(self, resume: Resume, data: dict) -> Resume: 

481 """Update the flat data of this plugin.""" 

482 plugin_data = self.get_data(resume) 

483 plugin_data["flat"] = data 

484 return self.set_data(resume, plugin_data) 

485 

486 def delete(self, resume: Resume, data: dict) -> Resume: 

487 """Delete an item from the items list of this plugin.""" 

488 plugin_data = self.get_data(resume) 

489 items = plugin_data.get("items", []) 

490 for i, item in enumerate(items): 

491 if item["id"] == data["id"]: 

492 items.pop(i) 

493 break 

494 plugin_data["items"] = items 

495 return self.set_data(resume, plugin_data) 

496 

497 

498class ListAdmin: 

499 """ 

500 This class contains the logic of the list plugin concerned with the Django admin interface. 

501 

502 Simple crud operations are supported. Each item in the list is a json serializable 

503 dict and should have an "id" field. 

504 

505 Why have an own class for this? Because the admin interface is different from the 

506 inline editing on the website itself. For example: the admin interface has a change 

507 view where all forms are displayed at once. Which makes sense, because the admin is 

508 for editing. 

509 """ 

510 

511 admin_change_form_template = ( 

512 "django_resume/admin/list_plugin_admin_change_form_htmx.html" 

513 ) 

514 admin_item_change_form_template = ( 

515 "django_resume/admin/list_plugin_admin_item_form.html" 

516 ) 

517 admin_flat_form_template = "django_resume/admin/list_plugin_admin_flat_form.html" 

518 

519 def __init__( 

520 self, 

521 *, 

522 plugin_name: str, 

523 plugin_verbose_name, 

524 form_classes: dict, 

525 data: ListData, 

526 ): 

527 self.plugin_name = plugin_name 

528 self.plugin_verbose_name = plugin_verbose_name 

529 self.form_classes = form_classes 

530 self.data = data 

531 

532 def get_change_url(self, resume_id: int) -> str: 

533 """ 

534 Main admin view for this plugin. This view should display a list of item 

535 forms with update buttons for existing items and a button to get a form to 

536 add a new item. And a form to change the data for the plugin that is stored 

537 in a flat format. 

538 """ 

539 return reverse( 

540 f"admin:{self.plugin_name}-admin-change", kwargs={"resume_id": resume_id} 

541 ) 

542 

543 def get_admin_link(self, resume_id: int) -> str: 

544 """ 

545 Return a link to the main admin view for this plugin. This is used to have the 

546 plugins show up as readonly fields in the resume change view and to have a link 

547 to be able to edit the plugin data. 

548 """ 

549 url = self.get_change_url(resume_id) 

550 return format_html( 

551 '<a href="{}">{}</a>', url, f"Edit {self.plugin_verbose_name}" 

552 ) 

553 

554 def get_change_flat_post_url(self, resume_id: int) -> str: 

555 """Used for create and update flat data.""" 

556 return reverse( 

557 f"admin:{self.plugin_name}-admin-flat-post", kwargs={"resume_id": resume_id} 

558 ) 

559 

560 def get_change_item_post_url(self, resume_id: int) -> str: 

561 """Used for create and update item.""" 

562 return reverse( 

563 f"admin:{self.plugin_name}-admin-item-post", kwargs={"resume_id": resume_id} 

564 ) 

565 

566 def get_delete_item_url(self, resume_id: int, item_id: str) -> str: 

567 """Used for delete item.""" 

568 return reverse( 

569 f"admin:{self.plugin_name}-admin-item-delete", 

570 kwargs={"resume_id": resume_id, "item_id": item_id}, 

571 ) 

572 

573 def get_item_add_form_url(self, resume_id: int) -> str: 

574 """ 

575 Returns the url of a view that returns a form to add a new item. The resume_id 

576 is needed to be able to add the right post url to the form. 

577 """ 

578 return reverse( 

579 f"admin:{self.plugin_name}-admin-item-add", kwargs={"resume_id": resume_id} 

580 ) 

581 

582 # crud views 

583 

584 @staticmethod 

585 def check_permissions(request: HttpRequest, resume: Resume) -> bool: 

586 is_owner = resume.owner == request.user 

587 is_staff = request.user.is_staff 

588 return is_owner and is_staff 

589 

590 def get_resume_or_error(self, request: HttpRequest, resume_id: int) -> Resume: 

591 """Returns the resume or generates a 404 or 403 response.""" 

592 resume = get_object_or_404(Resume, id=resume_id) 

593 if not self.check_permissions(request, resume): 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true

594 raise PermissionDenied("Permission denied") 

595 return resume 

596 

597 def get_add_item_form_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

598 """Return a single empty form to add a new item.""" 

599 resume = self.get_resume_or_error(request, resume_id) 

600 form_class = self.form_classes["item"] 

601 existing_items = self.data.get_data(resume).get("items", []) 

602 form = form_class(initial={}, resume=resume, existing_items=existing_items) 

603 form.post_url = self.get_change_item_post_url(resume.pk) 

604 context = {"form": form} 

605 return render(request, self.admin_item_change_form_template, context) 

606 

607 def get_change_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

608 """Return the main admin view for this plugin.""" 

609 resume = self.get_resume_or_error(request, resume_id) 

610 context = { 

611 "title": f"{self.plugin_verbose_name} for {resume.name}", 

612 "resume": resume, 

613 "opts": Resume._meta, 

614 # context for admin/change_form.html template 

615 "add": False, 

616 "change": True, 

617 "is_popup": False, 

618 "save_as": False, 

619 "has_add_permission": False, 

620 "has_view_permission": True, 

621 "has_change_permission": True, 

622 "has_delete_permission": False, 

623 "has_editable_inline_admin_formsets": False, 

624 } 

625 plugin_data = self.data.get_data(resume) 

626 form_classes = self.form_classes 

627 # flat form 

628 flat_form_class = form_classes["flat"] 

629 flat_form = flat_form_class(initial=plugin_data.get("flat", {})) 

630 flat_form.post_url = self.get_change_flat_post_url(resume.pk) 

631 context["flat_form"] = flat_form 

632 # item forms 

633 item_form_class = form_classes["item"] 

634 initial_items_data = plugin_data.get("items", []) 

635 post_url = self.get_change_item_post_url(resume.id) 

636 item_forms = [] 

637 for initial_item_data in initial_items_data: 637 ↛ 638line 637 didn't jump to line 638 because the loop on line 637 never started

638 form = item_form_class( 

639 initial=initial_item_data, 

640 resume=resume, 

641 existing_items=initial_items_data, 

642 ) 

643 form.post_url = post_url 

644 form.delete_url = self.get_delete_item_url( 

645 resume.id, initial_item_data["id"] 

646 ) 

647 item_forms.append(form) 

648 context["add_item_form_url"] = self.get_item_add_form_url(resume.id) 

649 context["item_forms"] = item_forms 

650 return render(request, self.admin_change_form_template, context) 

651 

652 def post_item_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

653 """Handle post requests to create or update a single item.""" 

654 resume = self.get_resume_or_error(request, resume_id) 

655 form_class = self.form_classes["item"] 

656 existing_items = self.data.get_data(resume).get("items", []) 

657 form = form_class(request.POST, resume=resume, existing_items=existing_items) 

658 form.post_url = self.get_change_item_post_url(resume.pk) 

659 context = {"form": form} 

660 if form.is_valid(): 660 ↛ 687line 660 didn't jump to line 687 because the condition on line 660 was always true

661 # try to find out whether we are updating an existing item or creating a new one 

662 existing = True 

663 item_id = form.cleaned_data.get("id", None) 

664 if item_id is not None: 664 ↛ 670line 664 didn't jump to line 670 because the condition on line 664 was always true

665 item = self.data.get_item_by_id(resume, item_id) 

666 if item is None: 

667 existing = False 

668 else: 

669 # no item_id -> new item 

670 existing = False 

671 if existing: 

672 # update existing item 

673 item_id = form.cleaned_data["id"] 

674 resume = self.data.update(resume, form.cleaned_data) 

675 else: 

676 # create new item 

677 data = form.cleaned_data 

678 item_id = str(uuid4()) 

679 data["id"] = item_id 

680 resume = self.data.create(resume, data) 

681 # weird hack to make the form look like it is for an existing item 

682 # if there's a better way to do this, please let me know FIXME 

683 form.data = form.data.copy() 

684 form.data["id"] = item_id 

685 resume.save() 

686 form.delete_url = self.get_delete_item_url(resume.id, item_id) 

687 return render(request, self.admin_item_change_form_template, context) 

688 

689 def post_flat_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

690 """Handle post requests to update flat data.""" 

691 resume = self.get_resume_or_error(request, resume_id) 

692 form_class = self.form_classes["flat"] 

693 form = form_class(request.POST) 

694 form.post_url = self.get_change_flat_post_url(resume.pk) 

695 context = {"form": form} 

696 if form.is_valid(): 696 ↛ 699line 696 didn't jump to line 699 because the condition on line 696 was always true

697 resume = self.data.update_flat(resume, form.cleaned_data) 

698 resume.save() 

699 return render(request, self.admin_flat_form_template, context) 

700 

701 def delete_item_view(self, request: HttpRequest, resume_id: int, item_id: str) -> HttpResponse: 

702 """Delete an item from the items list of this plugin.""" 

703 resume = self.get_resume_or_error(request, resume_id) 

704 resume = self.data.delete(resume, {"id": item_id}) 

705 resume.save() 

706 return HttpResponse(status=200) 

707 

708 # urlpatterns 

709 

710 def get_urls(self, admin_view: Callable) -> URLPatterns: 

711 """ 

712 This method should return a list of urls that are used to manage the 

713 plugin data in the admin interface. 

714 """ 

715 plugin_name = self.plugin_name 

716 urls = [ 

717 path( 

718 f"<int:resume_id>/plugin/{plugin_name}/change/", 

719 admin_view(self.get_change_view), 

720 name=f"{plugin_name}-admin-change", 

721 ), 

722 path( 

723 f"<int:resume_id>/plugin/{plugin_name}/item/post/", 

724 admin_view(self.post_item_view), 

725 name=f"{plugin_name}-admin-item-post", 

726 ), 

727 path( 

728 f"<int:resume_id>/plugin/{plugin_name}/add/", 

729 admin_view(self.get_add_item_form_view), 

730 name=f"{plugin_name}-admin-item-add", 

731 ), 

732 path( 

733 f"<int:resume_id>/plugin/{plugin_name}/delete/<str:item_id>/", 

734 admin_view(self.delete_item_view), 

735 name=f"{plugin_name}-admin-item-delete", 

736 ), 

737 path( 

738 f"<int:resume_id>/plugin/{plugin_name}/flat/post/", 

739 admin_view(self.post_flat_view), 

740 name=f"{plugin_name}-admin-flat-post", 

741 ), 

742 ] 

743 return urls 

744 

745 

746class ListInline: 

747 """ 

748 This class contains the logic of the list plugin concerned with the inline editing 

749 of the plugin data on the website itself. 

750 """ 

751 

752 def __init__( 

753 self, 

754 *, 

755 plugin_name: str, 

756 plugin_verbose_name: str, 

757 form_classes: dict, 

758 data: ListData, 

759 templates: ListTemplates, 

760 ): 

761 self.plugin_name = plugin_name 

762 self.plugin_verbose_name = plugin_verbose_name 

763 self.form_classes = form_classes 

764 self.data = data 

765 self.templates = templates 

766 

767 # urls 

768 

769 def get_edit_flat_post_url(self, resume_id: int) -> str: 

770 return reverse( 

771 f"django_resume:{self.plugin_name}-edit-flat-post", 

772 kwargs={"resume_id": resume_id}, 

773 ) 

774 

775 def get_edit_flat_url(self, resume_id: int) -> str: 

776 return reverse( 

777 f"django_resume:{self.plugin_name}-edit-flat", 

778 kwargs={"resume_id": resume_id}, 

779 ) 

780 

781 def get_edit_item_url(self, resume_id: int, item_id=None) -> str: 

782 if item_id is None: 

783 return reverse( 

784 f"django_resume:{self.plugin_name}-add-item", 

785 kwargs={"resume_id": resume_id}, 

786 ) 

787 else: 

788 return reverse( 

789 f"django_resume:{self.plugin_name}-edit-item", 

790 kwargs={"resume_id": resume_id, "item_id": item_id}, 

791 ) 

792 

793 def get_post_item_url(self, resume_id: int) -> str: 

794 return reverse( 

795 f"django_resume:{self.plugin_name}-item-post", 

796 kwargs={"resume_id": resume_id}, 

797 ) 

798 

799 def get_delete_item_url(self, resume_id: int, item_id: str) -> str: 

800 return reverse( 

801 f"django_resume:{self.plugin_name}-delete-item", 

802 kwargs={"resume_id": resume_id, "item_id": item_id}, 

803 ) 

804 

805 # crud views 

806 

807 @staticmethod 

808 def check_permissions(request: HttpRequest, resume: Resume) -> bool: 

809 return resume.owner == request.user 

810 

811 def get_resume_or_error(self, request: HttpRequest, resume_id: int) -> Resume: 

812 """Returns the resume or generates a 404 or 403 response.""" 

813 resume = get_object_or_404(Resume, id=resume_id) 

814 if not self.check_permissions(request, resume): 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true

815 raise PermissionDenied("Permission denied") 

816 return resume 

817 

818 def get_edit_flat_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

819 """Return a form to edit the flat data (not items) of this plugin.""" 

820 resume = self.get_resume_or_error(request, resume_id) 

821 plugin_data = self.data.get_data(resume) 

822 flat_form_class = self.form_classes["flat"] 

823 flat_form = flat_form_class(initial=plugin_data.get("flat", {})) 

824 flat_form.post_url = self.get_edit_flat_post_url(resume.pk) 

825 context = { 

826 "form": flat_form, 

827 "edit_flat_post_url": self.get_edit_flat_post_url(resume.pk), 

828 } 

829 return render(request, self.templates.flat_form, context=context) 

830 

831 def post_edit_flat_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

832 """Handle post requests to update flat data.""" 

833 resume = self.get_resume_or_error(request, resume_id) 

834 flat_form_class = self.form_classes["flat"] 

835 plugin_data = self.data.get_data(resume) 

836 flat_form = flat_form_class(request.POST, initial=plugin_data.get("flat", {})) 

837 context = {} 

838 if flat_form.is_valid(): 

839 resume = self.data.update_flat(resume, flat_form.cleaned_data) 

840 resume.save() 

841 resume.refresh_from_db() 

842 plugin_data = self.data.get_data(resume) 

843 context["edit_flat_url"] = self.get_edit_flat_url(resume.pk) 

844 context = flat_form.set_context(plugin_data["flat"], context) 

845 context["show_edit_button"] = True 

846 return render(request, self.templates.flat, context=context) 

847 else: 

848 context["form"] = flat_form 

849 context["edit_flat_post_url"] = self.get_edit_flat_post_url(resume.pk) 

850 response = render(request, self.templates.flat_form, context=context) 

851 return response 

852 

853 def get_item_view(self, request: HttpRequest, resume_id: int, item_id=None) -> HttpResponse: 

854 """Return a form to edit an item.""" 

855 resume = self.get_resume_or_error(request, resume_id) 

856 plugin_data = self.data.get_data(resume) 

857 existing_items = plugin_data.get("items", []) 

858 form_class = self.form_classes["item"] 

859 # get the item data if we are editing an existing item 

860 initial = form_class.get_initial() 

861 if item_id is not None: 

862 for item in existing_items: 

863 if item["id"] == item_id: 863 ↛ 862line 863 didn't jump to line 862 because the condition on line 863 was always true

864 initial = item 

865 form = form_class(initial=initial, resume=resume, existing_items=existing_items) 

866 form.post_url = self.get_post_item_url(resume.pk) 

867 context = {"form": form, "plugin_name": self.plugin_name} 

868 return render(request, self.templates.item_form, context=context) 

869 

870 def post_item_view(self, request: HttpRequest, resume_id: int) -> HttpResponse: 

871 """Handle post requests to create or update a single item.""" 

872 resume = self.get_resume_or_error(request, resume_id) 

873 form_class = self.form_classes["item"] 

874 existing_items = self.data.get_data(resume).get("items", []) 

875 form = form_class(request.POST, resume=resume, existing_items=existing_items) 

876 form.post_url = self.get_post_item_url(resume.pk) 

877 context = {"form": form} 

878 if form.is_valid(): 878 ↛ 915line 878 didn't jump to line 915 because the condition on line 878 was always true

879 # try to find out whether we are updating an existing item or creating a new one 

880 existing = True 

881 item_id = form.cleaned_data.get("id", None) 

882 if item_id is not None: 882 ↛ 888line 882 didn't jump to line 888 because the condition on line 882 was always true

883 item = self.data.get_item_by_id(resume, item_id) 

884 if item is None: 

885 existing = False 

886 else: 

887 # no item_id -> new item 

888 existing = False 

889 if existing: 

890 # update existing item 

891 item_id = form.cleaned_data["id"] 

892 resume = self.data.update(resume, form.cleaned_data) 

893 else: 

894 # create new item 

895 data = form.cleaned_data 

896 item_id = str(uuid4()) 

897 data["id"] = item_id 

898 resume = self.data.create(resume, data) 

899 # weird hack to make the form look like it is for an existing item 

900 # if there's a better way to do this, please let me know FIXME 

901 form.data = form.data.copy() 

902 form.data["id"] = item_id 

903 resume.save() 

904 item = self.data.get_item_by_id(resume, item_id) 

905 # populate entry because it's used in the standard item template, 

906 # and we are no longer rendering a form when the form was valid 

907 context["edit_url"] = self.get_edit_item_url(resume.id, item_id) 

908 context["delete_url"] = self.get_delete_item_url(resume.id, item_id) 

909 form.set_context(item, context) 

910 context["show_edit_button"] = True 

911 context["plugin_name"] = self.plugin_name # for javascript 

912 return render(request, self.templates.item, context) 

913 else: 

914 # form is invalid 

915 return render(request, self.templates.item_form, context) 

916 

917 def delete_item_view(self, request: HttpRequest, resume_id: int, item_id: str) -> HttpResponse: 

918 """Delete an item from the items list of this plugin.""" 

919 resume = self.get_resume_or_error(request, resume_id) 

920 resume = self.data.delete(resume, {"id": item_id}) 

921 resume.save() 

922 return HttpResponse(status=200) 

923 

924 # urlpatterns 

925 def get_urls(self) -> URLPatterns: 

926 plugin_name = self.plugin_name 

927 urls = [ 

928 # flat 

929 path( 

930 f"<int:resume_id>/plugin/{plugin_name}/edit/flat/", 

931 self.get_edit_flat_view, 

932 name=f"{plugin_name}-edit-flat", 

933 ), 

934 path( 

935 f"<int:resume_id>/plugin/{plugin_name}/edit/flat/post/", 

936 self.post_edit_flat_view, 

937 name=f"{plugin_name}-edit-flat-post", 

938 ), 

939 # item 

940 path( 

941 f"<int:resume_id>/plugin/{plugin_name}/edit/item/<str:item_id>", 

942 self.get_item_view, 

943 name=f"{plugin_name}-edit-item", 

944 ), 

945 path( 

946 f"<int:resume_id>/plugin/{plugin_name}/edit/item/", 

947 self.get_item_view, 

948 name=f"{plugin_name}-add-item", 

949 ), 

950 path( 

951 f"<int:resume_id>/plugin/{plugin_name}/edit/item/post/", 

952 self.post_item_view, 

953 name=f"{plugin_name}-item-post", 

954 ), 

955 path( 

956 f"<int:resume_id>/plugin/{plugin_name}/delete/<str:item_id>/", 

957 self.delete_item_view, 

958 name=f"{plugin_name}-delete-item", 

959 ), 

960 ] 

961 return urls 

962 

963 

964class ListPlugin: 

965 """ 

966 A plugin that displays a list of items. Simple crud operations are supported. 

967 Each item in the list is a json serializable dict and should have an "id" field. 

968 

969 Additional flat data can be stored in the plugin_data['flat'] field. 

970 """ 

971 

972 name = "list_plugin" 

973 verbose_name = "List Plugin" 

974 templates: ListTemplates = ListTemplates( 

975 main="", flat="", flat_form="", item="", item_form="" 

976 ) # overwrite this 

977 

978 def __init__(self): 

979 super().__init__() 

980 self.data = data = ListData(plugin_name=self.name) 

981 form_classes = self.get_form_classes() 

982 self.admin = ListAdmin( 

983 plugin_name=self.name, 

984 plugin_verbose_name=self.verbose_name, 

985 form_classes=form_classes, 

986 data=data, 

987 ) 

988 self.inline = ListInline( 

989 plugin_name=self.name, 

990 plugin_verbose_name=self.verbose_name, 

991 form_classes=form_classes, 

992 data=data, 

993 templates=self.templates, 

994 ) 

995 

996 # list logic 

997 

998 def get_flat_form_class(self) -> type[forms.Form]: 

999 """Set inline_form_class attribute or overwrite this method.""" 

1000 if hasattr(self, "flat_form_class"): 

1001 return self.flat_form_class 

1002 return SimpleJsonForm # default 

1003 

1004 @staticmethod 

1005 def items_ordered_by_position(items, reverse=False): 

1006 return sorted(items, key=lambda item: item.get("position", 0), reverse=reverse) 

1007 

1008 def get_context( 

1009 self, 

1010 _request: HttpRequest, 

1011 plugin_data: dict, 

1012 resume_pk: int, 

1013 *, 

1014 context: ContextDict, 

1015 edit: bool = False, 

1016 ) -> ContextDict: 

1017 if plugin_data.get("flat", {}) == {}: 1017 ↛ 1026line 1017 didn't jump to line 1026 because the condition on line 1017 was always true

1018 # no flat data yet, use initial data from inline form 

1019 form = self.get_flat_form_class()() 

1020 initial_values = { 

1021 field_name: form.get_initial_for_field(field, field_name) 

1022 for field_name, field in form.fields.items() 

1023 } 

1024 plugin_data["flat"] = initial_values 

1025 # add flat data to context 

1026 context.update(plugin_data["flat"]) 

1027 

1028 ordered_entries = self.items_ordered_by_position( 

1029 plugin_data.get("items", []), reverse=True 

1030 ) 

1031 if edit: 

1032 # if there should be edit buttons, add the edit URLs to each entry 

1033 context["show_edit_button"] = True 

1034 for entry in ordered_entries: 1034 ↛ 1035line 1034 didn't jump to line 1035 because the loop on line 1034 never started

1035 entry["edit_url"] = self.inline.get_edit_item_url( 

1036 resume_pk, item_id=entry["id"] 

1037 ) 

1038 entry["delete_url"] = self.inline.get_delete_item_url( 

1039 resume_pk, item_id=entry["id"] 

1040 ) 

1041 print("edit flat post: ", self.inline.get_edit_flat_post_url(resume_pk)) 

1042 context.update( 

1043 { 

1044 "plugin_name": self.name, 

1045 "templates": self.templates, 

1046 "ordered_entries": ordered_entries, 

1047 "add_item_url": self.inline.get_edit_item_url(resume_pk), 

1048 "edit_flat_url": self.inline.get_edit_flat_url(resume_pk), 

1049 "edit_flat_post_url": self.inline.get_edit_flat_post_url(resume_pk), 

1050 } 

1051 ) 

1052 return context 

1053 

1054 # plugin protocol methods 

1055 

1056 def get_admin_urls(self, admin_view: Callable) -> URLPatterns: 

1057 return self.admin.get_urls(admin_view) 

1058 

1059 def get_admin_link(self, resume_id: int | None) -> str: 

1060 if resume_id is None: 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true

1061 return "" 

1062 return self.admin.get_admin_link(resume_id) 

1063 

1064 def get_inline_urls(self) -> URLPatterns: 

1065 return self.inline.get_urls() 

1066 

1067 def get_form_classes(self) -> dict[str, type[forms.Form]]: 

1068 """Please implement this method.""" 

1069 return {} 

1070 

1071 def get_data(self, resume: Resume) -> dict: 

1072 return self.data.get_data(resume)