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

1 from uuid import uuid4

2

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

4

5 from django import forms

6 from django.contrib.auth.decorators import login_required

7 from django.http import HttpResponse, HttpRequest

8 from django.shortcuts import get_object_or_404, render

9 from django.urls import reverse, path, URLPattern

10 from django.utils.html import format_html

11 from django.core.exceptions import PermissionDenied

12

13 from ..models import Resume

14

15

16 URLPatterns: TypeAlias = list[URLPattern]

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

18 ContextDict: TypeAlias = dict[str, Any]

19

20

21@runtime_checkable

22 class 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

63 class 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

83 class SimpleJsonForm(forms.Form):

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

85

86

87 class 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

205 class SimpleTemplates:

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

207 self.main = main

208 self.form = form

209

210

211 class 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

307 class 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

393 class 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

422 class 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

433 class 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

498 class 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

746 class 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

964 class 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)