carltongibson / neapolitan

Quick CRUD views for Django
https://noumenal.es/neapolitan/
MIT License
525 stars 39 forks source link

Adding parent / child functionality #61

Open awapf opened 2 months ago

awapf commented 2 months ago

I played around with neapolitan and it looks quite productive. My use case includes a lot of parent -> child relationships:

Author --> Book (book always is written by an author) House --> Room (room does not exist without a parent house)

I tried to add this functionality by creating a subclass of CrudView and it works. But there are a lot of changes and I cannot override the Role methods because it is an enum.

  1. Is there a planed featue for this use case?
  2. Is there a simpler way to achieve that than the subclass below?
  3. If there is no simpler way, I could actually see that this could be mergable with the CRUDView. Is that an option?
  4. Why are the url_pattern, get_url, reverse and maybe_reverse on the Role and not on the CrudView? It feels moving them to the CrudView makes it more understandable/easier to change.
class ParentCrudView(CRUDView):
    parent_model = None

    parent_lookup_field = None
    parent_lookup_url_kwarg = "parent_pk"
    parent_path_converter = "int"

    @classproperty
    def parent_url_base(cls):
        return cls.parent_model._meta.model_name

    def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(
            **{self.parent_lookup_field: self.kwargs[self.parent_lookup_url_kwarg]}
        )

    def get_context_data(self, **kwargs):
        kwargs = super().get_context_data(**kwargs)
        kwargs["create_view_url"] = self.maybe_reverse(Role.CREATE)
        return kwargs

    @classmethod
    def get_urls(cls, roles=None):
        """Classmethod to generate URL patterns for the view."""
        if roles is None:
            roles = iter(Role)
        return [cls.get_url(role) for role in roles]

    @classmethod
    def url_pattern(view_cls, role: Role):
        parent_url_base = view_cls.parent_url_base
        parent_url_kwarg = (
            view_cls.parent_lookup_url_kwarg or view_cls.parent_lookup_field
        )
        parent_path_converter = view_cls.parent_path_converter

        parent_path = f"{parent_url_base}/<{parent_path_converter}:{parent_url_kwarg}>"

        url_base = f"{parent_path}/{view_cls.url_base}"
        url_kwarg = view_cls.lookup_url_kwarg or view_cls.lookup_field
        path_converter = view_cls.path_converter

        match role:
            case Role.LIST:
                return f"{url_base}/"
            case Role.DETAIL:
                return f"{url_base}/<{path_converter}:{url_kwarg}>/"
            case Role.CREATE:
                return f"{url_base}/new/"
            case Role.UPDATE:
                return f"{url_base}/<{path_converter}:{url_kwarg}>/edit/"
            case Role.DELETE:
                return f"{url_base}/<{path_converter}:{url_kwarg}>/delete/"

    @classmethod
    def get_url(view_cls, role: Role):
        return path(
            view_cls.url_pattern(role),
            view_cls.as_view(role=role),
            name=f"{view_cls.parent_url_base}-{view_cls.url_base}-{role.url_name_component}",
        )

    def reverse(self, role, object=None):
        url_name = f"{self.parent_url_base}-{self.url_base}-{role.url_name_component}"
        url_kwarg = self.lookup_url_kwarg or self.lookup_field
        parent_url_kwarg = self.parent_lookup_url_kwarg or self.parent_lookup_field
        match role:
            case Role.LIST | Role.CREATE:
                return reverse(
                    url_name,
                    kwargs={
                        parent_url_kwarg: self.kwargs[self.parent_lookup_url_kwarg]
                    },
                )
            case _:
                return reverse(
                    url_name,
                    kwargs={
                        parent_url_kwarg: self.kwargs[self.parent_lookup_url_kwarg],
                        url_kwarg: getattr(object, self.lookup_field),
                    },
                )

    def maybe_reverse(self, role, object=None):
        try:
            return self.reverse(role, object)
        except NoReverseMatch:
            return None

    def process_form(self, request, *args, **kwargs):
        if self.role is Role.UPDATE:
            self.object = self.get_object()
        form = self.get_form(
            data=request.POST,
            files=request.FILES,
            instance=self.object,
        )

        if form.is_valid():
            parent_pk = self.kwargs[self.parent_lookup_url_kwarg]
            setattr(form.instance, self.parent_lookup_field, parent_pk)
            return self.form_valid(form)

        return self.form_invalid(form)

    def action_links(self, object):
        actions = [
            (url, name)
            for url, name in [
                (self.maybe_reverse(Role.DETAIL, object), "View"),
                (self.maybe_reverse(Role.UPDATE, object), "Edit"),
                (self.maybe_reverse(Role.DELETE, object), "Delete"),
            ]
            if url is not None
        ]
        return (a(href=url)[anchor_text] for url, anchor_text in actions)

    def obj_list(self, objects):
        fields = self.fields
        headers = [objects[0]._meta.get_field(f).verbose_name for f in fields]
        obj_list = [
            {
                "object": object,
                "fields": [
                    object._meta.get_field(f).value_to_string(object) for f in fields
                ],
                "actions": self.action_links(object),
            }
            for object in objects
        ]
        return div[
            table[
                thead[tr[(th[capfirst(header)] for header in headers), th["Actions"]]],
                tbody[
                    (
                        tr[(td[field] for field in obj["fields"]), td[obj["actions"]]]
                        for obj in obj_list
                    )
                ],
            ]
        ]

    def get_success_url(self):
        assert self.model is not None, (
            "'%s' must define 'model' or override 'get_success_url()'"
            % self.__class__.__name__
        )
        if self.role is Role.DELETE:
            success_url = self.maybe_reverse(Role.LIST)
        else:
            success_url = self.maybe_reverse(Role.DETAIL, self.object)
        return success_url

    def render_to_response(self, context):
        if self.role == Role.LIST:
            return HttpResponse(
                html[
                    body[
                        div[
                            h1[
                                capfirst(
                                    context["object_verbose_name_plural"],
                                ),
                            ],
                            context["create_view_url"]
                            and div[
                                a(href=context["create_view_url"])[
                                    f"Add a new { context['object_verbose_name'] }"
                                ]
                            ],
                            self.obj_list(context["object_list"])
                            if context["object_list"]
                            else p[
                                f"There are no { context['object_verbose_name_plural'] }. Create one now?"
                            ],
                        ]
                    ]
                ]
            )

        return super().render_to_response(context)

The html stuff is provided by the htpy library.

ryanhiebert commented 1 week ago

This is something that I wanted, and I ended up restructuring my app so that I could make the routes flat. I used to use drf-nested-routes to do something similar with Django Rest Framework, and tbh I was never totally happy with the results. It worked, though, and it's been long enough that I'm not precisely sure what felt off about it.

carltongibson commented 1 week ago

I have plans here but it will definitely be on a flat basis. Nested routes open up a whole load of weird edge cases that aren't worth the trouble, in my experience.

I have the patterns working well in the WORK project. Just a question of finding the time to extract them.