Skip to content

Commit

Permalink
Merge pull request #512 from pyinat/life-lists
Browse files Browse the repository at this point in the history
Add /taxa/lifelist_metadata endpoint and integrate into ObservationController.life_list()
  • Loading branch information
JWCook authored Jul 17, 2023
2 parents 44b9f4b + 5bad51d commit ad029b5
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 76 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']

Expand Down
111 changes: 56 additions & 55 deletions docs/endpoints.md
Original file line number Diff line number Diff line change
@@ -1,79 +1,80 @@
(endpoints)=
# {fa}`table` Endpoint Summary

Below is a list of iNaturalist API endpoints that have either been added or may be added in the
future, along with their corresponding functions in pyinaturalist.
Below is a list of iNaturalist API endpoints that have either been added or are likely be added in
the future, along with their corresponding functions in pyinaturalist.

## v1 API
For all available endpoints, see: <http://api.inaturalist.org/v1/docs/>

| Method | Endpoint | Function
| ------ | -------- | --------
| Method | Endpoint | Function |
| ------ | ---------------------------------- | ------------------------------------------------------------ |
| POST | /annotations |
| DELETE | /annotations/{id} |
| POST | /comments |
| DELETE | /comments/{id} |
| PUT | /comments/{id} |
| GET | /controlled_terms | {py:func}`.get_controlled_terms`
| GET | /controlled_terms/for_taxon | {py:func}`.get_controlled_terms`
| GET | /identifications | {py:func}`.get_identifications`
| GET | /identifications/{id} | {py:func}`.get_identifications_by_id`
| GET | /controlled_terms | {py:func}`.get_controlled_terms` |
| GET | /controlled_terms/for_taxon | {py:func}`.get_controlled_terms` |
| GET | /identifications | {py:func}`.get_identifications` |
| GET | /identifications/{id} | {py:func}`.get_identifications_by_id` |
| GET | /identifications/species_counts |
| GET | /identifications/identifiers |
| GET | /identifications/observers |
| GET | /identifications/similar_species |
| GET | /messages | {py:func}`.get_messages`
| GET | /messages/{id} | {py:func}`.get_message_by_id`
| GET | /messages/unread | {py:func}`.get_unread_meassage_count`
| DELETE | /observation_field_values/{id} | {py:func}`.delete_observation_field`
| PUT | /observation_field_values/{id} | {py:func}`.set_observation_field`
| POST | /observation_field_values | {py:func}`.set_observation_field`
| POST | /observation_photos | {py:func}`.upload`
| POST | /observation_sounds | {py:func}`.upload`
| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.delete_observation`
| GET | /observations/{id} | {py:func}`.get_observations_by_id`
| PUT | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.update_observation`
| GET | /observations/{id}/taxon_summary | {py:func}`.get_observation_taxon_summary`
| GET | /observations | {py:func}`~pyinaturalist.v1.observations.get_observations`
| POST | /observations | {py:func}`~pyinaturalist.v1.observations.create_observation`
| GET | /observations/histogram | {py:func}`.get_observation_histogram`
| GET | /observations/identifiers | {py:func}`.get_observation_identifiers`
| GET | /observations/observers | {py:func}`.get_observation_observers`
| GET | /observations/popular_field_values | {py:func}`.get_observation_popular_field_values`
| GET | /observations/species_counts | {py:func}`.get_observation_species_counts`
| GET | /observations/taxonomy | {py:func}`.get_observation_taxonomy`
| GET | /places/{id} | {py:func}`.get_places_by_id`
| GET | /places/autocomplete | {py:func}`.get_places_autocomplete`
| GET | /places/nearby | {py:func}`.get_places_nearby`
| GET | /posts | {py:func}`.get_posts`
| GET | /projects | {py:func}`.get_projects`
| GET | /projects/{id} | {py:func}`.get_projects_by_id`
| PUT | /projects/{id} | {py:func}`.update_project`
| GET | /messages | {py:func}`.get_messages` |
| GET | /messages/{id} | {py:func}`.get_message_by_id` |
| GET | /messages/unread | {py:func}`.get_unread_meassage_count` |
| DELETE | /observation_field_values/{id} | {py:func}`.delete_observation_field` |
| PUT | /observation_field_values/{id} | {py:func}`.set_observation_field` |
| POST | /observation_field_values | {py:func}`.set_observation_field` |
| POST | /observation_photos | {py:func}`.upload` |
| POST | /observation_sounds | {py:func}`.upload` |
| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.delete_observation` |
| GET | /observations/{id} | {py:func}`.get_observations_by_id` |
| PUT | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.update_observation` |
| GET | /observations/{id}/taxon_summary | {py:func}`.get_observation_taxon_summary` |
| GET | /observations | {py:func}`~pyinaturalist.v1.observations.get_observations` |
| POST | /observations | {py:func}`~pyinaturalist.v1.observations.create_observation` |
| GET | /observations/histogram | {py:func}`.get_observation_histogram` |
| GET | /observations/identifiers | {py:func}`.get_observation_identifiers` |
| GET | /observations/observers | {py:func}`.get_observation_observers` |
| GET | /observations/popular_field_values | {py:func}`.get_observation_popular_field_values` |
| GET | /observations/species_counts | {py:func}`.get_observation_species_counts` |
| GET | /observations/taxonomy | {py:func}`.get_observation_taxonomy` |
| GET | /places/{id} | {py:func}`.get_places_by_id` |
| GET | /places/autocomplete | {py:func}`.get_places_autocomplete` |
| GET | /places/nearby | {py:func}`.get_places_nearby` |
| GET | /posts | {py:func}`.get_posts` |
| GET | /projects | {py:func}`.get_projects` |
| GET | /projects/{id} | {py:func}`.get_projects_by_id` |
| PUT | /projects/{id} | {py:func}`.update_project` |
| GET | /projects/{id}/members |
| GET | /projects/{id}/subscriptions |
| POST | /projects/{id}/add | {py:func}`.add_project_observation`
| DELETE | /projects/{id}/remove | {py:func}`.delete_project_observation`
| POST | /projects/{id}/add | {py:func}`.add_project_observation` |
| DELETE | /projects/{id}/remove | {py:func}`.delete_project_observation` |
| GET | /projects/autocomplete |
| GET | /search | {py:func}`.search`
| GET | /taxa | {py:func}`.get_taxa`
| GET | /taxa/{id} | {py:func}`.get_taxa_by_id`
| GET | /taxa/{id}/map_layers | {py:func}`.get_taxa_map_layers`
| GET | /taxa/autocomplete | {py:func}`.get_taxa_autocomplete`
| GET | /users/{id} | {py:func}`.get_user_by_id`
| GET | /search | {py:func}`.search` |
| GET | /taxa | {py:func}`.get_taxa` |
| GET | /taxa/{id} | {py:func}`.get_taxa_by_id` |
| GET | /taxa/{id}/map_layers | {py:func}`.get_taxa_map_layers` |
| GET | /taxa/autocomplete | {py:func}`.get_taxa_autocomplete` |
| GET | /taxa/lifelist_metadata | {py:func}`.get_lifelist_metadata` |
| GET | /users/{id} | {py:func}`.get_user_by_id` |
| GET | /users/{id}/projects |
| GET | /users/autocomplete | {py:func}`.get_users_autocomplete`
| GET | /users/me | {py:func}`.get_current_user`
| GET | /users/autocomplete | {py:func}`.get_users_autocomplete` |
| GET | /users/me | {py:func}`.get_current_user` |

## v0 API
For all available endpoints, see: <https://www.inaturalist.org/pages/api+reference>

| Method | Endpoint | Function
| ------ | -------- | --------
| GET | /observations | {py:func}`~pyinaturalist.v0.observations.get_observations`
| POST | /observations | {py:func}`~pyinaturalist.v0.observations.create_observation`
| PUT | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.update_observation`
| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.delete_observation`
| GET | /observation_fields | {py:func}`.get_observation_fields`
| PUT | /observation_field_values/{id} | {py:func}`.put_observation_field_values`
| POST | /observation_photos | {py:func}`.upload_photos`
| POST | /observation_sounds | {py:func}`.upload_sounds`
| Method | Endpoint | Function |
| ------ | ------------------------------ | ------------------------------------------------------------ |
| GET | /observations | {py:func}`~pyinaturalist.v0.observations.get_observations` |
| POST | /observations | {py:func}`~pyinaturalist.v0.observations.create_observation` |
| PUT | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.update_observation` |
| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.delete_observation` |
| GET | /observation_fields | {py:func}`.get_observation_fields` |
| PUT | /observation_field_values/{id} | {py:func}`.put_observation_field_values` |
| POST | /observation_photos | {py:func}`.upload_photos` |
| POST | /observation_sounds | {py:func}`.upload_sounds` |
23 changes: 21 additions & 2 deletions pyinaturalist/controllers/observation_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from pyinaturalist.v1 import (
create_observation,
delete_observation,
get_life_list_metadata,
get_observation_histogram,
get_observation_identifiers,
get_observation_observers,
Expand Down Expand Up @@ -89,9 +90,27 @@ def identifiers(self, **params) -> UserCounts:
response = self.client.request(get_observation_identifiers, **params)
return UserCounts.from_json(response)

@document_controller_params(get_observation_taxonomy, add_common_args=False)
def life_list(self, user_id: IntOrStr, **params) -> LifeList:
@document_controller_params(get_observation_taxonomy)
def life_list(
self, user_id: Optional[IntOrStr] = None, locale: Optional[str] = None, **params
) -> LifeList:
"""Get taxa from a user's dynamic life list
Args:
user_id: iNaturalist user ID or username
locale: Locale preference for taxon common names
"""
response = self.client.request(get_observation_taxonomy, user_id=user_id, **params)

# Add additional metadata to a life list response; requires a user ID
if user_id:
metadata = self.client.request(
get_life_list_metadata, user_id=user_id, locale=locale, **params
)
meta_by_id = {item['id']: item for item in metadata['results']}
for taxon in response['results']:
taxon.update(meta_by_id.get(taxon['id'], {}))

return LifeList.from_json(response)

@document_controller_params(get_observation_observers)
Expand Down
2 changes: 1 addition & 1 deletion pyinaturalist/docs/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ def _observation_id(observation_id: int):
"""


def _project_id(observation_id: Optional[int] = None):
def _project_id(project_id: Optional[int] = None):
"""Args:
project_id: Only show users who are members of this project
"""
Expand Down
1 change: 1 addition & 0 deletions pyinaturalist/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from pyinaturalist.v1.search import search
from pyinaturalist.v1.taxa import (
get_life_list_metadata,
get_taxa,
get_taxa_autocomplete,
get_taxa_by_id,
Expand Down
20 changes: 10 additions & 10 deletions pyinaturalist/v1/observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
API_V1,
V1_OBS_ORDER_BY_PROPERTIES,
HistogramResponse,
IntOrStr,
JsonResponse,
ListResponse,
MultiFile,
Expand Down Expand Up @@ -284,6 +283,7 @@ def get_observation_species_counts(**params) -> JsonResponse:
@document_request_params(*docs._get_observations)
def get_observation_popular_field_values(**params) -> JsonResponse:
"""Get controlled terms values and a monthly histogram of observations matching the search
criteria.
.. rubric:: Notes
Expand All @@ -309,12 +309,15 @@ def get_observation_popular_field_values(**params) -> JsonResponse:
return response_json


def get_observation_taxonomy(user_id: Optional[IntOrStr] = None, **params) -> JsonResponse:
"""Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used
for life lists.
@document_request_params(*docs._get_observations)
def get_observation_taxonomy(**params) -> JsonResponse:
"""Get observation counts for all taxa in observations matching the search criteria.
Args:
user_id: iNaturalist user ID or username
.. rubric:: Notes
* Undocumented in API reference
* On iNaturalist.org, this is mainly used for dynamic life lists
* Results are returned in a flat list, but are ordered as they would be in a taxonomic tree
Example:
>>> response = get_observation_taxonomy(user_id='my_username')
Expand All @@ -328,10 +331,7 @@ def get_observation_taxonomy(user_id: Optional[IntOrStr] = None, **params) -> Js
Returns:
Response dict containing taxon records with counts
"""
if params.get('page') == 'all':
return paginate_all(get, f'{API_V1}/observations/taxonomy', user_id=user_id, **params)
else:
return get(f'{API_V1}/observations/taxonomy', user_id=user_id, **params).json()
return get(f'{API_V1}/observations/taxonomy', **params).json()


@document_common_args
Expand Down
33 changes: 32 additions & 1 deletion pyinaturalist/v1/taxa.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional

from pyinaturalist.constants import API_V1, JsonResponse, MultiInt
from pyinaturalist.constants import API_V1, IntOrStr, JsonResponse, MultiInt
from pyinaturalist.converters import convert_all_timestamps
from pyinaturalist.docs import document_request_params
from pyinaturalist.docs import templates as docs
Expand Down Expand Up @@ -166,3 +166,34 @@ def get_taxa_map_layers(taxon_id: int, **params) -> JsonResponse:
response = get(f'{API_V1}/taxa/{taxon_id}/map_layers', **params).json()
response['gbif_url'] = f'https://www.gbif.org/species/{response["gbif_id"]}'
return response


def get_life_list_metadata(
user_id: IntOrStr, locale: Optional[str] = None, **params
) -> JsonResponse:
"""Get common names and default taxon photos for a user's life list
.. rubric:: Notes
* Undocumented in API reference
* On iNaturalist.org, this is used for dynamic life lists
Args:
user_id: iNaturalist user ID or username
locale: Locale preference for taxon common names
Example:
>>> response = get_life_list_metadata(user_id='my_username')
.. admonition:: Example Response
:class: toggle
.. literalinclude:: ../sample_data/get_lifelist_metadata.json
:language: JSON
Returns:
Response dict containing taxon common names and photo URLs
"""
return get(
f'{API_V1}/taxa/lifelist_metadata', observed_by_user_id=user_id, locale=locale, **params
).json()
38 changes: 32 additions & 6 deletions test/controllers/test_observation_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,42 @@ def test_life_list(requests_mock):
status_code=200,
)
client = iNatClient()
results = client.observations.life_list(user_id=545640, taxon_id=52775)
results = client.observations.life_list(taxon_id=52775)

assert isinstance(results, LifeList)
assert len(results) == 31

bombus = results[8]
assert bombus.id == 52775
assert bombus.name == 'Bombus'
assert bombus.count == 4
assert bombus.descendant_obs_count == results.get_count(52775) == 154
t = results[8]
assert t.id == 52775
assert t.name == 'Bombus'
assert t.count == 4
assert t.descendant_obs_count == results.get_count(52775) == 154


def test_life_list__with_user_id(requests_mock):
"""With user_id, the results should include extra metadata"""
requests_mock.get(
f'{API_V1}/observations/taxonomy',
json=j_life_list_1,
status_code=200,
)
requests_mock.get(
f'{API_V1}/taxa/lifelist_metadata',
json=j_life_list_metadata,
status_code=200,
)
client = iNatClient()
results = client.observations.life_list(user_id=545640, taxon_id=574)

assert isinstance(results, LifeList)
assert len(results) == 10

t = results[4]
assert t.id == 573
assert t.name == 'Galliformes'
assert t.preferred_common_name == 'Landfowl'
assert t.count == 0
assert t.descendant_obs_count == results.get_count(573) == 4


def test_popular_fields(requests_mock):
Expand Down
1 change: 1 addition & 0 deletions test/sample_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def load_all_sample_data() -> Dict[str, Dict]:
j_identification_3 = j_observation_2['identifications'][0]
j_life_list_1 = SAMPLE_DATA['get_observation_taxonomy']
j_life_list_2 = SAMPLE_DATA['get_observation_taxonomy_by_genus']
j_life_list_metadata = SAMPLE_DATA['get_lifelist_metadata']
j_listed_taxon_1 = j_taxon_summary_2_listed['listed_taxon']
j_listed_taxon_2_partial = j_taxon_1['listed_taxa'][0]
j_message = SAMPLE_DATA['get_messages']['results'][0]
Expand Down
Loading

0 comments on commit ad029b5

Please sign in to comment.