diff --git a/pyinaturalist/controllers/observation_controller.py b/pyinaturalist/controllers/observation_controller.py index 42b2e2ea..847a8dc8 100644 --- a/pyinaturalist/controllers/observation_controller.py +++ b/pyinaturalist/controllers/observation_controller.py @@ -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, @@ -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 ( @@ -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 """ @@ -372,7 +375,6 @@ 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, @@ -380,7 +382,7 @@ def upload( 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 @@ -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, @@ -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): diff --git a/pyinaturalist/models/media.py b/pyinaturalist/models/media.py index eabd40d7..18640eef 100644 --- a/pyinaturalist/models/media.py +++ b/pyinaturalist/models/media.py @@ -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 @@ -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: @@ -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 @@ -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: diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py index b6bc4653..b94fc2b2 100644 --- a/pyinaturalist/v1/observations.py +++ b/pyinaturalist/v1/observations.py @@ -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') @@ -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) diff --git a/test/controllers/test_observation_controller.py b/test/controllers/test_observation_controller.py index 9dbfefdc..96decea5 100644 --- a/test/controllers/test_observation_controller.py +++ b/test/controllers/test_observation_controller.py @@ -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 @@ -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() @@ -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() diff --git a/test/sample_data.py b/test/sample_data.py index 3eece571..be1fe053 100644 --- a/test/sample_data.py +++ b/test/sample_data.py @@ -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'] @@ -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'] diff --git a/test/test_models.py b/test/test_models.py index 875c5405..0e2c674e 100644 --- a/test/test_models.py +++ b/test/test_models.py @@ -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 # -------------------- diff --git a/test/v1/test_observations.py b/test/v1/test_observations.py index 8021495c..33543fa3 100644 --- a/test/v1/test_observations.py +++ b/test/v1/test_observations.py @@ -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):