Skip to content

Commit 96f75df

Browse files
committed
Merge branch 'main' into web-894-update-inaturalistapi-to-use-body-parser-2x
2 parents 662b872 + 82c1453 commit 96f75df

19 files changed

+7861
-1891
lines changed

.github/workflows/CI-build-test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ jobs:
3434
sudo sysctl -w vm.max_map_count=262144
3535
3636
- name: Runs Elasticsearch
37-
uses: miyataka/elastic-github-actions/elasticsearch@feature/plugin_support
37+
uses: elastic/elastic-github-actions/elasticsearch@master
3838
with:
3939
stack-version: 8.15.3
40+
security-enabled: false
4041
plugins: analysis-kuromoji
4142

4243
- name: Elasticsearch is reachable
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
const _ = require( "lodash" );
2+
const { exemplar_identifications: exemplarIdentifications } = require( "inaturalistjs" );
3+
const ExemplarIdentification = require( "../../models/exemplar_identification" );
4+
const esClient = require( "../../es_client" );
5+
const ESModel = require( "../../models/es_model" );
6+
const ControlledTerm = require( "../../models/controlled_term" );
7+
const InaturalistAPI = require( "../../inaturalist_api" );
8+
const util = require( "../../util" );
9+
10+
const ExemplarIdentificationsController = class ExemplarIdentificationsController {
11+
static async search( req ) {
12+
if ( !req.userSession || ( req.userSession && !req.userSession.isAdmin ) ) {
13+
throw util.httpError( 401, "Unauthorized" );
14+
}
15+
let response = await ExemplarIdentificationsController.resultsForRequest( req );
16+
if ( req.query.include_category_counts ) {
17+
response = await ExemplarIdentificationsController.includeCategoryCounts( req, response );
18+
}
19+
if ( req.query.include_category_controlled_terms ) {
20+
response = await ExemplarIdentificationsController.includeCategoryControlledTerms(
21+
req, response
22+
);
23+
}
24+
return response;
25+
}
26+
27+
static async resultsForRequest( req ) {
28+
const response = await ExemplarIdentificationsController.elasticResults( req );
29+
const exemplars = _.map(
30+
response.hits.hits,
31+
h => new ExemplarIdentification( h._source )
32+
);
33+
await ExemplarIdentification.preloadAllAssociations( req, exemplars );
34+
return {
35+
total_results: response.hits.total
36+
? response.hits.total.value
37+
: exemplars.length,
38+
page: Number( req.elastic_query.page ),
39+
per_page: Number( req.elastic_query.per_page ),
40+
results: exemplars
41+
};
42+
}
43+
44+
static async elasticResults( req ) {
45+
const query = ExemplarIdentificationsController.reqToElasticQuery( req );
46+
return ESModel.elasticResults( req, query, "exemplar_identifications", {
47+
trackTotalHits: !req.query.skip_total_hits && !req.query.no_total_hits,
48+
skipTotalHits: req.query.skip_total_hits,
49+
noTotalHits: req.query.no_total_hits
50+
} );
51+
}
52+
53+
static reqToElasticQuery( req ) {
54+
const p = req.query;
55+
const params = _.assignIn( { }, p );
56+
const searchFilters = [];
57+
const inverseFilters = [];
58+
59+
searchFilters.push( esClient.termFilter( "active", true ) );
60+
61+
_.each( [
62+
{ http_param: "id", es_field: "id" },
63+
{ http_param: "taxon_id", es_field: "identification.taxon.ancestor_ids.keyword" },
64+
{ http_param: "direct_taxon_id", es_field: "identification.taxon.id.keyword" }
65+
], filter => {
66+
if ( params[filter.http_param] && params[filter.http_param] !== "any" ) {
67+
searchFilters.push( esClient.termFilter(
68+
filter.es_field, params[filter.http_param]
69+
) );
70+
}
71+
} );
72+
73+
if ( params.upvoted === "true" ) {
74+
searchFilters.push( { range: { cached_votes_total: { gt: 0 } } } );
75+
} else if ( params.upvoted === "false" ) {
76+
inverseFilters.push( { range: { cached_votes_total: { gt: 0 } } } );
77+
}
78+
if ( params.downvoted === "true" ) {
79+
searchFilters.push( { range: { cached_votes_total: { lt: 0 } } } );
80+
} else if ( params.downvoted === "false" ) {
81+
inverseFilters.push( { range: { cached_votes_total: { lt: 0 } } } );
82+
}
83+
if ( params.nominated === "true" ) {
84+
searchFilters.push( { exists: { field: "nominated_by_user_id" } } );
85+
} else if ( params.nominated === "false" ) {
86+
inverseFilters.push( { exists: { field: "nominated_by_user_id" } } );
87+
}
88+
89+
if ( params.q ) {
90+
searchFilters.push( {
91+
match: {
92+
"identification.body": {
93+
query: params.q,
94+
operator: "and"
95+
}
96+
}
97+
} );
98+
}
99+
100+
if ( params.term_value_id ) {
101+
searchFilters.push( {
102+
nested: {
103+
path: "identification.observation.annotations",
104+
query: {
105+
bool: {
106+
filter: esClient.termFilter(
107+
"identification.observation.annotations.controlled_value_id.keyword",
108+
params.term_value_id
109+
)
110+
}
111+
}
112+
}
113+
} );
114+
}
115+
116+
const sortOrder = ( params.order || "desc" ).toLowerCase( );
117+
let sort;
118+
switch ( params.order_by ) {
119+
case "word_count":
120+
sort = { "identification.body_word_length": sortOrder };
121+
break;
122+
case "created_at":
123+
sort = { "identification.created_at": sortOrder };
124+
break;
125+
default:
126+
sort = {
127+
cached_votes_total: sortOrder,
128+
id: "desc"
129+
};
130+
}
131+
132+
const elasticQuery = {
133+
filters: searchFilters,
134+
inverse_filters: inverseFilters,
135+
per_page: InaturalistAPI.perPage( req, { default: 50, max: 200 } ),
136+
page: req.query.page || 1,
137+
sort
138+
};
139+
return elasticQuery;
140+
}
141+
142+
static async includeCategoryCounts( req, response ) {
143+
const aggQuery = {
144+
size: 0,
145+
filters: [
146+
esClient.termFilter( "identification.taxon.id.keyword", req.query.direct_taxon_id ),
147+
esClient.termFilter( "active", true )
148+
],
149+
aggs: {
150+
category_counts: {
151+
filters: {
152+
filters: {
153+
upvoted: {
154+
bool: {
155+
filter: [
156+
{ range: { cached_votes_total: { gt: 0 } } },
157+
{ exists: { field: "nominated_by_user_id" } }
158+
]
159+
}
160+
},
161+
downvoted: {
162+
bool: {
163+
filter: [
164+
{ range: { cached_votes_total: { lt: 0 } } },
165+
{ exists: { field: "nominated_by_user_id" } }
166+
]
167+
}
168+
},
169+
no_votes: {
170+
bool: {
171+
filter: [
172+
{ term: { cached_votes_total: 0 } },
173+
{ exists: { field: "nominated_by_user_id" } }
174+
]
175+
}
176+
},
177+
not_nominated: {
178+
bool: {
179+
must_not: [
180+
{ exists: { field: "nominated_by_user_id" } }
181+
]
182+
}
183+
}
184+
}
185+
}
186+
}
187+
}
188+
};
189+
const aggsResponse = await ESModel.elasticResults( req, aggQuery, "exemplar_identifications", {
190+
noTotalHits: true
191+
} );
192+
const counts = aggsResponse.aggregations.category_counts.buckets;
193+
return {
194+
...response,
195+
category_counts: {
196+
upvoted: counts.upvoted.doc_count,
197+
no_votes: counts.no_votes.doc_count,
198+
downvoted: counts.downvoted.doc_count,
199+
not_nominated: counts.not_nominated.doc_count
200+
}
201+
};
202+
}
203+
204+
static async includeCategoryControlledTerms( req, response ) {
205+
const query = ExemplarIdentificationsController.reqToElasticQuery( req );
206+
const filters = _.reject( query.filters, filter => (
207+
filter?.nested?.path === "identification.observation.annotations"
208+
) );
209+
const aggQuery = {
210+
size: 0,
211+
filters,
212+
inverse_filters: query.inverse_filters,
213+
aggs: {
214+
nested_annotations: {
215+
nested: { path: "identification.observation.annotations" },
216+
aggs: {
217+
attributes: {
218+
terms: {
219+
field: "identification.observation.annotations.concatenated_attr_val",
220+
size: 100
221+
}
222+
}
223+
}
224+
}
225+
}
226+
};
227+
const aggsResponse = await ESModel.elasticResults( req, aggQuery, "exemplar_identifications", {
228+
noTotalHits: true
229+
} );
230+
let controlledTerms = [];
231+
const controlledTermsIDs = { };
232+
_.each( aggsResponse.aggregations.nested_annotations.attributes.buckets, b => {
233+
const pieces = b.key.split( "|" );
234+
controlledTermsIDs[Number( pieces[0] )] = true;
235+
controlledTermsIDs[Number( pieces[1] )] = true;
236+
const result = {
237+
controlled_attribute_id: pieces[0],
238+
controlled_value_id: pieces[1],
239+
count: b.doc_count
240+
};
241+
controlledTerms.push( result );
242+
} );
243+
const terms = await ESModel.fetchInstancesByIDsObject( controlledTermsIDs, ControlledTerm );
244+
_.each( terms, ( t, controlledAttributeID ) => {
245+
terms[controlledAttributeID] = new ControlledTerm( t );
246+
terms[controlledAttributeID].values = _.map( t.values, v => ( new ControlledTerm( v ) ) );
247+
} );
248+
_.each( controlledTerms, r => {
249+
if ( terms[r.controlled_attribute_id] ) {
250+
r.controlled_attribute = {
251+
id: terms[r.controlled_attribute_id].id,
252+
label: terms[r.controlled_attribute_id].label
253+
};
254+
delete r.controlled_attribute_id;
255+
}
256+
if ( terms[r.controlled_value_id] ) {
257+
r.controlled_value = {
258+
id: terms[r.controlled_value_id].id,
259+
label: terms[r.controlled_value_id].label
260+
};
261+
delete r.controlled_value_id;
262+
}
263+
} );
264+
controlledTerms = _.filter( controlledTerms, r => (
265+
r.controlled_attribute && r.controlled_value
266+
) );
267+
return {
268+
...response,
269+
category_controlled_terms: controlledTerms
270+
};
271+
}
272+
273+
static async vote( req ) {
274+
req.params.id = req.params.id || req.params.uuid;
275+
// If there are no errors, this will be a 204 and there's no response
276+
await InaturalistAPI.iNatJSWrap( exemplarIdentifications.vote, req );
277+
return null;
278+
}
279+
280+
static async unvote( req ) {
281+
req.params.id = req.params.id || req.params.uuid;
282+
// If there are no errors, this will be a 204 and there's no response
283+
await InaturalistAPI.iNatJSWrap( exemplarIdentifications.unvote, req );
284+
return null;
285+
}
286+
};
287+
288+
module.exports = ExemplarIdentificationsController;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const _ = require( "lodash" );
2+
const ESModel = require( "./es_model" );
3+
const ControlledTerm = require( "./controlled_term" );
4+
const Model = require( "./model" );
5+
const ObservationPreload = require( "./observation_preload" );
6+
const User = require( "./user" );
7+
const Identification = require( "./identification" );
8+
9+
const ExemplarIdentification = class ExemplarIdentification extends Model {
10+
static async preloadAllAssociations( req, exemplarIdentifications ) {
11+
const identifications = _.map( _.map( exemplarIdentifications, "identification" ), identification => (
12+
{ id: identification.id }
13+
) );
14+
await ESModel.fetchBelongsTo( identifications, Identification, {
15+
foreignKey: "id", source: { excludes: ["observation", "taxon", "current_taxon"] }
16+
} );
17+
const identificationInstances = _.keyBy( _.map( identifications, "identification" ), "id" );
18+
_.each( exemplarIdentifications, exemplarIdentification => {
19+
if ( _.has( identificationInstances, exemplarIdentification.identification?.id ) ) {
20+
exemplarIdentification.identification = _.assign(
21+
identificationInstances[exemplarIdentification.identification.id],
22+
exemplarIdentification.identification
23+
);
24+
}
25+
} );
26+
27+
await ExemplarIdentification.preloadAnnotationControlledTerms( exemplarIdentifications );
28+
const withUsers = _.filter( _.flattenDeep( [
29+
_.map( exemplarIdentifications, "identification" ),
30+
_.map( exemplarIdentifications, "votes" )] ), _.identity );
31+
await ESModel.fetchBelongsTo( withUsers, User );
32+
await ESModel.fetchBelongsTo( exemplarIdentifications, User, {
33+
idFields: { nominated_by_user_id: "nominated_by_user" }
34+
} );
35+
await ObservationPreload.observationSounds( _.map( exemplarIdentifications, "identification.observation" ) );
36+
await ObservationPreload.observationPhotos( _.map( exemplarIdentifications, "identification.observation" ) );
37+
}
38+
39+
static async preloadAnnotationControlledTerms( exemplarIdentifications ) {
40+
return ESModel.fetchBelongsTo(
41+
_.flattenDeep( _.map( exemplarIdentifications, "identification.observation.annotations" ) ),
42+
ControlledTerm,
43+
{
44+
idFields: {
45+
controlled_value_id: "controlled_value",
46+
controlled_attribute_id: "controlled_attribute"
47+
},
48+
source: {
49+
includes: ["id", "labels"]
50+
}
51+
}
52+
);
53+
}
54+
55+
static async preloadUsers( exemplarIdentifications ) {
56+
const userIDs = {};
57+
_.each( exemplarIdentifications, tid => {
58+
userIDs[tid.user.id] = 1;
59+
if ( tid.nominated_by_user ) {
60+
userIDs[tid.nominated_by_user.id] = 1;
61+
}
62+
} );
63+
const users = await ESModel.fetchInstancesByIDsObject( userIDs, User, {
64+
source: {
65+
includes: ["id", "login", "icon", "name"]
66+
}
67+
} );
68+
_.each( exemplarIdentifications, tid => {
69+
if ( users[tid.user.id] ) {
70+
tid.user = users[tid.user.id];
71+
}
72+
if ( tid.nominated_by_user && users[tid.nominated_by_user.id] ) {
73+
tid.nominated_by_user = users[tid.nominated_by_user.id];
74+
}
75+
} );
76+
}
77+
};
78+
79+
ExemplarIdentification.indexName = "exemplar_identifications";
80+
81+
module.exports = ExemplarIdentification;

lib/models/observation.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ const Observation = class Observation extends Model {
255255
},
256256
source: { excludes: ["photos", "taxon_photos", "place_ids"] }
257257
};
258-
await ObservationPreload.identifications( obs );
258+
await ObservationPreload.identifications( req, obs );
259259
await ObservationPreload.projectObservations( obs );
260260
_.each( obs, o => {
261261
o.comments = _.map( o.comments, c => new Comment( c ) );

0 commit comments

Comments
 (0)