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
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-13 13:17 +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 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)
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)
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
209class SimpleTemplates:
210 def __init__(self, *, main: str, form: str):
211 self.main = main
212 self.form = form
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
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 )
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 )
243 @staticmethod
244 def check_permissions(request: HttpRequest, resume: Resume) -> bool:
245 return resume.owner == request.user
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
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)
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)
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
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 """
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 )
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 )
344 # plugin protocol methods
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
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
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
382 def get_admin_urls(self, admin_view: Callable) -> URLPatterns:
383 return self.admin.get_urls(admin_view)
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)
390 def get_inline_urls(self) -> URLPatterns:
391 return self.inline.get_urls()
393 def get_data(self, resume: Resume) -> dict:
394 return self.data.get_data(resume)
397class ListItemFormMixin(forms.Form):
398 id = forms.CharField(widget=forms.HiddenInput(), required=False)
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)
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)
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"]
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
437class ListData:
438 """
439 This class contains the logic of the list plugin concerned with the data handling.
441 Simple crud operations are supported.
442 """
444 def __init__(self, *, plugin_name: str):
445 self.plugin_name = plugin_name
447 # read
448 def get_data(self, resume: Resume) -> dict:
449 return resume.plugin_data.get(self.plugin_name, {})
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
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
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
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)
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)
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)
502class ListAdmin:
503 """
504 This class contains the logic of the list plugin concerned with the Django admin interface.
506 Simple crud operations are supported. Each item in the list is a json serializable
507 dict and should have an "id" field.
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 """
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"
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
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 )
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 )
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 )
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 )
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 )
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 )
586 # crud views
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
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
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)
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)
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)
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)
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)
716 # urlpatterns
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
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 """
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
775 # urls
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 )
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 )
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 )
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 )
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 )
813 # crud views
815 @staticmethod
816 def check_permissions(request: HttpRequest, resume: Resume) -> bool:
817 return resume.owner == request.user
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
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)
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
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)
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)
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)
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
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.
981 Additional flat data can be stored in the plugin_data['flat'] field.
982 """
984 name = "list_plugin"
985 verbose_name = "List Plugin"
986 templates: ListTemplates = ListTemplates(
987 main="", flat="", flat_form="", item="", item_form=""
988 ) # overwrite this
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 )
1008 # list logic
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
1016 @staticmethod
1017 def items_ordered_by_position(items, reverse=False):
1018 return sorted(items, key=lambda item: item.get("position", 0), reverse=reverse)
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"])
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
1066 # plugin protocol methods
1068 def get_admin_urls(self, admin_view: Callable) -> URLPatterns:
1069 return self.admin.get_urls(admin_view)
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)
1076 def get_inline_urls(self) -> URLPatterns:
1077 return self.inline.get_urls()
1079 def get_form_classes(self) -> dict[str, type[forms.Form]]:
1080 """Please implement this method."""
1081 return {}
1083 def get_data(self, resume: Resume) -> dict:
1084 return self.data.get_data(resume)