diff --git a/lib/controllers/v1/observations_controller.js b/lib/controllers/v1/observations_controller.js index dc15db72..97519c52 100644 --- a/lib/controllers/v1/observations_controller.js +++ b/lib/controllers/v1/observations_controller.js @@ -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; @@ -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, + 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 ); diff --git a/lib/inaturalist_api.js b/lib/inaturalist_api.js index b51fd2ad..127880f0 100644 --- a/lib/inaturalist_api.js +++ b/lib/inaturalist_api.js @@ -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 } ); diff --git a/lib/views/swagger_v1.yml.ejs b/lib/views/swagger_v1.yml.ejs index a877ded9..d34a175f 100644 --- a/lib/views/swagger_v1.yml.ejs +++ b/lib/views/swagger_v1.yml.ejs @@ -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 @@ -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" diff --git a/schema/fixtures.js b/schema/fixtures.js index 84c1b1eb..3232da07 100644 --- a/schema/fixtures.js +++ b/schema/fixtures.js @@ -99,6 +99,7 @@ "name": "United States", "slug": "united-states", "display_name_autocomplete": "United States", + "display_name": "United States", "location": "48.8907012939,-116.9820022583", "admin_level": 0, "bbox_area": 5500, diff --git a/test/integration/v1/observations.js b/test/integration/v1/observations.js index 7e03ad8a..9bf83e86 100644 --- a/test/integration/v1/observations.js +++ b/test/integration/v1/observations.js @@ -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" )