Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CRUDView does not work with namespaced urlpatterns #16

Open
LucidDan opened this issue Jun 7, 2023 · 8 comments
Open

CRUDView does not work with namespaced urlpatterns #16

LucidDan opened this issue Jun 7, 2023 · 8 comments
Labels
wontfix This will not be worked on

Comments

@LucidDan
Copy link
Contributor

LucidDan commented Jun 7, 2023

Perhaps this isn't very high priority for now and could just be documented as a known limitation, and I know you're potentially going to make API changes anyway. But I thought it was worth mentioning.

A simple example here: https://github.com/LucidDan/neapolitan-example - the main branch is a working copy of the tutorial project, slightly altered to use a projects.urls module.

The branch namespaces breaks due to the reverse urls not being valid, because of the addition of app_name = "projects" in the projects.urls module:

Internal Server Error: /project/
Traceback (most recent call last):
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/neapolitan/views.py", line 426, in view
    return self.dispatch(request, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
    return handler(request, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/neapolitan/views.py", line 312, in list
    context = self.get_context_data(
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/neapolitan/views.py", line 250, in get_context_data
    kwargs["create_view_url"] = reverse(f"{self.model._meta.model_name}-create")
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/django/urls/base.py", line 88, in reverse
    return resolver._reverse_with_prefix(view, prefix, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dans/Library/Application Support/pdm/venvs/neapolitan-test-O2zzKXMP-3.11/lib/python3.11/site-packages/django/urls/resolvers.py", line 828, in _reverse_with_prefix
    raise NoReverseMatch(msg)
django.urls.exceptions.NoReverseMatch: Reverse for 'project-create' not found. 'project-create' is not a valid view function or pattern name.

I really like url namespaces and find them extremely useful in large projects, but even the Django implementation itself is a bit magical and complicated to use...I'd totally understand if you decided its better to just say "yeah this doesn't work, make sure you do get_urls() from your main project urls.py" 🤷

@carltongibson
Copy link
Owner

Yeah...

I basically never use namespaces, since they're a bit of a pain, and real life URL name collisions are quite rare.

Maybe get_urls() could take a namespace. But how would that get passed down to the various places where the url name for reverse is needed? (Would that be worth the price of admission here?)

@LucidDan
Copy link
Contributor Author

LucidDan commented Jun 7, 2023

Yeah agreed. I like them, but every time I use them I find myself thinking "you know, I could just name all of these urls explicitly with a prefix instead of a namespace".

In any case, I had noodled on how to support it - I can see both of these being possibly good ways to handle it:

urlpatterns += get_urls(namespace="app_name")

as well as:

class ProjectView(CRUDView):
    namespace = "app_name"
    # ...

I suppose though, it does make a little more sense in get_urls(), because generating the urls is when you'd "decide" the url namespace...but the problem with that is since get_urls() is a classmethod, it can't set the namespace for the CRUDView, which needs to have the namespace stored so it knows how to do its reverse() calls correctly.

It seems to me like maybe the latter approach with the attribute in the CRUDView is the only way that would work in the current API?

@carltongibson
Copy link
Owner

It's the {% url ... %} usages too…

@carltongibson
Copy link
Owner

carltongibson commented Jun 7, 2023

More or less, I think I'd lean to not namespacing unless a proof-of-concept can demonstrate that adding it is not going to cause issues.

Currently we're able to programmatically construct the right view names, like so:

reverse(f"{model_name}-detail", kwargs={"pk": object.pk})

And similar.

We'll need some complexity of different URL patterns maybe, but everywhere and always needing to handle an optional namespace there seems like quite a lot of grief.

Happy to be shown otherwise, but it's not something I'm going to put thought to myself at this stage.

@LucidDan
Copy link
Contributor Author

LucidDan commented Jun 7, 2023

Makes sense to me!

@carltongibson carltongibson added the wontfix This will not be worked on label Jun 7, 2023
@carltongibson carltongibson closed this as not planned Won't fix, can't repro, duplicate, stale Jun 7, 2023
@carltongibson
Copy link
Owner

For reference, there was good discussion about this on fosstodon

https://fosstodon.org/@carlton/110503156769347222

I'm not adverse to looking at something here if it's clean, but I'm not personally going to work on it, so closing for that reason.

@carltongibson carltongibson reopened this Jul 8, 2024
@carltongibson
Copy link
Owner

Reopening this, as it might be feasible now that URL name generation AND reversing lives within the view.

Still not 100% convinced but it's gone from No to Maybe.

@mfoulds
Copy link

mfoulds commented Nov 14, 2024

I've had a go at supporting this with a mixin and subclassed CRUDView, as shown below. It supports reversing namedspace urlpatterns.

Usage

The key is you have to specify the namespace, as follows:

# apps/fstp/views.py
class TenderCRUDView(NominoCRUDView):
    model = models.Tender
    namespace = "fstp" #<----
    fields = ["related_customer","name","submission_date","current_version",]

The app's urls.py is below. In the project urls.py we have path("fstp/", include("apps.fstp.urls", namespace="fstp")).

# apps/fstp/urls.py
from django.urls import path
from . import views

app_name = "fstp"

urlpatterns = [
    path("test-import/", views.test_import_view, name="test_import"),
]

urlpatterns += views.TenderCRUDView.get_urls()

Overrides

Some of the methods shown below do not change functionality; they are only needed:

  • to support the mixin structure instead of directly modifying Role; or
  • because I set this up as an app called nominopolitan instead of directly overriding neapolitan

Mixin

The only change really needed is to the action_links() method. I included the tag registers because I was redirecting them to the specific nominopolitan/partial/ templates.

from django import template
from django.utils.safestring import mark_safe

register = template.Library()

from django.urls import reverse, NoReverseMatch

def action_links(view, object):
    prefix = view.get_prefix()

    actions = [
        (url, name)
        for url, name in [
            (
                view.safe_reverse(f"{prefix}-detail", kwargs={"pk": object.pk}),
                "View",
            ),
            (
                view.safe_reverse(f"{prefix}-update", kwargs={"pk": object.pk}),
                "Edit",
            ),
            (
                view.safe_reverse(f"{prefix}-delete", kwargs={"pk": object.pk}),
                "Delete",
            ),
        ]
        if url is not None
    ]
    links = [f"<a href='{url}'>{anchor_text}</a>" for url, anchor_text in actions]
    return mark_safe(" | ".join(links))

@register.inclusion_tag("nominopolitan/partial/detail.html")
def object_detail(object, fields):
# ---> original code goes here

@register.inclusion_tag("nominopolitan/partial/list.html")
def object_list(objects, view):
# ---> original code goes here

Subclass of CRUDView

I used a mixin NominopolitanMixin to override functionality from Role and then subclassed CRUDView and included the mixin.

from neapolitan.views import CRUDView, Role
from django.urls import NoReverseMatch, path, reverse
from django.utils.decorators import classonlymethod
from django.core.exceptions import ImproperlyConfigured

import logging
log = logging.getLogger(__name__)

class NominopolitanMixin:

    def reverse(self, role, view, object=None):
        """Allows for reverse of namespaced URLS.

        Args:
            role (Role): Needed to work in mixin. If if Role this would be self.
            view (CRUDView): The view from CRUDView or its subclass  
            object (_type_, optional): As per original Role. Defaults to None.
        """
        url_name = (
            f"{view.namespace}:{view.url_base}-{role.url_name_component}"
            if view.namespace
            else f"{view.url_base}-{role.url_name_component}"
        )
        url_kwarg = view.lookup_url_kwarg or view.lookup_field

        match role:
            case Role.LIST | Role.CREATE:
                return reverse(url_name)
            case _:
                if object is None:
                    raise ValueError("Object required for detail, update, and delete URLs")
                return reverse(
                    url_name,
                    kwargs={url_kwarg: getattr(object, view.lookup_field)},
                )

    # *** No changes in functionality. Only required to work in mixin structure

    def maybe_reverse(self, view, object=None):
        """
        Required in order to work in mixin. 
        No change to functionality from Role.        
        """
        try:
            return self.reverse(view, object)
        except NoReverseMatch:
            return None

    @staticmethod
    def get_url(role, view_cls):
        """
        No change in functionality from Role.get_url but needs to be 
        a static method to work in the mixin.
        """
        return path(
            role.url_pattern(view_cls),
            view_cls.as_view(role=role),
            name=f"{view_cls.url_base}-{role.url_name_component}",
        )


class NominoCRUDView(NominopolitanMixin, CRUDView):
    """Base class for all CRUD views with custom functionality."""
    class Meta:
        abstract = True

    # Add new attribute namespace
    namespace = None

    def get_prefix(self):
        """Helper function to get the prefix for the URL name"""
        return f"{self.namespace}:{self.url_base}" if self.namespace else self.url_base

    def safe_reverse(self, viewname, kwargs=None):
        """Attempt to reverse a URL, returning None if it fails."""
        try:
            return reverse(viewname, kwargs=kwargs)
        except NoReverseMatch:
            return None

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # Override the create_view_url to use our namespaced reverse
        view_name = f"{self.get_prefix()}-create"
        context["create_view_url"] = self.safe_reverse(view_name)

        return context

    def get_success_url(self):
        """
        Override to include self.namespace if it exists, using self.get_prefix()
        """
        assert self.model is not None, (
            "'%s' must define 'model' or override 'get_success_url()'"
            % self.__class__.__name__
        )

        url_name = f"{self.get_prefix()}-list"
        if self.role is Role.DELETE:
            success_url = reverse(url_name)
        else:
            detail_url = f"{self.get_prefix()}-detail"
            success_url = reverse(detail_url, kwargs={"pk": self.object.pk})

        return success_url

    # *** Below methods only required in order to work in specific subclassed app structure.
             # If modifying neapolitan these would not be necessary.

    def get_template_names(self):
        """Only required to work in with subclassed app templates.
        Changes `f"neapolitan/object"` to `f"nominopolitan/object"`

        """
        if self.template_name is not None:
            return [self.template_name]

        if self.model is not None and self.template_name_suffix is not None:
            return [
                f"{self.model._meta.app_label}/"
                f"{self.model._meta.object_name.lower()}"
                f"{self.template_name_suffix}.html",
                # change to use specified app templates
                f"nominopolitan/object{self.template_name_suffix}.html",
            ]
        msg = (
            "'%s' must either define 'template_name' or 'model' and "
            "'template_name_suffix', or override 'get_template_names()'"
        )
        raise ImproperlyConfigured(msg % self.__class__.__name__)

    @classonlymethod
    def get_urls(cls, roles=None):
        """Required to specify mixin as class"""
        if roles is None:
            roles = iter(Role)
        return [NominopolitanMixin.get_url(role, cls) for role in roles]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests

3 participants