|
1 | | -from django_restful_admin import site as rest |
| 1 | +import logging |
| 2 | +from urllib.parse import quote |
| 3 | + |
2 | 4 | from django.apps import apps |
| 5 | +from django.db import models |
| 6 | +from django.urls import reverse |
| 7 | +from django.utils.html import format_html |
| 8 | +from django.utils.safestring import mark_safe |
| 9 | +from jsoneditor.forms import JSONEditor |
| 10 | +from unfold.admin import ModelAdmin |
| 11 | + |
| 12 | +logger = logging.getLogger(__name__) |
3 | 13 |
|
| 14 | +from django_restful_admin import site as rest |
4 | 15 |
|
5 | 16 | # Register all models for REST API except the registered ones |
6 | 17 | for model in apps.get_models(): |
7 | 18 | if model in rest._registry: |
8 | 19 | continue |
9 | 20 | rest.register(model) |
| 21 | + |
| 22 | + |
| 23 | +class DefaultModelAdmin(ModelAdmin): |
| 24 | + list_filter_submit = True |
| 25 | + list_filter_sheet = False |
| 26 | + list_fullwidth = True |
| 27 | + add_fieldsets = () |
| 28 | + formfield_overrides = { |
| 29 | + models.JSONField: {"widget": JSONEditor(attrs={"style": "background-color: white !important;"})} |
| 30 | + } |
| 31 | + |
| 32 | + def get_list_display(self, request): |
| 33 | + """ |
| 34 | + make sure model primary key is always present as first column for standard UX |
| 35 | + """ |
| 36 | + default_list_display = list(super(DefaultModelAdmin, self).get_list_display(request)) |
| 37 | + |
| 38 | + pk = self.model._meta.pk.name |
| 39 | + if pk in default_list_display: |
| 40 | + default_list_display.remove(pk) |
| 41 | + default_list_display.insert(0, self.model._meta.pk.name) |
| 42 | + |
| 43 | + return default_list_display |
| 44 | + |
| 45 | + def get_list_display_links(self, request, list_display): |
| 46 | + default_list_display_links = super(DefaultModelAdmin, self).get_list_display_links(request, list_display) |
| 47 | + |
| 48 | + if not default_list_display_links: |
| 49 | + default_list_display_links = ("pk",) |
| 50 | + |
| 51 | + return default_list_display_links |
| 52 | + |
| 53 | + |
| 54 | +class ReverseReadonlyMixin: |
| 55 | + """ |
| 56 | + Mixin to add a readonly 'reverse' field showing related objects in Django admin. |
| 57 | + Should be used with ModelAdmin or a compatible base class. |
| 58 | + """ |
| 59 | + |
| 60 | + def get_readonly_fields(self, request, obj=None): |
| 61 | + parent_method = getattr(super(), "get_readonly_fields", None) |
| 62 | + fields = list(parent_method(request, obj)) if parent_method else [] |
| 63 | + if obj and "reverse" not in fields: |
| 64 | + fields.append("reverse") |
| 65 | + return fields |
| 66 | + |
| 67 | + def get_fieldsets(self, request, obj=None): |
| 68 | + parent_method = getattr(super(), "get_fieldsets", None) |
| 69 | + fieldsets = list(parent_method(request, obj)) if parent_method else [] |
| 70 | + if not fieldsets: |
| 71 | + return fieldsets |
| 72 | + label, opts = fieldsets[0] |
| 73 | + opts = dict(opts) |
| 74 | + opts.setdefault("classes", []).append("tab") |
| 75 | + if "fields" in opts: |
| 76 | + opts["fields"] = tuple(f for f in opts["fields"] if f != "reverse") |
| 77 | + fieldsets[0] = ("General", opts) |
| 78 | + if obj: |
| 79 | + fieldsets.append(("Relationships", {"classes": ["tab"], "fields": ("reverse",)})) |
| 80 | + return tuple(fieldsets) |
| 81 | + |
| 82 | + def reverse(self, obj): |
| 83 | + """Render all relationships for the current object.""" |
| 84 | + if not obj or not obj.pk: |
| 85 | + return format_html("<div>No relationships available for new objects.</div>") |
| 86 | + try: |
| 87 | + html = self._render_relationships(obj) |
| 88 | + return format_html("<div class='relationships-container'>{}</div>", html) |
| 89 | + except Exception as e: |
| 90 | + return format_html("<div class='text-red-500'>Error loading relationships: {}</div>", str(e)) |
| 91 | + |
| 92 | + def _render_relationships(self, obj): |
| 93 | + html = [] |
| 94 | + forward_html = self._get_forward_relationships(obj) |
| 95 | + if forward_html: |
| 96 | + html.append("<strong>Forward Relationships</strong>") |
| 97 | + html.append(forward_html) |
| 98 | + reverse_html = self._get_reverse_relationships(obj) |
| 99 | + if reverse_html: |
| 100 | + html.append("<br><strong>Reverse Relationships</strong>") |
| 101 | + html.append(reverse_html) |
| 102 | + if not html: |
| 103 | + html.append("<div>No relationships found.</div>") |
| 104 | + return mark_safe("\n".join(html)) |
| 105 | + |
| 106 | + def _get_forward_relationships(self, obj): |
| 107 | + html = [] |
| 108 | + opts = obj._meta |
| 109 | + for field in opts.get_fields(): |
| 110 | + if field.is_relation: |
| 111 | + try: |
| 112 | + if not field.many_to_many and hasattr(field, "related_model"): |
| 113 | + rel_obj = getattr(obj, field.name, None) |
| 114 | + if rel_obj: |
| 115 | + html.append( |
| 116 | + self._format_relation(field, rel_obj, single_obj=True, relation_type="ForeignKey") |
| 117 | + ) |
| 118 | + elif field.many_to_many and not field.auto_created: |
| 119 | + manager = getattr(obj, field.name) |
| 120 | + rel_objs = manager.all()[:10] |
| 121 | + total_count = manager.count() |
| 122 | + if rel_objs: |
| 123 | + html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany")) |
| 124 | + except AttributeError: |
| 125 | + continue |
| 126 | + return mark_safe("\n".join(html)) |
| 127 | + |
| 128 | + def _get_reverse_relationships(self, obj): |
| 129 | + html = [] |
| 130 | + opts = obj._meta |
| 131 | + for field in opts.get_fields(): |
| 132 | + if field.auto_created and field.is_relation: |
| 133 | + try: |
| 134 | + manager = getattr(obj, field.get_accessor_name()) |
| 135 | + if field.one_to_many: |
| 136 | + rel_objs = manager.all()[:10] |
| 137 | + total_count = manager.count() |
| 138 | + if rel_objs: |
| 139 | + html.append(self._format_relation(field, rel_objs, total_count, "ForeignKey")) |
| 140 | + elif field.one_to_one: |
| 141 | + rel_obj = getattr(manager, getattr(field.related_model._meta, "model_name", ""), None) |
| 142 | + if rel_obj: |
| 143 | + html.append( |
| 144 | + self._format_relation(field, rel_obj, single_obj=True, relation_type="OneToOne") |
| 145 | + ) |
| 146 | + elif field.many_to_many: |
| 147 | + rel_objs = manager.all()[:10] |
| 148 | + total_count = manager.count() |
| 149 | + if rel_objs: |
| 150 | + html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany")) |
| 151 | + except AttributeError: |
| 152 | + continue |
| 153 | + return mark_safe("\n".join(html)) |
| 154 | + |
| 155 | + def _format_relation(self, field, related_objs, total_count=None, relation_type=None, single_obj=False): |
| 156 | + label = getattr(field, "verbose_name", None) |
| 157 | + if not label and hasattr(field, "related_model") and field.related_model: |
| 158 | + label = getattr(field.related_model._meta, "verbose_name_plural", str(field.related_model)) |
| 159 | + label = label.title() if isinstance(label, str) else str(field) |
| 160 | + rel_type = relation_type or "Relation" |
| 161 | + header = f'<div class="py-1">{label}: <b>{rel_type}</b>' |
| 162 | + if total_count is not None: |
| 163 | + header += f" ({total_count} total)" |
| 164 | + header += "</div>" |
| 165 | + |
| 166 | + def obj_link(obj): |
| 167 | + url = self._get_admin_url(obj) |
| 168 | + return f'<a href="{url}" style="color: rgb(59, 130, 246);">{obj}</a>' if url else str(obj) |
| 169 | + |
| 170 | + if single_obj: |
| 171 | + body = f'<div class="pl-4" style="padding-left:2em">• {obj_link(related_objs)}</div>' |
| 172 | + else: |
| 173 | + body = "\n".join( |
| 174 | + f'<div class="pl-4" style="padding-left:2em">• {obj_link(obj)}</div>' for obj in related_objs |
| 175 | + ) |
| 176 | + if total_count and total_count > 10: |
| 177 | + body += ( |
| 178 | + f'\n<div class="pl-4 text-gray-500" style="padding-left:2em">... and {total_count - 10} more</div>' |
| 179 | + ) |
| 180 | + return mark_safe(header + "\n" + body) |
| 181 | + |
| 182 | + def _get_admin_url(self, obj): |
| 183 | + if not obj or not obj.pk: |
| 184 | + return None |
| 185 | + try: |
| 186 | + opts = obj._meta |
| 187 | + return reverse(f"admin:{opts.app_label}_{opts.model_name}_change", args=[quote(str(obj.pk))]) |
| 188 | + except Exception: |
| 189 | + return None |
0 commit comments