diff --git a/django_admin_react/api/README.md b/django_admin_react/api/README.md index 0159ba6..41bc15e 100644 --- a/django_admin_react/api/README.md +++ b/django_admin_react/api/README.md @@ -26,6 +26,7 @@ the design. | `permissions.py` | Staff + AdminSite.has_permission gate; per-op delegation. | | `registry.py` | AdminSite introspection helpers. | | `serializers.py` | Conservative field serialization + denylist. | +| `custom_views.py` | Surface a ModelAdmin's custom `get_urls()` routes (#439). | | `views/` | One module per endpoint. | Implementation status is tracked in `../README.md`. diff --git a/django_admin_react/api/custom_views.py b/django_admin_react/api/custom_views.py new file mode 100644 index 0000000..efa7bb0 --- /dev/null +++ b/django_admin_react/api/custom_views.py @@ -0,0 +1,216 @@ +"""Surface a ``ModelAdmin``'s *custom* admin views to the SPA (Issue #439). + +Many consumers add bespoke admin pages — report / import-export / +dashboard pages and per-object tool views — by overriding +``ModelAdmin.get_urls()`` (or ``AdminSite.get_urls()``). The SPA cannot +render those Django-templated pages itself, but it can **link out** to +them (Option A): a real ```` to the legacy +admin-rendered page. This module is the design-safe foundation — the +same ``{name, label, url, level}`` payload also powers a future iframe +or native approach. + +How custom routes are distinguished from the standard CRUD set +---------------------------------------------------------------- + +Django's ``ModelAdmin.get_urls`` always emits five *named* routes for a +model — ``__{changelist,add,change,delete,history}`` — plus +one unnamed catch-all (the legacy ``/`` redirect, ``name=None``). +Anything a consumer adds on top has a different (or no) standard suffix. +We compute the standard names from ``model._meta`` and treat every +remaining *named* route as a custom view. Unnamed routes are skipped: +without a name they can't be ``reverse()``-d through the admin +namespace, so the SPA could never link to them anyway. + +How URLs are reversed +--------------------- + +Custom admin routes live under the **admin site's** URL namespace +(``admin_site.name``, e.g. ``"admin"``). We reverse each route as +``reverse(f"{admin_site.name}:{name}", args=[...])`` — ``args=[obj.pk]`` +for an object-level route, ``args=[]`` for a changelist-level one. Every +reverse is guarded; an un-reversible route (legacy admin not mounted, +extra capture groups we don't fill, a consumer ``reverse`` quirk) is +silently skipped rather than raising. + +Hardening (``SECURITY.md`` §3): this module introduces **no new +permission surface**. It is only ever called from the detail / registry +views, *after* their staff + ``has_view_permission`` gates have run, and +it never reads object data beyond the ``pk`` it is handed. A misbehaving +consumer ``get_urls`` must never 500 a response — every introspection +step degrades to ``[]``. +""" + +from __future__ import annotations + +from typing import Any + +from django.contrib.admin.options import ModelAdmin +from django.db.models import Model +from django.urls import reverse +from django.utils.text import capfirst + +# Capture-group names Django uses for the per-object segment of a +# ``ModelAdmin`` route. If a custom route's regex captures any of these +# (or, defensively, a generic ``pk``), it is an *object-level* view that +# needs an object id to reverse. +_OBJECT_ID_GROUPS: frozenset[str] = frozenset({"object_id", "pk"}) + + +def _standard_route_names(model: type[Model]) -> frozenset[str]: + """The five route names Django's ``ModelAdmin.get_urls`` always emits. + + Built from ``model._meta`` exactly the way Django builds them in + ``ModelAdmin.get_urls`` (``info = app_label, model_name``), so this + stays correct for any model without depending on Django internals. + """ + info = f"{model._meta.app_label}_{model._meta.model_name}" + return frozenset( + { + f"{info}_changelist", + f"{info}_add", + f"{info}_change", + f"{info}_delete", + f"{info}_history", + } + ) + + +def _is_object_level(pattern: Any) -> bool: + """``True`` if the URL pattern captures an object id. + + Inspects the compiled regex's named capture groups for an + ``object_id`` / ``pk`` group — the marker that the route is a + per-object tool view (e.g. ``/make-report/``) rather than a + page that stands alone (e.g. ``import/``). Any introspection error + is treated as "not object-level" (changelist-level) so we still + attempt a no-args reverse. + """ + try: + groups = pattern.regex.groupindex.keys() + except Exception: + return False + return any(group in _OBJECT_ID_GROUPS for group in groups) + + +def _label_for_route(entry: Any, name: str) -> str: + """Human-readable label for a custom route. + + Prefers a ``short_description`` on the view callable (Django's own + convention for naming admin callables); falls back to humanising the + route name — ``capfirst(name.replace("_", " "))``. The model/app + prefix that ``get_urls`` bakes into the name (``__``) is + stripped first so the label reads as the *action* (e.g. + ``"Send report"``) rather than the wiring. + """ + callback = getattr(entry, "callback", None) + short = getattr(callback, "short_description", None) + if short: + return str(short) + return str(capfirst(name.replace("_", " "))) + + +def _reverse_or_none(admin_site: Any, name: str, args: list[Any]) -> str | None: + """Reverse ``:`` with ``args``, or ``None``. + + Guards every reverse: an un-reversible route (legacy admin not + mounted, a route needing more args than we supply, a custom + namespace quirk) degrades to ``None`` and is dropped by the caller. + """ + site_name = getattr(admin_site, "name", None) + if not site_name: + return None + try: + return reverse(f"{site_name}:{name}", args=args) + except Exception: + return None + + +def custom_views_for( + model_admin: ModelAdmin, + admin_site: Any, + *, + obj: Model | None = None, +) -> list[dict[str, Any]]: + """Custom (non-CRUD) admin views for a ``ModelAdmin`` (Issue #439). + + Walks ``model_admin.get_urls()``, drops the five standard CRUD + routes (and any unnamed route), and returns a descriptor per + remaining custom route:: + + {"name": str, "label": str, "url": str, "level": "object"|"changelist"} + + Reversal rules: + + - ``level == "object"`` routes need an object id. When ``obj`` is + provided they are reversed with ``args=[obj.pk]``; when ``obj`` is + ``None`` (the registry / changelist context) they are skipped — + there is no object to point them at. + - ``level == "changelist"`` routes are reversed with ``args=[]``. + + Returns ``[]`` when the admin exposes no custom routes, or when none + of them reverse. NEVER raises: a misbehaving consumer ``get_urls`` + degrades to ``[]`` (so the caller's detail / registry response is + unaffected). + """ + try: + urls = model_admin.get_urls() + except Exception: + return [] + + try: + standard = _standard_route_names(model_admin.model) + except Exception: + return [] + + out: list[dict[str, Any]] = [] + for entry in urls: + descriptor = _descriptor_for_route(entry, standard, admin_site, obj) + if descriptor is not None: + out.append(descriptor) + return out + + +def _descriptor_for_route( + entry: Any, + standard: frozenset[str], + admin_site: Any, + obj: Model | None, +) -> dict[str, Any] | None: + """Build one custom-view descriptor, or ``None`` to skip the route. + + A route is skipped (``None``) when it is unnamed, is a standard CRUD + route, is object-level but we have no object, fails to reverse, or + raises during introspection. One bad route never sinks the rest: + every failure degrades to ``None`` rather than propagating. + """ + try: + name = getattr(entry, "name", None) + # Unnamed routes (Django's legacy ``/`` catch-all) and the + # five standard CRUD routes are not "custom views". + if not name or name in standard: + return None + + pattern = getattr(entry, "pattern", None) + object_level = _is_object_level(pattern) if pattern is not None else False + + if object_level: + if obj is None: + # No object to anchor the route to in this context. + return None + url = _reverse_or_none(admin_site, name, [obj.pk]) + level = "object" + else: + url = _reverse_or_none(admin_site, name, []) + level = "changelist" + + if url is None: + return None + + return { + "name": str(name), + "label": _label_for_route(entry, str(name)), + "url": url, + "level": level, + } + except Exception: + return None diff --git a/django_admin_react/api/registry.py b/django_admin_react/api/registry.py index b58a499..488eee3 100644 --- a/django_admin_react/api/registry.py +++ b/django_admin_react/api/registry.py @@ -17,6 +17,8 @@ from django.http import HttpRequest from django.utils.module_loading import import_string +from django_admin_react.api.custom_views import custom_views_for + def get_admin_site() -> AdminSite: """Resolve the configured admin site instance. @@ -70,16 +72,27 @@ def _model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[st } -def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpRequest) -> dict: +def _model_entry( + model: type[Model], + model_admin: ModelAdmin, + request: HttpRequest, + admin_site: AdminSite, +) -> dict: """Single ``models[]`` element for the registry response. Wire shape is documented in ``docs/api-contract.md`` §2. Only metadata + the four ``has_*_permission`` booleans go on the wire; no model field schemas, no row counts — those are detail/list endpoint responsibilities. + + Changelist-level custom views (Issue #439) are attached when the + consumer's ``ModelAdmin.get_urls`` exposes any — so the SPA can link + to a model-wide report / import page from the list/home. Object-level + custom views are *not* surfaced here (no object to anchor them to); + those live on the detail payload. The key is omitted when empty. """ meta = model._meta - return { + entry = { "app_label": meta.app_label, "model_name": meta.model_name, "object_name": meta.object_name, @@ -87,6 +100,10 @@ def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpReque "verbose_name_plural": str(meta.verbose_name_plural), "permissions": _model_permissions(model_admin, request), } + extra_views = custom_views_for(model_admin, admin_site, obj=None) + if extra_views: + entry["custom_views"] = extra_views + return entry def _user_payload(request: HttpRequest) -> dict: @@ -195,7 +212,7 @@ def build_registry_payload(admin_site: AdminSite, request: HttpRequest) -> dict: # ``SECURITY.md`` §3). if not model_admin.has_view_permission(request): continue - entry = _model_entry(model, model_admin, request) + entry = _model_entry(model, model_admin, request, admin_site) entry["real_app_label"] = model._meta.app_label entry["app_label"] = group_label models_payload.append(entry) diff --git a/django_admin_react/api/views/detail.py b/django_admin_react/api/views/detail.py index d0ce51d..5a547e9 100644 --- a/django_admin_react/api/views/detail.py +++ b/django_admin_react/api/views/detail.py @@ -31,6 +31,7 @@ from django.http import JsonResponse from django.views.generic import View +from django_admin_react.api.custom_views import custom_views_for from django_admin_react.api.inlines import inlines_payload from django_admin_react.api.permissions import forbidden_response from django_admin_react.api.permissions import is_admin_user @@ -119,7 +120,7 @@ def _build_payload( ) -> dict[str, Any]: """Compose the full detail response body (contract §4).""" visible_names = _visible_field_names(model_admin, request, obj) - return { + payload: dict[str, Any] = { "app_label": model._meta.app_label, "model_name": model._meta.model_name, "pk": obj.pk, @@ -132,6 +133,16 @@ def _build_payload( "inlines": inlines_payload(model_admin, obj, request, admin_site), "view_on_site_url": _view_on_site_url(model_admin, obj), } + # Custom admin views (Issue #439): link-outs to the consumer's bespoke + # admin pages reached via ``ModelAdmin.get_urls()``. Object-level routes + # are reversed with this object's pk; changelist-level routes (simple, + # no-arg) are included too so the SPA can offer them from the detail + # toolbar. Only attached when non-empty so older clients and plain + # admins see no extra key. + extra_views = custom_views_for(model_admin, admin_site, obj=obj) + if extra_views: + payload["custom_views"] = extra_views + return payload def _view_on_site_url(model_admin: ModelAdmin, obj: Model) -> str | None: diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index ada383c..ad44f86 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -21,6 +21,7 @@ import { updateObject, useApiClient, useDetail, + type CustomView, type DeletePreviewResponse, type DetailResponse, type FieldDescriptor, @@ -229,6 +230,9 @@ export function DetailPage() { View on site )} + {data.custom_views && data.custom_views.length > 0 && ( + + )} {canChange && ( + {open && ( +
+ {views.map((v) => ( + setOpen(false)} + > + {v.label} + + ))} +
+ )} + + ); +} + interface DeleteButtonProps { label: string; loadPreview: () => Promise; diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index 8cd28b5..07edded 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -38,6 +38,26 @@ export interface RegistryUser { display_name: string; } +/** + * A consumer's bespoke admin page reached via `ModelAdmin.get_urls()` + * (Issue #439). The SPA links out to it — a real `` + * to the Django-rendered page — since it can't render the page itself. + * + * - `level: 'object'` — a per-object tool view; `url` already carries + * the object's pk (only present on the detail response). + * - `level: 'changelist'` — a model-wide page (report / import); `url` + * takes no object (can appear on the registry model entry too). + */ +export interface CustomView { + /** The URL pattern name (a stable id for the route). */ + name: string; + /** Humanized name, or the view callable's `short_description`. */ + label: string; + /** The reversed admin URL (object-level URLs carry the pk). */ + url: string; + level: 'object' | 'changelist'; +} + export interface RegistryModelEntry { /** * The *display* app label — equals the group label when the @@ -58,6 +78,12 @@ export interface RegistryModelEntry { verbose_name: string; verbose_name_plural: string; permissions: Permissions; + /** + * Changelist-level custom admin views for this model (Issue #439). + * Object-level views live on the detail response, not here. Optional; + * absent when the model's admin exposes no custom routes. + */ + custom_views?: CustomView[]; } export interface RegistryAppEntry { @@ -423,6 +449,11 @@ export interface DetailResponse { * object's get_absolute_url). `null` when not applicable. Optional for * back-compat with older backends. */ view_on_site_url?: string | null; + /** Custom admin views reachable via `ModelAdmin.get_urls()` (Issue #439): + * object-level tool views (url carries this object's pk) plus any + * changelist-level pages. The SPA links out to the Django-rendered + * page. Optional/absent when the admin exposes no custom routes. */ + custom_views?: CustomView[]; } /** diff --git a/frontend/packages/data/src/index.ts b/frontend/packages/data/src/index.ts index d2c52c1..dcaa41c 100644 --- a/frontend/packages/data/src/index.ts +++ b/frontend/packages/data/src/index.ts @@ -20,6 +20,7 @@ export type { ColumnDescriptor, CreatePayload, CreateResponse, + CustomView, DateHierarchy, DateHierarchyBucket, DeleteCascadeEntry, diff --git a/tests/test_custom_views.py b/tests/test_custom_views.py new file mode 100644 index 0000000..886ad25 --- /dev/null +++ b/tests/test_custom_views.py @@ -0,0 +1,290 @@ +"""Tests for custom admin views surfaced to the SPA (Issue #439). + +The package introspects ``ModelAdmin.get_urls()`` and exposes the +consumer's *extra* admin routes (everything that isn't the standard +``changelist|add|change|delete|history`` CRUD set) so the SPA can link +out to the Django-rendered page. Each surfaced route carries +``{name, label, url, level}``. + +This rides the existing detail / registry gates — no new permission +surface — so the security matrix here is light (one anon→403 check on +the detail endpoint). The feature-specific assertions are: + +- An object-level custom view appears on the detail payload with + ``level == "object"`` and a reversible ``url`` carrying the object's + pk. +- A changelist-level custom view appears with ``level == "changelist"`` + and a no-arg ``url``; it also rides through to the registry model + entry. +- The five standard CRUD routes are NOT surfaced as custom views. +- A plain admin (no custom ``get_urls``) yields no ``custom_views`` key. +- A misbehaving ``get_urls`` never 500s the detail endpoint. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from importlib import import_module +from importlib import reload + +import pytest +from django.conf import settings +from django.contrib import admin +from django.contrib.auth.models import Group +from django.http import HttpResponse +from django.test import Client +from django.urls import clear_url_caches +from django.urls import path + +DETAIL_URL = "/admin-react/api/v1/auth/group/{pk}/" +REGISTRY_URL = "/admin-react/api/v1/registry/" + + +def _report_view(request): # noqa: ANN001, ANN202 + return HttpResponse("report") + + +_report_view.short_description = "Group Report" + + +def _tool_view(request, object_id): # noqa: ANN001, ANN202, ARG001 + return HttpResponse("tool") + + +@contextmanager +def custom_get_urls(extra_factory) -> Iterator[None]: # noqa: ANN001 + """Patch ``GroupAdmin.get_urls`` to prepend ``extra_factory(self)`` routes. + + ``path("admin/", admin.site.urls)`` freezes ``admin.site.get_urls()`` + into the root URLconf's ``urlpatterns`` the first time that module is + imported (by an earlier request in the suite). To make the patched + routes reverse-able regardless of suite ordering, we reload the root + URLconf module (re-evaluating ``admin.site.urls``) and clear the URL + caches — then undo both on exit so other tests see the pristine wiring. + """ + model_admin = admin.site._registry[Group] + original = model_admin.get_urls + + def patched(): # noqa: ANN202 + return list(extra_factory(model_admin)) + original() + + model_admin.get_urls = patched + root_urlconf = import_module(settings.ROOT_URLCONF) + reload(root_urlconf) + clear_url_caches() + try: + yield + finally: + model_admin.get_urls = original + reload(root_urlconf) + clear_url_caches() + + +@contextmanager +def patch_get_urls(replacement) -> Iterator[None]: # noqa: ANN001 + """Patch ``GroupAdmin.get_urls`` directly (no URLconf reload). + + For robustness tests that only exercise the package's introspection + (``custom_views_for`` calls ``get_urls()`` itself) — they never need + the routes to be reverse-able, so we skip the URLconf reload that + would otherwise evaluate (and re-raise) a deliberately-broken + ``get_urls`` at build time. + """ + model_admin = admin.site._registry[Group] + original = model_admin.get_urls + model_admin.get_urls = replacement + try: + yield + finally: + model_admin.get_urls = original + + +def _object_and_changelist_routes(model_admin): # noqa: ANN001, ANN202 + """One changelist-level + one object-level custom route.""" + return [ + path( + "report/", + model_admin.admin_site.admin_view(_report_view), + name="auth_group_report", + ), + path( + "/send/", + model_admin.admin_site.admin_view(_tool_view), + name="auth_group_send", + ), + ] + + +# --------------------------------------------------------------------------- # +# Auth (inherited gate — one representative check) # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_detail_anonymous_forbidden(anon_client: Client) -> None: + g = Group.objects.create(name="alpha") + with custom_get_urls(_object_and_changelist_routes): + response = anon_client.get(DETAIL_URL.format(pk=g.pk)) + assert response.status_code == 403 + # No body leakage — the custom-view payload must never reach an + # unauthenticated caller. + assert "custom_views" not in response.json() + + +# --------------------------------------------------------------------------- # +# Detail payload surfaces custom views # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_detail_exposes_object_and_changelist_custom_views( + superuser_client: Client, +) -> None: + g = Group.objects.create(name="alpha") + with custom_get_urls(_object_and_changelist_routes): + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + + assert response.status_code == 200, response.content + views = response.json()["custom_views"] + by_name = {v["name"]: v for v in views} + + assert "auth_group_report" in by_name + assert "auth_group_send" in by_name + + # Object-level: level == "object", url carries this object's pk. + send = by_name["auth_group_send"] + assert send["level"] == "object" + assert send["url"] == f"/admin/auth/group/{g.pk}/send/" + + # Changelist-level: level == "changelist", url takes no object. + report = by_name["auth_group_report"] + assert report["level"] == "changelist" + assert report["url"] == "/admin/auth/group/report/" + + +@pytest.mark.django_db +def test_label_prefers_short_description_else_humanizes( + superuser_client: Client, +) -> None: + g = Group.objects.create(name="alpha") + with custom_get_urls(_object_and_changelist_routes): + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + + by_name = {v["name"]: v for v in response.json()["custom_views"]} + # ``_report_view.short_description`` wins for the report route. + assert by_name["auth_group_report"]["label"] == "Group Report" + # ``_tool_view`` has no short_description → humanized route name. + assert by_name["auth_group_send"]["label"] == "Auth group send" + + +@pytest.mark.django_db +def test_standard_crud_routes_not_surfaced(superuser_client: Client) -> None: + """The five Django CRUD routes must never appear as custom views.""" + g = Group.objects.create(name="alpha") + with custom_get_urls(_object_and_changelist_routes): + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + + names = {v["name"] for v in response.json()["custom_views"]} + for standard in ( + "auth_group_changelist", + "auth_group_add", + "auth_group_change", + "auth_group_delete", + "auth_group_history", + ): + assert standard not in names + + +# --------------------------------------------------------------------------- # +# Plain admin (no custom get_urls) → no key at all # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_plain_admin_has_no_custom_views_key(superuser_client: Client) -> None: + g = Group.objects.create(name="alpha") + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + assert response.status_code == 200, response.content + assert "custom_views" not in response.json() + + +# --------------------------------------------------------------------------- # +# Registry surfaces changelist-level custom views only # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_registry_surfaces_changelist_level_only(superuser_client: Client) -> None: + with custom_get_urls(_object_and_changelist_routes): + response = superuser_client.get(REGISTRY_URL) + + assert response.status_code == 200, response.content + entry = _find_group_entry(response.json()) + assert entry is not None + views = entry.get("custom_views", []) + names = {v["name"] for v in views} + # Changelist-level route rides through; object-level does NOT (no + # object to anchor it to in the registry context). + assert "auth_group_report" in names + assert "auth_group_send" not in names + assert all(v["level"] == "changelist" for v in views) + + +@pytest.mark.django_db +def test_registry_plain_admin_no_custom_views_key(superuser_client: Client) -> None: + response = superuser_client.get(REGISTRY_URL) + assert response.status_code == 200, response.content + entry = _find_group_entry(response.json()) + assert entry is not None + assert "custom_views" not in entry + + +# --------------------------------------------------------------------------- # +# Robustness: a misbehaving get_urls must never 500 # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_misbehaving_get_urls_does_not_500(superuser_client: Client) -> None: + g = Group.objects.create(name="alpha") + + def boom(): # noqa: ANN202 + raise RuntimeError("consumer get_urls blew up") + + with patch_get_urls(boom): + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + + # Degrades cleanly: a 200 detail with no custom_views key, not a 500. + assert response.status_code == 200, response.content + assert "custom_views" not in response.json() + + +@pytest.mark.django_db +def test_unreversible_custom_route_skipped(superuser_client: Client) -> None: + """A named custom route that can't reverse is silently dropped. + + Here the route declares a second capture group the package never + fills, so ``reverse(..., args=[pk])`` raises → the route is skipped + rather than 500-ing the endpoint. + """ + g = Group.objects.create(name="alpha") + + def two_arg_route(model_admin): # noqa: ANN001, ANN202 + return [ + path( + "/x//", + model_admin.admin_site.admin_view(_tool_view), + name="auth_group_twoarg", + ), + ] + + with custom_get_urls(two_arg_route): + response = superuser_client.get(DETAIL_URL.format(pk=g.pk)) + + assert response.status_code == 200, response.content + # Either no key (only route dropped) or, if present, our route is absent. + names = {v["name"] for v in response.json().get("custom_views", [])} + assert "auth_group_twoarg" not in names + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _find_group_entry(registry_payload: dict): # noqa: ANN001, ANN202 + """Locate the ``auth.group`` model entry in a registry response.""" + for app in registry_payload.get("apps", []): + for model in app.get("models", []): + if model.get("real_app_label") == "auth" and model.get("model_name") == "group": + return model + return None