diff --git a/app/api/images/images.list.controller.js b/app/api/images/images.list.controller.js index 4f913f1..df88c97 100644 --- a/app/api/images/images.list.controller.js +++ b/app/api/images/images.list.controller.js @@ -77,7 +77,7 @@ const getImages = async (req, orderkey, count = true) => { const query = req.query; // TODO add image width for media url const attributes = parseAttributes(query); - const orderBy = orderkey ? orderkey: query.sortKey; + const orderBy = orderkey ? orderkey : query.sortKey; let whereClauses = []; const states = query.state ? query.state : ['waiting_validation', 'validated']; @@ -93,7 +93,7 @@ const getImages = async (req, orderkey, count = true) => { if (!userHasRole(req, 'owner_validator', 'owner_admin', 'super_admin')) { throw authorizationError('Unpublished images can only be accessed by respective owners or super administrators'); } - const { where: scopeOwner } = getOwnerScope(req); + const { where: scopeOwner } = getOwnerScope(req); // retrieve only unpublished images whereClauses.push({ is_published: false }); // restrict to current owner @@ -141,12 +141,13 @@ const getImages = async (req, orderkey, count = true) => { } if (query.keyword) { - whereClauses.push({ [Op.or]: { - original_id: iLikeFormatter(query.keyword), - title: iLikeFormatter(query.keyword), - caption: iLikeFormatter(query.keyword) - } - }); + whereClauses.push({ + [Op.or]: { + original_id: iLikeFormatter(query.keyword), + title: iLikeFormatter(query.keyword), + caption: iLikeFormatter(query.keyword) + } + }); } if (query.user_id) { @@ -234,6 +235,10 @@ const getImages = async (req, orderkey, count = true) => { }); } + if (query.view_type) { + whereClauses.push({ view_type: inUniqueOrList(query.view_type) }); + } + if (isGeoref) { if (query.bbox) { whereClauses.push({ @@ -243,10 +248,10 @@ const getImages = async (req, orderkey, count = true) => { if (query.wkt_roi) { whereClauses.push({ location: wktFormatter( - query.wkt_roi, - query.intersect_location || false, - query.intersect_footprint || false - ) + query.wkt_roi, + query.intersect_location || false, + query.intersect_footprint || false + ) }); } } @@ -254,7 +259,7 @@ const getImages = async (req, orderkey, count = true) => { // Filter locked images if (query.only_unlocked) { - whereClauses.push( models.sequelize.literal( + whereClauses.push(models.sequelize.literal( "last_start IS NULL OR (EXTRACT(EPOCH FROM now())-EXTRACT(EPOCH FROM last_start))/60 >= 240" )); } @@ -276,6 +281,12 @@ const getImages = async (req, orderkey, count = true) => { const randomOrder = models.sequelize.literal("random()"); + // findAll returns an array where findAndCountAll returns object {rows, count} + const response = { + rows: [], + count: null + } + if (!isGeoref) { let whereClauseApriori = {} let includeOption = null; @@ -299,20 +310,21 @@ const getImages = async (req, orderkey, count = true) => { } const orderById = [["id"]]; - includeOption = [{ - model: models.apriori_locations, - attributes: [ - [models.sequelize.literal("ST_X(geom)"), "longitude"], - [models.sequelize.literal("ST_Y(geom)"), "latitude"], - "exact" - ], - where: whereClauseApriori, - required: true, - duplicating: false, - order: orderBy === 'distance' ? orderByApriori : undefined - }, - includeCollectionFilter - ]; + includeOption = [ + { + model: models.apriori_locations, + attributes: [ + [models.sequelize.literal("ST_X(geom)"), "longitude"], + [models.sequelize.literal("ST_Y(geom)"), "latitude"], + "exact" + ], + where: whereClauseApriori, + required: true, + duplicating: false, + order: orderBy === 'distance' ? orderByApriori : undefined + }, + includeCollectionFilter + ]; const sequelizeQuery = { subQuery: false, attributes: attributes, @@ -323,17 +335,17 @@ const getImages = async (req, orderkey, count = true) => { include: includeOption }; if (count) { - const response = await models.images.findAndCountAll(sequelizeQuery); - // Count the total number of images matching the query + response.rows = await models.images.findAll(sequelizeQuery); + // Count the total number of matching images, removing duplicates when in contribute mode, i.e. apriori_locations const countPromise = await models.images.count({ where: { [Op.and]: whereClauses }, include: includeOption, distinct: 'images.id' - }) + }); response.count = countPromise return response; } else { - const response = await models.images.findAll(sequelizeQuery); + response.rows = await models.images.findAll(sequelizeQuery); return response; } } else { @@ -349,20 +361,20 @@ const getImages = async (req, orderkey, count = true) => { include: [includeCollectionFilter] }; if (count) { - const response = await models.images.findAndCountAll(sequelizeQuery); - return response; + const imagesAndCount = await models.images.findAndCountAll(sequelizeQuery); + return imagesAndCount; } else { - const response = await models.images.findAll(sequelizeQuery); + response.rows = await models.images.findAll(sequelizeQuery); return response; } } }; -exports.getList = utils.route(async (req, res) => { +exports.getList = utils.route(async (req, res) => { const images = await getImages(req); //Build media if (!req.query.attributes || req.query.attributes.includes('media')) { //only return media if no specific attributes requested or if media requested - if(images.rows) { + if (images.rows) { await mediaUtils.setListImageUrl(images.rows, /* image_width */ 200, /* image_height */ null); } images.rows.forEach((image) => { @@ -376,9 +388,8 @@ exports.getList = utils.route(async (req, res) => { exports.getListId = utils.route(async (req, res) => { req.query = { ...req.query, attributes: ["id"] }; const images = await getImages(req, /*orderBy*/ 'id', /*count*/ false); - // Send flattened objects - res.status(200).send(images.map(obj => obj.id)); + res.status(200).send(images.rows.map(obj => obj.id)); }); exports.getListMetadata = utils.route(async (req, res) => { @@ -392,7 +403,7 @@ exports.getListMetadata = utils.route(async (req, res) => { } else if (user.hasRole('owner_admin') || user.hasRole('owner_validator')) { // An owner administrator or validator can only request image metadata for // their owner. - const accessibleOwnerIds = [ user.owner_id ]; + const accessibleOwnerIds = [user.owner_id]; ownerIds = requestedOwnerIds ? intersection(requestedOwnerIds, accessibleOwnerIds) : accessibleOwnerIds; if (requestedOwnerIds && ownerIds.length !== requestedOwnerIds.length) { throw authorizationError('An owner validator or administrator can only access metadata for images linked to the same owner'); @@ -416,39 +427,39 @@ exports.getListMetadata = utils.route(async (req, res) => { // Include geolocation information and toponyms if geolocalisation is true const includeGeoloc = [ - { - model: models.geolocalisations, - attributes: [ - [models.sequelize.literal("st_X(geolocalisation.location)"), "longitude"], - [models.sequelize.literal("st_Y(geolocalisation.location)"), "latitude"], - [models.sequelize.literal("st_Z(geolocalisation.location)"), "altitude"], - "azimuth", - "tilt", - "roll", - "focal", - [models.sequelize.literal("st_AsText(geolocalisation.location)"), "point"], - [models.sequelize.literal("st_AsText(geolocalisation.footprint)"), "footprint"], - ], - where: { - [Op.and]: [ - { - [Op.or]: [ - {state: 'validated'}, - {state: 'improved'} - ] - }, - { - id: {[Op.col]: 'images.geolocalisation_id'} - } - ] + { + model: models.geolocalisations, + attributes: [ + [models.sequelize.literal("st_X(geolocalisation.location)"), "longitude"], + [models.sequelize.literal("st_Y(geolocalisation.location)"), "latitude"], + [models.sequelize.literal("st_Z(geolocalisation.location)"), "altitude"], + "azimuth", + "tilt", + "roll", + "focal", + [models.sequelize.literal("st_AsText(geolocalisation.location)"), "point"], + [models.sequelize.literal("st_AsText(geolocalisation.footprint)"), "footprint"], + ], + where: { + [Op.and]: [ + { + [Op.or]: [ + {state: 'validated'}, + {state: 'improved'} + ] + }, + { + id: {[Op.col]: 'images.geolocalisation_id'} + } + ] + }, + required: false }, - required: false - }, - { - model: models.geometadata, - attributes: [], - required: false - }]; + { + model: models.geometadata, + attributes: [], + required: false + }]; const countTotal = await models.images.count({ where: cleanedWhere @@ -557,3 +568,125 @@ exports.getStats = utils.route(async (req, res) => { rows: result }); }); + +exports.getImagesBound = utils.route(async (req, res) => { + const query = req.query; + const attributes = parseAttributes(query); + const orderBy = query.sortKey; + const orderByNearest = models.sequelize.literal(`images.location <-> st_setsrid(ST_makepoint(${query.longitude}, ${query.latitude}), 4326)`); + + let whereClauses = []; + + if (query.owner_id) { + whereClauses.push({ owner_id: inUniqueOrList(query.owner_id) }); + } + + if (query.collection_id) { + whereClauses.push({ collection_id: inUniqueOrList(query.collection_id) }); + } + + if (query.keyword) { + whereClauses.push({ + [Op.or]: { + original_id: iLikeFormatter(query.keyword), + title: iLikeFormatter(query.keyword), + caption: iLikeFormatter(query.keyword) + } + }); + } + + if (query.place_names) { + whereClauses.push({ geotags_array: containsUniqueOrList(query.place_names) }); + } + + if (query.date_shot_min || query.date_shot_max) { + whereClauses.push({ + // Single date + [Op.or]: { + date_shot: { + [Op.and]: { + [Op.not]: null, + [Op.gte]: query.date_shot_min, + [Op.lte]: query.date_shot_max + }, + }, + // Range of dates + [Op.and]: { + date_shot_min: { + [Op.and]: { + [Op.not]: null, + [Op.gte]: query.date_shot_min, + [Op.lte]: query.date_shot_max + } + }, + date_shot_max: { + [Op.and]: { + [Op.not]: null, + [Op.gte]: query.date_shot_min, + [Op.lte]: query.date_shot_max + } + } + } + } + }); + } + + if (query.view_type) { + whereClauses.push({ view_type: inUniqueOrList(query.view_type) }); + } + + whereClauses.push( + Sequelize.literal( + `CASE + WHEN geometadatum.footprint IS NOT NULL AND ST_Contains(geometadatum.footprint, ST_SetSRID(ST_MakePoint(${query.longitude}, ${query.latitude}), 4326)) THEN true + ELSE ST_Contains(images.footprint, ST_SetSRID(ST_MakePoint(${query.longitude}, ${query.latitude}), 4326)) + END` + ) + ); + + whereClauses.push( + Sequelize.where( + Sequelize.fn( + 'ST_Distance', + Sequelize.cast( + Sequelize.fn('ST_SetSRID', Sequelize.fn('ST_MakePoint', query.longitude, query.latitude), 4326), + 'geography' + ), + Sequelize.cast( + Sequelize.col('images.location'), + 'geography' + ) + ), + { // Distance in meters (20 km = 20000 meters) + [Op.and]: [ + { [Op.lte]: query.distance }, // Distance less than or equal to query.distance + { [Op.gt]: 4 } // To avoid unwanted images locatated on point ie looking away from point + ] + } + ), + ); + + const sequelizeQuery = { + include: [{ + model: models.geometadata, + required: true, // Ensure that only images with geometadata are retrieved + attributes: [], // Exclude geometadata attributes from the result, we only need it for the join + }], + subQuery: false, + attributes: attributes, + where: { [Op.and]: whereClauses }, + order: orderBy === 'distance' ? orderByNearest : (orderBy === 'title' ? [['title']] : [['date_shot_min']]), + limit: query.limit || 30, + offset: query.offset || 0, + }; + + const images = await models.images.findAndCountAll(sequelizeQuery); + + if (images.rows) { + await mediaUtils.setListImageUrl(images.rows, /* image_width */ 200, /* image_height */ null); + } + images.rows.forEach((image) => { + delete image.dataValues.iiif_data; + }); + res.status(200).send(images); +}); diff --git a/app/api/images/images.openapi.params.yml b/app/api/images/images.openapi.params.yml index 1d895a3..cbcae9a 100644 --- a/app/api/images/images.openapi.params.yml +++ b/app/api/images/images.openapi.params.yml @@ -188,3 +188,13 @@ schema: type: string format: date-time +- name: view_type + in: query + description: Select only image whos view_type is equal to. + example: ['terrestrial', 'lowOblique', 'highOblique', 'nadir'] + schema: + type: array + minItems: 1 + maxItems: 4 + items: + type: string diff --git a/app/api/images/images.openapi.yml b/app/api/images/images.openapi.yml index 683413b..668af6b 100644 --- a/app/api/images/images.openapi.yml +++ b/app/api/images/images.openapi.yml @@ -490,3 +490,104 @@ $ref: "#/components/responses/RequestParametersValidationErrors" "404": $ref: "#/components/responses/NotFoundError" + +/images/boundingbox: + get: + summary: Get images where frootprint covers a geo point. + operationId: getImagesBound + parameters: + - $ref: "#/components/parameters/LanguageParameter" + - $ref: "#/components/parameters/LimitParameter" + - $ref: "#/components/parameters/OffsetParameter" + - name: collection_id + in: query + description: Select only the images belonging to the collection(s) with the specified ID(s). + example: 3 + schema: + $ref: "#/components/schemas/ApiIdListParameter" + - name: date_shot_min + in: query + description: Select only images shot after the specified date. + schema: + type: string + format: date + - name: date_shot_max + in: query + description: Select only images shot before the specified date. + schema: + type: string + format: date + - name: keyword + in: query + description: Select only images with a title, description or original ID containing at least one of the specified keywords. + schema: + errorMessage: should be a string or an array of strings + oneOf: + - type: string + - type: array + items: + type: string + - name: sortKey + in: query + description: Sort images by Distance, title or date shot. + schema: + errorMessage: should be a string + type: string + - name: owner_id + in: query + description: Select only the images belonging to the owner(s) with the specified ID(s). + example: 3 + schema: + $ref: "#/components/schemas/ApiIdListParameter" + - name: place_names + in: query + description: Select only images where the specified places are visible. + schema: + errorMessage: should be a string or an array of strings + oneOf: + - type: string + - type: array + items: + type: string + - name: latitude + in: query + description: Latitude of the bounding box center. + example: 46.841 + required: true + schema: + type: number + - name: longitude + in: query + description: Longitude of the bounding box center. + example: 7.663 + required: true + schema: + type: number + - name: distance + in: query + description: Distance from the center to the bounding box edges (in kilometers). + example: 2 + required: true + schema: + type: number + - name: view_type + in: query + description: Select only image whos view_type is equal to. + example: ['terrestrial', 'lowOblique', 'highOblique', 'nadir'] + schema: + type: array + minItems: 1 + maxItems: 4 + items: + type: string + responses: + "200": + description: Successful request. + content: + application/json: + schema: + $ref: "#/components/schemas/ImageList" + "400": + $ref: "#/components/responses/RequestParametersValidationErrors" + "404": + $ref: "#/components/responses/NotFoundError" diff --git a/app/api/images/images.routes.js b/app/api/images/images.routes.js index ab1994a..8d8b5dc 100644 --- a/app/api/images/images.routes.js +++ b/app/api/images/images.routes.js @@ -80,6 +80,12 @@ module.exports = () => { controller.getFootprint ); + // Get images where frootprint covers a geo point + router.get("/images/boundingbox", + validateDocumentedRequestParametersFor('GET', '/images/boundingbox'), + listController.getImagesBound + ); + // Update the state of an image. router.put("/images/:id/state", authenticate(),