Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rotate and resize photo before saving #2015

Merged
merged 7 commits into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions ynr/apps/moderation_queue/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from moderation_queue.helpers import convert_image_to_png
from moderation_queue.helpers import (
convert_image_to_png,
resize_photo,
rotate_photo,
)
from people.forms.forms import StrippedCharField
from PIL import Image as PILImage

Expand Down Expand Up @@ -55,10 +59,15 @@ def clean(self):

def save(self, commit):
"""
Before saving, convert the image to a PNG. This is done while
the image is still an InMemoryUpload object
Before saving, resize and rotate the image as needed
and convert the image to a PNG. This is done while the
image is still an InMemoryUpload object.
"""
png_image = convert_image_to_png(self.instance.image.file)

original_image = self.instance.image
photo = rotate_photo(original_image)
resized_photo = resize_photo(photo, original_image)
png_image = convert_image_to_png(resized_photo)
filename = self.instance.image.name
extension = filename.split(".")[-1]
filename = filename.replace(extension, "png")
Expand Down
53 changes: 50 additions & 3 deletions ynr/apps/moderation_queue/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .models import QueuedImage
from django.shortcuts import render
from PIL import Image as PillowImage
from PIL import ExifTags, ImageOps


def upload_photo_response(request, person, image_form, url_form):
Expand Down Expand Up @@ -44,13 +45,59 @@ def image_form_valid_response(request, person, image_form):
)


def convert_image_to_png(image):
def rotate_photo(original_image):
# TO DO issue #2026 : This does not handle URL
# uploads.

# If an image has an EXIF Orientation tag, other than 1,
# return a new image that is transposed accordingly.
# The new image will have the orientation data removed.
# https://pillow.readthedocs.io/en/stable/_modules/PIL/ImageOps.html#exif_transpose
# Otherwise, return a copy of the image. If an image
# has an EXIF Orientation tag of 1, it might still
# need to be rotated, but we can handle that in the
# review process.
pil_image = PillowImage.open(original_image)

for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == "Orientation":
break
exif = pil_image.getexif()
if exif and exif[274]:
pil_image = ImageOps.exif_transpose(pil_image)
buffer = BytesIO()
pil_image.save(buffer, "PNG")
return pil_image


def resize_photo(photo, original_image):
if not isinstance(photo, PillowImage.Image):
pil_image = PillowImage.open(photo)
else:
pil_image = photo

if original_image.width > 5000 or original_image.height > 5000:
size = 2000, 2000
pil_image.thumbnail(size)
buffer = BytesIO()
pil_image.save(buffer, "PNG")
return pil_image
return photo


def convert_image_to_png(photo):
# Some uploaded images are CYMK, which gives you an error when
# you try to write them as PNG, so convert to RGBA (this is
# RGBA rather than RGB so that any alpha channel (transparency)
# is preserved).
original = PillowImage.open(image)
converted = original.convert("RGBA")

# If the photo is not already a PillowImage object
# coming from the form, then we need to
# open it as a PillowImage object before
# converting it to RGBA.
if not isinstance(photo, PillowImage.Image):
photo = PillowImage.open(photo)
converted = photo.convert("RGBA")
bytes_obj = BytesIO()
converted.save(bytes_obj, "PNG")
return bytes_obj
Expand Down
8 changes: 8 additions & 0 deletions ynr/apps/moderation_queue/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ast
from datetime import date
from os.path import join, splitext
from tempfile import NamedTemporaryFile
Expand Down Expand Up @@ -139,6 +140,13 @@ def crop_image(self):
cropped.save(ntf.name, "PNG")
return ntf

@property
def facial_detection(self):
if self.detection_metadata:
self.detection_metadata = ast.literal_eval(self.detection_metadata)
return self.detection_metadata
return {}


class SuggestedPostLock(models.Model):
ballot = models.ForeignKey("candidates.Ballot", on_delete=models.CASCADE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,18 @@ <h3>Rotate the image as needed</h3>
<input type="hidden" name="queued_image_id" value="{{ queued_image.id }}">
<button name="rotate" type="submit" value="left">Anti-clockwise ⟲</button>
<button name="rotate" type="submit" value="right">Clockwise ⟳</button>


<p>
{% if queued_image.detection_metadata and queued_image.user.is_superuser %}
<details>
<summary>Facial recognition data (click to expand)</summary>
<pre>{{ queued_image.detection_metadata | pprint}}<pre>
</details>
{% elif not queued_image.detection_metadata %}
Image analysis is not available for this photo.
{% endif %}
</p>

<h3>User-submitted information</h3>

<p>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions ynr/apps/moderation_queue/tests/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,15 @@
BROKEN_IMAGE_FILENAME = abspath(
join(dirname(__file__), "broke-image-example.htm")
)

ROTATED_IMAGE_FILENAME = abspath(
join(dirname(__file__), "rotated_photo_with_exif.jpg")
)

QUEUED_IMAGE_FILENAME = abspath(
join(dirname(__file__), "media/queued-images/example-queued-image.png")
)

XL_IMAGE_FILENAME = abspath(
join(dirname(__file__), "example-queued-image-xl.jpg")
)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 99 additions & 6 deletions ynr/apps/moderation_queue/tests/test_upload_photo.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import os
from os.path import dirname, join, realpath
from shutil import rmtree
from urllib.parse import urlsplit
from PIL import Image as PillowImage

from candidates.models import LoggedAction
from candidates.tests.uk_examples import UK2015ExamplesMixin
from django.contrib.auth.models import User
from django.core.files.storage import FileSystemStorage
from django.test.utils import override_settings
from django.urls import reverse
from django_webtest import WebTest
from mock import Mock, patch
from webtest import Upload

from moderation_queue.helpers import (
ImageDownloadException,
convert_image_to_png,
download_image_from_url,
)
from candidates.models import LoggedAction
from candidates.tests.uk_examples import UK2015ExamplesMixin
from moderation_queue.models import QueuedImage
from moderation_queue.tests.paths import EXAMPLE_IMAGE_FILENAME
from moderation_queue.tests.paths import (
EXAMPLE_IMAGE_FILENAME,
ROTATED_IMAGE_FILENAME,
XL_IMAGE_FILENAME,
)
from people.tests.factories import PersonFactory
from PIL import Image as PillowImage
from webtest import Upload

from ynr.helpers import mkdir_p

TEST_MEDIA_ROOT = realpath(join(dirname(__file__), "media"))
Expand All @@ -30,6 +35,8 @@
class PhotoUploadImageTests(UK2015ExamplesMixin, WebTest):

example_image_filename = EXAMPLE_IMAGE_FILENAME
rotated_image_filename = ROTATED_IMAGE_FILENAME
xl_image_filename = XL_IMAGE_FILENAME

@classmethod
def setUpClass(cls):
Expand All @@ -56,6 +63,61 @@ def tearDown(self):
super().tearDown()
self.test_upload_user.delete()

def invalid_form(self):
return self.form_page_response.forms["person-upload-photo-url"]

def valid_form(self):
form = self.form_page_response.forms["person-upload-photo-image"]
form["image"] = Upload(self.rotated_image_filename)
form["justification_for_use"] = "copyright-assigned"
form["why_allowed"] = "profile-photo"
return form

def get_and_head_methods(self, *all_mock_requests):
return [
getattr(mock_requests, attr)
for mock_requests in all_mock_requests
for attr in ("get", "head")
]

def successful_get_rotated_image(self, *all_mock_requests, **kwargs):
content_type = kwargs.get("content_type", "image/jpeg")
headers = {"content-type": content_type}
with open(self.rotated_image_filename, "rb") as image:
image_data = image.read()
for mock_method in self.get_and_head_methods(*all_mock_requests):
setattr(
mock_method,
"return_value",
Mock(
status_code=200,
headers=headers,
# The chunk size is larger than the example
# image, so we don't need to worry about
# returning subsequent chunks.
iter_content=lambda **kwargs: [image_data],
),
)

def successful_get_oversized_image(self, *all_mock_requests, **kwargs):
content_type = kwargs.get("content_type", "image/jpeg")
headers = {"content-type": content_type}
with open(self.xl_image_filename, "rb") as image:
image_data = image.read()
for mock_method in self.get_and_head_methods(*all_mock_requests):
setattr(
mock_method,
"return_value",
Mock(
status_code=200,
headers=headers,
# The chunk size is larger than the example
# image, so we don't need to worry about
# returning subsequent chunks.
iter_content=lambda **kwargs: [image_data],
),
)

def test_queued_images_form_visibility(self):
QueuedImage.objects.create(
person_id=2009,
Expand Down Expand Up @@ -120,6 +182,37 @@ def test_shows_photo_policy_text_in_photo_upload_page(self):
response = self.app.get(upload_form_url, user=self.test_upload_user)
self.assertContains(response, "Photo policy")

def test_resize_image(self, *all_mock_requests):
# Test that the image is less than or equal to 5MB after
# upload and before saving to the database.
image_size = os.path.getsize(self.xl_image_filename)
self.assertGreater(image_size, 5000000)

upload_form_file = reverse("photo-upload", kwargs={"person_id": "2009"})
self.form_page_response = self.app.get(
upload_form_file, user=self.test_upload_user
)
self.successful_get_oversized_image(*all_mock_requests)
self.valid_form().submit()

queued_image = QueuedImage.objects.filter(person_id=2009).last()
image_size = queued_image.image.size
self.assertLessEqual(image_size, 5000000)

def test_rotate_image_from_file_upload(self, *all_mock_requests):
exif = PillowImage.open(ROTATED_IMAGE_FILENAME)._getexif()
self.assertEqual(exif[274], 1)

upload_form_file = reverse("photo-upload", kwargs={"person_id": "2009"})
self.form_page_response = self.app.get(
upload_form_file, user=self.test_upload_user
)
self.successful_get_rotated_image(*all_mock_requests)
self.valid_form().submit()
queued_image = QueuedImage.objects.filter(person_id=2009).last()
exif = PillowImage.open(queued_image.image)._getexif()
self.assertEqual(exif, None)


@patch("moderation_queue.forms.requests")
@patch("moderation_queue.helpers.requests")
Expand Down
41 changes: 36 additions & 5 deletions ynr/apps/people/admin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from candidates.models import LoggedAction
from candidates.models.db import ActionType
from candidates.views.version_data import get_client_ip
from django import forms
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import path, reverse
from django.utils.html import mark_safe
from django.views.generic import TemplateView
from sorl.thumbnail.admin.current import AdminImageWidget

from candidates.models import LoggedAction
from candidates.models.db import ActionType
from candidates.views.version_data import get_client_ip
from people.data_removal_helpers import DataRemover
from people.models import (
EditLimitationStatuses,
Expand All @@ -16,6 +15,7 @@
PersonNameSynonym,
)
from popolo.models import Membership
from sorl.thumbnail.admin.current import AdminImageWidget


class RemovePersonalDataView(TemplateView):
Expand Down Expand Up @@ -96,8 +96,32 @@ class PersonAdmin(admin.ModelAdmin):
)

list_filter = ("edit_limitations",)
list_display = (
"name",
"image_preview",
"image_filetype",
)
inlines = [PersonImageInline]

def image_preview(self, obj):
person = Person.objects.get(pk=obj.pk)
image_url = mark_safe(
'<img src="/media/{}" width="50" height="50" />'.format(
obj.image.image.name
)
)
if person.image:
return image_url
else:
return "No Image Found"

def image_filetype(self, obj):
person = Person.objects.get(pk=obj.pk)
if person.image:
return person.image.image.name.split(".")[-1]
else:
return "No Image Found"

def get_urls(self):
urls = super().get_urls()
custom_urls = [
Expand Down Expand Up @@ -131,6 +155,13 @@ def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)


class PersonImageAdmin(admin.ModelAdmin):
model = PersonImage
list_display = ("person", "image", "id", "is_primary")
list_filter = ("is_primary",)
fields = ("id", "image", "is_primary", "person")


class PersonNameSynonymAdmin(admin.ModelAdmin):
search_fields = ("term", "synonym")
list_display = ("term", "synonym")
Expand Down
Loading
Loading