Repository: liqd/a4-meinberlin

Issue:
Bplan: Image size restrictions are not checked when imported with the api
The project form limits image to a minimum size. Those restrictions are not checked when importing images with the api.
If a smaller image is already imported, the project can not be edited in the dashboard.


Discussion:

Resolving patch:
diff --git a/apps/bplan/serializers.py b/apps/bplan/serializers.py
--- a/apps/bplan/serializers.py
+++ b/apps/bplan/serializers.py
@@ -1,18 +1,22 @@
-import os
-
+import datetime
+import posixpath
 from urllib import request
 from urllib.parse import urlparse
 
 from django.apps import apps
 from django.conf import settings
 from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError
+from django.core.files.images import ImageFile
 from django.core.urlresolvers import reverse
 from django.utils.translation import ugettext as _
 
 from rest_framework import serializers
 
+from adhocracy4.images.validators import validate_image
 from adhocracy4.modules import models as module_models
 from adhocracy4.phases import models as phase_models
+from adhocracy4.projects import models as project_models
 
 from .models import Bplan
 from .phases import StatementPhase
@@ -21,8 +25,6 @@
 BPLAN_EMBED = '<iframe height="500" style="width: 100%; min-height: 300px; ' \
               'max-height: 100vh" src="{}" frameborder="0"></iframe>'
 
-PROJECT_IMAGE_DIR = 'projects/backgrounds/'
-
 
 class BplanSerializer(serializers.ModelSerializer):
     id = serializers.IntegerField(required=False)
@@ -59,12 +61,8 @@ def create(self, validated_data):
 
         image_url = validated_data.pop('image_url', None)
         if image_url:
-            try:
-                validated_data['image'] = \
-                    self._download_image_from_url(image_url)
-            except:
-                raise serializers.ValidationError(
-                    'Failed to download image {}'.format(image_url))
+            validated_data['image'] = \
+                self._download_image_from_url(image_url)
 
         bplan = super().create(validated_data)
         self._create_module_and_phase(bplan, start_date, end_date)
@@ -87,6 +85,28 @@ def _create_module_and_phase(self, bplan, start_date, end_date):
             end_date=end_date
         )
 
+    def update(self, instance, validated_data):
+        start_date = validated_data.pop('start_date', None)
+        end_date = validated_data.pop('end_date', None)
+        if start_date or end_date:
+            self._update_phase(instance, start_date, end_date)
+
+        image_url = validated_data.pop('image_url', None)
+        if image_url:
+            validated_data['image'] = \
+                self._download_image_from_url(image_url)
+
+        return super().update(instance, validated_data)
+
+    def _update_phase(self, bplan, start_date, end_date):
+        module = module_models.Module.objects.get(project=bplan)
+        phase = phase_models.Phase.objects.get(module=module)
+        if start_date:
+            phase.start_date = start_date
+        if end_date:
+            phase.end_date = end_date
+        phase.save()
+
     def to_representation(self, instance):
         dict = super().to_representation(instance)
         dict['embed_code'] = self._response_embed_code(instance)
@@ -105,11 +125,43 @@ def _get_absolute_url(self, bplan):
 
     def _download_image_from_url(self, url):
         parsed_url = urlparse(url)
-        file_path = os.path.join(PROJECT_IMAGE_DIR,
-                                 os.path.basename(parsed_url.path))
-        file_name = os.path.join(settings.MEDIA_ROOT, file_path)
-        file_dir = os.path.dirname(file_name)
-        os.makedirs(file_dir, exist_ok=True)
-
-        request.urlretrieve(url, file_name)
-        return file_path
+        file_name = self._generate_image_filename(
+            posixpath.basename(parsed_url.path))
+        try:
+            with request.urlopen(url) as f:
+                file_name = self._image_storage.save(file_name, f)
+        except Exception as e:
+            self._image_storage.delete(file_name)
+            raise serializers.ValidationError(
+                'Failed to download image {}'.format(url))
+
+        try:
+            self._validate_image(file_name)
+        except ValidationError as e:
+            self._image_storage.delete(file_name)
+            raise serializers.ValidationError(e)
+
+        return file_name
+
+    def _validate_image(self, file_name):
+        image_file = self._image_storage.open(file_name, 'rb')
+        image = ImageFile(image_file, file_name)
+        config = settings.IMAGE_ALIASES.get('*', {})
+        config.update(settings.IMAGE_ALIASES['heroimage'])
+        validate_image(image, **config)
+
+    @property
+    def _image_storage(self):
+        return project_models.Project._meta.get_field('image').storage
+
+    @property
+    def _image_upload_to(self):
+        return project_models.Project._meta.get_field('image').upload_to
+
+    def _generate_image_filename(self, filename):
+        if callable(self._image_upload_to):
+            raise Exception(_('Callable upload_to fields are not supported'))
+
+        dirname = datetime.datetime.now().strftime(self._image_upload_to)
+        filename = posixpath.join(dirname, filename)
+        return self._image_storage.get_available_name(filename)


Test patch:
diff --git a/tests/bplan/test_bplan_api.py b/tests/bplan/test_bplan_api.py
--- a/tests/bplan/test_bplan_api.py
+++ b/tests/bplan/test_bplan_api.py
@@ -59,7 +59,9 @@ def test_initiator_add_bplan(apiclient, organisation):
 
 
 @pytest.mark.django_db
-def test_initiator_update_bplan(apiclient, bplan):
+def test_initiator_update_bplan(apiclient, bplan, phase):
+    phase.module.project = bplan
+    phase.module.save()
     url = reverse(
         'bplan-detail',
         kwargs={
@@ -85,8 +87,10 @@ def test_initiator_update_bplan(apiclient, bplan):
 
 
 @pytest.mark.django_db
-def test_initiator_update_bplan_field(apiclient, bplan_factory):
+def test_initiator_update_bplan_field(apiclient, bplan_factory, phase):
     bplan = bplan_factory(is_draft=False)
+    phase.module.project = bplan
+    phase.module.save()
     assert bplan.is_draft is False
     url = reverse(
         'bplan-detail',
@@ -106,6 +110,32 @@ def test_initiator_update_bplan_field(apiclient, bplan_factory):
     assert bplan.is_draft is True
 
 
+@pytest.mark.django_db
+def test_initiator_update_bplan_phase(apiclient, bplan_factory, phase):
+    bplan = bplan_factory(is_draft=False)
+    phase.module.project = bplan
+    phase.module.save()
+    assert bplan.is_draft is False
+    url = reverse(
+        'bplan-detail',
+        kwargs={
+            'organisation_pk': bplan.organisation.pk,
+            'pk': bplan.pk
+        }
+    )
+    data = {
+        "start_date": "2013-01-01 18:00",
+        "end_date": "2021-01-01 18:00",
+    }
+    user = bplan.organisation.initiators.first()
+    apiclient.force_authenticate(user=user)
+    response = apiclient.patch(url, data, format='json')
+    assert response.status_code == status.HTTP_200_OK
+    phase = phase_models.Phase.objects.get(module=phase.module)
+    assert phase.start_date == parse("2013-01-01 17:00:00 UTC")
+    assert phase.end_date == parse("2021-01-01 17:00:00 UTC")
+
+
 @pytest.mark.django_db
 def test_non_initiator_cannot_update_bplan(apiclient, bplan, user2):
     url = reverse(