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

509 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-13 13:17 +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 setattr( 

146 form, "post_url", self.get_change_post_url(resume.pk) 

147 ) # make mypy happy 

148 context = { 

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

150 "resume": resume, 

151 "opts": Resume._meta, 

152 "form": form, 

153 "form_template": self.change_form, 

154 # context for admin/change_form.html template 

155 "add": False, 

156 "change": True, 

157 "is_popup": False, 

158 "save_as": False, 

159 "has_add_permission": False, 

160 "has_view_permission": True, 

161 "has_change_permission": True, 

162 "has_delete_permission": False, 

163 "has_editable_inline_admin_formsets": False, 

164 } 

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

166 

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

168 """ 

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

170 the form with errors. 

171 """ 

172 resume = self.get_resume_or_error(request, resume_id) 

173 form = self.form_class(request.POST) 

174 setattr( 

175 form, "post_url", self.get_change_post_url(resume.pk) 

176 ) # make mypy happy 

177 context = {"form": form} 

178 if form.is_valid(): 

179 if self.form_class == SimpleJsonForm: 

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

181 plugin_data = form.cleaned_data["plugin_data"] 

182 else: 

183 plugin_data = form.cleaned_data 

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

185 resume.save() 

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

187 

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

189 """ 

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

191 plugin data in the admin interface. 

192 """ 

193 plugin_name = self.plugin_name 

194 urls = [ 

195 path( 

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

197 login_required(admin_view(self.get_change_view)), 

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

199 ), 

200 path( 

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

202 login_required(admin_view(self.post_view)), 

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

204 ), 

205 ] 

206 return urls 

207 

208 

209class SimpleTemplates: 

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

211 self.main = main 

212 self.form = form 

213 

214 

215class SimpleInline: 

216 def __init__( 

217 self, 

218 *, 

219 plugin_name: str, 

220 plugin_verbose_name: str, 

221 form_class: type[forms.Form], 

222 data: SimpleData, 

223 templates: SimpleTemplates, 

224 get_context: Callable, 

225 ): 

226 self.plugin_name = plugin_name 

227 self.plugin_verbose_name = plugin_verbose_name 

228 self.form_class = form_class 

229 self.data = data 

230 self.templates = templates 

231 self.get_context = get_context 

232 

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

234 return reverse( 

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

236 ) 

237 

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

239 return reverse( 

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

241 ) 

242 

243 @staticmethod 

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

245 return resume.owner == request.user 

246 

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

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

249 resume = get_object_or_404(Resume, id=resume_id) 

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

251 raise PermissionDenied("Permission denied") 

252 return resume 

253 

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

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

256 resume = self.get_resume_or_error(request, resume_id) 

257 plugin_data = self.data.get_data(resume) 

258 form = self.form_class(initial=plugin_data) 

259 setattr(form, "post_url", self.get_post_url(resume.pk)) # make mypy happy 

260 context = {"form": form} 

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

262 

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

264 """ 

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

266 the form with errors. 

267 """ 

268 resume = self.get_resume_or_error(request, resume_id) 

269 plugin_data = self.data.get_data(resume) 

270 form_class = self.form_class 

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

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

273 setattr(form, "post_url", self.get_post_url(resume.pk)) # make mypy happy 

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

275 if form.is_valid(): 

276 # update the plugin data and render the main template 

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

278 resume.save() 

279 # update the context with the new plugin data from plugin 

280 updated_plugin_data = self.data.get_data(resume) 

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

282 request, updated_plugin_data, resume.pk, context=context 

283 ) 

284 context["show_edit_button"] = True 

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

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

287 # render the form again with errors 

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

289 

290 def get_urls(self) -> URLPatterns: 

291 """ 

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

293 """ 

294 plugin_name = self.plugin_name 

295 urls: URLPatterns = [ 

296 # flat 

297 path( 

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

299 login_required(self.get_edit_view), 

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

301 ), 

302 path( 

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

304 login_required(self.post_view), 

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

306 ), 

307 ] 

308 return urls 

309 

310 

311class SimplePlugin: 

312 """ 

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

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

315 complex logic. 

316 """ 

317 

318 name = "simple_plugin" 

319 verbose_name = "Simple Plugin" 

320 templates: SimpleTemplates = SimpleTemplates( 

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

322 main="django_resume/simple_plugin/plain/content.html", 

323 form="django_resume/simple_plugin/plain/form.html", 

324 ) 

325 

326 def __init__(self): 

327 super().__init__() 

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

329 self.admin = SimpleAdmin( 

330 plugin_name=self.name, 

331 plugin_verbose_name=self.verbose_name, 

332 form_class=self.get_admin_form_class(), 

333 data=data, 

334 ) 

335 self.inline = SimpleInline( 

336 plugin_name=self.name, 

337 plugin_verbose_name=self.verbose_name, 

338 form_class=self.get_inline_form_class(), 

339 data=data, 

340 templates=self.templates, 

341 get_context=self.get_context, 

342 ) 

343 

344 # plugin protocol methods 

345 

346 def get_context( 

347 self, 

348 _request: HttpRequest, 

349 plugin_data: dict, 

350 resume_pk: int, 

351 *, 

352 context: ContextDict, 

353 edit: bool = False, 

354 ) -> ContextDict: 

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

356 if plugin_data == {}: 

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

358 form = self.get_inline_form_class()() 

359 initial_values = { 

360 field_name: form.get_initial_for_field(field, field_name) 

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

362 } 

363 plugin_data = initial_values 

364 context.update(plugin_data) 

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

366 context["show_edit_button"] = edit 

367 context["templates"] = self.templates 

368 return context 

369 

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

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

372 if hasattr(self, "admin_form_class"): 

373 return self.admin_form_class 

374 return SimpleJsonForm # default 

375 

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

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

378 if hasattr(self, "inline_form_class"): 

379 return self.inline_form_class 

380 return SimpleJsonForm # default 

381 

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

383 return self.admin.get_urls(admin_view) 

384 

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

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

387 return "" 

388 return self.admin.get_admin_link(resume_id) 

389 

390 def get_inline_urls(self) -> URLPatterns: 

391 return self.inline.get_urls() 

392 

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

394 return self.data.get_data(resume) 

395 

396 

397class ListItemFormMixin(forms.Form): 

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

399 

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

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

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

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

404 

405 @property 

406 def is_new(self): 

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

408 if self.is_bound: 

409 return False 

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

411 

412 @property 

413 def item_id(self): 

414 """ 

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

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

417 forms on the page. 

418 """ 

419 if self.is_bound: 

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

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

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

423 return self.initial["id"] 

424 

425 

426class ListTemplates: 

427 def __init__( 

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

429 ): 

430 self.main = main 

431 self.flat = flat 

432 self.flat_form = flat_form 

433 self.item = item 

434 self.item_form = item_form 

435 

436 

437class ListData: 

438 """ 

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

440 

441 Simple crud operations are supported. 

442 """ 

443 

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

445 self.plugin_name = plugin_name 

446 

447 # read 

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

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

450 

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

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

453 for item in items: 

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

455 return item 

456 return None 

457 

458 # write 

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

460 if not resume.plugin_data: 

461 resume.plugin_data = {} 

462 resume.plugin_data[self.plugin_name] = data 

463 return resume 

464 

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

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

467 plugin_data = self.get_data(resume) 

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

469 resume = self.set_data(resume, plugin_data) 

470 return resume 

471 

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

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

474 plugin_data = self.get_data(resume) 

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

476 print(items, data) 

477 for item in items: 

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

479 item.update(data) 

480 break 

481 plugin_data["items"] = items 

482 return self.set_data(resume, plugin_data) 

483 

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

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

486 plugin_data = self.get_data(resume) 

487 plugin_data["flat"] = data 

488 return self.set_data(resume, plugin_data) 

489 

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

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

492 plugin_data = self.get_data(resume) 

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

494 for i, item in enumerate(items): 

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

496 items.pop(i) 

497 break 

498 plugin_data["items"] = items 

499 return self.set_data(resume, plugin_data) 

500 

501 

502class ListAdmin: 

503 """ 

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

505 

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

507 dict and should have an "id" field. 

508 

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

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

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

512 for editing. 

513 """ 

514 

515 admin_change_form_template = ( 

516 "django_resume/admin/list_plugin_admin_change_form_htmx.html" 

517 ) 

518 admin_item_change_form_template = ( 

519 "django_resume/admin/list_plugin_admin_item_form.html" 

520 ) 

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

522 

523 def __init__( 

524 self, 

525 *, 

526 plugin_name: str, 

527 plugin_verbose_name, 

528 form_classes: dict, 

529 data: ListData, 

530 ): 

531 self.plugin_name = plugin_name 

532 self.plugin_verbose_name = plugin_verbose_name 

533 self.form_classes = form_classes 

534 self.data = data 

535 

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

537 """ 

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

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

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

541 in a flat format. 

542 """ 

543 return reverse( 

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

545 ) 

546 

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

548 """ 

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

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

551 to be able to edit the plugin data. 

552 """ 

553 url = self.get_change_url(resume_id) 

554 return format_html( 

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

556 ) 

557 

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

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

560 return reverse( 

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

562 ) 

563 

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

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

566 return reverse( 

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

568 ) 

569 

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

571 """Used for delete item.""" 

572 return reverse( 

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

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

575 ) 

576 

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

578 """ 

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

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

581 """ 

582 return reverse( 

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

584 ) 

585 

586 # crud views 

587 

588 @staticmethod 

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

590 is_owner = resume.owner == request.user 

591 is_staff = request.user.is_staff 

592 return is_owner and is_staff 

593 

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

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

596 resume = get_object_or_404(Resume, id=resume_id) 

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

598 raise PermissionDenied("Permission denied") 

599 return resume 

600 

601 def get_add_item_form_view( 

602 self, request: HttpRequest, resume_id: int 

603 ) -> HttpResponse: 

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

605 resume = self.get_resume_or_error(request, resume_id) 

606 form_class = self.form_classes["item"] 

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

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

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

610 context = {"form": form} 

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

612 

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

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

615 resume = self.get_resume_or_error(request, resume_id) 

616 context = { 

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

618 "resume": resume, 

619 "opts": Resume._meta, 

620 # context for admin/change_form.html template 

621 "add": False, 

622 "change": True, 

623 "is_popup": False, 

624 "save_as": False, 

625 "has_add_permission": False, 

626 "has_view_permission": True, 

627 "has_change_permission": True, 

628 "has_delete_permission": False, 

629 "has_editable_inline_admin_formsets": False, 

630 } 

631 plugin_data = self.data.get_data(resume) 

632 form_classes = self.form_classes 

633 # flat form 

634 flat_form_class = form_classes["flat"] 

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

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

637 context["flat_form"] = flat_form 

638 # item forms 

639 item_form_class = form_classes["item"] 

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

641 post_url = self.get_change_item_post_url(resume.id) 

642 item_forms = [] 

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

644 form = item_form_class( 

645 initial=initial_item_data, 

646 resume=resume, 

647 existing_items=initial_items_data, 

648 ) 

649 form.post_url = post_url 

650 form.delete_url = self.get_delete_item_url( 

651 resume.id, initial_item_data["id"] 

652 ) 

653 item_forms.append(form) 

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

655 context["item_forms"] = item_forms 

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

657 

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

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

660 resume = self.get_resume_or_error(request, resume_id) 

661 form_class = self.form_classes["item"] 

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

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

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

665 context = {"form": form} 

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

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

668 existing = True 

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

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

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

672 if item is None: 

673 existing = False 

674 else: 

675 # no item_id -> new item 

676 existing = False 

677 if existing: 

678 # update existing item 

679 item_id = form.cleaned_data["id"] 

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

681 else: 

682 # create new item 

683 data = form.cleaned_data 

684 item_id = str(uuid4()) 

685 data["id"] = item_id 

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

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

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

689 form.data = form.data.copy() 

690 form.data["id"] = item_id 

691 resume.save() 

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

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

694 

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

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

697 resume = self.get_resume_or_error(request, resume_id) 

698 form_class = self.form_classes["flat"] 

699 form = form_class(request.POST) 

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

701 context = {"form": form} 

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

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

704 resume.save() 

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

706 

707 def delete_item_view( 

708 self, request: HttpRequest, resume_id: int, item_id: str 

709 ) -> HttpResponse: 

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

711 resume = self.get_resume_or_error(request, resume_id) 

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

713 resume.save() 

714 return HttpResponse(status=200) 

715 

716 # urlpatterns 

717 

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

719 """ 

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

721 plugin data in the admin interface. 

722 """ 

723 plugin_name = self.plugin_name 

724 urls = [ 

725 path( 

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

727 admin_view(self.get_change_view), 

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

729 ), 

730 path( 

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

732 admin_view(self.post_item_view), 

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

734 ), 

735 path( 

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

737 admin_view(self.get_add_item_form_view), 

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

739 ), 

740 path( 

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

742 admin_view(self.delete_item_view), 

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

744 ), 

745 path( 

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

747 admin_view(self.post_flat_view), 

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

749 ), 

750 ] 

751 return urls 

752 

753 

754class ListInline: 

755 """ 

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

757 of the plugin data on the website itself. 

758 """ 

759 

760 def __init__( 

761 self, 

762 *, 

763 plugin_name: str, 

764 plugin_verbose_name: str, 

765 form_classes: dict, 

766 data: ListData, 

767 templates: ListTemplates, 

768 ): 

769 self.plugin_name = plugin_name 

770 self.plugin_verbose_name = plugin_verbose_name 

771 self.form_classes = form_classes 

772 self.data = data 

773 self.templates = templates 

774 

775 # urls 

776 

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

778 return reverse( 

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

780 kwargs={"resume_id": resume_id}, 

781 ) 

782 

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

784 return reverse( 

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

786 kwargs={"resume_id": resume_id}, 

787 ) 

788 

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

790 if item_id is None: 

791 return reverse( 

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

793 kwargs={"resume_id": resume_id}, 

794 ) 

795 else: 

796 return reverse( 

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

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

799 ) 

800 

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

802 return reverse( 

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

804 kwargs={"resume_id": resume_id}, 

805 ) 

806 

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

808 return reverse( 

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

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

811 ) 

812 

813 # crud views 

814 

815 @staticmethod 

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

817 return resume.owner == request.user 

818 

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

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

821 resume = get_object_or_404(Resume, id=resume_id) 

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

823 raise PermissionDenied("Permission denied") 

824 return resume 

825 

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

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

828 resume = self.get_resume_or_error(request, resume_id) 

829 plugin_data = self.data.get_data(resume) 

830 flat_form_class = self.form_classes["flat"] 

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

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

833 context = { 

834 "form": flat_form, 

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

836 } 

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

838 

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

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

841 resume = self.get_resume_or_error(request, resume_id) 

842 flat_form_class = self.form_classes["flat"] 

843 plugin_data = self.data.get_data(resume) 

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

845 context: dict[str, Any] = {} 

846 if flat_form.is_valid(): 

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

848 resume.save() 

849 resume.refresh_from_db() 

850 plugin_data = self.data.get_data(resume) 

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

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

853 context["show_edit_button"] = True 

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

855 else: 

856 context["form"] = flat_form 

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

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

859 return response 

860 

861 def get_item_view( 

862 self, request: HttpRequest, resume_id: int, item_id=None 

863 ) -> HttpResponse: 

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

865 resume = self.get_resume_or_error(request, resume_id) 

866 plugin_data = self.data.get_data(resume) 

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

868 form_class = self.form_classes["item"] 

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

870 initial = form_class.get_initial() 

871 if item_id is not None: 

872 for item in existing_items: 

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

874 initial = item 

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

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

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

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

879 

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

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

882 resume = self.get_resume_or_error(request, resume_id) 

883 form_class = self.form_classes["item"] 

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

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

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

887 context = {"form": form} 

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

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

890 existing = True 

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

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

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

894 if item is None: 

895 existing = False 

896 else: 

897 # no item_id -> new item 

898 existing = False 

899 if existing: 

900 # update existing item 

901 item_id = form.cleaned_data["id"] 

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

903 else: 

904 # create new item 

905 data = form.cleaned_data 

906 item_id = str(uuid4()) 

907 data["id"] = item_id 

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

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

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

911 form.data = form.data.copy() 

912 form.data["id"] = item_id 

913 resume.save() 

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

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

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

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

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

919 form.set_context(item, context) 

920 context["show_edit_button"] = True 

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

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

923 else: 

924 # form is invalid 

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

926 

927 def delete_item_view( 

928 self, request: HttpRequest, resume_id: int, item_id: str 

929 ) -> HttpResponse: 

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

931 resume = self.get_resume_or_error(request, resume_id) 

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

933 resume.save() 

934 return HttpResponse(status=200) 

935 

936 # urlpatterns 

937 def get_urls(self) -> URLPatterns: 

938 plugin_name = self.plugin_name 

939 urls = [ 

940 # flat 

941 path( 

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

943 self.get_edit_flat_view, 

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

945 ), 

946 path( 

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

948 self.post_edit_flat_view, 

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

950 ), 

951 # item 

952 path( 

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

954 self.get_item_view, 

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

956 ), 

957 path( 

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

959 self.get_item_view, 

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

961 ), 

962 path( 

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

964 self.post_item_view, 

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

966 ), 

967 path( 

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

969 self.delete_item_view, 

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

971 ), 

972 ] 

973 return urls 

974 

975 

976class ListPlugin: 

977 """ 

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

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

980 

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

982 """ 

983 

984 name = "list_plugin" 

985 verbose_name = "List Plugin" 

986 templates: ListTemplates = ListTemplates( 

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

988 ) # overwrite this 

989 

990 def __init__(self): 

991 super().__init__() 

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

993 form_classes = self.get_form_classes() 

994 self.admin = ListAdmin( 

995 plugin_name=self.name, 

996 plugin_verbose_name=self.verbose_name, 

997 form_classes=form_classes, 

998 data=data, 

999 ) 

1000 self.inline = ListInline( 

1001 plugin_name=self.name, 

1002 plugin_verbose_name=self.verbose_name, 

1003 form_classes=form_classes, 

1004 data=data, 

1005 templates=self.templates, 

1006 ) 

1007 

1008 # list logic 

1009 

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

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

1012 if hasattr(self, "flat_form_class"): 

1013 return self.flat_form_class 

1014 return SimpleJsonForm # default 

1015 

1016 @staticmethod 

1017 def items_ordered_by_position(items, reverse=False): 

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

1019 

1020 def get_context( 

1021 self, 

1022 _request: HttpRequest, 

1023 plugin_data: dict, 

1024 resume_pk: int, 

1025 *, 

1026 context: ContextDict, 

1027 edit: bool = False, 

1028 ) -> ContextDict: 

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

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

1031 form = self.get_flat_form_class()() 

1032 initial_values = { 

1033 field_name: form.get_initial_for_field(field, field_name) 

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

1035 } 

1036 plugin_data["flat"] = initial_values 

1037 # add flat data to context 

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

1039 

1040 ordered_entries = self.items_ordered_by_position( 

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

1042 ) 

1043 if edit: 

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

1045 context["show_edit_button"] = True 

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

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

1048 resume_pk, item_id=entry["id"] 

1049 ) 

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

1051 resume_pk, item_id=entry["id"] 

1052 ) 

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

1054 context.update( 

1055 { 

1056 "plugin_name": self.name, 

1057 "templates": self.templates, 

1058 "ordered_entries": ordered_entries, 

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

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

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

1062 } 

1063 ) 

1064 return context 

1065 

1066 # plugin protocol methods 

1067 

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

1069 return self.admin.get_urls(admin_view) 

1070 

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

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

1073 return "" 

1074 return self.admin.get_admin_link(resume_id) 

1075 

1076 def get_inline_urls(self) -> URLPatterns: 

1077 return self.inline.get_urls() 

1078 

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

1080 """Please implement this method.""" 

1081 return {} 

1082 

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

1084 return self.data.get_data(resume)