Quellcode durchsuchen

Add logic to detect duplicates when uploading an image via a modal

Add client-side logic to confirm a duplicate upload

Add template shown when a duplicate image is found via modal upload

Style duplicate upload template shown in modal

Add test for duplicate image upload in modal chooser

Avoid loading all duplicates in memory and use f-string

Make template fragments translatable and use image templatetag to render images

Use class selector instead of direct element one

Set primary button to 'Use existing and delete new' on duplicate upload
Tidiane Dia vor 3 Jahren
Ursprung
Commit
503298556e

+ 13 - 0
client/src/entrypoints/images/image-chooser-modal.js

@@ -154,6 +154,19 @@ window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
     modal.respond('imageChosen', jsonData.result);
     modal.close();
   },
+  duplicate_found: function (modal, jsonData) {
+    $('#tab-upload', modal.body).replaceWith(jsonData.htmlFragment);
+    $('.use-new-image', modal.body).on('click', function () {
+      modal.loadUrl(this.href);
+      return false;
+    });
+    $('.use-existing-image', modal.body).on('click', function () {
+      var form = $(this).closest('form');
+      var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val();
+      modal.postForm(this.href, { csrfmiddlewaretoken: CSRFToken });
+      return false;
+    });
+  },
   reshow_upload_form: function (modal, jsonData) {
     $('#tab-upload', modal.body).replaceWith(jsonData.htmlFragment);
     initTabs();

+ 9 - 0
client/webpack.config.js

@@ -227,6 +227,15 @@ module.exports = function exports(env, argv) {
       'scss',
       'focal-point-chooser.scss',
     );
+  sassEntry[getOutputPath('images', 'css', 'chooser-duplicate-upload')] =
+    path.resolve(
+      'wagtail',
+      'images',
+      'static_src',
+      'wagtailimages',
+      'scss',
+      'chooser-duplicate-upload.scss',
+    );
   sassEntry[getOutputPath('users', 'css', 'groups_edit')] = path.resolve(
     'wagtail',
     'users',

+ 21 - 0
wagtail/images/static_src/wagtailimages/scss/chooser-duplicate-upload.scss

@@ -0,0 +1,21 @@
+.duplicate-upload {
+  &__figure {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+
+    img {
+      margin: 1rem auto;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    justify-content: flex-end;
+    margin-top: 2.5rem;
+
+    .use-existing-image {
+      margin-inline-start: 1rem;
+    }
+  }
+}

+ 35 - 0
wagtail/images/templates/wagtailimages/chooser/confirm_duplicate_upload.html

@@ -0,0 +1,35 @@
+{% load i18n wagtailadmin_tags wagtailimages_tags %}
+
+{% block extra_css %}
+    <link rel="stylesheet" href="{% versioned_static 'wagtailimages/css/chooser-duplicate-upload.css' %}" type="text/css" />
+{% endblock %}
+
+<section id="upload" class="duplicate-upload active nice-padding">
+    <p class="help-block help-warning">
+        {% icon name='warning' %}
+        {% trans "Upload successful. However, your new image seems to be a duplicate of an existing image. You may delete it if it wasn't required." %}
+    </p>
+
+    <figure class="duplicate-upload__figure col6">
+        <figcaption>{% trans "Existing" %}</figcaption>
+        {% image existing_image max-800x600 class="show-transparency" %}
+        <a href="{% url 'wagtailimages:edit' existing_image.id %}">{{ existing_image.title }}</a>
+    </figure>
+    <figure class="duplicate-upload__figure col6">
+        <figcaption>{% trans "New" %}</figcaption>
+        {% image new_image max-800x600 class="show-transparency" %}
+        <a href="{% url 'wagtailimages:edit' new_image.id %}">{{ new_image.title }}</a>
+    </figure>
+
+    <div class="duplicate-upload__actions col12">
+        <a href="{{ confirm_duplicate_upload_action }}" class="use-new-image button button-secondary">
+            {% trans 'Use new image' %}
+        </a>
+        <form method="POST">
+            {% csrf_token %}
+            <a href="{{ cancel_duplicate_upload_action }}" class="use-existing-image button button-longrunning">
+                {% trans "Use existing and delete new" %}
+            </a>
+        </form>
+    </div>
+</section>

+ 107 - 0
wagtail/images/tests/test_admin_views.py

@@ -1326,6 +1326,113 @@ class TestImageChooserUploadView(TestCase, WagtailTestUtils):
         # The form should have an error
         self.assertFormError(response, "form", "file", "This field is required.")
 
+    def test_upload_duplicate(self):
+        def post_image(title="Test image"):
+            return self.client.post(
+                reverse("wagtailimages:chooser_upload"),
+                {
+                    "image-chooser-upload-title": title,
+                    "image-chooser-upload-file": SimpleUploadedFile(
+                        "test.png", get_test_image_file().file.getvalue()
+                    ),
+                },
+            )
+
+        # Post image then post duplicate
+        post_image()
+        response = post_image(title="Test duplicate image")
+
+        # Check response
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(
+            response, "wagtailimages/chooser/confirm_duplicate_upload.html"
+        )
+
+        # Check context
+        Image = get_image_model()
+        new_image = Image.objects.get(title="Test duplicate image")
+        existing_image = Image.objects.get(title="Test image")
+        self.assertEqual(response.context["new_image"], new_image)
+        self.assertEqual(response.context["existing_image"], existing_image)
+
+        choose_new_image_action = reverse(
+            "wagtailimages:image_chosen", args=(new_image.id,)
+        )
+        self.assertEqual(
+            response.context["confirm_duplicate_upload_action"], choose_new_image_action
+        )
+
+        choose_existing_image_action = (
+            reverse("wagtailimages:delete", args=(new_image.id,))
+            + "?"
+            + urlencode(
+                {
+                    "next": reverse(
+                        "wagtailimages:image_chosen", args=(existing_image.id,)
+                    )
+                }
+            )
+        )
+        self.assertEqual(
+            response.context["cancel_duplicate_upload_action"],
+            choose_existing_image_action,
+        )
+
+        # Check JSON
+        response_json = json.loads(response.content.decode())
+        self.assertEqual(response_json["step"], "duplicate_found")
+
+    def test_upload_duplicate_select_format(self):
+        def post_image(title="Test image"):
+            return self.client.post(
+                reverse("wagtailimages:chooser_upload") + "?select_format=true",
+                {
+                    "image-chooser-upload-title": title,
+                    "image-chooser-upload-file": SimpleUploadedFile(
+                        "test.png", get_test_image_file().file.getvalue()
+                    ),
+                },
+            )
+
+        # Post image then post duplicate
+        post_image()
+        response = post_image(title="Test duplicate image")
+
+        # Check response
+        self.assertEqual(response.status_code, 200)
+
+        # Check context
+        Image = get_image_model()
+        new_image = Image.objects.get(title="Test duplicate image")
+        existing_image = Image.objects.get(title="Test image")
+
+        choose_new_image_action = reverse(
+            "wagtailimages:chooser_select_format", args=(new_image.id,)
+        )
+        self.assertEqual(
+            response.context["confirm_duplicate_upload_action"], choose_new_image_action
+        )
+
+        choose_existing_image_action = (
+            reverse("wagtailimages:delete", args=(new_image.id,))
+            + "?"
+            + urlencode(
+                {
+                    "next": reverse(
+                        "wagtailimages:chooser_select_format", args=(existing_image.id,)
+                    )
+                }
+            )
+        )
+        self.assertEqual(
+            response.context["cancel_duplicate_upload_action"],
+            choose_existing_image_action,
+        )
+
+        # Check JSON
+        response_json = json.loads(response.content.decode())
+        self.assertEqual(response_json["step"], "duplicate_found")
+
     def test_select_format_flag_after_upload_form_error(self):
         submit_url = reverse("wagtailimages:chooser_upload") + "?select_format=true"
         response = self.client.post(

+ 47 - 0
wagtail/images/views/chooser.py

@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404
 from django.template.loader import render_to_string
 from django.template.response import TemplateResponse
 from django.urls import reverse
+from django.utils.http import urlencode
 from django.utils.translation import gettext as _
 from django.views.generic.base import View
 
@@ -16,6 +17,7 @@ from wagtail.images import get_image_model
 from wagtail.images.formats import get_image_format
 from wagtail.images.forms import ImageInsertionForm, get_image_form
 from wagtail.images.permissions import permission_policy
+from wagtail.images.utils import find_image_duplicates
 from wagtail.search import index as search_index
 
 permission_checker = PermissionPolicyChecker(permission_policy)
@@ -156,6 +158,42 @@ def image_chosen(request, image_id):
     )
 
 
+def duplicate_found(request, new_image, existing_image):
+    next_step_url = (
+        "wagtailimages:chooser_select_format"
+        if request.GET.get("select_format")
+        else "wagtailimages:image_chosen"
+    )
+    choose_new_image_url = reverse(next_step_url, args=(new_image.id,))
+    choose_existing_image_url = reverse(next_step_url, args=(existing_image.id,))
+
+    cancel_duplicate_upload_action = (
+        f"{reverse('wagtailimages:delete', args=(new_image.id,))}?"
+        f"{urlencode({'next': choose_existing_image_url})}"
+    )
+
+    duplicate_upload_html = render_to_string(
+        "wagtailimages/chooser/confirm_duplicate_upload.html",
+        {
+            "new_image": new_image,
+            "existing_image": existing_image,
+            "confirm_duplicate_upload_action": choose_new_image_url,
+            "cancel_duplicate_upload_action": cancel_duplicate_upload_action,
+        },
+        request,
+    )
+    return render_modal_workflow(
+        request,
+        None,
+        None,
+        None,
+        json_data={
+            "step": "duplicate_found",
+            "htmlFragment": duplicate_upload_html,
+        },
+    )
+
+
 @permission_checker.require("add")
 def chooser_upload(request):
     Image = get_image_model()
@@ -185,6 +223,15 @@ def chooser_upload(request):
             # Reindex the image to make sure all tags are indexed
             search_index.insert_or_update_object(image)
 
+            duplicates = find_image_duplicates(
+                image=image,
+                user=request.user,
+                permission_policy=permission_policy,
+            )
+            existing_image = duplicates.first()
+            if existing_image:
+                return duplicate_found(request, image, existing_image)
+
             if request.GET.get("select_format"):
                 form = ImageInsertionForm(
                     initial={"alt_text": image.default_alt_text},