diff options
Diffstat (limited to 'postgresqleu')
-rw-r--r-- | postgresqleu/confreg/admin.py | 4 | ||||
-rw-r--r-- | postgresqleu/confreg/backendforms.py | 7 | ||||
-rw-r--r-- | postgresqleu/confreg/forms.py | 5 | ||||
-rw-r--r-- | postgresqleu/confreg/migrations/0065_speaker_photo_bytea.py | 2 | ||||
-rw-r--r-- | postgresqleu/confreg/migrations/0087_speaker_photo512.py | 19 | ||||
-rw-r--r-- | postgresqleu/confreg/models.py | 33 | ||||
-rw-r--r-- | postgresqleu/confreg/views.py | 21 | ||||
-rw-r--r-- | postgresqleu/urls.py | 2 | ||||
-rw-r--r-- | postgresqleu/util/backendforms.py | 1 | ||||
-rw-r--r-- | postgresqleu/util/backendviews.py | 2 | ||||
-rw-r--r-- | postgresqleu/util/fields.py | 41 | ||||
-rw-r--r-- | postgresqleu/util/image.py | 28 | ||||
-rw-r--r-- | postgresqleu/util/widgets.py | 1 |
13 files changed, 139 insertions, 27 deletions
diff --git a/postgresqleu/confreg/admin.py b/postgresqleu/confreg/admin.py index 1e4657a2..f922d55e 100644 --- a/postgresqleu/confreg/admin.py +++ b/postgresqleu/confreg/admin.py @@ -286,7 +286,7 @@ class ConferenceAdditionalOptionAdmin(admin.ModelAdmin): class SpeakerAdminForm(ConcurrentProtectedModelForm): - exclude_fields_from_validation = ['photo', ] + exclude_fields_from_validation = ['photo', 'photo512', ] class Meta: model = Speaker @@ -294,7 +294,7 @@ class SpeakerAdminForm(ConcurrentProtectedModelForm): class SpeakerAdmin(admin.ModelAdmin): - list_display = ['user', 'email', 'fullname', 'has_abstract', 'has_photo'] + list_display = ['user', 'email', 'fullname', 'has_abstract', 'has_photo', 'has_photo512'] search_fields = ['fullname', 'user__email'] autocomplete_fields = ('user', ) ordering = ['fullname'] diff --git a/postgresqleu/confreg/backendforms.py b/postgresqleu/confreg/backendforms.py index d38c1904..20f9aea7 100644 --- a/postgresqleu/confreg/backendforms.py +++ b/postgresqleu/confreg/backendforms.py @@ -798,11 +798,14 @@ class BackendSpeakerForm(BackendForm): list_fields = ['fullname', 'user', 'company', ] markdown_fields = ['abstract', ] readonly_fields = ['user', ] - exclude_fields_from_validation = ['user', 'photo', ] + exclude_fields_from_validation = ['user', 'photo512', ] + # We must save the photo field as well, since it's being updaed in the pre_save signal, + # and we want to include that updating. + extra_update_fields = ['photo', ] class Meta: model = Speaker - fields = ['fullname', 'user', 'twittername', 'company', 'abstract', 'photo', ] + fields = ['fullname', 'user', 'twittername', 'company', 'abstract', 'photo512', ] widgets = { 'user': StaticTextWidget, } diff --git a/postgresqleu/confreg/forms.py b/postgresqleu/confreg/forms.py index 372ec836..953fef4f 100644 --- a/postgresqleu/confreg/forms.py +++ b/postgresqleu/confreg/forms.py @@ -462,10 +462,11 @@ class ConferenceFeedbackForm(forms.Form): class SpeakerProfileForm(forms.ModelForm): class Meta: model = Speaker - exclude = ('user', 'speakertoken') + exclude = ('user', 'speakertoken', 'photo') def __init__(self, *args, **kwargs): super(SpeakerProfileForm, self).__init__(*args, **kwargs) + self.fields['photo512'].help_text = 'Photo will be rescaled to 512x512 pixels. We reserve the right to make minor edits to speaker photos if necessary' def clean_twittername(self): if not self.cleaned_data['twittername']: @@ -503,7 +504,7 @@ class CallForPapersForm(forms.ModelForm): if 'data' in kwargs and 'speaker' in kwargs['data']: vals.extend([int(x) for x in kwargs['data'].getlist('speaker')]) - self.fields['speaker'].queryset = Speaker.objects.defer('photo').filter(pk__in=vals) + self.fields['speaker'].queryset = Speaker.objects.defer('photo', 'photo512').filter(pk__in=vals) self.fields['speaker'].label_from_instance = lambda x: "{0} <{1}>".format(x.fullname, x.email) self.fields['speaker'].required = True self.fields['speaker'].help_text = "Type the beginning of a speakers email address to add more speakers" diff --git a/postgresqleu/confreg/migrations/0065_speaker_photo_bytea.py b/postgresqleu/confreg/migrations/0065_speaker_photo_bytea.py index aad3b470..9b5979b0 100644 --- a/postgresqleu/confreg/migrations/0065_speaker_photo_bytea.py +++ b/postgresqleu/confreg/migrations/0065_speaker_photo_bytea.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='speaker', name='photo', - field=postgresqleu.util.fields.ImageBinaryField(blank=True, null=True, verbose_name='Photo', max_length=1000000), + field=postgresqleu.util.fields.ImageBinaryField(blank=True, null=True, verbose_name='Photo (low res)', max_length=1000000), ), migrations.RunSQL("UPDATE confreg_speaker SET photo=decode(confreg_speaker_photo.photo, 'base64') FROM confreg_speaker_photo WHERE confreg_speaker_photo.id=confreg_speaker.id"), migrations.DeleteModel( diff --git a/postgresqleu/confreg/migrations/0087_speaker_photo512.py b/postgresqleu/confreg/migrations/0087_speaker_photo512.py new file mode 100644 index 00000000..1bdbf385 --- /dev/null +++ b/postgresqleu/confreg/migrations/0087_speaker_photo512.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.11 on 2022-06-26 17:09 + +from django.db import migrations +import postgresqleu.util.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('confreg', '0086_conferenceregistrationlog_changedata'), + ] + + operations = [ + migrations.AddField( + model_name='speaker', + name='photo512', + field=postgresqleu.util.fields.ImageBinaryField(blank=True, max_length=1000000, null=True, verbose_name='Photo'), + ), + ] diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py index 0bdea427..baa2a163 100644 --- a/postgresqleu/confreg/models.py +++ b/postgresqleu/confreg/models.py @@ -3,6 +3,7 @@ from django.db import models from django.db.models import Q from django.db.models.expressions import F +from django.db.models.signals import pre_save from django.contrib.auth.models import User from django.conf import settings from django.core.exceptions import ValidationError @@ -14,6 +15,7 @@ from django.template.defaultfilters import slugify from django.contrib.postgres.fields import DateTimeRangeField from django.contrib.postgres.indexes import GinIndex from django.core.serializers.json import DjangoJSONEncoder +from django.dispatch import receiver from postgresqleu.util.validators import validate_lowercase, validate_urlname from postgresqleu.util.validators import TwitterValidator @@ -22,6 +24,7 @@ from postgresqleu.util.forms import ChoiceArrayField from postgresqleu.util.fields import LowercaseEmailField, ImageBinaryField, PdfBinaryField from postgresqleu.util.time import today_conference from postgresqleu.util.db import exec_no_result +from postgresqleu.util.image import rescale_image_bytes import base64 import pytz @@ -917,11 +920,12 @@ class Speaker(models.Model): verbose_name='Twitter name') company = models.CharField(max_length=100, null=False, blank=True) abstract = models.TextField(null=False, blank=True, verbose_name="Bio") - photo = ImageBinaryField(blank=True, null=True, verbose_name="Photo", max_length=1000000, max_resolution=(128, 128)) + photo = ImageBinaryField(blank=True, null=True, verbose_name="Photo (low res)", max_length=1000000, resolution=(128, 128)) + photo512 = ImageBinaryField(blank=True, null=True, verbose_name="Photo", max_length=1000000, resolution=(512, 512), auto_scale=True) lastmodified = models.DateTimeField(auto_now=True, null=False, blank=False) speakertoken = models.TextField(null=False, blank=False, unique=True) - _safe_attributes = ('id', 'name', 'fullname', 'twittername', 'company', 'abstract', 'photo', 'has_photo', 'photo_data', 'lastmodified', ) + _safe_attributes = ('id', 'name', 'fullname', 'twittername', 'company', 'abstract', 'photo', 'photo512', 'has_photo', 'has_photo512', 'photo_data', 'photo_data512', 'lastmodified', ) json_included_attributes = ['fullname', 'twittername', 'company', 'abstract', 'lastmodified'] @property @@ -946,17 +950,21 @@ class Speaker(models.Model): return len(self.abstract) > 0 has_abstract.boolean = True + @property def has_photo(self): return (self.photo is not None and self.photo != "") - has_photo.boolean = True + + @property + def has_photo512(self): + return (self.photo512 is not None and self.photo512 != "") @cached_property def photo_data(self): return base64.b64encode(self.photo).decode('ascii') - @property - def photofile(self): - return self.photo + @cached_property + def photo512_data(self): + return base64.b64encode(self.photo512).decode('ascii') def __str__(self): return self.name @@ -969,12 +977,25 @@ class Speaker(models.Model): # our fix. So make the fix conditional until we have 3.2.10 everywhere so we can throw it away if 'photo' in r[2] and isinstance(r[2]['photo'], memoryview): r[2]['photo'] = bytes(r[2]['photo']) + if 'photo512' in r[2] and isinstance(r[2]['photo512'], memoryview): + r[2]['photo512'] = bytes(r[2]['photo512']) return r class Meta: ordering = ['fullname', ] +@receiver(pre_save, sender=Speaker) +def _speaker_photo_resizer(sender, instance, *args, **kwargs): + if instance.photo512: + instance.photo = rescale_image_bytes(instance.photo512, (128, 128)) + else: + # If there is a photo, remove it but only if the photo512 entry has actually + # been deleted (which means it's not NULL anymore) + if instance.photo and instance.photo512 is not None: + instance.photo = None + + class DeletedItems(models.Model): itemid = models.IntegerField(null=False, blank=False) type = models.CharField(max_length=16, blank=False, null=False) diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py index 0174fd9f..23e0b28d 100644 --- a/postgresqleu/confreg/views.py +++ b/postgresqleu/confreg/views.py @@ -1226,7 +1226,8 @@ INNER JOIN confreg_room r ON r.id=t.room_id GROUP BY day 'name', spk.fullname, 'company', spk.company, 'twittername', spk.twittername, - 'hasphoto', spk.photo IS NOT NULL AND spk.photo != ''::bytea + 'hasphoto', spk.photo IS NOT NULL AND spk.photo != ''::bytea, + 'hasphoto512', spk.photo512 IS NOT NULL AND spk.photo512 != ''::bytea ) ORDER BY spk.fullname) FILTER (WHERE spk.id IS NOT NULL), '[]') AS speakers FROM confreg_conferencesession s LEFT JOIN confreg_track t ON t.id=s.track_id @@ -1486,9 +1487,20 @@ def speaker_card(request, confname, speakerid, cardformat): }) -def speakerphoto(request, speakerid): +def speakerphoto(request, speakerid, phototype='1/'): speaker = get_object_or_404(Speaker, pk=speakerid) - return HttpResponse(bytes(speaker.photo), content_type='image/jpg') + if phototype is None or phototype == '1/': + if not speaker.photo: + raise Http404() + photo = bytes(speaker.photo) + elif phototype == '5/': + if not speaker.photo512: + raise Http404() + photo = bytes(speaker.photo512) + else: + raise Http404() + content_type = 'image/png' if photo[:8] == b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A' else 'image/jpg' + return HttpResponse(photo, content_type=content_type) @login_required @@ -1511,7 +1523,8 @@ def speakerprofile(request, confurlname=None): conferences = [] callforpapers = None except Exception: - pass + # We used to accept errors here. Why? + raise if request.method == 'POST': # Attempt to save diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py index f474e85d..8433b076 100644 --- a/postgresqleu/urls.py +++ b/postgresqleu/urls.py @@ -124,7 +124,7 @@ urlpatterns.extend([ url(r'^events/([^/]+)/checkin/([a-z0-9]{64})/$', postgresqleu.confreg.checkin.checkin), url(r'^events/([^/]+)/checkin/([a-z0-9]{64})/api/(\w+)/$', postgresqleu.confreg.checkin.api), url(r'^events/([^/]+)/sessions/$', postgresqleu.confreg.views.sessionlist), - url(r'^events/speaker/(\d+)/photo/$', postgresqleu.confreg.views.speakerphoto), + url(r'^events/speaker/(\d+)/photo/(\d+/)?$', postgresqleu.confreg.views.speakerphoto), url(r'^events/speakerprofile/$', postgresqleu.confreg.views.speakerprofile), url(r'^events/([^/]+)/speakerprofile/$', postgresqleu.confreg.views.speakerprofile), url(r'^events/([^/]+)/callforpapers/$', postgresqleu.confreg.views.callforpapers), diff --git a/postgresqleu/util/backendforms.py b/postgresqleu/util/backendforms.py index 6c22192c..755bcace 100644 --- a/postgresqleu/util/backendforms.py +++ b/postgresqleu/util/backendforms.py @@ -28,6 +28,7 @@ class BackendForm(ConcurrentProtectedModelForm): vat_fields = {} verbose_field_names = {} exclude_date_validators = [] + extra_update_fields = [] form_before_new = None newformdata = None _newformdata = _NewFormDataField() diff --git a/postgresqleu/util/backendviews.py b/postgresqleu/util/backendviews.py index abe6dedb..fac9c5cf 100644 --- a/postgresqleu/util/backendviews.py +++ b/postgresqleu/util/backendviews.py @@ -170,7 +170,7 @@ def backend_process_form(request, urlname, formclass, id, cancel_url='../', save for fn, ffields in form.json_form_fields.items(): all_excludes.extend(ffields) - form.instance.save(update_fields=[f for f in form.fields.keys() if f not in all_excludes and not isinstance(form[f].field, forms.ModelMultipleChoiceField)]) + form.instance.save(update_fields=[f for f in form.fields.keys() if f not in all_excludes and not isinstance(form[f].field, forms.ModelMultipleChoiceField)] + form.extra_update_fields) # Merge fields stored in json if form.json_form_fields: diff --git a/postgresqleu/util/fields.py b/postgresqleu/util/fields.py index ae41061a..88b381b0 100644 --- a/postgresqleu/util/fields.py +++ b/postgresqleu/util/fields.py @@ -2,7 +2,9 @@ from django.db import models from django.core.exceptions import ValidationError from .forms import ImageBinaryFormField, PdfBinaryFormField -from PIL import ImageFile +import io + +from PIL import Image, ImageFile from postgresqleu.util.magic import magicdb @@ -19,7 +21,8 @@ class ImageBinaryField(models.Field): empty_values = [None, b''] def __init__(self, max_length, *args, **kwargs): - self.max_resolution = kwargs.pop('max_resolution', None) + self.resolution = kwargs.pop('resolution', None) + self.auto_scale = kwargs.pop('auto_scale', False) super(ImageBinaryField, self).__init__(*args, **kwargs) self.max_length = max_length @@ -65,12 +68,34 @@ class ImageBinaryField(models.Field): except Exception as e: raise ValidationError("Could not parse image: %s" % e) - if img.format.upper() != 'JPEG': - raise ValidationError("Only JPEG files are allowed") - - if self.max_resolution: - if img.size[0] > self.max_resolution[0] or img.size[1] > self.max_resolution[1]: - raise ValidationError("Maximum image size is {}x{}".format(*self.max_resolution)) + if img.format.upper() not in ('JPEG', 'PNG'): + raise ValidationError("Only JPEG or PNG files are allowed") + + if self.resolution: + if img.size[0] != self.resolution[0] or img.size[1] != self.resolution[1]: + if self.auto_scale: + scale = min( + float(self.resolution[0]) / float(img.size[0]), + float(self.resolution[1]) / float(img.size[1]), + ) + newimg = img.resize( + (int(img.size[0] * scale), int(img.size[1] * scale)), + Image.BICUBIC, + ) + saver = io.BytesIO() + if newimg.size[0] != newimg.size[1]: + # This is not a square, so we have to roll it again + centeredimg = Image.new('RGBA', self.resolution) + centeredimg.paste(newimg, ( + (self.resolution[0] - newimg.size[0]) // 2, + (self.resolution[1] - newimg.size[1]) // 2, + )) + centeredimg.save(saver, format='PNG') + else: + newimg.save(saver, format="PNG") + value = saver.getvalue() + else: + raise ValidationError("Image size must be {}x{}".format(*self.resolution)) return value diff --git a/postgresqleu/util/image.py b/postgresqleu/util/image.py new file mode 100644 index 00000000..507f9968 --- /dev/null +++ b/postgresqleu/util/image.py @@ -0,0 +1,28 @@ +import io + +from PIL import Image, ImageFile + + +# Rescale an image in the form of bytes to a new set of bytes +# in the same format. Assumes the aspect is correct and that +# the incoming data is valid (it's expected to be for example +# the output of previous image operations) +def rescale_image_bytes(origbytes, resolution): + p = ImageFile.Parser() + p.feed(origbytes) + p.close() + img = p.image + + scale = min( + float(resolution[0]) / float(img.size[0]), + float(resolution[1]) / float(img.size[1]), + ) + + newimg = img.resize( + (int(img.size[0] * scale), int(img.size[1] * scale)), + Image.BICUBIC, + ) + saver = io.BytesIO() + newimg.save(saver, format=img.format) + + return saver.getvalue() diff --git a/postgresqleu/util/widgets.py b/postgresqleu/util/widgets.py index 65aad0a1..d7b46062 100644 --- a/postgresqleu/util/widgets.py +++ b/postgresqleu/util/widgets.py @@ -94,6 +94,7 @@ class InlineImageUploadWidget(forms.ClearableFileInput): context = self.get_context(name, value, attrs) if value and not isinstance(value, UploadedFile): context['widget']['value'] = base64.b64encode(value).decode('ascii') + context['widget']['imagetype'] = 'image/png' if bytes(value[:8]) == b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A' else 'image/jpg' return mark_safe(loader.render_to_string('confreg/widgets/inline_photo_upload_widget.html', context)) |