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
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-11 12:04 +0200
1from uuid import uuid4
3from typing import Protocol, runtime_checkable, Callable, TypeAlias, Any
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
13from ..models import Resume
16URLPatterns: TypeAlias = list[URLPattern]
17FormClasses: TypeAlias = dict[str, type[forms.Form]]
18ContextDict: TypeAlias = dict[str, Any]
21@runtime_checkable
22class Plugin(Protocol):
23 name: str
24 verbose_name: str
25 form_classes: FormClasses
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
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
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
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
46 def get_data(self, resume: Resume) -> dict:
47 """Return the plugin data for a resume."""
48 ... # pragma: no cover
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
63class SimpleData:
64 def __init__(self, *, plugin_name: str):
65 self.plugin_name = plugin_name
67 def get_data(self, resume: Resume) -> dict:
68 return resume.plugin_data.get(self.plugin_name, {})
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
76 def create(self, resume: Resume, data: dict) -> Resume:
77 return self.set_data(resume, data)
79 def update(self, resume: Resume, data: dict) -> Resume:
80 return self.set_data(resume, data)
83class SimpleJsonForm(forms.Form):
84 plugin_data = forms.JSONField(widget=forms.Textarea)
87class SimpleAdmin:
88 admin_template = "django_resume/admin/simple_plugin_admin_view.html"
89 change_form = "django_resume/admin/simple_plugin_admin_form.html"
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
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
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
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 )
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 )
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 )
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)
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)
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
205class SimpleTemplates:
206 def __init__(self, *, main: str, form: str):
207 self.main = main
208 self.form = form
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
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 )
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 )
239 @staticmethod
240 def check_permissions(request: HttpRequest, resume: Resume) -> bool:
241 return resume.owner == request.user
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
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)
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)
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
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 """
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 )
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 )
340 # plugin protocol methods
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
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
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
378 def get_admin_urls(self, admin_view: Callable) -> URLPatterns:
379 return self.admin.get_urls(admin_view)
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)
386 def get_inline_urls(self) -> URLPatterns:
387 return self.inline.get_urls()
389 def get_data(self, resume: Resume) -> dict:
390 return self.data.get_data(resume)
393class ListItemFormMixin(forms.Form):
394 id = forms.CharField(widget=forms.HiddenInput(), required=False)
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)
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)
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"]
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
433class ListData:
434 """
435 This class contains the logic of the list plugin concerned with the data handling.
437 Simple crud operations are supported.
438 """
440 def __init__(self, *, plugin_name: str):
441 self.plugin_name = plugin_name
443 # read
444 def get_data(self, resume: Resume) -> dict:
445 return resume.plugin_data.get(self.plugin_name, {})
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
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
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
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)
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)
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)
498class ListAdmin:
499 """
500 This class contains the logic of the list plugin concerned with the Django admin interface.
502 Simple crud operations are supported. Each item in the list is a json serializable
503 dict and should have an "id" field.
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 """
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"
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
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 )
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 )
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 )
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 )
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 )
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 )
582 # crud views
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
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
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)
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)
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)
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)
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)
708 # urlpatterns
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
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 """
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
767 # urls
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 )
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 )
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 )
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 )
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 )
805 # crud views
807 @staticmethod
808 def check_permissions(request: HttpRequest, resume: Resume) -> bool:
809 return resume.owner == request.user
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
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)
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
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)
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)
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)
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
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.
969 Additional flat data can be stored in the plugin_data['flat'] field.
970 """
972 name = "list_plugin"
973 verbose_name = "List Plugin"
974 templates: ListTemplates = ListTemplates(
975 main="", flat="", flat_form="", item="", item_form=""
976 ) # overwrite this
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 )
996 # list logic
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
1004 @staticmethod
1005 def items_ordered_by_position(items, reverse=False):
1006 return sorted(items, key=lambda item: item.get("position", 0), reverse=reverse)
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"])
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
1054 # plugin protocol methods
1056 def get_admin_urls(self, admin_view: Callable) -> URLPatterns:
1057 return self.admin.get_urls(admin_view)
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)
1064 def get_inline_urls(self) -> URLPatterns:
1065 return self.inline.get_urls()
1067 def get_form_classes(self) -> dict[str, type[forms.Form]]:
1068 """Please implement this method."""
1069 return {}
1071 def get_data(self, resume: Resume) -> dict:
1072 return self.data.get_data(resume)