Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions lib/controllers/v1/observations_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ ObservationsController.prepareElasticDataForResponse = ( data, req ) => {
} else if ( req.inat && req.inat.impliedBounds ) {
response.total_bounds = req.inat.impliedBounds;
}
if ( data.aggregations?.by_place ) {
response.aggregations = {
by_place: data.aggregations.by_place
};
}
response.page = Number( req.elastic_query.page );
response.per_page = Number( req.elastic_query.per_page );
response.results = obs;
Expand Down Expand Up @@ -406,11 +411,67 @@ ObservationsController.speciesCountsCacheWrapper = async req => (
"ObservationsController.speciesCounts" )
);

ObservationsController.placesCountsCacheWrapper = async req => (
ObservationsController.methodCacheWrapper( req,
ObservationsController.placesCounts,
"ObservationsController.placesCounts" )
);

ObservationsController.speciesCounts = async req => {
const leafCounts = await ObservationsController.leafCounts( req );
return TaxaController.speciesCountsResponse( req, leafCounts );
};

ObservationsController.placesCounts = async ( req, options = {} ) => {
const page = parseInt( req.query.page || 1, 10 );
const perPage = parseInt( req.query.per_page || 30, 10 );
const offset = ( page - 1 ) * perPage;
const order = req.query.order === "asc" ? "asc" : "desc";

req.query.aggs = {
by_place: {
terms: {
field: "place_ids",
size: 1000,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have enough context to define the appropriate size for a search like this — 1000 seems like a reasonable limit to me, but my reasoning isn't very strong here. I'm open to suggestions.

order: { _count: order }
}
}
};
req.query.per_page = 0;

const data = await ObservationsController.resultsForRequest( req, options );
const buckets = data.aggregations?.by_place?.buckets || [];

const places = await Promise.all(
buckets.map( bucket => Place.findByID( bucket.key, {
fields: ["id", "name", "display_name"]
} ) )
);

const results = places
.map( ( place, i ) => {
if ( !place ) return null;
return {
count: buckets[i].doc_count,
place: {
id: place.id,
name: place.name,
display_name: place.display_name
}
};
} )
.filter( Boolean );

const paginated = results.slice( offset, offset + perPage );

return {
total_results: results.length,
page,
per_page: perPage,
results: paginated
};
};

ObservationsController.taxa = async req => {
if ( !req.query.user_id ) {
throw new Error( 422 );
Expand Down
3 changes: 3 additions & 0 deletions lib/inaturalist_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ InaturalistAPI.server = async ( ) => {
dfault( "get", "/v1/observations/species_counts", ObservationsController.speciesCountsCacheWrapper, {
setTTL: true
} );
dfault( "get", "/v1/observations/place_counts", ObservationsController.placesCountsCacheWrapper, {
setTTL: true
} );
dfault( "get", "/v1/observations/taxa_counts_by_month", ObservationsController.taxaCountsByMonth, {
setTTL: true
} );
Expand Down
38 changes: 38 additions & 0 deletions lib/views/swagger_v1.yml.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,29 @@ paths:
description: Unexpected error
schema:
$ref: "#/definitions/Error"
/observations/place_counts:
get:
summary: Observation Place Counts
description: |
Given zero to many of the following parameters, returns the number of observations matching
the search criteria, grouped by place. Each result includes the place and the count of
associated observations. This endpoint works similarly to `/observations`, but instead of
returning individual observations, it returns aggregated results per place. A
maximum of 1000 results will be returned
parameters:
<%- include( "_observation_search_params_v1.yml.ejs", { type: "index" } ) %>
tags:
- Observations
responses:
200:
description: |
Returns an object with metadata and an array of taxa
schema:
$ref: "#/definitions/PlacesCountsResponse"
default:
description: Unexpected error
schema:
$ref: "#/definitions/Error"
/observations/popular_field_values:
get:
summary: Observation Popular Field Values
Expand Down Expand Up @@ -4416,6 +4439,21 @@ definitions:
type: integer
taxon:
$ref: "#/definitions/ShowTaxon"
PlacesCountsResponse:
allOf:
- $ref: "#/definitions/BaseResponse"
- required:
- results
properties:
results:
type: array
items:
type: object
properties:
count:
type: integer
place:
$ref: "#/definitions/CorePlace"
PlacesResponse:
allOf:
- $ref: "#/definitions/BaseResponse"
Expand Down
1 change: 1 addition & 0 deletions schema/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"name": "United States",
"slug": "united-states",
"display_name_autocomplete": "United States",
"display_name": "United States",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I used "CorePlace" as a place object for my response, I need to add this to the test for working. I'm open to suggestions about this, maybe I should use another object

"location": "48.8907012939,-116.9820022583",
"admin_level": 0,
"bbox_area": 5500,
Expand Down
39 changes: 39 additions & 0 deletions test/integration/v1/observations.js
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,45 @@ describe( "Observations", ( ) => {
} );
} );

describe( "place_counts", ( ) => {
it( "returns JSON", function ( done ) {
request( this.app ).get( "/v1/observations/place_counts?order=desc&order_by=created_at" )
.expect( "Content-Type", /json/ )
.expect( 200, done );
} );

it( "sorts by count desc by default", function ( done ) {
request( this.app ).get( "/v1/observations/place_counts?order=desc&order_by=created_at" ).expect( res => {
expect( res.body.results.length ).to.be.greaterThan( 1 );
expect( res.body.results[0].count ).to.be.at.least( res.body.results[1].count );
} ).expect( 200, done );
} );

it( "can sort by count asc", function ( done ) {
request( this.app ).get( "/v1/observations/place_counts?order=asc&order_by=created_at" ).expect( res => {
expect( res.body.results.length ).to.be.greaterThan( 1 );
expect( res.body.results[1].count ).to.be.at.least( res.body.results[0].count );
} ).expect( 200, done );
} );

it( "supports pagination", function ( done ) {
request( this.app ).get( "/v1/observations/place_counts?order=desc&order_by=created_at&per_page=1&page=2" ).expect( res => {
expect( res.body.page ).to.eq( 2 );
expect( res.body.per_page ).to.eq( 1 );
} ).expect( 200, done );
} );

it( "returns results places counts with expected fields", function ( done ) {
request( this.app ).get( "/v1/observations/place_counts?order=desc&order_by=created_at" ).expect( res => {
const result = res.body.results[0];
expect( result ).to.have.property( "count" );
expect( result.place ).to.have.property( "id" );
expect( result.place ).to.have.property( "name" );
expect( result.place ).to.have.property( "display_name" );
} ).expect( 200, done );
} );
} );

describe( "iconic_taxa_counts", ( ) => {
it( "returns json", function ( done ) {
request( this.app ).get( "/v1/observations/iconic_taxa_counts" )
Expand Down