From 445b075def2c037b971518963b70ce13df5e88a2 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Tue, 1 Mar 2022 08:09:58 +0100
Subject: [PATCH] Fixed #33547 -- Fixed error when rendering invalid inlines
 with readonly fields in admin.

Regression in de95c826673be9ea519acc86fd898631d1a11356.

Thanks David Glenck for the report.
---
 .../contrib/admin/templatetags/admin_modify.py   |  6 +++++-
 docs/releases/4.0.3.txt                          |  4 ++++
 tests/admin_inlines/models.py                    |  4 ++++
 tests/admin_inlines/tests.py                     | 16 ++++++++++++++++
 4 files changed, 29 insertions(+), 1 deletion(-)

diff --git a/django/contrib/admin/templatetags/admin_modify.py b/django/contrib/admin/templatetags/admin_modify.py
index 910e6b68b97..9df4b7aadb9 100644
--- a/django/contrib/admin/templatetags/admin_modify.py
+++ b/django/contrib/admin/templatetags/admin_modify.py
@@ -138,7 +138,11 @@ def cell_count(inline_admin_form):
         # Count all visible fields.
         for line in fieldset:
             for field in line:
-                if not field.field.is_hidden:
+                try:
+                    is_hidden = field.field.is_hidden
+                except AttributeError:
+                    is_hidden = field.field["is_hidden"]
+                if not is_hidden:
                     count += 1
     if inline_admin_form.formset.can_delete:
         # Delete checkbox
diff --git a/docs/releases/4.0.3.txt b/docs/releases/4.0.3.txt
index 17e9f65074a..415e00d80f3 100644
--- a/docs/releases/4.0.3.txt
+++ b/docs/releases/4.0.3.txt
@@ -15,3 +15,7 @@ Bugfixes
 * Prevented, following a regression in Django 4.0.1, :djadmin:`makemigrations`
   from generating infinite migrations for a model with ``ManyToManyField`` to
   a lowercased swappable model such as ``'auth.user'`` (:ticket:`33515`).
+
+* Fixed a regression in Django 4.0 that caused a crash when rendering invalid
+  inlines with :attr:`~django.contrib.admin.ModelAdmin.readonly_fields` in the
+  admin (:ticket:`33547`).
diff --git a/tests/admin_inlines/models.py b/tests/admin_inlines/models.py
index 47c5b91828d..a8d2ee02e13 100644
--- a/tests/admin_inlines/models.py
+++ b/tests/admin_inlines/models.py
@@ -5,6 +5,7 @@ import random
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import models
 
 
@@ -204,6 +205,9 @@ class Question(models.Model):
     text = models.CharField(max_length=40)
     poll = models.ForeignKey(Poll, models.CASCADE)
 
+    def clean(self):
+        raise ValidationError("Always invalid model.")
+
 
 class Novel(models.Model):
     name = models.CharField(max_length=40)
diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py
index b84faee190a..9002af9933f 100644
--- a/tests/admin_inlines/tests.py
+++ b/tests/admin_inlines/tests.py
@@ -241,6 +241,22 @@ class TestInline(TestDataMixin, TestCase):
         # column cells
         self.assertContains(response, "<p>Callable in QuestionInline</p>")
 
+    def test_model_error_inline_with_readonly_field(self):
+        poll = Poll.objects.create(name="Test poll")
+        data = {
+            "question_set-TOTAL_FORMS": 1,
+            "question_set-INITIAL_FORMS": 0,
+            "question_set-MAX_NUM_FORMS": 0,
+            "_save": "Save",
+            "question_set-0-text": "Question",
+            "question_set-0-poll": poll.pk,
+        }
+        response = self.client.post(
+            reverse("admin:admin_inlines_poll_change", args=(poll.pk,)),
+            data,
+        )
+        self.assertContains(response, "Always invalid model.")
+
     def test_help_text(self):
         """
         The inlines' model field help texts are displayed when using both the