Repository: mozmeao/snippets-service

Issue:
Allow deletion of Icons not currently in use
Hi @glogiotatidis & @pmclanahan 

We have a lot of icons no longer appliable due to their pixel size or interface incompatability. Unable to remove them without deleting unpublished snippets (no current job tied to snippet). However, we would like to retain the snippets for reference or reuse in the future. Recommend icon archival button that allows the user to categorize and hide old icons as we continue to grow/change. Once an icon is removed to archive, the snippet that retains that icon should prompt a change to a new icon when it comes time to publish a job or the icon is stripped or a generic icon indicator to be updated. Implimentation is needed as we have new publishers joining the platform, worried about governance when it comes to programming. Thanks!

![Screen Shot 2020-09-30 at 10 29 33 AM](https://user-images.githubusercontent.com/54955529/95223207-2d42f980-07ae-11eb-9c0a-1f8f8e4f8963.png)



Discussion:
Instead of archiving we can just allow deletion of icons not associated with a Published Job. Would that work @Mindy-Moz ?
Yes that works too as long as associated snippets that previously used that icon are not impacted.
> Yes that works too as long as associated snippets that previously used that icon are not impacted.

They are going to be impacted in the sense that they won't have icons anymore but I judging from your initial comment I guess you mean that copy is not lost
Yeah, I meant the copy wouldn't be lost. Works for me what you will
implement. Thanks!

On Mon, Oct 12, 2020 at 4:23 AM Giorgos Logiotatidis <
notifications@github.com> wrote:

> Yes that works too as long as associated snippets that previously used
> that icon are not impacted.
>
> They are going to be impacted in the sense that they won't have icons
> anymore but I judging from your initial comment I guess you mean that copy
> is not lost
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/mozmeao/snippets-service/issues/1447#issuecomment-707059820>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ANDI4CPMY5F2TU7DSAE75UDSKLRK7ANCNFSM4SGGO7EA>
> .
>


Resolving patch:
diff --git a/snippets/base/migrations/0040_auto_20201014_0405.py b/snippets/base/migrations/0040_auto_20201014_0405.py
new file mode 100644
--- /dev/null
+++ b/snippets/base/migrations/0040_auto_20201014_0405.py
@@ -0,0 +1,104 @@
+# Generated by Django 2.2.13 on 2020-10-14 04:05
+
+from django.db import migrations, models
+import snippets.base.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('base', '0039_sendtodevicesinglescenetemplate'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='fundraisingtemplate',
+            name='icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='fundraising_icons', to='base.Icon'),
+        ),
+        migrations.AlterField(
+            model_name='fundraisingtemplate',
+            name='title_icon',
+            field=models.ForeignKey(blank=True, help_text='Small icon that shows up before the title / text. 64x64px.PNG. Grayscale.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='fundraising_title_icons', to='base.Icon', verbose_name='Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='fxasignuptemplate',
+            name='scene1_icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='fxasignup_scene1_icons', to='base.Icon', verbose_name='Scene 1 Icon'),
+        ),
+        migrations.AlterField(
+            model_name='fxasignuptemplate',
+            name='scene1_section_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Section title icon. 64x64px. PNG. scene1_section_title_text must also be specified to display.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='fxa_scene1_section_icons', to='base.Icon', verbose_name='Section Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='fxasignuptemplate',
+            name='scene1_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Small icon that shows up before the title / text. 64x64px.PNG. Grayscale.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='fxasignup_scene1_title_icons', to='base.Icon', verbose_name='Scene 1 Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='newslettertemplate',
+            name='scene1_icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='newsletter_scene1_icons', to='base.Icon', verbose_name='Scene 1 Icon'),
+        ),
+        migrations.AlterField(
+            model_name='newslettertemplate',
+            name='scene1_section_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Section title icon. 64x64px. PNG. scene1_section_title_text must also be specified to display.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='newsletter_scene1_section_icons', to='base.Icon', verbose_name='Section Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='newslettertemplate',
+            name='scene1_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Small icon that shows up before the title / text. 64x64px.PNG. Grayscale.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='newsletter_scene1_title_icons', to='base.Icon', verbose_name='Scene 1 Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicesinglescenetemplate',
+            name='icon',
+            field=models.ForeignKey(help_text='Image to display above the form. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevicesinglescene_icons', to='base.Icon', verbose_name='Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicesinglescenetemplate',
+            name='section_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Section title icon. 64x64px. PNG. section_title_text must also be specified to display.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevicesinglescene_section_icons', to='base.Icon', verbose_name='Section Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicetemplate',
+            name='scene1_icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192 PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevice_scene1_icons', to='base.Icon', verbose_name='Scene 1 Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicetemplate',
+            name='scene1_section_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Section title icon. 64x64px. PNG. scene1_section_title_text must also be specified to display.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevice_scene1_section_icons', to='base.Icon', verbose_name='Section Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicetemplate',
+            name='scene1_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Small icon that shows up before the title / text. 64x64px.PNG. Grayscale.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevice_scene1_title_icons', to='base.Icon', verbose_name='Scene 1 Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='sendtodevicetemplate',
+            name='scene2_icon',
+            field=models.ForeignKey(help_text='Image to display above the form. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='sendtodevice_scene2_icons', to='base.Icon', verbose_name='Scene 2 Icon'),
+        ),
+        migrations.AlterField(
+            model_name='simplebelowsearchtemplate',
+            name='icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='simple_below_search_icons', to='base.Icon'),
+        ),
+        migrations.AlterField(
+            model_name='simpletemplate',
+            name='icon',
+            field=models.ForeignKey(help_text='Snippet icon. 192x192px PNG.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='simple_icons', to='base.Icon'),
+        ),
+        migrations.AlterField(
+            model_name='simpletemplate',
+            name='section_title_icon',
+            field=models.ForeignKey(blank=True, help_text='Section title icon. 64x64px. PNG. section_title_text must also be specified to display.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='simple_section_icons', to='base.Icon', verbose_name='Section Title Icon'),
+        ),
+        migrations.AlterField(
+            model_name='simpletemplate',
+            name='title_icon',
+            field=models.ForeignKey(blank=True, help_text='Small icon that shows up before the title / text. 64x64px.PNG. Grayscale.', null=True, on_delete=snippets.base.models.Icon.check_if_icon_can_be_deleted, related_name='simple_title_icons', to='base.Icon', verbose_name='Title Icon'),
+        ),
+    ]
diff --git a/snippets/base/models.py b/snippets/base/models.py
--- a/snippets/base/models.py
+++ b/snippets/base/models.py
@@ -678,6 +678,27 @@ def clean(self):
                     settings.IMAGE_MAX_SIZE / 1024, self.image.size / 1024)
             })
 
+    @staticmethod
+    def check_if_icon_can_be_deleted(collector, field, sub_objs, using):
+        """Checks if Icon is related to Templates that are in Draft, Scheduled or
+        Published state and prevents deletion.
+
+        """
+        asrsnippets = ASRSnippet.objects.filter(
+            template_relation__in=sub_objs, jobs__status__lte=Job.PUBLISHED
+        )
+        if asrsnippets.exists():
+            # Icon activelly used.
+            raise models.ProtectedError(
+                'Icon is in use by ASRSnippets with Draft, Scheduled or Published Jobs.',
+                asrsnippets
+            )
+        else:
+            # Icon can be deleted, set ForeignKeys referencing it to NULL.
+            # ASRSnippets that referenced this Icon will be required to set a
+            # new Icon before they can be saved through the UI.
+            collector.add_field_update(field, None, sub_objs)
+
 
 class Template(models.Model):
     TARGETING = ''
@@ -826,7 +847,7 @@ class SimpleTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.SET_NULL,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         related_name='simple_title_icons',
         verbose_name='Title Icon',
         help_text=('Small icon that shows up before the title / text. 64x64px.'
@@ -841,7 +862,9 @@ class SimpleTemplate(Template):
     )
     icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         related_name='simple_icons',
         help_text='Snippet icon. 192x192px PNG.'
     )
@@ -871,7 +894,7 @@ class SimpleTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Section Title Icon',
         related_name='simple_section_icons',
         help_text=('Section title icon. 64x64px. PNG. '
@@ -989,7 +1012,9 @@ class FundraisingTemplate(Template):
     )
     icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        blank=False,
+        null=True,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         related_name='fundraising_icons',
         help_text='Snippet icon. 192x192px PNG.'
     )
@@ -998,7 +1023,7 @@ class FundraisingTemplate(Template):
         blank=True,
         null=True,
         verbose_name='Title Icon',
-        on_delete=models.SET_NULL,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         related_name='fundraising_title_icons',
         help_text=('Small icon that shows up before the title / text. 64x64px.'
                    'PNG. Grayscale.')
@@ -1084,7 +1109,7 @@ class FxASignupTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.SET_NULL,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Scene 1 Title Icon',
         related_name='fxasignup_scene1_title_icons',
         help_text=('Small icon that shows up before the title / text. 64x64px.'
@@ -1101,7 +1126,9 @@ class FxASignupTemplate(Template):
     )
     scene1_icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         verbose_name='Scene 1 Icon',
         related_name='fxasignup_scene1_icons',
         help_text='Snippet icon. 192x192px PNG.')
@@ -1127,7 +1154,7 @@ class FxASignupTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Section Title Icon',
         related_name='fxa_scene1_section_icons',
         help_text=('Section title icon. 64x64px. PNG. '
@@ -1251,7 +1278,7 @@ class NewsletterTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.SET_NULL,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Scene 1 Title Icon',
         related_name='newsletter_scene1_title_icons',
         help_text=('Small icon that shows up before the title / text. 64x64px.'
@@ -1268,7 +1295,9 @@ class NewsletterTemplate(Template):
     )
     scene1_icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         verbose_name='Scene 1 Icon',
         related_name='newsletter_scene1_icons',
         help_text='Snippet icon. 192x192px PNG.')
@@ -1294,7 +1323,7 @@ class NewsletterTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Section Title Icon',
         related_name='newsletter_scene1_section_icons',
         help_text=('Section title icon. 64x64px. PNG. '
@@ -1447,7 +1476,7 @@ class SendToDeviceTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.SET_NULL,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Scene 1 Title Icon',
         related_name='sendtodevice_scene1_title_icons',
         help_text=('Small icon that shows up before the title / text. 64x64px.'
@@ -1464,7 +1493,9 @@ class SendToDeviceTemplate(Template):
     )
     scene1_icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        blank=False,
+        null=True,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Scene 1 Icon',
         related_name='sendtodevice_scene1_icons',
         help_text='Snippet icon. 192x192 PNG.')
@@ -1490,7 +1521,7 @@ class SendToDeviceTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Section Title Icon',
         related_name='sendtodevice_scene1_section_icons',
         help_text=('Section title icon. 64x64px. PNG. '
@@ -1524,7 +1555,9 @@ class SendToDeviceTemplate(Template):
     )
     scene2_icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         verbose_name='Scene 2 Icon',
         related_name='sendtodevice_scene2_icons',
         help_text='Image to display above the form. 192x192px PNG.'
@@ -1677,7 +1710,7 @@ class SendToDeviceSingleSceneTemplate(Template):
         Icon,
         blank=True,
         null=True,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
         verbose_name='Section Title Icon',
         related_name='sendtodevicesinglescene_section_icons',
         help_text=('Section title icon. 64x64px. PNG. '
@@ -1702,7 +1735,9 @@ class SendToDeviceSingleSceneTemplate(Template):
     )
     icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         verbose_name='Icon',
         related_name='sendtodevicesinglescene_icons',
         help_text='Image to display above the form. 192x192px PNG.'
@@ -1844,7 +1879,9 @@ class SimpleBelowSearchTemplate(Template):
     )
     icon = models.ForeignKey(
         Icon,
-        on_delete=models.PROTECT,
+        on_delete=Icon.check_if_icon_can_be_deleted,
+        blank=False,
+        null=True,
         related_name='simple_below_search_icons',
         help_text='Snippet icon. 192x192px PNG.'
     )


Test patch:
diff --git a/snippets/base/tests/test_models.py b/snippets/base/tests/test_models.py
--- a/snippets/base/tests/test_models.py
+++ b/snippets/base/tests/test_models.py
@@ -7,6 +7,7 @@
 from django.core.exceptions import ValidationError
 from django.core.files import File
 from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.db.models.deletion import ProtectedError
 from django.test.utils import override_settings
 from django.urls import reverse
 
@@ -505,6 +506,17 @@ def test_invalid_image(self):
         except ValidationError as e:
             self.assertEqual(e.message_dict['image'], ['Upload only PNG images.'])
 
+    def test_can_be_deleted(self):
+        job = JobFactory(status=Job.PUBLISHED)
+        icon = job.snippet.template_ng.icon
+        self.assertRaises(ProtectedError, icon.delete)
+
+        job.change_status(status=Job.COMPLETED)
+        icon.delete()
+        job.refresh_from_db()
+
+        self.assertEqual(job.snippet.template_ng.icon, None)
+
 
 class ASRSnippetTests(TestCase):
     def test_render(self):