From 5e0c95429923ba3c7cd99d4d8014c24be7c640b9 Mon Sep 17 00:00:00 2001 From: J-E Castagnede Date: Wed, 13 Mar 2024 10:57:59 +0100 Subject: [PATCH] move report endpoint --- geotrek/api/tests/test_v2.py | 73 ++++- geotrek/api/v2/serializers.py | 34 +- geotrek/api/v2/views/feedback.py | 83 +++++ geotrek/feedback/helpers.py | 7 - .../management/commands/sync_suricate.py | 10 +- geotrek/feedback/serializers.py | 31 -- geotrek/feedback/tests/test_views.py | 308 +----------------- geotrek/feedback/urls.py | 11 +- geotrek/feedback/views.py | 125 +------ 9 files changed, 211 insertions(+), 471 deletions(-) diff --git a/geotrek/api/tests/test_v2.py b/geotrek/api/tests/test_v2.py index 3a13f8f018..755032d557 100644 --- a/geotrek/api/tests/test_v2.py +++ b/geotrek/api/tests/test_v2.py @@ -1,6 +1,6 @@ import datetime import json -from unittest import skipIf +from unittest import skipIf, mock from dateutil.relativedelta import relativedelta from django.conf import settings @@ -15,7 +15,8 @@ from django.utils import timezone from freezegun.api import freeze_time from mapentity.tests.factories import SuperUserFactory -from rest_framework.test import APITestCase +from paperclip.models import random_suffix_regexp +from rest_framework.test import APITestCase, APIClient from geotrek import __version__ from geotrek.api.v2.views.trekking import TrekViewSet @@ -25,10 +26,11 @@ from geotrek.common.tests import factories as common_factory from geotrek.common.utils.testdata import (get_dummy_uploaded_document, get_dummy_uploaded_file, - get_dummy_uploaded_image) + get_dummy_uploaded_image, get_dummy_uploaded_image_svg) from geotrek.core import models as path_models from geotrek.core.tests import factories as core_factory -from geotrek.feedback.tests import factories as feedback_factory +from geotrek.feedback import models as feedback_models +from geotrek.feedback.tests import factories as feedback_factory, factories as feedback_factories from geotrek.flatpages.tests import factories as flatpages_factory from geotrek.infrastructure import models as infrastructure_models from geotrek.infrastructure.tests import factories as infrastructure_factory @@ -4702,3 +4704,66 @@ def test_cache_invalidates_along_x_forwarded_proto_header(self): response = self.client.get(reverse('apiv2:practice-detail', args=(self.practice.pk,))) data = response.json() self.assertTrue(data['pictogram'].startswith('http://')) + + +class CreateReportsAPITest(TestCase): + @classmethod + def setUpTestData(cls): + cls.add_url = '/api/en/reports/report' + cls.data = { + 'geom': '{"type": "Point", "coordinates": [3, 46.5]}', + 'email': 'yeah@you.com', + 'activity': feedback_factories.ReportActivityFactory.create().pk, + 'problem_magnitude': feedback_factories.ReportProblemMagnitudeFactory.create().pk + } + + def post_report_data(self, data): + client = APIClient() + response = client.post(self.add_url, data=data, + allow_redirects=False) + self.assertEqual(response.status_code, 201, self.add_url) + return response + + def test_reports_can_be_created_using_post(self): + self.post_report_data(self.data) + self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) + report = feedback_models.Report.objects.get() + self.assertAlmostEqual(report.geom.x, 700000) + self.assertAlmostEqual(report.geom.y, 6600000) + + def test_reports_can_be_created_without_geom(self): + self.data.pop('geom') + self.post_report_data(self.data) + self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) + + def test_reports_with_file(self): + self.data['image'] = get_dummy_uploaded_image() + self.post_report_data(self.data) + self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) + report = feedback_models.Report.objects.get() + self.assertEqual(report.attachments.count(), 1) + regexp = f"dummy_img{random_suffix_regexp()}.jpeg" + self.assertRegex(report.attachments.first().attachment_file.name, regexp) + self.assertTrue(report.attachments.first().is_image) + + @mock.patch('geotrek.api.v2.views.feedback.logger') + def test_reports_with_failed_image(self, mock_logger): + self.data['image'] = get_dummy_uploaded_image_svg() + self.data['comment'] = "We have a problem" + new_report_id = self.post_report_data(self.data).data.get('id') + self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) + report = feedback_models.Report.objects.get(pk=new_report_id) + self.assertEqual(report.comment, "We have a problem") + mock_logger.error.assert_called_with(f"Failed to convert attachment dummy_img.svg for report {new_report_id}: cannot identify image file ") + self.assertEqual(report.attachments.count(), 0) + + @mock.patch('geotrek.api.v2.views.feedback.logger') + def test_reports_with_bad_file_format(self, mock_logger): + self.data['image'] = get_dummy_uploaded_document() + self.data['comment'] = "We have a problem" + new_report_id = self.post_report_data(self.data).data.get('id') + self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) + report = feedback_models.Report.objects.get(pk=new_report_id) + self.assertEqual(report.comment, "We have a problem") + mock_logger.error.assert_called_with(f"Invalid attachment dummy_file.odt for report {new_report_id} : {{\'attachment_file\': ['File mime type “text/plain” is not allowed for “odt”.']}}") + self.assertEqual(report.attachments.count(), 0) diff --git a/geotrek/api/v2/serializers.py b/geotrek/api/v2/serializers.py index 036e184823..29b032a6fd 100644 --- a/geotrek/api/v2/serializers.py +++ b/geotrek/api/v2/serializers.py @@ -3,9 +3,10 @@ from bs4 import BeautifulSoup from django.conf import settings from django.contrib.gis.db.models.functions import Transform -from django.contrib.gis.geos import MultiLineString, Point +from django.contrib.gis.geos import MultiLineString, Point, GEOSGeometry from django.db.models import F from django.urls import reverse +from django.utils.html import escape from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ from drf_dynamic_fields import DynamicFieldsMixin @@ -15,9 +16,11 @@ from easy_thumbnails.files import get_thumbnailer from modeltranslation.utils import build_localized_fieldname from PIL.Image import DecompressionBombError -from rest_framework import serializers +from rest_framework import serializers, serializers as rest_serializers from rest_framework.relations import HyperlinkedIdentityField from rest_framework_gis import serializers as geo_serializers +from rest_framework_gis.fields import GeometryField +from rest_framework_gis.serializers import GeoFeatureModelSerializer from geotrek.api.v2.functions import Length3D from geotrek.api.v2.mixins import PDFSerializerMixin, PublishedRelatedObjectsSerializerMixin @@ -1522,3 +1525,30 @@ class BladeTypeSerializer(DynamicFieldsMixin, serializers.ModelSerializer): class Meta: model = signage_models.BladeType fields = ('id', 'label', 'structure') + + +class ReportAPISerializer(rest_serializers.ModelSerializer): + class Meta: + model = feedback_models.Report + id_field = 'id' + fields = ('id', 'email', 'activity', 'comment', 'category', + 'status', 'problem_magnitude', 'related_trek', + 'geom') + extra_kwargs = { + 'geom': {'write_only': True}, + } + + def validate_geom(self, value): + return GEOSGeometry(value, srid=4326) + + def validate_comment(self, value): + return escape(value) + + +class ReportAPIGeojsonSerializer(GeoFeatureModelSerializer, ReportAPISerializer): + # Annotated geom field with API_SRID + api_geom = GeometryField(read_only=True, precision=7) + + class Meta(ReportAPISerializer.Meta): + geo_field = 'api_geom' + fields = ReportAPISerializer.Meta.fields + ('api_geom', ) diff --git a/geotrek/api/v2/views/feedback.py b/geotrek/api/v2/views/feedback.py index fbda51dafe..f577d0317c 100644 --- a/geotrek/api/v2/views/feedback.py +++ b/geotrek/api/v2/views/feedback.py @@ -1,6 +1,25 @@ +import logging +import os + +from PIL import Image +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.files import File +from django.core.mail import send_mail +from django.utils.translation import gettext as _ +from rest_framework.mixins import CreateModelMixin +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import GenericViewSet + from geotrek.api.v2 import serializers as api_serializers, viewsets as api_viewsets +from geotrek.common.models import Attachment, FileType from geotrek.feedback import models as feedback_models +logger = logging.getLogger(__name__) + class ReportStatusViewSet(api_viewsets.GeotrekViewSet): serializer_class = api_serializers.ReportStatusSerializer @@ -24,3 +43,67 @@ class ReportProblemMagnitudeViewSet(api_viewsets.GeotrekViewSet): serializer_class = api_serializers.ReportProblemMagnitudeSerializer queryset = feedback_models.ReportProblemMagnitude.objects \ .order_by('pk') # Required for reliable pagination + + +class ReportViewSet(GenericViewSet, + CreateModelMixin): + model = feedback_models.Report + parser_classes = [FormParser, MultiPartParser] + serializer_class = api_serializers.ReportAPISerializer + authentication_classes = [] + permission_classes = [AllowAny] + + def create(self, request, *args, **kwargs): + response = super().create(request) + creator, created = get_user_model().objects.get_or_create( + username="feedback", defaults={"is_active": False} + ) + for file in request._request.FILES.values(): + attachment = Attachment( + filetype=FileType.objects.get_or_create(type=settings.REPORT_FILETYPE)[ + 0 + ], + content_type=ContentType.objects.get_for_model(feedback_models.Report), + object_id=response.data.get("id"), + creator=creator, + attachment_file=file, + ) + name, extension = os.path.splitext(file.name) + try: + attachment.full_clean() # Check that file extension and mimetypes are allowed + except ValidationError as e: + logger.error(f"Invalid attachment {name}{extension} for report {response.data.get('id')} : " + str(e)) + else: + try: + # Reencode file to bitmap then back to jpeg lfor safety + if not os.path.exists(f"{settings.TMP_DIR}/report_file/"): + os.mkdir(f"{settings.TMP_DIR}/report_file/") + tmp_bmp_path = os.path.join(f"{settings.TMP_DIR}/report_file/", f"{name}.bmp") + tmp_jpeg_path = os.path.join(f"{settings.TMP_DIR}/report_file/", f"{name}.jpeg") + Image.open(file).save(tmp_bmp_path) + Image.open(tmp_bmp_path).save(tmp_jpeg_path) + with open(tmp_jpeg_path, 'rb') as converted_file: + attachment.attachment_file = File(converted_file, name=f"{name}.jpeg") + attachment.save() + os.remove(tmp_bmp_path) + os.remove(tmp_jpeg_path) + except Exception as e: + logger.error(f"Failed to convert attachment {name}{extension} for report {response.data.get('id')}: " + str(e)) + + if settings.SEND_REPORT_ACK and response.status_code == 201: + send_mail( + _("Geotrek : Signal a mistake"), + _( + """Hello, + +We acknowledge receipt of your feedback, thank you for your interest in Geotrek. + +Best regards, + +The Geotrek Team +http://www.geotrek.fr""" + ), + settings.DEFAULT_FROM_EMAIL, + [request.data.get("email")], + ) + return response diff --git a/geotrek/feedback/helpers.py b/geotrek/feedback/helpers.py index d149872283..5e20d0e2d5 100644 --- a/geotrek/feedback/helpers.py +++ b/geotrek/feedback/helpers.py @@ -185,13 +185,6 @@ def __init__(self, pending_requests_model=None): self.AUTH = settings.SURICATE_MANAGEMENT_SETTINGS["AUTH"] if self.USE_AUTH else None -def test_suricate_connection(): - print("API Standard :") - SuricateStandardRequestManager().test_suricate_connection() - print("API Gestion :") - SuricateGestionRequestManager().test_suricate_connection() - - class SuricateMessenger: def __init__(self, pending_requests_model=None): diff --git a/geotrek/feedback/management/commands/sync_suricate.py b/geotrek/feedback/management/commands/sync_suricate.py index 0db96b5bae..ec556e100e 100644 --- a/geotrek/feedback/management/commands/sync_suricate.py +++ b/geotrek/feedback/management/commands/sync_suricate.py @@ -3,8 +3,8 @@ from django.conf import settings from django.core.management.base import BaseCommand +from geotrek.feedback.helpers import SuricateStandardRequestManager, SuricateGestionRequestManager from geotrek.feedback.parsers import SuricateParser -from geotrek.feedback.helpers import test_suricate_connection logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ def handle(self, *args, **options): report = options["report"] no_notification = options["no_notif"] if options['test']: - test_suricate_connection() + self.test_suricate_connection() elif report is not None: parser.get_alert(verbosity, report) else: @@ -69,3 +69,9 @@ def handle(self, *args, **options): parser.get_alerts(verbosity=verbosity, should_notify=not (no_notification)) else: logger.error("To use this command, please activate setting SURICATE_WORKFLOW_ENABLED.") + + def test_suricate_connection(self): + self.stdout.write("API Standard :") + SuricateStandardRequestManager().test_suricate_connection() + self.stdout.write("API Gestion :") + SuricateGestionRequestManager().test_suricate_connection() diff --git a/geotrek/feedback/serializers.py b/geotrek/feedback/serializers.py index f0c65e28dc..03534926ee 100644 --- a/geotrek/feedback/serializers.py +++ b/geotrek/feedback/serializers.py @@ -1,10 +1,6 @@ -from django.contrib.gis.geos import GEOSGeometry -from django.utils.html import escape from drf_dynamic_fields import DynamicFieldsMixin from mapentity.serializers import MapentityGeojsonModelSerializer from rest_framework import serializers as rest_serializers -from rest_framework_gis.fields import GeometryField -from rest_framework_gis.serializers import GeoFeatureModelSerializer from geotrek.feedback import models as feedback_models @@ -32,33 +28,6 @@ class Meta(MapentityGeojsonModelSerializer.Meta): fields = ["id", "name", "color"] -class ReportAPISerializer(rest_serializers.ModelSerializer): - class Meta: - model = feedback_models.Report - id_field = 'id' - fields = ('id', 'email', 'activity', 'comment', 'category', - 'status', 'problem_magnitude', 'related_trek', - 'geom') - extra_kwargs = { - 'geom': {'write_only': True}, - } - - def validate_geom(self, value): - return GEOSGeometry(value, srid=4326) - - def validate_comment(self, value): - return escape(value) - - -class ReportAPIGeojsonSerializer(GeoFeatureModelSerializer, ReportAPISerializer): - # Annotated geom field with API_SRID - api_geom = GeometryField(read_only=True, precision=7) - - class Meta(ReportAPISerializer.Meta): - geo_field = 'api_geom' - fields = ReportAPISerializer.Meta.fields + ('api_geom', ) - - class ReportActivitySerializer(rest_serializers.ModelSerializer): class Meta: model = feedback_models.ReportActivity diff --git a/geotrek/feedback/tests/test_views.py b/geotrek/feedback/tests/test_views.py index a691736298..06ceabecef 100644 --- a/geotrek/feedback/tests/test_views.py +++ b/geotrek/feedback/tests/test_views.py @@ -1,6 +1,5 @@ import csv import json -from datetime import datetime from io import StringIO from unittest import mock @@ -11,29 +10,23 @@ from django.test import TestCase from django.test.utils import override_settings from django.utils import translation -from django.utils.module_loading import import_string -from django.utils.translation import gettext_lazy as _ -from freezegun import freeze_time from mapentity.tests.factories import SuperUserFactory, UserFactory -from paperclip.models import random_suffix_regexp from rest_framework.reverse import reverse -from rest_framework.test import APIClient from geotrek.authent.tests.base import AuthentFixturesMixin -from geotrek.common.tests import (CommonTest, GeotrekAPITestCase, - TranslationResetMixin) -from geotrek.common.utils.testdata import (get_dummy_uploaded_document, - get_dummy_uploaded_image, - get_dummy_uploaded_image_svg) from geotrek.feedback import models as feedback_models from geotrek.maintenance.tests.factories import ( - InfrastructureInterventionFactory, ReportInterventionFactory) + InfrastructureInterventionFactory, + ReportInterventionFactory, +) from . import factories as feedback_factories -from .test_suricate_sync import (SURICATE_REPORT_SETTINGS, - test_for_all_suricate_modes, - test_for_report_and_basic_modes, - test_for_workflow_mode) +from .test_suricate_sync import ( + SURICATE_REPORT_SETTINGS, + test_for_all_suricate_modes, + test_for_report_and_basic_modes, + test_for_workflow_mode, +) class ReportViewsetMailSend(TestCase): @@ -46,7 +39,7 @@ def test_mail_send_on_request(self, mocked_post): mock_response.content = json.dumps({"code_ok": 'true'}).encode() mock_response.status_code = 200 mocked_post.return_value = mock_response - self.client.post( + response = self.client.post( '/api/en/reports/report', { 'geom': '{\"type\":\"Point\",\"coordinates\":[4.3728446995373815,43.856935212211454]}', @@ -55,10 +48,10 @@ def test_mail_send_on_request(self, mocked_post): 'activity': feedback_factories.ReportActivityFactory.create().pk, 'problem_magnitude': feedback_factories.ReportProblemMagnitudeFactory.create().pk, }) - self.assertEqual(len(mail.outbox), 2) - self.assertEqual(mail.outbox[1].subject, "Geotrek : Signal a mistake") - self.assertIn("We acknowledge receipt of your feedback", mail.outbox[1].body) - self.assertEqual(mail.outbox[1].from_email, settings.DEFAULT_FROM_EMAIL) + self.assertEqual(response.status_code, 201) + self.assertEqual(mail.outbox[-1].subject, "Geotrek : Signal a mistake") + self.assertIn("We acknowledge receipt of your feedback", mail.outbox[-1].body) + self.assertEqual(mail.outbox[-1].from_email, settings.DEFAULT_FROM_EMAIL) created_report = feedback_models.Report.objects.filter(email="test_post@geotrek.local").first() self.assertEqual(created_report.comment, "Test comment <>") @@ -114,279 +107,6 @@ def test_report_layer_cache(self): self.filed_report = feedback_factories.ReportFactory(status=self.filed_status) -class ReportViewsTest(GeotrekAPITestCase, CommonTest): - model = feedback_models.Report - modelfactory = feedback_factories.ReportFactory - userfactory = SuperUserFactory - expected_json_geom = { - 'type': 'Point', - 'coordinates': [3.0, 46.5], - } - extra_column_list = ['comment', 'advice'] - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - feedback_factories.WorkflowManagerFactory(user=UserFactory()) - - def get_expected_geojson_geom(self): - return self.expected_json_geom - - def get_expected_geojson_attrs(self): - return { - 'id': self.obj.pk, - 'name': self.obj.name - } - - def get_expected_json_attrs(self): - return { - 'activity': self.obj.activity.pk, - 'category': self.obj.category.pk, - 'comment': self.obj.comment, - 'related_trek': None, - 'email': self.obj.email, - 'status': self.obj.status_id, - 'problem_magnitude': self.obj.problem_magnitude.pk - } - - def get_expected_datatables_attrs(self): - return { - 'activity': self.obj.activity.label, - 'category': self.obj.category.label, - 'date_update': '17/03/2020 00:00:00', - 'id': self.obj.pk, - 'status': str(self.obj.status), - 'eid': f'Report {self.obj.pk}' - } - - def get_bad_data(self): - return {'geom': 'FOO'}, _('Invalid geometry value.') - - def get_good_data(self): - return { - 'geom': '{"type": "Point", "coordinates": [0, 0]}', - 'email': 'yeah@you.com', - 'activity': feedback_factories.ReportActivityFactory.create().pk, - 'problem_magnitude': feedback_factories.ReportProblemMagnitudeFactory.create().pk, - } - - def test_good_data_with_name(self): - """Test report created if `name` in data""" - data = self.get_good_data() - data['name'] = 'Anonymous' - response = self.client.post(self._get_add_url(), data) - self.assertEqual(response.status_code, 302) - obj = self.model.objects.last() - self.assertEqual(obj.email, data['email']) - self.logout() - - def test_crud_status(self): - if self.model is None: - return # Abstract test should not run - - obj = self.modelfactory() - - response = self.client.get(obj.get_list_url()) - self.assertEqual(response.status_code, 200) - - response = self.client.get(obj.get_detail_url().replace(str(obj.pk), '1234567890')) - self.assertEqual(response.status_code, 404) - - response = self.client.get(obj.get_detail_url()) - self.assertEqual(response.status_code, 200) - - response = self.client.get(obj.get_update_url()) - self.assertEqual(response.status_code, 200) - self._post_update_form(obj) - self._check_update_geom_permission(response) - - response = self.client.get(obj.get_delete_url()) - self.assertEqual(response.status_code, 200) - - url = obj.get_detail_url() - obj.delete() - response = self.client.get(url) - # No delete mixin - self.assertEqual(response.status_code, 200) - - self._post_add_form() - - # Test to update without login - self.logout() - - obj = self.modelfactory() - - response = self.client.get(self.model.get_add_url()) - self.assertEqual(response.status_code, 302) - response = self.client.get(obj.get_update_url()) - self.assertEqual(response.status_code, 302) - - @test_for_all_suricate_modes - def test_custom_columns_mixin_on_list(self): - # Assert columns equal mandatory columns plus custom extra columns - if self.model is None: - return - with override_settings(COLUMNS_LISTS={'feedback_view': self.extra_column_list}): - self.assertEqual(import_string(f'geotrek.{self.model._meta.app_label}.views.{self.model.__name__}List')().columns, - ['id', 'eid', 'activity', 'comment', 'advice']) - - @test_for_all_suricate_modes - def test_custom_columns_mixin_on_export(self): - # Assert columns equal mandatory columns plus custom extra columns - if self.model is None: - return - with override_settings(COLUMNS_LISTS={'feedback_export': self.extra_column_list}): - self.assertEqual(import_string(f'geotrek.{self.model._meta.app_label}.views.{self.model.__name__}FormatList')().columns, - ['id', 'email', 'comment', 'advice']) - - @freeze_time("2020-03-17") - def test_api_datatables_list_for_model_in_suricate_mode(self): - self.report = feedback_factories.ReportFactory() - with override_settings(SURICATE_WORKFLOW_ENABLED=True): - list_url = '/api/{modelname}/drf/{modelname}s.datatables'.format(modelname=self.model._meta.model_name) - response = self.client.get(list_url) - self.assertEqual(response.status_code, 200, f"{list_url} not found") - content_json = response.json() - datatable_attrs = { - 'activity': self.report.activity.label, - 'category': self.report.category.label, - 'date_update': '17/03/2020 00:00:00', - 'id': self.report.pk, - 'status': str(self.report.status), - 'eid': f'Report {self.report.eid}' - } - self.assertEqual(content_json, {'data': [datatable_attrs], - 'draw': 1, - 'recordsFiltered': 1, - 'recordsTotal': 1}) - - -class BaseAPITest(TestCase): - @classmethod - def setUpTestData(cls): - cls.user = UserFactory(password='booh') - perm = Permission.objects.get_by_natural_key('add_report', 'feedback', 'report') - cls.user.user_permissions.add(perm) - - cls.login_url = '/login/' - - -class CreateReportsAPITest(BaseAPITest): - @classmethod - def setUpTestData(cls): - cls.add_url = '/api/en/reports/report' - cls.data = { - 'geom': '{"type": "Point", "coordinates": [3, 46.5]}', - 'email': 'yeah@you.com', - 'activity': feedback_factories.ReportActivityFactory.create().pk, - 'problem_magnitude': feedback_factories.ReportProblemMagnitudeFactory.create().pk - } - - def post_report_data(self, data): - client = APIClient() - response = client.post(self.add_url, data=data, - allow_redirects=False) - self.assertEqual(response.status_code, 201, self.add_url) - return response - - def test_reports_can_be_created_using_post(self): - self.post_report_data(self.data) - self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) - report = feedback_models.Report.objects.get() - self.assertAlmostEqual(report.geom.x, 700000) - self.assertAlmostEqual(report.geom.y, 6600000) - - def test_reports_can_be_created_without_geom(self): - self.data.pop('geom') - self.post_report_data(self.data) - self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) - - def test_reports_with_file(self): - self.data['image'] = get_dummy_uploaded_image() - self.post_report_data(self.data) - self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) - report = feedback_models.Report.objects.get() - self.assertEqual(report.attachments.count(), 1) - regexp = f"dummy_img{random_suffix_regexp()}.jpeg" - self.assertRegex(report.attachments.first().attachment_file.name, regexp) - self.assertTrue(report.attachments.first().is_image) - - @mock.patch('geotrek.feedback.views.logger') - def test_reports_with_failed_image(self, mock_logger): - self.data['image'] = get_dummy_uploaded_image_svg() - self.data['comment'] = "We have a problem" - new_report_id = self.post_report_data(self.data).data.get('id') - self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) - report = feedback_models.Report.objects.get(pk=new_report_id) - self.assertEqual(report.comment, "We have a problem") - mock_logger.error.assert_called_with(f"Failed to convert attachment dummy_img.svg for report {new_report_id}: cannot identify image file ") - self.assertEqual(report.attachments.count(), 0) - - @mock.patch('geotrek.feedback.views.logger') - def test_reports_with_bad_file_format(self, mock_logger): - self.data['image'] = get_dummy_uploaded_document() - self.data['comment'] = "We have a problem" - new_report_id = self.post_report_data(self.data).data.get('id') - self.assertTrue(feedback_models.Report.objects.filter(email='yeah@you.com').exists()) - report = feedback_models.Report.objects.get(pk=new_report_id) - self.assertEqual(report.comment, "We have a problem") - mock_logger.error.assert_called_with(f"Invalid attachment dummy_file.odt for report {new_report_id} : {{\'attachment_file\': ['File mime type “text/plain” is not allowed for “odt”.']}}") - self.assertEqual(report.attachments.count(), 0) - - -class ListCategoriesTest(TranslationResetMixin, BaseAPITest): - @classmethod - def setUpTestData(cls): - cls.cat = feedback_factories.ReportCategoryFactory(label_it='Obstaculo') - - def test_categories_can_be_obtained_as_json(self): - response = self.client.get('/api/en/feedback/categories.json') - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data[0]['id'], self.cat.id) - self.assertEqual(data[0]['label'], self.cat.label) - - def test_categories_are_translated(self): - response = self.client.get('/api/it/feedback/categories.json') - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data[0]['label'], self.cat.label_it) - - -class ListOptionsTest(TranslationResetMixin, BaseAPITest): - @classmethod - def setUpTestData(cls): - cls.activity = feedback_factories.ReportActivityFactory(label_it='Hiking') - cls.cat = feedback_factories.ReportCategoryFactory(label_it='Obstaculo') - cls.pb_magnitude = feedback_factories.ReportProblemMagnitudeFactory(label_it='Possible') - - def test_options_can_be_obtained_as_json(self): - response = self.client.get('/api/en/feedback/options.json') - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data['activities'][0]['id'], self.activity.id) - self.assertEqual(data['activities'][0]['label'], self.activity.label) - self.assertEqual(data['categories'][0]['id'], self.cat.id) - self.assertEqual(data['categories'][0]['label'], self.cat.label) - self.assertEqual(data['magnitudeProblems'][0]['id'], self.pb_magnitude.id) - self.assertEqual(data['magnitudeProblems'][0]['label'], self.pb_magnitude.label) - - def test_options_are_translated(self): - response = self.client.get('/api/it/feedback/options.json') - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data['activities'][0]['label'], self.activity.label_it) - self.assertEqual(data['categories'][0]['label'], self.cat.label_it) - self.assertEqual(data['magnitudeProblems'][0]['label'], self.pb_magnitude.label_it) - - def test_display_dates(self): - date_time_1 = datetime.strptime("24/03/21 20:51", '%d/%m/%y %H:%M') - date_time_2 = datetime.strptime("28/03/21 5:51", '%d/%m/%y %H:%M') - r = feedback_factories.ReportFactory(created_in_suricate=date_time_1, last_updated_in_suricate=date_time_2) - self.assertEqual("03/24/2021 8:51 p.m.", r.created_in_suricate_display) - self.assertEqual("03/28/2021 5:51 a.m.", r.last_updated_in_suricate_display) - - class SuricateViewPermissions(AuthentFixturesMixin, TestCase): @classmethod def setUpTestData(cls): diff --git a/geotrek/feedback/urls.py b/geotrek/feedback/urls.py index 4a9edc087f..c6a71cd694 100644 --- a/geotrek/feedback/urls.py +++ b/geotrek/feedback/urls.py @@ -1,22 +1,19 @@ from django.conf import settings from django.urls import path, register_converter from mapentity.registry import registry -from rest_framework.routers import DefaultRouter from geotrek.common.urls import LangConverter from geotrek.feedback import models as feedback_models -from .views import CategoryList, FeedbackOptionsView, ReportAPIViewSet +from geotrek.api.v2.views.feedback import ReportViewSet register_converter(LangConverter, 'lang') app_name = 'feedback' urlpatterns = [ - path('api//feedback/categories.json', CategoryList.as_view(), name="categories_json"), - path('api//feedback/options.json', FeedbackOptionsView.as_view(), name="options_json"), + # backward compatible report endpoint + path('api//reports/report', ReportViewSet.as_view({"post": "create"}), name="report"), ] -router = DefaultRouter(trailing_slash=False) -router.register(r'^api/(?P[a-z]{2}(-[a-z]{2,4})?)/reports', ReportAPIViewSet, basename='report') -urlpatterns += router.urls + urlpatterns += registry.register(feedback_models.Report, menu=settings.REPORT_MODEL_ENABLED) diff --git a/geotrek/feedback/views.py b/geotrek/feedback/views.py index dda9a2ad81..1bca61dbb1 100644 --- a/geotrek/feedback/views.py +++ b/geotrek/feedback/views.py @@ -1,32 +1,17 @@ -import os - from crispy_forms.helper import FormHelper from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.contrib.gis.db.models.functions import Transform -from django.core.exceptions import ValidationError -from django.core.files import File -from django.core.mail import send_mail from django.db.models import CharField, F, Value from django.db.models.functions import Concat from django.urls.base import reverse from django.utils.translation import get_language from django.utils.translation import gettext as _ -from django.views.generic.list import ListView from mapentity import views as mapentity_views -from PIL import Image from rest_framework.authentication import (BasicAuthentication, SessionAuthentication) -from rest_framework.decorators import action -from rest_framework.parsers import FormParser, MultiPartParser -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated -from geotrek.common.mixins.api import APIViewSet from geotrek.common.mixins.views import CustomColumnsMixin -from geotrek.common.models import Attachment, FileType from geotrek.common.viewsets import GeotrekMapentityViewSet from . import models as feedback_models @@ -92,41 +77,6 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) -class CategoryList(mapentity_views.JSONResponseMixin, ListView): - model = feedback_models.ReportCategory - - def get_context_data(self, **kwargs): - return [{"id": c.id, "label": c.label} for c in self.object_list] - - -class FeedbackOptionsView(APIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, *args, **kwargs): - categories = feedback_models.ReportCategory.objects.all() - cat_serializer = feedback_serializers.ReportCategorySerializer( - categories, many=True - ) - activities = feedback_models.ReportActivity.objects.all() - activities_serializer = feedback_serializers.ReportActivitySerializer( - activities, many=True - ) - magnitude_problems = feedback_models.ReportProblemMagnitude.objects.all() - mag_serializer = feedback_serializers.ReportProblemMagnitudeSerializer( - magnitude_problems, many=True - ) - - options = { - "categories": cat_serializer.data, - "activities": activities_serializer.data, - "magnitudeProblems": mag_serializer.data, - } - - return Response(options) - - class ReportCreate(mapentity_views.MapEntityCreate): model = feedback_models.Report form_class = ReportForm @@ -182,76 +132,3 @@ def view_cache_key(self): self.request.user.pk if settings.SURICATE_WORKFLOW_ENABLED else '' ) return geojson_lookup - - -class ReportAPIViewSet(APIViewSet): - queryset = feedback_models.Report.objects.existing()\ - .select_related("activity", "category", "problem_magnitude", "status", "related_trek")\ - .prefetch_related("attachments") - parser_classes = [FormParser, MultiPartParser] - serializer_class = feedback_serializers.ReportAPISerializer - geojson_serializer_class = feedback_serializers.ReportAPIGeojsonSerializer - authentication_classes = [] - permission_classes = [AllowAny] - - def get_queryset(self): - queryset = super().get_queryset() - return queryset.select_related( - "activity", "category", "problem_magnitude", "status", "related_trek" - ) - - @action(detail=False, methods=["post"]) - def report(self, request, lang=None): - response = super().create(request) - creator, created = get_user_model().objects.get_or_create( - username="feedback", defaults={"is_active": False} - ) - for file in request._request.FILES.values(): - attachment = Attachment( - filetype=FileType.objects.get_or_create(type=settings.REPORT_FILETYPE)[ - 0 - ], - content_type=ContentType.objects.get_for_model(feedback_models.Report), - object_id=response.data.get("id"), - creator=creator, - attachment_file=file, - ) - name, extension = os.path.splitext(file.name) - try: - attachment.full_clean() # Check that file extension and mimetypes are allowed - except ValidationError as e: - logger.error(f"Invalid attachment {name}{extension} for report {response.data.get('id')} : " + str(e)) - else: - try: - # Reencode file to bitmap then back to jpeg lfor safety - if not os.path.exists(f"{settings.TMP_DIR}/report_file/"): - os.mkdir(f"{settings.TMP_DIR}/report_file/") - tmp_bmp_path = os.path.join(f"{settings.TMP_DIR}/report_file/", f"{name}.bmp") - tmp_jpeg_path = os.path.join(f"{settings.TMP_DIR}/report_file/", f"{name}.jpeg") - Image.open(file).save(tmp_bmp_path) - Image.open(tmp_bmp_path).save(tmp_jpeg_path) - with open(tmp_jpeg_path, 'rb') as converted_file: - attachment.attachment_file = File(converted_file, name=f"{name}.jpeg") - attachment.save() - os.remove(tmp_bmp_path) - os.remove(tmp_jpeg_path) - except Exception as e: - logger.error(f"Failed to convert attachment {name}{extension} for report {response.data.get('id')}: " + str(e)) - - if settings.SEND_REPORT_ACK and response.status_code == 201: - send_mail( - _("Geotrek : Signal a mistake"), - _( - """Hello, - -We acknowledge receipt of your feedback, thank you for your interest in Geotrek. - -Best regards, - -The Geotrek Team -http://www.geotrek.fr""" - ), - settings.DEFAULT_FROM_EMAIL, - [request.data.get("email")], - ) - return response