Skip to content

Commit

Permalink
Update ObservationController.upload() to return Photo/Sound objects
Browse files Browse the repository at this point in the history
  • Loading branch information
JWCook committed Dec 12, 2023
1 parent 03785a5 commit 44b1a2e
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 19 deletions.
25 changes: 17 additions & 8 deletions pyinaturalist/controllers/observation_controller.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
# TODO: param sections are so long, they really need to be in dropdowns
from typing import Callable, List, Optional
from typing import Callable, List, Optional, Union

from pyinaturalist.constants import (
API_V1,
V1_OBS_ORDER_BY_PROPERTIES,
HistogramResponse,
IntOrStr,
ListResponse,
MultiFile,
MultiInt,
MultiIntOrStr,
Expand All @@ -16,14 +14,16 @@
from pyinaturalist.docs import copy_doc_signature
from pyinaturalist.docs import templates as docs
from pyinaturalist.models import (
Annotation,
ControlledTermCounts,
LifeList,
Observation,
Photo,
Sound,
TaxonCounts,
TaxonSummary,
UserCounts,
)
from pyinaturalist.models.controlled_term import Annotation
from pyinaturalist.paginator import IDPaginator, IDRangePaginator, Paginator
from pyinaturalist.request_params import validate_multiple_choice_param
from pyinaturalist.v1 import (
Expand All @@ -50,6 +50,9 @@ class ObservationController(BaseController):
def __call__(self, observation_id: int, **params) -> Optional[Observation]:
"""Get a single observation by ID
Example:
>>> client.observations(16227955)
Args:
observation_ids: A single observation ID
"""
Expand Down Expand Up @@ -372,15 +375,14 @@ def update(self, **params) -> Observation:
response = update_observation(**params)
return Observation.from_json(response)

# TODO: Add model for sound files, return list of model objects
def upload(
self,
observation_id: int,
photos: Optional[MultiFile] = None,
sounds: Optional[MultiFile] = None,
photo_ids: Optional[MultiIntOrStr] = None,
**params,
) -> ListResponse:
) -> List[Union[Photo, Sound]]:
"""Upload one or more local photo and/or sound files, and add them to an existing observation.
You may also attach a previously uploaded photo by photo ID, e.g. if your photo contains
Expand Down Expand Up @@ -415,9 +417,9 @@ def upload(
access_token: Access token for user authentication, as returned by :func:`get_access_token()`
Returns:
Information about the uploaded file(s)
:py:class:`.Photo` or :py:class:`.Sound` objects for each uploaded file
"""
return self.client.request(
responses = self.client.request(
upload,
auth=True,
observation_id=observation_id,
Expand All @@ -426,6 +428,13 @@ def upload(
photo_ids=photo_ids,
**params,
)
response_objs: List[Union[Photo, Sound]] = []
for response in responses:
if 'photo' in response:
response_objs.append(Photo.from_json(response))
elif 'sound' in response:
response_objs.append(Sound.from_json(response))
return response_objs


class ObservationPaginator(IDRangePaginator):
Expand Down
13 changes: 11 additions & 2 deletions pyinaturalist/models/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
TableRow,
)
from pyinaturalist.converters import format_dimensions, format_license
from pyinaturalist.models import BaseModel, define_model, field
from pyinaturalist.models import BaseModel, datetime_field, define_model, field


@define_model
Expand All @@ -27,6 +27,8 @@ class BaseMedia(BaseModel):
options=ALL_LICENSES,
doc='Creative Commons license code',
)
created_at: str = datetime_field(doc='Date the file was added to iNaturalist')
updated_at: str = datetime_field(doc='Date the file was last updated on iNaturalist')

@property
def ext(self) -> str:
Expand Down Expand Up @@ -80,7 +82,7 @@ def __attrs_post_init__(self):
def from_json(cls, value: JsonResponse, **kwargs) -> 'Photo':
"""Flatten out potentially nested photo field before initializing"""
if 'photo' in value:
value = value['photo']
value.update(value.pop('photo'))
return super(Photo, cls).from_json(value, **kwargs)

@property
Expand Down Expand Up @@ -203,6 +205,13 @@ class Sound(BaseMedia):
# flags: List = field(factory=list)
# play_local: bool = field(default=None)

@classmethod
def from_json(cls, value: JsonResponse, **kwargs) -> 'Sound':
"""Flatten out potentially nested sound field before initializing"""
if 'sound' in value:
value.update(value.pop('sound'))
return super(Sound, cls).from_json(value, **kwargs)

# Aliases
@property
def mimetype(self) -> str:
Expand Down
14 changes: 11 additions & 3 deletions pyinaturalist/v1/observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ def upload(
Information about the uploaded file(s)
"""
params['raise_for_status'] = False
session = params.pop('session', None)
responses = []
photos, sounds = ensure_list(photos), ensure_list(sounds)
logger.info(f'Uploading {len(photos)} photos and {len(sounds)} sounds')
Expand All @@ -525,20 +526,27 @@ def upload(
photo_params = deepcopy(params)
photo_params['observation_photo[observation_id]'] = observation_id
for photo in photos:
response = post(f'{API_V1}/observation_photos', files=photo, **photo_params)
response = post(
f'{API_V1}/observation_photos', files=photo, session=session, **photo_params
)
responses.append(response)

# Upload sounds
sound_params = deepcopy(params)
sound_params['observation_sound[observation_id]'] = observation_id
for sound in sounds:
response = post(f'{API_V1}/observation_sounds', files=sound, **sound_params)
response = post(
f'{API_V1}/observation_sounds', files=sound, session=session, **sound_params
)
responses.append(response)

# Attach previously uploaded photos by ID
if photo_ids:
response = update_observation(
observation_id, photo_ids=photo_ids, access_token=params.get('access_token', None)
observation_id,
photo_ids=photo_ids,
session=session,
access_token=params.get('access_token', None),
)
responses.append(response)

Expand Down
42 changes: 37 additions & 5 deletions test/controllers/test_observation_controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ruff: noqa: F405
from datetime import datetime
from io import BytesIO
from unittest.mock import patch

from dateutil.tz import tzutc

Expand Down Expand Up @@ -281,6 +283,41 @@ def test_taxon_summary__with_listed_taxon(requests_mock):
assert 'western honey bee' in results.wikipedia_summary


@patch('pyinaturalist.client.get_access_token', return_value='token')
@patch('pyinaturalist.v1.observations.update_observation')
def test_upload(mock_update_observation, mock_get_access_token, requests_mock):
requests_mock.post(
f'{API_V1}/observation_photos',
json=SAMPLE_DATA['post_observation_photos'],
status_code=200,
)
requests_mock.post(
f'{API_V1}/observation_sounds',
json=SAMPLE_DATA['post_observation_sounds'],
status_code=200,
)

client = iNatClient()
client._access_token = 'token'
media_objs = client.observations.upload(
1234,
photos=BytesIO(),
sounds=BytesIO(),
photo_ids=[5678],
)
photo, sound = media_objs
assert photo.id == 1234
assert photo.observation_id == 1234
assert isinstance(photo.created_at, datetime)

assert sound.id == 239936
assert sound.file_content_type == 'audio/mpeg'
assert isinstance(sound.created_at, datetime)

# Attaching existing photos to the observation uses a separate endpoint
assert mock_update_observation.call_args[1]['photo_ids'] == [5678]


# TODO:
# def test_create():
# client = iNatClient()
Expand All @@ -290,8 +327,3 @@ def test_taxon_summary__with_listed_taxon(requests_mock):
# def test_delete():
# client = iNatClient()
# results = client.observations.delete()


# def test_upload():
# client = iNatClient()
# results = client.observations.upload()
2 changes: 2 additions & 0 deletions test/sample_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def load_all_sample_data() -> Dict[str, Dict]:
j_quality_metric_1 = j_observation_6_metrics['quality_metrics'][0]
j_photo_1 = j_taxon_1['taxon_photos'][0]['photo']
j_photo_2_partial = j_taxon_1['default_photo']
j_photo_3_nested = SAMPLE_DATA['upload_photos_and_sounds'][1]
j_place_1 = SAMPLE_DATA['get_places_by_id']['results'][1]
j_place_2 = SAMPLE_DATA['get_places_autocomplete']['results'][0]
j_places_nearby = SAMPLE_DATA['get_places_nearby']['results']
Expand All @@ -94,6 +95,7 @@ def load_all_sample_data() -> Dict[str, Dict]:
j_search_result_3_project = j_search_results[2]
j_search_result_4_user = j_search_results[3]
j_sound_1 = j_observation_4_sounds['sounds'][0]
j_sound_2_nested = SAMPLE_DATA['upload_photos_and_sounds'][1]
j_species_count_1 = SAMPLE_DATA['get_observation_species_counts']['results'][0]
j_species_count_2 = SAMPLE_DATA['get_observation_species_counts']['results'][1]
j_users = SAMPLE_DATA['get_users_autocomplete']['results']
Expand Down
8 changes: 8 additions & 0 deletions test/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,14 @@ def test_sound__aliases():
assert sound.mimetype == sound.file_content_type == 'audio/x-wav'


def test_sound__nested_record():
sound = Sound.from_json(j_sound_2_nested)
assert sound.uuid == '5c858ffa-696b-4bf2-beab-9f519901bd17'
assert sound.url.startswith('https://static.inaturalist.org/')
assert isinstance(sound.created_at, datetime)
assert isinstance(sound.updated_at, datetime)


# Taxa
# --------------------

Expand Down
4 changes: 3 additions & 1 deletion test/v1/test_observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,9 @@ def test_upload(requests_mock):
@patch('pyinaturalist.v1.observations.update_observation')
def test_upload__with_photo_ids(mock_update_observation):
upload(1234, access_token='token', photo_ids=[5678])
mock_update_observation.assert_called_with(1234, access_token='token', photo_ids=[5678])
mock_update_observation.assert_called_with(
1234, access_token='token', session=None, photo_ids=[5678]
)


def test_delete_observation(requests_mock):
Expand Down

0 comments on commit 44b1a2e

Please sign in to comment.