From 6a4fce0177f8c22c7c8bca7a94a57ce3155592b4 Mon Sep 17 00:00:00 2001 From: Oliver Falk Date: Thu, 14 Jun 2018 14:01:01 +0200 Subject: [PATCH] Feature 'crop photo' --- config.py | 3 + ivatar/ivataraccount/models.py | 86 ++++++++++++++++++- .../ivataraccount/templates/crop_photo.html | 55 ++++++++++++ ivatar/ivataraccount/urls.py | 2 + ivatar/ivataraccount/views.py | 45 ++++++++++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 ivatar/ivataraccount/templates/crop_photo.html diff --git a/config.py b/config.py index 02d1e33..6b3a645 100644 --- a/config.py +++ b/config.py @@ -61,6 +61,9 @@ DEFAULT_FROM_EMAIL = SERVER_EMAIL MAX_NUM_PHOTOS = 5 MAX_PHOTO_SIZE = 10485760 # in bytes +MAX_PIXELS = 7000 +AVATAR_MAX_SIZE = 512 +JPEG_QUALITY = 85 BOOTSTRAP4 = { 'include_jquery': False, diff --git a/ivatar/ivataraccount/models.py b/ivatar/ivataraccount/models.py index 1f948cc..6536028 100644 --- a/ivatar/ivataraccount/models.py +++ b/ivatar/ivataraccount/models.py @@ -13,14 +13,19 @@ from urllib.parse import urlsplit, urlunsplit from PIL import Image from django.contrib.auth.models import User +from django.contrib import messages from django.db import models from django.utils import timezone +from django.http import HttpResponseRedirect +from django.urls import reverse_lazy from django.utils.translation import ugettext_lazy as _ from openid.association import Association as OIDAssociation from openid.store import nonce as oidnonce from openid.store.interface import OpenIDStore +from ipware import get_client_ip from ivatar.settings import MAX_LENGTH_EMAIL, logger +from ivatar.settings import MAX_PIXELS, AVATAR_MAX_SIZE, JPEG_QUALITY from .gravatar import get_photo as get_gravatar_photo @@ -38,6 +43,17 @@ def file_format(image_type): elif image_type == 'GIF': return 'gif' +def pil_format(image_type): + ''' + Helper method returning the 'encoder name' for PIL + ''' + if image_type == 'jpg': + return 'JPEG' + elif image_type == 'png': + return 'PNG' + elif image_type == 'gif': + return 'GIF' + logger.info('Unsupported file format: %s' % image_type) return None @@ -122,13 +138,81 @@ class Photo(BaseAccountModel): # Testing? Ideas anyone? except Exception as e: # pylint: disable=unused-variable # For debugging only - # print('Exception caught: %s' % e) + print('Exception caught: %s' % e) return False self.format = file_format(img.format) if not self.format: + print('Format not recognized') return False return super().save(*args, **kwargs) + def perform_crop(self, request, dimensions, email, openid): + if request.user.photo_set.count() == 1: + # This is the first photo, assign to all confirmed addresses + for email in request.user.confirmedemail_set.all(): + email.photo = self + email.save() + + for openid in request.user.confirmedopenid_set.all(): + openid.photo = self + openid.save() + + if email: + # Explicitely asked + email.photo = self + email.save() + + if openid: + # Explicitely asked + openid.photo = self + openid.save() + + # Do the real work cropping + img = Image.open(BytesIO(self.data)) + + # This should be anyway checked during save... + a, b = img.size + if a > MAX_PIXELS or b > MAX_PIXELS: + messages.error(request, _('Image dimensions are too big(max: %s x %s' % (MAX_PIXELS, MAX_PIXELS))) + return HttpResponseRedirect(reverse_lazy('profile')) + + w = dimensions['w'] + h = dimensions['h'] + x = dimensions['x'] + y = dimensions['y'] + + if w == 0 and h == 0: + w, h = a, b + i = min(w, h) + w, h = i, i + elif w < 0 or (x + w) > a or h < 0 or (y + h) > b: + messages.error(request, _('Crop outside of original image bounding box')) + return HttpResponseRedirect(reverse_lazy('profile')) + + cropped = img.crop((x, y, x + w, y + h)) + # cropped.load() + # Resize the image only if it's larger than the specified max width. + cropped_w, cropped_h = cropped.size + max_w = AVATAR_MAX_SIZE + if cropped_w > max_w or cropped_h > max_w: + cropped = cropped.resize((max_w, max_w), Image.ANTIALIAS) + + data = BytesIO() + cropped.save(data, pil_format(self.format), quality=JPEG_QUALITY) + data.seek(0) + # Create new photo? + # photo = Photo() + # photo.user = request.user + # photo.ip_address = get_client_ip(request) + # photo.data = data.read() + # photo.save() + + # Overwrite the existing image + self.data = data.read() + self.save() + + return HttpResponseRedirect(reverse_lazy('profile')) + class ConfirmedEmailManager(models.Manager): ''' diff --git a/ivatar/ivataraccount/templates/crop_photo.html b/ivatar/ivataraccount/templates/crop_photo.html new file mode 100644 index 0000000..eb8b53c --- /dev/null +++ b/ivatar/ivataraccount/templates/crop_photo.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans 'Crop photo' %} - ivatar{% endblock title %} + +{% block header %}{% endblock header %} + +{% block content %} + +

{% trans 'Crop photo' %}

+ +

{% trans 'Draw a square around the portion of the image you want to use:' %}

+ +
{% csrf_token %} + {% if email %}{% endif %} + {% if openid %}{% endif %} + +

+ + + + + +

+  {% trans 'Cancel' %}

+ +
+ +{% endblock content %} + +{% block bootstrap4_after_content %} +{{ block.super }} + + + +{% endblock bootstrap4_after_content %} diff --git a/ivatar/ivataraccount/urls.py b/ivatar/ivataraccount/urls.py index a52e1dc..785b5d3 100644 --- a/ivatar/ivataraccount/urls.py +++ b/ivatar/ivataraccount/urls.py @@ -9,6 +9,7 @@ from . views import RemoveUnconfirmedOpenIDView, RemoveConfirmedOpenIDView from . views import ImportPhotoView, RawImageView, DeletePhotoView from . views import UploadPhotoView, AssignPhotoOpenIDView from . views import AddOpenIDView, RedirectOpenIDView, ConfirmOpenIDView +from . views import CropPhotoView from django.contrib.auth.views import login, logout from django.urls import reverse_lazy @@ -71,4 +72,5 @@ urlpatterns = [ 'delete_photo/(?P\d+)', DeletePhotoView.as_view(), name='delete_photo'), url('raw_image/(?P\d+)', RawImageView.as_view(), name='raw_image'), + url('crop_photo/(?P\d+)', CropPhotoView.as_view(), name='crop_photo'), ] diff --git a/ivatar/ivataraccount/views.py b/ivatar/ivataraccount/views.py index 87c0213..d3147af 100644 --- a/ivatar/ivataraccount/views.py +++ b/ivatar/ivataraccount/views.py @@ -355,6 +355,8 @@ class UploadPhotoView(SuccessMessageMixin, FormView): messages.error(self.request, _('Invalid Format')) return HttpResponseRedirect(reverse_lazy('profile')) + # Override success URL -> Redirect to crop page. + self.success_url = reverse_lazy('crop_photo', args=[photo.pk]) return super().form_valid(form, *args, **kwargs) @@ -513,3 +515,46 @@ class ConfirmOpenIDView(View): # pragma: no cover def post(self, request, *args, **kwargs): return self.do_request(request.POST, *args, **kwargs) + + +@method_decorator(login_required, name='dispatch') +class CropPhotoView(TemplateView): + ''' + View class for cropping photos + ''' + template_name = 'crop_photo.html' + success_url = reverse_lazy('profile') + model = Photo + + def get(self, request, *args, **kwargs): + photo = self.model.objects.get(pk=kwargs['pk'], user=request.user) + email = request.GET.get('email') + openid = request.GET.get('openid') + return render(self.request, self.template_name, { + 'photo': photo, + 'email': email, + 'openid': openid, + }) + + def post(self, request, *args, **kwargs): + photo = self.model.objects.get(pk=kwargs['pk'], user=request.user) + dimensions = { + 'x': int(request.POST['x']), + 'y': int(request.POST['y']), + 'w': int(request.POST['w']), + 'h': int(request.POST['h']) + } + email = openid = None + if 'email' in request.POST: + try: + email = ConfirmedEmail.objects.get(email=request.POST['email']) + except: + pass # Ignore automatic assignment + + if 'openid' in request.POST: + try: + openid = ConfirmedOpenId.objects.get(openid=request.POST['openid']) + except: + pass # Ignore automatic assignment + + return photo.perform_crop(request, dimensions, email, openid)