Repository: cal-itp/benefits

Issue:
Cannot save `EligibilityVerifier` without providing an `AuthProvider`
_(Prompted by post-merge steps for #1970)_

Intended behavior is that an `EligibilityVerifier` can exist without an `AuthProvider`. However, currently it is not possible to save one in that state from the admin interface.

The reason why we have existing `EligibilityVerifier`s with no `auth_provider` is because they were created externally rather than through the admin interface (see #1856 for notes on that process).

## To Reproduce

Steps to reproduce the behavior:

1. Go to `/admin`
2. Log in
3. Click on "Eligibility verifiers"
4. Choose an eligibility verifier that does not have an auth provider
5. Go into the form for editing it
6. Update some field (e.g. `Name`)
7. Click "Save"
8. Notice error saying the `Auth provider` field is required

## Expected behavior

The eligibility verifier is saved with the update you made.

## Screenshots
<details><summary>Expand to see</summary>

![image](https://github.com/cal-itp/benefits/assets/25497886/e308261f-9e84-4cb2-9c96-28ba1b4030e6)
</details>

## Additional context

* Current field definition: [`auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True)`](https://github.com/cal-itp/benefits/blob/2bcf0a37bb1da4105810975cb4a120649907e99d/benefits/core/models.py#L175)
* Django docs:
   * [`Field.blank`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#blank)
   * [`Field.null`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#null)
   * [`ForeignKey.on_delete`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.ForeignKey.on_delete)

Whether as a part of this issue or a separate one, we should go through and make sure all our model fields work as intended from the admin interface.


Discussion:

Resolving patch:
diff --git a/benefits/core/admin.py b/benefits/core/admin.py
--- a/benefits/core/admin.py
+++ b/benefits/core/admin.py
@@ -17,6 +17,7 @@
 
 
 for model in [
+    models.AuthProvider,
     models.EligibilityType,
     models.PaymentProcessor,
     models.PemData,
diff --git a/benefits/core/migrations/0006_alter_allow_blank.py b/benefits/core/migrations/0006_alter_allow_blank.py
new file mode 100644
--- /dev/null
+++ b/benefits/core/migrations/0006_alter_allow_blank.py
@@ -0,0 +1,93 @@
+# Generated by Django 5.0.3 on 2024-03-25 19:48
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0005_move_help_template_to_eligibilityverifier"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="authprovider",
+            name="claim",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="authprovider",
+            name="scope",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="authprovider",
+            name="sign_out_button_template",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="authprovider",
+            name="sign_out_link_template",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="api_auth_header",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="api_url",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="auth_provider",
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="core.authprovider"
+            ),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="form_class",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="help_template",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="jwe_cek_enc",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="jwe_encryption_alg",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="jws_signing_alg",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="public_key",
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata"
+            ),
+        ),
+        migrations.AlterField(
+            model_name="eligibilityverifier",
+            name="start_template",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="pemdata",
+            name="remote_url",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/benefits/core/models.py b/benefits/core/models.py
--- a/benefits/core/models.py
+++ b/benefits/core/models.py
@@ -51,9 +51,9 @@ class PemData(models.Model):
     # Human description of the PEM data
     label = models.TextField()
     # The name of a secret with data in utf-8 encoded PEM text format
-    text_secret_name = SecretNameField(null=True)
+    text_secret_name = SecretNameField(null=True, blank=True)
     # Public URL hosting the utf-8 encoded PEM text
-    remote_url = models.TextField(null=True)
+    remote_url = models.TextField(null=True, blank=True)
 
     def __str__(self):
         return self.label
@@ -81,13 +81,13 @@ class AuthProvider(models.Model):
     """An entity that provides authentication for eligibility verifiers."""
 
     id = models.AutoField(primary_key=True)
-    sign_out_button_template = models.TextField(null=True)
-    sign_out_link_template = models.TextField(null=True)
+    sign_out_button_template = models.TextField(null=True, blank=True)
+    sign_out_link_template = models.TextField(null=True, blank=True)
     client_name = models.TextField()
     client_id_secret_name = SecretNameField()
     authority = models.TextField()
-    scope = models.TextField(null=True)
-    claim = models.TextField(null=True)
+    scope = models.TextField(null=True, blank=True)
+    claim = models.TextField(null=True, blank=True)
     scheme = models.TextField()
 
     @property
@@ -160,24 +160,24 @@ class EligibilityVerifier(models.Model):
     name = models.TextField()
     display_order = models.PositiveSmallIntegerField(default=0, blank=False, null=False)
     active = models.BooleanField(default=False)
-    api_url = models.TextField(null=True)
-    api_auth_header = models.TextField(null=True)
-    api_auth_key_secret_name = SecretNameField(null=True)
+    api_url = models.TextField(null=True, blank=True)
+    api_auth_header = models.TextField(null=True, blank=True)
+    api_auth_key_secret_name = SecretNameField(null=True, blank=True)
     eligibility_type = models.ForeignKey(EligibilityType, on_delete=models.PROTECT)
     # public key is used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier
-    public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True)
+    public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True, blank=True)
     # The JWE-compatible Content Encryption Key (CEK) key-length and mode
-    jwe_cek_enc = models.TextField(null=True)
+    jwe_cek_enc = models.TextField(null=True, blank=True)
     # The JWE-compatible encryption algorithm
-    jwe_encryption_alg = models.TextField(null=True)
+    jwe_encryption_alg = models.TextField(null=True, blank=True)
     # The JWS-compatible signing algorithm
-    jws_signing_alg = models.TextField(null=True)
-    auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True)
+    jws_signing_alg = models.TextField(null=True, blank=True)
+    auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True, blank=True)
     selection_label_template = models.TextField()
-    start_template = models.TextField(null=True)
+    start_template = models.TextField(null=True, blank=True)
     # reference to a form class used by this Verifier, e.g. benefits.app.forms.FormClass
-    form_class = models.TextField(null=True)
-    help_template = models.TextField(null=True)
+    form_class = models.TextField(null=True, blank=True)
+    help_template = models.TextField(null=True, blank=True)
 
     class Meta:
         ordering = ["display_order"]


Test patch: