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

Allow plugins to replace core views #1466

Closed
2 of 12 tasks
lampwins opened this issue Mar 9, 2022 · 5 comments · Fixed by #1957
Closed
2 of 12 tasks

Allow plugins to replace core views #1466

lampwins opened this issue Mar 9, 2022 · 5 comments · Fixed by #1957
Assignees
Labels
type: feature Introduction of new or enhanced functionality to the application
Milestone

Comments

@lampwins
Copy link
Member

lampwins commented Mar 9, 2022

As ...

P.D. - Plugin Developer

I want ...

A plugin to define a view that can functionally replace a core view by changing which view is routed to in the URL path from urlpatterns

So that ...

I can provide functionality in a plugin that entirely replaces the implementation of a core view. For example, I may want to drastically change the look and feel of the IPAM prefix-list or detail views by completely suppressing certain model fields from table columns or filter fields. I may also want to provide my own filterset and filter form.

Plugin developers are doing this today by having a plugin provide a new view under its own URL namespace and using hacks in the permission framework to "hide" the existing views from navigation. This is very cumbersome as it requires the usage of custom permission actions and the unnecessary implementation of other related model views to override things like return URLs. Even with this hacky implementation, consider that a user may still be able to access the core view by navigating through other areas of the UI. For example, accessing the existing Prefix view by navigating through IP Addresses. This creates very bad UX.

I know this is done when...

  • A plugin developer is able to register a view that is defined in the plugin and fully conforms to the core view's functional interface
  • A plugin developer can specify which core view and URL paths should be replaced by the registered plugin view

Optional - Feature groups this request pertains to.

  • Automation
  • Circuits
  • DCIM
  • IPAM
  • Misc (including Data Sources)
  • Organization
  • Plugins (and other Extensibility)
  • Security (Secrets, etc)
  • Image Management
  • UI/UX
  • Documentation
  • Other (not directly a platform feature)

Database Changes

No response

External Dependencies

No response

@lampwins lampwins added the type: feature Introduction of new or enhanced functionality to the application label Mar 9, 2022
@glennmatthews
Copy link
Contributor

Interesting idea! This would introduce a new way that plugins can potentially conflict with one another, i.e. what should happen if two different plugins both attempt to override the same core view?

@lampwins
Copy link
Member Author

This is certainly an advanced feature and I envision it really only being used by an org's internally developed plugins. I would be fine with the order within PLUGINS dictating which view wins.

@lampwins lampwins added this to the v1.4.0 milestone Apr 8, 2022
@lampwins
Copy link
Member Author

It was excitingly easy to get this off the ground...

diff --git a/nautobot/extras/plugins/__init__.py b/nautobot/extras/plugins/__init__.py
index 3f78020dc..180ab94de 100644
--- a/nautobot/extras/plugins/__init__.py
+++ b/nautobot/extras/plugins/__init__.py
@@ -7,7 +7,7 @@ from packaging import version
 
 from django.core.exceptions import ValidationError
 from django.template.loader import get_template
-from django.urls import URLPattern
+from django.urls import get_resolver, URLPattern
 
 from nautobot.core.apps import (
     NautobotConfig,
@@ -94,6 +94,7 @@ class PluginConfig(NautobotConfig):
     menu_items = "navigation.menu_items"
     secrets_providers = "secrets.secrets_providers"
     template_extensions = "template_content.template_extensions"
+    override_views = "views.override_views"
 
     def ready(self):
         """Callback after plugin app is loaded."""
@@ -190,6 +191,11 @@ class PluginConfig(NautobotConfig):
                         f"{filter_extension.model} -> {filterform_field_name}"
                     )
 
+        # Register override view (if any)
+        override_views = import_object(f"{self.__module__}.{self.override_views}")
+        if override_views is not None:
+            register_override_views(override_views, self.name)
+
     @classmethod
     def validate(cls, user_config, nautobot_version):
         """Validate the user_config for baseline correctness."""
@@ -603,3 +609,29 @@ def register_custom_validators(class_list):
             raise TypeError(f"PluginCustomValidator class {custom_validator} does not define a valid model!")
 
         registry["plugin_custom_validators"][custom_validator.model].append(custom_validator)
+
+
+#
+# Override views
+#
+
+def register_override_views(override_views, plugin):
+    resolver = get_resolver()
+
+    for qualified_view_name, view in override_views.items():
+        try:
+            app_name, view_name = qualified_view_name.split(":")
+        except ValueError:
+            raise ValidationError(
+                f"Plugin {plugin} tried to override view {qualified_view_name} but only top level namespace views are supported (e.g. `dcim:device`)."
+            )
+
+        app_resolver = resolver.namespace_dict.get(app_name)
+        if not app_resolver:
+            raise ValidationError(
+                f"Plugin {plugin} tried to override view {qualified_view_name} but {app_name} is not a valid core app."
+            )
+
+        for pattern in app_resolver[1].url_patterns:
+            if isinstance(pattern, URLPattern) and hasattr(pattern, "name") and pattern.name == view_name:
+                pattern.callback = view

Plugin views.py:

from django.http import HttpResponse
from django.views import View


class DeviceOverrideView(View):
    def get(self, request, *args, **kwargs):
        return HttpResponse("Hello world! I'm a view provided by a plugin to override the `dcim:device` view.")


override_views = {
    "dcim:device": DeviceOverrideView.as_view()
}

@lampwins
Copy link
Member Author

lampwins commented Apr 17, 2022

Because of the way we have namespacing currently setup (for better or worse), this will work for DRF API routes too.

>>> resolver.app_dict
{'djdt': ['djdt'], 'health_check': ['health_check'], 'social': ['social'], 'plugins': ['plugins'], 'admin': ['admin'], 'plugins-api': ['plugins-api'], 'virtualization-api': ['virtualization-api'], 'users-api': ['users-api'], 'tenancy-api': ['tenancy-api'], 'ipam-api': ['ipam-api'], 'extras-api': ['extras-api'], 'dcim-api': ['dcim-api'], 'circuits-api': ['circuits-api'], 'virtualization': ['virtualization'], 'user': ['user'], 'tenancy': ['tenancy'], 'ipam': ['ipam'], 'extras': ['extras'], 'dcim': ['dcim'], 'circuits': ['circuits']}

@bryanculver
Copy link
Member

Completed in #1957.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 11, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
type: feature Introduction of new or enhanced functionality to the application
Projects
No open projects
Archived in project
Development

Successfully merging a pull request may close this issue.

4 participants