Repository: wagtail/wagtail

Issue:
Images management command to purge and regenerate renditions
### Is your proposal related to a problem?

- Occasionally, you can create problems when syncing data from production environments or you just want to clear out unused renditions.
- There are also cases where there is a need to clear space on a server and we may want to intentionally only load image renditions when requested.

### Describe the solution you'd like

A new management command, in the images app, that provides the ability to regenerate image renditions. With an optional argument to also purge any existing ones.

* `manage.py update_image_renditions` - will regenerate all the rendition images (not the DB entries)
* `manage.py update_image_renditions --purge` - will first purge all rendition images and then regenerate all the rendition images
* `manage.py update_image_renditions --purge-only` - will ONLY purge all rendition images


### Describe alternatives you've considered

- Use something like `rm -rf` within the media folder, however this risks removing images that are not renditions, plus will mean that the next load for users will be slow for each image needed.

### Additional context

- See the documented method of writing Django Management commands https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/
- There is an old PR that started this https://github.com/wagtail/wagtail/pull/759 - could be a good reference
- There is also this PR that may be covering similar functionality https://github.com/wagtail/wagtail/pull/6595
- Use the Django documented `self.stdout.write("Unterminated line", ending='')` and `            self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % poll_id))` for messages, not `print`
- Should have documentation added to the page https://docs.wagtail.org/en/stable/reference/management_commands.html
- Needs unit tests


Images management command to purge and regenerate renditions
### Is your proposal related to a problem?

- Occasionally, you can create problems when syncing data from production environments or you just want to clear out unused renditions.
- There are also cases where there is a need to clear space on a server and we may want to intentionally only load image renditions when requested.

### Describe the solution you'd like

A new management command, in the images app, that provides the ability to regenerate image renditions. With an optional argument to also purge any existing ones.

* `manage.py update_image_renditions` - will regenerate all the rendition images (not the DB entries)
* `manage.py update_image_renditions --purge` - will first purge all rendition images and then regenerate all the rendition images
* `manage.py update_image_renditions --purge-only` - will ONLY purge all rendition images


### Describe alternatives you've considered

- Use something like `rm -rf` within the media folder, however this risks removing images that are not renditions, plus will mean that the next load for users will be slow for each image needed.

### Additional context

- See the documented method of writing Django Management commands https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/
- There is an old PR that started this https://github.com/wagtail/wagtail/pull/759 - could be a good reference
- There is also this PR that may be covering similar functionality https://github.com/wagtail/wagtail/pull/6595
- Use the Django documented `self.stdout.write("Unterminated line", ending='')` and `            self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % poll_id))` for messages, not `print`
- Should have documentation added to the page https://docs.wagtail.org/en/stable/reference/management_commands.html
- Needs unit tests




Discussion:
@lb- I am new to wagtail and I want to become familiar with how it works. I have done mysite tutorial with wagtail. I would like to give this issue a try. Can you assign it to me?

Hey @Hitansh-Shah - no need to assign this to you, simply get started on it and give it a go. 

Please read through these PRs in full (code changes, comments) as they are  previous attempts at this and will help you understand the problem and potential solutions.

- https://github.com/wagtail/wagtail/pull/759
- https://github.com/wagtail/wagtail/pull/6595

It's ok to start small, even a PR that does not include the Purge parts is great. Better a small change with unit tests and docs than a big change that gets to complex and can never be merged.

Also, it might be worth reading this PR - https://github.com/wagtail/wagtail/pull/8069 - it ha some related functionality that may be worth understanding. 
Thanks @lb- . I will go through the resources you have attached and try to brew something. I will let you know if I am stuck somewhere.
Hey @lb- , so I looked into the resources and the requirements of this issue. So I am just confirming what I understood so far and what I am planning to do. For regenerating the renditions I will just loop through all the renditions and for each rendition I will use the `get_rendition` method on `rendition.image` with `rendition.filter` as the parameter (all of this in the command function obviously). This will regenerate each and every rendition. I don't know if I have caught on to the problem, please correct me if I am wrong. If I am in the right direction do let me know so that I can open a draft Pr to work further. Thanks


That makes sense and appears to summarise correctly @Hitansh-Shah 

As noted above, feel free to start with a simple version of this that simply purges (deletes) renditions (even if that means a different name is needed. 

Please also namespace the command. `wagtail_update_image_renditions` 

See https://github.com/wagtail/wagtail/issues/8162 for context 
@lb- I am new to wagtail and I want to become familiar with how it works. I have done mysite tutorial with wagtail. I would like to give this issue a try. Can you assign it to me?

Hey @Hitansh-Shah - no need to assign this to you, simply get started on it and give it a go. 

Please read through these PRs in full (code changes, comments) as they are  previous attempts at this and will help you understand the problem and potential solutions.

- https://github.com/wagtail/wagtail/pull/759
- https://github.com/wagtail/wagtail/pull/6595

It's ok to start small, even a PR that does not include the Purge parts is great. Better a small change with unit tests and docs than a big change that gets to complex and can never be merged.

Also, it might be worth reading this PR - https://github.com/wagtail/wagtail/pull/8069 - it ha some related functionality that may be worth understanding. 
Thanks @lb- . I will go through the resources you have attached and try to brew something. I will let you know if I am stuck somewhere.
Hey @lb- , so I looked into the resources and the requirements of this issue. So I am just confirming what I understood so far and what I am planning to do. For regenerating the renditions I will just loop through all the renditions and for each rendition I will use the `get_rendition` method on `rendition.image` with `rendition.filter` as the parameter (all of this in the command function obviously). This will regenerate each and every rendition. I don't know if I have caught on to the problem, please correct me if I am wrong. If I am in the right direction do let me know so that I can open a draft Pr to work further. Thanks


That makes sense and appears to summarise correctly @Hitansh-Shah 

As noted above, feel free to start with a simple version of this that simply purges (deletes) renditions (even if that means a different name is needed. 

Please also namespace the command. `wagtail_update_image_renditions` 

See https://github.com/wagtail/wagtail/issues/8162 for context 

Resolving patch:
diff --git a/wagtail/images/management/__init__.py b/wagtail/images/management/__init__.py
new file mode 100644
diff --git a/wagtail/images/management/commands/__init__.py b/wagtail/images/management/commands/__init__.py
new file mode 100644
diff --git a/wagtail/images/management/commands/wagtail_update_image_renditions.py b/wagtail/images/management/commands/wagtail_update_image_renditions.py
new file mode 100644
--- /dev/null
+++ b/wagtail/images/management/commands/wagtail_update_image_renditions.py
@@ -0,0 +1,56 @@
+from django.core.management.base import BaseCommand
+
+from wagtail.images import get_image_model
+
+
+class Command(BaseCommand):
+    """Command to create missing image renditions with the option to remove (purge) any existing ones."""
+
+    help = "This command will generate all image renditions, with an option to purge existing renditions first."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--purge-only",
+            action="store_true",
+            help="Purge all image renditions without regenerating them",
+        )
+
+    def handle(self, *args, **options):
+        renditions = get_image_model().get_rendition_model().objects.all()
+        if len(renditions) == 0:
+            self.stdout.write("No image renditions found.")
+            return
+
+        success_count = 0
+        if options["purge_only"]:
+            for rendition in renditions:
+                try:
+                    rendition_image = rendition.image
+                    rendition.delete()
+                    success_count = success_count + 1
+                except Exception:
+                    self.stderr.write(
+                        f"Could not purge rendition for {rendition_image.title}"
+                    )
+            self.stdout.write(
+                self.style.SUCCESS(
+                    f"Successfully purged {success_count} image rendition(s)"
+                )
+            )
+        else:
+            for rendition in renditions:
+                try:
+                    rendition_filter = rendition.filter
+                    rendition_image = rendition.image
+                    rendition.delete()
+                    rendition_image.get_rendition(rendition_filter)
+                    success_count = success_count + 1
+                except Exception:
+                    self.stderr.write(
+                        f"Could not regenerate rendition for {rendition_image.title}"
+                    )
+            self.stdout.write(
+                self.style.SUCCESS(
+                    f"Successfully regenerated {success_count} image rendition(s)"
+                )
+            )


Test patch:
diff --git a/wagtail/images/tests/test_management_commands.py b/wagtail/images/tests/test_management_commands.py
new file mode 100644
--- /dev/null
+++ b/wagtail/images/tests/test_management_commands.py
@@ -0,0 +1,89 @@
+import re
+from io import StringIO
+
+from django.core import management
+from django.test import TestCase
+
+from wagtail.images import get_image_model
+
+from .utils import Image, get_test_image_file
+
+
+class TestUpdateImageRenditions(TestCase):
+    def setUp(self):
+        self.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(filename="test_image.png", colour="white"),
+        )
+
+        self.rendition = Image.get_rendition_model().objects.create(
+            image=self.image,
+            filter_spec="original",
+            width=1000,
+            height=1000,
+            file=get_test_image_file(
+                filename="test_rendition.png", colour="white", size=(1000, 1000)
+            ),
+        )
+
+    def delete_renditions(self):
+        renditions = get_image_model().get_rendition_model().objects.all()
+        for rendition in renditions:
+            try:
+                rendition_image = rendition.image
+                rendition.delete()
+            except Exception:
+                print(f"Could not delete rendition for {rendition_image}")
+
+    def run_command(self, **options):
+        output = StringIO()
+        management.call_command(
+            "wagtail_update_image_renditions", stdout=output, **options
+        )
+        output.seek(0)
+
+        return output
+
+    def test_exits_early_for_no_renditions(self):
+        self.delete_renditions()
+        # checking when command is called without any arguments
+        output = self.run_command()
+        self.assertEqual(output.read(), "No image renditions found.\n")
+
+        # checking when command is called with '--purge-only'
+        output = self.run_command(purge_only=True)
+        self.assertEqual(output.read(), "No image renditions found.\n")
+
+    def test_image_renditions(self):
+        renditions = get_image_model().get_rendition_model().objects.all()
+        total_renditions = len(renditions)
+        output = self.run_command()
+        reaesc = re.compile(r"\x1b[^m]*m")
+        output_string = reaesc.sub("", output.read())
+        # checking if the number of renditions regenerated equal total_renditions
+        self.assertEqual(
+            output_string,
+            f"Successfully regenerated {total_renditions} image rendition(s)\n",
+        )
+
+        # checking if the number of renditions now equal total_renditions
+        renditions_now = get_image_model().get_rendition_model().objects.all()
+        total_renditions_now = len(renditions_now)
+        self.assertEqual(total_renditions_now, total_renditions)
+
+    def test_image_renditions_with_purge_only(self):
+        renditions = get_image_model().get_rendition_model().objects.all()
+        total_renditions = len(renditions)
+        output = self.run_command(purge_only=True)
+        reaesc = re.compile(r"\x1b[^m]*m")
+        output_string = reaesc.sub("", output.read())
+        # checking if the number of renditions purged equal total_renditions
+        self.assertEqual(
+            output_string,
+            f"Successfully purged {total_renditions} image rendition(s)\n",
+        )
+
+        # checking if the number of renditions now equal 0
+        renditions_now = get_image_model().get_rendition_model().objects.all()
+        total_renditions_now = len(renditions_now)
+        self.assertEqual(total_renditions_now, 0)