diff --git a/README.md b/README.md index 179b32a..88cc127 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ The Django admin allows to list rows in an easy way. Some feature that seems to be "missing" is to jump in an efficient way to the detail view of a *related* object. For example if a model `A` has a `ForeignKey` to `B`, then the `ModelAdmin` of `A` can show the `__str__` of `B`, but without a link. +Django's admin actions are also very useful, but what seems to be missing is an easy way to just run the action on a single row without too much "hassle". + This package provides a mixin to effectively add such links. ## Installation @@ -17,9 +19,28 @@ You can install the package with: pip install django-adminlink ``` +You do *not* need to add `'django_adminlink'` to the `INSTALLED_APPS` settings *unless* you use the `SingleItemActionMixin` or a derived product from it. In that case, +you need to make use of the `static/js/single_admin_action.js` file that ships with it. Then the `INSTALLED_APPS` looks like: + + +```python3 +# settings.py + +# … + +INSTALLED_APPS = [ + # …, + 'django_adminlink' +] +``` + ## Usage -Once the package is installed, you can use the `LinkFieldAdminMixin` mixin in the admins where you want `ForeignKey`s and `OneToOneField`s to be linked to the corresponding admin detail view of that object: +Once the package is installed, we can work with the mixins provided by the package. + +### Adding links to `ForeignKey` fields + +you can use the `LinkFieldAdminMixin` mixin in the admins where you want `ForeignKey`s and `OneToOneField`s to be linked to the corresponding admin detail view of that object: ```python3 from django.contrib import admin @@ -32,3 +53,33 @@ class MovieAdmin(LinkFieldAdminMixin, admin.ModelAdmin): ``` If `genre` is a `ForeignKey` to a `Genre` model for example, and `Genre` has its own `ModelAdmin`, it will automatically convert `genre` into a column that adds a link to the admin detail view of the corresponding genre. + +### Single row actions + +The package also provides a `SingleItemActionMixin`, this enables to add a column at the right end of the admin that contains (one or more) buttons. These buttons then run a Django admin action on a *single* record. + +One can specify which actions to run by listing these, for example: + +```python3 +from django.contrib import admin +from django_adminlink.admin import SingleItemActionMixin + +@admin.register(Movie) +class MovieAdmin(SingleItemActionMixin, admin.ModelAdmin): + action_buttons = {'delete': 'delete_selected'} +``` + +One can work with a dictionary that has as key the "label" of the button, and as value the name (key) of the action to work with. This will add a button with the label "delete" as last column. When clicked, that row, and only that row is then removed. + +The package does not perform the action itself: it works with a small amount of *JavaScript* that just disables all checkboxes, enables only the checkbox of the selected row, and finally submits the action form, letting Django handle the rest of the logic. + +If the label(s) and action(s) are the same, one can also work with a list of the names of the actions, like: + +```python3 +from django.contrib import admin +from django_adminlink.admin import SingleItemActionMixin + +@admin.register(Movie) +class MovieAdmin(SingleItemActionMixin, admin.ModelAdmin): + action_buttons = ['delete_selected'] +``` \ No newline at end of file diff --git a/django_adminlink/admin.py b/django_adminlink/admin.py index 0fbf496..b4f53ef 100644 --- a/django_adminlink/admin.py +++ b/django_adminlink/admin.py @@ -1,14 +1,21 @@ from django.contrib import admin from django.core.exceptions import FieldDoesNotExist from django.db import models +from django.forms import Media from django.urls import reverse -from django.utils.html import format_html +from django.utils.html import format_html, format_html_join class LinkFieldAdminMixin: admin_site_to_link = None + admin_url_namespace = "admin" def _convert_list_display_item(self, field_name): + """ + Converts a list display field name to a callable that renders a link for ForeignKey fields. + + If the specified field is a ForeignKey, returns a callable that displays the related object as a clickable link to its admin change page. Otherwise, returns the original field name. + """ if isinstance(field_name, str): try: field = self.model._meta.get_field(field_name) @@ -28,14 +35,31 @@ def get_list_display(self, request): return result def _link_to_model_field(self, field): + """ + Returns a callable that renders a ForeignKey field as a clickable link to the related object's admin change page. + + If the related model is not registered with the admin site, returns the field name instead. + """ related_model = field.related_model admin_site = self.admin_site_to_link or admin.site model_admin = admin_site._registry.get(related_model) if model_admin is not None: - url_root = f"admin:{related_model._meta.app_label}_{related_model._meta.model_name}_change" + url_root = f"{related_model._meta.app_label}_{related_model._meta.model_name}_change" + if self.admin_url_namespace: + # prefix with namespace + url_root = f"{self.admin_url_namespace}:{url_root}" @admin.display(description=field.name, ordering=f"{field.name}") def column_render(obj): + """ + Renders a foreign key field as a clickable link to the related object's admin change page. + + Args: + obj: The model instance containing the foreign key field. + + Returns: + An HTML anchor element linking to the related object's admin page, or None if the field is not set. + """ key = getattr(obj, field.name) if key is not None: return format_html( @@ -53,3 +77,53 @@ def column_render(obj): class LinkFieldAdmin(LinkFieldAdminMixin, admin.ModelAdmin): pass + + +class SingleItemActionMixin: + action_buttons = [] + + @admin.display(description="actions") + def action_button_column(self, obj): + """ + Renders action buttons for each object in the admin list display. + + Each button is configured with data attributes for the action name and object primary key, + and triggers the `get_checkboxes` JavaScript function when clicked. + """ + if isinstance(self.action_buttons, dict): + action_buttons = self.action_buttons.items() + else: + action_buttons = [(x, x) for x in self.action_buttons] + return format_html_join( + "", + '', + [(item, item, str(obj.pk), label) for label, item in action_buttons], + ) + + def get_list_display(self, request): + """ + Extends the list display to include a column of action buttons if any are defined. + + If the `action_buttons` attribute is set, appends the `action_button_column` to the list + display; otherwise, returns the default list display. + """ + items = super().get_list_display(request) + if self.action_buttons: + return [*items, self.action_button_column] + else: + # if no action buttons are used, we can simply drop the column + return items + + @property + def media(self): + """ + Extends the admin media to include JavaScript for single-item action buttons. + + Returns: + The combined media object with the additional JavaScript file included. + """ + return super().media + Media(js=["js/single_admin_action.js"]) + + +class SingleItemActionAdmin(SingleItemActionMixin, admin.ModelAdmin): + pass diff --git a/django_adminlink/models.py b/django_adminlink/models.py new file mode 100644 index 0000000..e69de29 diff --git a/django_adminlink/static/js/single_admin_action.js b/django_adminlink/static/js/single_admin_action.js new file mode 100644 index 0000000..0b3ee17 --- /dev/null +++ b/django_adminlink/static/js/single_admin_action.js @@ -0,0 +1,28 @@ +/** + * Selects a single item in a Django admin list and triggers the specified admin action for that item. + * + * Retrieves the primary key and action from the provided element's data attributes, updates the action selector, unchecks all other items, checks the targeted item, and submits the form to perform the action on that item only. + * + * @param {Element} e - The element containing `data-pk` and `data-action` attributes for the target item and action. + */ +function get_checkboxes(e) { + const pk = e.getAttribute('data-pk'); + const action = e.getAttribute('data-action'); + const actionSelector = document.querySelector(`select[name=action]`); + if(actionSelector == null) { + return; + } + for(const acrossInput of document.querySelectorAll('div.actions input.select-across')) { + acrossInput.value = 0; + } + actionSelector.value = action; + for(const item of document.querySelectorAll('input.action-select[type=checkbox]')) { + item.checked = false; + } + const item = document.querySelector(`input.action-select[type=checkbox][value="${pk}"]`); + if(item == null) { + return; + } + item.checked = true; + item.form.submit(); +} \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index c7958f7..0933763 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,8 +11,8 @@ copyright = "2025, Willem Van Onsem" author = "Willem Van Onsem" -release = "0.1.0" -version = "0.1.0" +release = "0.2.0" +version = "0.2.0" path.insert(0, dirname(dirname(dirname(__file__)))) diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 8cded40..cf59537 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -14,3 +14,17 @@ Once the package is installed, you can use the `LinkFieldAdminMixin` mixin in th list_display = ['__str__', 'genre'] If `genre` is a `ForeignKey` to a `Genre` model for example, and `Genre` has its own `ModelAdmin`, it will automatically convert `genre` into a column that adds a link to the admin detail view of the corresponding genre. + + +Another option is to use with the `SingleItemActionMixin` to add a button per row to do (one or more) actions for that specific line with: + + ..code-block:: python3 + + from django.contrib import admin + from django_adminlink.admin import SingleItemActionMixin + + @admin.register(Movie) + class MovieAdmin(SingleItemActionMixin, admin.ModelAdmin): + action_buttons = {'delete': 'delete_selected'} + +here the `action_buttons` is a dictionary (or list) of labels that map to the admin actions. If the label is the same for each admin action, you can use a list, where you thus list the label and admin action only once. \ No newline at end of file diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 4c2b42f..615647c 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -8,5 +8,5 @@ The package can be fetched as `django-adminlink`, so for example with `pip` with pip3 install django-adminlink -The item is not a Django app, so one should *not* to include it in the `INSTALLED_APPS`. It is only a module that -offers some functionality to use in Django applications. \ No newline at end of file +The item is a Django app, but you do not per se have to install it as such. If you don't make use of the `SingleItemActionMixin`, +you don't need to add `'django_adminlink'` to the `INSTALLED_APPS`, otherwise you need to do this to include the static file. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a185141..c9ca9c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-adminlink" -version = "0.1.0" +version = "0.2.0" authors = [{name = "Willem Van Onsem", email = "yourfriends@hapytex.eu"}] [build-system]