Skip to content
Merged
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,9 +19,27 @@ 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, since then
you need to make use of the `static/js/single_admin_action.js` file that ships with it. So 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
Expand All @@ -32,3 +52,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 the one of the selected row, and finally submits the action form, and lets Django handle the logic further.

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']
```
78 changes: 76 additions & 2 deletions django_adminlink/admin.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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(
Expand All @@ -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(
"",
'<button type="button" class="button button-action-{}" data-action="{}" data-pk="{}" onclick="get_checkboxes(this)">{}</button>',
[(item, item, str(obj.pk), label) for label, item in action_buttons],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Empty file added django_adminlink/models.py
Empty file.
28 changes: 28 additions & 0 deletions django_adminlink/static/js/single_admin_action.js
Original file line number Diff line number Diff line change
@@ -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();
}
14 changes: 14 additions & 0 deletions docs/source/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/source/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
Loading