From 25e73797b51f66ce8ec490d53f405bd672fc1d96 Mon Sep 17 00:00:00 2001
From: Cuong Nguyen <cuongnb14@gmail.com>
Date: Tue, 10 Oct 2023 08:57:35 +0700
Subject: [PATCH] add link for foreign key in display field

---
 admin_extended/base/__init__.py               |  2 +
 .../base/custom_table_admin_page.py           | 39 ++++++++++++++
 .../{base.py => base/extended_admin_model.py} | 47 ++---------------
 admin_extended/{ => base}/mixins.py           | 52 ++++++++++++++++++-
 admin_extended/base/utils.py                  |  5 ++
 5 files changed, 101 insertions(+), 44 deletions(-)
 create mode 100644 admin_extended/base/__init__.py
 create mode 100644 admin_extended/base/custom_table_admin_page.py
 rename admin_extended/{base.py => base/extended_admin_model.py} (81%)
 rename admin_extended/{ => base}/mixins.py (74%)
 create mode 100644 admin_extended/base/utils.py

diff --git a/admin_extended/base/__init__.py b/admin_extended/base/__init__.py
new file mode 100644
index 0000000..b59eddb
--- /dev/null
+++ b/admin_extended/base/__init__.py
@@ -0,0 +1,2 @@
+from .custom_table_admin_page import TableData, CustomTableAdminPage
+from .extended_admin_model import ExtendedAdminModel
\ No newline at end of file
diff --git a/admin_extended/base/custom_table_admin_page.py b/admin_extended/base/custom_table_admin_page.py
new file mode 100644
index 0000000..416a734
--- /dev/null
+++ b/admin_extended/base/custom_table_admin_page.py
@@ -0,0 +1,39 @@
+from dataclasses import dataclass
+
+from django.contrib import admin
+from django.shortcuts import render
+from django.urls import path
+
+
+@dataclass
+class TableData:
+    header: str
+    table_titles = []
+    table_rows = []
+
+    def add_rows(self, row: list):
+        self.table_rows.append(row)
+
+
+class CustomTableAdminPage(admin.ModelAdmin):
+    model = None
+
+    def get_urls(self):
+        view_name = '{}_{}_changelist'.format(self.model._meta.app_label, self.model._meta.model_name)
+        return [
+            path('', self.custom_view, name=view_name),
+        ]
+
+    def get_table_data(self):
+        """
+        return list of TableData
+        """
+        raise NotImplementedError()
+
+    def custom_view(self, request, *args, **kwargs):
+        context = {
+            **admin.site.each_context(request),
+            'tables': self.get_table_data(),
+        }
+
+        return render(request, 'admin/custom/custom_table_page.html', context)
diff --git a/admin_extended/base.py b/admin_extended/base/extended_admin_model.py
similarity index 81%
rename from admin_extended/base.py
rename to admin_extended/base/extended_admin_model.py
index c2c5c33..72ad68a 100644
--- a/admin_extended/base.py
+++ b/admin_extended/base/extended_admin_model.py
@@ -1,29 +1,13 @@
 import copy
-from dataclasses import dataclass
 
 from django.contrib import messages
 from django.contrib import admin
-from django.shortcuts import render
-from django.urls import path
-from .mixins import UIUtilsMixin, ObjectToolModelAdminMixin
-from .settings import ADMIN_EXTENDED_SETTINGS
+from .mixins import UIUtilsMixin, ObjectToolModelAdminMixin, DispayLinkAdapter
+from .utils import has_search_fields
+from ..settings import ADMIN_EXTENDED_SETTINGS
 
 
-def has_search_fields(field):
-    model_admin = admin.site._registry.get(field.related_model)
-    return model_admin and model_admin.search_fields
-
-@dataclass
-class TableData:
-    header: str
-    table_titles = []
-    table_rows = []
-
-    def add_rows(self, row: list):
-        self.table_rows.append(row)
-
-
-class ExtendedAdminModel(ObjectToolModelAdminMixin, UIUtilsMixin, admin.ModelAdmin):
+class ExtendedAdminModel(ObjectToolModelAdminMixin, UIUtilsMixin, DispayLinkAdapter, admin.ModelAdmin):
     """
     Extend base model admin: tabbable inline model, separate view, edit model,...
 
@@ -147,26 +131,3 @@ def get_inline_instances(self, request, obj=None):
         request.is_tabbed = self.tab_inline
         return super().get_inline_instances(request, obj)
 
-
-class CustomTableAdminPage(admin.ModelAdmin):
-    model = None
-
-    def get_urls(self):
-        view_name = '{}_{}_changelist'.format(self.model._meta.app_label, self.model._meta.model_name)
-        return [
-            path('', self.custom_view, name=view_name),
-        ]
-
-    def get_table_data(self):
-        """
-        return list of TableData
-        """
-        raise NotImplementedError()
-
-    def custom_view(self, request, *args, **kwargs):
-        context = {
-            **admin.site.each_context(request),
-            'tables': self.get_table_data(),
-        }
-
-        return render(request, 'admin/custom/custom_table_page.html', context)
diff --git a/admin_extended/mixins.py b/admin_extended/base/mixins.py
similarity index 74%
rename from admin_extended/mixins.py
rename to admin_extended/base/mixins.py
index 93ed15a..e9d7a6c 100644
--- a/admin_extended/mixins.py
+++ b/admin_extended/base/mixins.py
@@ -1,6 +1,5 @@
 import json
 from django.utils.html import format_html
-from django.shortcuts import redirect
 from django.urls import path, reverse
 
 
@@ -122,3 +121,54 @@ def changelist_view(self, request, extra_context=None):
         extra_context['change_list_object_tools'] = self._get_render_change_list_object_tools(request)
 
         return super().changelist_view(request, extra_context)
+
+
+class DispayLinkAdapter:
+
+    def _foreign_key_link(self, field_name, description):
+        """
+        Converts a foreign key value into clickable links.
+
+        If field_name is 'parent', link text will be str(obj.parent)
+        Link will be admin url for the admin url for obj.parent.id:change
+        """
+
+        def _display_fn(obj):
+            linked_obj = getattr(obj, field_name)
+            if linked_obj is None:
+                return '-'
+            app_label = linked_obj._meta.app_label
+            model_name = linked_obj._meta.model_name
+            view_name = f'admin:{app_label}_{model_name}_change'
+            link_url = reverse(view_name, args=[linked_obj.pk])
+            return format_html('<a href="{}">{}</a>', link_url, linked_obj)
+
+        _display_fn.short_description = description
+        return _display_fn
+    
+
+    def convert_display_fields(self, list_display):
+        field_mapping = {}
+        for field in self.model._meta.fields:
+
+            field_mapping[field.attname] = {
+                'class_name': field.__class__.__name__,
+                'verbose_name': field.verbose_name,
+            }
+
+            if field.__class__.__name__ == 'ForeignKey':
+                field_mapping[field.attname[:-3]] = field_mapping[field.attname]  # Eg `user_id` -> `user` key have same info
+
+        results = [list_display[0]]
+        for field_name in list_display[1:]:  # ignore first field
+            field_info = field_mapping.get(field_name)
+            if field_info and field_info['class_name'] == 'ForeignKey':
+                results.append(self._foreign_key_link(field_name, field_info['verbose_name']))
+            else:
+                results.append(field_name)
+
+        return results
+
+    def get_list_display(self, request):
+        list_display = super().get_list_display(request)
+        return self.convert_display_fields(list_display)
diff --git a/admin_extended/base/utils.py b/admin_extended/base/utils.py
new file mode 100644
index 0000000..62cbac4
--- /dev/null
+++ b/admin_extended/base/utils.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+
+def has_search_fields(field):
+    model_admin = admin.site._registry.get(field.related_model)
+    return model_admin and model_admin.search_fields