Skip to content

Commit 17a023c

Browse files
authored
Merge pull request #320 from Mosquito-Alert/api_bugfix_post_images
Fix post images
2 parents 9156620 + fdb37f0 commit 17a023c

File tree

9 files changed

+209
-20
lines changed

9 files changed

+209
-20
lines changed

api/parsers.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import json
2+
from django.http import QueryDict
3+
from rest_framework import parsers
4+
5+
# NOTE: This class is needed to work with auto-generated OpenAPI SDKs.
6+
# It's important to mention that MultiParser from DRF needs from nested
7+
# dotted notation, e.g: location.point.latitude, location.point.longitude
8+
# See: https://b0uh.github.io/drf-how-to-handle-arrays-and-nested-objects-in-a-multipartform-data-request.html
9+
# But most OpenAPI SDKs (like openapi-generator) do not support that.
10+
# They only support nested JSON objects (encoded to string!), e.g:
11+
# location: '{"point": {"latitude": .., "longitude": ..} }'
12+
# This class converts those JSON strings into dotted notation keys.
13+
# If ever need to use bracket notation see: https://github.com/remigermain/nested-multipart-parser/
14+
class MultiPartJsonNestedParser(parsers.MultiPartParser):
15+
"""
16+
A custom multipart parser that extends MultiPartParser.
17+
18+
It parses nested JSON strings found in the value of form data fields
19+
and converts them into dotted notation keys in the QueryDict.
20+
"""
21+
def parse(self, stream, media_type=None, parser_context=None):
22+
"""
23+
Parses the multi-part request data and converts nested JSON to dotted notation.
24+
25+
Returns a tuple of (QueryDict, MultiValueDict).
26+
"""
27+
# Call the base parser to get the initial QueryDict (data) and MultiValueDict (files)
28+
result = super().parse(stream, media_type, parser_context)
29+
data = result.data
30+
files = result.files
31+
32+
# Create a mutable copy of the data QueryDict for modification
33+
mutable_data = data.copy()
34+
new_data = {}
35+
36+
# Iterate over all keys in the QueryDict
37+
for key, value_list in mutable_data.lists():
38+
# A value_list from QueryDict is always a list of strings
39+
40+
# 1. Attempt to parse the first value as JSON if it seems like a dictionary
41+
# We assume non-list values (like 'created_at') are single-element lists.
42+
# If the list has multiple elements, we treat the field as a list of non-JSON strings
43+
# and leave it alone (e.g., 'tags': ['tag1', 'tag2']).
44+
if len(value_list) == 1 and isinstance(value_list[0], str) and value_list[0].strip().startswith('{'):
45+
try:
46+
json_data = json.loads(value_list[0])
47+
# 2. Flatten the JSON dictionary into dotted notation
48+
flattened = self._flatten_dict(json_data, parent_key=key)
49+
# 3. Add the flattened data to our new_data dictionary
50+
new_data.update(flattened)
51+
52+
# Remove the original key as it's been expanded
53+
# This is implicitly done by building new_data, but for clarity:
54+
# mutable_data.pop(key)
55+
56+
except json.JSONDecodeError:
57+
# Not valid JSON, treat it as a regular string field
58+
new_data[key] = value_list
59+
60+
else:
61+
# Field is not a single JSON string, e.g., 'note': [''] or 'tags': ['tag1', 'tag2']
62+
# Keep the original data intact
63+
new_data[key] = value_list
64+
65+
# Convert the resulting dictionary back into a QueryDict
66+
# We need to construct it carefully as QueryDict expects lists of values
67+
final_data = QueryDict('', mutable=True)
68+
for k, v in new_data.items():
69+
# v will be either a list (from original data) or a single value (from flattened json)
70+
if isinstance(v, list):
71+
final_data.setlist(k, v)
72+
else:
73+
final_data[k] = v
74+
75+
return parsers.DataAndFiles(final_data, files)
76+
77+
def _flatten_dict(self, d, parent_key='', sep='.'):
78+
"""
79+
Recursively flattens a nested dictionary into a single-level dictionary
80+
with dotted keys.
81+
"""
82+
items = []
83+
for k, v in d.items():
84+
new_key = parent_key + sep + k if parent_key else k
85+
if isinstance(v, dict):
86+
# Recurse into nested dictionaries
87+
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
88+
elif isinstance(v, list):
89+
# Handle lists by keeping the key and setting the value as the list
90+
# This is a simplification; a more complex parser might flatten lists too.
91+
items.append((new_key, v))
92+
else:
93+
# Add simple key-value pair
94+
items.append((new_key, v))
95+
96+
# When converting back to QueryDict, simple values (not lists) should be
97+
# left as single values for the QueryDict to handle correctly.
98+
final_flat_dict = {}
99+
for k, v in items:
100+
# Important: QueryDict expects lists for multi-value fields.
101+
# If the value is a list (from the JSON), keep it as a list.
102+
if isinstance(v, list):
103+
final_flat_dict[k] = v
104+
else:
105+
# For single values (str, int, float, bool, None), QueryDict will
106+
# automatically wrap it in a list upon assignment.
107+
# However, for consistency with how QueryDict works in general, we
108+
# store the single value.
109+
final_flat_dict[k] = str(v) # Convert to string for form data
110+
111+
return final_flat_dict

api/serializers.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from uuid import UUID
55

66
from django.contrib.auth import get_user_model
7+
from django.contrib.gis.geos import Point
78
from django.db import transaction
89

910
from drf_spectacular.utils import extend_schema_field
@@ -533,11 +534,10 @@ class SimplePhotoSerializer(serializers.ModelSerializer):
533534
source="photo", use_url=True, read_only=True,
534535
help_text="URL of the photo associated with the item. Note: This URL may change over time. Do not rely on it for permanent storage."
535536
)
536-
file = serializers.ImageField(required=True, source="photo", write_only=True)
537537

538538
class Meta:
539539
model = Photo
540-
fields = ("uuid", "url", "file")
540+
fields = ("uuid", "url")
541541
read_only_fields = (
542542
"uuid",
543543
)
@@ -552,7 +552,23 @@ class AdmBoundarySerializer(serializers.Serializer):
552552
source = serializers.CharField(required=True, allow_null=False)
553553
level = serializers.IntegerField(required=True, min_value=0)
554554

555-
point = PointField(required=True)
555+
class PointSerializer(serializers.Serializer):
556+
latitude = WritableSerializerMethodField(
557+
field_class=serializers.FloatField,
558+
required=True,
559+
)
560+
longitude = WritableSerializerMethodField(
561+
field_class=serializers.FloatField,
562+
required=True,
563+
)
564+
565+
def get_latitude(self, obj: Point) -> float:
566+
return obj.y
567+
568+
def get_longitude(self, obj: Point) -> float:
569+
return obj.x
570+
571+
point = PointSerializer(required=True)
556572
timezone = TimeZoneSerializerChoiceField(read_only=True, allow_null=True)
557573
country = CountrySerializer(read_only=True, allow_null=True)
558574
adm_boundaries = AdmBoundarySerializer(many=True, read_only=True)
@@ -580,8 +596,8 @@ def to_internal_value(self, data):
580596
preffix = "selected"
581597

582598
point = ret.pop("point")
583-
ret[f"{preffix}_location_lat"] = point.y
584-
ret[f"{preffix}_location_lon"] = point.x
599+
ret[f"{preffix}_location_lat"] = point['latitude']
600+
ret[f"{preffix}_location_lon"] = point['longitude']
585601

586602
return ret
587603

@@ -736,19 +752,46 @@ class Meta:
736752
read_only_fields = fields
737753

738754
class BaseReportWithPhotosSerializer(BaseReportSerializer):
739-
photos = SimplePhotoSerializer(required=True, many=True)
755+
756+
def get_fields(self):
757+
fields = super().get_fields()
758+
request = self.context.get("request")
759+
760+
# Use different field behavior depending on request method
761+
if request and request.method in ("POST", "PUT", "PATCH"):
762+
# Write mode — accept uploaded image files
763+
fields["photos"] = serializers.ListField(
764+
child=serializers.ImageField(required=True),
765+
write_only=True,
766+
min_length=1,
767+
)
768+
else:
769+
# Read mode — return nested photo serializer
770+
fields["photos"] = SimplePhotoSerializer(many=True, read_only=True)
771+
772+
return fields
740773

741774
@transaction.atomic
742775
def create(self, validated_data):
743776
photos = validated_data.pop("photos", [])
744777

745778
instance = super().create(validated_data)
746779

780+
# NOTE: do not use bulk here.
747781
for photo in photos:
748-
_ = Photo.objects.create(report=instance, **photo)
782+
_ = Photo.objects.create(report=instance, photo=photo)
749783

750784
return instance
751785

786+
def to_representation(self, instance):
787+
"""
788+
Always serialize output using the read-only `photos` definition,
789+
even if this serializer was initialized in write mode.
790+
"""
791+
# Rebind `photos` temporarily for output
792+
self.fields["photos"] = SimplePhotoSerializer(many=True, read_only=True)
793+
return super().to_representation(instance)
794+
752795
class Meta(BaseReportSerializer.Meta):
753796
fields = BaseReportSerializer.Meta.fields + ("photos",)
754797

api/tests/integration/bites/create.tavern.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ stages:
4141
data: &request_bite_data
4242
created_at: '2024-01-01T00:00:00Z'
4343
sent_at: '2024-01-01T00:30:00Z'
44-
location.point: !raw '{"latitude": 41.67419, "longitude": 2.79036}'
44+
location.point.latitude: 41.67419
45+
location.point.longitude: 2.79036
4546
location.source: 'auto'
4647
note: "Test"
4748
tags:

api/tests/integration/breeding_sites/create.tavern.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ stages:
4242
Authorization: "Bearer {app_user_token:s}"
4343
method: "POST"
4444
files:
45-
photos[0]file: "{test_jpg_image_path}"
46-
photos[1]file: "{test_png_image_path}"
45+
photos[0]: "{test_jpg_image_path}"
46+
photos[1]: "{test_png_image_path}"
4747
data: &request_site_data
4848
created_at: '2024-01-01T00:00:00Z'
4949
sent_at: '2024-01-01T00:30:00Z'
50-
location.point: !raw '{"latitude": 41.67419, "longitude": 2.79036}'
50+
location.point.latitude: 41.67419
51+
location.point.longitude: 2.79036
5152
location.source: 'auto'
5253
note: "Test"
5354
tags:
@@ -112,6 +113,6 @@ stages:
112113
request:
113114
<<: *request_breeding_site
114115
files:
115-
photos[0]file: "{test_non_image_path}"
116+
photos[0]: "{test_non_image_path}"
116117
response:
117118
status_code: 400

api/tests/integration/observations/create.tavern.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ stages:
4242
Authorization: "Bearer {app_user_token:s}"
4343
method: "POST"
4444
files:
45-
photos[0]file: "{test_jpg_image_path}"
46-
photos[1]file: "{test_png_image_path}"
45+
photos[0]: "{test_jpg_image_path}"
46+
photos[1]: "{test_png_image_path}"
4747
data: &request_site_data
4848
created_at: '2024-01-01T00:00:00Z'
4949
sent_at: '2024-01-01T00:30:00Z'
50-
location.point: !raw '{"latitude": 41.67419, "longitude": 2.79036}'
50+
location.point.latitude: 41.67419
51+
location.point.longitude: 2.79036
5152
location.source: 'auto'
5253
note: "Test"
5354
tags:
@@ -114,6 +115,6 @@ stages:
114115
request:
115116
<<: *request_observation
116117
files:
117-
photos[0]file: "{test_non_image_path}"
118+
photos[0]: "{test_non_image_path}"
118119
response:
119120
status_code: 400

api/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
UpdateModelMixin,
2626
DestroyModelMixin,
2727
)
28-
from rest_framework.parsers import MultiPartParser, FormParser
28+
from rest_framework.parsers import FormParser
2929
from rest_framework.permissions import AllowAny, IsAuthenticated, SAFE_METHODS
3030
from rest_framework.response import Response
3131
from rest_framework.settings import api_settings
@@ -57,6 +57,7 @@
5757
TaxonFilter
5858
)
5959
from .mixins import IdentificationTaskNestedAttribute
60+
from .parsers import MultiPartJsonNestedParser
6061
from .serializers import (
6162
PartnerSerializer,
6263
CampaignSerializer,
@@ -346,7 +347,7 @@ def get_parsers(self):
346347
# Since photos are required on POST, only allow
347348
# parasers that allow files.
348349
if self.request and self.request.method == 'POST':
349-
return [MultiPartParser(), FormParser()]
350+
return [MultiPartJsonNestedParser(), FormParser()]
350351
return super().get_parsers()
351352

352353
class BiteViewSet(BaseReportViewSet):

api/viewsets.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from rest_framework.authentication import TokenAuthentication
22
from rest_framework.viewsets import GenericViewSet as DRFGenericViewSet
33
from rest_framework.pagination import PageNumberPagination
4-
from rest_framework.parsers import JSONParser, FormParser, MultiPartParser
4+
from rest_framework.parsers import JSONParser, FormParser
55
from rest_framework.renderers import JSONRenderer
66
from rest_framework_nested.viewsets import NestedViewSetMixin as OriginalNestedViewSetMixin, _force_mutable
77

88
from .auth.authentication import AppUserJWTAuthentication, NonAppUserSessionAuthentication
9+
from .parsers import MultiPartJsonNestedParser
910
from .permissions import UserObjectPermissions, IsMobileUser, DjangoRegularUserModelPermissions
1011

1112

@@ -31,7 +32,7 @@ def pagination_class(self):
3132
return self._pagination_class
3233

3334
permission_classes = (UserObjectPermissions,)
34-
parser_classes = (JSONParser, FormParser, MultiPartParser)
35+
parser_classes = (JSONParser, FormParser, MultiPartJsonNestedParser)
3536
renderer_classes = (JSONRenderer,)
3637

3738

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 3.2.25 on 2025-10-16 13:53
2+
3+
from django.db import migrations, models
4+
import tigaserver_app.models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('tigaserver_app', '0085_auto_20250822_1214'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='historicalreport',
16+
name='report_id',
17+
field=models.CharField(db_index=True, default=tigaserver_app.models.generate_report_id, help_text='4-digit alpha-numeric code generated on user phone to identify each unique report from that user. Digits should lbe randomly drawn from the set of all lowercase and uppercase alphabetic characters and 0-9, but excluding 0, o, and O to avoid confusion if we ever need user to be able to refer to a report ID in correspondence with MoveLab (as was previously the case when we had them sending samples).', max_length=4),
18+
),
19+
migrations.AlterField(
20+
model_name='report',
21+
name='report_id',
22+
field=models.CharField(db_index=True, default=tigaserver_app.models.generate_report_id, help_text='4-digit alpha-numeric code generated on user phone to identify each unique report from that user. Digits should lbe randomly drawn from the set of all lowercase and uppercase alphabetic characters and 0-9, but excluding 0, o, and O to avoid confusion if we ever need user to be able to refer to a report ID in correspondence with MoveLab (as was previously the case when we had them sending samples).', max_length=4),
23+
),
24+
]

tigaserver_app/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from PIL import Image
1212
import pydenticon
1313
import os
14+
import random
1415
from slugify import slugify
16+
import string
1517
from typing import List, Optional, Union
1618
import uuid
1719

@@ -781,6 +783,9 @@ class UUIDTaggedItem(GenericUUIDTaggedItemBase, TaggedItemBase):
781783
class Meta(GenericUUIDTaggedItemBase.Meta, TaggedItemBase.Meta):
782784
abstract = False
783785

786+
def generate_report_id():
787+
return ''.join(random.choices(string.ascii_letters + string.digits, k=4))
788+
784789
class Report(TimeZoneModelMixin, models.Model):
785790
TYPE_BITE = "bite"
786791
TYPE_ADULT = "adult"
@@ -849,6 +854,7 @@ class Report(TimeZoneModelMixin, models.Model):
849854
report_id = models.CharField(
850855
max_length=4,
851856
db_index=True,
857+
default=generate_report_id,
852858
help_text="4-digit alpha-numeric code generated on user phone to identify each unique report from that user. Digits should lbe randomly drawn from the set of all lowercase and uppercase alphabetic characters and 0-9, but excluding 0, o, and O to avoid confusion if we ever need user to be able to refer to a report ID in correspondence with MoveLab (as was previously the case when we had them sending samples).",
853859
)
854860

0 commit comments

Comments
 (0)