Repository: wagtail/wagtail

Issue:
Allow specifying a custom base URL when registering ModelAdmin
### Is your proposal related to a problem?

Prompted by https://stackoverflow.com/q/71248504/1853523, where a user set up ModelAdmin for a model in an app named 'users', which resulted in it registering URLs under `/admin/users/` that clashed with Wagtail's own URLs from `wagtail.users`. It seems that there's currently no straightforward way to customise the URL path that ModelAdmin registers - you need to override AdminURLHelper instead.

### Describe the solution you'd like

ModelAdmin could support a `base_url_path` option, e.g.

    class BookAdmin(ModelAdmin):
        model = Book
        base_url_path = 'library'  # make URLs appear under `/admin/library/` instead of `/admin/myapp/books/`

This could be implemented as follows:

* Extend `wagtail.contrib.modeladmin.helpers.url.AdminURLHelper.__init__` to accept an optional `base_url_path` keyword argument
* In `_get_action_url_pattern`, use this to build the URL string, falling back on `{app_label}/{model_name}` if not specified
* Update `wagtail.contrib.modeladmin.options.ModelAdmin` to add `base_url_path` to the list of config attributes, and pass this on when initialising `self.url_helper`. Note that we need to deal with the possibility that a user might have created a subclass of AdminURLHelper in their own code and overridden `__init__`, as in this case their code won't know about the new argument - I'd suggest using [`accepts_kwarg`](https://github.com/wagtail/wagtail/blob/e2b30c1690fb231abbb2e896e5553119db24cf8b/wagtail/core/utils.py#L166) to check this before calling it, and outputting a deprecation warning if it doesn't. We can then remove that extra check in a future release.
* Add tests and documentation
Allow specifying a custom base URL when registering ModelAdmin
### Is your proposal related to a problem?

Prompted by https://stackoverflow.com/q/71248504/1853523, where a user set up ModelAdmin for a model in an app named 'users', which resulted in it registering URLs under `/admin/users/` that clashed with Wagtail's own URLs from `wagtail.users`. It seems that there's currently no straightforward way to customise the URL path that ModelAdmin registers - you need to override AdminURLHelper instead.

### Describe the solution you'd like

ModelAdmin could support a `base_url_path` option, e.g.

    class BookAdmin(ModelAdmin):
        model = Book
        base_url_path = 'library'  # make URLs appear under `/admin/library/` instead of `/admin/myapp/books/`

This could be implemented as follows:

* Extend `wagtail.contrib.modeladmin.helpers.url.AdminURLHelper.__init__` to accept an optional `base_url_path` keyword argument
* In `_get_action_url_pattern`, use this to build the URL string, falling back on `{app_label}/{model_name}` if not specified
* Update `wagtail.contrib.modeladmin.options.ModelAdmin` to add `base_url_path` to the list of config attributes, and pass this on when initialising `self.url_helper`. Note that we need to deal with the possibility that a user might have created a subclass of AdminURLHelper in their own code and overridden `__init__`, as in this case their code won't know about the new argument - I'd suggest using [`accepts_kwarg`](https://github.com/wagtail/wagtail/blob/e2b30c1690fb231abbb2e896e5553119db24cf8b/wagtail/core/utils.py#L166) to check this before calling it, and outputting a deprecation warning if it doesn't. We can then remove that extra check in a future release.
* Add tests and documentation


Discussion:
Can I work on this ? 
It would be my first contribution 
@Nawarrr Yes, go ahead!
I have a little question regarding accepts_kwarg, I'm not sure where exactly I should be checking before calling it, but that's what came up to my mind, the 

```
def get_url_helper_class(self): 
        if not accepts_kwarg(AdminURLHelper.__init__, 'base_url_path'):
            raise DeprecationWarning("AdminURLHelper.__init__ is Depricated")
        if self.url_helper_class:
            return self.url_helper_class
        if self.is_pagemodel:
            return PageAdminURLHelper
        return AdminURLHelper
```

this is the line where self.url_helper is initialized 

`self.url_helper = self.get_url_helper_class()(self.model , self.base_url_path)`



The `accepts_kwarg` check would go just before we create the AdminURLHelper instance, to make sure the constructor actually accepts the arguments we want to pass it. So, the line `self.url_helper = self.get_url_helper_class()(self.model , self.base_url_path)` would become something like:

    url_helper_class = self.get_url_helper_class()
    # make sure the url_helper_class constructor accepts a base_url_path argument before we call it
    if accepts_kwarg(url_helper_class, 'base_url_path'):
        self.url_helper = url_helper_class(self.model, base_url_path=self.base_url_path)
    else:
        warn(
            "%s.__init__ needs to be updated to accept a base_url_path keyword argument" % url_helper_class.__name__,
            category=RemovedInWagtail219Warning
        )
        # fall back to calling the old signature, so that we're not breaking existing code
        self.url_helper = url_helper_class(self.model)

Hi, because @Nawarrr hasn't commented in 2 weeks, so I have worked on this issue and come up with a solution.

1. About the tests, I'm thinking about editing a Model in wagtail\test\modeladmintest\wagtail_hooks.py to include base_url_path, and then in wagtail\contrib\modeladmin\tests\test_modeladmin_edit_handlers.py, in response = self.client.get(...) for the appropriate model, I will edit the URL there to fit base_url_path. Is it okay?

2. For documentation, in [ModelAdmin](https://docs.wagtail.org/en/latest/reference/contrib/modeladmin/index.html#want-to-know-more-about-customising-modeladmin), should we create a sub-page for customizing base URL, or can I just add it to the [Additional tips and tricks](https://docs.wagtail.org/en/latest/reference/contrib/modeladmin/tips_and_tricks/index.html)?

Should I still create a pull request without tests and documentations, and amend it later?
Go for it @vupham04 thanks for communicating.
Can I work on this ? 
It would be my first contribution 
@Nawarrr Yes, go ahead!
I have a little question regarding accepts_kwarg, I'm not sure where exactly I should be checking before calling it, but that's what came up to my mind, the 

```
def get_url_helper_class(self): 
        if not accepts_kwarg(AdminURLHelper.__init__, 'base_url_path'):
            raise DeprecationWarning("AdminURLHelper.__init__ is Depricated")
        if self.url_helper_class:
            return self.url_helper_class
        if self.is_pagemodel:
            return PageAdminURLHelper
        return AdminURLHelper
```

this is the line where self.url_helper is initialized 

`self.url_helper = self.get_url_helper_class()(self.model , self.base_url_path)`



The `accepts_kwarg` check would go just before we create the AdminURLHelper instance, to make sure the constructor actually accepts the arguments we want to pass it. So, the line `self.url_helper = self.get_url_helper_class()(self.model , self.base_url_path)` would become something like:

    url_helper_class = self.get_url_helper_class()
    # make sure the url_helper_class constructor accepts a base_url_path argument before we call it
    if accepts_kwarg(url_helper_class, 'base_url_path'):
        self.url_helper = url_helper_class(self.model, base_url_path=self.base_url_path)
    else:
        warn(
            "%s.__init__ needs to be updated to accept a base_url_path keyword argument" % url_helper_class.__name__,
            category=RemovedInWagtail219Warning
        )
        # fall back to calling the old signature, so that we're not breaking existing code
        self.url_helper = url_helper_class(self.model)

Hi, because @Nawarrr hasn't commented in 2 weeks, so I have worked on this issue and come up with a solution.

1. About the tests, I'm thinking about editing a Model in wagtail\test\modeladmintest\wagtail_hooks.py to include base_url_path, and then in wagtail\contrib\modeladmin\tests\test_modeladmin_edit_handlers.py, in response = self.client.get(...) for the appropriate model, I will edit the URL there to fit base_url_path. Is it okay?

2. For documentation, in [ModelAdmin](https://docs.wagtail.org/en/latest/reference/contrib/modeladmin/index.html#want-to-know-more-about-customising-modeladmin), should we create a sub-page for customizing base URL, or can I just add it to the [Additional tips and tricks](https://docs.wagtail.org/en/latest/reference/contrib/modeladmin/tips_and_tricks/index.html)?

Should I still create a pull request without tests and documentations, and amend it later?
Go for it @vupham04 thanks for communicating.

Resolving patch:
diff --git a/wagtail/contrib/modeladmin/helpers/url.py b/wagtail/contrib/modeladmin/helpers/url.py
--- a/wagtail/contrib/modeladmin/helpers/url.py
+++ b/wagtail/contrib/modeladmin/helpers/url.py
@@ -6,19 +6,24 @@
 
 
 class AdminURLHelper:
-    def __init__(self, model):
+    def __init__(self, model, base_url_path=None):
         self.model = model
         self.opts = model._meta
+        self.base_url_path = self._get_base_url_path(base_url_path)
+
+    def _get_base_url_path(self, base_url_path):
+        if base_url_path:
+            return base_url_path.strip().strip("/")
+        return r"%s/%s" % (self.opts.app_label, self.opts.model_name)
 
     def _get_action_url_pattern(self, action):
         if action == "index":
-            return r"^%s/%s/$" % (self.opts.app_label, self.opts.model_name)
-        return r"^%s/%s/%s/$" % (self.opts.app_label, self.opts.model_name, action)
+            return r"^%s/$" % (self.base_url_path)
+        return r"^%s/%s/$" % (self.base_url_path, action)
 
     def _get_object_specific_action_url_pattern(self, action):
-        return r"^%s/%s/%s/(?P<instance_pk>[-\w]+)/$" % (
-            self.opts.app_label,
-            self.opts.model_name,
+        return r"^%s/%s/(?P<instance_pk>[-\w]+)/$" % (
+            self.base_url_path,
             action,
         )
 
@@ -28,9 +33,8 @@ def get_action_url_pattern(self, action):
         return self._get_object_specific_action_url_pattern(action)
 
     def get_action_url_name(self, action):
-        return "%s_%s_modeladmin_%s" % (
-            self.opts.app_label,
-            self.opts.model_name,
+        return "%s_modeladmin_%s" % (
+            self.base_url_path.replace("/", "_"),
             action,
         )
 
diff --git a/wagtail/contrib/modeladmin/options.py b/wagtail/contrib/modeladmin/options.py
--- a/wagtail/contrib/modeladmin/options.py
+++ b/wagtail/contrib/modeladmin/options.py
@@ -13,6 +13,7 @@
 from wagtail.admin.admin_url_finder import register_admin_url_finder
 from wagtail.admin.checks import check_panels_in_model
 from wagtail.admin.panels import ObjectList, extract_panel_definitions_from_model_class
+from wagtail.coreutils import accepts_kwarg
 from wagtail.models import Page, TranslatableMixin
 from wagtail.utils.deprecation import RemovedInWagtail50Warning
 
@@ -141,6 +142,7 @@ class ModelAdmin(WagtailRegisterable):
     form_view_extra_css = []
     form_view_extra_js = []
     form_fields_exclude = []
+    base_url_path = None
 
     def __init__(self, parent=None):
         """
@@ -157,7 +159,18 @@ def __init__(self, parent=None):
         self.permission_helper = self.get_permission_helper_class()(
             self.model, self.inspect_view_enabled
         )
-        self.url_helper = self.get_url_helper_class()(self.model)
+        url_helper_class = self.get_url_helper_class()
+        if accepts_kwarg(url_helper_class, "base_url_path"):
+            self.url_helper = url_helper_class(
+                self.model, base_url_path=self.base_url_path
+            )
+        else:
+            warn(
+                "%s.__init__ needs to be updated to accept a `base_url_path` keyword argument"
+                % url_helper_class.__name__,
+                category=RemovedInWagtail50Warning,
+            )
+            self.url_helper = url_helper_class(self.model)
 
         # Needed to support RelatedFieldListFilter
         # See: https://github.com/wagtail/wagtail/issues/5105


Test patch:
diff --git a/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py b/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py
--- a/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py
+++ b/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py
@@ -50,7 +50,7 @@ def test_model_admin_edit_handler_called(self, mock_modeladmin_get_edit_handler)
     def test_model_panels(self):
         """loads the 'create' view and verifies that form fields are returned
         which have been defined via model Friend.panels"""
-        response = self.client.get("/admin/modeladmintest/friend/create/")
+        response = self.client.get("/admin/friendadmin/create/")
         self.assertEqual(
             list(response.context["form"].fields), ["first_name", "phone_number"]
         )
diff --git a/wagtail/test/modeladmintest/wagtail_hooks.py b/wagtail/test/modeladmintest/wagtail_hooks.py
--- a/wagtail/test/modeladmintest/wagtail_hooks.py
+++ b/wagtail/test/modeladmintest/wagtail_hooks.py
@@ -148,6 +148,7 @@ class PersonAdmin(ModelAdmin):
 
 class FriendAdmin(ModelAdmin):
     model = Friend
+    base_url_path = "friendadmin"
 
 
 class VisitorAdmin(ModelAdmin):